diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx index 3ea59e635e2..5eb53046128 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.test.tsx @@ -281,8 +281,7 @@ describe('MobileNativeChatQueuedMessages', () => { it('heads the box with why the queue is paused, for each reason', async () => { const rows: readonly [AgentSessionQueuePause['reason'], string][] = [ - ['stopped', 'Queue paused because you interrupted'], - ['cleared', 'Queue paused after you cleared the conversation'] + ['stopped', 'Queue paused because you interrupted'] ] for (const [reason, label] of rows) { const mounted = await mountPaused({ pause: { reason } }) diff --git a/mobile/src/session/MobileNativeChatQueuedMessages.tsx b/mobile/src/session/MobileNativeChatQueuedMessages.tsx index f600781dde5..fa44fdd01fd 100644 --- a/mobile/src/session/MobileNativeChatQueuedMessages.tsx +++ b/mobile/src/session/MobileNativeChatQueuedMessages.tsx @@ -26,7 +26,7 @@ const RESUME_KEY = '\u0000resume' export type MobileNativeChatQueuedMessagesProps = { cards?: MobileQueuedMessageCard[] /** Steer for a waiting card, the paused queue's included; plain Send for a card held on its own - * (its send failed, or the host kept it unsent), or a returned one. */ + * (its send failed), or a returned one. */ onSend?: (messageId: string) => Promise onDelete?: (messageId: string) => Promise /** Copy the card's text into the composer, then delete the card. */ diff --git a/mobile/src/session/mobile-structured-queued-message-cards.test.ts b/mobile/src/session/mobile-structured-queued-message-cards.test.ts index 3341b7a80d9..8ead70b2027 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.test.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' import { DISPATCH_REJECTED_HOST_RESTARTED } from '../../../src/shared/structured-agent-session-dispatch-rejection' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - QUEUED_MESSAGE_PAUSED_SEND_FAILED -} from '../../../src/shared/agent-session-wire' +import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../src/shared/agent-session-wire' import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' import { mobileQueueHasResumableCard, @@ -103,35 +100,12 @@ describe('mobileQueuedMessageCards', () => { expect(resumable([failed])).toBe(false) expect(resumable([failed, waiting])).toBe(true) expect(resumable([waiting, { ...returned, position: 3 }])).toBe(true) - // A kept card is held on its own, like a failed one: the drain goes past it. - const kept = draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }) - expect(resumable([kept])).toBe(false) - expect(resumable([kept, waiting])).toBe(true) - }) - - // The host kept it unsent across a restart or a close; the cards behind it are not held by it. - it('captions a kept card as not sent yet, and leaves the cards behind it plainly queued', () => { - const cards = mobileQueuedMessageCards( - [ - draft({ messageId: 'k', paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_KEPT }), - draft({ messageId: 'b', position: 2 }) - ], - [], - { pendingPrompt: false } - ) - expect(cards.map(({ caption, needsAttention }) => ({ caption, needsAttention }))).toEqual([ - { caption: 'Not sent yet — tap Send to send it', needsAttention: false }, - { caption: null, needsAttention: false } - ]) }) it('words the paused queue by reason, and one this build does not know as a plain pause', () => { expect(mobileQueuePauseLabel({ reason: 'stopped' })).toBe( 'Queue paused because you interrupted' ) - expect(mobileQueuePauseLabel({ reason: 'cleared' })).toBe( - 'Queue paused after you cleared the conversation' - ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a reason newer than this build's union, as a newer host would send it. const newer = { reason: 'later_reason' } as never expect(mobileQueuePauseLabel(newer)).toBe('Queue paused') diff --git a/mobile/src/session/mobile-structured-queued-message-cards.ts b/mobile/src/session/mobile-structured-queued-message-cards.ts index 6388efbf412..ac423e35850 100644 --- a/mobile/src/session/mobile-structured-queued-message-cards.ts +++ b/mobile/src/session/mobile-structured-queued-message-cards.ts @@ -8,7 +8,6 @@ import { agentSessionWriteNoticeEnglish } from '../../../src/shared/agent-sessio import { dispatchWasWithdrawn } from '../../../src/shared/structured-agent-session-dispatch-rejection' import { structuredAgentSessionAttemptFailureParts } from '../../../src/shared/structured-agent-session-send-disposition' import { - QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, type AgentSessionQueuePause @@ -53,22 +52,17 @@ function returnedCaption( ) } -/** One card's own hold: a failed conversion, or a send the host kept; the queue's pause is the - * list's first row. */ +/** One card's own hold: a failed conversion; the queue's pause is the list's first row. */ function pausedCaption(reason: string | undefined): string { if (reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED) { return "Couldn't send — tap Send to retry" } - if (reason === QUEUED_MESSAGE_PAUSED_KEPT) { - return 'Not sent yet — tap Send to send it' - } // Absent or unknown (newer host) marker: a plain pause, promising no release rule. return 'Paused' } const QUEUE_PAUSE_LABELS: Readonly> = { - stopped: 'Queue paused because you interrupted', - cleared: 'Queue paused after you cleared the conversation' + stopped: 'Queue paused because you interrupted' } /** Whether Resume would send anything: a waiting card with no hold of its own, ahead of any diff --git a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx index c14e823ab40..ceaee501b65 100644 --- a/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session-queued.test.tsx @@ -577,21 +577,20 @@ describe('mobile structured queued messages', () => { listener?.( batchEvent( [ - queuedDraft({ messageId: 'kept-1', paused: true, pausedReason: 'kept' }), + queuedDraft({ messageId: 'kept-1' }), queuedDraft({ messageId: 'behind', position: 2 }) ], [], - // This host publishes no restart pause; a Stop's stands in for any queue-wide one. - { reason: 'stopped' } + null ) ) ) + // Plain waiting cards: the host holds them until the chat's next turn, and shows no row. expect(hook!.queued.cards.map(({ messageId, caption }) => ({ messageId, caption }))).toEqual([ - { messageId: 'kept-1', caption: 'Not sent yet — tap Send to send it' }, + { messageId: 'kept-1', caption: null }, { messageId: 'behind', caption: null } ]) - // The kept card is held on its own, so Resume would send the card behind it. - expect(hook!.queued.pause).toEqual({ reason: 'stopped' }) + expect(hook!.queued.pause).toBeNull() }) }) diff --git a/mobile/src/session/use-mobile-structured-queued-message-controls.ts b/mobile/src/session/use-mobile-structured-queued-message-controls.ts index f65ecf2d46e..a786eeacbe0 100644 --- a/mobile/src/session/use-mobile-structured-queued-message-controls.ts +++ b/mobile/src/session/use-mobile-structured-queued-message-controls.ts @@ -1,8 +1,8 @@ // The queued-draft surface the structured session exposes: cards derived from // the published list and the Send-now / Delete / Edit actions. Shown whatever // the queue capability says: a host that does not queue sends still publishes a -// message it kept across a restart or a close, and only queueing a new send is -// gated. A host older than the queue publishes no list, so shows no cards. +// message it kept as a card across a restart or a close, and only queueing a new +// send is gated. A host older than the queue publishes no list, so shows no cards. // Nothing here is durable: the host owns the queue, and the published list is // the only truth a card action ever needs. 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 7c6deabef40..b952f7672c5 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 @@ -10,34 +10,29 @@ import { } from '../../../shared/structured-agent-session-dispatch-rejection' import { rejectedDraftSettlement } from './journal-dispatch-settlement' -function settle(kind: SubmissionRejectionKind, origin?: 'client' | 'host') { - return rejectedDraftSettlement({ - ...agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }), - origin - }) +function settle(kind: SubmissionRejectionKind) { + return rejectedDraftSettlement( + agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) + ) } 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', kept: false }) + expect(settle('cancelled')).toEqual({ state: 'waiting' }) expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_CANCELLED })).toEqual({ - state: 'waiting', - kept: false + state: 'waiting' }) - expect(settle('notDelivered')).toEqual({ state: 'waiting', kept: false }) + expect(settle('notDelivered')).toEqual({ state: 'waiting' }) }) - // A Send the person asked for and the host never handed over waits for them; the queue's own - // hand-off, or one from a build that recorded no origin, waits under the queue's pause. - it('a restart or close before hand-over sends it back to waiting, kept only when the person sent it', () => { + // 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, 'client')).toEqual({ state: 'waiting', kept: true }) - expect(settle(kind, 'host')).toEqual({ state: 'waiting', kept: false }) - expect(settle(kind)).toEqual({ state: 'waiting', kept: false }) + expect(settle(kind)).toEqual({ state: 'waiting' }) } - expect( - rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED, origin: 'client' }) - ).toEqual({ state: 'waiting', kept: true }) + expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({ + state: 'waiting' + }) }) it('a failure returns the card for the user to act on', () => { 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 6b3e53f36b3..c56e4f47d45 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 @@ -30,27 +30,18 @@ export function consumedSubmissionWasRejected( } /** What a consumed draft becomes when its submission is rejected. */ -export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting'; kept: boolean } +export type RejectedDraftSettlement = { state: 'returned' | 'waiting' } /** - * Where no one failed the user, the draft goes back to waiting at its own position, under whatever - * pauses the queue: a Stop's, or the restart's, derived from the host instance. A Send the person - * asked for (`origin` client) that a restart or a close cut short is kept (`kept`) until they send - * it again, as the host keeps every message a person sent and it never handed over; the queue's - * own hand-off is not theirs, so it waits as any queued card does: under the restart's pause after - * a restart, and plainly queued after a close in the same process. A returned card would block the drafts - * behind it on a failure that never happened. A failure returns the card with its refusal for the - * user to act on. + * Where no one failed the user, the draft goes back to waiting, under whatever pauses the queue: a + * Stop's, or, when a restart or a close cut it short, the reopen's, so it follows the chat's next + * turn like every card the chat closed with. A returned card would block the drafts behind it on a + * failure that never happened. A failure returns the card with its refusal for the user to act on. */ export function rejectedDraftSettlement( - rejected: Pick & { rejection?: unknown } + rejected: Pick & { rejection?: unknown } ): RejectedDraftSettlement { - const { verdict, kind } = classifyDispatchRejection(rejected) - if (verdict !== null) { - return { state: 'returned' } - } - const cutShort = kind === 'hostRestarted' || kind === 'chatClosed' - return { state: 'waiting', kept: cutShort && rejected.origin === 'client' } + return { state: classifyDispatchRejection(rejected).verdict !== null ? 'returned' : 'waiting' } } /** True when committing this row NEWLY settles the submission to `rejected` — diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 6337b00417b..36247e2571f 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -68,7 +68,7 @@ function replace(input: { reason: 'legacy_import', fence: 1, items: input.items, - queuePause: { lifted: false, liveStop: null }, + queuePause: { lifted: false, liveStop: null, reopened: false }, now, mintEpoch: () => `epoch-${clock}`, onPublished: input.onPublished ?? (() => undefined) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index da53b2e9a11..dd56f2ac0f7 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 @@ -16,6 +16,7 @@ import type { JournalLoad } from './journal-open' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' import { + buildJournalQueueReopenRow, buildJournalQueueResumeRow, buildJournalStopEventRow } from './journal-stop-and-resume-rows' @@ -77,7 +78,7 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } - const { lifted, liveStop } = input.queuePause + const { lifted, liveStop, reopened } = input.queuePause const place = () => ({ state, seq: state.lastSequence + 1, fence: input.fence, ts: input.now() }) if (lifted) { const row = buildJournalQueueResumeRow(place()) @@ -89,6 +90,11 @@ export function replaceJournalEpoch(input: { applyJournalRow(state, row) rows.push(row) } + if (reopened) { + const row = buildJournalQueueReopenRow(place()) + applyJournalRow(state, row) + rows.push(row) + } const { sessionId } = input.identity input.database.transaction((db) => { 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 bb6eb2ef4d7..9614c7548ff 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 @@ -5,7 +5,10 @@ // 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 { + AgentJournalCursor, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, AGENT_SESSION_OPERATION_FUTURE_SKEW_MS @@ -15,7 +18,7 @@ import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' import type { JournalOperationReceipt, JournalRowTransactionHook } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' -import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds' +import { holdQueuedMessages } from './queued-message-holds' import { deriveQueuePauses, journalUserStopInForce, @@ -55,8 +58,8 @@ export type JournalQueuedMessagesDeps = { database: () => JournalHostDatabase readOnly: () => boolean state: () => JournalReducerState - /** Whether this handle found the row at `sequence` on disk when it opened. */ - wroteBeforeOpen: (sequence: number) => boolean + /** 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 @@ -157,8 +160,21 @@ export class JournalQueuedMessages { } /** The queue's pauses in force, derived from the fold and the cards (`queued-message-pause.ts`). */ - pauses(hostInstance: string): DerivedQueuePause[] { - return this.derivePauses(this.list(), hostInstance) + pauses(): DerivedQueuePause[] { + return this.derivePauses(this.list()) + } + + /** 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((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') + }) } /** The person's Stop still pausing the queue, if any (`journalUserStopInForce`). */ @@ -167,37 +183,17 @@ export class JournalQueuedMessages { return journalUserStopInForce(state.queuePauseMarks, state.latestAcceptedTurnSequence) } - private derivePauses( - cards: readonly QueuedMessageRow[], - hostInstance: string - ): DerivedQueuePause[] { + private derivePauses(cards: readonly QueuedMessageRow[]): DerivedQueuePause[] { const state = this.deps.state() return deriveQueuePauses({ epoch: state.epoch, marks: state.queuePauseMarks, latestAcceptedTurnSequence: state.latestAcceptedTurnSequence, cards, - hostInstance, - restartEnded: this.restartEnded() + reopenFloor: this.deps.reopenFloor() }) } - /** A turn started since this handle opened, which ends a restart's pause. */ - restartEnded(): boolean { - const latest = this.deps.state().latestAcceptedTurnSequence - return latest > 0 && !this.deps.wroteBeforeOpen(latest) - } - - /** Adopts waiting rows another host instance wrote into this one, ending a restart's pause. - * Returns whether anything changed. */ - adopt(hostInstance: string): Promise { - const { sessionId } = this.deps - return this.transact( - (db) => adoptQueuedMessages(db, { sessionId, hostInstance }), - (changed) => changed > 0 - ).then((changed) => changed > 0) - } - /** 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. */ @@ -286,7 +282,7 @@ export class JournalQueuedMessages { // pick and this claim (both run on the session's serialized lane, held across the send), so // this guards any pause-relevant row written off that lane from overtaking a held card. const cards = listQueuedMessages(db, this.deps.sessionId) - const pauses = this.derivePauses(cards, input.yieldsToPause.hostInstance) + const pauses = this.derivePauses(cards) if (nextSendableQueuedCard(pauses, cards)?.messageId !== input.messageId) { throw new QueuedMessageNotConsumableError(input.messageId, input.expect) } 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 d2a95a6db72..1d226761ae2 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 @@ -95,6 +95,12 @@ export type JournalTombstoneRow = JournalRowBase & { stopEvent?: JournalStopEvent /** Present: not a removal but a person's Resume of the queue, on an id no item ever takes. */ queueResume?: true + /** Present: not a removal but a reopen that found waiting cards (`queued-message-pause.ts`), on + * an id no item ever takes. */ + queueReopen?: true + /** On a reopen mark written after the chat stopped: where it stopped, so a send accepted since + * lifts it. Absent: the mark's own row. */ + queueReopenSince?: number } /** One Stop that took effect. Temporary carrier: a tombstone's extra key, which every host ignores, @@ -112,16 +118,21 @@ export type JournalStopEvent = { caller?: string } -/** A tombstone that carries a Stop event or a Resume mark instead of removing an item. */ +/** A tombstone that carries a Stop event, a Resume or a reopen mark instead of removing an item. */ export type JournalStopOrResumeRow = JournalTombstoneRow & ( | { stopEvent: NonNullable } | { queueResume: NonNullable } + | { queueReopen: NonNullable } ) -/** A Stop's event or a Resume. Any value counts, so a newer build's mark never removes an item. */ +/** A Stop's event, a Resume or a reopen mark. Any value counts, so a newer build's mark never + * removes an item. */ export function isJournalStopOrResumeRow(row: JournalRow): row is JournalStopOrResumeRow { - return row.kind === 'tombstone' && (row.stopEvent !== undefined || row.queueResume !== undefined) + return ( + row.kind === 'tombstone' && + (row.stopEvent !== undefined || row.queueResume !== undefined || row.queueReopen !== undefined) + ) } /** The write-ahead row. Durable BEFORE the adapter dispatches anything; it diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts index e763af8b2e6..c4ddb98bc39 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts @@ -1,12 +1,13 @@ -// The rows a Stop's event and a person's Resume append: tombstones of ids no item ever takes, -// each carrying its record as an extra key (`journal-row-schema.ts` says why not a row kind). +// The rows a Stop's event, a person's Resume and a reopen that found waiting cards append: +// tombstones of ids no item ever takes, each carrying its record as an extra key +// (`journal-row-schema.ts` says why not a row kind). import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { JournalReducerState } from './journal-reducer' import { journalRowBase } from './journal-row-builders' import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema' -/** One id per mark kind; no item ever takes either. */ +/** One id per mark kind; no item ever takes any of them. */ const JOURNAL_STOP_EVENT_ITEM_ID = agentJournalItemKey({ provider: 'orca', clientMessageId: 'stop-event' @@ -15,6 +16,10 @@ const JOURNAL_QUEUE_RESUME_ITEM_ID = agentJournalItemKey({ provider: 'orca', clientMessageId: 'queue-resume' }) +const JOURNAL_QUEUE_REOPEN_ITEM_ID = agentJournalItemKey({ + provider: 'orca', + clientMessageId: 'queue-reopen' +}) type RowPlace = { state: JournalReducerState; seq: number; fence: number; ts: number } @@ -40,6 +45,19 @@ export function buildJournalQueueResumeRow(input: RowPlace): JournalTombstoneRow } } +export function buildJournalQueueReopenRow( + input: RowPlace & { since?: number } +): JournalTombstoneRow { + return { + kind: 'tombstone', + itemId: JOURNAL_QUEUE_REOPEN_ITEM_ID, + revision: 1, + queueReopen: true, + ...(input.since !== undefined ? { queueReopenSince: input.since } : {}), + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + /** A Stop taking effect now: its event's time is its row's. */ export function journalStopEventRowBuilder( state: () => JournalReducerState, @@ -56,3 +74,12 @@ export function journalQueueResumeRowBuilder( ): (seq: number, ts: number) => JournalTombstoneRow { return (seq, ts) => buildJournalQueueResumeRow({ state: state(), seq, fence, ts }) } + +export function journalQueueReopenRowBuilder( + state: () => JournalReducerState, + fence: number, + since?: number +): (seq: number, ts: number) => JournalTombstoneRow { + return (seq, ts) => + buildJournalQueueReopenRow({ state: state(), seq, fence, ts, ...(since ? { since } : {}) }) +} 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 600d673b6d8..506547beb10 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 @@ -69,7 +69,8 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal queuePauseRestatement: () => journalQueuePauseRestatement( host.state().queuePauseMarks, - host.state().latestAcceptedTurnSequence + host.state().latestAcceptedTurnSequence, + host.journal().queuedMessages.pauses() ), cursor: host.cursor, adopt: host.adopt @@ -81,7 +82,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal database: host.database, readOnly: host.readOnly, state: host.state, - wroteBeforeOpen: (sequence) => host.journal().wroteBeforeOpen(sequence), + reopenFloor: () => host.journal().reopenFloor(), committed: host.notifyCommitted }) const rowWriter = new JournalRowWriter({ 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 7492ea7e461..41b892adf34 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 @@ -112,9 +112,9 @@ export type JournalSubmissionConsume = { /** 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, as - * this host instance derives it, holds the card. Send-now omits it. */ - yieldsToPause?: { 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 } export type JournalItemAppendInput = { 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 7548f6e5502..06bdfba25fe 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -57,6 +57,7 @@ import type { import type { JournalQueuedMessages } from './journal-queued-messages' import { journalQueueResumeRowBuilder, + journalQueueReopenRowBuilder, journalStopEventRowBuilder } from './journal-stop-and-resume-rows' import type { AgentJournalEpochReason, JournalStopEvent } from './journal-row-schema' @@ -84,6 +85,7 @@ export class AgentSessionJournal { private state: JournalReducerState private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } + private reopenUnmarked: AgentJournalCursor | null = null private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue private readonly rowWriter: JournalRowWriter @@ -156,6 +158,12 @@ export class AgentSessionJournal { ) } + /** Where the reopen's pause begins when this handle could not write its mark: where the mark + * would have gone. Null once a mark is written. Per handle, so the next open marks again. */ + reopenFloor(): AgentJournalCursor | null { + return this.reopenUnmarked + } + async open(): Promise { await this.restore() this.openedThrough = this.cursor() @@ -292,6 +300,23 @@ export class AgentSessionJournal { return this.rowWriter.append(journalQueueResumeRowBuilder(() => this.state, fence)) } + /** This open found waiting cards an earlier handle wrote (`queued-message-pause.ts`). */ + appendQueueReopen(fence: number, since?: number): Promise { + return this.rowWriter.append(journalQueueReopenRowBuilder(() => this.state, fence, since)) + } + + /** Marks the reopen when a card waits or is mid-hand-off (it may come back to waiting), from + * `since` when the chat stopped before now; a failed write leaves where the mark would have gone + * as the pause's start (`reopenFloor`), and throws. */ + async markQueueReopen(fence: number, since?: number): Promise { + if (this.queuedMessages.awaitReopenMark()) { + const sequence = since ?? this.state.lastSequence + 1 + this.reopenUnmarked = { epoch: this.state.epoch, sequence } + await this.appendQueueReopen(fence, since) + this.reopenUnmarked = null + } + } + appendLifecycleBatch(input: JournalLifecycleBatchInput): Promise { return this.lifecycleBatchAppender.append(input) } diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts index 293519d3680..29fb7eb35fa 100644 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts @@ -17,7 +17,6 @@ import { } from '../../../shared/agent-session-message-source' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' import { structuredAgentSessionCompactBody } from '../agent-session-wire/structured-agent-session-command-turn' @@ -180,7 +179,7 @@ describe('which sends an earlier host process left unsent are kept', () => { }) expect(reopened.queuedMessages.get(id)).toMatchObject({ state: 'waiting', - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + holdReason: null, hostInstance: HOST, body: message(`text of ${id}`), fingerprint: fingerprint(message(`text of ${id}`)), @@ -229,14 +228,13 @@ describe('which sends an earlier host process left unsent are kept', () => { } }) - // Only a Send the person asked for comes back kept; the queue's own hand-off waits under the - // restart's pause, where it stood. + // Whoever sent it, the card comes back with no hold of its own, to wait under the reopen's pause. it.each([ - { by: 'Send now', origin: 'client' as const, holdReason: QUEUED_MESSAGE_PAUSED_KEPT }, - { by: 'the queue', origin: 'host' as const, holdReason: null } + { by: 'Send now', origin: 'client' as const }, + { by: 'the queue', origin: 'host' as const } ])( 'a card’s own hand-off by $by returns its card, and makes no second one', - async ({ origin, holdReason }) => { + async ({ origin }) => { const journal = await afterRestart(async (earlier) => { await earlier.queuedMessages.insert({ messageId: 'card', @@ -262,7 +260,7 @@ describe('which sends an earlier host process left unsent are kept', () => { expect(journal.queuedMessages.get('card')).toMatchObject({ state: 'waiting', consumedAs: null, - holdReason + holdReason: null }) expect(journal.queuedMessages.get('handoff')).toBeNull() } @@ -383,7 +381,14 @@ describe('where kept sends go in the queue', () => { }) // That run kept A, at a stale place behind C, then died before B. await interrupted.resolveDispatch( - { clientMessageId: 'A', state: 'rejected', ...HOST_RESTARTED, fence: 0, recovered: true }, + { + clientMessageId: 'A', + state: 'rejected', + ...HOST_RESTARTED, + fence: 0, + recovered: true, + keptAsQueuedMessageId: 'A' + }, (db) => { interrupted.queuedMessages.holdInTransaction(db, { card: { @@ -391,7 +396,6 @@ describe('where kept sends go in the queue', () => { body: message('text of A'), fingerprint: fingerprint(message('text of A')), hostInstance: HOST, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, queuedAt: { epoch: 'epoch-1', sequence: interrupted.submission('A')!.acceptedSequence! @@ -419,7 +423,7 @@ describe('a close of the chat', () => { expect(cardOrder(journal)).toEqual(['person']) expect(journal.queuedMessages.get('person')).toMatchObject({ - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, + holdReason: null, hostInstance: HOST }) for (const id of ['person', 'mail']) { 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 9341fbce6e1..fd5a774c7bf 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 @@ -1,9 +1,10 @@ // What becomes of a send the host accepted and can no longer hand over: an earlier host process // quit or crashed first (settled at the next open), or the chat is closing. Either way the agent // provably never got it. A person's message (typed, or a launch's first prompt) is kept as a card -// at the head of the queue, held until the person sends, edits or deletes it (`kept`). Everything -// else is rejected as before, because something else re-derives it or the person re-runs it. The -// submission itself is always rejected, so it is never handed over twice. +// at the head of the queue, an ordinary one: like every card the chat closed with, it waits for the +// chat's next turn (`queued-message-pause.ts`). Everything else is rejected as before, because +// something else re-derives it or the person re-runs it. The submission itself is always rejected, +// so it is never handed over twice. import type { AgentJournalItemBody, @@ -12,7 +13,6 @@ import type { } from '../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' 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' @@ -75,7 +75,8 @@ export function isUnansweredHandedOverSubmission( * both. This batch's cards, and the card of a Send the person asked for, go to the head of the * queue in the order they were accepted, behind the cards an earlier settlement kept; the queue's * own hand-off returns its card where it stood. Throws once every row was - * tried when one of them could not be written at all: that row stays queued. + * tried when one of them could not be written at all: that row stays queued. Resolves where the + * newest send it settled was accepted, or null when it settled none. */ export async function holdUnsentSends( journal: AgentSessionJournal, @@ -87,7 +88,7 @@ export async function holdUnsentSends( * handed and never echoed. It ran none, so each is unsent as surely as a queued one. */ unrun?: true } -): Promise { +): Promise { const { hold } = input const unsent = journal .submissions() @@ -101,7 +102,7 @@ export async function holdUnsentSends( ) .sort((a, b) => (a.acceptedSequence ?? 0) - (b.acceptedSequence ?? 0)) if (unsent.length === 0) { - return + return null } const { epoch } = journal.cursor() const kept = unsent.map((submission) => ({ @@ -144,7 +145,6 @@ export async function holdUnsentSends( card.body ), hostInstance: input.hostInstance, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, queuedAt: { epoch, sequence: submission.acceptedSequence ?? 0 }, position: card.position } @@ -185,6 +185,7 @@ export async function holdUnsentSends( if (failures.length > 0) { throw new AggregateError(failures, 'settling unsent sends failed') } + return unsent.at(-1)?.acceptedSequence ?? 0 } /** Where each card of the batch goes: right before every other card, the cards an earlier @@ -196,8 +197,18 @@ function headOfQueuePositions( batch: readonly { submission: AgentJournalSubmission; body: AgentJournalMessageItem | null }[] ): { placed: QueuedMessagePositionMove[]; earlier: QueuedMessagePositionMove[] } { const cards = journal.queuedMessages.list() + const keptIds = new Set( + journal.submissions().flatMap((entry) => entry.keptAsQueuedMessageId ?? []) + ) + const { epoch } = journal.cursor() + // Kept by an earlier settlement: its rejection names it, or, past an epoch that dropped that + // row, it was queued in an earlier epoch, before anything this one accepted. const earlier = cards - .filter((card) => card.state === 'waiting' && card.holdReason === QUEUED_MESSAGE_PAUSED_KEPT) + .filter( + (card) => + card.state === 'waiting' && + (keptIds.has(card.messageId) || (card.queuedAt !== null && card.queuedAt.epoch !== epoch)) + ) .map((card) => card.messageId) const placed: QueuedMessagePositionMove[] = [] for (const { submission, body } of batch) { 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 index 05867436e12..c3f90590c11 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-holds.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-holds.ts @@ -1,7 +1,7 @@ // Per-draft holds: what keeps one card from auto-sending, stored on its row. A -// Stop or a restart pauses the queue instead (`queued-message-pause.ts`, derived); -// a per-draft hold is a conversion that failed, or a send the host kept -// (`QueuedMessageHoldReason`), which an explicit Send releases. +// 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' @@ -27,23 +27,3 @@ export function holdQueuedMessages( } return held } - -/** Ends a restart's pause: waiting rows another host instance wrote are adopted - * into this one, the same fact the pause is derived from, so no second copy - * exists. Also clears a per-row 'stopped' hold an earlier build of the queue - * wrote, which this build only ever lifts. Returns how many rows it changed. */ -export function adoptQueuedMessages( - db: Database.Database, - input: { sessionId: string; hostInstance: string } -): number { - return Number( - db - .prepare( - `UPDATE queued_messages - SET host_instance = ?, hold_reason = CASE WHEN hold_reason = 'stopped' THEN NULL ELSE hold_reason END - WHERE session_id = ? AND state = 'waiting' - AND (host_instance <> ? OR hold_reason = 'stopped')` - ) - .run(input.hostInstance, input.sessionId, input.hostInstance).changes ?? 0 - ) -} diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts index 83570726945..dabc1d64335 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts @@ -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 } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalMessageItem, AgentSessionJournalIdentity @@ -29,7 +29,6 @@ import { } from './queued-message-pause' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' const IDENTITY: AgentSessionJournalIdentity = { @@ -97,12 +96,12 @@ function userStop(journal: AgentSessionJournal) { } function reason(journal: AgentSessionJournal): string | null { - return journal.queuedMessages.pauses(HOST)[0]?.reason ?? null + return journal.queuedMessages.pauses()[0]?.reason ?? null } /** Each card, and whether a pause in force holds it. */ function held(journal: AgentSessionJournal): [string, boolean][] { - const pauses = journal.queuedMessages.pauses(HOST) + const pauses = journal.queuedMessages.pauses() return journal.queuedMessages .list() .filter((card) => card.state === 'waiting') @@ -308,7 +307,7 @@ describe("the queue's pause, derived from the journal", () => { expect: 'waiting', settledByOp: null, hostInstance: HOST, - ...(automatic ? { yieldsToPause: { hostInstance: HOST } } : {}) + ...(automatic ? { yieldsToPause: true as const } : {}) } ) await expect(consume('drain-1', true)).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) @@ -390,7 +389,7 @@ describe("the queue's pause, derived from the journal", () => { await turn(journal, 'typed') await userStop(journal) await journal.replaceEpochItems('handle_forked', 0, []) - expect(journal.queuedMessages.pauses(HOST).map((pause) => pause.reason)).toEqual(['stopped']) + expect(journal.queuedMessages.pauses().map((pause) => pause.reason)).toEqual(['stopped']) await journal.appendQueueResume(0) expect(reason(journal)).toBeNull() }) @@ -460,7 +459,7 @@ describe('which cards a pause holds', () => { expect: 'waiting', settledByOp: null, hostInstance: HOST, - yieldsToPause: { hostInstance: HOST } + yieldsToPause: true } ) ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) @@ -480,89 +479,191 @@ describe('which cards a pause holds', () => { }) }) -describe("a restart's pause", () => { - it("adopting a restart's rows moves them into this instance and clears an older build's stored 'stopped' hold; send_failed stays", async () => { - const journal = await open() - await journal.queuedMessages.insert({ - messageId: 'draft-restart', - body: message('written before the restart'), - fingerprint: 'fp-draft-restart', - hostInstance: 'proc-0' - }) - expect(reason(journal)).toBe('restarted') - await queueDraft(journal, 'draft-legacy') - await queueDraft(journal, 'draft-failed') - await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' }) - const db = new Database(journalDatabasePath(root)) - db.prepare("UPDATE queued_messages SET hold_reason = 'stopped' WHERE message_id = ?").run( - 'draft-legacy' - ) - db.close() - journal.queuedMessages.invalidate() - expect(await journal.queuedMessages.adopt(HOST)).toBe(true) +describe("a reopen's pause", () => { + /** This handle closes and the next one opens and marks it, as a quit, a crash or a chat close + * leaves it; `failMark` makes the mark's write fail. */ + async function reopen( + journal: AgentSessionJournal, + failMark = false + ): Promise { + await journal.close() + const reopened = await open() + if (failMark) { + vi.spyOn(reopened, 'appendQueueReopen').mockRejectedValueOnce(new Error('disk full')) + await expect(reopened.markQueueReopen(0)).rejects.toThrow('disk full') + } else { + await reopened.markQueueReopen(0) + } + return reopened + } + + it('holds the cards queued before the reopen, never one queued after its mark', async () => { + let journal = await open() + await queueDraft(journal, 'before') expect(reason(journal)).toBeNull() - expect( - journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance, row.holdReason]) - ).toEqual([ - ['draft-restart', HOST, null], - ['draft-legacy', HOST, null], - ['draft-failed', HOST, 'send_failed'] + journal = await reopen(journal) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([ + ['before', true], + ['after', false] ]) + expect(reason(journal)).toBe('restarted') }) - it('adoption and Resume keep a kept card held: only its own Send, Edit or Delete releases it', async () => { - const journal = await open() - const kept = await journal.queuedMessages.insert({ - messageId: 'kept', - body: message('kept across a restart'), - fingerprint: 'fp-kept', - hostInstance: 'proc-0', - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }) - expect(kept.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) - expect(await journal.queuedMessages.adopt(HOST)).toBe(true) - expect(journal.queuedMessages.get('kept')).toMatchObject({ - hostInstance: HOST, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }) + it.each([ + ['an accepted turn', (journal: AgentSessionJournal) => turn(journal, 'carry-on')], + ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] + ])('%s after the reopen lifts it', async (_name, lift) => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + await lift(journal) + expect(reason(journal)).toBeNull() }) - it('an adoption with nothing to adopt changes nothing and fires no commit notification', async () => { + it('a turn the agent has not accepted yet lifts nothing', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + await turn(journal, 'carry-on', false) + expect(reason(journal)).toBe('restarted') + }) + + // A hand-off in flight may come back to waiting after the open: the open marks for it too. + it('marks a reopen whose only card is mid-hand-off, and holds it once it comes back', async () => { + let journal = await open() + await queueDraft(journal, 'in-flight') + await journal.appendSubmission( + { + clientMessageId: 'drain-1', + payloadFingerprint: 'fp-in-flight', + body: message('in-flight'), + fence: 0, + handoverRecorded: true + }, + { messageId: 'in-flight', expect: 'waiting', settledByOp: null, hostInstance: HOST } + ) + journal = await reopen(journal) + await journal.resolveDispatch({ + clientMessageId: 'drain-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), + fence: 0 + }) + expect(held(journal)).toEqual([['in-flight', true]]) + }) + + // Written after a later send was accepted, the mark starts where the chat stopped, so that + // send still lifts it; it never narrows a wider mark before it. + it('a mark written late starts where the chat stopped, and narrows no earlier mark', async () => { const journal = await open() - await queueDraft(journal, 'draft-1') - const revision = journal.queuedMessages.revision() - expect(await journal.queuedMessages.adopt(HOST)).toBe(false) - expect(journal.queuedMessages.revision()).toBe(revision) + await queueDraft(journal, 'kept') + const stopped = journal.cursor().sequence + 1 + await turn(journal, 'later') + await journal.markQueueReopen(0, stopped) + expect(reason(journal)).toBeNull() + + const reopened = await reopen(journal) + expect(reason(reopened)).toBe('restarted') + await reopened.markQueueReopen(0, stopped) + expect(reason(reopened)).toBe('restarted') + }) + + it('a reopen with no waiting card writes nothing', async () => { + let journal = await open() + await turn(journal, 'earlier') + const before = journal.cursor() + journal = await reopen(journal) + expect(journal.cursor()).toEqual(before) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([['after', false]]) + }) + + it('a second reopen before any turn still holds them; one after a turn holds them again', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + journal = await reopen(journal) + expect(reason(journal)).toBe('restarted') + await turn(journal, 'carry-on') + expect(reason(journal)).toBeNull() + // Still waiting (here, nothing drained it): the chat closed with it again, so it waits again. + journal = await reopen(journal) + expect(reason(journal)).toBe('restarted') + }) + + // The open could not write its mark: the open itself is the boundary, so nothing sends by + // itself; the next turn or Resume lifts it, and the next open marks again. + it('without a mark the open itself is where the pause begins', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal, true) + expect(held(journal)).toEqual([['before', true]]) + await turn(journal, 'carry-on') + expect(reason(journal)).toBeNull() + }) + + it("reads an earlier build's stored 'kept' and 'stopped' holds as none; send_failed stays", async () => { + let journal = await open() + for (const id of ['was-kept', 'was-stopped', 'failed']) { + await queueDraft(journal, id) + } + await journal.queuedMessages.hold({ messageIds: ['failed'], reason: 'send_failed' }) + const db = new Database(journalDatabasePath(root)) + for (const [id, stored] of [ + ['was-kept', 'kept'], + ['was-stopped', 'stopped'] + ]) { + db.prepare('UPDATE queued_messages SET hold_reason = ? WHERE message_id = ?').run(stored, id) + } + db.close() + journal = await reopen(journal) + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.holdReason])).toEqual([ + ['was-kept', null], + ['was-stopped', null], + ['failed', 'send_failed'] + ]) + expect(held(journal)).toEqual([ + ['was-kept', true], + ['was-stopped', true], + ['failed', false] + ]) }) }) describe('which cards the pauses in force hold', () => { type Card = Parameters[1] & { messageId: string } - const DEAD = 'proc-0' function card(messageId: string, queuedAfter: number, fields: Partial = {}): Card { const queuedAt = { epoch: 'epoch-1', sequence: queuedAfter + 1 } - const base = { state: 'waiting', holdReason: null, hostInstance: HOST, carriedFrom: null } + const base = { state: 'waiting', holdReason: null, carriedFrom: null } return { messageId, ...base, queuedAt, ...fields } } - /** A Stop at sequence 5 unless `stopped` is 0; no turn or Resume since. */ - function pausesOver(cards: readonly Card[], stopped = 5) { + /** A Stop at sequence 5 unless `stopped` is 0; this handle opened at `opened` and could not mark + * it (0: nothing to mark); no turn, Resume or reopen mark since. */ + function pausesOver(cards: readonly Card[], stopped = 5, opened = 0) { return deriveQueuePauses({ epoch: 'epoch-1', marks: { latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, - resumedSequence: 0 + resumedSequence: 0, + reopenedSequence: 0 }, latestAcceptedTurnSequence: 0, cards, - hostInstance: HOST, - restartEnded: false + reopenFloor: opened ? { epoch: 'epoch-1', sequence: opened + 1 } : null }) } - function holding(cards: readonly Card[], stopped?: number): [string, string | null][] { - const pauses = pausesOver(cards, stopped) + function holding( + cards: readonly Card[], + stopped?: number, + opened?: number + ): [string, string | null][] { + const pauses = pausesOver(cards, stopped, opened) return cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) } @@ -580,64 +681,51 @@ describe('which cards the pauses in force hold', () => { ]) }) - it("a Stop that holds nothing never hides a restart's: a dead process's card queued after it waits", () => { - const after = card('after', 5, { hostInstance: DEAD }) - expect(pausesOver([after]).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) - expect(holding([after])).toEqual([['after', 'restarted']]) - expect(nextSendableQueuedCard(pausesOver([after]), [after])).toBeNull() - expect(resumableQueuePause(pausesOver([after]), [after])?.reason).toBe('restarted') - // Written by this process, nothing holds it: a card queued after a Stop sends normally. - const live = card('after', 5) - expect(nextSendableQueuedCard(pausesOver([live]), [live])).toBe(live) + it("a Stop that holds nothing never hides a reopen's: a card from before the open waits", () => { + const after = card('after', 5) + expect(pausesOver([after], 5, 6).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) + expect(holding([after], 5, 6)).toEqual([['after', 'restarted']]) + expect(nextSendableQueuedCard(pausesOver([after], 5, 6), [after])).toBeNull() + // Queued since the open, nothing holds it: a card queued after a Stop sends normally. + expect(nextSendableQueuedCard(pausesOver([after]), [after])).toBe(after) }) it("a card names the first pause holding it, and the header names the first held card's", () => { - const cards = [ - card('before', 3, { hostInstance: DEAD }), - card('after', 5, { hostInstance: DEAD }) - ] - expect(holding(cards)).toEqual([ + const cards = [card('before', 3), card('after', 5)] + expect(holding(cards, 5, 6)).toEqual([ ['before', 'stopped'], ['after', 'restarted'] ]) - expect(resumableQueuePause(pausesOver(cards), cards)?.reason).toBe('stopped') + expect(resumableQueuePause(pausesOver(cards, 5, 6), cards)?.reason).toBe('stopped') }) - // Held on its own, as a card whose send failed: the queue goes past it, and Resume is offered - // over the cards a pause holds behind it. - it('a kept card is skipped like a send_failed one; the cards behind it still send', () => { - const behind = card('behind', 2) - const kept = [card('kept', 1, { holdReason: QUEUED_MESSAGE_PAUSED_KEPT }), behind] - expect(holding(kept, 0)).toEqual([ - ['kept', null], - ['behind', null] + // R13-1: a message Orca kept after a quit is an ordinary card, in order, under the reopen's pause. + it('a card from before the open waits in order, and so do the cards behind it', () => { + const cards = [card('kept', 1), card('behind', 2)] + expect(holding(cards, 0, 4)).toEqual([ + ['kept', 'restarted'], + ['behind', 'restarted'] ]) - expect(nextSendableQueuedCard(pausesOver(kept, 0), kept)).toBe(behind) - expect(resumableQueuePause(pausesOver(kept, 0), kept)).toBeNull() - const restarted = [kept[0]!, card('dead', 2, { hostInstance: DEAD })] - expect(nextSendableQueuedCard(pausesOver(restarted, 0), restarted)).toBeNull() - expect(resumableQueuePause(pausesOver(restarted, 0), restarted)?.reason).toBe('restarted') + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull() }) - // A dead process's card held on its own waits for its own Send, so it pauses no other card. - it('a card held on its own from a dead process starts no restart pause', () => { - for (const holdReason of [QUEUED_MESSAGE_PAUSED_KEPT, 'send_failed']) { - const cards = [card('held', 1, { hostInstance: DEAD, holdReason }), card('live', 2)] - expect(pausesOver(cards, 0)).toEqual([]) - expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)?.messageId).toBe('live') - } + // A card held on its own waits for its own Send, so it starts no reopen pause for the others. + it('a card held on its own from before the open starts no reopen pause', () => { + const cards = [card('held', 1, { holdReason: 'send_failed' }), card('live', 5)] + expect(pausesOver(cards, 0, 4)).toEqual([]) + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)?.messageId).toBe('live') }) - it("a /clear's pause that holds nothing never hides a restart's", () => { + it("a /clear's pause that holds nothing never hides a reopen's", () => { const cards = [ card('carried', 1, { carriedFrom: 'source-session', holdReason: 'send_failed' }), - card('typed', 2, { hostInstance: DEAD }) + card('typed', 2) ] - expect(pausesOver(cards, 0).map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) - expect(holding(cards, 0)).toEqual([ + expect(pausesOver(cards, 0, 4).map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) + expect(holding(cards, 0, 4)).toEqual([ ['carried', null], ['typed', 'restarted'] ]) - expect(nextSendableQueuedCard(pausesOver(cards, 0), cards)).toBeNull() + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull() }) }) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 5b13856b8f0..a7a652d81b7 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -5,12 +5,14 @@ // person's pauses. // - 'cleared': a card /clear carried into this conversation waits, and no turn or Resume has // happened here since. -// - 'restarted': a waiting card with no hold of its own was written by another host process, -// and no turn has started since this conversation opened. Never published: after a restart -// nothing sends by itself, and the next turn (the carry-on or the person's own message) runs -// first. +// - 'restarted': a card queued before this conversation last opened waits — Orca quit or +// crashed, or the chat closed, while it waited — and no turn or Resume has happened since the +// open. The open marks itself with a row when it finds waiting cards (`queueReopen`), so a card +// queued after it is never mistaken for one from before. Never published: nothing sends by +// itself, and the next turn (the carry-on or the person's own message) runs first. // Any accepted turn lifts them, whoever sent it: a person, Orca's own messages, or the queue. -// A card held on its own (`hold_reason`) is outside every pause: only an action on it releases it. +// A card held on its own (`hold_reason`, a failed conversion) is outside every pause: only an +// action on it releases it. import type { AgentJournalCursor, @@ -33,29 +35,32 @@ export type JournalStopSettle = { export type JournalStopFailedOn = { turnId: string } | { openedAfter: number } -/** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ +/** The latest Stop event, whatever its reason, the latest Resume row and the latest reopen mark, + * folded by the reducer. */ export type JournalQueuePauseMarks = { latestStop: { sequence: number; event: JournalStopEvent; settle?: JournalStopSettle } | null /** 0 when none. */ resumedSequence: number + /** 0 when none. */ + reopenedSequence: number } export type DerivedQueuePause = { reason: QueuePauseReason - /** Where a Stop's pause began: a card queued at or after it is newer. Null for the others. */ + /** Where a Stop's or a reopen's pause began: a card queued at or after it is newer. Null for + * /clear's, which holds the cards it carried. */ since: AgentJournalCursor | null } type QueueCard = { state: string holdReason: string | null - hostInstance: string carriedFrom: string | null queuedAt: AgentJournalCursor | null } export function createJournalQueuePauseMarks(): JournalQueuePauseMarks { - return { latestStop: null, resumedSequence: 0 } + return { latestStop: null, resumedSequence: 0, reopenedSequence: 0 } } /** The keys are read from disk unchecked: a value no build writes (a corrupt row) is ignored. */ @@ -79,6 +84,11 @@ export function foldJournalQueuePauseMark( marks.latestStop = { sequence: row.seq, event: row.stopEvent } } else if (row.queueResume === true) { marks.resumedSequence = row.seq + } else if (row.queueReopen === true) { + // Never earlier than a mark before it: a late mark of one send narrows no wider one. + const since = row.queueReopenSince + const start = typeof since === 'number' && since > 0 && since <= row.seq ? since : row.seq + marks.reopenedSequence = Math.max(marks.reopenedSequence, start) } } @@ -103,20 +113,23 @@ export function journalUserStopInForce( } /** What a rewind's new epoch restates so its pauses read as they did: a lift of /clear's pause (a - * turn or a Resume happened), then the Stop still in force, in that order so the lift - * never ends the Stop. */ + * turn or a Resume happened), then the Stop still in force, then the reopen's pause still holding + * a card, in that order so the lift never ends either. */ export type JournalQueuePauseRestatement = { lifted: boolean liveStop: JournalStopEvent | null + reopened: boolean } export function journalQueuePauseRestatement( marks: JournalQueuePauseMarks, - latestAcceptedTurnSequence: number + latestAcceptedTurnSequence: number, + pauses: readonly DerivedQueuePause[] ): JournalQueuePauseRestatement { return { lifted: latestAcceptedTurnSequence > 0 || marks.resumedSequence > 0, - liveStop: journalUserStopInForce(marks, latestAcceptedTurnSequence)?.event ?? null + liveStop: journalUserStopInForce(marks, latestAcceptedTurnSequence)?.event ?? null, + reopened: pauses.some((pause) => pause.reason === 'restarted') } } @@ -127,9 +140,8 @@ export function deriveQueuePauses(input: { marks: JournalQueuePauseMarks latestAcceptedTurnSequence: number cards: readonly QueueCard[] - hostInstance: string - /** A turn started since this conversation opened. */ - restartEnded: boolean + /** Where the reopen's pause begins when this handle could not mark it; null otherwise. */ + reopenFloor: AgentJournalCursor | null }): DerivedQueuePause[] { const { epoch, marks, latestAcceptedTurnSequence } = input const pauses: DerivedQueuePause[] = [] @@ -142,21 +154,42 @@ export function deriveQueuePauses(input: { if (carried.length > 0 && latestAcceptedTurnSequence === 0 && marks.resumedSequence === 0) { pauses.push({ reason: 'cleared', since: null }) } - // A card held on its own waits for its own Send whoever wrote it, so it pauses nothing else. - const foreign = waiting.some( - (card) => card.holdReason === null && card.hostInstance !== input.hostInstance - ) - if (!input.restartEnded && foreign) { - // The process that wrote a card is gone: every card waits, whenever it was written. - pauses.push({ reason: 'restarted', since: null }) + const reopened = reopenPause(input) + if ( + reopened && + waiting.some((card) => card.holdReason === null && queuedBefore(reopened, card)) + ) { + pauses.push(reopened) } return pauses } -/** Queued before the pause began: for /clear, a card it carried; for a restart, every card. For a - * Stop, a card queued before its row; one from another epoch (before a rewind) or from a build - * that recorded no position counts as before. A withdrawn steer keeps its position, so is held. */ -function queuedBeforePause(pause: DerivedQueuePause, card: QueueCard): boolean { +/** Where the latest reopen began: its mark, or, when this handle could not write one, the open + * itself, so a failed write holds a little more and never sends anything by itself. A floor from + * another epoch is not this one's (a rewind restates the mark). Null once a turn or a Resume came + * after it. */ +function reopenPause(input: { + epoch: string + marks: JournalQueuePauseMarks + latestAcceptedTurnSequence: number + reopenFloor: AgentJournalCursor | null +}): DerivedQueuePause | null { + const { epoch, marks, reopenFloor } = input + const floor = reopenFloor?.epoch === epoch ? reopenFloor.sequence : 0 + const sequence = Math.max(marks.reopenedSequence, floor) + if (sequence === 0) { + return null + } + if (Math.max(input.latestAcceptedTurnSequence, marks.resumedSequence) >= sequence) { + return null + } + return { reason: 'restarted', since: { epoch, sequence } } +} + +/** Queued before the pause began: for /clear, a card it carried. For a Stop or a reopen, a card + * queued before its row; one from another epoch (before a rewind) or from a build that recorded + * no position counts as before. A withdrawn steer keeps its position, so is held. */ +function queuedBefore(pause: DerivedQueuePause, card: QueueCard): boolean { if (pause.reason === 'cleared') { return card.carriedFrom !== null } @@ -180,7 +213,7 @@ export function queuePauseHolding( return undefined } // A card queued AFTER a Stop is a new instruction and is not held; it still waits behind a held one. - return pauses.find((pause) => queuedBeforePause(pause, card)) + return pauses.find((pause) => queuedBefore(pause, card)) } /** The card the queue sends next: the oldest waiting one with no hold of its own, unless a 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 029bdb74e6f..9c30bf4311d 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 @@ -58,7 +58,6 @@ export function settleOwedQueuedMessages( consumedRef, reason: submission?.reason ?? null, rejection: submission?.rejection, - origin: submission?.origin, now: input.now }) settled += changed ? 1 : 0 @@ -124,7 +123,6 @@ export function settleQueuedMessagesForRow( consumedRef: row.clientMessageId, reason: row.reason, rejection: row.rejection, - origin: submission?.origin, now: input.now }) return changed + (settled ? 1 : 0) diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 7160718124b..02e07bc53ee 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -13,7 +13,6 @@ import type { } from '../../../shared/agent-session-journal-types' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import Database from '../../sqlite/sync-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { journalDatabasePath } from './journal-host-database' @@ -420,11 +419,11 @@ describe('returned transition (D1/N4)', () => { }) it.each([ - { by: 'the queue', origin: 'host' as const, holdReason: null }, - { by: 'the person', origin: 'client' as const, holdReason: QUEUED_MESSAGE_PAUSED_KEPT } + { 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, holdReason }) => { + async ({ origin }) => { let journal = await open() await queueDraft(journal, 'draft-1') await consumeDraft(journal, 'draft-1', { origin }) @@ -434,11 +433,10 @@ describe('returned transition (D1/N4)', () => { await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => journal.wroteBeforeOpen(submission.acceptedSequence) ) - // The queue's own hand-off waits under the restart's pause, derived from the row's host - // instance; a Send the person asked for waits for them, kept. + // 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, + holdReason: null, hostInstance: 'proc-1', consumedAs: null, returnedReason: null @@ -595,23 +593,15 @@ describe('open-time repair and retention', () => { } }) - // The repair reaches the live hook's answer from the stored rejection and who asked for it. + // The repair reaches the live hook's answer from the stored rejection. it.each([ - { - origin: 'client' as const, - cause: 'hostRestarted' as const, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }, - { origin: 'host' as const, cause: 'hostRestarted' as const, holdReason: null }, - { - origin: 'client' as const, - cause: 'chatClosed' as const, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }, - { origin: 'host' as const, cause: 'chatClosed' as const, holdReason: null } + { 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, holdReason $holdReason', - async ({ origin, cause, holdReason }) => { + '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 }) @@ -629,7 +619,7 @@ describe('open-time repair and retention', () => { journal = await open() expect(journal.queuedMessages.get('draft-1')).toMatchObject({ state: 'waiting', - holdReason, + holdReason: null, consumedAs: null }) } 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 1e5fdb2ac03..51233b66abb 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 @@ -55,7 +55,7 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul createdAt: record.created_at, hostInstance: record.host_instance, state, - holdReason: record.hold_reason, + holdReason: storedHoldReason(record.hold_reason), returnedReason: record.returned_reason, returnedRejection: storedRejection(record.returned_rejection), settledAt: record.settled_at, @@ -69,6 +69,14 @@ export function readStoredQueuedMessageRow(row: unknown): QueuedMessageRow | nul } } +/** Holds an earlier build stored that this one derives instead: a kept send (`kept`) waits under + * the reopen's pause, a Stop's (`stopped`) under the Stop's. */ +const QUEUED_MESSAGE_RETIRED_HOLD_REASONS: ReadonlySet = new Set(['kept', 'stopped']) + +function storedHoldReason(stored: string | null): string | null { + return stored !== null && QUEUED_MESSAGE_RETIRED_HOLD_REASONS.has(stored) ? null : stored +} + function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null { if (json === null) { return null 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 f3e82613b1f..c9b380a2535 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 @@ -13,23 +13,18 @@ import type { AgentJournalCursor, AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - type QUEUED_MESSAGE_PAUSED_SEND_FAILED -} from '../../../shared/agent-session-queued-message-wire' +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' -/** Why ONE waiting draft is held from auto-sending: its conversion failed (`send_failed`), or it - * is a send the host accepted and kept across a restart or a close (`kept`). Stored on the row, so - * it survives handle eviction and restart; a wire marker (it publishes as `pausedReason`). A Stop - * or a restart pauses the whole queue instead. A reader treats an unknown stored value as a plain - * hold. */ -export type QueuedMessageHoldReason = - | typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED - | typeof QUEUED_MESSAGE_PAUSED_KEPT +/** 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. */ +export type QueuedMessageHoldReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED /** Definitively unsettled: what Stop, /clear, Edit and the budget count, and * what the published list shows. Pending/unknown/accepted deliveries and @@ -231,8 +226,8 @@ export function withdrawQueuedMessages( * 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 draft back to waiting keeps its position and carries no refusal, held as `kept` - * when the settlement says so; its spent submissions stay findable by their `queuedMessageId` link. + * nothing. A draft back to waiting keeps its position and carries no refusal; its spent + * submissions stay findable by their `queuedMessageId` link. */ export function settleRejectedQueuedMessage( db: Database.Database, @@ -241,8 +236,6 @@ export function settleRejectedQueuedMessage( consumedRef: string reason: string | null rejection: UnreadAgentSessionFailureFact | undefined - /** Who asked for the rejected hand-off (`AgentJournalSubmission.origin`). */ - origin: 'client' | 'host' | undefined now: number } ): boolean { @@ -252,15 +245,11 @@ export function settleRejectedQueuedMessage( ? db .prepare( `UPDATE queued_messages - SET state = 'waiting', hold_reason = ?, consumed_as = NULL, + 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( - settlement.kept ? QUEUED_MESSAGE_PAUSED_KEPT : null, - input.sessionId, - input.consumedRef - ) + .run(input.sessionId, input.consumedRef) : db .prepare( `UPDATE queued_messages diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index f60b2f29583..1100c0abf81 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -52,7 +52,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending) - const closeConversation = async (sessionId: string): Promise => { + const closeConversation = async (sessionId: string, atRest = false): Promise => { // The handle carries the proof that releases a lease its child's wind-down could not. if (await releaseLeaseOfEndedStructuredAgentSessionChild(host.context(), sessionId)) { return false @@ -66,7 +66,8 @@ export function createStructuredAgentSessionConversationLifetime(host: { host.closeStatus(id, { listed: !tabs.present || tabs.sessionIds.includes(id) }) } }, - sessionId + sessionId, + atRest ) } @@ -91,7 +92,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause: 'host-stop' }), - closeConversation, + closeConversation: (sessionId) => closeConversation(sessionId, true), logger: deps().logger, ...deps().idleSweep }) @@ -140,7 +141,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { }) }, /** Ends a chat's resources, not the chat: its record and journal stay on disk, and what is - * still queued will not be sent; a person's message stays as a held card. */ + * still queued waits for the chat's next turn; a person's unsent message stays as a card. */ close: (sessionId: string, cause: StructuredAgentSessionCloseCause): Promise => { // Outside the queue: a start the provider never answers must not hold the close behind it. host.context().runtimeState.acquireAborts.abort(sessionId, 'closed while starting') @@ -148,8 +149,11 @@ export function createStructuredAgentSessionConversationLifetime(host: { readRefusals.forget(sessionId) const session = sessions.get(sessionId) if (session) { - // Settled before the stop, so no start delivers it. - await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal) + // Settled and marked before the stop, so no start delivers it, and nothing it closed with + // sends by itself, even if the handle stays open. + await holdClosedStructuredAgentSessionSends(deps(), sessionId, session.journal, { + mark: 'always' + }) } await stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, { cause }) await closeConversation(sessionId) 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 aa1a5c3d619..ec7f478501f 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 @@ -6,8 +6,9 @@ // crash boundary. That // needs no lease: provider history decides such a row later, under a won lease, in the attach. A // row an earlier process accepted and never handed over (it quit or crashed first) is settled here -// too, before any reader, command or child sees it: a person's message is kept as a held card, the -// rest rejected (`journal-unsent-send-hold.ts`). Nothing here starts a provider child. +// too, before any reader, command or child sees it: a person's message is kept as a card, the rest +// rejected (`journal-unsent-send-hold.ts`). The cards then wait for the chat's next turn +// (`queued-message-pause.ts`). Nothing here starts a provider child. import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -23,7 +24,10 @@ 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 { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + markStructuredQueueReopen, + structuredAgentSessionHostInstance +} from './structured-agent-session-queued-pause' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession @@ -115,6 +119,9 @@ export async function openStructuredAgentSessionConversationJournal( error }) } + // After the leftovers became cards, so the mark follows every card this open found. Every open + // comes after the chat stopped running: the idle sweep never closes one with cards waiting. + await markStructuredQueueReopen(sessionId, journal, fence, deps.logger) // No child in this process writes to a journal nobody had open, so whatever it shows running // belongs to a generation that is gone, whatever the lease still claims. Settled before any // reader or child sees it. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index a7989f940ef..380742f0e23 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -17,8 +17,6 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { holdUnsentSends } from '../agent-session-journal/journal-unsent-send-hold' -import { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentRegistry } from './structured-agent-registry' import { @@ -41,6 +39,7 @@ import { handOverSubmission } from './structured-agent-session-turns' import { structuredAgentSessionNextHandover } from './structured-agent-session-opening-send' import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime' export type StructuredAgentSessionDeliveryLoopDeps = { sessions: ReadonlyMap @@ -167,11 +166,12 @@ export class StructuredAgentSessionDeliveryLoop { // The open already did, unless its write failed: a row an earlier handle wrote is never handed // over, whether it outlived a quit or a crash. A failure here throws, so none is: this run then // fails, which rejects every queued send, this process's own too. - await holdUnsentSends(session.journal, { - fence: this.deps.conversationFence(sessionId), - hostInstance: structuredAgentSessionHostInstance(), - hold: { cause: 'hostRestarted' } - }) + await holdRestartedStructuredAgentSessionSends( + this.deps.logger, + sessionId, + session.journal, + this.deps.conversationFence(sessionId) + ) if (!(await this.closeWhatTheUserClosed(sessionId, session))) { // Never start an agent for a message the user closed; the next wake re-derives and retries. return this.stop(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 4e4247c316f..9abd58aa021 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -74,7 +74,10 @@ export function createStructuredAgentSessionConversationDelivery(input: { holdClosed: async (sessionId, which) => { const session = sessions.get(sessionId) return session - ? holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, which) + ? holdClosedStructuredAgentSessionSends(deps, sessionId, session.journal, { + mark: 'settled', + which + }) : true }, failureTextContext: (sessionId) => 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 84dec78d071..17441c67b93 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,7 +24,10 @@ 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 { structuredAgentSessionHostInstance } from './structured-agent-session-queued-pause' +import { + markStructuredQueueReopen, + structuredAgentSessionHostInstance +} from './structured-agent-session-queued-pause' import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter' export type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-stop-event' import { @@ -61,31 +64,62 @@ type ConversationCloseDeps = Pick & { } /** What is still queued when the chat closes will not be handed over: a person's message is kept - * as a held card, the rest rejected (`journal-unsent-send-hold.ts`). A quit is not a close: the - * next open settles what it left. `which` narrows it to the messages a close that did not complete + * as a card that waits for the chat's next turn, the rest rejected (`journal-unsent-send-hold.ts`). + * A quit is not a close: the next open settles what it left. The chat stops running, so the + * reopen mark follows (`markStructuredQueueReopen`): on every `close`, or only once it settled a + * send, for a later re-check of the same close, which must never mark past a new send. `which` narrows it to the messages a close that did not complete * closed. Best effort, so a close never waits on it: resolves false when it failed, reported and * never thrown. */ export async function holdClosedStructuredAgentSessionSends( deps: ConversationCloseDeps, sessionId: string, journal: StructuredAgentSessionHostSession['journal'], - which?: (submission: AgentJournalSubmission) => boolean + close: { mark: 'always' | 'settled'; which?: (submission: AgentJournalSubmission) => boolean } ): Promise { - return holdUnsentSends(journal, { - fence: structuredAgentSessionConversationFence(deps.store, sessionId), + const fence = structuredAgentSessionConversationFence(deps.store, sessionId) + const { which } = close + const settled = await holdUnsentSends(journal, { + fence, hostInstance: structuredAgentSessionHostInstance(), hold: { cause: 'chatClosed', ...(which ? { which } : {}) } }).then( - () => true, + (newest) => ({ ok: true, newest }), (error: unknown) => { deps.logger.warn('settling queued messages of a closed chat failed', { scope: 'queued-abandon', sessionId, error }) - return false + return { ok: false, newest: null } } ) + if (close.mark === 'always') { + await markStructuredQueueReopen(sessionId, journal, fence, deps.logger) + } else if (settled.newest !== null) { + // A send that woke this re-check came after the ones it settled: the mark starts at them. + await markStructuredQueueReopen(sessionId, journal, fence, deps.logger, settled.newest + 1) + } + return settled.ok +} + +/** What an earlier host process left queued and the open could not settle (its write failed): + * kept or rejected now, never handed over. A failure throws. A send that woke this came after the + * ones it settled, so the reopen mark starts where they did. */ +export async function holdRestartedStructuredAgentSessionSends( + logger: StructuredAgentSessionHostDeps['logger'], + sessionId: string, + journal: StructuredAgentSessionHostSession['journal'], + fence: number +): Promise { + const hostInstance = structuredAgentSessionHostInstance() + const settled = await holdUnsentSends(journal, { + fence, + hostInstance, + hold: { cause: 'hostRestarted' } + }) + if (settled !== null) { + await markStructuredQueueReopen(sessionId, journal, fence, logger, settled + 1) + } } /** @@ -159,14 +193,18 @@ export type StructuredAgentSessionCloseCause = Extract< > /** Whether the conversation's handle is only a cache now: no child, and nothing queued or waiting - * on the provider. */ + * on the provider. `atRest`: the idle sweep's own close, which also keeps a chat with a card + * waiting, since a reopen marks it to wait for a turn (`markStructuredQueueReopen`), and an + * eviction the person never saw must not; a person's close has marked them already. */ export function structuredAgentSessionConversationClosable( - session: StructuredAgentSessionHostSession + session: StructuredAgentSessionHostSession, + atRest = false ): boolean { return ( session.child === null && !session.journal.submissions().some(isQueuedAgentJournalSubmission) && - session.journal.pendingSubmissions().length === 0 + session.journal.pendingSubmissions().length === 0 && + !(atRest && session.journal.queuedMessages.awaitReopenMark()) ) } @@ -181,10 +219,11 @@ export async function closeStructuredAgentSessionConversationUnderSerialize( /** The status row outlives the handle; see `StructuredAgentSessionClientDelivery`. */ closeStatus: (sessionId: string) => void }, - sessionId: string + sessionId: string, + atRest = false ): Promise { const session = context.sessions.get(sessionId) - if (!session || !structuredAgentSessionConversationClosable(session)) { + if (!session || !structuredAgentSessionConversationClosable(session, atRest)) { return false } context.sessions.delete(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 13edb107a4b..8c98a685c7d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -29,6 +29,7 @@ import { ensureStructuredAgentSessionAgent } from './structured-agent-session-ag import { StructuredAgentSessionHost } from './structured-agent-session-host' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { HOST_TEST_LOCATION, HOST_TEST_NOW as NOW, @@ -773,7 +774,7 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(await statusRows()).toEqual([]) }) - it('keeps a person’s message the user closed as a held card, and starts no child for it', async () => { + it('keeps a person’s message the user closed as a waiting card, and starts no child for it', async () => { const start = deferred() adapterExtras = { closeSession: vi.fn(async () => true) } await restartHost() @@ -797,6 +798,15 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(page.ok && page.page.queuedMessages?.map((card) => card.messageId)).toEqual([first]) expect(acquire).toHaveBeenCalledTimes(starts) expect(dispatch).not.toHaveBeenCalled() + // The close stopped the chat running: the card waits for its next turn, by a mark the + // re-check wrote once, when it kept the card. + const journal = conversation()!.journal + expect(structuredQueuePauses(journal).map((pause) => pause.reason)).toEqual(['restarted']) + const marks = vi.spyOn(journal, 'appendQueueReopen') + await accept('second', { person: true }) + await settleLoop() + // A later send's re-check settles nothing, so it marks nothing past that send. + expect(marks).not.toHaveBeenCalled() }) it('starts no child when closing what was queued fails, and closes it on the next wake', async () => { 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 10b48321bc9..d4050e8e39a 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 @@ -21,7 +21,7 @@ import { nativeChatComposerPrimaryAction, type NativeChatComposerPrimaryAction } from '../../../renderer/src/components/native-chat/native-chat-composer-primary-action' -import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { readQueuePublication, structuredQueueSendGate @@ -512,11 +512,17 @@ describe("the queue's next card on a history page", () => { const card = await queuedDraft('held, then released') await rig.stop() await rig.settleAccepted(working, 'stopped') - // The Resume row is written; its adoption, and so the drain behind it, waits. + // The Resume row is written; the Resume itself, and so the drain behind it, waits. let release: () => void = () => undefined - const adopt = vi - .spyOn(JournalQueuedMessages.prototype, 'adopt') - .mockImplementationOnce(() => new Promise((resolve) => (release = () => resolve(false)))) + const held = new Promise((resolve) => (release = resolve)) + const write = AgentSessionJournal.prototype.appendQueueResume + const resume = vi + .spyOn(AgentSessionJournal.prototype, 'appendQueueResume') + .mockImplementationOnce(async function (this: AgentSessionJournal, fence: number) { + const cursor = await write.call(this, fence) + await held + return cursor + }) const resumed = rig.resume() try { await eventually(async () => { @@ -525,7 +531,7 @@ describe("the queue's next card on a history page", () => { }) } finally { release() - adopt.mockRestore() + resume.mockRestore() } expect(await resumed).toMatchObject({ ok: true, value: { resumed: true } }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index dda3d2112cd..7ab0fcfc1f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -152,7 +152,8 @@ describe('/clear', () => { { messageId: firstId, state: 'waiting' }, { messageId: secondId, state: 'waiting' } ]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Held, unshown: nothing runs in the fresh conversation. + expect(await rig.queuePause(replacementId)).toBeNull() const body = hostTestMessage('first in the new chat') const fields = { body, delivery: 'queue-if-active' as const } 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 a0e3af88c49..fd3294ddc7d 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 @@ -203,13 +203,10 @@ export async function createQueuedMessageTestRig( }) } - /** A host-process restart, as the queue sees it: the conversation closes, and - * opens afresh under a new instance id while its rows survive. The close is an eviction, whose - * Stop event ends a person's Stop pause if work runs; a quit writes none, so a test of that - * pause across a restart uses `crashRestartHostProcess`. */ + /** A host-process restart: the app quits (its own teardown runs) and a new host opens the same + * state. A quit writes no close's Stop event, so a person's Stop pause survives it. */ async function restartHostProcess(): Promise { - await host.close(SESSION, 'evict') - rotateStructuredAgentSessionHostInstanceForTests() + await quitRestartHostProcess() } /** A host process that dies with no close: a new host opens the same state directory. */ 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 686940c4fa7..8e934435235 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 @@ -298,7 +298,7 @@ describe('held drafts', () => { await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() expect(await rig.queuePause()).toBeNull() - // The user's send starting its turn lifts it, and adopts the row into this instance. + // The user's send starting its turn lifts it. const next = send('user starts a new turn') await next.result expect(await rig.handoff(draftId)).toBeUndefined() @@ -420,10 +420,12 @@ describe('Stop and Delete', () => { await stop() expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) await settleAccepted(working, 'a') - // Evict the handle and reopen (the history read opens the conversation at - // rest): the pause is derived from what the journal holds, so nothing drains. + // Close and reopen it (the history read opens it at rest): the derived pause still holds, so + // nothing drains; a close hides the row, as nothing runs there until its next turn. await host.close(SESSION, 'evict') - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.queuePause()).toBeNull() + const reopened = host.collaboratorsForTests().sessions.get(SESSION)!.journal + expect(structuredQueuePauses(reopened).map((pause) => pause.reason)).toContain('stopped') await new Promise((resolve) => setTimeout(resolve, 250)) expect(await rig.handoff(draftId)).toBeUndefined() // Send-now overrides the pause — the user acting is a release. @@ -614,15 +616,15 @@ describe('/clear', () => { if (!replacementId) { throw new Error('expected a replacement session') } - // The source's cards are spent tombstones; the replacement shows them on a - // queue paused by the clear — not "because you interrupted" — until the user - // acts: Resume, or their next send starting its turn. + // The source's cards are spent tombstones; the replacement shows them as plain waiting cards, + // held by the clear with no row (nothing runs there), until the user acts: Resume, or their + // next send starting its turn. expect(await drafts()).toHaveLength(0) expect(await drafts(replacementId)).toEqual([ { messageId: firstId, state: 'waiting' }, { messageId: secondId, state: 'waiting' } ]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.queuePause(replacementId)).toBeNull() // Paused from before the first carried card lands: the idle replacement auto-sends nothing. await new Promise((resolve) => setTimeout(resolve, 250)) expect((await host.journalSnapshot(replacementId)).submissions).toHaveLength(0) @@ -663,14 +665,14 @@ describe('/clear', () => { ) }) - it("the replacement's 'cleared' pause lifts through Resume exactly like a Stop's", async () => { + it("the replacement's unshown 'cleared' pause lifts through Resume exactly like a Stop's", async () => { const [firstId] = await pausedDrafts() const cleared = await clear(hostTestOperationId()) const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined if (!replacementId) { throw new Error('expected a replacement session') } - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.queuePause(replacementId)).toBeNull() const resumed = await host.queuedMessagesResume(CALLER, { envelope: envelope( {}, @@ -733,7 +735,7 @@ describe('/clear', () => { // The refusal belonged to the source's submissions; on the replacement the // text is simply a waiting draft again, behind the replacement's pause. expect(await drafts(replacementId)).toEqual([{ messageId: draftId, state: 'waiting' }]) - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + expect(await rig.queuePause(replacementId)).toBeNull() expect(await drafts()).toHaveLength(0) }) 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 3acc45552aa..fe8b31efe90 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 @@ -367,7 +367,7 @@ export class StructuredAgentSessionQueuedMessageDrain { expect: 'waiting', settledByOp: null, hostInstance: structuredAgentSessionHostInstance(), - yieldsToPause: { hostInstance: structuredAgentSessionHostInstance() } + yieldsToPause: true } ) } catch (error) { 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 b637e1fc89e..3ba5bbb612e 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 @@ -7,13 +7,12 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - type AgentSessionMutationEnvelope, - type AgentSessionMutationResult, - type AgentSessionQueuedMessageDeleteResult, - type AgentSessionQueuedMessagesResumeResult, - type AgentSessionSendResult +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionQueuedMessageDeleteResult, + AgentSessionQueuedMessagesResumeResult, + AgentSessionSendResult } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' @@ -117,10 +116,6 @@ export async function carryQueuedMessagesToClearReplacement( fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), carriedFrom: ctx.sessionId, - // A kept send stays held there too: no later message may release it. - ...(row.holdReason === QUEUED_MESSAGE_PAUSED_KEPT - ? { holdReason: QUEUED_MESSAGE_PAUSED_KEPT } - : {}) }) } await withdrawQueuedMessagesForOperation(ctx.journal, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index d55f7cea3fb..418c51f5cc7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -20,11 +20,7 @@ import { } from './structured-agent-session-queued-message-rig.test-fixture' import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test-fixture' import { sameQueuePause } from './structured-agent-session-queued-publication' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { - structuredAgentSessionHostInstance, - structuredQueuePauses -} from './structured-agent-session-queued-pause' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' let rig: QueuedMessageTestRig @@ -365,7 +361,7 @@ describe('a pause only over cards Resume could send', () => { }) describe("a restart's pause", () => { - it('once a turn ends it, stays ended when the conversation reopens', async () => { + it('a turn ends it; closing again with a card still waiting holds that card again', async () => { const working = await rig.workingSend() const first = await queuedDraft('first') const second = await queuedDraft('second') @@ -377,16 +373,17 @@ describe("a restart's pause", () => { await next.result await rig.settleAccepted(next.id, 'b') await eventually(async () => expect(await rig.handoff(first)).toBeDefined()) - // Reopened, that turn is "before this open", yet the pause it ended stays ended: - // the lift adopted the rows into this process. + // Closed again with a card still waiting, it waits again for the next turn, unshown. await rig.host.close(HOST_TEST_SESSION, 'evict') expect(await rig.queuePause()).toBeNull() expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' }) + expect(derivedPauses()).toEqual(['restarted']) }) }) describe('a card handed off after a restart', () => { - it('belongs to the process that sent it: withdrawn back to waiting, it raises no restart pause', async () => { + // Returned, not waiting, when Orca restarted: the reopen holds nothing; only the Stop does. + it('sent again and withdrawn by a Stop, it waits under that Stop alone, shown; Resume lifts it', async () => { const working = await rig.workingSend() const draftId = await queuedDraft('refused, then re-sent after a restart') await rig.settleAccepted(working, 'a') @@ -396,7 +393,7 @@ describe('a card handed off after a restart', () => { expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'returned' }]) ) await rig.restartHostProcess() - // Sent again in this process, then withdrawn by a Stop before the agent had it. + // Sent again after the restart, then withdrawn by a Stop before the agent had it. // Its delivery is held, so the Stop runs ahead of the handover. const { held, release } = holdDelivery() const handedOver = rig.dispatch.mock.calls.length @@ -410,18 +407,10 @@ describe('a card handed off after a restart', () => { await eventually(async () => expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }]) ) - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!journal) { - throw new Error('expected the conversation open') - } - expect(journal.queuedMessages.get(draftId)?.hostInstance).toBe( - structuredAgentSessionHostInstance() - ) - // With the Stop's pause gone, nothing else holds it: no restart happened since it was sent. - await journal.appendQueueResume( - structuredAgentSessionConversationFence(rig.store, HOST_TEST_SESSION) - ) - expect(structuredQueuePauses(journal)).toEqual([]) + expect(derivedPauses()).toEqual(['stopped']) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + expect(derivedPauses()).toEqual([]) }) }) 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 6722313d039..e59a56a7c79 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 @@ -1,6 +1,6 @@ // Whether the queue is paused, and why — derived from the journal and the cards -// (`queued-message-pause.ts`), never stored. A Stop's event and a Resume are journal -// rows; an explicit Resume lifts any pause. +// (`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' @@ -8,15 +8,15 @@ import type { DerivedQueuePause } from '../agent-session-journal/queued-message- 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`); a draft written by another - * instance pauses the queue rather than auto-sending after a restart. */ + * `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 } -/** Simulates a host-process restart. Tests only. */ +/** A new stamp, as a new host process mints. Tests only; no pause reads it. */ export function rotateStructuredAgentSessionHostInstanceForTests(): string { hostInstance = randomUUID() return hostInstance @@ -26,38 +26,34 @@ type PauseJournal = Pick /** The queue's pauses in force, derived; none when the queue sends on its own. */ export function structuredQueuePauses(journal: PauseJournal): DerivedQueuePause[] { - return journal.queuedMessages.pauses(hostInstance) + return journal.queuedMessages.pauses() } -/** - * Every journal publish: a restart's rows are adopted into this instance once a turn started. - * The derivation already reads them as lifted; the write keeps that answer when the handle - * reopens (its "since this conversation opened" moves). Bookkeeping: a failure is reported. - */ -export async function adoptEndedRestartPause( +/** The mark of a chat that stopped running with cards waiting — Orca quit or crashed, or the chat + * was closed — so they wait for its next turn (`queued-message-pause.ts`). Every open marks, and + * so does a person's close: the idle sweep never closes a chat with cards waiting, so its + * eviction never reopens one. `since`: where the chat stopped, for a mark written after a send + * that came later, which must still lift it. Bookkeeping, so a failure is reported and never + * thrown; the pause then starts where the mark would have gone, holding no less. */ +export async function markStructuredQueueReopen( sessionId: string, - journal: PauseJournal, - logger: StructuredAgentSessionLogger + journal: Pick, + fence: number, + logger: StructuredAgentSessionLogger, + since?: number ): Promise { try { - const { queuedMessages } = journal - const restarted = queuedMessages - .list() - .some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance) - if (restarted && queuedMessages.restartEnded()) { - await queuedMessages.adopt(hostInstance) - } + await journal.markQueueReopen(fence, since) } catch (error) { - logger.warn("adopting a restart's queued cards after a started turn failed", { - scope: 'queue-pause-adoption', + logger.warn('marking a reopened queue failed', { + scope: 'queue-reopen-mark', sessionId, error }) } } -/** Resume: a journal row that ends a Stop's or a /clear's pause, and adoption of a restart's - * rows. Returns whether the queue was paused. */ +/** Resume: a journal row that ends every pause. Returns whether the queue was paused. */ export async function resumeStructuredQueue( journal: Pick, fence: number @@ -66,6 +62,5 @@ export async function resumeStructuredQueue( return false } await journal.appendQueueResume(fence) - await journal.queuedMessages.adopt(hostInstance) return true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts index af1b7e567b3..d48d9bf27c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.ts @@ -4,7 +4,6 @@ // streams from re-sending it. They ride together: a client never sees one without the others. import { - QUEUED_MESSAGE_PAUSED_KEPT, QUEUED_MESSAGE_PAUSED_SEND_FAILED, type AgentSessionQueuedMessage, type AgentSessionQueuedMessagePausedReason, @@ -42,9 +41,8 @@ export function structuredQueueSendGate( }) } -/** Waiting and returned rows only. `paused` is a per-card hold (a failed - * conversion, or a send the host kept); a Stop or a /clear pauses the queue, published once - * beside it. */ +/** Waiting and returned rows only. `paused` is a per-card hold (a failed conversion); a person's + * Stop pauses the queue, published once beside it. */ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] { const published: AgentSessionQueuedMessage[] = [] for (const row of journal.queuedMessages.list()) { @@ -72,7 +70,7 @@ function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSess function isPublishedPausedReason( reason: string | null ): reason is AgentSessionQueuedMessagePausedReason { - return reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED || reason === QUEUED_MESSAGE_PAUSED_KEPT + return reason === QUEUED_MESSAGE_PAUSED_SEND_FAILED } type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] } @@ -114,18 +112,29 @@ export function readQueuePublication( gate: QueueSendGate ): QueuePublication { const queuedMessages = readPublishedQueuedMessages(journal) - // Read per emit: the pause also turns on submissions (a turn starting). Shown only over a card - // Resume would send, so its header never offers to send nothing; deleting a blocking returned - // card shows it again. After a restart nothing is shown, a Stop's or /clear's included: the - // chat's next turn lifts every pause, and the cards read as plain waiting cards until then. + // Read per emit: the pause also turns on submissions (a turn starting). Only a person's Stop is + // shown, and only over a card Resume would send, so its header never offers to send nothing; + // deleting a blocking returned card shows it again. After a /clear, a restart or a close nothing + // is shown, a Stop's from before it included: nothing runs in the chat, its next turn lifts every + // pause, and the cards read as plain waiting cards until then. A Stop the person makes after the + // reopen shows. const pauses = structuredQueuePauses(journal) - const restarted = pauses.some((pause) => pause.reason === 'restarted') - const resumable = restarted ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) + const stop = pauses.find((pause) => pause.reason === 'stopped')?.since + const silent = pauses.some( + (pause) => + pause.reason === 'cleared' || + (pause.reason === 'restarted' && + (!stop || + !pause.since || + stop.epoch !== pause.since.epoch || + stop.sequence < pause.since.sequence)) + ) + const resumable = silent ? null : resumableQueuePause(pauses, journal.queuedMessages.list()) // The submissions are read only while a pause would show, never while the queue runs freely. const pause = resumable && !queuePauseLiftOnItsWay(resumable, journal.submissions()) ? resumable : null - // `restarted` is already excluded above; the test narrows the type. - const queuePause = pause && pause.reason !== 'restarted' ? { reason: pause.reason } : null + // The test narrows the type. + const queuePause = pause?.reason === 'stopped' ? { reason: pause.reason } : null const nextQueuedMessageId = nextStructuredQueuedMessage({ journal, ...gate() })?.messageId ?? null const previous = publications.get(journal) if ( 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 001e0b10786..2b0aae62f48 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 @@ -246,7 +246,11 @@ describe("a /clear's carried cards", () => { if (!replacementId) { throw new Error(`expected a replacement session: ${JSON.stringify(cleared)}`) } - expect(await rig.queuePause(replacementId)).toEqual({ reason: 'cleared' }) + // Held there, unshown: nothing runs in the fresh conversation. + expect(await rig.queuePause(replacementId)).toBeNull() + expect(structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason)).toEqual([ + 'cleared' + ]) // Idle there, so the person's send goes straight out rather than queueing. const text = hostTestMessage('hi') const person = rig.host.send(QUEUED_RIG_CALLER, { @@ -299,7 +303,9 @@ describe('no stored pause', () => { ...fields }) const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined - expect(replacementId && (await rig.queuePause(replacementId))).toEqual({ reason: 'cleared' }) + expect( + replacementId && structuredQueuePauses(journal(replacementId)).map((pause) => pause.reason) + ).toEqual(['cleared']) expect(pauseTables()).toBe(0) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index af7aee44bc9..a5ddfeb6474 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -9,7 +9,6 @@ import type { } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { StructuredAgentSessionQueuedMessageDrain } from './structured-agent-session-queued-messages' -import { adoptEndedRestartPause } from './structured-agent-session-queued-pause' import { deleteQueuedStructuredAgentMessage, resumeStructuredAgentQueue, @@ -39,14 +38,9 @@ export function wireStructuredAgentSessionQueuedMessages( return { drain, /** Every journal publish: turn, submission, prompt, command and Stop - * settlements are all commits, and each re-derives the drain's gates — - * and adopts a restart's cards once a turn started. */ + * settlements are all commits, and each re-derives the drain's gates. */ onJournalActivity: (sessionId: string) => { sessions.touch(sessionId) - const journal = sessions.get(sessionId)?.journal - if (journal) { - void adoptEndedRestartPause(sessionId, journal, context().deps.logger) - } drain.schedule(sessionId) }, queuedMessageSend: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index b5d33be6827..9c9722de00b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -3,6 +3,7 @@ // does a Stop of a start that never landed; a card it held, which Resume releases, and anything // sent after it end as their own. Turn rows name the send that opened them, as Codex writes them. +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { @@ -128,6 +129,13 @@ async function mailTurn(): Promise { } /** The host evicts the chat; the Stop events as its provider close finds them. */ +/** The person's Stop still pauses the reopened chat. The close hides its row, as every close of a + * chat does: nothing runs there until its next turn. */ +async function stillStopped(): Promise { + expect(await rig.queuePause()).toBeNull() + return structuredQueuePauses(journal()).some((pause) => pause.reason === 'stopped') +} + async function evictedAt(): Promise { let atClose: JournalStopEvent[] = [] rig.closeSession.mockImplementationOnce(async () => { @@ -371,7 +379,7 @@ describe('a host stop with no turn running after a Stop that named none', () => expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') expect(await evictedAt()).toEqual(['user-stop']) - expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await stillStopped()).toBe(true) expect(await rig.handoff(card)).toBeUndefined() } ) @@ -386,7 +394,7 @@ describe('a host stop with no turn running after a Stop that named none', () => expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(await evictedAt()).toEqual(['user-stop']) - expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await stillStopped()).toBe(true) expect(await rig.handoff(card)).toBeUndefined() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index 3f1afc89d6b..17a274d6f5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -251,7 +251,9 @@ describe('every Stop entry writes its event, with its reason, before it ends the await rig.host.close(HOST_TEST_SESSION, 'evict') expect(atClose.events?.map((event) => event.reason)).toEqual(['user-stop']) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + // The close hides the row, as every close of a chat does; the Stop still pauses. + expect(await rig.queuePause()).toBeNull() + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toContain('stopped') }, 20_000 ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts index 5112b6ff74d..fe6a29cdcae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unrun-send-hold.test.ts @@ -1,13 +1,14 @@ // A message handed to an agent whose start never answers provably never ran: its CLI takes no // message before it answers initialize. So when the chat ends then, the message is settled as a // queued one is for the same end (`journal-unsent-send-hold.ts`): a quit or a close keeps a -// person's words as a held card, a person's Stop withdraws it. Against the real host, store and +// person's words as an ordinary card that waits for the chat's next turn, a person's Stop +// withdraws it. Against the real host, store and // journal, with an agent that stays starting. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' +import { structuredQueuePauses } from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, eventually, @@ -17,7 +18,17 @@ import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-te const words = (kind: 'hostRestarted' | 'chatClosed' | 'cancelled') => agentSessionFailureWords(agentSessionFailureFact(kind), { surface: 'rejection' }) -const KEPT = { state: 'waiting', paused: true } +/** A kept send: an ordinary waiting card, with no hold of its own. */ +const KEPT = { state: 'waiting' } + +/** What holds the queue, derived from the open journal. */ +function derivedPauses(): string[] { + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('expected the conversation open') + } + return structuredQueuePauses(journal).map((pause) => pause.reason) +} let rig: QueuedMessageTestRig @@ -47,7 +58,7 @@ async function handedToHungStart(text: string): Promise { } describe('a message handed to a start that never answered, then the chat ends', () => { - it('is a held card after a quit, rejected as a restart, as a queued one is', async () => { + it('is a card after a quit, rejected as a restart, held by the reopen until a turn', async () => { const id = await handedToHungStart('kept through quit') await rig.quitRestartHostProcess() @@ -59,7 +70,8 @@ describe('a message handed to a start that never answered, then the chat ends', expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) const [card] = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.queuedMessages.list() ?? [] - expect(card).toMatchObject({ holdReason: QUEUED_MESSAGE_PAUSED_KEPT }) + expect(card).toMatchObject({ holdReason: null }) + expect(derivedPauses()).toEqual(['restarted']) // Nothing ran, so there is nothing to resume: the card alone holds the words. expect(await rig.restartOffers()).toEqual([]) }) @@ -81,7 +93,7 @@ describe('a message handed to a start that never answered, then the chat ends', }) it.each(['user-close', 'evict'] as const)( - 'is a held card after a %s, rejected as closed, as a queued one is', + 'is a card after a %s, rejected as closed, held by the reopen until a turn', async (cause) => { const id = await handedToHungStart('kept at close') await rig.host.close(SESSION, cause) @@ -93,6 +105,7 @@ describe('a message handed to a start that never answered, then the chat ends', keptAsQueuedMessageId: id }) expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(derivedPauses()).toEqual(['restarted']) } ) 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 d3a658d4265..3f9e272a80f 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 @@ -1,13 +1,12 @@ // A message the host accepted while the agent was starting, then Orca quit or crashed, or the chat -// closed, before handing it over: it is kept as a held card at the head of the queue, never sent on -// its own, and released only by the person's own Send, Edit or Delete on it. Against the real host, -// store and journal. +// closed, before handing it over: it is kept as an ordinary card at the head of the queue, never +// sent on its own; like every card the chat closed with, it follows the chat's next accepted turn +// (or a Resume, Send now, Edit or Delete). Against the real host, store and journal. 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 { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' import type { AgentMessageSource } from '../../../shared/agent-session-message-source' import { projectStructuredAgentSessionMessages } from '../../../shared/structured-agent-session-message-projection' import { createStructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' @@ -17,7 +16,10 @@ 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 } from './structured-agent-session-queued-pause' +import { + structuredAgentSessionHostInstance, + structuredQueuePauses +} from './structured-agent-session-queued-pause' import { createQueuedMessageTestRig, eventually, @@ -48,7 +50,8 @@ const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRes const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { surface: 'rejection' }) -const KEPT = { state: 'waiting', paused: true } +/** A kept send: an ordinary waiting card, with no hold of its own. */ +const KEPT = { state: 'waiting' } let rig: QueuedMessageTestRig @@ -133,33 +136,36 @@ describe('a message accepted while the agent starts, then Orca stops', () => { it.each([ { how: 'quit', restart: () => quitRestart() }, { how: 'crash', restart: async () => rig.crashRestartHostProcess() } - ])('is a held card after a $how, and its submission is rejected unseen', async ({ restart }) => { - const id = await acceptWhileStarting(sendRequest('hello after restart')) - await restart() + ])( + 'is a waiting card after a $how, and its submission is rejected unseen', + async ({ restart }) => { + const id = await acceptWhileStarting(sendRequest('hello after restart')) + await restart() - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - // Held on its own: no queue-wide pause, so no Resume to press. - expect(await rig.queuePause()).toBeNull() - expect(await rig.submission(id)).toMatchObject({ - dispatchState: 'rejected', - ...HOST_RESTARTED, - origin: 'client', - source: { kind: 'user' } - }) - const [card] = journal().queuedMessages.list() - expect(card).toMatchObject({ - messageId: id, - holdReason: QUEUED_MESSAGE_PAUSED_KEPT, - hostInstance: structuredAgentSessionHostInstance(), - body: hostTestMessage('hello after restart'), - queuedAt: { epoch: journal().epoch, sequence: expect.any(Number) } - }) - // Nothing is sent on its own, however many times the chat reopens. - await rig.host.close(SESSION, 'evict') - rig.crashRestartHostProcess() - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - expect(rig.dispatch).not.toHaveBeenCalled() - }) + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + // Nothing runs in the chat, so no paused row: it waits for the chat's next turn. + expect(await rig.queuePause()).toBeNull() + expect(await rig.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED, + origin: 'client', + source: { kind: 'user' } + }) + const [card] = journal().queuedMessages.list() + expect(card).toMatchObject({ + messageId: id, + holdReason: null, + hostInstance: structuredAgentSessionHostInstance(), + body: hostTestMessage('hello after restart'), + queuedAt: { epoch: journal().epoch, sequence: expect.any(Number) } + }) + // Nothing is sent on its own, however many times the chat reopens. + await rig.host.close(SESSION, 'evict') + rig.crashRestartHostProcess() + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(rig.dispatch).not.toHaveBeenCalled() + } + ) it('quit writes nothing for it: no closed-chat rejection, still queued until the next open', async () => { const id = await acceptWhileStarting(sendRequest('kept through quit')) @@ -182,33 +188,32 @@ describe('a message accepted while the agent starts, then Orca stops', () => { }) }) -describe('only an action on the card releases a kept card', () => { - it('a later message is delivered alone; Resume sends nothing; its own Send sends it once', async () => { +// R13-1: a kept send waits in order, like every card the chat closed with, and follows the +// chat's next accepted turn; no "press Send" step. +describe('the next accepted turn releases a kept card', () => { + it('a later message runs first, then the card follows it in order', async () => { const id = await acceptWhileStarting(sendRequest('the kept words')) await quitRestart() - // The person types the same words again, as one who cannot see cards would. - const retyped = rig.send('the kept words') - await retyped.result + const next = rig.send('a new request') + await next.result await eventually(async () => - expect((await rig.submission(retyped.id))?.handedOverAt).toBeDefined() + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() ) - await rig.settleAccepted(retyped.id, 'retyped') - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(dispatchedTexts()).toEqual(['the kept words']) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - - await rig.resume() - await new Promise((resolve) => setTimeout(resolve, 100)) expect(await rig.handoff(id)).toBeUndefined() - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + await rig.settleAccepted(next.id, 'next') + await eventually(() => expect(dispatchedTexts()).toEqual(['a new request', 'the kept words'])) + expect(await rig.handoff(id)).toMatchObject({ origin: 'host' }) + }) - expect(await rig.sendNow(id)).toMatchObject({ ok: true }) - const handoff = await rig.handoff(id) - expect(handoff?.clientMessageId).not.toBe(id) - expect(handoff).toMatchObject({ origin: 'client' }) - await eventually(() => expect(dispatchedTexts()).toEqual(['the kept words', 'the kept words'])) - expect(await rig.drafts()).toEqual([]) + it('a Resume sends it, and nothing sends it before', async () => { + const id = await acceptWhileStarting(sendRequest('resume me')) + await quitRestart() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() + expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } }) + await eventually(() => expect(dispatchedTexts()).toEqual(['resume me'])) + expect(await rig.handoff(id)).toBeDefined() }) it('Send now sends it at once, and Delete removes it', async () => { @@ -273,7 +278,7 @@ describe('only an action on the card releases a kept card', () => { expect(await rig.drafts()).toEqual([]) }) - it('a Stop on the reopened chat leaves it kept: the open settles it before the Stop runs', async () => { + it('a Stop on the reopened chat leaves its card waiting: the open settles it before the Stop runs', async () => { const id = await acceptWhileStarting(sendRequest('survives a stop')) await rig.crashRestartHostProcess() @@ -283,26 +288,33 @@ describe('only an action on the card releases a kept card', () => { expect(await rig.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) }) - // A Send now cut short by a quit hands the card back kept, so a retyped copy still goes alone. - // After a second restart the kept card is another process's, yet it pauses nothing else. - it('a later restart leaves the cards queued after it unpaused', async () => { + // Another restart before any turn: the card still waits, and the first turn there releases it + // ahead of the cards queued behind that turn. + it('a second restart still holds it; the next turn sends it before the cards queued after', async () => { const id = await acceptWhileStarting(sendRequest('kept twice over')) await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) rig.crashRestartHostProcess() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() const working = await rig.workingSend() const later = rig.send('queued after the restart', 'queue-if-active') expect(await later.result).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) expect(await rig.queuePause()).toBeNull() await rig.settleAccepted(working, 'working') + await eventually(() => expect(dispatchedTexts()).toEqual(['work on this', 'kept twice over'])) + await rig.settleAccepted(await rig.handoffId(id), 'kept') await eventually(() => - expect(dispatchedTexts()).toEqual(['work on this', 'queued after the restart']) + expect(dispatchedTexts()).toEqual([ + 'work on this', + 'kept twice over', + 'queued after the restart' + ]) ) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) }) - it('stays kept when its own Send is cut short by another quit', async () => { + it('its own Send cut short by another quit brings it back at the head, waiting', async () => { const id = await acceptWhileStarting(sendRequest('the kept words')) await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) @@ -321,16 +333,9 @@ describe('only an action on the card releases a kept card', () => { await quitRestart() expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) - expect(journal().queuedMessages.get(id)?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) - const retyped = rig.send('the kept words') - await retyped.result - await eventually(async () => - expect((await rig.submission(retyped.id))?.handedOverAt).toBeDefined() - ) - await rig.settleAccepted(retyped.id, 'retyped') + expect(journal().queuedMessages.get(id)?.holdReason).toBeNull() await new Promise((resolve) => setTimeout(resolve, 100)) - expect(dispatchedTexts()).toEqual(['the kept words']) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(rig.dispatch).not.toHaveBeenCalled() }) }) @@ -437,8 +442,8 @@ describe('the queue at a quit', () => { } ) - // The card the person pushed ahead waits for their own Send; Resume sends the rest. - it('Send now on an ordinary card, cut short by a quit, returns it kept while Resume sends the rest', async () => { + // 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() const first = rig.send('first queued', 'queue-if-active') await first.result @@ -455,9 +460,9 @@ describe('the queue at a quit', () => { ]) expect(await rig.queuePause()).toBeNull() await rig.resume() - await eventually(() => expect(dispatchedTexts()).toEqual(['first queued'])) - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(await rig.drafts()).toEqual([{ messageId: pushed.id, ...KEPT }]) + await eventually(() => expect(dispatchedTexts()).toEqual(['pushed ahead'])) + await rig.settleAccepted(await rig.handoffId(pushed.id), 'pushed') + await eventually(() => expect(dispatchedTexts()).toEqual(['pushed ahead', 'first queued'])) }) }) @@ -465,7 +470,7 @@ describe('the queue at a quit', () => { // chat, and the chat can be reopened from its history. describe('a message accepted while the agent starts, then the chat closes', () => { it.each(['user-close', 'evict'] as const)( - 'after a %s it is a held card the reopened chat shows, rejected as closed', + 'after a %s it is a waiting card the reopened chat shows, rejected as closed', async (cause) => { const id = await acceptWhileStarting(sendRequest('kept at close')) await closeWhileStarting(cause) @@ -488,9 +493,9 @@ describe('a message accepted while the agent starts, then the chat closes', () = .join('|') expect(texts).not.toContain('kept at close') expect(page.page.queuedMessages?.[0]).toMatchObject({ - body: hostTestMessage('kept at close'), - pausedReason: QUEUED_MESSAGE_PAUSED_KEPT + body: hostTestMessage('kept at close') }) + expect(page.page.queuedMessages?.[0]).not.toHaveProperty('paused') expect(rig.dispatch).not.toHaveBeenCalled() } ) @@ -499,7 +504,7 @@ describe('a message accepted while the agent starts, then the chat closes', () = // The desktop's outbox keeps an unconfirmed send and sends it again, under the same id, once the // app is back. That replay meets the card the open made from the same send. describe('the same send arriving again after the restart', () => { - it('a client that never asked to queue is answered from its own record: one card, still held, nothing sent', async () => { + it('a client that never asked to queue is answered from its own record: one card, still waiting, nothing sent', async () => { const request = sendRequest('sent again by the outbox') const id = await acceptWhileStarting(request) await quitRestart() @@ -521,16 +526,17 @@ describe('the same send arriving again after the restart', () => { await new Promise((resolve) => setTimeout(resolve, 100)) expect(rig.dispatch).not.toHaveBeenCalled() - // Retry on such a client sends the words under a new id: delivered once, the card still held. + // Retry on such a client sends the words under a new id, and the card follows that turn, as + // every card the chat closed with does: the same words twice, by the one rule. const retried = rig.send('sent again by the outbox') await retried.result await eventually(async () => expect((await rig.submission(retried.id))?.handedOverAt).toBeDefined() ) await rig.settleAccepted(retried.id, 'retried') - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(dispatchedTexts()).toEqual(['sent again by the outbox']) - expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + await eventually(() => + expect(dispatchedTexts()).toEqual(['sent again by the outbox', 'sent again by the outbox']) + ) }) it('a client that asked to queue is told the host holds it', async () => { @@ -624,8 +630,8 @@ describe('what is not kept', () => { }) }) -describe('/clear carries a kept card still held', () => { - it('a turn in the new conversation never releases it', async () => { +describe('/clear carries a kept card', () => { + it('the first turn in the new conversation releases it, after that turn', async () => { const id = await acceptWhileStarting(sendRequest('carried through clear')) await quitRestart() expect(await rig.drafts()).toHaveLength(1) @@ -639,8 +645,8 @@ describe('/clear carries a kept card still held', () => { throw new Error('expected a replacement session') } expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) - const carried = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal - expect(carried?.queuedMessages.list()[0]?.holdReason).toBe(QUEUED_MESSAGE_PAUSED_KEPT) + // Nothing runs in the fresh conversation, so no paused row. + expect(await rig.queuePause(replacementId)).toBeNull() const body = hostTestMessage('first turn after the clear') const turn = await rig.host.send(QUEUED_RIG_CALLER, { @@ -670,9 +676,131 @@ describe('/clear carries a kept card still held', () => { clientMessageId: turnId, providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-x', ordinal: 0 } }) + await eventually(() => + expect(dispatchedTexts()).toEqual(['first turn after the clear', 'carried through clear']) + ) + }) +}) + +// One rule for a chat that was not running: a card queued while it ran drains as its turn ends, +// and a card it closed with waits for its next accepted turn, written down by the reopen's mark. +describe("the reopen's mark", () => { + it('a card queued mid-turn drains when that turn ends, with no reopen and no mark', async () => { + const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + const working = await rig.workingSend() + const queued = rig.send('queued mid-turn', 'queue-if-active') + expect(await queued.result).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) + await rig.settleAccepted(working, 'working') + await eventually(() => expect(dispatchedTexts()).toEqual(['work on this', 'queued mid-turn'])) + expect(marks).not.toHaveBeenCalled() + }) + + it.each(['user-close', 'evict'] as const)( + 'a %s and a reopen in the same process: the card waits for the next turn', + async (cause) => { + const working = await rig.workingSend() + const queued = rig.send('queued before the close', 'queue-if-active') + await queued.result + await rig.host.close(SESSION, cause) + expect(await rig.drafts()).toEqual([{ messageId: queued.id, state: 'waiting' }]) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(await rig.handoff(queued.id)).toBeUndefined() + expect(dispatchedTexts()).toEqual(['work on this']) + void working + + const next = rig.send('after the reopen') + await next.result + await eventually(async () => + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(next.id, 'next') + await eventually(() => + expect(dispatchedTexts()).toEqual([ + 'work on this', + 'after the reopen', + 'queued before the close' + ]) + ) + } + ) + + // An idle eviction is Orca's own act: the person sees the same rows, and nothing new is held. + it('an idle-sweep eviction of a stopped chat writes nothing, and its Stop row stays', async () => { + await rig.dispose() + rig = await createQueuedMessageTestRig({ idleSweep: { idleMs: 0, intervalMs: 60 * 60 * 1000 } }) + const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + const working = await rig.workingSend() + const held = rig.send('held by the Stop', 'queue-if-active') + await held.result + expect(await rig.stop()).toMatchObject({ ok: true }) + await rig.settleAccepted(working, 'stopped') + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + + expect(rig.closeSession).toHaveBeenCalledTimes(1) + // A chat with cards waiting keeps its handle, so no reopen can mark them. + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.handoff(held.id)).toBeUndefined() + expect(marks).not.toHaveBeenCalled() + // Kept only while a card waits: once the last one leaves, the next sweep drops it as usual. + expect(await rig.deleteQueued(held.id)).toMatchObject({ ok: true, value: { deleted: true } }) + await rig.host.collaboratorsForTests().lifetime.idleSweep.tick() + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(false) + }) + + // The startup restore opens a visible chat through the same open as any reader. + it('a chat the startup restore reopens after a crash holds its cards', async () => { + const working = await rig.workingSend() + const queued = rig.send('queued behind work', 'queue-if-active') + await queued.result + void working + const sent = rig.dispatch.mock.calls.length + rig.crashRestartHostProcess() + await rig.host.restoreReadableSessions([SESSION]) + expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) await new Promise((resolve) => setTimeout(resolve, 100)) - expect(await rig.drafts(replacementId)).toEqual([{ messageId: id, ...KEPT }]) - expect(dispatchedTexts()).toEqual(['first turn after the clear']) + expect(await rig.handoff(queued.id)).toBeUndefined() + expect(rig.dispatch).toHaveBeenCalledTimes(sent) + expect(structuredQueuePauses(journal()).map((pause) => pause.reason)).toEqual(['restarted']) + }) + + it('an idle chat with no waiting card is closed and reopened without writing anything', async () => { + const marks = vi.spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + const working = await rig.workingSend() + await rig.settleAccepted(working, 'working') + const rows = (await rig.host.journalSnapshot(SESSION)).cursor + await rig.host.close(SESSION, 'evict') + expect(await rig.drafts()).toEqual([]) + expect((await rig.host.journalSnapshot(SESSION)).cursor).toEqual(rows) + expect(marks).not.toHaveBeenCalled() + }) + + // Bookkeeping never gates the person: the open goes on, and the open itself is where the pause + // begins, so nothing sends by itself; the next turn releases the card, and the next open marks. + it('an open whose mark cannot be written still opens, and still holds the card', async () => { + const id = await acceptWhileStarting(sendRequest('kept, mark failed')) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const marks = vi + .spyOn(AgentSessionJournal.prototype, 'appendQueueReopen') + .mockRejectedValueOnce(new Error('disk full')) + await quitRestart() + + expect(await rig.drafts()).toEqual([{ messageId: id, ...KEPT }]) + expect(marks).toHaveBeenCalledOnce() + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(rig.dispatch).not.toHaveBeenCalled() + + const next = rig.send('a new request') + await next.result + await eventually(async () => + expect((await rig.submission(next.id))?.handedOverAt).toBeDefined() + ) + await rig.settleAccepted(next.id, 'next') + await eventually(() => + expect(dispatchedTexts()).toEqual(['a new request', 'kept, mark failed']) + ) }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index d9dad7cb829..d30d41e3559 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -21,10 +21,7 @@ import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/st import { classifyDispatchRejection } from '../../../../shared/structured-agent-session-dispatch-rejection' import { readWholeAgentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionWriteNoticeText } from './agent-session-write-notice-text' -import { - QUEUED_MESSAGE_PAUSED_KEPT, - QUEUED_MESSAGE_PAUSED_SEND_FAILED -} from '../../../../shared/agent-session-wire' +import { QUEUED_MESSAGE_PAUSED_SEND_FAILED } from '../../../../shared/agent-session-wire' import { isMacPlatform } from './native-chat-shortcut' import { queuedMessageCardSteers, @@ -67,12 +64,6 @@ export function queuedMessageCardCaption(card: QueuedMessageCard): string | null "Couldn't send — press Send to retry." ) } - if (card.pausedReason === QUEUED_MESSAGE_PAUSED_KEPT) { - return translate( - 'components.native-chat.queuedMessages.pausedKept', - 'Not sent yet — press Send to send it.' - ) - } return translate('components.native-chat.queuedMessages.paused', 'Paused') case 'behind-returned': return translate( diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx index 65150b38c63..fc43152b8a7 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsx @@ -305,7 +305,6 @@ describe('NativeChatQueuedMessageList', () => { it.each([ ['stopped', 'Queue paused because you interrupted'], - ['cleared', 'Queue paused after you cleared the conversation'], ['some-newer-reason', 'Queue paused'] ])( "a queue the host holds ('%s') shows one header row above the cards, with Resume; the held card still reads Steer", @@ -526,22 +525,6 @@ describe('NativeChatQueuedMessageList', () => { expect(screen.getAllByRole('button', { name: 'Send' }).length).toBeGreaterThan(0) }) - // A message the host kept after a restart or a close is not a mid-turn steer: on an idle chat - // its action is plainly Send, and its caption says it was never sent. - it('a kept card says it was not sent yet and offers Send, with no Resume over it', async () => { - renderList( - // The controller names no pause over a card held on its own (`queuedMessagesQueuePause`). - controller([card({ messageId: 'held', hold: 'paused', pausedReason: 'kept' })], null) - ) - const [row] = screen.getAllByRole('listitem') - expect(row?.textContent).toContain('Not sent yet — press Send to send it.') - expect(row?.textContent).not.toContain('kept') - fireEvent.focus(screen.getByRole('button', { name: 'Send' })) - expect((await screen.findAllByText('Send this message now')).length).toBeGreaterThan(0) - expect(screen.queryByRole('button', { name: 'Steer' })).toBeNull() - expect(screen.queryByRole('button', { name: 'Resume' })).toBeNull() - }) - it('an absent or unknown pause marker reads as a plain pause, never raw', () => { renderList( controller([ @@ -581,11 +564,7 @@ describe('NativeChatQueuedMessageList', () => { ])( 'offers Turn off queueing and the send chord only when the host queues sends ($queueCapable)', async ({ queueCapable, offered }) => { - const owner = controller( - [card({ messageId: 'kept', hold: 'paused', pausedReason: 'kept' })], - null, - queueCapable - ) + const owner = controller([card({ messageId: 'kept', hold: 'paused' })], null, queueCapable) renderList(owner) fireEvent.focus(screen.getByRole('button', { name: 'Send' })) const hint = await screen.findAllByText('Send this message now') diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx index 460b15c886a..ea5b454f997 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx @@ -88,11 +88,6 @@ function queuePauseText(pause: { reason: string }): string { 'components.native-chat.queuedMessages.queuePausedStopped', 'Queue paused because you interrupted' ) - case 'cleared': - return translate( - 'components.native-chat.queuedMessages.queuePausedCleared', - 'Queue paused after you cleared the conversation' - ) default: return translate('components.native-chat.queuedMessages.queuePaused', 'Queue paused') } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts index 28df16cb67b..78fe7711998 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.ts @@ -176,14 +176,10 @@ describe('queued message cards', () => { const project = (messages: AgentSessionQueuedMessage[], queuePaused = true) => projectQueuedMessageCards(messages, [], { hasPendingPrompt: false, queuePaused }) expect(queuedMessagesQueuePause(project([draft('held', 1)]), stopped)).toEqual(stopped) - expect(queuedMessagesQueuePause(project([draft('held', 1)]), { reason: 'cleared' })).toEqual({ - reason: 'cleared' - }) const unsendable = [ draft('returned', 1, { state: 'returned', returnedReason: null }), draft('behind', 2), - draft('failed', 3, { paused: true, pausedReason: 'send_failed' }), - draft('kept', 4, { paused: true, pausedReason: 'kept' }) + draft('failed', 3, { paused: true, pausedReason: 'send_failed' }) ] expect(queuedMessagesQueuePause(project(unsendable), stopped)).toBeNull() // No published pause (an idle chat after a restart): plain cards, no header. @@ -192,13 +188,13 @@ describe('queued message cards', () => { expect(queuedMessagesQueuePause(unpaused, null)).toBeNull() }) - // A kept card is held on its own, like a failed one: the host sends the cards behind it. - it('a card behind a kept or a send_failed card is not held by it', () => { + // A card held on its own (a failed send, or a newer host's hold) holds nothing behind it. + it('a card behind a card held on its own is not held by it', () => { const cards = projectQueuedMessageCards( [ draft('failed', 1, { paused: true, pausedReason: 'send_failed' }), draft('after-failed', 2), - draft('kept', 3, { paused: true, pausedReason: 'kept' }), + draft('newer-hold', 3, { paused: true }), draft('behind', 4) ], [], diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8dceef41172..fc97de96510 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18548,10 +18548,8 @@ "editAlreadySent": "Already sent — your text is still in the composer.", "withdrawnHold": "Stopped before it was sent", "pausedSendFailed": "Couldn't send — press Send to retry.", - "pausedKept": "Not sent yet — press Send to send it.", "paused": "Paused", "queuePausedStopped": "Queue paused because you interrupted", - "queuePausedCleared": "Queue paused after you cleared the conversation", "queuePaused": "Queue paused", "resume": "Resume", "sendConfirmTitle": "Send message?", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 72024979288..d9cafabd60d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17987,10 +17987,8 @@ "turnOffQueueing": "Desactivar la cola", "withdrawnHold": "Detenido antes de enviarse", "pausedSendFailed": "No se pudo enviar. Pulsa Enviar para reintentar.", - "pausedKept": "Aún no se ha enviado. Pulsa Enviar para enviarlo.", "paused": "En pausa", "queuePausedStopped": "Cola en pausa porque interrumpiste", - "queuePausedCleared": "Cola en pausa después de que borraste la conversación", "queuePaused": "Cola en pausa", "resume": "Reanudar", "sendConfirmTitle": "¿Enviar mensaje?", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 8e1c7b8e260..9999b2f9975 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -18220,10 +18220,8 @@ "turnOffQueueing": "Désactiver la file d'attente", "withdrawnHold": "Arrêté avant l'envoi", "pausedSendFailed": "Envoi impossible. Appuyez sur Envoyer pour réessayer.", - "pausedKept": "Pas encore envoyé. Appuyez sur Envoyer pour l'envoyer.", "paused": "En pause", "queuePausedStopped": "File d'attente en pause, car vous avez interrompu", - "queuePausedCleared": "File d'attente en pause après l'effacement de la conversation", "queuePaused": "File d'attente en pause", "resume": "Reprendre", "sendConfirmTitle": "Envoyer le message ?", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b0bf44ffd36..ca313e7942b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18154,10 +18154,8 @@ "turnOffQueueing": "キューへの追加をオフにする", "withdrawnHold": "送信前に停止されました", "pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。", - "pausedKept": "まだ送信されていません。「送信」を押すと送信されます。", "paused": "一時停止中", "queuePausedStopped": "中断したため、キューを一時停止しました", - "queuePausedCleared": "会話を消去したため、キューを一時停止しました", "queuePaused": "キューは一時停止中です", "resume": "再開", "sendConfirmTitle": "メッセージを送信しますか?", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index eb6834be3d0..49daef9ca7c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18154,10 +18154,8 @@ "turnOffQueueing": "대기열 사용 안 함", "withdrawnHold": "전송 전에 중지됨", "pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.", - "pausedKept": "아직 보내지 않았습니다. 보내기를 눌러 보내세요.", "paused": "일시 중지됨", "queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다", - "queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다", "queuePaused": "대기열이 일시 중지되었습니다", "resume": "재개", "sendConfirmTitle": "메시지를 보낼까요?", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 5c130bd2b63..b19f3a7a101 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18119,10 +18119,8 @@ "turnOffQueueing": "关闭排队", "withdrawnHold": "在发送前已停止", "pausedSendFailed": "无法发送。点按发送以重试。", - "pausedKept": "尚未发送。点按发送即可发送。", "paused": "已暂停", "queuePausedStopped": "因你中断了操作,队列已暂停", - "queuePausedCleared": "清除对话后,队列已暂停", "queuePaused": "队列已暂停", "resume": "继续", "sendConfirmTitle": "发送消息?", diff --git a/src/shared/agent-session-queued-message-wire.ts b/src/shared/agent-session-queued-message-wire.ts index a1df60b85f6..d10d263aa02 100644 --- a/src/shared/agent-session-queued-message-wire.ts +++ b/src/shared/agent-session-queued-message-wire.ts @@ -7,21 +7,15 @@ import type { AgentJournalMessageItem } from './agent-session-journal-types' /** The draft could not be converted into a send; an explicit Send retries it. */ export const QUEUED_MESSAGE_PAUSED_SEND_FAILED = 'send_failed' as const -/** A person's message the host accepted and never handed over before a restart or a close of the - * chat. It waits for the person's own Send; the cards behind it still send, as past a failed one. */ -export const QUEUED_MESSAGE_PAUSED_KEPT = 'kept' as const +export type AgentSessionQueuedMessagePausedReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED -export type AgentSessionQueuedMessagePausedReason = - | typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED - | typeof QUEUED_MESSAGE_PAUSED_KEPT - -/** The whole queue is paused and sends nothing on its own: 'stopped' — the user - * interrupted ("Queue paused because you interrupted") — or 'cleared' — a /clear - * carried the cards into a fresh conversation. Resume (`agentSession.queuedMessagesResume`), - * or any turn starting, lifts it; Send-now on one card sends that card and leaves the - * rest paused until its turn starts. A client treats an unknown reason as a - * plain pause, so a newer host can add one. */ -export type AgentSessionQueuePause = { reason: 'stopped' | 'cleared' } +/** The whole queue is paused and sends nothing on its own: 'stopped' — the user interrupted + * ("Queue paused because you interrupted"). Resume (`agentSession.queuedMessagesResume`), or any + * turn starting, lifts it; Send-now on one card sends that card and leaves the rest paused until + * its turn starts. A /clear or a reopen also holds cards until the chat's next turn, but nothing + * runs then, so neither is published. A client treats an unknown reason as a plain pause, so a + * newer host can add one. */ +export type AgentSessionQueuePause = { reason: 'stopped' } /** What rides beside a frame's `queuedMessages`, published together with the list. */ export type AgentSessionQueuePublicationFields = { @@ -44,12 +38,11 @@ export type AgentSessionQueuedMessage = { position: number body: AgentJournalMessageItem state: 'waiting' | 'returned' - /** This one card is held, whatever the queue's pause: its conversion failed, or the host kept it. */ + /** This one card is held, whatever the queue's pause: its conversion failed. */ paused?: true - /** Why it is held, as a marker the client localizes: 'send_failed' ("couldn't - * send") or 'kept' (accepted, never sent); only an explicit Send releases either. A client - * must treat an unknown marker as a plain hold, so a newer host can add one. The queue-level - * pause is `queuePause`, published beside the list. */ + /** Why it is held, as a marker the client localizes: 'send_failed' ("couldn't send"); only an + * explicit Send releases it. A client must treat an unknown marker as a plain hold, so a newer + * host can add one. The queue-level pause is `queuePause`, published beside the list. */ pausedReason?: AgentSessionQueuedMessagePausedReason /** A returned card's refusal: the `reason` and `rejection` pair its submission settled with. * Only a failure returns a card; a draft a Stop or restart took back waits again. Clients classify it from `returnedRejection` (falling back to `returnedReason` when a host diff --git a/src/shared/structured-agent-session-coalescer.test.ts b/src/shared/structured-agent-session-coalescer.test.ts index 08dbf9ed1f9..5076e6023d1 100644 --- a/src/shared/structured-agent-session-coalescer.test.ts +++ b/src/shared/structured-agent-session-coalescer.test.ts @@ -115,10 +115,10 @@ describe('structured agent session event coalescer', () => { const events: AgentSessionSubscribeEvent[] = [] const coalescer = createStructuredAgentSessionEventCoalescer((event) => events.push(event)) - coalescer.push({ ...batch(1), queuedMessages: [], queuePause: { reason: 'cleared' } }) + coalescer.push({ ...batch(1), queuedMessages: [], queuePause: { reason: 'stopped' } }) coalescer.push(batch(2)) coalescer.flush() - expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'cleared' } }) + expect(events[0]).toMatchObject({ queuedMessages: [], queuePause: { reason: 'stopped' } }) coalescer.push({ ...batch(3), queuedMessages: [], queuePause: null }) coalescer.push({ ...batch(4), queuedMessages: [], queuePause: { reason: 'stopped' } }) 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 ac18fe15986..2d440b4a6ac 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 @@ -31,8 +31,8 @@ import { renderJournalState } from '../../../src/main/native-chat/agent-session-journal/journal-reducer' -// A release that knows neither the Stop event nor the Resume marker: an unknown row kind would -// make it delete the journal from that row on, so both ride a tombstone it already reads. +// A release that knows none of the Stop event, the Resume marker and the reopen mark: an unknown +// row kind would make it delete the journal from that row on, so each rides a tombstone it reads. const BASELINE_REF = 'v1.4.218' const JOURNAL = 'src/main/native-chat/agent-session-journal' // A main build that shares this one's host database and schema version, so a downgrade to it opens @@ -143,7 +143,7 @@ type OldReplay = { } // Both downgrade probes load real old builds, including cold extraction and transforms. -test("an older build keeps every row around a Stop's event and a Resume, and folds the rows after them", async () => { +test("an older build keeps every row around a Stop's event, a Resume and a reopen mark, and folds the rows after them", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-stop-event-downgrade-')) const journals = createTrackedJournalOpener() try { @@ -160,6 +160,7 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) await journal.appendStopEvent({ reason: 'user-close', turnId: 'turn-1' }, 1) await journal.appendQueueResume(1) + await journal.appendQueueReopen(1) const afterMarks = journal.cursor() await append(1, 'after the Stop') const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) @@ -226,7 +227,7 @@ test("an older build keeps every row around a Stop's event and a Resume, and fol }) expect(projected.ok).toBe(true) expect(projected.batch?.items).toEqual([]) - expect(projected.batch?.removedItemIds).toHaveLength(2) + expect(projected.batch?.removedItemIds).toHaveLength(3) const liveIds = new Set(journal.snapshot().items.map((entry) => entry.itemId)) expect(projected.batch?.removedItemIds.some((id) => liveIds.has(id))).toBe(false) } finally { @@ -274,9 +275,7 @@ test("an older build opens this build's journal writable and appends to it; the await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 1) await journal.appendItem(item(1), { kind: 'status', text: 'after the Stop' }, scope) const wrote = { cursor: journal.cursor(), items: itemIds(journal) } - expect(journal.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ - 'stopped' - ]) + expect(journal.queuedMessages.pauses().map((pause) => pause.reason)).toEqual(['stopped']) await journals.closeAll() const rowsBefore = storedRows(directory) @@ -303,9 +302,7 @@ test("an older build opens this build's journal writable and appends to it; the const upgraded = await journals.open({ identity: IDENTITY, stateDirectory: directory }) expect(upgraded.cursor().sequence).toBe(wrote.cursor.sequence + 1) expect(itemIds(upgraded)).toEqual([...wrote.items, 'codex:thread-1:turn-1:2']) - expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ - 'stopped' - ]) + expect(upgraded.queuedMessages.pauses().map((pause) => pause.reason)).toEqual(['stopped']) } finally { await journals.closeAll() rmSync(directory, { recursive: true, force: true }) 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 24b495575fb..f6ac7f11cb5 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 @@ -11,19 +11,17 @@ import { claudeProviderHandle } from '../../../src/shared/agent-session-provider import { USER_MESSAGE_SOURCE } from '../../../src/shared/agent-session-message-source' import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure' import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../src/shared/agent-session-queued-message-wire' 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 held by a new value in its existing `hold_reason` -// column, placed ahead of the queue: no new state or column. A build with the queue but without -// this change reads an unknown hold as a plain one: it must list the card first and never send it, -// even after a person's turn ends a restart's pause, and must still settle a send this build's quit -// left queued. One exception lives in that build's own code: its /clear carries every card over -// without its hold, so the replacement's next turn sends a carried kept card. The main commit this change branched from, which has the queue; move it to the -// newest release that has the queue and predates this change. A baseline holding this change tests -// no downgrade. +// 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. const BASELINE_REF = '5a56636f6679071d6ec68b851ef7932cd3222560' const JOURNAL = 'src/main/native-chat/agent-session-journal' const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { @@ -130,7 +128,7 @@ async function acceptPersonSend( }) } -test('an older build lists a kept card first and never sends it, even after a person’s turn', async () => { +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 { @@ -149,6 +147,7 @@ test('an older build lists a kept card first and never sends it, even after a pe hostInstance: 'host-b', hold: { cause: 'hostRestarted' } }) + await reopened.markQueueReopen(0) expect(reopened.queuedMessages.list().map((card) => card.messageId)).toEqual([ 'kept', 'queued-card' @@ -165,25 +164,22 @@ test('an older build lists a kept card first and never sends it, even after a pe state, holdReason })) + expect(downgraded.repair).toEqual({ malformedRows: 0 }) expect(cards()).toEqual([ - { messageId: 'kept', state: 'waiting', holdReason: QUEUED_MESSAGE_PAUSED_KEPT }, + { 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' }) - // A person's turn there adopts every card into its process, which ends the restart's - // pause; the kept card's hold survives it, so its drain never picks it. That build skips a - // held card as it skips a failed send's, so the card behind it is the one it sends. + // 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(cards()[0]).toMatchObject({ - messageId: 'kept', - holdReason: QUEUED_MESSAGE_PAUSED_KEPT - }) - expect(downgraded.queuedMessages.pauses('host-c')).toEqual([]) - expect( - nextSendable(downgraded.queuedMessages.pauses('host-c'), downgraded.queuedMessages.list()) - ?.messageId - ).toBe('queued-card') + expect(pauses()).toEqual([]) + expect(nextSendable(pauses(), downgraded.queuedMessages.list())?.messageId).toBe('kept') } finally { await older.closeAll() }