Repository navigation
feat(claude): message running turns and keep the process across interrupts - #25
Merged
Merged
Conversation
…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>
|
- 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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
start_turnreturnedRuntimeBusy, so applications interrupted the turn.Messages into a running turn
TurnHandle::send_messageand the cloneableTurnHandle::message_handle()write more user input into the running Claude process. Each message is written as auserframe with its own UUID.command_lifecycleframes instead of the firstresult. This also closes the fix(claude): keep background subagents alive across retained turns #24 race where an unrelated follow-upresultcould end a turn early.InvalidRequestandDeliveryState::NotSent.TimeoutwithNotSent. Resend onlyNotSentfailures.Cooperative interrupt
interrupt()on a retained Claude turn ends itCancelledand keeps the process. What else stops depends on where Claude is:interruptcontrol request withcancel_queued, the documentedinterrupt_cancel_queued_v1capability.Stoppedtask activity.After the stop:
So applications should send follow-ups with
send_messageorstart_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_messagesandRuntimeDriverCapabilities::live_messages. The latter is only set when retention is enabled, and is alwaysfalseon protocol clients.MessageDelivery,TurnMessageHandle, andRuntimeTurnExecutor::send_retained_message.AgentAdapter::encode_user_message,retained_interrupt_settled, andretained_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:
cancel_queued, plus the re-interrupt fallback for older CLIs; an answered turn settles with nothing sent; the message cap; UUID format.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, andcargo 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 bothsonnetandhaiku, with zero process replacements. The Claude process identity is checked across interrupts via the Bash tool's parent PID.foldNotSent.multiqueuedidlebg-shellbg-agentStopped.bg-agent-answeredapprovalThe fix(claude): keep background subagents alive across retained turns #24 smokes
claude_background_handoff_smokeandclaude_subagent_smokestill pass. No processes are left behind.🤖 Generated with Claude Code