Skip to content

feat(claude): message running turns and keep the process across interrupts - #25

Merged
dviejokfs merged 4 commits into
mainfrom
feat/claude-steer-and-soft-interrupt
Oct 2, 2026
Merged

dviejokfs merged 4 commits into
mainfrom
feat/claude-steer-and-soft-interrupt

Conversation

@dviejokfs

@dviejokfs dviejokfs commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Follow-up to #24. #24 let a turn that had already answered hand its process to the next prompt. That still left two ways to lose a Claude process and its background work:

  • A follow-up while Claude is still answering. start_turn returned RuntimeBusy, so applications interrupted the turn.
  • Any interrupt. Interrupting killed the process.

Messages into a running turn

  • TurnHandle::send_message and the cloneable TurnHandle::message_handle() write more user input into the running Claude process. Each message is written as a user frame with its own UUID.
  • Claude queues these messages and answers them within the same exchange, usually folding them into the reply it is already writing.
  • The turn now ends when every command it sent has ended. Completion is tracked through Claude's command_lifecycle frames instead of the first result. This also closes the fix(claude): keep background subagents alive across retained turns #24 race where an unrelated follow-up result could end a turn early.
  • A message to a turn that has ended fails with InvalidRequest and DeliveryState::NotSent.
  • A message the turn does not take within 10s is atomically withdrawn, so it is never written later, and fails as Timeout with NotSent. Resend only NotSent failures.
  • Messages are bounded: 32 per turn, an 8-deep queue.

Cooperative interrupt

interrupt() on a retained Claude turn ends it Cancelled and keeps the process. What else stops depends on where Claude is:

  • Claude is still working in the foreground. The SDK sends Claude's interrupt control request with cancel_queued, the documented interrupt_cancel_queued_v1 capability.
    • This stops the reply, the foreground tools, and any queued messages, atomically.
    • Claude's own stop also ends background subagents. This is Claude CLI behavior: the print-mode stop handler kills every non-durable task, and no request field opts out. The stop is reported to the app as Stopped task activity.
    • Background shells keep running.
  • Claude has already answered and only background work runs. The SDK sends Claude nothing. All background work, including subagents, keeps running.

After the stop:

  • Claude may write the stopped exchange to its transcript just after it reports the turn over. When no background work remains, the SDK reads on until the stream is quiet, so the process is kept at a clean boundary.
  • If Claude does not confirm the stop within 3s, the process is retired as before.
  • A process with surviving background work is parked. A reader keeps parsing its output and buffers events (max 1,024; held approval requests are never evicted) and approval requests (max 16; overflow is denied and reported as a warning) for the next turn.
  • Once background work drains, a parked process waits only the follow-up grace for Claude's answer, then the idle timeout.

So applications should send follow-ups with send_message or start_turn (neither stops anything) and interrupt only when the user asks to stop.

API

All additions are additive with defaults that keep the previous behavior:

  • TurnCapabilities::live_messages and RuntimeDriverCapabilities::live_messages. The latter is only set when retention is enabled, and is always false on protocol clients.
  • MessageDelivery, TurnMessageHandle, and RuntimeTurnExecutor::send_retained_message.
  • AgentAdapter::encode_user_message, retained_interrupt_settled, and retained_background_work.

Codex and OpenCode are unchanged.

Load and bounds

This is control-plane only, with one retained process per runtime. Message queues and parked buffers have fixed caps, and parked processes expire.

Testing

  • Unit tests:

    • Claude adapter: folded messages; a reply before a queued message; Claude's own follow-up commands; interrupt with cancel_queued, plus the re-interrupt fallback for older CLIs; an answered turn settles with nothing sent; the message cap; UUID format.
    • Runtime: parked buffer eviction keeps pending requests; denied requests are reported; drained-work grace; withdrawn messages are never written.
    • retained: message delivery, a finished turn, validation, capability gating, and remote handles reporting no live messages.
  • tests/retained_live_messages.rs: 8 end-to-end tests against a fixture modelling Claude's command queue, lifecycle frames, interrupts, background tasks, and the late transcript frames after a stop. Without the quiet-boundary fix, the fixture reproduces the retired-process bug.

  • CI gate, run locally: fmt, clippy -D warnings (Rust 1.99), the feature matrix, package checks, cargo doc, fullstack-example clippy, and cargo test --all-features (407 tests).

  • Live e2e against Claude CLI 2.1.287: cargo run --example claude_live_messages_smoke -- <model> [scenario...]. All 8 scenarios pass on both sonnet and haiku, with zero process replacements. The Claude process identity is checked across interrupts via the Bash tool's parent PID.

    Scenario Verifies
    fold A message sent during a foreground command is answered by the same turn; a late message is rejected as NotSent.
    multi Two messages sent during one command are both answered.
    queued A message queued behind an interrupted command never runs.
    idle An interrupt with no background work keeps the same process and stops the foreground command.
    bg-shell A background shell survives an interrupt in the same process, and its completion reaches a later turn.
    bg-agent Interrupted mid-work: the process is kept, and Claude's stop of the subagent is reported as Stopped.
    bg-agent-answered Interrupted after the answer: the subagent keeps running, and its completion reaches a later turn.
    approval A background subagent's approval, requested while no turn runs, is held and answered by the next turn's handler.

    The fix(claude): keep background subagents alive across retained turns #24 smokes claude_background_handoff_smoke and claude_subagent_smoke still pass. No processes are left behind.

