Skip to content

Settle provider prompts that complete without starting a turn - #1432

Merged
ymichael merged 2 commits into
get-bb:mainfrom
jerrison:settle-zero-work-claude-result
Aug 13, 2026
Merged

Settle provider prompts that complete without starting a turn#1432
ymichael merged 2 commits into
get-bb:mainfrom
jerrison:settle-zero-work-claude-result

Conversation

@jerrison

@jerrison jerrison commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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 /clear locally 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 no
turn/completed. The thread remained active, bb thread wait --status idle
hung, and accepted input queued behind the abandoned turn could not drain.

The underlying lifecycle problem is broader than Claude's /clear: a provider
can 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:

  1. Complete the currently open turn when one exists.
  2. Otherwise, start and complete a synthetic turn only while accepted input is
    still pending.
  3. Emit nothing when neither condition holds.

The pending accepted-input queue is the ownership proof that keeps this narrow.
onTurnStart drains it, so a late terminal signal cannot open a second empty
turn after the real turn has already closed.

Provider coverage

  • Claude Code: a terminal result can settle accepted input even when no
    earlier SDK message started the turn. This covers the /clear regression.
  • ACP: turn/completed follows the same terminal-turn rule.
  • Pi: agent_end follows the same rule, and the bridge reports when
    session.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

  • Process-detach recovery remains separate and is covered by Settle pending turns when providers exit before turn start #1234.
  • This does not add a general stuck-turn watchdog or reconciler.
  • This does not introduce a provider-independent /clear command or a new
    first-class clear timeline event; it fixes settlement when a provider handles
    a prompt without ordinary turn activity.

Wire compatibility

HOST_DAEMON_PROTOCOL_VERSION is bumped to 115 because Pi now sends a
prompt-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-contract through Turbo.

  • Live reproduction against the source-built app:

    bb thread tell <id> "/clear"
    bb thread wait <id> --status idle --timeout 60

    The thread reached idle in 1.6 seconds, and its accepted-message queue was
    empty afterward.

@jerrison

Copy link
Copy Markdown
Contributor Author

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 app

The 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 if(i.currentTurnId){ in host-daemon/dist/daemon-bundle.mjs:

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 bb CLI against a real thread — which asserts the defect: send /clear, then expect the turn not to settle within 45s and the thread to still be active. On 2026-08-12 it flipped, on the same machine and bb version, with the patch as the only change:

Before After
bb thread wait --status idle --timeout 45 timed out reached idle
thread status active idle
wall clock for the case 54.2s 5.5s

The 5.5s is the part I would look at: it is no longer waiting out the bound at all. sdk/conversation_reset still arrives first and the context is genuinely empty afterwards, so the Clear itself is unchanged — the turn now just ends with it. No second, empty turn opened on any run.

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 node --check, not a source build. One machine, one provider (claude-code), model claude-haiku-4-5. Please read it as a strong signal rather than a substitute for CI.

Merged main, and took protocol version 113

#1427 landed with HOST_DAEMON_PROTOCOL_VERSION = 112 while this branch was also sitting on 112, so both sides claimed the same number and the same block of contract.test.ts. Resolved in 2fe2b95 by taking 113 and keeping #1427's 112 line in the version history, so the two read in landing order.

Worth flagging for anything else racing a protocol bump: commands.ts merged clean and was still wrong. Both sides had made the byte-identical 111 → 112 edit, so git saw no conflict and only the contract test caught the collision.

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 ask

Question. #1427 adds clearActiveTurnState?() for the same class of bug — a provider reporting no active turn — but keys off isAcpProviderId and an ACP bridge error code, so it does not reach the claude-code result path. I have kept this on ensureTurnStarted. If you would rather it route through the new hook, say so and I will rewrite it.

Ask. The workflow run on 2fe2b95 is sitting at action_required — fork PRs here need "Approve and run". CI was green on 1eaac7c, and the only changes since are the merge and the 112 → 113 bump.

@ymichael

Copy link
Copy Markdown
Collaborator

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

@ymichael ymichael self-assigned this Aug 13, 2026
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.
@ymichael
ymichael force-pushed the settle-zero-work-claude-result branch from 2fe2b95 to 5b02d28 Compare August 13, 2026 04:55
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>
@ymichael
ymichael force-pushed the settle-zero-work-claude-result branch from 5b02d28 to 5197a57 Compare August 13, 2026 04:55
@ymichael ymichael changed the title Settle a Claude run that reports a result without starting a turn Settle provider prompts that complete without starting a turn Aug 13, 2026
@ymichael
ymichael merged commit 7c3d1ab into get-bb:main Aug 13, 2026
9 checks passed
ymichael added a commit that referenced this pull request Aug 13, 2026
## 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>
ymichael added a commit that referenced this pull request Aug 17, 2026
…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>
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.

claude-code: a /clear turn never settles — the thread stays active until some later turn completes

2 participants