Repository navigation
fix(native-chat): keep a message accepted before a quit or crash as a held card - #24660
Conversation
… held card A send the host accepted while the agent was still starting, and never handed over, was rejected unseen at quit or at the next open after a crash. The next open now keeps a person's message (typed, or a launch's first prompt) as a waiting card at the head of the queue, held until Resume, Send now, Edit or Delete; quit no longer rejects it. Each submission records its source so a restart knows which leftovers to keep. A direct send's replay answers from its own record, never the queued arm. Cards shown without the queue capability hide Turn off queueing and the steer chord.
…e, not only a restart The host now keeps a person's message it accepted and never handed over with one rule wherever it can no longer hand it over: a quit or crash (settled at the next open) and a close of the chat (tab close, worktree teardown, orchestration stop). - The hold is card state: a new per-card hold_reason 'kept', published as the existing pausedReason, instead of a fake host_instance value. host_instance means the owner again, and the pause clause, adoption filter and /clear carry special cases are gone. A kept card holds the cards behind it until the person sends, edits or deletes it; a send_failed card still does not. - A card hand-off rejected by a restart or a close returns as a kept card (rejectedDraftSettlement), so a person's next message can never release it. - One hold function, parameterized by cause (hostRestarted / chatClosed), replaces the close path's plain rejection. - The phone shows published cards and per-card holds whatever the queue capability says; only queueing a new send stays gated. - source gains 'dispatch' for the orchestration preamble (still rejected); an unknown source is kept as written and never takes the legacy rule. - Kept cards from earlier settlements stay ahead of a batch's new ones.
… leave the queue as a crash does - A kept card is held on its own, as a send_failed one is: the queue sends the cards behind it, and the "a message ahead needs attention" caption no longer appears behind it (desktop and phone). - Quit disposes the queue's drain together with delivery, so it mints no hand-off that only the next process could settle. - Only a Send the person asked for (origin client) that a restart or close cut short returns kept; the queue's own hand-off returns where it stood, under the restart's pause, as on main. - Comments that said only a capable host gets cards or the card actions now say the capability gates only queueing a new send.
…-message-survives-quit
…nds a hand-off A drain step already past its first check when quit begins no longer makes a hand-off. Adds regression tests for that, for Send now on an ordinary card cut short by a quit, and for the open-time repair deriving kept from the hand-off's origin; fixes the pause and settlement comments that called a kept card one the queue never passes.
…the others After a second restart a kept (or send_failed) card is another process's, but it waits for its own Send, so it no longer pauses every other card under "Queue paused because Orca restarted".
…ace survives an Edit or Delete The rejection row of a send the host kept as a card now names that card (`keptAsQueuedMessageId`), in the same transaction that writes the card, and the fold publishes it on the submission. The shared projection draws such a send only as its card: once the card is sent, edited or deleted, neither the send nor the sending desktop's local copy of it shows.
No client parses published submissions with it (they arrive as typed frames, and the host's history pages carry the field, as the Edit/Delete test reads); listing the field put the file over its line budget.
…ing it The outbox reconcile and the send disposition drop an entry whose submission the host rejected as kept as a card, as they already do for a Stop's withdrawal, so the copy never comes back as "Not sent / Retry" once the submission falls out of the loaded page. The projection reads the reconciled outbox, so its separate filter goes.
…-message-survives-quit
…-message-survives-quit
Review statusThe problem. In a structured chat, a message Orca accepted but had not yet handed to the agent (the agent was still starting, a chat's launch prompt, or a message waiting behind What changes for you. The message now comes back as a card above the message box that says "Not sent yet — press Send to send it." This happens after a quit, a crash or a tab close, on the desktop and on the phone. It is never sent by itself, not at launch and not when you send a newer message. It goes out when you press Send, or you can Edit or Delete it. A newer message you type goes out right away, and other queued cards are not held behind it. On the desktop, a message cut short by a graceful quit now waits as this card instead of being resent by itself. Fixed during review (each with a regression test that fails when its fix is reverted):
Deferred (not in this PR):
Verified
Not verified
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review. 📝 WalkthroughWalkthroughThe journal records send sources and links rejected submissions to retained queued cards. On restart or chat close, eligible unsent messages are retained as individually held cards, and other selected submissions are rejected without cards. Queue selection, recovery, and card publication account for the kept hold. Mobile and desktop clients show the card and its Send guidance. Message projection and outbox reconciliation use the recorded card association to suppress duplicate local copies. Tests cover recovery, card actions, rendering, and older-build compatibility. Priority: ⬇️ Low Merge Risk: 🔵 Low · up to A minor wording issue in the desktop Chinese caption remains open; it is cosmetic and does not block merging, but it should be fixed as a quick follow-up. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to Interrupted messages now remain available until the user chooses to send or discard them. The reviewed paths preserve explicit-send and session-ownership checks. Older-version behavior and some client paths remain only partly verified. Retained concerns Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
⚔️ Resolve merge conflicts 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Advanced
Run ID: d92a4f91-4515-49d0-bfdc-56cbbbd75cd1
📒 Files selected for processing (77)
mobile/src/session/MobileNativeChatQueuedMessages.tsxmobile/src/session/mobile-native-chat-controller-contract.tsmobile/src/session/mobile-native-chat-pending-echo.tsmobile/src/session/mobile-structured-queued-message-cards.test.tsmobile/src/session/mobile-structured-queued-message-cards.tsmobile/src/session/use-mobile-structured-agent-session-queued.test.tsxmobile/src/session/use-mobile-structured-agent-session.tsmobile/src/session/use-mobile-structured-queued-message-controls.tssrc/main/native-chat/agent-session-journal/journal-dispatch-reducer.tssrc/main/native-chat/agent-session-journal/journal-dispatch-settlement.test.tssrc/main/native-chat/agent-session-journal/journal-dispatch-settlement.tssrc/main/native-chat/agent-session-journal/journal-queued-messages.tssrc/main/native-chat/agent-session-journal/journal-row-builders.tssrc/main/native-chat/agent-session-journal/journal-row-schema.tssrc/main/native-chat/agent-session-journal/journal-store-contracts.tssrc/main/native-chat/agent-session-journal/journal-store.tssrc/main/native-chat/agent-session-journal/journal-submission-fold.tssrc/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.tssrc/main/native-chat/agent-session-journal/journal-unsent-send-hold.tssrc/main/native-chat/agent-session-journal/queued-message-holds.tssrc/main/native-chat/agent-session-journal/queued-message-pause.test.tssrc/main/native-chat/agent-session-journal/queued-message-pause.tssrc/main/native-chat/agent-session-journal/queued-message-positions.tssrc/main/native-chat/agent-session-journal/queued-message-retention.tssrc/main/native-chat/agent-session-journal/queued-message-settlement.tssrc/main/native-chat/agent-session-journal/queued-message-store.test.tssrc/main/native-chat/agent-session-journal/queued-message-table.tssrc/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.tssrc/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host.tssrc/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.tssrc/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.tssrc/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.tssrc/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.tssrc/main/native-chat/agent-session-wire/structured-agent-session-queued-publication.tssrc/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.tssrc/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-turns.tssrc/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.tssrc/main/runtime/orchestration/structured-mailbox-pointer-host.test.tssrc/main/runtime/orchestration/structured-mailbox-pointer-host.tssrc/main/runtime/rpc/methods/agent-launch-structured-prompt.test.tssrc/main/runtime/rpc/methods/agent-launch-structured-prompt.tssrc/main/runtime/rpc/methods/orchestration-structured-worker-session.test.tssrc/main/runtime/rpc/methods/orchestration-structured-worker-session.tssrc/main/runtime/rpc/methods/structured-agent-session-queued-methods.tssrc/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsxsrc/renderer/src/components/native-chat/NativeChatQueuedMessageList.test.tsxsrc/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsxsrc/renderer/src/components/native-chat/structured-agent-session-queued-cards.test.tssrc/renderer/src/components/native-chat/use-structured-agent-session-outbox.queue-delivery.test.tsxsrc/renderer/src/components/native-chat/use-structured-agent-session-queued-messages.tssrc/renderer/src/components/native-chat/use-structured-agent-session.tssrc/renderer/src/i18n/locales/en.jsonsrc/renderer/src/i18n/locales/es.jsonsrc/renderer/src/i18n/locales/fr.jsonsrc/renderer/src/i18n/locales/ja.jsonsrc/renderer/src/i18n/locales/ko.jsonsrc/renderer/src/i18n/locales/zh.jsonsrc/renderer/src/runtime/structured-agent-session-host-capability.tssrc/shared/agent-session-journal-types.tssrc/shared/agent-session-queued-message-wire.tssrc/shared/protocol-version.tssrc/shared/structured-agent-session-message-projection.test.tssrc/shared/structured-agent-session-outbox.tssrc/shared/structured-agent-session-send-disposition.tstests/e2e/cross-version-wire/kept-card-downgrade.unit.test.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review.
| "turnOffQueueing": "关闭排队", | ||
| "withdrawnHold": "在发送前已停止", | ||
| "pausedSendFailed": "无法发送。点按发送以重试。", | ||
| "pausedKept": "尚未发送。点按发送即可发送。", |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use "点击" for the desktop caption.
This renderer string is the desktop caption. The English source says "press Send". "点按" is the mobile tap verb. On desktop, use "点击".
Proposed fix
- "pausedKept": "尚未发送。点按发送即可发送。",
+ "pausedKept": "尚未发送。点击发送即可发送。",📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "pausedKept": "尚未发送。点按发送即可发送。", | |
| "pausedKept": "尚未发送。点击发送即可发送。", |
There was a problem hiding this comment.
ℹ️ No critical issues — two rough edges in error paths, inline.
Reviewed changes
- Held-card settlement core. New
holdUnsentSends(journal-unsent-send-hold.ts) rejects a send the host accepted and never handed over, creating akeptqueue card in the same transaction viaholdInTransaction, and placing it at the head of the queue (headOfQueuePositions+ newmoveQueuedMessages). - Open / delivery-loop / close wiring. The open, the delivery loop's first step (replacing
rejectQueuedSubmissions) and the close all callholdUnsentSends; quit teardown stops settling and disposes the queue drain, leaving the next open to settle. - Source tracking. New
AgentJournalSubmissionSource(person|launch|mail|dispatch|continuation|queue) recorded on submissions; onlyperson/launch(or an older build'sorigin:'client'row) becomes a card. - Rejection settlement.
rejectedDraftSettlementnow returns{kept}forhostRestarted/chatClosedwhen a person asked;settleRejectedQueuedMessagestores thekepthold; repair-at-open reaches the same answer. - Card semantics. A held card pauses nothing, is skipped by the drain, survives
/clearcarry with its hold, and its local outbox copy is retired rather than shown as Retry. - UI un-gating. Desktop and mobile now render published cards (and their actions) regardless of the queue capability;
queueCapableonly gatesdelivery, the queueing setting and the steer chord. NewpausedKeptstring in six locales. - Tests. New unit/host/cross-version/projection tests plus updates to pause, store, dispatch-settlement, outbox and UI tests.
Two low-severity findings are inline. Both sit on error/edge paths rather than the normal quit or crash flow, which I traced end to end without finding a double-delivery or an unsettled-leak path.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
| const { clientMessageId } = submission | ||
| const moves = [ | ||
| // The cards an earlier settlement kept move with the first row. | ||
| ...(index === 0 ? positions.earlier : []), |
There was a problem hiding this comment.
This batch's relocation of pre-existing kept cards (positions.earlier) is folded only into the first row's transaction. If the first resolveDispatch throws and its transaction rolls back, the earlier moves are never re-applied, yet every later card is still inserted at the position headOfQueuePositions computed for the moved queue — so a new kept card can land on a position an earlier-kept card still holds, and the documented "behind the cards an earlier settlement kept" order can invert. Only held cards collide, so the drain is unaffected; the observable effect is duplicate position values and a wrong order among held cards.
Technical details
# `earlier` moves are lost when the first row fails
## Affected sites
- `journal-unsent-send-hold.ts:100-108` — `moves` only includes `positions.earlier` for `index === 0`.
- `journal-unsent-send-hold.ts:149-163` — a keep failure falls back to a plain `resolveDispatch(reject)`, discarding the rolled-back moves.
- `journal-unsent-send-hold.ts:200-205` — `first = anchor - earlier.length - placed.length` assumes the `earlier` block moved.
## Trigger
- Cards: `X` kept at position 0, ordinary card `Y` at 1.
- Reopen with unsent `s0` (index 0), `s1`: intended `X->-2, s0->-1, s1->0`.
- `s0`'s append throws; its transaction rolls back (`X` stays at 0, no `s0` card).
- `s1`'s row succeeds and inserts at the precomputed 0 -> `X(0)` and `s1(0)` collide.
## Required outcome
- After a per-row write failure, the earlier block and the newly placed cards must still end at unique, non-overlapping positions (or neither side moves), so the head-of-queue ordering invariant holds regardless of which rows failed.
## Suggested approach
- Apply the `earlier` moves in their own preceding transaction (or recompute `placed` positions after a failure) so a later row never assumes a move that was rolled back.# Conflicts: # src/main/runtime/orchestration/structured-mailbox-pointer-host.ts # src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts
There was a problem hiding this comment.
✅ No new issues found in this delta.
Reviewed changes
This run covered what changed since the prior Pullfrog review: a merge of origin/main into the branch, whose only substantive effect is re-wiring the PR's submission source tracking onto main's refactored orchestration send path.
sourcerouted throughsendAgentTurn.StructuredSessionTurnSendgains a requiredsource: AgentJournalHostSendSource(launch | mail | dispatch | continuation), forwarded onto theagentSession.sendmutation (send-agent-turn.ts:52,:138).- Structured callers carry their source. The mail pointer lane sends
source: 'mail'and a structured worker's dispatch preamble sendssource: 'dispatch', resolving the two merge conflicts while preserving the values the pre-merge files set directly. - Tests updated to match.
send-agent-turn.test.ts,send-agent-turn-host.test.ts,structured-mailbox-pointer-host.test.tsandorchestration-structured-worker-session.test.tsassert the new field. - Drain quit-gate test adapted.
structured-agent-session-unsent-send-hold.test.tsre-hooks from main's removedflushStreamedEventsstep toqueuedMessages.settlementOwed/settleOwed; it still asserts that a quit beginning mid-step makes no hand-off. - Comment-only fix in
structured-agent-session-queued-methods.ts(cards are not gated onagent-session.queued-messages.v1).
The two prior error-path findings (the lost earlier position moves on a first-row keep failure, and the delivery loop's lost read-only fail-closed guard) are unchanged by these commits and remain open, so this review is not an approval.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…to its own fixture The rig fixture grew past the 300-line limit once main's changes merged in.
# Conflicts: # src/main/native-chat/agent-session-journal/journal-queued-messages.ts # src/main/native-chat/agent-session-journal/journal-store.ts
# Conflicts: # src/shared/structured-agent-session-outbox.ts # src/shared/structured-agent-session-send-disposition.ts
… still existing The transcript hid a rejected send while a queued card held it under its id, so the card's Edit or Delete brought back a "Not sent" row. It now reads the send's own keptAsQueuedMessageId, which the host records with the rejection, and the live card list is no longer threaded to the transcript or the delivery notices.
…n journal collaborator The journal store went past its line limit once main's ledger receipt joined this branch's transaction hook. The submission and dispatch-transition writes, and what commits in their transaction, now live in JournalSubmissionWriter; the store's methods delegate to it unchanged.
The schema drops keys it does not list, so a reader that kept a parsed submission would lose keptAsQueuedMessageId and source, both persisted with the row. The submission schema and the failure fact it shares with item bodies move to their own modules, with room for both fields.
…2025/accepted-message-survives-quit # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts # src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts # src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts # src/shared/agent-session-journal-schemas.ts
…rns no text The host answers a resend of a message it kept as a card with that message's rejected submission, marked keptAsQueuedMessageId. The phone read it as any rejection: "Message not sent" and the text back in the composer, while the card showed the same text. It now answers like a queued send, as the desktop's send disposition does: the id is spent, no error, and the card holds the text.
…2025/accepted-message-survives-quit # Conflicts: # src/main/native-chat/agent-session-wire/structured-agent-session-claude-unproven-stop-send.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts # src/main/native-chat/agent-session-wire/structured-agent-session-host.ts
Quit now stops delivery, the queue drain included, at its first step (stopDelivery), before teardown drains recovery. The drain-step quit test runs from that step as well as from the flush.
…2025/accepted-message-survives-quit # Conflicts: # src/main/native-chat/agent-session-journal/journal-store.ts # src/main/native-chat/agent-session-journal/queued-message-pause.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts
There was a problem hiding this comment.
✅ No new issues found in this delta.
Reviewed changes
This run covered what changed since the prior Pullfrog review (a70aa698): one authored mobile fix and three merges of origin/main (through 990b0ed5cd).
- A kept resend answers like a queued send on the phone.
mobileStructuredSendDeliverynow maps arejectedsubmission carryingkeptAsQueuedMessageIdto{ outcome: 'queued', operationIdSpent: true, error: null }, so the phone shows no "Message not sent" and does not hand the text back to the composer — matching the desktop'sdisposeStructuredAgentSessionSendResult. - Merged
origin/mainthree times. The merges bring main's opaque provider handle (AgentSessionJournalProviderHandle,claudeProviderHandle/codexProviderHandle), the child-close stop refactor, and the OpenCode worker fix. No new wire field or stream frame. - Conflict resolutions keep the branch's logic.
journal-store.tskeeps the branch'sJournalSubmissionWriterdelegation;agent-session-journal-schemas.tskeepsAgentSessionFailureFactSchemabeside main'sopenDiscriminatedUnion; the open/close/delivery seams keepholdUnsentSends/holdClosedStructuredAgentSessionSendsbeside main's new stop machinery;stopDeliverynow disposes both the conversation delivery and the queue drain.
I ran pnpm tc:node, pnpm tc:web, and the mobile tsc --noEmit; the focused suites (mobile-structured-send-delivery, journal-unsent-send-hold, queued-message-pause, structured-agent-session-unsent-send-hold) all pass.
The read-only fail-closed finding is resolved: main removed the public AgentSessionJournal.isReadOnly getter, so the merge dropped holdUnsentSends's early return and its writes now throw journal_read_only, aborting the delivery step before ensureProviderChild. The earlier position-move finding (lost earlier moves when the first row's keep fails) is unchanged and still open, so this is not an approval.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…2025/accepted-message-survives-quit # Conflicts: # src/main/native-chat/agent-session-journal/journal-dispatch-reducer.ts # src/main/native-chat/agent-session-journal/journal-queued-messages.ts # src/main/native-chat/agent-session-journal/journal-row-builders.ts # src/main/native-chat/agent-session-journal/journal-row-schema.ts # src/main/native-chat/agent-session-journal/journal-store-contracts.ts # src/main/native-chat/agent-session-journal/queued-message-table.ts # src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts # src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts # src/main/runtime/orchestration/send-agent-turn-host.test.ts # src/main/runtime/orchestration/send-agent-turn.test.ts # src/main/runtime/orchestration/send-agent-turn.ts # src/shared/agent-session-failure-fact-schema.ts # src/shared/agent-session-journal-schemas.ts
…2025/accepted-message-survives-quit
…as another build would #25078 made a card's source required, so the tests that insert a card pass the person's. The hold's unknown-kind and unreadable-source cases now rewrite the stored row the way a newer build would leave it, instead of casting a type.
…2025/accepted-message-survives-quit # Conflicts: # src/main/native-chat/agent-session-journal/journal-store-collaborators.ts # src/main/native-chat/agent-session-journal/journal-store.ts # src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts
…2025/accepted-message-survives-quit
…at quit Main's #25159 left the host at its line limit; quit's stop now disposes both in one expression instead of a block.
…2025/accepted-message-survives-quit
…2025/accepted-message-survives-quit # Conflicts: # src/renderer/src/runtime/structured-agent-session-host-capability.ts
…2025/accepted-message-survives-quit
Bun formats a method's stack frame without its class ("at step"), so the quit
test's caller check never matched, the step was never held, and both cases timed
out once CI ran Vitest on Bun (#25840). Only the drain step heals owed queue
bookkeeping, so the hold needs no caller check.
…-history - #24660: the row writer's planned write is public writeRows (the new step writer calls it); the transactional write that also stores the chat's status is commitRows, so every path still writes the status row once per transaction. - #25181: the open settlement plan ends a running call as its turn's row ended (runningCallEnd / terminalAgentJournalBody) and revises calls an unproven settle closed once a proof names their owner; the status facts read requiresTerminalSettlement, the same facts as before. Liveness keeps main's lostLiveWorkJournalBody under the per-item roster revision. - #24660: the open plan settles what a gone process left queued through holdUnsentSends (a person's message becomes a held card); its leftovers are exactly the queued sends written before the open. - #25159: reading and settling a chat need no adapter. The persisted tab listing lists every tab with a record, startup settles any chat whose record exists, and adapterSupportsRecord / hostCanSettleRecord are gone, as on main. - Line caps: the runtime's startup step moves into its own layer (orca-runtime-structured-agent-session-startup-step.ts), and the host exposes its restore as one `startup` surface instead of four pass-through methods. - The golden digest is unchanged, so the status rules stay at 3.
…essage #24660 records on each submission who it is from (`source.kind`) so a restart or a close keeps only a person's unsent send as a card. That record and its keep rule are kept exactly; the kind is now read off what the send carries, in one place (sendPlan): a person's send ('user': a client's, or a launch's first prompt the host sends for them), another agent's message whose body names its sender ('agent'), or neither (a dispatch preamble, a restart continuation). The queued card's own source column is dropped again: the sender is on the card's body, and kept cards are written without it.
…r-stop Main's #24660 keeps a person's unsent message as a card after a quit, crash or close, and it reads the submission's origin (client or host) to decide which sends to keep. This branch had removed origin and the client send's userSend flag as unused, so they are restored as main has them; comments now say origin only decides what a restart or a close keeps, never what lifts a pause (any accepted turn or Resume still does). The queue drain's quit gate is kept once, with main's re-check right before the hand-off. Main's new kept-card tests are adjusted to this branch's rule that the restart's hold is never published (queuePause null; the cards still wait).
…is host publishes no restart pause Main's #24660 test published queuePause 'restarted', which this branch's wire type no longer lists, so the mobile tests typecheck ratchet failed.
…nd nothing sends by itself after a restart (#24586) * fix(native-chat): drop the queue-paused header and Resume button A Stop, a restart or /clear holds the queued cards. The hold stays; only the header row naming why, and its Resume button, go. A held card shows no caption, and its own Steer, or any new message, releases the queue. * test(native-chat): type the unknown hold reason a newer host may publish * fix(native-chat): a held card offers Send, not Steer, when no turn runs Steer vs Send now follows whether a turn is running, not the card's hold, so a card held after a Stop, a restart or /clear reads Send. * fix(native-chat): the queue sends past held cards instead of stalling behind them A card queued after a Stop (or written after a restart or /clear) sent only once the cards held before it were released; with no header to explain or release the hold, it sat silently. The next sendable card now skips held cards; a returned card still blocks what is behind it. * fix(native-chat): the queue's send of a card is the person's turn, so held cards follow it After a Stop, a card queued later sent past the held cards, but the queue recorded that send as Orca's own turn. It never ended the Stop's pause, so the held cards then waited forever with nothing on the card saying why. A queued card is always something the person wrote: only the client send RPC may now create one. The queue's send of it is therefore recorded as the person's turn, which ends the Stop's pause once the agent takes it, and the held cards then drain in order. * fix(native-chat): a queued card carries its author, so the queue's send of it is that author's turn Main now lets Orca's own sends ask to queue (sendAgentTurn's 'queue' delivery), so "every card is a person's" no longer holds by refusing host sends. Each card records who wrote it (the submission's client/host vocabulary) in a new nullable column; the drain records that origin, so a person's card ends a Stop's pause and Orca's does not. /clear carries the author. Rows from before the column read as a person's. The userSend-only admission gate is removed. * docs(native-chat): state why an unrecorded card author reads as a person's * fix(native-chat): a restart holds only cards written before it, and an idle held queue offers Resume A restart's pause held every waiting card, including one a person typed after the restart while Orca's own continuation ran, and nothing released it except a per-card Send. It now holds only cards another host process wrote, the same way a Stop holds only cards queued before it. The composer's primary button becomes Resume (Play) while nothing is typed, no turn runs and the host holds a card Resume would send, whatever held it (Stop, restart or /clear). It calls the existing agentSession.queuedMessagesResume, guarded against a second press in flight. A card nothing holds keeps the run going between a turn's end and the queue's send of it, so its Steer no longer flips to Send for the frame in between. * fix(native-chat): the host publishes which pause holds each queued card The host published one pause for the whole queue, so a client held every waiting card while it was set. Between a turn's end and the queue's send of a card queued after a Stop or restart, the composer could flash Resume and the cards Send, and a card queued after a Stop lost its "Waiting for your answer" caption. Each published card now carries an optional `heldBy`: the pause holding it, or null, derived from the same rule the drain reads. A client holds only those cards; against a host without the field it falls back to the queue-level pause. * test(native-chat): Resume needs the queue capability and is disabled whenever Send is * docs(native-chat): describe per-card holds in the queue contract and table comments * fix(native-chat): the composer goes from Resume straight to Stop, and Resume returns focus After Resume, the host lifts the hold in one update and sends the first card in a later one. In between nothing was running, so the composer's button flashed a disabled Send. A card nothing holds now keeps the queue's run going for the button too: an empty composer shows Stop, disabled until the turn starts. Not when the host refuses every send (a rewind whose outcome is unknown, read from its status), where nothing is coming. The same fix removes the Stop, Send, Stop flip between queued turns. Resume disables the button, which dropped keyboard focus; focus now returns to the composer. * fix(native-chat): the host names the card its queue sends next, so the chat stays working across the gap A turn's end, or a Resume, and the queue's send of the next card commit as two host updates. In between nothing was running, so the working status, timer, pickers and composer button flipped for one update. The client guessed the drain from its own copy of the host's gates, which missed a /clear-replaced source and covered only the button. The queue publication now carries `nextQueuedMessageId`: the drain's own next card through the drain's own gate (`nextStructuredQueuedMessage`, which the drain step now calls), null whenever the host would refuse the send. The client derives one fact, the queue is about to send, and every working reader follows it; Stop stays disabled until a turn can be stopped. The client-side copy of the gates and the status-feed rewind read are removed. * test(native-chat): the queue's next card survives the coalescer, the reducer and a history page * test(native-chat): build the snapshot that names the next card through its helper * feat(native-chat): a held queue keeps its header row, and a new message asks before passing it The queue's header row ("Queue paused because you interrupted", or Orca restarted, or you cleared the conversation) comes back above the cards it holds, with Resume; it names the oldest held card's pause, as the host publishes it per card, and hides over cards held only on their own or returned. The header's Resume and the composer's share one in-flight guard. A held card reads Steer again whether or not a turn runs; a card held on its own or returned keeps Send. Sending a message while the header shows (Enter or the button) first asks "Send message?": Clear queue deletes every card and then sends (a failed delete sends nothing), Send message sends and keeps the cards, which follow the new turn, and dismissing sends nothing and keeps the draft. Host commands send as they are. * fix(native-chat): the paused row goes while your own message is on its way to lift it After "Send message" over a held queue, the row kept saying "Queue paused…" until the agent accepted the new turn. The chat now reads that gap from the outbox: while this composer's direct send is recorded by the host and not yet accepted, the controller shows no paused row (and so no Resume or confirmation). A refusal settles the entry and the row comes back, since the hold did not lift. Orca's own sends never enter this outbox, and the queue's send of a card goes under a fresh id, so neither hides it. Nothing is stored. * fix(native-chat): a "Send message?" choice is taken once, and a failed Clear queue is one toast The closing dialog stays mounted and clickable through its exit animation, and a double-click or a held Enter lands twice before any re-render, so Send message (or Clear queue) could send the captured message twice. The pending send now lives in a ref that the first choice takes; a second one finds nothing. Clear queue deletes one card at a time and stops at the first failure, so a failed press shows one toast instead of one per card. The dialog keeps its compact width at desktop sizes and the primitive's narrow-window gutter (`max-w-sm sm:max-w-sm`, as the other compact confirmations). * fix(native-chat): Clear queue's message goes out once, and keeps text typed while it waits After Clear queue, the message waited in the composer while the cards were deleted one by one. A second Enter in that window sent it again, and text typed meanwhile was wiped when the chained send was accepted. From the Clear queue choice until its message has gone out, the composer's structured send does nothing. The chained send (and Send message's) now carries the composition it was taken from, and the composer is cleared on acceptance only if it still holds exactly that, as host commands already do. Also: the v1 contract comment names `nextQueuedMessageId` and its absent- means-null fallback, and the own-send check returns at once on an empty outbox. * fix(native-chat): the queue carries on after any turn, in order, and a restart sends nothing by itself - Any accepted turn ends a Stop's or a /clear's pause, whoever sent it (a person, Orca's own messages, or the queue), and so does Resume. The card and submission author fields that only fed the old person-only rule are gone. - The queue sends strictly in order: a card never overtakes a held one. - After a restart nothing sends by itself and no paused row shows: the chat's next turn (the carry-on, or the person's own message) runs first, then the cards. - Resume and "Send message?" are offered only while nothing runs and no prompt waits. * fix(native-chat): after a restart no queue pause shows, and a card written before the next turn waits for it too * fix(native-chat): a quit hands no queued card off, and the paused row goes while any turn that will lift it is on its way - The queue stops handing cards off when the host tears down. A card sent during a quit was refused at close, and that refused send withdrew the chat's restart offer, so resuming after the relaunch sent nothing. - The host publishes no pause while a turn sent after it (your message, Steer, or Orca's own) waits for the agent; a refusal shows it again. This replaces the client's own-send check. - A card written after a restart is an ordinary card again: it waits while any card from before the restart still waits. * refactor(native-chat): the host's paused-row-while-a-turn-is-on-its-way check in one expression * refactor(native-chat): the composer's queue Resume rides the structured transport beside the held queue * fix(native-chat): the "Send message?" choice ends with the pause it asked about; tests follow main's draft props - The open dialog closes when the queue's pause lifts under it (Orca's mail, another client's Resume, any accepted turn): nothing is sent, the draft stays, and the next Enter sends as usual. The pending choice records the hold it was asked under; nothing new is stored. - The composer-field Resume test passes main's dropScopeKey/draftScopeKey. - The dialog test expects main's rule: only the sent text leaves the composer. * test(mobile): a host-kept card's test stands in a Stop's pause, as this host publishes no restart pause Main's #24660 test published queuePause 'restarted', which this branch's wire type no longer lists, so the mobile tests typecheck ratchet failed.
… and follows the chat's next turn (#25960) * fix(native-chat): drop the queue-paused header and Resume button A Stop, a restart or /clear holds the queued cards. The hold stays; only the header row naming why, and its Resume button, go. A held card shows no caption, and its own Steer, or any new message, releases the queue. * test(native-chat): type the unknown hold reason a newer host may publish * fix(native-chat): a held card offers Send, not Steer, when no turn runs Steer vs Send now follows whether a turn is running, not the card's hold, so a card held after a Stop, a restart or /clear reads Send. * fix(native-chat): the queue sends past held cards instead of stalling behind them A card queued after a Stop (or written after a restart or /clear) sent only once the cards held before it were released; with no header to explain or release the hold, it sat silently. The next sendable card now skips held cards; a returned card still blocks what is behind it. * fix(native-chat): the queue's send of a card is the person's turn, so held cards follow it After a Stop, a card queued later sent past the held cards, but the queue recorded that send as Orca's own turn. It never ended the Stop's pause, so the held cards then waited forever with nothing on the card saying why. A queued card is always something the person wrote: only the client send RPC may now create one. The queue's send of it is therefore recorded as the person's turn, which ends the Stop's pause once the agent takes it, and the held cards then drain in order. * fix(native-chat): a queued card carries its author, so the queue's send of it is that author's turn Main now lets Orca's own sends ask to queue (sendAgentTurn's 'queue' delivery), so "every card is a person's" no longer holds by refusing host sends. Each card records who wrote it (the submission's client/host vocabulary) in a new nullable column; the drain records that origin, so a person's card ends a Stop's pause and Orca's does not. /clear carries the author. Rows from before the column read as a person's. The userSend-only admission gate is removed. * docs(native-chat): state why an unrecorded card author reads as a person's * fix(native-chat): a restart holds only cards written before it, and an idle held queue offers Resume A restart's pause held every waiting card, including one a person typed after the restart while Orca's own continuation ran, and nothing released it except a per-card Send. It now holds only cards another host process wrote, the same way a Stop holds only cards queued before it. The composer's primary button becomes Resume (Play) while nothing is typed, no turn runs and the host holds a card Resume would send, whatever held it (Stop, restart or /clear). It calls the existing agentSession.queuedMessagesResume, guarded against a second press in flight. A card nothing holds keeps the run going between a turn's end and the queue's send of it, so its Steer no longer flips to Send for the frame in between. * fix(native-chat): the host publishes which pause holds each queued card The host published one pause for the whole queue, so a client held every waiting card while it was set. Between a turn's end and the queue's send of a card queued after a Stop or restart, the composer could flash Resume and the cards Send, and a card queued after a Stop lost its "Waiting for your answer" caption. Each published card now carries an optional `heldBy`: the pause holding it, or null, derived from the same rule the drain reads. A client holds only those cards; against a host without the field it falls back to the queue-level pause. * test(native-chat): Resume needs the queue capability and is disabled whenever Send is * docs(native-chat): describe per-card holds in the queue contract and table comments * fix(native-chat): the composer goes from Resume straight to Stop, and Resume returns focus After Resume, the host lifts the hold in one update and sends the first card in a later one. In between nothing was running, so the composer's button flashed a disabled Send. A card nothing holds now keeps the queue's run going for the button too: an empty composer shows Stop, disabled until the turn starts. Not when the host refuses every send (a rewind whose outcome is unknown, read from its status), where nothing is coming. The same fix removes the Stop, Send, Stop flip between queued turns. Resume disables the button, which dropped keyboard focus; focus now returns to the composer. * fix(native-chat): the host names the card its queue sends next, so the chat stays working across the gap A turn's end, or a Resume, and the queue's send of the next card commit as two host updates. In between nothing was running, so the working status, timer, pickers and composer button flipped for one update. The client guessed the drain from its own copy of the host's gates, which missed a /clear-replaced source and covered only the button. The queue publication now carries `nextQueuedMessageId`: the drain's own next card through the drain's own gate (`nextStructuredQueuedMessage`, which the drain step now calls), null whenever the host would refuse the send. The client derives one fact, the queue is about to send, and every working reader follows it; Stop stays disabled until a turn can be stopped. The client-side copy of the gates and the status-feed rewind read are removed. * test(native-chat): the queue's next card survives the coalescer, the reducer and a history page * test(native-chat): build the snapshot that names the next card through its helper * feat(native-chat): a held queue keeps its header row, and a new message asks before passing it The queue's header row ("Queue paused because you interrupted", or Orca restarted, or you cleared the conversation) comes back above the cards it holds, with Resume; it names the oldest held card's pause, as the host publishes it per card, and hides over cards held only on their own or returned. The header's Resume and the composer's share one in-flight guard. A held card reads Steer again whether or not a turn runs; a card held on its own or returned keeps Send. Sending a message while the header shows (Enter or the button) first asks "Send message?": Clear queue deletes every card and then sends (a failed delete sends nothing), Send message sends and keeps the cards, which follow the new turn, and dismissing sends nothing and keeps the draft. Host commands send as they are. * fix(native-chat): the paused row goes while your own message is on its way to lift it After "Send message" over a held queue, the row kept saying "Queue paused…" until the agent accepted the new turn. The chat now reads that gap from the outbox: while this composer's direct send is recorded by the host and not yet accepted, the controller shows no paused row (and so no Resume or confirmation). A refusal settles the entry and the row comes back, since the hold did not lift. Orca's own sends never enter this outbox, and the queue's send of a card goes under a fresh id, so neither hides it. Nothing is stored. * fix(native-chat): a "Send message?" choice is taken once, and a failed Clear queue is one toast The closing dialog stays mounted and clickable through its exit animation, and a double-click or a held Enter lands twice before any re-render, so Send message (or Clear queue) could send the captured message twice. The pending send now lives in a ref that the first choice takes; a second one finds nothing. Clear queue deletes one card at a time and stops at the first failure, so a failed press shows one toast instead of one per card. The dialog keeps its compact width at desktop sizes and the primitive's narrow-window gutter (`max-w-sm sm:max-w-sm`, as the other compact confirmations). * fix(native-chat): Clear queue's message goes out once, and keeps text typed while it waits After Clear queue, the message waited in the composer while the cards were deleted one by one. A second Enter in that window sent it again, and text typed meanwhile was wiped when the chained send was accepted. From the Clear queue choice until its message has gone out, the composer's structured send does nothing. The chained send (and Send message's) now carries the composition it was taken from, and the composer is cleared on acceptance only if it still holds exactly that, as host commands already do. Also: the v1 contract comment names `nextQueuedMessageId` and its absent- means-null fallback, and the own-send check returns at once on an empty outbox. * fix(native-chat): the queue carries on after any turn, in order, and a restart sends nothing by itself - Any accepted turn ends a Stop's or a /clear's pause, whoever sent it (a person, Orca's own messages, or the queue), and so does Resume. The card and submission author fields that only fed the old person-only rule are gone. - The queue sends strictly in order: a card never overtakes a held one. - After a restart nothing sends by itself and no paused row shows: the chat's next turn (the carry-on, or the person's own message) runs first, then the cards. - Resume and "Send message?" are offered only while nothing runs and no prompt waits. * fix(native-chat): after a restart no queue pause shows, and a card written before the next turn waits for it too * fix(native-chat): a quit hands no queued card off, and the paused row goes while any turn that will lift it is on its way - The queue stops handing cards off when the host tears down. A card sent during a quit was refused at close, and that refused send withdrew the chat's restart offer, so resuming after the relaunch sent nothing. - The host publishes no pause while a turn sent after it (your message, Steer, or Orca's own) waits for the agent; a refusal shows it again. This replaces the client's own-send check. - A card written after a restart is an ordinary card again: it waits while any card from before the restart still waits. * refactor(native-chat): the host's paused-row-while-a-turn-is-on-its-way check in one expression * refactor(native-chat): the composer's queue Resume rides the structured transport beside the held queue * fix(native-chat): the "Send message?" choice ends with the pause it asked about; tests follow main's draft props - The open dialog closes when the queue's pause lifts under it (Orca's mail, another client's Resume, any accepted turn): nothing is sent, the draft stays, and the next Enter sends as usual. The pending choice records the hold it was asked under; nothing new is stored. - The composer-field Resume test passes main's dropScopeKey/draftScopeKey. - The dialog test expects main's rule: only the sent text leaves the composer. * fix(native-chat): a message kept after a quit or close waits like every other card, and follows the chat's next turn A message Orca accepted but never handed to the agent before a quit, crash or close came back as a card held on its own ("Not sent yet — press Send"): the queue skipped past it, so later cards sent first, and only the person's own Send released it. It is now an ordinary card at the head of the queue that waits, with every card the chat closed with, for the chat's next accepted turn. One rule for a chat that was not running, derived from the journal: when this host first opens a chat (after a restart or crash), or a person closes it, and cards are waiting, a reopen mark is written (a tombstone carrier key, like the Stop and Resume marks). The cards queued before it wait until a turn is accepted or Resume comes after it; nothing sends by itself, and no paused row shows, a Stop's included. The idle sweep's own eviction writes nothing and changes nothing the person sees. A mark that cannot be written leaves the open working and holds from the open itself until the next turn; the next open marks again. A rewind restates the mark. /clear's carried cards also wait unshown. This replaces the host-instance comparison and its adoption write, and the 'kept' hold (stored 'kept' and legacy 'stopped' holds now read as none). * fix(native-chat): mark the reopen in the one open path, and keep an idle chat with waiting cards open Review round 1: the startup restore opened chats past the per-host first-open mark, so their cards could send by themselves after a quit or crash; a card mid-hand-off at the open got no mark; a close that left the chat open re-marked after every new send. - Every open marks when a card waits, or is mid-hand-off with its send unanswered. - The idle sweep keeps a chat's handle while cards wait, so its eviction never reopens one and stays invisible; it drops it on the next sweep once they leave. - A person's close marks once; its delivery re-check marks only when it settled a send. - A failed mark holds from where the mark would have gone. - A Stop made after a reopen shows its row. - The rig's restart is a real quit and relaunch. * fix(native-chat): review round 2: restore the dropped Resume and failed-Stop tests; a late mark starts where the chat stopped - Restores eight tests the previous commit dropped by mistake. - A mark the delivery loop or a close's re-check writes after a later send starts where the chat stopped, so that send still lifts it; a later mark never narrows an earlier, wider one. - Only the idle sweep's own close keeps a chat with a card waiting (or mid-hand-off) open, and it still releases an ended child's lease first; a person's close drops it as before. * test(mobile): a host-kept card's test stands in a Stop's pause, as this host publishes no restart pause Main's #24660 test published queuePause 'restarted', which this branch's wire type no longer lists, so the mobile tests typecheck ratchet failed. * refactor(native-chat): settle a restart's leftovers and mark them in one host-lifetime step Keeps the delivery loop under its line limit after main's Stopping change; no behaviour change. * test(native-chat): a kept card's Resume and failed-mark tests quit through the held start's release Main's #25152 holds the start these tests send into; quitting without releasing it left the quit waiting.

ELI5
You send a message in a chat while the agent is still starting up. Orca says "got it" right away. If Orca then quit or crashed, or you closed the chat, before the agent actually received the message, Orca threw it away: when you came back it was not in the message box, not in the chat, and not in the queue. Now Orca keeps it. When you open the chat again, the message waits as a card above the message box that says "Not sent yet — press Send to send it." It goes to the agent only when you press Send on it, and you can Edit or Delete it instead.
What Changed
The problem, as you see it.
/compact) is accepted at once but handed to the agent a few seconds later.Before: quit, crash or close in that window, and the message is lost, unless the desktop's browser copy happened to survive a quit.
After:
agent.launch) becomes a card at the head of the chat's queue, in the order you sent them. After a quit or crash that happens the next time the chat opens; after a close it happens at the close, and the card is there when the chat is reopened from Agent Session History.The mechanism.
holdUnsentSends(journal-unsent-send-hold.ts), settles a message the host accepted and can no longer hand over, given why:hostRestarted(at chat open, and the delivery loop's first step as a retry: the messages an earlier Orca process accepted) orchatClosed(the close itself, before the agent is stopped, and the delivery loop's retry of a close that stopped the agent and then did not finish). Each message is marked "not sent" for that reason, and a person's message becomes a card in the same database transaction, so a crash can never leave one without the other.structured-agent-session-host-teardown.ts), and it stops the queue's automatic sending together with delivery (structured-agent-session-host.ts), so it starts no hand-off that only the next Orca process could settle, not even from a step already running when quit begins. The next open settles what quit left, exactly as after a crash.kept, in the card's existinghold_reasoncolumn, published to clients as the existing per-cardpausedReason. The column naming which Orca process owns the card is left meaning only that.send_failed) is: the queue never sends it by itself and goes on to the cards behind it.journal-dispatch-settlement.ts), so their next message can never release it. A card the queue was sending on its own comes back where it stood, as before: under the restart pause after a quit or crash, and as an ordinary queued card after a close in the same Orca session. So Send now on an ordinary card, cut short by a quit, comes back as a kept card that waits for its own Send, while Resume sends the rest of the queue. Both are decided from the rejected send itself (its reason, and that a person asked for it), so the repair that runs at open after a skipped bookkeeping step reaches the same answer and nothing new is stored.main(feat(orchestration): a native chat gets the orchestration pointer a CLI agent gets, through the same send as your messages #25078'sAgentSessionMessageSource: the person, or another agent). There is one concept, not a second "who sent it" field:/compact, or a chat's first prompt fromagent.launch) records the kinduser. Orchestration mail recordsagent. A worker's dispatch instructions and a restart's "continue" message record nothing, since no person wrote them.userbecomes a card. Everything else is rejected as before: orchestration mail is sent again by its mailbox after a restart, and after a close once the agent runs another accepted turn or new mail arrives; a restart's "continue" offer is offered again; a worker's dispatch belongs to orchestration (see Review);/compactis not re-run.origin: client) is kept. A kind this build does not know (a newer build's), or a source it cannot read, is kept as written and never treated as missing, so it is never turned into a card. The check is strict on purpose: the card's own source reader treats anything unreadable as the person's, which would keep the wrong message here.structured-agent-session-mutation-plans.ts), so a client that never asked to queue never gets the "queued" answer it cannot read.Why
agent.launchhave none, the desktop's copy can fail to save, and the desktop discards it at tab close.Differences from the common pattern
structured-agent-session-unsent-send-hold.test.ts, which then fails): the reopened chat started its agent and handed the message over on its own, with nobody looking, and a Stop on the reopened chat withdrew it as "cancelled" with no card left, so the text was gone.Linked Issue
Fixes QA-7 from #24461's live QA. #24461 depends on this PR: once the host keeps an accepted message, #24461 can release its client-side hold when the host accepts.
Visual Proof
Live QA on a Mac (M4 Air), desktop unless noted; Q-numbers are the live QA scenarios (see Testing).
Q1: a message sent while the agent is still starting, just before a graceful quit.
Q1: after relaunch, one kept card ("Not sent yet — press Send to send it.", Send), and no Retry row.
Q2: Orca killed (
kill -9) about a second after the send; the relaunch shows the kept card.Q2b: killed 100 ms after the send: one kept card.
Q2b: killed 200 ms after the send: one kept card.
Q2b: killed 300 ms after the send: one kept card.
Q3: Send on the kept card delivers it to the agent once.
Q4: a new message goes to the agent; the kept card stays.
Q6:
/clearcarries the kept card into the new conversation, still kept.Q7: after a tab close, the reopened chat shows the kept card. Reopened through the history row's reveal action: the test agent writes no transcript, so the history list itself was empty.
Q7: the same reopened chat, card and transcript.
Q8: Edit puts the kept card's text in the message box and removes the card.
Q8: Delete removes the kept card.
Q9: with queueing off, the card's menu offers only Edit, its button reads Send, and there is no "Turn off queueing" or shortcut hint.
Q10: after relaunch the desktop shows the kept card alone, with no "not sent / Retry" row.
Q11: the kept card, sent from the phone (which showed "Not sent yet — tap Send to send it"), delivered once, as the desktop shows it.
Final head smoke, Q1: the kept card after a graceful quit.
Final head smoke, Q5: still kept after a second relaunch, no agent started, no restart header.
Final head smoke, Q5: still kept after a third relaunch.
Final head smoke, Q4: a new message delivered, the kept card stays.
Testing
Live QA on a Mac (M4 Air). The full run was on 272bfa8: 12 scenarios pass and Q12 is blocked (no phone screen launches an agent with a prompt for a local repo). A smoke run on the final head 5e6ea97 passed Q1, Q5 (second and third restart) and Q4, with no restart header and no Resume over the kept card. Killing Orca (
kill -9) at 100, 200 and 300 ms after a send left exactly one kept card each time, where main lost 9 of 9 such messages. Limits of that run: the test agent writes no transcript, so Q7's reopen used the history row's reveal action directly; Q10's desktop outbox was already empty, so the same-id resend path was covered by tests only; phone screenshots could not be captured, so Q11 was checked from the phone's accessibility tree.Each new test was checked against a deliberate break of the code it guards (the test failed, then the break was reverted):
structured-agent-session-unsent-send-hold.test.ts(real host, store and journal): after a quit and after a crash the message is a kept card and its submission is marked not sent and hidden; nothing is sent across repeated reopens; several messages keep their order; a newer message (the same words retyped) is delivered alone, Resume sends nothing, and the card's own Send sends it once; cards queued behind a working turn come back the same after a quit and after a crash, under the restart pause, with no hand-off made at quit; Delete; a Stop on the reopened chat leaves the card; a Send cut short by a second quit returns the card kept, and a retyped copy is still delivered once; after a tab close (user-close) and a worktree teardown (evict) the reopened chat shows the kept card and not a bubble; the desktop's same-id resend creates nothing and delivers nothing; orchestration mail and dispatch are not kept; a failed card write falls back to "not sent"; the delivery loop settles what the open could not;/clearcarries the card still kept.journal-unsent-send-hold.test.ts: which sends are kept and which are not (including asourcefrom a newer build and adispatch); a close keeps in place and settles only what it names; head-of-queue order, including a card's own interrupted send, a run a crash cut short, and a card kept before a rewind started a new numbering.queued-message-pause.test.ts: a kept card is skipped like a failed one, and the cards behind it still send; adoption keeps the hold.journal-dispatch-settlement.test.ts,queued-message-store.test.ts,journal-unsent-send-hold.test.ts: a Send the person asked for, cut short by a restart or close, returns kept and goes to the head; the queue's own hand-off returns plainly where it stood; one a Stop withdrew is not kept.structured-agent-session-provider-child-record.test.ts: a person's message that a close cut short is a kept card, and no agent starts for it.kept-card-downgrade.unit.test.ts(cross-version): the build this branched from lists a kept card first, keeps it held after a person's next turn and never picks it to send, and settles a message this build's quit left queued.journal-unsent-send-hold.test.ts: kept foruser(typed and launch) and for an older build's client row; rejected foragent, for no source (dispatch, continuation), for an unknown kind, and for a source with no readable kind. A sent message keeps only the kind, never the senders.structured-agent-session-unsent-send-hold.test.ts(real host): mail and a dispatch accepted before a crash are rejected, and mail records onlyagent.send-agent-turn-host.test.ts: mail sent to an idle chat recordsagentwith no sender detail.agent-launch-structured-prompt.test.ts: the launch prompt sends as the person's.orchestration-structured-worker-session.test.tsandstructured-agent-session-restart-continuation-wait.test.ts: the dispatch and the continuation send no source.NativeChatQueuedMessageList.test.tsx(caption, Send, hidden queueing controls),structured-agent-session-queued-cards.test.ts(cards behind a kept one are not held by it). Phone:use-mobile-structured-agent-session-queued.test.tsx(cards show from a host that does not offer queueing, and Resume is offered for the card behind a kept one),mobile-structured-queued-message-cards.test.ts.Platforms: logic only, no platform-specific code. Remote (SSH): the card is made on the computer that runs the chat; quitting only the desktop does not restart a remote host, and a remote host's own restart or close keeps the message. Folder workspaces behave the same as git worktrees.
Review
/clearcarries every card into the new conversation without its hold, so the first message there lets it send the carried card, which a person may already have retyped. Builds older than the queue ignore cards, and the message reads as "not sent", as today. A message this build's quit left waiting is marked "not sent" by an older build's next open, as today.keptis a new value of the existingpausedReason, which clients already treat as a plain hold when unknown;source(its kind only) is an extra key on published submissions that clients ignore.Notes
Security, cross-platform, SSH, mobile, backwards compatibility and performance considered above. Settling runs once per chat open, once per close, and in the delivery loop's first step; each is one pass over the chat's sends and writes nothing when nothing was left unsent.
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)