diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 3e77bce6dc51..438f57a0afdb 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -145,6 +145,7 @@ const NODE_RUNTIME_INCLUDE = [ 'src/main/native-chat/structured-chat-naming.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-legacy-import.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts', 'src/main/runtime/structured-session-mail-redrive-wiring.test.ts', 'src/main/runtime/rpc/ws-transport.test.ts', diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 292d62ca3055..3a390264bdc9 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -251,6 +251,7 @@ async function replayCapture( const WITHDRAWN = { state: 'rejected', + unrun: true, reason: DISPATCH_REJECTED_CANCELLED, rejection: { kind: 'cancelled' } } @@ -514,7 +515,7 @@ describe('a send the CLI ended before starting it', () => { }) expect(replay.settlementsFor('client-B')).toEqual([ - { state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact(kind)) } + { state: 'rejected', unrun: true, ...claudeDispatchRejection(agentSessionFailureFact(kind)) } ]) expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) }) diff --git a/src/main/claude/claude-structured-control-actions.test.ts b/src/main/claude/claude-structured-control-actions.test.ts index ff34648136e5..55fb23a218a5 100644 --- a/src/main/claude/claude-structured-control-actions.test.ts +++ b/src/main/claude/claude-structured-control-actions.test.ts @@ -95,6 +95,7 @@ describe('cancelClaudeTurn', () => { { clientMessageId: 'client-1', state: 'rejected', + unrun: true, reason: 'provider_cancelled_before_start', rejection: { kind: 'cancelled' } } @@ -136,6 +137,7 @@ describe('cancelClaudeTurn', () => { expect(settled).toHaveBeenNthCalledWith(1, { clientMessageId: 'client-0', state: 'rejected', + unrun: true, reason: 'provider_cancelled_before_start', rejection: { kind: 'cancelled' } }) @@ -170,6 +172,7 @@ describe('cancelClaudeTurn', () => { expect(settled).toHaveBeenCalledWith({ clientMessageId: 'client-ambiguous', state: 'rejected', + unrun: true, reason: 'provider_cancelled_before_start', rejection: { kind: 'cancelled' } }) diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index ef550a60624e..55fe77f1fc36 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { isAbortSignalReason } from '../../shared/abort-signal-reason' import { forgetRetiredWaiter, forgetWaiter, @@ -65,6 +66,7 @@ export function rejectClaudeDispatchWaiters( onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'rejected', + unrun: true, ...claudeDispatchRejection(fact) }) } @@ -121,6 +123,7 @@ export async function dispatchClaudeTurn( requestedAt?: number /** The frame's uuid, for a caller that correlates the provider's answer to it. */ sentUuid?: string + signal?: AbortSignal }, beforeDispatch?: () => Promise ): Promise { @@ -174,7 +177,11 @@ export async function dispatchClaudeTurn( } catch (error) { const replay = pending.replay if (!replay) { - if (error instanceof AgentSessionPreDispatchError) { + // A Stop during authorization, before the SDK took the frame, stays the abort the host reads. + if ( + error instanceof AgentSessionPreDispatchError || + isAbortSignalReason(input.signal, error) + ) { throw error } if (isAgentSessionOptionRejectedError(error)) { diff --git a/src/main/claude/claude-structured-message-options.test.ts b/src/main/claude/claude-structured-message-options.test.ts index 39eb600de1ac..c24e8a4db7d0 100644 --- a/src/main/claude/claude-structured-message-options.test.ts +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -11,6 +11,7 @@ import { import { captureStructuredExecutionOptions } from '../native-chat/agent-session-wire/structured-agent-session-execution-options' import { claudeAndCodexAgents } from '../native-chat/agent-session-wire/structured-agent-session-adapter-router-test-support' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { ClaudeControlRequestError } from './claude-stream-json-connection' const MODELS = [ { value: 'default', resolvedModel: 'claude-sonnet-5' }, @@ -217,6 +218,46 @@ describe('Claude accepted message choices', () => { expect(order).toEqual(['model:opus', 'effort:{"effortLevel":"high"}', 'prompt']) }) + // The host reads the abort itself as never written, so a Stop there must not surface as a refusal. + it('surfaces a Stop while it applies a choice as the abort, and a refusal as a refusal', async () => { + let refuse = false + const claude = fakeClaude({ + initModels: MODELS, + routes: { + list_models: () => MODELS, + set_model: () => { + if (refuse) { + throw new ClaudeControlRequestError('set_model', 'unknown model') + } + return new Promise(() => {}) + } + } + }) + const adapter = await acquired(claude) + const stop = new AbortController() + const dispatch = (clientMessageId: string, signal?: AbortSignal) => + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId, + body: USER_MESSAGE, + executionOptions: { model: 'opus' }, + fence: 7, + ...(signal ? { signal } : {}) + }) + const stopped = dispatch('stopped', stop.signal) + await vi.waitFor(() => + expect(claude.connections[0]!.calls.some((call) => call.subtype === 'set_model')).toBe(true) + ) + stop.abort(new Error('stopped while starting')) + await expect(stopped).rejects.toBe(stop.signal.reason) + refuse = true + await expect(dispatch('refused')).resolves.toMatchObject({ + state: 'rejected', + rejection: { kind: 'providerRejected' } + }) + expect(claude.connections[0]!.sent).toHaveLength(0) + }) + it('restores A after an applied unanswered B and an uncorrelated old init', async () => { let actual: string | undefined = 'sonnet' const claude = fakeClaude({ initModels: MODELS, routes: { list_models: () => MODELS } }) diff --git a/src/main/claude/claude-structured-message-options.ts b/src/main/claude/claude-structured-message-options.ts index 38610e964bba..ace93fe01f3b 100644 --- a/src/main/claude/claude-structured-message-options.ts +++ b/src/main/claude/claude-structured-message-options.ts @@ -11,7 +11,11 @@ import { import { listedModels } from './claude-structured-model-catalog' import { setClaudeStructuredOption } from './claude-structured-options' import type { ClaudeSession } from './claude-structured-session-state' -import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason' +import { + isAbortSignalReason, + throwIfSignalAborted, + waitForPromiseWithSignal +} from '../../shared/abort-signal-reason' const defaultLogger = createStructuredAgentSessionLogger() @@ -110,7 +114,8 @@ export async function dispatchClaudeMessage( error } ) - throw error instanceof AgentSessionOptionRejectedError + throw error instanceof AgentSessionOptionRejectedError || + isAbortSignalReason(input.signal, error) ? error : new AgentSessionOptionRejectedError(error) } diff --git a/src/main/claude/claude-structured-queued-stop.test.ts b/src/main/claude/claude-structured-queued-stop.test.ts index 2ffb3749c166..65ec73d60f64 100644 --- a/src/main/claude/claude-structured-queued-stop.test.ts +++ b/src/main/claude/claude-structured-queued-stop.test.ts @@ -175,6 +175,7 @@ const WITHDRAWN = [ sessionId: 'session-1', clientMessageId: 'client-b', state: 'rejected', + unrun: true, reason: DISPATCH_REJECTED_CANCELLED, rejection: { kind: 'cancelled' } } diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index fe10a9163393..5f083727ad23 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -658,7 +658,9 @@ describe.skipIf(!realClaudeAvailable)(suiteTitle, () => { clientMessageId: 'real-cli-queued-stop-b', state: 'rejected', reason: 'provider_cancelled_before_start', - rejection: { kind: 'cancelled' } + rejection: { kind: 'cancelled' }, + // Claude cancelled it unrun: a queued card's hand-off records that as proof. + unrun: true }) } finally { await adapter.closeAll() diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index c4f3268aa19c..9ddb23879901 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -84,7 +84,8 @@ export type ClaudeLateDispatchOutcome = clientMessageId: string providerIdentity: AgentJournalItemIdentity } - | ({ clientMessageId: string; state: 'rejected' } & AgentJournalDispatchRejection) + /** `unrun`: the CLI reported it removed the send without running it. */ + | ({ clientMessageId: string; state: 'rejected'; unrun?: true } & AgentJournalDispatchRejection) /** The CLI took the send and let it go unanswered: it may have run, so it is never re-sent. */ | { clientMessageId: string; state: 'unknown'; reason: string } diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index 43bcdf072bd8..e8b8835cc124 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -241,7 +241,6 @@ describe('codex turn lifecycle rows', () => { expect(appended.ok && appended.rows).toEqual([ expect.objectContaining({ kind: 'item', - v: 3, ts: 1_000, body: { kind: 'turn', diff --git a/src/main/codex/codex-structured-thread-catch-up-echo.spec.ts b/src/main/codex/codex-structured-thread-catch-up-echo.spec.ts index 47ac69db5ba2..72e0a32e9e67 100644 --- a/src/main/codex/codex-structured-thread-catch-up-echo.spec.ts +++ b/src/main/codex/codex-structured-thread-catch-up-echo.spec.ts @@ -15,7 +15,6 @@ import { applyJournalRow, createJournalReducerState } from '../native-chat/agent-session-journal/journal-reducer' -import { draftsDeliveredByAppliedEcho } from '../native-chat/agent-session-journal/queued-message-delivered-echo' export function registerCodexCatchUpEchoTests(input: { chat: typeof catchUpChat @@ -37,9 +36,7 @@ export function registerCodexCatchUpEchoTests(input: { const fingerprint = agentSessionSendBodyFingerprint(SESSION, body) await c.journal.queuedMessages.insert({ messageId: 'draft', - body, - fingerprint, - hostInstance: 'p' + body }) const states = ['unknown', 'rejected'] as const for (const state of states) { @@ -51,9 +48,7 @@ export function registerCodexCatchUpEchoTests(input: { fence: 1, handoverRecorded: true }, - state === 'rejected' - ? { messageId: 'draft', expect: 'waiting', settledByOp: null } - : undefined + state === 'rejected' ? { messageId: 'draft', expect: 'waiting' } : undefined ) await c.journal.resolveDispatch({ clientMessageId: state, @@ -135,7 +130,7 @@ export function registerCodexCatchUpEchoTests(input: { for (const row of replay.rows) { applyJournalRow(state, row) } - expect(draftsDeliveredByAppliedEcho(state, [draft])).toEqual([]) + expect(state.queue.cards.get('draft')).toEqual(draft) }) it('keeps native display identity after a real retained cut and disk reopen', async () => { diff --git a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts index ba187a1572f1..31a3215d0f10 100644 --- a/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts +++ b/src/main/native-chat/agent-session-attachments/agent-session-attachment-claims.test.ts @@ -21,6 +21,7 @@ import { agentSessionAttachmentStoreRoot } from './agent-session-attachment-references' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' const UPLOAD_A = '0b6f8a52-4a3e-4c4e-9a59-1d5d1f2b8c01' const UPLOAD_B = '7c2d9e10-3f4a-4b5c-8d6e-2f1a0b9c8d02' @@ -216,8 +217,6 @@ describe('claims written with the message', () => { .insert({ messageId: 'draft-0', body: missing, - fingerprint: 'fp', - hostInstance: 'p', requireAttachments: true }) .catch((caught: unknown) => caught) @@ -229,18 +228,15 @@ describe('claims written with the message', () => { await journal.queuedMessages.insert({ messageId: 'draft-1', body, - fingerprint: 'fp', - hostInstance: 'p', requireAttachments: true }) await journal.context.clear( { operationId: 'clear', afterFence: 0, clearedAt: 1000 }, - { write: () => {}, committed: () => {} }, - 'caller-clear' + { write: () => {}, committed: () => {} } ) await journals.closeAll() const reopened = await open() - expect(reopened.queuedMessages.get('draft-1')).toMatchObject({ body, carriedFrom: null }) + expect(reopened.queuedMessages.get('draft-1')).toMatchObject({ body, state: 'waiting' }) expect(claims()).toEqual([{ upload_id: UPLOAD_A, session_id: IDENTITY.sessionId }]) }) @@ -251,14 +247,18 @@ describe('claims written with the message', () => { await journal.queuedMessages.insert({ messageId: 'draft-1', body, - fingerprint: 'fp', - hostInstance: 'p', requireAttachments: true }) await rm(join(storeRoot, UPLOAD_A), { recursive: true }) await journal.appendSubmission( - { clientMessageId: 'handoff-1', payloadFingerprint: 'fp', body, fence: 0, origin: 'client' }, - { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + { + clientMessageId: 'handoff-1', + payloadFingerprint: agentSessionSendBodyFingerprint(IDENTITY.sessionId, body), + body, + fence: 0, + origin: 'client' + }, + { messageId: 'draft-1', expect: 'waiting' } ) expect(journal.submission('handoff-1')).toBeTruthy() }) diff --git a/src/main/native-chat/agent-session-journal/command-receipt-transaction.spec.ts b/src/main/native-chat/agent-session-journal/command-receipt-transaction.spec.ts index c094e4f8dcd0..ae7e59688168 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-transaction.spec.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-transaction.spec.ts @@ -108,7 +108,6 @@ export function registerCommandReceiptTransactionTests(): void { }) await writer.enqueue( epochRow, - undefined, buildCommandReceiptTransaction(scope, (row) => { if (!row) { throw new Error('expected the committed journal row') @@ -177,6 +176,7 @@ export function registerCommandReceiptTransactionTests(): void { fence: 0, items: [], queuePause: { lifted: false, liveStop: null, reopened: false }, + queue: { cards: [], receipt: { write: () => {}, committed: () => {} } }, now: () => 2000, mintEpoch: () => 'epoch-2', onPublished: () => undefined diff --git a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts index acaaf99e0ae3..0d39d02f21d1 100644 --- a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts @@ -90,9 +90,17 @@ function journalClosedSets() { /** Recorded with the row version it holds for. Change it only together with that decision. */ const SNAPSHOT = { - v: 3, + v: 4, closed: { - 'row.kind': ['dispatch', 'epoch', 'item', 'lifecycle-batch', 'submission', 'tombstone'], + 'row.kind': [ + 'dispatch', + 'epoch', + 'item', + 'lifecycle-batch', + 'queue', + 'submission', + 'tombstone' + ], 'row.mutations[].kind': ['item', 'tombstone'], 'body.contextUsage.used.kind': ['estimate', 'report', 'unknown'], 'body.contextUsage.used.categories[].deferred': [true] diff --git a/src/main/native-chat/agent-session-journal/journal-context-clear.ts b/src/main/native-chat/agent-session-journal/journal-context-clear.ts index 8630fe4d704f..4558428ab4f9 100644 --- a/src/main/native-chat/agent-session-journal/journal-context-clear.ts +++ b/src/main/native-chat/agent-session-journal/journal-context-clear.ts @@ -2,7 +2,7 @@ import type { AgentSessionProviderContextBoundary } from '../../../shared/agent- import type { JournalReducerState } from './journal-reducer' import { journalItemRowBuilder } from './journal-row-builders' import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' -import type { JournalQueuedMessages } from './journal-queued-messages' +import type { JournalQueuedMessages, JournalQueuePlan } from './journal-queued-messages' import type { JournalTombstoneRow } from './journal-row-schema' import { buildJournalQueueClearRow } from './journal-stop-and-resume-rows' @@ -12,19 +12,21 @@ export function appendJournalContextClear(input: { cards: JournalQueuedMessages boundary: AgentSessionProviderContextBoundary receipt: JournalOperationReceipt - settledByOp: string }) { const { boundary, state } = input - let commandIds: string[] = [] + let commands: JournalQueuePlan | undefined return input.writer .enqueueRows( () => { const cards = input.cards.list() - commandIds = cards - .filter( - (card) => card.body.command && (card.state === 'waiting' || card.state === 'returned') - ) - .map((card) => card.messageId) + // A command is about the context the clear just ended: its card goes with the divider. + commands = input.cards.plan( + cards + .filter( + (card) => card.body.command && (card.state === 'waiting' || card.state === 'returned') + ) + .map((card) => ({ messageId: card.messageId, change: { op: 'withdrawn' as const } })) + ) const messageIds = cards .filter((card) => card.state === 'waiting' && !card.body.command) .map((card) => card.messageId) @@ -47,18 +49,19 @@ export function appendJournalContextClear(input: { ts, fence: boundary.afterFence, clear: { operationId: boundary.operationId, messageIds } - }) + }), + ...commands.rows ] }, { write: (db) => { - input.cards.withdrawInTransaction(db, { - messageIds: commandIds, - settledByOp: input.settledByOp - }) + commands?.receipt.write(db) input.receipt.write(db) }, - committed: input.receipt.committed + committed: () => { + commands?.receipt.committed() + input.receipt.committed() + } } ) .then((rows) => ({ diff --git a/src/main/native-chat/agent-session-journal/journal-context-controller.ts b/src/main/native-chat/agent-session-journal/journal-context-controller.ts index 7403760e8e91..e509844fb2a2 100644 --- a/src/main/native-chat/agent-session-journal/journal-context-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-context-controller.ts @@ -23,12 +23,8 @@ export class JournalContextController { return sequence > 0 ? { epoch: state.epoch, sequence } : null } - clear( - boundary: AgentSessionProviderContextBoundary, - receipt: JournalOperationReceipt, - settledByOp: string - ) { - return appendJournalContextClear({ ...this.deps, boundary, receipt, settledByOp }) + clear(boundary: AgentSessionProviderContextBoundary, receipt: JournalOperationReceipt) { + return appendJournalContextClear({ ...this.deps, boundary, receipt }) } rewind( diff --git a/src/main/native-chat/agent-session-journal/journal-context-rewind.ts b/src/main/native-chat/agent-session-journal/journal-context-rewind.ts index 8db6d9c0c7f7..0af34b6ccfee 100644 --- a/src/main/native-chat/agent-session-journal/journal-context-rewind.ts +++ b/src/main/native-chat/agent-session-journal/journal-context-rewind.ts @@ -10,7 +10,7 @@ import { buildJournalItemRow, buildJournalTombstoneRow } from './journal-row-bui import type { JournalRowWriter, JournalOperationReceipt } from './journal-row-writer' import { buildJournalQueueClearRow } from './journal-stop-and-resume-rows' import { journalQueuePauseRestatement } from './queued-message-pause' -import type { JournalQueuedMessages } from './journal-queued-messages' +import type { JournalQueuedMessages, JournalQueuePlan } from './journal-queued-messages' import type { JournalRow } from './journal-row-schema' export async function rewindJournalContext(input: { @@ -23,6 +23,7 @@ export async function rewindJournalContext(input: { receipt: (cursor: AgentJournalCursor) => JournalOperationReceipt }): Promise { let receipt: JournalOperationReceipt | undefined + let adoption: JournalQueuePlan | undefined const rows = await input.writer.enqueueRows( () => { const state = input.state() @@ -53,8 +54,10 @@ export async function rewindJournalContext(input: { removed.set(agentJournalSubmissionKey(submission.clientMessageId), true) } } + // Ahead of the tombstones: a legacy card read through a hand-off this retires keeps its reading. + adoption = input.cards.adoptionOfAll() const revisions = new Map() - const plan: ((seq: number, ts: number) => JournalRow)[] = [] + const plan: ((seq: number, ts: number) => JournalRow)[] = [...adoption.rows] for (const [itemId, retireSubmission] of removed) { const row = buildJournalTombstoneRow({ state, itemId, seq: 0, ts: 0, fence: input.fence }) revisions.set(itemId, row.revision) @@ -98,8 +101,14 @@ export async function rewindJournalContext(input: { return plan }, { - write: (db) => receipt!.write(db), - committed: () => receipt!.committed() + write: (db) => { + adoption?.receipt.write(db) + receipt!.write(db) + }, + committed: () => { + adoption?.receipt.committed() + receipt!.committed() + } } ) const last = rows.at(-1)! diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index 43377a923dd9..54f9a27d5950 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts @@ -22,7 +22,7 @@ export function applyJournalDispatchRow( row: Extract ): void { const submission = state.submissions.get(row.clientMessageId) - // Shared with the queued-draft returned hook: a row ignored here must not alter a draft. + // Shared with the queue's fold: a row ignored here must not alter a card. if (!submission || !journalDispatchRowApplies(submission)) { return } @@ -107,7 +107,7 @@ function readAnsweredTurn(value: unknown): AgentJournalAnsweredTurn | null { /** A stored rejection fact, read where it can be placed; a kind it cannot place is kept as * written, so the classifier still knows a fact was there without this build claiming what it - * says. Shared with the queued-draft table, whose returned card mirrors its submission. */ + * says. Shared with the queue's fold, whose returned card carries its hand-off's. */ export function readStoredRejectionFact(value: unknown): UnreadAgentSessionFailureFact | undefined { return readAgentSessionFailureFact(value) ?? unreadFailureFact(value) } diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts index 2211e0ea5361..c7d723a3f6ab 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.ts @@ -4,20 +4,24 @@ import { type SubmissionRejectionKind } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { - DISPATCH_REJECTED_CANCELLED, - DISPATCH_REJECTED_HOST_RESTARTED -} from '../../../shared/structured-agent-session-dispatch-rejection' -import { rejectedDraftSettlement } from './journal-dispatch-settlement' +import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../shared/structured-agent-session-dispatch-rejection' +import { queuedHandOffOutcome } from './journal-dispatch-settlement' import { journalPendingSubmissionResolutions, type JournalPendingSubmission } from './journal-pending-submission-recovery' -function settle(kind: SubmissionRejectionKind) { - return rejectedDraftSettlement( - agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) +/** A card's hand-off, handed to the agent unless `held`, refused with `kind`. */ +function settle(kind: SubmissionRejectionKind, proof: { held?: true; unrun?: true } = {}) { + const words = agentSessionFailureWords( + agentSessionFailureFact(kind, proof.unrun ? { neverReachedAgent: true } : {}), + { surface: 'rejection' } ) + return queuedHandOffOutcome({ + ...words, + handoverRecorded: true, + ...(proof.held ? {} : { handedOverAt: 1 }) + }) } describe('what a rejection does to the draft it was consumed from', () => { @@ -56,22 +60,19 @@ describe('what a rejection does to the draft it was consumed from', () => { ]) }) - it("a Stop's withdrawal sends it back to waiting, under the queue's pause rather than a hold of its own", () => { - expect(settle('cancelled')).toEqual({ state: 'waiting' }) - expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_CANCELLED })).toEqual({ - state: 'waiting' - }) - expect(settle('notDelivered')).toEqual({ state: 'waiting' }) + it('a Stop waits it again only where Orca proves the agent never got it', () => { + expect(settle('cancelled', { held: true })).toBe('waiting') + expect(settle('cancelled', { unrun: true })).toBe('waiting') + expect(settle('cancelled')).toBe('inDoubt') + expect(settle('notDelivered')).toBe('inDoubt') }) - // Whoever sent it, it waits under the reopen's pause like every card the chat closed with. it('a restart or close before hand-over sends it back to waiting, with no hold of its own', () => { for (const kind of ['hostRestarted', 'chatClosed'] as const) { - expect(settle(kind)).toEqual({ state: 'waiting' }) + expect(settle(kind, { held: true })).toBe('waiting') } - expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({ - state: 'waiting' - }) + // An older host's send carries no hand-over record: it may have gone at once. + expect(queuedHandOffOutcome({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toBe('inDoubt') }) it('a failure returns the card for the user to act on', () => { @@ -83,10 +84,8 @@ describe('what a rejection does to the draft it was consumed from', () => { 'writeFailed', 'queueFull' ] as const) { - expect(settle(kind)).toEqual({ state: 'returned' }) + expect(settle(kind)).toBe('returned') } - expect(rejectedDraftSettlement({ reason: 'the provider said no' })).toEqual({ - state: 'returned' - }) + expect(queuedHandOffOutcome({ reason: 'the provider said no' })).toBe('returned') }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts index 28bfdb3358b9..924dba513af5 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts @@ -1,12 +1,12 @@ // The one decision for whether a committed dispatch row changes a submission's -// effective delivery answer, and what a rejection does to the draft it was -// consumed from. The reducer folds rows through the first and the queued -// draft's settlement hook fires through it, so the two can never disagree: a -// row the reducer ignores must not alter a draft. +// effective delivery answer, and what a rejection does to the card it was +// handed off from. The reducer folds rows through the first and the queue's +// fold reads the answer it leaves, so the two can never disagree: a row the +// reducer ignores must not alter a card. +import { agentJournalSubmissionProvenUnwritten } from '../../../shared/agent-session-queued-submission' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' -import type { JournalDispatchRow } from './journal-row-schema' /** `rejected` and `accepted` are terminal; a late row for an absent or settled * submission must not reopen the answer. */ @@ -20,33 +20,18 @@ export function journalDispatchRowApplies( ) } -/** A consumed draft's submission settled `rejected`: the draft is settled by - * `rejectedDraftSettlement`, since its text has no other holder once it left - * the sender's outbox as a draft. */ -export function consumedSubmissionWasRejected( - submission: Pick | undefined -): boolean { - return submission?.dispatchState === 'rejected' -} - -/** What a consumed draft becomes when its submission is rejected. */ -export type RejectedDraftSettlement = { state: 'returned' | 'waiting' } +/** What a refused hand-off makes of its card: a failure returns it; a message Orca proves never + * reached the agent waits again; anything else may have reached it, so it is never sent again. */ +export type QueuedHandOffOutcome = 'returned' | 'waiting' | 'inDoubt' -/** - * A withdrawal waits at its original position under the queue's pause; a failure returns the - * card, except a command refused in its own turn, whose row already reports it. - */ -export function rejectedDraftSettlement( - rejected: Pick & { rejection?: unknown } -): RejectedDraftSettlement { - return { state: classifyDispatchRejection(rejected).verdict !== null ? 'returned' : 'waiting' } -} - -/** True when committing this row NEWLY settles the submission to `rejected` — - * the only transition that settles a consumed draft. */ -export function journalDispatchRowNewlyRejects( - submission: Pick | undefined, - row: Pick -): boolean { - return row.state === 'rejected' && journalDispatchRowApplies(submission) +export function queuedHandOffOutcome( + rejected: Pick< + AgentJournalSubmission, + 'reason' | 'rejection' | 'handoverRecorded' | 'handedOverAt' | 'answeredInTurn' + > +): QueuedHandOffOutcome { + if (classifyDispatchRejection(rejected).verdict !== null) { + return 'returned' + } + return agentJournalSubmissionProvenUnwritten(rejected) ? 'waiting' : 'inDoubt' } diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts index 2fb07dd53e14..fbaf339e6249 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts @@ -6,6 +6,7 @@ import type { JournalReducerState } from './journal-reducer' import type { JournalHostDatabase } from './journal-host-database' import { replaceJournalEpoch, type JournalReplacementItem } from './journal-epoch-replacement' import type { JournalQueuePauseRestatement } from './queued-message-pause' +import type { JournalQueueRestatement } from './journal-queued-messages' import { publishNewEpoch } from './journal-epoch-rollover' import type { JournalLoad } from './journal-open' import type { AgentJournalEpochReason } from './journal-row-schema' @@ -26,6 +27,8 @@ export class JournalEpochController { state: () => JournalReducerState /** What of the live epoch's Stop and Resume a replacement restates. */ queuePauseRestatement: () => JournalQueuePauseRestatement + /** The cards a new epoch writes again (`retainedQueueFacts`). */ + queueRestatement: () => JournalQueueRestatement cursor: () => AgentJournalCursor adopt: (loaded: JournalLoad) => void } @@ -39,6 +42,7 @@ export class JournalEpochController { reason, fence, now: this.deps.now(), + queue: this.deps.queueRestatement(), onPublished: this.deps.adopt }) } @@ -76,6 +80,7 @@ export class JournalEpochController { receipt, previous: this.deps.state(), queuePause: this.deps.queuePauseRestatement(), + queue: this.deps.queueRestatement(), now: this.deps.now, mintEpoch: this.deps.mintEpoch, onPublished: this.deps.adopt diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.spec.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.spec.ts index a4d883c8b5d4..f431f98b7c7b 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.spec.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.spec.ts @@ -70,6 +70,7 @@ export function registerJournalEpochReplacementTests(): void { fence: 1, items: input.items, queuePause: { lifted: false, liveStop: null, reopened: false }, + queue: { cards: [], receipt: { write: () => {}, committed: () => {} } }, now, mintEpoch: () => `epoch-${clock}`, onPublished: input.onPublished ?? (() => undefined) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 18d258a1495c..08ddcbb1f9f5 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -6,7 +6,8 @@ import { // Republishing a live item set into a fresh epoch. // // One transaction: discard the old epoch's rows, insert the epoch row plus the -// replacement items, and move the session projection. +// replacement items and the queue's cards still owed an answer, and move the +// session projection. import { sameJournalConversation } from './journal-history-origin' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' @@ -36,6 +37,8 @@ import { buildJournalSubmissionRow, journalRowBase } from './journal-row-builders' +import { buildJournalQueueRow } from './journal-queue-events' +import type { JournalQueueRestatement } from './journal-queued-messages' import { buildJournalQueueReopenRow, buildJournalQueueClearRow, @@ -79,6 +82,8 @@ export function replaceJournalEpoch(input: { /** Restated in the new epoch, or the rewind would release cards the person stopped, or bring * back a /clear pause they already lifted. */ queuePause: JournalQueuePauseRestatement + /** The cards restated ahead of the carried submissions, which relink theirs. */ + queue: JournalQueueRestatement now: () => number mintEpoch: () => string /** Called the instant the transaction commits, before any fallible follow-up. */ @@ -130,6 +135,19 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } + for (const { messageId, change } of input.queue.cards) { + if (change) { + const row = buildJournalQueueRow( + state, + messageId, + change, + state.lastSequence + 1, + input.now() + ) + applyJournalRow(state, row) + rows.push(row) + } + } // An accepted message that never reached the agent survives a provider history replacement. const queued = [...(input.previous?.submissions.values() ?? [])] .filter(isQueuedAgentJournalSubmission) @@ -186,9 +204,11 @@ export function replaceJournalEpoch(input: { for (const row of rows) { insertJournalRow(db, sessionId, row) } + input.queue.receipt.write(db) publishJournalSessionEpoch(db, input.identity, epoch) receipt?.write(db) }) + input.queue.receipt.committed() // COMMIT landed: on disk the superseded rows are gone and this epoch is the // live one. The caller adopts that immediately, or a later failure leaves the diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts index 477a79f11ed1..9e57524db2d6 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts @@ -1,8 +1,9 @@ // Opening a new epoch. // // One transaction: discard every row of the superseded epoch, insert the new -// epoch row at sequence 1, and move the session projection onto it. Superseded -// rows are DELETED rather than retained — nothing would ever shed them. +// epoch row at sequence 1 and the queue's cards still owed an answer, and move +// the session projection onto it. Superseded rows are DELETED rather than +// retained — nothing would ever shed them. import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' @@ -10,6 +11,8 @@ import { agentSessionJournalProviderHandle } from '../../../shared/agent-session import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import { buildJournalQueueRow } from './journal-queue-events' +import type { JournalQueueRestatement } from './journal-queued-messages' import { deleteJournalEpochRows, insertJournalRow, @@ -25,6 +28,9 @@ export function publishNewEpoch(input: { reason: AgentJournalEpochReason fence: number now: number + /** The cards restated in the new epoch; it carries no submission, so a card whose hand-off was + * not handed over yet waits again (`retainedQueueFacts`). */ + queue: JournalQueueRestatement /** Called the instant the transaction commits, before any fallible follow-up. */ onPublished: (loaded: JournalLoad) => void }): void { @@ -41,20 +47,32 @@ export function publishNewEpoch(input: { } const { sessionId } = input.identity + const state = createJournalReducerState(sessionId, input.epoch) + applyJournalRow(state, row) + const rows: JournalRow[] = [row] + for (const { messageId, change } of input.queue.cards) { + if (change) { + const card = buildJournalQueueRow(state, messageId, change, state.lastSequence + 1, input.now) + applyJournalRow(state, card) + rows.push(card) + } + } input.database.transaction((db) => { const retired = readJournalSessionEpoch(db, sessionId) if (retired !== null) { deleteJournalEpochRows(db, sessionId, retired) } - insertJournalRow(db, sessionId, row) + for (const each of rows) { + insertJournalRow(db, sessionId, each) + } + input.queue.receipt.write(db) publishJournalSessionEpoch(db, input.identity, input.epoch) }) // COMMIT landed: on disk the superseded prefix is gone and this epoch is the // live one. The caller adopts that immediately, or a later failure leaves the // store writing into an epoch that no longer exists. - const state = createJournalReducerState(sessionId, input.epoch) - applyJournalRow(state, row) + input.queue.receipt.committed() state.oldestSequence = 1 input.onPublished({ state, newer: null, damage: null }) } diff --git a/src/main/native-chat/agent-session-journal/journal-queue-events.ts b/src/main/native-chat/agent-session-journal/journal-queue-events.ts new file mode 100644 index 000000000000..5564f5439b50 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queue-events.ts @@ -0,0 +1,117 @@ +// The queue's journal rows. Every fact about a queued message is one of these, in the chat's own +// history beside the submission that hands it off and the dispatch row that answers it, so the +// card is folded by THE reducer (`journal-queue-fold.ts`) and nothing stores it a second time. + +import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import { + AGENT_SESSION_JOURNAL_QUEUE_SCHEMA_VERSION, + type AgentJournalCursor, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import { + readAgentJournalMessageBody, + type AgentJournalContentVerdict +} from '../../../shared/agent-session-journal-body-admission' +import { isJournalTag } from '../../../shared/agent-session-journal-open-union' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRowBase } from './journal-row-schema' + +export type JournalQueueChange = + | { + op: 'queued' + body: AgentJournalMessageItem + /** Queue order: the queue sends the lowest first. */ + position: number + /** Where the journal stood when it was queued; null on a card from before it was recorded. */ + queuedAt: AgentJournalCursor | null + /** Holds this one card from sending by itself (`QueuedMessageHoldReason`). */ + holdReason?: string + /** The model and effort its send carried. */ + options?: AgentSessionExecutionOptions + /** A restated or imported card's hand-off: the submission it went out as. */ + attempt?: string + /** Only where the refusal's own rows are gone (a replaced epoch, an imported card whose hand-off + * is not in this one), so exactly one copy of a refusal exists at any time. */ + returned?: { reason: string | null; rejection?: UnreadAgentSessionFailureFact } + } + | { op: 'edited'; body: AgentJournalMessageItem } + | { op: 'moved'; position: number } + | { op: 'held'; holdReason: string } + | { op: 'withdrawn' } + +export type JournalQueuedChange = Extract + +export type JournalQueueRow = JournalRowBase & { + kind: 'queue' + /** The card's stable id: the send it was queued as. */ + messageId: string + change: JournalQueueChange +} + +/** One card's change; absent: only adopt the card into the journal (`journal-queue-import.ts`). */ +export type JournalQueueCardChange = { messageId: string; change?: JournalQueueChange } + +/** A queue row in the live epoch. Its fence is the journal's own: a card is no generation's work, + * so it never goes stale behind a newer owner. */ +export function journalQueueRowBuilder( + state: () => JournalReducerState, + messageId: string, + change: JournalQueueChange +): (seq: number, ts: number) => JournalQueueRow { + return (seq, ts) => buildJournalQueueRow(state(), messageId, change, seq, ts) +} + +export function buildJournalQueueRow( + state: JournalReducerState, + messageId: string, + change: JournalQueueChange, + seq: number, + ts: number +): JournalQueueRow { + return { + kind: 'queue', + messageId, + change, + v: AGENT_SESSION_JOURNAL_QUEUE_SCHEMA_VERSION, + epoch: state.epoch, + seq, + fence: state.highestFence, + ts + } +} + +/** The row's own fields. Optional fields are read defensively by the fold, so only what a card + * cannot exist without fails the row; an op this build does not know is a newer build's. */ +export function journalQueueRowContent( + record: Record +): AgentJournalContentVerdict { + const { change } = record + if ( + typeof record.messageId !== 'string' || + record.messageId.length === 0 || + typeof change !== 'object' || + change === null || + !('op' in change) + ) { + return 'malformed' + } + const position = 'position' in change && Number.isFinite(change.position) + const body = 'body' in change ? readAgentJournalMessageBody(change.body) : 'malformed' + switch (change.op) { + case 'queued': + return position ? body : 'malformed' + case 'edited': + return body + case 'moved': + return position ? 'readable' : 'malformed' + case 'held': + return 'holdReason' in change && typeof change.holdReason === 'string' + ? 'readable' + : 'malformed' + case 'withdrawn': + return 'readable' + default: + return isJournalTag(change.op) ? 'unreadable' : 'malformed' + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-queue-fold.ts b/src/main/native-chat/agent-session-journal/journal-queue-fold.ts new file mode 100644 index 000000000000..35fdaa8d7682 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queue-fold.ts @@ -0,0 +1,247 @@ +// The queue's fold: each card as the chat's own rows say it stands. A card's outcome is decided by +// the row that decides it (its hand-off's dispatch row) and kept from then on, so a later rewind +// that retires the hand-off cannot change it, and replay reads the same. A hand-off ends the card's +// queue life: it waits again only where Orca proves the agent never got it. + +import type { + AgentJournalCursor, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' +import { structuredAgentSessionCommandTurnIdentity } from '../../../shared/structured-agent-session-command-turn-identity' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' +import { readStoredRejectionFact } from './journal-dispatch-reducer' +import { queuedHandOffOutcome } from './journal-dispatch-settlement' +import type { JournalQueueRow, JournalQueuedChange } from './journal-queue-events' +import type { JournalReducerState } from './journal-reducer' +import type { JournalRow } from './journal-row-schema' +import { foldQueuedHandOffInDoubt } from './journal-submission-fold' +import type { QueuedMessageRow } from './queued-message-table' + +export type JournalQueueState = { + cards: Map + /** Moves with every card change, so a reader's memo recomputes only when it must. */ + revision: number +} + +export function createJournalQueueState(): JournalQueueState { + return { cards: new Map(), revision: 0 } +} + +/** + * What a refusal makes of the dispatched card it handed off (`queuedHandOffOutcome`): one Orca + * proves never reached the agent waits again at its own position under the queue's pause; one that + * may have reached it stays spent; a failure returns the card with its reason, except a command + * refused in its own turn, which is spent because that turn's row says why and a returned card + * would hold every card behind it. + */ +export function rejectedCardTransition( + card: QueuedMessageRow, + rejected: AgentJournalSubmission, + commandTurnReported: boolean +): QueuedMessageRow { + const outcome = queuedHandOffOutcome(rejected) + if (outcome === 'waiting') { + return { ...card, state: 'waiting', holdReason: null, consumedAs: null } + } + if (outcome === 'inDoubt') { + return card + } + if (card.body.command && commandTurnReported) { + return { ...card, state: 'withdrawn' } + } + return { + ...card, + state: 'returned', + returnedReason: rejected.reason, + returnedRejection: rejected.rejection ?? null + } +} + +/** The submission's command turn is in the journal: its own row reports the refusal. */ +function commandTurnReported(state: JournalReducerState, clientMessageId: string): boolean { + return state.items.has( + agentJournalItemKey(structuredAgentSessionCommandTurnIdentity(clientMessageId)) + ) +} + +/** + * THE classification of a `queued` change, for the fold, the import and the read of a card not yet + * imported, so each reads a card exactly as the others do. Its hand-off still in this epoch settles + * it as that hand-off's answer does; gone, the saved refusal returns it, and with none it is a + * hand-off nothing can answer: hidden, never resent and never shown as refused. Pure: the fold + * settles a hand-off in doubt afterwards (`settleHandOffInDoubt`). + */ +export function queuedCardFromChange( + state: JournalReducerState, + messageId: string, + change: JournalQueuedChange +): QueuedMessageRow { + const options = readAgentSessionExecutionOptions(change.options) + const card: QueuedMessageRow = { + messageId, + position: change.position, + body: change.body, + fingerprint: agentSessionSendBodyFingerprint(state.sessionId, change.body), + state: 'waiting', + holdReason: typeof change.holdReason === 'string' ? change.holdReason : null, + returnedReason: null, + returnedRejection: null, + consumedAs: null, + queuedAt: readQueuedAt(change.queuedAt), + ...(options ? { options } : {}) + } + const attempt = + typeof change.attempt === 'string' && change.attempt.length > 0 ? change.attempt : null + const submission = attempt === null ? undefined : state.submissions.get(attempt) + if (submission) { + const handedOff: QueuedMessageRow = { + ...card, + state: 'dispatched', + holdReason: null, + consumedAs: attempt + } + return settledHandOff(state, handedOff, submission) + } + const { returned } = change + if (typeof returned === 'object' && returned !== null) { + return { + ...card, + state: 'returned', + holdReason: null, + consumedAs: attempt, + returnedReason: typeof returned.reason === 'string' ? returned.reason : null, + returnedRejection: readStoredRejectionFact(returned.rejection) ?? null + } + } + if (attempt !== null) { + return { ...card, state: 'dispatched', holdReason: null, consumedAs: attempt } + } + return card +} + +/** After any row applies: what it does to the cards. */ +export function foldJournalQueueRow(state: JournalReducerState, row: JournalRow): void { + if (row.kind === 'queue') { + applyQueueRow(state, row) + } else if (row.kind === 'submission') { + const card = + typeof row.queuedMessageId === 'string' + ? state.queue.cards.get(row.queuedMessageId) + : undefined + if (card) { + setCard(state, { + ...card, + state: 'dispatched', + holdReason: null, + consumedAs: row.clientMessageId, + returnedReason: null, + returnedRejection: null + }) + } + } else if (row.kind === 'dispatch') { + const submission = state.submissions.get(row.clientMessageId) + const card = cardHandedOffAs(state, submission) + if (card && submission) { + setCard(state, settledHandOff(state, card, submission)) + settleHandOffInDoubt(state, row.clientMessageId) + } + } else if (row.kind === 'item' || row.kind === 'lifecycle-batch') { + spendReportedCommandCards(state) + } +} + +function applyQueueRow(state: JournalReducerState, row: JournalQueueRow): void { + const { change } = row + if (change.op === 'queued') { + const card = queuedCardFromChange(state, row.messageId, change) + setCard(state, card) + if (card.consumedAs !== null) { + settleHandOffInDoubt(state, card.consumedAs) + } + return + } + const card = state.queue.cards.get(row.messageId) + if (!card) { + return + } + if (change.op === 'edited') { + const fingerprint = agentSessionSendBodyFingerprint(state.sessionId, change.body) + setCard(state, { ...card, body: change.body, fingerprint }) + } else if (change.op === 'moved') { + setCard(state, { ...card, position: change.position }) + } else if (change.op === 'held') { + setCard(state, card.state === 'waiting' ? { ...card, holdReason: change.holdReason } : card) + } else if (change.op === 'withdrawn') { + setCard(state, { ...card, state: 'withdrawn', holdReason: null }) + } +} + +/** The card handed off as this submission, while that is still its current hand-off: an older + * attempt's late answer never moves a newer one. */ +function cardHandedOffAs( + state: JournalReducerState, + submission: AgentJournalSubmission | undefined +): QueuedMessageRow | undefined { + const card = + submission?.queuedMessageId === undefined + ? undefined + : state.queue.cards.get(submission.queuedMessageId) + return card?.state === 'dispatched' && card.consumedAs === submission?.clientMessageId + ? card + : undefined +} + +function settledHandOff( + state: JournalReducerState, + card: QueuedMessageRow, + submission: AgentJournalSubmission +): QueuedMessageRow { + if (submission.dispatchState !== 'rejected') { + return card + } + const reported = commandTurnReported(state, submission.clientMessageId) + return rejectedCardTransition(card, submission, reported) +} + +/** The card's own hand-off, refused while it may have reached the agent: read as sent + * (`foldQueuedHandOffInDoubt`). */ +function settleHandOffInDoubt(state: JournalReducerState, clientMessageId: string): void { + const submission = state.submissions.get(clientMessageId) + if (submission?.dispatchState === 'rejected' && queuedHandOffOutcome(submission) === 'inDoubt') { + foldQueuedHandOffInDoubt(state, submission) + } +} + +/** A returned command card is spent once its own turn's row reports the refusal. */ +function spendReportedCommandCards(state: JournalReducerState): void { + for (const card of state.queue.cards.values()) { + if ( + card.state === 'returned' && + card.body.command && + card.consumedAs !== null && + commandTurnReported(state, card.consumedAs) + ) { + setCard(state, { ...card, state: 'withdrawn' }) + } + } +} + +function setCard(state: JournalReducerState, card: QueuedMessageRow): void { + if (state.queue.cards.get(card.messageId) !== card) { + state.queue.cards.set(card.messageId, card) + state.queue.revision += 1 + } +} + +function readQueuedAt(value: unknown): AgentJournalCursor | null { + return typeof value === 'object' && + value !== null && + 'epoch' in value && + typeof value.epoch === 'string' && + 'sequence' in value && + typeof value.sequence === 'number' + ? { epoch: value.epoch, sequence: value.sequence } + : null +} diff --git a/src/main/native-chat/agent-session-journal/journal-queue-import.ts b/src/main/native-chat/agent-session-journal/journal-queue-import.ts new file mode 100644 index 000000000000..a2330411e9ce --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queue-import.ts @@ -0,0 +1,251 @@ +// Bringing the `queued_messages` rows earlier builds wrote into the journal. Those rows are the only +// record of the work this owes, so nothing else stores it: it ends once no readable row is left. +// Until then the queue's one view reads each through the fold's own classification, so a card reads +// the same before and after it moves, and every action on one moves it in its own transaction. The +// bulk import runs once per open and is never retried in a loop: the next open tries again. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { journalDispatchRowApplies, queuedHandOffOutcome } from './journal-dispatch-settlement' +import { journalQueueRowBuilder, type JournalQueuedChange } from './journal-queue-events' +import { queuedCardFromChange } from './journal-queue-fold' +import { queuedChangeOf } from './journal-queue-retention' +import type { JournalReducerState } from './journal-reducer' +import type { JournalOperationReceipt } from './journal-row-writer' +import type { JournalQueuedMessagesDeps } from './journal-store-contracts' +import { foldQueuedHandOffInDoubt } from './journal-submission-fold' +import { + QUEUED_MESSAGE_HELD_DELIVERY_UNCONFIRMED, + type QueuedMessageRow +} from './queued-message-table' +import { + LEGACY_QUEUED_MESSAGE_COLUMNS, + readStoredQueuedMessageRow +} from './queued-message-stored-row' + +type LegacyDeps = Pick< + JournalQueuedMessagesDeps, + 'sessionId' | 'database' | 'state' | 'serialize' | 'readOnly' | 'writer' | 'committed' +> + +/** The `queued` change a legacy row moves into the journal as, or null when it is answered and + * nothing is written: withdrawn or delivered. Its hand-off link is kept, and its stored refusal + * only where that hand-off is not in this epoch. */ +export function legacyQueuedChange( + state: JournalReducerState, + row: QueuedMessageRow +): JournalQueuedChange | null { + const unconfirmed = row.state === 'waiting' ? unconfirmedHandOff(state, row) : null + const attempt = row.state === 'waiting' ? (unconfirmed?.attempt ?? null) : row.consumedAs + if (row.state === 'withdrawn' || (row.state === 'dispatched' && attempt === null)) { + return null + } + const holdReason = unconfirmed?.holdReason ?? (row.state === 'waiting' ? row.holdReason : null) + const change: JournalQueuedChange = { + ...queuedChangeOf(row), + ...(holdReason !== null ? { holdReason } : {}), + ...(attempt !== null ? { attempt } : {}), + ...(row.state === 'returned' && (attempt === null || !state.submissions.has(attempt)) + ? { + returned: { + reason: row.returnedReason, + ...(row.returnedRejection ? { rejection: row.returnedRejection } : {}) + } + } + : {}) + } + const card = queuedCardFromChange(state, row.messageId, change) + return unanswered(card, state.submissions) ? change : null +} + +/** + * Earlier builds put a card back to waiting after any refusal without a verdict, and cleared its + * link. Its own hand-offs in this epoch say whether one may have reached the agent: unchanged and + * last sent as that one, the card is spent on it, as today; edited, or sent again since, it is held + * for its own Send. With no hand-off in doubt it waits as it did: one that never reached the agent + * is no delivery. + */ +function unconfirmedHandOff( + state: JournalReducerState, + row: QueuedMessageRow +): + | { attempt: string; holdReason?: undefined } + | { attempt?: undefined; holdReason: string } + | null { + const attempts = [...state.submissions.values()] + .filter((submission) => submission.queuedMessageId === row.messageId) + .sort((a, b) => (a.submittedSequence ?? 0) - (b.submittedSequence ?? 0)) + if (!attempts.some(handOffInDoubt)) { + return null + } + const latest = attempts.at(-1)! + return handOffInDoubt(latest) && latest.payloadFingerprint === row.fingerprint + ? { attempt: latest.clientMessageId } + : { holdReason: QUEUED_MESSAGE_HELD_DELIVERY_UNCONFIRMED } +} + +function handOffInDoubt(submission: AgentJournalSubmission): boolean { + return submission.dispatchState === 'rejected' && queuedHandOffOutcome(submission) === 'inDoubt' +} + +/** Waiting, returned, or handed off as a send a later row may still answer (as the fold keeps it), + * that may have reached the agent (its message then reads as sent), or that nothing can answer. */ +function unanswered( + card: QueuedMessageRow, + submissions: ReadonlyMap +): boolean { + if (card.state !== 'dispatched') { + return card.state !== 'withdrawn' + } + const handOff = card.consumedAs === null ? undefined : submissions.get(card.consumedAs) + return handOff === undefined || journalDispatchRowApplies(handOff) || handOffInDoubt(handOff) +} + +/** The legacy rows still on disk for one session, as the queue's view reads them. */ +export class LegacyQueuedCards { + /** Moves after every commit that deleted legacy rows. */ + private revision = 0 + private stored: { revision: number; rows: readonly QueuedMessageRow[] } | null = null + private viewed: { key: string; cards: readonly QueuedMessageRow[] } | null = null + + constructor(private readonly deps: LegacyDeps) {} + + /** Empty once no readable legacy row is left: the fallback is then off for this handle. A card's + * reading follows the fold, so it moves with every row while any is left. */ + key(): string { + if (this.rows().length === 0) { + return '' + } + const { epoch, lastSequence } = this.deps.state() + return `${this.revision}:${epoch}:${lastSequence}` + } + + cards(): readonly QueuedMessageRow[] { + const key = this.key() + if (key === '') { + return [] + } + if (this.viewed?.key !== key) { + const state = this.deps.state() + const cards = this.rows().flatMap((row) => { + const change = legacyQueuedChange(state, row) + return change ? [queuedCardFromChange(state, row.messageId, change)] : [] + }) + this.viewed = { key, cards } + } + return this.viewed.cards + } + + /** Failed adoption must not hide a spent message; derive its adopted display on copies only. */ + displayState(): JournalReducerState { + const state = this.deps.state() + const handedOff = this.cards().flatMap((card) => { + const submission = + card.consumedAs === null ? undefined : state.submissions.get(card.consumedAs) + return submission && handOffInDoubt(submission) ? [{ ...submission }] : [] + }) + if (handedOff.length === 0) { + return state + } + const display = { + ...state, + items: new Map(state.items), + submissions: new Map(state.submissions) + } + for (const submission of handedOff) { + display.submissions.set(submission.clientMessageId, submission) + foldQueuedHandOffInDoubt(display, submission) + } + return display + } + + /** On the journal's lane: the change that moves this card into the journal, read from disk now; + * null when it is not a legacy card. */ + adoption(messageId: string): JournalQueuedChange | null { + if (this.key() === '') { + return null + } + const { db } = this.deps.database() + const row = db + .prepare( + `SELECT ${LEGACY_QUEUED_MESSAGE_COLUMNS} FROM queued_messages WHERE session_id = ? AND message_id = ?` + ) + .get(this.deps.sessionId, messageId) + const card = row === undefined ? null : readStoredQueuedMessageRow(row) + return card && legacyQueuedChange(this.deps.state(), card) + } + + /** Deletes these legacy rows in the caller's transaction; the view re-reads once it commits. */ + deletion(messageIds: readonly string[]): JournalOperationReceipt { + return { + write: (db) => { + const remove = db.prepare( + 'DELETE FROM queued_messages WHERE session_id = ? AND message_id = ?' + ) + for (const messageId of messageIds) { + remove.run(this.deps.sessionId, messageId) + } + }, + committed: () => { + this.revision += messageIds.length > 0 ? 1 : 0 + } + } + } + + /** Every readable legacy row's card id. */ + messageIds(): string[] { + return this.rows().map((row) => row.messageId) + } + + /** `deletion` of every readable legacy row: a new epoch restates their cards with its own. */ + deletionOfAll(): JournalOperationReceipt { + return this.deletion(this.messageIds()) + } + + /** Every readable row, unanswered ones as journal rows, all deleted, in ONE transaction; a + * failure is reported and leaves both as they were. */ + importAtOpen(): Promise { + if (this.deps.readOnly() || this.rows().length === 0) { + return Promise.resolve() + } + return this.deps + .serialize(() => { + const rows = this.read() + const deletion = this.deletion(rows.map((row) => row.messageId)) + const plan = rows.flatMap((row) => { + const change = legacyQueuedChange(this.deps.state(), row) + return change ? [journalQueueRowBuilder(this.deps.state, row.messageId, change)] : [] + }) + if (plan.length > 0) { + this.deps.writer.writeRows(() => plan, deletion) + return + } + // No row to write: the writer opens no transaction for none, so the deletes take their own. + this.deps.database().transaction((db) => deletion.write(db)) + deletion.committed() + this.deps.committed() + }) + .catch((error: unknown) => { + console.warn('[journal-open] queued-message import skipped:', { + sessionId: this.deps.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + }) + } + + private rows(): readonly QueuedMessageRow[] { + if (this.stored?.revision !== this.revision) { + this.stored = { revision: this.revision, rows: this.read() } + } + return this.stored.rows + } + + private read(): QueuedMessageRow[] { + return this.deps + .database() + .db.prepare( + `SELECT ${LEGACY_QUEUED_MESSAGE_COLUMNS} FROM queued_messages WHERE session_id = ? ORDER BY position ASC` + ) + .all(this.deps.sessionId) + .flatMap((row) => readStoredQueuedMessageRow(row) ?? []) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-queue-retention.ts b/src/main/native-chat/agent-session-journal/journal-queue-retention.ts new file mode 100644 index 000000000000..fa4eb6f2e3c7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queue-retention.ts @@ -0,0 +1,59 @@ +// What of the queue a new epoch restates. Replacing or rolling over an epoch deletes its rows, +// the cards' rows and their hand-offs with them, so every card still owed an answer is written +// again in the deleting transaction, the way the queue's pauses are. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import type { JournalQueueCardChange, JournalQueuedChange } from './journal-queue-events' +import type { QueuedMessageRow } from './queued-message-table' + +/** + * THE retention rule, one for replacement and rollover: a waiting card; a returned one, with its + * refusal written down because its own rows are going; and a card handed off as a send not yet + * handed over, which provably never reached the agent and so waits again (a replacement's carried + * submission relinks it). Everything else is answered, or can no longer be. + */ +export function retainedQueueFacts( + cards: readonly QueuedMessageRow[], + submissions: ReadonlyMap +): JournalQueueCardChange[] { + return cards.flatMap((card) => { + const handOff = card.consumedAs === null ? undefined : submissions.get(card.consumedAs) + const unsent = + card.state === 'dispatched' && + handOff !== undefined && + isQueuedAgentJournalSubmission(handOff) + if (card.state !== 'waiting' && card.state !== 'returned' && !unsent) { + return [] + } + const change: JournalQueuedChange = { + ...queuedChangeOf(card), + ...(card.state === 'waiting' && card.holdReason !== null + ? { holdReason: card.holdReason } + : {}), + ...(card.state === 'returned' + ? { + ...(card.consumedAs !== null ? { attempt: card.consumedAs } : {}), + returned: { + reason: card.returnedReason, + ...(card.returnedRejection ? { rejection: card.returnedRejection } : {}) + } + } + : {}) + } + return [{ messageId: card.messageId, change }] + }) +} + +/** A card's own fields as a `queued` change: its text, place, time and options. */ +export function queuedChangeOf( + card: Pick +): JournalQueuedChange { + return { + op: 'queued', + body: card.body, + position: card.position, + queuedAt: card.queuedAt, + ...(card.options ? { options: card.options } : {}) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-queued-message-contracts.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-message-contracts.test.ts index 0214b87410ab..e2e8459df060 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-message-contracts.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-message-contracts.test.ts @@ -2,10 +2,7 @@ import { afterEach, describe, vi } from 'vitest' import { registerJournalPiContextImportTests } from './journal-pi-context-import.spec' import { registerJournalProducerInheritanceTests } from './journal-producer-inheritance.spec' import { registerJournalUnsentSendHoldTests } from './journal-unsent-send-hold.spec' -import { registerQueuedMessageBookkeepingFailureTests } from './queued-message-bookkeeping-failure.spec' -import { registerQueuedMessageDeliveredEchoTests } from './queued-message-delivered-echo.spec' import { registerQueuedMessagePauseTests } from './queued-message-pause.spec' -import { registerQueuedMessageStoreTests } from './queued-message-store.spec' import { registerQueuedMessageUpdateTests } from './queued-message-update.spec' afterEach(() => vi.restoreAllMocks()) @@ -13,8 +10,5 @@ afterEach(() => vi.restoreAllMocks()) describe('journal-pi-context-import', registerJournalPiContextImportTests) describe('journal-producer-inheritance', registerJournalProducerInheritanceTests) describe('journal-unsent-send-hold', registerJournalUnsentSendHoldTests) -describe('queued-message-bookkeeping-failure', registerQueuedMessageBookkeepingFailureTests) -describe('queued-message-delivered-echo', registerQueuedMessageDeliveredEchoTests) describe('queued-message-pause', registerQueuedMessagePauseTests) -describe('queued-message-store', registerQueuedMessageStoreTests) describe('queued-message-update', registerQueuedMessageUpdateTests) 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 2f65cdb206be..2ed8d1743eb0 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 @@ -1,20 +1,20 @@ -// The journal's draft-store collaborator: every read and write of one session's -// `queued_messages` rows, serialized on the same queue as the journal's own -// appends so a draft mutation can never interleave with the consume that -// converts it. Drafts are NEVER owed work: nothing here feeds the reducer, -// working status, teardown, or the idle sweep. +// The queue's one view and its writes. A card is folded from the chat's own rows +// (`journal-queue-fold.ts`), so every change to one is a journal row on the journal's own lane, +// committed with whatever else the action writes: the queue keeps no second copy to fall behind. +// Drafts are NEVER owed work: nothing here feeds working status, teardown, or the idle sweep. -import type Database from '../../sqlite/sync-database' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionQueuedMessageUpdateResult } from '../../../shared/agent-session-wire' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import { - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, - AGENT_SESSION_OPERATION_FUTURE_SKEW_MS -} from '../../../shared/agent-session-host-authority' -import type { JournalRow } from './journal-row-schema' -import { writeReceiptIfChanged, type JournalOperationReceipt } from './journal-row-writer' + journalQueueRowBuilder, + type JournalQueueCardChange, + type JournalQueueRow +} from './journal-queue-events' +import { LegacyQueuedCards } from './journal-queue-import' +import { retainedQueueFacts } from './journal-queue-retention' +import { composeJournalOperationReceipts, type JournalOperationReceipt } from './journal-row-writer' import type { JournalQueuedMessagesDeps, JournalSubmissionConsume } from './journal-store-contracts' -import { holdQueuedMessages } from './queued-message-holds' import { deriveQueuePauses, journalUserStopInForce, @@ -23,351 +23,316 @@ import { type JournalQueuePauseMarks } from './queued-message-pause' import { - consumeQueuedMessageInTransaction, - getQueuedMessage, - insertQueuedMessage, - listQueuedMessages, - queuedMessagesSettledByOp, - withdrawQueuedMessages, + isUnsettledQueuedMessage, type QueuedMessageHoldReason, type QueuedMessageRow } from './queued-message-table' -import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' -import { moveQueuedMessages, type QueuedMessagePositionMove } from './queued-message-positions' -import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' -import { - queuedMessageSettlementOwed, - queuedMessagesAwaitReopenMark, - settleOwedQueuedMessages, - settleQueuedMessagesForRow -} from './queued-message-settlement' -import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards' -import type { JournalWriteResult } from './journal-write-queue' import { QueuedMessageNotConsumableError } from './queued-message-consume-error' import { QueuedMessageEditLeases } from './queued-message-edit-leases' -import { updateQueuedMessage, type QueuedMessageUpdate } from './queued-message-update' +import { queuedMessageUpdateDecision, type QueuedMessageUpdate } from './queued-message-update' +import type { JournalReducerState } from './journal-reducer' + +/** Card rows to write in one transaction, with what must commit beside them. */ +export type JournalQueuePlan = { + rows: ((seq: number, ts: number) => JournalQueueRow)[] + receipt: JournalOperationReceipt +} -/** Tombstones must outlive the window in which their operation id could still be admitted as new. */ -export const QUEUED_MESSAGE_REPLAY_WINDOW_MS = - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +/** What a new epoch writes again of the queue (`retainedQueueFacts`). */ +export type JournalQueueRestatement = { + cards: readonly JournalQueueCardChange[] + receipt: JournalOperationReceipt +} export class JournalQueuedMessages { readonly editLeases = new QueuedMessageEditLeases(() => this.list()) - /** Bumped on every draft-table write, so publication memos recompute only when they must. */ - private changeRevision = 0 - private listed: { revision: number; rows: readonly QueuedMessageRow[] } | null = null + private readonly legacy: LegacyQueuedCards + private viewed: { key: string; cards: readonly QueuedMessageRow[] } | null = null - constructor(private readonly deps: JournalQueuedMessagesDeps) {} + constructor(private readonly deps: JournalQueuedMessagesDeps) { + this.legacy = new LegacyQueuedCards(deps) + } get sessionId(): string { return this.deps.sessionId } - revision(): number { - return this.changeRevision + displayState(): JournalReducerState { + return this.legacy.displayState() } - /** A journal transaction rolled back: nothing read inside it may stay cached. */ - invalidate(): void { - this.changeRevision++ + /** Moves with every card change, so publication memos recompute only when they must. */ + revision(): string { + const { epoch, queue } = this.deps.state() + return `${epoch}:${queue.revision}:${this.legacy.key()}` } - /** Cached per revision: the drain re-checks on every journal publish, so an - * unchanged table must cost no SQL read or body parse on token streams. */ + /** The cards every reader sees, in queue order: the fold's, and any an earlier build wrote that + * are not in the journal yet (`journal-queue-import.ts`). */ list(): readonly QueuedMessageRow[] { - if (this.listed?.revision !== this.changeRevision) { - this.listed = { - revision: this.changeRevision, - rows: listQueuedMessages(this.deps.database().db, this.deps.sessionId) - } + const key = this.revision() + if (this.viewed?.key !== key) { + const cards = [...this.deps.state().queue.cards.values(), ...this.legacy.cards()] + this.viewed = { key, cards: cards.sort((a, b) => a.position - b.position) } } - return this.listed.rows + return this.viewed.cards } get(messageId: string): QueuedMessageRow | null { - return getQueuedMessage(this.deps.database().db, this.deps.sessionId, messageId) - } - - /** An in-place edit; a changed card spends every edit lease on its old body. Only uploads the - * old body did not name must still be stored, so a swept kept image never blocks a text fix. */ - update(input: QueuedMessageUpdate): Promise> { - return this.transact( - (db) => - updateQueuedMessage(db, this.deps.sessionId, input, (body, previous) => - this.deps.claimAttachments(db, body, true, previous) - ), - (result) => result.status === 'updated', - undefined, - () => this.editLeases.retire(input.messageId) + return ( + this.deps.state().queue.cards.get(messageId) ?? + this.legacy.cards().find((card) => card.messageId === messageId) ?? + null ) } - /** Replay receipts for one caller-scoped operation key. */ - receipts(settledByOp: string): QueuedMessageRow[] { - return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp) - } - /** Attachments and the send receipt commit with the card. */ insert( input: { messageId: string body: AgentJournalMessageItem - fingerprint: string - hostInstance: string requireAttachments?: true holdReason?: QueuedMessageHoldReason options?: AgentSessionExecutionOptions }, receipt?: JournalOperationReceipt ): Promise { - const { sessionId } = this.deps - const { requireAttachments, ...draft } = input - let inserted = false - return this.transact( - (db) => { - const existing = getQueuedMessage(db, sessionId, input.messageId) - if (existing) { - // One id, one draft: admission replays a recorded operation before it - // gets here, so an existing row is the same accept landing twice. - return existing + const { messageId, body } = input + const claims: JournalOperationReceipt = { + write: (db) => this.deps.claimAttachments(db, body, input.requireAttachments === true), + committed: () => {} + } + return this.write( + () => { + // One id, one card: admission replays a recorded operation before it gets here, so an + // existing card is the same accept landing twice. + if (this.get(messageId)) { + return { changes: [], result: undefined } } - this.deps.claimAttachments(db, input.body, requireAttachments === true) - inserted = true const { epoch, lastSequence } = this.deps.state() - const row = insertQueuedMessage(db, { - ...draft, - sessionId, + const position = Math.max(0, ...this.list().map((card) => card.position)) + 1 + const change = { + op: 'queued' as const, + body, + position, queuedAt: { epoch, sequence: lastSequence }, - now: this.deps.now() - }) - return row + ...(input.holdReason ? { holdReason: input.holdReason } : {}), + ...(input.options ? { options: input.options } : {}) + } + return { changes: [{ messageId, change }], result: undefined } }, - () => inserted, - receipt - ) - } - - /** Hold one waiting draft whose conversion failed. Stored on the row, so it - * survives handle eviction and restart; withdraw and consume clear it in their - * own UPDATE. */ - hold(input: { messageIds: readonly string[]; reason: QueuedMessageHoldReason }): Promise { - return this.transact( - (db) => holdQueuedMessages(db, { ...input, sessionId: this.deps.sessionId }), - (held) => held > 0 - ).then(() => undefined) - } - - /** The queue's pauses in force, derived from the fold and the cards (`queued-message-pause.ts`). */ - pauses(): DerivedQueuePause[] { - return this.derivePauses(this.list()) - } - - /** `queuedMessagesAwaitReopenMark`. */ - awaitReopenMark(): boolean { - return queuedMessagesAwaitReopenMark(this.list(), this.deps.state().submissions) - } - - /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ - userStopInForce(): JournalQueuePauseMarks['latestStop'] { - const state = this.deps.state() - return journalUserStopInForce(state.queuePauseMarks, state.latestAcceptedTurnSequence) - } - - private derivePauses(cards: readonly QueuedMessageRow[]): DerivedQueuePause[] { - const state = this.deps.state() - return deriveQueuePauses({ - epoch: state.epoch, - marks: state.queuePauseMarks, - latestAcceptedTurnSequence: state.latestAcceptedTurnSequence, - cards, - reopenFloor: this.deps.reopenFloor() + composeJournalOperationReceipts(claims, ...(receipt ? [receipt] : [])) + ).then(() => { + const card = this.get(messageId) + if (!card) { + throw new Error('agent_session_queued_message_lost') + } + return card }) } - /** Inside the caller's journal-row transaction (`journal-unsent-send-hold.ts`): one kept send - * becomes a card, and the cards ahead of the queue take the positions given. False when a card - * by that id already exists, which then stands. */ - holdInTransaction( - db: Database.Database, - input: { - card: Omit[1], 'sessionId' | 'now'> | null - positions: readonly QueuedMessagePositionMove[] - } - ): boolean { - const { sessionId } = this.deps - this.changeRevision += moveQueuedMessages(db, sessionId, input.positions) - if (!input.card || getQueuedMessage(db, sessionId, input.card.messageId)) { - return false + /** An in-place edit; a changed card spends every edit lease on its old body. Only uploads the + * old body did not name must still be stored, so a swept kept image never blocks a text fix; + * they are claimed in the edit's own transaction, which a gone one rolls back. */ + update(input: QueuedMessageUpdate): Promise { + let edit: { body: AgentJournalMessageItem; previous: AgentJournalMessageItem } | undefined + const receipt: JournalOperationReceipt = { + write: (db) => { + if (edit) { + this.deps.claimAttachments(db, edit.body, true, edit.previous) + } + }, + committed: () => this.editLeases.retire(input.messageId) } - insertQueuedMessage(db, { ...input.card, sessionId, now: this.deps.now() }) - this.changeRevision++ - return true + return this.write(() => { + edit = undefined + const decision = queuedMessageUpdateDecision( + this.get(input.messageId), + this.deps.sessionId, + input + ) + if (decision.status !== 'ready') { + return { changes: [], result: decision } + } + edit = { body: decision.body, previous: decision.previous } + const { messageId } = input + return { + changes: [{ messageId, change: { op: 'edited', body: decision.body } }], + result: { status: 'updated', messageId, fingerprint: decision.fingerprint } + } + }, receipt) + } + + /** Hold waiting cards whose conversion failed; an explicit Send releases one. */ + hold(input: { messageIds: readonly string[]; reason: QueuedMessageHoldReason }): Promise { + return this.write(() => ({ + changes: input.messageIds.flatMap((messageId) => { + const card = this.get(messageId) + return card?.state === 'waiting' && card.holdReason !== input.reason + ? [{ messageId, change: { op: 'held' as const, holdReason: input.reason } }] + : [] + }), + result: undefined + })) } - /** Compare-and-transition waiting ∪ returned rows to op-stamped tombstones, - * kept only so a replay of the settling operation answers "spent". `receipt`: with a withdrawal. */ + /** Withdraws the waiting and returned cards named; `receipt` commits with the withdrawal. */ withdraw( - input: { messageIds: readonly string[]; settledByOp: string }, + messageIds: readonly string[], receipt?: JournalOperationReceipt ): Promise { - if (input.messageIds.length === 0) { + if (messageIds.length === 0) { // Delete races and empty carries land here; neither may cost a write transaction. return Promise.resolve([]) } - return this.transact( - (db) => - withdrawQueuedMessages(db, { - ...input, - sessionId: this.deps.sessionId, - now: this.deps.now() - }), - (withdrawn) => withdrawn.length > 0, - receipt - ) - } - - /** Withdraw commands with the clear's divider and receipt on the same connection. */ - withdrawInTransaction( - db: Database.Database, - input: { messageIds: readonly string[]; settledByOp: string } - ): void { - if (this.deps.database().db !== db) { - throw new AgentSessionJournalError('journal_closed', 'withdraw crossed database handles') - } - this.changeRevision += withdrawQueuedMessages(db, { - ...input, - sessionId: this.deps.sessionId, - now: this.deps.now() - }).length - } - - /** 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, receipt and `adopted` first. */ - private transact( - run: (db: Database.Database) => JournalWriteResult, - changed: (result: T) => boolean, - receipt?: JournalOperationReceipt, - adopted?: () => void - ): Promise { - return this.deps.serialize(() => { - assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const result = this.deps.database().transaction(writeReceiptIfChanged(run, changed, receipt)) - if (changed(result)) { - receipt?.committed() - adopted?.() - this.changeRevision++ - this.deps.committed() + return this.write(() => { + const cards = messageIds.flatMap((id) => { + const card = this.get(id) + return card && isUnsettledQueuedMessage(card) ? [card] : [] + }) + return { + changes: cards.map(({ messageId }) => ({ + messageId, + change: { op: 'withdrawn' as const } + })), + result: cards.map((card) => ({ ...card, state: 'withdrawn', holdReason: null })) } - return result - }) + }, receipt) } - /** The standing writer hook, within the append's transaction - * (`settleQueuedMessagesForRow`). */ - onRowInTransaction(db: Database.Database, row: JournalRow): void { - this.changeRevision += settleQueuedMessagesForRow(db, { - sessionId: this.deps.sessionId, - state: this.deps.state(), - drafts: () => this.list(), - row, - now: this.deps.now() - }) + /** On the journal's lane: rows for these changes, each card an earlier build wrote moved into the + * journal first, its legacy row deleted in the same transaction (`journal-queue-import.ts`). */ + plan(changes: readonly JournalQueueCardChange[]): JournalQueuePlan { + const rows: JournalQueuePlan['rows'] = [] + const adopted: string[] = [] + for (const { messageId, change } of changes) { + const own = this.deps.state().queue.cards.has(messageId) || adopted.includes(messageId) + const adoption = own ? null : this.legacy.adoption(messageId) + if (adoption) { + rows.push(journalQueueRowBuilder(this.deps.state, messageId, adoption)) + adopted.push(messageId) + } + if (change) { + rows.push(journalQueueRowBuilder(this.deps.state, messageId, change)) + } + } + return { rows, receipt: this.legacy.deletion(adopted) } } - /** The in-transaction consume for `appendSubmission`; a false compare-and-set - * throws so the whole append — draft transition AND submission row — rolls back. */ - consumeInTransaction( - db: Database.Database, + /** On the lane, before a hand-off's submission is written: the card is what the person acted + * on, and for the queue's own send still the one it sends next. Throws otherwise. */ + assertConsumable( input: JournalSubmissionConsume & { consumedAs: string; fingerprint: string } ): void { - const { db: own } = this.deps.database() - if (own !== db) { - // Same handle only: a second connection could not join the transaction. - throw new AgentSessionJournalError('journal_closed', 'consume crossed database handles') - } - if (input.yieldsToPause) { - // Judged again here, by the drain's own rule. Today a Stop cannot land between the drain's - // pick and this claim (both run on the session's serialized lane, held across the send), so - // this guards any pause-relevant row written off that lane from overtaking a held card. - const cards = listQueuedMessages(db, this.deps.sessionId) - const pauses = this.derivePauses(cards) + const card = this.get(input.messageId) + const consumable = + card !== null && + card.state === input.expect && + card.fingerprint === input.fingerprint && + input.consumedAs !== input.messageId + if (consumable && input.yieldsToPause) { + const cards = this.list() const held = this.editLeases.heldIds(cards) - if (nextSendableQueuedCard(pauses, cards, held)?.messageId !== input.messageId) { - throw new QueuedMessageNotConsumableError(input.messageId, input.expect) + const next = nextSendableQueuedCard(this.derivePauses(cards), cards, held) + if (next?.messageId === input.messageId) { + return } + } else if (consumable) { + return } - const consumed = consumeQueuedMessageInTransaction(db, { - ...input, - sessionId: this.deps.sessionId, - now: this.deps.now() - }) - if (!consumed) { - throw new QueuedMessageNotConsumableError(input.messageId, input.expect) + throw new QueuedMessageNotConsumableError(input.messageId, input.expect) + } + + /** What a new epoch restates, with every legacy card: their rows go in the same transaction. */ + restatement(): JournalQueueRestatement { + return { + cards: retainedQueueFacts(this.list(), this.deps.state().submissions), + receipt: this.legacy.deletionOfAll() } - this.changeRevision++ } - /** A skipped live settlement the journal already decided (`queued-message-settlement.ts`). */ - settlementOwed(): boolean { - return queuedMessageSettlementOwed(this.list(), this.deps.state().submissions) + /** Every legacy card moved into the journal, every legacy row deleted, with a write that retires + * hand-offs: each card's reading is then latched in the fold before its hand-off is gone. */ + adoptionOfAll(): JournalQueuePlan { + const { rows } = this.plan(this.legacy.messageIds().map((messageId) => ({ messageId }))) + return { rows, receipt: this.legacy.deletionOfAll() } } - /** Waiting drafts a skipped echo hook left unwithdrawn; reads every item, so only the drain - * step asks, right before a draft would send. */ - deliveredByEchoOwed(): boolean { - return draftsDeliveredByAppliedEcho(this.deps.state(), this.list()).length > 0 + /** The bulk move of an earlier build's cards, once per open (`LegacyQueuedCards.importAtOpen`). */ + importAtOpen(): Promise { + return this.legacy.importAtOpen() } - /** Applies owed settlements now, so a skipped live transition heals without a reopen. */ - settleOwed(): Promise { - return this.transact( - (db) => - settleOwedQueuedMessages(db, { - sessionId: this.deps.sessionId, - state: this.deps.state(), - now: this.deps.now() - }), - (settled) => settled > 0 - ).then(() => undefined) + /** The queue's pauses in force, derived from the fold and the cards (`queued-message-pause.ts`). */ + pauses(): DerivedQueuePause[] { + return this.derivePauses(this.list()) } - /** Bookkeeping at open: a failure is reported and retried at the next open, - * never allowed to fail opening the chat. */ - repairAndPruneAtOpen(): Promise { - return this.repairAndPrune().catch((error: unknown) => { - console.warn('[journal-open] queued-message repair skipped:', { - sessionId: this.deps.sessionId, - error: error instanceof Error ? error.message : String(error) - }) + /** A card waits, or is mid-hand-off and may come back to waiting: a chat that stops running + * marks it (`AgentSessionJournal.markQueueReopen`). */ + awaitReopenMark(): boolean { + const { submissions } = this.deps.state() + return this.list().some((card) => { + if (card.state === 'waiting') { + return true + } + const handOff = card.consumedAs ? submissions.get(card.consumedAs) : undefined + // A recovered doubt outlived its writer: nothing brings that card back to waiting. + return ( + card.state === 'dispatched' && + (handOff?.dispatchState === 'pending' || + (handOff?.dispatchState === 'unknown' && handOff.recovered !== true)) + ) }) } - /** - * Open-time reconciliation, a re-derivation behind the stored fact: owed - * settlements apply exactly as the live hook would have (covers consume → - * crash → downgrade → upgrade, where the old build rejected the leftover with - * no hook), then retention runs. - */ - repairAndPrune(): Promise { - // No draft, no work, and no write. - if (this.deps.readOnly() || this.list().length === 0) { - return Promise.resolve() - } - const { sessionId } = this.deps - return this.transact( - (db) => { - const [now, state] = [this.deps.now(), this.deps.state()] - return ( - settleOwedQueuedMessages(db, { sessionId, state, now }) + - pruneQueuedMessages(db, { - sessionId, - now, - replayWindowMs: QUEUED_MESSAGE_REPLAY_WINDOW_MS, - submissionVerdict: retainedSubmissionVerdict(state.submissions) - }) - ) - }, - (changed) => changed > 0 - ).then(() => undefined) + /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ + userStopInForce(): JournalQueuePauseMarks['latestStop'] { + const state = this.deps.state() + return journalUserStopInForce(state.queuePauseMarks, state.latestAcceptedTurnSequence) + } + + private derivePauses(cards: readonly QueuedMessageRow[]): DerivedQueuePause[] { + const state = this.deps.state() + return deriveQueuePauses({ + epoch: state.epoch, + marks: state.queuePauseMarks, + latestAcceptedTurnSequence: state.latestAcceptedTurnSequence, + cards, + reopenFloor: this.deps.reopenFloor() + }) + } + + /** One write of card changes, planned at its own turn on the journal's lane so it reads every + * write ahead of it; `receipt` commits with the rows, and nothing is written for no change. */ + private write( + plan: () => { changes: readonly JournalQueueCardChange[]; result: T }, + receipt?: JournalOperationReceipt + ): Promise { + let planned: { result: T; plan: JournalQueuePlan } | undefined + return this.deps.writer + .enqueueRows( + () => { + const next = plan() + planned = { result: next.result, plan: this.plan(next.changes) } + return planned.plan.rows + }, + { + write: (db, row) => { + planned?.plan.receipt.write(db, row) + receipt?.write(db, row) + }, + committed: () => { + planned?.plan.receipt.committed() + receipt?.committed() + } + } + ) + .then(() => { + if (!planned) { + throw new Error('agent_session_queue_write_unplanned') + } + return planned.result + }) } } diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index e4c547e08eef..225548e903ba 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -42,6 +42,11 @@ import { foldJournalQueuePauseMark, type JournalQueuePauseMarks } from './queued-message-pause' +import { + createJournalQueueState, + foldJournalQueueRow, + type JournalQueueState +} from './journal-queue-fold' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -81,6 +86,13 @@ export type JournalReducerState = { queuePauseMarks: JournalQueuePauseMarks /** Host-only: each handed-over send's stopped-message history, from its latest `pending` row. */ stoppedHistoryCarried: Map + /** Where each queued card's hand-off was placed when handed over; never stored. */ + queuedHandOffPlaces: Map< + string, + Pick + > + /** The queued messages, folded from their own rows and their hand-offs (`journal-queue-fold.ts`). */ + queue: JournalQueueState /** Original adoption evidence, independent of the current epoch's reason/time. */ historyOrigin: JournalHistoryOrigin | null } @@ -108,6 +120,8 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou latestAcceptedTurnSequence: 0, queuePauseMarks: createJournalQueuePauseMarks(), stoppedHistoryCarried: new Map(), + queuedHandOffPlaces: new Map(), + queue: createJournalQueueState(), historyOrigin: null } } @@ -117,12 +131,21 @@ export function applyJournalRow( row: JournalRow, savedAt = row.ts ): void { + applyJournalRowFacts(state, row, savedAt) + foldJournalQueueRow(state, row) +} + +function applyJournalRowFacts(state: JournalReducerState, row: JournalRow, savedAt: number): void { state.lastSequence = Math.max(state.lastSequence, row.seq) state.highestFence = Math.max(state.highestFence, row.fence) if (row.kind === 'epoch') { state.historyOrigin = journalHistoryOrigin(row) return } + // A queued card is never activity: it feeds no working status and no idle sweep. + if (row.kind === 'queue') { + return + } state.lastActivityAt = Math.max(state.lastActivityAt, row.ts) if (row.kind === 'item') { if (journalItemRevisionIsStale(state, row.itemId, row.revision)) { diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts index b44ecc5e78f5..7ec74106e216 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.ts @@ -56,6 +56,9 @@ function comparableSubmissions(journal: AgentSessionJournal): AgentJournalSubmis (submission.dispatchState !== 'pending' && submission.dispatchState !== 'unknown') || // Never handed over, so provider history cannot hold it. isQueuedAgentJournalSubmission(submission) || + // A queued card's hand-off is never judged unsent from what history lacks: its fold reads + // it as sent unless Orca proves otherwise. + submission.queuedMessageId !== undefined || // The provider's copy leads with the stopped history it carried, so its fingerprint cannot // match; like an attachment's, its absence is no evidence and it stays `unknown`. journal.stoppedHistoryCarried().has(submission.clientMessageId) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index c1e75b40a0e8..d0ba36aae064 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -41,6 +41,7 @@ import { import { isJournalTag } from '../../../shared/agent-session-journal-open-union' import type { StructuredAgentSessionStopCause } from '../agent-session-wire/structured-agent-session-stop-cause' import { dropUnusableRowAnnotations } from './journal-row-unusable-annotations' +import { journalQueueRowContent, type JournalQueueRow } from './journal-queue-events' /** Producer linkage rides the row BASE rather than the body: older builds read * the two nested prompt shapes strictly, so an unknown key there made the whole @@ -48,7 +49,7 @@ import { dropUnusableRowAnnotations } from './journal-row-unusable-annotations' * unknown `v` makes a row unreadable (released older hosts keep the chat read-only; * this build fails its load), while an unknown KEY is ignored below, so an older host reads a stamped row and * behaves exactly as it does today. */ -type JournalRowBase = AgentJournalProducerLinkage & { +export type JournalRowBase = AgentJournalProducerLinkage & { /** Schema version of THIS row. */ v: number epoch: string @@ -247,6 +248,7 @@ export type JournalRow = | JournalSubmissionRow | JournalDispatchRow | JournalLifecycleBatchRow + | JournalQueueRow export type JournalRowParse = | { ok: true; row: JournalRow } @@ -387,7 +389,8 @@ const ROW_CONTENT_CHECK_BY_KIND: Record< ), ...mutations.map((mutation) => lifecycleMutationContent(mutation)) ) - } + }, + queue: journalQueueRowContent } export const KNOWN_ROW_KINDS = new Map(Object.entries(ROW_CONTENT_CHECK_BY_KIND)) 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 d1d94b3f37e6..43a51de35c6c 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 @@ -6,11 +6,6 @@ import type { JournalRow } from './journal-row-schema' import { assertJournalFence, assertJournalWritable } from './journal-write-guards' import type { JournalWriteBody } from './journal-write-queue' -/** 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 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. */ @@ -52,25 +47,16 @@ export type JournalRowWriterDeps = { highestFence: () => number nextSequence: () => number commit: (rows: readonly JournalRow[], savedAt: number) => void - /** Standing hook run for EVERY appended row — the queued-draft returned - * transition rides here so no rejection path can bypass it. Bookkeeping: it - * runs in its own savepoint, so its failure is reported and never vetoes the row. */ - inTransaction?: JournalRowTransactionHook - /** After any rollback, so a cache filled inside the transaction cannot outlive it. */ - rolledBack?: () => void /** Rows an OS crash may lose without losing anything but streamed text; they commit without * the per-commit fsync (`isJournalStreamedTextRevision`). */ streamedTextRevision?: (row: JournalRow) => boolean } -const BOOKKEEPING_SAVEPOINT = 'journal_row_bookkeeping' - export class JournalRowWriter { constructor(private readonly deps: JournalRowWriterDeps) {} enqueue( build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook, receipt?: JournalOperationReceipt ): Promise { return this.deps.serialize(() => { @@ -78,21 +64,14 @@ export class JournalRowWriter { const savedAt = this.deps.now() const row = build(this.deps.nextSequence(), savedAt) assertJournalFence(row.fence, this.deps.highestFence()) - try { - // One INSERT: the chat's epoch pointer moves only when the epoch does. - this.deps.database().transaction( - (db) => { - insertJournalRow(db, this.deps.sessionId, row, savedAt) - hook?.(db, row) - receipt?.write(db, row) - this.runBookkeeping(db, row) - }, - !hook && !receipt && this.streamed([row]) - ) - } catch (error) { - this.deps.rolledBack?.() - throw error - } + // One INSERT: the chat's epoch pointer moves only when the epoch does. + this.deps.database().transaction( + (db) => { + insertJournalRow(db, this.deps.sessionId, row, savedAt) + receipt?.write(db, row) + }, + !receipt && this.streamed([row]) + ) // COMMIT landed, so the row is durable: adopt it before anything that can // fail. Rejecting here instead would leave the next append reusing a // sequence the table already holds. The ledger first: it cannot throw, the fold can. @@ -129,21 +108,15 @@ export class JournalRowWriter { for (const row of rows) { assertJournalFence(row.fence, this.deps.highestFence()) } - try { - this.deps.database().transaction( - (db) => { - for (const row of rows) { - insertJournalRow(db, this.deps.sessionId, row, ts) - this.runBookkeeping(db, row) - } - receipt?.write(db, receiptRow?.(rows)) - }, - !receipt && this.streamed(rows) - ) - } catch (error) { - this.deps.rolledBack?.() - throw error - } + this.deps.database().transaction( + (db) => { + for (const row of rows) { + insertJournalRow(db, this.deps.sessionId, row, ts) + } + receipt?.write(db, receiptRow?.(rows)) + }, + !receipt && this.streamed(rows) + ) receipt?.committed() this.deps.commit(rows, ts) return rows @@ -153,10 +126,9 @@ export class JournalRowWriter { * replay uses — all inside one serialized step — answering where the row landed. */ append( build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook, receipt?: JournalOperationReceipt ): Promise { - return this.enqueue(build, hook, receipt).then((row) => ({ + return this.enqueue(build, receipt).then((row) => ({ epoch: row.epoch, sequence: row.seq })) @@ -166,27 +138,4 @@ export class JournalRowWriter { const { streamedTextRevision } = this.deps return streamedTextRevision !== undefined && rows.every((row) => streamedTextRevision(row)) } - - private runBookkeeping(db: Database.Database, row: JournalRow): void { - const hook = this.deps.inTransaction - if (!hook) { - return - } - db.exec(`SAVEPOINT ${BOOKKEEPING_SAVEPOINT}`) - try { - hook(db, row) - db.exec(`RELEASE ${BOOKKEEPING_SAVEPOINT}`) - } catch (error) { - db.exec(`ROLLBACK TO ${BOOKKEEPING_SAVEPOINT}`) - db.exec(`RELEASE ${BOOKKEEPING_SAVEPOINT}`) - this.deps.rolledBack?.() - // The draft store re-derives what this missed from the committed rows: at open, and in - // the drain step before a draft sends. - console.warn('[journal-append] row bookkeeping skipped:', { - sessionId: this.deps.sessionId, - kind: row.kind, - error: error instanceof Error ? error.message : String(error) - }) - } - } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 44e1321cad5e..53db5ae485f8 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 @@ -83,6 +83,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal host.state().latestAcceptedTurnSequence, host.journal().queuedMessages.pauses() ), + queueRestatement: () => host.journal().queuedMessages.restatement(), cursor: host.cursor, adopt: host.adopt }) @@ -96,17 +97,6 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal ...(previous ? { previous } : {}), now: host.now() }) - const queuedMessages = new JournalQueuedMessages({ - sessionId: host.identity.sessionId, - now: host.now, - serialize: host.serialize, - database: host.database, - readOnly: host.readOnly, - state: host.state, - reopenFloor: () => host.journal().reopenFloor(), - committed: host.notifyCommitted, - claimAttachments - }) const rowWriter = new JournalRowWriter({ sessionId: host.identity.sessionId, now: host.now, @@ -116,12 +106,19 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal highestFence: () => host.state().highestFence, nextSequence: () => host.state().lastSequence + 1, commit: host.commit, - // Every rejection is a dispatch row through this one writer; the draft - // returned-transition rides it so no path can bypass the hook. - inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row), - rolledBack: () => queuedMessages.invalidate(), streamedTextRevision: (row) => isJournalStreamedTextRevision(host.state(), row) }) + const queuedMessages = new JournalQueuedMessages({ + sessionId: host.identity.sessionId, + serialize: host.serialize, + database: host.database, + readOnly: host.readOnly, + state: host.state, + reopenFloor: () => host.journal().reopenFloor(), + writer: rowWriter, + committed: host.notifyCommitted, + claimAttachments + }) const lifecycleBatchAppender = new JournalLifecycleBatchAppender({ state: host.state, cursor: host.cursor, @@ -137,12 +134,9 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal cards: queuedMessages }), stops: new JournalStopAcceptor({ writer: rowWriter, state: host.state }), - // Behind the stored fact: settles drafts whose consumed submission the loaded journal shows - // refused (a downgrade wrote no hook), then prunes. Bookkeeping, never failing the open. + // Moves the cards an earlier build wrote into the journal, once per open; never fails it. restore: () => - restoreJournalStore(host, { epochController }).then(() => - queuedMessages.repairAndPruneAtOpen() - ), + restoreJournalStore(host, { epochController }).then(() => queuedMessages.importAtOpen()), rowWriter, submissionWriter: new JournalSubmissionWriter({ state: host.state, diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 6f63c3f1fab8..b0fdb7a07901 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -18,6 +18,7 @@ import type { JournalHostDatabase } from './journal-host-database' import type { JournalLifecycleMutationInput } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' import type { JournalReducerState } from './journal-reducer' +import type { JournalRowWriter } from './journal-row-writer' import type { JournalAttachmentClaim } from './journal-submission-hook' import type { JournalWriteBody } from './journal-write-queue' @@ -139,15 +140,11 @@ export type JournalSubmissionInput = { options?: AgentSessionExecutionOptions } -/** A submission append that converts a queued draft, in one transaction. */ +/** A submission append that hands off a queued card: the submission names it, and that row is + * the whole hand-off. */ export type JournalSubmissionConsume = { messageId: string expect: 'waiting' | 'returned' - /** The operation ledger's caller-scoped key; null for the host's own drain. */ - settledByOp: string | null - /** The host process handing it off, stamped on the draft so a hand-off withdrawn back to - * waiting belongs to the process that sent it, not the one that first wrote the card. */ - hostInstance?: string /** The queue's own send: refused in the consume's transaction while the queue's pause holds * the card. Send-now omits it. */ yieldsToPause?: true @@ -162,18 +159,17 @@ export type JournalItemAppendInput = { /** What `JournalQueuedMessages` reads from the journal that owns it. */ export type JournalQueuedMessagesDeps = { sessionId: string - now: () => number serialize: (run: JournalWriteBody) => Promise database: () => JournalHostDatabase readOnly: () => boolean state: () => JournalReducerState /** Where the reopen's pause begins when this handle could not mark it (`reopenFloor`). */ reopenFloor: () => AgentJournalCursor | null - /** The journal's own commit notification. Every standalone draft-table - * transaction that changed rows fires it after COMMIT, so a draft or hold - * change publishes and wakes the drain through the same path a journal row - * does — no call site can forget. In-transaction consume and the returned - * transition already ride their row's own commit. */ + /** The journal's own writer: every card change is a journal row, published and waking the drain + * through the row's own commit. */ + writer: JournalRowWriter + /** The journal's commit notification, for the one write that appends no row: an import that + * only deletes legacy rows. */ committed: () => void claimAttachments: JournalAttachmentClaim } 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 b33984c8542e..aee913eb5ba9 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -51,18 +51,14 @@ import type { JournalTombstoneInput, ResolveDispatchInput } from './journal-store-contracts' -import type { JournalQueuedMessages } from './journal-queued-messages' +import type { JournalQueuedMessages, JournalQueuePlan } from './journal-queued-messages' import { journalQueueResumeRowBuilder, journalQueueReopenRowBuilder, journalStopEventRowBuilder } from './journal-stop-and-resume-rows' import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' -import type { - JournalOperationReceipt, - JournalRowTransactionHook, - JournalRowWriter -} from './journal-row-writer' +import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' import type { JournalSubmissionWriter } from './journal-submission-writer' import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' @@ -96,7 +92,7 @@ export class AgentSessionJournal { private readonly submissionWriter: JournalSubmissionWriter private readonly stepWriter: JournalStepWriter private readonly restore: () => Promise - /** Draft rows queued while the agent works; never reducer input or owed work. */ + /** The cards queued while the agent works (`journal-queue-fold.ts`); never owed work. */ readonly queuedMessages: JournalQueuedMessages readonly stopMarks: JournalStopMarks /** A person's Stop accepted (`journal-stop-acceptance.ts`). */ @@ -133,7 +129,7 @@ export class AgentSessionJournal { }, notifyCommitted: () => this.onCommitted?.(), journal: () => this, - enqueue: (build, receipt) => this.rowWriter.enqueue(build, undefined, receipt) + enqueue: (build, receipt) => this.rowWriter.enqueue(build, receipt) }) this.rowWriter = collaborators.rowWriter this.epochController = collaborators.epochController @@ -196,7 +192,7 @@ export class AgentSessionJournal { sequence: this.state.lastSequence }) - snapshot = (): AgentJournalSnapshot => renderJournalState(this.state) + snapshot = (): AgentJournalSnapshot => renderJournalState(this.queuedMessages.displayState()) /** Visits reduced items without allocating and sorting a full snapshot. */ visitItems = ( @@ -318,7 +314,7 @@ export class AgentSessionJournal { /** A person's Resume of the queue. */ appendQueueResume(fence: number, receipt?: JournalOperationReceipt): Promise { const build = journalQueueResumeRowBuilder(() => this.state, fence) - return this.rowWriter.append(build, undefined, receipt) + return this.rowWriter.append(build, receipt) } /** This open found waiting cards an earlier handle wrote (`queued-message-pause.ts`). */ @@ -361,9 +357,9 @@ export class AgentSessionJournal { /** A dispatch transition (`JournalSubmissionWriter.resolveDispatch`). */ resolveDispatch( input: ResolveDispatchInput, - hook?: JournalRowTransactionHook + cards?: () => JournalQueuePlan ): Promise { - return this.submissionWriter.resolveDispatch(input, hook) + return this.submissionWriter.resolveDispatch(input, cards) } /** A provider's late receipt (`JournalSubmissionWriter.acceptLate`). */ 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 2dbf0e72101b..27bbd7c44df8 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 @@ -12,6 +12,8 @@ import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' import { journalDispatchRowApplies } from './journal-dispatch-settlement' +import { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } from './journal-dispatch-doubt-reasons' +import { dispatchWasWithdrawn } from '../../../shared/structured-agent-session-dispatch-rejection' export function applyJournalSubmission( state: JournalReducerState, @@ -70,12 +72,43 @@ export function placeHandedOverMessage( return } const { sequenceIndex: _acceptedAt, ...accepted } = item - state.items.set(itemId, { - ...accepted, + const place = { sequence: row.seq, observedAt: row.ts, turnScope: row.turnScope ?? state.derivedTurnScope.scopeFor(item.body) - }) + } + state.items.set(itemId, { ...accepted, ...place }) + if (submission.queuedMessageId !== undefined) { + state.queuedHandOffPlaces.set(submission.clientMessageId, place) + } +} + +/** + * A queued card's hand-off refused with no proof the agent never got it: it may have run, so its + * message reads as sent and is never sent again. With a turn it was answered into, it reads as + * that turn's unconfirmed message, where it was handed over, and the turn's own end says Stop; a + * withdrawal before any turn opened reads as a person's own send a Stop took back. Derived on + * every fold from the stored rejection, which stays as written. + */ +export function foldQueuedHandOffInDoubt( + state: JournalReducerState, + submission: AgentJournalSubmission +): void { + const itemId = agentJournalSubmissionKey(submission.clientMessageId) + const item = state.items.get(itemId) + const place = state.queuedHandOffPlaces.get(submission.clientMessageId) + if (item && place) { + const { sequenceIndex: _rejectedAt, ...rest } = item + state.items.set(itemId, { ...rest, ...place }) + } + if (dispatchWasWithdrawn(submission) && !submission.answeredInTurn) { + delete submission.queuedMessageId + return + } + submission.dispatchState = 'unknown' + submission.reason = DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED + submission.recovered = true + delete submission.rejection } /** A rejected message — queued, handed over, or sent directly — joins the conversation where it was diff --git a/src/main/native-chat/agent-session-journal/journal-submission-hook.ts b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts index b0ecdc1d61f7..ec4e4ed6cc65 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-hook.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-hook.ts @@ -1,8 +1,5 @@ import type Database from '../../sqlite/sync-database' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { JournalQueuedMessages } from './journal-queued-messages' -import type { JournalRowTransactionHook } from './journal-row-writer' -import type { JournalSubmissionConsume, JournalSubmissionInput } from './journal-store-contracts' /** Claims the stored chat attachments a message written to this journal names, in the write's own * transaction; `required` refuses the whole write when one is no longer stored, except one the @@ -13,24 +10,3 @@ export type JournalAttachmentClaim = ( required: boolean, previous?: AgentJournalMessageItem ) => void - -/** The submission append's hook, inside its transaction: converts the draft it hands off, if any, - * and claims the stored attachments it names. A client's new message must name only attachments - * still stored; a draft's conversion was claimed when the draft was written. */ -export function journalSubmissionHook( - queuedMessages: JournalQueuedMessages, - claimAttachments: JournalAttachmentClaim, - input: Pick, - consume: JournalSubmissionConsume | undefined -): JournalRowTransactionHook { - return (db) => { - if (consume) { - queuedMessages.consumeInTransaction(db, { - ...consume, - consumedAs: input.clientMessageId, - fingerprint: input.payloadFingerprint - }) - } - claimAttachments(db, input.body, input.origin === 'client' && !consume) - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.spec.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.spec.ts index 0294759ba692..8e121e729d83 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.spec.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.spec.ts @@ -11,6 +11,7 @@ import type { AgentJournalMessageItem, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { createJournalReducerState, applyJournalRow } from './journal-reducer' import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' @@ -33,6 +34,7 @@ export function registerJournalSubmissionQueuedLinkTests(): void { role: 'user', blocks: [{ type: 'text', text: 'queued text' }] } + const FINGERPRINT = agentSessionSendBodyFingerprint(IDENTITY.sessionId, BODY) let root: string let clock = 1_000 @@ -48,15 +50,10 @@ export function registerJournalSubmissionQueuedLinkTests(): void { } async function handOff(journal: AgentSessionJournal, draftId: string, submissionId: string) { - await journal.queuedMessages.insert({ - messageId: draftId, - body: BODY, - fingerprint: 'fp', - hostInstance: 'p' - }) + await journal.queuedMessages.insert({ messageId: draftId, body: BODY }) await journal.appendSubmission( - { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, - { messageId: draftId, expect: 'waiting', settledByOp: null } + { clientMessageId: submissionId, payloadFingerprint: FINGERPRINT, body: BODY, fence: 0 }, + { messageId: draftId, expect: 'waiting' } ) } @@ -97,22 +94,17 @@ export function registerJournalSubmissionQueuedLinkTests(): void { it('refuses a caller naming a different draft than the one it consumes, and writes nothing', async () => { const journal = await open() - await journal.queuedMessages.insert({ - messageId: 'draft-1', - body: BODY, - fingerprint: 'fp', - hostInstance: 'p' - }) + await journal.queuedMessages.insert({ messageId: 'draft-1', body: BODY }) await expect( journal.appendSubmission( { clientMessageId: 'handoff-1', - payloadFingerprint: 'fp', + payloadFingerprint: FINGERPRINT, body: BODY, fence: 0, queuedMessageId: 'draft-2' }, - { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + { messageId: 'draft-1', expect: 'waiting' } ) ).rejects.toThrow() expect(journal.submissions()).toHaveLength(0) 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 f94095d56a24..1b7f9f33e9a9 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 @@ -1,20 +1,17 @@ // A sent message's rows: its write-ahead submission and each dispatch transition, with what must -// commit in the same transaction (a queued draft's consume, the send's ledger answer, a kept card). +// commit in the same transaction (a queued card's hand-off, the send's ledger answer, a kept card). import type { AgentJournalCursor, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { journalDispatchRowApplies } from './journal-dispatch-settlement' -import type { JournalQueuedMessages } from './journal-queued-messages' -import { journalSubmissionHook, type JournalAttachmentClaim } from './journal-submission-hook' +import type { JournalQueuedMessages, JournalQueuePlan } from './journal-queued-messages' +import type { JournalAttachmentClaim } from './journal-submission-hook' import type { JournalReducerState } from './journal-reducer' import { journalDispatchRowBuilder, journalSubmissionRowBuilder } from './journal-row-builders' -import type { - JournalOperationReceipt, - JournalRowTransactionHook, - JournalRowWriter -} from './journal-row-writer' +import type { JournalRow } from './journal-row-schema' +import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-writer' import type { JournalSubmissionConsume, JournalSubmissionInput, @@ -39,18 +36,46 @@ export class JournalSubmissionWriter { */ append( input: JournalSubmissionInput, - /** Present: this submission is a queued draft's conversion, and the draft's - * state transition commits in the SAME transaction — exactly-once consume. */ + /** Present: this submission hands off a queued card. The row naming it IS the hand-off, checked + * against the card at its turn on the lane: one id, one delivery. */ consume?: JournalSubmissionConsume, /** The send's ledger answer, committed with this row. */ receipt?: JournalOperationReceipt ): Promise { const { claimAttachments, identity, queuedMessages, rowWriter, state } = this.deps - return rowWriter.append( - journalSubmissionRowBuilder(state, identity, input, consume), - journalSubmissionHook(queuedMessages, claimAttachments, input, consume), - receipt - ) + const submission = journalSubmissionRowBuilder(state, identity, input, consume) + let card: JournalQueuePlan | undefined + return rowWriter + .enqueueRows( + () => { + if (!consume) { + return [submission] + } + queuedMessages.assertConsumable({ + ...consume, + consumedAs: input.clientMessageId, + fingerprint: input.payloadFingerprint + }) + // A card an earlier build wrote moves into the journal with its hand-off. + card = queuedMessages.plan([{ messageId: consume.messageId }]) + return [...card.rows, submission] + }, + { + write: (db, row) => { + card?.receipt.write(db) + // A client's new message must name only attachments still stored; a card's were + // claimed when it was queued. + claimAttachments(db, input.body, input.origin === 'client' && !consume) + receipt?.write(db, row) + }, + committed: () => { + card?.receipt.committed() + receipt?.committed() + } + }, + (rows) => rows.at(-1) + ) + .then(rowCursor) } /** @@ -59,13 +84,31 @@ export class JournalSubmissionWriter { * Accepting REQUIRES the provider identity rather than a free-form id: the * adopted key is what the provider's echo will upsert into, so a mismatched * string here would silently give the user a second copy of their own message. - * `hook` runs in the row's transaction: it commits with the row, or rolls it back by throwing. + * `cards` commit with the row, or roll it back by throwing. */ resolveDispatch( input: ResolveDispatchInput, - hook?: JournalRowTransactionHook + /** Card rows that commit with this one, planned at its turn on the lane. */ + cards?: () => JournalQueuePlan ): Promise { - return this.deps.rowWriter.append(journalDispatchRowBuilder(this.deps.state, input), hook) + const dispatch = journalDispatchRowBuilder(this.deps.state, input) + if (!cards) { + return this.deps.rowWriter.append(dispatch) + } + let planned: JournalQueuePlan | undefined + return this.deps.rowWriter + .enqueueRows( + () => { + planned = cards() + return [dispatch, ...planned.rows] + }, + { + write: (db) => planned?.receipt.write(db), + committed: () => planned?.receipt.committed() + }, + (rows) => rows[0] + ) + .then((rows) => rowCursor(rows.slice(0, 1))) } /** A provider's late receipt, decided at its turn in the queue: a send a write ahead of it @@ -79,3 +122,12 @@ export class JournalSubmissionWriter { ) } } + +/** Where the last of the rows landed: the submission, or the dispatch row. */ +function rowCursor(rows: readonly JournalRow[]): AgentJournalCursor { + const row = rows.at(-1) + if (!row) { + throw new Error('agent_session_journal_row_unwritten') + } + return { epoch: row.epoch, sequence: row.seq } +} diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.spec.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.spec.ts index 4300d0b574a0..53b37b8d16e9 100644 --- a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.spec.ts +++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.spec.ts @@ -159,6 +159,18 @@ export function registerJournalUnknownRowKindTests(): void { kind: 'lifecycle-batch', settlementId: 's', mutations: [{ kind: 'tombstone', itemId: 'i', revision: 3 }] + }, + queue: { + ...base, + seq: 7, + kind: 'queue', + messageId: 'q', + change: { + op: 'queued', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, + position: 1, + queuedAt: null + } } } } diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.spec.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.spec.ts index 3b169d670bb7..1c16105833b1 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.spec.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.spec.ts @@ -49,7 +49,6 @@ export function registerJournalUnsentSendHoldTests(): void { surface: 'rejection' }) /** The live host process the cards are written for. */ - const HOST = 'host-instance-2' let root: string let clock = 1_000 @@ -78,7 +77,7 @@ export function registerJournalUnsentSendHoldTests(): void { } function hold(journal: AgentSessionJournal, settle: UnsentSendHold = { cause: 'hostRestarted' }) { - return holdUnsentSends(journal, { fence: 0, hostInstance: HOST, hold: settle }) + return holdUnsentSends(journal, { fence: 0, hold: settle }) } /** Orchestration mail as the mailbox sends it: from another agent, naming its sender. */ @@ -190,10 +189,8 @@ export function registerJournalUnsentSendHoldTests(): void { expect(reopened.queuedMessages.get(id)).toMatchObject({ state: 'waiting', holdReason: null, - hostInstance: HOST, body: message(`text of ${id}`), fingerprint: fingerprint(message(`text of ${id}`)), - carriedFrom: null, queuedAt: { epoch: 'epoch-1', sequence: reopened.submission(id)?.acceptedSequence } }) } @@ -255,9 +252,7 @@ export function registerJournalUnsentSendHoldTests(): void { const journal = await afterRestart(async (earlier) => { await earlier.queuedMessages.insert({ messageId: 'card', - body: message('card text'), - fingerprint: fingerprint(message('card text')), - hostInstance: 'proc-1' + body: message('card text') }) await earlier.appendSubmission( { @@ -268,7 +263,7 @@ export function registerJournalUnsentSendHoldTests(): void { handoverRecorded: true, origin }, - { messageId: 'card', expect: 'waiting', settledByOp: null } + { messageId: 'card', expect: 'waiting' } ) }) await hold(journal) @@ -355,15 +350,11 @@ export function registerJournalUnsentSendHoldTests(): void { const journal = await afterRestart(async (earlier) => { await earlier.queuedMessages.insert({ messageId: 'H', - body: message('H'), - fingerprint: fingerprint(message('H')), - hostInstance: 'proc-1' + body: message('H') }) await earlier.queuedMessages.insert({ messageId: 'C', - body: message('C'), - fingerprint: fingerprint(message('C')), - hostInstance: 'proc-1' + body: message('C') }) await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) await earlier.appendSubmission( @@ -375,7 +366,7 @@ export function registerJournalUnsentSendHoldTests(): void { handoverRecorded: true, origin }, - { messageId: 'H', expect: 'waiting', settledByOp: null } + { messageId: 'H', expect: 'waiting' } ) await accept(earlier, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) }) @@ -389,9 +380,7 @@ export function registerJournalUnsentSendHoldTests(): void { const interrupted = await afterRestart(async (earlier) => { await earlier.queuedMessages.insert({ messageId: 'C', - body: message('C'), - fingerprint: fingerprint(message('C')), - hostInstance: 'proc-1' + body: message('C') }) await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) await accept(earlier, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) @@ -406,22 +395,21 @@ export function registerJournalUnsentSendHoldTests(): void { recovered: true, keptAsQueuedMessageId: 'A' }, - (db) => { - interrupted.queuedMessages.holdInTransaction(db, { - card: { + () => + interrupted.queuedMessages.plan([ + { messageId: 'A', - body: message('text of A'), - fingerprint: fingerprint(message('text of A')), - hostInstance: HOST, - queuedAt: { - epoch: 'epoch-1', - sequence: interrupted.submission('A')!.acceptedSequence! - }, - position: 5 - }, - positions: [] - }) - } + change: { + op: 'queued', + body: message('text of A'), + queuedAt: { + epoch: 'epoch-1', + sequence: interrupted.submission('A')!.acceptedSequence! + }, + position: 5 + } + } + ]) ) await interrupted.close() const journal = await open() @@ -439,10 +427,7 @@ export function registerJournalUnsentSendHoldTests(): void { await hold(journal, { cause: 'chatClosed' }) expect(cardOrder(journal)).toEqual(['person']) - expect(journal.queuedMessages.get('person')).toMatchObject({ - holdReason: null, - hostInstance: HOST - }) + expect(journal.queuedMessages.get('person')).toMatchObject({ holdReason: null }) for (const id of ['person', 'mail']) { expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) } diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index 08a8f91cfa89..f682cb484f24 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts @@ -16,10 +16,12 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import type { AgentSessionJournal } from './journal-store' -import type { JournalRowTransactionHook } from './journal-row-writer' -import type { QueuedMessagePositionMove } from './queued-message-positions' +import type { JournalQueueCardChange } from './journal-queue-events' +import type { JournalQueuePlan } from './journal-queued-messages' + +/** A card the batch places, by its id. */ +type QueuedMessagePositionMove = { messageId: string; position: number } /** Why the host can no longer hand a send over, which also says which sends it is. */ export type UnsentSendHold = @@ -29,7 +31,7 @@ export type UnsentSendHold = | { cause: 'chatClosed'; which?: (submission: AgentJournalSubmission) => boolean } /** The body an unsent send is kept with, or null when it is rejected instead: - * - a card's own hand-off: its rejection already returns the card (`rejectedDraftSettlement`); + * - a card's own hand-off: its rejection already returns the card (`queuedHandOffOutcome`); * - `/compact` and other commands: a command in flight is not resumed, the person re-runs it; * - a direct image: it retains the existing rejected-send recovery instead of becoming a card; * - a send not from a person: orchestration mail (the mailbox re-sends it), a dispatch preamble @@ -82,7 +84,6 @@ export async function holdUnsentSends( journal: AgentSessionJournal, input: { fence: number - hostInstance: string hold: UnsentSendHold /** In place of the queued sends: those a child that ended before it answered its start was * handed and never echoed. It ran none, so each is unsent as surely as a queued one. */ @@ -122,37 +123,38 @@ export async function holdUnsentSends( const moves = [ // The cards an earlier settlement kept move with the first row. ...(index === 0 ? positions.earlier : []), - ...(submission.queuedMessageId !== undefined - ? positions.placed.filter( - (entry) => 'consumedAs' in entry && entry.consumedAs === clientMessageId - ) - : []) + ...positions.placed.filter((entry) => entry.messageId === submission.queuedMessageId) ] - const position = positions.placed.find( - (entry) => 'messageId' in entry && entry.messageId === clientMessageId - )?.position + const position = positions.placed.find((entry) => entry.messageId === clientMessageId)?.position const card = body && position !== undefined ? { body, position } : null - const keep: JournalRowTransactionHook | undefined = + const keep: (() => JournalQueuePlan) | undefined = card || moves.length > 0 - ? (db) => { - journal.queuedMessages.holdInTransaction(db, { - card: card - ? { - messageId: clientMessageId, - body: card.body, - fingerprint: agentSessionSendBodyFingerprint( - journal.queuedMessages.sessionId, - card.body - ), - hostInstance: input.hostInstance, - queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, - position: card.position, - // Kept with the message, so the card runs on what it was sent with. - ...(submission.options ? { options: submission.options } : {}) - } - : null, - positions: moves - }) + ? () => { + const { queuedMessages } = journal + const changes: JournalQueueCardChange[] = moves.flatMap((move) => + queuedMessages.get(move.messageId)?.position === move.position + ? [] + : [ + { + messageId: move.messageId, + change: { op: 'moved' as const, position: move.position } + } + ] + ) + // A card by that id already stands: it is not written again. + if (card && !queuedMessages.get(clientMessageId)) { + const queuedAt = { epoch, sequence: submission.acceptedSequence ?? 0 } + const change = { + op: 'queued' as const, + body: card.body, + position: card.position, + queuedAt, + // Kept with the message, so the card runs on what it was sent with. + ...(submission.options ? { options: submission.options } : {}) + } + changes.push({ messageId: clientMessageId, change }) + } + return queuedMessages.plan(changes) } : undefined const reject = { @@ -218,18 +220,16 @@ function headOfQueuePositions( if (body && !earlier.includes(clientMessageId)) { placed.push({ messageId: clientMessageId, position: 0 }) } else if ( - // Kept by its settlement (`rejectedDraftSettlement`): a Send the person asked for. + // Kept by its settlement (`queuedHandOffOutcome`): a Send the person asked for. submission.origin === 'client' && + submission.queuedMessageId !== undefined && cards.some((card) => card.consumedAs === clientMessageId) ) { - placed.push({ consumedAs: clientMessageId, position: 0 }) + placed.push({ messageId: submission.queuedMessageId, position: 0 }) } } const inHead = (card: (typeof cards)[number]): boolean => - earlier.includes(card.messageId) || - placed.some((move) => - 'messageId' in move ? move.messageId === card.messageId : move.consumedAs === card.consumedAs - ) + earlier.includes(card.messageId) || placed.some((move) => move.messageId === card.messageId) const others = cards.filter((card) => !inHead(card)).map((card) => card.position) const anchor = others.length > 0 ? Math.min(...others) : 1 const first = anchor - earlier.length - placed.length diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.spec.ts deleted file mode 100644 index ba7a26a13323..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.spec.ts +++ /dev/null @@ -1,225 +0,0 @@ -// Draft bookkeeping never vetoes a journal row: a failing draft transition -// rolls back alone and the next open re-derives it, and a draft table an -// earlier build created gains the columns this build writes. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import Database from '../../sqlite/sync-database' -import { journalDatabasePath } from './journal-host-database' -import { JournalQueuedMessages } from './journal-queued-messages' -import type { AgentSessionJournal } from './journal-store' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener -} from './journal-host-database-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -export function registerQueuedMessageBookkeepingFailureTests(): void { - const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-q', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('native-1', null) - } - const REFUSAL = agentSessionFailureWords( - agentSessionFailureFact('providerRejected', { - detail: { text: 'Claude refused this payload', audience: 'person' } - }), - { surface: 'rejection' } - ) - - const BODY: AgentJournalMessageItem = { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'queued text' }] - } - - let root: string - let clock = 1_000 - const journals = createTrackedJournalOpener() - - function open(): Promise { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => ++clock, - mintEpoch: () => `epoch-${clock}` - }) - } - - async function queueAndConsume(journal: AgentSessionJournal, messageId: string): Promise { - const body: AgentJournalMessageItem = { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'queued text' }] - } - await journal.queuedMessages.insert({ - messageId, - body, - fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1' - }) - await journal.appendSubmission( - { - clientMessageId: `sub-${messageId}`, - payloadFingerprint: `fp-${messageId}`, - body, - fence: 0, - handoverRecorded: true - }, - { messageId, expect: 'waiting', settledByOp: null } - ) - } - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-queued-bookkeeping-')) - clock = 1_000 - }) - - afterEach(async () => { - vi.restoreAllMocks() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) - }) - - describe('draft bookkeeping inside a journal append', () => { - it('a throwing draft transition still commits the rejection row, and the next open recovers the draft', async () => { - let journal = await open() - await queueAndConsume(journal, 'draft-1') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementationOnce(() => { - throw new Error('table queued_messages has no column named returned_rejection') - }) - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...REFUSAL, - fence: 0 - }) - // The journal's own answer stands: Stop, failed starts and refusals depend on it. - expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - expect(warn).toHaveBeenCalledWith( - '[journal-append] row bookkeeping skipped:', - expect.objectContaining({ kind: 'dispatch' }) - ) - await journal.close() - journal = await open() - expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'returned', - returnedReason: REFUSAL.reason, - returnedRejection: { kind: 'providerRejected' } - }) - }) - - it('an older draft table without a later column is healed at open, so a refusal returns the card', async () => { - const first = await open() - await first.close() - // The table is healed when the host opens its database, so the host restarts around the edit. - closeTestJournalHostDatabases() - const db = new Database(journalDatabasePath(root)) - db.exec('DROP TABLE queued_messages') - // The shape an earlier build of the draft table wrote: no returned_rejection. - db.exec(`CREATE TABLE queued_messages ( - session_id TEXT NOT NULL, message_id TEXT NOT NULL, position INTEGER NOT NULL, - body_json TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at INTEGER NOT NULL, - host_instance TEXT NOT NULL, state TEXT NOT NULL, hold_reason TEXT, - returned_reason TEXT, settled_at INTEGER, settled_by_op TEXT, consumed_as TEXT, - PRIMARY KEY (session_id, message_id))`) - db.close() - const journal = await open() - await queueAndConsume(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...REFUSAL, - fence: 0 - }) - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'returned', - returnedRejection: { kind: 'providerRejected' } - }) - }) - - describe('a failed COMMIT', () => { - /** The append's own COMMIT fails once, after its hooks ran. */ - function failNextCommit() { - const exec = Database.prototype.exec - let armed = true - return vi.spyOn(Database.prototype, 'exec').mockImplementation(function ( - this: Database, - sql: string - ) { - if (armed && sql === 'COMMIT') { - armed = false - throw new Error('SQLITE_FULL') - } - return exec.call(this, sql) - }) - } - - async function consumeFailingCommit(journal: AgentSessionJournal): Promise { - await journal.queuedMessages.insert({ - messageId: 'draft-1', - body: BODY, - fingerprint: 'fp-draft-1', - hostInstance: 'proc-1' - }) - expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) - const commit = failNextCommit() - try { - await expect( - journal.appendSubmission( - { - clientMessageId: 'sub-draft-1', - payloadFingerprint: 'fp-draft-1', - body: BODY, - fence: 0 - }, - { messageId: 'draft-1', expect: 'waiting', settledByOp: null } - ) - ).rejects.toThrow('SQLITE_FULL') - } finally { - commit.mockRestore() - } - } - - it('leaves no uncommitted draft state cached: the per-row hook reads drafts only for an echo', async () => { - const journal = await open() - const list = vi.spyOn(JournalQueuedMessages.prototype, 'list') - await consumeFailingCommit(journal) - // Once by the test itself before the append; never inside it. - expect(list).toHaveBeenCalledTimes(1) - list.mockRestore() - expect(journal.submissions()).toHaveLength(0) - expect(journal.queuedMessages.list()).toMatchObject([ - { messageId: 'draft-1', state: 'waiting' } - ]) - }) - - it('invalidates what any read inside the rolled-back transaction cached', async () => { - const journal = await open() - // Some other bookkeeping reads the list inside the transaction, after the consume wrote. - vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementation( - function (this: JournalQueuedMessages) { - this.list() - } - ) - await consumeFailingCommit(journal) - expect(journal.queuedMessages.list()).toMatchObject([ - { messageId: 'draft-1', state: 'waiting' } - ]) - }) - }) - }) -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.spec.ts deleted file mode 100644 index 881c9dbe8067..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.spec.ts +++ /dev/null @@ -1,201 +0,0 @@ -// A draft sent back to waiting after a handed-over hand-off was rejected as -// never delivered is withdrawn when the provider echoes that message: the -// first send reached the agent, so sending it again would repeat it. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import type { - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' -import { JournalQueuedMessages } from './journal-queued-messages' -import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-host-database-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -export function registerQueuedMessageDeliveredEchoTests(): void { - const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-q', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('native-1', null) - } - const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { - surface: 'rejection' - }) - - let root: string - let clock = 1_000 - const journals = createTrackedJournalOpener() - - function message(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } - } - - function echo(journal: AgentSessionJournal, uuid: string, text: string) { - return journal.appendItem({ provider: 'claude', sessionId: 'native-1', uuid }, message(text), { - fence: 0, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - - /** A draft consumed and handed to the agent, then rejected as never delivered (the provider - * confirmed a Stop withdrew it): back to waiting. `handedOver: false` rejects it before - * hand-over instead, which proves it was never written. */ - async function withdrawnDraft( - text: string, - options: { handedOver: boolean } = { handedOver: true } - ): Promise { - const journal = await open() - await handOffAndReject(journal, text, options) - return journal - } - - function open(): Promise { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => ++clock, - mintEpoch: () => `epoch-${clock}` - }) - } - - async function handOffAndReject( - journal: AgentSessionJournal, - text: string, - options: { handedOver: boolean } = { handedOver: true } - ): Promise { - const body = message(text) - const fingerprint = agentSessionSendBodyFingerprint(IDENTITY.sessionId, body) - await journal.queuedMessages.insert({ - messageId: 'draft-1', - body, - fingerprint, - hostInstance: 'p' - }) - await journal.appendSubmission( - { - clientMessageId: 'sub-draft-1', - payloadFingerprint: fingerprint, - body, - fence: 0, - handoverRecorded: true - }, - { messageId: 'draft-1', expect: 'waiting', settledByOp: null } - ) - if (options.handedOver) { - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'pending', - fence: 0, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...STOP_WITHDRAWAL, - fence: 0 - }) - } else { - await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) - } - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - consumedAs: null - }) - } - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-queued-echo-')) - clock = 1_000 - }) - - afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) - }) - - describe("a waiting draft whose 'never delivered' claim an echo disproves", () => { - it('is withdrawn when the provider echoes the message it was withdrawn from', async () => { - const journal = await withdrawnDraft('did it land?') - await echo(journal, 'echo-1', 'did it land?') - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'withdrawn', - settledByOp: null - }) - // The rejection stays terminal: the echo is kept apart, not folded into it. - expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') - expect(journal.snapshot().items.map((item) => item.itemId)).toHaveLength(2) - }) - - it('stays waiting when the rejected hand-off never reached the agent: the echo is some other message', async () => { - const journal = await withdrawnDraft('did it land?', { handedOver: false }) - await echo(journal, 'echo-1', 'did it land?') - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - - it('stays waiting for an echo of some other text', async () => { - const journal = await withdrawnDraft('did it land?') - await echo(journal, 'echo-1', 'something else') - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - - it('stays waiting when a live send of the same text claims the echo', async () => { - const journal = await withdrawnDraft('did it land?') - const body = message('did it land?') - await journal.appendSubmission({ - clientMessageId: 'typed-again', - payloadFingerprint: agentSessionSendBodyFingerprint(IDENTITY.sessionId, body), - body, - fence: 0, - handoverRecorded: true - }) - await echo(journal, 'echo-1', 'did it land?') - expect(journal.submission('typed-again')?.dispatchState).toBe('accepted') - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - - describe('when the per-row hook was skipped', () => { - it('the re-derivation withdraws it from the echo already in the journal, and at reopen', async () => { - let journal = await withdrawnDraft('did it land?') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const hook = vi - .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') - .mockImplementationOnce(() => { - throw new Error('bookkeeping failed') - }) - try { - await echo(journal, 'echo-1', 'did it land?') - } finally { - hook.mockRestore() - warn.mockRestore() - } - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(true) - // The drain's heal, before the draft could send again. - await journal.queuedMessages.settleOwed() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false) - await journal.close() - journal = await open() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - }) - - it('an unclaimed echo from before the hand-off proves nothing about it', async () => { - const journal = await open() - await echo(journal, 'typed-in-the-agent', 'did it land?') - await handOffAndReject(journal, 'did it land?') - expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false) - await journal.queuedMessages.settleOwed() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - }) - }) -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts deleted file mode 100644 index c927aef1b976..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { journalItemDeclaresEcho, type JournalItemEcho } from './journal-item-echo' -// A draft sent back to waiting rests on its submission's "never delivered" -// claim. The provider echoing that message proves the claim wrong: the first -// delivery happened. The reducer keeps such an echo apart (a rejected -// submission may not claim it), so it is read here, from the row itself. - -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' -import { - isProviderUserMessageEcho, - journalEchoClaimant, - type JournalReducerState -} from './journal-reducer' -import type { JournalRow } from './journal-row-schema' -import type { QueuedMessageRow } from './queued-message-table' - -/** The waiting draft this appended row proves was delivered, or null. Called - * before the row applies, for every row, so a row that is no new provider - * echo of a user message returns before anything else is read. */ -export function draftDeliveredByEcho( - state: JournalReducerState, - drafts: () => readonly QueuedMessageRow[], - row: JournalRow -): string | null { - const declared = new Set() - const echoes = appendedItems(row).filter((item) => { - if (journalItemDeclaresEcho(state.itemEchoes, item.itemId, item)) { - declared.add(item.itemId) - } - return ( - !declared.has(item.itemId) && - isProviderUserMessageEcho(item.itemId, item.body) && - !state.items.has(item.itemId) && - !state.aliases.has(item.itemId) && - journalEchoClaimant(state, item.itemId, item.body) === null - ) - }) - if (echoes.length === 0) { - return null - } - const spent = spentWaitingDrafts(state, drafts()) - for (const item of echoes) { - const delivered = spent.find((draft) => echoProvesDelivered(state, draft, item.body, row.seq)) - if (delivered) { - return delivered.draft.messageId - } - } - return null -} - -/** - * The re-derivation behind that per-row hook, which is bookkeeping and may be - * skipped: waiting drafts an echo ALREADY in the journal proves delivered. An - * unclaimed echo is the item still stored under its provider id — a claimed one - * was folded into its submission's item. Reads every item, so callers run it - * only where a draft is about to send, never per streamed row. - */ -export function draftsDeliveredByAppliedEcho( - state: JournalReducerState, - drafts: readonly QueuedMessageRow[] -): string[] { - const spent = spentWaitingDrafts(state, drafts) - if (spent.length === 0) { - return [] - } - const delivered = new Set() - for (const item of state.items.values()) { - if ( - journalItemDeclaresEcho(state.itemEchoes, item.itemId) || - !isProviderUserMessageEcho(item.itemId, item.body) - ) { - continue - } - for (const candidate of spent) { - if (echoProvesDelivered(state, candidate, item.body, item.sequence)) { - delivered.add(candidate.draft.messageId) - } - } - } - return [...delivered] -} - -type SpentDraft = { draft: QueuedMessageRow; since: number } - -/** Waiting drafts some hand-off of which was handed over, then rejected as never delivered; - * `since` is the earliest such hand-off's row. A hand-off rejected before hand-over is - * provably unwritten: an echo matching it is some other message, and must not delete the card. */ -function spentWaitingDrafts( - state: JournalReducerState, - drafts: readonly QueuedMessageRow[] -): SpentDraft[] { - const since = new Map() - for (const submission of state.submissions.values()) { - if ( - submission.queuedMessageId !== undefined && - submission.dispatchState === 'rejected' && - submission.handedOverAt !== undefined - ) { - const sequence = submission.acceptedSequence ?? 0 - const earliest = since.get(submission.queuedMessageId) - since.set(submission.queuedMessageId, Math.min(earliest ?? sequence, sequence)) - } - } - return drafts.flatMap((draft) => { - const from = since.get(draft.messageId) - return draft.state === 'waiting' && from !== undefined ? [{ draft, since: from }] : [] - }) -} - -/** The one predicate both paths share: an unclaimed echo appended after a disproved hand-off, - * carrying the draft's own payload. */ -function echoProvesDelivered( - state: JournalReducerState, - spent: SpentDraft, - body: AgentJournalItemBody, - sequence: number -): boolean { - return ( - sequence > spent.since && - agentSessionSendBodyFingerprint(state.sessionId, body) === spent.draft.fingerprint - ) -} - -function appendedItems( - row: JournalRow -): (JournalItemEcho & { itemId: string; body: AgentJournalItemBody })[] { - if (row.kind === 'item') { - return [row] - } - if (row.kind === 'lifecycle-batch') { - return row.mutations.flatMap((mutation) => (mutation.kind === 'item' ? [mutation] : [])) - } - return [] -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts index 7d85e0a58c5b..13a02fca7866 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-edit-leases.test.ts @@ -4,21 +4,15 @@ import type { QueuedMessageRow } from './queued-message-table' function card(messageId = 'card'): QueuedMessageRow { return { - sessionId: 'folder-session', messageId, position: 1, body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, fingerprint: 'base', - createdAt: 1, - hostInstance: 'host-a', state: 'waiting', holdReason: null, returnedReason: null, returnedRejection: null, - settledAt: null, - settledByOp: null, consumedAs: null, - carriedFrom: null, queuedAt: null } } diff --git a/src/main/native-chat/agent-session-journal/queued-message-holds.ts b/src/main/native-chat/agent-session-journal/queued-message-holds.ts deleted file mode 100644 index c3f90590c117..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-holds.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Per-draft holds: what keeps one card from auto-sending, stored on its row. A -// Stop, a /clear or a reopen pauses the queue instead (`queued-message-pause.ts`, -// derived); a per-draft hold is a conversion that failed (`QueuedMessageHoldReason`), -// which an explicit Send releases. - -import type Database from '../../sqlite/sync-database' -import type { QueuedMessageHoldReason } from './queued-message-table' - -/** Hold waiting drafts from auto-sending. The hold retires with the row: consume - * and withdraw clear it in their own UPDATE. Returns how many rows it newly reached. */ -export function holdQueuedMessages( - db: Database.Database, - input: { - sessionId: string - messageIds: readonly string[] - reason: QueuedMessageHoldReason - } -): number { - const update = db.prepare( - `UPDATE queued_messages SET hold_reason = ? - WHERE session_id = ? AND message_id = ? AND state = 'waiting' - AND (hold_reason IS NULL OR hold_reason <> ?)` - ) - let held = 0 - for (const messageId of input.messageIds) { - held += Number(update.run(input.reason, input.sessionId, messageId, input.reason).changes ?? 0) - } - return held -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.spec.ts index 328960d45e5e..30068eba8357 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.spec.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.spec.ts @@ -29,6 +29,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' export function registerQueuedMessagePauseTests(): void { const IDENTITY: AgentSessionJournalIdentity = { @@ -38,7 +39,6 @@ export function registerQueuedMessagePauseTests(): void { agent: 'claude', providerHandle: claudeProviderHandle('native-1', null) } - const HOST = 'proc-1' let root: string let clock = 1_000 @@ -48,6 +48,10 @@ export function registerQueuedMessagePauseTests(): void { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } + function fingerprintOf(text: string): string { + return agentSessionSendBodyFingerprint(IDENTITY.sessionId, message(text)) + } + function open(): Promise { return journals.open({ identity: IDENTITY, @@ -58,19 +62,13 @@ export function registerQueuedMessagePauseTests(): void { } function queueDraft(journal: AgentSessionJournal, messageId: string) { - return journal.queuedMessages.insert({ - messageId, - body: message(messageId), - fingerprint: `fp-${messageId}`, - hostInstance: HOST - }) + return journal.queuedMessages.insert({ messageId, body: message(messageId) }) } function clearContext(journal: AgentSessionJournal) { return journal.context.clear( { operationId: `clear-${++clock}`, afterFence: 0, clearedAt: clock }, - { write: () => {}, committed: () => {} }, - `caller-clear-${clock}` + { write: () => {}, committed: () => {} } ) } @@ -309,7 +307,7 @@ export function registerQueuedMessagePauseTests(): void { journal.appendSubmission( { clientMessageId: id, - payloadFingerprint: 'fp-draft-1', + payloadFingerprint: fingerprintOf('draft-1'), body: message('draft-1'), fence: 0, handoverRecorded: true @@ -317,8 +315,6 @@ export function registerQueuedMessagePauseTests(): void { { messageId: 'draft-1', expect: 'waiting', - settledByOp: null, - hostInstance: HOST, ...(automatic ? { yieldsToPause: true as const } : {}) } ) @@ -433,12 +429,12 @@ export function registerQueuedMessagePauseTests(): void { await journal.appendSubmission( { clientMessageId: 'send-now-1', - payloadFingerprint: 'fp-steered', + payloadFingerprint: fingerprintOf('steered'), body: message('steered'), fence: 0, handoverRecorded: true }, - { messageId: 'steered', expect: 'waiting', settledByOp: null, hostInstance: HOST } + { messageId: 'steered', expect: 'waiting' } ) await userStop(journal) await queueDraft(journal, 'after') @@ -463,7 +459,7 @@ export function registerQueuedMessagePauseTests(): void { journal.appendSubmission( { clientMessageId: 'drain-newer', - payloadFingerprint: 'fp-newer', + payloadFingerprint: fingerprintOf('newer'), body: message('newer'), fence: 0, handoverRecorded: true @@ -471,8 +467,6 @@ export function registerQueuedMessagePauseTests(): void { { messageId: 'newer', expect: 'waiting', - settledByOp: null, - hostInstance: HOST, yieldsToPause: true } ) @@ -551,12 +545,12 @@ export function registerQueuedMessagePauseTests(): void { await journal.appendSubmission( { clientMessageId: 'drain-1', - payloadFingerprint: 'fp-in-flight', + payloadFingerprint: fingerprintOf('in-flight'), body: message('in-flight'), fence: 0, handoverRecorded: true }, - { messageId: 'in-flight', expect: 'waiting', settledByOp: null, hostInstance: HOST } + { messageId: 'in-flight', expect: 'waiting' } ) journal = await reopen(journal) await journal.resolveDispatch({ @@ -622,19 +616,17 @@ export function registerQueuedMessagePauseTests(): void { it("reads an earlier build's stored 'kept' and 'stopped' holds as none; send_failed stays", async () => { let journal = await open() - for (const id of ['was-kept', 'was-stopped', 'failed']) { - await queueDraft(journal, id) - } - await journal.queuedMessages.hold({ messageIds: ['failed'], reason: 'send_failed' }) const db = new Database(journalDatabasePath(root)) - for (const [id, stored] of [ - ['was-kept', 'kept'], - ['was-stopped', 'stopped'] - ]) { - db.prepare('UPDATE queued_messages SET hold_reason = ? WHERE message_id = ?').run( - stored, - id - ) + for (const [position, id, stored] of [ + [1, 'was-kept', 'kept'], + [2, 'was-stopped', 'stopped'], + [3, 'failed', 'send_failed'] + ] as const) { + db.prepare( + `INSERT INTO queued_messages (session_id, message_id, position, body_json, fingerprint, + created_at, host_instance, state, hold_reason) + VALUES (?, ?, ?, ?, '', 0, 'earlier', 'waiting', ?)` + ).run(IDENTITY.sessionId, id, position, JSON.stringify(message(id)), stored) } db.close() journal = await reopen(journal) diff --git a/src/main/native-chat/agent-session-journal/queued-message-positions.ts b/src/main/native-chat/agent-session-journal/queued-message-positions.ts deleted file mode 100644 index 9916e6fc31b6..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-positions.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Where a card sits in its queue, for a writer placing cards ahead of the others -// (`journal-unsent-send-hold.ts`). The queue sends in position order. - -import type Database from '../../sqlite/sync-database' - -/** A card to move, named by its id or, for one handed off, by the submission that consumed it. */ -export type QueuedMessagePositionMove = - | { messageId: string; position: number } - | { consumedAs: string; position: number } - -/** Returns how many rows moved. */ -export function moveQueuedMessages( - db: Database.Database, - sessionId: string, - moves: readonly QueuedMessagePositionMove[] -): number { - let moved = 0 - for (const move of moves) { - const [column, key] = - 'messageId' in move ? ['message_id', move.messageId] : ['consumed_as', move.consumedAs] - moved += Number( - db - .prepare( - `UPDATE queued_messages SET position = ? - WHERE session_id = ? AND ${column} = ? AND position <> ?` - ) - .run(move.position, sessionId, key, move.position).changes ?? 0 - ) - } - return moved -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-retention.ts b/src/main/native-chat/agent-session-journal/queued-message-retention.ts deleted file mode 100644 index 46717268ad10..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-retention.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Retention for the draft table: which settled rows may be deleted, and when. A row is kept for -// as long as anything could still read it — a replayed operation, or a late refusal returning it. - -import type Database from '../../sqlite/sync-database' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { listQueuedMessages } from './queued-message-table' - -/** What the loaded journal says about a dispatched draft's consumed submission. */ -export type QueuedMessageSubmissionVerdict = - /** Still owed an answer — a crash leftover the next open settles; keep the row. */ - | 'pending' - /** `accepted` or `unknown`: terminal and not refused. */ - | 'terminal-not-refused' - /** Absent from the current epoch. */ - | 'absent' - /** Effectively rejected; the open-time repair settles it rather than pruning. */ - | 'rejected' - -/** - * Retention: `withdrawn` tombstones live for the operation-replay window; - * a `dispatched` row only once its consumed submission is terminal-and-not- - * refused or absent AND the window has passed — never while pending, so a slow - * refusal can still return it. `waiting` and `returned` rows are never pruned. - */ -export function pruneQueuedMessages( - db: Database.Database, - input: { - sessionId: string - now: number - replayWindowMs: number - submissionVerdict: (consumedRef: string) => QueuedMessageSubmissionVerdict - } -): number { - const cutoff = input.now - input.replayWindowMs - const tombstones = db - .prepare( - `DELETE FROM queued_messages - WHERE session_id = ? AND state = 'withdrawn' AND settled_at IS NOT NULL AND settled_at < ?` - ) - .run(input.sessionId, cutoff) - let pruned = Number(tombstones.changes ?? 0) - for (const row of listQueuedMessages(db, input.sessionId)) { - if (row.state !== 'dispatched' || row.settledAt === null || row.settledAt >= cutoff) { - continue - } - // A dispatched row always names its hand-off; one that does not has nothing to wait for. - const verdict = row.consumedAs === null ? 'absent' : input.submissionVerdict(row.consumedAs) - if (verdict === 'terminal-not-refused' || verdict === 'absent') { - db.prepare('DELETE FROM queued_messages WHERE session_id = ? AND message_id = ?').run( - input.sessionId, - row.messageId - ) - pruned += 1 - } - } - return pruned -} - -/** How retention reads a consumed submission from the loaded journal. Run after the - * owed settlements, which already settled every rejected row. */ -export function retainedSubmissionVerdict( - submissions: ReadonlyMap -): (consumedRef: string) => QueuedMessageSubmissionVerdict { - return (consumedRef) => { - const submission = submissions.get(consumedRef) - if (!submission) { - return 'absent' - } - if (submission.dispatchState === 'accepted' || submission.dispatchState === 'unknown') { - return 'terminal-not-refused' - } - return submission.dispatchState === 'rejected' ? 'rejected' : 'pending' - } -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts deleted file mode 100644 index 212f572fd6cf..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ /dev/null @@ -1,155 +0,0 @@ -// What a journal row does to the drafts, and the re-derivation behind it. The -// live hook runs inside each append's transaction; it is bookkeeping and may be -// skipped, so a dispatched draft whose current submission the journal already -// rejected is owed the settlement it would have applied, which the open-time -// repair and the drain both apply. - -import type Database from '../../sqlite/sync-database' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { - consumedSubmissionWasRejected, - journalDispatchRowNewlyRejects -} from './journal-dispatch-settlement' -import type { JournalReducerState } from './journal-reducer' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { structuredAgentSessionCommandTurnIdentity } from '../../../shared/structured-agent-session-command-turn-identity' -import type { JournalRow } from './journal-row-schema' -import { draftDeliveredByEcho, draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' -import { - listQueuedMessages, - settleRejectedQueuedMessage, - withdrawQueuedMessages, - type QueuedMessageRow -} from './queued-message-table' - -type Submissions = ReadonlyMap - -/** Some dispatched draft still waits on a settlement the journal already decided. */ -export function queuedMessageSettlementOwed( - rows: readonly QueuedMessageRow[], - submissions: Submissions -): boolean { - return rows.some( - (row) => - row.state === 'dispatched' && - row.consumedAs !== null && - consumedSubmissionWasRejected(submissions.get(row.consumedAs)) - ) -} - -/** A card waits, or is mid-hand-off and may come back to waiting: a chat that stops running - * marks it (`AgentSessionJournal.markQueueReopen`). */ -export function queuedMessagesAwaitReopenMark( - rows: readonly QueuedMessageRow[], - submissions: Submissions -): boolean { - return rows.some((row) => { - if (row.state === 'waiting') { - return true - } - const handOff = row.consumedAs ? submissions.get(row.consumedAs)?.dispatchState : undefined - return row.state === 'dispatched' && (handOff === 'pending' || handOff === 'unknown') - }) -} - -/** Applies each owed settlement, and withdraws each waiting draft an applied echo proves - * delivered (`draftsDeliveredByAppliedEcho`); returns how many drafts changed. */ -export function settleOwedQueuedMessages( - db: Database.Database, - input: { sessionId: string; state: JournalReducerState; now: number } -): number { - const { submissions } = input.state - let settled = 0 - for (const row of listQueuedMessages(db, input.sessionId)) { - const consumedRef = row.consumedAs - const submission = consumedRef === null ? undefined : submissions.get(consumedRef) - if ( - row.state !== 'dispatched' || - consumedRef === null || - !consumedSubmissionWasRejected(submission) - ) { - continue - } - const changed = settleRejectedQueuedMessage(db, { - sessionId: input.sessionId, - consumedRef, - reason: submission?.reason ?? null, - rejection: submission?.rejection, - commandTurnReported: commandTurnReported(input.state, consumedRef), - now: input.now - }) - settled += changed ? 1 : 0 - } - const delivered = draftsDeliveredByAppliedEcho( - input.state, - listQueuedMessages(db, input.sessionId) - ) - if (delivered.length > 0) { - settled += withdrawQueuedMessages(db, { - sessionId: input.sessionId, - messageIds: delivered, - settledByOp: null, - now: input.now - }).length - } - return settled -} - -/** - * The live hook, before `row` applies: an echo proving a waiting draft's first - * send was delivered withdraws it; a row that NEWLY settles a dispatched - * draft's current submission to `rejected` settles the draft — a refusal - * returns it, a withdrawal (a Stop, a restart) sends it back to waiting - * (`rejectedDraftSettlement`). - * Decided by the same function the reducer folds rows through, so a row the - * journal's settlement rules ignore never alters a draft. Returns how many - * drafts changed. - */ -export function settleQueuedMessagesForRow( - db: Database.Database, - input: { - sessionId: string - state: JournalReducerState - /** Read only once the row holds an unclaimed echo: a list read inside the append's - * transaction must not be cached under state a rollback could undo. */ - drafts: () => readonly QueuedMessageRow[] - row: JournalRow - now: number - } -): number { - const { row } = input - let changed = 0 - const delivered = draftDeliveredByEcho(input.state, input.drafts, row) - if (delivered !== null) { - // Its first send reached the agent after all; sending it again would repeat it. - changed += withdrawQueuedMessages(db, { - sessionId: input.sessionId, - messageIds: [delivered], - settledByOp: null, - now: input.now - }).length - } - if (row.kind !== 'dispatch' || row.state !== 'rejected') { - return changed - } - const submission = input.state.submissions.get(row.clientMessageId) - if (!journalDispatchRowNewlyRejects(submission, row)) { - return changed - } - const settled = settleRejectedQueuedMessage(db, { - sessionId: input.sessionId, - consumedRef: row.clientMessageId, - reason: row.reason, - rejection: row.rejection, - commandTurnReported: commandTurnReported(input.state, row.clientMessageId), - now: input.now - }) - return changed + (settled ? 1 : 0) -} - -/** The submission's command turn is in the journal: its own row reports the refusal. */ -function commandTurnReported(state: JournalReducerState, clientMessageId: string): boolean { - return state.items.has( - agentJournalItemKey(structuredAgentSessionCommandTurnIdentity(clientMessageId)) - ) -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-store.spec.ts deleted file mode 100644 index 5da3dec95154..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-store.spec.ts +++ /dev/null @@ -1,838 +0,0 @@ -// The draft store's contract: exactly-once consume in one transaction, the -// rejected-draft settlement following the journal's EFFECTIVE settlement, retention -// that never outruns a slow refusal, and rows that survive epoch replacement. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import type { - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import Database from '../../sqlite/sync-database' -import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' -import { journalDatabasePath } from './journal-host-database' -import { JournalQueuedMessages, QUEUED_MESSAGE_REPLAY_WINDOW_MS } from './journal-queued-messages' -import { QueuedMessageNotConsumableError } from './queued-message-consume-error' -import type { AgentSessionJournal } from './journal-store' -import type { AgentMessageSource } from '../../../shared/agent-session-message-source' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener -} from './journal-host-database-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -export function registerQueuedMessageStoreTests(): void { - const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-q', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('native-1', null) - } - - let root: string - let clock = 1_000 - - function tick(): number { - clock += 1 - return clock - } - - function message(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } - } - - const journals = createTrackedJournalOpener() - const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { - surface: 'rejection' - }) - const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { - surface: 'rejection' - }) - const PROVIDER_REFUSAL = agentSessionFailureFact('providerRejected', { - detail: { text: 'Claude refused this payload', audience: 'person' } - }) - /** A provider refusal as a settled rejection stores it: its sentence and typed fact. */ - function refusal(text: string) { - return agentSessionFailureWords( - agentSessionFailureFact('providerRejected', { detail: { text, audience: 'person' } }), - { surface: 'rejection' } - ) - } - - async function open(): Promise { - const journal = await journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: tick, - mintEpoch: () => `epoch-${clock}` - }) - return journal - } - - async function queueDraft(journal: AgentSessionJournal, messageId: string, text = 'queued text') { - return journal.queuedMessages.insert({ - messageId, - body: message(text), - fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1' - }) - } - - async function consumeDraft( - journal: AgentSessionJournal, - messageId: string, - options: { - as?: string - expect?: 'waiting' | 'returned' - settledByOp?: string | null - origin?: 'client' | 'host' - } = {} - ) { - const draft = journal.queuedMessages.get(messageId) - await journal.appendSubmission( - { - clientMessageId: options.as ?? `sub-${messageId}`, - payloadFingerprint: draft?.fingerprint ?? `fp-${messageId}`, - body: draft?.body ?? message('queued text'), - fence: 0, - handoverRecorded: true, - ...(options.origin ? { origin: options.origin } : {}) - }, - { - messageId, - expect: options.expect ?? 'waiting', - settledByOp: options.settledByOp ?? null - } - ) - } - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-queued-message-')) - clock = 1_000 - }) - - afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) - }) - - describe('draft rows', () => { - it('creates the table at open without bumping user_version, so an old build stays writable', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await journal.close() - const db = new Database(journalDatabasePath(root), { readonly: true }) - try { - const version = Number(db.pragma('user_version', { simple: true })) - // An old build compares stored == supported and keeps writing; a bump - // would cost it every chat after a downgrade. - expect(version).toBe(JOURNAL_DB_SCHEMA_VERSION) - const table = db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") - .get('queued_messages') - expect(table).toBeDefined() - } finally { - db.close() - } - }) - - it('opens a database an older build shaped (no drafts table) and creates the table', async () => { - const first = await open() - await first.appendItem( - { provider: 'orca', clientMessageId: 'seed' }, - { kind: 'status', text: 'seed' }, - { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await first.close() - // The table is created when the host opens its database, so the host restarts around the edit. - closeTestJournalHostDatabases() - const db = new Database(journalDatabasePath(root)) - db.exec('DROP TABLE queued_messages') - db.close() - const journal = await open() - const row = await queueDraft(journal, 'draft-1') - expect(row.position).toBe(1) - }) - - it('assigns monotonic positions and lists in order', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await queueDraft(journal, 'draft-2') - const listed = journal.queuedMessages.list() - expect(listed.map((row) => [row.messageId, row.position, row.state])).toEqual([ - ['draft-1', 1, 'waiting'], - ['draft-2', 2, 'waiting'] - ]) - }) - - it('replays an insert under an already-used id instead of duplicating', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - const again = await queueDraft(journal, 'draft-1') - expect(again.position).toBe(1) - expect(journal.queuedMessages.list()).toHaveLength(1) - }) - - it("keeps an agent card's body as written across reopen: the queue reads nothing of its sender", async () => { - const from: AgentMessageSource = { - kind: 'agent', - senders: [ - { - party: { - address: 'structworker_1', - terminalHandle: 'structworker_1', - orcaSessionId: null - }, - name: 'Reviewer' - } - ], - orchestration: { - message: 'mail-notice', - mailbox: 'run:r1', - dispatchId: 'd1', - messages: [{ messageId: 'm1', runId: 'r1', from: 'structworker_1' }] - } - } - const first = await open() - const insert = (messageId: string, body: AgentJournalMessageItem) => - first.queuedMessages.insert({ - messageId, - body, - fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1' - }) - await insert('agent-card', { ...message('You have 1 orchestration message.'), from }) - await insert('newer-kind', { - ...message('a task'), - from: { ...from, orchestration: null } - }) - await insert('malformed', message('typed')) - await first.close() - closeTestJournalHostDatabases() - const db = new Database(journalDatabasePath(root)) - // A newer build's message kind, and a value no build writes: carried as written, for clients to read. - const setFrom = db.prepare( - "UPDATE queued_messages SET body_json = json_set(body_json, '$.from', json(?)) WHERE message_id = ?" - ) - setFrom.run( - JSON.stringify({ ...from, orchestration: { message: 'task', taskId: 't1' } }), - 'newer-kind' - ) - setFrom.run(JSON.stringify('nobody'), 'malformed') - // A table from a build that also kept the sender in a column of its own. - db.exec('ALTER TABLE queued_messages ADD COLUMN source_json TEXT') - db.close() - const reopened = await open() - expect(reopened.queuedMessages.list().map((row) => [row.messageId, row.body.from])).toEqual([ - ['agent-card', from], - ['newer-kind', { ...from, orchestration: { message: 'task', taskId: 't1' } }], - ['malformed', 'nobody'] - ]) - // That older table still takes new cards. - await queueDraft(reopened, 'after') - expect(reopened.queuedMessages.list()).toHaveLength(4) - }) - - it('drafts survive epoch replacement, which deletes only journal rows', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await journal.replaceEpochItems('handle_forked', 0, []) - expect(journal.queuedMessages.list().map((row) => row.messageId)).toEqual(['draft-1']) - }) - }) - - describe('consume', () => { - it('converts waiting → dispatched and appends the submission in one transaction', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - const row = journal.queuedMessages.get('draft-1') - expect(row?.state).toBe('dispatched') - expect(row?.consumedAs).toBe('sub-draft-1') - expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1']) - }) - - it('never hands a draft off under its own id: the submission names it by link, not id equality', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await expect(consumeDraft(journal, 'draft-1', { as: 'draft-1' })).rejects.toBeInstanceOf( - QueuedMessageNotConsumableError - ) - expect(journal.submissions()).toHaveLength(0) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - - it('never records a second submission under an id it already holds, whatever state it settled in', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('refused'), - fence: 0 - }) - const cursor = journal.cursor() - await expect( - journal.appendSubmission({ - clientMessageId: 'sub-draft-1', - payloadFingerprint: 'fp-draft-1', - body: message('queued text'), - fence: 0, - handoverRecorded: true - }) - ).rejects.toMatchObject({ code: 'journal_submission_exists' }) - // The refusal stands: re-appending would reset it to pending and hand it over again. - expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') - expect(journal.cursor()).toEqual(cursor) - }) - - it('a second consume of the same draft fails and appends nothing (exactly-once)', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await expect(consumeDraft(journal, 'draft-1', { as: 'second-id' })).rejects.toBeInstanceOf( - QueuedMessageNotConsumableError - ) - expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1']) - }) - - it('a consume racing a withdraw loses and appends nothing', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.withdraw({ - messageIds: ['draft-1'], - settledByOp: 'caller\u0000op-1' - }) - await expect(consumeDraft(journal, 'draft-1')).rejects.toBeInstanceOf( - QueuedMessageNotConsumableError - ) - expect(journal.submissions()).toHaveLength(0) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - }) - - it('a consume carrying text the card no longer holds loses: an edit since the read never sends the old text', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1', 'old') - const read = journal.queuedMessages.get('draft-1')! - await journal.queuedMessages.update({ - messageId: 'draft-1', - expectedBodyFingerprint: read.fingerprint, - text: 'new' - }) - await expect( - journal.appendSubmission( - { - clientMessageId: 'sub-draft-1', - payloadFingerprint: read.fingerprint, - body: read.body, - fence: 0, - handoverRecorded: true - }, - { messageId: 'draft-1', expect: 'waiting', settledByOp: null } - ) - ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) - expect(journal.submissions()).toHaveLength(0) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - - it('a failed submission insert rolls the draft transition back', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - const cursor = journal.cursor() - // Occupy the next sequence directly so the append's INSERT violates the - // primary key inside the transaction, after the draft was transitioned. - const db = new Database(journalDatabasePath(root)) - db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ).run(IDENTITY.sessionId, cursor.epoch, cursor.sequence + 1, tick(), '{}') - db.close() - await expect(consumeDraft(journal, 'draft-1')).rejects.toThrow() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - }) - - describe('returned transition (D1/N4)', () => { - it('a non-withdrawn rejection returns the consumed draft with its reason', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('Claude refused this payload'), - rejection: PROVIDER_REFUSAL, - fence: 0 - }) - const row = journal.queuedMessages.get('draft-1') - expect(row?.state).toBe('returned') - expect(row?.returnedReason).toBe(refusal('Claude refused this payload').reason) - expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL) - }) - - it('a late rejection row after acceptance settles nothing and returns no card', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'accepted', - providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, - fence: 0 - }) - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('late duplicate'), - fence: 0 - }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - }) - - it("a Stop's withdrawal before the agent received it sends the draft back to waiting, held like the rest, and it survives a restart", async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - // The Stop's own withdrawal path: the queued (not handed over) submission. - expect(await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL)).toEqual(['sub-draft-1']) - // Nothing failed: no refusal to show, its position kept, its spent id recorded. - const requeued = { - state: 'waiting', - position: 1, - holdReason: null, - consumedAs: null, - returnedReason: null, - returnedRejection: null - } - expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued) - // Atomic with the rejection row: a crash before the Stop answered keeps the text. - await journal.close() - clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 - journal = await open() - expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued) - }) - - it('a draft sent back to waiting hands off again under a fresh id, never a spent one', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) - // The spent id already names a rejected submission: one id, one delivery. - await expect(consumeDraft(journal, 'draft-1')).rejects.toMatchObject({ - code: 'journal_submission_exists' - }) - expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') - await consumeDraft(journal, 'draft-1', { as: 'fresh-1' }) - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'dispatched', - consumedAs: 'fresh-1' - }) - // Both hand-offs name the draft. - expect(journal.submission('fresh-1')).toMatchObject({ - dispatchState: 'pending', - queuedMessageId: 'draft-1' - }) - expect(journal.submission('sub-draft-1')?.queuedMessageId).toBe('draft-1') - }) - - it.each([ - { by: 'the queue', origin: 'host' as const }, - { by: 'the person', origin: 'client' as const } - ])( - 'a restart between $by’s consume and handover sends the draft back to waiting', - async ({ origin }) => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1', { origin }) - await journal.close() - journal = await open() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => - journal.wroteBeforeOpen(submission.acceptedSequence) - ) - // Whoever sent it, it waits with no hold of its own, under the reopen's pause. - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - holdReason: null, - hostInstance: 'proc-1', - consumedAs: null, - returnedReason: null - }) - } - ) - - it('refuse → Send under a fresh id → refuse again returns the card again; a late duplicate of the first refusal never touches the re-send (N4)', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('first refusal'), - rejection: PROVIDER_REFUSAL, - fence: 0 - }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('returned') - // Send on the returned card re-consumes under a fresh submission id. - await consumeDraft(journal, 'draft-1', { as: 'resend-1', expect: 'returned' }) - // The earlier refusal retires with the card: the row now describes the re-send. - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'dispatched', - consumedAs: 'resend-1', - returnedReason: null, - returnedRejection: null - }) - // A duplicate resolution of the FIRST submission is ignored by the journal - // and must not alter the draft's current relation. - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('duplicate of first refusal'), - fence: 0 - }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - // The re-send's own refusal returns the card, matched via consumed_as. - await journal.resolveDispatch({ - clientMessageId: 'resend-1', - state: 'rejected', - ...refusal('second refusal'), - fence: 0 - }) - const returned = journal.queuedMessages.get('draft-1') - expect(returned?.state).toBe('returned') - expect(returned?.returnedReason).toBe(refusal('second refusal').reason) - // The first refusal's fact does not outlive it: the pair is the second submission's. - expect(returned?.returnedRejection).toEqual(refusal('second refusal').rejection) - expect(returned?.returnedRejection).not.toEqual(PROVIDER_REFUSAL) - }) - - it('a rejection never revives a withdrawn draft', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('refused'), - fence: 0 - }) - await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('again'), - fence: 0 - }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - }) - - it('the returned row and its stored reason survive epoch replacement and reopen (B1)', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('stored refusal'), - fence: 0 - }) - await journal.replaceEpochItems('handle_forked', 0, []) - await journal.close() - journal = await open() - const row = journal.queuedMessages.get('draft-1') - expect(row?.state).toBe('returned') - expect(row?.returnedReason).toBe(refusal('stored refusal').reason) - }) - }) - - describe('withdraw', () => { - it('withdraws waiting and returned rows together into op-stamped receipts', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1', 'first text') - await queueDraft(journal, 'draft-2', 'second text') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('refused'), - fence: 0 - }) - const withdrawn = await journal.queuedMessages.withdraw({ - messageIds: ['draft-1', 'draft-2'], - settledByOp: 'caller\u0000stop-1' - }) - expect(withdrawn.map((row) => [row.messageId, row.body.blocks])).toEqual([ - ['draft-1', [{ type: 'text', text: 'first text' }]], - ['draft-2', [{ type: 'text', text: 'second text' }]] - ]) - // A lost acknowledgement replays from the tombstones, keyed by the - // caller-scoped operation key — never from the ledger. - const receipts = journal.queuedMessages.receipts('caller\u0000stop-1') - expect(receipts.map((row) => row.messageId)).toEqual(['draft-1', 'draft-2']) - // Pending or dispatched rows stay outside the withdrawable set. - const second = await journal.queuedMessages.withdraw({ - messageIds: ['draft-1'], - settledByOp: 'caller\u0000stop-2' - }) - expect(second).toHaveLength(0) - }) - - it('two callers reusing one operation id read only their own receipts', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.withdraw({ - messageIds: ['draft-1'], - settledByOp: 'caller-a\u0000op-1' - }) - expect(journal.queuedMessages.receipts('caller-b\u0000op-1')).toHaveLength(0) - expect(journal.queuedMessages.receipts('caller-a\u0000op-1')).toHaveLength(1) - }) - }) - - describe('open-time repair and retention', () => { - it('a failed repair is reported and skipped, never failing the open', async () => { - const repair = vi - .spyOn(JournalQueuedMessages.prototype, 'repairAndPrune') - .mockRejectedValueOnce(new Error('SQLITE_FULL')) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - try { - const journal = await open() - expect(warn).toHaveBeenCalledWith( - '[journal-open] queued-message repair skipped:', - expect.objectContaining({ error: 'SQLITE_FULL' }) - ) - await queueDraft(journal, 'draft-1') - expect(journal.queuedMessages.list()).toHaveLength(1) - } finally { - repair.mockRestore() - warn.mockRestore() - } - }) - - // The repair reaches the live hook's answer from the stored rejection. - it.each([ - { origin: 'client' as const, cause: 'hostRestarted' as const }, - { origin: 'host' as const, cause: 'hostRestarted' as const }, - { origin: 'client' as const, cause: 'chatClosed' as const }, - { origin: 'host' as const, cause: 'chatClosed' as const } - ])( - 'a skipped hook for a $origin hand-off cut short ($cause) is repaired at open, back to waiting', - async ({ origin, cause }) => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1', { origin }) - await journal.rejectQueuedSubmissions( - 0, - agentSessionFailureWords(agentSessionFailureFact(cause), { surface: 'rejection' }) - ) - await journal.close() - // The hook "was skipped": the draft is back to dispatched behind the stored rejection. - const db = new Database(journalDatabasePath(root)) - db.prepare( - "UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?" - ).run('draft-1') - db.close() - journal = await open() - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - holdReason: null, - consumedAs: null - }) - } - ) - - it('returns a dispatched row whose loaded submission is effectively rejected (downgrade wrote no hook)', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('refused while downgraded'), - rejection: PROVIDER_REFUSAL, - fence: 0 - }) - await journal.close() - // Simulate the old build having written the rejection with no hook: put the - // draft back to dispatched behind the stored fact. - const db = new Database(journalDatabasePath(root)) - db.prepare( - "UPDATE queued_messages SET state = 'dispatched', returned_reason = NULL, returned_rejection = NULL WHERE message_id = ?" - ).run('draft-1') - db.close() - journal = await open() - const row = journal.queuedMessages.get('draft-1') - expect(row?.state).toBe('returned') - expect(row?.returnedReason).toBe(refusal('refused while downgraded').reason) - expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL) - }) - - it('sends back to waiting a dispatched row whose submission a Stop withdrew with no hook, never leaving it dispatched', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) - await journal.close() - const db = new Database(journalDatabasePath(root)) - db.prepare( - "UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?" - ).run('draft-1') - db.close() - clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 - journal = await open() - // The same settlement the live hook applies. - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - holdReason: null, - consumedAs: null, - returnedReason: null - }) - }) - - it('keeps a dispatched row while its submission is still pending, even past the window, so a late rejection still settles it (N5)', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.close() - // Reopen "25 hours" later: the submission is still queued/pending. - clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 60 * 60 * 1000 - journal = await open() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - // The delivery loop's leftover rejection now sends it back to waiting. - await journal.rejectQueuedSubmissions(0, HOST_RESTARTED) - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - consumedAs: null - }) - }) - - it('prunes accepted and withdrawn rows once the replay window passes, and never waiting or returned rows', async () => { - let journal = await open() - await queueDraft(journal, 'accepted-1') - await consumeDraft(journal, 'accepted-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-accepted-1', - state: 'accepted', - providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, - fence: 0 - }) - await queueDraft(journal, 'withdrawn-1') - await journal.queuedMessages.withdraw({ - messageIds: ['withdrawn-1'], - settledByOp: 'c\u0000op-w' - }) - await queueDraft(journal, 'waiting-1') - await queueDraft(journal, 'returned-1') - await consumeDraft(journal, 'returned-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-returned-1', - state: 'rejected', - ...refusal('refused'), - fence: 0 - }) - await journal.close() - clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 - journal = await open() - expect(journal.queuedMessages.list().map((row) => [row.messageId, row.state])).toEqual([ - ['waiting-1', 'waiting'], - ['returned-1', 'returned'] - ]) - }) - - it('keeps fresh tombstones inside the replay window', async () => { - let journal = await open() - await queueDraft(journal, 'withdrawn-1') - await journal.queuedMessages.withdraw({ - messageIds: ['withdrawn-1'], - settledByOp: 'c\u0000op-w' - }) - await journal.close() - clock += 1_000 - journal = await open() - expect(journal.queuedMessages.get('withdrawn-1')?.state).toBe('withdrawn') - }) - }) - - describe('holds', () => { - it('a hold is stored on the row, survives reopen, and withdraw clears it', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) - expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed') - await journal.close() - journal = await open() - expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed') - await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'withdrawn', - holdReason: null - }) - }) - - it('consume clears the hold in the same transaction (Send-now overrides it)', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) - await consumeDraft(journal, 'draft-1') - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'dispatched', - holdReason: null - }) - }) - - it('a withdraw naming no drafts touches nothing — a Delete race with no rows costs no write', async () => { - const journal = await open() - await journal.close() - await expect( - journal.queuedMessages.withdraw({ messageIds: [], settledByOp: 'c\u0000op' }) - ).resolves.toEqual([]) - }) - - it('holds reach only waiting rows', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('refused'), - fence: 0 - }) - await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'returned', - holdReason: null - }) - }) - }) - - describe('the commit listener', () => { - it('draft-table writes fire it exactly when rows changed, so no caller publishes by hand', async () => { - const journal = await open() - let commits = 0 - journal.observeCommits(() => { - commits += 1 - }) - await queueDraft(journal, 'draft-1') - expect(commits).toBe(1) - // An idempotent replay changes nothing and stays silent. - await queueDraft(journal, 'draft-1') - expect(commits).toBe(1) - await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) - expect(commits).toBe(2) - await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) - expect(commits).toBe(2) - await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) - expect(commits).toBe(3) - await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op-2' }) - expect(commits).toBe(3) - }) - }) -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts index e291d69e647d..d99edc832d40 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-stored-row.ts @@ -1,38 +1,37 @@ -// Reads a `queued_messages` row back (`queued-message-table.ts`): a row this build cannot -// re-materialize is dropped, never shown as an empty message. +// Reads a `queued_messages` row an earlier build wrote (`journal-queue-import.ts`): a row this build +// cannot re-materialize is dropped, never shown as an empty message, and stays on disk. import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { readAgentJournalMessageBody } from '../../../shared/agent-session-journal-body-admission' import { readStoredRejectionFact } from './journal-dispatch-reducer' import type { QueuedMessageRow } from './queued-message-table' import { readAgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' -/** A `queued_messages` row as this file's SELECTs return it; null when it cannot be read back. */ +export const LEGACY_QUEUED_MESSAGE_COLUMNS = + 'message_id, position, body_json, fingerprint, state, hold_reason, returned_reason, returned_rejection, consumed_as, queued_epoch, queued_sequence, options_json' + +/** A `queued_messages` row as the legacy SELECTs return it; null when it cannot be read back, or + * its body could not be written to the journal. */ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | null { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: rows come from this file's own SELECTs, which name exactly these columns; better-sqlite3 types them as unknown. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: rows come from the legacy SELECTs (`LEGACY_QUEUED_MESSAGE_COLUMNS`), which name exactly these columns; better-sqlite3 types them as unknown. const record = row as { - session_id: string message_id: string position: number body_json: string fingerprint: string - created_at: number - host_instance: string state: string hold_reason: string | null returned_reason: string | null returned_rejection: string | null - settled_at: number | null - settled_by_op: string | null consumed_as: string | null - carried_from: string | null queued_epoch: string | null queued_sequence: number | null options_json: string | null } let body: AgentJournalMessageItem try { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: body_json is written only by insertQueuedMessage from a schema-validated AgentJournalMessageItem. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: checked by readAgentJournalMessageBody just below before any use. body = JSON.parse(record.body_json) as AgentJournalMessageItem } catch { // Our own writer stringified it; an unreadable body is corruption, and a @@ -41,29 +40,21 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul } const state = record.state if ( - state !== 'waiting' && - state !== 'dispatched' && - state !== 'returned' && - state !== 'withdrawn' + readAgentJournalMessageBody(body) !== 'readable' || + (state !== 'waiting' && state !== 'dispatched' && state !== 'returned' && state !== 'withdrawn') ) { return null } return { - sessionId: record.session_id, messageId: record.message_id, position: record.position, body, fingerprint: record.fingerprint, - createdAt: record.created_at, - hostInstance: record.host_instance, state, holdReason: storedHoldReason(record.hold_reason), returnedReason: record.returned_reason, returnedRejection: storedRejection(record.returned_rejection), - settledAt: record.settled_at, - settledByOp: record.settled_by_op, consumedAs: record.consumed_as, - carriedFrom: record.carried_from, queuedAt: record.queued_epoch !== null && typeof record.queued_sequence === 'number' ? { epoch: record.queued_epoch, sequence: record.queued_sequence } diff --git a/src/main/native-chat/agent-session-journal/queued-message-table.ts b/src/main/native-chat/agent-session-journal/queued-message-table.ts index 1a89cb6f5cca..3fde08c9895e 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-table.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-table.ts @@ -1,318 +1,59 @@ -// Host-owned draft rows for messages queued while the main agent is working. -// -// A queued message is NOT a journal row: it becomes one — an ordinary -// submission — only when consume converts it, in the same transaction as the -// submission's append. Until then it lives here, `session_id`-keyed so it -// survives epoch rollover and replacement (`journal-row-table.ts` deletes only -// `journal_rows`). After a refusal its text survives as a `returned` row a -// rewind cannot delete — a command refused in its own turn is spent instead, its -// turn's row saying why; after a withdrawal it waits again. +// A queued message as every reader sees it: a card the journal fold derives from the chat's own +// rows (`journal-queue-fold.ts`). It becomes an ordinary submission when it is handed off, and the +// hand-off's answer settles it: a refusal returns it, one Orca proves never reached the agent waits +// again, and anything else spends it. +// The `queued_messages` table earlier builds wrote is read only to bring its cards into the +// journal (`journal-queue-import.ts`). -import type Database from '../../sqlite/sync-database' import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentJournalCursor, AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../shared/agent-session-queued-message-wire' -import { rejectedDraftSettlement } from './journal-dispatch-settlement' -import { readStoredQueuedMessageRow } from './queued-message-stored-row' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' -/** Why ONE waiting draft is held from auto-sending: its conversion failed (`send_failed`). Stored - * on the row, so it survives handle eviction and restart; a wire marker (it publishes as - * `pausedReason`). A Stop, a /clear or a reopen pauses the queue instead. A reader treats an - * unknown stored value as a plain hold, and the holds earlier builds wrote that this one derives - * (`QUEUED_MESSAGE_RETIRED_HOLD_REASONS`) as none. */ +/** Why ONE waiting card is held from auto-sending: its conversion failed (`send_failed`). A wire + * marker (it publishes as `pausedReason`). A Stop, a /clear or a reopen pauses the queue instead. A + * reader treats an unknown value as a plain hold, and the holds earlier builds wrote that this one + * derives (`QUEUED_MESSAGE_RETIRED_HOLD_REASONS`) as none. */ export type QueuedMessageHoldReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED +/** Host-only: a card an earlier build put back to waiting whose hand-off may have reached the agent, + * edited or sent again since, so it waits for its own Send (`journal-queue-import.ts`). Published + * as a plain hold. */ +export const QUEUED_MESSAGE_HELD_DELIVERY_UNCONFIRMED = 'delivery_unconfirmed' + /** Definitively unsettled: what Stop, /clear, Edit and the published-bytes bound count, and * what the published list shows. Pending/unknown/accepted deliveries and - * tombstones stay outside it. */ + * withdrawn cards stay outside it. */ export function isUnsettledQueuedMessage(row: Pick): boolean { return row.state === 'waiting' || row.state === 'returned' } +/** A fold card; the name is the one readers have always used. */ export type QueuedMessageRow = { - sessionId: string messageId: string position: number body: AgentJournalMessageItem + /** `agentSessionSendBodyFingerprint` of `body`, derived, never stored. */ fingerprint: string - createdAt: number - hostInstance: string state: QueuedMessageState - /** Non-null holds this one waiting draft from auto-sending; typed values in + /** Non-null holds this one waiting card from auto-sending; typed values in * `QueuedMessageHoldReason`, unknown strings read as a plain hold. */ holdReason: string | null - /** A returned card's refusal, mirroring its submission's `reason` and `rejection` pair. */ + /** A returned card's refusal, its hand-off's `reason` and `rejection` pair. */ returnedReason: string | null returnedRejection: UnreadAgentSessionFailureFact | null - settledAt: number | null - /** The operation ledger's caller-scoped key, making settled rows mutation receipts. */ - settledByOp: string | null - /** The submission that last handed it off: set on every dispatched row, kept on a returned - * card, cleared when a withdrawal sends it back to waiting. Host-only; the published link is + /** The submission that last handed it off: set on every dispatched card, kept on a returned + * one, cleared when a withdrawal sends it back to waiting. Host-only; the published link is * the submission's `queuedMessageId`. */ consumedAs: string | null - /** Inert historical column; current inserts write null. */ - carriedFrom: string | null /** Where the journal stood when it was queued: a Stop's pause holds only cards queued before - * it. Null on rows from builds before it was recorded, which read as queued before any Stop. */ + * it. Null on cards from builds before it was recorded, which read as queued before any Stop. */ queuedAt: AgentJournalCursor | null /** The choices its send carried, applied to the turn it starts when it goes out. */ options?: AgentSessionExecutionOptions } - -const COLUMNS = - 'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as, carried_from, queued_epoch, queued_sequence, options_json' - -export function insertQueuedMessage( - db: Database.Database, - input: { - sessionId: string - messageId: string - body: AgentJournalMessageItem - fingerprint: string - hostInstance: string - queuedAt: AgentJournalCursor - now: number - /** Absent: after every other card. */ - position?: number - holdReason?: QueuedMessageHoldReason - options?: AgentSessionExecutionOptions - } -): QueuedMessageRow { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the statement selects exactly one aliased numeric column; better-sqlite3 types rows as unknown. - const highest = db - .prepare('SELECT COALESCE(MAX(position), 0) AS p FROM queued_messages WHERE session_id = ?') - .get(input.sessionId) as { p?: number } | undefined - const position = input.position ?? Number(highest?.p ?? 0) + 1 - db.prepare( - `INSERT INTO queued_messages (${COLUMNS}) - VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?)` - ).run( - input.sessionId, - input.messageId, - position, - JSON.stringify(input.body), - input.fingerprint, - input.now, - input.hostInstance, - input.holdReason ?? null, - null, - input.queuedAt.epoch, - input.queuedAt.sequence, - input.options ? JSON.stringify(input.options) : null - ) - return { - sessionId: input.sessionId, - messageId: input.messageId, - position, - body: input.body, - fingerprint: input.fingerprint, - createdAt: input.now, - hostInstance: input.hostInstance, - state: 'waiting', - holdReason: input.holdReason ?? null, - returnedReason: null, - returnedRejection: null, - settledAt: null, - settledByOp: null, - consumedAs: null, - carriedFrom: null, - queuedAt: input.queuedAt, - ...(input.options ? { options: input.options } : {}) - } -} - -export function listQueuedMessages(db: Database.Database, sessionId: string): QueuedMessageRow[] { - return db - .prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? ORDER BY position ASC`) - .all(sessionId) - .flatMap((row) => readStoredQueuedMessageRow(row) ?? []) -} - -export function getQueuedMessage( - db: Database.Database, - sessionId: string, - messageId: string -): QueuedMessageRow | null { - const row = db - .prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? AND message_id = ?`) - .get(sessionId, messageId) - return row === undefined ? null : readStoredQueuedMessageRow(row) -} - -/** - * The one waiting→dispatched (or returned→dispatched) transition, always under - * a fresh submission id — never the draft's own — so no reader can mistake id - * equality for the hand-off link. MUST run inside the caller's transaction — the journal - * writer's, between BEGIN IMMEDIATE and COMMIT — so a failed submission append - * rolls the consume back and a failed consume rolls the append back. Returns - * false when the draft was not in the expected state, in which case the caller - * throws to abort the append. - */ -export function consumeQueuedMessageInTransaction( - db: Database.Database, - input: { - sessionId: string - messageId: string - expect: 'waiting' | 'returned' - /** The body being sent: a card edited since it was read is not consumed with its old text. */ - fingerprint: string - /** The fresh submission id; never the draft's own id. */ - consumedAs: string - settledByOp: string | null - /** The handing-off process; absent keeps the row's own. */ - hostInstance?: string - now: number - } -): boolean { - if (input.consumedAs === input.messageId) { - return false - } - const changed = db - .prepare( - `UPDATE queued_messages - SET state = 'dispatched', hold_reason = NULL, returned_reason = NULL, returned_rejection = NULL, - settled_at = ?, settled_by_op = ?, consumed_as = ?, host_instance = COALESCE(?, host_instance) - WHERE session_id = ? AND message_id = ? AND state = ? AND fingerprint = ?` - ) - .run( - input.now, - input.settledByOp, - input.consumedAs, - input.hostInstance ?? null, - input.sessionId, - input.messageId, - input.expect, - input.fingerprint - ) - return Number(changed.changes ?? 0) === 1 -} - -/** Compare-and-transition unsettled rows (waiting ∪ returned) to withdrawn - * tombstones stamped with the operation's caller-scoped key, kept only so a - * replay of the settling operation answers "spent"; null when the host itself - * withdrew it. Returns the rows actually transitioned; their text stays in - * this database, never on the wire. */ -export function withdrawQueuedMessages( - db: Database.Database, - input: { - sessionId: string - messageIds: readonly string[] - settledByOp: string | null - now: number - } -): QueuedMessageRow[] { - const withdrawn: QueuedMessageRow[] = [] - for (const messageId of input.messageIds) { - const row = getQueuedMessage(db, input.sessionId, messageId) - if (!row || !isUnsettledQueuedMessage(row)) { - continue - } - db.prepare( - `UPDATE queued_messages - SET state = 'withdrawn', hold_reason = NULL, settled_at = ?, settled_by_op = ? - WHERE session_id = ? AND message_id = ? AND state IN ('waiting', 'returned')` - ).run(input.now, input.settledByOp, input.sessionId, messageId) - withdrawn.push({ - ...row, - state: 'withdrawn', - holdReason: null, - settledAt: input.now, - settledByOp: input.settledByOp - }) - } - return withdrawn -} - -/** - * dispatched → returned, or back to waiting (`rejectedDraftSettlement`), - * matched on the draft's CURRENT hand-off (`consumed_as`), so a re-send refused - * again still settles while a late duplicate of an earlier refusal matches - * nothing. A withdrawal keeps its position; a command refused in its own turn is spent. - */ -export function settleRejectedQueuedMessage( - db: Database.Database, - input: { - sessionId: string - consumedRef: string - reason: string | null - rejection: UnreadAgentSessionFailureFact | undefined - /** The refused submission's command turn exists, so its own row says why. */ - commandTurnReported: boolean - now: number - } -): boolean { - const settlement = rejectedDraftSettlement(input) - if ( - settlement.state === 'returned' && - input.commandTurnReported && - spendRefusedCommandCard(db, input) - ) { - return true - } - const changed = - settlement.state === 'waiting' - ? db - .prepare( - `UPDATE queued_messages - SET state = 'waiting', hold_reason = NULL, consumed_as = NULL, - returned_reason = NULL, returned_rejection = NULL, settled_at = NULL, settled_by_op = NULL - WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` - ) - .run(input.sessionId, input.consumedRef) - : db - .prepare( - `UPDATE queued_messages - SET state = 'returned', returned_reason = ?, returned_rejection = ?, settled_at = ? - WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?` - ) - .run( - input.reason, - input.rejection ? JSON.stringify(input.rejection) : null, - input.now, - input.sessionId, - input.consumedRef - ) - return Number(changed.changes ?? 0) > 0 -} - -/** - * A command card refused in its own turn is spent, not returned: that turn's row says why, once, - * and a returned card would hold every card behind it. Refused before any turn (a failed start), - * it is returned like any card; a restart or a close leaves it waiting under the queue's pause. - * False when the dispatched card is not a command. - */ -function spendRefusedCommandCard( - db: Database.Database, - input: { sessionId: string; consumedRef: string; now: number } -): boolean { - const spent = db - .prepare( - `UPDATE queued_messages SET state = 'withdrawn', settled_at = ? - WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ? - AND json_extract(body_json, '$.command') IS NOT NULL` - ) - .run(input.now, input.sessionId, input.consumedRef) - return Number(spent.changes ?? 0) > 0 -} - -/** Replay receipts: every row a given caller-scoped operation settled. */ -export function queuedMessagesSettledByOp( - db: Database.Database, - sessionId: string, - settledByOp: string -): QueuedMessageRow[] { - return db - .prepare( - `SELECT ${COLUMNS} FROM queued_messages - WHERE session_id = ? AND settled_by_op = ? ORDER BY position ASC` - ) - .all(sessionId, settledByOp) - .flatMap((row) => readStoredQueuedMessageRow(row) ?? []) -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-update.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-update.spec.ts index d80255422592..c9392b569cff 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-update.spec.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-update.spec.ts @@ -1,48 +1,41 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import Database from '../../sqlite/sync-database' -import { ensureQueuedMessagesTable } from './queued-message-schema' -import { getQueuedMessage, insertQueuedMessage } from './queued-message-table' -import { updateQueuedMessage } from './queued-message-update' +import { describe, expect, it } from 'vitest' +import { queuedMessageUpdateDecision } from './queued-message-update' +import type { QueuedMessageRow } from './queued-message-table' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { MAX_PROMPT_BYTES } from '../../../shared/rpc-contract/structured-agent-session-params' export function registerQueuedMessageUpdateTests(): void { - let db: Database.Database const sessionId = 'folder-session' const initial: AgentJournalMessageItem = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'base' }] } - beforeEach(() => { - db = new Database(':memory:') - ensureQueuedMessagesTable(db) - }) - afterEach(() => db.close()) - function insert(body = initial, messageId = 'card') { - return insertQueuedMessage(db, { - sessionId, - messageId, + function card(body = initial, state: QueuedMessageRow['state'] = 'waiting'): QueuedMessageRow { + return { + messageId: 'card', + position: 1, body, fingerprint: agentSessionSendBodyFingerprint(sessionId, body), - hostInstance: 'remote-host', - queuedAt: { epoch: 'epoch', sequence: 4 }, - now: 1, - holdReason: 'send_failed' - }) + state, + holdReason: null, + returnedReason: null, + returnedRejection: null, + consumedAs: null, + queuedAt: { epoch: 'epoch', sequence: 4 } + } } - function update(text: string, fingerprint = agentSessionSendBodyFingerprint(sessionId, initial)) { - return updateQueuedMessage( - db, - sessionId, - { messageId: 'card', expectedBodyFingerprint: fingerprint, text }, - () => {} - ) + function decide(row: QueuedMessageRow | null, text: string, base = row?.fingerprint ?? '') { + return queuedMessageUpdateDecision(row, sessionId, { + messageId: 'card', + expectedBodyFingerprint: base, + text + }) } - describe('atomic queued text update', () => { - it('preserves identity, cursor, order, sender, kind, role, send mode and attachment order', () => { + describe('queued text update decision', () => { + it('keeps sender, send mode and attachment order, replacing only the text', () => { const body: AgentJournalMessageItem = { ...initial, sentAs: 'goal', @@ -54,132 +47,71 @@ export function registerQueuedMessageUpdateTests(): void { { type: 'text', text: 'two' } ] } - const before = insert(body) - expect(update('edited', before.fingerprint).status).toBe('updated') - const after = getQueuedMessage(db, sessionId, 'card')! - expect(after).toEqual({ - ...before, + const decision = decide(card(body), 'edited') + expect(decision).toMatchObject({ + status: 'ready', body: { ...body, blocks: [body.blocks[0]!, { type: 'text', text: 'edited' }, body.blocks[2]!] - }, - fingerprint: after.fingerprint - }) - expect(after.fingerprint).toBe(agentSessionSendBodyFingerprint(sessionId, after.body)) - }) - it('saving the same text keeps several text blocks as they are', () => { - const body: AgentJournalMessageItem = { - ...initial, - blocks: [ - { type: 'text', text: 'one' }, - { type: 'text', text: 'two' } - ] - } - const before = insert(body) - expect(update('one\ntwo', before.fingerprint).status).toBe('unchanged') - expect(getQueuedMessage(db, sessionId, 'card')).toEqual(before) - }) - it('retains returned-card refusal and failure hold rather than requeueing', () => { - insert() - db.prepare( - "UPDATE queued_messages SET state = 'returned', returned_reason = 'refused', consumed_as = 'submission' WHERE message_id = 'card'" - ).run() - const before = getQueuedMessage(db, sessionId, 'card')! - expect(update('edit').status).toBe('updated') - expect(getQueuedMessage(db, sessionId, 'card')).toMatchObject({ - state: 'returned', - returnedReason: before.returnedReason, - consumedAs: before.consumedAs, - holdReason: before.holdReason, - position: before.position + } }) }) it('checks the resulting body before the expected base, making lost-answer retry safe', () => { - insert() - expect(update('edit').status).toBe('updated') - expect(update('edit').status).toBe('unchanged') - expect(update('other').status).toBe('changed') - }) - it('accepts semantic A/B/A and preserves the latest host-owned sender', () => { - insert() + const edited = card({ ...initial, blocks: [{ type: 'text', text: 'edit' }] }) const base = agentSessionSendBodyFingerprint(sessionId, initial) - update('B') - const second = getQueuedMessage(db, sessionId, 'card')! - update('base', second.fingerprint) - const from: NonNullable = { - kind: 'agent', - senders: [], - orchestration: null - } - const latestBody = { ...initial, from } - db.prepare("UPDATE queued_messages SET body_json = ? WHERE message_id = 'card'").run( - JSON.stringify(latestBody) - ) - expect(agentSessionSendBodyFingerprint(sessionId, latestBody)).toBe(base) - expect(update('C', base).status).toBe('updated') - expect(getQueuedMessage(db, sessionId, 'card')?.body.from).toEqual(from) + expect(decide(edited, 'edit', base).status).toBe('unchanged') + expect(decide(edited, 'other', base).status).toBe('changed') + expect(decide(card(), 'edit').status).toBe('ready') }) - it('refuses empty, oversized, command and unsupported bodies without modifying them', () => { - insert() - expect(update(' ').status).toBe('not-editable') - expect(update('é'.repeat(MAX_PROMPT_BYTES)).status).toBe('not-editable') - db.prepare('DELETE FROM queued_messages').run() - const command = insert({ ...initial, command: { name: 'compact' } }) - expect(update('edit', command.fingerprint).status).toBe('not-editable') - expect(getQueuedMessage(db, sessionId, 'card')).toEqual(command) - db.prepare('DELETE FROM queued_messages').run() - const unsupported = insert({ + it('refuses empty, oversized, command and unsupported bodies', () => { + expect(decide(card(), ' ').status).toBe('not-editable') + expect(decide(card(), 'é'.repeat(MAX_PROMPT_BYTES)).status).toBe('not-editable') + expect(decide(card({ ...initial, command: { name: 'compact' } }), 'edit').status).toBe( + 'not-editable' + ) + const unsupported = card({ ...initial, blocks: [{ type: 'tool-result', callId: 'tool', output: 'retained' }] }) - expect(update('edit', unsupported.fingerprint).status).toBe('not-editable') - expect(getQueuedMessage(db, sessionId, 'card')).toEqual(unsupported) + expect(decide(unsupported, 'edit').status).toBe('not-editable') }) it('allows empty text with a retained attachment, and inserts text before one with none', () => { - const before = insert({ + const captioned = card({ ...initial, blocks: [ { type: 'text', text: 'caption' }, { type: 'image-ref', path: 'file.png' } ] }) - expect(update('', before.fingerprint).status).toBe('updated') - expect(getQueuedMessage(db, sessionId, 'card')?.body.blocks).toEqual([ - { type: 'text', text: '' }, - { type: 'image-ref', path: 'file.png' } - ]) - const imageOnly = insert( - { ...initial, blocks: [{ type: 'image-ref', path: 'file.png' }] }, - 'image' - ) - expect( - updateQueuedMessage( - db, - sessionId, - { messageId: 'image', expectedBodyFingerprint: imageOnly.fingerprint, text: 'added' }, - () => {} - ).status - ).toBe('updated') - expect(getQueuedMessage(db, sessionId, 'image')?.body.blocks).toEqual([ - { type: 'text', text: 'added' }, - { type: 'image-ref', path: 'file.png' } - ]) - }) - it('a failed transaction rolls back body and fingerprint together', () => { - const before = insert() - db.exec('BEGIN IMMEDIATE') - update('edit') - db.exec('ROLLBACK') - expect(getQueuedMessage(db, sessionId, 'card')).toEqual(before) + expect(decide(captioned, '')).toMatchObject({ + body: { + blocks: [ + { type: 'text', text: '' }, + { type: 'image-ref', path: 'file.png' } + ] + } + }) + const imageOnly = card({ ...initial, blocks: [{ type: 'image-ref', path: 'file.png' }] }) + expect(decide(imageOnly, 'added')).toMatchObject({ + body: { + blocks: [ + { type: 'text', text: 'added' }, + { type: 'image-ref', path: 'file.png' } + ] + } + }) }) - it('dispatch, withdrawal and a pruned tombstone all answer gone', () => { - insert() - db.prepare("UPDATE queued_messages SET state = 'dispatched' WHERE message_id = 'card'").run() - expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'dispatched' }) - db.prepare("UPDATE queued_messages SET state = 'withdrawn' WHERE message_id = 'card'").run() - expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'withdrawn' }) - db.prepare('DELETE FROM queued_messages').run() - expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'missing' }) + it('a returned card stays editable; a dispatched, withdrawn or missing one answers gone', () => { + expect(decide(card(initial, 'returned'), 'edit').status).toBe('ready') + expect(decide(card(initial, 'dispatched'), 'edit')).toMatchObject({ + status: 'gone', + disposition: 'dispatched' + }) + expect(decide(card(initial, 'withdrawn'), 'edit')).toMatchObject({ + status: 'gone', + disposition: 'withdrawn' + }) + expect(decide(null, 'edit')).toMatchObject({ status: 'gone', disposition: 'missing' }) }) }) } diff --git a/src/main/native-chat/agent-session-journal/queued-message-update.ts b/src/main/native-chat/agent-session-journal/queued-message-update.ts index a12b5026c19d..d2141a497054 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-update.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-update.ts @@ -1,13 +1,8 @@ -import type Database from '../../sqlite/sync-database' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionQueuedMessageUpdateResult } from '../../../shared/agent-session-wire' import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { queuedMessageWithEdit, type QueuedMessageEdit } from '../../../shared/queued-message-edit' -import { - getQueuedMessage, - isUnsettledQueuedMessage, - type QueuedMessageRow -} from './queued-message-table' +import { isUnsettledQueuedMessage, type QueuedMessageRow } from './queued-message-table' export type QueuedMessageUpdate = QueuedMessageEdit & { messageId: string @@ -49,27 +44,3 @@ export function queuedMessageUpdateDecision( } return { status: 'ready', body: edited.body, previous: row.body, fingerprint } } - -/** Inside the journal's draft transaction; only body and fingerprint change, so the card keeps - * its id, position, state, holds and hand-off link. The new body's uploads are claimed in the - * same transaction (`claim` throws to roll the edit back when one it newly names is gone). */ -export function updateQueuedMessage( - db: Database.Database, - sessionId: string, - input: QueuedMessageUpdate, - claim: (body: AgentJournalMessageItem, previous: AgentJournalMessageItem) => void -): AgentSessionQueuedMessageUpdateResult { - const decision = queuedMessageUpdateDecision( - getQueuedMessage(db, sessionId, input.messageId), - sessionId, - input - ) - if (decision.status !== 'ready') { - return decision - } - claim(decision.body, decision.previous) - db.prepare( - 'UPDATE queued_messages SET body_json = ?, fingerprint = ? WHERE session_id = ? AND message_id = ?' - ).run(JSON.stringify(decision.body), decision.fingerprint, sessionId, input.messageId) - return { status: 'updated', messageId: input.messageId, fingerprint: decision.fingerprint } -} diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts index dc63bc0aebc7..991c3f5cc25f 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -64,9 +64,19 @@ export function projectJournalBatch(input: { ) continue } - if (row.kind === 'submission' || row.kind === 'dispatch') { - touchedClientMessageIds.add(row.clientMessageId) - touchedItemIds.add(agentJournalSubmissionKey(row.clientMessageId)) + // A card's row naming its hand-off can change how that send reads (`foldQueuedHandOffInDoubt`). + const attempt = + row.kind === 'queue' && row.change.op === 'queued' ? row.change.attempt : undefined + const clientMessageId = + row.kind === 'submission' || row.kind === 'dispatch' + ? row.clientMessageId + : attempt === undefined + ? undefined + : input.snapshot.submissions.find((submission) => submission.clientMessageId === attempt) + ?.clientMessageId + if (clientMessageId !== undefined) { + touchedClientMessageIds.add(clientMessageId) + touchedItemIds.add(agentJournalSubmissionKey(clientMessageId)) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 5f531e831f45..37bc72302e26 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -274,7 +274,8 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & body: AgentJournalMessageItem /** Immutable choices of this submission; adapters apply them only to this dispatch. */ executionOptions?: AgentSessionExecutionOptions - /** Host-local cancellation while choices are applied before prompt admission. */ + /** Host-local cancellation while choices are applied before prompt admission. An abort surfaces + * as the signal's own reason, unwrapped and only before the write: the host reads it as unwritten. */ signal?: AbortSignal fence: number /** Host clock on the submission row this send came from; the origin the turn diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index e49076f4882b..7ac66219011f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -42,6 +42,8 @@ let host: StructuredAgentSessionHost let adapter: ClaudeStructuredSessionAdapter let store: AgentSessionRecordStore let queued: string[] +/** Sends the CLI reports cancelled through `command_lifecycle` as it takes the interrupt. */ +let lifecycleCancelled: string[] let claude: ReturnType beforeEach(async () => { @@ -49,15 +51,26 @@ beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-claude-queued-stop-')) resetHostTestOperationIds() queued = [] + lifecycleCancelled = [] claude = fakeClaude({ replayUuid: null, initProof: 'session-start', // What the real CLI answers: cancel_queued cancels the queue, a plain interrupt keeps it. routes: { - interrupt: (params) => - params?.cancelQueued + interrupt: (params) => { + for (const commandUuid of lifecycleCancelled.splice(0)) { + claude.connections[0]!.handlers.onMessage?.({ + type: 'command_lifecycle', + command_uuid: commandUuid, + state: 'cancelled', + uuid: `lifecycle-${commandUuid}`, + session_id: PROVIDER_SESSION_ID + }) + } + return params?.cancelQueued ? { still_queued: [], cancelled: queued.splice(0) } : { still_queued: [...queued] } + } } }) const lifecycle: Promise[] = [] @@ -306,7 +319,8 @@ it('leaves a host-queued follow-up and a newer turn running when the Stop names expect(rows).not.toContain('The provider had already finished this turn.') }, 15_000) -it('a card sent now into the running turn comes back paused when Stop withdraws it, and is not sent again', async () => { +// The CLI's own report that it removed the send unrun is the proof a queued card needs to wait again. +it('a card sent now into the running turn comes back paused when Claude reports it cancelled unrun, and is not sent again', async () => { const connection = claude.connections[0]! const turnId = await openFirstTurn(connection) const body = hostTestMessage('And then this.') @@ -327,7 +341,7 @@ it('a card sent now into the running turn comes back paused when Stop withdraws expect(sentNow).toMatchObject({ ok: true }) // Folded into the running turn: Claude holds it until that turn ends. await eventually(() => expect(connection.sent).toHaveLength(2)) - queued.push(String(connection.sent.at(-1)!.uuid)) + lifecycleCancelled.push(String(connection.sent.at(-1)!.uuid)) const sends = async () => (await host.journalSnapshot(SESSION)).submissions .filter((entry) => entry.queuedMessageId === cardId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts index 1a88aa0ce86a..e71127d852bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts @@ -140,9 +140,7 @@ describe('rewind behind a clear boundary', () => { const target = agentJournalSubmissionKey(later) const newer = await journal().queuedMessages.insert({ messageId: 'queued-after-stop', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'newer card' }] }, - fingerprint: 'newer-card', - hostInstance: 'test' + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'newer card' }] } }) const originalPause = structuredQueuePauses(journal()).find( (entry) => entry.reason === 'stopped' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts index ebfb2beea3e1..631e41e901d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-version-recovery.test.ts @@ -3,7 +3,6 @@ import { AgentCliInstallationError } from '../agent-cli-installation-error' import { isStructuredAgentSessionStartFailureRow } from '../../../shared/structured-agent-session-start-failure-row-key' import { projectQueuedMessageCards } from '../../../renderer/src/components/native-chat/structured-agent-session-queued-cards' import { HOST_TEST_SESSION, hostTestMessage } from './structured-agent-session-host-test-data' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, eventually, @@ -67,8 +66,6 @@ it.each(['returned', 'held'] as const)( await journal.queuedMessages.insert({ messageId: id, body: hostTestMessage('retained instruction'), - fingerprint: 'retained-instruction', - hostInstance: structuredAgentSessionHostInstance(), holdReason: 'send_failed' }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 85738139603c..addbacfeb410 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -24,10 +24,7 @@ import { import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' -import { - markStructuredQueueReopen, - structuredAgentSessionHostInstance -} from './structured-agent-session-queued-pause' +import { markStructuredQueueReopen } from './structured-agent-session-queued-pause' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -118,11 +115,7 @@ export async function openStructuredAgentSessionConversationJournal( } try { // Before a Stop this open serves can withdraw one: a Stop never withdraws a card. - await holdUnsentSends(journal, { - fence, - hostInstance: structuredAgentSessionHostInstance(), - hold: { cause: 'hostRestarted' } - }) + await holdUnsentSends(journal, { fence, hold: { cause: 'hostRestarted' } }) } catch (error) { // The row stays queued. The delivery loop's first step tries again before it hands anything // over; if that fails too, the loop fails and rejects every queued send. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts index 8a2c77554395..80ab19b95962 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ended-prompt-cancel.test.ts @@ -74,9 +74,7 @@ async function endedCardAndLiveTurn(): Promise<{ journal: AgentSessionJournal; i await turn('turn-2', LIVE) await journal.queuedMessages.insert({ messageId: 'queued-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after this' }] }, - fingerprint: 'queued-1', - hostInstance: 'host-1' + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after this' }] } }) return { journal, itemId: card.itemId } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index 628d449cc4fe..7a5416cbdd3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts @@ -1,7 +1,6 @@ import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { StructuredAgentSessionLifecycleEvent } from './structured-agent-session-adapter' import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import { stopAgentSessionProviderRoot } from './structured-agent-session-provider-exit-proof' import type { StructuredAgentSessionHostDeps, @@ -69,12 +68,7 @@ export class StructuredAgentSessionEventRecovery { holdUnrunSends: async (sessionId, fence, cause) => { const journal = this.context.sessions.get(sessionId)?.journal if (journal) { - await holdUnsentSends(journal, { - fence, - hostInstance: structuredAgentSessionHostInstance(), - hold: { cause }, - unrun: true - }) + await holdUnsentSends(journal, { fence, hold: { cause }, unrun: true }) } }, route: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index 95bfaee26f9b..730aecce5df0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts @@ -8,7 +8,7 @@ import { import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-options' import type { StructuredAgentSessionRanRecorder } from './structured-agent-session-ran-options' import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' -import { abortSignalReason } from '../../../shared/abort-signal-reason' +import { isAbortSignalReason } from '../../../shared/abort-signal-reason' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import type { @@ -92,6 +92,7 @@ export async function beginSubmissionHandover( ctx.workChanged ) const attempt = ctx.acquireAborts?.begin(ctx.sessionId, true) + const queued = submission.queuedMessageId !== undefined try { await ctx.journal.resolveDispatch({ clientMessageId, @@ -117,8 +118,9 @@ export async function beginSubmissionHandover( dispatch: async () => { let outcome: AgentSessionDispatchOutcome | null try { + // Aborted before the call, Orca still held it: a queued card waits again. outcome = attempt?.signal.aborted - ? cancelledDispatch() + ? cancelledDispatch(queued) : // A child gone since the row: its end settles the row, and nothing reaches the wire. stillCurrent() ? await dispatchSafely( @@ -127,6 +129,7 @@ export async function beginSubmissionHandover( carried ? withStoppedHistory(body, carried.messages) : body, requestedAt, submission.options, + queued, attempt?.signal ) : null @@ -202,6 +205,7 @@ async function dispatchSafely( body: AgentJournalMessageItem, requestedAt: number, executionOptions: AgentSessionExecutionOptions | undefined, + queued: boolean, signal?: AbortSignal ): Promise { try { @@ -216,10 +220,12 @@ async function dispatchSafely( onInputPreparation: ctx.recordRanOptions?.prepareInput, onExecutionSnapshot: ctx.recordRanOptions?.checkpoint }) - return signal?.aborted && outcome.state === 'rejected' ? cancelledDispatch() : outcome + // The agent's own answer stands, aborted or not. + return outcome } catch (error) { - if (signal?.aborted && error === abortSignalReason(signal)) { - return cancelledDispatch() + // The signal reaches only an adapter's preparation, so its own reason proves nothing was written. + if (isAbortSignalReason(signal, error)) { + return cancelledDispatch(queued) } if (isAgentSessionOptionRejectedError(error)) { return { @@ -234,9 +240,14 @@ async function dispatchSafely( } } -function cancelledDispatch(): AgentSessionDispatchOutcome { +/** Only a queued card's hand-off records the proof; on a direct send it would owe the message as + * stopped history. */ +function cancelledDispatch(neverReachedAgent: boolean): AgentSessionDispatchOutcome { return { state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + ...agentSessionFailureWords( + agentSessionFailureFact('cancelled', neverReachedAgent ? { neverReachedAgent } : {}), + { surface: 'rejection' } + ) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 0d18d7f96974..51d545fe940e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -24,10 +24,7 @@ import type { } from './structured-agent-session-host-types' import type { StructuredAgentSessionChildExit } from './structured-agent-session-child-exit' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { - markStructuredQueueReopen, - structuredAgentSessionHostInstance -} from './structured-agent-session-queued-pause' +import { markStructuredQueueReopen } from './structured-agent-session-queued-pause' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import { @@ -88,7 +85,6 @@ export async function holdClosedStructuredAgentSessionSends( const { which } = close const settled = await holdUnsentSends(journal, { fence, - hostInstance: structuredAgentSessionHostInstance(), hold: { cause: 'chatClosed', ...(which ? { which } : {}) } }).then( (newest) => ({ ok: true, newest }), @@ -119,12 +115,7 @@ export async function holdRestartedStructuredAgentSessionSends( journal: StructuredAgentSessionHostSession['journal'], fence: number ): Promise { - const hostInstance = structuredAgentSessionHostInstance() - const settled = await holdUnsentSends(journal, { - fence, - hostInstance, - hold: { cause: 'hostRestarted' } - }) + const settled = await holdUnsentSends(journal, { fence, hold: { cause: 'hostRestarted' } }) if (settled !== null) { await markStructuredQueueReopen(sessionId, journal, fence, logger, settled + 1) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts index d926eee872bf..8a5391d2903e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts @@ -9,8 +9,10 @@ import { structuredAgentSessionConversationFence } from './structured-agent-sess import { whileStructuredAgentSessionHandingOver } from './structured-agent-session-handing-over' /** Settle a provider-proven outcome independently of an in-flight client mutation. `unknown` is - * released doubt: the provider let the send go unanswered, so nothing re-sends it. The send reads - * held until its row settles; a failed write leaves it unanswered, which clients learn at once. */ + * released doubt: the provider let the send go unanswered, so nothing re-sends it. `unrun`: the + * provider reported it never ran the send, which a queued card's hand-off records as proof, so + * the card waits again. A direct send keeps the plain refusal: there the proof would owe it as + * stopped history. */ export async function settleStructuredAgentSessionLateDispatch( context: StructuredAgentSessionMutationContext, input: { @@ -21,6 +23,7 @@ export async function settleStructuredAgentSessionLateDispatch( | ({ state: 'rejected' answeredInTurn?: AgentJournalAnsweredTurnIdentity + unrun?: true } & AgentJournalDispatchRejection) | { state: 'unknown'; reason: string } ) @@ -30,6 +33,11 @@ export async function settleStructuredAgentSessionLateDispatch( return } const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) + const unrunCard = + 'state' in input && + input.state === 'rejected' && + input.unrun === true && + session.journal.submission(input.clientMessageId)?.queuedMessageId !== undefined const settlement: ResolveDispatchInput = 'providerIdentity' in input ? { @@ -50,7 +58,9 @@ export async function settleStructuredAgentSessionLateDispatch( clientMessageId: input.clientMessageId, state: 'rejected', reason: input.reason, - rejection: input.rejection, + rejection: unrunCard + ? { ...input.rejection, neverReachedAgent: true } + : input.rejection, ...(input.answeredInTurn ? { answeredInTurn: input.answeredInTurn } : {}), fence } 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 78c59a80dfbb..146822ac4a6e 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 @@ -5,9 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { holdLane } from './structured-agent-session-delivery-hold.test-fixture' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { @@ -175,25 +173,6 @@ describe('Send now', () => { }) }) - 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') @@ -260,7 +239,7 @@ describe('Send now', () => { expect(await rig.handoff(draftId)).toBeUndefined() }) - it('answers a retry after a rewind dropped its hand-off as spent, and sends nothing again', async () => { + it('answers a retry after a rewind dropped its hand-off as lost, and never sends under that id again', async () => { await rig.workingSend() const draftId = await queuedDraft('rewound away') const id = opId() @@ -270,7 +249,10 @@ describe('Send now', () => { ok: false, refusal: { code: 'agent_session_operation_unknown', details: { reason: 'resultLost' } } }) - expect(await rig.handoff(draftId)).toBeUndefined() + // The dropped hand-off was never handed over, so its card waits again; it never goes out + // under the retried id. + const { submissions } = await rig.host.journalSnapshot(SESSION) + expect(submissions.some((entry) => entry.clientMessageId === id)).toBe(false) }) }) @@ -634,12 +616,9 @@ describe('source-bound ordinary Continue', () => { const current = await limitedTurn('current') const release = holdLane(rig.host, SESSION) try { - const body = hostTestMessage('backlog') const row = await journal.queuedMessages.insert({ messageId: opId(), - body, - fingerprint: agentSessionSendBodyFingerprint(SESSION, body), - hostInstance: structuredAgentSessionHostInstance() + body: hostTestMessage('backlog') }) journal.queuedMessages.editLeases.acquire( { callerKey: CALLER.callerKey, editId: 'edit' }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts index 9e41458574e1..4044171bae24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-card-holds.test.ts @@ -22,10 +22,7 @@ import { readQueuePublication, structuredQueueSendGate } from './structured-agent-session-queued-publication' -import { - structuredAgentSessionHostInstance, - structuredQueuePauses -} from './structured-agent-session-queued-pause' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { HOST_TEST_SESSION, @@ -547,9 +544,7 @@ describe('where the host would refuse the send', () => { const card = 'left-behind' await journal.queuedMessages.insert({ messageId: card, - body: hostTestMessage('queued after clear'), - fingerprint: 'fp-left-behind', - hostInstance: structuredAgentSessionHostInstance() + body: hostTestMessage('queued after clear') }) const record = rig.store.getRecord(HOST_TEST_SESSION) if (!record) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts index 3d085f823576..bef81cec1baa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-edit.test.ts @@ -190,9 +190,7 @@ describe('saving a queued card in place', () => { const body = { ...hostTestMessage('/compact'), command: { name: 'compact' } } const command = await journal().queuedMessages.insert({ messageId: 'command-card', - body, - fingerprint: agentSessionSendBodyFingerprint(HOST_TEST_SESSION, body), - hostInstance: 'host' + body }) expect(await hold(command.messageId)).toEqual({ status: 'not-editable' }) expect(await update(command.messageId, command.fingerprint, 'text')).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-legacy-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-legacy-import.test.ts new file mode 100644 index 000000000000..87928d5fa242 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-legacy-import.test.ts @@ -0,0 +1,368 @@ +// An earlier build kept queued cards in their own table. They move into the chat's journal: the +// queue reads each the same before and after, each action moves only its own card, and the bulk +// import runs once per open, all-or-nothing, until no readable legacy row is left. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import { activeProviderContext } from '../../../shared/agent-session-provider-context' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' +import { structuredAgentSessionCommandTurnIdentity } from '../../../shared/structured-agent-session-command-turn-identity' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + HOST_TEST_SESSION as SESSION, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: true, rewind: async () => ({ ok: true }) }) +}) + +afterEach(() => rig.dispose()) + +const db = () => openTestJournalHostDatabase(rig.root).db +const message = (text: string): AgentJournalMessageItem => ({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text }], + ...(text === '/compact' ? { command: { name: 'compact' } } : {}) +}) +const words = (fact: 'providerRejected' | 'cancelled') => + agentSessionFailureWords( + fact === 'providerRejected' + ? agentSessionFailureFact(fact, { detail: { text: 'refused', audience: 'person' } }) + : agentSessionFailureFact(fact), + { surface: 'rejection' } + ) + +function journalFor(sessionId: string): AgentSessionJournal { + return new AgentSessionJournal({ + database: openTestJournalHostDatabase(rig.root), + identity: { + sessionId, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: activeProviderContext(rig.store.getRecord(SESSION)!).head!.handle + } + }) +} + +/** An earlier build's hand-off of `card`, answered `as`. */ +async function handOff( + journal: AgentSessionJournal, + attempt: string, + card: string, + text: string, + as: 'providerRejected' | 'cancelled' | 'accepted', + fence = 0 +): Promise { + const body = message(text) + const payloadFingerprint = agentSessionSendBodyFingerprint(journal.queuedMessages.sessionId, body) + await journal.appendSubmission({ + clientMessageId: attempt, + payloadFingerprint, + body, + fence, + handoverRecorded: true, + queuedMessageId: card + }) + await journal.resolveDispatch({ + clientMessageId: attempt, + state: 'pending', + fence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.resolveDispatch( + as === 'accepted' + ? { clientMessageId: attempt, state: 'accepted', providerIdentity: null, fence } + : { clientMessageId: attempt, state: 'rejected', ...words(as), fence } + ) +} + +type Legacy = [id: string, state: string, text: string | null, consumedAs?: string, hold?: string] + +function seedLegacy(sessionId: string, rows: readonly Legacy[]): void { + const insert = db().prepare( + `INSERT INTO queued_messages (session_id, message_id, position, body_json, fingerprint, + created_at, host_instance, state, hold_reason, returned_reason, consumed_as) + VALUES (?, ?, ?, ?, ?, 0, 'earlier', ?, ?, ?, ?)` + ) + rows.forEach(([id, state, text, consumedAs, hold], index) => { + const body = text === null ? '{' : JSON.stringify(message(text)) + const print = text === null ? '' : agentSessionSendBodyFingerprint(sessionId, message(text)) + const reason = state === 'returned' ? 'refused before' : null + insert.run( + sessionId, + id, + index + 1, + body, + print, + state, + hold ?? null, + reason, + consumedAs ?? null + ) + }) +} + +const legacyIds = (sessionId = SESSION) => + db() + .prepare('SELECT message_id AS id FROM queued_messages WHERE session_id = ? ORDER BY position') + .all(sessionId) + .map((row) => (typeof row === 'object' && row !== null && 'id' in row ? row.id : null)) + +/** Each queue row on disk: its card, op and whether it saved a refusal. */ +function queueRows(sessionId = SESSION): string[] { + return db() + .prepare('SELECT row_json FROM journal_rows WHERE session_id = ? ORDER BY seq') + .all(sessionId) + .flatMap((row) => { + const json = typeof row === 'object' && row !== null && 'row_json' in row ? row.row_json : '' + const parsed: unknown = JSON.parse(String(json)) + if (typeof parsed !== 'object' || parsed === null || !('kind' in parsed)) { + return [] + } + if (parsed.kind !== 'queue' || !('messageId' in parsed) || !('change' in parsed)) { + return [] + } + const change = JSON.stringify(parsed.change) + const op = /"op":"(\w+)"/.exec(change)?.[1] + return [`${String(parsed.messageId)}:${op}${change.includes('"returned"') ? '+refusal' : ''}`] + }) +} + +async function nothingSentTo(...cards: string[]): Promise { + await new Promise((resolve) => setTimeout(resolve, 100)) + for (const card of cards) { + expect(await rig.handoff(card)).toBeUndefined() + } +} + +it("moves an earlier build's cards into the journal, each action its own card, the rest once per open", async () => { + // An earlier build's chat: its hand-offs are journal rows, its cards rows of their own table. + await rig.drafts() + await rig.host.close(SESSION, 'evict') + const earlier = journalFor(SESSION) + await earlier.open() + await handOff(earlier, 'att-present', 'ret-present', 'returned here', 'providerRejected') + await handOff( + earlier, + 'att-refused', + 'disp-refused', + 'refused, never settled', + 'providerRejected' + ) + // Stopped after hand-over, which that build sent back to waiting: the agent may have them. + await handOff(earlier, 'att-stopped', 'stopped-same', 'stopped, unchanged', 'cancelled') + await handOff(earlier, 'att-edited', 'stopped-edited', 'stopped, then edited', 'cancelled') + await earlier.close() + seedLegacy(SESSION, [ + ['ret-present', 'returned', 'returned here, edited', 'att-present'], + ['ret-gone', 'returned', 'returned in a lost epoch', 'att-gone'], + ['cmd-gone', 'returned', '/compact', 'att-cmd'], + ['disp-refused', 'dispatched', 'refused, never settled', 'att-refused'], + ['stopped-same', 'waiting', 'stopped, unchanged'], + ['stopped-edited', 'waiting', 'stopped, then edited, changed'], + ['orphan', 'dispatched', 'its hand-off is gone', 'att-orphan'], + ['edited', 'waiting', 'waiting, edited'], + ['held', 'waiting', 'its send failed', undefined, 'send_failed'], + ['gone', 'withdrawn', 'deleted'], + ['broken', 'waiting', null] + ]) + const all = legacyIds() + + // The first open's import fails: nothing moves, and the view reads every card as it will. + db().exec(`CREATE TEMP TRIGGER fail_import BEFORE DELETE ON queued_messages + BEGIN SELECT RAISE(ABORT, 'disk full'); END`) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const published = [ + { messageId: 'ret-present', state: 'returned' }, + { messageId: 'ret-gone', state: 'returned' }, + { messageId: 'cmd-gone', state: 'returned' }, + { messageId: 'disp-refused', state: 'returned' }, + { messageId: 'stopped-edited', state: 'waiting', paused: true }, + { messageId: 'edited', state: 'waiting' }, + { messageId: 'held', state: 'waiting', paused: true } + ] + expect(await rig.drafts()).toEqual(published) + const stopped = async () => { + const { items, submissions } = await rig.host.journalSnapshot(SESSION) + return projectStructuredAgentSessionMessages(items, [], submissions).find( + (entry) => entry.id === agentJournalSubmissionKey('att-stopped') + ) + } + const live = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + const rawStopped = structuredClone(live.submission('att-stopped')) + const displayedStopped = await stopped() + expect(displayedStopped).toMatchObject({ + stoppedBeforeStart: true, + blocks: [{ type: 'text', text: 'stopped, unchanged' }] + }) + expect(live.submission('att-stopped')).toEqual(rawStopped) + await nothingSentTo('stopped-same') + db().exec('DROP TRIGGER fail_import') + expect(warned).toHaveBeenCalledWith( + '[journal-open] queued-message import skipped:', + expect.objectContaining({ sessionId: SESSION }) + ) + warned.mockRestore() + expect(legacyIds()).toEqual(all) + expect(queueRows()).toEqual([]) + + // Delete and Send each move only their own card, in the action's transaction. + const deletion = hostTestOperationId() + expect(await rig.deleteQueued('ret-present', deletion)).toMatchObject({ + value: { deleted: true } + }) + const sent = await rig.sendNow('ret-gone') + expect(sent).toMatchObject({ ok: true, value: { submission: { queuedMessageId: 'ret-gone' } } }) + expect(queueRows()).toEqual([ + 'ret-present:queued', + 'ret-present:withdrawn', + 'ret-gone:queued+refusal' + ]) + expect(legacyIds()).toEqual(all.filter((id) => id !== 'ret-present' && id !== 'ret-gone')) + // The returned cards ahead still hold the queue. + await nothingSentTo('edited', 'held') + + // The next open moves the rest in one transaction: answered cards write nothing, and a refusal + // is written only where its hand-off is not in this epoch. + await rig.restartHostProcess() + expect(await rig.drafts()).toEqual(published.slice(2)) + expect(legacyIds()).toEqual(['broken']) + expect(queueRows().slice(3)).toEqual([ + 'cmd-gone:queued+refusal', + 'disp-refused:queued', + 'stopped-same:queued', + 'stopped-edited:queued', + 'orphan:queued', + 'edited:queued', + 'held:queued' + ]) + // Unchanged, it is spent on that hand-off: drawn as the person's send its Stop took back. + expect(await stopped()).toEqual(displayedStopped) + + // The command card's own turn reports its refusal later: it is spent, not left holding the queue. + const adopted = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + await adopted.appendItem( + structuredAgentSessionCommandTurnIdentity('att-cmd'), + { kind: 'status', text: 'Compact failed' }, + { + fence: rig.store.getRecord(SESSION)!.lease.runtimeFence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + expect(await rig.drafts()).toEqual(published.slice(3)) + expect(await rig.deleteQueued('disp-refused')).toMatchObject({ value: { deleted: true } }) + await rig.resume() + await eventually(async () => expect(await rig.handoff('edited')).toBeDefined()) + await nothingSentTo('held') + expect((await rig.handoff('stopped-edited'))?.clientMessageId).toBe('att-edited') + // Edited since, it waits for its own Send. + expect(await rig.sendNow('stopped-edited')).toMatchObject({ ok: true }) + const resent = await rig.handoffId('stopped-edited') + expect(resent).not.toBe('att-edited') + await rig.settleAccepted(resent, 'resent') + + // A third open finds nothing to move, and an old Delete still answers from its receipt. + const rows = queueRows() + await rig.restartHostProcess() + await rig.drafts() + expect(queueRows()).toEqual(rows) + expect(await stopped()).toEqual(displayedStopped) + expect(legacyIds()).toEqual(['broken']) + expect(await rig.deleteQueued('ret-present', deletion)).toMatchObject({ + replayed: true, + value: { deleted: true } + }) + + // A chat holding only answered legacy rows has nothing to write: they go in a transaction alone. + const other = journalFor('session-y') + await other.open() + await handOff(other, 'y-att', 'y-sent', 'delivered', 'accepted') + await other.close() + seedLegacy('session-y', [ + ['y-gone', 'withdrawn', 'deleted'], + ['y-sent', 'dispatched', 'delivered', 'y-att'] + ]) + const reopened = journalFor('session-y') + await reopened.open() + expect(legacyIds('session-y')).toEqual([]) + expect(queueRows('session-y')).toEqual([]) + expect(reopened.queuedMessages.list()).toEqual([]) + await reopened.close() +}) + +it('a rewind while the import has failed moves the legacy cards first: a refused one stays returned', async () => { + // Made before the clear, which leaves the chat no provider handle to read. + const earlier = journalFor(SESSION) + const clear = { command: 'clear' as const } + expect( + await rig.host.conversationCommand(CALLER, { + ...clear, + envelope: rig.envelope(clear, 'agentSession.conversationCommand', hostTestOperationId()) + }) + ).toMatchObject({ ok: true }) + const target = rig.send('rewound away') + expect(await target.result).toMatchObject({ ok: true }) + await eventually(async () => + expect((await rig.submission(target.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(target.id, 'target') + await rig.host.close(SESSION, 'evict') + await earlier.open() + const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence + await handOff( + earlier, + 'att-refused', + 'disp-refused', + 'refused, never settled', + 'providerRejected', + fence + ) + await earlier.close() + seedLegacy(SESSION, [ + ['disp-refused', 'dispatched', 'refused, never settled', 'att-refused'], + ['edited', 'waiting', 'waiting, edited'] + ]) + db().exec(`CREATE TEMP TRIGGER fail_import BEFORE DELETE ON queued_messages + BEGIN SELECT RAISE(ABORT, 'disk full'); END`) + const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const held = [ + { messageId: 'disp-refused', state: 'returned' }, + { messageId: 'edited', state: 'waiting' } + ] + expect(await rig.drafts()).toEqual(held) + db().exec('DROP TRIGGER fail_import') + warned.mockRestore() + + const live = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + const rewound = { itemId: agentJournalSubmissionKey(target.id), expectedEpoch: live.epoch } + expect( + await rig.host.rewind(CALLER, { + ...rewound, + envelope: rig.envelope(rewound, 'agentSession.rewind', hostTestOperationId()) + }) + ).toMatchObject({ ok: true }) + expect(live.epoch).toBe(rewound.expectedEpoch) + expect(live.submission('att-refused')).toBeUndefined() + expect(await rig.drafts()).toEqual(held) + await nothingSentTo('edited') + expect(legacyIds()).toEqual([]) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 4b5a5b7fefca..e6c3489d7544 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -20,7 +20,6 @@ import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps } from './structured-agent-session-host' -import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -238,7 +237,6 @@ export async function createQueuedMessageTestRig( /** A host process that dies with no close: a new host opens the same state directory. */ function crashRestartHostProcess(): void { - rotateStructuredAgentSessionHostInstanceForTests() host = makeHost() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index 11a09ab7c012..2bb9bfbae2ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -17,10 +17,7 @@ import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agen import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { JournalStopAcceptor } from '../agent-session-journal/journal-stop-acceptance' import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' -import { - rotateStructuredAgentSessionHostInstanceForTests, - structuredQueuePauses -} from './structured-agent-session-queued-pause' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, eventually, @@ -167,8 +164,8 @@ describe('drain', () => { it('takes no serialized drain step while the session is working, then drains when the work settles', async () => { const working = await workingSend() - // Read only by a drain step, so it marks one. - const step = vi.spyOn(JournalQueuedMessages.prototype, 'deliveredByEchoOwed') + // Read only by a hand-off, which only a drain step makes here. + const step = vi.spyOn(JournalQueuedMessages.prototype, 'assertConsumable') const queued = await send('waits for the turn', 'queue-if-active').result await send('and another', 'queue-if-active').result if (!queued.ok || !('queued' in queued.value)) { @@ -221,32 +218,6 @@ describe('drain', () => { ) }) - it('a skipped settlement hook heals on the next drain step, not only at the next open', async () => { - const working = await workingSend() - const queued = await send('refused while the hook fails', 'queue-if-active').result - if (!queued.ok || !('queued' in queued.value)) { - throw new Error('expected a queued receipt') - } - const draftId = queued.value.queued.messageId - await settleAccepted(working, 'a') - await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined()) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const hook = vi - .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') - .mockImplementationOnce(() => { - throw new Error('bookkeeping failed') - }) - try { - await rig.settleRejected(await rig.handoffId(draftId), 'provider refused this payload') - await eventually(async () => - expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) - ) - } finally { - hook.mockRestore() - warn.mockRestore() - } - }) - it('a waiting draft behind a returned card does not drain until the card is acted on (S5)', async () => { const working = await workingSend() const first = await send('to be refused', 'queue-if-active').result @@ -343,9 +314,8 @@ describe('held drafts', () => { expect(page.ok && page.page.queuedMessages?.[0]?.pausedReason).toBe( QUEUED_MESSAGE_PAUSED_SEND_FAILED ) - // The marker is stored on the row, so a host restart keeps "Couldn't send" - // instead of downgrading the card to a plain pause. - rotateStructuredAgentSessionHostInstanceForTests() + // The marker is a journal row, so a reread keeps "Couldn't send" instead of downgrading the + // card to a plain pause. const restarted = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(restarted.ok && restarted.page.queuedMessages?.[0]?.pausedReason).toBe( QUEUED_MESSAGE_PAUSED_SEND_FAILED diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index fcd1a624ddbb..c1b4f052fe67 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -14,7 +14,6 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' -import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation' import { queuedMessagesPublishedBytesRefusal } from './structured-agent-session-queued-published-bytes' @@ -28,10 +27,7 @@ import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { QueuedMessageNotConsumableError } from '../agent-session-journal/queued-message-consume-error' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' -import { - structuredAgentSessionHostInstance, - structuredQueuePauses -} from './structured-agent-session-queued-pause' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { nextSendableQueuedCard } from '../agent-session-journal/queued-message-pause' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { agentSessionAttachmentExpiredRefusal } from './structured-agent-session-turns' @@ -220,9 +216,6 @@ export async function maybeQueueStructuredAgentSessionSend( { messageId: clientMessageId, body: params.body, - // In the session that will send it: the reducer aliases the provider's echo by exactly this. - fingerprint: agentSessionSendBodyFingerprint(ctx.sessionId, params.body), - hostInstance: structuredAgentSessionHostInstance(), // Applied when the card goes out, to the turn it starts. options: captured.options, ...(params.userSend ? { requireAttachments: true } : {}) @@ -288,9 +281,8 @@ export class StructuredAgentSessionQueuedMessageDrain { // and each schedules again; the step re-reads every gate. try { if ( - !journal.queuedMessages.settlementOwed() && - (oldestActionableQueuedMessage(journal, true) === null || - this.deps.currentWork(sessionId)?.working() !== false) + oldestActionableQueuedMessage(journal, true) === null || + this.deps.currentWork(sessionId)?.working() !== false ) { return } @@ -323,16 +315,6 @@ export class StructuredAgentSessionQueuedMessageDrain { return } const journal = session.journal - if (journal.queuedMessages.settlementOwed() || journal.queuedMessages.deliveredByEchoOwed()) { - // A live per-row hook was skipped; heal now, before a draft sends, rather than at reopen. - await journal.queuedMessages.settleOwed().catch((error: unknown) => { - this.deps.logger.warn('settling owed queued-message bookkeeping failed', { - scope: 'queued-settle-owed', - sessionId, - error - }) - }) - } const fence = this.deps.conversationFence(sessionId) // Whatever clears a hold publishes or commits, which re-derives this step. const record = this.deps.getRecord(sessionId) @@ -355,13 +337,7 @@ export class StructuredAgentSessionQueuedMessageDrain { fence, handoverRecorded: true }, - { - messageId: next.messageId, - expect: 'waiting', - settledByOp: null, - hostInstance: structuredAgentSessionHostInstance(), - yieldsToPause: true - }, + { messageId: next.messageId, expect: 'waiting', yieldsToPause: true }, this.deps.submissionReceipt?.(sessionId) ) } catch (error) { @@ -369,10 +345,9 @@ export class StructuredAgentSessionQueuedMessageDrain { // Lost a race with a Send-now, a Delete or a Stop; their transition stands. return } - // Pre-consume failure: the draft stays waiting, held with the marker on - // the card (a stored fact, so it survives eviction and restart). The - // hold's own commit notification publishes it. An explicit Send retries; - // no automatic retry loop. + // The hand-off was not written: the card stays waiting, held with the marker on it (a + // journal row, so it survives eviction and restart). The hold's own commit publishes it. + // An explicit Send retries; no automatic retry loop. await journal.queuedMessages .hold({ messageIds: [next.messageId], reason: QUEUED_MESSAGE_PAUSED_SEND_FAILED }) .catch(() => {}) 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 b10c1ef734a8..ba561e2408b1 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,12 +1,11 @@ // `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 +// the conversation's queued cards. Each action's command receipt commits with the journal row it +// writes, so a lost acknowledgement replays from that receipt and the row it points at, never // from the operation ledger. No mutation returns draft text: // the published list is the one authority a client renders. import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' import type { AgentSessionMutationEnvelope, AgentSessionMutationResult, @@ -14,21 +13,15 @@ import type { AgentSessionQueuedMessagesResumeResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { QueuedMessageNotConsumableError } from '../agent-session-journal/queued-message-consume-error' import { refusingNewerOrcaJournal } from '../agent-session-journal/journal-open-failure' -import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' import { journalRowReceiptResult, 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, - structuredAgentSessionHostInstance -} from './structured-agent-session-queued-pause' +import { resumeStructuredQueue } from './structured-agent-session-queued-pause' import { mutateStructuredAgentSession, type StructuredAgentSessionMutationContext @@ -65,28 +58,6 @@ function submissionRowReceipt( 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. */ -export async function withdrawQueuedMessagesForOperation( - journal: AgentSessionJournal, - input: { - sessionId: string - messageIds: readonly string[] - callerKey: string - operationId: string - }, - receipt?: JournalOperationReceipt -): Promise { - 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 * conversation opened for the write. */ export function mutateQueued( @@ -172,12 +143,7 @@ export function sendQueuedStructuredAgentMessage( fence: ctx.fence, handoverRecorded: true }, - { - messageId, - expect: row.state, - settledByOp: agentSessionOperationKey(ctx.resolvedBy, operationId), - hostInstance: structuredAgentSessionHostInstance() - }, + { messageId, expect: row.state }, ctx.operationReceipt ) } catch (error) { @@ -215,7 +181,6 @@ export function deleteQueuedStructuredAgentMessage( params: { envelope: AgentSessionMutationEnvelope; messageId: string } ): Promise> { const { messageId } = params - const operationId = params.envelope.clientOperationId const plan: MutationPlan = { method: 'agentSession.queuedMessageDelete', fields: { messageId }, @@ -242,19 +207,9 @@ export function deleteQueuedStructuredAgentMessage( if (row.state === 'withdrawn') { return { ok: true, value: { deleted: false, messageId, disposition: 'withdrawn' } } } - // 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 - }, - ctx.operationReceipt - ) + // The withdrawal's row notifies through the journal's commit listener, which also + // re-derives the drain — deleting a returned card can unblock the cards behind it. + const withdrawn = await ctx.journal.queuedMessages.withdraw([messageId], ctx.operationReceipt) return withdrawn.length > 0 ? { ok: true, value: { deleted: true, messageId } } : { ok: true, value: { deleted: false, messageId, disposition: 'withdrawn' } } 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 9dd963bb43bf..ce1bc5fe9f18 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 @@ -2,27 +2,11 @@ // (`queued-message-pause.ts`), never stored. A Stop's event, a Resume and a reopen that found // waiting cards are journal rows; an explicit Resume lifts any pause. -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 - * `runtimeId` (`orca-runtime-runtime-id.ts`), stamped on the cards this process writes or hands - * off. No pause reads it: an older build holds every card another instance wrote. */ -let hostInstance = randomUUID() - -export function structuredAgentSessionHostInstance(): string { - return hostInstance -} - -/** A new stamp, as a new host process mints. Tests only; no pause reads it. */ -export function rotateStructuredAgentSessionHostInstanceForTests(): string { - hostInstance = randomUUID() - return hostInstance -} - type PauseJournal = Pick /** The queue's pauses in force, derived; none when the queue sends on its own. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-refused-card.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-refused-card.test.ts new file mode 100644 index 000000000000..8a94be5687f0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-refused-card.test.ts @@ -0,0 +1,141 @@ +// A refused queued card comes back as a returned card that holds the cards behind it, read from the +// chat's own rows with no card write of its own: through a rewind that retires its refused hand-off, +// a new epoch, a restart and a Resume. Only its own Send and Delete act on it. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + HOST_TEST_SESSION as SESSION, + hostTestOperationId +} from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig +const rewind = vi.fn>() + +beforeEach(async () => { + rewind.mockReset().mockResolvedValue({ ok: true }) + rig = await createQueuedMessageTestRig({ restartable: true, rewind }) +}) + +afterEach(() => rig.dispose()) + +const journal = () => rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +/** The chat's queue rows on disk, in order. */ +function queueRows(): { messageId: string; op: string }[] { + return openTestJournalHostDatabase(rig.root) + .db.prepare('SELECT row_json FROM journal_rows WHERE session_id = ? ORDER BY seq') + .all(SESSION) + .flatMap((row) => { + const parsed: unknown = + typeof row === 'object' && row !== null && 'row_json' in row + ? JSON.parse(String(row.row_json)) + : null + return typeof parsed === 'object' && + parsed !== null && + 'kind' in parsed && + parsed.kind === 'queue' && + 'messageId' in parsed && + 'change' in parsed && + typeof parsed.change === 'object' && + parsed.change !== null && + 'op' in parsed.change + ? [{ messageId: String(parsed.messageId), op: String(parsed.change.op) }] + : [] + }) +} + +/** Lets the drain run its steps; none may hand these cards off. */ +async function expectNothingSent(...draftIds: string[]): Promise { + await new Promise((resolve) => setTimeout(resolve, 100)) + for (const draftId of draftIds) { + expect(await rig.handoff(draftId)).toBeUndefined() + } +} + +async function sendTurn(text: string, turn: string): Promise { + const sent = rig.send(text) + expect(await sent.result).toMatchObject({ ok: true }) + await eventually(async () => expect((await rig.submission(sent.id))?.handedOverAt).toBeDefined()) + await rig.settleAccepted(sent.id, turn) + return agentJournalSubmissionKey(sent.id) +} + +it('holds the cards behind it through a rewind, a new epoch, a restart and a Resume, until its own Send or Delete', async () => { + const fields = { command: 'clear' as const } + expect( + await rig.host.conversationCommand(CALLER, { + ...fields, + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()) + }) + ).toMatchObject({ ok: true }) + const target = await sendTurn('rewound away', 'target') + const working = await rig.workingSend() + const [a, b, c] = [await queuedDraft('A'), await queuedDraft('B'), await queuedDraft('C')] + await rig.settleAccepted(working, 'working') + await eventually(async () => expect((await rig.handoff(a))?.handedOverAt).toBeDefined()) + const refused = await rig.handoffId(a) + await rig.settleRejected(refused, 'provider refused the card') + const held = [ + { messageId: a, state: 'returned' }, + { messageId: b, state: 'waiting' }, + { messageId: c, state: 'waiting' } + ] + await eventually(async () => expect(await rig.drafts()).toEqual(held)) + // The refusal is the dispatch row alone: no card row records it. + expect(queueRows()).toEqual([a, b, c].map((messageId) => ({ messageId, op: 'queued' }))) + + // A rewind past the refused hand-off retires its submission; the card stays returned. + const rewound = { itemId: target, expectedEpoch: journal().cursor().epoch } + expect( + await rig.host.rewind(CALLER, { + ...rewound, + envelope: rig.envelope(rewound, 'agentSession.rewind', hostTestOperationId()) + }) + ).toMatchObject({ ok: true }) + expect(journal().submission(refused)).toBeUndefined() + expect(await rig.drafts()).toEqual(held) + await expectNothingSent(b, c) + + // A new epoch deletes the refusal's rows: it is restated with the card, and replay reads it. + await journal().rollEpoch('handle_forked', rig.store.getRecord(SESSION)!.lease.runtimeFence) + await rig.restartHostProcess() + expect(await rig.drafts()).toEqual(held) + await rig.resume() + await expectNothingSent(b, c) + const page = await rig.host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.[0]?.returnedRejection).toMatchObject({ + kind: 'providerRejected' + }) + + // Its own Send hands it off under a fresh id; refused again, it holds the queue again. + const resent = await rig.sendNow(a) + if (!resent.ok || !('submission' in resent.value)) { + throw new Error('expected the submission arm') + } + expect(resent.value.submission).toMatchObject({ queuedMessageId: a }) + await rig.settleRejected(resent.value.submission.clientMessageId, 'refused again') + await eventually(async () => expect(await rig.drafts()).toEqual(held)) + await expectNothingSent(b, c) + + // Its own Delete releases the next card, in order. + expect(await rig.deleteQueued(a)).toMatchObject({ ok: true, value: { deleted: true } }) + await eventually(async () => expect(await rig.handoff(b)).toBeDefined()) + expect(await rig.handoff(c)).toBeUndefined() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts index 539b1eb3cd06..9f8dc54730a4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts @@ -25,14 +25,14 @@ export function queuedSendAnswer( queued: { messageId: draft.messageId, position: draft.position, state: draft.state } } } - // The draft row was pruned; its last hand-off still names it. Only a withdrawn row is pruned - // while its last hand-off stands rejected — a card the user deleted — so it answers withdrawn, - // never as a refused send. + // The card is gone (an earlier build pruned it, or a new epoch did not keep it); its last + // hand-off still names it. Only a withdrawn card goes while its last hand-off stands rejected — + // a card the user deleted — so it answers withdrawn, never as a refused send. const handoff = journal .submissions() .findLast((entry) => entry.queuedMessageId === clientMessageId) if (handoff?.dispatchState === 'rejected') { - // The pruned row's position went with it; a withdrawn receipt names no place in the queue. + // The gone card's position went with it; a withdrawn receipt names no place in the queue. return { clientMessageId, queued: { messageId: clientMessageId, position: 0, state: 'withdrawn' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts index 71196b6117ab..a655f4e0524e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts @@ -1,15 +1,19 @@ // Stop writes one event row before it interrupts, and the queue's pause is derived from it: // through the real host, the cards queued before a Stop wait, a card queued after it sends -// normally but never ahead of them, a withdrawn card comes back under it, a crash keeps it, any -// turn sent after it ends it, and no stored pause is ever written. +// normally but never ahead of them, a card already handed over stays sent, a crash keeps the +// pause, any turn sent after it ends it, and no stored pause is ever written. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import Database from '../../sqlite/sync-database' import { journalDatabasePath } from '../agent-session-journal/journal-host-database' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalAnsweredTurnIdentity } from '../../../shared/agent-session-journal-types' +import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' import { HOST_TEST_SESSION, + HOST_TEST_THREAD, hostTestMessage, hostTestOperationId } from './structured-agent-session-host-test-data' @@ -69,15 +73,32 @@ async function mailTurn(): Promise { return mail.id } -function withdraw(clientMessageId: string) { +/** A Stop's withdrawal as the provider writes it, naming the turn it was answered into if any. */ +function withdraw(clientMessageId: string, answeredInTurn?: AgentJournalAnsweredTurnIdentity) { return rig.host.settleLateDispatch({ sessionId: HOST_TEST_SESSION, clientMessageId, state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), + ...(answeredInTurn ? { answeredInTurn } : {}) }) } +async function send(clientMessageId: string) { + return (await rig.host.journalSnapshot(HOST_TEST_SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +/** How the shared projection every client runs draws this send, or null when it hides it. */ +async function drawn(clientMessageId: string) { + const { items, submissions } = await rig.host.journalSnapshot(HOST_TEST_SESSION) + const message = projectStructuredAgentSessionMessages(items, [], submissions).find( + (entry) => entry.id === agentJournalSubmissionKey(clientMessageId) + ) + return message ? { stoppedBeforeStart: message.stoppedBeforeStart } : null +} + /** Tables that could store a pause: none, the journal rows are the only record. */ function pauseTables(): number { const db = new Database(journalDatabasePath(rig.root), { readonly: true }) @@ -153,27 +174,61 @@ describe("Stop's event", () => { await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) }) - it('a card the Stop withdrew comes back once, under the pause, and is never sent again on its own', async () => { + it('a card handed over and stopped before the agent confirms it reads as sent, never sent again; only the cards behind it send', async () => { const working = await rig.workingSend() await openRigTurnFor(rig, working) - const sentId = await queuedDraft('sent now into the turn') - await rig.sendNow(sentId) - await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined()) - const handoffId = await rig.handoffId(sentId) + const steered = await queuedDraft('sent now into the turn') + const opening = await queuedDraft('opens its own turn') + const last = await queuedDraft('waiting behind them') + await rig.sendNow(steered) + await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) + const steer = await rig.handoffId(steered) + // Codex ends the stopped turn without taking the steer: the agent may have it, nothing proves + // it did not. await rig.stop() - await withdraw(handoffId) - await withdraw(handoffId) + await withdraw(steer, { + turn: { + provider: 'codex', + threadId: HOST_TEST_THREAD, + turnId: `turn-of-${working}`, + ordinal: 0 + }, + via: 'steer' + }) await rig.settleAccepted(working, 'stopped') await openRigTurnFor(rig, working, 'interrupted') - await vi.advanceTimersByTimeAsync(250) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) - expect(await rig.drafts()).toEqual([{ messageId: sentId, state: 'waiting' }]) - const sends = (await rig.host.journalSnapshot(HOST_TEST_SESSION)).submissions.filter( - (entry) => entry.queuedMessageId === sentId - ) - expect(sends.map((entry) => entry.dispatchState)).toEqual(['rejected']) + await expectHeld('stopped', opening, last) + expect(await rig.drafts()).toEqual([ + { messageId: opening, state: 'waiting' }, + { messageId: last, state: 'waiting' } + ]) + // The turn's message, unconfirmed, as an old client's projection draws it too. + expect(await send(steer)).toMatchObject({ dispatchState: 'unknown', recovered: true }) + expect(await drawn(steer)).toEqual({ stoppedBeforeStart: undefined }) + + // Resume hands the next card over; a Stop before its turn opens takes it back, unconfirmed. expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => expect(await rig.handoffId(sentId)).not.toBe(handoffId)) + await eventually(async () => expect((await rig.handoff(opening))?.handedOverAt).toBeDefined()) + const opener = await rig.handoffId(opening) + await rig.stop() + await withdraw(opener) + await expectHeld('stopped', last) + expect(await rig.drafts()).toEqual([{ messageId: last, state: 'waiting' }]) + // Drawn as a person's own send a Stop took back. + expect(await send(opener)).toMatchObject({ dispatchState: 'rejected' }) + expect(await drawn(opener)).toEqual({ stoppedBeforeStart: true }) + + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(async () => expect(await rig.handoff(last)).toBeDefined()) + await rig.settleAccepted(await rig.handoffId(last), 'last') + expect([await rig.handoffId(steered), (await send(opener))?.dispatchState]).toEqual([ + steer, + 'rejected' + ]) + // Neither stopped card can come back, so a reopen holds nothing. + await rig.restartHostProcess() + await rig.queuePause() + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).not.toContain('restarted') }) it('survives a host crash, unshown: the next open marks the hand-off unknown, nothing sends, Resume releases', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts index 6e97ea7e03f6..319f00459411 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.ts @@ -5,7 +5,6 @@ // under a fresh submission id. import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' import { JournalStopAcceptor } from '../agent-session-journal/journal-stop-acceptance' import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' import { holdDelivery } from './structured-agent-session-delivery-hold.test-fixture' @@ -134,53 +133,3 @@ it('a Stop whose acceptance fails takes back no hand-off; the draft sends as it expect(await rig.handoffId(a)).toBe(firstA) expect((await rig.submission(firstA))?.dispatchState).not.toBe('rejected') }) - -/** A consumed card whose delivery is held, so a Stop withdraws it ahead of its handover; the - * settlement hook throws on that withdrawal's row, leaving the card owed a return. */ -async function stopWithSkippedSettlement(): Promise<{ a: string; working: string }> { - const working = await rig.workingSend() - const a = await queuedDraft('A') - const { release } = holdDelivery() - await rig.settleAccepted(working, 'working') - await eventually(async () => expect(await rig.handoff(a)).toBeDefined()) - const settle = JournalQueuedMessages.prototype.onRowInTransaction - let skipped = false - const hook = vi - .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') - .mockImplementation(function (this: JournalQueuedMessages, db, row) { - if (!skipped && row.kind === 'dispatch' && row.state === 'rejected') { - skipped = true - throw new Error('bookkeeping failed') - } - return settle.call(this, db, row) - }) - const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - try { - const stopped = rig.stop() - release() - expect(await stopped).toMatchObject({ ok: true }) - } finally { - hook.mockRestore() - warned.mockRestore() - release() - } - expect(skipped).toBe(true) - return { a, working } -} - -it("a Stop whose withdrawal's settlement was skipped still pauses the card it sent back", async () => { - const { a } = await stopWithSkippedSettlement() - // The drain heals the skipped return; the Stop's row already pauses whatever comes back. - await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'stopped' })) - await observationClock.observe(250) - // Healed back to waiting, but the Stop's pause holds it: it does not send. - expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }]) - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await eventually(async () => - expect( - (await rig.host.journalSnapshot(SESSION)).submissions.filter( - (entry) => entry.queuedMessageId === a - ) - ).toHaveLength(2) - ) -}) 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 db9107193361..19a81b368a70 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 @@ -43,7 +43,6 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho import { recordingProductionStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' import type { StructuredAgentRegistry } from './structured-agent-registry' -import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause' /** What `interruptedRestart('queued-cards')` queues behind the running turn, in order. */ export const QUEUED_BEFORE_QUIT = ['card A', 'card B'] as const @@ -147,10 +146,6 @@ export async function interruptedRestart( } } await previous.host.flushAllStreamedEvents() - if (work === 'queued-cards') { - // The relaunch is a new process: the cards it finds were written by the one that quit. - rotateStructuredAgentSessionHostInstanceForTests() - } const store = await openTestAgentSessionRecordStore(previous.root) const closeSession = vi.fn(async () => true) // The relaunch comes after the quit that recorded the offer. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts index 40fa8a913f13..cfffd3a9b950 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts @@ -4,6 +4,7 @@ // of the aborted start, so it gets a start of its own rather than that start's refusal. import { afterEach, expect, it, vi } from 'vitest' +import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason' import { JournalStopAcceptor } from '../agent-session-journal/journal-stop-acceptance' import { HOST_TEST_SESSION as SESSION, @@ -129,3 +130,44 @@ it('answers that it stopped the queued message even when its write throws after expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) expect(await rig.submission(first.id)).toMatchObject({ rejection: { kind: 'cancelled' } }) }) + +// A Stop before Orca calls the agent, or while the agent still prepares the message (its options, +// the previous turn opening): the agent never got it, so the card waits again. +it.each(['before the call', 'while the agent prepares it'] as const)( + 'returns a queued card to waiting when the Stop lands %s', + async (when) => { + rig = await createQueuedMessageTestRig({ restartable: true }) + const working = await rig.workingSend() + const queued = await rig.send('queued behind the turn', 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + const card = queued.value.queued.messageId + let stopping: ReturnType | undefined + if (when === 'before the call') { + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + const resolve = journal.resolveDispatch.bind(journal) + vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (input) => { + const written = await resolve(input) + if (input.state === 'pending' && input.clientMessageId !== working && !stopping) { + stopping = rig.stop() + } + return written + }) + } else { + rig.dispatch.mockImplementationOnce(async (input) => { + stopping = rig.stop() + return waitForPromiseWithSignal(new Promise(() => {}), input.signal) + }) + } + await rig.settleAccepted(working, 'working') + + await eventually(() => expect(stopping).toBeDefined()) + expect(await stopping).toMatchObject({ ok: true }) + await eventually(async () => + expect(await rig.drafts()).toEqual([{ messageId: card, state: 'waiting' }]) + ) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(rig.dispatch).toHaveBeenCalledTimes(when === 'before the call' ? 1 : 2) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index 0f722ad9959f..c985e94f1937 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -231,7 +231,7 @@ describe("a Stop's event", () => { expect(stopEvents()).toHaveLength(2) }) - it('a second press after a card was sent into the turn writes again, and holds that card', async () => { + it('a second press after a card was sent into the turn writes again, and the card stays sent', async () => { rig = await createQueuedMessageTestRig() const working = await rig.workingSend() await rig.stop() @@ -242,11 +242,13 @@ describe("a Stop's event", () => { await eventually(async () => expect((await rig.handoff(steered))?.handedOverAt).toBeDefined()) await rig.stop() expect(stopEvents()).toHaveLength(2) - await withdraw(await rig.handoffId(steered)) + const handoff = await rig.handoffId(steered) + await withdraw(handoff) await rig.settleAccepted(working, 'stopped') await observationClock.observe(250) - expect(await rig.drafts()).toEqual([{ messageId: steered, state: 'waiting' }]) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // Handed over before the Stop with nothing proving the agent never got it: never re-queued. + expect(await rig.drafts()).toEqual([]) + expect((await rig.submission(handoff))?.dispatchState).toBe('rejected') }) it('naming no turn, names the turn running when it takes effect', async () => { @@ -302,8 +304,8 @@ describe("a Stop's event", () => { // Stop and the drain share one serialized lane, so this interleaving is forced: a pause // written after the drain chose the card must still hold it in the claim's transaction. vi.spyOn(open, 'appendSubmission').mockImplementation(async (input, consume) => { - // The queue's own claim is the one no operation settles. - if (consume?.settledByOp === null && consume.messageId === draftId && !injected) { + // The queue's own claim is the one that yields to a pause. + if (consume?.yieldsToPause && consume.messageId === draftId && !injected) { injected = true await open.appendStopEvent({ reason: 'user-stop' }, input.fence) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index 5e28a4fa9cab..0bec45fb6fbc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -16,10 +16,7 @@ import { openAgentSessionJournal } from '../agent-session-journal/journal-store- import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { journalIdentityFor } from './structured-agent-session-attach' import { attachParamsForRecord } from './structured-agent-session-conversation-open' -import { - structuredAgentSessionHostInstance, - structuredQueuePauses -} from './structured-agent-session-queued-pause' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, eventually, @@ -159,7 +156,6 @@ describe('a message accepted while the agent starts, then Orca stops', () => { expect(card).toMatchObject({ messageId: id, holdReason: null, - hostInstance: structuredAgentSessionHostInstance(), body: hostTestMessage('hello after restart'), queuedAt: { epoch: journal().epoch, sequence: expect.any(Number) } }) @@ -385,61 +381,6 @@ describe('the queue at a quit', () => { await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) } - // Quit's first step is `stopDelivery`, before teardown drains recovery; the flush repeats it. - it.each(['stopDelivery', 'flushAllStreamedEvents'] as const)( - 'a drain step already running when quit begins (%s) makes no hand-off', - async (quitStep) => { - await compactRunning() - const queued = rig.send('queued behind the compact', 'queue-if-active') - expect(await queued.result).toMatchObject({ - ok: true, - value: { queued: { state: 'waiting' } } - }) - const host = rig.host - const { queuedMessages } = journal() - const settleOwed = queuedMessages.settleOwed.bind(queuedMessages) - let release = (): void => undefined - const held = new Promise((resolve) => (release = resolve)) - let reached = (): void => undefined - const inStep = new Promise((resolve) => (reached = resolve)) - let blocked = false - // Holds the drain step at its one await, past its first dispose check, until quit has begun. - // Only the drain step heals owed bookkeeping, so no caller check is needed (nor a stack read, - // which runtimes format differently). - const owed = vi.spyOn(queuedMessages, 'settlementOwed').mockImplementation(() => !blocked) - const healing = vi.spyOn(queuedMessages, 'settleOwed').mockImplementation(async () => { - if (!blocked) { - blocked = true - reached() - await held - } - return settleOwed() - }) - rig.finishCompact() - await inStep - if (quitStep === 'stopDelivery') { - host.stopDelivery() - release() - await healing.mock.results[0]?.value - // The step's append check runs on the turn after its heal resolves. - await vi.advanceTimersByTimeAsync(0) - expect(await rig.handoff(queued.id)).toBeUndefined() - } else { - const quitting = host.flushAllStreamedEvents() - release() - await quitting - } - owed.mockRestore() - healing.mockRestore() - rig.crashRestartHostProcess() - - expect(await rig.drafts()).toEqual([{ messageId: queued.id, state: 'waiting' }]) - expect(await rig.queuePause()).toBeNull() - expect(await rig.handoff(queued.id)).toBeUndefined() - expect(rig.dispatch).not.toHaveBeenCalled() - } - ) - // The card the person pushed ahead comes back at the head; Resume sends it, then the rest. it('Send now on an ordinary card, cut short by a quit, returns it to the head; Resume sends both in order', async () => { await compactRunning() @@ -581,7 +522,7 @@ describe('what is not kept', () => { it('a send whose card could not be written is rejected as before, and nothing stays queued', async () => { const id = await acceptWhileStarting(sendRequest('card write fails')) const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - vi.spyOn(JournalQueuedMessages.prototype, 'holdInTransaction').mockImplementation(() => { + vi.spyOn(JournalQueuedMessages.prototype, 'plan').mockImplementation(() => { throw new Error('disk full') }) rig.crashRestartHostProcess() diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 1832c25ac58b..a97c6a9a85aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -7,7 +7,6 @@ import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' -import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import { sendPreparation } from './structured-agent-session-send-preparation' import { @@ -157,8 +156,7 @@ async function clearUnderSerialize( } await ctx.journal.context.clear( providerContextBoundaryForClear(clear), - ctx.operationReceipt, - agentSessionOperationKey(caller.callerKey, clientOperationId) + ctx.operationReceipt ) return { ok: true, value: completed } } diff --git a/src/main/runtime/agent-session-conversation-clear-commit.test.ts b/src/main/runtime/agent-session-conversation-clear-commit.test.ts index 8d7d24d97687..61ace58a3d82 100644 --- a/src/main/runtime/agent-session-conversation-clear-commit.test.ts +++ b/src/main/runtime/agent-session-conversation-clear-commit.test.ts @@ -10,7 +10,6 @@ import { type AgentSessionConversationClear } from './agent-session-conversation-command-record' import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import { agentSessionOperationKey } from '../../shared/agent-session-operation-ledger' import { claudeProviderHandle } from '../../shared/agent-session-provider-handle-encoding' import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' @@ -127,8 +126,7 @@ async function prepare(released = true) { const append = (input = completed) => journal.context.clear( providerContextBoundaryForClear(input), - store.conversationReceipts.clear(() => input, operation), - agentSessionOperationKey(operation.callerKey, operation.operationId) + store.conversationReceipts.clear(() => input, operation) ) return { store, journal, completed, operation, append } } @@ -142,9 +140,7 @@ async function queueClearCards(journal: AgentSessionJournal) { role: 'user', blocks: [{ type: 'text', text: messageId }], ...(messageId === 'command' ? { command: { name: 'compact' } } : {}) - }, - fingerprint: messageId, - hostInstance: 'host-1' + } }) } } @@ -170,10 +166,7 @@ describe('clear transaction', () => { expect(reopened.getSessionTabId(SOURCE)).toBe('tab-alpha') expect(JSON.stringify(reopened.getRecord(SOURCE))).not.toContain('pre-clear-provider-context') expect(journal.cursor().epoch).toBe(epoch) - expect(journal.queuedMessages.get('command')).toMatchObject({ - state: 'withdrawn', - settledByOp: agentSessionOperationKey(operation.callerKey, operation.operationId) - }) + expect(journal.queuedMessages.get('command')).toMatchObject({ state: 'withdrawn' }) expect(journal.queuedMessages.pauses()).toMatchObject([{ messageIds: ['first', 'last'] }]) expect( reopened.getOperationRow(operation.callerKey, operation.operationId)?.outcome.status diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 13423e9fa070..8954c3e38fde 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -442,7 +442,8 @@ describe('a queued card sent now into the turn a Stop ends', () => { } } - it('comes back as a paused waiting card, and nothing sends it again', async () => { + // The agent may have it, and nothing proves it did not: it stays sent, never sent again. + it("reads as the stopped turn's unconfirmed message, and nothing sends it again", async () => { await runningTurn() const { messageId: cardId } = await queueThenSendNow('and check the tests') // Steered into the running turn, with no echo yet. @@ -452,16 +453,11 @@ describe('a queued card sent now into the turn a Stop ends', () => { await stop('turn-1') - await vi.waitFor(async () => { - const [withdrawn] = await handoffs(cardId) - expect(verdictOf([withdrawn!], withdrawn!.clientMessageId)).toBe('withdrawn') - }) await vi.waitFor( async () => - expect({ ...(await queue()), sends: await sends(cardId) }).toEqual({ - pause: { reason: 'stopped' }, - cards: [{ messageId: cardId, state: 'waiting' }], - sends: [{ verdict: 'withdrawn' }] + expect({ cards: (await queue()).cards, sends: await sends(cardId) }).toEqual({ + cards: [], + sends: [{ verdict: 'unknown' }] }), { timeout: 5_000 } ) @@ -476,7 +472,7 @@ describe('a queued card sent now into the turn a Stop ends', () => { clock.restore() } expect(steers + answers).toBe(2) - expect(await sends(cardId)).toEqual([{ verdict: 'withdrawn' }]) + expect(await sends(cardId)).toEqual([{ verdict: 'unknown' }]) }, 20_000) }) @@ -523,7 +519,7 @@ describe("a queued card's Send-now while a Codex turn runs", () => { expect((await settled()).owesWork).toBe(false) }) - it('is withdrawn when a Stop ends that turn, on a Codex that names a steered start falsely', async () => { + it('is settled when a Stop ends that turn, on a Codex that names a steered start falsely', async () => { // Before 0.148, a steered turn/start answers with an id no turn opens or ends under, // so only the steer's own answer names the turn whose end settles the send. turns = codexTurnLifecycleFake( @@ -538,8 +534,9 @@ describe("a queued card's Send-now while a Codex turn runs", () => { await stop('turn-1') + // A card's hand-off the agent may have: read as sent, never as refused. await vi.waitFor(async () => - expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') ) expect(steers).toBe(1) }) diff --git a/src/shared/abort-signal-reason.ts b/src/shared/abort-signal-reason.ts index 6b7aa2bec403..f1b33d82ac6b 100644 --- a/src/shared/abort-signal-reason.ts +++ b/src/shared/abort-signal-reason.ts @@ -7,6 +7,11 @@ export function abortSignalReason(signal: AbortSignal): Error { return Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }) } +/** The signal's own reason, thrown: the abort itself, not a failure it raced. */ +export function isAbortSignalReason(signal: AbortSignal | undefined, error: unknown): boolean { + return signal?.aborted === true && error === abortSignalReason(signal) +} + export function throwIfSignalAborted(signal?: AbortSignal): void { if (signal?.aborted) { throw abortSignalReason(signal) diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index f42c0eb661f1..b412b60316c1 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -27,8 +27,11 @@ export { type AgentType } /** v3 introduced the `turn` item. A row without one is still written at v2 so * an older host keeps reading it; the first v3 row stops that host writing the chat * (a released one keeps it read-only, this build fails its load) instead of truncating. */ -export const AGENT_SESSION_JOURNAL_SCHEMA_VERSION = 3 +export const AGENT_SESSION_JOURNAL_SCHEMA_VERSION = 4 export const AGENT_SESSION_JOURNAL_TURN_ITEM_SCHEMA_VERSION = 3 +/** v4 introduced the `queue` row: an older build keeps a chat holding one read-only rather than + * meeting a kind it does not know. Every other row keeps its own version. */ +export const AGENT_SESSION_JOURNAL_QUEUE_SCHEMA_VERSION = 4 const AGENT_SESSION_JOURNAL_PRE_TURN_SCHEMA_VERSION = 2 export function journalRowSchemaVersion(bodies: readonly { kind: string }[]): number { diff --git a/src/shared/agent-session-queued-submission.ts b/src/shared/agent-session-queued-submission.ts index 6295897fd54e..fd847e004759 100644 --- a/src/shared/agent-session-queued-submission.ts +++ b/src/shared/agent-session-queued-submission.ts @@ -1,3 +1,4 @@ +import { readAgentSessionFailureFact } from './agent-session-failure' import type { AgentJournalSubmission } from './agent-session-journal-types' /** Accepted by the host and not yet handed to the provider: provably unwritten, so every way out @@ -11,3 +12,19 @@ export function isQueuedAgentJournalSubmission( submission.handedOverAt === undefined ) } + +/** Orca can prove the agent never got it: the host still held it, or whoever settled it recorded + * that it never reached the agent. A missing hand-off on an older host's send proves nothing: + * older hosts sent at once. */ +export function agentJournalSubmissionProvenUnwritten( + submission: Pick< + AgentJournalSubmission, + 'handoverRecorded' | 'handedOverAt' | 'rejection' | 'answeredInTurn' + > +): boolean { + return ( + (submission.handoverRecorded === true && submission.handedOverAt === undefined) || + (readAgentSessionFailureFact(submission.rejection)?.neverReachedAgent === true && + !submission.answeredInTurn) + ) +} diff --git a/src/shared/agent-session-turn-record.test.ts b/src/shared/agent-session-turn-record.test.ts index 971857979819..d210bcfdc8d1 100644 --- a/src/shared/agent-session-turn-record.test.ts +++ b/src/shared/agent-session-turn-record.test.ts @@ -204,7 +204,8 @@ describe('legacyAgentJournalTurnStatusBody', () => { describe('journalRowSchemaVersion', () => { it('stamps only rows that carry a turn item with the current version', () => { - expect(AGENT_SESSION_JOURNAL_SCHEMA_VERSION).toBe(3) + // The newest this build reads is the queue row's. + expect(AGENT_SESSION_JOURNAL_SCHEMA_VERSION).toBe(4) expect(journalRowSchemaVersion([agentJournalTurnBody(turn)])).toBe(3) expect(journalRowSchemaVersion([{ kind: 'message' }, { kind: 'status' }])).toBe(2) expect(journalRowSchemaVersion([])).toBe(2) diff --git a/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts index f7abbe134dc6..237fde781618 100644 --- a/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE, - AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + AGENT_SESSION_JOURNAL_TURN_ITEM_SCHEMA_VERSION, type AgentSessionJournalIdentity, type AgentSessionJournalProviderHandle } from '../../../src/shared/agent-session-journal-types' @@ -115,7 +115,8 @@ async function journalWithNewerRow( ) const seq = journal.cursor().sequence + 1 const newer = JSON.stringify({ - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + // A version the older build reads, so it meets the kind rather than a newer version. + v: AGENT_SESSION_JOURNAL_TURN_ITEM_SCHEMA_VERSION, epoch: journal.epoch, seq, fence: 1, diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index 2d440b4a6ac3..f2412b3c762c 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { expect, test } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE, - AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + AGENT_SESSION_JOURNAL_TURN_ITEM_SCHEMA_VERSION, type AgentJournalItemIdentity, type AgentSessionJournalIdentity, type AgentSessionJournalProviderHandle @@ -163,11 +163,9 @@ test("an older build keeps every row around a Stop's event, a Resume and a reope await journal.appendQueueReopen(1) const afterMarks = journal.cursor() await append(1, 'after the Stop') - const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) - if (!since.ok) { - throw new Error(`expected rows, got reset ${since.reset}`) - } - const rows: JournalRow[] = since.rows + // As stored: a read raises each row's `v` to the newest this build reads, which the older + // build does not. + const rows: JournalRow[] = storedRows(directory).map((json) => JSON.parse(json)) // The older build, after a downgrade, replays the same rows from its own database. const checkout = await materializeReleaseCheckout(BASELINE_REF) @@ -325,7 +323,8 @@ test("this build keeps a newer build's row kind and refuses the load; a build be await journal.appendItem(item(0), { kind: 'status', text: 'before' }, scope) const at = journal.cursor().sequence + 1 const newer = JSON.stringify({ - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + // A version the older build reads, so it meets the kind rather than a newer version. + v: AGENT_SESSION_JOURNAL_TURN_ITEM_SCHEMA_VERSION, kind: 'future-mark', epoch: journal.epoch, seq: at, diff --git a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts index f6ac7f11cb5f..04727b1e300c 100644 --- a/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts @@ -12,16 +12,13 @@ import { USER_MESSAGE_SOURCE } from '../../../src/shared/agent-session-message-s import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' import { createTrackedJournalOpener } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' -import { holdUnsentSends } from '../../../src/main/native-chat/agent-session-journal/journal-unsent-send-hold' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' -// A kept send is an ordinary waiting card placed ahead of the queue, and the reopen's mark is a -// tombstone carrier key: no new state, column or row kind. A build with the queue but without -// either holds a card another host process wrote under its own restart pause, so it must list the -// kept card first, never send it by itself, send it first once a turn there ends that pause, and -// still settle a send this build's quit left queued. The main commit the kept send branched from, -// which has the queue; move it to the newest release that has the queue and predates it. A -// baseline holding this change tests no downgrade. +// A send this build's quit left queued is an ordinary pending submission, so a build from before +// the kept send still reads it, its source included, and settles it on its next start. (A queued +// card is a journal row this build writes at a newer row version, which keeps the chat read-only +// there; users don't downgrade.) The main commit the kept send branched from; move it to the +// newest release that predates it. A baseline holding this change tests no downgrade. const BASELINE_REF = '5a56636f6679071d6ec68b851ef7932cd3222560' const JOURNAL = 'src/main/native-chat/agent-session-journal' const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { @@ -50,20 +47,7 @@ function message(text: string): AgentJournalMessageItem { return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } } -type OlderCard = { - messageId: string - state: string - position: number - hostInstance: string - holdReason: string | null -} - type OlderJournal = { - queuedMessages: { - list: () => readonly OlderCard[] - pauses: (hostInstance: string) => { reason: string }[] - adopt: (hostInstance: string) => Promise - } submission: (id: string) => { dispatchState: string; reason: string | null } | undefined wroteBeforeOpen: (sequence: number | undefined) => boolean rejectQueuedSubmissions: ( @@ -82,23 +66,6 @@ type OlderOpener = { closeAll: () => Promise } -type OlderNextSendable = ( - pauses: readonly { reason: string }[], - cards: readonly OlderCard[] -) => OlderCard | null - -/** The pinned build's drain pick (`nextSendableQueuedCard`): the card it would send next. */ -async function olderNextSendable(): Promise { - const checkout = await materializeReleaseCheckout(BASELINE_REF) - const pause = await importReleaseCheckoutModule(checkout, `${JOURNAL}/queued-message-pause.ts`) - const next = pause.nextSendableQueuedCard - if (typeof next !== 'function') { - throw new Error('the pinned build exports no nextSendableQueuedCard') - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned build's own drain pick, called with that build's own pauses and cards. - return next as OlderNextSendable -} - async function olderOpener(): Promise { const checkout = await materializeReleaseCheckout(BASELINE_REF) const support = await importReleaseCheckoutModule( @@ -128,67 +95,6 @@ async function acceptPersonSend( }) } -test('an older build lists a kept card first, holds it, and sends it first once a turn there lifts its pause', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-kept-card-downgrade-')) - const journals = createTrackedJournalOpener() - try { - const earlier = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - await earlier.queuedMessages.insert({ - messageId: 'queued-card', - body: message('queued-card'), - fingerprint: 'fp-queued-card', - hostInstance: 'host-a' - }) - await acceptPersonSend(earlier, 'kept') - await journals.closeAll() - const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - await holdUnsentSends(reopened, { - fence: 0, - hostInstance: 'host-b', - hold: { cause: 'hostRestarted' } - }) - await reopened.markQueueReopen(0) - expect(reopened.queuedMessages.list().map((card) => card.messageId)).toEqual([ - 'kept', - 'queued-card' - ]) - await journals.closeAll() - - const older = await olderOpener() - const nextSendable = await olderNextSendable() - try { - const downgraded = await older.open({ identity: OLDER_IDENTITY, stateDirectory: directory }) - const cards = () => - downgraded.queuedMessages.list().map(({ messageId, state, holdReason }) => ({ - messageId, - state, - holdReason - })) - expect(downgraded.repair).toEqual({ malformedRows: 0 }) - expect(cards()).toEqual([ - { messageId: 'kept', state: 'waiting', holdReason: null }, - { messageId: 'queued-card', state: 'waiting', holdReason: null } - ]) - expect(downgraded.queuedMessages.list()[0]!.position).toBeLessThan(1) - expect(downgraded.submission('kept')).toMatchObject({ dispatchState: 'rejected' }) - // Written by another process, so that build's restart pause holds both: nothing sends. - const pauses = () => downgraded.queuedMessages.pauses('host-c') - expect(pauses().map((pause) => pause.reason)).toEqual(['restarted']) - expect(nextSendable(pauses(), downgraded.queuedMessages.list())).toBeNull() - // A person's turn there adopts every card into its process, which ends that pause; the - // kept card, first in the queue, is the one it sends first. - expect(await downgraded.queuedMessages.adopt('host-c')).toBe(true) - expect(pauses()).toEqual([]) - expect(nextSendable(pauses(), downgraded.queuedMessages.list())?.messageId).toBe('kept') - } finally { - await older.closeAll() - } - } finally { - await journals.closeAll() - rmSync(directory, { recursive: true, force: true }) - } -}, 120_000) - test("an older build reads the send this build's quit left queued, its source included, and settles it", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-kept-leftover-downgrade-')) const journals = createTrackedJournalOpener()