Settle provider prompts that complete without starting a turn - #1432
Conversation
0e4b790 to
1eaac7c
Compare
|
Thanks @ymichael for the hardening commit — I have left it untouched. Two updates: this is verified end-to-end now, and the branch is unconflicted again. Verified against a running appThe caveat in the description no longer holds. I still have not built bb from source, so instead I applied the equivalent edit to the shipped daemon bundle of bb 0.37.0 on macOS, immediately before the minified gate i.currentTurnId||i.pendingAcceptedUserMessages.length>0&&e.ensureTurnStarted({events:n,state:i,threadId:r});I have an automated case in my own repo — a harness that drives the
The 5.5s is the part I would look at: it is no longer waiting out the bound at all. What this does not cover. It validates the logic, not your compiled artifact: my edit is the minified equivalent of the diff, applied by script and checked with Merged main, and took protocol version 113#1427 landed with Worth flagging for anything else racing a protocol bump: I merged rather than rebased so as not to rewrite your commit. Happy to squash or rebase if you would rather the branch were linear. One question, and one askQuestion. #1427 adds Ask. The workflow run on 2fe2b95 is sitting at |
|
Sorry my agent was too eager and pushed before I told it to - working on some changes here so we get this fix in for all providers - ty for your patience |
A `/clear` is resolved inside the Claude Code CLI with no model call, so nothing emits `turn/started` and `state.currentTurnId` is never set. The CLI still closes the run — `conversation_reset`, `system`/init, then `result` with `subtype: "success"` and `num_turns: 0` — but the result handler is gated on there already being an open turn, so the one message that would settle it takes the empty branch. Nothing reports `run.succeeded`, and `active` has no other exit, so the thread shows as working until unrelated traffic closes the turn. Observed holding one open for 14h 26m; queued input never drained either, because draining happens in `onTurnStart`. Settle it the way the synthetic no-response message does one case earlier: open the turn while a dispatched prompt is still unclaimed, then let the existing block complete it. `pendingAcceptedUserMessages` is what keeps this narrow — it is non-empty only between dispatch and the first `turn/started`, since `onTurnStart` drains it. So a result whose turn has already closed, as when `thread/stop` calls `finishOpenProviderTurn` before the CLI's result lands, still emits nothing instead of opening a second, empty turn. Tests cover both directions.
2fe2b95 to
5b02d28
Compare
Apply Jerrison's accepted-input invariant to normalized provider terminal signals for Claude, ACP, and Pi. A successful terminal signal now opens and settles the pending turn only when accepted input remains unclaimed, so zero-work prompts cannot leave threads active or queues stuck. Cover queue draining and each provider's no-activity terminal path, and bump the host daemon protocol for the changed event behavior. Co-authored-by: Jerrison Li <1813092+jerrison@users.noreply.github.com>
5b02d28 to
5197a57
Compare
## Problem When a user submits a turn, the server persists `client/turn/requested` and marks the thread active before the provider emits `turn/started`. A provider can acknowledge the `turn/start` RPC and then exit before emitting that first lifecycle event. At that point the command has succeeded, so there is no command failure to reconcile, but `activeTurnId` is still null. Existing provider-exit handling treated the thread like an idle resident session and emitted nothing, leaving it visibly **Working** with no provider process alive. ## Lifecycle contract The runtime now includes its existing `pendingTurnStart` state in the final per-thread snapshot captured before process-exit cleanup. On an unexpected provider exit, the host daemon applies these rules: 1. An active turn follows the existing turn-scoped completion and error path. 2. A pending turn start with no active turn emits the existing thread-scoped `provider_process_exited` error. 3. An idle session emits nothing. 4. An expected process exit emits nothing. The server already treats `provider_process_exited` as `run.failed`, so the thread moves from `active` to `error`, pending interactions are interrupted, and parent-thread notification behavior remains consistent with other failed turns. ## Relationship to #1432 #1432 settles prompts when a provider returns a terminal signal without first starting a turn. This PR covers the complementary case where the provider process disappears and no terminal signal can arrive. ## Safety and non-goals - The behavior is provider-independent, although the reported failure was observed with Claude Code. - It relies on an actual unexpected process exit; it does not add an inactivity timeout, watchdog, or general stuck-turn reconciler. - `pendingTurnStart` is set before dispatch and cleared by `turn/started`, `turn/completed`, a terminal provider error, command failure cleanup, or thread cleanup. This distinguishes the vulnerable window from an idle resident session. - No persisted event schema or database model changes are required. ## Wire compatibility `HOST_DAEMON_PROTOCOL_VERSION` is bumped to **116** because an updated daemon can now emit a failure event for a pre-`turn/started` process exit. ## Verification - Real child-process regression: `turn/start` succeeds, the provider exits before `turn/started`, and the exit snapshot retains `pendingTurnStart: true`. - Host-daemon event construction and event transport are covered. - Server ingestion confirms `provider_process_exited` transitions an active thread to `error` and preserves parent-notification behavior. - `@bb/agent-runtime`: 941 tests passed across 46 files. - `@bb/host-daemon`: 548 tests passed across 46 files. - `@bb/host-daemon-contract`: 49 tests passed across 3 files. - `@bb/server`: 1,471 tests passed across 162 files. - Turbo typechecks passed for all four packages above. - Prettier and `git diff --check` passed. Jonathan Borgwing authored the original diagnosis and implementation. The rebased branch preserves both original commits and credits Jonathan on the post-ack regression hardening commit. --------- Co-authored-by: Michael Yong <wrong92@gmail.com>
…gences - 87 protocol bumps (26->121), not 92; turn-settlement fixes are #1196/#1234/#1321/#1432 - #75 is a pre-GitHub ticket id; cite commit 1a5620b - ProviderAdapter has 18 members, not 16; corrected per-provider line counts - The outbound vocabulary is a shared 7-method core with real divergences (acp lacks fork; codex maps stop/discard/compact/skills to different methods and has 4 methods no bb bridge speaks) - phase 1 must pick canonical mappings; codex bridge is a mapping layer, not a passthrough - Canonical PendingInteractionPayload union lives in @bb/domain, not shared/pending-interaction-normalization.ts (codex-only helper) - classify split is claude-vs-rest; normalizeExecutionOptions is claude-only Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes #1431.
The original diagnosis and narrow Claude Code fix are Jerrison's work. This
branch preserves that commit and credits Jerrison on the generalized change.
Problem
Claude Code handles
/clearlocally and returns a successful zero-work result:there is no assistant message, stream event, or tool call to start a bb turn.
The result handler previously required
state.currentTurnId, so it emitted noturn/completed. The thread remainedactive,bb thread wait --status idlehung, and accepted input queued behind the abandoned turn could not drain.
The underlying lifecycle problem is broader than Claude's
/clear: a providercan accept a prompt and then report a terminal signal before emitting any of the
ordinary activity that starts a bb turn.
Contract
Provider terminal translators now resolve the turn through one shared rule:
still pending.
The pending accepted-input queue is the ownership proof that keeps this narrow.
onTurnStartdrains it, so a late terminal signal cannot open a second emptyturn after the real turn has already closed.
Provider coverage
resultcan settle accepted input even when noearlier SDK message started the turn. This covers the
/clearregression.turn/completedfollows the same terminal-turn rule.agent_endfollows the same rule, and the bridge reports whensession.prompt()settles without producing an SDK event.Codex is unchanged because its native lifecycle already settles this shape.
The synthetic lifecycle also drives the existing server completion path, so a
queued message is sent after the zero-work turn completes instead of remaining
stuck.
Deliberate non-goals
/clearcommand or a newfirst-class clear timeline event; it fixes settlement when a provider handles
a prompt without ordinary turn activity.
Wire compatibility
HOST_DAEMON_PROTOCOL_VERSIONis bumped to 115 because Pi now sends aprompt-settled bridge event that can change host-daemon event output.
Verification
@bb/agent-runtime: 941 tests passed across 46 files.@bb/server: 1,470 tests passed across 162 files.@bb/host-daemon-contract: 49 tests passed across 3 files.Typechecks passed for
@bb/agent-runtime,@bb/server,@bb/host-daemon,and
@bb/host-daemon-contractthrough Turbo.Live reproduction against the source-built app:
The thread reached
idlein 1.6 seconds, and its accepted-message queue wasempty afterward.