mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
fix(native-chat): a message kept after a quit or close waits in order and follows the chat's next turn (#25960)
* fix(native-chat): drop the queue-paused header and Resume button
A Stop, a restart or /clear holds the queued cards. The hold stays; only the
header row naming why, and its Resume button, go. A held card shows no
caption, and its own Steer, or any new message, releases the queue.
* test(native-chat): type the unknown hold reason a newer host may publish
* fix(native-chat): a held card offers Send, not Steer, when no turn runs
Steer vs Send now follows whether a turn is running, not the card's hold,
so a card held after a Stop, a restart or /clear reads Send.
* fix(native-chat): the queue sends past held cards instead of stalling behind them
A card queued after a Stop (or written after a restart or /clear) sent only
once the cards held before it were released; with no header to explain or
release the hold, it sat silently. The next sendable card now skips held
cards; a returned card still blocks what is behind it.
* fix(native-chat): the queue's send of a card is the person's turn, so held cards follow it
After a Stop, a card queued later sent past the held cards, but the queue
recorded that send as Orca's own turn. It never ended the Stop's pause, so
the held cards then waited forever with nothing on the card saying why.
A queued card is always something the person wrote: only the client send
RPC may now create one. The queue's send of it is therefore recorded as the
person's turn, which ends the Stop's pause once the agent takes it, and the
held cards then drain in order.
* fix(native-chat): a queued card carries its author, so the queue's send of it is that author's turn
Main now lets Orca's own sends ask to queue (sendAgentTurn's 'queue' delivery),
so "every card is a person's" no longer holds by refusing host sends. Each card
records who wrote it (the submission's client/host vocabulary) in a new nullable
column; the drain records that origin, so a person's card ends a Stop's pause
and Orca's does not. /clear carries the author. Rows from before the column
read as a person's. The userSend-only admission gate is removed.
* docs(native-chat): state why an unrecorded card author reads as a person's
* fix(native-chat): a restart holds only cards written before it, and an idle held queue offers Resume
A restart's pause held every waiting card, including one a person typed after the restart while
Orca's own continuation ran, and nothing released it except a per-card Send. It now holds only
cards another host process wrote, the same way a Stop holds only cards queued before it.
The composer's primary button becomes Resume (Play) while nothing is typed, no turn runs and the
host holds a card Resume would send, whatever held it (Stop, restart or /clear). It calls the
existing agentSession.queuedMessagesResume, guarded against a second press in flight.
A card nothing holds keeps the run going between a turn's end and the queue's send of it, so its
Steer no longer flips to Send for the frame in between.
* fix(native-chat): the host publishes which pause holds each queued card
The host published one pause for the whole queue, so a client held every waiting card while it was
set. Between a turn's end and the queue's send of a card queued after a Stop or restart, the
composer could flash Resume and the cards Send, and a card queued after a Stop lost its
"Waiting for your answer" caption.
Each published card now carries an optional `heldBy`: the pause holding it, or null, derived from
the same rule the drain reads. A client holds only those cards; against a host without the field
it falls back to the queue-level pause.
* test(native-chat): Resume needs the queue capability and is disabled whenever Send is
* docs(native-chat): describe per-card holds in the queue contract and table comments
* fix(native-chat): the composer goes from Resume straight to Stop, and Resume returns focus
After Resume, the host lifts the hold in one update and sends the first card in a later one. In
between nothing was running, so the composer's button flashed a disabled Send. A card nothing
holds now keeps the queue's run going for the button too: an empty composer shows Stop, disabled
until the turn starts. Not when the host refuses every send (a rewind whose outcome is unknown,
read from its status), where nothing is coming. The same fix removes the Stop, Send, Stop flip
between queued turns.
Resume disables the button, which dropped keyboard focus; focus now returns to the composer.
* fix(native-chat): the host names the card its queue sends next, so the chat stays working across the gap
A turn's end, or a Resume, and the queue's send of the next card commit as two host updates. In
between nothing was running, so the working status, timer, pickers and composer button flipped
for one update. The client guessed the drain from its own copy of the host's gates, which missed a
/clear-replaced source and covered only the button.
The queue publication now carries `nextQueuedMessageId`: the drain's own next card through the
drain's own gate (`nextStructuredQueuedMessage`, which the drain step now calls), null whenever the
host would refuse the send. The client derives one fact, the queue is about to send, and every
working reader follows it; Stop stays disabled until a turn can be stopped. The client-side copy of
the gates and the status-feed rewind read are removed.
* test(native-chat): the queue's next card survives the coalescer, the reducer and a history page
* test(native-chat): build the snapshot that names the next card through its helper
* feat(native-chat): a held queue keeps its header row, and a new message asks before passing it
The queue's header row ("Queue paused because you interrupted", or Orca
restarted, or you cleared the conversation) comes back above the cards it
holds, with Resume; it names the oldest held card's pause, as the host
publishes it per card, and hides over cards held only on their own or
returned. The header's Resume and the composer's share one in-flight guard.
A held card reads Steer again whether or not a turn runs; a card held on its
own or returned keeps Send.
Sending a message while the header shows (Enter or the button) first asks
"Send message?": Clear queue deletes every card and then sends (a failed
delete sends nothing), Send message sends and keeps the cards, which follow
the new turn, and dismissing sends nothing and keeps the draft. Host
commands send as they are.
* fix(native-chat): the paused row goes while your own message is on its way to lift it
After "Send message" over a held queue, the row kept saying "Queue paused…"
until the agent accepted the new turn. The chat now reads that gap from the
outbox: while this composer's direct send is recorded by the host and not yet
accepted, the controller shows no paused row (and so no Resume or
confirmation). A refusal settles the entry and the row comes back, since the
hold did not lift. Orca's own sends never enter this outbox, and the queue's
send of a card goes under a fresh id, so neither hides it. Nothing is stored.
* fix(native-chat): a "Send message?" choice is taken once, and a failed Clear queue is one toast
The closing dialog stays mounted and clickable through its exit animation,
and a double-click or a held Enter lands twice before any re-render, so
Send message (or Clear queue) could send the captured message twice. The
pending send now lives in a ref that the first choice takes; a second one
finds nothing.
Clear queue deletes one card at a time and stops at the first failure, so a
failed press shows one toast instead of one per card.
The dialog keeps its compact width at desktop sizes and the primitive's
narrow-window gutter (`max-w-sm sm:max-w-sm`, as the other compact
confirmations).
* fix(native-chat): Clear queue's message goes out once, and keeps text typed while it waits
After Clear queue, the message waited in the composer while the cards were
deleted one by one. A second Enter in that window sent it again, and text
typed meanwhile was wiped when the chained send was accepted.
From the Clear queue choice until its message has gone out, the composer's
structured send does nothing. The chained send (and Send message's) now
carries the composition it was taken from, and the composer is cleared on
acceptance only if it still holds exactly that, as host commands already do.
Also: the v1 contract comment names `nextQueuedMessageId` and its absent-
means-null fallback, and the own-send check returns at once on an empty
outbox.
* fix(native-chat): the queue carries on after any turn, in order, and a restart sends nothing by itself
- Any accepted turn ends a Stop's or a /clear's pause, whoever sent it (a person,
Orca's own messages, or the queue), and so does Resume. The card and submission
author fields that only fed the old person-only rule are gone.
- The queue sends strictly in order: a card never overtakes a held one.
- After a restart nothing sends by itself and no paused row shows: the chat's next
turn (the carry-on, or the person's own message) runs first, then the cards.
- Resume and "Send message?" are offered only while nothing runs and no prompt waits.
* fix(native-chat): after a restart no queue pause shows, and a card written before the next turn waits for it too
* fix(native-chat): a quit hands no queued card off, and the paused row goes while any turn that will lift it is on its way
- The queue stops handing cards off when the host tears down. A card sent during
a quit was refused at close, and that refused send withdrew the chat's restart
offer, so resuming after the relaunch sent nothing.
- The host publishes no pause while a turn sent after it (your message, Steer, or
Orca's own) waits for the agent; a refusal shows it again. This replaces the
client's own-send check.
- A card written after a restart is an ordinary card again: it waits while any
card from before the restart still waits.
* refactor(native-chat): the host's paused-row-while-a-turn-is-on-its-way check in one expression
* refactor(native-chat): the composer's queue Resume rides the structured transport beside the held queue
* fix(native-chat): the "Send message?" choice ends with the pause it asked about; tests follow main's draft props
- The open dialog closes when the queue's pause lifts under it (Orca's mail, another client's
Resume, any accepted turn): nothing is sent, the draft stays, and the next Enter sends as
usual. The pending choice records the hold it was asked under; nothing new is stored.
- The composer-field Resume test passes main's dropScopeKey/draftScopeKey.
- The dialog test expects main's rule: only the sent text leaves the composer.
* fix(native-chat): a message kept after a quit or close waits like every other card, and follows the chat's next turn
A message Orca accepted but never handed to the agent before a quit, crash or
close came back as a card held on its own ("Not sent yet — press Send"): the
queue skipped past it, so later cards sent first, and only the person's own
Send released it. It is now an ordinary card at the head of the queue that
waits, with every card the chat closed with, for the chat's next accepted turn.
One rule for a chat that was not running, derived from the journal: when this
host first opens a chat (after a restart or crash), or a person closes it, and
cards are waiting, a reopen mark is written (a tombstone carrier key, like the
Stop and Resume marks). The cards queued before it wait until a turn is
accepted or Resume comes after it; nothing sends by itself, and no paused row
shows, a Stop's included. The idle sweep's own eviction writes nothing and
changes nothing the person sees. A mark that cannot be written leaves the open
working and holds from the open itself until the next turn; the next open marks
again. A rewind restates the mark. /clear's carried cards also wait unshown.
This replaces the host-instance comparison and its adoption write, and the
'kept' hold (stored 'kept' and legacy 'stopped' holds now read as none).
* fix(native-chat): mark the reopen in the one open path, and keep an idle chat with waiting cards open
Review round 1: the startup restore opened chats past the per-host first-open
mark, so their cards could send by themselves after a quit or crash; a card
mid-hand-off at the open got no mark; a close that left the chat open re-marked
after every new send.
- Every open marks when a card waits, or is mid-hand-off with its send unanswered.
- The idle sweep keeps a chat's handle while cards wait, so its eviction never
reopens one and stays invisible; it drops it on the next sweep once they leave.
- A person's close marks once; its delivery re-check marks only when it settled
a send.
- A failed mark holds from where the mark would have gone.
- A Stop made after a reopen shows its row.
- The rig's restart is a real quit and relaunch.
* fix(native-chat): review round 2: restore the dropped Resume and failed-Stop tests; a late mark starts where the chat stopped
- Restores eight tests the previous commit dropped by mistake.
- A mark the delivery loop or a close's re-check writes after a later send starts
where the chat stopped, so that send still lifts it; a later mark never narrows
an earlier, wider one.
- Only the idle sweep's own close keeps a chat with a card waiting (or mid-hand-off)
open, and it still releases an ended child's lease first; a person's close
drops it as before.
* test(mobile): a host-kept card's test stands in a Stop's pause, as this host publishes no restart pause
Main's #24660 test published queuePause 'restarted', which this branch's wire
type no longer lists, so the mobile tests typecheck ratchet failed.
* refactor(native-chat): settle a restart's leftovers and mark them in one host-lifetime step
Keeps the delivery loop under its line limit after main's Stopping change; no
behaviour change.
* test(native-chat): a kept card's Resume and failed-mark tests quit through the held start's release
Main's #25152 holds the start these tests send into; quitting without releasing it left the quit waiting.
This commit is contained in:
@@ -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 } })
|
||||
|
||||
@@ -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<boolean>
|
||||
onDelete?: (messageId: string) => Promise<boolean>
|
||||
/** Copy the card's text into the composer, then delete the card. */
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<Record<string, string>> = {
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<AgentJournalSubmission, 'reason' | 'origin'> & { rejection?: unknown }
|
||||
rejected: Pick<AgentJournalSubmission, 'reason'> & { 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` —
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<JournalTombstoneRow['stopEvent']> }
|
||||
| { queueResume: NonNullable<JournalTombstoneRow['queueResume']> }
|
||||
| { queueReopen: NonNullable<JournalTombstoneRow['queueReopen']> }
|
||||
)
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -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 } : {}) })
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<void> {
|
||||
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<AgentJournalCursor> {
|
||||
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<void> {
|
||||
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<AgentJournalCursor> {
|
||||
return this.lifecycleBatchAppender.append(input)
|
||||
}
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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<void> {
|
||||
): Promise<number | null> {
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<AgentSessionJournal> {
|
||||
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<typeof queuePauseHolding>[1] & { messageId: string }
|
||||
const DEAD = 'proc-0'
|
||||
|
||||
function card(messageId: string, queuedAfter: number, fields: Partial<Card> = {}): 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<string> = 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-6
@@ -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<boolean> => {
|
||||
const closeConversation = async (sessionId: string, atRest = false): Promise<boolean> => {
|
||||
// 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<void> => {
|
||||
// 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)
|
||||
|
||||
+10
-3
@@ -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.
|
||||
|
||||
@@ -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<string, StructuredAgentSessionHostSession>
|
||||
@@ -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)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<StructuredAgentSessionHostDeps, 'logger'> & {
|
||||
}
|
||||
|
||||
/** 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<boolean> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
const session = context.sessions.get(sessionId)
|
||||
if (!session || !structuredAgentSessionConversationClosable(session)) {
|
||||
if (!session || !structuredAgentSessionConversationClosable(session, atRest)) {
|
||||
return false
|
||||
}
|
||||
context.sessions.delete(sessionId)
|
||||
|
||||
+11
-1
@@ -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<void>()
|
||||
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 () => {
|
||||
|
||||
+12
-6
@@ -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<void>((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 } })
|
||||
})
|
||||
|
||||
+2
-1
@@ -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 }
|
||||
|
||||
+3
-6
@@ -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<void> {
|
||||
await host.close(SESSION, 'evict')
|
||||
rotateStructuredAgentSessionHostInstanceForTests()
|
||||
await quitRestartHostProcess()
|
||||
}
|
||||
|
||||
/** A host process that dies with no close: a new host opens the same state directory. */
|
||||
|
||||
+13
-11
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -367,7 +367,7 @@ export class StructuredAgentSessionQueuedMessageDrain {
|
||||
expect: 'waiting',
|
||||
settledByOp: null,
|
||||
hostInstance: structuredAgentSessionHostInstance(),
|
||||
yieldsToPause: { hostInstance: structuredAgentSessionHostInstance() }
|
||||
yieldsToPause: true
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
|
||||
+6
-11
@@ -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, {
|
||||
|
||||
+11
-22
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<AgentSessionJournal, 'queuedMessages'>
|
||||
|
||||
/** 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<AgentSessionJournal, 'markQueueReopen'>,
|
||||
fence: number,
|
||||
logger: StructuredAgentSessionLogger,
|
||||
since?: number
|
||||
): Promise<void> {
|
||||
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<AgentSessionJournal, 'queuedMessages' | 'appendQueueResume'>,
|
||||
fence: number
|
||||
@@ -66,6 +62,5 @@ export async function resumeStructuredQueue(
|
||||
return false
|
||||
}
|
||||
await journal.appendQueueResume(fence)
|
||||
await journal.queuedMessages.adopt(hostInstance)
|
||||
return true
|
||||
}
|
||||
|
||||
+22
-13
@@ -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 (
|
||||
|
||||
+8
-2
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: (
|
||||
|
||||
+10
-2
@@ -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<void> {
|
||||
}
|
||||
|
||||
/** 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<boolean> {
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
return structuredQueuePauses(journal()).some((pause) => pause.reason === 'stopped')
|
||||
}
|
||||
|
||||
async function evictedAt(): Promise<string[]> {
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
+3
-1
@@ -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
|
||||
)
|
||||
|
||||
+19
-6
@@ -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<string> {
|
||||
}
|
||||
|
||||
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'])
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+215
-87
@@ -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'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
+4
-8
@@ -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)
|
||||
],
|
||||
[],
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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 ?",
|
||||
|
||||
@@ -18154,10 +18154,8 @@
|
||||
"turnOffQueueing": "キューへの追加をオフにする",
|
||||
"withdrawnHold": "送信前に停止されました",
|
||||
"pausedSendFailed": "送信できませんでした。「送信」を押して再試行してください。",
|
||||
"pausedKept": "まだ送信されていません。「送信」を押すと送信されます。",
|
||||
"paused": "一時停止中",
|
||||
"queuePausedStopped": "中断したため、キューを一時停止しました",
|
||||
"queuePausedCleared": "会話を消去したため、キューを一時停止しました",
|
||||
"queuePaused": "キューは一時停止中です",
|
||||
"resume": "再開",
|
||||
"sendConfirmTitle": "メッセージを送信しますか?",
|
||||
|
||||
@@ -18154,10 +18154,8 @@
|
||||
"turnOffQueueing": "대기열 사용 안 함",
|
||||
"withdrawnHold": "전송 전에 중지됨",
|
||||
"pausedSendFailed": "보낼 수 없습니다. 보내기를 눌러 다시 시도하세요.",
|
||||
"pausedKept": "아직 보내지 않았습니다. 보내기를 눌러 보내세요.",
|
||||
"paused": "일시 중지됨",
|
||||
"queuePausedStopped": "중단했기 때문에 대기열이 일시 중지되었습니다",
|
||||
"queuePausedCleared": "대화를 지워서 대기열이 일시 중지되었습니다",
|
||||
"queuePaused": "대기열이 일시 중지되었습니다",
|
||||
"resume": "재개",
|
||||
"sendConfirmTitle": "메시지를 보낼까요?",
|
||||
|
||||
@@ -18119,10 +18119,8 @@
|
||||
"turnOffQueueing": "关闭排队",
|
||||
"withdrawnHold": "在发送前已停止",
|
||||
"pausedSendFailed": "无法发送。点按发送以重试。",
|
||||
"pausedKept": "尚未发送。点按发送即可发送。",
|
||||
"paused": "已暂停",
|
||||
"queuePausedStopped": "因你中断了操作,队列已暂停",
|
||||
"queuePausedCleared": "清除对话后,队列已暂停",
|
||||
"queuePaused": "队列已暂停",
|
||||
"resume": "继续",
|
||||
"sendConfirmTitle": "发送消息?",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user