Skip to content

fix(native-chat): a message sent while Stopping waits at the end of the queue, and a person's Stop never holds another agent's mail - #25041

Closed
brennanb2025 wants to merge 249 commits into
mainfrom
brennanb2025/stop-queue-after-stop
Closed

brennanb2025 wants to merge 249 commits into
mainfrom
brennanb2025/stop-queue-after-stop

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 19 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​875 $\color{#cf222e}{\Huge{\mathbf{−}}}$​114 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​761
Prod 25 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​285 $\color{#cf222e}{\Huge{\mathbf{−}}}$​159 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​126

Lands with the queued-messages capability rollout (#21062). Based on main (with #24586). Nothing here takes effect on a host that does not advertise the queued-messages capability, which no shipped host does yet; the capability bump that turns it on is a separate, later PR.

ELI5

Press Stop, then type another message while the chat still reads "Stopping…". With queueing on, that message now waits in the queue, at the end, paused by your Stop like everything else you queued, until you press Resume or start a new turn. A message another agent sends this chat (orchestration mail) is never held by your Stop: it runs when the stop lands, and the queue labels it as waiting, not paused.

What Changed

The problem. With queueing advertised, a message sent while "Stopping…":

  1. Had no card, then became a bubble. A person's Stop holds the session's lane until the agent answers its interrupt, which can take seconds (longer over SSH). The send was admitted only behind it. By then the turn read ended, so the host sent it plain: nothing on screen during "Stopping…", then a bubble and a new turn, ignoring the Stop's pause.
  2. Was promised the wrong thing. The composer said "Queue a message to run after the stop", but your Stop pauses the queue, so a queued message waits for Resume.
  3. Labelled another agent's mail as paused. Under a person's Stop every card read "paused", including orchestration mail, which runs when the stop lands anyway: a label that flashes and is then contradicted.

Before / after (only where the host advertises queueing):

Where Before After
A message you send while "Stopping…", queueing on (desktop) or on the phone No card until the stop lands, then sent plain A card at the end of the queue at once, held by your Stop's pause, even when it is the only card; no "Send message?" dialog while the chat is working
The same, queueing off (desktop) Sent plain after the stop Unchanged: sent plain, runs when the stop lands
Cards waiting when you Stop, or added while it is in force Cards queued before the Stop held Every one of your cards held, in order, until Resume or a turn you start
Orchestration mail, queued before your Stop or during it Held with your cards Runs when the stop lands, ahead of your held cards; its turn lifts the pause, so your cards then follow in order
A mail card's label under your Stop "Paused" Waiting, like any card the queue will send
The composer placeholder while "Stopping…", queueing on "Queue a message to run after the stop" The normal prompt (the card waits for Resume); "Send a message to run after the stop" stays where queueing is off

Mechanism.

  • The card is written without waiting behind the Stop. While the host reads that a person's Stop is ending the work, a text send asking to be queued gets the usual ledger and lease admission, then a plan that only writes the card through the journal's ordered writer: always a card, never the lane. A close or reopen during admission writes nothing and leaves the operation pending, so a resend runs it afresh. A replay answers the same card.
  • One pause rule, read by the drain, its consume and admission alike (queued-message-pause.ts): a person's stopped pause holds every waiting card except orchestration mail; a /clear's pause holds only the cards it carried; a restart's holds all.
  • Mail is a message whose body names its sender (from, from feat(native-chat): show which agent a chat message is from, and open it #25888). A client's own send cannot set from, so its presence is the whole test, on the host and on both clients (queuePauseHoldsCard in src/shared). Clients check presence rather than parsing the sender, so a newer build's sender shape still reads as mail exactly as the host reads it; parsing would bring the "paused" flash back for those.
  • Clients pass the pause itself (its reason) instead of a yes/no, so only cards that pause holds read paused.
  • No wire change. Every fact used here is already published: the pause and its reason beside the list, and from on each card's body.

Why

  • A message typed after pressing Stop is a new instruction, but your Stop says "don't run anything until I say so"; holding it at the end with the rest, in order, keeps both. Starting a turn or Resume releases everything in order.
  • Orchestration mail acts like a normal message from another agent: it is delivered once the agent is idle, as terminal orchestration delivers it, and is not the person's to pause.
  • Writing the card off the lane is safe because a card has no order to keep with the Stop. Only its later dispatch is ordered, and that already waits for the stopped turn to end.
  • Alternatives considered:
    • Let a message typed during Stopping skip the paused cards (an earlier version of this PR, with a heldByPause card field). It reorders the queue against the person's own order and needed a new wire field.
    • Parse from on the client to decide "mail". It disagrees with the host for any sender shape the client cannot parse.

Differences from the common pattern

Difference Label
A message sent during "Stopping…" waits even when it is the only card. Intended. It is the queue's one pause rule applied to it, so the person's Stop means the same thing for every card.
Orchestration mail is never held by a person's Stop. Intended. Mail is another agent's message, not the person's queued work; holding it would stall the sender with no way for it to know why.
Asymmetry in the fallback. A send admitted off the lane falls back to the lane if Stopping cleared by the time it runs; there, a preparation refusal is recorded after the operation's ledger row, so a retry replays the refusal, while an on-lane refusal places no row. Temporary. It needs Stopping to clear inside the admission window and a preparation refusal together. Follow-up: run the preparation before placing the row on this path.

Mixed versions.

  • No new field. An older client labels every card under a pause as paused, mail included, as today (pinned in tests/e2e/cross-version-wire/queued-card-sender-downgrade.unit.test.ts against a build without this change).
  • A card from a host without from (before feat(native-chat): show which agent a chat message is from, and open it #25888) reads as the person's; such a host also predates this rule and holds mail, so the label matches.
  • Only delivery: 'queue-if-active' text sends go off the lane. An older host never advertises queueing, so a newer client never asks it to queue.

Linked Issue

None: maintainer change, the queue half of #24369 split out to land with #21062.

Visual Proof

N/A until #21062: with queueing off, which is every shipped host today, nothing here is visible. Live QA of the queue-on build is still to run (see Testing).

Testing

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

  • End to end through the shipped host, journal and Codex adapter (structured-agent-session-codex-interrupt-order.test.ts): while the Stop waits on Codex's answer, a send asking to be queued returns a card at once; a retry answers the same card; after the stop it waits with the held cards, and Resume sends them in order.

  • Host: the pause rule (queued-message-pause.test.ts); a send while Stopping (structured-agent-session-queued-send-while-stopping.test.ts: always a card at the end; mail runs first, then the person's cards; only the mail card's body names a sender); the stop-row, pause-lift and Stop-event tests; a restart still holding mail queued after a Stop.

  • Clients: desktop and phone card labels (a person's Stop marks only their cards paused, any other pause marks all); the desktop send while Stopping follows the queueing setting with no dialog while working; the placeholder.

  • Cross-version: queued-card-sender-downgrade (above), plus the kept-card and Stop-event downgrade files.

  • Each rule was checked by breaking it and watching its tests fail (removing the mail check turns 7 tests in 3 files red).

  • At 2d950cad6c9: 20 desktop test files (243 tests) and 4 phone files (82 tests) pass; tc:node, tc:web, phone tsc and its tests-typecheck ratchet pass; oxlint and oxfmt are clean.

  • Still to do before ready: port the client tests to the new desktop send layer (fix(native-chat): no saved outbox on the desktop; one send at a time, and the host owns what it accepted #25959) once it lands; live QA with queueing on.

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance

  • SSH: everything runs on the execution host; clients present what it publishes.
  • Performance: the queue's published list is keyed on its pauses too, so a Stop or a person's turn republishes it once.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…keeps it paused

Red on main: Codex's turn end withdraws the steered hand-off and the queue
sends the card again as a new host turn, with no pause recorded.
… still unanswered

A card sent now into the running turn was still pending when Stop judged the
pause, so nothing was recorded; the interrupt then withdrew the hand-off, the
card went back to waiting unpaused, and the queue sent it again as a new turn.
The pause now counts a hand-off that may still return to waiting, judged with
the appended row applied, so a withdrawal lands under the pause and an
acceptance retires it in that same write. Codex and Claude both hit it.
…ry path that ends it

The provider's answer, the provider dying, the chat closing, a restart, a
withdrawal still owed at open, and a /clear (refused until the hand-off ends,
then carrying every waiting card paused 'cleared'); each ends with the queue
sending again.
…journal rows

Stop now appends one journal row where it takes effect, before the interrupt,
whatever the queue holds; Resume appends its own. The pause is a pure function
of the fold: the latest Stop with no later Resume and no later accepted turn a
person asked for. A /clear's carried cards name their source, which is the
replacement's 'cleared' pause. Host-origin turns never lift either.

One predicate decides which cards a pause holds; by default every waiting card
without a hold of its own, including one queued after the Stop. The drain's
consume re-judges it inside its own transaction.

The rows are tombstones of an id no item takes, carrying the mark: a released
host reads an unknown row kind as corruption and truncates the journal there.

Deletes the stored pause (recordPause, the retire hook on every appended row,
the settle-before-record step, mayReturnToWaiting and its row overlay) and the
tests that only proved it retires. The queued_message_pauses table stays in the
schema, unread and unwritten, for downgrade safety.
…ead of held ones

A Stop's pause now holds only the cards queued before its row, plus a steer it
withdrew, which returns to its own place. Each card records the journal
position it was queued at, and the one hold rule compares that with the Stop
row. A card queued after the Stop is a new instruction: it sends as usual, but
the drain still stops at the first held card, so it never overtakes them.
/clear's pause holds the cards it carried. Holding every card again is a
one-line switch in that rule.
… in its transaction

The drain's pick and its consume now read one function, nextSendableQueuedCard,
so a Stop row that lands between them holds a newer card behind an older held
one exactly as the pick would. Notes why Stop and Resume ride a tombstone row.
The queue's pause is derived from journal rows, so nothing reads or writes
queued_message_pauses. It was still created on every open "for downgrade
safety", but an older build creates it itself when it opens the database, so
the table only sat empty in every new database. The tests now pin that no
pause table exists.
A Stop holds only the cards queued before it. The pause derivation still
returned the Stop alone whenever it was in force, so the restart pause was
never considered: a card queued after the Stop, written by a host process
that has since exited, sent by itself after Orca restarted, with no pause
header and no Resume. A /clear pause that held nothing could hide it the
same way.

Every pause in force is now derived. A card is held if any of them holds
it, and it names the first that does. The drain's pick, the consume
transaction's re-check and the published header all read that one rule;
the header names the pause holding the first card Resume would send.
- The Claude and Codex Stop-withdraws-a-steer tests checked "not sent
  again" at one instant, before a queue ignoring the pause re-sends. They
  now wait for the stopped turn to end and re-check after a quiet window.
- The deleted-card test read a card queued after the Stop, which sends
  whether or not a person's turn lifts it; it now reads the Stop's pause
  before and after that turn.
- Unit cases pin that a Stop holds a card with no recorded position and one
  queued before a rewind.
…turn and caller

The Stop row that paused the queue becomes the general Stop event
{ reason, turnId?, at, caller? }, whose reason is the host's existing stop
cause. It still rides a tombstone of a host-only id (a released host deletes
the journal from the first unknown row kind), and Resume keeps its own marker
on its own id. Only a person's Stop (reason user-stop) pauses the queue.
… the at-start stop

Through the real host: the event names the turn and who asked and is in the
journal when the interrupt reaches the agent; at an agent still starting it is
there before the start is ended and holds a card queued before it; an idle Stop
writes one only when it withdrew a send; and the queue's claim re-judges a
pause that landed after its pick.
…the Stop's timing

Also says precisely what the claim's in-transaction pause check defends
against: the Stop and the drain share one serialized lane.
… Stop events

Replays this build's rows from the released build's own journal database: every
row is kept, the history after the Stop still folds, and an older client is sent
only removed ids no item uses.
… a writable downgrade

The Stop-event downgrade test ran in no CI lane: unit shards exclude the
cross-version folder, and the cross-version lane runs a fixed file list that
did not name it. It is now on that list.

Its only case replayed the rows into a release's own fresh database, because
that release cannot open the current host database. A second case opens the
journal this build wrote with a main build that shares the database: it opens
writable, keeps every row, appends, and this build then reopens it with the
person's Stop still pausing the queue.
A Stop reaching a running agent wrote a Stop event on every press. Two
presses before the first interrupt landed wrote two events, so a card
queued between them counted as before the latest Stop and was held,
though a card queued after a Stop should send normally. A Stop naming a
turn that had already ended, as a phone sends late, also wrote an event
for a turn it never stopped.

It now writes one only when it withdrew a queued send, or stops something
no event records yet: not a turn the journal no longer runs, and not the
live turn a Stop still in force already names, unless a card was handed
over into it since, which this Stop's interrupt sends back and must hold.
The interrupt and the "already finished" note are unchanged. A Stop at a
starting agent still always writes.
…-state-on-b

# Conflicts:
#	mobile/src/session/MobileNativeChatTurnStatus.test.ts
#	mobile/src/session/MobileNativeChatTurnStatus.tsx
#	mobile/src/session/MobileNativeChatView.tsx
#	mobile/src/session/use-mobile-native-chat-turn-disclosure.ts
#	src/renderer/src/components/native-chat/NativeChatMessageList.tsx
#	src/renderer/src/components/native-chat/NativeChatQueuedMessageList.tsx
#	src/renderer/src/components/native-chat/NativeChatTurnActivityLine.tsx
…-state-on-b

# Conflicts:
#	mobile/src/session/MobileNativeChatView.tsx
#	mobile/src/session/use-mobile-structured-agent-session.ts
#	src/renderer/src/components/native-chat/NativeChatComposer.test.tsx
…e limits after the main merge

The Stop control now disables itself while Stopping, so the composer passes
the flag through and its test file stays as main has it. The phone chat
header's Stop moves to its own component.
…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).
…' into brennanb2025/stop-queue-after-stop

Conflicts in #25041's own host and client files took #24369's side; the old
overtaking design there is rebuilt on top in the following commits.
…s-after-stop' into brennanb2025/stop-queue-after-stop

Composer: Stop reads Stopping… while this client's Stop is in flight (#24369) and gives way
to Resume when the queue is held and idle (#24586). Stop is offered while the queue sends next
but live only once a turn can stop. queued-stop-row test takes #24586's version.
…no longer needs

A person's Stop now holds every card while it is in force, so no card queued under it is let
through and the per-card mark carries nothing. Desktop and phone labels go with it.
…chestration mail runs past it

Before, the Stop's pause held only cards queued before its row, so a message queued while the
stopped turn wound down ran as soon as the stop landed. Now the pause holds every card until
Resume or a turn accepted after the Stop. Mail never waits on a person's Resume: the person's Stop
holds no mail card, and the queue's one ordering exception lets mail pass cards held only by that
Stop. Its accepted turn lifts the pause, so the person's cards follow in order. The drain, its
consume and admission all read the same rule (nextSendableQueuedCard).
…at once, beside the Stop's lane

A person's Stop holds the session's lane until its provider answers, so a send asking to be queued
showed nothing for the whole Stopping window. While the host reads Stopping, a text send asking to
be queued is admitted beside the lane and written as a card at the end through the journal's own
ordered writer. It is always the card: a conversation closed or replaced since admission throws,
leaving the operation pending for a resend to rerun, and a send-blocking record refuses as the lane
does. A /clear in flight, a rewind the lane would recover, or an acceptance-time check keeps the
lane. The queue decision and its card write move to structured-agent-session-queued-send.ts.
…d message runs after the stop

A message queued while Stopping now waits for Resume, so "Queue a message to run after the stop"
was false. The composer shows its normal prompt there, desktop and phone. "Send a message to run
after the stop" stays where sends are not queued: the host holds that send until the stop lands.
…tting, with no dialog while working

Queueing on asks a queueing host to queue it and draws no bubble; queueing off sends it plain,
marked as sent while stopping. The "Send message?" choice is never offered while a turn runs.
…hatever else that Stop holds

Said in the queue's pick itself rather than left to the hold rule, so a later change to which
cards a Stop holds cannot let a person's card overtake.
@brennanb2025
brennanb2025 changed the base branch from brennanb2025/stopping-state-on-b to main October 6, 2026 19:18
@brennanb2025
brennanb2025 changed the base branch from main to brennanb2025/chat-queue-continues-after-stop October 6, 2026 19:19
…r-stop

Main's #24369 adds a Stopping state to the composer's Stop control and its own
stop controls; the composer keeps this branch's Resume action beside it
(Stopping… / Stop / Resume / Send), and Stop stays live only once a turn can be
stopped. Main's new Stopping test passes the primary action this branch requires.
The composer's queue field props come from the queue's composer hook as one
object, to stay under the file's line limit.
…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.
…s Stop never holds; say mail passes a Stop's held cards where the queue rule is described
…son's Stop marks only their own cards paused; another agent's mail reads as waiting
@brennanb2025
brennanb2025 changed the base branch from brennanb2025/chat-queue-continues-after-stop to main October 7, 2026 01:12
…ueue-after-stop

Rebuilt as main plus this branch's own changes against its old base: the branch carried #24586's
unsquashed history, so main's squash conflicted on every shared file. The person's-Stop mail
check now reads the message body's sender (`from`, from #25888) and the separate card `source`
field is gone; the moved queue step takes main's fingerprint and its replay answer.
@brennanb2025 brennanb2025 changed the title fix(native-chat): queue a message sent while Stopping, where the host queues sends fix(native-chat): a message sent while Stopping waits at the end of the queue, and a person's Stop never holds another agent's mail Oct 7, 2026
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing this stale queue implementation after a replacement issue is created. Preserve immediate cards for messages sent during Stop and the rule that another agent’s mail is not held by a person’s pause.

The remaining problem is tracked in #27657.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant