mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +00:00
feat(native-chat): hold mid-turn messages in a host-owned queue (#23726)
* refactor(native-chat): remove the unused terminal handoff No client ever called agentSession.requestHandoff or mounted the handoff chrome. Delete the handoff coordinator, the terminal-owner runtime, the proof write path and the unmounted UI. Keep agentSession.handoffStatus, which released desktop clients read for worktree activation, and let records an older build left mid handoff reconcile through the ordinary restart and recovery paths. * fix(native-chat): never let the pre-stop snapshot hold a chat's stop Eviction now drains delivered events before quit's resume-offer snapshot. An unbounded wait there sits ahead of the provider stop, so a sink whose journal write stalls kept the child running until the step deadline aborted the eviction. The offer is advisory: bound the drain and stop the child regardless. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop helpers only the terminal handoff called `claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and `queryWindowsProcessRowsFresh` lost their last caller with the handoff. The fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`, the teardown path that still depends on that contract. Co-Authored-By: Claude <noreply@anthropic.com> * docs(native-chat): stop citing the removed handoff in lifecycle comments Six comments still named the handoff coordinator, a handoff suspend, or a terminal-owned session as live participants in the flows they describe. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stalled snapshot drain without a cast Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): pin that a start dead before proving owes no settlement The removed restart handoff test pinned this branch; nothing else did. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): keep the owner-status read behind an in-flight attach The handoff removal dropped the per-session queue from `handoffStatus`, so a read landing mid-start reported the reservation (no owner) instead of the settled chat owner, and shipped desktop clients blocked worktree activation on it. The read is queued again, as it was before the removal. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(terminal): remove the agent-session PTY write gate The gate only refused a write when a PTY had been bound to a chat session, and the only code that ever bound one was the terminal handoff this branch removes. With it gone, every admit/readmit returned "admitted" unconditionally, so the checks on the renderer write path, the runtime controller backstop, terminal.send, agent prompts, preview input and orchestration pointers, the refusal fields on terminal.send and worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane orchestration routing could no longer run. Ordinary writes take the same path in the same order as before. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(native-chat): drop the transcript helpers only the handoff called appendLegacyTranscriptMessages fed the terminal transcript catch-up and proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost their last caller with the handoff. Their tests now go through the live entry points instead: the roster bounds through the legacy import, the pinned-read and growth tests through the ancestry replay the history window uses, and the marker rules through the string proof in their own file rather than the session-file resolver's. Co-Authored-By: Claude <noreply@anthropic.com> * fix(native-chat): stop calling a starting chat "mid-handoff" A send refused because the chat's owner is not settled showed "The session is mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that reach it are a chat that is still starting, or one whose previous agent process has not yet been confirmed stopped. The message now says which of the two it is. The refusal code is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * test(native-chat): type the stand-in roster decoder without a cast Co-Authored-By: Claude <noreply@anthropic.com> * refactor(codex): name the pinned rollout lookup for what it does With the terminal handoff gone, the module named codex-tui-rollout-proof holds only the pinned rollout lookup that structured Codex launches use to resume a thread, so the name described code that no longer exists. Rename the module and its options type. Also drop a mobile allowlist assertion that pinned the removed agentSession.requestHandoff method, which no longer exists to allow. * refactor(native-chat): type the owner-status reply as the host sends it The handoffStatus reply type still listed the terminal handoff's fields and states (terminal placement, host label, proof retry, queued and waiting phases, the to-terminal direction). No host writes them any more and the only client reader parses the reply as unknown, so they described nothing. The reply on the wire is unchanged. * refactor(native-chat): normalize terminal-handoff lease values once at decode Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types still admitted them, so readers across the host kept branches for values no path produces and the compiler could not point at them. The store now validates the on-disk shape, which still accepts those values so an older record is not quarantined, and maps them once while parsing: - `preparing` and `old-owner-stopped` become `recovering` - a `tui` lease becomes `native`; when it records a process it also becomes `conflicted`, the claim every build probes but never stops. A plain native owner would be stopped by restart recovery, here and in older builds. Revisions are taken over the normalized state on both sides of every compare, and the mapped record reaches disk with the store's first transaction, the same way the tab-id backfill does. The in-memory types narrow to what this build writes, and the branches that existed only for the removed values go. Structured-worker identity keeps its verdict for a former terminal owner by refusing a conflicted claim rather than a non-native kind. * refactor(native-chat): stop threading the owner kind through a reservation A reservation only ever names a native owner now, so the request no longer carries a kind and the reserved lease records `native` directly. The attach params keep `runtimeKind`: agentSession.ensure and create accept it, and the operation fingerprint stored in the ledger covers it. * test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else Hiding a tab also committed the visibility index, so the no-op transaction wrote the file even when its open-time revision was wrong. Committing the index first leaves the pending rewrite as the only reason to write. * fix(native-chat): name a chat write by its target, not the owner generation A write carried the fence of the last frame the pane read, and the host refused it unless that fence was still current. An idle release and the restart after it each move the fence, and the release publishes nothing, so a send after a release was refused "Expected runtime fence 1; the session is at 3", and a Stop queued behind a cold start was refused as stale. Every write already names what it acts on: a send its conversation, a cancel its turn, a prompt answer its item revision, a rewind its epoch; an option is last-writer-wins. So admission stops comparing the client's fence, and the rebase that papered over one restart (admitAtResumedFence, resumedFromFence) goes with it. The writer-lease check stays, and so does the attach's compare-and-swap. Frames now stamp the fence read when each frame is sent instead of a copy each subscriber kept, which went stale on the same release. * fix(native-chat): every journal append reaches the chats that are open A journal write and its delivery to open readers were two calls, and some writers made only the first. A failed start whose lease could not be handed back, a provider revision with no frame behind it, and eviction's settlement were all journaled without reaching an open chat. A journal handle now reports every durable change, and the host's session map binds that report to the session's readers when the handle is set. Writers no longer publish what they append; the per-writer publish calls are deleted. * test(native-chat): an epoch replacement reaches the open chat * test(native-chat): each row reaches an open chat once, and a live handle enters only through the map * test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite The seeded record had no surface tab id, so the next open backfilled one and that rewrite alone made the no-op transaction write. The test passed with the legacy-lease rewrite signal removed. * test(worktree-activation): restore the OMP surfaced-agent resume test The handoff removal deleted it alongside the terminal-owner tests, but it covers the surfaced-PTY block that still guards resume, including an agent whose ownership is unknown. * perf(native-chat): a publish behind a delivered commit reads nothing Each commit now delivers itself, so the publish a provider frame still sends afterwards found every reader caught up but still read rows and rebuilt the timeline for each one. A caught-up reader now skips the read. * test(native-chat): state why the teardown test's fake journal is safe to cast * docs(native-chat): say mutation admission checks only the writer lease * docs(native-chat): drop the send rebase from comments that still described it * fix(native-chat): a message is accepted, then delivered A send to a chat with no running agent restarted the agent inside the send call, before the message was recorded, so the client waited for the whole start and a failed restart refused the message. Claude held prompts sent during startup, and those could settle as "unconfirmed". A send is now accepted inside the session's serialized queue: one ledger row and one submission row marked handoverRecorded, published, answered pending. A per-session delivery loop exists while a message is queued. It starts the agent through the same serialized attach a hold uses, waits outside the queue for a Claude child to prove its start, and hands the oldest queued message over as its own serialized step, writing dispatch{pending} before the adapter call. A start it needed and did not get writes one error-tone row and rejects every queued message with the same words; a start Stop cancelled writes none. Settlement follows from the rows. A queued message is provably unwritten, so a close, an eviction or an exit rejects it. A handed-over message stays in doubt. A queued row at or below the sequence a handle found when it opened was left by an earlier process and is rejected at open, with no latch. Stop withdraws queued messages with no writer lease and no fence. An attach failure keeps the conversation open, and the attach adopts its journal. Owed work counts the loop and queued rows. A compaction or rewind found prepared when a conversation opens was started under a child this process no longer has, so the open settles it rather than leaving it to refuse every send until a view attaches. The open cursor is scoped to its epoch, because sequences restart when an epoch is replaced. Deleted: restart-before-admission, recordFailedRestart, the fence rebase, Claude's startup gate, the attach's forget on failure and its own crash boundary. Clients without agent-session.accepted-send.v1 get their reply held until the handover; the desktop and paired desktop lists advertise it. * fix(native-chat): settle queued messages only for the child that ended A child that proved its start and then exited before its message was handed over left the message queued: the exit settlement returned early when nothing else was in flight. Delivery then started another child for it, and a child that died the same way started another, without end and without a row. A retried settlement for an earlier generation, run by the attach that delivery started, did the opposite: with that generation's turn unfinished it rejected the message queued for the child being attached. The settlement now takes the rejection for queued messages from its caller. The unexpected exit and the eviction pass one, and it applies even with no other work in flight; the retry for an earlier generation passes none. * fix(native-chat): an adoption that fails to import keeps the conversation open The attach now writes into the conversation's own open journal, but a failed transcript import still closed it as if it were the attach's provisional one. The conversation stayed indexed with a closed journal, so every later send answered "could not be recorded" and every attach failed again until the app restarted. The import now closes only a journal the attach opened for itself. * perf(native-chat): the recovering open reads the journal once Every conversation open now goes through the recovering open, including the read restore of every chat at startup, which used to replay its journal once. The recovering open replayed it twice: once to probe it and again inside the open. The probe is now handed to the open as its load. * fix(native-chat): an attach that fails after indexing its child leaves no child behind A failed attach now keeps the conversation open, but a failure after `onAttached` indexed the child (the rewind or compaction recovery, or the attach's own success record) left that entry claiming a child the failure path had already released. The next send found the phantom, skipped the start, and wrote at a fence the journal had moved past, so the message stayed queued for good. The entry now drops the released child and its event sink, and follows the record's fence, as a failure before indexing already did. * fix(native-chat): a withdrawn message shows no error, and a rejection outlasts the send's answer The error strip for a message the host accepted and then did not deliver matched the entry before the outbox reconciled, so a Stop's withdrawal, which the reconcile drops, showed "Orca could not send your message" with nothing to retry. It now reads the reconciled entry. A rejection the journal records before the send's own pending answer lands is final as well: that answer no longer puts the entry back to dispatching with no Retry. * fix(orchestration): a structured worker whose agent outlasts the preamble wait is left unknown, not torn down The preamble waits for its submission to be delivered while the worker's agent starts. When that wait ran out it threw operation_unknown, and the failed-start teardown then closed the session, which rejected the very preamble the host was about to deliver. It now reports a turn start nobody observed yet: the worker is start-unknown with its session kept, the host delivers the preamble when the agent starts, and the worker's report settles the dispatch as for any unobserved start. The receipt no longer suggests reading a screen a structured worker lacks. * fix(native-chat): a message rejected while its chat was closed reads as not sent A remount reads an entry it left dispatching as unconfirmed. When the journal had rejected it meanwhile, as a failed start or a quit now does, the reconcile left it unconfirmed: it blocked every later message behind a Retry and no reason, and the delivery probe, seeing the journal already answered, never ran. The reconcile now settles it as rejected like a dispatching one. * test(orchestration): name why the readiness settlement fakes are cast * fix(native-chat): keep each pane's own fence on frames so a failed restart is not resent * docs(native-chat): drop the fence from the admission the send effects run behind * docs(native-chat): give the fence move on release the reason that still holds * docs(native-chat): stop citing a write fence check in launch and mailbox comments Three places still gave the removed fence check as a reason: the launch replay said admission puts the ledger ahead of the fence, the launch surface said a send must name the lease it was admitted against, and the direct-mailbox path said the lease fence decides whether delivery is safe. Admission now checks only the writer lease. * refactor(native-chat): the provider child is its own record A conversation now outlives any number of provider children, so the child is one record on the conversation's entry instead of five loose fields beside its journal. It is written in one place: indexed only once an attach has fully succeeded, and ended through one function that an exit, a failed re-attach, a Stop and an eviction all share, matched on the child's generation and fence. - A failed attach writes no child, so there is nothing to unwind: the field unwind and the fence patch after it are gone. - Conversation writes read the record's fence, the way mutation admission already does; a child's own writes use its fence. The four stored-fence patches, and the settlement retry's overwrite of the conversation's fence, are gone. - The owed wind-down is its own tombstone, carrying the child it is owed for, and is no longer dropped when an attach replaced the whole entry. - Stop on a child still proving its start stops only the child: its lease goes back and the chat is told it is idle, but the journal, the holders and the readers stay. Close is that stop plus the conversation's close. - The settlement retry uses the conversation's own journal, opened through the host's one open. * fix(native-chat): the delivery loop alone settles a message its start or child failed A queued message was settled by whichever path happened to end the child first: the loop, the unexpected exit, eviction's work settlement, the open's leftover rule, and the startup branch that rejected every pending row. That gave two failure rows with different tones for one start, a loop that could hand over to a different child than the one it waited on, and a Claude start that died while starting reading unlike every other failed start. - The loop remembers the child it waited on. At handover, if that child is gone or replaced, it reads how it ended: a Stop continues; anything else writes one failure row and rejects every queued message with the same words, then stops. A child still starting whose start the adapter says did not land fails the same way. The exit, eviction and the settlement retry only settle the handed-over and legacy rows of the child that ended. - One failure row, always an error, keyed by the start. A start a view began that dies with nothing queued writes the same row through the same builder, so a second report revises it. - The open no longer rejects leftovers; the loop's first step does, and the open wakes it. - `awaitStarted` answers why a start did not land, so the row says it even when the loop sees the failure before the exit is processed. - Quit closes every conversation the way closing a chat does: what is still queued is rejected as closed, with or without a child, and a start the loop already has in flight is waited for so the child it produces is stopped rather than left behind. * refactor(native-chat): a stopped child ends on the one reading of its stop The eviction step reads a stop's result through `stopAgentSessionProviderRoot` and hands that verdict to the child's ending, so the host never forms a second view of whether the root is gone. Every ending carries it: a stop's comes from that reading, an exit's root is gone by definition, and a failed re-attach passes what its release saw. The end-of-child record can therefore also carry a stop whose root was not seen to go, which nothing ends on yet. * feat(native-chat): the host says it accepts a send before any agent has it The host now lists agent-session.accepted-send.v1 among its own runtime capabilities, the same string capable clients already send. A client can then tell a host that answers a send at acceptance, and admits a Stop with no writer before a turn starts, from an older one that still restarts the agent inside the send. Additive: an older client ignores a capability it does not know. * refactor(native-chat): an attach never opens a journal of its own The attach adopts the conversation's open journal, which outlives it, so it no longer opens one for a direct caller either. That leaves nothing for a failed adopted import to close, and the flag that told the two cases apart is gone. Tests that attach without a host open the conversation the way a host does. * fix(native-chat): a moved fence resends nothing on a host that accepts first The outbox treated any fence change as a new owner: it dropped the answer of a send in flight, queued that send to go out again under the same id, and unblocked a refused head. On an older host that is how a send the restart refused, unrecorded, gets another try. On a host that records every send before it starts an agent, a fence moves because that start ran, so the same rule resent into every failed start. With a fence stamped on every frame, that became a loop. The outbox now reacts to a fence change only when the host has not advertised that it accepts a send before any agent has it. On such a host, only a Retry or a new send goes out, and a failed start reaches the client as a rejected message it keeps with its Retry. Against an older host, or before one has answered, the outbox behaves as it did. Desktop and paired web share this hook. * refactor(native-chat): a child's end says whether the user or the host stopped it The end-of-child record's cause now tells a user's Stop from the host stopping the child for a cause of its own: `user-stop` and `host-stop` replace `stop`. The delivery loop goes on after a user's Stop, as before, and fails the start it was waiting on after a host stop, with the one error row and every queued message rejected, in the stop's reason when it gave one. The reason stays description only. Stop passes `user-stop`; nothing passes `host-stop` yet. * fix(native-chat): a chat whose only work is a queued message is not offered for resume A message accepted while the agent was starting counts as working in the chat, and quit rejects it as never sent. The teardown snapshot read the same working rule, so a relaunch offered to resume a chat whose agent never had the message. The snapshot now reads only what was handed over. * test(native-chat): type the queued-message fixtures in the resume-offer tests * fix(native-chat): a start that dies while a message waits on it is that message's failed start Opening a chat's tab starts an agent for the view, and a send accepted meanwhile waits on it. When that start died, its exit wrote the start's error row and left the message queued, so the delivery loop started a second agent into the same failure and wrote a second row. A child's end now records where the conversation's journal stood, and the loop settles a message accepted before a failed start ended with that start: one row, under its key, and no second start. A message sent after the failure still gets a fresh start. * docs(native-chat): say what an attach's open conversation and unconfirmed ids are now * test(native-chat): pin what a failed start settles, and what a resume offer names A view's child that dies while a sent message waits settles that message only when it died starting and no child has taken its place: a proven child's crash, or a second start since, gets the message delivered. The resume offer names the handed-over message, never a newer one still queued. * test(native-chat): the failed-start pins fail on what the message became, not on a timeout * test(orchestration): the preamble's host stub is typed, not cast The preamble send now takes only what it reads of the host, the send, the settlement wait and the record's fence, so its test builds that host with real types instead of `as never`. * fix(native-chat): a Stop that names no turn stops what the conversation has in flight Between handing a message to the agent and the agent opening its turn, there is no turn id a client could name, so a Stop in that gap was refused as "already finished" while the agent went on to answer. A cancel's turn id is now an optional precondition instead of its target: with none, the host withdraws what is queued and, when the journal still reads working, asks the adapter to stop whatever the child has in flight. Claude's interrupt is session-scoped, so it is guarded by fence and acquisition generation rather than a turn identity. Codex interrupts the turn its latest turn/start answered with until the journal shows one. A cancel that names its turn behaves exactly as before. * fix(native-chat): Stop is there from the moment a message is sent The composer showed Stop only once the agent had opened a turn, so for the second or two after a send the chat read "thinking" with no way to stop it. Against a host that takes a Stop naming no turn, Stop now shows whenever the chat reads working (a turn, a queued message, or a handed-over one still unanswered) or this client still has a message on its way. Pressing it, or Escape, first drops every outbox entry the journal does not hold yet, so nothing goes out after the Stop, then sends the conversation-wide cancel. A send already on its way reaches the host ahead of the cancel, which withdraws it there. Against an older host Stop still needs a running turn. The unconfirmed-send probe moves into its own hook so the outbox hook stays in budget. * fix(native-chat): Stop before a turn is gated on its own host capability A host that accepts sends first (agent-session.accepted-send.v1) can still predate the cancel that names no turn and would refuse it as invalid, since clients and hosts ship independently. Hosts that take that cancel now advertise agent-session.conversation-stop.v1, and the renderer shows Stop before a turn opens, and sends the no-turn cancel, only to a host advertising it. Every other host keeps a Stop that needs, and names, a running turn. The host capability probe the accepted-send hook used is generalized so both read one path. * test(native-chat): a build advertises conversation stop exactly where its cancel may name no turn * fix(native-chat): a view never restarts a chat whose last start failed A Claude chat whose CLI exits during startup left one red row per start, and every time a view bound to it (the chat opening right after its create died, or the user switching back to it) the hold started the CLI again, so the same launch-failure row repeated. Only a send retries a failed start now, the same rule provider-exit recovery already applied; the rule lives in one predicate the hold, exit recovery and the delivery loop share. * test(native-chat): start the child the loop waits on with an attach, not a second view A view no longer starts a child whose last start failed, so the R2 case that waits on a child started since the failure now gets that child from a client attach, the one non-send starter left. * fix(native-chat): settle a gone generation's turn wherever a conversation opens A send that opens a chat this process had not read yet (after a crash, from a phone or the CLI) went through the delivery open, which never settled what the dead generation left running; only the read restore and a successful acquire did. When the send's start then failed, the turn stayed running for every reader. The settlement now runs in the one journal open, at the crash boundary, for every opener except an acquisition, which settles from the evidence it read before its reserve; the read restore's separate step is gone. * test(native-chat): prove the next child's start settles the turn an earlier child left The R1 case lost its only settlement assertion when the latch it checked was deleted. It now seeds the running turn the earlier child left and asserts it ends at the exit's receipt, with the exit's row, before the message is handed to the new child. * test(native-chat): count a failed start's rows by row, not by text Comparing the set of texts passed when two different rows carried the same words, which is the duplicate the test exists to catch. * test(native-chat): give the failed-start and stale-turn waits a loaded runner's budget * test(native-chat): pin the open's and the send's start and row counts, however the view binds Opening a fresh chat whose starts fail makes one start and one row, with two views bound before or after the create's child died; one send makes one more of each. * fix(native-chat): settle a gone generation's turn at every open but an acquisition's The journal open skipped the settlement whenever the lease read reserved or live, to leave an acquisition's own open to the acquisition. But a lease a crashed process left in recovery also reads live, until the next acquire resolves it. A send that opened such a chat, from a phone or the CLI after a crash on a host that could not prove the old owner gone, skipped the settlement; when its start then failed, the dead turn stayed running for every reader. The acquisition now says it is the opener, and every other open settles, whatever the lease still claims. * test(native-chat): hold the create's start open until the views bind The "view binds while the create is still starting" case gave the create a 300 ms head start and asserted the views bound before it died. On a loaded runner the holds took longer, the create's exit landed first, and the case failed its own precondition. The create's initialize now waits on a gate the test releases once the views are bound. * fix(native-chat): Stop reads the one working rule every session list reads While Claude retries a rate-limited request it never echoes the message, so no turn opens: the sidebar read Working from the unanswered send while the composer showed Send. The chat's working state, the host's session-list status and the host's no-turn Stop check now call one shared rule instead of three copies. * test(native-chat): a rate-limit retry pins only that no turn opens, not how its rows are kept * fix(native-chat): Stop leaves a message waiting on its Retry, and does not show for one A send that failed holds the queue until the user retries it, and one the host restarted under is parked the same way. Stop counted both as still on their way, so it showed in an idle chat and could never go away, and pressing it dropped the failed message along with its Retry. * test(native-chat): the chat's Stop and a session list read the main agent alike over their own copies The chat reduces its stream and a list reads the status feed. Driven through the real host for a rate-limit retry with no turn, a subagent still running after the main turn, and the handed-over child exiting. * refactor(mobile): the chat reads the main agent's working state through the shared rule Behaviour is unchanged: the same two terms, now from the one function the host projection and the desktop chat read. * fix(codex): a Stop naming no turn never interrupts an earlier turn It fell back to the id an earlier turn/start answered with when the latest start went unanswered, or when the journal showed a compaction Codex had not started, and reported that as stopped. * fix(native-chat): a Stop naming no turn never says a turn had already finished When the provider found nothing left to stop, for instance a turn that ended between the host's check and the interrupt, the chat got "The provider had already finished this turn." for a turn the Stop never named. It now ends quietly, as a Stop with nothing in flight does. * fix(native-chat): one Stop the host could not settle no longer refuses every later one A Stop naming no turn has one operation key per session. When the host could not settle one, it answered every later Stop under the same id as unknown until the id expired. Once the host says so, the next press is a new Stop; transport doubt still replays the same id. * refactor(native-chat): drop the composer's second error formatter After the merge with main, every chat write in the composer path reports its failure as a typed outcome worded by the refusal-notice table, so the send's catch sees only a local throw. The {code, message} formatter this branch added for it has no payload left to format, and its claim to be the one way a chat words a failure is no longer true. The composer send is main's again. * test(native-chat): pin the reason on a message rejected while its chat was closed The reopen test checked only that the message reads as not sent; it now also checks the Retry row carries the host's reason. * test(native-chat): read Stop operation ids without a cast * fix(native-chat): a Stop whose answer was lost no longer swallows the next one A Stop that names no turn has one operation key per chat. When its answer was lost in transit, the chat kept the id, so every later Stop replayed it; the host answers a replay as already handled, so for up to a day Stop stopped nothing. The id is now dropped once the call settles, however it settles. A second press while the first is still on its way still shares its id. * refactor(native-chat): a Stop naming no target keeps its operation id only for its own call The chat kept each write's operation id per payload across calls, and dropped it only on some settle paths. That is right for a write naming what it acts on, but a Stop naming no turn, and a stop of every background task, share one payload with every later one, so any path that kept the id made the next Stop replay as already handled and stop nothing. One path was still open: an answer that arrived after the chat moved to a new fence. Whether a write names its target is now decided once, before its id is picked. One that names none keeps its id only while its call is in flight, so a press made meanwhile joins it, and releases it when the call settles, however it settles. The release runs only while the key still holds that call's id, so a joined call settling late cannot drop a newer one's. This replaces the per-path exceptions for a thrown call. * test(native-chat): read the Stop fences without a cast * test(native-chat): pin the new id for a named cancel the host could not settle After the Stop naming no turn moved to a per-call id, the only test of the unknown-refusal release was gone, and the half that stays, for a cancel naming its turn, could be removed with every test green. * fix(native-chat): a Stop pressed after a new message stops it, even while the last Stop is unanswered A Stop naming no turn shared its operation id with any press made while it was still in flight. The host runs a chat's writes in order, so a message sent between two presses was accepted after the first Stop ran, and the second press replayed that Stop as already handled and left the message running, although the chat had already withdrawn it from the outbox. A write naming no target now gets a new id on every press and is never kept, so each Stop acts on whatever is running when the host reaches it. A write naming its target keeps its id exactly as before. A double press can ask the provider to stop the same turn twice, which it tolerates. * fix(native-chat): Stop no longer blinks off as Claude opens the turn for a message Claude's echo of a sent message both answers the send and opens its turn. The echo settled the send first, so the host published the message as answered one frame before the turn it opened, and for that frame the chat read nothing running: Stop turned back into Send, and Working blinked off in every session list, for tens of milliseconds on each turn. The echo now settles the send after the turn it opens has been emitted, so the running turn is published first. * fix(native-chat): a message a Stop withdrew comes back to its sender's composer A Stop withdraws every message the host holds but has not run, and S also drops the ones this client had not handed over yet. Either way the message left the chat and its text survived only in a hidden journal row and the in-memory ArrowUp history. The sending client now puts the withdrawn text and images back in that pane's composer, after whatever is typed there. Withdrawn is read from the rejection reason through one shared check, which the outbox reconcile now uses too. The composer is written before the entry leaves storage, so a failure between the two repeats the text instead of losing it, and an entry storage no longer holds is never given back again, so a replay, a second view or a remount restores it once. Only this client's outbox holds the entry, so other viewers still see the message disappear. A failed Stop withdraws nothing on the host and gives nothing back. * fix(native-chat): withdrawn text put back during an IME composition is not lost While the IME owns the field, the composer ignores a programmatic draft, and the next composed keystroke wrote the draft without the restored text, after its outbox entry had already been dropped. The composer now holds text appended mid-composition, keeps it in the cache after each composed write, and shows it once the composition settles, the way attachments that land mid-composition already wait for it. * test(native-chat): pin that only a withdrawn message comes back to the composer * test(native-chat): set up the composer's window API for every describe in the composition-race file * docs(native-chat): note that the withdrawn check reads the legacy reason until a typed category lands * test(native-chat): pin that text put back mid-composition shows once, even beside a mid-composition clear * feat(native-chat): host-owned queued-message draft store in the session journal A queued mid-turn message is a draft row in the session's journal.db, created idempotently at every writable open with no user_version bump so a downgrade stays writable. Consume converts one draft into an ordinary submission inside the journal writer's own transaction (exactly-once), and a standing writer hook returns a consumed draft only when a committed row newly settles its current consumed submission to a non-withdrawn rejection — the same decision the reducer folds rows through. Open-time repair re-derives returned state behind the stored fact; retention never prunes a row whose refusal could still return it. * feat(native-chat): queued-messages wire contract, dark capability, and send classifiers The send result becomes a union: today's submission arm unchanged, plus a capability-gated queued arm only clients that sent delivery:'queue-if-active' ever receive. Whole-list queuedMessages fields ride the subscribe events and history pages; Stop gains withdrawQueued with the withdrawn bodies in its result; clear's result carries withdrawn drafts too. Both classifiers treat queued as accepted/spent. agent-session.queued-messages.v1 is defined but deliberately NOT advertised: the rollout prerequisites (Claude fold receipt, integrated Codex steer matrix) are not in this host. * feat(native-chat): queue a capable mid-turn send as a draft, drain it at turn end, and let Stop and clear return its text A send carrying delivery:'queue-if-active' while the session owes work — or behind an actionable backlog — becomes a host-held draft instead of a submission. A serialized drain woken by journal commits, draft mutations and conversation opens re-derives its gates from live facts (streamed-event barrier first, backlog never a gate) and converts the oldest actionable draft through the exactly-once consume; from that instant today's delivery pipeline runs unchanged. Stop pauses the withdrawable frontier at the stop step (a process-level pause set that survives handle eviction and, via the per-process host instance, restarts), then withdraws it with the text in the result for capable clients; /clear does the same for the superseded source. The draft list publishes whole per emit with identity dedup, rides only the final catch-up page, and attaches to history pages. queuedMessageSend overrides queue policy only; queuedMessageDelete hands the body back. Replays for all of it answer from op-stamped tombstone receipts. * test(native-chat): pin mid-turn queueing against the real host Accept (working/backlog/text-only/budget/replay), the one-per-settle drain, returned cards with N1 overtake and the N4 re-send loop, Stop withdraw with tombstone replays, the process-level pause across evict/reopen, Delete receipts, /clear returning the withdrawn text, and publication (hydration, unchanged-cursor insert, same-frame consume, identity dedup). * test(native-chat): read the queued receipt ids before the wait closures * chore(native-chat): SAFETY rationales on the sqlite row casts and a cast-free mobile narrowing * fix(native-chat): queued-draft bookkeeping never costs a publish, an open, a clear or a history read - Cache the draft list per draft-table revision. The drain re-checks on every journal publish, so each streamed delta was running a SELECT and parsing every draft body the handle had ever written (tombstones included). - Open-time repair/prune failures are reported and skipped; they no longer fail opening the chat. - /clear on a source with no drafts answers exactly as before: no empty `withdrawnQueued`, no empty write transaction, no extra publish. A draft read failure after the committed clear no longer turns it into a refusal. - History pages read drafts through the same guarded reader as subscribers. - Publication moves to its own module; the held-draft rule lives with the pause state; one pending-prompt check; drop an export nothing calls. - Tests: restart-held drafts, pre-consume failure pause + Send retry, failed open repair, clear with no drafts. * fix(native-chat): a Stop that withdraws a consumed draft's send gives its text back A queued draft converted into a submission leaves the sender's outbox, so when a Stop withdrew that submission before the agent received it, the text had no holder: the draft stayed `dispatched` forever and nothing restored it. - The returned-card rule now follows every effective `rejected` settlement of a consumed draft's submission, a Stop's withdrawal included, with the withdrawal reason stored as the fact (`dispatchWasWithdrawn`). The writer hook and the open-time repair share the rule, so no rejected submission can leave its draft `dispatched`. - A capable Stop withdraws the cards it returned itself along with its frontier, stamped with its caller-scoped key: the text comes back once in `withdrawnQueued` and replays from the tombstone. An old client's Stop leaves a returned card. - Stop's draft steps move to structured-agent-session-queued-stop.ts. - Tests: Stop between consume and the agent's receipt for both client kinds, its replay, a crash after the withdrawal, restart in the window, and the repair of a hookless withdrawal. * perf(native-chat): the queued-draft drain takes no serialized step while the agent works The drain was woken by every journal publish and, with a draft waiting, queued a serialized step (streamed-event flush included) per publish, only to find the session still working. During a streamed turn that is one step per delta, contending with Stop and every other mutation for the session's queue. The pre-check now also skips while the session is working. Whatever ends the work is itself a commit that schedules again, and the step still re-reads every gate after its flush, so no wake is lost. - Test: queued sends during a turn take no drain step; settling the turn drains. * fix(native-chat): a clear withdraws queued text only for a caller that can take it back; paused reasons are markers An older client running /clear had its source's waiting and returned drafts withdrawn and their text returned in a `withdrawnQueued` field it does not read, so the text was lost. Clear now mirrors Stop: `withdrawQueued: true` on `agentSession.conversationCommand` (strict params, sent only when the queued-messages capability is advertised) withdraws the drafts and returns their text once, replaying from the tombstones. Without it the source keeps its cards: the supersession fence already blocks the drain, and Delete still hands the text back. A paused card's reason was host-authored English on the wire. It is now a typed marker (`send_failed`) the client localizes, like `returnedReason`; a client treats an unknown marker as a plain pause. - Tests: an old client's clear leaves the cards and its replay stays field-free, then Delete returns the text; a capable clear returns the text once and replays it; the paused marker. * fix(native-chat): a draft pause that commits no journal row still reaches live subscribers A pause writes no journal row, so it reaches subscribers only on the next publish. Two pauses had none behind them: the drain's pre-consume failure (the session is idle by then, so nothing else commits) and an old client's Stop that interrupted nothing. A live card kept reading as waiting, with no failure marker, until some unrelated commit arrived. The drain now publishes after pausing a draft it failed to convert, and an old client's Stop publishes when it paused a frontier. - Tests: a failed conversion and an idle old-client Stop each reach a live subscriber as a paused card; both fail without the fix. * fix(native-chat): a failed clear wakes the queued drain, a failed Stop withdrawal still publishes its pause A conversation command can settle on the record alone (a retried clear that fails), so drafts held behind its prepared phase waited for an unrelated journal commit; the command controller now re-derives the drain when any command finishes. A capable Stop whose withdrawal write failed never published the pause it set, and a publish failure after a committed withdrawal (Stop or clear) dropped the bodies from the answer; publishing now happens outside the withdrawal and can no longer discard its result. Tests reset the process-level pause set between cases: operation ids repeat per test, so a shuffled order held later tests' drafts. * refactor(native-chat): the draft store notifies through the journal's commit listener, the hold is a stored row fact, and one typed gate decides every queue hold R1: every standalone draft-table transaction that changed rows (insert, withdraw, hold, open-time repair) fires the journal's own commit listener after COMMIT, so a draft or hold change publishes and wakes the drain through the same path a journal row does — no call site can forget. All hand-written publish/wake plumbing for draft changes is deleted; wakeQueuedDrain survives only as the record-input wake (a conversation command can settle on the record alone). R2: the process-level pause set becomes a hold_reason column on the draft row (pre-ship, so no migration): holds survive eviction and restart, keep their send-failed marker across restarts, die with the session's journal, and are cleared by consume and withdraw in their own UPDATE. The host-instance derivation stays the one restart mechanism. R3: one typed structuredQueueHold (blocked | command | prompt | working) consumed by admission, the drain step and Send-now, with each caller's override set written beside it. A capable send during a late-result /compact now queues instead of being refused (PLAN §3.1); the dead prepared-command branches and the drain's duplicated gate list are gone. prompt outranks working so Send-now's one override cannot swallow it. R4: one isUnsettledQueuedMessage predicate for the withdrawable/budget filters. Loop 4: a replayed send whose draft was refused answers with the returned card, never the rejected submission, so the text cannot render twice. Rewind completion was verified to publish after the record clears (the rewind path's own publish; the open path's recovery precedes the open snapshot). * fix(native-chat): a Stop with no drafts writes nothing, and a failed hold still lets a capable Stop withdraw The stored hold turned Stop's in-memory pause into a draft-table write, so every Stop (drafts or not, capability advertised or not) opened a BEGIN IMMEDIATE/COMMIT. An empty hold now returns before the serialized write. A hold that threw also emptied the frontier, so a capable Stop withdrew only returned cards and left the waiting drafts unheld to auto-send after the interrupt. The frontier is read once and survives a failed hold. * fix(native-chat): a capable Stop with no drafts writes nothing The empty-hold guard from the previous fix did not reach withdraw, so every capable Stop still opened a write transaction after the interrupt, and a closed handle turned its empty answer into a missing field. The draft store now answers an empty withdraw without a transaction, for every caller. * refactor(native-chat): Stop and /clear never withdraw queued drafts; no text rides the wire back Adopt the host-owned-queue model end to end: a Stop holds the waiting frontier ('stopped') for EVERY client and interrupts — the cards stay published as paused, Send-now overrides per card, and the pause dies when the user next starts a turn (an ordinary dispatched send lifts 'stopped' holds in the same serialized step; 'send_failed' holds still need their explicit Send). /clear carries the source's unsettled drafts to the replacement session as born-held rows — identical for every client version — then tombstones the source. Delete answers with no body: the card leaving the published list is the outcome. Removed (never shipped; the capability was dark and unadvertised, so no wire compatibility is affected): CancelParams.withdrawQueued and its refine, ConversationCommandParams.withdrawQueued, CancelResult.withdrawnQueued, ConversationCommandResult.withdrawnQueued, AgentSessionWithdrawnQueuedMessage, the Delete result body, settleStopQueuedWithdrawal and the cancel finisher, withdrawClearedSourceQueuedMessages, replayWithdrawnQueuedMessages, and cancelPlan's tombstone replay. This also removes the defect where a withdrawal took every row regardless of which client sent it (a phone Stop pulled desktop-typed text): nothing moves text anymore, so a Stop from one client can never relocate another client's drafts. Hold and carry writes are bookkeeping: a failure is logged and never gates the interrupt or the clear. * feat(native-chat): a restart hold lifts like a Stop's, and paused cards say why The user's next dispatched send lifts every stop-shaped hold in one UPDATE: stored 'stopped' rows, and restart-held rows (host_instance mismatch), which are adopted into the running instance — the same fact the derivation reads, so no second copy of the hold exists. 'send_failed' still requires its explicit Send. Publication now marks stop/restart holds with pausedReason 'stopped' (an additive optional value on a dark capability), so clients can caption them "sends after your next message" and keep "couldn't send" for 'send_failed'. * fix(native-chat): only a client's own send lifts a Stop's queue pause The lift ran for every accepted host send, so orchestration mail, a restart continuation and a launch prompt released drafts the user had stopped (and adopted restart-held rows into the running instance). The client-facing agentSession.send RPC now marks its sends as the user's own; host-internal senders leave the pause alone. Also drops comments still describing the withdrawn return-text rule. * fix(native-chat): a Stop's queue pause lifts when the user's send starts its turn The pause lifted as soon as the host accepted a user send, so a send the provider then refused (a failed child start, a refused turn/start) had already released the stopped drafts into the same failure. The host now remembers a client's own send, in memory, until the provider answers it: acceptance lifts the stop-shaped holds, a refusal forgets it with the holds intact, and a later Stop supersedes it. Nothing is persisted, so a restart between the send and its turn start leaves the cards held for the user's next send rather than sending them unasked. * fix(native-chat): a consumed draft's turn starting lifts a Stop's queue pause Drafts are only ever a client's own sends, so a drained draft or a Send-now is a user send for the pause: its submission joins the same in-memory set a direct send uses, and the provider accepting it lifts the stop-shaped holds. Before, a message typed while a stopped turn wound down drained as a draft and left the older stopped cards held, so their "sends after your next message" caption was false. A refused consumption lifts nothing, a later Stop still clears the set, and orchestration mail and restart continuations still never lift. * fix(native-chat): queue a capable send behind a /compact and re-scope /clear's carried drafts - A text send with queue-if-active during a /compact in flight is admitted on the compact's side lane as a held draft instead of being refused; it may only become a draft, so one the gate no longer holds is refused rather than dispatched. - Drafts /clear carries to the replacement are fingerprinted for the replacement session, so the provider's echo folds into the sent bubble. - The in-memory set of user sends awaiting their turn is capped; sends settling unknown no longer grow it without bound. - Correct the userSend comment: the renderer's launch prompt goes through the client RPC and does set it. * fix(native-chat): a returned queued card carries the typed rejection fact, like a rejected submission A consumed draft the agent never ran comes back as a returned card. The card kept only the rejection's sentence, while its submission now also records the typed fact a client classifies from. A host-restart rejection's sentence carries no legacy marker, so such a card could not be told apart from a provider's refusal. The draft table stores the submission's fact next to its reason (`returned_rejection`, written by the same settlement that sets the reason, and read back with the reducer's own fact reader), and the card publishes it as `returnedRejection`. Both are overwritten on every return, so a re-sent card never keeps an earlier refusal's fact, and a /clear carry inserts a plain held draft with neither. Retention moves to queued-message-retention.ts to keep the table module within max-lines. * fix(native-chat): fit the queue to main's typed rejections and compaction result Main (#23026) dropped the disposition's fresh-id retry field, gives a rejected dispatch a typed sentence plus fact, and types /compact's result. The queued-draft disposition and the queue tests now use those shapes. * fix(native-chat): draft bookkeeping can never roll back the journal row it rides The queued-draft returned transition runs inside every journal append's transaction. A throw there (a draft table an earlier build created without the returned_rejection column) rolled back the journal's own rejection row, so a Stop, a failed start or a provider refusal could not be recorded. The standing hook now runs in its own savepoint: its failure is logged and rolls back alone, and the open-time repair re-derives the missed transition from the committed row. The draft table also gains any missing nullable column at open. * fix(native-chat): a draft a Stop or restart took back waits again instead of blocking the queue Cards A, B and C wait; the turn ends and the drain consumes A, but the agent has not taken it yet. A Stop then pauses B and C and withdraws A's submission, which made A a returned card. The user's next send lifted B and C, yet a returned card blocks everything behind it, so B and C never sent although they read "sends after your next message". A restart or close before hand-over did the same. Nobody failed the user there, so the draft now goes back to waiting at its own position, under the hold that same event put on the drafts behind it: a Stop's 'stopped', or no stored hold after a restart, whose hold derives from the host instance. It carries no refusal, and records its spent submission id in consumed_as, so its next consume (the drain, or Send on the card) mints a fresh id through the same path a returned card's re-send uses. Provider refusals and other failures still return the card. The live settlement hook and the open-time repair share one decision. After a Stop and the user's next turn, A drains first, then B, then C, one per turn. * fix(native-chat): Delete and Send on a queued card answer at once during a /compact A /compact holds the chat's serialized lane for its whole provider call, and the queued-card Delete and Send ran on that lane, so both hung until the compaction finished. They now run on the side lane a draft-only send already uses while a compaction is in flight: Delete completes at once, and Send reaches its readable "wait for the conversation operation" refusal at once. The drain stays on the main lane and keeps its command hold, so nothing sends until the compaction settles. * fix(native-chat): a re-sent returned card drops the refusal it came back with Re-consuming a returned card left returned_reason and returned_rejection on the now-dispatched row, so the row described a refusal that no longer applied. The consume clears both in the same update that moves the card to dispatched. * perf(native-chat): the queue gate reads pending prompts without rendering the journal The prompt check ran on every send admission and drain step, and read journal.snapshot(), which copies and sorts every item in the chat. It now walks the reduced items in place with journal.visitItems; the answer is the same, since the snapshot only sorts those items. * fix(native-chat): a Stop that fails leaves the queued cards as it found them Stop holds the waiting cards before it withdraws queued sends and interrupts the agent. When a later step threw or the Stop was refused, the cards stayed paused ("sends after your next message") although a failed Stop is meant to change nothing. A failed Stop now undoes exactly what it added: each card it held gets back the hold it replaced, a consumed card its withdrawal sent back to waiting is released, and the user sends it had set aside can again lift the pause. Holds an earlier Stop or a restart put on the cards stay. The hold SQL moves to its own module, and the draft store's standalone transactions share one helper. * docs(native-chat): confirmed cancellation is no longer a queue rollout prerequisite Stop withdrawing queued sends with a typed cancellation landed on main with #23026. The comment gating the queued-messages capability now lists only what remains: the Codex steer matrix (#21062), the Claude fold receipt, turn-owner bars, and the desktop and phone clients. * docs(native-chat): the Claude fold receipt and turn-owner bars have landed; Codex steer and the clients remain * fix(native-chat): Send on a queued card during a /compact is refused before it takes a lane Send-now chose its lane once, at entry. During a /compact it took the side lane, where it could wait behind a Stop, then run after the compaction had settled and append a real submission unserialized against the main lane. While a compaction is in flight, Send-now is now answered with the "wait for the conversation operation" refusal before entering any lane, and otherwise it runs on the main lane. Only Delete keeps the side lane, whose compare-and-set withdrawal is safe on either. * fix(native-chat): a Stop that fails after reaching the agent keeps the queue paused A failed Stop undid its queue holds whenever it threw, including after the interrupt had already gone to the provider (a status-note write failing after cancelTurn, or after stopping a starting agent). The turn could be stopped while the cards drained as if no Stop was pressed. The Stop now marks the step that reaches the provider, and undoes its holds only when it failed before that. A Stop the agent refused answers ok and keeps its holds; the comment no longer claims otherwise. * fix(native-chat): a skipped draft settlement heals on the next drain step, not only at reopen The draft settlement rides each journal append as bookkeeping, and a failure there is logged and skipped. Only the open-time repair re-derived it, so a consumed draft whose submission was rejected stayed dispatched (invisible, and blocking nothing it should) until the chat reopened. The re-derivation is now its own function, shared by the open-time repair and the drain: whenever a dispatched draft's submission is already rejected, the drain step applies the owed settlement first. * fix(native-chat): one id is never recorded as a submission twice A second submission row under an id the journal already holds replaces the submission with a fresh pending one, so a rejected message could be handed over again under its own id. Send on a queued card could do exactly that: if the host died after it consumed the card under the operation's id but before its answer settled, the rerun consumed again under the same id. The journal now refuses a submission under an id it already records, so no id is delivered twice whatever the caller does. And a Send-now rerun that finds the card consumed under its own operation id answers with that submission instead of consuming again. * fix(native-chat): a waiting draft whose first send the agent echoed is withdrawn, never resent A consumed draft goes back to waiting when its submission is rejected as never delivered (a Stop's withdrawal, a restart, a close), and then sends again automatically. That rests on the "never delivered" claim. If the provider then echoes that message, the first delivery happened, and the automatic resend would give the agent the same message twice. The reducer already keeps such an echo apart, since a rejected submission may not claim it, so the draft store reads it from the appended row itself: a provider echo of a user message that no live submission claims, matching a waiting draft whose spent submission is rejected, withdraws that draft the way a Delete would. The echo-claiming rule is split out of the reducer's aliasing so both read the same decision, and the per-row draft hook moves beside the settlement re-derivation. * feat(native-chat): a submission names the queued draft it hands off Clients told a queued card's hand-off apart from other sends by comparing the draft's id with the submission's id. That holds only for a draft's first hand-off: a re-send, or a draft that goes back to waiting and drains again, goes out under a fresh id, and the clients showed the card and the sent message together, or restored text the host still held. Every submission the host creates by handing off a draft now carries queuedMessageId, the draft's id. It is written on the submission's journal row as an optional key (older readers keep it and ignore it), carried by the reducer, listed in the published submission schema (which otherwise strips it), and stamped where the row is built from the consume itself, so no hand-off path can leave it off; a caller naming a different draft is refused. A direct send names none. The queued-messages capability comment makes the link part of v1. * refactor(native-chat): every queued draft goes out under a fresh submission id A draft's first hand-off reused the draft's own id as the submission id, so comparing a draft id with a submission id looked right in every first-send test and failed only on a re-send or a requeued draft. Every hand-off now uses a fresh id (the drain mints one; Send on a card uses its operation's id), so id equality is never true and a reader must use the submission's queuedMessageId. The host gets simpler: queuedMessageNeedsFreshSubmissionId is gone, consumed_as is set on every dispatched row and cleared when a withdrawal sends the draft back to waiting (its spent submissions stay findable by their link), the consume refuses the draft's own id, and the consumedAs ?? messageId fallbacks collapse. The delivered-echo check finds spent hand-offs by link. A send this host queued, asked again (a lost answer's replay, or a rerun the operation ledger no longer covers), answers from its draft and then from the hand-off that names it, through one function. The rerun path used to be kept from sending twice only because a submission sat under the send's own id; with fresh ids that guard is now explicit. A Send-now rerun recognises its own consume by the link instead of consumed_as. * fix(native-chat): an echo withdraws a draft only if its rejected hand-off reached the agent The delivered-echo rule withdrew a waiting draft when a provider echo matched any rejected hand-off of it, including one a Stop rejected before it was ever handed over. That hand-off is provably unwritten, so a matching unclaimed echo is some other message, and the rule silently deleted the card. Only a hand-off that was handed over and then rejected as never delivered can be disproved by an echo now. * fix(native-chat): a skipped echo withdrawal is re-derived before the draft can send again The delivered-echo withdrawal rides each journal append as bookkeeping, and a skipped hook left the draft waiting, so it later sent the same message a second time. Nothing re-derived it. The draft store now also withdraws, in its owed-settlement pass, each waiting draft that an echo already in the journal proves delivered: an unclaimed provider user message (still stored under its own id), carrying the draft's payload, appended after a hand-off that was handed over and rejected. The live hook and the re-derivation share one predicate. The pass runs at open and in the drain step, right before a draft would send; it reads every item, so it never runs per streamed row. * fix(native-chat): a rolled-back journal append leaves no draft state cached The draft store caches its row list by revision. The per-row hook read that list eagerly inside the append's transaction, after the consume in the same transaction had already written and bumped the revision, so a failed COMMIT left the cache showing a hand-off that never happened. The hook now reads the drafts only once a row holds an unclaimed echo, and any rollback of a journal append or of its bookkeeping savepoint invalidates the cache, so no other read inside the transaction can leave it stale either. * fix(native-chat): a replay of a deleted queued card answers withdrawn, not refused Once a deleted card's tombstone is pruned, a replay of the send that queued it found the draft through its last hand-off. When that hand-off had been rejected (the card came back, and the user then deleted it), the replay answered with the rejected submission, which clients show as a failed send with a Retry. Only a withdrawn row is pruned while its last hand-off stands rejected, so the replay now answers queued, withdrawn. * refactor(native-chat): name the queue's pause-lift for what it releases * chore(native-chat): one import of the mutation helpers * feat(native-chat): a Stop pauses the whole queue, derived from the journal, with an explicit Resume After a Stop, each waiting card was held on its own row ('stopped'), lifted when the host saw, in memory, that a user send made after the Stop had its turn accepted. The cards read "sends after your next message" one by one, there was no way to resume the queue without sending something, and the in-memory record of user sends was lost on a restart or eviction. The pause is now the queue's, and derived rather than stored as a flag: - 'stopped': the user's last Stop took effect at a recorded journal position and no turn a person asked for has started since. "A person asked for it" is the new `origin: 'client'` on the submission row (a send over the client send RPC, or a card they sent now); orchestration mail, a restart continuation, a host-sent launch prompt and the queue's own drain record `host` and never lift it. - 'restarted': a waiting card was written by another host process and no person's turn has started since this conversation opened. Resume (`agentSession.queuedMessagesResume`) lifts either. Send-now sends one card; the rest stay paused until that card's turn starts, which is a person's turn like any other. The journal's row kinds are closed (an older build truncates a journal at a row kind it does not know), so the one event the journal cannot carry, where the Stop took effect, is recorded beside the drafts in `queued_message_pauses`; everything after it is read from the journal. A Stop records it only once it takes effect (after withdrawing queued sends, as it reaches the agent), so a Stop that fails first leaves nothing to undo, and the per-row hold, its undo and `userSendsAwaitingTurn` are gone. A card keeps a hold of its own only when its conversion failed ('send_failed'). The pause is published once, as `queuePause` beside `queuedMessages`, on live frames, catch-up and history. A /clear starts its replacement paused, as after a Stop, since the carried cards were written for the context it discarded. * feat(native-chat): a /clear's replacement queue reads paused because of the clear, not an interrupt The replacement's pause was recorded as 'stopped', which clients show as "Queue paused because you interrupted" although the user cleared the chat. It is now its own reason, 'cleared', on queuePause.reason ('stopped' | 'restarted' | 'cleared'). It lifts and resumes exactly like a Stop's: through Resume, or the user's next turn starting on the replacement. * fix(native-chat): a queue pause covers only the cards it paused A Stop recorded its pause fact even when the queue had no cards, and the fact outlived the cards it did pause. The published list hid a pause over no cards, but the drain still treated the queue as paused, so a card typed much later — during an orchestration-mail turn, or a correction typed before the stopped turn ended — sat under "paused because you interrupted" with no Stop of its own. A Stop now records its pause only if the queue holds a card when the Stop takes effect (the hand-offs its withdrawal sent back included). The fact is retired in the same transaction as the Delete, consume or withdrawal that empties the queue, never from an async publish. A /clear's carry now lands each card with its 'cleared' pause in one transaction, so a failed insert leaves no pause over an empty replacement. * perf(native-chat): the queue's pause reads the latest person's turn in O(1) The pause is derived on every publish, per subscriber, and each derivation copied and scanned every submission to find a person's accepted turn after the Stop. The reducer now keeps that fact as it folds rows: the submission row of the latest accepted turn whose origin is `client`. The Stop's and the restart's lift both read it directly. * fix(native-chat): a card handed off after a restart belongs to the process that sent it A draft's host_instance was only ever the process that first wrote it (or adopted it while waiting). A returned card from before a restart, sent again in this process and then withdrawn back to waiting, still carried the old process, so it raised a 'restarted' pause although no restart happened since it was sent. Every hand-off (the drain, Send on a card) now stamps the handing-off process on the draft in the consume's own update. * fix(native-chat): a queue pause shows only while Resume would send something After a Stop whose only remaining card was a returned one, or after a restart with only a card held by its own failed send, the queue published a pause with a Resume that could send nothing: a returned card waits for the user anyway, and a held one for its own Send. The pause is now published, recorded by a Stop, and kept only over a card it can hold back — waiting, with no hold of its own. The fact is retired in the same transaction as the write that removes the last such card, a hold or a refusal included. The publication's dedup also compared only the pause's reason, so a pause appearing or clearing with no readable reason could read as unchanged; it now compares presence first. * fix(native-chat): a queue pause counts only cards Resume would actually send A waiting card behind a returned one is blocked until the user acts on the returned card — the drain never sends past it — so a pause over only such cards still offered a Resume that sent nothing. The rule for "a card Resume would send" is now one function: waiting, no hold of its own, and not behind a returned card. The publication, a Stop's record and the fact's retirement all read it; retirement reads the rows in position order inside the same transaction as the write that took the last such card. * fix(native-chat): a returned card that blocks the paused cards hides the pause but keeps it The last change retired a Stop's pause as soon as a returned card blocked every paused card. Deleting that returned card then sent the cards behind it at once, with no Resume — not what the user asked for. The two rules are now separate. The pause is KEPT (recorded by a Stop, retired in the same transaction as the write that takes the last one) while any waiting card with no hold of its own exists, wherever it sits. It is PUBLISHED only while such a card is not behind a returned one, so the header never offers a Resume that sends nothing. Deleting the blocking card shows the pause again, and the cards behind it wait for Resume or the user's next turn. * fix(native-chat): a Stop pauses a card its withdrawal sent back even when that settlement was skipped The Stop checked the draft table for a card to pause. When the per-row hook that settles a withdrawn hand-off was skipped, that card was still 'dispatched', so the Stop recorded no pause; the drain later healed it back to waiting and sent it, although the user had pressed Stop. Retirement had the same blind spot and could drop a pause while such a card was owed. What a pause holds back is now one predicate, judged inside the transaction that records or retires it: a waiting card with no hold of its own (one SQL EXISTS), or a dispatched card whose consumed submission was rejected with a settlement back to waiting (read against the journal's submissions). The Stop first runs the owed settlement, as the drain does; if that fails, the owed card still counts, so the pause is recorded rather than skipped. recordPause now checks inside its own transaction and returns whether it recorded, and any draft-table write (and the per-row hook, the consume and the open-time repair) retires a pause that no longer holds anything back. * test(native-chat): pin the per-row hook's pause retirement; skip the judgement when no pause exists The retirement test recorded its second pause over a queue with nothing to hold back, so the recording returned false and the "retired" assertion proved nothing; ablating the per-row hook's retirement passed every test. The hold case now asserts the pause was recorded, and a new test has a delivered echo, through the per-row hook, withdraw the last card a recorded pause holds back. Retirement runs on every appended journal row, so it now checks the pause row by key first and judges nothing when no pause is recorded. Two comments were brought in line with the owed-hand-off rule and rewrapped. * test(native-chat): match main's append and dispatch shapes in the queue tests * fix(native-chat): read a compaction's settled submission through the send-result union --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,29 @@ describe('mobileStructuredSendDelivery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies a queued draft answer as accepted and spends its id, replays included', () => {
|
||||
// The host holds the message now; a later identical send is a new message.
|
||||
for (const state of ['waiting', 'dispatched', 'returned', 'withdrawn'] as const) {
|
||||
const queued: StructuredAgentSessionMutationCallResult<AgentSessionSendResult> = {
|
||||
status: 'accepted',
|
||||
value: {
|
||||
clientMessageId: 'client-1',
|
||||
queued: { messageId: 'client-1', position: 1, state }
|
||||
}
|
||||
}
|
||||
expect(mobileStructuredSendDelivery(queued)).toEqual({
|
||||
outcome: 'accepted',
|
||||
operationIdSpent: true,
|
||||
error: null
|
||||
})
|
||||
expect(mobileStructuredSendDelivery(queued, true)).toEqual({
|
||||
outcome: 'accepted',
|
||||
operationIdSpent: true,
|
||||
error: null
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('does not report a retained payload replay as a new accepted send', () => {
|
||||
for (const dispatchState of ['accepted', 'pending'] as const) {
|
||||
expect(mobileStructuredSendDelivery(accepted(dispatchState), true)).toEqual({
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
// the retry has to stay a replay. Rotating here is what sent one message to a
|
||||
// model five times.
|
||||
|
||||
import type { AgentJournalSubmission } from '../../../src/shared/agent-session-journal-types'
|
||||
import type { AgentSessionSendResult } from '../../../src/shared/agent-session-wire'
|
||||
import { agentSessionRefusalOperationState } from '../../../src/shared/agent-session-refusal-retry'
|
||||
import { structuredAgentSessionRejectionNotice } from '../../../src/shared/structured-agent-session-send-disposition'
|
||||
@@ -60,7 +61,14 @@ export function mobileStructuredSendDelivery(
|
||||
error: result.message
|
||||
}
|
||||
}
|
||||
const submission = result.value.submission as AgentSessionSendResult['submission'] | undefined
|
||||
if ('queued' in result.value && result.value.queued) {
|
||||
// The host holds (or already settled) the draft: the send is spent — a
|
||||
// later identical message is a new message. A withdrawn replay is spent
|
||||
// too, never unknown: its card was deleted or carried by a /clear.
|
||||
return { outcome: 'accepted', operationIdSpent: true, error: null }
|
||||
}
|
||||
const submission: AgentJournalSubmission | undefined =
|
||||
'submission' in result.value ? result.value.submission : undefined
|
||||
if (!submission || submission.dispatchState === 'unknown') {
|
||||
return { outcome: 'unknown', operationIdSpent: false, error: null }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files'
|
||||
import { createJournalTablesSql, JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
|
||||
import { ensureQueuedMessagesTable } from './queued-message-schema'
|
||||
|
||||
export const JOURNAL_BUSY_TIMEOUT_MS = 5000
|
||||
|
||||
@@ -37,6 +38,10 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase {
|
||||
try {
|
||||
configureJournalPragmas(probe)
|
||||
createJournalSchema(probe, stored)
|
||||
// Outside `createJournalSchema` on purpose: its early return skips a db
|
||||
// already at the current version, and this table must exist at EVERY
|
||||
// writable open with no `user_version` bump (see `ensureQueuedMessagesTable`).
|
||||
ensureQueuedMessagesTable(probe)
|
||||
hardenSqliteDatabaseFiles(dbPath)
|
||||
const opened = { db: probe, readOnly: false }
|
||||
transferred = true
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
type UnreadAgentSessionFailureFact
|
||||
} from '../../../shared/agent-session-failure'
|
||||
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import { journalDispatchRowApplies } from './journal-dispatch-settlement'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import { placeHandedOverMessage } from './journal-submission-fold'
|
||||
import { notePersonTurnAccepted, placeHandedOverMessage } from './journal-submission-fold'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
|
||||
export function applyJournalDispatchRow(
|
||||
@@ -15,23 +16,15 @@ export function applyJournalDispatchRow(
|
||||
row: Extract<JournalRow, { kind: 'dispatch' }>
|
||||
): void {
|
||||
const submission = state.submissions.get(row.clientMessageId)
|
||||
if (!submission) {
|
||||
return
|
||||
}
|
||||
// `rejected` is terminal; a late `unknown` must not reopen a settled answer.
|
||||
if (submission.dispatchState === 'rejected' || submission.dispatchState === 'accepted') {
|
||||
// Shared with the queued-draft returned hook: a row ignored here must not alter a draft.
|
||||
if (!submission || !journalDispatchRowApplies(submission)) {
|
||||
return
|
||||
}
|
||||
submission.fence = row.fence
|
||||
submission.dispatchState = row.state
|
||||
submission.providerItemId = row.providerItemId
|
||||
submission.reason = row.reason
|
||||
// Read where it can be placed; a kind it cannot place is kept as written, so the classifier
|
||||
// still knows a fact was there without this build claiming what it says.
|
||||
const rejection =
|
||||
row.state === 'rejected'
|
||||
? (readAgentSessionFailureFact(row.rejection) ?? unreadFailureFact(row.rejection))
|
||||
: undefined
|
||||
const rejection = row.state === 'rejected' ? readStoredRejectionFact(row.rejection) : undefined
|
||||
if (rejection) {
|
||||
submission.rejection = rejection
|
||||
} else {
|
||||
@@ -47,6 +40,9 @@ export function applyJournalDispatchRow(
|
||||
} else {
|
||||
delete submission.recovered
|
||||
}
|
||||
if (row.state === 'accepted') {
|
||||
notePersonTurnAccepted(state, submission)
|
||||
}
|
||||
if (row.state !== 'accepted' || !row.providerItemId) {
|
||||
return
|
||||
}
|
||||
@@ -59,6 +55,13 @@ export function applyJournalDispatchRow(
|
||||
})
|
||||
}
|
||||
|
||||
/** A stored rejection fact, read where it can be placed; a kind it cannot place is kept as
|
||||
* written, so the classifier still knows a fact was there without this build claiming what it
|
||||
* says. Shared with the queued-draft table, whose returned card mirrors its submission. */
|
||||
export function readStoredRejectionFact(value: unknown): UnreadAgentSessionFailureFact | undefined {
|
||||
return readAgentSessionFailureFact(value) ?? unreadFailureFact(value)
|
||||
}
|
||||
|
||||
function unreadFailureFact(value: unknown): UnreadAgentSessionFailureFact | undefined {
|
||||
return typeof value === 'object' &&
|
||||
value !== null &&
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentSessionFailureFact,
|
||||
type SubmissionRejectionKind
|
||||
} from '../../../shared/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
|
||||
import {
|
||||
DISPATCH_REJECTED_CANCELLED,
|
||||
DISPATCH_REJECTED_HOST_RESTARTED
|
||||
} from '../../../shared/structured-agent-session-dispatch-rejection'
|
||||
import { rejectedDraftSettlement } from './journal-dispatch-settlement'
|
||||
|
||||
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' })
|
||||
expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_CANCELLED })).toEqual({
|
||||
state: 'waiting'
|
||||
})
|
||||
})
|
||||
|
||||
it('a restart or close before hand-over sends it back to waiting too', () => {
|
||||
for (const kind of ['hostRestarted', 'chatClosed', 'notDelivered'] as const) {
|
||||
expect(settle(kind)).toEqual({ state: 'waiting' })
|
||||
}
|
||||
expect(rejectedDraftSettlement({ reason: DISPATCH_REJECTED_HOST_RESTARTED })).toEqual({
|
||||
state: 'waiting'
|
||||
})
|
||||
})
|
||||
|
||||
it('a failure returns the card for the user to act on', () => {
|
||||
for (const kind of [
|
||||
'providerRejected',
|
||||
'providerExited',
|
||||
'hostStopped',
|
||||
'startFailed',
|
||||
'writeFailed',
|
||||
'queueFull'
|
||||
] as const) {
|
||||
expect(settle(kind)).toEqual({ state: 'returned' })
|
||||
}
|
||||
expect(rejectedDraftSettlement({ reason: 'the provider said no' })).toEqual({
|
||||
state: 'returned'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
// The one decision for whether a committed dispatch row changes a submission's
|
||||
// effective delivery answer, and what a rejection does to the draft it was
|
||||
// consumed from. The reducer folds rows through the first and the queued
|
||||
// draft's settlement hook fires through it, so the two can never disagree: a
|
||||
// row the reducer ignores must not alter a draft.
|
||||
|
||||
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
|
||||
import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection'
|
||||
import type { JournalDispatchRow } from './journal-row-schema'
|
||||
|
||||
/** `rejected` and `accepted` are terminal; a late row for an absent or settled
|
||||
* submission must not reopen the answer. */
|
||||
export function journalDispatchRowApplies(
|
||||
submission: Pick<AgentJournalSubmission, 'dispatchState'> | undefined
|
||||
): boolean {
|
||||
return (
|
||||
submission !== undefined &&
|
||||
submission.dispatchState !== 'rejected' &&
|
||||
submission.dispatchState !== 'accepted'
|
||||
)
|
||||
}
|
||||
|
||||
/** A consumed draft's submission settled `rejected`: the draft is settled by
|
||||
* `rejectedDraftSettlement`, since its text has no other holder once it left
|
||||
* the sender's outbox as a draft. */
|
||||
export function consumedSubmissionWasRejected(
|
||||
submission: Pick<AgentJournalSubmission, 'dispatchState'> | undefined
|
||||
): boolean {
|
||||
return submission?.dispatchState === 'rejected'
|
||||
}
|
||||
|
||||
/** What a consumed draft becomes when its submission is rejected. */
|
||||
export type RejectedDraftSettlement = { state: 'returned' } | { state: 'waiting' }
|
||||
|
||||
/**
|
||||
* Where no one failed the user — a Stop withdrew it, or a restart or close
|
||||
* interrupted it before hand-over — the draft goes back to waiting at its own
|
||||
* position, under whatever pauses the queue: the Stop's own pause, or the
|
||||
* restart's, derived from the host instance. 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(
|
||||
rejection: Pick<AgentJournalSubmission, 'reason'> & { rejection?: unknown }
|
||||
): RejectedDraftSettlement {
|
||||
return classifyDispatchRejection(rejection).verdict === null
|
||||
? { state: 'waiting' }
|
||||
: { state: 'returned' }
|
||||
}
|
||||
|
||||
/** True when committing this row NEWLY settles the submission to `rejected` —
|
||||
* the only transition that settles a consumed draft. */
|
||||
export function journalDispatchRowNewlyRejects(
|
||||
submission: Pick<AgentJournalSubmission, 'dispatchState'> | undefined,
|
||||
row: Pick<JournalDispatchRow, 'state'>
|
||||
): boolean {
|
||||
return row.state === 'rejected' && journalDispatchRowApplies(submission)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// The journal's draft-store collaborator: every read and write of one session's
|
||||
// `queued_messages` rows, serialized on the same queue as the journal's own
|
||||
// appends so a draft mutation can never interleave with the consume that
|
||||
// converts it. Drafts are NEVER owed work: nothing here feeds the reducer,
|
||||
// working status, teardown, or the idle sweep.
|
||||
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS,
|
||||
AGENT_SESSION_OPERATION_FUTURE_SKEW_MS
|
||||
} from '../../../shared/agent-session-host-authority'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import type { JournalSubmissionConsume } from './journal-store-contracts'
|
||||
import { adoptQueuedMessages, holdQueuedMessages } from './queued-message-holds'
|
||||
import {
|
||||
clearQueuePause,
|
||||
queuePauseHoldsBack,
|
||||
readQueuePause,
|
||||
recordQueuePause,
|
||||
retireQueuePauseIfNothingHeld,
|
||||
type QueuePauseFact,
|
||||
type QueuePauseReason
|
||||
} from './queued-message-pause-table'
|
||||
import {
|
||||
consumeQueuedMessageInTransaction,
|
||||
getQueuedMessage,
|
||||
insertQueuedMessage,
|
||||
listQueuedMessages,
|
||||
queuedMessagesSettledByOp,
|
||||
withdrawQueuedMessages,
|
||||
type QueuedMessageHoldReason,
|
||||
type QueuedMessageRow
|
||||
} from './queued-message-table'
|
||||
import { draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo'
|
||||
import { pruneQueuedMessages, retainedSubmissionVerdict } from './queued-message-retention'
|
||||
import {
|
||||
owedBackToWaiting,
|
||||
queuedMessageSettlementOwed,
|
||||
settleOwedQueuedMessages,
|
||||
settleQueuedMessagesForRow
|
||||
} from './queued-message-settlement'
|
||||
import { AgentSessionJournalError, assertJournalWritable } from './journal-write-guards'
|
||||
|
||||
/** Tombstones must outlive the window in which their operation id could still be admitted as new. */
|
||||
export const QUEUED_MESSAGE_REPLAY_WINDOW_MS =
|
||||
AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS
|
||||
|
||||
export type JournalQueuedMessagesDeps = {
|
||||
sessionId: string
|
||||
now: () => number
|
||||
serialize: <T>(run: () => Promise<T>) => Promise<T>
|
||||
database: () => { db: Database.Database }
|
||||
readOnly: () => boolean
|
||||
state: () => JournalReducerState
|
||||
/** The journal's own commit notification. Every standalone draft-table
|
||||
* transaction that changed rows fires it after COMMIT, so a draft or hold
|
||||
* change publishes and wakes the drain through the same path a journal row
|
||||
* does — no call site can forget. In-transaction consume and the returned
|
||||
* transition already ride their row's own commit. */
|
||||
committed: () => void
|
||||
}
|
||||
|
||||
export class JournalQueuedMessages {
|
||||
/** Bumped on every draft-table write, so publication memos recompute only when they must. */
|
||||
private changeRevision = 0
|
||||
private listed: { revision: number; rows: readonly QueuedMessageRow[] } | null = null
|
||||
private paused: { revision: number; fact: QueuePauseFact | null } | null = null
|
||||
|
||||
constructor(private readonly deps: JournalQueuedMessagesDeps) {}
|
||||
|
||||
revision(): number {
|
||||
return this.changeRevision
|
||||
}
|
||||
|
||||
/** The submission row of the latest accepted turn a person asked for; 0 when none. What
|
||||
* ends the queue's pause, read from the reducer in O(1). */
|
||||
latestPersonTurnSequence(): number {
|
||||
return this.deps.state().latestPersonTurnSequence
|
||||
}
|
||||
|
||||
/** A journal transaction rolled back: nothing read inside it may stay cached. */
|
||||
invalidate(): void {
|
||||
this.changeRevision++
|
||||
}
|
||||
|
||||
/** Cached per revision: the drain re-checks on every journal publish, so an
|
||||
* unchanged table must cost no SQL read or body parse on token streams. */
|
||||
list(): readonly QueuedMessageRow[] {
|
||||
if (this.listed?.revision !== this.changeRevision) {
|
||||
this.listed = {
|
||||
revision: this.changeRevision,
|
||||
rows: listQueuedMessages(this.deps.database().db, this.deps.sessionId)
|
||||
}
|
||||
}
|
||||
return this.listed.rows
|
||||
}
|
||||
|
||||
get(messageId: string): QueuedMessageRow | null {
|
||||
return getQueuedMessage(this.deps.database().db, this.deps.sessionId, messageId)
|
||||
}
|
||||
|
||||
/** Replay receipts for one caller-scoped operation key. */
|
||||
receipts(settledByOp: string): QueuedMessageRow[] {
|
||||
return queuedMessagesSettledByOp(this.deps.database().db, this.deps.sessionId, settledByOp)
|
||||
}
|
||||
|
||||
/** `pausedBy`: the queue is paused in the SAME transaction as this card lands
|
||||
* (a /clear's carry), so the drain never sees it unpaused and no pause fact
|
||||
* exists without a card under it. */
|
||||
insert(input: {
|
||||
messageId: string
|
||||
body: AgentJournalMessageItem
|
||||
fingerprint: string
|
||||
hostInstance: string
|
||||
pausedBy?: QueuePauseReason
|
||||
}): Promise<QueuedMessageRow> {
|
||||
const { pausedBy, ...draft } = input
|
||||
const { sessionId } = this.deps
|
||||
let inserted = false
|
||||
return this.transact(
|
||||
(db) => {
|
||||
const existing = getQueuedMessage(db, sessionId, draft.messageId)
|
||||
if (existing) {
|
||||
// One id, one draft: admission replays a recorded operation before it
|
||||
// gets here, so an existing row is the same accept landing twice.
|
||||
return existing
|
||||
}
|
||||
inserted = true
|
||||
const row = insertQueuedMessage(db, { ...draft, sessionId, now: this.deps.now() })
|
||||
if (pausedBy) {
|
||||
recordQueuePause(db, { sessionId, fact: this.pauseFact(pausedBy) })
|
||||
}
|
||||
return row
|
||||
},
|
||||
() => inserted
|
||||
)
|
||||
}
|
||||
|
||||
/** Hold one waiting draft whose conversion failed. Stored on the row, so it
|
||||
* survives handle eviction and restart; withdraw and consume clear it in their
|
||||
* own UPDATE. */
|
||||
hold(input: { messageIds: readonly string[]; reason: QueuedMessageHoldReason }): Promise<void> {
|
||||
return this.transact(
|
||||
(db) => holdQueuedMessages(db, { ...input, sessionId: this.deps.sessionId }),
|
||||
(held) => held > 0
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
/** Where the user's last Stop took effect, if it is still recorded; cached per revision. */
|
||||
pause(): QueuePauseFact | null {
|
||||
if (this.paused?.revision !== this.changeRevision) {
|
||||
this.paused = {
|
||||
revision: this.changeRevision,
|
||||
fact: readQueuePause(this.deps.database().db, this.deps.sessionId)
|
||||
}
|
||||
}
|
||||
return this.paused.fact
|
||||
}
|
||||
|
||||
/** A Stop took effect here: the queue is paused from this position on — if, judged in
|
||||
* the same transaction, it holds back a card at all. Returns whether it recorded. */
|
||||
recordPause(reason: QueuePauseReason): Promise<boolean> {
|
||||
const fact = this.pauseFact(reason)
|
||||
return this.transact(
|
||||
(db) =>
|
||||
queuePauseHoldsBack(db, this.pauseScope()) &&
|
||||
(recordQueuePause(db, { sessionId: this.deps.sessionId, fact }), true),
|
||||
(recorded) => recorded
|
||||
)
|
||||
}
|
||||
|
||||
/** What `queuePauseHoldsBack` judges a pause by, from this journal's submissions. */
|
||||
private pauseScope() {
|
||||
return {
|
||||
sessionId: this.deps.sessionId,
|
||||
owedToWaiting: owedBackToWaiting(this.deps.state().submissions)
|
||||
}
|
||||
}
|
||||
|
||||
private pauseFact(reason: QueuePauseReason): QueuePauseFact {
|
||||
const { epoch, lastSequence: sequence } = this.deps.state()
|
||||
return { reason, epoch, sequence, recordedAt: this.deps.now() }
|
||||
}
|
||||
|
||||
/** Ends the queue's pause: `stop` retires that Stop fact (never a later one),
|
||||
* `adoptInto` adopts a restart's rows into this host instance. Returns whether
|
||||
* anything changed. */
|
||||
liftPause(input: { stop: QueuePauseFact | null; adoptInto: string | null }): Promise<boolean> {
|
||||
const { sessionId } = this.deps
|
||||
return this.transact(
|
||||
(db) =>
|
||||
(input.stop ? clearQueuePause(db, { sessionId, fact: input.stop }) : 0) +
|
||||
(input.adoptInto === null
|
||||
? 0
|
||||
: adoptQueuedMessages(db, { sessionId, hostInstance: input.adoptInto })),
|
||||
(changed) => changed > 0
|
||||
).then((changed) => changed > 0)
|
||||
}
|
||||
|
||||
/** Compare-and-transition waiting ∪ returned rows to op-stamped tombstones,
|
||||
* kept only so a replay of the settling operation answers "spent". */
|
||||
withdraw(input: {
|
||||
messageIds: readonly string[]
|
||||
settledByOp: string
|
||||
}): Promise<QueuedMessageRow[]> {
|
||||
if (input.messageIds.length === 0) {
|
||||
// Delete races and empty carries land here; neither may cost a write transaction.
|
||||
return Promise.resolve([])
|
||||
}
|
||||
return this.transact(
|
||||
(db) =>
|
||||
withdrawQueuedMessages(db, {
|
||||
...input,
|
||||
sessionId: this.deps.sessionId,
|
||||
now: this.deps.now()
|
||||
}),
|
||||
(withdrawn) => withdrawn.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
/** One standalone draft-table transaction on the journal's queue; one that
|
||||
* changed rows bumps the revision and notifies after COMMIT. */
|
||||
private transact<T>(
|
||||
run: (db: Database.Database) => T,
|
||||
changed: (result: T) => boolean
|
||||
): Promise<T> {
|
||||
return this.deps.serialize(async () => {
|
||||
assertJournalWritable(this.deps.readOnly(), this.deps.sessionId)
|
||||
const { db } = this.deps.database()
|
||||
db.exec('BEGIN IMMEDIATE')
|
||||
let result: T
|
||||
let retired: number
|
||||
try {
|
||||
result = run(db)
|
||||
// Any draft write may take the last card a pause holds back.
|
||||
retired = retireQueuePauseIfNothingHeld(db, this.pauseScope())
|
||||
db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
if (changed(result) || retired > 0) {
|
||||
this.changeRevision++
|
||||
this.deps.committed()
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
/** The standing writer hook, within the append's transaction
|
||||
* (`settleQueuedMessagesForRow`). */
|
||||
onRowInTransaction(db: Database.Database, row: JournalRow): void {
|
||||
this.changeRevision += settleQueuedMessagesForRow(db, {
|
||||
sessionId: this.deps.sessionId,
|
||||
state: this.deps.state(),
|
||||
drafts: () => this.list(),
|
||||
row,
|
||||
now: this.deps.now()
|
||||
})
|
||||
this.changeRevision += retireQueuePauseIfNothingHeld(db, this.pauseScope())
|
||||
}
|
||||
|
||||
/** The in-transaction consume for `appendSubmission`; a false compare-and-set
|
||||
* throws so the whole append — draft transition AND submission row — rolls back. */
|
||||
consumeInTransaction(
|
||||
db: Database.Database,
|
||||
input: JournalSubmissionConsume & { consumedAs: string }
|
||||
): void {
|
||||
const { db: own } = this.deps.database()
|
||||
if (own !== db) {
|
||||
// Same handle only: a second connection could not join the transaction.
|
||||
throw new AgentSessionJournalError('journal_closed', 'consume crossed database handles')
|
||||
}
|
||||
const consumed = consumeQueuedMessageInTransaction(db, {
|
||||
...input,
|
||||
sessionId: this.deps.sessionId,
|
||||
now: this.deps.now()
|
||||
})
|
||||
if (!consumed) {
|
||||
throw new QueuedMessageNotConsumableError(input.messageId, input.expect)
|
||||
}
|
||||
retireQueuePauseIfNothingHeld(db, this.pauseScope())
|
||||
this.changeRevision++
|
||||
}
|
||||
|
||||
/** A skipped live settlement the journal already decided (`queued-message-settlement.ts`). */
|
||||
settlementOwed(): boolean {
|
||||
return queuedMessageSettlementOwed(this.list(), this.deps.state().submissions)
|
||||
}
|
||||
|
||||
/** Waiting drafts a skipped echo hook left unwithdrawn; reads every item, so only the drain
|
||||
* step asks, right before a draft would send. */
|
||||
deliveredByEchoOwed(): boolean {
|
||||
return draftsDeliveredByAppliedEcho(this.deps.state(), this.list()).length > 0
|
||||
}
|
||||
|
||||
/** Applies owed settlements now, so a skipped live transition heals without a reopen. */
|
||||
settleOwed(): Promise<void> {
|
||||
return this.transact(
|
||||
(db) =>
|
||||
settleOwedQueuedMessages(db, {
|
||||
sessionId: this.deps.sessionId,
|
||||
state: this.deps.state(),
|
||||
now: this.deps.now()
|
||||
}),
|
||||
(settled) => settled > 0
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
/** Bookkeeping at open: a failure is reported and retried at the next open,
|
||||
* never allowed to fail opening the chat. */
|
||||
repairAndPruneAtOpen(): Promise<void> {
|
||||
return this.repairAndPrune().catch((error: unknown) => {
|
||||
console.warn('[journal-open] queued-message repair skipped:', {
|
||||
sessionId: this.deps.sessionId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open-time reconciliation, a re-derivation behind the stored fact: owed
|
||||
* settlements apply exactly as the live hook would have (covers consume →
|
||||
* crash → downgrade → upgrade, where the old build rejected the leftover with
|
||||
* no hook), then retention runs; a pause left holding back nothing retires.
|
||||
*/
|
||||
repairAndPrune(): Promise<void> {
|
||||
if (this.deps.readOnly()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
const { sessionId } = this.deps
|
||||
return this.transact(
|
||||
(db) => {
|
||||
const [now, state] = [this.deps.now(), this.deps.state()]
|
||||
return (
|
||||
settleOwedQueuedMessages(db, { sessionId, state, now }) +
|
||||
pruneQueuedMessages(db, {
|
||||
sessionId,
|
||||
now,
|
||||
replayWindowMs: QUEUED_MESSAGE_REPLAY_WINDOW_MS,
|
||||
submissionVerdict: retainedSubmissionVerdict(state.submissions)
|
||||
})
|
||||
)
|
||||
},
|
||||
(changed) => changed > 0
|
||||
).then(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/** The per-append hook converting one draft inside the append's own transaction. */
|
||||
export function queuedMessageConsumeHook(
|
||||
queuedMessages: JournalQueuedMessages,
|
||||
consumedAs: string,
|
||||
consume: JournalSubmissionConsume
|
||||
): (db: Database.Database) => void {
|
||||
return (db) => queuedMessages.consumeInTransaction(db, { ...consume, consumedAs })
|
||||
}
|
||||
|
||||
export class QueuedMessageNotConsumableError extends Error {
|
||||
constructor(
|
||||
readonly messageId: string,
|
||||
readonly expected: 'waiting' | 'returned'
|
||||
) {
|
||||
super(`queued message ${messageId} is no longer ${expected}`)
|
||||
this.name = 'QueuedMessageNotConsumableError'
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,9 @@ export type JournalReducerState = {
|
||||
appliedSettlementIds: Set<string>
|
||||
/** Scope for rows stored without one; rebuilt by replay, never persisted. */
|
||||
derivedTurnScope: JournalDerivedTurnScope
|
||||
/** The submission row of the latest turn a person asked for (`origin: 'client'`) that the
|
||||
* provider accepted; 0 when none. Kept as it folds so the queue's pause reads it in O(1). */
|
||||
latestPersonTurnSequence: number
|
||||
}
|
||||
|
||||
export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState {
|
||||
@@ -71,7 +74,8 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou
|
||||
receipts: new Map(),
|
||||
aliases: new Map(),
|
||||
appliedSettlementIds: new Set(),
|
||||
derivedTurnScope: new JournalDerivedTurnScope()
|
||||
derivedTurnScope: new JournalDerivedTurnScope(),
|
||||
latestPersonTurnSequence: 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,16 +158,38 @@ export function resolveJournalItemId(
|
||||
if (aliased) {
|
||||
return aliased
|
||||
}
|
||||
const identity = parseAgentJournalItemKey(itemId)
|
||||
if (
|
||||
!body ||
|
||||
body.kind !== 'message' ||
|
||||
body.role !== 'user' ||
|
||||
!identity ||
|
||||
identity.provider === 'orca'
|
||||
) {
|
||||
const submissionId = journalEchoClaimant(state, itemId, body)
|
||||
if (!submissionId) {
|
||||
return itemId
|
||||
}
|
||||
state.aliases.set(itemId, submissionId)
|
||||
return submissionId
|
||||
}
|
||||
|
||||
/** A user message the provider wrote: the only item a submission's echo can be. */
|
||||
export function isProviderUserMessageEcho(
|
||||
itemId: string,
|
||||
body: AgentJournalRenderItem['body']
|
||||
): boolean {
|
||||
const identity = parseAgentJournalItemKey(itemId)
|
||||
return (
|
||||
body.kind === 'message' &&
|
||||
body.role === 'user' &&
|
||||
identity !== null &&
|
||||
identity.provider !== 'orca'
|
||||
)
|
||||
}
|
||||
|
||||
/** The submission item a provider's echo of a user message would fold into, read without
|
||||
* claiming it; null when the item is not such an echo, or no submission may claim it. */
|
||||
export function journalEchoClaimant(
|
||||
state: JournalReducerState,
|
||||
itemId: string,
|
||||
body?: AgentJournalRenderItem['body']
|
||||
): string | null {
|
||||
if (!body || !isProviderUserMessageEcho(itemId, body)) {
|
||||
return null
|
||||
}
|
||||
const fingerprint = structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: state.sessionId,
|
||||
@@ -184,12 +210,7 @@ export function resolveJournalItemId(
|
||||
candidate.payloadFingerprint === fingerprint &&
|
||||
state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0
|
||||
)
|
||||
if (!submission) {
|
||||
return itemId
|
||||
}
|
||||
const submissionId = agentJournalSubmissionKey(submission.clientMessageId)
|
||||
state.aliases.set(itemId, submissionId)
|
||||
return submissionId
|
||||
return submission ? agentJournalSubmissionKey(submission.clientMessageId) : null
|
||||
}
|
||||
|
||||
function resolveItemId(state: JournalReducerState, itemId: string): string {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS
|
||||
} from './journal-row-schema'
|
||||
import { boundInlineText, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds'
|
||||
import { assertSubmissionIdUnused } from './journal-write-guards'
|
||||
import type { ResolveDispatchInput } from './journal-store-contracts'
|
||||
|
||||
type RowBuilder<T> = (seq: number, ts: number) => T
|
||||
@@ -68,10 +69,28 @@ export function journalSubmissionRowBuilder(
|
||||
body: AgentJournalMessageItem
|
||||
fence: number
|
||||
handoverRecorded?: true
|
||||
}
|
||||
queuedMessageId?: string
|
||||
origin?: 'client' | 'host'
|
||||
},
|
||||
/** Present when the append hands off a queued draft: the row names that draft, stamped here
|
||||
* from the consume itself so no hand-off path can leave the link off. */
|
||||
consume?: { messageId: string }
|
||||
): RowBuilder<JournalSubmissionRow> {
|
||||
return (seq, ts) =>
|
||||
buildJournalSubmissionRow({ state: state(), providerHandle, ...input, seq, ts })
|
||||
return (seq, ts) => {
|
||||
assertSubmissionIdUnused(state().submissions, input.clientMessageId)
|
||||
if (consume && (input.queuedMessageId ?? consume.messageId) !== consume.messageId) {
|
||||
throw new Error(`submission ${input.clientMessageId} names a draft it does not consume`)
|
||||
}
|
||||
const queuedMessageId = consume?.messageId ?? input.queuedMessageId
|
||||
return buildJournalSubmissionRow({
|
||||
state: state(),
|
||||
providerHandle,
|
||||
...input,
|
||||
...(queuedMessageId !== undefined ? { queuedMessageId } : {}),
|
||||
seq,
|
||||
ts
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function journalDispatchRowBuilder(
|
||||
@@ -279,6 +298,8 @@ export function buildJournalSubmissionRow(input: {
|
||||
fence: number
|
||||
ts: number
|
||||
handoverRecorded?: true
|
||||
queuedMessageId?: string
|
||||
origin?: 'client' | 'host'
|
||||
}): JournalSubmissionRow {
|
||||
return {
|
||||
kind: 'submission',
|
||||
@@ -287,6 +308,8 @@ export function buildJournalSubmissionRow(input: {
|
||||
providerHandle: input.providerHandle,
|
||||
body: input.body,
|
||||
...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts),
|
||||
...(input.handoverRecorded ? { handoverRecorded: true } : {})
|
||||
...(input.handoverRecorded ? { handoverRecorded: true } : {}),
|
||||
...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}),
|
||||
...(input.origin !== undefined ? { origin: input.origin } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +86,18 @@ export type JournalSubmissionRow = JournalRowBase & {
|
||||
/** Accepted to be handed over by a later `dispatch{pending}` row; absent on rows whose writer
|
||||
* dispatched in the same step. Older readers keep the key and ignore it. */
|
||||
handoverRecorded?: true
|
||||
/** The queued draft this submission hands off; absent for a direct send. Older readers keep
|
||||
* the key and ignore it. */
|
||||
queuedMessageId?: string
|
||||
/** Who asked for this turn: `client` for a person's send over the client send RPC (typed, or
|
||||
* a queued card they sent now); `host` for Orca's own — orchestration mail, a restart
|
||||
* continuation, a launch prompt, the queue's automatic drain. Absent on rows from before it
|
||||
* was recorded. Older readers keep the key and ignore it. */
|
||||
origin?: JournalSubmissionOrigin
|
||||
}
|
||||
|
||||
export type JournalSubmissionOrigin = 'client' | 'host'
|
||||
|
||||
export type JournalDispatchRow = JournalRowBase & {
|
||||
kind: 'dispatch'
|
||||
clientMessageId: string
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import { insertJournalRow, upsertJournalSessionRow } from './journal-row-table'
|
||||
import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import { assertJournalFence, assertJournalWritable } from './journal-write-guards'
|
||||
|
||||
/** Runs between BEGIN IMMEDIATE and COMMIT, on the SAME connection as the row
|
||||
* insert; a throw rolls the whole append back. Synchronous by construction so
|
||||
* nothing can interleave inside the transaction. */
|
||||
export type JournalRowTransactionHook = (db: Database.Database, row: JournalRow) => void
|
||||
|
||||
export type JournalRowWriterDeps = {
|
||||
sessionId: string
|
||||
now: () => number
|
||||
@@ -12,12 +18,23 @@ export type JournalRowWriterDeps = {
|
||||
highestFence: () => number
|
||||
nextSequence: () => number
|
||||
commit: (row: JournalRow) => void
|
||||
/** Standing hook run for EVERY appended row — the queued-draft returned
|
||||
* transition rides here so no rejection path can bypass it. Bookkeeping: it
|
||||
* runs in its own savepoint, so its failure is reported and never vetoes the row. */
|
||||
inTransaction?: JournalRowTransactionHook
|
||||
/** After any rollback, so a cache filled inside the transaction cannot outlive it. */
|
||||
rolledBack?: () => void
|
||||
}
|
||||
|
||||
const BOOKKEEPING_SAVEPOINT = 'journal_row_bookkeeping'
|
||||
|
||||
export class JournalRowWriter {
|
||||
constructor(private readonly deps: JournalRowWriterDeps) {}
|
||||
|
||||
enqueue(build: (seq: number, ts: number) => JournalRow): Promise<JournalRow> {
|
||||
enqueue(
|
||||
build: (seq: number, ts: number) => JournalRow,
|
||||
hook?: JournalRowTransactionHook
|
||||
): Promise<JournalRow> {
|
||||
return this.deps.serialize(async () => {
|
||||
assertJournalWritable(this.deps.readOnly(), this.deps.sessionId)
|
||||
const row = build(this.deps.nextSequence(), this.deps.now())
|
||||
@@ -27,9 +44,12 @@ export class JournalRowWriter {
|
||||
try {
|
||||
insertJournalRow(db, this.deps.sessionId, row)
|
||||
upsertJournalSessionRow(db, this.deps.sessionId, row.epoch, row.ts)
|
||||
hook?.(db, row)
|
||||
this.runBookkeeping(db, row)
|
||||
db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK')
|
||||
this.deps.rolledBack?.()
|
||||
throw error
|
||||
}
|
||||
// COMMIT landed, so the row is durable: adopt it before anything that can
|
||||
@@ -39,4 +59,36 @@ export class JournalRowWriter {
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
/** Assign the next sequence, make the row durable, and fold it through the SAME reducer
|
||||
* replay uses — all inside one serialized step — answering where the row landed. */
|
||||
append(
|
||||
build: (seq: number, ts: number) => JournalRow,
|
||||
hook?: JournalRowTransactionHook
|
||||
): Promise<AgentJournalCursor> {
|
||||
return this.enqueue(build, hook).then((row) => ({ epoch: row.epoch, sequence: row.seq }))
|
||||
}
|
||||
|
||||
private runBookkeeping(db: Database.Database, row: JournalRow): void {
|
||||
const hook = this.deps.inTransaction
|
||||
if (!hook) {
|
||||
return
|
||||
}
|
||||
db.exec(`SAVEPOINT ${BOOKKEEPING_SAVEPOINT}`)
|
||||
try {
|
||||
hook(db, row)
|
||||
db.exec(`RELEASE ${BOOKKEEPING_SAVEPOINT}`)
|
||||
} catch (error) {
|
||||
db.exec(`ROLLBACK TO ${BOOKKEEPING_SAVEPOINT}`)
|
||||
db.exec(`RELEASE ${BOOKKEEPING_SAVEPOINT}`)
|
||||
this.deps.rolledBack?.()
|
||||
// The draft store re-derives what this missed from the committed rows: at open, and in
|
||||
// the drain step before a draft sends.
|
||||
console.warn('[journal-append] row bookkeeping skipped:', {
|
||||
sessionId: this.deps.sessionId,
|
||||
kind: row.kind,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { JournalEpochController } from './journal-epoch-controller'
|
||||
import { JournalItemAppender } from './journal-item-appender'
|
||||
import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender'
|
||||
import type { JournalLoad } from './journal-open'
|
||||
import { JournalQueuedMessages } from './journal-queued-messages'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import { JournalRowWriter } from './journal-row-writer'
|
||||
import { restoreJournalStore } from './journal-store-restore'
|
||||
@@ -19,6 +20,10 @@ import type { JournalRow } from './journal-row-schema'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
|
||||
export type JournalStoreHost = {
|
||||
/** Fires the journal's commit listener for a durable change that appended no
|
||||
* row — a standalone draft-table transaction — so readers learn of it the
|
||||
* same way they learn of a row. */
|
||||
notifyCommitted: () => void
|
||||
identity: AgentSessionJournalIdentity
|
||||
journalDir: string
|
||||
now: () => number
|
||||
@@ -44,6 +49,7 @@ export type JournalStoreCollaborators = {
|
||||
epochController: JournalEpochController
|
||||
itemAppender: JournalItemAppender
|
||||
lifecycleBatchAppender: JournalLifecycleBatchAppender
|
||||
queuedMessages: JournalQueuedMessages
|
||||
/** Restores the store's state from disk. Owned here because it needs the same
|
||||
* collaborators the constructor just built. */
|
||||
restore: () => Promise<void>
|
||||
@@ -62,9 +68,24 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal
|
||||
cursor: host.cursor,
|
||||
adopt: host.adopt
|
||||
})
|
||||
const queuedMessages = new JournalQueuedMessages({
|
||||
sessionId: host.identity.sessionId,
|
||||
now: host.now,
|
||||
serialize: host.serialize,
|
||||
database: host.database,
|
||||
readOnly: host.readOnly,
|
||||
state: host.state,
|
||||
committed: host.notifyCommitted
|
||||
})
|
||||
return {
|
||||
epochController,
|
||||
restore: () => restoreJournalStore(host, { epochController }),
|
||||
queuedMessages,
|
||||
// Behind the stored fact: settles drafts whose consumed submission the loaded journal shows
|
||||
// refused (a downgrade wrote no hook), then prunes. Bookkeeping, never failing the open.
|
||||
restore: () =>
|
||||
restoreJournalStore(host, { epochController }).then(() =>
|
||||
queuedMessages.repairAndPruneAtOpen()
|
||||
),
|
||||
rowWriter: new JournalRowWriter({
|
||||
sessionId: host.identity.sessionId,
|
||||
now: host.now,
|
||||
@@ -73,7 +94,11 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal
|
||||
readOnly: host.readOnly,
|
||||
highestFence: () => host.state().highestFence,
|
||||
nextSequence: () => host.state().lastSequence + 1,
|
||||
commit: host.commit
|
||||
commit: host.commit,
|
||||
// Every rejection is a dispatch row through this one writer; the draft
|
||||
// returned-transition rides it so no path can bypass the hook.
|
||||
inTransaction: (db, row) => queuedMessages.onRowInTransaction(db, row),
|
||||
rolledBack: () => queuedMessages.invalidate()
|
||||
}),
|
||||
itemAppender: new JournalItemAppender({
|
||||
state: host.state,
|
||||
|
||||
@@ -70,6 +70,21 @@ export type JournalSubmissionInput = {
|
||||
fence: number
|
||||
/** The send is accepted now and handed over later, by a `dispatch{pending}` row. */
|
||||
handoverRecorded?: true
|
||||
/** Stamped by `appendSubmission` from its consume; a caller-passed value must match it. */
|
||||
queuedMessageId?: string
|
||||
/** Who asked for this turn (`JournalSubmissionRow.origin`). */
|
||||
origin?: 'client' | 'host'
|
||||
}
|
||||
|
||||
/** A submission append that converts a queued draft, in one transaction. */
|
||||
export type JournalSubmissionConsume = {
|
||||
messageId: string
|
||||
expect: 'waiting' | 'returned'
|
||||
/** The operation ledger's caller-scoped key; null for the host's own drain. */
|
||||
settledByOp: string | null
|
||||
/** The host process handing it off, stamped on the draft so a hand-off withdrawn back to
|
||||
* waiting belongs to the process that sent it, not the one that first wrote the card. */
|
||||
hostInstance?: string
|
||||
}
|
||||
|
||||
export type JournalItemAppendInput = {
|
||||
|
||||
@@ -52,11 +52,13 @@ import type {
|
||||
JournalItemAppendOptions,
|
||||
JournalLifecycleBatchInput,
|
||||
JournalReadSince,
|
||||
JournalSubmissionConsume,
|
||||
JournalSubmissionInput,
|
||||
JournalTombstoneInput,
|
||||
ResolveDispatchInput
|
||||
} from './journal-store-contracts'
|
||||
import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema'
|
||||
import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages'
|
||||
import type { AgentJournalEpochReason } from './journal-row-schema'
|
||||
import { AgentSessionJournalError } from './journal-write-guards'
|
||||
import type { JournalRowWriter } from './journal-row-writer'
|
||||
import type { JournalEpochController } from './journal-epoch-controller'
|
||||
@@ -89,6 +91,8 @@ export class AgentSessionJournal {
|
||||
private readonly itemAppender: JournalItemAppender
|
||||
private readonly lifecycleBatchAppender: JournalLifecycleBatchAppender
|
||||
private readonly restore: () => Promise<void>
|
||||
/** Draft rows queued while the agent works; never reducer input or owed work. */
|
||||
readonly queuedMessages: JournalQueuedMessages
|
||||
|
||||
constructor(options: AgentSessionJournalOptions) {
|
||||
this.identity = options.identity
|
||||
@@ -125,18 +129,20 @@ export class AgentSessionJournal {
|
||||
applyJournalRow(this.state, row)
|
||||
this.onCommitted?.()
|
||||
},
|
||||
notifyCommitted: () => this.onCommitted?.(),
|
||||
loaded: () => this.loaded,
|
||||
malformedRows: () => this.malformedRows,
|
||||
setMalformedRows: (count) => {
|
||||
this.malformedRows = count
|
||||
},
|
||||
journal: () => this,
|
||||
enqueue: (build) => this.enqueue(build)
|
||||
enqueue: (build) => this.rowWriter.enqueue(build)
|
||||
})
|
||||
this.rowWriter = collaborators.rowWriter
|
||||
this.epochController = collaborators.epochController
|
||||
this.itemAppender = collaborators.itemAppender
|
||||
this.lifecycleBatchAppender = collaborators.lifecycleBatchAppender
|
||||
this.queuedMessages = collaborators.queuedMessages
|
||||
this.restore = collaborators.restore
|
||||
}
|
||||
|
||||
@@ -245,6 +251,8 @@ export class AgentSessionJournal {
|
||||
|
||||
submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()]
|
||||
|
||||
submission = (clientMessageId: string) => this.state.submissions.get(clientMessageId)
|
||||
|
||||
pendingSubmissions = (): AgentJournalSubmission[] =>
|
||||
this.submissions().filter((entry) => entry.dispatchState === 'pending')
|
||||
|
||||
@@ -289,8 +297,8 @@ export class AgentSessionJournal {
|
||||
options: JournalTombstoneInput
|
||||
): Promise<AgentJournalCursor> {
|
||||
const itemId = agentJournalItemKey(identity)
|
||||
return this.enqueue(journalTombstoneRowBuilder(() => this.state, itemId, options.fence)).then(
|
||||
(row) => ({ epoch: row.epoch, sequence: row.seq })
|
||||
return this.rowWriter.append(
|
||||
journalTombstoneRowBuilder(() => this.state, itemId, options.fence)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -303,10 +311,16 @@ export class AgentSessionJournal {
|
||||
* anything, and it doubles as the optimistic user bubble so an accepted echo
|
||||
* reconciles into an existing slot instead of appending a second copy.
|
||||
*/
|
||||
appendSubmission(input: JournalSubmissionInput): Promise<AgentJournalCursor> {
|
||||
return this.enqueue(
|
||||
journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input)
|
||||
).then((row) => ({ epoch: row.epoch, sequence: row.seq }))
|
||||
appendSubmission(
|
||||
input: JournalSubmissionInput,
|
||||
/** Present: this submission is a queued draft's conversion, and the draft's
|
||||
* state transition commits in the SAME transaction — exactly-once consume. */
|
||||
consume?: JournalSubmissionConsume
|
||||
): Promise<AgentJournalCursor> {
|
||||
return this.rowWriter.append(
|
||||
journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input, consume),
|
||||
consume && queuedMessageConsumeHook(this.queuedMessages, input.clientMessageId, consume)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,10 +331,7 @@ export class AgentSessionJournal {
|
||||
* string here would silently give the user a second copy of their own message.
|
||||
*/
|
||||
resolveDispatch(input: ResolveDispatchInput): Promise<AgentJournalCursor> {
|
||||
return this.enqueue(journalDispatchRowBuilder(() => this.state, input)).then((row) => ({
|
||||
epoch: row.epoch,
|
||||
sequence: row.seq
|
||||
}))
|
||||
return this.rowWriter.append(journalDispatchRowBuilder(() => this.state, input))
|
||||
}
|
||||
|
||||
/** Retire unanswered sends after their execution owner ended, without assuming delivery. */
|
||||
@@ -372,13 +383,4 @@ export class AgentSessionJournal {
|
||||
}
|
||||
return this.database
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign the next sequence, make the row durable, and fold it through the
|
||||
* SAME reducer replay uses — all inside one serialized step, so concurrent
|
||||
* callers cannot interleave and mint the same sequence.
|
||||
*/
|
||||
private enqueue(build: (seq: number, ts: number) => JournalRow): Promise<JournalRow> {
|
||||
return this.rowWriter.enqueue(build)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { journalRenderItem } from './journal-render-item'
|
||||
import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import { journalDispatchRowApplies } from './journal-dispatch-settlement'
|
||||
|
||||
export function applyJournalSubmission(
|
||||
state: JournalReducerState,
|
||||
@@ -24,7 +25,12 @@ export function applyJournalSubmission(
|
||||
reason: null,
|
||||
submittedAt: row.ts,
|
||||
resolvedAt: null,
|
||||
...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {})
|
||||
...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}),
|
||||
// A malformed stored link is dropped, never the row.
|
||||
...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0
|
||||
? { queuedMessageId: row.queuedMessageId }
|
||||
: {}),
|
||||
...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {})
|
||||
})
|
||||
const itemId = agentJournalSubmissionKey(row.clientMessageId)
|
||||
// A message handed over later belongs to no turn until its handover names one.
|
||||
@@ -75,15 +81,12 @@ export function acceptSubmissionFromProviderItem(
|
||||
const submission = [...state.submissions.values()].find(
|
||||
(candidate) => agentJournalSubmissionKey(candidate.clientMessageId) === resolvedItemId
|
||||
)
|
||||
if (
|
||||
!submission ||
|
||||
submission.dispatchState === 'accepted' ||
|
||||
submission.dispatchState === 'rejected'
|
||||
) {
|
||||
if (!submission || !journalDispatchRowApplies(submission)) {
|
||||
return
|
||||
}
|
||||
submission.fence = row.fence
|
||||
submission.dispatchState = 'accepted'
|
||||
notePersonTurnAccepted(state, submission)
|
||||
submission.providerItemId = providerItemId
|
||||
submission.reason = null
|
||||
submission.resolvedAt = row.ts
|
||||
@@ -95,3 +98,16 @@ export function acceptSubmissionFromProviderItem(
|
||||
acceptedAt: row.ts
|
||||
})
|
||||
}
|
||||
|
||||
/** A person's turn the provider accepted: the fact the queue's pause is lifted by. */
|
||||
export function notePersonTurnAccepted(
|
||||
state: JournalReducerState,
|
||||
submission: Pick<AgentJournalSubmission, 'origin' | 'acceptedSequence'>
|
||||
): void {
|
||||
if (submission.origin === 'client' && submission.acceptedSequence !== undefined) {
|
||||
state.latestPersonTurnSequence = Math.max(
|
||||
state.latestPersonTurnSequence,
|
||||
submission.acceptedSequence
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// A submission that hands off a queued draft names that draft (`queuedMessageId`),
|
||||
// persisted on its journal row and published on the submission; a direct send
|
||||
// names none. Clients read the link, never a draft id compared with a submission id.
|
||||
|
||||
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 { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-schemas'
|
||||
import type {
|
||||
AgentJournalMessageItem,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { createJournalReducerState, applyJournalRow } from './journal-reducer'
|
||||
import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
import { createTrackedJournalOpener } from './journal-store-test-open'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-q',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'claude',
|
||||
providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null }
|
||||
}
|
||||
const BODY: AgentJournalMessageItem = {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'queued text' }]
|
||||
}
|
||||
|
||||
let root: string
|
||||
let clock = 1_000
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
function open(): Promise<AgentSessionJournal> {
|
||||
return journals.open({
|
||||
identity: IDENTITY,
|
||||
journalDir: root,
|
||||
now: () => ++clock,
|
||||
mintEpoch: () => `epoch-${clock}`
|
||||
})
|
||||
}
|
||||
|
||||
async function handOff(journal: AgentSessionJournal, draftId: string, submissionId: string) {
|
||||
await journal.queuedMessages.insert({
|
||||
messageId: draftId,
|
||||
body: BODY,
|
||||
fingerprint: 'fp',
|
||||
hostInstance: 'p'
|
||||
})
|
||||
await journal.appendSubmission(
|
||||
{ clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 },
|
||||
{ messageId: draftId, expect: 'waiting', settledByOp: null }
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-queued-link-'))
|
||||
clock = 1_000
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('the submission names the queued draft it hands off', () => {
|
||||
it('on every hand-off, and never on a direct send', async () => {
|
||||
const journal = await open()
|
||||
await handOff(journal, 'draft-1', 'handoff-1')
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'direct-1',
|
||||
payloadFingerprint: 'fp-direct',
|
||||
body: BODY,
|
||||
fence: 0
|
||||
})
|
||||
expect(journal.submission('handoff-1')?.queuedMessageId).toBe('draft-1')
|
||||
expect(journal.submission('direct-1')).not.toHaveProperty('queuedMessageId')
|
||||
})
|
||||
|
||||
it('survives a reload, which replays the rows through the reducer', async () => {
|
||||
let journal = await open()
|
||||
await handOff(journal, 'draft-1', 'handoff-1')
|
||||
await journal.close()
|
||||
journal = await open()
|
||||
expect(journal.submission('handoff-1')?.queuedMessageId).toBe('draft-1')
|
||||
expect(
|
||||
journal.snapshot().submissions.find((entry) => entry.clientMessageId === 'handoff-1')
|
||||
).toMatchObject({ queuedMessageId: 'draft-1' })
|
||||
})
|
||||
|
||||
it('refuses a caller naming a different draft than the one it consumes, and writes nothing', async () => {
|
||||
const journal = await open()
|
||||
await journal.queuedMessages.insert({
|
||||
messageId: 'draft-1',
|
||||
body: BODY,
|
||||
fingerprint: 'fp',
|
||||
hostInstance: 'p'
|
||||
})
|
||||
await expect(
|
||||
journal.appendSubmission(
|
||||
{
|
||||
clientMessageId: 'handoff-1',
|
||||
payloadFingerprint: 'fp',
|
||||
body: BODY,
|
||||
fence: 0,
|
||||
queuedMessageId: 'draft-2'
|
||||
},
|
||||
{ messageId: 'draft-1', expect: 'waiting', settledByOp: null }
|
||||
)
|
||||
).rejects.toThrow()
|
||||
expect(journal.submissions()).toHaveLength(0)
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting')
|
||||
})
|
||||
|
||||
it('is published: the wire schema keeps the field rather than stripping it', async () => {
|
||||
const journal = await open()
|
||||
await handOff(journal, 'draft-1', 'handoff-1')
|
||||
const published = AgentJournalSubmissionSchema.parse(journal.submission('handoff-1'))
|
||||
expect(published.queuedMessageId).toBe('draft-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the persisted row', () => {
|
||||
const row: JournalRow = {
|
||||
v: 1,
|
||||
kind: 'submission',
|
||||
epoch: 'epoch-1',
|
||||
seq: 1,
|
||||
fence: 0,
|
||||
ts: 1,
|
||||
clientMessageId: 'handoff-1',
|
||||
payloadFingerprint: 'fp',
|
||||
providerHandle: IDENTITY.providerHandle,
|
||||
body: BODY,
|
||||
queuedMessageId: 'draft-1'
|
||||
}
|
||||
|
||||
it('parses with the key, which a reader that does not know it simply keeps', () => {
|
||||
const parsed = parseJournalRow(serializeJournalRow(row))
|
||||
expect(parsed).toMatchObject({ ok: true, row: { queuedMessageId: 'draft-1' } })
|
||||
})
|
||||
|
||||
it('keeps a row whose stored link is malformed, dropping only the link', () => {
|
||||
const parsed = parseJournalRow(JSON.stringify({ ...row, queuedMessageId: 42 }))
|
||||
if (!parsed.ok) {
|
||||
throw new Error('the row must survive a malformed link')
|
||||
}
|
||||
const state = createJournalReducerState('session-q', 'epoch-1')
|
||||
applyJournalRow(state, parsed.row)
|
||||
expect(state.submissions.get('handoff-1')).not.toHaveProperty('queuedMessageId')
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,11 @@
|
||||
|
||||
export class AgentSessionJournalError extends Error {
|
||||
constructor(
|
||||
readonly code: 'journal_read_only' | 'journal_stale_fence' | 'journal_closed',
|
||||
readonly code:
|
||||
| 'journal_read_only'
|
||||
| 'journal_stale_fence'
|
||||
| 'journal_closed'
|
||||
| 'journal_submission_exists',
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
@@ -34,3 +38,17 @@ export function assertJournalFence(fence: number, highestFence: number): void {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One id, one delivery: a second submission row under an id would reset its
|
||||
* settled answer to pending and hand the message over again. */
|
||||
export function assertSubmissionIdUnused(
|
||||
submissions: ReadonlyMap<string, unknown>,
|
||||
clientMessageId: string
|
||||
): void {
|
||||
if (submissions.has(clientMessageId)) {
|
||||
throw new AgentSessionJournalError(
|
||||
'journal_submission_exists',
|
||||
`a submission ${clientMessageId} is already recorded`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
// Draft bookkeeping never vetoes a journal row: a failing draft transition
|
||||
// rolls back alone and the next open re-derives it, and a draft table an
|
||||
// earlier build created gains the columns this build writes.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentJournalMessageItem,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { journalDatabaseFile } from './journal-paths'
|
||||
import { JournalQueuedMessages } from './journal-queued-messages'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
import { createTrackedJournalOpener } from './journal-store-test-open'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-q',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'claude',
|
||||
providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null }
|
||||
}
|
||||
const REFUSAL = agentSessionFailureWords(
|
||||
agentSessionFailureFact('providerRejected', {
|
||||
detail: { text: 'Claude refused this payload', audience: 'person' }
|
||||
}),
|
||||
{ surface: 'rejection' }
|
||||
)
|
||||
|
||||
const BODY: AgentJournalMessageItem = {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'queued text' }]
|
||||
}
|
||||
|
||||
let root: string
|
||||
let clock = 1_000
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
function open(): Promise<AgentSessionJournal> {
|
||||
return journals.open({
|
||||
identity: IDENTITY,
|
||||
journalDir: root,
|
||||
now: () => ++clock,
|
||||
mintEpoch: () => `epoch-${clock}`
|
||||
})
|
||||
}
|
||||
|
||||
async function queueAndConsume(journal: AgentSessionJournal, messageId: string): Promise<void> {
|
||||
const body: AgentJournalMessageItem = {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text: 'queued text' }]
|
||||
}
|
||||
await journal.queuedMessages.insert({
|
||||
messageId,
|
||||
body,
|
||||
fingerprint: `fp-${messageId}`,
|
||||
hostInstance: 'proc-1'
|
||||
})
|
||||
await journal.appendSubmission(
|
||||
{
|
||||
clientMessageId: `sub-${messageId}`,
|
||||
payloadFingerprint: `fp-${messageId}`,
|
||||
body,
|
||||
fence: 0,
|
||||
handoverRecorded: true
|
||||
},
|
||||
{ messageId, expect: 'waiting', settledByOp: null }
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-queued-bookkeeping-'))
|
||||
clock = 1_000
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('draft bookkeeping inside a journal append', () => {
|
||||
it('a throwing draft transition still commits the rejection row, and the next open recovers the draft', async () => {
|
||||
let journal = await open()
|
||||
await queueAndConsume(journal, 'draft-1')
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementationOnce(() => {
|
||||
throw new Error('table queued_messages has no column named returned_rejection')
|
||||
})
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...REFUSAL,
|
||||
fence: 0
|
||||
})
|
||||
// The journal's own answer stands: Stop, failed starts and refusals depend on it.
|
||||
expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected')
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched')
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[journal-append] row bookkeeping skipped:',
|
||||
expect.objectContaining({ kind: 'dispatch' })
|
||||
)
|
||||
await journal.close()
|
||||
journal = await open()
|
||||
expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected')
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject({
|
||||
state: 'returned',
|
||||
returnedReason: REFUSAL.reason,
|
||||
returnedRejection: { kind: 'providerRejected' }
|
||||
})
|
||||
})
|
||||
|
||||
it('an older draft table without a later column is healed at open, so a refusal returns the card', async () => {
|
||||
const first = await open()
|
||||
await first.close()
|
||||
const db = new Database(journalDatabaseFile(root))
|
||||
db.exec('DROP TABLE queued_messages')
|
||||
// The shape an earlier build of the draft table wrote: no returned_rejection.
|
||||
db.exec(`CREATE TABLE queued_messages (
|
||||
session_id TEXT NOT NULL, message_id TEXT NOT NULL, position INTEGER NOT NULL,
|
||||
body_json TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at INTEGER NOT NULL,
|
||||
host_instance TEXT NOT NULL, state TEXT NOT NULL, hold_reason TEXT,
|
||||
returned_reason TEXT, settled_at INTEGER, settled_by_op TEXT, consumed_as TEXT,
|
||||
PRIMARY KEY (session_id, message_id))`)
|
||||
db.close()
|
||||
const journal = await open()
|
||||
expect(journal.isReadOnly).toBe(false)
|
||||
await queueAndConsume(journal, 'draft-1')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...REFUSAL,
|
||||
fence: 0
|
||||
})
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject({
|
||||
state: 'returned',
|
||||
returnedRejection: { kind: 'providerRejected' }
|
||||
})
|
||||
})
|
||||
|
||||
describe('a failed COMMIT', () => {
|
||||
/** The append's own COMMIT fails once, after its hooks ran. */
|
||||
function failNextCommit() {
|
||||
const exec = Database.prototype.exec
|
||||
let armed = true
|
||||
return vi.spyOn(Database.prototype, 'exec').mockImplementation(function (
|
||||
this: Database,
|
||||
sql: string
|
||||
) {
|
||||
if (armed && sql === 'COMMIT') {
|
||||
armed = false
|
||||
throw new Error('SQLITE_FULL')
|
||||
}
|
||||
return exec.call(this, sql)
|
||||
})
|
||||
}
|
||||
|
||||
async function consumeFailingCommit(journal: AgentSessionJournal): Promise<void> {
|
||||
await journal.queuedMessages.insert({
|
||||
messageId: 'draft-1',
|
||||
body: BODY,
|
||||
fingerprint: 'fp-draft-1',
|
||||
hostInstance: 'proc-1'
|
||||
})
|
||||
expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }])
|
||||
const commit = failNextCommit()
|
||||
try {
|
||||
await expect(
|
||||
journal.appendSubmission(
|
||||
{ clientMessageId: 'sub-draft-1', payloadFingerprint: 'fp', body: BODY, fence: 0 },
|
||||
{ messageId: 'draft-1', expect: 'waiting', settledByOp: null }
|
||||
)
|
||||
).rejects.toThrow('SQLITE_FULL')
|
||||
} finally {
|
||||
commit.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
it('leaves no uncommitted draft state cached: the per-row hook reads drafts only for an echo', async () => {
|
||||
const journal = await open()
|
||||
const list = vi.spyOn(JournalQueuedMessages.prototype, 'list')
|
||||
await consumeFailingCommit(journal)
|
||||
// Once by the test itself before the append; never inside it.
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
list.mockRestore()
|
||||
expect(journal.submissions()).toHaveLength(0)
|
||||
expect(journal.queuedMessages.list()).toMatchObject([
|
||||
{ messageId: 'draft-1', state: 'waiting' }
|
||||
])
|
||||
})
|
||||
|
||||
it('invalidates what any read inside the rolled-back transaction cached', async () => {
|
||||
const journal = await open()
|
||||
// Some other bookkeeping reads the list inside the transaction, after the consume wrote.
|
||||
vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementation(function (
|
||||
this: JournalQueuedMessages
|
||||
) {
|
||||
this.list()
|
||||
})
|
||||
await consumeFailingCommit(journal)
|
||||
expect(journal.queuedMessages.list()).toMatchObject([
|
||||
{ messageId: 'draft-1', state: 'waiting' }
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
// A draft sent back to waiting after a handed-over hand-off was rejected as
|
||||
// never delivered is withdrawn when the provider echoes that message: the
|
||||
// first send reached the agent, so sending it again would repeat it.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentJournalMessageItem,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
|
||||
import { queuedMessageFingerprint } from '../agent-session-wire/structured-agent-session-queued-messages'
|
||||
import { JournalQueuedMessages } from './journal-queued-messages'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
import { createTrackedJournalOpener } from './journal-store-test-open'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-q',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'claude',
|
||||
providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null }
|
||||
}
|
||||
const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), {
|
||||
surface: 'rejection'
|
||||
})
|
||||
|
||||
let root: string
|
||||
let clock = 1_000
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
function message(text: string): AgentJournalMessageItem {
|
||||
return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
function echo(journal: AgentSessionJournal, uuid: string, text: string) {
|
||||
return journal.appendItem({ provider: 'claude', sessionId: 'native-1', uuid }, message(text), {
|
||||
fence: 0,
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
})
|
||||
}
|
||||
|
||||
/** A draft consumed and handed to the agent, then rejected as never delivered (the provider
|
||||
* confirmed a Stop withdrew it): back to waiting. `handedOver: false` rejects it before
|
||||
* hand-over instead, which proves it was never written. */
|
||||
async function withdrawnDraft(
|
||||
text: string,
|
||||
options: { handedOver: boolean } = { handedOver: true }
|
||||
): Promise<AgentSessionJournal> {
|
||||
const journal = await open()
|
||||
await handOffAndReject(journal, text, options)
|
||||
return journal
|
||||
}
|
||||
|
||||
function open(): Promise<AgentSessionJournal> {
|
||||
return journals.open({
|
||||
identity: IDENTITY,
|
||||
journalDir: root,
|
||||
now: () => ++clock,
|
||||
mintEpoch: () => `epoch-${clock}`
|
||||
})
|
||||
}
|
||||
|
||||
async function handOffAndReject(
|
||||
journal: AgentSessionJournal,
|
||||
text: string,
|
||||
options: { handedOver: boolean } = { handedOver: true }
|
||||
): Promise<void> {
|
||||
const body = message(text)
|
||||
const fingerprint = queuedMessageFingerprint(IDENTITY.sessionId, body)
|
||||
await journal.queuedMessages.insert({
|
||||
messageId: 'draft-1',
|
||||
body,
|
||||
fingerprint,
|
||||
hostInstance: 'p'
|
||||
})
|
||||
await journal.appendSubmission(
|
||||
{
|
||||
clientMessageId: 'sub-draft-1',
|
||||
payloadFingerprint: fingerprint,
|
||||
body,
|
||||
fence: 0,
|
||||
handoverRecorded: true
|
||||
},
|
||||
{ messageId: 'draft-1', expect: 'waiting', settledByOp: null }
|
||||
)
|
||||
if (options.handedOver) {
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'pending',
|
||||
fence: 0,
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
})
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...STOP_WITHDRAWAL,
|
||||
fence: 0
|
||||
})
|
||||
} else {
|
||||
await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL)
|
||||
}
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject({
|
||||
state: 'waiting',
|
||||
consumedAs: null
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-queued-echo-'))
|
||||
clock = 1_000
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("a waiting draft whose 'never delivered' claim an echo disproves", () => {
|
||||
it('is withdrawn when the provider echoes the message it was withdrawn from', async () => {
|
||||
const journal = await withdrawnDraft('did it land?')
|
||||
await echo(journal, 'echo-1', 'did it land?')
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject({
|
||||
state: 'withdrawn',
|
||||
settledByOp: null
|
||||
})
|
||||
// The rejection stays terminal: the echo is kept apart, not folded into it.
|
||||
expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected')
|
||||
expect(journal.snapshot().items.map((item) => item.itemId)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('stays waiting when the rejected hand-off never reached the agent: the echo is some other message', async () => {
|
||||
const journal = await withdrawnDraft('did it land?', { handedOver: false })
|
||||
await echo(journal, 'echo-1', 'did it land?')
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting')
|
||||
})
|
||||
|
||||
it('retires the pause in the per-row hook when that echo withdraws the last card it holds back', async () => {
|
||||
const journal = await withdrawnDraft('did it land?')
|
||||
expect(await journal.queuedMessages.recordPause('stopped')).toBe(true)
|
||||
await echo(journal, 'echo-1', 'did it land?')
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn')
|
||||
expect(journal.queuedMessages.pause()).toBeNull()
|
||||
})
|
||||
|
||||
it('stays waiting for an echo of some other text', async () => {
|
||||
const journal = await withdrawnDraft('did it land?')
|
||||
await echo(journal, 'echo-1', 'something else')
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting')
|
||||
})
|
||||
|
||||
it('stays waiting when a live send of the same text claims the echo', async () => {
|
||||
const journal = await withdrawnDraft('did it land?')
|
||||
const body = message('did it land?')
|
||||
await journal.appendSubmission({
|
||||
clientMessageId: 'typed-again',
|
||||
payloadFingerprint: queuedMessageFingerprint(IDENTITY.sessionId, body),
|
||||
body,
|
||||
fence: 0,
|
||||
handoverRecorded: true
|
||||
})
|
||||
await echo(journal, 'echo-1', 'did it land?')
|
||||
expect(journal.submission('typed-again')?.dispatchState).toBe('accepted')
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting')
|
||||
})
|
||||
|
||||
describe('when the per-row hook was skipped', () => {
|
||||
it('the re-derivation withdraws it from the echo already in the journal, and at reopen', async () => {
|
||||
let journal = await withdrawnDraft('did it land?')
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const hook = vi
|
||||
.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction')
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('bookkeeping failed')
|
||||
})
|
||||
try {
|
||||
await echo(journal, 'echo-1', 'did it land?')
|
||||
} finally {
|
||||
hook.mockRestore()
|
||||
warn.mockRestore()
|
||||
}
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting')
|
||||
expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(true)
|
||||
// The drain's heal, before the draft could send again.
|
||||
await journal.queuedMessages.settleOwed()
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn')
|
||||
expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false)
|
||||
await journal.close()
|
||||
journal = await open()
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn')
|
||||
})
|
||||
|
||||
it('an unclaimed echo from before the hand-off proves nothing about it', async () => {
|
||||
const journal = await open()
|
||||
await echo(journal, 'typed-in-the-agent', 'did it land?')
|
||||
await handOffAndReject(journal, 'did it land?')
|
||||
expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false)
|
||||
await journal.queuedMessages.settleOwed()
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
// A draft sent back to waiting rests on its submission's "never delivered"
|
||||
// claim. The provider echoing that message proves the claim wrong: the first
|
||||
// delivery happened. The reducer keeps such an echo apart (a rejected
|
||||
// submission may not claim it), so it is read here, from the row itself.
|
||||
|
||||
import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types'
|
||||
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
|
||||
import {
|
||||
isProviderUserMessageEcho,
|
||||
journalEchoClaimant,
|
||||
type JournalReducerState
|
||||
} from './journal-reducer'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import type { QueuedMessageRow } from './queued-message-table'
|
||||
|
||||
/** The waiting draft this appended row proves was delivered, or null. Called
|
||||
* before the row applies, for every row, so a row that is no new provider
|
||||
* echo of a user message returns before anything else is read. */
|
||||
export function draftDeliveredByEcho(
|
||||
state: JournalReducerState,
|
||||
drafts: () => readonly QueuedMessageRow[],
|
||||
row: JournalRow
|
||||
): string | null {
|
||||
const echoes = appendedItems(row).filter(
|
||||
(item) =>
|
||||
isProviderUserMessageEcho(item.itemId, item.body) &&
|
||||
!state.items.has(item.itemId) &&
|
||||
!state.aliases.has(item.itemId) &&
|
||||
journalEchoClaimant(state, item.itemId, item.body) === null
|
||||
)
|
||||
if (echoes.length === 0) {
|
||||
return null
|
||||
}
|
||||
const spent = spentWaitingDrafts(state, drafts())
|
||||
for (const item of echoes) {
|
||||
const delivered = spent.find((draft) => echoProvesDelivered(state, draft, item.body, row.seq))
|
||||
if (delivered) {
|
||||
return delivered.draft.messageId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The re-derivation behind that per-row hook, which is bookkeeping and may be
|
||||
* skipped: waiting drafts an echo ALREADY in the journal proves delivered. An
|
||||
* unclaimed echo is the item still stored under its provider id — a claimed one
|
||||
* was folded into its submission's item. Reads every item, so callers run it
|
||||
* only where a draft is about to send, never per streamed row.
|
||||
*/
|
||||
export function draftsDeliveredByAppliedEcho(
|
||||
state: JournalReducerState,
|
||||
drafts: readonly QueuedMessageRow[]
|
||||
): string[] {
|
||||
const spent = spentWaitingDrafts(state, drafts)
|
||||
if (spent.length === 0) {
|
||||
return []
|
||||
}
|
||||
const delivered = new Set<string>()
|
||||
for (const item of state.items.values()) {
|
||||
if (!isProviderUserMessageEcho(item.itemId, item.body)) {
|
||||
continue
|
||||
}
|
||||
for (const candidate of spent) {
|
||||
if (echoProvesDelivered(state, candidate, item.body, item.sequence)) {
|
||||
delivered.add(candidate.draft.messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...delivered]
|
||||
}
|
||||
|
||||
type SpentDraft = { draft: QueuedMessageRow; since: number }
|
||||
|
||||
/** Waiting drafts some hand-off of which was handed over, then rejected as never delivered;
|
||||
* `since` is the earliest such hand-off's row. A hand-off rejected before hand-over is
|
||||
* provably unwritten: an echo matching it is some other message, and must not delete the card. */
|
||||
function spentWaitingDrafts(
|
||||
state: JournalReducerState,
|
||||
drafts: readonly QueuedMessageRow[]
|
||||
): SpentDraft[] {
|
||||
const since = new Map<string, number>()
|
||||
for (const submission of state.submissions.values()) {
|
||||
if (
|
||||
submission.queuedMessageId !== undefined &&
|
||||
submission.dispatchState === 'rejected' &&
|
||||
submission.handedOverAt !== undefined
|
||||
) {
|
||||
const sequence = submission.acceptedSequence ?? 0
|
||||
const earliest = since.get(submission.queuedMessageId)
|
||||
since.set(submission.queuedMessageId, Math.min(earliest ?? sequence, sequence))
|
||||
}
|
||||
}
|
||||
return drafts.flatMap((draft) => {
|
||||
const from = since.get(draft.messageId)
|
||||
return draft.state === 'waiting' && from !== undefined ? [{ draft, since: from }] : []
|
||||
})
|
||||
}
|
||||
|
||||
/** The one predicate both paths share: an unclaimed echo appended after a disproved hand-off,
|
||||
* carrying the draft's own payload. */
|
||||
function echoProvesDelivered(
|
||||
state: JournalReducerState,
|
||||
spent: SpentDraft,
|
||||
body: AgentJournalItemBody,
|
||||
sequence: number
|
||||
): boolean {
|
||||
return (
|
||||
sequence > spent.since &&
|
||||
structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId: state.sessionId,
|
||||
fields: { body }
|
||||
}) === spent.draft.fingerprint
|
||||
)
|
||||
}
|
||||
|
||||
function appendedItems(row: JournalRow): { itemId: string; body: AgentJournalItemBody }[] {
|
||||
if (row.kind === 'item') {
|
||||
return [{ itemId: row.itemId, body: row.body }]
|
||||
}
|
||||
if (row.kind === 'lifecycle-batch') {
|
||||
return row.mutations.flatMap((mutation) =>
|
||||
mutation.kind === 'item' ? [{ itemId: mutation.itemId, body: mutation.body }] : []
|
||||
)
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Per-draft holds: what keeps one card from auto-sending, stored on its row. A
|
||||
// Stop or a restart pauses the whole queue instead (`queued-message-pause-table.ts`,
|
||||
// and the host instance each row records); a per-draft hold is only a
|
||||
// conversion that failed, which an explicit Send releases.
|
||||
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import type { QueuedMessageHoldReason } from './queued-message-table'
|
||||
|
||||
/** Hold waiting drafts from auto-sending. The hold retires with the row: consume
|
||||
* and withdraw clear it in their own UPDATE. Returns how many rows it newly reached. */
|
||||
export function holdQueuedMessages(
|
||||
db: Database.Database,
|
||||
input: {
|
||||
sessionId: string
|
||||
messageIds: readonly string[]
|
||||
reason: QueuedMessageHoldReason
|
||||
}
|
||||
): number {
|
||||
const update = db.prepare(
|
||||
`UPDATE queued_messages SET hold_reason = ?
|
||||
WHERE session_id = ? AND message_id = ? AND state = 'waiting'
|
||||
AND (hold_reason IS NULL OR hold_reason <> ?)`
|
||||
)
|
||||
let held = 0
|
||||
for (const messageId of input.messageIds) {
|
||||
held += Number(update.run(input.reason, input.sessionId, messageId, input.reason).changes ?? 0)
|
||||
}
|
||||
return held
|
||||
}
|
||||
|
||||
/** 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
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// The queue-level pause fact: where in the journal the user's last Stop (or a
|
||||
// /clear, which starts its replacement paused) took effect. The pause itself is never stored — it is derived from this fact and
|
||||
// the journal rows after it (a user-requested turn that started ends it); the
|
||||
// fact only records the one event the journal's closed row kinds cannot carry.
|
||||
// An explicit Resume retires it.
|
||||
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
|
||||
export type QueuePauseReason = 'stopped' | 'cleared'
|
||||
|
||||
export type QueuePauseFact = {
|
||||
reason: QueuePauseReason
|
||||
/** The journal position the Stop took effect at: rows after it are later. */
|
||||
epoch: string
|
||||
sequence: number
|
||||
recordedAt: number
|
||||
}
|
||||
|
||||
export function readQueuePause(db: Database.Database, sessionId: string): QueuePauseFact | null {
|
||||
const row: unknown = db
|
||||
.prepare(
|
||||
'SELECT reason, epoch, sequence, recorded_at FROM queued_message_pauses WHERE session_id = ?'
|
||||
)
|
||||
.get(sessionId)
|
||||
if (
|
||||
typeof row !== 'object' ||
|
||||
row === null ||
|
||||
!('reason' in row) ||
|
||||
(row.reason !== 'stopped' && row.reason !== 'cleared') ||
|
||||
!('epoch' in row) ||
|
||||
typeof row.epoch !== 'string' ||
|
||||
!('sequence' in row) ||
|
||||
typeof row.sequence !== 'number' ||
|
||||
!('recorded_at' in row) ||
|
||||
typeof row.recorded_at !== 'number'
|
||||
) {
|
||||
// A reason this build cannot place reads as no pause rather than a wrong one.
|
||||
return null
|
||||
}
|
||||
return {
|
||||
reason: row.reason,
|
||||
epoch: row.epoch,
|
||||
sequence: row.sequence,
|
||||
recordedAt: row.recorded_at
|
||||
}
|
||||
}
|
||||
|
||||
/** The latest Stop replaces an earlier one: only the last interruption decides. */
|
||||
export function recordQueuePause(
|
||||
db: Database.Database,
|
||||
input: { sessionId: string; fact: QueuePauseFact }
|
||||
): void {
|
||||
db.prepare(
|
||||
`INSERT INTO queued_message_pauses (session_id, reason, epoch, sequence, recorded_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (session_id) DO UPDATE SET
|
||||
reason = excluded.reason, epoch = excluded.epoch,
|
||||
sequence = excluded.sequence, recorded_at = excluded.recorded_at`
|
||||
).run(
|
||||
input.sessionId,
|
||||
input.fact.reason,
|
||||
input.fact.epoch,
|
||||
input.fact.sequence,
|
||||
input.fact.recordedAt
|
||||
)
|
||||
}
|
||||
|
||||
/** Compare-and-clear: only the fact the caller judged, so a Stop recorded since stands. */
|
||||
export function clearQueuePause(
|
||||
db: Database.Database,
|
||||
input: { sessionId: string; fact: Pick<QueuePauseFact, 'epoch' | 'sequence'> }
|
||||
): number {
|
||||
return Number(
|
||||
db
|
||||
.prepare(
|
||||
'DELETE FROM queued_message_pauses WHERE session_id = ? AND epoch = ? AND sequence = ?'
|
||||
)
|
||||
.run(input.sessionId, input.fact.epoch, input.fact.sequence).changes ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
type QueueCardState = { state: string; holdReason: string | null }
|
||||
|
||||
/** A card a pause holds back: waiting, with no hold of its own, wherever it sits.
|
||||
* While one exists — or a hand-off still owed a return to waiting
|
||||
* (`queuePauseHoldsBack`) — the pause is KEPT: a Stop records it, and it is
|
||||
* retired only once none remains, so deleting a returned card that blocks such
|
||||
* cards leaves them paused rather than sending them unasked. */
|
||||
export function isPausableQueuedMessage(row: QueueCardState): boolean {
|
||||
return row.state === 'waiting' && row.holdReason === null
|
||||
}
|
||||
|
||||
/** Whether Resume would send anything: a pausable card not behind a returned one,
|
||||
* which blocks everything after it until the user acts, exactly as the drain
|
||||
* reads it. Only then is the kept pause PUBLISHED, so its header never offers a
|
||||
* Resume that sends nothing. */
|
||||
export function hasResumableQueuedMessage(rows: readonly QueueCardState[]): boolean {
|
||||
for (const row of rows) {
|
||||
if (row.state === 'returned') {
|
||||
return false
|
||||
}
|
||||
if (isPausableQueuedMessage(row)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** What a queue pause holds back, judged inside the caller's transaction: a waiting
|
||||
* card with no hold of its own (`isPausableQueuedMessage`, in SQL), or a dispatched
|
||||
* one whose settlement back to waiting is still owed — its hook was skipped, so the
|
||||
* row has not caught up with its rejected submission, which only the journal's
|
||||
* submissions can tell (`owedToWaiting`). */
|
||||
export function queuePauseHoldsBack(
|
||||
db: Database.Database,
|
||||
input: { sessionId: string; owedToWaiting: (consumedRef: string) => boolean }
|
||||
): boolean {
|
||||
const pausable = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM queued_messages
|
||||
WHERE session_id = ? AND state = 'waiting' AND hold_reason IS NULL LIMIT 1`
|
||||
)
|
||||
.get(input.sessionId)
|
||||
if (pausable !== undefined) {
|
||||
return true
|
||||
}
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT consumed_as FROM queued_messages
|
||||
WHERE session_id = ? AND state = 'dispatched' AND consumed_as IS NOT NULL`
|
||||
)
|
||||
.all(input.sessionId)
|
||||
.some(
|
||||
(row) =>
|
||||
typeof row === 'object' &&
|
||||
row !== null &&
|
||||
'consumed_as' in row &&
|
||||
typeof row.consumed_as === 'string' &&
|
||||
input.owedToWaiting(row.consumed_as)
|
||||
)
|
||||
}
|
||||
|
||||
/** A pause is over the cards it paused: once it holds back none (`queuePauseHoldsBack`),
|
||||
* the fact goes too, in the same transaction as the write that took the last one, so
|
||||
* it can never outlive them and catch a card typed long after. */
|
||||
export function retireQueuePauseIfNothingHeld(
|
||||
db: Database.Database,
|
||||
input: { sessionId: string; owedToWaiting: (consumedRef: string) => boolean }
|
||||
): number {
|
||||
// Runs on every appended journal row: with no pause recorded there is nothing to judge.
|
||||
const recorded = db
|
||||
.prepare('SELECT 1 FROM queued_message_pauses WHERE session_id = ?')
|
||||
.get(input.sessionId)
|
||||
if (recorded === undefined || queuePauseHoldsBack(db, input)) {
|
||||
return 0
|
||||
}
|
||||
return Number(
|
||||
db.prepare('DELETE FROM queued_message_pauses WHERE session_id = ?').run(input.sessionId)
|
||||
.changes ?? 0
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Retention for the draft table: which settled rows may be deleted, and when. A row is kept for
|
||||
// as long as anything could still read it — a replayed operation, or a late refusal returning it.
|
||||
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
|
||||
import { listQueuedMessages } from './queued-message-table'
|
||||
|
||||
/** What the loaded journal says about a dispatched draft's consumed submission. */
|
||||
export type QueuedMessageSubmissionVerdict =
|
||||
/** Still owed an answer — a crash leftover the delivery loop will reject; keep the row. */
|
||||
| 'pending'
|
||||
/** `accepted` or `unknown`: terminal and not refused. */
|
||||
| 'terminal-not-refused'
|
||||
/** Absent from the current epoch. */
|
||||
| 'absent'
|
||||
/** Effectively rejected; the open-time repair settles it rather than pruning. */
|
||||
| 'rejected'
|
||||
|
||||
/**
|
||||
* Retention: `withdrawn` tombstones live for the operation-replay window;
|
||||
* a `dispatched` row only once its consumed submission is terminal-and-not-
|
||||
* refused or absent AND the window has passed — never while pending, so a slow
|
||||
* refusal can still return it. `waiting` and `returned` rows are never pruned.
|
||||
*/
|
||||
export function pruneQueuedMessages(
|
||||
db: Database.Database,
|
||||
input: {
|
||||
sessionId: string
|
||||
now: number
|
||||
replayWindowMs: number
|
||||
submissionVerdict: (consumedRef: string) => QueuedMessageSubmissionVerdict
|
||||
}
|
||||
): number {
|
||||
const cutoff = input.now - input.replayWindowMs
|
||||
const tombstones = db
|
||||
.prepare(
|
||||
`DELETE FROM queued_messages
|
||||
WHERE session_id = ? AND state = 'withdrawn' AND settled_at IS NOT NULL AND settled_at < ?`
|
||||
)
|
||||
.run(input.sessionId, cutoff)
|
||||
let pruned = Number(tombstones.changes ?? 0)
|
||||
for (const row of listQueuedMessages(db, input.sessionId)) {
|
||||
if (row.state !== 'dispatched' || row.settledAt === null || row.settledAt >= cutoff) {
|
||||
continue
|
||||
}
|
||||
// A dispatched row always names its hand-off; one that does not has nothing to wait for.
|
||||
const verdict = row.consumedAs === null ? 'absent' : input.submissionVerdict(row.consumedAs)
|
||||
if (verdict === 'terminal-not-refused' || verdict === 'absent') {
|
||||
db.prepare('DELETE FROM queued_messages WHERE session_id = ? AND message_id = ?').run(
|
||||
input.sessionId,
|
||||
row.messageId
|
||||
)
|
||||
pruned += 1
|
||||
}
|
||||
}
|
||||
return pruned
|
||||
}
|
||||
|
||||
/** How retention reads a consumed submission from the loaded journal. Run after the
|
||||
* owed settlements, which already settled every rejected row. */
|
||||
export function retainedSubmissionVerdict(
|
||||
submissions: ReadonlyMap<string, AgentJournalSubmission>
|
||||
): (consumedRef: string) => QueuedMessageSubmissionVerdict {
|
||||
return (consumedRef) => {
|
||||
const submission = submissions.get(consumedRef)
|
||||
if (!submission) {
|
||||
return 'absent'
|
||||
}
|
||||
if (submission.dispatchState === 'accepted' || submission.dispatchState === 'unknown') {
|
||||
return 'terminal-not-refused'
|
||||
}
|
||||
return submission.dispatchState === 'rejected' ? 'rejected' : 'pending'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// The draft table's shape, created and healed at every writable open.
|
||||
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
|
||||
/** Columns a later build added, so an older draft table can gain them in place. */
|
||||
const NULLABLE_COLUMNS: readonly (readonly [name: string, type: string])[] = [
|
||||
['hold_reason', 'TEXT'],
|
||||
['returned_reason', 'TEXT'],
|
||||
['returned_rejection', 'TEXT'],
|
||||
['settled_at', 'INTEGER'],
|
||||
['settled_by_op', 'TEXT'],
|
||||
['consumed_as', 'TEXT']
|
||||
]
|
||||
|
||||
/**
|
||||
* Created idempotently at EVERY writable open, never lazily at first insert, so
|
||||
* no reader hits "no such table". Deliberately no `user_version` bump: an old
|
||||
* build sees stored == supported and stays writable, ignoring the table; a bump
|
||||
* would latch every opened db read-only after a downgrade (`journal-database.ts`).
|
||||
* For the same reason a missing column is added here rather than versioned:
|
||||
* `CREATE TABLE IF NOT EXISTS` never reshapes a table an earlier build created.
|
||||
*/
|
||||
export function ensureQueuedMessagesTable(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS queued_messages (
|
||||
session_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
body_json TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
host_instance TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
hold_reason TEXT,
|
||||
returned_reason TEXT,
|
||||
returned_rejection TEXT,
|
||||
settled_at INTEGER,
|
||||
settled_by_op TEXT,
|
||||
consumed_as TEXT,
|
||||
PRIMARY KEY (session_id, message_id)
|
||||
);
|
||||
`)
|
||||
const names = new Set(
|
||||
db
|
||||
.prepare('PRAGMA table_info(queued_messages)')
|
||||
.all()
|
||||
.flatMap((column) =>
|
||||
typeof column === 'object' &&
|
||||
column !== null &&
|
||||
'name' in column &&
|
||||
typeof column.name === 'string'
|
||||
? [column.name]
|
||||
: []
|
||||
)
|
||||
)
|
||||
for (const [name, type] of NULLABLE_COLUMNS) {
|
||||
if (!names.has(name)) {
|
||||
db.exec(`ALTER TABLE queued_messages ADD COLUMN ${name} ${type}`)
|
||||
}
|
||||
}
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS queued_messages_consumed_as
|
||||
ON queued_messages (session_id, consumed_as) WHERE consumed_as IS NOT NULL;
|
||||
CREATE TABLE IF NOT EXISTS queued_message_pauses (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
reason TEXT NOT NULL,
|
||||
epoch TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
recorded_at INTEGER NOT NULL
|
||||
);
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// What a journal row does to the drafts, and the re-derivation behind it. The
|
||||
// live hook runs inside each append's transaction; it is bookkeeping and may be
|
||||
// skipped, so a dispatched draft whose current submission the journal already
|
||||
// rejected is owed the settlement it would have applied, which the open-time
|
||||
// repair and the drain both apply.
|
||||
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
consumedSubmissionWasRejected,
|
||||
journalDispatchRowNewlyRejects,
|
||||
rejectedDraftSettlement
|
||||
} from './journal-dispatch-settlement'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
import { draftDeliveredByEcho, draftsDeliveredByAppliedEcho } from './queued-message-delivered-echo'
|
||||
import {
|
||||
listQueuedMessages,
|
||||
settleRejectedQueuedMessage,
|
||||
withdrawQueuedMessages,
|
||||
type QueuedMessageRow
|
||||
} from './queued-message-table'
|
||||
|
||||
type Submissions = ReadonlyMap<string, AgentJournalSubmission>
|
||||
|
||||
/** Some dispatched draft still waits on a settlement the journal already decided. */
|
||||
export function queuedMessageSettlementOwed(
|
||||
rows: readonly QueuedMessageRow[],
|
||||
submissions: Submissions
|
||||
): boolean {
|
||||
return rows.some(
|
||||
(row) =>
|
||||
row.state === 'dispatched' &&
|
||||
row.consumedAs !== null &&
|
||||
consumedSubmissionWasRejected(submissions.get(row.consumedAs))
|
||||
)
|
||||
}
|
||||
|
||||
/** A dispatched draft's consumed submission settled so that the draft is owed a
|
||||
* return to waiting (a withdrawal, not a refusal): what a queue pause must still
|
||||
* count as a card it holds back while that settlement is owed. */
|
||||
export function owedBackToWaiting(submissions: Submissions): (consumedRef: string) => boolean {
|
||||
return (consumedRef) => {
|
||||
const submission = submissions.get(consumedRef)
|
||||
return (
|
||||
submission !== undefined &&
|
||||
consumedSubmissionWasRejected(submission) &&
|
||||
rejectedDraftSettlement(submission).state === 'waiting'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies each owed settlement, and withdraws each waiting draft an applied echo proves
|
||||
* delivered (`draftsDeliveredByAppliedEcho`); returns how many drafts changed. */
|
||||
export function settleOwedQueuedMessages(
|
||||
db: Database.Database,
|
||||
input: { sessionId: string; state: JournalReducerState; now: number }
|
||||
): number {
|
||||
const { submissions } = input.state
|
||||
let settled = 0
|
||||
for (const row of listQueuedMessages(db, input.sessionId)) {
|
||||
const consumedRef = row.consumedAs
|
||||
const submission = consumedRef === null ? undefined : submissions.get(consumedRef)
|
||||
if (
|
||||
row.state !== 'dispatched' ||
|
||||
consumedRef === null ||
|
||||
!consumedSubmissionWasRejected(submission)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const changed = settleRejectedQueuedMessage(db, {
|
||||
sessionId: input.sessionId,
|
||||
consumedRef,
|
||||
reason: submission?.reason ?? null,
|
||||
rejection: submission?.rejection,
|
||||
now: input.now
|
||||
})
|
||||
settled += changed ? 1 : 0
|
||||
}
|
||||
const delivered = draftsDeliveredByAppliedEcho(
|
||||
input.state,
|
||||
listQueuedMessages(db, input.sessionId)
|
||||
)
|
||||
if (delivered.length > 0) {
|
||||
settled += withdrawQueuedMessages(db, {
|
||||
sessionId: input.sessionId,
|
||||
messageIds: delivered,
|
||||
settledByOp: null,
|
||||
now: input.now
|
||||
}).length
|
||||
}
|
||||
return settled
|
||||
}
|
||||
|
||||
/**
|
||||
* The live hook, before `row` applies: an echo proving a waiting draft's first
|
||||
* send was delivered withdraws it; a row that NEWLY settles a dispatched
|
||||
* draft's current submission to `rejected` settles the draft — a refusal
|
||||
* returns it, a withdrawal (a Stop, a restart) sends it back to waiting.
|
||||
* Decided by the same function the reducer folds rows through, so a row the
|
||||
* journal's settlement rules ignore never alters a draft. Returns how many
|
||||
* drafts changed.
|
||||
*/
|
||||
export function settleQueuedMessagesForRow(
|
||||
db: Database.Database,
|
||||
input: {
|
||||
sessionId: string
|
||||
state: JournalReducerState
|
||||
/** Read only once the row holds an unclaimed echo: a list read inside the append's
|
||||
* transaction must not be cached under state a rollback could undo. */
|
||||
drafts: () => readonly QueuedMessageRow[]
|
||||
row: JournalRow
|
||||
now: number
|
||||
}
|
||||
): number {
|
||||
const { row } = input
|
||||
let changed = 0
|
||||
const delivered = draftDeliveredByEcho(input.state, input.drafts, row)
|
||||
if (delivered !== null) {
|
||||
// Its first send reached the agent after all; sending it again would repeat it.
|
||||
changed += withdrawQueuedMessages(db, {
|
||||
sessionId: input.sessionId,
|
||||
messageIds: [delivered],
|
||||
settledByOp: null,
|
||||
now: input.now
|
||||
}).length
|
||||
}
|
||||
if (row.kind !== 'dispatch' || row.state !== 'rejected') {
|
||||
return changed
|
||||
}
|
||||
if (!journalDispatchRowNewlyRejects(input.state.submissions.get(row.clientMessageId), row)) {
|
||||
return changed
|
||||
}
|
||||
const settled = settleRejectedQueuedMessage(db, {
|
||||
sessionId: input.sessionId,
|
||||
consumedRef: row.clientMessageId,
|
||||
reason: row.reason,
|
||||
rejection: row.rejection,
|
||||
now: input.now
|
||||
})
|
||||
return changed + (settled ? 1 : 0)
|
||||
}
|
||||
@@ -0,0 +1,835 @@
|
||||
// The draft store's contract: exactly-once consume in one transaction, the
|
||||
// rejected-draft settlement following the journal's EFFECTIVE settlement, retention
|
||||
// that never outruns a slow refusal, and rows that survive epoch replacement.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentJournalMessageItem,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
|
||||
import Database from '../../sqlite/sync-database'
|
||||
import { journalDatabaseFile } from './journal-paths'
|
||||
import {
|
||||
JournalQueuedMessages,
|
||||
QUEUED_MESSAGE_REPLAY_WINDOW_MS,
|
||||
QueuedMessageNotConsumableError
|
||||
} from './journal-queued-messages'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
import { createTrackedJournalOpener } from './journal-store-test-open'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-q',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'claude',
|
||||
providerHandle: { kind: 'claude', sessionId: 'native-1', leafUuid: null }
|
||||
}
|
||||
|
||||
let root: string
|
||||
let clock = 1_000
|
||||
|
||||
function tick(): number {
|
||||
clock += 1
|
||||
return clock
|
||||
}
|
||||
|
||||
function message(text: string): AgentJournalMessageItem {
|
||||
return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
const journals = createTrackedJournalOpener()
|
||||
const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), {
|
||||
surface: 'rejection'
|
||||
})
|
||||
const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), {
|
||||
surface: 'rejection'
|
||||
})
|
||||
const PROVIDER_REFUSAL = agentSessionFailureFact('providerRejected', {
|
||||
detail: { text: 'Claude refused this payload', audience: 'person' }
|
||||
})
|
||||
/** A provider refusal as a settled rejection stores it: its sentence and typed fact. */
|
||||
function refusal(text: string) {
|
||||
return agentSessionFailureWords(
|
||||
agentSessionFailureFact('providerRejected', { detail: { text, audience: 'person' } }),
|
||||
{ surface: 'rejection' }
|
||||
)
|
||||
}
|
||||
|
||||
async function open(): Promise<AgentSessionJournal> {
|
||||
const journal = await journals.open({
|
||||
identity: IDENTITY,
|
||||
journalDir: root,
|
||||
now: tick,
|
||||
mintEpoch: () => `epoch-${clock}`
|
||||
})
|
||||
return journal
|
||||
}
|
||||
|
||||
async function queueDraft(journal: AgentSessionJournal, messageId: string, text = 'queued text') {
|
||||
return journal.queuedMessages.insert({
|
||||
messageId,
|
||||
body: message(text),
|
||||
fingerprint: `fp-${messageId}`,
|
||||
hostInstance: 'proc-1'
|
||||
})
|
||||
}
|
||||
|
||||
async function consumeDraft(
|
||||
journal: AgentSessionJournal,
|
||||
messageId: string,
|
||||
options: { as?: string; expect?: 'waiting' | 'returned'; settledByOp?: string | null } = {}
|
||||
) {
|
||||
const draft = journal.queuedMessages.get(messageId)
|
||||
await journal.appendSubmission(
|
||||
{
|
||||
clientMessageId: options.as ?? `sub-${messageId}`,
|
||||
payloadFingerprint: draft?.fingerprint ?? `fp-${messageId}`,
|
||||
body: draft?.body ?? message('queued text'),
|
||||
fence: 0,
|
||||
handoverRecorded: true
|
||||
},
|
||||
{
|
||||
messageId,
|
||||
expect: options.expect ?? 'waiting',
|
||||
settledByOp: options.settledByOp ?? null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-queued-message-'))
|
||||
clock = 1_000
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('draft rows', () => {
|
||||
it('creates the table at open without bumping user_version, so an old build stays writable', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await journal.close()
|
||||
const db = new Database(journalDatabaseFile(root), { readonly: true })
|
||||
try {
|
||||
const version = Number(db.pragma('user_version', { simple: true }))
|
||||
// An old build compares stored == supported and keeps writing; a bump
|
||||
// would latch it read-only after downgrade.
|
||||
expect(version).toBe(2)
|
||||
const table = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get('queued_messages')
|
||||
expect(table).toBeDefined()
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('opens a database an older build shaped (no drafts table) and creates the table', async () => {
|
||||
const first = await open()
|
||||
await first.appendItem(
|
||||
{ provider: 'orca', clientMessageId: 'seed' },
|
||||
{ kind: 'status', text: 'seed' },
|
||||
{ fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await first.close()
|
||||
const db = new Database(journalDatabaseFile(root))
|
||||
db.exec('DROP TABLE queued_messages')
|
||||
db.close()
|
||||
const journal = await open()
|
||||
expect(journal.isReadOnly).toBe(false)
|
||||
const row = await queueDraft(journal, 'draft-1')
|
||||
expect(row.position).toBe(1)
|
||||
})
|
||||
|
||||
it('assigns monotonic positions and lists in order', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await queueDraft(journal, 'draft-2')
|
||||
const listed = journal.queuedMessages.list()
|
||||
expect(listed.map((row) => [row.messageId, row.position, row.state])).toEqual([
|
||||
['draft-1', 1, 'waiting'],
|
||||
['draft-2', 2, 'waiting']
|
||||
])
|
||||
})
|
||||
|
||||
it('replays an insert under an already-used id instead of duplicating', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
const again = await queueDraft(journal, 'draft-1')
|
||||
expect(again.position).toBe(1)
|
||||
expect(journal.queuedMessages.list()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drafts survive epoch replacement, which deletes only journal rows', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await journal.replaceEpochItems('handle_forked', 0, [])
|
||||
expect(journal.queuedMessages.list().map((row) => row.messageId)).toEqual(['draft-1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('consume', () => {
|
||||
it('converts waiting → dispatched and appends the submission in one transaction', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
const row = journal.queuedMessages.get('draft-1')
|
||||
expect(row?.state).toBe('dispatched')
|
||||
expect(row?.consumedAs).toBe('sub-draft-1')
|
||||
expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1'])
|
||||
})
|
||||
|
||||
it('never hands a draft off under its own id: the submission names it by link, not id equality', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await expect(consumeDraft(journal, 'draft-1', { as: 'draft-1' })).rejects.toBeInstanceOf(
|
||||
QueuedMessageNotConsumableError
|
||||
)
|
||||
expect(journal.submissions()).toHaveLength(0)
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting')
|
||||
})
|
||||
|
||||
it('never records a second submission under an id it already holds, whatever state it settled in', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...refusal('refused'),
|
||||
fence: 0
|
||||
})
|
||||
const cursor = journal.cursor()
|
||||
await expect(
|
||||
journal.appendSubmission({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
payloadFingerprint: 'fp-draft-1',
|
||||
body: message('queued text'),
|
||||
fence: 0,
|
||||
handoverRecorded: true
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'journal_submission_exists' })
|
||||
// The refusal stands: re-appending would reset it to pending and hand it over again.
|
||||
expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected')
|
||||
expect(journal.cursor()).toEqual(cursor)
|
||||
})
|
||||
|
||||
it('a second consume of the same draft fails and appends nothing (exactly-once)', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await expect(consumeDraft(journal, 'draft-1', { as: 'second-id' })).rejects.toBeInstanceOf(
|
||||
QueuedMessageNotConsumableError
|
||||
)
|
||||
expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1'])
|
||||
})
|
||||
|
||||
it('a consume racing a withdraw loses and appends nothing', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await journal.queuedMessages.withdraw({
|
||||
messageIds: ['draft-1'],
|
||||
settledByOp: 'caller\u0000op-1'
|
||||
})
|
||||
await expect(consumeDraft(journal, 'draft-1')).rejects.toBeInstanceOf(
|
||||
QueuedMessageNotConsumableError
|
||||
)
|
||||
expect(journal.submissions()).toHaveLength(0)
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn')
|
||||
})
|
||||
|
||||
it('a failed submission insert rolls the draft transition back', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
const cursor = journal.cursor()
|
||||
// Occupy the next sequence directly so the append's INSERT violates the
|
||||
// primary key inside the transaction, after the draft was transitioned.
|
||||
const db = new Database(journalDatabaseFile(root))
|
||||
db.prepare(
|
||||
'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(IDENTITY.sessionId, cursor.epoch, cursor.sequence + 1, tick(), '{}')
|
||||
db.close()
|
||||
await expect(consumeDraft(journal, 'draft-1')).rejects.toThrow()
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting')
|
||||
})
|
||||
})
|
||||
|
||||
describe('returned transition (D1/N4)', () => {
|
||||
it('a non-withdrawn rejection returns the consumed draft with its reason', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...refusal('Claude refused this payload'),
|
||||
rejection: PROVIDER_REFUSAL,
|
||||
fence: 0
|
||||
})
|
||||
const row = journal.queuedMessages.get('draft-1')
|
||||
expect(row?.state).toBe('returned')
|
||||
expect(row?.returnedReason).toBe(refusal('Claude refused this payload').reason)
|
||||
expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL)
|
||||
})
|
||||
|
||||
it('a late rejection row after acceptance settles nothing and returns no card', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'accepted',
|
||||
providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' },
|
||||
fence: 0
|
||||
})
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...refusal('late duplicate'),
|
||||
fence: 0
|
||||
})
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched')
|
||||
})
|
||||
|
||||
it("a Stop's withdrawal before the agent received it sends the draft back to waiting, held like the rest, and it survives a restart", async () => {
|
||||
let journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
// The Stop's own withdrawal path: the queued (not handed over) submission.
|
||||
expect(await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL)).toEqual(['sub-draft-1'])
|
||||
// Nothing failed: no refusal to show, its position kept, its spent id recorded.
|
||||
const requeued = {
|
||||
state: 'waiting',
|
||||
position: 1,
|
||||
holdReason: null,
|
||||
consumedAs: null,
|
||||
returnedReason: null,
|
||||
returnedRejection: null
|
||||
}
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued)
|
||||
// Atomic with the rejection row: a crash before the Stop answered keeps the text.
|
||||
await journal.close()
|
||||
clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000
|
||||
journal = await open()
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued)
|
||||
})
|
||||
|
||||
it('a draft sent back to waiting hands off again under a fresh id, never a spent one', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL)
|
||||
// The spent id already names a rejected submission: one id, one delivery.
|
||||
await expect(consumeDraft(journal, 'draft-1')).rejects.toMatchObject({
|
||||
code: 'journal_submission_exists'
|
||||
})
|
||||
expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected')
|
||||
await consumeDraft(journal, 'draft-1', { as: 'fresh-1' })
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject({
|
||||
state: 'dispatched',
|
||||
consumedAs: 'fresh-1'
|
||||
})
|
||||
// Both hand-offs name the draft.
|
||||
expect(journal.submission('fresh-1')).toMatchObject({
|
||||
dispatchState: 'pending',
|
||||
queuedMessageId: 'draft-1'
|
||||
})
|
||||
expect(journal.submission('sub-draft-1')?.queuedMessageId).toBe('draft-1')
|
||||
})
|
||||
|
||||
it("a restart between consume and handover sends the draft back to waiting under the restart's pause", async () => {
|
||||
let journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.close()
|
||||
journal = await open()
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched')
|
||||
await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) =>
|
||||
journal.wroteBeforeOpen(submission.acceptedSequence)
|
||||
)
|
||||
// No stored hold: the restart's pause derives from the row's host instance.
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject({
|
||||
state: 'waiting',
|
||||
holdReason: null,
|
||||
hostInstance: 'proc-1',
|
||||
consumedAs: null,
|
||||
returnedReason: null
|
||||
})
|
||||
})
|
||||
|
||||
it('refuse → Send under a fresh id → refuse again returns the card again; a late duplicate of the first refusal never touches the re-send (N4)', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...refusal('first refusal'),
|
||||
rejection: PROVIDER_REFUSAL,
|
||||
fence: 0
|
||||
})
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('returned')
|
||||
// Send on the returned card re-consumes under a fresh submission id.
|
||||
await consumeDraft(journal, 'draft-1', { as: 'resend-1', expect: 'returned' })
|
||||
// The earlier refusal retires with the card: the row now describes the re-send.
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject({
|
||||
state: 'dispatched',
|
||||
consumedAs: 'resend-1',
|
||||
returnedReason: null,
|
||||
returnedRejection: null
|
||||
})
|
||||
// A duplicate resolution of the FIRST submission is ignored by the journal
|
||||
// and must not alter the draft's current relation.
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...refusal('duplicate of first refusal'),
|
||||
fence: 0
|
||||
})
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched')
|
||||
// The re-send's own refusal returns the card, matched via consumed_as.
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'resend-1',
|
||||
state: 'rejected',
|
||||
...refusal('second refusal'),
|
||||
fence: 0
|
||||
})
|
||||
const returned = journal.queuedMessages.get('draft-1')
|
||||
expect(returned?.state).toBe('returned')
|
||||
expect(returned?.returnedReason).toBe(refusal('second refusal').reason)
|
||||
// The first refusal's fact does not outlive it: the pair is the second submission's.
|
||||
expect(returned?.returnedRejection).toEqual(refusal('second refusal').rejection)
|
||||
expect(returned?.returnedRejection).not.toEqual(PROVIDER_REFUSAL)
|
||||
})
|
||||
|
||||
it('a rejection never revives a withdrawn draft', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...refusal('refused'),
|
||||
fence: 0
|
||||
})
|
||||
await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' })
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...refusal('again'),
|
||||
fence: 0
|
||||
})
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn')
|
||||
})
|
||||
|
||||
it('the returned row and its stored reason survive epoch replacement and reopen (B1)', async () => {
|
||||
let journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...refusal('stored refusal'),
|
||||
fence: 0
|
||||
})
|
||||
await journal.replaceEpochItems('handle_forked', 0, [])
|
||||
await journal.close()
|
||||
journal = await open()
|
||||
const row = journal.queuedMessages.get('draft-1')
|
||||
expect(row?.state).toBe('returned')
|
||||
expect(row?.returnedReason).toBe(refusal('stored refusal').reason)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withdraw', () => {
|
||||
it('withdraws waiting and returned rows together into op-stamped receipts', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1', 'first text')
|
||||
await queueDraft(journal, 'draft-2', 'second text')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...refusal('refused'),
|
||||
fence: 0
|
||||
})
|
||||
const withdrawn = await journal.queuedMessages.withdraw({
|
||||
messageIds: ['draft-1', 'draft-2'],
|
||||
settledByOp: 'caller\u0000stop-1'
|
||||
})
|
||||
expect(withdrawn.map((row) => [row.messageId, row.body.blocks])).toEqual([
|
||||
['draft-1', [{ type: 'text', text: 'first text' }]],
|
||||
['draft-2', [{ type: 'text', text: 'second text' }]]
|
||||
])
|
||||
// A lost acknowledgement replays from the tombstones, keyed by the
|
||||
// caller-scoped operation key — never from the ledger.
|
||||
const receipts = journal.queuedMessages.receipts('caller\u0000stop-1')
|
||||
expect(receipts.map((row) => row.messageId)).toEqual(['draft-1', 'draft-2'])
|
||||
// Pending or dispatched rows stay outside the withdrawable set.
|
||||
const second = await journal.queuedMessages.withdraw({
|
||||
messageIds: ['draft-1'],
|
||||
settledByOp: 'caller\u0000stop-2'
|
||||
})
|
||||
expect(second).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('two callers reusing one operation id read only their own receipts', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await journal.queuedMessages.withdraw({
|
||||
messageIds: ['draft-1'],
|
||||
settledByOp: 'caller-a\u0000op-1'
|
||||
})
|
||||
expect(journal.queuedMessages.receipts('caller-b\u0000op-1')).toHaveLength(0)
|
||||
expect(journal.queuedMessages.receipts('caller-a\u0000op-1')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('open-time repair and retention', () => {
|
||||
it('a failed repair is reported and skipped, never failing the open', async () => {
|
||||
const repair = vi
|
||||
.spyOn(JournalQueuedMessages.prototype, 'repairAndPrune')
|
||||
.mockRejectedValueOnce(new Error('SQLITE_FULL'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
try {
|
||||
const journal = await open()
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[journal-open] queued-message repair skipped:',
|
||||
expect.objectContaining({ error: 'SQLITE_FULL' })
|
||||
)
|
||||
await queueDraft(journal, 'draft-1')
|
||||
expect(journal.queuedMessages.list()).toHaveLength(1)
|
||||
} finally {
|
||||
repair.mockRestore()
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns a dispatched row whose loaded submission is effectively rejected (downgrade wrote no hook)', async () => {
|
||||
let journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...refusal('refused while downgraded'),
|
||||
rejection: PROVIDER_REFUSAL,
|
||||
fence: 0
|
||||
})
|
||||
await journal.close()
|
||||
// Simulate the old build having written the rejection with no hook: put the
|
||||
// draft back to dispatched behind the stored fact.
|
||||
const db = new Database(journalDatabaseFile(root))
|
||||
db.prepare(
|
||||
"UPDATE queued_messages SET state = 'dispatched', returned_reason = NULL, returned_rejection = NULL WHERE message_id = ?"
|
||||
).run('draft-1')
|
||||
db.close()
|
||||
journal = await open()
|
||||
const row = journal.queuedMessages.get('draft-1')
|
||||
expect(row?.state).toBe('returned')
|
||||
expect(row?.returnedReason).toBe(refusal('refused while downgraded').reason)
|
||||
expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL)
|
||||
})
|
||||
|
||||
it('sends back to waiting a dispatched row whose submission a Stop withdrew with no hook, never leaving it dispatched', async () => {
|
||||
let journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL)
|
||||
await journal.close()
|
||||
const db = new Database(journalDatabaseFile(root))
|
||||
db.prepare(
|
||||
"UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?"
|
||||
).run('draft-1')
|
||||
db.close()
|
||||
clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000
|
||||
journal = await open()
|
||||
// The same settlement the live hook applies.
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject({
|
||||
state: 'waiting',
|
||||
holdReason: null,
|
||||
consumedAs: null,
|
||||
returnedReason: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a dispatched row while its submission is still pending, even past the window, so a late rejection still settles it (N5)', async () => {
|
||||
let journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.close()
|
||||
// Reopen "25 hours" later: the submission is still queued/pending.
|
||||
clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 60 * 60 * 1000
|
||||
journal = await open()
|
||||
expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched')
|
||||
// The delivery loop's leftover rejection now sends it back to waiting.
|
||||
await journal.rejectQueuedSubmissions(0, HOST_RESTARTED)
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject({
|
||||
state: 'waiting',
|
||||
consumedAs: null
|
||||
})
|
||||
})
|
||||
|
||||
it('prunes accepted and withdrawn rows once the replay window passes, and never waiting or returned rows', async () => {
|
||||
let journal = await open()
|
||||
await queueDraft(journal, 'accepted-1')
|
||||
await consumeDraft(journal, 'accepted-1')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-accepted-1',
|
||||
state: 'accepted',
|
||||
providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' },
|
||||
fence: 0
|
||||
})
|
||||
await queueDraft(journal, 'withdrawn-1')
|
||||
await journal.queuedMessages.withdraw({
|
||||
messageIds: ['withdrawn-1'],
|
||||
settledByOp: 'c\u0000op-w'
|
||||
})
|
||||
await queueDraft(journal, 'waiting-1')
|
||||
await queueDraft(journal, 'returned-1')
|
||||
await consumeDraft(journal, 'returned-1')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-returned-1',
|
||||
state: 'rejected',
|
||||
...refusal('refused'),
|
||||
fence: 0
|
||||
})
|
||||
await journal.close()
|
||||
clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000
|
||||
journal = await open()
|
||||
expect(journal.queuedMessages.list().map((row) => [row.messageId, row.state])).toEqual([
|
||||
['waiting-1', 'waiting'],
|
||||
['returned-1', 'returned']
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps fresh tombstones inside the replay window', async () => {
|
||||
let journal = await open()
|
||||
await queueDraft(journal, 'withdrawn-1')
|
||||
await journal.queuedMessages.withdraw({
|
||||
messageIds: ['withdrawn-1'],
|
||||
settledByOp: 'c\u0000op-w'
|
||||
})
|
||||
await journal.close()
|
||||
clock += 1_000
|
||||
journal = await open()
|
||||
expect(journal.queuedMessages.get('withdrawn-1')?.state).toBe('withdrawn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('holds', () => {
|
||||
it('a hold is stored on the row, survives reopen, and withdraw clears it', async () => {
|
||||
let journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' })
|
||||
expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed')
|
||||
await journal.close()
|
||||
journal = await open()
|
||||
expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed')
|
||||
await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' })
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject({
|
||||
state: 'withdrawn',
|
||||
holdReason: null
|
||||
})
|
||||
})
|
||||
|
||||
it('consume clears the hold in the same transaction (Send-now overrides it)', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' })
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject({
|
||||
state: 'dispatched',
|
||||
holdReason: null
|
||||
})
|
||||
})
|
||||
|
||||
it('a withdraw naming no drafts touches nothing — a Delete race with no rows costs no write', async () => {
|
||||
const journal = await open()
|
||||
await journal.close()
|
||||
await expect(
|
||||
journal.queuedMessages.withdraw({ messageIds: [], settledByOp: 'c\u0000op' })
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('holds reach only waiting rows', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await consumeDraft(journal, 'draft-1')
|
||||
await journal.resolveDispatch({
|
||||
clientMessageId: 'sub-draft-1',
|
||||
state: 'rejected',
|
||||
...refusal('refused'),
|
||||
fence: 0
|
||||
})
|
||||
await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' })
|
||||
expect(journal.queuedMessages.get('draft-1')).toMatchObject({
|
||||
state: 'returned',
|
||||
holdReason: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("the queue's Stop fact", () => {
|
||||
it('records where the Stop took effect, survives reopen, and the latest Stop replaces an earlier one', async () => {
|
||||
let journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await journal.queuedMessages.recordPause('stopped')
|
||||
const first = journal.queuedMessages.pause()
|
||||
expect(first).toMatchObject({ reason: 'stopped', sequence: journal.cursor().sequence })
|
||||
await journal.appendItem(
|
||||
{ provider: 'orca', clientMessageId: 'later' },
|
||||
{ kind: 'status', text: 'later' },
|
||||
{ fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await journal.queuedMessages.recordPause('stopped')
|
||||
expect(journal.queuedMessages.pause()?.sequence).toBe((first?.sequence ?? 0) + 1)
|
||||
await journal.close()
|
||||
journal = await open()
|
||||
expect(journal.queuedMessages.pause()?.sequence).toBe((first?.sequence ?? 0) + 1)
|
||||
// The pause is the queue's, never a row's.
|
||||
expect(journal.queuedMessages.get('draft-1')?.holdReason).toBeNull()
|
||||
})
|
||||
|
||||
it("a /clear's replacement records its pause as 'cleared', read back the same way", async () => {
|
||||
let journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await journal.queuedMessages.recordPause('cleared')
|
||||
await journal.close()
|
||||
journal = await open()
|
||||
expect(journal.queuedMessages.pause()).toMatchObject({ reason: 'cleared' })
|
||||
})
|
||||
|
||||
it('retires in the write that takes the last card it holds back: a hold of its own', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-held')
|
||||
expect(await journal.queuedMessages.recordPause('stopped')).toBe(true)
|
||||
await journal.queuedMessages.hold({ messageIds: ['draft-held'], reason: 'send_failed' })
|
||||
expect(journal.queuedMessages.pause()).toBeNull()
|
||||
})
|
||||
|
||||
it('records nothing over a queue with no card it holds back, judged in its own transaction', async () => {
|
||||
const journal = await open()
|
||||
expect(await journal.queuedMessages.recordPause('stopped')).toBe(false)
|
||||
expect(journal.queuedMessages.pause()).toBeNull()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
expect(await journal.queuedMessages.recordPause('stopped')).toBe(true)
|
||||
expect(journal.queuedMessages.pause()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a hand-off whose return to waiting is still owed (its hook skipped) keeps the pause', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-sent')
|
||||
await queueDraft(journal, 'draft-other')
|
||||
await consumeDraft(journal, 'draft-sent')
|
||||
expect(await journal.queuedMessages.recordPause('stopped')).toBe(true)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const hook = vi
|
||||
.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction')
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('bookkeeping failed')
|
||||
})
|
||||
try {
|
||||
await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL)
|
||||
} finally {
|
||||
hook.mockRestore()
|
||||
warn.mockRestore()
|
||||
}
|
||||
expect(journal.queuedMessages.get('draft-sent')?.state).toBe('dispatched')
|
||||
// The only waiting card goes; the owed one is still a card the pause holds back.
|
||||
await journal.queuedMessages.withdraw({ messageIds: ['draft-other'], settledByOp: 'c\u0000op' })
|
||||
expect(journal.queuedMessages.pause()).not.toBeNull()
|
||||
await journal.queuedMessages.settleOwed()
|
||||
expect(journal.queuedMessages.get('draft-sent')?.state).toBe('waiting')
|
||||
expect(journal.queuedMessages.pause()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('lifting retires only the Stop fact it judged, never one recorded since', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
await journal.queuedMessages.recordPause('stopped')
|
||||
const judged = journal.queuedMessages.pause()
|
||||
await journal.appendItem(
|
||||
{ provider: 'orca', clientMessageId: 'later' },
|
||||
{ kind: 'status', text: 'later' },
|
||||
{ fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await journal.queuedMessages.recordPause('stopped')
|
||||
expect(await journal.queuedMessages.liftPause({ stop: judged, adoptInto: null })).toBe(false)
|
||||
expect(journal.queuedMessages.pause()).not.toBeNull()
|
||||
expect(
|
||||
await journal.queuedMessages.liftPause({
|
||||
stop: journal.queuedMessages.pause(),
|
||||
adoptInto: null
|
||||
})
|
||||
).toBe(true)
|
||||
expect(journal.queuedMessages.pause()).toBeNull()
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
await queueDraft(journal, 'draft-legacy')
|
||||
await queueDraft(journal, 'draft-failed')
|
||||
await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' })
|
||||
const db = new Database(journalDatabaseFile(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.liftPause({ stop: null, adoptInto: 'proc-1' })).toBe(true)
|
||||
expect(
|
||||
journal.queuedMessages.list().map((row) => [row.messageId, row.hostInstance, row.holdReason])
|
||||
).toEqual([
|
||||
['draft-restart', 'proc-1', null],
|
||||
['draft-legacy', 'proc-1', null],
|
||||
['draft-failed', 'proc-1', 'send_failed']
|
||||
])
|
||||
})
|
||||
|
||||
it('a lift with nothing to lift changes nothing and fires no commit notification', async () => {
|
||||
const journal = await open()
|
||||
await queueDraft(journal, 'draft-1')
|
||||
const revision = journal.queuedMessages.revision()
|
||||
expect(await journal.queuedMessages.liftPause({ stop: null, adoptInto: 'proc-1' })).toBe(false)
|
||||
expect(journal.queuedMessages.revision()).toBe(revision)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the commit listener', () => {
|
||||
it('draft-table writes fire it exactly when rows changed, so no caller publishes by hand', async () => {
|
||||
const journal = await open()
|
||||
let commits = 0
|
||||
journal.observeCommits(() => {
|
||||
commits += 1
|
||||
})
|
||||
await queueDraft(journal, 'draft-1')
|
||||
expect(commits).toBe(1)
|
||||
// An idempotent replay changes nothing and stays silent.
|
||||
await queueDraft(journal, 'draft-1')
|
||||
expect(commits).toBe(1)
|
||||
await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' })
|
||||
expect(commits).toBe(2)
|
||||
await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' })
|
||||
expect(commits).toBe(2)
|
||||
await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' })
|
||||
expect(commits).toBe(3)
|
||||
await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op-2' })
|
||||
expect(commits).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
// Host-owned draft rows for messages queued while the main agent is working.
|
||||
//
|
||||
// A queued message is NOT a journal row: it becomes one — an ordinary
|
||||
// submission — only when consume converts it, in the same transaction as the
|
||||
// submission's append. Until then it lives here, `session_id`-keyed so it
|
||||
// survives epoch rollover and replacement (`journal-row-table.ts` deletes only
|
||||
// `journal_rows`). After a refusal its text survives as a `returned` row a
|
||||
// rewind cannot delete; after a withdrawal it waits again.
|
||||
|
||||
import type Database from '../../sqlite/sync-database'
|
||||
import type { UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
|
||||
import { rejectedDraftSettlement } from './journal-dispatch-settlement'
|
||||
import { readStoredRejectionFact } from './journal-dispatch-reducer'
|
||||
|
||||
export type QueuedMessageState = 'waiting' | 'dispatched' | 'returned' | 'withdrawn'
|
||||
|
||||
/** Why ONE waiting draft is held from auto-sending: its conversion failed.
|
||||
* 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 = 'send_failed'
|
||||
|
||||
/** Definitively unsettled: what Stop, /clear, Edit and the budget count, and
|
||||
* what the published list shows. Pending/unknown/accepted deliveries and
|
||||
* tombstones stay outside it. */
|
||||
export function isUnsettledQueuedMessage(row: Pick<QueuedMessageRow, 'state'>): boolean {
|
||||
return row.state === 'waiting' || row.state === 'returned'
|
||||
}
|
||||
|
||||
export type QueuedMessageRow = {
|
||||
sessionId: string
|
||||
messageId: string
|
||||
position: number
|
||||
body: AgentJournalMessageItem
|
||||
fingerprint: string
|
||||
createdAt: number
|
||||
hostInstance: string
|
||||
state: QueuedMessageState
|
||||
/** Non-null holds this one waiting draft from auto-sending; typed values in
|
||||
* `QueuedMessageHoldReason`, unknown strings read as a plain hold. */
|
||||
holdReason: string | null
|
||||
/** A returned card's refusal, mirroring its submission's `reason` and `rejection` pair. */
|
||||
returnedReason: string | null
|
||||
returnedRejection: UnreadAgentSessionFailureFact | null
|
||||
settledAt: number | null
|
||||
/** The operation ledger's caller-scoped key, making settled rows mutation receipts. */
|
||||
settledByOp: string | null
|
||||
/** The submission that last handed it off: set on every dispatched row, kept on a returned
|
||||
* card, cleared when a withdrawal sends it back to waiting. Host-only; the published link is
|
||||
* the submission's `queuedMessageId`. */
|
||||
consumedAs: string | null
|
||||
}
|
||||
|
||||
const COLUMNS =
|
||||
'session_id, message_id, position, body_json, fingerprint, created_at, host_instance, state, hold_reason, returned_reason, returned_rejection, settled_at, settled_by_op, consumed_as'
|
||||
|
||||
export function insertQueuedMessage(
|
||||
db: Database.Database,
|
||||
input: {
|
||||
sessionId: string
|
||||
messageId: string
|
||||
body: AgentJournalMessageItem
|
||||
fingerprint: string
|
||||
hostInstance: string
|
||||
now: number
|
||||
}
|
||||
): QueuedMessageRow {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the statement selects exactly one aliased numeric column; better-sqlite3 types rows as unknown.
|
||||
const highest = db
|
||||
.prepare('SELECT COALESCE(MAX(position), 0) AS p FROM queued_messages WHERE session_id = ?')
|
||||
.get(input.sessionId) as { p?: number } | undefined
|
||||
const position = Number(highest?.p ?? 0) + 1
|
||||
db.prepare(
|
||||
`INSERT INTO queued_messages (${COLUMNS})
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'waiting', NULL, NULL, NULL, NULL, NULL, NULL)`
|
||||
).run(
|
||||
input.sessionId,
|
||||
input.messageId,
|
||||
position,
|
||||
JSON.stringify(input.body),
|
||||
input.fingerprint,
|
||||
input.now,
|
||||
input.hostInstance
|
||||
)
|
||||
return {
|
||||
sessionId: input.sessionId,
|
||||
messageId: input.messageId,
|
||||
position,
|
||||
body: input.body,
|
||||
fingerprint: input.fingerprint,
|
||||
createdAt: input.now,
|
||||
hostInstance: input.hostInstance,
|
||||
state: 'waiting',
|
||||
holdReason: null,
|
||||
returnedReason: null,
|
||||
returnedRejection: null,
|
||||
settledAt: null,
|
||||
settledByOp: null,
|
||||
consumedAs: null
|
||||
}
|
||||
}
|
||||
|
||||
export function listQueuedMessages(db: Database.Database, sessionId: string): QueuedMessageRow[] {
|
||||
return db
|
||||
.prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? ORDER BY position ASC`)
|
||||
.all(sessionId)
|
||||
.flatMap((row) => toStoredRow(row) ?? [])
|
||||
}
|
||||
|
||||
export function getQueuedMessage(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
messageId: string
|
||||
): QueuedMessageRow | null {
|
||||
const row = db
|
||||
.prepare(`SELECT ${COLUMNS} FROM queued_messages WHERE session_id = ? AND message_id = ?`)
|
||||
.get(sessionId, messageId)
|
||||
return row === undefined ? null : toStoredRow(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one waiting→dispatched (or returned→dispatched) transition, always under
|
||||
* a fresh submission id — never the draft's own — so no reader can mistake id
|
||||
* equality for the hand-off link. MUST run inside the caller's transaction — the journal
|
||||
* writer's, between BEGIN IMMEDIATE and COMMIT — so a failed submission append
|
||||
* rolls the consume back and a failed consume rolls the append back. Returns
|
||||
* false when the draft was not in the expected state, in which case the caller
|
||||
* throws to abort the append.
|
||||
*/
|
||||
export function consumeQueuedMessageInTransaction(
|
||||
db: Database.Database,
|
||||
input: {
|
||||
sessionId: string
|
||||
messageId: string
|
||||
expect: 'waiting' | 'returned'
|
||||
/** The fresh submission id; never the draft's own id. */
|
||||
consumedAs: string
|
||||
settledByOp: string | null
|
||||
/** The handing-off process; absent keeps the row's own. */
|
||||
hostInstance?: string
|
||||
now: number
|
||||
}
|
||||
): boolean {
|
||||
if (input.consumedAs === input.messageId) {
|
||||
return false
|
||||
}
|
||||
const changed = db
|
||||
.prepare(
|
||||
`UPDATE queued_messages
|
||||
SET state = 'dispatched', hold_reason = NULL, returned_reason = NULL, returned_rejection = NULL,
|
||||
settled_at = ?, settled_by_op = ?, consumed_as = ?, host_instance = COALESCE(?, host_instance)
|
||||
WHERE session_id = ? AND message_id = ? AND state = ?`
|
||||
)
|
||||
.run(
|
||||
input.now,
|
||||
input.settledByOp,
|
||||
input.consumedAs,
|
||||
input.hostInstance ?? null,
|
||||
input.sessionId,
|
||||
input.messageId,
|
||||
input.expect
|
||||
)
|
||||
return Number(changed.changes ?? 0) === 1
|
||||
}
|
||||
|
||||
/** Compare-and-transition unsettled rows (waiting ∪ returned) to withdrawn
|
||||
* tombstones stamped with the operation's caller-scoped key, kept only so a
|
||||
* replay of the settling operation answers "spent"; null when the host itself
|
||||
* withdrew it. Returns the rows actually transitioned; their text stays in
|
||||
* this database, never on the wire. */
|
||||
export function withdrawQueuedMessages(
|
||||
db: Database.Database,
|
||||
input: {
|
||||
sessionId: string
|
||||
messageIds: readonly string[]
|
||||
settledByOp: string | null
|
||||
now: number
|
||||
}
|
||||
): QueuedMessageRow[] {
|
||||
const withdrawn: QueuedMessageRow[] = []
|
||||
for (const messageId of input.messageIds) {
|
||||
const row = getQueuedMessage(db, input.sessionId, messageId)
|
||||
if (!row || !isUnsettledQueuedMessage(row)) {
|
||||
continue
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE queued_messages
|
||||
SET state = 'withdrawn', hold_reason = NULL, settled_at = ?, settled_by_op = ?
|
||||
WHERE session_id = ? AND message_id = ? AND state IN ('waiting', 'returned')`
|
||||
).run(input.now, input.settledByOp, input.sessionId, messageId)
|
||||
withdrawn.push({
|
||||
...row,
|
||||
state: 'withdrawn',
|
||||
holdReason: null,
|
||||
settledAt: input.now,
|
||||
settledByOp: input.settledByOp
|
||||
})
|
||||
}
|
||||
return withdrawn
|
||||
}
|
||||
|
||||
/**
|
||||
* dispatched → returned, or back to waiting (`rejectedDraftSettlement`),
|
||||
* matched on the draft's CURRENT hand-off (`consumed_as`), so a re-send refused
|
||||
* again still settles while a late duplicate of an earlier refusal matches
|
||||
* nothing. A 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,
|
||||
input: {
|
||||
sessionId: string
|
||||
consumedRef: string
|
||||
reason: string | null
|
||||
rejection: UnreadAgentSessionFailureFact | undefined
|
||||
now: number
|
||||
}
|
||||
): boolean {
|
||||
const settlement = rejectedDraftSettlement({ reason: input.reason, rejection: input.rejection })
|
||||
const changed =
|
||||
settlement.state === 'waiting'
|
||||
? db
|
||||
.prepare(
|
||||
`UPDATE queued_messages
|
||||
SET state = 'waiting', hold_reason = NULL, consumed_as = NULL,
|
||||
returned_reason = NULL, returned_rejection = NULL, settled_at = NULL, settled_by_op = NULL
|
||||
WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?`
|
||||
)
|
||||
.run(input.sessionId, input.consumedRef)
|
||||
: db
|
||||
.prepare(
|
||||
`UPDATE queued_messages
|
||||
SET state = 'returned', returned_reason = ?, returned_rejection = ?, settled_at = ?
|
||||
WHERE session_id = ? AND state = 'dispatched' AND consumed_as = ?`
|
||||
)
|
||||
.run(
|
||||
input.reason,
|
||||
input.rejection ? JSON.stringify(input.rejection) : null,
|
||||
input.now,
|
||||
input.sessionId,
|
||||
input.consumedRef
|
||||
)
|
||||
return Number(changed.changes ?? 0) > 0
|
||||
}
|
||||
|
||||
/** Replay receipts: every row a given caller-scoped operation settled. */
|
||||
export function queuedMessagesSettledByOp(
|
||||
db: Database.Database,
|
||||
sessionId: string,
|
||||
settledByOp: string
|
||||
): QueuedMessageRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT ${COLUMNS} FROM queued_messages
|
||||
WHERE session_id = ? AND settled_by_op = ? ORDER BY position ASC`
|
||||
)
|
||||
.all(sessionId, settledByOp)
|
||||
.flatMap((row) => toStoredRow(row) ?? [])
|
||||
}
|
||||
|
||||
function toStoredRow(row: unknown): QueuedMessageRow | null {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: rows come from this file's own SELECTs, which name exactly these columns; better-sqlite3 types them as unknown.
|
||||
const record = row as {
|
||||
session_id: string
|
||||
message_id: string
|
||||
position: number
|
||||
body_json: string
|
||||
fingerprint: string
|
||||
created_at: number
|
||||
host_instance: string
|
||||
state: string
|
||||
hold_reason: string | null
|
||||
returned_reason: string | null
|
||||
returned_rejection: string | null
|
||||
settled_at: number | null
|
||||
settled_by_op: string | null
|
||||
consumed_as: string | null
|
||||
}
|
||||
let body: AgentJournalMessageItem
|
||||
try {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: body_json is written only by insertQueuedMessage from a schema-validated AgentJournalMessageItem.
|
||||
body = JSON.parse(record.body_json) as AgentJournalMessageItem
|
||||
} catch {
|
||||
// Our own writer stringified it; an unreadable body is corruption, and a
|
||||
// row we cannot re-materialize must not masquerade as an empty message.
|
||||
return null
|
||||
}
|
||||
const state = record.state
|
||||
if (
|
||||
state !== 'waiting' &&
|
||||
state !== 'dispatched' &&
|
||||
state !== 'returned' &&
|
||||
state !== 'withdrawn'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
sessionId: record.session_id,
|
||||
messageId: record.message_id,
|
||||
position: record.position,
|
||||
body,
|
||||
fingerprint: record.fingerprint,
|
||||
createdAt: record.created_at,
|
||||
hostInstance: record.host_instance,
|
||||
state,
|
||||
holdReason: record.hold_reason,
|
||||
returnedReason: record.returned_reason,
|
||||
returnedRejection: storedRejection(record.returned_rejection),
|
||||
settledAt: record.settled_at,
|
||||
settledByOp: record.settled_by_op,
|
||||
consumedAs: record.consumed_as
|
||||
}
|
||||
}
|
||||
|
||||
function storedRejection(json: string | null): UnreadAgentSessionFailureFact | null {
|
||||
if (json === null) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return readStoredRejectionFact(JSON.parse(json)) ?? null
|
||||
} catch {
|
||||
// The refusal stays readable from `returned_reason`; a bad fact must not lose the card.
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// One subscriber's catch-up: page it forward to the journal head, or hand it
|
||||
// the empty caught-up frame its per-emit fields still owe it. Split from the
|
||||
// subscriber registry so the registry stays the bookkeeping and this stays the
|
||||
// paging policy.
|
||||
|
||||
import {
|
||||
AGENT_SESSION_HISTORY_MAX_LIMIT,
|
||||
type AgentSessionBackgroundTaskState,
|
||||
type AgentSessionSlashCommand,
|
||||
type AgentSessionSubscribeEvent,
|
||||
type AgentSessionTurnActivity
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { sameJournalCursor } from '../agent-session-journal/journal-cursor'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { emptyAgentSessionBatch } from './agent-session-empty-batch'
|
||||
import { createAgentSessionCatchUpReader } from './agent-session-history-page'
|
||||
import {
|
||||
subscriberQueuedMessagesChanged,
|
||||
type SubscriberFieldHooks
|
||||
} from './agent-session-subscriber-frame-fields'
|
||||
import type { Subscriber } from './structured-agent-session-subscribers'
|
||||
|
||||
export type SubscriberDeliveryPort = {
|
||||
hooks: SubscriberFieldHooks & {
|
||||
readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined
|
||||
}
|
||||
emit: (
|
||||
subscriber: Subscriber,
|
||||
event: AgentSessionSubscribeEvent,
|
||||
options?: { withholdQueued?: boolean }
|
||||
) => void
|
||||
isActive: (subscriber: Subscriber) => boolean
|
||||
activity: (sessionId: string) => AgentSessionTurnActivity | null
|
||||
}
|
||||
|
||||
export function deliverToSubscriber(
|
||||
port: SubscriberDeliveryPort,
|
||||
input: {
|
||||
subscriber: Subscriber
|
||||
journal: AgentSessionJournal
|
||||
hostNow: number
|
||||
emitCheckpoint: boolean
|
||||
backgroundTasks?: AgentSessionBackgroundTaskState | null | undefined
|
||||
activity?: AgentSessionTurnActivity | null | undefined
|
||||
}
|
||||
): void {
|
||||
const { subscriber, journal, hostNow, emitCheckpoint, backgroundTasks, activity } = input
|
||||
const checkpointActivity = emitCheckpoint ? port.activity(subscriber.sessionId) : undefined
|
||||
const publishedActivity = activity !== undefined ? activity : checkpointActivity
|
||||
const shared = {
|
||||
hostNow,
|
||||
...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
|
||||
...(publishedActivity !== undefined ? { activity: publishedActivity } : {})
|
||||
}
|
||||
// Caught up, so there are no rows to read: every publish behind a commit's own delivery.
|
||||
if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) {
|
||||
emitCaughtUp(port, subscriber, emitCheckpoint, shared)
|
||||
return
|
||||
}
|
||||
const readPage = createAgentSessionCatchUpReader(journal)
|
||||
while (true) {
|
||||
const result = readPage({
|
||||
sessionId: subscriber.sessionId,
|
||||
direction: 'after',
|
||||
cursor: subscriber.cursor,
|
||||
limit: AGENT_SESSION_HISTORY_MAX_LIMIT
|
||||
})
|
||||
if (!result.ok) {
|
||||
const page = { ...result.page, fence: subscriber.fence }
|
||||
port.emit(subscriber, {
|
||||
type: 'reset',
|
||||
sessionId: subscriber.sessionId,
|
||||
reset: result.reset,
|
||||
page,
|
||||
fence: subscriber.fence,
|
||||
...shared
|
||||
})
|
||||
subscriber.cursor = page.liveCursor ?? page.window.nextCursor
|
||||
return
|
||||
}
|
||||
const page = result.page
|
||||
const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence
|
||||
if (!advanced) {
|
||||
emitCaughtUp(port, subscriber, emitCheckpoint, shared)
|
||||
return
|
||||
}
|
||||
port.emit(
|
||||
subscriber,
|
||||
{
|
||||
type: 'batch',
|
||||
sessionId: subscriber.sessionId,
|
||||
batch: {
|
||||
cursor: page.window.nextCursor,
|
||||
items: page.items,
|
||||
removedItemIds: page.removedItemIds,
|
||||
submissions: page.submissions
|
||||
},
|
||||
fence: subscriber.fence,
|
||||
...shared
|
||||
},
|
||||
// On a multi-page catch-up the draft list rides only the final page, or a
|
||||
// consumed card would vanish pages before its bubble arrives.
|
||||
{ withholdQueued: page.hasNewer }
|
||||
)
|
||||
subscriber.cursor = page.window.nextCursor
|
||||
if (!page.hasNewer || !port.isActive(subscriber)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitCaughtUp(
|
||||
port: SubscriberDeliveryPort,
|
||||
subscriber: Subscriber,
|
||||
emitCheckpoint: boolean,
|
||||
shared: {
|
||||
hostNow: number
|
||||
backgroundTasks?: AgentSessionBackgroundTaskState | null
|
||||
activity?: AgentSessionTurnActivity | null
|
||||
}
|
||||
): void {
|
||||
const commandsChanged =
|
||||
port.hooks.readCommands !== undefined &&
|
||||
(port.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands
|
||||
const queuedChanged = subscriberQueuedMessagesChanged(port.hooks, subscriber)
|
||||
if (emitCheckpoint || shared.activity !== undefined || commandsChanged || queuedChanged) {
|
||||
port.emit(subscriber, {
|
||||
type: 'batch',
|
||||
sessionId: subscriber.sessionId,
|
||||
batch: emptyAgentSessionBatch(subscriber.cursor),
|
||||
fence: subscriber.fence,
|
||||
...shared
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Which per-emit fields ride one subscriber frame: the provider command catalog
|
||||
// and the queue publication (the draft list with the queue's pause). Both are
|
||||
// identity-deduplicated against the LAST VALUE SENT — never advanced on a frame
|
||||
// that withheld the field, or the final replacement would be suppressed — and
|
||||
// both attach whole to hydrating frames.
|
||||
|
||||
import type {
|
||||
AgentSessionSlashCommand,
|
||||
AgentSessionSubscribeEvent
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { QueuePublication } from './structured-agent-session-queued-publication'
|
||||
|
||||
export type SubscriberFieldState = {
|
||||
sessionId: string
|
||||
commands?: AgentSessionSlashCommand[] | null
|
||||
/** The last queue publication actually SENT. */
|
||||
queuePublication?: QueuePublication
|
||||
}
|
||||
|
||||
export type SubscriberFieldHooks = {
|
||||
readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined
|
||||
readQueuePublication?: (sessionId: string) => QueuePublication | undefined
|
||||
}
|
||||
|
||||
export type SubscriberFrame = {
|
||||
frame: AgentSessionSubscribeEvent
|
||||
commands: AgentSessionSlashCommand[] | null
|
||||
attachedQueued: boolean
|
||||
queued: QueuePublication | undefined
|
||||
}
|
||||
|
||||
/** Builds the frame to emit; the caller stores the returned refs only after the
|
||||
* emit succeeded, so a dropped subscriber never advances its dedup state. */
|
||||
export function buildSubscriberFrame(
|
||||
hooks: SubscriberFieldHooks,
|
||||
subscriber: SubscriberFieldState,
|
||||
event: AgentSessionSubscribeEvent,
|
||||
withholdQueued: boolean
|
||||
): SubscriberFrame {
|
||||
const commands = hooks.readCommands?.(subscriber.sessionId) ?? null
|
||||
const includeCommands =
|
||||
hooks.readCommands !== undefined &&
|
||||
event.type !== 'end' &&
|
||||
(event.type !== 'batch' || commands !== subscriber.commands)
|
||||
// Withheld on intermediate catch-up pages (the caller says so), attached to
|
||||
// every hydrating frame, and to batches only when the list changed.
|
||||
const queued = withholdQueued ? undefined : hooks.readQueuePublication?.(subscriber.sessionId)
|
||||
const attachedQueued =
|
||||
queued !== undefined &&
|
||||
event.type !== 'end' &&
|
||||
(event.type !== 'batch' || queued !== subscriber.queuePublication)
|
||||
return {
|
||||
frame: {
|
||||
...event,
|
||||
...(includeCommands ? { commands: commands ?? null } : {}),
|
||||
...(attachedQueued && queued
|
||||
? { queuedMessages: queued.queuedMessages, queuePause: queued.queuePause }
|
||||
: {})
|
||||
},
|
||||
commands,
|
||||
attachedQueued,
|
||||
queued
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a caught-up publish with no rows still owes this subscriber a frame:
|
||||
* draft inserts and pause changes write no journal row, so an unchanged cursor
|
||||
* must still deliver the changed publication. */
|
||||
export function subscriberQueuedMessagesChanged(
|
||||
hooks: SubscriberFieldHooks,
|
||||
subscriber: SubscriberFieldState
|
||||
): boolean {
|
||||
return (
|
||||
hooks.readQueuePublication !== undefined &&
|
||||
hooks.readQueuePublication(subscriber.sessionId) !== subscriber.queuePublication
|
||||
)
|
||||
}
|
||||
+9
-1
@@ -4,6 +4,7 @@ import type {
|
||||
AgentSessionHistoryResult
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result'
|
||||
import { tryReadQueuePublication } from './structured-agent-session-queued-publication'
|
||||
import type {
|
||||
AgentSessionSubscribers,
|
||||
AgentSessionSubscribeInput
|
||||
@@ -29,18 +30,25 @@ export class StructuredAgentSessionBackgroundTaskChannel {
|
||||
) {}
|
||||
|
||||
async history(request: AgentSessionHistoryRequest): Promise<AgentSessionHistoryResult> {
|
||||
const journal = (await this.conversation(request.sessionId)).journal
|
||||
const result = readStructuredAgentSessionHistoryResult({
|
||||
journal: (await this.conversation(request.sessionId)).journal,
|
||||
journal,
|
||||
record: this.deps.store.getRecord(request.sessionId),
|
||||
request
|
||||
})
|
||||
const backgroundTasks = this.state(request.sessionId)
|
||||
const queue = tryReadQueuePublication(journal)
|
||||
const hostNow = this.deps.now?.() ?? Date.now()
|
||||
return {
|
||||
...result,
|
||||
page: {
|
||||
...result.page,
|
||||
hostNow,
|
||||
// A stale history answer never replaces newer live subscription state;
|
||||
// the client's reducer keeps live-over-history precedence.
|
||||
...(queue !== undefined
|
||||
? { queuedMessages: queue.queuedMessages, queuePause: queue.queuePause }
|
||||
: {}),
|
||||
...(backgroundTasks !== undefined ? { backgroundTasks } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-ref
|
||||
import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { AgentSessionSubscribers } from './structured-agent-session-subscribers'
|
||||
import { tryReadQueuePublication } from './structured-agent-session-queued-publication'
|
||||
import type {
|
||||
StructuredAgentSessionHostDeps,
|
||||
StructuredAgentSessionHostSession
|
||||
@@ -49,6 +50,8 @@ export class StructuredAgentSessionClientDelivery {
|
||||
this.waitForSendSettlement = this.sendSettlement.wait
|
||||
this.subscribers = new AgentSessionSubscribers({
|
||||
readCommands: (sessionId) => deps().adapter.readCommands?.(sessionId),
|
||||
readQueuePublication: (sessionId) =>
|
||||
tryReadQueuePublication(sessions.get(sessionId)?.journal),
|
||||
onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export class StructuredAgentSessionConversations extends Map<
|
||||
private readonly delivery: {
|
||||
deliver: (sessionId: string, journal: AgentSessionJournal) => void
|
||||
onDeliveryError: (sessionId: string, error: unknown) => void
|
||||
/** A conversation became held: state that waited on it (queued drafts) re-derives. */
|
||||
onOpened?: (sessionId: string) => void
|
||||
now: () => number
|
||||
}
|
||||
) {
|
||||
@@ -49,7 +51,9 @@ export class StructuredAgentSessionConversations extends Map<
|
||||
})
|
||||
})
|
||||
this.activity.set(sessionId, this.delivery.now())
|
||||
return super.set(sessionId, session)
|
||||
const adopted = super.set(sessionId, session)
|
||||
this.delivery.onOpened?.(sessionId)
|
||||
return adopted
|
||||
}
|
||||
|
||||
override delete(sessionId: string): boolean {
|
||||
|
||||
+61
-40
@@ -49,6 +49,8 @@ import {
|
||||
setOptionPlan,
|
||||
type MutationPlan
|
||||
} from './structured-agent-session-mutation-plans'
|
||||
import { runQueueableStructuredAgentSessionSend } from './structured-agent-session-queued-send'
|
||||
import { runStopWithQueuePause } from './structured-agent-session-queued-stop'
|
||||
import type {
|
||||
StructuredAgentSessionCaller,
|
||||
StructuredAgentSessionHostDeps,
|
||||
@@ -75,6 +77,10 @@ export type StructuredAgentSessionMutationContext = {
|
||||
wakeDelivery: (sessionId: string) => void
|
||||
/** Stops the session's provider child, keeping its conversation; inside the caller's serialize. */
|
||||
stopAgent: (sessionId: string) => Promise<void>
|
||||
/** Only for gate inputs living in the RECORD store, which can settle with no
|
||||
* journal commit (a conversation command). Draft-table changes need no call:
|
||||
* the draft store notifies through the journal's own commit listener. */
|
||||
wakeQueuedDrain?: (sessionId: string) => void
|
||||
now: () => number
|
||||
}
|
||||
|
||||
@@ -110,6 +116,13 @@ export function sendStructuredAgentSessionTurn(
|
||||
envelope: AgentSessionMutationEnvelope
|
||||
body: AgentJournalMessageItem
|
||||
retryUnknown?: true
|
||||
delivery?: 'queue-if-active'
|
||||
/** Host-local, set only by the client-facing `agentSession.send` RPC (the
|
||||
* renderer's launch prompt included): recorded as the submission's `client`
|
||||
* origin, whose started turn ends a Stop's or a restart's queue pause.
|
||||
* Orchestration mail, a restart continuation and `agent.launch`'s host-sent
|
||||
* prompt never set it. */
|
||||
userSend?: true
|
||||
beforeRun?: () => void
|
||||
}
|
||||
): Promise<AgentSessionMutationResult<AgentSessionSendResult>> {
|
||||
@@ -120,17 +133,15 @@ export function sendStructuredAgentSessionTurn(
|
||||
params.envelope,
|
||||
{
|
||||
...plan,
|
||||
run: async (ctx) => {
|
||||
const blocked = structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId))
|
||||
if (blocked) {
|
||||
return blocked
|
||||
}
|
||||
const accepted = await plan.run(ctx)
|
||||
if (accepted.ok) {
|
||||
context.wakeDelivery(ctx.sessionId)
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
run: (ctx) =>
|
||||
runQueueableStructuredAgentSessionSend(
|
||||
context,
|
||||
ctx,
|
||||
params,
|
||||
async () =>
|
||||
structuredAgentSessionSendBlock(context.deps.store.getRecord(ctx.sessionId)) ??
|
||||
(await plan.run(ctx))
|
||||
)
|
||||
},
|
||||
sendPreparation(context, params.envelope)
|
||||
)
|
||||
@@ -166,36 +177,46 @@ export function cancelStructuredAgentSessionTurn(
|
||||
params.envelope,
|
||||
{
|
||||
...plan,
|
||||
run: async (ctx) => {
|
||||
// Stop withdraws every queued message first, whatever the start or the child is doing.
|
||||
const withdrawn = await ctx.journal.rejectQueuedSubmissions(
|
||||
ctx.fence,
|
||||
agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' })
|
||||
)
|
||||
const named = params.turnId !== undefined ? { turnId: params.turnId } : {}
|
||||
const child = context.sessions.get(ctx.sessionId)?.child
|
||||
if (child?.phase === 'starting') {
|
||||
// A start that may never land is the one thing here Stop has to end; the chat stays.
|
||||
await context.stopAgent(ctx.sessionId)
|
||||
return { ok: true, value: { ...named, cancelled: true } }
|
||||
}
|
||||
// A Stop naming no turn ends nothing more unless the session reads working, by the rule
|
||||
// every session list and the chat's own Stop read it.
|
||||
const inFlight =
|
||||
params.turnId !== undefined ||
|
||||
isStructuredAgentSessionMainAgentWorking(
|
||||
ctx.journal.activeTurnId(),
|
||||
ctx.journal.submissions(),
|
||||
ctx.fence
|
||||
// Stop's queue step, the same for every client: once the Stop takes effect
|
||||
// the queue is paused. The cards stay published; nothing is withdrawn and no
|
||||
// text ever rides the answer.
|
||||
run: (ctx) =>
|
||||
runStopWithQueuePause(ctx, async (tookEffect) => {
|
||||
// Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing.
|
||||
const withdrawn = await ctx.journal.rejectQueuedSubmissions(
|
||||
ctx.fence,
|
||||
agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' })
|
||||
)
|
||||
const record = context.deps.store.getRecord(ctx.sessionId)
|
||||
return child && inFlight
|
||||
? plan.run({
|
||||
...ctx,
|
||||
failureTextContext: structuredAgentSessionFailureWordsContext(record)
|
||||
})
|
||||
: { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } }
|
||||
}
|
||||
const named = params.turnId !== undefined ? { turnId: params.turnId } : {}
|
||||
const child = context.sessions.get(ctx.sessionId)?.child
|
||||
if (child?.phase === 'starting') {
|
||||
// A start that may never land is the one thing here Stop has to end; the chat stays.
|
||||
await tookEffect()
|
||||
await context.stopAgent(ctx.sessionId)
|
||||
return { ok: true, value: { ...named, cancelled: true } }
|
||||
}
|
||||
// A Stop naming no turn ends nothing more unless the session reads working, by the rule
|
||||
// every session list and the chat's own Stop read it.
|
||||
const inFlight =
|
||||
params.turnId !== undefined ||
|
||||
isStructuredAgentSessionMainAgentWorking(
|
||||
ctx.journal.activeTurnId(),
|
||||
ctx.journal.submissions(),
|
||||
ctx.fence
|
||||
)
|
||||
const record = context.deps.store.getRecord(ctx.sessionId)
|
||||
if (!child || !inFlight) {
|
||||
if (withdrawn.length > 0) {
|
||||
await tookEffect()
|
||||
}
|
||||
return { ok: true, value: { ...named, cancelled: withdrawn.length > 0 } }
|
||||
}
|
||||
await tookEffect()
|
||||
return plan.run({
|
||||
...ctx,
|
||||
failureTextContext: structuredAgentSessionFailureWordsContext(record)
|
||||
})
|
||||
})
|
||||
},
|
||||
openForWrite(context, params.envelope)
|
||||
)
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring'
|
||||
import { createStructuredAgentSessionConversationDelivery } from './structured-agent-session-host-delivery'
|
||||
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
|
||||
import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring'
|
||||
export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
|
||||
|
||||
export class StructuredAgentSessionHost {
|
||||
@@ -68,14 +69,18 @@ export class StructuredAgentSessionHost {
|
||||
this.conversationDelivery.afterCommit(sessionId, journal)
|
||||
},
|
||||
onDeliveryError: (sessionId, error) => this.deps.onEventSinkError?.({ sessionId, error }),
|
||||
onOpened: (sessionId) => this.queued.drain.schedule(sessionId),
|
||||
now: () => this.now()
|
||||
})
|
||||
private readonly queued = wireStructuredAgentSessionQueuedMessages(this.sessions, () =>
|
||||
this.mutationContext()
|
||||
)
|
||||
// Every journal publish is activity: the one renewal the idle sweep reads.
|
||||
private readonly clientDelivery = new StructuredAgentSessionClientDelivery(
|
||||
this.sessions,
|
||||
() => this.now(),
|
||||
() => this.deps,
|
||||
(sessionId) => this.sessions.touch(sessionId),
|
||||
(sessionId) => this.queued.onJournalActivity(sessionId),
|
||||
(sessionId) => this.restartResume.onAgentStarted(sessionId)
|
||||
)
|
||||
private readonly subscribers = this.clientDelivery.subscribers
|
||||
@@ -276,12 +281,17 @@ export class StructuredAgentSessionHost {
|
||||
ensureStructuredAgentSessionAgentForOperation(this.attachContext(), sessionId),
|
||||
wakeDelivery: (sessionId) => this.conversationDelivery.loop.wake(sessionId),
|
||||
stopAgent: this.lifetime.stopAgent,
|
||||
wakeQueuedDrain: (sessionId) => this.queued.drain.schedule(sessionId),
|
||||
now: () => this.now()
|
||||
}
|
||||
}
|
||||
|
||||
send = this.conversationCommands.send
|
||||
|
||||
queuedMessageSend = this.queued.queuedMessageSend
|
||||
queuedMessageDelete = this.queued.queuedMessageDelete
|
||||
queuedMessagesResume = this.queued.queuedMessagesResume
|
||||
|
||||
waitForSendSettlement = this.clientDelivery.waitForSendSettlement
|
||||
|
||||
private mutations = structuredAgentSessionMutationDelegates(() => this.mutationContext())
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionConversationCommandResult } from '../../../shared/agent-session-conversation-command'
|
||||
import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons'
|
||||
import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation'
|
||||
import {
|
||||
STRUCTURED_AGENT_SESSION_COMPACT_COMMAND,
|
||||
structuredAgentSessionCompactBody
|
||||
@@ -29,6 +30,17 @@ import {
|
||||
type TurnOutcome
|
||||
} from './structured-agent-session-turns'
|
||||
import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt'
|
||||
import { queuedSendAnswer } from './structured-agent-session-queued-send-answer'
|
||||
|
||||
/** The body-only hash: what the reducer recomputes to alias a provider echo
|
||||
* onto its submission, so the stored value must never include control fields. */
|
||||
function sendBodyFingerprint(sessionId: string, body: AgentJournalMessageItem): string {
|
||||
return structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId,
|
||||
fields: { body }
|
||||
})
|
||||
}
|
||||
|
||||
export type MutationPlan<TValue> = {
|
||||
method: string
|
||||
@@ -48,6 +60,8 @@ export function sendPlan(params: {
|
||||
envelope: AgentSessionMutationEnvelope
|
||||
body: AgentJournalMessageItem
|
||||
retryUnknown?: true
|
||||
delivery?: 'queue-if-active'
|
||||
userSend?: true
|
||||
beforeRun?: () => void
|
||||
}): MutationPlan<AgentSessionSendResult> {
|
||||
// The operation id IS the client message id: one send, one durable row, one
|
||||
@@ -58,8 +72,9 @@ export function sendPlan(params: {
|
||||
operationIdScope: 'global',
|
||||
conversationWrite: true,
|
||||
markUnknownBeforeRun: true,
|
||||
// A control signal is not payload; it cannot alter durable replay.
|
||||
fields: { body: params.body },
|
||||
// `delivery` joins the OPERATION fingerprint only; the submission row keeps
|
||||
// the body-only fingerprint the reducer's echo-aliasing recomputes.
|
||||
fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) },
|
||||
recoverUnknownFromDurableState: true,
|
||||
// `retryUnknown` is a compatibility-only client signal. A recorded send
|
||||
// always replays and never reaches the provider twice.
|
||||
@@ -67,12 +82,19 @@ export function sendPlan(params: {
|
||||
// Asked at acceptance: a send accepted after this one is queued behind it.
|
||||
params.beforeRun?.()
|
||||
return performSend(ctx, {
|
||||
origin: params.userSend ? 'client' : 'host',
|
||||
clientMessageId,
|
||||
payloadFingerprint: params.envelope.payloadFingerprint,
|
||||
payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body),
|
||||
body: params.body
|
||||
})
|
||||
},
|
||||
replay: (ctx, outcome) => {
|
||||
// A send this host queued answers from its draft, then its hand-off; a
|
||||
// withdrawn draft replays as spent — never as missing-submission doubt.
|
||||
const queued = queuedSendAnswer(ctx.journal, clientMessageId)
|
||||
if (queued) {
|
||||
return queued
|
||||
}
|
||||
const submission = ctx.journal
|
||||
.submissions()
|
||||
.find((entry) => entry.clientMessageId === clientMessageId)
|
||||
@@ -122,6 +144,8 @@ export function conversationCommandPlan(params: {
|
||||
run: async (ctx) => {
|
||||
const sent = await performSend(ctx, {
|
||||
clientMessageId,
|
||||
// Only a client asks through the command RPC: the person's own turn.
|
||||
origin: 'client',
|
||||
payloadFingerprint: params.envelope.payloadFingerprint,
|
||||
body: structuredAgentSessionCompactBody()
|
||||
})
|
||||
@@ -168,7 +192,7 @@ export function cancelPlan(params: {
|
||||
...(params.stopChild ? { stopChild: params.stopChild } : {})
|
||||
}),
|
||||
// Interrupting twice would kill a turn the client never asked to stop, so a
|
||||
// replay reports the turn as already handled instead.
|
||||
// replay reports the turn as already handled.
|
||||
replay: () => ({
|
||||
...(params.turnId !== undefined ? { turnId: params.turnId } : {}),
|
||||
cancelled: false
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
// Queued drafts across conversation commands: a /compact is a queued message
|
||||
// and then a turn, so a capable send during it becomes a card that waits for it
|
||||
// like any turn, while Delete and Send-now answer at once; a /clear in flight
|
||||
// admits no draft onto the source it is superseding; and a draft /clear carries
|
||||
// to its replacement is fingerprinted for the replacement, so the provider's
|
||||
// echo folds into its sent bubble.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
createQueuedMessageTestRig,
|
||||
eventually,
|
||||
QUEUED_RIG_CALLER as CALLER,
|
||||
type QueuedMessageTestRig
|
||||
} from './structured-agent-session-queued-message-rig.test-fixture'
|
||||
import {
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
HOST_TEST_THREAD as THREAD,
|
||||
hostTestMessage,
|
||||
hostTestOperationId
|
||||
} from './structured-agent-session-host-test-data'
|
||||
|
||||
let rig: QueuedMessageTestRig
|
||||
|
||||
beforeEach(async () => {
|
||||
rig = await createQueuedMessageTestRig()
|
||||
})
|
||||
|
||||
afterEach(() => rig.dispose())
|
||||
|
||||
const WAIT_REFUSAL = {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_operation_invalid',
|
||||
details: { reason: 'conversationCommandInFlight' },
|
||||
message: 'Wait for the conversation operation to finish.'
|
||||
}
|
||||
}
|
||||
|
||||
function command(name: 'compact' | 'clear') {
|
||||
const fields = { command: name }
|
||||
return rig.host.conversationCommand(CALLER, {
|
||||
envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()),
|
||||
...fields
|
||||
})
|
||||
}
|
||||
|
||||
async function queuedId(
|
||||
result: ReturnType<QueuedMessageTestRig['send']>['result']
|
||||
): Promise<string> {
|
||||
const queued = await result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
return queued.value.queued.messageId
|
||||
}
|
||||
|
||||
describe('a /compact in flight', () => {
|
||||
/** A /compact the provider took: a queued message, then its own turn, running until the
|
||||
* provider ends it (`finishCompact`). */
|
||||
async function compactRunning(): Promise<void> {
|
||||
expect(await command('compact')).toMatchObject({ ok: true, value: { command: 'compact' } })
|
||||
await eventually(() => expect(rig.compact).toHaveBeenCalledOnce())
|
||||
}
|
||||
|
||||
it('turns a capable send into a card, which waits for the compaction and then drains', async () => {
|
||||
await compactRunning()
|
||||
const draftId = await queuedId(rig.send('sent while compacting', 'queue-if-active').result)
|
||||
expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting' }])
|
||||
// The compaction's turn owes work, so the drain waits behind it like any turn.
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
expect(await rig.handoff(draftId)).toBeUndefined()
|
||||
rig.finishCompact()
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
expect(await rig.drafts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('answers Delete at once, and Send-now sends its card behind the compaction like any send', async () => {
|
||||
await compactRunning()
|
||||
const deletedId = await queuedId(rig.send('deleted while compacting', 'queue-if-active').result)
|
||||
const sentId = await queuedId(rig.send('sent now while compacting', 'queue-if-active').result)
|
||||
const hung = new Promise<'hung'>((resolve) => setTimeout(() => resolve('hung'), 2_000))
|
||||
// Nothing holds the session's lane for the compaction's length any more.
|
||||
expect(await Promise.race([rig.deleteQueued(deletedId), hung])).toMatchObject({
|
||||
ok: true,
|
||||
value: { deleted: true }
|
||||
})
|
||||
expect(await Promise.race([rig.sendNow(sentId), hung])).toMatchObject({
|
||||
ok: true,
|
||||
value: { submission: { queuedMessageId: sentId } }
|
||||
})
|
||||
// Accepted, then handed over only once the compaction ends — the order every send keeps.
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
expect(rig.dispatch).toHaveBeenCalledTimes(0)
|
||||
rig.finishCompact()
|
||||
await eventually(async () => expect((await rig.handoff(sentId))?.handedOverAt).toBeDefined())
|
||||
})
|
||||
})
|
||||
|
||||
describe('/clear', () => {
|
||||
it('in flight, refuses a capable send as today: no card lands on the source it supersedes', async () => {
|
||||
const attach = rig.host.attach.bind(rig.host)
|
||||
let release: (() => void) | undefined
|
||||
const released = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const spy = vi.spyOn(rig.host, 'attach').mockImplementationOnce(async (...args) => {
|
||||
await released
|
||||
return attach(...args)
|
||||
})
|
||||
try {
|
||||
const cleared = command('clear')
|
||||
await eventually(() =>
|
||||
expect(rig.store.getRecord(SESSION)?.conversationCommand).toMatchObject({
|
||||
command: 'clear',
|
||||
phase: 'prepared',
|
||||
replacementSessionId: expect.any(String)
|
||||
})
|
||||
)
|
||||
expect(await rig.send('sent while clearing', 'queue-if-active').result).toEqual(WAIT_REFUSAL)
|
||||
release?.()
|
||||
const done = await cleared
|
||||
const replacementId = done.ok ? done.value.replacementSessionId : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
}
|
||||
expect(await rig.drafts()).toHaveLength(0)
|
||||
expect(await rig.drafts(replacementId)).toHaveLength(0)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it("a carried draft sent on the replacement: the provider's echo folds into its one bubble", async () => {
|
||||
const working = await rig.workingSend()
|
||||
const draftId = await queuedId(rig.send('carried text', 'queue-if-active').result)
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'a')
|
||||
const cleared = await command('clear')
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
}
|
||||
expect(await rig.sendNow(draftId, hostTestOperationId(), replacementId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { submission: expect.anything() }
|
||||
})
|
||||
const journal = rig.host.collaboratorsForTests().sessions.get(replacementId)?.journal
|
||||
const sent = journal?.submissions().findLast((entry) => entry.queuedMessageId === draftId)
|
||||
if (!journal || !sent) {
|
||||
throw new Error('expected the carried draft sent on the replacement')
|
||||
}
|
||||
await journal.appendItem(
|
||||
{ provider: 'codex', threadId: THREAD, turnId: 'turn-echo', ordinal: 0 },
|
||||
hostTestMessage('carried text'),
|
||||
{ fence: sent.fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
const snapshot = await rig.host.journalSnapshot(replacementId)
|
||||
const userBubbles = snapshot.items.filter(
|
||||
(item) => item.body.kind === 'message' && item.body.role === 'user'
|
||||
)
|
||||
expect(userBubbles).toHaveLength(1)
|
||||
expect(snapshot.submissions.find((entry) => entry.queuedMessageId === draftId)).toMatchObject({
|
||||
dispatchState: 'accepted'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,303 @@
|
||||
// The one queue gate: admission, the drain step and Send-now consume a single
|
||||
// typed hold decision, so the lists cannot drift — pinned here with a clear in
|
||||
// doubt, Send-now's override set, and the replay-preference rule for a refused
|
||||
// draft.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
import { structuredQueueHold } from './structured-agent-session-queued-messages'
|
||||
import {
|
||||
createQueuedMessageTestRig,
|
||||
eventually,
|
||||
QUEUED_RIG_CALLER as CALLER,
|
||||
type QueuedMessageTestRig
|
||||
} from './structured-agent-session-queued-message-rig.test-fixture'
|
||||
import {
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
hostTestMessage,
|
||||
hostTestOperationId
|
||||
} from './structured-agent-session-host-test-data'
|
||||
|
||||
let rig: QueuedMessageTestRig
|
||||
let host: QueuedMessageTestRig['host']
|
||||
let store: QueuedMessageTestRig['store']
|
||||
|
||||
beforeEach(async () => {
|
||||
rig = await createQueuedMessageTestRig()
|
||||
;({ host, store } = rig)
|
||||
})
|
||||
|
||||
afterEach(() => rig.dispose())
|
||||
|
||||
const envelope: QueuedMessageTestRig['envelope'] = (...args) => rig.envelope(...args)
|
||||
const send: QueuedMessageTestRig['send'] = (...args) => rig.send(...args)
|
||||
const sendNow: QueuedMessageTestRig['sendNow'] = (...args) => rig.sendNow(...args)
|
||||
const submission: QueuedMessageTestRig['submission'] = (...args) => rig.submission(...args)
|
||||
const drafts: QueuedMessageTestRig['drafts'] = () => rig.drafts()
|
||||
const workingSend: QueuedMessageTestRig['workingSend'] = () => rig.workingSend()
|
||||
const settleAccepted: QueuedMessageTestRig['settleAccepted'] = (...args) =>
|
||||
rig.settleAccepted(...args)
|
||||
const settleRejected: QueuedMessageTestRig['settleRejected'] = (...args) =>
|
||||
rig.settleRejected(...args)
|
||||
|
||||
describe('the one queue gate', () => {
|
||||
it('Send-now refuses while a clear is in doubt, with the refusal any send gets', async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('queued behind the clear', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
await store.setConversationCommand(SESSION, 1, {
|
||||
command: 'clear',
|
||||
runtimeFence: 1,
|
||||
operationId: hostTestOperationId(),
|
||||
callerKey: CALLER.callerKey,
|
||||
phase: 'prepared',
|
||||
state: 'unknown'
|
||||
})
|
||||
expect(await sendNow(draftId)).toMatchObject({ ok: false })
|
||||
await settleAccepted(working, 'a')
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
expect(await rig.handoff(draftId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('Send-now refuses on a pending prompt and overrides a running turn', async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('queued mid-turn', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal
|
||||
await journal.appendItem(
|
||||
{ provider: 'orca', clientMessageId: 'prompt-1' },
|
||||
{
|
||||
kind: 'approval',
|
||||
title: 'Allow the tool?',
|
||||
detail: null,
|
||||
options: [],
|
||||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||||
},
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
expect(await sendNow(draftId)).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { message: expect.stringContaining('pending request') }
|
||||
})
|
||||
// Read in place: the gate runs on every admission and drain step.
|
||||
const snapshot = vi.spyOn(journal, 'snapshot')
|
||||
expect(structuredQueueHold({ journal, record: store.getRecord(SESSION), fence: 1 })).toBe(
|
||||
'prompt'
|
||||
)
|
||||
expect(snapshot).not.toHaveBeenCalled()
|
||||
snapshot.mockRestore()
|
||||
await journal.appendItem(
|
||||
{ provider: 'orca', clientMessageId: 'prompt-1' },
|
||||
{
|
||||
kind: 'approval',
|
||||
title: 'Allow the tool?',
|
||||
detail: null,
|
||||
options: [],
|
||||
resolution: {
|
||||
state: 'resolved',
|
||||
selectedOptionId: 'allow',
|
||||
resolvedBy: 'client-1',
|
||||
resolvedAt: 1
|
||||
}
|
||||
},
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
// The turn still runs (`working`), which Send-now alone may override.
|
||||
expect(await submission(working)).toMatchObject({ dispatchState: 'pending' })
|
||||
expect(await sendNow(draftId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { submission: expect.anything() }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('replay preference', () => {
|
||||
it('a replayed send whose draft was refused answers with the returned card, never the rejected submission', async () => {
|
||||
const working = await workingSend()
|
||||
const body = hostTestMessage('refused later')
|
||||
const clientOperationId = hostTestOperationId()
|
||||
const params = {
|
||||
envelope: envelope(
|
||||
{ body, delivery: 'queue-if-active' },
|
||||
'agentSession.send',
|
||||
clientOperationId
|
||||
),
|
||||
body,
|
||||
delivery: 'queue-if-active' as const
|
||||
}
|
||||
expect(await host.send(CALLER, params)).toMatchObject({
|
||||
ok: true,
|
||||
value: { queued: { state: 'waiting' } }
|
||||
})
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined())
|
||||
await settleRejected(await rig.handoffId(clientOperationId), 'provider refused this payload')
|
||||
await eventually(async () =>
|
||||
expect(await drafts()).toMatchObject([{ messageId: clientOperationId, state: 'returned' }])
|
||||
)
|
||||
// The original reply was lost; the retry must agree with the card, or the
|
||||
// same text renders twice — once on a Retry row, once on the card.
|
||||
const replay = await host.send(CALLER, params)
|
||||
expect(replay).toMatchObject({
|
||||
ok: true,
|
||||
replayed: true,
|
||||
value: { queued: { messageId: clientOperationId, state: 'returned' } }
|
||||
})
|
||||
if (replay.ok && 'submission' in replay.value) {
|
||||
throw new Error('replay answered with the rejected submission')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('replay of a deleted card', () => {
|
||||
it('answers withdrawn once its row is pruned, never with the rejected hand-off it came back from', async () => {
|
||||
const working = await workingSend()
|
||||
const body = hostTestMessage('refused, then deleted')
|
||||
const clientOperationId = hostTestOperationId()
|
||||
const params = {
|
||||
envelope: envelope(
|
||||
{ body, delivery: 'queue-if-active' },
|
||||
'agentSession.send',
|
||||
clientOperationId
|
||||
),
|
||||
body,
|
||||
delivery: 'queue-if-active' as const
|
||||
}
|
||||
await host.send(CALLER, params)
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined())
|
||||
await settleRejected(await rig.handoffId(clientOperationId), 'provider refused this payload')
|
||||
await eventually(async () =>
|
||||
expect(await drafts()).toMatchObject([{ messageId: clientOperationId, state: 'returned' }])
|
||||
)
|
||||
expect(await rig.deleteQueued(clientOperationId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { deleted: true }
|
||||
})
|
||||
// Retention later drops the tombstone; the rejected hand-off still names the draft.
|
||||
const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal
|
||||
if (!journal) {
|
||||
throw new Error('expected the conversation open')
|
||||
}
|
||||
vi.spyOn(journal.queuedMessages, 'get').mockReturnValue(null)
|
||||
const replay = await host.send(CALLER, params)
|
||||
expect(replay).toMatchObject({
|
||||
ok: true,
|
||||
replayed: true,
|
||||
value: { queued: { messageId: clientOperationId, state: 'withdrawn' } }
|
||||
})
|
||||
if (replay.ok && 'submission' in replay.value) {
|
||||
throw new Error('replay answered with the rejected hand-off')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('the hand-off link on answers', () => {
|
||||
it('a replayed queued send, once drained, answers with the hand-off that names its draft', async () => {
|
||||
const working = await workingSend()
|
||||
const body = hostTestMessage('drained later')
|
||||
const clientOperationId = hostTestOperationId()
|
||||
const params = {
|
||||
envelope: envelope(
|
||||
{ body, delivery: 'queue-if-active' },
|
||||
'agentSession.send',
|
||||
clientOperationId
|
||||
),
|
||||
body,
|
||||
delivery: 'queue-if-active' as const
|
||||
}
|
||||
await host.send(CALLER, params)
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(async () =>
|
||||
expect((await rig.handoff(clientOperationId))?.queuedMessageId).toBe(clientOperationId)
|
||||
)
|
||||
const replayed = await host.send(CALLER, params)
|
||||
expect(replayed).toMatchObject({
|
||||
ok: true,
|
||||
replayed: true,
|
||||
value: { submission: { queuedMessageId: clientOperationId } }
|
||||
})
|
||||
// Handed off under a fresh id, never the draft's (the send operation's) own.
|
||||
expect(
|
||||
replayed.ok && 'submission' in replayed.value && replayed.value.submission.clientMessageId
|
||||
).not.toBe(clientOperationId)
|
||||
// The first send, direct, names no draft.
|
||||
expect(await submission(working)).not.toHaveProperty('queuedMessageId')
|
||||
})
|
||||
|
||||
it('a queued send asked again after its ledger row is gone answers with its hand-off, never sending twice', async () => {
|
||||
const working = await workingSend()
|
||||
const body = hostTestMessage('asked again')
|
||||
const clientOperationId = hostTestOperationId()
|
||||
const params = {
|
||||
envelope: envelope(
|
||||
{ body, delivery: 'queue-if-active' },
|
||||
'agentSession.send',
|
||||
clientOperationId
|
||||
),
|
||||
body,
|
||||
delivery: 'queue-if-active' as const,
|
||||
userSend: true as const
|
||||
}
|
||||
await host.send(CALLER, params)
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined())
|
||||
const handedOffAs = await rig.handoffId(clientOperationId)
|
||||
// The ledger forgot the id, so the send runs again rather than replaying.
|
||||
const operations = store['transactions'].state.operations
|
||||
for (const [key, row] of operations) {
|
||||
if (row.operationId === clientOperationId) {
|
||||
operations.delete(key)
|
||||
}
|
||||
}
|
||||
const count = (await host.journalSnapshot(SESSION)).submissions.length
|
||||
expect(await host.send(CALLER, params)).toMatchObject({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
value: { submission: { clientMessageId: handedOffAs, queuedMessageId: clientOperationId } }
|
||||
})
|
||||
expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(count)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Send-now rerun', () => {
|
||||
it('a Send whose answer never settled answers again with the submission it made, never re-sending it', async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('refused twice', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
await settleRejected(await rig.handoffId(draftId), 'first refusal')
|
||||
await eventually(async () =>
|
||||
expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }])
|
||||
)
|
||||
const operationId = hostTestOperationId()
|
||||
expect(await sendNow(draftId, operationId)).toMatchObject({ ok: true })
|
||||
await settleRejected(operationId, 'second refusal')
|
||||
await eventually(async () =>
|
||||
expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }])
|
||||
)
|
||||
// The host died before the Send's answer settled: its ledger row is still pending, so it reruns.
|
||||
for (const row of store['transactions'].state.operations.values()) {
|
||||
if (row.operationId === operationId) {
|
||||
row.outcome = { status: 'pending' }
|
||||
}
|
||||
}
|
||||
const count = (await host.journalSnapshot(SESSION)).submissions.length
|
||||
expect(await sendNow(draftId, operationId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { clientMessageId: operationId, submission: { dispatchState: 'rejected' } }
|
||||
})
|
||||
expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(count)
|
||||
expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }])
|
||||
})
|
||||
})
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
// One real-host rig for the mid-turn queue suites: store, journal, adapter
|
||||
// mocks, and the send/stop/draft helpers every suite shares.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, vi, type Mock } 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 type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire'
|
||||
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
|
||||
import { StructuredAgentSessionHost } from './structured-agent-session-host'
|
||||
import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause'
|
||||
import {
|
||||
HOST_TEST_NOW as NOW,
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
HOST_TEST_THREAD as THREAD,
|
||||
hostTestAttachParams,
|
||||
hostTestMessage,
|
||||
hostTestOperationId,
|
||||
resetHostTestOperationIds
|
||||
} from './structured-agent-session-host-test-data'
|
||||
|
||||
export const QUEUED_RIG_CALLER = { callerKey: 'client-1' }
|
||||
|
||||
export function eventually(assertion: () => void | Promise<void>): Promise<void> {
|
||||
return vi.waitFor(assertion, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
export type QueuedMessageTestRig = Awaited<ReturnType<typeof createQueuedMessageTestRig>>
|
||||
|
||||
export async function createQueuedMessageTestRig() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-'))
|
||||
resetHostTestOperationIds()
|
||||
// Admitted: the message is written and unanswered, so the session owes work
|
||||
// until the test settles it.
|
||||
const dispatch: Mock<StructuredAgentSessionAdapter['dispatch']> = vi.fn(async () => ({
|
||||
state: 'admitted' as const
|
||||
}))
|
||||
const awaitStarted: Mock<NonNullable<StructuredAgentSessionAdapter['awaitStarted']>> = vi.fn(
|
||||
async () => undefined
|
||||
)
|
||||
// The provider's receipt of a /compact; its end arrives later, as `finishCompact` writes it.
|
||||
const compact: Mock<NonNullable<StructuredAgentSessionAdapter['compact']>> = vi.fn(async () => ({
|
||||
state: 'accepted' as const,
|
||||
providerIdentity: null
|
||||
}))
|
||||
let events: StructuredAgentSessionEventSink | undefined
|
||||
const store = await AgentSessionRecordStore.open({
|
||||
directory: join(root, 'store'),
|
||||
hostId: 'local'
|
||||
})
|
||||
const host = new StructuredAgentSessionHost({
|
||||
store,
|
||||
adapter: {
|
||||
acquire: async ({ fence, spawnToken, events: sink }) => {
|
||||
events = sink
|
||||
return {
|
||||
process: {
|
||||
hostId: 'local',
|
||||
pid: 4242,
|
||||
processStartTimeMs: 1_700_000_000_000,
|
||||
spawnToken
|
||||
},
|
||||
acquisitionGeneration: 'generation-1',
|
||||
link: {
|
||||
linkId: `link-${fence}`,
|
||||
handle: { provider: 'codex' as const, threadId: THREAD },
|
||||
origin: 'created' as const,
|
||||
mintedAtFence: fence,
|
||||
observedAt: NOW
|
||||
}
|
||||
}
|
||||
},
|
||||
dispatch,
|
||||
awaitStarted,
|
||||
closeSession: vi.fn(async () => true),
|
||||
releaseAcquisition: vi.fn(async () => true),
|
||||
compact,
|
||||
cancelTurn: vi.fn(async () => ({ cancelled: true })),
|
||||
answerPrompt: vi.fn(async () => undefined),
|
||||
setOption: vi.fn(async () => undefined)
|
||||
},
|
||||
journalRoot: root,
|
||||
claimKeyId: 'key-1',
|
||||
mintSpawnToken: () => 'spawn-1',
|
||||
now: () => NOW
|
||||
})
|
||||
expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
|
||||
function envelope(
|
||||
fields: Record<string, unknown>,
|
||||
method: string,
|
||||
clientOperationId: string,
|
||||
sessionId = SESSION
|
||||
) {
|
||||
return {
|
||||
sessionId,
|
||||
clientOperationId,
|
||||
expectedRuntimeFence: 1,
|
||||
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
||||
method,
|
||||
sessionId,
|
||||
fields
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** A client's send, as the `agentSession.send` RPC hands it to the host;
|
||||
* `internal` is a host-side sender (orchestration mail, a restart continuation). */
|
||||
function send(text: string, delivery?: 'queue-if-active', options?: { internal?: true }) {
|
||||
const body = hostTestMessage(text)
|
||||
const clientOperationId = hostTestOperationId()
|
||||
const fields = { body, ...(delivery ? { delivery } : {}) }
|
||||
const result = host.send(QUEUED_RIG_CALLER, {
|
||||
envelope: envelope(fields, 'agentSession.send', clientOperationId),
|
||||
body,
|
||||
...(delivery ? { delivery } : {}),
|
||||
...(options?.internal ? {} : { userSend: true as const })
|
||||
})
|
||||
return { id: clientOperationId, result }
|
||||
}
|
||||
|
||||
function stop(clientOperationId = hostTestOperationId(), caller = QUEUED_RIG_CALLER) {
|
||||
return host.cancel(caller, {
|
||||
envelope: envelope({}, 'agentSession.cancel', clientOperationId)
|
||||
})
|
||||
}
|
||||
|
||||
function sendNow(
|
||||
messageId: string,
|
||||
clientOperationId = hostTestOperationId(),
|
||||
sessionId = SESSION
|
||||
) {
|
||||
return host.queuedMessageSend(QUEUED_RIG_CALLER, {
|
||||
envelope: envelope(
|
||||
{ messageId },
|
||||
'agentSession.queuedMessageSend',
|
||||
clientOperationId,
|
||||
sessionId
|
||||
),
|
||||
messageId
|
||||
})
|
||||
}
|
||||
|
||||
function deleteQueued(messageId: string, clientOperationId = hostTestOperationId()) {
|
||||
return host.queuedMessageDelete(QUEUED_RIG_CALLER, {
|
||||
envelope: envelope({ messageId }, 'agentSession.queuedMessageDelete', clientOperationId),
|
||||
messageId
|
||||
})
|
||||
}
|
||||
|
||||
async function submission(id: string): Promise<AgentJournalSubmission | undefined> {
|
||||
return (await host.journalSnapshot(SESSION)).submissions.find(
|
||||
(entry) => entry.clientMessageId === id
|
||||
)
|
||||
}
|
||||
|
||||
/** The latest submission that hands off this draft, found by its link. */
|
||||
async function handoff(draftId: string): Promise<AgentJournalSubmission | undefined> {
|
||||
return (await host.journalSnapshot(SESSION)).submissions.findLast(
|
||||
(entry) => entry.queuedMessageId === draftId
|
||||
)
|
||||
}
|
||||
|
||||
/** The submission id a draft went out under: never the draft's own id. */
|
||||
async function handoffId(draftId: string): Promise<string> {
|
||||
const sent = await handoff(draftId)
|
||||
if (!sent) {
|
||||
throw new Error(`draft ${draftId} has not been handed off`)
|
||||
}
|
||||
return sent.clientMessageId
|
||||
}
|
||||
|
||||
async function drafts(
|
||||
sessionId = SESSION
|
||||
): Promise<{ messageId: string; state: string; paused?: true }[]> {
|
||||
const page = await host.history({ sessionId, direction: 'tail' })
|
||||
if (!page.ok) {
|
||||
throw new Error('history refused')
|
||||
}
|
||||
return (page.page.queuedMessages ?? []).map(({ messageId, state, paused }) => ({
|
||||
messageId,
|
||||
state,
|
||||
...(paused ? { paused } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
/** A first send that keeps the session working until the test settles it. */
|
||||
async function workingSend(): Promise<string> {
|
||||
const { id, result } = send('work on this')
|
||||
await result
|
||||
await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined())
|
||||
return id
|
||||
}
|
||||
|
||||
async function settleAccepted(id: string, itemId: string): Promise<void> {
|
||||
await host.settleLateDispatch({
|
||||
sessionId: SESSION,
|
||||
clientMessageId: id,
|
||||
providerIdentity: {
|
||||
provider: 'codex',
|
||||
threadId: THREAD,
|
||||
turnId: `turn-${itemId}`,
|
||||
ordinal: 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** A provider refusal, written as the host writes one: the sentence and the typed fact. */
|
||||
async function settleRejected(id: string, providerText: string): Promise<void> {
|
||||
const detail = { text: providerText, audience: 'person' as const }
|
||||
await host.settleLateDispatch({
|
||||
sessionId: SESSION,
|
||||
clientMessageId: id,
|
||||
state: 'rejected',
|
||||
...agentSessionFailureWords(agentSessionFailureFact('providerRejected', { detail }), {
|
||||
surface: 'rejection'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** What the provider's translator writes when a /compact's turn ends, as a success. */
|
||||
function finishCompact(): void {
|
||||
const { command } = compact.mock.calls.at(-1)![0]
|
||||
events!.appendLifecycleBatch!(
|
||||
`turn-completed:${command.clientMessageId}`,
|
||||
[
|
||||
{
|
||||
kind: 'item',
|
||||
identity: command.identity,
|
||||
body: { ...command.running, state: 'completed', outcome: 'success', completedAt: NOW },
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE
|
||||
}
|
||||
],
|
||||
{ lifecycle: true }
|
||||
)
|
||||
}
|
||||
|
||||
/** A host-process restart, as the queue sees it: the conversation closes, and
|
||||
* opens afresh under a new instance id while its rows survive. */
|
||||
async function restartHostProcess(): Promise<void> {
|
||||
await host.close(SESSION)
|
||||
rotateStructuredAgentSessionHostInstanceForTests()
|
||||
}
|
||||
|
||||
/** The queue's published pause: null when it sends on its own. */
|
||||
async function queuePause(sessionId = SESSION): Promise<AgentSessionQueuePause | null> {
|
||||
const page = await host.history({ sessionId, direction: 'tail' })
|
||||
if (!page.ok) {
|
||||
throw new Error('history refused')
|
||||
}
|
||||
return page.page.queuePause ?? null
|
||||
}
|
||||
|
||||
function resume(clientOperationId = hostTestOperationId()) {
|
||||
return host.queuedMessagesResume(QUEUED_RIG_CALLER, {
|
||||
envelope: envelope({}, 'agentSession.queuedMessagesResume', clientOperationId)
|
||||
})
|
||||
}
|
||||
|
||||
async function dispose(): Promise<void> {
|
||||
await host.flushAllStreamedEvents()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
store,
|
||||
host,
|
||||
dispatch,
|
||||
awaitStarted,
|
||||
compact,
|
||||
finishCompact,
|
||||
envelope,
|
||||
send,
|
||||
stop,
|
||||
sendNow,
|
||||
deleteQueued,
|
||||
submission,
|
||||
handoff,
|
||||
handoffId,
|
||||
drafts,
|
||||
workingSend,
|
||||
settleAccepted,
|
||||
settleRejected,
|
||||
restartHostProcess,
|
||||
queuePause,
|
||||
resume,
|
||||
dispose
|
||||
}
|
||||
}
|
||||
+879
@@ -0,0 +1,879 @@
|
||||
// Mid-turn queueing against the real host, store and journal: a capable send
|
||||
// while the session owes work becomes a draft, the drain converts exactly one
|
||||
// draft when the work settles, Stop holds the queue (never withdrawing text)
|
||||
// until a user send starts its turn and lifts the pause, /clear carries the
|
||||
// cards to its replacement session, and a refused conversion comes back as a
|
||||
// returned card while a withdrawn one waits again.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
QUEUED_MESSAGE_PAUSED_SEND_FAILED,
|
||||
type AgentSessionQueuedMessage,
|
||||
type AgentSessionSubscribeEvent
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { ConversationCommandParams } from '../../../shared/rpc-contract/structured-agent-session-params'
|
||||
import { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages'
|
||||
import { rotateStructuredAgentSessionHostInstanceForTests } from './structured-agent-session-queued-pause'
|
||||
import {
|
||||
createQueuedMessageTestRig,
|
||||
eventually,
|
||||
QUEUED_RIG_CALLER as CALLER,
|
||||
type QueuedMessageTestRig
|
||||
} from './structured-agent-session-queued-message-rig.test-fixture'
|
||||
import {
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
hostTestMessage,
|
||||
hostTestOperationId
|
||||
} from './structured-agent-session-host-test-data'
|
||||
|
||||
let rig: QueuedMessageTestRig
|
||||
let host: QueuedMessageTestRig['host']
|
||||
let store: QueuedMessageTestRig['store']
|
||||
let dispatch: QueuedMessageTestRig['dispatch']
|
||||
let awaitStarted: QueuedMessageTestRig['awaitStarted']
|
||||
|
||||
beforeEach(async () => {
|
||||
rig = await createQueuedMessageTestRig()
|
||||
;({ host, store, dispatch, awaitStarted } = rig)
|
||||
})
|
||||
|
||||
afterEach(() => rig.dispose())
|
||||
|
||||
const envelope: QueuedMessageTestRig['envelope'] = (...args) => rig.envelope(...args)
|
||||
const send: QueuedMessageTestRig['send'] = (...args) => rig.send(...args)
|
||||
const stop: QueuedMessageTestRig['stop'] = (...args) => rig.stop(...args)
|
||||
const sendNow: QueuedMessageTestRig['sendNow'] = (...args) => rig.sendNow(...args)
|
||||
const deleteQueued: QueuedMessageTestRig['deleteQueued'] = (...args) => rig.deleteQueued(...args)
|
||||
const drafts: QueuedMessageTestRig['drafts'] = (...args) => rig.drafts(...args)
|
||||
const workingSend: QueuedMessageTestRig['workingSend'] = () => rig.workingSend()
|
||||
const settleAccepted: QueuedMessageTestRig['settleAccepted'] = (...args) =>
|
||||
rig.settleAccepted(...args)
|
||||
const settleRejected: QueuedMessageTestRig['settleRejected'] = (...args) =>
|
||||
rig.settleRejected(...args)
|
||||
|
||||
describe('accept', () => {
|
||||
it('queues a capable send while the session owes work; an ordinary send still dispatches', async () => {
|
||||
await workingSend()
|
||||
const queued = await send('queued behind', 'queue-if-active').result
|
||||
expect(queued).toMatchObject({
|
||||
ok: true,
|
||||
value: { queued: { position: 1, state: 'waiting' } }
|
||||
})
|
||||
// The draft is not a submission, feeds no reducer, and owes no work.
|
||||
expect((await host.journalSnapshot(SESSION)).submissions).toHaveLength(1)
|
||||
expect(await drafts()).toMatchObject([{ state: 'waiting' }])
|
||||
})
|
||||
|
||||
it('replays the same queued answer for the same operation id', async () => {
|
||||
await workingSend()
|
||||
const body = hostTestMessage('queued behind')
|
||||
const clientOperationId = hostTestOperationId()
|
||||
const params = {
|
||||
envelope: envelope(
|
||||
{ body, delivery: 'queue-if-active' },
|
||||
'agentSession.send',
|
||||
clientOperationId
|
||||
),
|
||||
body,
|
||||
delivery: 'queue-if-active' as const
|
||||
}
|
||||
expect(await host.send(CALLER, params)).toMatchObject({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
value: { queued: { state: 'waiting' } }
|
||||
})
|
||||
expect(await host.send(CALLER, params)).toMatchObject({
|
||||
ok: true,
|
||||
replayed: true,
|
||||
value: { queued: { state: 'waiting' } }
|
||||
})
|
||||
expect(await drafts()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('routes an image send to the immediate path even while working (text-only v1)', async () => {
|
||||
await workingSend()
|
||||
const body = {
|
||||
kind: 'message' as const,
|
||||
role: 'user' as const,
|
||||
blocks: [{ type: 'image-ref' as const, path: '/tmp/shot.png' }]
|
||||
}
|
||||
const clientOperationId = hostTestOperationId()
|
||||
const result = await host.send(CALLER, {
|
||||
envelope: envelope(
|
||||
{ body, delivery: 'queue-if-active' },
|
||||
'agentSession.send',
|
||||
clientOperationId
|
||||
),
|
||||
body,
|
||||
delivery: 'queue-if-active'
|
||||
})
|
||||
expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } })
|
||||
expect(await drafts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a send without the delivery field never queues, whatever the session is doing', async () => {
|
||||
await workingSend()
|
||||
const { result } = send('old client send')
|
||||
expect(await result).toMatchObject({ ok: true, value: { submission: expect.anything() } })
|
||||
expect(await drafts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('refuses past the draft-count budget with a readable message', async () => {
|
||||
await workingSend()
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
expect(await send(`draft ${index}`, 'queue-if-active').result).toMatchObject({ ok: true })
|
||||
}
|
||||
expect(await send('one too many', 'queue-if-active').result).toMatchObject({
|
||||
ok: false,
|
||||
refusal: { message: expect.stringContaining('queue is full') }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('drain', () => {
|
||||
it('drains a single draft when the owed work settles, and one of two drafts per settle (A1)', async () => {
|
||||
const working = await workingSend()
|
||||
const first = await send('first queued', 'queue-if-active').result
|
||||
const second = await send('second queued', 'queue-if-active').result
|
||||
if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) {
|
||||
throw new Error('expected queued receipts')
|
||||
}
|
||||
const firstId = first.value.queued.messageId
|
||||
const secondId = second.value.queued.messageId
|
||||
await settleAccepted(working, 'a')
|
||||
// The drain converts the OLDEST actionable draft; the consumed submission
|
||||
// owes work again, which holds the second draft (one message per turn).
|
||||
await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined())
|
||||
expect(await rig.handoff(secondId)).toBeUndefined()
|
||||
expect(await drafts()).toMatchObject([{ messageId: secondId, state: 'waiting' }])
|
||||
await settleAccepted(await rig.handoffId(firstId), 'b')
|
||||
await eventually(async () => expect(await rig.handoff(secondId)).toBeDefined())
|
||||
expect(await drafts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('takes no serialized drain step while the session is working, then drains when the work settles', async () => {
|
||||
const working = await workingSend()
|
||||
const flush = vi.spyOn(host, 'flushStreamedEvents')
|
||||
const queued = await send('waits for the turn', 'queue-if-active').result
|
||||
await send('and another', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
// Every wake during the turn is answered by the pre-check, not a step.
|
||||
expect(flush).not.toHaveBeenCalled()
|
||||
await settleAccepted(working, 'a')
|
||||
const draftId = queued.value.queued.messageId
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
expect(flush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a refused conversion returns the card with its stored reason, and an idle send overtakes a lone returned card (N1)', async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('will be refused', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
await settleRejected(await rig.handoffId(draftId), 'provider refused this payload')
|
||||
await eventually(async () =>
|
||||
expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }])
|
||||
)
|
||||
// Classified like a rejected submission: from the fact, not the sentence.
|
||||
const page = await host.history({ sessionId: SESSION, direction: 'tail' })
|
||||
expect(page.ok && page.page.queuedMessages?.[0]?.returnedRejection).toEqual({
|
||||
kind: 'providerRejected',
|
||||
detail: { text: 'provider refused this payload', audience: 'person' }
|
||||
})
|
||||
// The lone returned card traps nothing: a new capable send goes immediately.
|
||||
const overtaking = await send('sent past the card', 'queue-if-active').result
|
||||
expect(overtaking).toMatchObject({ ok: true, value: { submission: expect.anything() } })
|
||||
// And the card still offers Send: a fresh submission id re-delivers it.
|
||||
const resent = await sendNow(draftId)
|
||||
expect(resent).toMatchObject({ ok: true, value: { submission: expect.anything() } })
|
||||
if (!resent.ok || !('submission' in resent.value)) {
|
||||
throw new Error('expected the submission arm')
|
||||
}
|
||||
expect(resent.value.submission.clientMessageId).not.toBe(draftId)
|
||||
// The answer names the card it sent; clients read that, never id equality.
|
||||
expect(resent.value.submission.queuedMessageId).toBe(draftId)
|
||||
expect(await drafts()).toHaveLength(0)
|
||||
// Refused again: the card returns, matched through its current submission (N4).
|
||||
await settleRejected(resent.value.submission.clientMessageId, 'refused again')
|
||||
await eventually(async () =>
|
||||
expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }])
|
||||
)
|
||||
})
|
||||
|
||||
it('a skipped settlement hook heals on the next drain step, not only at the next open', async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('refused while the hook fails', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const hook = vi
|
||||
.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction')
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('bookkeeping failed')
|
||||
})
|
||||
try {
|
||||
await settleRejected(await rig.handoffId(draftId), 'provider refused this payload')
|
||||
await eventually(async () =>
|
||||
expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }])
|
||||
)
|
||||
} finally {
|
||||
hook.mockRestore()
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('a waiting draft behind a returned card does not drain until the card is acted on (S5)', async () => {
|
||||
const working = await workingSend()
|
||||
const first = await send('to be refused', 'queue-if-active').result
|
||||
const second = await send('waits behind the card', 'queue-if-active').result
|
||||
if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) {
|
||||
throw new Error('expected queued receipts')
|
||||
}
|
||||
await settleAccepted(working, 'a')
|
||||
const firstId = first.value.queued.messageId
|
||||
const secondId = second.value.queued.messageId
|
||||
await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined())
|
||||
await settleRejected(await rig.handoffId(firstId), 'refused')
|
||||
await eventually(async () =>
|
||||
expect(await drafts()).toMatchObject([
|
||||
{ messageId: firstId, state: 'returned' },
|
||||
{ messageId: secondId, state: 'waiting' }
|
||||
])
|
||||
)
|
||||
// Deleting the card unblocks the one behind it.
|
||||
expect(await deleteQueued(firstId)).toMatchObject({ ok: true, value: { deleted: true } })
|
||||
await eventually(async () => expect(await rig.handoff(secondId)).toBeDefined())
|
||||
})
|
||||
})
|
||||
|
||||
describe('held drafts', () => {
|
||||
it('a restart pauses the queue, reason restarted, and it survives a reopen; never auto-sent', async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('written before the restart', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
await rig.restartHostProcess()
|
||||
await settleAccepted(working, 'a')
|
||||
// The queue is paused, not the card: it carries no hold of its own.
|
||||
expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }])
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'restarted' })
|
||||
await host.close(SESSION)
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'restarted' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
expect(await rig.handoff(draftId)).toBeUndefined()
|
||||
expect(await sendNow(draftId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { submission: expect.anything() }
|
||||
})
|
||||
})
|
||||
|
||||
it("a restart's pause also lifts when the user's next send starts its turn, exactly like a Stop's", async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('written before the restart', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
await rig.restartHostProcess()
|
||||
await settleAccepted(working, 'a')
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
expect(await rig.handoff(draftId)).toBeUndefined()
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'restarted' })
|
||||
// The user's send starting its turn lifts it, and adopts the row into this instance.
|
||||
const next = send('user starts a new turn')
|
||||
await next.result
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'restarted' })
|
||||
await settleAccepted(next.id, 'b')
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
})
|
||||
|
||||
it('a failed conversion leaves the draft waiting and paused with its error; Send retries', async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('conversion fails once', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
const original = AgentSessionJournal.prototype.appendSubmission
|
||||
const append = vi
|
||||
.spyOn(AgentSessionJournal.prototype, 'appendSubmission')
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error('disk full')
|
||||
})
|
||||
try {
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(async () =>
|
||||
expect(await drafts()).toMatchObject([
|
||||
{ messageId: draftId, state: 'waiting', paused: true }
|
||||
])
|
||||
)
|
||||
} finally {
|
||||
append.mockRestore()
|
||||
}
|
||||
expect(AgentSessionJournal.prototype.appendSubmission).toBe(original)
|
||||
const page = await host.history({ sessionId: SESSION, direction: 'tail' })
|
||||
// A marker the client localizes, never host-authored copy.
|
||||
expect(page.ok && page.page.queuedMessages?.[0]?.pausedReason).toBe(
|
||||
QUEUED_MESSAGE_PAUSED_SEND_FAILED
|
||||
)
|
||||
// The marker is stored on the row, so a host restart keeps "Couldn't send"
|
||||
// instead of downgrading the card to a plain pause.
|
||||
rotateStructuredAgentSessionHostInstanceForTests()
|
||||
const restarted = await host.history({ sessionId: SESSION, direction: 'tail' })
|
||||
expect(restarted.ok && restarted.page.queuedMessages?.[0]?.pausedReason).toBe(
|
||||
QUEUED_MESSAGE_PAUSED_SEND_FAILED
|
||||
)
|
||||
expect(await rig.handoff(draftId)).toBeUndefined()
|
||||
expect(await sendNow(draftId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { submission: expect.anything() }
|
||||
})
|
||||
expect(await drafts()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stop and Delete', () => {
|
||||
it('Stop pauses the queue — from ANY client — and the cards stay published; no text rides the answer', async () => {
|
||||
await workingSend()
|
||||
const first = await send('first text', 'queue-if-active').result
|
||||
const second = await send('second text', 'queue-if-active').result
|
||||
if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) {
|
||||
throw new Error('expected queued receipts')
|
||||
}
|
||||
// The Stop comes from a DIFFERENT client than the one that typed the
|
||||
// drafts: it must never move their text anywhere.
|
||||
const operationId = hostTestOperationId()
|
||||
const stopped = await stop(operationId, { callerKey: 'client-2' })
|
||||
expect(stopped).toMatchObject({ ok: true, value: { cancelled: true } })
|
||||
expect(stopped.ok && Object.keys(stopped.value).sort()).toEqual(['cancelled'])
|
||||
expect(await drafts()).toEqual([
|
||||
{ messageId: first.value.queued.messageId, state: 'waiting' },
|
||||
{ messageId: second.value.queued.messageId, state: 'waiting' }
|
||||
])
|
||||
// One pause for the whole queue: "Queue paused because you interrupted".
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
// A lost acknowledgement replays the settled Stop; still no text, no field.
|
||||
const replayed = await stop(operationId, { callerKey: 'client-2' })
|
||||
expect(replayed).toMatchObject({ ok: true, replayed: true, value: { cancelled: false } })
|
||||
expect(replayed.ok && Object.keys(replayed.value).sort()).toEqual(['cancelled'])
|
||||
expect(await drafts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
/** A draft consumed into a submission the delivery loop has not handed over:
|
||||
* the loop is held at the child's start proof until the returned release. */
|
||||
async function consumedButNotHandedOver(): Promise<{ draftId: string; release: () => void }> {
|
||||
const working = await workingSend()
|
||||
const queued = await send('stopped in flight', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
let release: () => void = () => undefined
|
||||
awaitStarted.mockImplementationOnce(
|
||||
() => new Promise<undefined>((resolve) => (release = () => resolve(undefined)))
|
||||
)
|
||||
await settleAccepted(working, 'a')
|
||||
const draftId = queued.value.queued.messageId
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
expect((await rig.handoff(draftId))?.handedOverAt).toBeUndefined()
|
||||
return { draftId, release: () => release() }
|
||||
}
|
||||
|
||||
it("a Stop between consume and the agent's receipt sends the draft back to waiting, paused like the rest", async () => {
|
||||
const { draftId, release } = await consumedButNotHandedOver()
|
||||
const stopped = await stop()
|
||||
release()
|
||||
expect(stopped).toMatchObject({ ok: true })
|
||||
expect(await drafts()).toEqual([{ messageId: draftId, state: 'waiting' }])
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
// Nothing failed, so the card carries no refusal: it reads like any other paused card.
|
||||
const page = await host.history({ sessionId: SESSION, direction: 'tail' })
|
||||
const card = page.ok ? page.page.queuedMessages?.[0] : undefined
|
||||
expect(card).not.toHaveProperty('returnedReason')
|
||||
expect(card).not.toHaveProperty('returnedRejection')
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('the Stop pause survives eviction and reopen, and Send-now overrides it', async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('paused by stop', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
await 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.
|
||||
await host.close(SESSION)
|
||||
expect(await rig.queuePause()).toEqual({ reason: '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.
|
||||
expect(await sendNow(draftId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { submission: expect.anything() }
|
||||
})
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
})
|
||||
|
||||
it("the user's next send lifts the stopped hold once its turn starts, and the held draft drains after that turn", async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('paused by stop', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
await stop()
|
||||
await settleAccepted(working, 'a')
|
||||
// Settling the stopped turn is not the user starting one: still paused.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
expect(await rig.handoff(draftId)).toBeUndefined()
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
// The host accepting the send is not yet a turn: the pause lifts when the
|
||||
// provider accepts it, and the draft drains after that turn.
|
||||
const next = send('user starts a new turn')
|
||||
await next.result
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
await settleAccepted(next.id, 'b')
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
})
|
||||
|
||||
it('a host-internal send (orchestration mail, a restart continuation, a host-sent launch prompt) never lifts the pause', async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('paused by stop', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
await stop()
|
||||
await settleAccepted(working, 'a')
|
||||
// All three reach the host as a send the client send RPC did not make.
|
||||
const mail = send('coordinator mail', undefined, { internal: true })
|
||||
expect(await mail.result).toMatchObject({ ok: true, value: { submission: expect.anything() } })
|
||||
// The journal records who asked, which is what the pause reads.
|
||||
expect(await rig.submission(mail.id)).toMatchObject({ origin: 'host' })
|
||||
expect(await rig.submission(working)).toMatchObject({ origin: 'client' })
|
||||
await settleAccepted(mail.id, 'b')
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
expect(await rig.handoff(draftId)).toBeUndefined()
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
})
|
||||
|
||||
it("a user send lifts nothing from a 'send_failed' hold — that card waits for its explicit Send", async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('conversion fails once', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
const append = vi
|
||||
.spyOn(AgentSessionJournal.prototype, 'appendSubmission')
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error('disk full')
|
||||
})
|
||||
try {
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(async () =>
|
||||
expect(await drafts()).toMatchObject([
|
||||
{ messageId: draftId, state: 'waiting', paused: true }
|
||||
])
|
||||
)
|
||||
} finally {
|
||||
append.mockRestore()
|
||||
}
|
||||
const next = send('user starts a new turn')
|
||||
await next.result
|
||||
await settleAccepted(next.id, 'b')
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
expect(await rig.handoff(draftId)).toBeUndefined()
|
||||
const page = await host.history({ sessionId: SESSION, direction: 'tail' })
|
||||
expect(page.ok && page.page.queuedMessages?.[0]?.pausedReason).toBe(
|
||||
QUEUED_MESSAGE_PAUSED_SEND_FAILED
|
||||
)
|
||||
// The explicit Send is still the release.
|
||||
expect(await sendNow(draftId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { submission: expect.anything() }
|
||||
})
|
||||
})
|
||||
|
||||
it('a Stop whose pause record fails still interrupts; only the pause is lost, and it is reported', async () => {
|
||||
await workingSend()
|
||||
const queued = await send('kept by the stop', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const record = vi
|
||||
.spyOn(JournalQueuedMessages.prototype, 'recordPause')
|
||||
.mockRejectedValueOnce(new Error('disk full'))
|
||||
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
try {
|
||||
expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } })
|
||||
expect(warned).toHaveBeenCalledWith(expect.stringContaining('queue pause'), expect.anything())
|
||||
} finally {
|
||||
record.mockRestore()
|
||||
warned.mockRestore()
|
||||
}
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
// The draft is intact (never withdrawn), merely unpaused.
|
||||
expect(await drafts()).toEqual([{ messageId: queued.value.queued.messageId, state: 'waiting' }])
|
||||
})
|
||||
|
||||
it('Delete returns no body, replays from the receipt, and a fresh delete reports the disposition', async () => {
|
||||
await workingSend()
|
||||
const queued = await send('delete me', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
const operationId = hostTestOperationId()
|
||||
const deleted = await deleteQueued(draftId, operationId)
|
||||
expect(deleted).toMatchObject({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
value: { deleted: true, messageId: draftId }
|
||||
})
|
||||
// The card leaving the published list is the whole answer.
|
||||
expect(deleted.ok && Object.keys(deleted.value).sort()).toEqual(['deleted', 'messageId'])
|
||||
expect(await drafts()).toHaveLength(0)
|
||||
expect(await deleteQueued(draftId, operationId)).toMatchObject({
|
||||
ok: true,
|
||||
replayed: true,
|
||||
value: { deleted: true, messageId: draftId }
|
||||
})
|
||||
// A FRESH delete of the already-withdrawn draft reports the disposition.
|
||||
expect(await deleteQueued(draftId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { deleted: false, disposition: 'withdrawn' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('/clear', () => {
|
||||
function clear(clientOperationId: string) {
|
||||
const fields = { command: 'clear' as const }
|
||||
return host.conversationCommand(CALLER, {
|
||||
envelope: envelope(fields, 'agentSession.conversationCommand', clientOperationId),
|
||||
...fields
|
||||
})
|
||||
}
|
||||
|
||||
/** Two drafts paused by a Stop, then the work settled so command admission
|
||||
* has nothing pending. */
|
||||
async function pausedDrafts(): Promise<[string, string]> {
|
||||
const working = await workingSend()
|
||||
const first = await send('first text', 'queue-if-active').result
|
||||
const second = await send('second text', 'queue-if-active').result
|
||||
if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) {
|
||||
throw new Error('expected queued receipts')
|
||||
}
|
||||
await stop()
|
||||
await settleAccepted(working, 'a')
|
||||
return [first.value.queued.messageId, second.value.queued.messageId]
|
||||
}
|
||||
|
||||
it('the clear schema refuses the never-shipped withdraw opt-in', () => {
|
||||
const base = { envelope: envelope({}, 'agentSession.conversationCommand', 'op-schema') }
|
||||
expect(ConversationCommandParams.safeParse({ ...base, command: 'clear' }).success).toBe(true)
|
||||
expect(
|
||||
ConversationCommandParams.safeParse({ ...base, command: 'clear', withdrawQueued: true })
|
||||
.success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('carries the drafts to the replacement session as visible cards on a paused queue — the same for every client', async () => {
|
||||
const [firstId, secondId] = await pausedDrafts()
|
||||
const operationId = hostTestOperationId()
|
||||
const cleared = await clear(operationId)
|
||||
expect(cleared).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } })
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
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.
|
||||
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' })
|
||||
// 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)
|
||||
// A lost acknowledgement's replay re-runs nothing and duplicates nothing.
|
||||
const replayed = await clear(operationId)
|
||||
expect(replayed).toMatchObject({ ok: true, replayed: true })
|
||||
expect(await drafts(replacementId)).toHaveLength(2)
|
||||
// The carried card still answers Send-now, on the replacement.
|
||||
expect(await rig.sendNow(firstId, hostTestOperationId(), replacementId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { submission: expect.anything() }
|
||||
})
|
||||
})
|
||||
|
||||
it("the replacement's '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' })
|
||||
const resumed = await host.queuedMessagesResume(CALLER, {
|
||||
envelope: envelope(
|
||||
{},
|
||||
'agentSession.queuedMessagesResume',
|
||||
hostTestOperationId(),
|
||||
replacementId
|
||||
)
|
||||
})
|
||||
expect(resumed).toMatchObject({ ok: true, value: { resumed: true } })
|
||||
expect(await rig.queuePause(replacementId)).toBeNull()
|
||||
await eventually(async () =>
|
||||
expect(
|
||||
(await host.journalSnapshot(replacementId)).submissions.some(
|
||||
(entry) => entry.queuedMessageId === firstId
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
})
|
||||
|
||||
it('a carry whose insert fails leaves no pause over the empty replacement', async () => {
|
||||
await pausedDrafts()
|
||||
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const insert = vi
|
||||
.spyOn(JournalQueuedMessages.prototype, 'insert')
|
||||
.mockRejectedValueOnce(new Error('disk full'))
|
||||
let replacementId: string | undefined
|
||||
try {
|
||||
const cleared = await clear(hostTestOperationId())
|
||||
replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
} finally {
|
||||
insert.mockRestore()
|
||||
warned.mockRestore()
|
||||
}
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
}
|
||||
expect(await drafts(replacementId)).toHaveLength(0)
|
||||
const journal = host.collaboratorsForTests().sessions.get(replacementId)?.journal
|
||||
expect(journal?.queuedMessages.pause()).toBeNull()
|
||||
})
|
||||
|
||||
it('a returned card carries over as a plain waiting draft on the paused replacement', async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('refused then cleared', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
await settleRejected(await rig.handoffId(draftId), 'provider refused this payload')
|
||||
await eventually(async () =>
|
||||
expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }])
|
||||
)
|
||||
const cleared = await clear(hostTestOperationId())
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
}
|
||||
// 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 drafts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a draft held by a clear left prepared drains when the retried clear fails with no journal commit', async () => {
|
||||
const working = await workingSend()
|
||||
const queued = await send('behind the clear', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
// A clear that threw left its prepared phase behind; the settling turn's drain step meets it.
|
||||
const operationId = hostTestOperationId()
|
||||
await store.setConversationCommand(SESSION, 1, {
|
||||
command: 'clear',
|
||||
runtimeFence: 1,
|
||||
operationId,
|
||||
callerKey: CALLER.callerKey,
|
||||
phase: 'prepared',
|
||||
state: 'unknown'
|
||||
})
|
||||
await settleAccepted(working, 'a')
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
expect(await rig.handoff(draftId)).toBeUndefined()
|
||||
// The retried clear fails definitively: it settles on the record alone.
|
||||
const attach = vi.spyOn(host, 'attach').mockResolvedValueOnce({
|
||||
ok: false,
|
||||
refusal: { code: 'structured_agent_session_unsupported', message: 'unsupported' }
|
||||
})
|
||||
try {
|
||||
expect(await clear(operationId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { command: 'clear', state: 'completed', error: expect.any(String) }
|
||||
})
|
||||
} finally {
|
||||
attach.mockRestore()
|
||||
}
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
})
|
||||
|
||||
it('a clear with no drafts carries nothing and answers exactly as before', async () => {
|
||||
const cleared = await clear(hostTestOperationId())
|
||||
expect(cleared).toMatchObject({ ok: true, value: { command: 'clear', state: 'completed' } })
|
||||
const replacementId = cleared.ok ? cleared.value.replacementSessionId : undefined
|
||||
if (!replacementId) {
|
||||
throw new Error('expected a replacement session')
|
||||
}
|
||||
expect(await drafts(replacementId)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('publication', () => {
|
||||
async function subscribeEvents(): Promise<AgentSessionSubscribeEvent[]> {
|
||||
const events: AgentSessionSubscribeEvent[] = []
|
||||
await host.subscribe({
|
||||
id: 'subscriber-1',
|
||||
sessionId: SESSION,
|
||||
emit: (event) => events.push(event)
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
function queuedFrames(events: AgentSessionSubscribeEvent[]): AgentSessionQueuedMessage[][] {
|
||||
return events.flatMap((event) =>
|
||||
event.type !== 'end' && event.queuedMessages !== undefined && event.queuedMessages !== null
|
||||
? [event.queuedMessages]
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
it('hydrates the list on subscribe, publishes draft inserts at an unchanged cursor, and carries the shrunk list with the consumed submission in one frame', async () => {
|
||||
const working = await workingSend()
|
||||
const events = await subscribeEvents()
|
||||
// Hydration: the opening snapshot carries the (empty) list.
|
||||
expect(events[0]).toMatchObject({ type: 'snapshot', queuedMessages: [] })
|
||||
const queued = await send('queued behind', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
// The insert writes no journal row, yet the caught-up publish delivers it.
|
||||
await eventually(() => {
|
||||
const lists = queuedFrames(events)
|
||||
expect(lists.at(-1)).toMatchObject([{ messageId: draftId, state: 'waiting' }])
|
||||
})
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
// The frame that carries the consumed submission also carries the shrunk list.
|
||||
const consumeFrame = events.find(
|
||||
(event) =>
|
||||
event.type === 'batch' &&
|
||||
event.batch.submissions.some((entry) => entry.queuedMessageId === draftId)
|
||||
)
|
||||
expect(consumeFrame).toBeDefined()
|
||||
if (consumeFrame?.type === 'batch') {
|
||||
expect(consumeFrame.queuedMessages).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('a failed conversion reaches live subscribers as a paused card, with no further journal commit', async () => {
|
||||
const working = await workingSend()
|
||||
const events = await subscribeEvents()
|
||||
const queued = await send('conversion fails once', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const draftId = queued.value.queued.messageId
|
||||
const append = vi
|
||||
.spyOn(AgentSessionJournal.prototype, 'appendSubmission')
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error('disk full')
|
||||
})
|
||||
try {
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(() =>
|
||||
expect(queuedFrames(events).at(-1)).toMatchObject([
|
||||
{ messageId: draftId, paused: true, pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED }
|
||||
])
|
||||
)
|
||||
} finally {
|
||||
append.mockRestore()
|
||||
}
|
||||
// Send releases the process-level pause, which later tests' reused ids would otherwise inherit.
|
||||
expect(await sendNow(draftId)).toMatchObject({ ok: true })
|
||||
})
|
||||
|
||||
it("live frames carry the queue's pause with the list, and Resume's lift", async () => {
|
||||
await workingSend()
|
||||
const events = await subscribeEvents()
|
||||
const queued = await send('paused by stop', 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
const pauses = () =>
|
||||
events.flatMap((event) =>
|
||||
event.type !== 'end' && event.queuePause !== undefined ? [event.queuePause] : []
|
||||
)
|
||||
await eventually(() => expect(pauses().at(-1)).toBeNull())
|
||||
await stop()
|
||||
await eventually(() => expect(pauses().at(-1)).toEqual({ reason: 'stopped' }))
|
||||
expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } })
|
||||
await eventually(() => expect(pauses().at(-1)).toBeNull())
|
||||
})
|
||||
|
||||
it('an idle Stop that takes no effect pauses nothing', async () => {
|
||||
const working = await workingSend()
|
||||
const first = await send('to be refused', 'queue-if-active').result
|
||||
const second = await send('waits behind the card', 'queue-if-active').result
|
||||
if (!first.ok || !('queued' in first.value) || !second.ok || !('queued' in second.value)) {
|
||||
throw new Error('expected queued receipts')
|
||||
}
|
||||
const firstId = first.value.queued.messageId
|
||||
await settleAccepted(working, 'a')
|
||||
await eventually(async () => expect(await rig.handoff(firstId)).toBeDefined())
|
||||
await settleRejected(await rig.handoffId(firstId), 'refused')
|
||||
await eventually(async () =>
|
||||
expect(await drafts()).toMatchObject([{ messageId: firstId, state: 'returned' }, {}])
|
||||
)
|
||||
// Idle, nothing in flight and nothing withdrawn: the Stop changes nothing.
|
||||
expect(await stop()).toMatchObject({ ok: true, value: { cancelled: false } })
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
})
|
||||
|
||||
it('an unchanged list is not re-sent on later frames', async () => {
|
||||
await workingSend()
|
||||
const events = await subscribeEvents()
|
||||
await send('queued behind', 'queue-if-active').result
|
||||
await eventually(() => expect(queuedFrames(events).length).toBeGreaterThan(0))
|
||||
const framesAfterInsert = queuedFrames(events).length
|
||||
// Another journal commit with no draft change re-sends nothing.
|
||||
const { result } = send('another working send')
|
||||
await result
|
||||
await eventually(async () => {
|
||||
const last = events.at(-1)
|
||||
expect(last?.type).toBe('batch')
|
||||
})
|
||||
expect(queuedFrames(events).length).toBe(framesAfterInsert)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,378 @@
|
||||
// Mid-turn queueing: the accept decision that turns a send into a host-held
|
||||
// draft, the serialized drain that converts one draft into an ordinary
|
||||
// submission when the session stops owing work, and the published draft list.
|
||||
//
|
||||
// Drafts are never owed work: they feed no reducer, no working status, no
|
||||
// teardown and no idle sweep. The drain re-reads every gate inside its own
|
||||
// serialized step, so there is no loop state to disagree with the journal.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
QUEUED_MESSAGE_PAUSED_SEND_FAILED,
|
||||
type AgentSessionSendResult,
|
||||
type AgentSessionWireRefusal
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import {
|
||||
createStructuredAgentSessionOperationId,
|
||||
structuredAgentSessionPayloadFingerprint
|
||||
} from '../../../shared/structured-agent-session-mutation'
|
||||
import { queuedSendAnswer } from './structured-agent-session-queued-send-answer'
|
||||
import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation'
|
||||
import { isUnsettledQueuedMessage } from '../agent-session-journal/queued-message-table'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages'
|
||||
import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table'
|
||||
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
|
||||
import {
|
||||
structuredAgentSessionHostInstance,
|
||||
structuredQueuePause
|
||||
} from './structured-agent-session-queued-pause'
|
||||
|
||||
/** Budget at accept, in the send schema's own unit (`Buffer.byteLength` of the
|
||||
* serialized blocks); refused readably rather than trimmed. */
|
||||
export const QUEUED_MESSAGES_MAX_COUNT = 20
|
||||
export const QUEUED_MESSAGES_MAX_TOTAL_BYTES = 1024 * 1024
|
||||
|
||||
/** Text-only v1: any image block routes to the immediate path. */
|
||||
export function queuedMessageBodyIsTextOnly(body: AgentJournalMessageItem): boolean {
|
||||
return body.blocks.every((block) => block.type === 'text')
|
||||
}
|
||||
|
||||
/** Walks the reduced items in place: the gate runs on every admission and
|
||||
* drain step, so it must not render a snapshot of the whole journal. */
|
||||
export function pendingPromptExists(journal: Pick<AgentSessionJournal, 'visitItems'>): boolean {
|
||||
let pending = false
|
||||
journal.visitItems((_itemId, _sequence, body) => {
|
||||
if (
|
||||
!pending &&
|
||||
(body.kind === 'approval' || body.kind === 'question') &&
|
||||
body.resolution.state === 'pending'
|
||||
) {
|
||||
pending = true
|
||||
}
|
||||
})
|
||||
return pending
|
||||
}
|
||||
|
||||
/** Waiting, not held on its own, not positioned behind a returned card, and the
|
||||
* queue not paused. The admission rule (§accept) and the drain's selection
|
||||
* both read it. */
|
||||
function oldestActionableQueuedMessage(
|
||||
journal: Pick<AgentSessionJournal, 'queuedMessages' | 'cursor' | 'wroteBeforeOpen'>
|
||||
): QueuedMessageRow | null {
|
||||
const rows = journal.queuedMessages.list()
|
||||
// Nothing waiting costs no pause derivation: this runs on every journal publish.
|
||||
if (!rows.some((row) => row.state === 'waiting') || structuredQueuePause(journal) !== null) {
|
||||
return null
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (row.state === 'returned') {
|
||||
// A returned card blocks everything after it until the user acts.
|
||||
return null
|
||||
}
|
||||
if (row.state === 'waiting' && row.holdReason === null) {
|
||||
return row
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the queue is not sending right now — ONE decision for admission, the
|
||||
* drain step and Send-now, so the lists cannot drift. Each caller's override
|
||||
* policy sits next to its use:
|
||||
*
|
||||
* admission: `blocked` refuses (the immediate path's own refusal); any other
|
||||
* hold, or an actionable backlog, queues the send as a draft.
|
||||
* drain step: any hold returns early; whatever clears it publishes or
|
||||
* commits, which re-derives.
|
||||
* Send-now: overrides only `working` (plus FIFO order and the stored hold);
|
||||
* `blocked` and `prompt` refuse readably.
|
||||
*
|
||||
* `blocked` is whatever refuses any send (an uncertain rewind, a clear in doubt,
|
||||
* a cleared source); the rest are waits. A /compact is a queued message and then a turn,
|
||||
* so it holds the queue as `working`; an older build's compaction record belongs
|
||||
* to a child this host no longer runs and holds nothing. Host-local vocabulary —
|
||||
* never on the wire.
|
||||
*/
|
||||
export type StructuredQueueHold = 'blocked' | 'working' | 'prompt'
|
||||
|
||||
export function structuredQueueHold(input: {
|
||||
journal: AgentSessionJournal
|
||||
record: AgentSessionRecord | null
|
||||
fence: number
|
||||
}): StructuredQueueHold | null {
|
||||
// Whatever refuses any send refuses the queue too: an uncertain rewind, a clear in
|
||||
// doubt, or a source a clear superseded. One rule, the immediate path's own.
|
||||
if (structuredAgentSessionSendBlock(input.record)) {
|
||||
return 'blocked'
|
||||
}
|
||||
const { journal } = input
|
||||
// `prompt` outranks `working`: it is the one wait Send-now may not override,
|
||||
// so a prompt raised mid-turn must not read as merely `working`.
|
||||
if (pendingPromptExists(journal)) {
|
||||
return 'prompt'
|
||||
}
|
||||
if (
|
||||
isStructuredAgentSessionMainAgentWorking(
|
||||
journal.activeTurnId(),
|
||||
journal.submissions(),
|
||||
input.fence
|
||||
)
|
||||
) {
|
||||
return 'working'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `queue-if-active` send becomes a draft: any queue hold short of
|
||||
* `blocked`, or an actionable draft already exists (FIFO backlog — an
|
||||
* ADMISSION rule only, never a drain gate). A lone returned card, or a paused
|
||||
* queue, does not trap a new send: the user acting now wins, and that send's
|
||||
* turn starting is what lifts the pause — Orca's own queue policy, a stated
|
||||
* deviation from held-head backlog counting.
|
||||
*/
|
||||
export function shouldQueueStructuredAgentSessionSend(input: {
|
||||
journal: AgentSessionJournal
|
||||
record: AgentSessionRecord | null
|
||||
fence: number
|
||||
}): boolean {
|
||||
const hold = structuredQueueHold(input)
|
||||
if (hold === 'blocked') {
|
||||
// The immediate path's own refusal (`structuredAgentSessionSendBlock`)
|
||||
// answers; queueing behind a fence would strand the draft.
|
||||
return false
|
||||
}
|
||||
if (hold !== null) {
|
||||
return true
|
||||
}
|
||||
return oldestActionableQueuedMessage(input.journal) !== null
|
||||
}
|
||||
|
||||
/** A draft's payload fingerprint in the session that will send it: the reducer
|
||||
* aliases the provider's echo to the submission by recomputing exactly this. */
|
||||
export function queuedMessageFingerprint(sessionId: string, body: AgentJournalMessageItem): string {
|
||||
return structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.send',
|
||||
sessionId,
|
||||
fields: { body }
|
||||
})
|
||||
}
|
||||
|
||||
/** The accept-side budget refusal, or null when the draft fits. */
|
||||
export function queuedMessageBudgetRefusal(
|
||||
journal: AgentSessionJournal,
|
||||
body: AgentJournalMessageItem
|
||||
): AgentSessionWireRefusal | null {
|
||||
const unsettled = journal.queuedMessages.list().filter(isUnsettledQueuedMessage)
|
||||
const bytes = unsettled.reduce(
|
||||
(sum, row) => sum + Buffer.byteLength(JSON.stringify(row.body.blocks), 'utf8'),
|
||||
Buffer.byteLength(JSON.stringify(body.blocks), 'utf8')
|
||||
)
|
||||
if (unsettled.length >= QUEUED_MESSAGES_MAX_COUNT || bytes > QUEUED_MESSAGES_MAX_TOTAL_BYTES) {
|
||||
return {
|
||||
code: 'agent_session_operation_invalid',
|
||||
message: 'The message queue is full. Send again after the current turn ends.'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The accept branch: a capable send while the session is working (or behind an
|
||||
* actionable backlog) becomes a draft instead of a submission. Returns null for
|
||||
* the immediate path — an incapable client, an image body (text-only v1), a
|
||||
* replayed id the journal already answers, or an idle session.
|
||||
*/
|
||||
export async function maybeQueueStructuredAgentSessionSend(
|
||||
context: {
|
||||
deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } }
|
||||
},
|
||||
ctx: {
|
||||
sessionId: string
|
||||
journal: AgentSessionJournal
|
||||
fence: number
|
||||
},
|
||||
params: {
|
||||
envelope: { clientOperationId: string }
|
||||
body: AgentJournalMessageItem
|
||||
delivery?: 'queue-if-active'
|
||||
}
|
||||
): Promise<
|
||||
| { ok: true; value: AgentSessionSendResult }
|
||||
| { ok: false; refusal: AgentSessionWireRefusal }
|
||||
| null
|
||||
> {
|
||||
const clientMessageId = params.envelope.clientOperationId
|
||||
if (params.delivery !== 'queue-if-active' || !queuedMessageBodyIsTextOnly(params.body)) {
|
||||
return null
|
||||
}
|
||||
// Asked again with no ledger answer: a send this host queued answers as its replay would —
|
||||
// its hand-off goes out under a fresh id, so no submission under this id guards it.
|
||||
const queuedBefore = queuedSendAnswer(ctx.journal, clientMessageId)
|
||||
if (queuedBefore) {
|
||||
return { ok: true, value: queuedBefore }
|
||||
}
|
||||
// A recorded direct submission under this id replays through today's path.
|
||||
if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
!shouldQueueStructuredAgentSessionSend({
|
||||
journal: ctx.journal,
|
||||
record: context.deps.store.getRecord(ctx.sessionId),
|
||||
fence: ctx.fence
|
||||
})
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const refusal = queuedMessageBudgetRefusal(ctx.journal, params.body)
|
||||
if (refusal) {
|
||||
return { ok: false, refusal }
|
||||
}
|
||||
// The insert notifies through the journal's commit listener: publication and
|
||||
// the drain re-derive with no call here to forget.
|
||||
const row = await ctx.journal.queuedMessages.insert({
|
||||
messageId: clientMessageId,
|
||||
body: params.body,
|
||||
fingerprint: queuedMessageFingerprint(ctx.sessionId, params.body),
|
||||
hostInstance: structuredAgentSessionHostInstance()
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
clientMessageId,
|
||||
queued: { messageId: row.messageId, position: row.position, state: row.state }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type QueuedMessageDrainDeps = {
|
||||
sessions: ReadonlyMap<string, StructuredAgentSessionHostSession>
|
||||
getRecord: (sessionId: string) => AgentSessionRecord | null
|
||||
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
|
||||
/** The streamed-event barrier: a turn-open already accepted by the host is
|
||||
* committed before the gates are read, so no stored busy flag is needed. */
|
||||
flushStreamedEvents: (sessionId: string) => Promise<void>
|
||||
conversationFence: (sessionId: string) => number
|
||||
/** The consumed submission is ordinary #22821 work from here on. */
|
||||
wakeDelivery: (sessionId: string) => void
|
||||
onError: (sessionId: string, error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The serialized drain. Woken by every journal commit (turn, submission, prompt,
|
||||
* command and Stop settlements are all commits), by draft mutations, and by the
|
||||
* conversation opening; each step re-derives everything and consumes at most one
|
||||
* draft — the consumed submission then owes work, which gates the next.
|
||||
*/
|
||||
export class StructuredAgentSessionQueuedMessageDrain {
|
||||
private readonly scheduled = new Set<string>()
|
||||
|
||||
constructor(private readonly deps: QueuedMessageDrainDeps) {}
|
||||
|
||||
schedule(sessionId: string): void {
|
||||
const journal = this.deps.sessions.get(sessionId)?.journal
|
||||
if (!journal || journal.isReadOnly) {
|
||||
return
|
||||
}
|
||||
// Cheap pre-check so token streams do not pay a serialized step per delta.
|
||||
// Skipping while working is safe: whatever ends the work is itself a commit
|
||||
// that schedules again, and the step re-reads every gate after its flush.
|
||||
try {
|
||||
if (
|
||||
!journal.queuedMessages.settlementOwed() &&
|
||||
(oldestActionableQueuedMessage(journal) === null ||
|
||||
isStructuredAgentSessionMainAgentWorking(
|
||||
journal.activeTurnId(),
|
||||
journal.submissions(),
|
||||
this.deps.conversationFence(sessionId)
|
||||
))
|
||||
) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// The handle is opening or closing; the next commit re-schedules.
|
||||
return
|
||||
}
|
||||
if (this.scheduled.has(sessionId)) {
|
||||
return
|
||||
}
|
||||
this.scheduled.add(sessionId)
|
||||
void this.deps
|
||||
.serialize(sessionId, () => {
|
||||
this.scheduled.delete(sessionId)
|
||||
return this.step(sessionId)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.scheduled.delete(sessionId)
|
||||
this.deps.onError(sessionId, error)
|
||||
})
|
||||
}
|
||||
|
||||
private async step(sessionId: string): Promise<void> {
|
||||
const session = this.deps.sessions.get(sessionId)
|
||||
if (!session || session.journal.isReadOnly) {
|
||||
return
|
||||
}
|
||||
await this.deps.flushStreamedEvents(sessionId)
|
||||
const journal = session.journal
|
||||
if (journal.queuedMessages.settlementOwed() || journal.queuedMessages.deliveredByEchoOwed()) {
|
||||
// A live per-row hook was skipped; heal now, before a draft sends, rather than at reopen.
|
||||
await journal.queuedMessages.settleOwed().catch((error: unknown) => {
|
||||
this.deps.onError(sessionId, error)
|
||||
})
|
||||
}
|
||||
const next = oldestActionableQueuedMessage(journal)
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
const record = this.deps.getRecord(sessionId)
|
||||
const fence = this.deps.conversationFence(sessionId)
|
||||
// Live facts only, through the one gate; the backlog is never a gate, so a
|
||||
// lone draft drains. Whatever clears a hold publishes or commits, which
|
||||
// re-derives this step.
|
||||
if (structuredQueueHold({ journal, record, fence }) !== null) {
|
||||
return
|
||||
}
|
||||
// Always a fresh id: the submission names its draft by `queuedMessageId`, never by id equality.
|
||||
const submissionId = createStructuredAgentSessionOperationId(randomUUID)
|
||||
try {
|
||||
await journal.appendSubmission(
|
||||
{
|
||||
clientMessageId: submissionId,
|
||||
// The queue's own automatic send: it never ends a pause.
|
||||
origin: 'host',
|
||||
payloadFingerprint: next.fingerprint,
|
||||
body: next.body,
|
||||
fence,
|
||||
handoverRecorded: true
|
||||
},
|
||||
{
|
||||
messageId: next.messageId,
|
||||
expect: 'waiting',
|
||||
settledByOp: null,
|
||||
hostInstance: structuredAgentSessionHostInstance()
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof QueuedMessageNotConsumableError) {
|
||||
// Lost a race with a Send-now or Delete; their transition stands.
|
||||
return
|
||||
}
|
||||
// Pre-consume failure: the draft stays waiting, held with the marker on
|
||||
// the card (a stored fact, so it survives eviction and restart). The
|
||||
// hold's own commit notification publishes it. An explicit Send retries;
|
||||
// no automatic retry loop.
|
||||
await journal.queuedMessages
|
||||
.hold({ messageIds: [next.messageId], reason: QUEUED_MESSAGE_PAUSED_SEND_FAILED })
|
||||
.catch(() => {})
|
||||
throw error
|
||||
}
|
||||
this.deps.wakeDelivery(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// `agentSession.queuedMessageSend` / `agentSession.queuedMessageDelete`, and
|
||||
// /clear's carry of the source's drafts to its replacement session. Settling
|
||||
// operations stamp op-scoped tombstone receipts, so a lost acknowledgement
|
||||
// replays from the rows themselves — never from the operation ledger, which
|
||||
// records only that an operation happened. No mutation returns draft text:
|
||||
// the published list is the one authority a client renders.
|
||||
|
||||
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
|
||||
import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger'
|
||||
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'
|
||||
import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table'
|
||||
import type { MutationPlan } from './structured-agent-session-mutation-plans'
|
||||
import {
|
||||
queuedMessageFingerprint,
|
||||
structuredQueueHold
|
||||
} from './structured-agent-session-queued-messages'
|
||||
import {
|
||||
resumeStructuredQueue,
|
||||
structuredAgentSessionHostInstance
|
||||
} from './structured-agent-session-queued-pause'
|
||||
import { unsettledQueuedMessages } from './structured-agent-session-queued-stop'
|
||||
import {
|
||||
mutateStructuredAgentSession,
|
||||
type StructuredAgentSessionMutationContext
|
||||
} from './structured-agent-session-host-mutations'
|
||||
import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types'
|
||||
import {
|
||||
openForWrite,
|
||||
structuredAgentSessionSendBlock
|
||||
} from './structured-agent-session-send-preparation'
|
||||
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
|
||||
|
||||
function invalid(message: string): {
|
||||
ok: false
|
||||
refusal: { code: 'agent_session_operation_invalid'; message: string }
|
||||
} {
|
||||
return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } }
|
||||
}
|
||||
|
||||
function submissionFor(
|
||||
ctx: AgentSessionTurnContext,
|
||||
clientMessageId: string
|
||||
): AgentJournalSubmission | undefined {
|
||||
return ctx.journal.submissions().find((entry) => entry.clientMessageId === clientMessageId)
|
||||
}
|
||||
|
||||
/** One transaction, stamped with the operation's caller-scoped key so a replay
|
||||
* answers "spent" from the receipts. Withdrawal retires any hold in the same
|
||||
* UPDATE, and the store's commit notification publishes the change. */
|
||||
export async function withdrawQueuedMessagesForOperation(
|
||||
journal: AgentSessionJournal,
|
||||
input: {
|
||||
sessionId: string
|
||||
messageIds: readonly string[]
|
||||
callerKey: string
|
||||
operationId: string
|
||||
}
|
||||
): Promise<QueuedMessageRow[]> {
|
||||
return journal.queuedMessages.withdraw({
|
||||
messageIds: input.messageIds,
|
||||
settledByOp: agentSessionOperationKey(input.callerKey, input.operationId)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* /clear's carry: the source's unsettled drafts become rows on the replacement
|
||||
* session — the SAME for every client version, with no text on the wire — so the
|
||||
* cards stay visible where the user now is. The replacement's queue starts
|
||||
* paused ('cleared'), lifted exactly like a Stop's: the cards were written for the context /clear just
|
||||
* discarded, so they wait for the user's next turn there, or Resume, rather than
|
||||
* sending into the fresh context unasked. Each card lands with the pause in one
|
||||
* transaction, so the drain never sees a carried card unpaused and no pause is
|
||||
* left over an empty queue if an insert fails. Runs after the
|
||||
* replacement's attach succeeded and before the clear commits. Each insert is
|
||||
* idempotent on (session, message), so the clear's rerun-while-prepared replays
|
||||
* it safely; the source rows are then tombstoned. Bookkeeping around the clear:
|
||||
* a failure leaves the cards on the superseded source — whose supersession
|
||||
* fence already blocks the drain — reported, never gating the clear. A crash
|
||||
* between the copy and the tombstone leaves both, which the fence also makes
|
||||
* harmless: nothing is lost and nothing runs.
|
||||
*/
|
||||
export async function carryQueuedMessagesToClearReplacement(
|
||||
ctx: AgentSessionTurnContext,
|
||||
input: {
|
||||
replacementSessionId: string
|
||||
replacementJournal: AgentSessionJournal | undefined
|
||||
callerKey: string
|
||||
operationId: string
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const rows = unsettledQueuedMessages(ctx.journal)
|
||||
if (rows.length === 0) {
|
||||
return
|
||||
}
|
||||
const replacement = input.replacementJournal
|
||||
if (!replacement) {
|
||||
throw new Error('the replacement journal is not open')
|
||||
}
|
||||
for (const row of rows) {
|
||||
// A returned card carries over as a plain waiting draft — its refusal
|
||||
// belonged to the source's submissions. The fingerprint is re-scoped to the
|
||||
// replacement, or its echo could never alias the sent bubble.
|
||||
await replacement.queuedMessages.insert({
|
||||
messageId: row.messageId,
|
||||
body: row.body,
|
||||
fingerprint: queuedMessageFingerprint(input.replacementSessionId, row.body),
|
||||
hostInstance: structuredAgentSessionHostInstance(),
|
||||
pausedBy: 'cleared'
|
||||
})
|
||||
}
|
||||
await withdrawQueuedMessagesForOperation(ctx.journal, {
|
||||
sessionId: ctx.sessionId,
|
||||
messageIds: rows.map((row) => row.messageId),
|
||||
callerKey: input.callerKey,
|
||||
operationId: input.operationId
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("[agent-session] /clear's queued-draft carry skipped:", {
|
||||
sessionId: ctx.sessionId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Draft actions run like any mutation: admitted on the session's lane, the
|
||||
* conversation opened for the write. */
|
||||
function mutateQueued<TValue>(
|
||||
context: StructuredAgentSessionMutationContext,
|
||||
caller: StructuredAgentSessionCaller,
|
||||
envelope: AgentSessionMutationEnvelope,
|
||||
plan: MutationPlan<TValue>
|
||||
): Promise<AgentSessionMutationResult<TValue>> {
|
||||
return mutateStructuredAgentSession(
|
||||
context,
|
||||
caller,
|
||||
envelope,
|
||||
plan,
|
||||
openForWrite(context, envelope)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send-now. It overrides ONLY queue policy — FIFO order, pause, the busy-turn
|
||||
* wait — through the same send block and pending-prompt gates as any send;
|
||||
* supersession, Stop and prepared commands are never overridden. The card goes
|
||||
* out under this operation's id, never its own, and the submission names it by
|
||||
* `queuedMessageId`; one id still means one delivery.
|
||||
*/
|
||||
export function sendQueuedStructuredAgentMessage(
|
||||
context: StructuredAgentSessionMutationContext,
|
||||
caller: StructuredAgentSessionCaller,
|
||||
params: { envelope: AgentSessionMutationEnvelope; messageId: string }
|
||||
): Promise<AgentSessionMutationResult<AgentSessionSendResult>> {
|
||||
const { messageId } = params
|
||||
const operationId = params.envelope.clientOperationId
|
||||
const plan: MutationPlan<AgentSessionSendResult> = {
|
||||
method: 'agentSession.queuedMessageSend',
|
||||
fields: { messageId },
|
||||
conversationWrite: true,
|
||||
run: async (ctx): Promise<TurnOutcome<AgentSessionSendResult>> => {
|
||||
// A rerun of this operation after it consumed the card (its answer never
|
||||
// settled): answer with the submission it made, never append it again.
|
||||
const consumedHere = submissionFor(ctx, operationId)
|
||||
if (consumedHere?.queuedMessageId === messageId) {
|
||||
return { ok: true, value: { clientMessageId: operationId, submission: consumedHere } }
|
||||
}
|
||||
// The one queue gate; Send-now's override set is exactly `working` (plus
|
||||
// FIFO order and the stored hold, which the consume below clears).
|
||||
const record = context.deps.store.getRecord(ctx.sessionId)
|
||||
const hold = structuredQueueHold({ journal: ctx.journal, record, fence: ctx.fence })
|
||||
if (hold === 'blocked') {
|
||||
return structuredAgentSessionSendBlock(record) ?? invalid('This conversation cannot send.')
|
||||
}
|
||||
if (hold === 'prompt') {
|
||||
return invalid('Answer the pending request before sending this message.')
|
||||
}
|
||||
const row = ctx.journal.queuedMessages.get(messageId)
|
||||
if (!row) {
|
||||
return invalid('No queued message by that id.')
|
||||
}
|
||||
if (row.state === 'withdrawn') {
|
||||
return invalid('This queued message was withdrawn.')
|
||||
}
|
||||
if (row.state === 'dispatched') {
|
||||
// Already a submission — answer with it rather than sending twice.
|
||||
const submission = row.consumedAs === null ? undefined : submissionFor(ctx, row.consumedAs)
|
||||
return submission
|
||||
? { ok: true, value: { clientMessageId: submission.clientMessageId, submission } }
|
||||
: invalid('This queued message was already sent.')
|
||||
}
|
||||
const submissionId = operationId
|
||||
try {
|
||||
await ctx.journal.appendSubmission(
|
||||
{
|
||||
clientMessageId: submissionId,
|
||||
// The person asked for this turn, so it ends a Stop's pause once it starts.
|
||||
origin: 'client',
|
||||
payloadFingerprint: row.fingerprint,
|
||||
body: row.body,
|
||||
fence: ctx.fence,
|
||||
handoverRecorded: true
|
||||
},
|
||||
{
|
||||
messageId,
|
||||
expect: row.state,
|
||||
settledByOp: agentSessionOperationKey(ctx.resolvedBy, operationId),
|
||||
hostInstance: structuredAgentSessionHostInstance()
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof QueuedMessageNotConsumableError) {
|
||||
return invalid('The queued message changed underneath this Send; try again.')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const submission = submissionFor(ctx, submissionId)
|
||||
if (!submission) {
|
||||
throw new Error('agent_session_submission_lost')
|
||||
}
|
||||
context.wakeDelivery(ctx.sessionId)
|
||||
return { ok: true, value: { clientMessageId: submissionId, submission } }
|
||||
},
|
||||
replay: (ctx) => {
|
||||
const opKey = agentSessionOperationKey(ctx.resolvedBy, operationId)
|
||||
const row = ctx.journal.queuedMessages
|
||||
.receipts(opKey)
|
||||
.find((receipt) => receipt.messageId === messageId)
|
||||
if (!row || row.state !== 'dispatched') {
|
||||
return null
|
||||
}
|
||||
const submission = row.consumedAs === null ? undefined : submissionFor(ctx, row.consumedAs)
|
||||
return submission ? { clientMessageId: submission.clientMessageId, submission } : null
|
||||
}
|
||||
}
|
||||
return mutateQueued(context, caller, params.envelope, plan)
|
||||
}
|
||||
|
||||
/** Delete = discard, with no body in the answer: the card leaving the published
|
||||
* list IS the outcome, so a lost answer needs no re-ask. An Edit is the client
|
||||
* copying the text it already renders, then this Delete. */
|
||||
export function deleteQueuedStructuredAgentMessage(
|
||||
context: StructuredAgentSessionMutationContext,
|
||||
caller: StructuredAgentSessionCaller,
|
||||
params: { envelope: AgentSessionMutationEnvelope; messageId: string }
|
||||
): Promise<AgentSessionMutationResult<AgentSessionQueuedMessageDeleteResult>> {
|
||||
const { messageId } = params
|
||||
const operationId = params.envelope.clientOperationId
|
||||
const plan: MutationPlan<AgentSessionQueuedMessageDeleteResult> = {
|
||||
method: 'agentSession.queuedMessageDelete',
|
||||
fields: { messageId },
|
||||
conversationWrite: true,
|
||||
run: async (ctx): Promise<TurnOutcome<AgentSessionQueuedMessageDeleteResult>> => {
|
||||
const row = ctx.journal.queuedMessages.get(messageId)
|
||||
if (!row) {
|
||||
return { ok: true, value: { deleted: false, messageId, disposition: 'missing' } }
|
||||
}
|
||||
if (row.state === 'dispatched') {
|
||||
return { ok: true, value: { deleted: false, messageId, disposition: 'dispatched' } }
|
||||
}
|
||||
if (row.state === 'withdrawn') {
|
||||
return { ok: true, value: { deleted: false, messageId, disposition: 'withdrawn' } }
|
||||
}
|
||||
// The withdrawal notifies through the journal's commit listener, which
|
||||
// also re-derives the drain — deleting a returned card can unblock the
|
||||
// drafts behind it.
|
||||
const withdrawn = await withdrawQueuedMessagesForOperation(ctx.journal, {
|
||||
sessionId: ctx.sessionId,
|
||||
messageIds: [messageId],
|
||||
callerKey: ctx.resolvedBy,
|
||||
operationId
|
||||
})
|
||||
return withdrawn.length > 0
|
||||
? { ok: true, value: { deleted: true, messageId } }
|
||||
: { ok: true, value: { deleted: false, messageId, disposition: 'withdrawn' } }
|
||||
},
|
||||
replay: (ctx) => {
|
||||
const replayed = ctx.journal.queuedMessages
|
||||
.receipts(agentSessionOperationKey(ctx.resolvedBy, operationId))
|
||||
.some((row) => row.messageId === messageId && row.state === 'withdrawn')
|
||||
return replayed ? { deleted: true, messageId } : null
|
||||
}
|
||||
}
|
||||
return mutateQueued(context, caller, params.envelope, plan)
|
||||
}
|
||||
|
||||
/** Resume: ends the queue's pause — a Stop's, or a restart's — so the cards send
|
||||
* again, oldest first, as the session goes idle. A no-op when nothing is paused,
|
||||
* and a per-card `send_failed` hold stays for its own Send. */
|
||||
export function resumeStructuredAgentQueue(
|
||||
context: StructuredAgentSessionMutationContext,
|
||||
caller: StructuredAgentSessionCaller,
|
||||
params: { envelope: AgentSessionMutationEnvelope }
|
||||
): Promise<AgentSessionMutationResult<AgentSessionQueuedMessagesResumeResult>> {
|
||||
const plan: MutationPlan<AgentSessionQueuedMessagesResumeResult> = {
|
||||
method: 'agentSession.queuedMessagesResume',
|
||||
fields: {},
|
||||
conversationWrite: true,
|
||||
// The lift notifies through the journal's commit listener, which publishes the
|
||||
// cleared pause and wakes the drain.
|
||||
run: async (ctx) => ({
|
||||
ok: true,
|
||||
value: { resumed: await resumeStructuredQueue(ctx.journal) }
|
||||
}),
|
||||
// Like Stop's replay: the Resume already ran, so this one lifts nothing.
|
||||
replay: () => ({ resumed: false })
|
||||
}
|
||||
return mutateQueued(context, caller, params.envelope, plan)
|
||||
}
|
||||
+476
@@ -0,0 +1,476 @@
|
||||
// A Stop pauses the whole queue, derived from the journal: it lasts until a turn
|
||||
// a person asked for (a send over the client RPC, or a card they sent now)
|
||||
// starts — the provider accepts it, never merely the host — or they Resume.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import {
|
||||
HOST_TEST_SESSION,
|
||||
hostTestMessage,
|
||||
hostTestOperationId
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import {
|
||||
QUEUED_RIG_CALLER,
|
||||
createQueuedMessageTestRig,
|
||||
eventually,
|
||||
type QueuedMessageTestRig
|
||||
} from './structured-agent-session-queued-message-rig.test-fixture'
|
||||
import { sameQueuePause } from './structured-agent-session-queued-publication'
|
||||
import {
|
||||
structuredAgentSessionHostInstance,
|
||||
structuredQueuePause
|
||||
} from './structured-agent-session-queued-pause'
|
||||
|
||||
let rig: QueuedMessageTestRig
|
||||
|
||||
beforeEach(async () => {
|
||||
rig = await createQueuedMessageTestRig()
|
||||
})
|
||||
|
||||
afterEach(() => rig.dispose())
|
||||
|
||||
/** No drain step may convert the drafts, and the queue reads paused. */
|
||||
async function expectPaused(...draftIds: string[]): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
for (const draftId of draftIds) {
|
||||
expect(await rig.handoff(draftId)).toBeUndefined()
|
||||
}
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
}
|
||||
|
||||
async function queuedDraft(text: string): Promise<string> {
|
||||
const queued = await rig.send(text, 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
return queued.value.queued.messageId
|
||||
}
|
||||
|
||||
/** A draft behind a Stop, with the stopped turn settled so the session is idle. */
|
||||
async function stoppedDraft(): Promise<string> {
|
||||
const working = await rig.workingSend()
|
||||
const draftId = await queuedDraft('paused by stop')
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'stopped')
|
||||
return draftId
|
||||
}
|
||||
|
||||
/** A queued draft handed off and handed over, found by its hand-off link. */
|
||||
async function handedOver(draftId: string): Promise<void> {
|
||||
await eventually(async () => expect((await rig.handoff(draftId))?.handedOverAt).toBeDefined())
|
||||
}
|
||||
|
||||
/** A user send the host accepted and handed over, still unanswered by the provider. */
|
||||
async function handedOverUserSend(text: string): Promise<string> {
|
||||
const { id, result } = rig.send(text)
|
||||
expect(await result).toMatchObject({ ok: true, value: { submission: expect.anything() } })
|
||||
await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined())
|
||||
return id
|
||||
}
|
||||
|
||||
describe("a Stop's queue pause", () => {
|
||||
it('outlives a user send the provider accepts and then refuses; a later send that starts lifts it', async () => {
|
||||
const draftId = await stoppedDraft()
|
||||
const refused = await handedOverUserSend('the start fails')
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
await rig.settleRejected(refused, 'turn/start refused')
|
||||
await expectPaused(draftId)
|
||||
const started = await handedOverUserSend('this one starts')
|
||||
await rig.settleAccepted(started, 'started')
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
})
|
||||
|
||||
it('a Stop after the user send supersedes it: that send starting its turn lifts nothing', async () => {
|
||||
const draftId = await stoppedDraft()
|
||||
const earlier = await handedOverUserSend('sent before the second stop')
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(earlier, 'late')
|
||||
await expectPaused(draftId)
|
||||
})
|
||||
|
||||
it('survives a restart, and a send made after the Stop still ends it when its turn starts there', async () => {
|
||||
const draftId = await stoppedDraft()
|
||||
const inFlight = await handedOverUserSend('sent before the restart')
|
||||
// Derived from the journal, not remembered: a restart forgets nothing it needs.
|
||||
await rig.restartHostProcess()
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
await rig.settleAccepted(inFlight, 'after-restart')
|
||||
// The Stop's pause is over; the restart's own lasts until a turn asked for since it.
|
||||
await eventually(async () => expect(await rig.queuePause()).toEqual({ reason: 'restarted' }))
|
||||
const next = rig.send('sent after the restart')
|
||||
await next.result
|
||||
await rig.settleAccepted(next.id, 'next')
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
})
|
||||
|
||||
it("a draft typed while the stopped turn winds down waits with the rest: the pause is the queue's", async () => {
|
||||
const working = await rig.workingSend()
|
||||
const olderId = await queuedDraft('paused by the stop')
|
||||
await rig.stop()
|
||||
const typedId = await queuedDraft('typed while stopping')
|
||||
await rig.settleAccepted(working, 'stopped')
|
||||
await expectPaused(olderId, typedId)
|
||||
expect(await rig.drafts()).toEqual([
|
||||
{ messageId: olderId, state: 'waiting' },
|
||||
{ messageId: typedId, state: 'waiting' }
|
||||
])
|
||||
})
|
||||
|
||||
it('Send-now sends only its own card; the rest stay paused until that turn starts, then drain after it', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const sentId = await queuedDraft('sent now')
|
||||
const heldId = await queuedDraft('held until that turn starts')
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'stopped')
|
||||
expect(await rig.sendNow(sentId)).toMatchObject({
|
||||
ok: true,
|
||||
value: { submission: { origin: 'client', queuedMessageId: sentId } }
|
||||
})
|
||||
await handedOver(sentId)
|
||||
// Only the card the user asked for went: the queue is still paused.
|
||||
await expectPaused(heldId)
|
||||
expect(await rig.drafts()).toEqual([{ messageId: heldId, state: 'waiting' }])
|
||||
// A turn the user asked for has now started, which ends the pause.
|
||||
await rig.settleAccepted(await rig.handoffId(sentId), 'sent-now')
|
||||
await eventually(async () => expect(await rig.handoff(heldId)).toBeDefined())
|
||||
})
|
||||
|
||||
it('a card sent now that the provider refuses lifts nothing', async () => {
|
||||
const working = await rig.workingSend()
|
||||
// Ahead of the refused card, so its return blocks nothing Resume would send.
|
||||
const heldId = await queuedDraft('held by the stop')
|
||||
const sentId = await queuedDraft('sent now, refused')
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'stopped')
|
||||
await rig.sendNow(sentId)
|
||||
await handedOver(sentId)
|
||||
await rig.settleRejected(await rig.handoffId(sentId), 'turn/start refused')
|
||||
await expectPaused(heldId)
|
||||
})
|
||||
|
||||
it('an old send answered again after its ledger row is gone lifts nothing from a later Stop', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const draftId = await queuedDraft('paused by stop')
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'stopped')
|
||||
// The ledger forgot the id, so the send runs again and answers with its accepted submission.
|
||||
const operations = rig.store['transactions'].state.operations
|
||||
for (const [key, row] of operations) {
|
||||
if (row.operationId === working) {
|
||||
operations.delete(key)
|
||||
}
|
||||
}
|
||||
const body = hostTestMessage('work on this')
|
||||
const replayed = await rig.host.send(QUEUED_RIG_CALLER, {
|
||||
envelope: rig.envelope({ body }, 'agentSession.send', working),
|
||||
body,
|
||||
userSend: true
|
||||
})
|
||||
expect(replayed).toMatchObject({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
value: { submission: { dispatchState: 'accepted' } }
|
||||
})
|
||||
// A later journal commit re-derives the pause; the old send was accepted before the Stop.
|
||||
const mail = rig.send('coordinator mail', undefined, { internal: true })
|
||||
await mail.result
|
||||
await rig.settleAccepted(mail.id, 'mail')
|
||||
await expectPaused(draftId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the pause read', () => {
|
||||
it("costs no scan of the submissions: the reducer keeps the latest person's accepted turn", async () => {
|
||||
const draftId = await stoppedDraft()
|
||||
const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal
|
||||
if (!journal) {
|
||||
throw new Error('expected the conversation open')
|
||||
}
|
||||
const scan = vi.spyOn(journal, 'submissions')
|
||||
// Read on every publish, per subscriber: it must not walk the submissions.
|
||||
expect(structuredQueuePause(journal)).toEqual({ reason: 'stopped' })
|
||||
expect(scan).not.toHaveBeenCalled()
|
||||
scan.mockRestore()
|
||||
const before = journal.queuedMessages.latestPersonTurnSequence()
|
||||
const mail = rig.send('coordinator mail', undefined, { internal: true })
|
||||
await mail.result
|
||||
await rig.settleAccepted(mail.id, 'mail')
|
||||
// Orca's own turn moves nothing; a person's does, and lifts the pause.
|
||||
expect(journal.queuedMessages.latestPersonTurnSequence()).toBe(before)
|
||||
const next = rig.send('user starts a new turn')
|
||||
await next.result
|
||||
await rig.settleAccepted(next.id, 'next')
|
||||
expect(journal.queuedMessages.latestPersonTurnSequence()).toBe(
|
||||
journal.submission(next.id)?.acceptedSequence
|
||||
)
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
})
|
||||
})
|
||||
|
||||
describe('a pause is over the cards it paused', () => {
|
||||
it('a Stop over an empty queue pauses nothing: a card typed during a later mail turn drains', async () => {
|
||||
const working = await rig.workingSend()
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'stopped')
|
||||
const mail = rig.send('coordinator mail', undefined, { internal: true })
|
||||
await mail.result
|
||||
await eventually(async () =>
|
||||
expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined()
|
||||
)
|
||||
const followUp = await queuedDraft('typed during the mail turn')
|
||||
await rig.settleAccepted(mail.id, 'mail')
|
||||
await eventually(async () => expect(await rig.handoff(followUp)).toBeDefined())
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
})
|
||||
|
||||
it('a Stop over an empty queue pauses nothing: a correction typed before the turn ends drains', async () => {
|
||||
const working = await rig.workingSend()
|
||||
await rig.stop()
|
||||
const correction = await queuedDraft('typed right after the stop')
|
||||
await rig.settleAccepted(working, 'stopped')
|
||||
await eventually(async () => expect(await rig.handoff(correction)).toBeDefined())
|
||||
})
|
||||
|
||||
it('deleting the last paused card ends the pause, so a card typed later is not held by it', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const only = await queuedDraft('paused, then deleted')
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'stopped')
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
expect(await rig.deleteQueued(only)).toMatchObject({ ok: true, value: { deleted: true } })
|
||||
const mail = rig.send('coordinator mail', undefined, { internal: true })
|
||||
await mail.result
|
||||
await eventually(async () =>
|
||||
expect((await rig.submission(mail.id))?.handedOverAt).toBeDefined()
|
||||
)
|
||||
const later = await queuedDraft('typed during the mail turn')
|
||||
await rig.settleAccepted(mail.id, 'mail')
|
||||
await eventually(async () => expect(await rig.handoff(later)).toBeDefined())
|
||||
})
|
||||
})
|
||||
|
||||
describe('a pause only over cards Resume could send', () => {
|
||||
it('a Stop that leaves only a returned card publishes no pause and keeps no fact', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const draftId = await queuedDraft('refused before the stop')
|
||||
await rig.settleAccepted(working, 'a')
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
await rig.settleRejected(await rig.handoffId(draftId), 'provider refused this payload')
|
||||
await eventually(async () =>
|
||||
expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'returned' }])
|
||||
)
|
||||
// A lone returned card traps nothing: this send goes now, and the Stop interrupts it.
|
||||
const next = await handedOverUserSend('sent past the card')
|
||||
expect(await rig.stop()).toMatchObject({ ok: true })
|
||||
await rig.settleAccepted(next, 'stopped')
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal
|
||||
expect(journal?.queuedMessages.pause()).toBeNull()
|
||||
})
|
||||
|
||||
it('a returned card blocking the paused cards hides the pause but keeps it; deleting that card shows it again, and only Resume sends', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const refusedId = await queuedDraft('refused after the stop')
|
||||
const behindId = await queuedDraft('waits behind the card')
|
||||
await rig.settleAccepted(working, 'a')
|
||||
await handedOver(refusedId)
|
||||
// The Stop interrupts the refused card's turn and pauses the card behind it.
|
||||
await rig.stop()
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
await rig.settleRejected(await rig.handoffId(refusedId), 'provider refused this payload')
|
||||
await eventually(async () =>
|
||||
expect(await rig.drafts()).toEqual([
|
||||
{ messageId: refusedId, state: 'returned' },
|
||||
{ messageId: behindId, state: 'waiting' }
|
||||
])
|
||||
)
|
||||
const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal
|
||||
if (!journal) {
|
||||
throw new Error('expected the conversation open')
|
||||
}
|
||||
// Resume would send nothing past the returned card, so no header offers it; the pause stays.
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
expect(journal.queuedMessages.pause()).toMatchObject({ reason: 'stopped' })
|
||||
// Deleting the blocking card shows the pause again: the card behind it does not send unasked.
|
||||
expect(await rig.deleteQueued(refusedId)).toMatchObject({ ok: true, value: { deleted: true } })
|
||||
await expectPaused(behindId)
|
||||
expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } })
|
||||
await eventually(async () => expect(await rig.handoff(behindId)).toBeDefined())
|
||||
})
|
||||
|
||||
it('a restart over only a card held by its own failed send publishes no pause', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const draftId = await queuedDraft('conversion fails once')
|
||||
const append = vi
|
||||
.spyOn(AgentSessionJournal.prototype, 'appendSubmission')
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error('disk full')
|
||||
})
|
||||
try {
|
||||
await rig.settleAccepted(working, 'a')
|
||||
await eventually(async () =>
|
||||
expect(await rig.drafts()).toEqual([{ messageId: draftId, state: 'waiting', paused: true }])
|
||||
)
|
||||
} finally {
|
||||
append.mockRestore()
|
||||
}
|
||||
await rig.restartHostProcess()
|
||||
// Only its own Send releases that card: a queue-level Resume would send nothing.
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
})
|
||||
|
||||
it('compares a pause by presence before reason, so appearing or clearing is always a change', () => {
|
||||
expect(sameQueuePause(null, {})).toBe(false)
|
||||
expect(sameQueuePause({}, null)).toBe(false)
|
||||
expect(sameQueuePause(null, null)).toBe(true)
|
||||
expect(sameQueuePause({ reason: 'stopped' }, { reason: 'stopped' })).toBe(true)
|
||||
expect(sameQueuePause({ reason: 'stopped' }, { reason: 'cleared' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("a restart's pause", () => {
|
||||
it("once a person's turn ends it, stays ended when the conversation reopens", async () => {
|
||||
const working = await rig.workingSend()
|
||||
const first = await queuedDraft('first')
|
||||
const second = await queuedDraft('second')
|
||||
await rig.restartHostProcess()
|
||||
await rig.settleAccepted(working, 'a')
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'restarted' })
|
||||
const next = rig.send('user starts a new turn')
|
||||
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.
|
||||
await rig.host.close(HOST_TEST_SESSION)
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
expect(await rig.drafts()).toContainEqual({ messageId: second, state: 'waiting' })
|
||||
})
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const working = await rig.workingSend()
|
||||
const draftId = await queuedDraft('refused, then re-sent after a restart')
|
||||
await rig.settleAccepted(working, 'a')
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
await rig.settleRejected(await rig.handoffId(draftId), 'provider refused this payload')
|
||||
await eventually(async () =>
|
||||
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.
|
||||
let release: () => void = () => undefined
|
||||
rig.awaitStarted.mockImplementationOnce(
|
||||
() => new Promise<undefined>((resolve) => (release = () => resolve(undefined)))
|
||||
)
|
||||
expect(await rig.sendNow(draftId)).toMatchObject({ ok: true })
|
||||
await rig.stop()
|
||||
release()
|
||||
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.queuedMessages.liftPause({
|
||||
stop: journal.queuedMessages.pause(),
|
||||
adoptInto: null
|
||||
})
|
||||
expect(structuredQueuePause(journal)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Resume', () => {
|
||||
it('lifts the pause and the queue drains, oldest first; a second Resume is a no-op', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const first = await queuedDraft('first')
|
||||
const second = await queuedDraft('second')
|
||||
await rig.stop()
|
||||
await rig.settleAccepted(working, 'stopped')
|
||||
await expectPaused(first, second)
|
||||
expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } })
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
await eventually(async () => expect(await rig.handoff(first)).toBeDefined())
|
||||
expect(await rig.handoff(second)).toBeUndefined()
|
||||
// Nothing is paused now: another Resume changes nothing.
|
||||
expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } })
|
||||
})
|
||||
|
||||
it('is idempotent: a replay of the same Resume answers without lifting a later pause', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const draftId = await queuedDraft('paused twice')
|
||||
await rig.stop()
|
||||
const operationId = hostTestOperationId()
|
||||
expect(await rig.resume(operationId)).toMatchObject({ ok: true, value: { resumed: true } })
|
||||
await rig.stop()
|
||||
expect(await rig.resume(operationId)).toMatchObject({
|
||||
ok: true,
|
||||
replayed: true,
|
||||
value: { resumed: false }
|
||||
})
|
||||
await rig.settleAccepted(working, 'stopped')
|
||||
await expectPaused(draftId)
|
||||
})
|
||||
|
||||
it("lifts a restart's pause too", async () => {
|
||||
const working = await rig.workingSend()
|
||||
const draftId = await queuedDraft('written before the restart')
|
||||
await rig.restartHostProcess()
|
||||
await rig.settleAccepted(working, 'a')
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'restarted' })
|
||||
expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } })
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
})
|
||||
|
||||
it('is a no-op on a queue that is not paused', async () => {
|
||||
await rig.workingSend()
|
||||
await queuedDraft('waiting behind the turn')
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: false } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a failed Stop', () => {
|
||||
it('records nothing when it fails before taking effect, so the queue sends as if no Stop was pressed', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const draftId = await queuedDraft('queued before the stop')
|
||||
const reject = vi
|
||||
.spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions')
|
||||
.mockRejectedValueOnce(new Error('disk full'))
|
||||
try {
|
||||
await expect(rig.stop()).rejects.toThrow('disk full')
|
||||
} finally {
|
||||
reject.mockRestore()
|
||||
}
|
||||
expect(await rig.queuePause()).toBeNull()
|
||||
await rig.settleAccepted(working, 'working')
|
||||
await eventually(async () => expect(await rig.handoff(draftId)).toBeDefined())
|
||||
})
|
||||
|
||||
it('keeps its pause when it fails after the interrupt reached the agent', async () => {
|
||||
await rig.workingSend()
|
||||
const draftId = await queuedDraft('paused by stop')
|
||||
const append = AgentSessionJournal.prototype.appendItem
|
||||
const failing = vi
|
||||
.spyOn(AgentSessionJournal.prototype, 'appendItem')
|
||||
.mockImplementation(async function (this: AgentSessionJournal, ...args) {
|
||||
// The status note written after the provider was asked to stop.
|
||||
if (args[1].kind === 'status') {
|
||||
throw new Error('disk full')
|
||||
}
|
||||
return append.apply(this, args)
|
||||
})
|
||||
try {
|
||||
await expect(rig.stop()).rejects.toThrow('disk full')
|
||||
} finally {
|
||||
failing.mockRestore()
|
||||
}
|
||||
await expectPaused(draftId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
// Whether the queue is paused, and why — DERIVED, never stored as a flag. The
|
||||
// queue is paused when:
|
||||
// - 'stopped': the user's last Stop took effect (its recorded journal
|
||||
// position) and no turn a person asked for has started since — or
|
||||
// 'cleared', the same for a /clear's replacement, whose carried cards
|
||||
// start paused; or
|
||||
// - 'restarted': a waiting draft was written by another host process and no
|
||||
// turn a person asked for has started since this conversation opened.
|
||||
// A person's turn is a submission whose recorded origin is `client` (a send over
|
||||
// the client send RPC, or a card they sent now) that the provider accepted.
|
||||
// Orchestration mail, a restart continuation, a host-sent launch prompt and the
|
||||
// queue's own drain are `host` and never lift it. An explicit Resume lifts any.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { QueuePauseFact } from '../agent-session-journal/queued-message-pause-table'
|
||||
|
||||
/** 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. */
|
||||
let hostInstance = randomUUID()
|
||||
|
||||
export function structuredAgentSessionHostInstance(): string {
|
||||
return hostInstance
|
||||
}
|
||||
|
||||
/** Simulates a host-process restart. Tests only. */
|
||||
export function rotateStructuredAgentSessionHostInstanceForTests(): string {
|
||||
hostInstance = randomUUID()
|
||||
return hostInstance
|
||||
}
|
||||
|
||||
type PauseJournal = Pick<AgentSessionJournal, 'queuedMessages' | 'cursor' | 'wroteBeforeOpen'>
|
||||
|
||||
// Both read the reducer's latest accepted person turn (its submission row), so a
|
||||
// derivation on every publish costs no scan of the submissions.
|
||||
|
||||
function stopEnded(journal: PauseJournal, stop: QueuePauseFact): boolean {
|
||||
const latest = journal.queuedMessages.latestPersonTurnSequence()
|
||||
// Sent after the Stop: a send made before it no longer lifts it, even if its turn starts later.
|
||||
return stop.epoch !== journal.cursor().epoch ? latest > 0 : latest > stop.sequence
|
||||
}
|
||||
|
||||
function restartPending(journal: PauseJournal): boolean {
|
||||
return journal.queuedMessages
|
||||
.list()
|
||||
.some((row) => row.state === 'waiting' && row.hostInstance !== hostInstance)
|
||||
}
|
||||
|
||||
function restartEnded(journal: PauseJournal): boolean {
|
||||
const latest = journal.queuedMessages.latestPersonTurnSequence()
|
||||
return latest > 0 && !journal.wroteBeforeOpen(latest)
|
||||
}
|
||||
|
||||
/** The queue's pause, derived; null when the queue sends on its own. */
|
||||
export function structuredQueuePause(journal: PauseJournal): AgentSessionQueuePause | null {
|
||||
const stop = journal.queuedMessages.pause()
|
||||
if (stop && !stopEnded(journal, stop)) {
|
||||
return { reason: stop.reason }
|
||||
}
|
||||
if (restartPending(journal) && !restartEnded(journal)) {
|
||||
return { reason: 'restarted' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every journal publish: retire what a person's started turn already ended — the
|
||||
* Stop fact it superseded, and a restart's rows, adopted into this instance. The
|
||||
* derivation already reads them as lifted; the write keeps that answer when the
|
||||
* handle reopens (the restart's "since this conversation opened" moves) and spares
|
||||
* later derivations the submission scan. Bookkeeping: a failure is reported.
|
||||
*/
|
||||
export async function retireEndedQueuePause(
|
||||
sessionId: string,
|
||||
journal: PauseJournal
|
||||
): Promise<void> {
|
||||
try {
|
||||
const stop = journal.queuedMessages.pause()
|
||||
const retireStop = stop !== null && stopEnded(journal, stop) ? stop : null
|
||||
const adopt = restartPending(journal) && restartEnded(journal)
|
||||
if (retireStop === null && !adopt) {
|
||||
return
|
||||
}
|
||||
await journal.queuedMessages.liftPause({
|
||||
stop: retireStop,
|
||||
adoptInto: adopt ? hostInstance : null
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn("[agent-session] a started turn's queue-pause retirement skipped:", {
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Resume: ends whichever pause holds the queue. Returns whether it was paused. */
|
||||
export async function resumeStructuredQueue(journal: PauseJournal): Promise<boolean> {
|
||||
if (structuredQueuePause(journal) === null) {
|
||||
return false
|
||||
}
|
||||
await journal.queuedMessages.liftPause({
|
||||
stop: journal.queuedMessages.pause(),
|
||||
adoptInto: hostInstance
|
||||
})
|
||||
return true
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
// The published view of a conversation's queue: its whole draft list and the
|
||||
// queue's pause, on the `commands` precedent — read per emit, reference-stable
|
||||
// while unchanged, so the subscribers' identity dedup keeps token streams from
|
||||
// re-sending it. The two ride together: a client never sees one without the other.
|
||||
|
||||
import {
|
||||
QUEUED_MESSAGE_PAUSED_SEND_FAILED,
|
||||
type AgentSessionQueuedMessage,
|
||||
type AgentSessionQueuePause
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { hasResumableQueuedMessage } from '../agent-session-journal/queued-message-pause-table'
|
||||
import { structuredQueuePause } from './structured-agent-session-queued-pause'
|
||||
|
||||
export type QueuePublication = {
|
||||
queuedMessages: AgentSessionQueuedMessage[]
|
||||
queuePause: AgentSessionQueuePause | null
|
||||
}
|
||||
|
||||
/** Waiting and returned rows only. `paused` is a per-card hold (a failed
|
||||
* conversion); a Stop or a restart pauses the queue, published once beside it. */
|
||||
function computePublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] {
|
||||
const published: AgentSessionQueuedMessage[] = []
|
||||
for (const row of journal.queuedMessages.list()) {
|
||||
if (row.state !== 'waiting' && row.state !== 'returned') {
|
||||
continue
|
||||
}
|
||||
const held = row.state === 'waiting' && row.holdReason !== null
|
||||
published.push({
|
||||
messageId: row.messageId,
|
||||
position: row.position,
|
||||
body: row.body,
|
||||
state: row.state,
|
||||
...(held ? { paused: true as const } : {}),
|
||||
// The stored reason is a typed marker; an unknown one reads as a plain hold.
|
||||
...(held && row.holdReason === QUEUED_MESSAGE_PAUSED_SEND_FAILED
|
||||
? { pausedReason: QUEUED_MESSAGE_PAUSED_SEND_FAILED }
|
||||
: {}),
|
||||
...(row.state === 'returned' ? { returnedReason: row.returnedReason } : {}),
|
||||
...(row.state === 'returned' && row.returnedRejection
|
||||
? { returnedRejection: row.returnedRejection }
|
||||
: {})
|
||||
})
|
||||
}
|
||||
return published
|
||||
}
|
||||
|
||||
type ListMemo = { key: string; serialized: string; list: AgentSessionQueuedMessage[] }
|
||||
|
||||
/** Reference-stable per journal handle: an unchanged list is never
|
||||
* re-serialized onto token-stream frames, and any draft-table write changes
|
||||
* the reference by construction. */
|
||||
const listMemos = new WeakMap<AgentSessionJournal, ListMemo>()
|
||||
const publications = new WeakMap<AgentSessionJournal, QueuePublication>()
|
||||
|
||||
function readPublishedQueuedMessages(journal: AgentSessionJournal): AgentSessionQueuedMessage[] {
|
||||
const key = String(journal.queuedMessages.revision())
|
||||
const memo = listMemos.get(journal)
|
||||
if (memo && memo.key === key) {
|
||||
return memo.list
|
||||
}
|
||||
const list = computePublishedQueuedMessages(journal)
|
||||
// Belt for the identity dedup: equal recomputed content keeps the previous reference.
|
||||
const serialized = JSON.stringify(list)
|
||||
if (memo && memo.serialized === serialized) {
|
||||
listMemos.set(journal, { key, serialized, list: memo.list })
|
||||
return memo.list
|
||||
}
|
||||
listMemos.set(journal, { key, serialized, list })
|
||||
return list
|
||||
}
|
||||
|
||||
/** Presence first: a pause appearing or clearing is a change even when neither side
|
||||
* names a reason this build can read. */
|
||||
export function sameQueuePause(
|
||||
previous: { reason?: string } | null,
|
||||
next: { reason?: string } | null
|
||||
): boolean {
|
||||
return (previous === null) === (next === null) && previous?.reason === next?.reason
|
||||
}
|
||||
|
||||
export function readQueuePublication(journal: AgentSessionJournal): QueuePublication {
|
||||
const queuedMessages = readPublishedQueuedMessages(journal)
|
||||
// Read per emit: the pause also turns on submissions (a person's turn starting).
|
||||
// Kept over any card it holds back, but shown only over one Resume would send, so its
|
||||
// header never offers to send nothing; deleting a blocking returned card shows it again.
|
||||
const pausable = hasResumableQueuedMessage(journal.queuedMessages.list())
|
||||
const queuePause = pausable ? structuredQueuePause(journal) : null
|
||||
const previous = publications.get(journal)
|
||||
if (
|
||||
previous &&
|
||||
previous.queuedMessages === queuedMessages &&
|
||||
sameQueuePause(previous.queuePause, queuePause)
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
const publication = { queuedMessages, queuePause }
|
||||
publications.set(journal, publication)
|
||||
return publication
|
||||
}
|
||||
|
||||
/** For readers that must never fail on drafts — a subscriber stream, a history
|
||||
* page: a closing handle answers "no claim" (absent) instead of throwing. */
|
||||
export function tryReadQueuePublication(
|
||||
journal: AgentSessionJournal | undefined
|
||||
): QueuePublication | undefined {
|
||||
try {
|
||||
return journal ? readQueuePublication(journal) : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// What a send this host queued answers with when it is asked again — a lost
|
||||
// acknowledgement's replay, or a rerun the operation ledger no longer covers:
|
||||
// from its draft first, then from the hand-off that names it.
|
||||
|
||||
import type { AgentSessionSendResult } from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
|
||||
/** Null for a send this host never queued. A refused conversion answers with
|
||||
* its returned card, never the rejected submission, or the same text would
|
||||
* render twice — on a Retry row AND the card. */
|
||||
export function queuedSendAnswer(
|
||||
journal: Pick<AgentSessionJournal, 'queuedMessages' | 'submission' | 'submissions'>,
|
||||
clientMessageId: string
|
||||
): AgentSessionSendResult | null {
|
||||
const draft = journal.queuedMessages.get(clientMessageId)
|
||||
if (draft) {
|
||||
const consumed =
|
||||
draft.state === 'dispatched' && draft.consumedAs !== null
|
||||
? journal.submission(draft.consumedAs)
|
||||
: undefined
|
||||
return consumed
|
||||
? { clientMessageId, submission: consumed }
|
||||
: {
|
||||
clientMessageId,
|
||||
queued: { messageId: draft.messageId, position: draft.position, state: draft.state }
|
||||
}
|
||||
}
|
||||
// The draft row was pruned; its last hand-off still names it. Only a withdrawn row is pruned
|
||||
// while its last hand-off stands rejected — a card the user deleted — so it answers withdrawn,
|
||||
// never as a refused send.
|
||||
const handoff = journal
|
||||
.submissions()
|
||||
.findLast((entry) => entry.queuedMessageId === clientMessageId)
|
||||
if (handoff?.dispatchState === 'rejected') {
|
||||
// The pruned row's position went with it; a withdrawn receipt names no place in the queue.
|
||||
return {
|
||||
clientMessageId,
|
||||
queued: { messageId: clientMessageId, position: 0, state: 'withdrawn' }
|
||||
}
|
||||
}
|
||||
return handoff ? { clientMessageId, submission: handoff } : null
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// A send's queue step around its immediate path: the queue decision before it. A
|
||||
// person's send lifts a paused queue through its recorded origin once its turn
|
||||
// starts (`structured-agent-session-queued-pause.ts`), not through anything here.
|
||||
|
||||
import type { AgentSessionSendResult } from '../../../shared/agent-session-wire'
|
||||
import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types'
|
||||
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
|
||||
import { maybeQueueStructuredAgentSessionSend } from './structured-agent-session-queued-messages'
|
||||
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
|
||||
|
||||
export async function runQueueableStructuredAgentSessionSend(
|
||||
context: StructuredAgentSessionMutationContext,
|
||||
ctx: AgentSessionTurnContext,
|
||||
params: {
|
||||
envelope: { clientOperationId: string }
|
||||
body: AgentJournalMessageItem
|
||||
delivery?: 'queue-if-active'
|
||||
userSend?: true
|
||||
},
|
||||
immediate: () => Promise<TurnOutcome<AgentSessionSendResult>>
|
||||
): Promise<TurnOutcome<AgentSessionSendResult>> {
|
||||
// The queue decision runs first: a capable send while the session owes work (a
|
||||
// /compact included — it is a queued message like any other) becomes a draft;
|
||||
// only a `blocked` hold, which never queues, falls through to the refusal.
|
||||
const queued = await maybeQueueStructuredAgentSessionSend(context, ctx, params)
|
||||
if (queued) {
|
||||
return queued
|
||||
}
|
||||
const accepted = await immediate()
|
||||
if (accepted.ok) {
|
||||
context.wakeDelivery(ctx.sessionId)
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Stop's pause on the queue. A Stop never withdraws a draft and no text ever
|
||||
// travels back over the wire: it records WHERE in the journal it took effect,
|
||||
// and the queue is paused from there (`structured-agent-session-queued-pause.ts`
|
||||
// derives it) until a turn a person asked for starts, or they Resume. The cards
|
||||
// stay published, and Send-now sends one card without lifting the pause for the
|
||||
// rest until that card's turn starts. The record is bookkeeping: a failure is
|
||||
// reported and never gates the interrupt.
|
||||
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import {
|
||||
isUnsettledQueuedMessage,
|
||||
type QueuedMessageRow
|
||||
} from '../agent-session-journal/queued-message-table'
|
||||
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
|
||||
|
||||
/** The one unsettled-card predicate /clear's carry and the budget share:
|
||||
* waiting or returned. Pending/unknown/accepted deliveries stay outside it. */
|
||||
export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMessageRow[] {
|
||||
return journal.queuedMessages.list().filter(isUnsettledQueuedMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a Stop and records its queue pause at the point it takes effect — after
|
||||
* it withdrew the queued sends, as it reaches the agent, or, reaching no agent,
|
||||
* once it withdrew something — and only over cards it then holds back. The Stop
|
||||
* calls `tookEffect` there. A Stop that throws before then changed nothing and
|
||||
* recorded nothing, so there is nothing to undo; one that fails after it keeps
|
||||
* the pause, since the interrupt may have landed. A draft whose hand-off the
|
||||
* Stop withdrew is back to waiting in its own place, under this same pause. The
|
||||
* drain cannot slip a draft in between: it runs on the same serialized lane as
|
||||
* the Stop.
|
||||
*/
|
||||
export async function runStopWithQueuePause<TValue>(
|
||||
ctx: AgentSessionTurnContext,
|
||||
stop: (tookEffect: () => Promise<void>) => Promise<TurnOutcome<TValue>>
|
||||
): Promise<TurnOutcome<TValue>> {
|
||||
let attempted = false
|
||||
return stop(async () => {
|
||||
if (attempted) {
|
||||
return
|
||||
}
|
||||
attempted = true
|
||||
const { queuedMessages } = ctx.journal
|
||||
// A hand-off this Stop's withdrawal sent back may not have caught up yet (its hook
|
||||
// was skipped): heal it first, as the drain would, so the pause sees it waiting.
|
||||
try {
|
||||
if (queuedMessages.settlementOwed()) {
|
||||
await queuedMessages.settleOwed()
|
||||
}
|
||||
} catch (error) {
|
||||
report(ctx, 'owed settlement', error)
|
||||
}
|
||||
// Recorded only over a card it holds back — judged in its own transaction, which
|
||||
// still counts an owed return to waiting if that heal failed.
|
||||
await queuedMessages
|
||||
.recordPause('stopped')
|
||||
.catch((error: unknown) => report(ctx, 'queue pause', error))
|
||||
})
|
||||
}
|
||||
|
||||
function report(ctx: AgentSessionTurnContext, step: string, error: unknown): void {
|
||||
console.warn(`[agent-session] Stop's ${step} skipped:`, {
|
||||
sessionId: ctx.sessionId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Host wiring for mid-turn queueing: builds the serialized drain from the
|
||||
// host's mutation context and exposes the draft actions (Send, Delete, Resume), so the host
|
||||
// class stays a description of its surface.
|
||||
|
||||
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
|
||||
import type {
|
||||
StructuredAgentSessionCaller,
|
||||
StructuredAgentSessionHostSession
|
||||
} from './structured-agent-session-host-types'
|
||||
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
|
||||
import { StructuredAgentSessionQueuedMessageDrain } from './structured-agent-session-queued-messages'
|
||||
import { retireEndedQueuePause } from './structured-agent-session-queued-pause'
|
||||
import {
|
||||
deleteQueuedStructuredAgentMessage,
|
||||
resumeStructuredAgentQueue,
|
||||
sendQueuedStructuredAgentMessage
|
||||
} from './structured-agent-session-queued-mutations'
|
||||
|
||||
/** `sessions` are the live conversations (their `touch` is the idle sweep's activity renewal,
|
||||
* which the drain's schedule rides); everything else comes from the host's mutation context,
|
||||
* read lazily because the host's fields are still initializing when this is built. */
|
||||
export function wireStructuredAgentSessionQueuedMessages(
|
||||
sessions: ReadonlyMap<string, StructuredAgentSessionHostSession> & {
|
||||
touch: (sessionId: string) => void
|
||||
},
|
||||
context: () => StructuredAgentSessionMutationContext
|
||||
) {
|
||||
const drain = new StructuredAgentSessionQueuedMessageDrain({
|
||||
sessions,
|
||||
getRecord: (sessionId) => context().deps.store.getRecord(sessionId),
|
||||
serialize: (sessionId, task) => context().serialize(sessionId, task),
|
||||
flushStreamedEvents: (sessionId) => context().flushStreamedEvents(sessionId),
|
||||
conversationFence: (sessionId) =>
|
||||
structuredAgentSessionConversationFence(context().deps.store, sessionId),
|
||||
wakeDelivery: (sessionId) => context().wakeDelivery(sessionId),
|
||||
onError: (sessionId, error) => context().deps.onEventSinkError?.({ sessionId, error })
|
||||
})
|
||||
return {
|
||||
drain,
|
||||
/** Every journal publish: turn, submission, prompt, command and Stop
|
||||
* settlements are all commits, and each re-derives the drain's gates —
|
||||
* and retires a queue pause a person's started turn already ended. */
|
||||
onJournalActivity: (sessionId: string) => {
|
||||
sessions.touch(sessionId)
|
||||
const journal = sessions.get(sessionId)?.journal
|
||||
if (journal && !journal.isReadOnly) {
|
||||
void retireEndedQueuePause(sessionId, journal)
|
||||
}
|
||||
drain.schedule(sessionId)
|
||||
},
|
||||
queuedMessageSend: (
|
||||
caller: StructuredAgentSessionCaller,
|
||||
params: Parameters<typeof sendQueuedStructuredAgentMessage>[2]
|
||||
) => sendQueuedStructuredAgentMessage(context(), caller, params),
|
||||
queuedMessageDelete: (
|
||||
caller: StructuredAgentSessionCaller,
|
||||
params: Parameters<typeof deleteQueuedStructuredAgentMessage>[2]
|
||||
) => deleteQueuedStructuredAgentMessage(context(), caller, params),
|
||||
queuedMessagesResume: (
|
||||
caller: StructuredAgentSessionCaller,
|
||||
params: Parameters<typeof resumeStructuredAgentQueue>[2]
|
||||
) => resumeStructuredAgentQueue(context(), caller, params)
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
// A consumed draft whose submission a Stop withdrew before the agent had it is
|
||||
// not a failure: it waits again at its own position under the Stop's hold, so
|
||||
// it never blocks the paused cards behind it. After the user's next turn the
|
||||
// whole queue drains one per turn in queue order, the withdrawn draft first,
|
||||
// under a fresh submission id.
|
||||
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { JournalQueuedMessages } from '../agent-session-journal/journal-queued-messages'
|
||||
import { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data'
|
||||
import {
|
||||
createQueuedMessageTestRig,
|
||||
eventually,
|
||||
type QueuedMessageTestRig
|
||||
} from './structured-agent-session-queued-message-rig.test-fixture'
|
||||
|
||||
let rig: QueuedMessageTestRig
|
||||
|
||||
beforeEach(async () => {
|
||||
rig = await createQueuedMessageTestRig()
|
||||
})
|
||||
|
||||
afterEach(() => rig.dispose())
|
||||
|
||||
async function queuedDraft(text: string): Promise<string> {
|
||||
const queued = await rig.send(text, 'queue-if-active').result
|
||||
if (!queued.ok || !('queued' in queued.value)) {
|
||||
throw new Error('expected a queued receipt')
|
||||
}
|
||||
return queued.value.queued.messageId
|
||||
}
|
||||
|
||||
async function submissionIds(): Promise<string[]> {
|
||||
return (await rig.host.journalSnapshot(SESSION)).submissions.map((entry) => entry.clientMessageId)
|
||||
}
|
||||
|
||||
async function handedOver(id: string): Promise<void> {
|
||||
await eventually(async () => expect((await rig.submission(id))?.handedOverAt).toBeDefined())
|
||||
}
|
||||
|
||||
it('Stop, then a user send: the withdrawn draft and the paused cards behind it drain one per turn, in queue order', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const a = await queuedDraft('A')
|
||||
const b = await queuedDraft('B')
|
||||
const c = await queuedDraft('C')
|
||||
// The turn ends and the drain consumes A; the agent's start is held, so A is not handed over.
|
||||
let release: () => void = () => undefined
|
||||
rig.awaitStarted.mockImplementationOnce(
|
||||
() => new Promise<undefined>((resolve) => (release = () => resolve(undefined)))
|
||||
)
|
||||
await rig.settleAccepted(working, 'working')
|
||||
await eventually(async () => expect(await rig.handoff(a)).toBeDefined())
|
||||
// Never under the draft's own id: the submission names A by its link.
|
||||
const firstA = await rig.handoffId(a)
|
||||
expect(firstA).not.toBe(a)
|
||||
expect((await rig.submission(firstA))?.handedOverAt).toBeUndefined()
|
||||
|
||||
expect(await rig.stop()).toMatchObject({ ok: true })
|
||||
release()
|
||||
// A is back in its place, behind the same queue pause as B and C: no returned card blocks them.
|
||||
const paused = [a, b, c].map((messageId) => ({ messageId, state: 'waiting' }))
|
||||
expect(await rig.drafts()).toEqual(paused)
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
|
||||
const d = rig.send('D')
|
||||
await d.result
|
||||
await handedOver(d.id)
|
||||
expect(await rig.drafts()).toEqual(paused)
|
||||
await rig.settleAccepted(d.id, 'd')
|
||||
|
||||
// After D's turn, A drains first, under another fresh id: the first names the withdrawn submission.
|
||||
const before = new Set([working, firstA, d.id])
|
||||
let resentA = ''
|
||||
await eventually(async () => {
|
||||
const fresh = (await submissionIds()).filter((id) => !before.has(id))
|
||||
expect(fresh).toHaveLength(1)
|
||||
resentA = fresh[0] ?? ''
|
||||
})
|
||||
expect((await rig.submission(firstA))?.dispatchState).toBe('rejected')
|
||||
// Both hand-offs of A name it; D, a direct send, names no draft.
|
||||
expect((await rig.submission(resentA))?.queuedMessageId).toBe(a)
|
||||
expect((await rig.submission(firstA))?.queuedMessageId).toBe(a)
|
||||
expect(await rig.submission(d.id)).not.toHaveProperty('queuedMessageId')
|
||||
expect((await rig.submission(resentA))?.payloadFingerprint).toBe(
|
||||
(await rig.submission(firstA))?.payloadFingerprint
|
||||
)
|
||||
expect(await rig.drafts()).toEqual([
|
||||
{ messageId: b, state: 'waiting' },
|
||||
{ messageId: c, state: 'waiting' }
|
||||
])
|
||||
|
||||
await handedOver(resentA)
|
||||
await rig.settleAccepted(resentA, 'a')
|
||||
await eventually(async () => expect((await rig.handoff(b))?.queuedMessageId).toBe(b))
|
||||
expect(await rig.handoff(c)).toBeUndefined()
|
||||
await handedOver(await rig.handoffId(b))
|
||||
await rig.settleAccepted(await rig.handoffId(b), 'b')
|
||||
await eventually(async () => expect(await rig.handoff(c)).toBeDefined())
|
||||
expect(await rig.drafts()).toEqual([])
|
||||
})
|
||||
|
||||
it('a Stop that fails after withdrawing a consumed draft releases it, and it sends again under a fresh id', async () => {
|
||||
const working = await rig.workingSend()
|
||||
const a = await queuedDraft('A')
|
||||
let release: () => void = () => undefined
|
||||
rig.awaitStarted.mockImplementationOnce(
|
||||
() => new Promise<undefined>((resolve) => (release = () => resolve(undefined)))
|
||||
)
|
||||
await rig.settleAccepted(working, 'working')
|
||||
await eventually(async () => expect(await rig.handoff(a)).toBeDefined())
|
||||
const firstA = await rig.handoffId(a)
|
||||
const withdraw = AgentSessionJournal.prototype.rejectQueuedSubmissions
|
||||
const failing = vi
|
||||
.spyOn(AgentSessionJournal.prototype, 'rejectQueuedSubmissions')
|
||||
.mockImplementation(async function (this: AgentSessionJournal, ...args) {
|
||||
const withdrawn = await withdraw.apply(this, args)
|
||||
// Only the Stop's own withdrawal fails, after it landed; the delivery loop's pass through.
|
||||
if (args[1].rejection.kind === 'cancelled') {
|
||||
throw new Error('disk full')
|
||||
}
|
||||
return withdrawn
|
||||
})
|
||||
try {
|
||||
await expect(rig.stop()).rejects.toThrow('disk full')
|
||||
} finally {
|
||||
failing.mockRestore()
|
||||
release()
|
||||
}
|
||||
expect((await rig.submission(firstA))?.dispatchState).toBe('rejected')
|
||||
const before = new Set([working, firstA])
|
||||
await eventually(async () => {
|
||||
expect((await submissionIds()).filter((id) => !before.has(id))).toHaveLength(1)
|
||||
expect(await rig.drafts()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
/** A consumed card whose delivery is held at the agent's start, so a Stop withdraws it;
|
||||
* the settlement hook throws on that withdrawal's row, leaving the card owed a return. */
|
||||
async function stopWithSkippedSettlement(): Promise<{ a: string; working: string }> {
|
||||
const working = await rig.workingSend()
|
||||
const a = await queuedDraft('A')
|
||||
let release: () => void = () => undefined
|
||||
rig.awaitStarted.mockImplementationOnce(
|
||||
() => new Promise<undefined>((resolve) => (release = () => resolve(undefined)))
|
||||
)
|
||||
await rig.settleAccepted(working, 'working')
|
||||
await eventually(async () => expect(await rig.handoff(a)).toBeDefined())
|
||||
const settle = JournalQueuedMessages.prototype.onRowInTransaction
|
||||
let skipped = false
|
||||
const hook = vi
|
||||
.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction')
|
||||
.mockImplementation(function (this: JournalQueuedMessages, db, row) {
|
||||
if (!skipped && row.kind === 'dispatch' && row.state === 'rejected') {
|
||||
skipped = true
|
||||
throw new Error('bookkeeping failed')
|
||||
}
|
||||
return settle.call(this, db, row)
|
||||
})
|
||||
const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
try {
|
||||
expect(await rig.stop()).toMatchObject({ ok: true })
|
||||
} finally {
|
||||
hook.mockRestore()
|
||||
warned.mockRestore()
|
||||
release()
|
||||
}
|
||||
expect(skipped).toBe(true)
|
||||
return { a, working }
|
||||
}
|
||||
|
||||
it("a Stop whose withdrawal's settlement was skipped still pauses the card it sent back", async () => {
|
||||
const { a } = await stopWithSkippedSettlement()
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
// Healed back to waiting, but the Stop's pause holds it: it does not send.
|
||||
expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }])
|
||||
expect(await rig.resume()).toMatchObject({ ok: true, value: { resumed: true } })
|
||||
await eventually(async () =>
|
||||
expect(
|
||||
(await rig.host.journalSnapshot(SESSION)).submissions.filter(
|
||||
(entry) => entry.queuedMessageId === a
|
||||
)
|
||||
).toHaveLength(2)
|
||||
)
|
||||
})
|
||||
|
||||
it('the pause is recorded even when healing the skipped settlement fails, since the card is still owed', async () => {
|
||||
const heal = vi
|
||||
.spyOn(JournalQueuedMessages.prototype, 'settleOwed')
|
||||
.mockRejectedValueOnce(new Error('disk full'))
|
||||
try {
|
||||
const { a } = await stopWithSkippedSettlement()
|
||||
const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal
|
||||
expect(journal?.queuedMessages.pause()).toMatchObject({ reason: 'stopped' })
|
||||
// The drain heals it later; the pause recorded over the owed card holds it then.
|
||||
await eventually(async () =>
|
||||
expect(await rig.drafts()).toEqual([{ messageId: a, state: 'waiting' }])
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
expect(await rig.queuePause()).toEqual({ reason: 'stopped' })
|
||||
expect(
|
||||
(await rig.host.journalSnapshot(SESSION)).submissions.filter(
|
||||
(entry) => entry.queuedMessageId === a
|
||||
)
|
||||
).toHaveLength(1)
|
||||
} finally {
|
||||
heal.mockRestore()
|
||||
}
|
||||
})
|
||||
+15
-8
@@ -15,10 +15,11 @@ import {
|
||||
type UnreadAgentSessionFailureFact
|
||||
} from '../../../shared/agent-session-failure'
|
||||
import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals'
|
||||
import type {
|
||||
AgentSessionMutationEnvelope,
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionSendResult
|
||||
import {
|
||||
agentSessionSendSubmission,
|
||||
type AgentSessionMutationEnvelope,
|
||||
type AgentSessionMutationResult,
|
||||
type AgentSessionSendResult
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import {
|
||||
@@ -95,9 +96,13 @@ export function restartContinuationDeps(
|
||||
}
|
||||
}),
|
||||
awaitSettlement: async (sessionId, clientMessageId) =>
|
||||
(await host.awaitSendSettlement(sessionId, clientMessageId))?.value.submission,
|
||||
agentSessionSendSubmission(
|
||||
(await host.awaitSendSettlement(sessionId, clientMessageId))?.value
|
||||
),
|
||||
awaitHandedOver: async (sessionId, clientMessageId) =>
|
||||
(await host.awaitSendHandedOver(sessionId, clientMessageId))?.value.submission,
|
||||
agentSessionSendSubmission(
|
||||
(await host.awaitSendHandedOver(sessionId, clientMessageId))?.value
|
||||
),
|
||||
onNoteFailed: host.onNoteFailed,
|
||||
note: restartNoteWriter(host)
|
||||
}
|
||||
@@ -155,8 +160,10 @@ export type StructuredAgentSessionContinuationDeps = {
|
||||
}) => Promise<{
|
||||
ok: boolean
|
||||
refusal?: { code: string }
|
||||
/** The submission is where the provider's answer lives; the envelope only says Orca took it. */
|
||||
value?: { submission?: { dispatchState?: string; reason?: string | null } }
|
||||
/** The submission is where the provider's answer lives; the envelope only says Orca took it.
|
||||
* A continuation never sends `delivery`, so a queued answer cannot arrive; the key exists so
|
||||
* the host's union return stays assignable. */
|
||||
value?: { submission?: { dispatchState?: string; reason?: string | null }; queued?: unknown }
|
||||
}>
|
||||
/**
|
||||
* Waits for that send's dispatch to stop being `pending`, through the host's existing settlement
|
||||
|
||||
+5
-2
@@ -62,8 +62,11 @@ describe('a wait that also ends behind a running command', () => {
|
||||
settlements.publish('session-1', queuedJournal('compact:cmd-1'))
|
||||
|
||||
const settled = await pending
|
||||
expect(settled?.value.submission.dispatchState).toBe('pending')
|
||||
expect(settled?.value.submission).not.toHaveProperty('handedOverAt')
|
||||
if (!settled || !('submission' in settled.value)) {
|
||||
throw new Error('expected the submission arm')
|
||||
}
|
||||
expect(settled.value.submission.dispatchState).toBe('pending')
|
||||
expect(settled.value.submission).not.toHaveProperty('handedOverAt')
|
||||
})
|
||||
|
||||
it('keeps waiting for the handover behind anything that is not a command', async () => {
|
||||
|
||||
@@ -67,6 +67,9 @@ describe('send', () => {
|
||||
if (!result.ok) {
|
||||
throw new Error(`expected a send, got ${result.refusal.code}`)
|
||||
}
|
||||
if (!('submission' in result.value)) {
|
||||
throw new Error('expected the submission arm')
|
||||
}
|
||||
// Answered once accepted; the delivery loop hands it over after.
|
||||
expect(result.value.submission).toMatchObject({
|
||||
dispatchState: 'pending',
|
||||
|
||||
@@ -8,20 +8,18 @@ import type {
|
||||
AgentJournalCursor,
|
||||
AgentJournalResetReason
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
AGENT_SESSION_HISTORY_MAX_LIMIT,
|
||||
type AgentSessionBackgroundTaskState,
|
||||
type AgentSessionSlashCommand,
|
||||
type AgentSessionSubscribeEvent,
|
||||
type AgentSessionTurnActivity
|
||||
import type {
|
||||
AgentSessionBackgroundTaskState,
|
||||
AgentSessionSlashCommand,
|
||||
AgentSessionSubscribeEvent,
|
||||
AgentSessionTurnActivity
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { sameJournalCursor } from '../agent-session-journal/journal-cursor'
|
||||
import { buildSubscriberFrame } from './agent-session-subscriber-frame-fields'
|
||||
import type { QueuePublication } from './structured-agent-session-queued-publication'
|
||||
import { deliverToSubscriber } from './agent-session-subscriber-catch-up'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { emptyAgentSessionBatch } from './agent-session-empty-batch'
|
||||
import {
|
||||
createAgentSessionCatchUpReader,
|
||||
readAgentSessionHydrationPage
|
||||
} from './agent-session-history-page'
|
||||
import { readAgentSessionHydrationPage } from './agent-session-history-page'
|
||||
import { rememberSessionActivity } from './structured-agent-session-activity-retention'
|
||||
|
||||
export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void
|
||||
@@ -32,17 +30,23 @@ export type AgentSessionSubscribeInput = {
|
||||
cursor?: AgentJournalCursor
|
||||
}
|
||||
|
||||
type Subscriber = {
|
||||
export type Subscriber = {
|
||||
id: string
|
||||
sessionId: string
|
||||
emit: AgentSessionSubscriberEmit
|
||||
cursor: AgentJournalCursor
|
||||
fence: number
|
||||
commands?: AgentSessionSlashCommand[] | null
|
||||
/** The last draft list actually SENT — never advanced on a page that withheld
|
||||
* it, or the final replacement would be suppressed by the identity dedup. */
|
||||
queuePublication?: QueuePublication
|
||||
}
|
||||
|
||||
export type AgentSessionSubscribersHooks = {
|
||||
readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined
|
||||
/** Revision-stable per emit: an unchanged list keeps its reference, so token
|
||||
* streams never re-serialize it; any draft-table write changes it. */
|
||||
readQueuePublication?: (sessionId: string) => QueuePublication | undefined
|
||||
/** Fires after publications that can change journal content. */
|
||||
onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void
|
||||
now?: () => number
|
||||
@@ -214,86 +218,15 @@ export class AgentSessionSubscribers {
|
||||
backgroundTasks?: AgentSessionBackgroundTaskState | null,
|
||||
activity?: AgentSessionTurnActivity | null
|
||||
): void {
|
||||
const checkpointActivity = emitCheckpoint
|
||||
? this.activityField(subscriber.sessionId).activity
|
||||
: undefined
|
||||
const publishedActivity = activity !== undefined ? activity : checkpointActivity
|
||||
// Caught up, so there are no rows to read: every publish behind a commit's own delivery.
|
||||
if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) {
|
||||
this.emitCaughtUp(subscriber, hostNow, emitCheckpoint, backgroundTasks, publishedActivity)
|
||||
return
|
||||
}
|
||||
const readPage = createAgentSessionCatchUpReader(journal)
|
||||
while (true) {
|
||||
const result = readPage({
|
||||
sessionId: subscriber.sessionId,
|
||||
direction: 'after',
|
||||
cursor: subscriber.cursor,
|
||||
limit: AGENT_SESSION_HISTORY_MAX_LIMIT
|
||||
})
|
||||
if (!result.ok) {
|
||||
const page = { ...result.page, fence: subscriber.fence }
|
||||
this.emit(subscriber, {
|
||||
type: 'reset',
|
||||
sessionId: subscriber.sessionId,
|
||||
reset: result.reset,
|
||||
page,
|
||||
fence: subscriber.fence,
|
||||
hostNow,
|
||||
...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
|
||||
...(publishedActivity !== undefined ? { activity: publishedActivity } : {})
|
||||
})
|
||||
subscriber.cursor = page.liveCursor ?? page.window.nextCursor
|
||||
return
|
||||
}
|
||||
const page = result.page
|
||||
const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence
|
||||
if (!advanced) {
|
||||
this.emitCaughtUp(subscriber, hostNow, emitCheckpoint, backgroundTasks, publishedActivity)
|
||||
return
|
||||
}
|
||||
this.emit(subscriber, {
|
||||
type: 'batch',
|
||||
sessionId: subscriber.sessionId,
|
||||
batch: {
|
||||
cursor: page.window.nextCursor,
|
||||
items: page.items,
|
||||
removedItemIds: page.removedItemIds,
|
||||
submissions: page.submissions
|
||||
},
|
||||
fence: subscriber.fence,
|
||||
hostNow,
|
||||
...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
|
||||
...(publishedActivity !== undefined ? { activity: publishedActivity } : {})
|
||||
})
|
||||
subscriber.cursor = page.window.nextCursor
|
||||
if (!page.hasNewer || !this.isActive(subscriber)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emitCaughtUp(
|
||||
subscriber: Subscriber,
|
||||
hostNow: number,
|
||||
emitCheckpoint: boolean,
|
||||
backgroundTasks: AgentSessionBackgroundTaskState | null | undefined,
|
||||
activity: AgentSessionTurnActivity | null | undefined
|
||||
): void {
|
||||
const commandsChanged =
|
||||
this.hooks.readCommands !== undefined &&
|
||||
(this.hooks.readCommands(subscriber.sessionId) ?? null) !== subscriber.commands
|
||||
if (emitCheckpoint || activity !== undefined || commandsChanged) {
|
||||
this.emit(subscriber, {
|
||||
type: 'batch',
|
||||
sessionId: subscriber.sessionId,
|
||||
batch: emptyAgentSessionBatch(subscriber.cursor),
|
||||
fence: subscriber.fence,
|
||||
hostNow,
|
||||
...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
|
||||
...(activity !== undefined ? { activity } : {})
|
||||
})
|
||||
}
|
||||
deliverToSubscriber(
|
||||
{
|
||||
hooks: this.hooks,
|
||||
emit: (target, event, options) => this.emit(target, event, options),
|
||||
isActive: (target) => this.isActive(target),
|
||||
activity: (sessionId) => this.activityField(sessionId).activity
|
||||
},
|
||||
{ subscriber, journal, hostNow, emitCheckpoint, backgroundTasks, activity }
|
||||
)
|
||||
}
|
||||
|
||||
private now = (): number => this.hooks.now?.() ?? Date.now()
|
||||
@@ -303,15 +236,23 @@ export class AgentSessionSubscribers {
|
||||
|
||||
/** A dead transport cannot be allowed to turn a durable mutation into an
|
||||
* unknown outcome or poison every later publication. */
|
||||
private emit(subscriber: Subscriber, event: AgentSessionSubscribeEvent): void {
|
||||
private emit(
|
||||
subscriber: Subscriber,
|
||||
event: AgentSessionSubscribeEvent,
|
||||
options?: { withholdQueued?: boolean }
|
||||
): void {
|
||||
try {
|
||||
const commands = this.hooks.readCommands?.(subscriber.sessionId) ?? null
|
||||
const includeCommands =
|
||||
this.hooks.readCommands !== undefined &&
|
||||
event.type !== 'end' &&
|
||||
(event.type !== 'batch' || commands !== subscriber.commands)
|
||||
subscriber.emit(includeCommands ? { ...event, commands: commands ?? null } : event)
|
||||
subscriber.commands = commands
|
||||
const built = buildSubscriberFrame(
|
||||
this.hooks,
|
||||
subscriber,
|
||||
event,
|
||||
options?.withholdQueued === true
|
||||
)
|
||||
subscriber.emit(built.frame)
|
||||
subscriber.commands = built.commands
|
||||
if (built.attachedQueued) {
|
||||
subscriber.queuePublication = built.queued
|
||||
}
|
||||
} catch {
|
||||
this.drop(subscriber)
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ export async function performSend(
|
||||
clientMessageId: string
|
||||
payloadFingerprint: string
|
||||
body: AgentJournalMessageItem
|
||||
/** Who asked for the turn; absent on callers that predate it. */
|
||||
origin?: 'client' | 'host'
|
||||
}
|
||||
): Promise<TurnOutcome<AgentSessionSendResult>> {
|
||||
const existing = ctx.journal
|
||||
|
||||
@@ -45,6 +45,9 @@ export class StructuredConversationCommandController {
|
||||
if (--entry.count === 0 && this.pending.get(params.envelope.sessionId) === entry) {
|
||||
this.pending.delete(params.envelope.sessionId)
|
||||
}
|
||||
// A clear can settle with no journal commit (a failed attach), and drafts held behind
|
||||
// its prepared phase would otherwise wait for an unrelated commit.
|
||||
this.context().wakeQueuedDrain?.(params.envelope.sessionId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type AgentSessionFailureWordsContext
|
||||
} from '../../../shared/agent-session-failure-words'
|
||||
import { structuredAgentSessionStartFailureFact } from './structured-agent-session-failure-text'
|
||||
import { carryQueuedMessagesToClearReplacement } from './structured-agent-session-queued-mutations'
|
||||
|
||||
/** A command's `error` is the sentence its row shows. */
|
||||
export function conversationCommandFailure(
|
||||
@@ -265,6 +266,15 @@ export function runStructuredConversationCommand(
|
||||
await store.setConversationCommand(sessionId, ctx.fence, failed)
|
||||
return { ok: true, value: failed }
|
||||
}
|
||||
// Carry the source's drafts to the replacement, the same for every client version:
|
||||
// the cards stay visible where the user now is, and no text rides the wire.
|
||||
// Bookkeeping — a failure is reported and never fails the clear.
|
||||
await carryQueuedMessagesToClearReplacement(ctx, {
|
||||
replacementSessionId,
|
||||
replacementJournal: context.sessions.get(replacementSessionId)?.journal,
|
||||
callerKey: caller.callerKey,
|
||||
operationId: clientOperationId
|
||||
})
|
||||
}
|
||||
const completed = {
|
||||
...base,
|
||||
|
||||
@@ -75,10 +75,14 @@ export async function runStructuredCompaction(
|
||||
return {
|
||||
...accepted,
|
||||
...(settled ? { cursor: settled.cursor } : {}),
|
||||
value: compactionReply(settled?.value.submission, {
|
||||
...structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(sessionId)),
|
||||
command: 'compact'
|
||||
})
|
||||
// A /compact is a command send, never a queued draft, so its settlement always carries the submission.
|
||||
value: compactionReply(
|
||||
settled && 'submission' in settled.value ? settled.value.submission : undefined,
|
||||
{
|
||||
...structuredAgentSessionFailureWordsContext(context.deps.store.getRecord(sessionId)),
|
||||
command: 'compact'
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise<str
|
||||
body
|
||||
})
|
||||
expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true, replayed: false })
|
||||
if (sent.ok) {
|
||||
if (sent.ok && 'submission' in sent.value) {
|
||||
answered.set(clientOperationId, sent.value.submission.dispatchState)
|
||||
}
|
||||
return clientOperationId
|
||||
|
||||
@@ -161,6 +161,9 @@ describe('mobile RPC allowlist', () => {
|
||||
'agentSession.reveal',
|
||||
'agentSession.send',
|
||||
'agentSession.cancel',
|
||||
'agentSession.queuedMessageSend',
|
||||
'agentSession.queuedMessageDelete',
|
||||
'agentSession.queuedMessagesResume',
|
||||
'agentSession.close',
|
||||
'agentSession.respondToApproval',
|
||||
'agentSession.respondToQuestion',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../shared/orchestration-timing-budgets'
|
||||
import { agentSessionSendSubmission } from '../../../shared/agent-session-wire'
|
||||
import { AGENT_SESSION_NOT_ATTACHED } from '../../native-chat/agent-session-wire/structured-agent-session-mutation-admission'
|
||||
import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type {
|
||||
@@ -115,17 +116,20 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo
|
||||
}
|
||||
// `pending` is not yet an acknowledgement; only `accepted` may consume mail. Accepted is not
|
||||
// delivered, so wait out a start; a wait that runs out parks for the next journal edge.
|
||||
const answered = agentSessionSendSubmission(result.value)
|
||||
const submission =
|
||||
result.value.submission.dispatchState === 'pending'
|
||||
? ((
|
||||
await host
|
||||
.waitForSendSettlement(input.sessionId, result.value.clientMessageId, {
|
||||
budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS
|
||||
})
|
||||
.catch(() => undefined)
|
||||
)?.value.submission ?? result.value.submission)
|
||||
: result.value.submission
|
||||
const state = submission.dispatchState
|
||||
answered?.dispatchState === 'pending'
|
||||
? (agentSessionSendSubmission(
|
||||
(
|
||||
await host
|
||||
.waitForSendSettlement(input.sessionId, result.value.clientMessageId, {
|
||||
budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS
|
||||
})
|
||||
.catch(() => undefined)
|
||||
)?.value
|
||||
) ?? answered)
|
||||
: answered
|
||||
const state = submission?.dispatchState
|
||||
return {
|
||||
kind: 'sent',
|
||||
state: state === 'accepted' ? 'accepted' : state === 'rejected' ? 'rejected' : 'unknown'
|
||||
|
||||
@@ -15,6 +15,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal'
|
||||
import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types'
|
||||
import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../../shared/orchestration-timing-budgets'
|
||||
import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire'
|
||||
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
@@ -245,20 +246,23 @@ export async function sendStructuredWorkerPreamble(args: {
|
||||
throw new Error(`The dispatch preamble was refused: ${result.refusal.message}`)
|
||||
}
|
||||
// Accepted is not delivered: the worker's agent may still be starting.
|
||||
const answered = agentSessionSendSubmission(result.value)
|
||||
const submission =
|
||||
result.value.submission.dispatchState === 'pending'
|
||||
? ((
|
||||
await args.host
|
||||
.waitForSendSettlement(args.sessionId, result.value.clientMessageId, {
|
||||
budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS
|
||||
})
|
||||
.catch(() => undefined)
|
||||
)?.value.submission ?? result.value.submission)
|
||||
: result.value.submission
|
||||
if (submission.dispatchState === 'accepted' || submission.dispatchState === 'pending') {
|
||||
answered?.dispatchState === 'pending'
|
||||
? (agentSessionSendSubmission(
|
||||
(
|
||||
await args.host
|
||||
.waitForSendSettlement(args.sessionId, result.value.clientMessageId, {
|
||||
budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS
|
||||
})
|
||||
.catch(() => undefined)
|
||||
)?.value
|
||||
) ?? answered)
|
||||
: answered
|
||||
if (submission?.dispatchState === 'accepted' || submission?.dispatchState === 'pending') {
|
||||
return submission.dispatchState
|
||||
}
|
||||
if (submission.dispatchState === 'rejected') {
|
||||
if (submission?.dispatchState === 'rejected') {
|
||||
// A rejection is a verdict, not a mystery: the preamble provably did not happen.
|
||||
// `dispatch_preamble_undelivered` says exactly that, and says it as a code rather
|
||||
// than as prose, so a coordinator can tell "we could not send it" apart from
|
||||
@@ -277,7 +281,7 @@ export async function sendStructuredWorkerPreamble(args: {
|
||||
// `outcome_unknown` receipt whose nextCommands send the coordinator to look.
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`The dispatch preamble was submitted but not acknowledged (${submission.dispatchState}): ${reasonClause(submission.reason)}.`
|
||||
`The dispatch preamble was submitted but not acknowledged (${submission?.dispatchState ?? 'unknown'}): ${reasonClause(submission?.reason)}.`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -56,6 +56,15 @@ export const ADMISSION_METHODS = [
|
||||
},
|
||||
{ method: 'agentSession.ensure', params: attachParams() },
|
||||
{ method: 'agentSession.send', params: sendParams() },
|
||||
{
|
||||
method: 'agentSession.queuedMessageSend',
|
||||
params: { envelope: envelope(), messageId: 'queued-1' }
|
||||
},
|
||||
{
|
||||
method: 'agentSession.queuedMessageDelete',
|
||||
params: { envelope: envelope(), messageId: 'queued-1' }
|
||||
},
|
||||
{ method: 'agentSession.queuedMessagesResume', params: { envelope: envelope() } },
|
||||
{
|
||||
method: 'agentSession.rewind',
|
||||
params: { envelope: envelope(), itemId: 'chosen', expectedEpoch: 'epoch' }
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// The queued-message actions: Send-now and Delete on one card, and Resume on a
|
||||
// paused queue. All gated on agent-session.queued-messages.v1; an older host
|
||||
// lacks the methods entirely.
|
||||
|
||||
import { defineMethod } from '../core'
|
||||
import {
|
||||
requireStructuredHost as requireHost,
|
||||
structuredCallerFor as callerFor
|
||||
} from './structured-agent-session-gate'
|
||||
import {
|
||||
QueuedMessageActionParams,
|
||||
QueuedMessagesResumeParams
|
||||
} from './structured-agent-session-schemas'
|
||||
|
||||
export const STRUCTURED_AGENT_SESSION_QUEUED_METHODS = [
|
||||
defineMethod({
|
||||
name: 'agentSession.queuedMessageSend',
|
||||
params: QueuedMessageActionParams,
|
||||
handler: async (params, ctx) => requireHost(ctx).queuedMessageSend(callerFor(ctx), params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.queuedMessageDelete',
|
||||
params: QueuedMessageActionParams,
|
||||
handler: async (params, ctx) => requireHost(ctx).queuedMessageDelete(callerFor(ctx), params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.queuedMessagesResume',
|
||||
params: QueuedMessagesResumeParams,
|
||||
handler: async (params, ctx) => requireHost(ctx).queuedMessagesResume(callerFor(ctx), params)
|
||||
})
|
||||
]
|
||||
@@ -16,6 +16,8 @@ export {
|
||||
ModelCatalogParams,
|
||||
MutationEnvelope,
|
||||
OptionsParams,
|
||||
QueuedMessageActionParams,
|
||||
QueuedMessagesResumeParams,
|
||||
RespondParams,
|
||||
RespondToQuestionParams,
|
||||
RestartResumableParams,
|
||||
|
||||
@@ -39,6 +39,12 @@ describe('agentSession.send reply timing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it("marks a client's send as the user's own, which alone lifts a Stop's queue pause", async () => {
|
||||
hostCalls.send.mockResolvedValueOnce(pendingSendResult())
|
||||
await call('agentSession.send', sendParams(), STRUCTURED_CLIENT)
|
||||
expect(hostCalls.send.mock.calls[0]?.[1]).toMatchObject({ userSend: true })
|
||||
})
|
||||
|
||||
it('answers at acceptance for the local desktop and paired desktop clients (W2)', async () => {
|
||||
for (const clientCapabilities of [
|
||||
DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/protocol-version'
|
||||
import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire'
|
||||
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
|
||||
import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement'
|
||||
import type { RpcContext } from '../core'
|
||||
@@ -19,11 +20,13 @@ export async function sendStructuredAgentSessionForClient(
|
||||
context: RpcContext
|
||||
) {
|
||||
const host = requireStructuredHost(context)
|
||||
const result = await host.send(structuredCallerFor(context), params)
|
||||
// Only a client's own send lifts a Stop's queue pause; host-internal senders never do.
|
||||
const result = await host.send(structuredCallerFor(context), { ...params, userSend: true })
|
||||
const capabilities = context.clientCapabilities ?? []
|
||||
if (
|
||||
!result.ok ||
|
||||
result.value.submission.dispatchState !== 'pending' ||
|
||||
// A queued answer only ever reaches a capable client, which renders it as-is.
|
||||
agentSessionSendSubmission(result.value)?.dispatchState !== 'pending' ||
|
||||
context.clientKind === undefined ||
|
||||
capabilities.includes(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY)
|
||||
) {
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('capability gating', () => {
|
||||
}
|
||||
// Bump deliberately: the whole agentSession.* surface is behind the structured capability,
|
||||
// so an additive method is invisible to old clients and needs no protocol bump.
|
||||
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(29)
|
||||
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(32)
|
||||
})
|
||||
|
||||
it('hides the surface from a declared client that did not advertise it', async () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from './structured-agent-session-create'
|
||||
import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold'
|
||||
import { STRUCTURED_AGENT_SESSION_REVEAL_METHODS } from './structured-agent-session-reveal'
|
||||
import { STRUCTURED_AGENT_SESSION_QUEUED_METHODS } from './structured-agent-session-queued-methods'
|
||||
import { STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS } from './structured-agent-session-restart-resume'
|
||||
import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal'
|
||||
import {
|
||||
@@ -206,6 +207,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [
|
||||
params: CancelParams,
|
||||
handler: async (params, ctx) => requireStructuredCleanupHost(ctx).cancel(callerFor(ctx), params)
|
||||
}),
|
||||
...STRUCTURED_AGENT_SESSION_QUEUED_METHODS,
|
||||
defineMethod({
|
||||
// Releasing a chat view, not ending a conversation: the record and journal stay on disk so the
|
||||
// same session can be attached again. Only the provider child and the in-memory entry go.
|
||||
|
||||
@@ -218,6 +218,9 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'agentSession.reveal',
|
||||
'agentSession.send',
|
||||
'agentSession.cancel',
|
||||
'agentSession.queuedMessageSend',
|
||||
'agentSession.queuedMessageDelete',
|
||||
'agentSession.queuedMessagesResume',
|
||||
'agentSession.close',
|
||||
'agentSession.respondToApproval',
|
||||
'agentSession.respondToQuestion',
|
||||
|
||||
+11
-4
@@ -118,10 +118,17 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: {
|
||||
createOperationId: args.createOperationId
|
||||
})
|
||||
)
|
||||
return result.ok
|
||||
? result.value.submission.dispatchState === 'accepted' ||
|
||||
result.value.submission.dispatchState === 'pending'
|
||||
: false
|
||||
if (!result.ok) {
|
||||
return false
|
||||
}
|
||||
// A queued answer retired the entry; the queue keeps moving.
|
||||
if ('queued' in result.value) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
result.value.submission.dispatchState === 'accepted' ||
|
||||
result.value.submission.dispatchState === 'pending'
|
||||
)
|
||||
} catch (caught) {
|
||||
if (args.dispatchGenerationRef.current !== args.dispatchGeneration) {
|
||||
return false
|
||||
|
||||
@@ -119,6 +119,11 @@ async function dispatchStructuredLaunchPrompt(
|
||||
)
|
||||
return false
|
||||
}
|
||||
if ('queued' in result.value) {
|
||||
// The host holds the draft; the outbox entry is spent.
|
||||
mutateEntry(entry, () => null)
|
||||
return true
|
||||
}
|
||||
const dispatchState = result.value.submission.dispatchState
|
||||
mutateEntry(entry, (current) =>
|
||||
dispatchState === 'accepted'
|
||||
|
||||
@@ -328,7 +328,9 @@ export const AgentJournalSubmissionSchema = z.object({
|
||||
recovered: z.literal(true).optional(),
|
||||
handoverRecorded: z.literal(true).optional(),
|
||||
handedOverAt: z.number().optional(),
|
||||
rejection: FailureFact.optional()
|
||||
rejection: FailureFact.optional(),
|
||||
// Listed, or the parse strips it: this schema drops unknown keys.
|
||||
queuedMessageId: z.string().min(1).optional()
|
||||
})
|
||||
|
||||
export function isAgentJournalResolution(value: unknown): value is AgentJournalResolution {
|
||||
|
||||
@@ -418,6 +418,12 @@ export type AgentJournalSubmission = {
|
||||
handedOverAt?: number
|
||||
/** Host-only: the submission row's sequence, which tells which host process accepted it. */
|
||||
acceptedSequence?: number
|
||||
/** The queued draft this submission hands off; absent for a direct send. Read this, never
|
||||
* a draft id compared with `clientMessageId`. */
|
||||
queuedMessageId?: string
|
||||
/** Host-only: who asked for this turn — a person over the client send RPC, or Orca itself.
|
||||
* A person's turn is what ends a Stop's queue pause. */
|
||||
origin?: 'client' | 'host'
|
||||
}
|
||||
|
||||
/** Durable answer to "did my send land?", keyed by client message id. Only an
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// The queued-message part of the agent-session wire: the published draft cards,
|
||||
// the queue's pause beside them, and the draft mutations' answers.
|
||||
|
||||
import type { UnreadAgentSessionFailureFact } from './agent-session-failure'
|
||||
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
|
||||
|
||||
export type AgentSessionQueuedMessagePausedReason = typeof QUEUED_MESSAGE_PAUSED_SEND_FAILED
|
||||
|
||||
/** The whole queue is paused and sends nothing on its own: 'stopped' — the user
|
||||
* interrupted ("Queue paused because you interrupted") — 'cleared' — a /clear
|
||||
* carried the cards into a fresh conversation — or 'restarted' — Orca restarted
|
||||
* with cards waiting. Resume (`agentSession.queuedMessagesResume`), or the user's own next
|
||||
* 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' | 'restarted' | 'cleared' }
|
||||
|
||||
export type AgentSessionQueuedMessagesResumeResult = {
|
||||
/** False when nothing was paused, and on a replay of an already-run Resume. */
|
||||
resumed: boolean
|
||||
}
|
||||
|
||||
/** One draft the host holds for this conversation, published whole-list on the
|
||||
* subscribe stream and on history pages. Text-only v1. */
|
||||
export type AgentSessionQueuedMessage = {
|
||||
messageId: string
|
||||
position: number
|
||||
body: AgentJournalMessageItem
|
||||
state: 'waiting' | 'returned'
|
||||
/** 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"; 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
|
||||
* wrote no fact) exactly as they classify a rejected submission's `rejection`, e.g.
|
||||
* `classifyDispatchRejection({ reason: returnedReason, rejection: returnedRejection })`. */
|
||||
returnedReason?: string | null
|
||||
returnedRejection?: UnreadAgentSessionFailureFact
|
||||
}
|
||||
|
||||
/** No body: the card leaving the published list IS the outcome, so a lost
|
||||
* answer needs no re-ask and no text ever rides the wire back. */
|
||||
export type AgentSessionQueuedMessageDeleteResult =
|
||||
| { deleted: true; messageId: string }
|
||||
/** `dispatched` means it already became a submission; `missing` covers a
|
||||
* pruned tombstone. Replays answer from tombstone receipts. */
|
||||
| { deleted: false; messageId: string; disposition: 'dispatched' | 'withdrawn' | 'missing' }
|
||||
@@ -4,8 +4,13 @@ import type {
|
||||
} from './agent-session-background-task-wire'
|
||||
import type { AgentSessionRewindReason, AgentSessionRewindSupport } from './agent-session-rewind'
|
||||
import type { AgentSessionWireRefusal } from './agent-session-wire-refusals'
|
||||
import type {
|
||||
AgentSessionQueuedMessage,
|
||||
AgentSessionQueuePause
|
||||
} from './agent-session-queued-message-wire'
|
||||
|
||||
export * from './agent-session-wire-refusals'
|
||||
export * from './agent-session-queued-message-wire'
|
||||
import type { AgentSessionConversationCommand } from './agent-session-conversation-command'
|
||||
import type { AgentSessionContextUsage } from './agent-session-context-usage'
|
||||
// ─── Structured agent-session wire contract ─────────────────────────────────
|
||||
@@ -103,6 +108,12 @@ export type AgentSessionHistoryPage = {
|
||||
hasNewer: boolean
|
||||
/** Present on hosts that expose provider-owned background task lifecycle. */
|
||||
backgroundTasks?: AgentSessionBackgroundTaskState | null
|
||||
/** The host's queued drafts. Absent = no claim (older host); `[]`/null = empty.
|
||||
* Live subscription state stays authoritative over a stale history answer. */
|
||||
queuedMessages?: AgentSessionQueuedMessage[] | null
|
||||
/** The queue's pause, published with the list: present whenever `queuedMessages` is, null
|
||||
* when the queue sends on its own. */
|
||||
queuePause?: AgentSessionQueuePause | null
|
||||
/** Host wall clock (ms epoch) when the page was read, so a client attaching mid-turn
|
||||
* can anchor a live counter on the real start. Absent from older hosts. */
|
||||
hostNow?: number
|
||||
@@ -140,6 +151,10 @@ export type AgentSessionSubscribeEvent =
|
||||
page: AgentSessionHistoryPage
|
||||
fence: number
|
||||
backgroundTasks?: AgentSessionBackgroundTaskState | null
|
||||
/** Whole-list draft publication; omitted when unchanged since the last frame sent. */
|
||||
queuedMessages?: AgentSessionQueuedMessage[] | null
|
||||
/** Rides with `queuedMessages`; null when the queue sends on its own. */
|
||||
queuePause?: AgentSessionQueuePause | null
|
||||
/** Omitted when unchanged; null clears a previous provider catalog. */
|
||||
commands?: AgentSessionSlashCommand[] | null
|
||||
/** Latest provider-authored turn activity; optional for mixed-version hosts. */
|
||||
@@ -152,6 +167,11 @@ export type AgentSessionSubscribeEvent =
|
||||
/** Optional so mixed-version cursors retain the ownership fence. */
|
||||
fence?: number
|
||||
backgroundTasks?: AgentSessionBackgroundTaskState | null
|
||||
/** Whole-list draft publication. On a multi-page catch-up it rides only the
|
||||
* final page, so a consumed card never vanishes before its bubble arrives. */
|
||||
queuedMessages?: AgentSessionQueuedMessage[] | null
|
||||
/** Rides with `queuedMessages`; null when the queue sends on its own. */
|
||||
queuePause?: AgentSessionQueuePause | null
|
||||
/** Omitted when unchanged; null clears a previous provider catalog. */
|
||||
commands?: AgentSessionSlashCommand[] | null
|
||||
/** Additive ephemeral state; it never creates or advances journal rows. */
|
||||
@@ -164,6 +184,10 @@ export type AgentSessionSubscribeEvent =
|
||||
page: AgentSessionHistoryPage
|
||||
fence: number
|
||||
backgroundTasks?: AgentSessionBackgroundTaskState | null
|
||||
/** Whole-list draft publication; a reset re-hydrates it with the page. */
|
||||
queuedMessages?: AgentSessionQueuedMessage[] | null
|
||||
/** Rides with `queuedMessages`; null when the queue sends on its own. */
|
||||
queuePause?: AgentSessionQueuePause | null
|
||||
/** Omitted when unchanged; null clears a previous provider catalog. */
|
||||
commands?: AgentSessionSlashCommand[] | null
|
||||
activity?: AgentSessionTurnActivity | null
|
||||
@@ -308,9 +332,30 @@ export type AgentSessionAttachResult = {
|
||||
tabId?: string
|
||||
}
|
||||
|
||||
export type AgentSessionSendResult = {
|
||||
clientMessageId: string
|
||||
submission: AgentJournalSubmission
|
||||
/** The host queued the send as a draft instead of submitting it. Only clients
|
||||
* that sent `delivery: 'queue-if-active'` — gated on
|
||||
* `agent-session.queued-messages.v1` — ever receive this arm; `state` other
|
||||
* than `waiting` appears only on replays of an already-settled draft. */
|
||||
export type AgentSessionQueuedSendReceipt = {
|
||||
messageId: string
|
||||
position: number
|
||||
state: 'waiting' | 'dispatched' | 'returned' | 'withdrawn'
|
||||
}
|
||||
|
||||
export type AgentSessionSendResult =
|
||||
| {
|
||||
clientMessageId: string
|
||||
submission: AgentJournalSubmission
|
||||
}
|
||||
| { clientMessageId: string; queued: AgentSessionQueuedSendReceipt }
|
||||
|
||||
/** The submission arm's payload; undefined for a queued answer. For callers that
|
||||
* never send `delivery` the queued arm cannot arrive, and `undefined` reads as
|
||||
* delivery-unknown rather than as an error. */
|
||||
export function agentSessionSendSubmission(
|
||||
result: AgentSessionSendResult | undefined
|
||||
): AgentJournalSubmission | undefined {
|
||||
return result !== undefined && 'submission' in result ? result.submission : undefined
|
||||
}
|
||||
|
||||
export type AgentSessionCancelResult = {
|
||||
|
||||
@@ -176,6 +176,17 @@ export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY =
|
||||
// only Stop a client can send before the provider has opened a turn.
|
||||
export const AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY =
|
||||
'agent-session.conversation-stop.v1' as const
|
||||
// Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a
|
||||
// capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON
|
||||
// PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer
|
||||
// matrix (#21062) in the shipped host, and the desktop and phone clients that render the queue.
|
||||
// v1 includes `submission.queuedMessageId` on every draft hand-off: a client reads that link and
|
||||
// never compares a draft id with a submission id. It also publishes the queue's pause once, as
|
||||
// `queuePause` beside the list, lifted by `agentSession.queuedMessagesResume` or the user's next
|
||||
// turn; cards carry a hold of their own only when their conversion failed. The host mechanism lands first; the constant
|
||||
// gates the rollout.
|
||||
export const AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY =
|
||||
'agent-session.queued-messages.v1' as const
|
||||
// Why: paired clients advertise Claude-structured support so the host can gate its agent-specific
|
||||
// journal and lifecycle surfaces independently from Codex support.
|
||||
export const CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY =
|
||||
|
||||
@@ -473,6 +473,8 @@ import {
|
||||
HoldParams,
|
||||
ModelCatalogParams,
|
||||
OptionsParams,
|
||||
QueuedMessageActionParams,
|
||||
QueuedMessagesResumeParams,
|
||||
RespondParams,
|
||||
RespondToQuestionParams,
|
||||
RestartResumableParams,
|
||||
@@ -578,6 +580,9 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'agentSession.hold': HoldParams,
|
||||
'agentSession.modelCatalog': ModelCatalogParams,
|
||||
'agentSession.options': OptionsParams,
|
||||
'agentSession.queuedMessageDelete': QueuedMessageActionParams,
|
||||
'agentSession.queuedMessageSend': QueuedMessageActionParams,
|
||||
'agentSession.queuedMessagesResume': QueuedMessagesResumeParams,
|
||||
'agentSession.release': HoldParams,
|
||||
'agentSession.respondToApproval': RespondParams,
|
||||
'agentSession.respondToQuestion': RespondToQuestionParams,
|
||||
|
||||
@@ -168,6 +168,10 @@ export const SendParams = z
|
||||
.object({
|
||||
envelope: MutationEnvelope,
|
||||
retryUnknown: z.literal(true).optional(),
|
||||
/** Queue the send as a host-held draft while the main agent is working. Strict object, so an
|
||||
* older host refuses it: clients send it only when `agent-session.queued-messages.v1` is
|
||||
* advertised. Participates in the operation fingerprint, never the body fingerprint. */
|
||||
delivery: z.literal('queue-if-active').optional(),
|
||||
body: z
|
||||
.object({
|
||||
kind: z.literal('message'),
|
||||
@@ -210,6 +214,19 @@ export const CancelParams = z
|
||||
}
|
||||
})
|
||||
|
||||
/** `agentSession.queuedMessageSend` / `agentSession.queuedMessageDelete`. Gated on
|
||||
* `agent-session.queued-messages.v1`; an older host lacks the methods entirely. */
|
||||
export const QueuedMessageActionParams = z
|
||||
.object({
|
||||
envelope: MutationEnvelope,
|
||||
messageId: Identifier('Invalid queued message id')
|
||||
})
|
||||
.strict()
|
||||
|
||||
/** `agentSession.queuedMessagesResume`: ends the queue's pause (a Stop's, or a
|
||||
* restart's) so the cards send again. Gated like the draft actions above. */
|
||||
export const QueuedMessagesResumeParams = z.object({ envelope: MutationEnvelope }).strict()
|
||||
|
||||
export const RespondParams = z
|
||||
.object({
|
||||
envelope: MutationEnvelope,
|
||||
|
||||
@@ -71,10 +71,54 @@ function notice(reason: string | null, rejection?: AgentSessionFailureFact): str
|
||||
)
|
||||
}
|
||||
|
||||
describe('a queued draft answer', () => {
|
||||
it('retires the outbox entry: the host-held draft carries any later refusal', () => {
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
blockedClientMessageId: null,
|
||||
result: {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 10 },
|
||||
value: {
|
||||
clientMessageId: 'client-1',
|
||||
queued: { messageId: 'client-1', position: 1, state: 'waiting' }
|
||||
}
|
||||
},
|
||||
createOperationId: () => 'unused'
|
||||
})
|
||||
expect(disposition.entries).toEqual([])
|
||||
expect(disposition.error).toBeNull()
|
||||
})
|
||||
|
||||
it('a withdrawn replay is spent, not unknown', () => {
|
||||
const disposition = disposeStructuredAgentSessionSendResult({
|
||||
entries: [entry],
|
||||
entry,
|
||||
blockedClientMessageId: null,
|
||||
result: {
|
||||
ok: true,
|
||||
replayed: true,
|
||||
fence: 1,
|
||||
cursor: { epoch: 'epoch-1', sequence: 10 },
|
||||
value: {
|
||||
clientMessageId: 'client-1',
|
||||
queued: { messageId: 'client-1', position: 1, state: 'withdrawn' }
|
||||
}
|
||||
},
|
||||
createOperationId: () => 'unused'
|
||||
})
|
||||
expect(disposition.entries).toEqual([])
|
||||
expect(disposition.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('what a rejection shows the user', () => {
|
||||
it('removes a queued message the provider confirms Stop cancelled', () => {
|
||||
const result = rejectedWith(DISPATCH_REJECTED_CANCELLED)
|
||||
if (!result.ok) {
|
||||
if (!result.ok || !('submission' in result.value)) {
|
||||
throw new Error('expected rejected submission fixture')
|
||||
}
|
||||
|
||||
@@ -128,7 +172,7 @@ describe('what a rejection shows the user', () => {
|
||||
|
||||
it('reads a withdrawal off the typed fact whatever the reason says', () => {
|
||||
const result = rejectedWith('Withdrawn.', { rejection: { kind: 'cancelled' } })
|
||||
if (!result.ok) {
|
||||
if (!result.ok || !('submission' in result.value)) {
|
||||
throw new Error('expected rejected submission fixture')
|
||||
}
|
||||
expect(reconcileStructuredAgentSessionOutbox([entry], [result.value.submission])).toEqual([])
|
||||
@@ -316,7 +360,7 @@ describe('what a refusal shows the user', () => {
|
||||
lastFailure: { kind: 'refused', code: 'agent_session_checkpoint_stale' }
|
||||
}
|
||||
const result = rejectedWith(null)
|
||||
if (!result.ok) {
|
||||
if (!result.ok || !('submission' in result.value)) {
|
||||
throw new Error('expected a send result')
|
||||
}
|
||||
result.value.submission = { ...result.value.submission, dispatchState: 'accepted' }
|
||||
@@ -360,7 +404,7 @@ describe('ambiguous operation refusals', () => {
|
||||
|
||||
it('parks a recovered missing submission without polling forever', () => {
|
||||
const result = rejectedWith(null)
|
||||
if (!result.ok) {
|
||||
if (!result.ok || !('submission' in result.value)) {
|
||||
throw new Error('expected a send result')
|
||||
}
|
||||
result.value.submission = {
|
||||
|
||||
@@ -85,7 +85,7 @@ function dropEntry(input: SendDispositionInput): StructuredAgentSessionOutboxEnt
|
||||
*/
|
||||
function refusedRedelivery(
|
||||
entry: StructuredAgentSessionOutboxEntry,
|
||||
submission: AgentSessionSendResult['submission']
|
||||
submission: AgentJournalSubmission
|
||||
): boolean {
|
||||
return (
|
||||
entry.retryAfterUnknownSubmittedAt !== null &&
|
||||
@@ -229,6 +229,16 @@ export function disposeStructuredAgentSessionSendResult(
|
||||
: refused.clientMessageId
|
||||
}
|
||||
}
|
||||
if ('queued' in result.value) {
|
||||
// The host holds the draft (or already settled it, on a replay). Either way
|
||||
// the send is spent and the queue owns it now: the outbox entry retires,
|
||||
// and the draft card — not this queue — carries any later refusal.
|
||||
return {
|
||||
entries: dropEntry(input),
|
||||
error: null,
|
||||
blockedClientMessageId: input.blockedClientMessageId
|
||||
}
|
||||
}
|
||||
const submission = result.value.submission
|
||||
if (refusedRedelivery(input.entry, submission)) {
|
||||
return {
|
||||
|
||||
@@ -53,6 +53,9 @@ export function structuredHostStub(
|
||||
})),
|
||||
waitForSendSettlement: vi.fn(),
|
||||
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
queuedMessageSend: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
queuedMessageDelete: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
queuedMessagesResume: vi.fn(async () => ({ ok: true, replayed: false })),
|
||||
rewind: vi.fn(async () => ({
|
||||
ok: true,
|
||||
replayed: false,
|
||||
|
||||
@@ -64,6 +64,23 @@ export const STRUCTURED_CALLS: {
|
||||
},
|
||||
{ method: 'agentSession.send', hostMethod: 'send', result: { ok: true, replayed: false } },
|
||||
{ method: 'agentSession.cancel', hostMethod: 'cancel', result: { ok: true, replayed: false } },
|
||||
// Draft mutations for mid-turn queueing. Methods exist ahead of the
|
||||
// capability's advertisement; only capability-gated clients ever call them.
|
||||
{
|
||||
method: 'agentSession.queuedMessageSend',
|
||||
hostMethod: 'queuedMessageSend',
|
||||
result: { ok: true, replayed: false }
|
||||
},
|
||||
{
|
||||
method: 'agentSession.queuedMessageDelete',
|
||||
hostMethod: 'queuedMessageDelete',
|
||||
result: { ok: true, replayed: false }
|
||||
},
|
||||
{
|
||||
method: 'agentSession.queuedMessagesResume',
|
||||
hostMethod: 'queuedMessagesResume',
|
||||
result: { ok: true, replayed: false }
|
||||
},
|
||||
{
|
||||
method: REWIND_METHOD,
|
||||
hostMethod: 'rewind',
|
||||
@@ -250,6 +267,13 @@ export function paramsFor(method: string): unknown {
|
||||
envelope: envelope({ method: 'agentSession.cancel', fields: { turnId: 'turn-1' }, fence }),
|
||||
turnId: 'turn-1'
|
||||
}
|
||||
case 'agentSession.queuedMessageSend':
|
||||
case 'agentSession.queuedMessageDelete': {
|
||||
const fields = { messageId: 'queued-1' }
|
||||
return { envelope: envelope({ method, fields, fence }), ...fields }
|
||||
}
|
||||
case 'agentSession.queuedMessagesResume':
|
||||
return { envelope: envelope({ method, fields: {}, fence }) }
|
||||
case 'agentSession.respondToApproval':
|
||||
case 'agentSession.respondToQuestion': {
|
||||
const fields = { itemId: 'item-1', expectedRevision: 1, optionId: 'allow' }
|
||||
|
||||
Reference in New Issue
Block a user