🤖 Generated with Claude Code

…kground work

A retained Claude turn now accepts further user messages while it runs
(TurnHandle::send_message / message_handle). Claude queues them and answers
within the same exchange; the turn completes once every command it sent has
ended, correlated through Claude's command_lifecycle frames instead of the
first result.

Interrupting a retained Claude turn is now cooperative: the interrupt control
request stops the foreground reply, its tools and queued messages, and the
process is kept. Background subagents and shells keep running; while no turn
owns the process the SDK keeps reading it and buffers their output and
approval requests (bounded) for the next turn. An interrupt Claude does not
confirm within three seconds still retires the process.

Support is reported by the new live_messages capability. New adapter and
executor hooks default to the previous behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High risk] Retained process lifecycle and message handling for Claude.

The PR appears safe to merge; the latest cleanup change fixes the remaining numbered finding.

What we checked:

  • Repeated cleanup is harmless: A second disposal returns DisposeOutcome::NotFound without touching another runtime. Every scenario uses the same ID that the cleanup constructs.

Summary

This PR adds messages into running retained Claude turns and keeps Claude processes across confirmed interrupts. It also buffers background output and approvals between turns.

  • The latest change cleans up each smoke scenario’s runtime on both success and failure.
  • The numbered finding from the previous review is fixed.
  • No new actionable issues were found in the latest change.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Running retained Claude turn] --> B[Send another message]
    B --> A
    A --> C[User interrupts]
    C --> D{Claude already answered?}
    D -->|Yes| E[End turn without sending a stop]
    D -->|No| F[Send interrupt and cancel queued messages]
    F --> G{Stop confirmed?}
    G -->|No| H[Retire process]
    G -->|Yes| I{Background work remains?}
    E --> I
    I -->|Yes| J[Park process and buffer output]
    I -->|No| K[Keep idle process until expiry]
    J --> L[Next turn inherits buffered output]
Loading

Reviews (4) · Last reviewed commit: "test(examples): dispose each live scenar..."

Comment thread src/protocol_client.rs
Comment thread src/runtime.rs
Comment thread src/runtime.rs Outdated
Comment thread docs/how-to/stream-claude-subagents.md Outdated
Comment thread src/runtime.rs
David Viejo and others added 2 commits October 2, 2026 10:43
- Protocol clients report live_messages as unavailable; the remote protocol
  cannot write into a running invocation.
- A parked process whose background work drained waits only the follow-up
  grace for Claude's answer, then starts its idle expiry, instead of holding
  its slot indefinitely.
- The parked event buffer never drops the request event of an approval it
  still holds, and approvals denied for overflow are reported as a warning on
  the next turn instead of being presented as pending.
- A message the turn does not take within the delivery timeout is atomically
  withdrawn, so it is never written later, and fails as Timeout with
  DeliveryState::NotSent. The docs now say to resend only NotSent failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssages atomically

End-to-end runs against the real Claude CLI found three problems:

- Claude sometimes writes a stopped exchange to its transcript (rejected tool
  results, the interruption marker) just after it reports the turn over. An
  idle process treated those frames as unexpected output and was retired, so
  the next turn spawned a new process. Settling an interrupt now reads on
  until the stream is quiet when no background work remains; a parked
  process already reads everything.
- Messages queued behind an interrupted exchange could still run. The stop
  now sends cancel_queued (interrupt_cancel_queued_v1), which cancels them
  atomically with the abort; CLIs without it fall back to stopping each one
  as it starts.
- Claude's stop also ends background subagents (background shells survive);
  only a turn that already answered is ended without sending Claude
  anything. The docs now say so instead of promising subagents survive.

The live smoke becomes a scenario suite (messages, queued messages, process
identity across interrupts, background shells and subagents, held background
approvals), and the fixture emits the late transcript frames so the retired
process regression is covered deterministically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dviejokfs dviejokfs changed the title feat(claude): message running turns and interrupt without killing background work feat(claude): message running turns and keep the process across interrupts Oct 2, 2026
Comment thread examples/claude_live_messages_smoke.rs
…come

A scenario that failed partway skipped its disposal, leaving its runtime and
any running turn to overlap the next scenario.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dviejokfs
dviejokfs merged commit ac661d2 into main Oct 2, 2026
7 checks passed
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