claude protocol adapter: split start() into an up phase and a run phase - #181
Merged
Merged
Conversation
Adapter::start() for protocol-mode claude runs used to await the entire turn inline, which held run/submit's own JSON-RPC response (and, through it, the daemon's single per-connection dispatch loop) for that run's whole duration. Split it at the initialize handshake: start() now returns once that handshake completes (matching what the TUI adapter's start() already means -- one meaning of start() across the tree), and the rest of the turn (prompt delivery, teardown, reconciliation) runs in a spawned run-phase task reporting exclusively through the sink from then on. Mechanism: a oneshot fired from inside drive_turn's own handshake arm, before the prompt is sent, with a defensive fallback at the turn-complete marker for a degenerate stream that never gets an explicit control_response -- that fallback now warns explicitly when it fires, naming what was never observed, so it stays a safety net rather than a second silent path restoring the exact symptom this fix closes. The child process handle is now Arc-wrapped so the spawned task and cancel() share it. A post-handshake failure (ordinary error or panic, the latter caught by a JoinHandle-watching supervisor) settles the run terminal directly, since start() has already returned Ok by then and nothing else will. No tokio::spawn was added at the orchestration/registry call sites -- they already just await whatever start() returns, so the earlier return propagates for free. Behavior change, stated explicitly: before this split, any failure inside start(), including mid-turn, abandoned the run's workspace lease through orchestration's own start-error path. After the split, a run-phase failure leaves the lease held -- released only by an explicit workspace/release or a later run/retry's abandonment -- exactly like a TuiAdapter run-phase failure already does. Pinned as a parity record (a_run_phase_failure_after_start_returns_ok_leaves_the_lease_held_like_tui_does in orchestration_rpc.rs), not a ruling that holding it is correct. Tests switched from asserting on start()'s own return to a wait_for poll against sink/DB state, since start() no longer waits for the whole turn: a_real_turn_attaches_and_detaches_a_real_pane, a_wedged_process_is_escalated_to_sigkill_rather_than_hung_on, a_clean_protocol_turn_settles_rather_than_fails, and turn_ended_is_journaled_before_process_exited_on_the_protocol_path. New: start_returns_once_the_handshake_completes_not_once_the_turn_ends (the reproduction this fix exists for -- confirmed red against the pre-split code, green after; its own doc comment works out the timing margin the way tui/adapter.rs's escalation tests already do, rather than picking numbers by feel), a_run_phase_panic_after_the_handshake_ still_settles_the_run_terminal (via a #[cfg(test)]-gated panic-injection seam; the JoinHandle-watching supervisor that responds to it is unconditional production code, not test-only), and the lease-parity test above. Adapter::start's trait doc comment now states the contract in two clauses (returns once up; the run's lifetime/terminal event/lease release owned by a named component after that) plus which adapter this clause is actually checked for today and which is asserted only by inspection.
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.
What
ClaudeProtocolAdapter::start()used to await the entire turn inline. That heldrun/submit's own JSON-RPC response — and, through it, the daemon's single per-connection dispatch loop — for a protocol-mode run's whole duration, stalling every other request on that connection (including the extension's own event-enrichment calls) for as long as the run took.This splits
start()at the initialize handshake:start()itself): trust precheck, process spawn,ProcessStartedemit. Returns once claude's own initializecontrol_responsearrives — matching what the TUI adapter'sstart()already means (one meaning ofstart()across the tree), stated as a two-clause contract inAdapter::start's own trait doc comment.Mechanism: a oneshot fired from inside
drive_turn's own handshake arm, before the prompt is written to stdin. A defensive fallback fires the same oneshot at the turn-complete marker for a degenerate stream that never emits an explicitcontrol_response— and now warns explicitly when it does, naming what was never observed, so it's a safety net rather than a second silent path restoring the exact symptom this fix closes.No
tokio::spawnwas added at the orchestration/registry call sites.orchestration.rs'sstart_queued_runandregistry.rs'sRunDriver::start/run_onealready just await whateverAdapter::startreturns — the earlier return propagates for free. Confirmed by the diff (5 files touched, neither of those two is one of them), not just by reading.Behavior change, named explicitly
Before this split, any failure inside
start(), including one occurring mid-turn, propagated as anErrfrom the whole future and was caught byorchestration.rs's own synchronous start-error path (abandon_and_announce+ensure_failed_after_start_error), which abandons the run's workspace lease.After the split, a run-phase failure (an ordinary
Err, or a panic — see below) happens afterstart()has already returnedOk, so that path never runs. The run still settles terminal (the run-phase task, or its panic supervisor, does that directly), but the lease is left held — released only by an explicitworkspace/releaseor a laterrun/retry's own abandonment. That's exactly how aTuiAdapterrun-phase failure already behaves.This is pinned as a parity record, not a correctness ruling — both this adapter's own
start()doc comment and the new test's doc comment say so explicitly. Whether a terminal state should auto-release its lease is the pending leases ADR's own question.Failure handling inside the run-phase task
Err(fromdrive_turnor a later sink emit) is caught by the task's own tail and settles the run terminal directly (settle_and_emit_exit_best_effort) — matching this function's pre-split behavior for the equivalent pre-handshake case exactly.JoinHandle; if the panic happened after the handshake (tracked by a plainAtomicBoolset at the same call sites as the readiness oneshot), the supervisor settles the run the same way. Tested via a#[cfg(test)]-gatedwith_run_phase_panicinjection seam — the gate covers only the panic trigger; the supervisor logic that responds to it is unconditional production code, one body, no test/production divergence.cancel()reaches the run through the sameArc<AsyncMutex<Child>>the run-phase task holds — it only kills the process; it never itself emitsProcessExited, so there's no double-emission risk.Reproduce-first
start_returns_once_the_handshake_completes_not_once_the_turn_endsis the reproduction this fix exists for: a fakeclaudethat completes the handshake immediately, then sleeps 6s before the rest of the turn. Confirmed RED against unpatchedmain/47be22d(stashed the fix, ran the test standalone — it timed out against a 3s bound) and confirmed GREEN after restoring the fix.Its own doc comment works out the timing margin with the same three-constraint discipline
tui::adapter::tests' load-tested margin comment uses, rather than picking numbers by feel:SLOW_TURN_SLEEP_SECS = 6s,bound = 3s. Worth noting explicitly here, per review: this margin is one-sided and cannot be eroded by load, which is a stronger guarantee than the precedent it cites. A realsleep Nhas a hard wall-clock floor — a loaded runner can only make the broken code's failure point later, never earlier — so there is no contention scenario where broken code produces a false pass. The only real exposure is the fixed code's own spawn overrunning the bound on an exceptionally loaded runner, which the 3s budget (for a trivial spawn + one read + one echo) is generous against. Someone tuning these numbers later should know which side is protected structurally (the broken side, unconditionally) and which by margin (the fixed side, by budget).Tests
Existing tests that synchronized on
start()'s own return (no longer valid, sincestart()now returns before the turn ends) converted to await_forpoll against sink/DB state instead:a_real_turn_attaches_and_detaches_a_real_panea_wedged_process_is_escalated_to_sigkill_rather_than_hung_ona_clean_protocol_turn_settles_rather_than_failsturn_ended_is_journaled_before_process_exited_on_the_protocol_pathNew:
start_returns_once_the_handshake_completes_not_once_the_turn_ends(reproduce-first, above)a_run_phase_panic_after_the_handshake_still_settles_the_run_terminala_run_phase_failure_after_start_returns_ok_leaves_the_lease_held_like_tui_does(orchestration_rpc.rs, beside the existingstart_queued_run_releases_the_lease_and_worktree_when_driver_start_fails— the contrasting start-time-Errcase) — proves the lease-parity behavior change above end to end:run/submitreturns before the run-phase fails, the run still settlesfailed, and the lease staysactive(leaseRequested, leaseAcquired, noleaseReleased).Known gap, named rather than silently skipped
Adapter::start's trait doc comment states its "returns once up" clause is test-checked for the protocol adapter only; the TUI adapter's own instance of it (run_pipeline's tailer/exit-watcher spawn) is asserted here only by inspection, not by a test that would fail if it regressed. A cross-adapter conformance check for every adapter is a follow-up, not built in this PR.Gate
cargo fmt --all --check— cleancargo clippy --all-targets --all-features -- -D warnings— 0 warningsbun run check(marker guard,generate --check, format, typecheck, extension build,bun test,cargo test --workspaceunder the vendor-CLI kill switch) — all green