Skip to content

fix(claude): a Stop ends Claude's process, and the next message resumes the conversation - #24235

Merged
brennanb2025 merged 22 commits into
mainfrom
brennanb2025/claude-stop-ends-session
Oct 1, 2026
Merged

brennanb2025 merged 22 commits into
mainfrom
brennanb2025/claude-stop-ends-session

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 17 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1440 $\color{#cf222e}{\Huge{\mathbf{−}}}$​503 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​937
Prod 27 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​708 $\color{#cf222e}{\Huge{\mathbf{−}}}$​356 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​352

What this fixes, and where

The problem. Pressing Stop in a native Claude chat only asked the running Claude process to interrupt the turn, and kept that process alive. So a Stop could fail to stop in three ways:

  • Claude refused the interrupt: the turn kept running and the chat said "Claude didn't stop.".
  • The interrupt got no answer: the chat waited about 30 s, then said "Cancellation was not confirmed.", and the turn could still be running.
  • Background work survived: a background shell command or a subagent Claude had started kept running after Stop, and the chat kept showing it as running ("Monitoring").

The cards had a matching problem: a card's X or Esc (on a permission, plan or question card), and the approval card's own "Stop" option, interrupted the turn the same way, with the same failure modes.

Why it happened. Stop was a request to a process that stayed alive. Whether anything ended depended on Claude taking that request, and background work outlives an interrupt by design.

Scope. Native (structured) Claude chats, on the desktop app and the phone, on local and SSH hosts alike: the change is on the host that runs Claude, and no message format changes. Codex chats are unchanged. Terminal agent tabs are unaffected.

Not affected: Codex's Stop and Codex cards, terminal agent tabs, an explicit Allow / Deny / answer on a card, Stopping one background task from its own control, and what happens to messages queued behind the turn (still withdrawn, never sent).

Depends on #24267 (must land first): #24267 makes a chat with no running Claude process list its model the way a running one does; this PR leaves the chat in that state after every Stop, so without it a stray "Medium" effort picker would appear after each Stop until the next reply.

ELI5

Before, Stop asked Claude to stop, and Claude could say no, not answer, or leave background work running.

Now Stop still asks first, gives Claude a moment (at most 3 s) to finish the turn tidily, then Orca ends that Claude process. Your next message starts Claude again on the same conversation, so nothing is lost; that first reply takes about 1.8 s longer.

A card's X now means "no" to that card, not "stop everything". A question's Cancel is a real Stop.

What Changed

Before After
Stop, Claude takes the interrupt Turn reads Interrupted; the same Claude process keeps running Turn reads Interrupted at once (26-96 ms in QA); Claude's process ends about 0.15-0.5 s later
Stop, Claude refuses the interrupt "Claude didn't stop."; the turn keeps running The process ends at once; the turn reads Interrupted, with "Cancellation requested."
Stop, the interrupt gets no answer About 30 s, then "Cancellation was not confirmed."; the turn may keep running The process ends within about 3 s; the turn reads Interrupted
Stop pressed right after Send, before Claude shows anything "Interrupted after 0s"; your message is kept Same
Background shell or subagent running at Stop Keeps running; the chat shows it running ("Monitoring") Ends with the process; Monitoring clears; the task reads stopped (Interrupted), not a neutral dot
A message you steered into the running turn Withdrawn and put back in the message box, never sent Same
The first message after a Stop Goes to the same process Orca restarts Claude on the same conversation: first reply +1.8 s median (3.26 s vs 1.49 s)
A message sent while the Stop is still ending the process Goes to the same process Waits for the old process to end, then goes once to the restarted one; no error
Approval card: X or Esc Interrupted the whole turn; Claude did not reply; the receipt read "Cancelled" Same as Deny for Claude, so the turn carries on; the receipt reads "Cancelled"
Approval card: options Allow / Allow for this session / Deny / Stop (Stop = deny and interrupt, process kept) Allow / Allow for this session / Deny. To stop, Deny (or X), then Stop
Plan card: X or Esc Interrupted the whole turn Claude is told the plan was dismissed and to wait; it ends its turn without a new plan ("Tell me what you'd like changed."); the receipt reads "Cancelled"
Plan card: options Approve plan / Keep planning / Stop Approve plan / Keep planning
Question card: Cancel Interrupted the turn, keeping the process (with the same failure modes as Stop) The chat's full Stop: the receipt reads "Cancelled" and the turn Interrupted together, the process ends, and the next message resumes

Mechanism.

  • Stop is two steps on the chat's own queue (structured-agent-session-chat-stop.ts, mutateWithChatStop). Every request for a chat runs one at a time on that queue.
    • Step 1: withdraw queued messages, record the Stop, interrupt Claude. The Stop answers here, so the button never waits on the close.
    • Step 2, queued in the same instant: wait for Claude to wind down, then end the process. A message sent meanwhile queues behind step 2, so it can only reach the restarted process.
  • Only Claude opts in. A new optional provider member, stopEndsSession (structured-agent-session-adapter-stop.ts), is true for Claude. Codex does not declare it, so its Stop is unchanged.
  • The wait (claude-request-end-wait.ts) is one budget of 3 s, counted from the interrupt.
    • It lasts while Claude has a turn open or a message it has not yet acknowledged. It ends early on the turn's result, Claude's idle report, or the process's exit.
    • It runs only when Claude took the interrupt. A refused or failed interrupt ends the process at once.
    • It exists because Orca saves where to resume only when the stopped turn ends. It also lets a Stop pressed before Claude acknowledged the message read Interrupted, not "Worked for 0s".
  • Claude's interrupt deadline drops from 30 s to the same 3 s.
  • The process ends through the close the idle sweep already uses to put a quiet chat to rest. Waking a resting chat on the next message is existing behaviour.
    • A failure in step 2 is reported, never shown as a failed Stop. The next Stop retries it, and so does the idle sweep.
  • Background work. When Orca ends Claude's process and proves its whole process tree is gone, still-running tasks are recorded as stopped (claude-structured-session-close.ts, ClaudeChildWorkDecoder.stopLive).
    • If a descendant was seen surviving, or Claude exited on its own, the tasks stay "ending unknown", as before.
  • Cards. The host asks the provider where a card's Cancel goes (routePromptCancel; structured-agent-session-prompt-cancel.ts):
    • Approval or plan card: a dismissal (dismissPrompt). The card is recorded as cancelled by you, and Claude gets a deny: the same reply as Deny for a tool, or "dismissed, end your turn and wait" for a plan. Nothing is interrupted.
    • Question card: the chat's Stop above, if the card came from the turn running now. A card left over from an earlier turn is only declined, so it never stops an unrelated newer turn.
    • Codex declares no route, so its cards keep the old interrupt.
  • Approval and plan cards no longer offer "Stop". A card created before the update still answers its old Stop option, now as a dismissal.

Why

Ending the process removes all three failure modes at once. A refusal, a lost answer and surviving background work all end with the process. No new state is stored: the wait lives only for the length of the Stop.

Alternatives considered:

  • End the process without interrupting first. Rejected: a message Claude still holds would be recorded as "may or may not have been delivered", never re-sent and never returned to you. The interrupt is what withdraws it cleanly.
  • End the process without waiting. Rejected: the next message would resume from the turn before the stopped one, and a Stop pressed before Claude acknowledged the message read "Worked for 0s" with a green done check (QA caught this on an early version of this PR).
  • Answer the Stop only after the process has ended. Rejected: up to about 8.5 s before the Stop answers. Answering after the interrupt and ending the process as the next queued step keeps the same order without making you wait.
  • A wait flag armed in the cancel and awaited in the close (an earlier version of this PR). Replaced: it was spread across five places and could linger. The wait is now one call that the Stop's second step owns.
  • Keep a card's X as an interrupt. Rejected: an X on a permission card should mean "no", and the old interrupt had the same failure modes as the old Stop.

Differences from the common pattern

  • Matches: interrupt, a short bounded wait, end the provider process, resume on the next message. Comparable hosts do the same for Claude (a 3-4 s wait), and they also end background work and pending cards with the process.
  • Matches: the wait is keyed on Claude finishing what it has in flight, not on a turn id. This covers a Stop pressed before Claude acknowledged the message.
  • Matches: card controls. No Stop while an approval is up (deny, then stop). An approval's dismiss is a plain deny. A plan's dismiss asks Claude to stop and wait. A question's Cancel is the chat's Stop.
  • Matches one of two shapes: when Stop answers. Comparable hosts split: one answers at once and runs the whole sequence on a serial queue; the other answers only after the close. This PR follows the first.
    • Intended: it answers once the interrupt is answered (at most 3 s), so the answer can report what the interrupt withdrew.
  • Matches one of two shapes: a refused or failed interrupt ends the process at once, without the wait. Comparable hosts split here too.
  • Intended: Orca's host, not the Claude integration, ends the process, through the same rest path an idle chat uses.
    • Why: Orca's host owns the process's lease, the chat's runtime fence (a counter that marks which process owns the chat), dead-turn settlement and status publishing.
    • A process the integration ended by itself would look like a crash.
  • Intended (not new here): ending the process waits for proof that it exited (stdin closed, then SIGTERM, then a supervised SIGKILL) before a new one can start. Comparable hosts close without waiting, or give up after one bound.
    • Why: a resumed Claude must never run beside the old one on the same conversation, and on a remote host, losing contact is not proof of exit. Every Orca close already works this way.
    • If the proof fails, the failure is reported and the Stop still succeeds.
  • Intended: background tasks read "stopped" only when Orca proved the process tree gone. Comparable hosts mark them stopped without checking.
    • Why: Orca checks the tree, and calling work "ended" while a descendant is seen alive would contradict its own evidence. Remote hosts follow the same rule.
  • Intended: a question card's Cancel stops the chat only when the card came from the running turn. One comparable host stops unconditionally; the other offers no Cancel there.
    • Why: a card left over from a finished turn (for example, a background agent's question) would otherwise stop and end a newer, unrelated turn. A scratch test reproduced this in Orca.
  • Intended: Codex keeps its process on Stop. Comparable hosts split on Codex, and an earlier ruling keeps Codex's background exec sessions alive across a Stop.
  • Temporary: no immediate "Stopping" state. The chat reads Working until Claude's interrupted result arrives, or the process ends. That is normally under 0.1 s, and up to about 3 s when Claude says nothing.
    • Comparable hosts change the chat at once.
    • This exists on main today. It is listed because this PR's Stop now includes the wait and the process end.
    • Follow-up: tracked by the maintainers.

Linked Issue

N/A (maintainer change).

Visual Proof

Captured with live Claude 2.1.286 on an M4 Air, in a hidden window via the Chrome DevTools Protocol.

Before After
Main: Stop about 100 ms after Send reads "Interrupted after 0s"
Main: Stop right after Send
This PR: same early Stop reads "Interrupted after 0s", no done check, message kept
PR: Stop right after Send

Stop mid-turn, then the next message resumes the same conversation:

Stop button while Claude streams

After Stop: Interrupted, Claude's process ended 220 ms after the click

Next message: restarted Claude resumes the same conversation and remembers the stopped turn

(The "Medium" control in the after-Stop composer is the stray picker fixed by #24267.)

Background work ends with the Stop:

Background shell row reads stopped; the running-work dock is gone

Foreground subagent and its shell end; the turn reads Interrupted (the row's "failed" label is the same on main)

Cards:

Approval card: Allow / Allow for this session / Deny, no Stop option

Approval X and Esc: receipts read Cancelled, Claude replies, the same process keeps running

Plan card X: Cancelled; Claude ends its turn without a new plan

Question card Cancel: Cancelled and Interrupted together; the process ends and the next message resumes

Testing

Live QA (macOS, M4 Air, real Claude Code 2.1.286, structured chat; isolated home and profile). The final round ran at 3666db1. The three later commits are a module move, the proven-tree check for background tasks (covered by unit tests) and comments.

Scenario Head Result
Stop mid-turn, next message resumes the same conversation with context 43e9dcc, 6f80b6c PASS: Interrupted +29-52 ms, process gone +235-341 ms, --resume with the same id
Stop with a background sleep 600 43e9dcc PASS: shell gone +239 ms, Claude +371 ms, Monitoring cleared
Stop while a subagent runs 43e9dcc PASS: subagent's shell gone +155 ms, Claude +376 ms
Background task reads stopped after Stop (final head) 3666db1 PASS: background shell row reads "stopped", shell gone +132 ms, Claude +287 ms, tab reads Interrupted, Monitoring cleared
Steered message, then Stop 43e9dcc PASS: put back in the message box; Claude never received it
Send right after Stop (0.46-1.16 s), 3 runs 43e9dcc PASS: delivered once each, no notice. The old process had already exited each time, so a send landing while it was alive is covered only by unit tests
Stop about 100 ms after Send in a new chat, 5 tries; same on main, 3 tries 3666db1 PASS: "Interrupted after 0s", message kept, no done check, resume works, matching main
Stop mid-stream in a new chat's first turn, then send 43e9dcc PASS: no "No conversation found"
Cold start, 4 runs each 43e9dcc Median time to the reply's first text: 1491 ms with a running process vs 3256 ms after a Stop (+~1.8 s). Spawn median 247 ms; one 10.3 s outlier
Approval card X and Esc 3666db1 PASS: "Cancelled", turn continues, Claude replies, same process, no file written
Plan card X and Esc 6f80b6c PASS: "Cancelled", Claude ends its turn with no new plan, same process
Question card Cancel 3666db1 PASS: "Cancelled" and Interrupted in the same 25 ms sample, process gone +195-223 ms, resume answers about the question
Question card left over from a finished turn 3666db1 BLOCKED: Claude Code gives background subagents no question tool, so such a card could not be produced. Unit-tested
Codex Stop keeps its process — BLOCKED: no Codex login in the isolated home, and credentials are never copied. Unit-tested
  • Cold-start method: an in-page sampler every 25 ms, timed from the Enter keypress (same clock as the shell) to the reply's first text. "Running" reused a live process. "After a Stop" waited until the old process was gone, plus 2 s.
  • Unit tests: a new host-level suite runs the shipping Claude adapter under the real host over a scripted Claude connection (19 cases).
    • Stop cases: a taken, refused, failed and unanswered interrupt; a Stop before the message is acknowledged; a withdrawn follow-up; background work; resume; sends during the Stop with the old fence; a second Stop; a close that cannot prove exit.
    • Card cases: approval, plan, question, a leftover question, and a question with nothing to stop.
    • Plus new cases for: the wait, host routing of card Cancels, host Stops for providers with and without stopEndsSession, the router, stopLive, proven vs unproven closes, and dismissal replies.
  • Ablations: about 45 targeted removals of the new behaviour (the second step, the wait and its wake-ups, the 3 s interrupt bound, card routing, the stopped-task rule, the proven-tree check, and others). Each one makes at least one test fail.
  • Local run at the final head: 335 named test files, 4628 passed, 8 skipped, 0 failed. Node typecheck clean.
  • CI at 826c755: green (18 checks pass, 15 skipped by path rules).

Not verified:

  • Windows, Linux, WSL and SSH were not tested live. On WSL, a background shell could outlive the ended process while being recorded stopped; the proven-tree check covers what the host can see.

  • Cold start on a remote host.

  • The leftover question card, live.

  • Codex, live.

  • The phone app, live: the phone's Stop and card Cancel go through the same host path, which the unit tests cover.

  • I manually tested these changes locally

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

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

Remote and mixed versions.

  • No message format changes. After a Stop, the chat publishes the resting state the idle sweep already publishes, so older desktops and phones render it, and their next message wakes the chat.
  • The host decides what a card's Cancel does, so an older client gets the new card behaviour from a new host. A new client on an older host keeps the old behaviour.
  • Cards now list three options (two for a plan); clients render whatever options the card carries.
  • SSH: the host that runs Claude runs the wait and the close. Losing contact never triggers either.
  • A message sent with the chat's fence (the counter that marks which process owns the chat) from before the Stop is not refused: the host does not check it on sends. Unit tests cover the plain and queue-if-busy cases.

Order with #24072 (records the Stop in the chat's history): the two PRs agree on the order inside Stop: withdraw queued messages, record the Stop, interrupt, end the process. Merging them touches only the Stop's body, now in structured-agent-session-chat-stop.ts.

Known follow-ups:

  • A "Stopping" state shown at once on desktop and phone (tracked by the maintainers).
  • A card's Cancel when no turn is running. Pre-existing: both clients refuse it, so a question left over after its turn ended cannot be cancelled.
  • A Stop during startup, and a Codex /compact Stop, still end the process inside the Stop's own step, so a close failure there can still fail that Stop.
  • Quitting the app during a Stop waits behind that Stop's wait and close on the chat's queue. On a slow close, this can outlast the quit's per-chat time limit (about 8 s) and leave cleanup to the next launch.
  • A turn that opens after a Stop relies on Claude reporting why it aborted to read Interrupted. An older Claude Code that omits the reason would read "Failed". This is pre-existing.

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)

…es the conversation

Claude's Stop now ends its child after the interrupt, whatever Claude answered, once the stopped turn ends or a 3 s grace runs out. The grace lets Claude's own result move the resume point past that turn, so the next send resumes after it. Background work ends with the child. Codex keeps its child.
…t step on the chat's lane ends the child

The Stop now answers as soon as the interrupt step finishes. Ending Claude's
process runs as a second task on the session's serialized lane, queued in the
same tick as the Stop, so a message sent meanwhile reaches only the resumed
child. That step waits for the stopped turn to end (its result, the CLI's idle,
the child's exit or its close), then ends the child; a failure there is
reported and never fails the Stop, and the wind-down it leaves owed is retried.

The interrupt's answer and the turn's end share one 3 s grace counted from when
the interrupt goes out, so an interrupt Claude never answers ends the child in
about 3 s instead of after the 30 s control deadline. The wait is derived per
call from Claude's open turn: the armed latch and the close's user-stop branch
are gone, and the close keeps its whole deadline for proving the exit.

A Stop naming a turn that has since ended (the phone names the turn it last
saw) now ends the child too when Claude took it, as it does when Claude
interrupts a handed-over follow-up whose turn has not opened.
… on the shipping adapter

- Nothing queued while the Stop's first step waits on the interrupt runs before
  the child ends.
- A message sent with the pre-Stop fence during the Stop is accepted with no
  notice, and reaches only the resumed child, after the old child's close.
- An interrupt Claude never answers ends the child within the grace.
- A close that cannot prove the exit leaves the Stop answered with its row; the
  failure goes to the host's error hook.
- A Stop naming the turn that just ended ends the child when Claude interrupts
  the handed-over follow-up.
- A second Stop pressed while the first ends the child stays quiet.
…n its interrupt fails or goes unanswered

The phone names the turn it last saw. When Claude interrupted a handed-over
follow-up for that Stop and the answer failed or never came, the child stayed.
Only a provider that answers it did not take the Stop keeps the child now.
Also pins a queue-if-active send made while the Stop ends the child.
… on a question, never a bare interrupt

A permission card's Cancel interrupted the turn and kept Claude running, the
old Stop on a second control: a refused interrupt let the turn run on and
background work survived. Now the host asks the provider how a card's Cancel
is answered. For Claude, an approval's Cancel sends the same reply as its Deny
option, so the turn goes on without that tool; a question's Cancel runs the
chat's Stop, which ends the child, and the next send resumes the conversation.
A provider that gives no answer keeps today's path, so Codex is unchanged.

The host decides, so desktops and phones of any version keep sending the same
cancel and get the new behaviour; nothing on the wire changes.

With no Claude caller left, the prompt-cancel interrupt is gone: the bound
claim, the cancellation observation, the admission of the prompt's
cancellation, the prompt's turn binding, and withdrawing a refused stop (every
Claude interrupt now precedes the child's end, so the recorded stop stands).
…Stop

The approval card's "Stop" option answered Claude with a deny that interrupts
the turn and kept Claude running, the last control on a Claude chat that
interrupted without ending the child. The host now routes it, like the card's
own Cancel, through the provider: for Claude that option is the chat's Stop.
It runs the same Stop body as the Stop button, in the same order (withdraw,
the Stop takes effect, interrupt), and the step that ends the child is queued
in the same synchronous call. The body now lives in one place,
structured-agent-session-chat-stop.ts, shared by the Stop button, a question
card's Cancel and this option.

The host decides, so an old card or client that sends option `cancel` gets the
real Stop; nothing on the wire changes. The interrupting deny reply is gone.
…d's Cancel asks Claude to wait

- Approval and plan cards no longer offer "Stop". Neither reference offers a
  Stop while an approval is up: the user denies, then stops. An older card's or
  client's `cancel` answer is a plain deny, and the respond path is main's again.
- A plan card's X / Esc no longer answers "Keep planning", which sent Claude
  straight back to revise and re-propose. It dismisses the plan: a deny that
  tells Claude to end its turn and wait for the user. No interrupt; the child
  stays. A tool approval's Cancel stays the Deny reply, and a question's Cancel
  stays the chat's Stop.
- The provider's routing takes the pending card itself
  (`routePromptCancel({ sessionId, prompt })`), so a plan is told from a tool
  approval, instead of a kind plus an optional option id that meant Cancel when
  absent. A routed answer may be one the card does not offer, like the
  dismissal.
- The chat's Stop is reachable only through `mutateWithChatStop`, which owns the
  mutation call and queues the step that ends the child in the same call, so
  no caller can run the Stop and forget the child's end.
… passes

The Stop's own body ends the child; the plan's default run serves only a card's
interrupt and the background-task stop, which never end it.
…op's first step, and answers Claude when nothing stops

A question card's Cancel runs the chat's Stop. The card stayed pending until
the child ended (up to about 3 s), so it could still be pressed and the second
press was refused once the chat rested. When the Stop found nothing in flight
(Claude asking after its main turn ended), the card stayed pending for good and
Claude's request went unanswered; the phone froze the card with every button
disabled.

Now the card is recorded as cancelled by the user in the Stop's own step, with
the receipt "Cancelled on <device>". A Stop that ends the session leaves
Claude's request to end with the child, so no reply races the interrupt; one
that ends nothing declines the request itself. The provider forgets the card
once the host records it, so neither Claude's own cancel nor the child's end
writes over the user's cancel. The card's Stop stops whatever the chat has in
flight, like the Stop button, instead of a turn the card names that may have
ended.

The same dismissal, a new provider member beside the cancel route, now answers
a plan card's Cancel: recorded as cancelled by the user, and Claude told to end
its turn and wait. That replaces resolving it as an option the card does not
offer.
…d a question card's Stop stays in its own turn

- A Stop pressed after Send but before Claude echoed it read as a normal finish
  ("Worked for 0s", a green done check): with no turn row to wait on, the child
  was ended at once, and the echo that would have opened the stopped turn
  landed on a retired send. The wait before the child ends is now keyed on what
  Claude has in flight (an open turn or a send it has not answered), not on a
  journal turn id, within the same 3 s from the interrupt and woken by the same
  settles; a settle that leaves something in flight waits on. The echo opens
  the turn, the aborted result ends it Interrupted. A Claude that says nothing
  is ended when the grace runs out, the unanswered send doubt as before.
- A question card's Cancel takes the chat's Stop only when the card belongs to
  the turn running now, judged after draining the provider's lifecycle. A card
  a finished turn raised, such as a background agent's, is dismissed instead:
  Claude's request is declined and nothing stops.
- Claude forgets a dismissed card before the host records it, so Claude's own
  cancel landing during that write cannot replace the user's "Cancelled on
  <device>".
…top-ends-session

# Conflicts:
#	src/main/claude/claude-structured-session-adapter.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts
…ild records, and guard the optional dismissal

Main's child records replaced the adapter's background-task callback: the
background-work test now reads the host's child record (live before the Stop,
settled after the child ends) and the session's agent status (done, Interrupted,
nothing left for Monitoring).
…background work reads stopped

- A tool approval card's X / Esc already sent Claude the plain deny, but the
  card read "Deny · Answered on <device>", the same as pressing Deny. It is
  now a dismissal like a plan card's: recorded as cancelled by the user, with
  the same deny reply, no interrupt and the child kept. Every card's Cancel
  now routes to a dismissal or the chat's Stop, so the option route is gone.
- When a Stop ended Claude's child, a background agent or shell it was running
  settled as an unknown ending (a neutral dot), while the same task's own stop
  reads Interrupted. A close Orca asks for (a Stop, a rest, a quit) now ends
  what still runs as stopped; only a session that dies on its own leaves the
  ending unknown. The close reads no stop cause. A rest never meets live child
  work: the idle sweep keeps a chat with any.
- If the host fails to record a dismissed card, Claude gets the card back, and
  a withdrawal Claude made meanwhile closes it, as before the host took it.
… breaking the chat-stop import cycle

chat-stop.ts imported mutateStructuredAgentSession from host-mutations.ts,
which imports mutateWithChatStop back. The mutation context and the one
admit-then-serialize path now live in structured-agent-session-mutation-context.ts,
which both import; host-mutations re-exports the context type for its other
readers.
…work's ending unknown

A Stop, rest or quit ended still-live background tasks as stopped even when the
close found Claude's root gone but a descendant still running. Only a close
that proved the whole tree gone stops them now; otherwise they settle unknown,
as for an exit of the session's own. The stopped ending is marked as Orca's,
so a frame of the child's own still replaces it.
What re-drives a failed child end, when the child-work decoder stops what is
live, what a Stop's wait re-reads on a result, and why a card's Stop is
unnamed.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready for review (depends on #24267)

The problem. In a native Claude chat, Stop only asked the running Claude process to interrupt, and kept that process alive. A Stop could fail to stop: Claude could refuse it ("Claude didn't stop."), the interrupt could go unanswered (about 30 s, then "Cancellation was not confirmed."), and background shells or subagents kept running with the chat stuck on "Monitoring". A card's X / Cancel had the same weakness.

What changes for you. Stop now always stops: the turn reads Interrupted at once, Claude gets at most 3 s to finish the turn tidily, then Orca ends Claude's process and anything it started. Your next message restarts Claude on the same conversation (the first reply after a Stop takes about 1.8 s longer). An approval or plan card's X / Esc now means "no" to that card and the turn carries on; a question card's Cancel is a real Stop; approval and plan cards no longer offer a "Stop" option. Codex is unchanged.

Fixed during review (each with a test that fails without it):

  • An unanswered interrupt still waited the old 30 s before the process ended; now one 3 s budget covers the interrupt and the wind-down.
  • A Stop that did interrupt could still show an error if ending the process failed; the Stop now answers as soon as the interrupt is sent, and ending the process runs as the chat's next queued step, with any failure reported, never shown as a failed Stop.
  • A Stop pressed in the 1-2 s before Claude echoed the message read "Worked for 0s" with a green done check (QA caught this live); it now reads "Interrupted", as on main.
  • Background tasks a Stop ended read as a neutral dot; they now read stopped, and only when Orca proved the processes are gone.
  • A phone Stop naming the turn that had just ended could leave Claude's process running.
  • Card behaviour now matches comparable apps: approval X / Esc is a plain deny with a "Cancelled" receipt; plan X / Esc tells Claude to stop and wait instead of "keep planning"; a question card's Cancel stops only the turn that asked it.
  • A new import cycle that failed static analysis; stale comments from the earlier design.

Depends on #24267, which must land first. After a Stop the chat rests with no Claude process, and on some models the composer then showed a stray "Medium" effort control; #24267 makes a resting chat's options match a running one.

Deferred follow-ups (none caused by this PR unless noted):

  • A "Stopping" state shown the moment Stop is pressed (pre-existing; tracked separately).
  • A foreground subagent ended by Stop reads "failed" in red, and a stopped background shell's row loses its label after the next message (both identical on main).
  • Neither client lets you Cancel a question card when no turn is running (pre-existing).
  • A Stop while a chat is still starting, or a Codex /compact Stop, can still fail if ending the process fails (pre-existing).
  • Quitting during a Stop waits behind it (made slightly more likely by this PR; bounded).
  • Cosmetic: receipts show the raw device name "desktop-renderer"; the transcript stops auto-following after a card receipt (both pre-existing).

Verified. Live on an M4 Air with real Claude (screenshots in the description): Stop mid-turn and resume with context; background shell and subagent ended; steered message withdrawn; a send right after Stop delivered once; an early Stop compared side by side with main; approval, plan and question cards; cold-start timing (median 1491 ms live vs 3256 ms after a Stop). Unit tests on the shipping Claude adapter cover every fix above, including a Stop racing a send. CI is green on the last pushed head; the final merge of main re-ran 369 test files (5016 passed) and typechecks clean.

Not verified live. Windows, Linux, WSL, SSH hosts and the phone app; a question card left over from a finished turn (Claude gives background subagents no question tool); Codex (no login in the isolated test home). These rest on unit tests.

@brennanb2025
brennanb2025 marked this pull request as ready for review October 1, 2026 06:12
@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium risk] Changes how Claude conversation stops and resumes work.

The PR should not merge until Stop has one bounded deadline on older Claude CLIs with queued messages.

Findings

  1. P1 Stop deadline multiplies per message ▶

Summary

The PR changes native Claude Stop into an interrupt followed by a serialized process close, then resumes the conversation on the next send. It also routes Claude card cancellation by card type and records still-running background work as stopped after a proven close. The older-CLI queued-message withdrawal path does not share the new three-second Stop budget.

Diagram
sequenceDiagram
  participant Client
  participant Host as Session queue
  participant Claude
  Client->>Host: Stop
  Host->>Claude: Interrupt
  Claude-->>Host: Receipt with queued UUIDs
  loop Each queued UUID on older CLI
    Host->>Claude: cancelAsyncMessage (up to 3s each)
  end
  Host-->>Client: Stop result
  Host->>Host: Wait for request end, then close process
Loading

Reviews (1) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..."

const { request, sessions, timeoutMs } = input
const { request, sessions } = input
// A Stop ends the child next, so its interrupt shares the grace with Claude's wind-down after it.
const timeoutMs = Math.min(input.timeoutMs ?? CLAUDE_STOP_GRACE_MS, CLAUDE_STOP_GRACE_MS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stop deadline multiplies per message

On a Claude CLI without interrupt_cancel_queued_v1, the interrupt receipt can list multiple queued messages. Although this line caps the interrupt timeout at three seconds, cancelClaudeTurn then cancels those messages one at a time, giving each request the full three seconds. If they do not answer, Stop can take three seconds per message before the process-close step even begins. Give the interrupt and queued-message withdrawals one shared deadline so Stop remains bounded.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one boundedness gap on the legacy cancel path, plus minor nits inline.

Reviewed changes

  • Stop ends Claude's child: structured-agent-session-chat-stop.ts folds the Stop into one body (mutateWithChatStop) that answers on the interrupt and queues, in the same synchronous tick, a second serialized step that waits for the wind-down and then ends the child.
  • Provider opt-in: new optional StructuredAgentSessionAdapterStop (stopEndsSession / awaitStoppedRequestEnd / routePromptCancel / dismissPrompt). Claude declares all of it; Codex declares none, so its Stop and cards are unchanged.
  • Bounded wind-down: new claude-request-end-wait.ts resolves when Claude has nothing in flight or after 3 s, woken on turn end, idle, exit and close; Claude's interrupt deadline drops 30 s → 3 s.
  • Cards: approval/plan X/Esc become a dismissal (deny); a question card's Cancel runs the chat's Stop; the Stop option is dropped from card presentations while an older card's cancel option still answers as a dismissal.
  • Background work: a close that proved the whole process tree gone marks still-live tasks stopped (stopLive); a close that saw a descendant survive, or a self-exit, leaves them unknown.
  • Refactor: mutateStructuredAgentSession and its context move to structured-agent-session-mutation-context.ts (breaking the chat-stop import cycle); ClaudePromptRegistry loses turn-bound claims and cancellation observations.

ℹ️ Merge order with #24267

This branch rests the chat after every Stop, and the PR body states the stray "Medium" effort picker is only avoided once #24267 lands first. #24267 is still open and unmerged. Confirm the merge order holds, otherwise merging this reintroduces the picker after every Stop.

Technical details
# Dependency sequencing

## Affected sites
- PR #24267 (`fix(native-chat): a resting chat lists its model the way the running agent does`) — currently open, `merged: false`.
- src/main/claude/claude-structured-session-adapter.ts — `stopEndsSession = () => true` makes every Claude Stop rest the chat.

## Required outcome
- #24267 merges first, or this PR waits. Both touch host option/model resolution; a revert of either re-exposes the picker.

ℹ️ Nitpicks

  • src/main/claude/claude-child-work-decoder.ts:129 — stopLive() stamps basis: 'stop-acknowledged' on every proven close (idle evict, quit, shutdown), but that enum is documented as the provider acknowledging a Stop. No consumer reads the basis today, so it is contract-purity rather than a visible mislabel; passing the close's stop cause through would let the basis match its meaning.
  • src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts:40 — the endStoppedStructuredAgentSession doc says the idle sweep retries an owed wind-down, but the sweep deliberately returns while the child still owes work, so recovery in that failure path actually rests on the next Stop.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

const { request, sessions, timeoutMs } = input
const { request, sessions } = input
// A Stop ends the child next, so its interrupt shares the grace with Claude's wind-down after it.
const timeoutMs = Math.min(input.timeoutMs ?? CLAUDE_STOP_GRACE_MS, CLAUDE_STOP_GRACE_MS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 3 s cap bounds each individual control request, but on a CLI without interrupt_cancel_queued_v1 the interrupt receipt's still_queued list is drained one cancelAsyncMessage at a time, each given the full timeout, so a Stop can take about 3 s per queued message before the child's end step even begins. The PR's bounded-3 s contract only holds on the capability path.

Technical details
# Stop's 3 s bound does not hold on the legacy cancel path

## Affected sites
- src/main/claude/claude-structured-prompt-ownership.ts:72 — caps the interrupt/`cancelAsyncMessage` timeout at 3 s.
- src/main/claude/claude-structured-control-actions.ts:50-58 — drains `receipt.still_queued` sequentially, each `cancelAsyncMessage(uuid, { timeoutMs })` receiving the same 3 s.

## Required outcome
- One deadline for the whole interrupt-and-withdraw sequence, so a Stop on a CLI without `interrupt_cancel_queued_v1` stays ~3 s regardless of how many messages were queued.

## Suggested approach
- Derive a single deadline from `stoppedAt` and pass each `cancelAsyncMessage` the remaining time, rather than the full budget.

}): Promise<void> {
const { request, sessions } = input
const session = sessions.get(request.sessionId)
const claim = session?.fence === request.fence ? session.prompts.claim(request.itemId) : null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dismissClaudeStructuredPrompt claims the prompt by fence, then awaits request.commit() (which yields) before answering, but unlike its sibling answerClaudeStructuredPrompt it never re-checks that the captured session is still the current entry in sessions — nor captures acquisitionGeneration. Impact is guarded downstream by answerClaudePrompt's ownsClaim, so this is symmetry/robustness rather than a wrong-answer path.

Technical details
# `dismissClaudeStructuredPrompt` is missing its sibling's recheck

## Affected sites
- src/main/claude/claude-structured-prompt-ownership.ts:195 — claims by fence only; no `sessions.get(request.sessionId) === session` / `acquisitionGeneration` guard around the `await request.commit()` at line 206.
- src/main/claude/claude-structured-prompt-ownership.ts:163-178 — `answerClaudeStructuredPrompt` captures `acquisitionGeneration` and re-checks identity after its commit.

## Required outcome
- The dismissal answers only the acquisition it was admitted for, matching `answerClaudeStructuredPrompt`.

## Open questions for the human
- Impact today is bounded by `answerClaudePrompt`'s `ownsClaim`, so this is consistency rather than a demonstrated wrong-answer path; confirm a commit cannot outlive the acquisition before deciding it is worth the extra guard.

@coderabbitai

coderabbitai Bot commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

🧰 Additional context used
📚 Code guidelines (1)
AGENTS.md — auto-discovered

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 9996fe97-a003-405e-bbc5-9b327908c32f

📥 Commits

Reviewing files that changed from the base of the PR and between 07e9fdf and da7c85b.

📒 Files selected for processing (44)
  • src/main/claude/claude-child-work-decoder.test.ts
  • src/main/claude/claude-child-work-decoder.ts
  • src/main/claude/claude-journal-translator-contract.ts
  • src/main/claude/claude-open-turn.ts
  • src/main/claude/claude-prompt-registry.ts
  • src/main/claude/claude-request-end-wait.test.ts
  • src/main/claude/claude-request-end-wait.ts
  • src/main/claude/claude-structured-child-work-captures.test.ts
  • src/main/claude/claude-structured-child-work-producer.test.ts
  • src/main/claude/claude-structured-control-actions.test.ts
  • src/main/claude/claude-structured-control-actions.ts
  • src/main/claude/claude-structured-in-turn-stop.test.ts
  • src/main/claude/claude-structured-inbound-control.ts
  • src/main/claude/claude-structured-journal-prompts.ts
  • src/main/claude/claude-structured-journal-translation.ts
  • src/main/claude/claude-structured-prompt-items.test.ts
  • src/main/claude/claude-structured-prompt-items.ts
  • src/main/claude/claude-structured-prompt-ownership.test.ts
  • src/main/claude/claude-structured-prompt-ownership.ts
  • src/main/claude/claude-structured-prompt-replies.ts
  • src/main/claude/claude-structured-resume-point.ts
  • src/main/claude/claude-structured-session-acquisition.ts
  • src/main/claude/claude-structured-session-adapter.ts
  • src/main/claude/claude-structured-session-close.test.ts
  • src/main/claude/claude-structured-session-close.ts
  • src/main/claude/claude-turn-outcome.test.ts
  • src/main/claude/claude-turn-ownership.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.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-cancel.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts
  • src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts
  • src/main/runtime/claude-structured-session-integration.test.ts
💤 Files with no reviewable changes (4)
  • src/main/claude/claude-structured-journal-translation.ts
  • src/main/claude/claude-turn-outcome.test.ts
  • src/main/claude/claude-open-turn.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The changes add serialized chat Stop handling and provider hooks for session wind-down. Claude Stop waits for in-flight requests within a grace period and stops live child work when session closure succeeds. Prompt-card cancellation now routes between stopping a turn and dismissing a card, with journal handling for dismissal commits. Tests cover these flows, session closure, and resumed Claude sessions.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to da7c8

Stop now ends Claude's process after a bounded wait, and the next message resumes the conversation. Question-card Cancel routes to Stop; approval and plan cards are dismissed as denials. No outstanding defect was found, and the change appears ready to merge.

Security Architecture Review

Security architecture risk: 🔵 Low · up to da7c8

The reviewed cancellation paths retain session checks and permission denial. No new permission bypass was demonstrated. Remaining uncertainty is concentrated in exceptional shutdown failures and recovery before subsequent work proceeds.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The examined cancellation sinks operate on the admitted session's provider child and associated work. Prompt-driven Stop is further restricted to the turn that raised the pending prompt; a prompt from an earlier turn is dismissed rather than allowed to stop the currently live turn.

Trust Boundaries and Controls

  • observed — Mutation admission recomputes the request fingerprint, passes caller identity into operation admission, and evaluates lease state before executing the plan. Prompt cancellation validates item membership, revision, and pending resolution in the target journal, then revalidates after event flushing before reaching Stop. The underlying caller-policy implementation was not independently established.

Resilience and Maintainability Implications

  • observed — The close policy distinguishes proven root exit from proven whole-tree shutdown. The added child-work stopped marking occurs only when connection closure reports complete success; root-exit finalization alone is not proof that every descendant was terminated.
  • observed — The wind-down helper catches wait and shutdown failures together, so a rejected wait bypasses child shutdown. Claude's current wait uses internally resolved notifications and a timeout, substantially limiting ordinary reachability of that branch. The inspected start helper accepts an existing child; exceptional-failure admission and recovery remain only partially resolved.

Hardening Proposals

  • proposed — Make mandatory child cleanup independent of successful request-end waiting, and explicitly establish that resumed work cannot reuse a child whose required teardown failed. This would strengthen the adapter lifecycle contract; it is not a verified attacker-reachable vulnerability in the current Claude wait implementation.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.70% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 61 functions across 40 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: Claude Stop ends the process and the next message resumes the conversation.
Description check ✅ Passed The description is comprehensive and follows the required structure. It explains the user impact, implementation, rationale, testing, visual proof, compatibility considerations, and known limitations.…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@brennanb2025
brennanb2025 merged commit 4bd55c0 into main Oct 1, 2026
60 checks passed
brennanb2025 added a commit that referenced this pull request Oct 1, 2026
…e-child-waiting

Committed with --no-verify: pre-commit max-lines fails on
structured-agent-session-adapter.ts, byte-identical to origin/main
4bd55c0, which #24235 put at 301/300; not touched by this PR. Full
oxlint reports that file as its only error.

Conflicts kept main's shape with this PR's additions on top: the Claude
child decoder keeps main's stopLive beside observeWaiting, and the prompt
registry and permission callback take main's removal of turnId while
keeping the asking agent. Main's new dismissPrompt path now republishes
child work as answerPrompt does, so a declined subagent request stops
reading waiting at once.
@pullfrog pullfrog Bot mentioned this pull request Oct 1, 2026
7 of 9 tasks
brennanb2025 added a commit that referenced this pull request Oct 1, 2026
#24235 makes a Claude Stop end its child after the interrupt. Ported onto B's
model: the Claude turn's cause is gone (the turn end reads the Stop event), so
recordTurnStop and the Stop's cause argument stay deleted, and the adapter
close takes no cause in the merged-in tests. A refused Claude interrupt still
ends the child, so its Stop writes no refusal: stop-refused is Codex-only, and
a restart test now pins that for a provider whose Stop ends its child.
brennanb2025 added a commit that referenced this pull request Oct 1, 2026
brennanb2025 added a commit that referenced this pull request Oct 1, 2026
…on-ending Stop

A Stop that ends the provider's session stopped its turn, so it writes its note even when the
provider declined the interrupt; a repeated Cancel of a cancelled prompt is answered at the prompt
Cancel's new entry point too.
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