Skip to content

claude protocol adapter: split start() into an up phase and a run phase - #181

Merged
nikolasd merged 1 commit into
mainfrom
crew-152-protocol-start-split
Sep 13, 2026
Merged

nikolasd merged 1 commit into
mainfrom
crew-152-protocol-start-split

Conversation

@nikolasd

Copy link
Copy Markdown
Owner

What

ClaudeProtocolAdapter::start() used to await the entire turn inline. That held run/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:

  • Up phase (inline, in start() itself): trust precheck, process spawn, ProcessStarted emit. Returns once claude's own initialize control_response arrives — matching what the TUI adapter's start() already means (one meaning of start() across the tree), stated as a two-clause contract in Adapter::start's own trait doc comment.
  • Run phase (a spawned task): prompt delivery, the rest of the turn, teardown, reconciliation — reports exclusively through the sink from then on.

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 explicit control_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::spawn was added at the orchestration/registry call sites. orchestration.rs's start_queued_run and registry.rs's RunDriver::start/run_one already just await whatever Adapter::start returns — 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 an Err from the whole future and was caught by orchestration.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 after start() has already returned Ok, 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 explicit workspace/release or a later run/retry's own abandonment. That's exactly how a TuiAdapter run-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

  • An ordinary post-handshake Err (from drive_turn or 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.
  • A panic in the run-phase task is caught by a separate supervisor task that watches its JoinHandle; if the panic happened after the handshake (tracked by a plain AtomicBool set at the same call sites as the readiness oneshot), the supervisor settles the run the same way. Tested via a #[cfg(test)]-gated with_run_phase_panic injection 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 same Arc<AsyncMutex<Child>> the run-phase task holds — it only kills the process; it never itself emits ProcessExited, so there's no double-emission risk.

Reproduce-first

start_returns_once_the_handshake_completes_not_once_the_turn_ends is the reproduction this fix exists for: a fake claude that completes the handshake immediately, then sleeps 6s before the rest of the turn. Confirmed RED against unpatched main/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 real sleep N has 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, since start() now returns before the turn ends) converted to a wait_for poll against sink/DB state instead:

  • 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
  • turn_ended_is_journaled_before_process_exited_on_the_protocol_path

New:

  • 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_terminal
  • a_run_phase_failure_after_start_returns_ok_leaves_the_lease_held_like_tui_does (orchestration_rpc.rs, beside the existing start_queued_run_releases_the_lease_and_worktree_when_driver_start_fails — the contrasting start-time-Err case) — proves the lease-parity behavior change above end to end: run/submit returns before the run-phase fails, the run still settles failed, and the lease stays active (leaseRequested, leaseAcquired, no leaseReleased).

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 — clean
  • cargo clippy --all-targets --all-features -- -D warnings — 0 warnings
  • bun run check (marker guard, generate --check, format, typecheck, extension build, bun test, cargo test --workspace under the vendor-CLI kill switch) — all green

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.
@nikolasd
nikolasd merged commit adf8b54 into main Sep 13, 2026
27 of 28 checks passed
@nikolasd
nikolasd deleted the crew-152-protocol-start-split branch September 13, 2026 17:29
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