Repository navigation
fix(native-chat): Stop is saved before it acts, and a late or repeated Stop does nothing - #27076
Conversation
Adds the agent_session_command_receipts table and an insert-if-absent receipt write that runs inside a command's own journal transaction. A receipt records the command's identity, canonical hash, status and a pointer to the row holding its result; it dies with its chat record. Nothing uses it yet: the existing operation ledger stays authoritative.
Rows are keyed by caller and operation id; a send looks across callers and other methods look up only their caller's row, matching the existing ledger. Bad rows and newer table shapes read as unreadable instead of throwing.
…tion id Global lookups now take rows in insertion order, so a read and an insert agree on which receipt answers an id regardless of the host clock. The primary key leads with the operation id, so its own index serves both lookups.
Send (direct, queued, steer) and /compact no longer commit a separate ledger reservation before the message is saved. Their command receipt is inserted-if-absent in the same transaction as the submission or queued draft; a repeated id replays from it, a changed request conflicts. Operation ids are checked for syntax only, so age, clock skew, pruning and the reservation's own storage can no longer refuse a send. The ledger success row they already co-committed stays as a temporary insert-if-absent write so families not yet switched keep seeing these ids; it is never read on this path.
…d space with Send, wake delivery once - After its commit, a send whose publication fails answers through the same receipt replay a retry takes, so one id gets one answer; the in-memory copy and journal reload are gone. - /compact ids are global like Send's: a retry under a new connection replays instead of needing a second acknowledgment write. - The receipt route calls its runner directly; the runner wakes delivery when a new message row commits. - The compatibility ledger row expires from host time; each temporary site is labelled. - Receipt read errors and post-commit failures are logged.
…d ledger Restart-continuation tests now model the case Send cannot vouch for (saved but not loaded into memory) and keep it unconfirmed; a later throw after the message is in the chat answers continued, as a resend would. Drop /compact's unreachable legacy replay branch and fix stale comments.
Send and /compact now route on acceptsWithCommandReceipt: main made /clear and rewind settlesWithWrite plans with their own success receipts, and they stay on ledger settlement. /compact keeps #25704's queued answer under receipt replay.
…rough their own receipt Send Now, Delete, Resume and prompt answers move onto the command-receipt route Send uses: the receipt is inserted with the command's own write. A no-op answers only once its receipt commits (otherwise outcome unknown), and a prompt answer's replay reads the exact revision it was accepted at.
Send Now's acknowledgement points at the submission it handed off; a question answer's receipt keeps the accepted resolution; a no-op that cannot save its receipt answers outcome unknown and holds nothing in memory; classified refusals pass through.
…eued messages Stop accepts through its own receipt: one transaction saves the Stop record (target turn, agent generation, cutoff), returns unsent messages to the queue as paused cards, and writes the receipt; only then does it interrupt the captured target. A failed save leaves the agent running and says so. A late Stop is a recorded no-op, and a retry is answered from the receipt.
…ed refuses, hold only what the Stop did not stop
- A Stop naming a turn that already ended is an accepted no-op.
- A card's Cancel saves its receipt with the dismissal row.
- A Stop saved but not carried out is refused as stopFailed ("Couldn't stop <agent>. Try again.") and its receipt is rewritten as rejected.
- A Stop during a start is saved first, then aborts that start; the abort-before-admission path is removed.
- The message the Stop stops is withdrawn as before; messages behind it are held as paused cards. Non-text messages are still withdrawn (TEMPORARY).
- Cleanups: one receipt commit path, dead fields and helpers removed, the returned-to-queue text names the agent.
…ssage fate as on main - Stop's withdrawal of queued sends is main's own rows (rejected, cancelled), written in the same transaction as the Stop event and its receipt; a failed save withdraws nothing. - A Stop arriving during a start aborts that wait on arrival, as on main (admits-now guard), then captures its target once in the session lane. The out-of-lane admission is removed. - A committed receipt is never rewritten: a failure after the save answers from the receipt and writes the existing 'Cancellation was not confirmed.' note. 'Couldn't stop <agent>' only when the save fails; a card Cancel that can't be saved says the question or approval wasn't cancelled. - A Stop naming a turn a rewind removed is late (an accepted no-op). - Removes the held-card settlement and returnedToQueue: what happens to a stopped message stays as on main.
…nly for unconfirmed interrupts - A Stop whose write throws after its receipt committed still interrupts (never 'Couldn't stop'). - An interrupt that worked answers stopped even when only its note fails to write. - A strict card interrupt that throws after its receipt committed gets the 'Cancellation was not confirmed.' note, through one shared helper with the turn path. - A target-less Stop stands while the main agent works, so a turn that opened while its save waited (a send made before the Stop) is interrupted, as on main. - A card Cancel that can't be saved says 'Couldn't cancel this question or approval. Try again.'
- The Stop event's turn is resolved when its write runs (the named turn, else the live turn), so a turn that opened while the save waited is the one marked interrupted and shown stopping. - The unconfirmed note is keyed by that turn; tests cover it and its own write failing. - Whether a Stop withdrew anything comes from its capture, so a throw after the commit can't flip it. - A Stop counts as saved anyway only when its own write committed the receipt.
…eipts-stop # Conflicts: # src/main/native-chat/agent-session-journal/command-receipt-schema.ts # src/main/native-chat/agent-session-journal/journal-queued-messages.ts # src/main/native-chat/agent-session-journal/journal-store.ts # src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts # src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts # src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts # src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts # src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts # src/main/runtime/agent-session-record-store.ts
…on receipt Main's create path (from #26332) hands performSend the founding receipt; the Stop changes widened the turn context's receipt to track its commit. Wrap it with the existing observedReceipt.
…ot done A background-task Stop now saves its receipt before it asks the agent. A refusal after that save (task still running, or a timeout / lost connection) was being replaced by the receipt's replay, so the person never heard the Stop failed. That refusal is now the first answer, as on main; the receipt stays saved and a retry of the same id is answered from it without asking again.
…w-up alone The late-Stop rule makes a Stop naming a turn that has ended an accepted no-op for every agent. The Claude case was rewritten with it; this is Grok's.
…Stop rule - The "leaves the child alone" Stop now reaches the provider (handed over, a turn with no row yet); the at-rest refusal case was a duplicate of the late no-op test and is removed. - The adapter comment and two test names say the gap rule covers a named turn with no journal row yet. - The Grok late-Stop test checks no status row and that the follow-up completes. - Name the two refusals refusalAnswers passes through.
Review summaryThis PR makes a native-chat Stop save itself before it acts, so a late or repeated Stop can't stop work sent after it. It went through four review rounds, a sync with main and a review after that sync, then a live check with real Claude Code. Head: Review roundsRound 1 (at
Round 2: four P2s. Scope was then narrowed (
Round 3 (at
A Stop pressed before an ordinary rewind was labelled TEMPORARY, with the rewind and /clear receipt PR as the follow-up. Round 4 (at Sync with main (
|
|
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
📝 Walkthrough
Priority: ➖ Normal Merge Risk: 🔵 Low · up to
|
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:
0107970c-24ba-4225-9d8d-2d3f76ded3c1
📒 Files selected for processing (68)
config/scripts/vitest-sqlite-runtime-files.mjssrc/main/acp/acp-structured-host-stop.test.tssrc/main/acp/acp-structured-session-adapter-lifecycle.test.tssrc/main/acp/acp-structured-session-adapter.tssrc/main/claude/claude-structured-conversation-stop.test.tssrc/main/native-chat/agent-session-journal/command-receipt-schema.tssrc/main/native-chat/agent-session-journal/journal-item-fold.tssrc/main/native-chat/agent-session-journal/journal-reducer.tssrc/main/native-chat/agent-session-journal/journal-row-writer.tssrc/main/native-chat/agent-session-journal/journal-stop-acceptance.test.tssrc/main/native-chat/agent-session-journal/journal-stop-acceptance.tssrc/main/native-chat/agent-session-journal/journal-store-collaborators.tssrc/main/native-chat/agent-session-journal/journal-store.tssrc/main/native-chat/agent-session-wire/structured-agent-session-background-stop-receipts.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.tssrc/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.tssrc/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-turn-end.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.tssrc/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-create.tssrc/main/native-chat/agent-session-wire/structured-agent-session-host.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.tssrc/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.tssrc/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.tssrc/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.tssrc/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.tssrc/main/native-chat/agent-session-wire/structured-agent-session-queued-withdrawn-draft.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-acceptance.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-failed-after-save.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-receipts.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-saves-first.test.tssrc/main/native-chat/agent-session-wire/structured-agent-session-stop-unconfirmed-note.tssrc/main/native-chat/agent-session-wire/structured-agent-session-targeted-cancel.tssrc/main/native-chat/agent-session-wire/structured-agent-session-turn-stop-notes.tssrc/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.tssrc/main/native-chat/agent-session-wire/structured-agent-session-turns.tssrc/renderer/src/components/native-chat/agent-session-failure-stop-words-text.tssrc/renderer/src/components/native-chat/agent-session-failure-words-text.tssrc/renderer/src/components/native-chat/agent-session-write-notice-text.tssrc/renderer/src/i18n/en-runtime-required.jsonsrc/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/shared/agent-session-failure-copy.tssrc/shared/agent-session-failure-words.tssrc/shared/agent-session-failure.tssrc/shared/agent-session-refusal-details.tssrc/shared/agent-session-refusal-notice-stop.test.tssrc/shared/agent-session-refusal-notice.test.tssrc/shared/agent-session-refusal-notice.tssrc/shared/agent-session-refusal-reason-words.tssrc/shared/agent-session-write-failure.tssrc/shared/agent-session-write-notice-copy.ts
💤 Files with no reviewable changes (1)
- src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.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.
…otes what happened A background-task Stop is saved before it asks the agent. The answer is that saved acceptance: a stopped task answers cancelled:true as before (the phone keeps the row "stopping" on it), and anything else answers cancelled:false. A retry answers from the receipt without asking again. What the agent then says comes back as a note keyed by the Stop's own id, written through the existing Stop-note path, and never as a refusal: - still running: "The background task wasn't stopped." (or "The background tasks weren't stopped." for Stop all), the new status-row failure kind backgroundTaskNotStopped; - timed out or lost the agent: "Cancellation was not confirmed." The note is the thread's, since tasks outlive their turn. A note that cannot be written is logged and changes no answer. The background-task body moves out of performCancel into the targeted cancel, and the refusalAnswers flag and its receipt are deleted.
…2025/chat-receipts-stop # Conflicts: # src/renderer/src/components/native-chat/agent-session-failure-words-text.ts # src/shared/agent-session-refusal-details.ts # src/shared/agent-session-write-failure.ts
A background-task Stop now answers its saved acceptance and says what happened in a note: "The background task wasn't stopped." when the command still runs, "Cancellation was not confirmed." when Codex was lost.
…2025/chat-receipts-stop
…eipts-stop # Conflicts: # config/scripts/vitest-sqlite-runtime-files.mjs # src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts # src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts # src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts
Merges the receipt, save-first, failed-after-save and background-task Stop tests into one flow file; drops unit and per-branch tests already covered by a flow test.
main now accepts a Stop in one transaction (#27076). The withdrawal rows that transaction writes for host-held sends carry neverReachedAgent, so the proof lands with the Stop itself; the old unawaited withdrawal path is gone. Trims the PR's tests to the main flow, the failed-save case and the contracts.
ELI5
Pressing Stop in a native chat has two problems today:
After this PR, Stop is saved before it does anything, the same way Send already is. A retry of the same Stop gets the same answer and never stops newer work. What happens to the messages you had queued does not change: that stays exactly as it is on main, and a separate change owns it.
What Changed
What you see:
Mechanism:
Stop goes through its own command receipt, the path fix(native-chat): accept sends through their own receipt #26672 and native chat: queued-message actions and question answers accept through their own receipt #26988 added for Send and the queued-message actions. A same-id retry is answered from the receipt. It never picks a target again and never interrupts again.
Save first, in one database transaction. The transaction writes:
Then the interrupt runs. Main wrote the withdrawal unawaited beside the interrupt.
Stop is admitted in the session's queue, like every other command. A Stop that arrives while the agent is still starting aborts that start on arrival, as main does. A replay of an already-answered Stop never aborts anything. The Stop then picks its target once, in the queue.
A committed receipt is never rewritten.
Late Stop rule. A Stop that names a turn that has ended is an accepted no-op with its receipt. That includes a turn a rewind removed within the same conversation history.
A card's Cancel saves its receipt in the same transaction as the card's dismissal.
Why
The bug class: Stop's effects were not tied to a durable record of what it was pressed on. A Stop that wasn't saved, or a retry of one, could act on newer work.
The fix follows the common pattern:
Differences from the common pattern, and from main:
cancelled: false, no withdrawal). On main, a Stop naming an ended reply still withdrew the queue and could interrupt the next message. A retry must acknowledge the original command and never act on newer work. This also lets a message sent after that reply run instead of being thrown away.cancelled: trueas on main, which the phone uses to keep the task row showing as stopping. This adds one new failure kind,backgroundTaskNotStopped. A client that predates it can't read the fact, so it shows the host-written words, as it does for any newer failure kind. Main's newer background-Stop behaviour is kept: the stops the agent accepts still update the subagent rows, and a Stop all with work no row lists still ends the agent when no reply is running, now after the receipt is saved.Older clients:
stopFailedandcancelNotSavedare new refusal reasons. A client that doesn't know them shows the generic words for that action, for example "The agent wasn't stopped." That is pinned by tests and was checked against the notice code from v1.4.223.Scope boundary: what happens to a stopped message (withdrawn, kept, or later passed to the agent) belongs to a separate change. That change builds on this one: this PR decides where and when the withdrawal is written, and that change decides what is written.
Linked Issue
Based on main. Follows #26672 and #26988 (queued-message actions and prompt answers through receipts), both merged.
Visual Proof
Real Claude Code in a hidden desktop build. BEFORE is main at
83056e5edb4; AFTER is this PR atb0e06c54513.1. Stop during a reply. BEFORE: the stopped reply, with "Cancellation requested." shown below it.
AFTER: the same stop. The reply reads "Interrupted after 8s", and the note is now inside the reply's details.
AFTER, with the reply's details opened: "Cancellation requested." is there.
4. Cancelling a question card (AFTER). The card is dismissed, it reads "Cancelled", and no "Couldn't" message appears.
5. Messages sent while a reply runs, then Stop. BEFORE: both follow-ups are withdrawn and the chat says "Stopped manually".
AFTER: the same result. The journal rows match BEFORE: the Stop event, then each message withdrawn in send order.
Testing
Live check, real Claude Code, BEFORE (main) vs AFTER (this PR). Every verdict was read against the screenshots and the chat's own journal database.
Automated:
src/main/native-chat(630 files) andsrc/main/acp(72 files) suites,src/main/claude, and every runtime and Codex test that sends a Stop.tc:node,tc:webandtc:cli.orca-ci-checksand the mobile test-typecheck ratchet.b66c8e06306.AI Disclosure
Review
Agent skill upstream boundary
Notes
Author: @BrennanKB5.
Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)