From ba0681beacc5c008f7ad66d03441818f6ec9f192 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:51:05 -0700 Subject: [PATCH 01/23] fix(native-chat): a refused queued message still blocks the queue when its card write fails Every reader of a queued card goes through one derived view of the journal's dispatch facts, so a card whose message was refused reads as returned even when its card-table update and heal both failed. One decision and one writer store the derived state for every action. --- .../vitest-sqlite-database-runtime-files.mjs | 1 + .../journal-queued-messages.ts | 53 ++--- ...queued-message-bookkeeping-failure.spec.ts | 2 +- .../queued-message-delivered-echo.spec.ts | 23 +++ .../queued-message-effective-card.ts | 186 ++++++++++++++++++ .../queued-message-settlement.ts | 91 +++------ .../queued-message-table.ts | 73 ------- ...-agent-session-queued-refused-card.test.ts | 117 +++++++++++ 8 files changed, 385 insertions(+), 161 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/queued-message-effective-card.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-refused-card.test.ts diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index 1cc0e2c026b7..8e0901fc311c 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -17,6 +17,7 @@ export const SQLITE_DATABASE_RUNTIME_INCLUDE = [ 'src/main/codex/codex-account-session-bridge.test.ts', 'src/main/codex/codex-index-heal-binary-contract.test.ts', 'src/main/codex/codex-state-db.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-refused-card.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts', 'src/main/native-chat/structured-agent-cli-conversation-adoption.test.ts', 'src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts', 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 2936fe965440..07845afa19c5 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 @@ -25,7 +25,6 @@ import { consumeQueuedMessageInTransaction, getQueuedMessage, insertQueuedMessage, - listQueuedMessages, queuedMessagesSettledByOp, withdrawQueuedMessages, type QueuedMessageHoldReason, @@ -34,8 +33,8 @@ import { import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' import { moveQueuedMessages, type QueuedMessagePositionMove } from './queued-message-positions' import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention' +import { EffectiveQueuedCardView } from './queued-message-effective-card' import { - queuedMessageSettlementOwed, queuedMessagesAwaitReopenMark, settleOwedQueuedMessages, settleQueuedMessagesForRow @@ -54,16 +53,18 @@ 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 view: EffectiveQueuedCardView - constructor(private readonly deps: JournalQueuedMessagesDeps) {} + constructor(private readonly deps: JournalQueuedMessagesDeps) { + this.view = new EffectiveQueuedCardView(deps, () => this.changeRevision) + } get sessionId(): string { return this.deps.sessionId } - revision(): number { - return this.changeRevision + revision(): string { + return this.view.revision() } /** A journal transaction rolled back: nothing read inside it may stay cached. */ @@ -71,26 +72,22 @@ export class JournalQueuedMessages { this.changeRevision++ } - /** 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 (`effectiveQueuedCard`), cached per revision. */ list(): readonly QueuedMessageRow[] { - if (this.listed?.revision !== this.changeRevision) { - this.listed = { - revision: this.changeRevision, - rows: listQueuedMessages(this.deps.database().db, this.deps.sessionId) - } - } - return this.listed.rows + return this.view.cards() } get(messageId: string): QueuedMessageRow | null { - return getQueuedMessage(this.deps.database().db, this.deps.sessionId, messageId) + return this.view.card(messageId) } /** An in-place text edit; a changed card spends every edit lease on its old text. */ update(input: QueuedMessageTextUpdate): Promise> { return this.transact( - (db) => updateQueuedMessageText(db, this.deps.sessionId, input), + (db) => { + this.changeRevision += this.view.store(db, [input.messageId]) + return updateQueuedMessageText(db, this.deps.sessionId, input) + }, (result) => result.status === 'updated', undefined, () => this.editLeases.retire(input.messageId) @@ -147,7 +144,10 @@ export class JournalQueuedMessages { * own UPDATE. */ hold(input: { messageIds: readonly string[]; reason: QueuedMessageHoldReason }): Promise { return this.transact( - (db) => holdQueuedMessages(db, { ...input, sessionId: this.deps.sessionId }), + (db) => { + this.changeRevision += this.view.store(db, input.messageIds) + return holdQueuedMessages(db, { ...input, sessionId: this.deps.sessionId }) + }, (held) => held > 0 ).then(() => undefined) } @@ -210,12 +210,14 @@ export class JournalQueuedMessages { return Promise.resolve([]) } return this.transact( - (db) => - withdrawQueuedMessages(db, { + (db) => { + this.changeRevision += this.view.store(db, input.messageIds) + return withdrawQueuedMessages(db, { ...input, sessionId: this.deps.sessionId, now: this.deps.now() - }), + }) + }, (withdrawn) => withdrawn.length > 0, receipt ) @@ -229,6 +231,7 @@ export class JournalQueuedMessages { if (this.deps.database().db !== db) { throw new AgentSessionJournalError('journal_closed', 'withdraw crossed database handles') } + this.changeRevision += this.view.store(db, input.messageIds) this.changeRevision += withdrawQueuedMessages(db, { ...input, sessionId: this.deps.sessionId, @@ -280,11 +283,12 @@ export class JournalQueuedMessages { // Same handle only: a second connection could not join the transaction. throw new AgentSessionJournalError('journal_closed', 'consume crossed database handles') } + this.changeRevision += this.view.store(db, [input.messageId]) 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 cards = this.view.cardsIn(db) const pauses = this.derivePauses(cards) const held = this.editLeases.heldIds(cards) if (nextSendableQueuedCard(pauses, cards, held)?.messageId !== input.messageId) { @@ -302,9 +306,10 @@ export class JournalQueuedMessages { this.changeRevision++ } - /** A skipped live settlement the journal already decided (`queued-message-settlement.ts`). */ + /** A stored card behind the one readers see: the heal owes its durable copy, which is what keeps + * the card across a rewind (`queued-message-retention.ts`). */ settlementOwed(): boolean { - return queuedMessageSettlementOwed(this.list(), this.deps.state().submissions) + return this.view.storedBehind() } /** Waiting drafts a skipped echo hook left unwithdrawn; reads every item, so only the drain 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 index ba7a26a13323..ffe91e1ce13d 100644 --- 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 @@ -107,7 +107,7 @@ export function registerQueuedMessageBookkeepingFailureTests(): void { }) // 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(journal.queuedMessages.get('draft-1')?.state).toBe('returned') expect(warn).toHaveBeenCalledWith( '[journal-append] row bookkeeping skipped:', expect.objectContaining({ kind: 'dispatch' }) 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 index 881c9dbe8067..490f66834819 100644 --- 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 @@ -188,6 +188,29 @@ export function registerQueuedMessageDeliveredEchoTests(): void { expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') }) + it('the echo withdraws a card the skipped rejection hook left stored as handed off', async () => { + const journal = await open() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const settle = JournalQueuedMessages.prototype.onRowInTransaction + vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementation( + function (this: JournalQueuedMessages, db, row) { + if (row.kind === 'dispatch' && row.state === 'rejected') { + throw new Error('bookkeeping failed') + } + return settle.call(this, db, row) + } + ) + try { + await handOffAndReject(journal, 'did it land?') + } finally { + vi.restoreAllMocks() + } + expect(journal.queuedMessages.settlementOwed()).toBe(true) + await echo(journal, 'echo-1', 'did it land?') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + expect(journal.queuedMessages.settlementOwed()).toBe(false) + }) + 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?') diff --git a/src/main/native-chat/agent-session-journal/queued-message-effective-card.ts b/src/main/native-chat/agent-session-journal/queued-message-effective-card.ts new file mode 100644 index 000000000000..1633f0768a28 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-effective-card.ts @@ -0,0 +1,186 @@ +// A card as every reader sees it. A refused hand-off is a journal row; moving its card out of +// `dispatched` is bookkeeping that may be skipped, so readers derive the card from the fold and +// the stored row is only a durable copy (the text a rewind must not delete), written through +// `writeEffectiveQueuedCards` by the live hook, the heal and the action about to change the card. + +import type Database from '../../sqlite/sync-database' +import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { structuredAgentSessionCommandTurnIdentity } from '../../../shared/structured-agent-session-command-turn-identity' +import { + consumedSubmissionWasRejected, + rejectedDraftSettlement +} from './journal-dispatch-settlement' +import type { JournalReducerState } from './journal-reducer' +import type { JournalQueuedMessagesDeps } from './journal-store-contracts' +import { getQueuedMessage, listQueuedMessages, type QueuedMessageRow } from './queued-message-table' + +type FoldFacts = Pick + +/** + * What a refusal makes of the dispatched card it handed off: a withdrawal waits again at its own + * position under the queue's pause; 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: { reason: string | null; rejection?: UnreadAgentSessionFailureFact }, + commandTurnReported: boolean +): QueuedMessageRow { + if (rejectedDraftSettlement(rejected).state === 'waiting') { + return { + ...card, + state: 'waiting', + holdReason: null, + consumedAs: null, + returnedReason: null, + returnedRejection: null, + settledAt: null, + settledByOp: null + } + } + if (card.body.command && commandTurnReported) { + return { ...card, state: 'withdrawn' } + } + return { + ...card, + state: 'returned', + returnedReason: rejected.reason, + returnedRejection: rejected.rejection ?? null + } +} + +/** The stored card, settled as its refused hand-off says; any other card is the same object. */ +export function effectiveQueuedCard(row: QueuedMessageRow, state: FoldFacts): QueuedMessageRow { + const submission = row.consumedAs === null ? undefined : state.submissions.get(row.consumedAs) + if (row.state !== 'dispatched' || !submission || !consumedSubmissionWasRejected(submission)) { + return row + } + return rejectedCardTransition( + row, + submission, + commandTurnReported(state, submission.clientMessageId) + ) +} + +/** The submission's command turn is in the journal: its own row reports the refusal. */ +export function commandTurnReported(state: FoldFacts, clientMessageId: string): boolean { + return state.items.has( + agentJournalItemKey(structuredAgentSessionCommandTurnIdentity(clientMessageId)) + ) +} + +/** The cards readers see over the stored rows, and the durable copy an action writes first. */ +export class EffectiveQueuedCardView { + private stored: { revision: number; rows: readonly QueuedMessageRow[] } | null = null + private viewed: { key: string; cards: readonly QueuedMessageRow[] } | null = null + + constructor( + private readonly deps: Pick< + JournalQueuedMessagesDeps, + 'sessionId' | 'now' | 'database' | 'state' + >, + /** Bumped on every draft-table write. */ + private readonly tableRevision: () => number + ) {} + + /** Also moves when the fold settles a stored hand-off, which changes a card with no write. */ + revision(): string { + const state = this.deps.state() + const handOffs = this.rows().flatMap((row) => + row.state === 'dispatched' ? [effectiveQueuedCard(row, state).state] : [] + ) + return `${this.tableRevision()}:${handOffs.join()}` + } + + /** 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. */ + cards(): readonly QueuedMessageRow[] { + const key = this.revision() + if (this.viewed?.key !== key) { + const state = this.deps.state() + this.viewed = { key, cards: this.rows().map((row) => effectiveQueuedCard(row, state)) } + } + return this.viewed.cards + } + + card(messageId: string): QueuedMessageRow | null { + const row = getQueuedMessage(this.deps.database().db, this.deps.sessionId, messageId) + return row && effectiveQueuedCard(row, this.deps.state()) + } + + /** Inside a transaction: read from `db`, never cached under state a rollback could undo. */ + cardsIn(db: Database.Database): QueuedMessageRow[] { + const state = this.deps.state() + return listQueuedMessages(db, this.deps.sessionId).map((row) => effectiveQueuedCard(row, state)) + } + + /** An action first stores its own cards as readers see them, so its compare-and-set matches what + * the person acted on; only its own, so another card's failed bookkeeping never blocks it. + * Returns how many changed. */ + store(db: Database.Database, messageIds: readonly string[]): number { + const { sessionId } = this.deps + const state = this.deps.state() + return writeEffectiveQueuedCards(db, { sessionId, now: this.deps.now(), messageIds }, (row) => + effectiveQueuedCard(row, state) + ) + } + + /** Some stored row is not the card readers see: its durable copy is still unwritten. */ + storedBehind(): boolean { + const cards = this.cards() + return this.rows().some((row, index) => row !== cards[index]) + } + + private rows(): readonly QueuedMessageRow[] { + const revision = this.tableRevision() + if (this.stored?.revision !== revision) { + this.stored = { + revision, + rows: listQueuedMessages(this.deps.database().db, this.deps.sessionId) + } + } + return this.stored.rows + } +} + +/** The one writer of a refused hand-off's card: stores `effective`'s answer wherever it differs + * from the stored row, matched on that row's hand-off. `messageIds` limits it to an action's own + * cards. Returns how many cards changed. */ +export function writeEffectiveQueuedCards( + db: Database.Database, + input: { sessionId: string; now: number; messageIds?: readonly string[] }, + effective: (row: QueuedMessageRow) => QueuedMessageRow +): number { + const rows = input.messageIds + ? input.messageIds.flatMap((id) => getQueuedMessage(db, input.sessionId, id) ?? []) + : listQueuedMessages(db, input.sessionId) + const write = db.prepare( + `UPDATE queued_messages + SET state = ?, hold_reason = ?, consumed_as = ?, returned_reason = ?, returned_rejection = ?, + settled_at = ?, settled_by_op = ? + WHERE session_id = ? AND message_id = ? AND state = 'dispatched' AND consumed_as = ?` + ) + let written = 0 + for (const row of rows) { + const card = effective(row) + if (card === row) { + continue + } + const changed = write.run( + card.state, + card.holdReason, + card.consumedAs, + card.returnedReason, + card.returnedRejection ? JSON.stringify(card.returnedRejection) : null, + card.state === 'waiting' ? null : input.now, + card.settledByOp, + input.sessionId, + row.messageId, + row.consumedAs + ) + written += Number(changed.changes ?? 0) + } + return written +} 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 index 212f572fd6cf..a0783f4d6474 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-settlement.ts @@ -1,42 +1,29 @@ // 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. +// skipped, so every reader takes the card the fold already settled +// (`effectiveQueuedCard`), and the open-time repair and the drain write that +// card's durable copy. import type Database from '../../sqlite/sync-database' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { - consumedSubmissionWasRejected, - journalDispatchRowNewlyRejects -} from './journal-dispatch-settlement' +import { 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 { + commandTurnReported, + effectiveQueuedCard, + rejectedCardTransition, + writeEffectiveQueuedCards +} from './queued-message-effective-card' 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( @@ -52,34 +39,14 @@ export function queuedMessagesAwaitReopenMark( }) } -/** Applies each owed settlement, and withdraws each waiting draft an applied echo proves - * delivered (`draftsDeliveredByAppliedEcho`); returns how many drafts changed. */ +/** Stores each card the fold already settled (`effectiveQueuedCard`), 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 - } + let settled = writeEffectiveQueuedCards(db, input, (row) => effectiveQueuedCard(row, input.state)) const delivered = draftsDeliveredByAppliedEcho( input.state, listQueuedMessages(db, input.sessionId) @@ -121,7 +88,11 @@ export function settleQueuedMessagesForRow( 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. + // Its first send reached the agent after all; sending it again would repeat it. Stored first + // as readers see it, so a card still stored as handed off is withdrawn too. + changed += writeEffectiveQueuedCards(db, { ...input, messageIds: [delivered] }, (card) => + effectiveQueuedCard(card, input.state) + ) changed += withdrawQueuedMessages(db, { sessionId: input.sessionId, messageIds: [delivered], @@ -136,20 +107,14 @@ export function settleQueuedMessagesForRow( 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)) + // Not folded in yet, so the row's own facts take the view's decision. + const reported = commandTurnReported(input.state, row.clientMessageId) + return ( + changed + + writeEffectiveQueuedCards(db, input, (card) => + card.state === 'dispatched' && card.consumedAs === row.clientMessageId + ? rejectedCardTransition(card, row, reported) + : card + ) ) } 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 827dd6111f63..8d37444a2d5f 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 @@ -15,7 +15,6 @@ import type { 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' export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn' @@ -229,78 +228,6 @@ export function withdrawQueuedMessages( 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, 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..2d01b59c336f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-refused-card.test.ts @@ -0,0 +1,117 @@ +// A refused queued card comes back as a returned card that holds the queue even when every write +// of the card fails: the live hook, the drain's heal and the open-time repair. The card behind it +// never sends, Resume does not release it, and its own Send and Delete work. + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + eventually, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + vi.useFakeTimers() + rig = await createQueuedMessageTestRig({ restartable: true }) +}) + +afterEach(async () => { + vi.restoreAllMocks() + try { + await rig.dispose() + } finally { + vi.useRealTimers() + } +}) + +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 +} + +/** Lets the drain run its steps; none may hand a card off. */ +async function expectNothingSent(...draftIds: string[]): Promise { + await vi.advanceTimersByTimeAsync(250) + for (const draftId of draftIds) { + expect(await rig.handoff(draftId)).toBeUndefined() + } +} + +/** A and B wait behind a turn; A goes out and the agent refuses it while no card write lands. */ +async function refusedWhileCardWritesFail(): Promise<{ first: string; second: string }> { + const working = await rig.workingSend() + const first = await queuedDraft('A') + const second = await queuedDraft('B') + await rig.settleAccepted(working, 'a') + await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) + const refused = await rig.handoffId(first) + const settle = JournalQueuedMessages.prototype.onRowInTransaction + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementation(function ( + this: JournalQueuedMessages, + db, + row + ) { + if (row.kind === 'dispatch' && row.clientMessageId === refused && row.state === 'rejected') { + throw new Error('bookkeeping failed') + } + return settle.call(this, db, row) + }) + vi.spyOn(JournalQueuedMessages.prototype, 'settleOwed').mockRejectedValue(new Error('heal')) + vi.spyOn(JournalQueuedMessages.prototype, 'repairAndPrune').mockRejectedValue(new Error('repair')) + await rig.settleRejected(refused, 'provider refused this payload') + await eventually(async () => + expect(await rig.drafts()).toEqual([ + { messageId: first, state: 'returned' }, + { messageId: second, state: 'waiting' } + ]) + ) + expectStoredAsHandedOff() + return { first, second } +} + +/** The injected failures held: the stored row still reads handed off, only the view returns it. */ +function expectStoredAsHandedOff(): void { + const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + expect(journal?.queuedMessages.settlementOwed()).toBe(true) +} + +it('holds the card behind it across a restart and a Resume, until it is deleted', async () => { + const { first, second } = await refusedWhileCardWritesFail() + const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + expect(page.ok && page.page.queuedMessages?.[0]?.returnedRejection).toMatchObject({ + kind: 'providerRejected' + }) + await expectNothingSent(second) + // Reopened with the card still stored as handed off: Resume lifts the restart's pause only. + await rig.restartHostProcess() + expect(await rig.drafts()).toEqual([ + { messageId: first, state: 'returned' }, + { messageId: second, state: 'waiting' } + ]) + expectStoredAsHandedOff() + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await expectNothingSent(second) + expect(await rig.deleteQueued(first)).toMatchObject({ ok: true, value: { deleted: true } }) + await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) +}) + +it('Send hands the refused card off again under a fresh id, ahead of the card behind it', async () => { + const { first, second } = await refusedWhileCardWritesFail() + const refused = await rig.handoffId(first) + const resent = await rig.sendNow(first) + if (!resent.ok || !('submission' in resent.value)) { + throw new Error('expected the submission arm') + } + expect(resent.value.submission).toMatchObject({ queuedMessageId: first }) + expect(resent.value.submission.clientMessageId).not.toBe(refused) + await expectNothingSent(second) + await rig.settleAccepted(resent.value.submission.clientMessageId, 'a-again') + await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) +}) From e42b90d5be3da7184ec0320191599fe3a2da7d2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:20:04 -0700 Subject: [PATCH 02/23] refactor(native-chat): keep each queued message in the chat's journal, not a second table A queued message lived in its own SQL table, and a hook that may be skipped copied each refusal from the journal into it. When that write failed, a refused card vanished: the cards behind it sent, and its Send and Delete did nothing. Each queued message is now a `queue` row in the chat's journal beside the submission that hands it off and the dispatch row that answers it. The reducer folds the card from those rows, so a refusal returns the card and holds the cards behind it with nothing written, and replay reads the same. Every action writes its own rows in one transaction. Cards an earlier build stored are read through the same classification until they move: each action moves its own card, and a bulk import runs once per open. A new epoch restates every card still owed an answer in the transaction that deletes the old rows. The settlement hook and its savepoint, the open-time heal, the drain's heal and the derived-card view are gone. The published card list is unchanged. --- .../journal-context-clear.ts | 31 +- .../journal-context-controller.ts | 8 +- .../journal-dispatch-reducer.ts | 4 +- .../journal-dispatch-settlement.ts | 27 +- .../journal-epoch-controller.ts | 5 + .../journal-epoch-replacement.ts | 22 +- .../journal-epoch-rollover.ts | 28 +- .../journal-queue-events.ts | 116 ++++ .../journal-queue-fold.ts | 252 +++++++++ .../journal-queue-import.ts | 185 +++++++ .../journal-queue-retention.ts | 59 ++ .../journal-queued-messages.ts | 507 ++++++++---------- .../agent-session-journal/journal-reducer.ts | 21 +- .../journal-row-schema.ts | 7 +- .../journal-row-writer.ts | 79 +-- .../journal-store-collaborators.ts | 34 +- .../journal-store-contracts.ts | 20 +- .../agent-session-journal/journal-store.ts | 14 +- .../journal-submission-hook.ts | 24 - .../journal-submission-writer.ts | 88 ++- .../journal-unsent-send-hold.ts | 74 +-- .../queued-message-delivered-echo.ts | 16 +- .../queued-message-effective-card.ts | 186 ------- .../queued-message-holds.ts | 29 - .../queued-message-positions.ts | 31 -- .../queued-message-retention.ts | 74 --- .../queued-message-settlement.ts | 120 ----- .../queued-message-stored-row.ts | 33 +- .../queued-message-table.ts | 226 +------- .../queued-message-text-update.ts | 28 +- ...uctured-agent-session-conversation-open.ts | 11 +- ...structured-agent-session-event-recovery.ts | 8 +- .../structured-agent-session-host-lifetime.ts | 13 +- ...tructured-agent-session-queued-messages.ts | 39 +- ...ructured-agent-session-queued-mutations.ts | 59 +- .../structured-agent-session-queued-pause.ts | 16 - ...ctured-agent-session-queued-send-answer.ts | 8 +- .../structured-conversation-command.ts | 4 +- src/shared/agent-session-journal-types.ts | 5 +- 39 files changed, 1152 insertions(+), 1359 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-queue-events.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-queue-fold.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-queue-import.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-queue-retention.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-effective-card.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-holds.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-positions.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-retention.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-settlement.ts 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-dispatch-reducer.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts index ff0f763ef7f8..94eb39129a9f 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.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-settlement.ts index 28bfdb3358b9..0ad307dedd22 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,11 @@ // 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 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,15 +19,6 @@ 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' } @@ -41,12 +31,3 @@ export function rejectedDraftSettlement( ): 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) -} 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 2648515c2b7a..a15531209967 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' @@ -25,6 +26,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 } @@ -38,6 +41,7 @@ export class JournalEpochController { reason, fence, now: this.deps.now(), + queue: this.deps.queueRestatement(), onPublished: this.deps.adopt }) } @@ -73,6 +77,7 @@ export class JournalEpochController { items, 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.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index 841e99688866..807296f8322f 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -1,7 +1,8 @@ // 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 { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' @@ -26,6 +27,8 @@ import { buildJournalSubmissionRow, journalRowBase } from './journal-row-builders' +import { buildJournalQueueRow } from './journal-queue-events' +import type { JournalQueueRestatement } from './journal-queued-messages' import { buildJournalQueueReopenRow, buildJournalQueueClearRow, @@ -66,6 +69,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. */ @@ -98,6 +103,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) @@ -153,8 +171,10 @@ export function replaceJournalEpoch(input: { for (const row of rows) { insertJournalRow(db, sessionId, row) } + input.queue.receipt.write(db) publishJournalSessionEpoch(db, input.identity, epoch) }) + 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..f1c721ac5cf6 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queue-events.ts @@ -0,0 +1,116 @@ +// 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 { + 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?: Readonly> + /** 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..9fdcbd40206f --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queue-fold.ts @@ -0,0 +1,252 @@ +// 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, an echo proving delivery) and kept from +// then on, so a later rewind that retires the hand-off cannot change it, and replay reads the same. + +import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' +import type { + AgentJournalCursor, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { isAgentSessionOptions } from '../../../shared/agent-session-record' +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 { rejectedDraftSettlement } 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 { draftDeliveredByEcho, draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' +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: a withdrawal waits again at its own + * position under the queue's pause; 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: { reason: string | null; rejection?: UnreadAgentSessionFailureFact }, + commandTurnReported: boolean +): QueuedMessageRow { + if (rejectedDraftSettlement(rejected).state === 'waiting') { + return { ...card, state: 'waiting', holdReason: null, consumedAs: null } + } + 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. + */ +export function queuedCardFromChange( + state: JournalReducerState, + messageId: string, + change: JournalQueuedChange +): QueuedMessageRow { + 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), + ...(isAgentSessionOptions(change.options) ? { options: change.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 deliveredWaitingCard(state, card) +} + +/** Before an item or batch applies: a provider echo proving a waiting card's earlier hand-off was + * delivered spends the card, so it is never sent twice. */ +export function foldJournalQueueEcho(state: JournalReducerState, row: JournalRow): void { + const { cards } = state.queue + if (cards.size === 0 || (row.kind !== 'item' && row.kind !== 'lifecycle-batch')) { + return + } + const delivered = draftDeliveredByEcho(state, () => [...cards.values()], row) + const card = delivered === null ? undefined : cards.get(delivered) + if (card) { + setCard(state, { ...card, state: 'withdrawn', holdReason: null }) + } +} + +/** 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)) + } + } 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') { + setCard(state, queuedCardFromChange(state, row.messageId, change)) + 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 deliveredWaitingCard(state, rejectedCardTransition(card, submission, reported)) +} + +/** A card that waits again is checked against the echoes already applied, so one whose earlier + * hand-off reached the agent after all is spent rather than resent. */ +function deliveredWaitingCard( + state: JournalReducerState, + card: QueuedMessageRow +): QueuedMessageRow { + if (card.state !== 'waiting' || draftsDeliveredByAppliedEcho(state, [card]).length === 0) { + return card + } + return { ...card, state: 'withdrawn', holdReason: null } +} + +/** 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..54c182e8090a --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queue-import.ts @@ -0,0 +1,185 @@ +// 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 { 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 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, delivered, or a hand-off an echo proves 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 attempt = row.state === 'waiting' ? null : row.consumedAs + if (row.state === 'withdrawn' || (row.state === 'dispatched' && attempt === null)) { + return null + } + const change: JournalQueuedChange = { + ...queuedChangeOf(row), + ...(row.state === 'waiting' && row.holdReason !== null ? { holdReason: row.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 +} + +/** Waiting, returned, or handed off as a send still owed an answer, 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 || handOff.dispatchState === 'pending' +} + +/** 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 + } + + /** 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 + } + } + } + + /** `deletion` of every readable legacy row: a new epoch restates their cards with its own. */ + deletionOfAll(): JournalOperationReceipt { + return this.deletion(this.rows().map((row) => row.messageId)) + } + + /** 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-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 07845afa19c5..e501991fb500 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,19 +1,19 @@ -// 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 { - 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, @@ -22,352 +22,293 @@ import { type JournalQueuePauseMarks } from './queued-message-pause' import { - consumeQueuedMessageInTransaction, - getQueuedMessage, - insertQueuedMessage, - 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 { EffectiveQueuedCardView } from './queued-message-effective-card' -import { - 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 { updateQueuedMessageText, type QueuedMessageTextUpdate } from './queued-message-text-update' +import { + queuedMessageTextUpdateDecision, + type QueuedMessageTextUpdate +} from './queued-message-text-update' -/** 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 +/** Card rows to write in one transaction, with what must commit beside them. */ +export type JournalQueuePlan = { + rows: ((seq: number, ts: number) => JournalQueueRow)[] + receipt: JournalOperationReceipt +} + +/** 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 readonly view: EffectiveQueuedCardView + private readonly legacy: LegacyQueuedCards + private viewed: { key: string; cards: readonly QueuedMessageRow[] } | null = null constructor(private readonly deps: JournalQueuedMessagesDeps) { - this.view = new EffectiveQueuedCardView(deps, () => this.changeRevision) + this.legacy = new LegacyQueuedCards(deps) } get sessionId(): string { return this.deps.sessionId } + /** Moves with every card change, so publication memos recompute only when they must. */ revision(): string { - return this.view.revision() - } - - /** A journal transaction rolled back: nothing read inside it may stay cached. */ - invalidate(): void { - this.changeRevision++ + const { epoch, queue } = this.deps.state() + return `${epoch}:${queue.revision}:${this.legacy.key()}` } - /** The cards every reader sees (`effectiveQueuedCard`), cached per revision. */ + /** 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[] { - return this.view.cards() + 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.viewed.cards } get(messageId: string): QueuedMessageRow | null { - return this.view.card(messageId) - } - - /** An in-place text edit; a changed card spends every edit lease on its old text. */ - update(input: QueuedMessageTextUpdate): Promise> { - return this.transact( - (db) => { - this.changeRevision += this.view.store(db, [input.messageId]) - return updateQueuedMessageText(db, this.deps.sessionId, input) - }, - (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?: Readonly> }, 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 - }, - () => 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) => { - this.changeRevision += this.view.store(db, input.messageIds) - return holdQueuedMessages(db, { ...input, sessionId: this.deps.sessionId }) + ...(input.holdReason ? { holdReason: input.holdReason } : {}), + ...(input.options ? { options: input.options } : {}) + } + return { changes: [{ messageId, change }], result: undefined } }, - (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 text edit; a changed card spends every edit lease on its old text. */ + update(input: QueuedMessageTextUpdate): Promise { + const leases: JournalOperationReceipt = { + write: () => {}, + committed: () => this.editLeases.retire(input.messageId) } - insertQueuedMessage(db, { ...input.card, sessionId, now: this.deps.now() }) - this.changeRevision++ - return true + return this.write(() => { + const decision = queuedMessageTextUpdateDecision( + this.get(input.messageId), + this.deps.sessionId, + input + ) + if (decision.status !== 'ready') { + return { changes: [], result: decision } + } + const { messageId } = input + return { + changes: [{ messageId, change: { op: 'edited', body: decision.body } }], + result: { status: 'updated', messageId, fingerprint: decision.fingerprint } + } + }, leases) } - /** 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. */ + /** 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 + })) + } + + /** 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) => { - this.changeRevision += this.view.store(db, input.messageIds) - return 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 += this.view.store(db, input.messageIds) - 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') - } - this.changeRevision += this.view.store(db, [input.messageId]) - 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 = this.view.cardsIn(db) - 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 stored card behind the one readers see: the heal owes its durable copy, which is what keeps - * the card across a rewind (`queued-message-retention.ts`). */ - settlementOwed(): boolean { - return this.view.storedBehind() + /** The bulk move of an earlier build's cards, once per open (`LegacyQueuedCards.importAtOpen`). */ + importAtOpen(): Promise { + return this.legacy.importAtOpen() + } + + /** The queue's pauses in force, derived from the fold and the cards (`queued-message-pause.ts`). */ + pauses(): DerivedQueuePause[] { + return this.derivePauses(this.list()) } - /** 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 + /** 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)?.dispatchState : undefined + return card.state === 'dispatched' && (handOff === 'pending' || handOff === 'unknown') + }) } - /** 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 person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ + userStopInForce(): JournalQueuePauseMarks['latestStop'] { + const state = this.deps.state() + return journalUserStopInForce(state.queuePauseMarks, state.latestAcceptedTurnSequence) } - /** 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) - }) + 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() }) } - /** - * 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) + /** 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 cbc87dc9e433..d3ff8ded97ae 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -40,6 +40,12 @@ import { foldJournalQueuePauseMark, type JournalQueuePauseMarks } from './queued-message-pause' +import { + createJournalQueueState, + foldJournalQueueEcho, + foldJournalQueueRow, + type JournalQueueState +} from './journal-queue-fold' export const MAX_JOURNAL_APPLIED_SETTLEMENT_IDS = 4_096 @@ -77,6 +83,8 @@ export type JournalReducerState = { queuePauseMarks: JournalQueuePauseMarks /** Host-only: each handed-over send's stopped-message history, from its latest `pending` row. */ stoppedHistoryCarried: Map + /** The queued messages, folded from their own rows and their hand-offs (`journal-queue-fold.ts`). */ + queue: JournalQueueState } export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { @@ -100,7 +108,8 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou derivedTurnScope: new JournalDerivedTurnScope(), latestAcceptedTurnSequence: 0, queuePauseMarks: createJournalQueuePauseMarks(), - stoppedHistoryCarried: new Map() + stoppedHistoryCarried: new Map(), + queue: createJournalQueueState() } } @@ -109,9 +118,17 @@ export function applyJournalRow( row: JournalRow, savedAt = row.ts ): void { + // An echo is read against the cards before it applies; every other row after. + foldJournalQueueEcho(state, row) + 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') { + // A queued card is never activity: it feeds no working status and no idle sweep. + if (row.kind === 'epoch' || row.kind === 'queue') { return } state.lastActivityAt = Math.max(state.lastActivityAt, row.ts) 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 88cde342d37e..455231fa3304 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 @@ -39,6 +39,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 @@ -46,7 +47,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 @@ -241,6 +242,7 @@ export type JournalRow = | JournalSubmissionRow | JournalDispatchRow | JournalLifecycleBatchRow + | JournalQueueRow export type JournalRowParse = | { ok: true; row: JournalRow } @@ -381,7 +383,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..f0a75a68b7b3 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 @@ -52,19 +52,11 @@ 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) {} @@ -78,21 +70,15 @@ 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) + hook?.(db, row) + receipt?.write(db, row) + }, + !hook && !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 +115,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 @@ -166,27 +146,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 e0c87b81707c..dbd80aa86946 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 }) @@ -95,17 +96,6 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal required, 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, @@ -115,12 +105,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, @@ -136,12 +133,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 c9f2feae47b1..e32f158eed53 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 @@ -16,6 +16,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' @@ -136,15 +137,11 @@ export type JournalSubmissionInput = { options?: Readonly> } -/** 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 @@ -159,18 +156,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 4dca9355e0ef..6422d3e512f3 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -52,18 +52,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' @@ -97,7 +93,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`). */ @@ -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) } /** Retire unanswered sends after their execution owner ended, without assuming delivery. */ 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 36893658f90a..8a782bf75afe 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. */ @@ -11,24 +8,3 @@ export type JournalAttachmentClaim = ( body: AgentJournalMessageItem, required: boolean ) => 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-writer.ts b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts index 1fc0ac21d0da..f7aac6f79a0e 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,19 +1,16 @@ // 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 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, @@ -38,18 +35,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) } /** @@ -58,12 +83,39 @@ 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))) + } +} + +/** 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-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index 08a8f91cfa89..9f2b09a49ec7 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 = @@ -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 = { @@ -220,16 +222,14 @@ function headOfQueuePositions( } else if ( // Kept by its settlement (`rejectedDraftSettlement`): 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-delivered-echo.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts index 7d739a3f6224..732e4bce589b 100644 --- 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 @@ -13,9 +13,9 @@ import { 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. */ +/** The waiting draft this appended row proves was delivered, or null. The fold + * calls it before the row applies, for every item 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[], @@ -42,11 +42,11 @@ export function draftDeliveredByEcho( } /** - * 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. + * The same proof for a card that comes to wait again: 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, but only once some hand-off of the card was refused + * after hand-over, so a card that never left costs nothing. */ export function draftsDeliveredByAppliedEcho( state: JournalReducerState, diff --git a/src/main/native-chat/agent-session-journal/queued-message-effective-card.ts b/src/main/native-chat/agent-session-journal/queued-message-effective-card.ts deleted file mode 100644 index 1633f0768a28..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-effective-card.ts +++ /dev/null @@ -1,186 +0,0 @@ -// A card as every reader sees it. A refused hand-off is a journal row; moving its card out of -// `dispatched` is bookkeeping that may be skipped, so readers derive the card from the fold and -// the stored row is only a durable copy (the text a rewind must not delete), written through -// `writeEffectiveQueuedCards` by the live hook, the heal and the action about to change the card. - -import type Database from '../../sqlite/sync-database' -import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { structuredAgentSessionCommandTurnIdentity } from '../../../shared/structured-agent-session-command-turn-identity' -import { - consumedSubmissionWasRejected, - rejectedDraftSettlement -} from './journal-dispatch-settlement' -import type { JournalReducerState } from './journal-reducer' -import type { JournalQueuedMessagesDeps } from './journal-store-contracts' -import { getQueuedMessage, listQueuedMessages, type QueuedMessageRow } from './queued-message-table' - -type FoldFacts = Pick - -/** - * What a refusal makes of the dispatched card it handed off: a withdrawal waits again at its own - * position under the queue's pause; 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: { reason: string | null; rejection?: UnreadAgentSessionFailureFact }, - commandTurnReported: boolean -): QueuedMessageRow { - if (rejectedDraftSettlement(rejected).state === 'waiting') { - return { - ...card, - state: 'waiting', - holdReason: null, - consumedAs: null, - returnedReason: null, - returnedRejection: null, - settledAt: null, - settledByOp: null - } - } - if (card.body.command && commandTurnReported) { - return { ...card, state: 'withdrawn' } - } - return { - ...card, - state: 'returned', - returnedReason: rejected.reason, - returnedRejection: rejected.rejection ?? null - } -} - -/** The stored card, settled as its refused hand-off says; any other card is the same object. */ -export function effectiveQueuedCard(row: QueuedMessageRow, state: FoldFacts): QueuedMessageRow { - const submission = row.consumedAs === null ? undefined : state.submissions.get(row.consumedAs) - if (row.state !== 'dispatched' || !submission || !consumedSubmissionWasRejected(submission)) { - return row - } - return rejectedCardTransition( - row, - submission, - commandTurnReported(state, submission.clientMessageId) - ) -} - -/** The submission's command turn is in the journal: its own row reports the refusal. */ -export function commandTurnReported(state: FoldFacts, clientMessageId: string): boolean { - return state.items.has( - agentJournalItemKey(structuredAgentSessionCommandTurnIdentity(clientMessageId)) - ) -} - -/** The cards readers see over the stored rows, and the durable copy an action writes first. */ -export class EffectiveQueuedCardView { - private stored: { revision: number; rows: readonly QueuedMessageRow[] } | null = null - private viewed: { key: string; cards: readonly QueuedMessageRow[] } | null = null - - constructor( - private readonly deps: Pick< - JournalQueuedMessagesDeps, - 'sessionId' | 'now' | 'database' | 'state' - >, - /** Bumped on every draft-table write. */ - private readonly tableRevision: () => number - ) {} - - /** Also moves when the fold settles a stored hand-off, which changes a card with no write. */ - revision(): string { - const state = this.deps.state() - const handOffs = this.rows().flatMap((row) => - row.state === 'dispatched' ? [effectiveQueuedCard(row, state).state] : [] - ) - return `${this.tableRevision()}:${handOffs.join()}` - } - - /** 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. */ - cards(): readonly QueuedMessageRow[] { - const key = this.revision() - if (this.viewed?.key !== key) { - const state = this.deps.state() - this.viewed = { key, cards: this.rows().map((row) => effectiveQueuedCard(row, state)) } - } - return this.viewed.cards - } - - card(messageId: string): QueuedMessageRow | null { - const row = getQueuedMessage(this.deps.database().db, this.deps.sessionId, messageId) - return row && effectiveQueuedCard(row, this.deps.state()) - } - - /** Inside a transaction: read from `db`, never cached under state a rollback could undo. */ - cardsIn(db: Database.Database): QueuedMessageRow[] { - const state = this.deps.state() - return listQueuedMessages(db, this.deps.sessionId).map((row) => effectiveQueuedCard(row, state)) - } - - /** An action first stores its own cards as readers see them, so its compare-and-set matches what - * the person acted on; only its own, so another card's failed bookkeeping never blocks it. - * Returns how many changed. */ - store(db: Database.Database, messageIds: readonly string[]): number { - const { sessionId } = this.deps - const state = this.deps.state() - return writeEffectiveQueuedCards(db, { sessionId, now: this.deps.now(), messageIds }, (row) => - effectiveQueuedCard(row, state) - ) - } - - /** Some stored row is not the card readers see: its durable copy is still unwritten. */ - storedBehind(): boolean { - const cards = this.cards() - return this.rows().some((row, index) => row !== cards[index]) - } - - private rows(): readonly QueuedMessageRow[] { - const revision = this.tableRevision() - if (this.stored?.revision !== revision) { - this.stored = { - revision, - rows: listQueuedMessages(this.deps.database().db, this.deps.sessionId) - } - } - return this.stored.rows - } -} - -/** The one writer of a refused hand-off's card: stores `effective`'s answer wherever it differs - * from the stored row, matched on that row's hand-off. `messageIds` limits it to an action's own - * cards. Returns how many cards changed. */ -export function writeEffectiveQueuedCards( - db: Database.Database, - input: { sessionId: string; now: number; messageIds?: readonly string[] }, - effective: (row: QueuedMessageRow) => QueuedMessageRow -): number { - const rows = input.messageIds - ? input.messageIds.flatMap((id) => getQueuedMessage(db, input.sessionId, id) ?? []) - : listQueuedMessages(db, input.sessionId) - const write = db.prepare( - `UPDATE queued_messages - SET state = ?, hold_reason = ?, consumed_as = ?, returned_reason = ?, returned_rejection = ?, - settled_at = ?, settled_by_op = ? - WHERE session_id = ? AND message_id = ? AND state = 'dispatched' AND consumed_as = ?` - ) - let written = 0 - for (const row of rows) { - const card = effective(row) - if (card === row) { - continue - } - const changed = write.run( - card.state, - card.holdReason, - card.consumedAs, - card.returnedReason, - card.returnedRejection ? JSON.stringify(card.returnedRejection) : null, - card.state === 'waiting' ? null : input.now, - card.settledByOp, - input.sessionId, - row.messageId, - row.consumedAs - ) - written += Number(changed.changes ?? 0) - } - return written -} 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-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 a0783f4d6474..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-settlement.ts +++ /dev/null @@ -1,120 +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 every reader takes the card the fold already settled -// (`effectiveQueuedCard`), and the open-time repair and the drain write that -// card's durable copy. - -import type Database from '../../sqlite/sync-database' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { journalDispatchRowNewlyRejects } from './journal-dispatch-settlement' -import type { JournalReducerState } from './journal-reducer' -import type { JournalRow } from './journal-row-schema' -import { draftDeliveredByEcho, draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' -import { - commandTurnReported, - effectiveQueuedCard, - rejectedCardTransition, - writeEffectiveQueuedCards -} from './queued-message-effective-card' -import { - listQueuedMessages, - withdrawQueuedMessages, - type QueuedMessageRow -} from './queued-message-table' - -type Submissions = ReadonlyMap - -/** 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') - }) -} - -/** Stores each card the fold already settled (`effectiveQueuedCard`), 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 { - let settled = writeEffectiveQueuedCards(db, input, (row) => effectiveQueuedCard(row, input.state)) - 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. Stored first - // as readers see it, so a card still stored as handed off is withdrawn too. - changed += writeEffectiveQueuedCards(db, { ...input, messageIds: [delivered] }, (card) => - effectiveQueuedCard(card, input.state) - ) - 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 - } - // Not folded in yet, so the row's own facts take the view's decision. - const reported = commandTurnReported(input.state, row.clientMessageId) - return ( - changed + - writeEffectiveQueuedCards(db, input, (card) => - card.state === 'dispatched' && card.consumedAs === row.clientMessageId - ? rejectedCardTransition(card, row, reported) - : card - ) - ) -} 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 16094cfd45e6..9c8a93bc2bfd 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 { isAgentSessionOptions } from '../../../shared/agent-session-record' -/** 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 8d37444a2d5f..03d46c0eac9a 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,244 +1,52 @@ -// 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, a withdrawal waits again, delivery 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 { readStoredQueuedMessageRow } from './queued-message-stored-row' 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 /** 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 model and effort its send carried, applied to the turn it starts when it goes out. */ options?: Readonly> } - -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?: Readonly> - } -): 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 -} - -/** 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-text-update.ts b/src/main/native-chat/agent-session-journal/queued-message-text-update.ts index 1c631b37c82f..c56c7f58dce6 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-text-update.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-text-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 { queuedMessageWithEditedText } from '../../../shared/queued-message-text-edit' -import { - getQueuedMessage, - isUnsettledQueuedMessage, - type QueuedMessageRow -} from './queued-message-table' +import { isUnsettledQueuedMessage, type QueuedMessageRow } from './queued-message-table' export type QueuedMessageTextUpdate = { messageId: string @@ -43,24 +38,3 @@ export function queuedMessageTextUpdateDecision( } return { status: 'ready', 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. */ -export function updateQueuedMessageText( - db: Database.Database, - sessionId: string, - input: QueuedMessageTextUpdate -): AgentSessionQueuedMessageUpdateResult { - const decision = queuedMessageTextUpdateDecision( - getQueuedMessage(db, sessionId, input.messageId), - sessionId, - input - ) - if (decision.status !== 'ready') { - return decision - } - 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/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-event-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-recovery.ts index b695435fd31e..b7e253816c7b 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, @@ -70,12 +69,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-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-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 99d276c64ca6..b2c97c124a4a 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' @@ -207,9 +203,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. ...(params.options ? { options: params.options } : {}), ...(params.userSend ? { requireAttachments: true } : {}) @@ -274,9 +267,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 } @@ -309,16 +301,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) @@ -341,23 +323,16 @@ export class StructuredAgentSessionQueuedMessageDrain { fence, handoverRecorded: true }, - { - messageId: next.messageId, - expect: 'waiting', - settledByOp: null, - hostInstance: structuredAgentSessionHostInstance(), - yieldsToPause: true - } + { messageId: next.messageId, expect: 'waiting', yieldsToPause: true } ) } catch (error) { if (error instanceof QueuedMessageNotConsumableError) { // 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-send-answer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send-answer.ts index e0eb83e0a0b5..f309f3eb9572 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-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/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index 1f556e922528..b61de04a15b5 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -26,8 +26,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 { From 4daf94e1f24b29f0dc1bab7cf47dd6b6042c9d51 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:33:24 -0700 Subject: [PATCH 03/23] test(native-chat): adapt the queue tests to cards kept in the journal Cards are written through the journal, so tests queue them with their body alone and hand them off under their real fingerprint. Tests of the removed settlement hook, heal and SQL store are deleted; the text-update spec now covers the edit decision alone, and a test of an earlier build's stored holds seeds the legacy table and reads the imported cards. Rolling over an epoch now keeps a card whose hand-off was never handed over, so that test checks the retried id never sends rather than that nothing sends. --- .../agent-session-attachment-claims.test.ts | 22 +- .../command-receipt-transaction.spec.ts | 1 + .../journal-closed-sets.test.ts | 12 +- .../journal-epoch-replacement.spec.ts | 1 + .../journal-queued-message-contracts.test.ts | 4 - .../journal-submission-queued-link.spec.ts | 24 +- .../journal-unsent-send-hold.spec.ts | 59 +- ...queued-message-bookkeeping-failure.spec.ts | 225 ----- .../queued-message-delivered-echo.spec.ts | 85 +- .../queued-message-edit-leases.test.ts | 6 - .../queued-message-pause.spec.ts | 51 +- .../queued-message-store.spec.ts | 838 ------------------ .../queued-message-text-update.spec.ts | 194 ++-- ...uctured-agent-session-clear-rewind.test.ts | 4 +- ...ent-session-codex-version-recovery.test.ts | 3 - ...-agent-session-ended-prompt-cancel.test.ts | 4 +- ...ent-session-queued-action-receipts.test.ts | 26 +- ...ed-agent-session-queued-card-holds.test.ts | 9 +- ...ructured-agent-session-queued-edit.test.ts | 4 +- ...session-queued-message-rig.test-fixture.ts | 2 - ...ured-agent-session-queued-messages.test.ts | 40 +- ...ent-session-queued-withdrawn-draft.test.ts | 51 -- ...ssion-restart-interruption-test-harness.ts | 5 - ...tructured-agent-session-stop-event.test.ts | 4 +- ...red-agent-session-unsent-send-hold.test.ts | 63 +- ...-session-conversation-clear-commit.test.ts | 13 +- ...ured-agent-session-failure-logging.test.ts | 12 +- 27 files changed, 187 insertions(+), 1575 deletions(-) delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-store.spec.ts 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..a0d7358b006b 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 @@ -177,6 +177,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-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-queued-message-contracts.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-message-contracts.test.ts index c4094a959f7c..67a069ed7396 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,8 @@ import { afterEach, describe, vi } from 'vitest' import { registerJournalPiContextImportTests } from './journal-pi-context-import.spec' import { registerJournalProducerInheritanceTests } from './journal-producer-inheritance.spec' import { registerJournalUnsentSendHoldTests } from './journal-unsent-send-hold.spec' -import { registerQueuedMessageBookkeepingFailureTests } from './queued-message-bookkeeping-failure.spec' import { registerQueuedMessageDeliveredEchoTests } from './queued-message-delivered-echo.spec' import { registerQueuedMessagePauseTests } from './queued-message-pause.spec' -import { registerQueuedMessageStoreTests } from './queued-message-store.spec' import { registerQueuedMessageTextUpdateTests } from './queued-message-text-update.spec' afterEach(() => vi.restoreAllMocks()) @@ -13,8 +11,6 @@ afterEach(() => vi.restoreAllMocks()) describe('journal-pi-context-import', registerJournalPiContextImportTests) describe('journal-producer-inheritance', registerJournalProducerInheritanceTests) describe('journal-unsent-send-hold', registerJournalUnsentSendHoldTests) -describe('queued-message-bookkeeping-failure', registerQueuedMessageBookkeepingFailureTests) describe('queued-message-delivered-echo', registerQueuedMessageDeliveredEchoTests) describe('queued-message-pause', registerQueuedMessagePauseTests) -describe('queued-message-store', registerQueuedMessageStoreTests) describe('queued-message-text-update', registerQueuedMessageTextUpdateTests) diff --git a/src/main/native-chat/agent-session-journal/journal-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-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/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 ffe91e1ce13d..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('returned') - 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 index 490f66834819..290dbc4e0b00 100644 --- 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 @@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { AgentJournalMessageItem, @@ -14,7 +14,6 @@ import type { 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' @@ -74,12 +73,7 @@ export function registerQueuedMessageDeliveredEchoTests(): void { ): Promise { const body = message(text) const fingerprint = agentSessionSendBodyFingerprint(IDENTITY.sessionId, body) - await journal.queuedMessages.insert({ - messageId: 'draft-1', - body, - fingerprint, - hostInstance: 'p' - }) + await journal.queuedMessages.insert({ messageId: 'draft-1', body }) await journal.appendSubmission( { clientMessageId: 'sub-draft-1', @@ -88,7 +82,7 @@ export function registerQueuedMessageDeliveredEchoTests(): void { fence: 0, handoverRecorded: true }, - { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + { messageId: 'draft-1', expect: 'waiting' } ) if (options.handedOver) { await journal.resolveDispatch({ @@ -126,10 +120,7 @@ export function registerQueuedMessageDeliveredEchoTests(): void { 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 - }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ state: 'withdrawn' }) // 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) @@ -162,63 +153,19 @@ export function registerQueuedMessageDeliveredEchoTests(): void { 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('the echo withdraws a card the skipped rejection hook left stored as handed off', async () => { - const journal = await open() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const settle = JournalQueuedMessages.prototype.onRowInTransaction - vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementation( - function (this: JournalQueuedMessages, db, row) { - if (row.kind === 'dispatch' && row.state === 'rejected') { - throw new Error('bookkeeping failed') - } - return settle.call(this, db, row) - } - ) - try { - await handOffAndReject(journal, 'did it land?') - } finally { - vi.restoreAllMocks() - } - expect(journal.queuedMessages.settlementOwed()).toBe(true) - await echo(journal, 'echo-1', 'did it land?') - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - expect(journal.queuedMessages.settlementOwed()).toBe(false) - }) + it('stays withdrawn when the chat reopens: replay folds the same rows', async () => { + let journal = await withdrawnDraft('did it land?') + await echo(journal, 'echo-1', 'did it land?') + 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') - }) + 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.get('draft-1')?.state).toBe('waiting') }) }) } 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-pause.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.spec.ts index 328960d45e5e..7fbb5787e108 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,12 +62,7 @@ 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) { @@ -309,7 +308,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 +316,6 @@ export function registerQueuedMessagePauseTests(): void { { messageId: 'draft-1', expect: 'waiting', - settledByOp: null, - hostInstance: HOST, ...(automatic ? { yieldsToPause: true as const } : {}) } ) @@ -433,12 +430,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 +460,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 +468,6 @@ export function registerQueuedMessagePauseTests(): void { { messageId: 'newer', expect: 'waiting', - settledByOp: null, - hostInstance: HOST, yieldsToPause: true } ) @@ -551,12 +546,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 +617,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-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-text-update.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-text-update.spec.ts index 4b760d94cc29..7e9bb5c2e957 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-text-update.spec.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-text-update.spec.ts @@ -1,47 +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 { updateQueuedMessageText } from './queued-message-text-update' +import { describe, expect, it } from 'vitest' +import { queuedMessageTextUpdateDecision } from './queued-message-text-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 registerQueuedMessageTextUpdateTests(): void { - let db: Database.Database const sessionId = 'folder-session' const initial: AgentJournalMessageItem = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'base' }] } - beforeEach(() => { - db = new Database(':memory:') - ensureQueuedMessagesTable(db) - }) - afterEach(() => db.close()) - function insert(body = initial, messageId = 'card') { - return insertQueuedMessage(db, { - sessionId, - messageId, + 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 updateQueuedMessageText(db, sessionId, { + function decide(row: QueuedMessageRow | null, text: string, base = row?.fingerprint ?? '') { + return queuedMessageTextUpdateDecision(row, sessionId, { messageId: 'card', - expectedBodyFingerprint: fingerprint, + 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', @@ -53,131 +47,71 @@ export function registerQueuedMessageTextUpdateTests(): 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( - updateQueuedMessageText(db, sessionId, { - messageId: 'image', - expectedBodyFingerprint: imageOnly.fingerprint, - text: 'added' - }).status - ).toBe('updated') - expect(getQueuedMessage(db, sessionId, 'image')?.body.blocks).toEqual([ - { type: 'text', text: 'added' }, - { type: 'image-ref', path: 'file.png' } - ]) - }) - it('a failed transaction rolls back body and fingerprint together', () => { - const before = insert() - db.exec('BEGIN IMMEDIATE') - update('edit') - db.exec('ROLLBACK') - expect(getQueuedMessage(db, sessionId, 'card')).toEqual(before) + 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-wire/structured-agent-session-clear-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts index 0a49400f0c51..dae9003cd7b1 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 b01020f7203b..f14b3a4ecdea 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 { CodexCliInstallationError } from '../../codex/codex-cli-installation-er 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, @@ -66,8 +65,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-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-queued-action-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts index 83b98e806eec..4e63b3bb7ed4 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 @@ -168,25 +168,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') @@ -253,7 +234,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() @@ -263,7 +244,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) }) }) 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 694482b418b0..519639317bdf 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 @@ -173,9 +173,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-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index dc00adacc46f..a5992a9136da 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 b00a1e63752b..1102e6383b98 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, @@ -168,8 +165,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)) { @@ -222,32 +219,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 @@ -344,9 +315,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-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-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index e6c917ba563f..6552e6789831 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 @@ -295,8 +295,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/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-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index 527dda544091..e25c0faae7f5 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -188,11 +188,19 @@ describe('host collaborators log with their own scope', () => { it('the queued-message drain logs a step that fails', async () => { const log = recordingStructuredAgentSessionLogger() - const journal = { queuedMessages: { settlementOwed: () => true } } + const card = { messageId: 'card', state: 'waiting', holdReason: null, queuedAt: null } + const journal = { + queuedMessages: { + list: () => [card], + pauses: () => [], + editLeases: { heldIds: () => new Set() } + } + } const drain = new StructuredAgentSessionQueuedMessageDrain( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a schedule past the owed-settlement check reads only these members. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a schedule past the sendable-card check reads only these members. { sessions: new Map([[SESSION, { journal }]]), + currentWork: () => ({ working: () => false }), serialize: rejectingSerialize, logger: log.logger } as unknown as QueuedMessageDrainDeps From 62a0bcf8ff99126df645132a6139fbfec794009d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:33:37 -0700 Subject: [PATCH 04/23] test(native-chat): a refused card holds the queue through rewind, a new epoch and a restart One whole-flow test through the host: the refusal writes no card row, a rewind that retires the refused hand-off and a rolled-over epoch keep the card returned, a restart and Resume send nothing behind it, its own Send refused again still holds the queue, and its Delete releases the next card in order. --- ...-agent-session-queued-refused-card.test.ts | 180 ++++++++++-------- 1 file changed, 102 insertions(+), 78 deletions(-) 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 index 2d01b59c336f..8a94be5687f0 100644 --- 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 @@ -1,31 +1,33 @@ -// A refused queued card comes back as a returned card that holds the queue even when every write -// of the card fails: the live hook, the drain's heal and the open-time repair. The card behind it -// never sends, Resume does not release it, and its own Send and Delete work. +// 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 { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' -import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +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 () => { - vi.useFakeTimers() - rig = await createQueuedMessageTestRig({ restartable: true }) + rewind.mockReset().mockResolvedValue({ ok: true }) + rig = await createQueuedMessageTestRig({ restartable: true, rewind }) }) -afterEach(async () => { - vi.restoreAllMocks() - try { - await rig.dispose() - } finally { - vi.useRealTimers() - } -}) +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 @@ -35,83 +37,105 @@ async function queuedDraft(text: string): Promise { return queued.value.queued.messageId } -/** Lets the drain run its steps; none may hand a card off. */ +/** 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 vi.advanceTimersByTimeAsync(250) + await new Promise((resolve) => setTimeout(resolve, 100)) for (const draftId of draftIds) { expect(await rig.handoff(draftId)).toBeUndefined() } } -/** A and B wait behind a turn; A goes out and the agent refuses it while no card write lands. */ -async function refusedWhileCardWritesFail(): Promise<{ first: string; second: string }> { - const working = await rig.workingSend() - const first = await queuedDraft('A') - const second = await queuedDraft('B') - await rig.settleAccepted(working, 'a') - await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - const refused = await rig.handoffId(first) - const settle = JournalQueuedMessages.prototype.onRowInTransaction - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementation(function ( - this: JournalQueuedMessages, - db, - row - ) { - if (row.kind === 'dispatch' && row.clientMessageId === refused && row.state === 'rejected') { - throw new Error('bookkeeping failed') - } - return settle.call(this, db, row) - }) - vi.spyOn(JournalQueuedMessages.prototype, 'settleOwed').mockRejectedValue(new Error('heal')) - vi.spyOn(JournalQueuedMessages.prototype, 'repairAndPrune').mockRejectedValue(new Error('repair')) - await rig.settleRejected(refused, 'provider refused this payload') - await eventually(async () => - expect(await rig.drafts()).toEqual([ - { messageId: first, state: 'returned' }, - { messageId: second, state: 'waiting' } - ]) - ) - expectStoredAsHandedOff() - return { first, second } +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) } -/** The injected failures held: the stored row still reads handed off, only the view returns it. */ -function expectStoredAsHandedOff(): void { - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - expect(journal?.queuedMessages.settlementOwed()).toBe(true) -} +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' }))) -it('holds the card behind it across a restart and a Resume, until it is deleted', async () => { - const { first, second } = await refusedWhileCardWritesFail() - const page = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + // 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' }) - await expectNothingSent(second) - // Reopened with the card still stored as handed off: Resume lifts the restart's pause only. - await rig.restartHostProcess() - expect(await rig.drafts()).toEqual([ - { messageId: first, state: 'returned' }, - { messageId: second, state: 'waiting' } - ]) - expectStoredAsHandedOff() - expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) - await expectNothingSent(second) - expect(await rig.deleteQueued(first)).toMatchObject({ ok: true, value: { deleted: true } }) - await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) -}) -it('Send hands the refused card off again under a fresh id, ahead of the card behind it', async () => { - const { first, second } = await refusedWhileCardWritesFail() - const refused = await rig.handoffId(first) - const resent = await rig.sendNow(first) + // 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: first }) - expect(resent.value.submission.clientMessageId).not.toBe(refused) - await expectNothingSent(second) - await rig.settleAccepted(resent.value.submission.clientMessageId, 'a-again') - await eventually(async () => expect(await rig.handoff(second)).toBeDefined()) + 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() }) From 4db1581fd8fb6ca74d605f7646c3b46ae343fd5b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:36:51 -0700 Subject: [PATCH 05/23] test(native-chat): an earlier build's queued cards move into the journal once, each read the same One mixed-import test through the host. The first open's import fails and moves nothing, yet every card reads as it will; Delete and Send each move only their own card. The next open moves the rest in one transaction, writing a refusal only where its hand-off is gone, nothing for answered cards, and leaving an unreadable row on disk. A later command turn spends a moved command card, a third open writes nothing, an old Delete replays from its receipt, and a chat with only answered legacy rows clears them in a delete-only transaction. --- ...agent-session-queued-legacy-import.test.ts | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-legacy-import.test.ts 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..3122df9b9ebd --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-legacy-import.test.ts @@ -0,0 +1,270 @@ +// 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 { 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 { 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, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig({ restartable: 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: Parameters[0]) => + 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' +): Promise { + const body = message(text) + const payloadFingerprint = agentSessionSendBodyFingerprint(journal.queuedMessages.sessionId, body) + await journal.appendSubmission({ + clientMessageId: attempt, + payloadFingerprint, + body, + fence: 0, + handoverRecorded: true, + queuedMessageId: card + }) + await journal.resolveDispatch({ + clientMessageId: attempt, + state: 'pending', + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.resolveDispatch( + as === 'accepted' + ? { clientMessageId: attempt, state: 'accepted', providerIdentity: null, fence: 0 } + : { clientMessageId: attempt, state: 'rejected', ...words(as), fence: 0 } + ) +} + +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 reason = state === 'returned' ? 'refused before' : null + insert.run(sessionId, id, index + 1, body, 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' + ) + await handOff(earlier, 'att-echoed', 'disp-echoed', 'withdrawn, yet delivered', 'cancelled') + await earlier.appendItem( + { provider: 'codex', threadId: 'thread', turnId: 'turn-echo', ordinal: 0 }, + message('withdrawn, yet delivered'), + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + 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'], + ['disp-echoed', 'dispatched', 'withdrawn, yet delivered', 'att-echoed'], + ['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: 'edited', state: 'waiting' }, + { messageId: 'held', state: 'waiting', paused: true } + ] + expect(await rig.drafts()).toEqual(published) + 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', + 'orphan:queued', + 'edited:queued', + 'held:queued' + ]) + + // The command card's own turn reports its refusal later: it is spent, not left holding the queue. + const live = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + await live.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()) + expect(await rig.handoff('held')).toBeUndefined() + + // 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(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() +}) From 6d22d2974ad73ddae4438c3a4683f1da8e85631a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:50:44 -0700 Subject: [PATCH 06/23] test(native-chat): adapt typechecked tests to cards kept in the journal The row-kind table lists a queue row, the clear and legacy-import tests match the current signatures, and the downgrade test keeps only the leftover-send case: a queued card is now a newer-version journal row an older build keeps read-only, and users don't downgrade. --- .../journal-unknown-row-kind.spec.ts | 12 ++ .../queued-message-pause.spec.ts | 3 +- ...agent-session-queued-legacy-import.test.ts | 2 +- .../kept-card-downgrade.unit.test.ts | 104 +----------------- 4 files changed, 19 insertions(+), 102 deletions(-) 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/queued-message-pause.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.spec.ts index 7fbb5787e108..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 @@ -68,8 +68,7 @@ export function registerQueuedMessagePauseTests(): void { function clearContext(journal: AgentSessionJournal) { return journal.context.clear( { operationId: `clear-${++clock}`, afterFence: 0, clearedAt: clock }, - { write: () => {}, committed: () => {} }, - `caller-clear-${clock}` + { write: () => {}, committed: () => {} } ) } 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 index 3122df9b9ebd..b9ece67ef049 100644 --- 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 @@ -39,7 +39,7 @@ const message = (text: string): AgentJournalMessageItem => ({ blocks: [{ type: 'text', text }], ...(text === '/compact' ? { command: { name: 'compact' } } : {}) }) -const words = (fact: Parameters[0]) => +const words = (fact: 'providerRejected' | 'cancelled') => agentSessionFailureWords( fact === 'providerRejected' ? agentSessionFailureFact(fact, { detail: { text: 'refused', audience: 'person' } }) 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() From a89bca365683576892d0ee40896345ba11a2e370 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:50:46 -0700 Subject: [PATCH 07/23] refactor(native-chat): drop the journal row writer's unused transaction hook No caller passes one since the queue's savepoint bookkeeping left. --- .../command-receipt-transaction.spec.ts | 1 - .../agent-session-journal/journal-row-writer.ts | 12 ++---------- .../agent-session-journal/journal-store.ts | 4 ++-- 3 files changed, 4 insertions(+), 13 deletions(-) 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 a0d7358b006b..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') 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 f0a75a68b7b3..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. */ @@ -62,7 +57,6 @@ export class JournalRowWriter { enqueue( build: (seq: number, ts: number) => JournalRow, - hook?: JournalRowTransactionHook, receipt?: JournalOperationReceipt ): Promise { return this.deps.serialize(() => { @@ -74,10 +68,9 @@ export class JournalRowWriter { this.deps.database().transaction( (db) => { insertJournalRow(db, this.deps.sessionId, row, savedAt) - hook?.(db, row) receipt?.write(db, row) }, - !hook && !receipt && this.streamed([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 @@ -133,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 })) 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 6422d3e512f3..c3b8331f7345 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -130,7 +130,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 @@ -314,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`). */ From 627e8a3d92bc1705b495ad8220b4d172bd134c84 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:03:50 -0700 Subject: [PATCH 08/23] test(native-chat): downgrade probes write rows at the version older builds read The highest version this build reads is now 4, the queue row's. A newer build's row in these probes is written at version 3 so the older build meets its kind, not a newer version, and the Stop-event probe hands the older build the rows as stored, since a read raises each row's version. --- .../agent-session-newer-chat-refusal.unit.test.ts | 5 +++-- .../agent-session-stop-event-downgrade.unit.test.ts | 13 ++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) 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, From 9e535d9c549a664de5cde9caf98d1ca36fb17aef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:05:33 -0700 Subject: [PATCH 09/23] test(native-chat): unit tests follow the queue row's version 4 The legacy-import test opens real SQLite, so it runs in the Node runtime project. The newest version this build reads is now 4, and a read raises a row's version to it, so the turn-lifecycle test no longer pins the in-memory version (the written one stays pinned by journalRowSchemaVersion). --- config/scripts/vitest-node-runtime-files.mjs | 1 + ...codex-structured-journal-translation-turn-lifecycle.test.ts | 1 - src/shared/agent-session-turn-record.test.ts | 3 ++- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 4387f9358039..412584d56452 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -87,6 +87,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-status-first-input-identity.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/native-chat/structured-chat-naming-command-first.test.ts', 'src/main/native-chat/structured-chat-naming-status-hook.test.ts', 'src/main/runtime/agent-session-conversation-name-store.test.ts', 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 0f51961b64d7..6a1d991497cc 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/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) From db686695d3acae265b25b4a7e8fba04b3085e8ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:08:00 -0700 Subject: [PATCH 10/23] fix(native-chat): one batch echoing two refused queued messages spends both The per-row delivery check returned only the first waiting card an echo proved delivered, so when one settlement carried echoes for two cards the second stayed waiting and was sent again. It now returns every card the row proves delivered, and the fold withdraws each; replay folds the same. --- .../journal-queue-fold.ts | 18 ++++++---- .../queued-message-delivered-echo.spec.ts | 36 +++++++++++++------ .../queued-message-delivered-echo.ts | 23 +++++------- 3 files changed, 46 insertions(+), 31 deletions(-) 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 index 9fdcbd40206f..f51b931da361 100644 --- a/src/main/native-chat/agent-session-journal/journal-queue-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-queue-fold.ts @@ -16,7 +16,10 @@ import { rejectedDraftSettlement } 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 { draftDeliveredByEcho, draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo' +import { + draftsDeliveredByAppliedEcho, + draftsDeliveredByEcho +} from './queued-message-delivered-echo' import type { QueuedMessageRow } from './queued-message-table' export type JournalQueueState = { @@ -114,17 +117,18 @@ export function queuedCardFromChange( return deliveredWaitingCard(state, card) } -/** Before an item or batch applies: a provider echo proving a waiting card's earlier hand-off was - * delivered spends the card, so it is never sent twice. */ +/** Before an item or batch applies: each provider echo proving a waiting card's earlier hand-off + * was delivered spends that card, so none is sent twice. */ export function foldJournalQueueEcho(state: JournalReducerState, row: JournalRow): void { const { cards } = state.queue if (cards.size === 0 || (row.kind !== 'item' && row.kind !== 'lifecycle-batch')) { return } - const delivered = draftDeliveredByEcho(state, () => [...cards.values()], row) - const card = delivered === null ? undefined : cards.get(delivered) - if (card) { - setCard(state, { ...card, state: 'withdrawn', holdReason: null }) + for (const delivered of draftsDeliveredByEcho(state, () => [...cards.values()], row)) { + const card = cards.get(delivered) + if (card) { + setCard(state, { ...card, state: 'withdrawn', holdReason: null }) + } } } 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 index 290dbc4e0b00..c49187c02a02 100644 --- 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 @@ -69,30 +69,31 @@ export function registerQueuedMessageDeliveredEchoTests(): void { async function handOffAndReject( journal: AgentSessionJournal, text: string, - options: { handedOver: boolean } = { handedOver: true } + options: { handedOver: boolean } = { handedOver: true }, + draft = 'draft-1' ): Promise { const body = message(text) const fingerprint = agentSessionSendBodyFingerprint(IDENTITY.sessionId, body) - await journal.queuedMessages.insert({ messageId: 'draft-1', body }) + await journal.queuedMessages.insert({ messageId: draft, body }) await journal.appendSubmission( { - clientMessageId: 'sub-draft-1', + clientMessageId: `sub-${draft}`, payloadFingerprint: fingerprint, body, fence: 0, handoverRecorded: true }, - { messageId: 'draft-1', expect: 'waiting' } + { messageId: draft, expect: 'waiting' } ) if (options.handedOver) { await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', + clientMessageId: `sub-${draft}`, state: 'pending', fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', + clientMessageId: `sub-${draft}`, state: 'rejected', ...STOP_WITHDRAWAL, fence: 0 @@ -100,10 +101,7 @@ export function registerQueuedMessageDeliveredEchoTests(): void { } else { await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) } - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - consumedAs: null - }) + expect(journal.queuedMessages.get(draft)).toMatchObject({ state: 'waiting', consumedAs: null }) } beforeEach(async () => { @@ -126,6 +124,24 @@ export function registerQueuedMessageDeliveredEchoTests(): void { expect(journal.snapshot().items.map((item) => item.itemId)).toHaveLength(2) }) + it('withdraws every draft one batch echoes, so neither is sent again', async () => { + const journal = await withdrawnDraft('first') + await handOffAndReject(journal, 'second', { handedOver: true }, 'draft-2') + const echoed = (uuid: string, text: string) => ({ + kind: 'item' as const, + identity: { provider: 'claude' as const, sessionId: 'native-1', uuid }, + body: message(text), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendLifecycleBatch({ + settlementId: 'turn-settled', + fence: 0, + mutations: [echoed('echo-1', 'first'), echoed('echo-2', 'second')] + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + expect(journal.queuedMessages.get('draft-2')?.state).toBe('withdrawn') + }) + 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?') 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 index 732e4bce589b..e87b274a116c 100644 --- 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 @@ -13,14 +13,14 @@ import { 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. The fold - * calls it before the row applies, for every item row, so a row that is no new - * provider echo of a user message returns before anything else is read. */ -export function draftDeliveredByEcho( +/** The waiting drafts this appended row proves were delivered. The fold calls it before the row + * applies, for every item row, so a row that is no new provider echo of a user message returns + * before anything else is read. */ +export function draftsDeliveredByEcho( state: JournalReducerState, drafts: () => readonly QueuedMessageRow[], row: JournalRow -): string | null { +): string[] { const echoes = appendedItems(row).filter( (item) => isProviderUserMessageEcho(item.itemId, item.body) && @@ -29,16 +29,11 @@ export function draftDeliveredByEcho( 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 [] } - return null + return spentWaitingDrafts(state, drafts()) + .filter((spent) => echoes.some((item) => echoProvesDelivered(state, spent, item.body, row.seq))) + .map((spent) => spent.draft.messageId) } /** From 0d31aca5d8f55cdf8bfdc5bb7a180f182d36ce3b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:14:23 -0700 Subject: [PATCH 11/23] fix(native-chat): a rewind moves an earlier build's cards into the journal first While the open's import had failed, an earlier build's card that read as refused through its hand-off lost that reading when an in-place rewind retired the hand-off: it disappeared and the card behind it sent. The rewind now adopts every legacy card ahead of its tombstones and deletes their rows in its own transaction, as an epoch replacement already does, so the fold latches each card's reading before its hand-off is gone. --- .../journal-context-rewind.ts | 17 ++++- .../journal-queue-import.ts | 7 +- .../journal-queued-messages.ts | 7 ++ ...agent-session-queued-legacy-import.test.ts | 73 +++++++++++++++++-- 4 files changed, 93 insertions(+), 11 deletions(-) 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-queue-import.ts b/src/main/native-chat/agent-session-journal/journal-queue-import.ts index 54c182e8090a..43fd817fe212 100644 --- a/src/main/native-chat/agent-session-journal/journal-queue-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-queue-import.ts @@ -130,9 +130,14 @@ export class LegacyQueuedCards { } } + /** 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.rows().map((row) => row.messageId)) + return this.deletion(this.messageIds()) } /** Every readable row, unanswered ones as journal rows, all deleted, in ONE transaction; a 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 e501991fb500..97092f5fe3da 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 @@ -239,6 +239,13 @@ export class JournalQueuedMessages { } } + /** 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() } + } + /** The bulk move of an earlier build's cards, once per open (`LegacyQueuedCards.importAtOpen`). */ importAtOpen(): Promise { return this.legacy.importAtOpen() 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 index b9ece67ef049..c8b319dda28a 100644 --- 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 @@ -3,6 +3,7 @@ // 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 { @@ -21,13 +22,14 @@ import { 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 }) + rig = await createQueuedMessageTestRig({ restartable: true, rewind: async () => ({ ok: true }) }) }) afterEach(() => rig.dispose()) @@ -66,7 +68,8 @@ async function handOff( attempt: string, card: string, text: string, - as: 'providerRejected' | 'cancelled' | 'accepted' + as: 'providerRejected' | 'cancelled' | 'accepted', + fence = 0 ): Promise { const body = message(text) const payloadFingerprint = agentSessionSendBodyFingerprint(journal.queuedMessages.sessionId, body) @@ -74,20 +77,20 @@ async function handOff( clientMessageId: attempt, payloadFingerprint, body, - fence: 0, + fence, handoverRecorded: true, queuedMessageId: card }) await journal.resolveDispatch({ clientMessageId: attempt, state: 'pending', - fence: 0, + fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await journal.resolveDispatch( as === 'accepted' - ? { clientMessageId: attempt, state: 'accepted', providerIdentity: null, fence: 0 } - : { clientMessageId: attempt, state: 'rejected', ...words(as), fence: 0 } + ? { clientMessageId: attempt, state: 'accepted', providerIdentity: null, fence } + : { clientMessageId: attempt, state: 'rejected', ...words(as), fence } ) } @@ -268,3 +271,61 @@ it("moves an earlier build's cards into the journal, each action its own card, t 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([]) +}) From e096d5f3c6b4a1b54a1553b49e43fb37bb01041f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:15:34 -0700 Subject: [PATCH 12/23] fix(native-chat): keep an earlier build's card whose hand-off is still unconfirmed The import dropped a legacy card handed off as a send whose delivery was unknown, though the fold keeps such a card linked and a later refusal returns it. The import now keeps every card whose hand-off a later row can still answer, through the same check the reducer folds dispatch rows by. --- .../agent-session-journal/journal-queue-import.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 43fd817fe212..f34b40fc297c 100644 --- a/src/main/native-chat/agent-session-journal/journal-queue-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-queue-import.ts @@ -5,6 +5,7 @@ // 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 } from './journal-dispatch-settlement' import { journalQueueRowBuilder, type JournalQueuedChange } from './journal-queue-events' import { queuedCardFromChange } from './journal-queue-fold' import { queuedChangeOf } from './journal-queue-retention' @@ -50,7 +51,8 @@ export function legacyQueuedChange( return unanswered(card, state.submissions) ? change : null } -/** Waiting, returned, or handed off as a send still owed an answer, or that nothing can answer. */ +/** Waiting, returned, or handed off as a send a later row may still answer (as the fold keeps it), + * or that nothing can answer. */ function unanswered( card: QueuedMessageRow, submissions: ReadonlyMap @@ -59,7 +61,7 @@ function unanswered( return card.state !== 'withdrawn' } const handOff = card.consumedAs === null ? undefined : submissions.get(card.consumedAs) - return handOff === undefined || handOff.dispatchState === 'pending' + return handOff === undefined || journalDispatchRowApplies(handOff) } /** The legacy rows still on disk for one session, as the queue's view reads them. */ From 88c44a963469bc580f94f615523f4010207c015d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:05:13 -0700 Subject: [PATCH 13/23] fix(native-chat): one delivery echo withdraws at most one queued card One provider echo proves one delivery, yet it withdrew every waiting card with the same text whose hand-off was refused, so a second identical card was lost. The fold now pairs each echo with one card: on its own row the earliest-positioned match no other echo of that row took, and for a card that waits again the earliest applied echo no card has taken. Paired echoes live in the fold's state, so live and replay pair the same; readers of cards not yet imported work on a copy. --- .../journal-queue-fold.ts | 46 +++++++----- .../journal-queue-import.ts | 7 +- .../queued-message-delivered-echo.spec.ts | 8 +++ .../queued-message-delivered-echo.ts | 70 ++++++++++++------- 4 files changed, 86 insertions(+), 45 deletions(-) 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 index f51b931da361..be8ba4ceaa67 100644 --- a/src/main/native-chat/agent-session-journal/journal-queue-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-queue-fold.ts @@ -16,20 +16,19 @@ import { rejectedDraftSettlement } 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 { - draftsDeliveredByAppliedEcho, - draftsDeliveredByEcho -} from './queued-message-delivered-echo' +import { appliedEchoDelivering, draftsDeliveredByEcho } from './queued-message-delivered-echo' 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 + /** Echoes that already proved a card delivered: each proves one delivery, never a second. */ + pairedEchoes: Set } export function createJournalQueueState(): JournalQueueState { - return { cards: new Map(), revision: 0 } + return { cards: new Map(), revision: 0, pairedEchoes: new Set() } } /** @@ -68,12 +67,14 @@ function commandTurnReported(state: JournalReducerState, clientMessageId: string * 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. + * hand-off nothing can answer: hidden, never resent and never shown as refused. An echo it pairs + * with joins `pairedEchoes`: the fold's own set, or a reader's copy of it. */ export function queuedCardFromChange( state: JournalReducerState, messageId: string, - change: JournalQueuedChange + change: JournalQueuedChange, + pairedEchoes: Set ): QueuedMessageRow { const card: QueuedMessageRow = { messageId, @@ -98,7 +99,7 @@ export function queuedCardFromChange( holdReason: null, consumedAs: attempt } - return settledHandOff(state, handedOff, submission) + return settledHandOff(state, handedOff, submission, pairedEchoes) } const { returned } = change if (typeof returned === 'object' && returned !== null) { @@ -114,7 +115,7 @@ export function queuedCardFromChange( if (attempt !== null) { return { ...card, state: 'dispatched', holdReason: null, consumedAs: attempt } } - return deliveredWaitingCard(state, card) + return deliveredWaitingCard(state, card, pairedEchoes) } /** Before an item or batch applies: each provider echo proving a waiting card's earlier hand-off @@ -124,7 +125,12 @@ export function foldJournalQueueEcho(state: JournalReducerState, row: JournalRow if (cards.size === 0 || (row.kind !== 'item' && row.kind !== 'lifecycle-batch')) { return } - for (const delivered of draftsDeliveredByEcho(state, () => [...cards.values()], row)) { + for (const delivered of draftsDeliveredByEcho( + state, + () => [...cards.values()], + row, + state.queue.pairedEchoes + )) { const card = cards.get(delivered) if (card) { setCard(state, { ...card, state: 'withdrawn', holdReason: null }) @@ -155,7 +161,7 @@ export function foldJournalQueueRow(state: JournalReducerState, row: JournalRow) const submission = state.submissions.get(row.clientMessageId) const card = cardHandedOffAs(state, submission) if (card && submission) { - setCard(state, settledHandOff(state, card, submission)) + setCard(state, settledHandOff(state, card, submission, state.queue.pairedEchoes)) } } else if (row.kind === 'item' || row.kind === 'lifecycle-batch') { spendReportedCommandCards(state) @@ -165,7 +171,7 @@ export function foldJournalQueueRow(state: JournalReducerState, row: JournalRow) function applyQueueRow(state: JournalReducerState, row: JournalQueueRow): void { const { change } = row if (change.op === 'queued') { - setCard(state, queuedCardFromChange(state, row.messageId, change)) + setCard(state, queuedCardFromChange(state, row.messageId, change, state.queue.pairedEchoes)) return } const card = state.queue.cards.get(row.messageId) @@ -202,24 +208,32 @@ function cardHandedOffAs( function settledHandOff( state: JournalReducerState, card: QueuedMessageRow, - submission: AgentJournalSubmission + submission: AgentJournalSubmission, + pairedEchoes: Set ): QueuedMessageRow { if (submission.dispatchState !== 'rejected') { return card } const reported = commandTurnReported(state, submission.clientMessageId) - return deliveredWaitingCard(state, rejectedCardTransition(card, submission, reported)) + return deliveredWaitingCard( + state, + rejectedCardTransition(card, submission, reported), + pairedEchoes + ) } /** A card that waits again is checked against the echoes already applied, so one whose earlier * hand-off reached the agent after all is spent rather than resent. */ function deliveredWaitingCard( state: JournalReducerState, - card: QueuedMessageRow + card: QueuedMessageRow, + pairedEchoes: Set ): QueuedMessageRow { - if (card.state !== 'waiting' || draftsDeliveredByAppliedEcho(state, [card]).length === 0) { + const echo = card.state === 'waiting' ? appliedEchoDelivering(state, card, pairedEchoes) : null + if (echo === null) { return card } + pairedEchoes.add(echo) return { ...card, state: 'withdrawn', holdReason: 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 index f34b40fc297c..45134f1333d9 100644 --- a/src/main/native-chat/agent-session-journal/journal-queue-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-queue-import.ts @@ -47,7 +47,8 @@ export function legacyQueuedChange( } : {}) } - const card = queuedCardFromChange(state, row.messageId, change) + // A reader's copy: only the fold, folding the imported row, pairs an echo for good. + const card = queuedCardFromChange(state, row.messageId, change, new Set(state.queue.pairedEchoes)) return unanswered(card, state.submissions) ? change : null } @@ -92,7 +93,9 @@ export class LegacyQueuedCards { const state = this.deps.state() const cards = this.rows().flatMap((row) => { const change = legacyQueuedChange(state, row) - return change ? [queuedCardFromChange(state, row.messageId, change)] : [] + return change + ? [queuedCardFromChange(state, row.messageId, change, new Set(state.queue.pairedEchoes))] + : [] }) this.viewed = { key, cards } } 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 index c49187c02a02..8b02b99935ce 100644 --- 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 @@ -142,6 +142,14 @@ export function registerQueuedMessageDeliveredEchoTests(): void { expect(journal.queuedMessages.get('draft-2')?.state).toBe('withdrawn') }) + it('withdraws only one of two identical drafts for one echo: it proves one delivery', async () => { + const journal = await withdrawnDraft('same text') + await handOffAndReject(journal, 'same text', { handedOver: true }, 'draft-2') + await echo(journal, 'echo-1', 'same text') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + expect(journal.queuedMessages.get('draft-2')?.state).toBe('waiting') + }) + 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?') 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 index e87b274a116c..fd630f0a18b9 100644 --- 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 @@ -13,13 +13,15 @@ import { import type { JournalRow } from './journal-row-schema' import type { QueuedMessageRow } from './queued-message-table' -/** The waiting drafts this appended row proves were delivered. The fold calls it before the row - * applies, for every item row, so a row that is no new provider echo of a user message returns - * before anything else is read. */ +/** The waiting drafts this appended row proves were delivered, one per echo: each echo proves one + * delivery, so it takes the earliest-positioned match no other echo took, and joins `pairedEchoes`. + * The fold calls it before the row applies, for every item row, so a row that is no new provider + * echo of a user message returns before anything else is read. */ export function draftsDeliveredByEcho( state: JournalReducerState, drafts: () => readonly QueuedMessageRow[], - row: JournalRow + row: JournalRow, + pairedEchoes: Set ): string[] { const echoes = appendedItems(row).filter( (item) => @@ -31,38 +33,52 @@ export function draftsDeliveredByEcho( if (echoes.length === 0) { return [] } - return spentWaitingDrafts(state, drafts()) - .filter((spent) => echoes.some((item) => echoProvesDelivered(state, spent, item.body, row.seq))) - .map((spent) => spent.draft.messageId) + const candidates = spentWaitingDrafts(state, drafts()).sort( + (a, b) => a.draft.position - b.draft.position + ) + const delivered: string[] = [] + for (const item of echoes) { + const match = candidates.find( + (candidate) => + !delivered.includes(candidate.draft.messageId) && + echoProvesDelivered(state, candidate, item.body, row.seq) + ) + if (match) { + delivered.push(match.draft.messageId) + pairedEchoes.add(item.itemId) + } + } + return delivered } /** - * The same proof for a card that comes to wait again: 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, but only once some hand-off of the card was refused - * after hand-over, so a card that never left costs nothing. + * The same proof for a card that comes to wait again: the earliest echo ALREADY in the journal + * that proves it delivered and no other card took, or null. 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, but only once some hand-off of the card was refused after hand-over, so a card that never + * left costs nothing. */ -export function draftsDeliveredByAppliedEcho( +export function appliedEchoDelivering( state: JournalReducerState, - drafts: readonly QueuedMessageRow[] -): string[] { - const spent = spentWaitingDrafts(state, drafts) - if (spent.length === 0) { - return [] + draft: QueuedMessageRow, + pairedEchoes: ReadonlySet +): string | null { + const [candidate] = spentWaitingDrafts(state, [draft]) + if (!candidate) { + return null } - const delivered = new Set() + let earliest: { itemId: string; sequence: number } | null = null for (const item of state.items.values()) { - if (!isProviderUserMessageEcho(item.itemId, item.body)) { - continue - } - for (const candidate of spent) { - if (echoProvesDelivered(state, candidate, item.body, item.sequence)) { - delivered.add(candidate.draft.messageId) - } + if ( + isProviderUserMessageEcho(item.itemId, item.body) && + !pairedEchoes.has(item.itemId) && + (earliest === null || item.sequence < earliest.sequence) && + echoProvesDelivered(state, candidate, item.body, item.sequence) + ) { + earliest = item } } - return [...delivered] + return earliest?.itemId ?? null } type SpentDraft = { draft: QueuedMessageRow; since: number } From f4f944244a86b985ae3f32e89d32b5f8b0d1d278 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:20:09 -0700 Subject: [PATCH 14/23] fix(native-chat): a queued message handed to the agent is sent again only on proof it never got it Pressing Stop after Orca handed a queued or steered message to the agent, before the agent confirmed it, put the card back in the queue, and Resume or the next send sent it again. A check matching the agent's echo of the text was the only guard against a double send, and it kept trading a resend for a lost card. Whether the card waited again was decided by the rejection's category, not by proof the agent never got the message. A refused hand-off now waits again only where Orca proves the agent never received it: the host still held it, or whoever settled it recorded that. A failure still returns the card and holds the cards behind it. Anything else may have reached the agent, so the card is spent and its message reads as sent, derived on every fold from the stored rejection: with the turn it was answered into, as that turn's unconfirmed message where it was handed over; withdrawn before any turn opened, as a person's own send a Stop took back. The reopen mark ignores a recovered doubt, and the restart reconciler no longer judges a queued hand-off unsent from what history lacks. The echo text matching and its pairing are deleted. A close or quit while the agent starts settles sends with the unrun hold. The host hands nothing to a child before its start answers (structured-agent-session-delivery-loop.ts: a handover needs phase ready), so such a send went to an earlier child and may have run: it now reads as sent instead of waiting again. --- .../journal-dispatch-settlement.test.ts | 45 ++-- .../journal-dispatch-settlement.ts | 24 ++- .../journal-queue-fold.ts | 94 ++++----- .../journal-queue-import.ts | 11 +- .../journal-queued-message-contracts.test.ts | 2 - .../journal-queued-messages.ts | 9 +- .../agent-session-journal/journal-reducer.ts | 9 +- .../journal-restart-reconciliation.ts | 3 + .../journal-submission-fold.ts | 39 +++- .../journal-unsent-send-hold.ts | 4 +- .../queued-message-delivered-echo.spec.ts | 195 ------------------ .../queued-message-delivered-echo.ts | 135 ------------ .../queued-message-table.ts | 3 +- .../agent-session-journal-batch.ts | 15 +- ...ured-agent-session-queued-stop-row.test.ts | 93 +++++++-- src/shared/agent-session-queued-submission.ts | 17 ++ 16 files changed, 234 insertions(+), 464 deletions(-) delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-delivered-echo.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts 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 0ad307dedd22..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 @@ -4,6 +4,7 @@ // 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' @@ -19,15 +20,18 @@ export function journalDispatchRowApplies( ) } -/** 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' } +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-queue-fold.ts b/src/main/native-chat/agent-session-journal/journal-queue-fold.ts index be8ba4ceaa67..5b7cbcbdb222 100644 --- a/src/main/native-chat/agent-session-journal/journal-queue-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-queue-fold.ts @@ -1,8 +1,8 @@ // 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, an echo proving delivery) and kept from -// then on, so a later rewind that retires the hand-off cannot change it, and replay reads the same. +// 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 { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentJournalCursor, AgentJournalSubmission @@ -12,39 +12,42 @@ import { isAgentSessionOptions } from '../../../shared/agent-session-record' 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 { rejectedDraftSettlement } from './journal-dispatch-settlement' +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 { appliedEchoDelivering, draftsDeliveredByEcho } from './queued-message-delivered-echo' +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 - /** Echoes that already proved a card delivered: each proves one delivery, never a second. */ - pairedEchoes: Set } export function createJournalQueueState(): JournalQueueState { - return { cards: new Map(), revision: 0, pairedEchoes: new Set() } + return { cards: new Map(), revision: 0 } } /** - * What a refusal makes of the dispatched card it handed off: a withdrawal waits again at its own - * position under the queue's pause; a failure returns the card with its reason, except a command + * 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: { reason: string | null; rejection?: UnreadAgentSessionFailureFact }, + rejected: AgentJournalSubmission, commandTurnReported: boolean ): QueuedMessageRow { - if (rejectedDraftSettlement(rejected).state === 'waiting') { + 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' } } @@ -67,14 +70,13 @@ function commandTurnReported(state: JournalReducerState, clientMessageId: string * 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. An echo it pairs - * with joins `pairedEchoes`: the fold's own set, or a reader's copy of it. + * 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, - pairedEchoes: Set + change: JournalQueuedChange ): QueuedMessageRow { const card: QueuedMessageRow = { messageId, @@ -99,7 +101,7 @@ export function queuedCardFromChange( holdReason: null, consumedAs: attempt } - return settledHandOff(state, handedOff, submission, pairedEchoes) + return settledHandOff(state, handedOff, submission) } const { returned } = change if (typeof returned === 'object' && returned !== null) { @@ -115,27 +117,7 @@ export function queuedCardFromChange( if (attempt !== null) { return { ...card, state: 'dispatched', holdReason: null, consumedAs: attempt } } - return deliveredWaitingCard(state, card, pairedEchoes) -} - -/** Before an item or batch applies: each provider echo proving a waiting card's earlier hand-off - * was delivered spends that card, so none is sent twice. */ -export function foldJournalQueueEcho(state: JournalReducerState, row: JournalRow): void { - const { cards } = state.queue - if (cards.size === 0 || (row.kind !== 'item' && row.kind !== 'lifecycle-batch')) { - return - } - for (const delivered of draftsDeliveredByEcho( - state, - () => [...cards.values()], - row, - state.queue.pairedEchoes - )) { - const card = cards.get(delivered) - if (card) { - setCard(state, { ...card, state: 'withdrawn', holdReason: null }) - } - } + return card } /** After any row applies: what it does to the cards. */ @@ -161,7 +143,8 @@ export function foldJournalQueueRow(state: JournalReducerState, row: JournalRow) const submission = state.submissions.get(row.clientMessageId) const card = cardHandedOffAs(state, submission) if (card && submission) { - setCard(state, settledHandOff(state, card, submission, state.queue.pairedEchoes)) + setCard(state, settledHandOff(state, card, submission)) + settleHandOffInDoubt(state, row.clientMessageId) } } else if (row.kind === 'item' || row.kind === 'lifecycle-batch') { spendReportedCommandCards(state) @@ -171,7 +154,11 @@ export function foldJournalQueueRow(state: JournalReducerState, row: JournalRow) function applyQueueRow(state: JournalReducerState, row: JournalQueueRow): void { const { change } = row if (change.op === 'queued') { - setCard(state, queuedCardFromChange(state, row.messageId, change, state.queue.pairedEchoes)) + 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) @@ -208,33 +195,22 @@ function cardHandedOffAs( function settledHandOff( state: JournalReducerState, card: QueuedMessageRow, - submission: AgentJournalSubmission, - pairedEchoes: Set + submission: AgentJournalSubmission ): QueuedMessageRow { if (submission.dispatchState !== 'rejected') { return card } const reported = commandTurnReported(state, submission.clientMessageId) - return deliveredWaitingCard( - state, - rejectedCardTransition(card, submission, reported), - pairedEchoes - ) + return rejectedCardTransition(card, submission, reported) } -/** A card that waits again is checked against the echoes already applied, so one whose earlier - * hand-off reached the agent after all is spent rather than resent. */ -function deliveredWaitingCard( - state: JournalReducerState, - card: QueuedMessageRow, - pairedEchoes: Set -): QueuedMessageRow { - const echo = card.state === 'waiting' ? appliedEchoDelivering(state, card, pairedEchoes) : null - if (echo === null) { - return card +/** 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) } - pairedEchoes.add(echo) - return { ...card, state: 'withdrawn', holdReason: null } } /** A returned command card is spent once its own turn's row reports the refusal. */ 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 index 45134f1333d9..acfcd03e26cf 100644 --- a/src/main/native-chat/agent-session-journal/journal-queue-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-queue-import.ts @@ -24,8 +24,8 @@ type LegacyDeps = Pick< > /** The `queued` change a legacy row moves into the journal as, or null when it is answered and - * nothing is written: withdrawn, delivered, or a hand-off an echo proves delivered. Its hand-off - * link is kept, and its stored refusal only where that hand-off is not in this epoch. */ + * 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 @@ -47,8 +47,7 @@ export function legacyQueuedChange( } : {}) } - // A reader's copy: only the fold, folding the imported row, pairs an echo for good. - const card = queuedCardFromChange(state, row.messageId, change, new Set(state.queue.pairedEchoes)) + const card = queuedCardFromChange(state, row.messageId, change) return unanswered(card, state.submissions) ? change : null } @@ -93,9 +92,7 @@ export class LegacyQueuedCards { const state = this.deps.state() const cards = this.rows().flatMap((row) => { const change = legacyQueuedChange(state, row) - return change - ? [queuedCardFromChange(state, row.messageId, change, new Set(state.queue.pairedEchoes))] - : [] + return change ? [queuedCardFromChange(state, row.messageId, change)] : [] }) this.viewed = { key, cards } } 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 67a069ed7396..93377571a762 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,7 +2,6 @@ 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 { registerQueuedMessageDeliveredEchoTests } from './queued-message-delivered-echo.spec' import { registerQueuedMessagePauseTests } from './queued-message-pause.spec' import { registerQueuedMessageTextUpdateTests } from './queued-message-text-update.spec' @@ -11,6 +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-delivered-echo', registerQueuedMessageDeliveredEchoTests) describe('queued-message-pause', registerQueuedMessagePauseTests) describe('queued-message-text-update', registerQueuedMessageTextUpdateTests) 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 97092f5fe3da..35145875127e 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 @@ -264,8 +264,13 @@ export class JournalQueuedMessages { if (card.state === 'waiting') { return true } - const handOff = card.consumedAs ? submissions.get(card.consumedAs)?.dispatchState : undefined - return card.state === 'dispatched' && (handOff === 'pending' || handOff === 'unknown') + 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)) + ) }) } 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 d3ff8ded97ae..ba3cf02480f5 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -42,7 +42,6 @@ import { } from './queued-message-pause' import { createJournalQueueState, - foldJournalQueueEcho, foldJournalQueueRow, type JournalQueueState } from './journal-queue-fold' @@ -83,6 +82,11 @@ 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 } @@ -109,6 +113,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou latestAcceptedTurnSequence: 0, queuePauseMarks: createJournalQueuePauseMarks(), stoppedHistoryCarried: new Map(), + queuedHandOffPlaces: new Map(), queue: createJournalQueueState() } } @@ -118,8 +123,6 @@ export function applyJournalRow( row: JournalRow, savedAt = row.ts ): void { - // An echo is read against the cards before it applies; every other row after. - foldJournalQueueEcho(state, row) applyJournalRowFacts(state, row, savedAt) foldJournalQueueRow(state, row) } 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-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 01d9e47ac7b0..ef4ae0226491 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 @@ -11,6 +11,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' import { isAgentSessionOptions } from '../../../shared/agent-session-record' export function applyJournalSubmission( @@ -69,12 +71,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-unsent-send-hold.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.ts index 9f2b09a49ec7..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 @@ -31,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 @@ -220,7 +220,7 @@ 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) 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 8b02b99935ce..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.spec.ts +++ /dev/null @@ -1,195 +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 } 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 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 }, - draft = 'draft-1' - ): Promise { - const body = message(text) - const fingerprint = agentSessionSendBodyFingerprint(IDENTITY.sessionId, body) - await journal.queuedMessages.insert({ messageId: draft, body }) - await journal.appendSubmission( - { - clientMessageId: `sub-${draft}`, - payloadFingerprint: fingerprint, - body, - fence: 0, - handoverRecorded: true - }, - { messageId: draft, expect: 'waiting' } - ) - if (options.handedOver) { - await journal.resolveDispatch({ - clientMessageId: `sub-${draft}`, - state: 'pending', - fence: 0, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.resolveDispatch({ - clientMessageId: `sub-${draft}`, - state: 'rejected', - ...STOP_WITHDRAWAL, - fence: 0 - }) - } else { - await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) - } - expect(journal.queuedMessages.get(draft)).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' }) - // 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('withdraws every draft one batch echoes, so neither is sent again', async () => { - const journal = await withdrawnDraft('first') - await handOffAndReject(journal, 'second', { handedOver: true }, 'draft-2') - const echoed = (uuid: string, text: string) => ({ - kind: 'item' as const, - identity: { provider: 'claude' as const, sessionId: 'native-1', uuid }, - body: message(text), - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendLifecycleBatch({ - settlementId: 'turn-settled', - fence: 0, - mutations: [echoed('echo-1', 'first'), echoed('echo-2', 'second')] - }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - expect(journal.queuedMessages.get('draft-2')?.state).toBe('withdrawn') - }) - - it('withdraws only one of two identical drafts for one echo: it proves one delivery', async () => { - const journal = await withdrawnDraft('same text') - await handOffAndReject(journal, 'same text', { handedOver: true }, 'draft-2') - await echo(journal, 'echo-1', 'same text') - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - expect(journal.queuedMessages.get('draft-2')?.state).toBe('waiting') - }) - - 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') - }) - - it('stays withdrawn when the chat reopens: replay folds the same rows', async () => { - let journal = await withdrawnDraft('did it land?') - await echo(journal, 'echo-1', 'did it land?') - 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.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 fd630f0a18b9..000000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.ts +++ /dev/null @@ -1,135 +0,0 @@ -// 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 drafts this appended row proves were delivered, one per echo: each echo proves one - * delivery, so it takes the earliest-positioned match no other echo took, and joins `pairedEchoes`. - * The fold calls it before the row applies, for every item row, so a row that is no new provider - * echo of a user message returns before anything else is read. */ -export function draftsDeliveredByEcho( - state: JournalReducerState, - drafts: () => readonly QueuedMessageRow[], - row: JournalRow, - pairedEchoes: Set -): string[] { - const echoes = appendedItems(row).filter( - (item) => - 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 [] - } - const candidates = spentWaitingDrafts(state, drafts()).sort( - (a, b) => a.draft.position - b.draft.position - ) - const delivered: string[] = [] - for (const item of echoes) { - const match = candidates.find( - (candidate) => - !delivered.includes(candidate.draft.messageId) && - echoProvesDelivered(state, candidate, item.body, row.seq) - ) - if (match) { - delivered.push(match.draft.messageId) - pairedEchoes.add(item.itemId) - } - } - return delivered -} - -/** - * The same proof for a card that comes to wait again: the earliest echo ALREADY in the journal - * that proves it delivered and no other card took, or null. 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, but only once some hand-off of the card was refused after hand-over, so a card that never - * left costs nothing. - */ -export function appliedEchoDelivering( - state: JournalReducerState, - draft: QueuedMessageRow, - pairedEchoes: ReadonlySet -): string | null { - const [candidate] = spentWaitingDrafts(state, [draft]) - if (!candidate) { - return null - } - let earliest: { itemId: string; sequence: number } | null = null - for (const item of state.items.values()) { - if ( - isProviderUserMessageEcho(item.itemId, item.body) && - !pairedEchoes.has(item.itemId) && - (earliest === null || item.sequence < earliest.sequence) && - echoProvesDelivered(state, candidate, item.body, item.sequence) - ) { - earliest = item - } - } - return earliest?.itemId ?? null -} - -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): { itemId: string; body: AgentJournalItemBody }[] { - if (row.kind === 'item') { - return [{ itemId: row.itemId, body: row.body }] - } - if (row.kind === 'lifecycle-batch') { - return row.mutations.flatMap((mutation) => - mutation.kind === 'item' ? [{ itemId: mutation.itemId, body: mutation.body }] : [] - ) - } - return [] -} 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 03d46c0eac9a..8fdc5a90b78c 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,6 +1,7 @@ // 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, a withdrawal waits again, delivery spends it. +// 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`). 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..7b51f07996ee 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,18 @@ 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 clientMessageId = + row.kind === 'submission' || row.kind === 'dispatch' + ? row.clientMessageId + : row.kind === 'queue' && row.change.op === 'queued' + ? input.snapshot.submissions.find( + (submission) => submission.clientMessageId === row.change.attempt + )?.clientMessageId + : undefined + if (clientMessageId !== undefined) { + touchedClientMessageIds.add(clientMessageId) + touchedItemIds.add(agentJournalSubmissionKey(clientMessageId)) } } 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/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) + ) +} From 8f84e49f609ed24b184cfbce272de679c5e802c9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:20:24 -0700 Subject: [PATCH 15/23] fix(native-chat): Claude's report that it removed a queued message unrun lets the card wait again Claude says when it removed a send without running it: the interrupt receipt's cancelled list, or a command_lifecycle cancelled, discarded or refused frame for a command that never started. Every one settles through rejectClaudeDispatchWaiters, which now marks the settlement unrun. For a queued card's hand-off the host records that as proof the agent never got it, so the card waits again under the queue's pause. A direct send keeps the plain refusal: there the proof would owe it as stopped history. --- .../claude/claude-command-lifecycle.test.ts | 3 ++- .../claude-structured-control-actions.test.ts | 3 +++ src/main/claude/claude-structured-dispatch.ts | 1 + .../claude-structured-queued-stop.test.ts | 1 + .../claude/claude-structured-session-state.ts | 3 ++- ...d-agent-session-claude-queued-stop.test.ts | 22 +++++++++++++++---- .../structured-agent-session-late-dispatch.ts | 15 +++++++++++-- 7 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 88aa6d4a12fb..859f9be6ca3f 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -253,6 +253,7 @@ async function replayCapture( const WITHDRAWN = { state: 'rejected', + unrun: true, reason: DISPATCH_REJECTED_CANCELLED, rejection: { kind: 'cancelled' } } @@ -516,7 +517,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 3467a474e0bf..6de7c547061b 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -68,6 +68,7 @@ export function rejectClaudeDispatchWaiters( onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'rejected', + unrun: true, ...claudeDispatchRejection(fact) }) } 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-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 44895a4b0272..392e177784f6 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/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-late-dispatch.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts index 3d38494fea93..0b64e000bcb4 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 @@ -7,7 +7,10 @@ import type { StructuredAgentSessionMutationContext } from './structured-agent-s import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' /** 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. */ + * 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: { @@ -18,6 +21,7 @@ export async function settleStructuredAgentSessionLateDispatch( | ({ state: 'rejected' answeredInTurn?: AgentJournalAnsweredTurnIdentity + unrun?: true } & AgentJournalDispatchRejection) | { state: 'unknown'; reason: string } ) @@ -27,6 +31,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 // The journal queue drains before close; the host queue would defer this past teardown. await session.journal.resolveDispatch( 'providerIdentity' in input @@ -48,7 +57,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 } From 41a28fda406fad07aaa0bab6e7c76719f989b2c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:20:26 -0700 Subject: [PATCH 16/23] fix(native-chat): an earlier build's card stopped after hand-over follows the same rule on upgrade Earlier builds put a card back to waiting after any refusal without a verdict and cleared its link, so a card the agent may already have would be sent again after the upgrade. The card's own hand-offs in the journal decide now: unchanged and last sent as one that may have reached the agent, it is spent on that hand-off and its message reads as sent; edited or sent again since, it is held for its own Send under a host-only marker that publishes as a plain hold; with no hand-off in doubt, including one that never left Orca, it waits as before. The import writes a card spent on a hand-off in doubt, so its message shows. --- .../journal-queue-import.ts | 47 +++++++++++++++--- .../queued-message-table.ts | 5 ++ ...agent-session-queued-legacy-import.test.ts | 48 +++++++++++++++---- 3 files changed, 84 insertions(+), 16 deletions(-) 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 index acfcd03e26cf..48fdd10c883c 100644 --- a/src/main/native-chat/agent-session-journal/journal-queue-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-queue-import.ts @@ -5,14 +5,17 @@ // 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 } from './journal-dispatch-settlement' +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 type { QueuedMessageRow } from './queued-message-table' +import { + QUEUED_MESSAGE_HELD_DELIVERY_UNCONFIRMED, + type QueuedMessageRow +} from './queued-message-table' import { LEGACY_QUEUED_MESSAGE_COLUMNS, readStoredQueuedMessageRow @@ -30,13 +33,15 @@ export function legacyQueuedChange( state: JournalReducerState, row: QueuedMessageRow ): JournalQueuedChange | null { - const attempt = row.state === 'waiting' ? null : row.consumedAs + 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), - ...(row.state === 'waiting' && row.holdReason !== null ? { holdReason: row.holdReason } : {}), + ...(holdReason !== null ? { holdReason } : {}), ...(attempt !== null ? { attempt } : {}), ...(row.state === 'returned' && (attempt === null || !state.submissions.has(attempt)) ? { @@ -51,8 +56,38 @@ export function legacyQueuedChange( 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), - * or that nothing can answer. */ + * that may have reached the agent (its message then reads as sent), or that nothing can answer. */ function unanswered( card: QueuedMessageRow, submissions: ReadonlyMap @@ -61,7 +96,7 @@ function unanswered( return card.state !== 'withdrawn' } const handOff = card.consumedAs === null ? undefined : submissions.get(card.consumedAs) - return handOff === undefined || journalDispatchRowApplies(handOff) + return handOff === undefined || journalDispatchRowApplies(handOff) || handOffInDoubt(handOff) } /** The legacy rows still on disk for one session, as the queue's view reads them. */ 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 8fdc5a90b78c..40c144098641 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 @@ -20,6 +20,11 @@ export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdr * 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 * withdrawn cards stay outside it. */ 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 index c8b319dda28a..809ef35ca12f 100644 --- 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 @@ -11,6 +11,7 @@ import { 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' @@ -100,12 +101,23 @@ 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', ?, ?, ?, ?)` + 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, state, hold ?? null, reason, consumedAs ?? null) + insert.run( + sessionId, + id, + index + 1, + body, + print, + state, + hold ?? null, + reason, + consumedAs ?? null + ) }) } @@ -156,19 +168,17 @@ it("moves an earlier build's cards into the journal, each action its own card, t 'refused, never settled', 'providerRejected' ) - await handOff(earlier, 'att-echoed', 'disp-echoed', 'withdrawn, yet delivered', 'cancelled') - await earlier.appendItem( - { provider: 'codex', threadId: 'thread', turnId: 'turn-echo', ordinal: 0 }, - message('withdrawn, yet delivered'), - { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) + // 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'], - ['disp-echoed', 'dispatched', 'withdrawn, yet delivered', 'att-echoed'], + ['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'], @@ -186,6 +196,7 @@ it("moves an earlier build's cards into the journal, each action its own card, t { 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 } ] @@ -223,10 +234,20 @@ it("moves an earlier build's cards into the journal, each action its own card, t 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. + const stopped = async () => { + const { items, submissions } = await rig.host.journalSnapshot(SESSION) + const drawn = projectStructuredAgentSessionMessages(items, [], submissions) + return drawn.find((entry) => entry.id === agentJournalSubmissionKey('att-stopped')) + ?.stoppedBeforeStart + } + expect(await stopped()).toBe(true) // The command card's own turn reports its refusal later: it is spent, not left holding the queue. const live = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal @@ -242,13 +263,20 @@ it("moves an earlier build's cards into the journal, each action its own card, t expect(await rig.deleteQueued('disp-refused')).toMatchObject({ value: { deleted: true } }) await rig.resume() await eventually(async () => expect(await rig.handoff('edited')).toBeDefined()) - expect(await rig.handoff('held')).toBeUndefined() + 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()).toBe(true) expect(legacyIds()).toEqual(['broken']) expect(await rig.deleteQueued('ret-present', deletion)).toMatchObject({ replayed: true, From 14c1a02e5dd0e92eb36adc9ff2e72e1f5860bc9f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:31:18 -0700 Subject: [PATCH 17/23] fix(native-chat): read a queued row's hand-off before the lookup that uses it The lookup's callback lost the narrowing of the row's change, which the mobile test typecheck rejects. --- .../agent-session-wire/agent-session-journal-batch.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 7b51f07996ee..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 @@ -65,14 +65,15 @@ export function projectJournalBatch(input: { continue } // 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 - : row.kind === 'queue' && row.change.op === 'queued' - ? input.snapshot.submissions.find( - (submission) => submission.clientMessageId === row.change.attempt - )?.clientMessageId - : undefined + : attempt === undefined + ? undefined + : input.snapshot.submissions.find((submission) => submission.clientMessageId === attempt) + ?.clientMessageId if (clientMessageId !== undefined) { touchedClientMessageIds.add(clientMessageId) touchedItemIds.add(agentJournalSubmissionKey(clientMessageId)) From ff114578825b458cd5860c27b2137d21c5cb6d82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:37:41 -0700 Subject: [PATCH 18/23] test(native-chat): a card steered into a turn a Stop ends stays sent in the Stop tests Three tests pinned the old rule, that a card handed into a running turn comes back to waiting when a Stop ends it. Under the new rule nothing proves the agent never got it, so the card is spent: with the turn named, its message reads as that turn's unconfirmed one; without, as a send the Stop took back. Neither is ever sent again. --- ...tructured-agent-session-stop-event.test.ts | 10 +++++---- ...-session-codex-turn-end-settlement.test.ts | 21 ++++++++----------- 2 files changed, 15 insertions(+), 16 deletions(-) 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 6552e6789831..99e84848563b 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 @@ -224,7 +224,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() @@ -235,11 +235,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 () => { 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 5dc03b0074f8..7e6450fcbcf7 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 @@ -441,7 +441,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 () => { const opening = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) turns.start() @@ -454,16 +455,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 } ) @@ -478,7 +474,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) }) @@ -535,7 +531,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( @@ -550,8 +546,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) }) From fb72fc82aaa35fc5b72dd102afa31adc8f19af2f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:33:00 -0700 Subject: [PATCH 19/23] fix(native-chat): a queued message stopped before Orca handed it to the agent waits again A Stop that aborts a hand-off after its row is written but before the agent call now records the withdrawal as never reaching the agent, for queued hand-offs only, so plan D's rule returns the card to waiting under the Stop's pause instead of spending it. Direct sends keep the plain withdrawal (the flag would owe them as stopped history). The agent's own refusal while aborted is kept rather than overwritten, and an abort thrown during the call stays in doubt. --- .../structured-agent-session-handover.ts | 14 ++++++--- ...ed-agent-session-stop-aborts-start.test.ts | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) 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 eefe8711a197..1af25767cc18 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 @@ -114,8 +114,10 @@ export async function beginSubmissionHandover( dispatch: async () => { let outcome: AgentSessionDispatchOutcome | null try { + // Aborted before the call, Orca still held it: a queued card waits again. Only a card carries + // the proof; on a direct send it would owe the message as stopped history. outcome = attempt?.signal.aborted - ? cancelledDispatch() + ? cancelledDispatch(submission.queuedMessageId !== undefined) : // A child gone since the row: its end settles the row, and nothing reaches the wire. stillCurrent() ? await dispatchSafely( @@ -203,7 +205,8 @@ async function dispatchSafely( fence: ctx.fence, requestedAt }) - return signal?.aborted && outcome.state === 'rejected' ? cancelledDispatch() : outcome + // An adapter's own refusal says the write never happened, aborted or not. + return outcome } catch (error) { if (signal?.aborted && error === abortSignalReason(signal)) { return cancelledDispatch() @@ -221,9 +224,12 @@ async function dispatchSafely( } } -function cancelledDispatch(): AgentSessionDispatchOutcome { +function cancelledDispatch(neverReachedAgent = false): 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-stop-aborts-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts index 40fa8a913f13..b65c67956d16 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 @@ -129,3 +129,33 @@ 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 between the hand-off's row and the agent call: Orca still held the card, so it waits again. +it('returns a queued card to waiting when the Stop lands before Orca hands it to the agent', async () => { + 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 + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + const resolve = journal.resolveDispatch.bind(journal) + let stopping: ReturnType | undefined + 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 + }) + 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(1) +}) From e19bd9507d17da0079781a69fac86afd2fb796e0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:38:51 -0700 Subject: [PATCH 20/23] fix(native-chat): a queued message stopped while the agent prepares it waits again The abort signal reaches only an adapter's preparation (its options, the previous turn opening), never its write, so the signal's own reason thrown back is the adapter saying nothing was written. The handover now reads that, for queued hand-offs only, as never reaching the agent, so the card waits again under the Stop's pause. Claude no longer wraps that abort as an option refusal, which had returned a stopped card as a refused one blocking the queue. A real refusal keeps its cause and its barrier; any other throw stays in doubt and is never resent. --- src/main/claude/claude-structured-dispatch.ts | 8 ++- .../claude-structured-message-options.test.ts | 41 ++++++++++++ .../claude-structured-message-options.ts | 9 ++- .../structured-agent-session-adapter.ts | 3 +- .../structured-agent-session-handover.ts | 21 +++--- ...ed-agent-session-stop-aborts-start.test.ts | 66 +++++++++++-------- src/shared/abort-signal-reason.ts | 5 ++ 7 files changed, 114 insertions(+), 39 deletions(-) diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 549daeb693c2..40a24d8c7827 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, @@ -119,6 +120,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 { @@ -172,7 +174,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 9a043e66da75..0f0cf430b1ba 100644 --- a/src/main/claude/claude-structured-message-options.test.ts +++ b/src/main/claude/claude-structured-message-options.test.ts @@ -8,6 +8,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' }, @@ -98,6 +99,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 3cef26e2dd16..efe4a137b868 100644 --- a/src/main/claude/claude-structured-message-options.ts +++ b/src/main/claude/claude-structured-message-options.ts @@ -5,7 +5,11 @@ import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wi 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' /** Applies the accepted message's choices while its prompt is still in the SDK input queue. */ export async function applyClaudeMessageOptions( @@ -91,7 +95,8 @@ export function dispatchClaudeMessage( await applyClaudeMessageOptions(session, messageOptions, timeoutMs, input.signal) input.signal?.throwIfAborted() } catch (error) { - throw error instanceof AgentSessionOptionRejectedError + throw error instanceof AgentSessionOptionRejectedError || + isAbortSignalReason(input.signal, error) ? error : new AgentSessionOptionRejectedError(error) } 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 baabf4eb1f10..67b80ab30e87 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 @@ -270,7 +270,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-handover.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handover.ts index 1af25767cc18..f315ab0a1422 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 @@ -7,7 +7,7 @@ import { } from '../../../shared/agent-session-failure' import type { AgentSessionExecutionOptions } from '../../../shared/agent-session-execution-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 { @@ -89,6 +89,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, @@ -114,10 +115,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. Only a card carries - // the proof; on a direct send it would owe the message as stopped history. + // Aborted before the call, Orca still held it: a queued card waits again. outcome = attempt?.signal.aborted - ? cancelledDispatch(submission.queuedMessageId !== undefined) + ? cancelledDispatch(queued) : // A child gone since the row: its end settles the row, and nothing reaches the wire. stillCurrent() ? await dispatchSafely( @@ -126,6 +126,7 @@ export async function beginSubmissionHandover( carried ? withStoppedHistory(body, carried.messages) : body, requestedAt, submission.options, + queued, attempt?.signal ) : null @@ -193,6 +194,7 @@ async function dispatchSafely( body: AgentJournalMessageItem, requestedAt: number, executionOptions: AgentSessionExecutionOptions | undefined, + queued: boolean, signal?: AbortSignal ): Promise { try { @@ -205,11 +207,12 @@ async function dispatchSafely( fence: ctx.fence, requestedAt }) - // An adapter's own refusal says the write never happened, aborted or not. + // 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 { @@ -224,7 +227,9 @@ async function dispatchSafely( } } -function cancelledDispatch(neverReachedAgent = false): 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( 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 b65c67956d16..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, @@ -130,32 +131,43 @@ it('answers that it stopped the queued message even when its write throws after expect(await rig.submission(first.id)).toMatchObject({ rejection: { kind: 'cancelled' } }) }) -// A Stop between the hand-off's row and the agent call: Orca still held the card, so it waits again. -it('returns a queued card to waiting when the Stop lands before Orca hands it to the agent', async () => { - 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 - const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal - const resolve = journal.resolveDispatch.bind(journal) - let stopping: ReturnType | undefined - vi.spyOn(journal, 'resolveDispatch').mockImplementation(async (input) => { - const written = await resolve(input) - if (input.state === 'pending' && input.clientMessageId !== working && !stopping) { - stopping = rig.stop() +// 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') } - return written - }) - await rig.settleAccepted(working, 'working') + 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(1) -}) + 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/shared/abort-signal-reason.ts b/src/shared/abort-signal-reason.ts index 06eaf2cf36e5..fa58a1d7b6f3 100644 --- a/src/shared/abort-signal-reason.ts +++ b/src/shared/abort-signal-reason.ts @@ -5,6 +5,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) From 7f25a0d893fc23c764f54f77512c16e6979dea7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:43:06 -0700 Subject: [PATCH 21/23] test(native-chat): the usage-limit Continue backlog case writes its card through the journal queue Main's #30396 wrote its backlog card with the stored fingerprint and host-instance stamp the journal queue derives instead; the card the Continue must yield to is the same waiting card. --- ...structured-agent-session-queued-action-receipts.test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 267578a41057..d94f7105bb7b 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 { @@ -617,12 +615,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' }, From e9513f81927849a03afad9cd578ed0669dc72bf8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:43:07 -0700 Subject: [PATCH 22/23] test(claude): the real-CLI queued Stop expects the unrun mark Claude's cancelled-unrun receipt carries --- src/main/claude/claude-structured-real-cli.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/claude/claude-structured-real-cli.test.ts b/src/main/claude/claude-structured-real-cli.test.ts index 66b996819014..0884c8ffc485 100644 --- a/src/main/claude/claude-structured-real-cli.test.ts +++ b/src/main/claude/claude-structured-real-cli.test.ts @@ -626,7 +626,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() From e9bfdafc1ddd35c5d7c350baaef708475689cb39 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:58:25 -0700 Subject: [PATCH 23/23] fix(native-chat): retain spent legacy messages when import fails --- .../journal-queue-import.ts | 24 +++++++++++++++ .../journal-queued-messages.ts | 5 ++++ .../agent-session-journal/journal-store.ts | 2 +- ...agent-session-queued-legacy-import.test.ts | 29 ++++++++++++------- 4 files changed, 49 insertions(+), 11 deletions(-) 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 index 48fdd10c883c..a2330411e9ce 100644 --- a/src/main/native-chat/agent-session-journal/journal-queue-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-queue-import.ts @@ -12,6 +12,7 @@ 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 @@ -134,6 +135,29 @@ export class LegacyQueuedCards { 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 { 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 db5955df251b..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 @@ -30,6 +30,7 @@ import { import { QueuedMessageNotConsumableError } from './queued-message-consume-error' import { QueuedMessageEditLeases } from './queued-message-edit-leases' 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 = { @@ -56,6 +57,10 @@ export class JournalQueuedMessages { return this.deps.sessionId } + displayState(): JournalReducerState { + return this.legacy.displayState() + } + /** Moves with every card change, so publication memos recompute only when they must. */ revision(): string { const { epoch, queue } = this.deps.state() 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 15abba029e94..19e7302090d9 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -192,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 = ( 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 index 809ef35ca12f..87928d5fa242 100644 --- 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 @@ -201,6 +201,21 @@ it("moves an earlier build's cards into the journal, each action its own card, t { 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:', @@ -241,17 +256,11 @@ it("moves an earlier build's cards into the journal, each action its own card, t 'held:queued' ]) // Unchanged, it is spent on that hand-off: drawn as the person's send its Stop took back. - const stopped = async () => { - const { items, submissions } = await rig.host.journalSnapshot(SESSION) - const drawn = projectStructuredAgentSessionMessages(items, [], submissions) - return drawn.find((entry) => entry.id === agentJournalSubmissionKey('att-stopped')) - ?.stoppedBeforeStart - } - expect(await stopped()).toBe(true) + expect(await stopped()).toEqual(displayedStopped) // The command card's own turn reports its refusal later: it is spent, not left holding the queue. - const live = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal - await live.appendItem( + const adopted = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + await adopted.appendItem( structuredAgentSessionCommandTurnIdentity('att-cmd'), { kind: 'status', text: 'Compact failed' }, { @@ -276,7 +285,7 @@ it("moves an earlier build's cards into the journal, each action its own card, t await rig.restartHostProcess() await rig.drafts() expect(queueRows()).toEqual(rows) - expect(await stopped()).toBe(true) + expect(await stopped()).toEqual(displayedStopped) expect(legacyIds()).toEqual(['broken']) expect(await rig.deleteQueued('ret-present', deletion)).toMatchObject({ replayed: true,