Skip to content

Fix Claude resumed-input turn correlation - #2013

Merged
ymichael merged 1 commit into
mainfrom
bb/investigate-missing-project-thread-thr_224ncw7wrf
Aug 20, 2026
Merged

ymichael merged 1 commit into
mainfrom
bb/investigate-missing-project-thread-thr_224ncw7wrf

Conversation

@ymichael

@ymichael ymichael commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

When Claude Code resumed a session that had orphaned background work, the SDK could drain a zero-work result with origin.kind=task-notification immediately before processing the newly queued human prompt. The Claude bridge emitted input.accepted as soon as it queued that prompt, and the translator allowed any result to claim a pending accepted input. The recovered notification therefore manufactured and completed an empty turn for the new message; the real answer continued later under a second, unaccepted turn. This is the mechanism independently reproduced in #1718 and observed again in the affected production thread.

What changed

  • Parse Claude result provenance and allow an idle result to claim pending input only when its origin is human (or omitted, which the SDK defines as human). A non-human result can still settle work when a turn is already open, preserving live background-notification loops and human zero-work commands such as /clear.
  • Make Claude turn/start match turn/steer: emit acceptance and answer the command only after the SDK prompt iterator consumes the queued input.
  • Add regressions for the recovered-task-notification sequence and for turn/start consumption ordering. Existing bridge tests now follow the same consumed-before-accepted contract.
  • Bump HOST_DAEMON_PROTOCOL_VERSION from 138 to 139 because older daemons emit the incorrect lifecycle semantics.

The uniform provider rule is: input.accepted means the provider consumed the input, never merely that bb queued it. Codex already follows that rule. Pi's prompt promise currently reports settlement rather than consumption, and ACP exposes no equivalent provider acknowledgement, so those bridges need provider-specific correlation work rather than a timing heuristic in this focused Claude fix. The Pi/ACP cross-provider follow-up is tracked in #2014. There are no CLI, guide, configuration, or user-facing documentation changes.

How you verified

  • Before the implementation, the new provenance regression received turn/started + turn/input/accepted + turn/completed instead of no events, and the new turn/start ordering regression observed a response before SDK consumption.
  • pnpm exec turbo run test --filter=bb-plugin-provider-claude-code --force — 259 tests passed, including /clear zero-work conformance.
  • pnpm exec turbo run typecheck --filter=bb-plugin-provider-claude-code --force — passed.
  • pnpm exec turbo run test typecheck --filter=@bb/host-daemon-contract --force — 52 tests passed; typecheck passed.
  • pnpm exec turbo run typecheck --filter=@bb/host-daemon --force — passed.
  • pnpm exec turbo run typecheck --filter=@bb/server --force — passed.
  • git diff --check — passed.

Fixes #1718

AGENT GENERATED: by GPT-5

@ymichael
ymichael merged commit ce4e64d into main Aug 20, 2026
13 checks passed
@ymichael
ymichael deleted the bb/investigate-missing-project-thread-thr_224ncw7wrf branch August 20, 2026 06:38
ymichael added a commit that referenced this pull request Aug 20, 2026
## What was wrong

[#2013](#2013) established the
uniform rule that `input.accepted` means the provider consumed the
input, never that bb queued it, because an acceptance still pending when
a stale terminal arrives lets that terminal claim the input and complete
an empty turn for a message the provider has not answered. Pi and ACP
still emitted acceptance at dispatch.

Pi's exposure is not just theoretical timing. `PiSdkSession.prompt()`
resolves as soon as pi queues a prompt that arrives while a run is still
unwinding, and the bridge reported that resolution as
`pi/prompt/settled` — a `claimIfIdle` turn terminal. So a `turn/start`
pi merely queued produced acceptance plus a terminal in the same tick,
which the assembler turned into a started-and-completed empty turn while
the real answer ran later under an unaccepted turn.

ACP emitted acceptance in the `turn/start` handler before the turn
opened, and for a steer it emitted acceptance at queue time even though
the queued input is dropped whenever the turn fails or the session stops
— reporting input the agent was never given as accepted into the turn.

## What changed

- `PiSdkSession` tracks pending input consumption for both of pi's
queues instead of steering only, and resolves it from pi's preflight
hook (the input entered a run) or from the queue update that delivers a
queued message. Its `prompt()` now returns that consumption signal
alongside the settlement of the run it started, and reports no
settlement for input pi queued into a run it did not start.
- The pi bridge answers `turn/start` and emits `input.accepted` only
once pi read the input.
- The ACP bridge carries the waiting command with the input and emits
`input.accepted` once the `session/prompt` request carrying it goes out,
so the acceptance names the open turn and a dropped steer is never
accepted. Every turn input still leaves with exactly one reply
([#853](#853)).
- `HOST_DAEMON_PROTOCOL_VERSION` 140 to 141: older daemons emit the
queue-time semantics and produce those phantom turns.

Two deviations from the issue's proposed fix:

1. The issue states pi's steer path "already waits for actual SDK
acceptance." It does not — `PiSdkSession.steer()` resolved once the SDK
took the message into its queue, the same queue-time violation as
`turn/start`.
2. Both steer paths deliberately keep answering their command at queue
time, because the runtime fails a bridge request that goes unanswered
for 30 seconds (`sendJsonRpcRequest`). Pi delivers steering only between
assistant turns, so a steer sent during a long tool call would time out;
ACP delivers a steer only when the cancelled prompt is reissued. Neither
can manufacture a turn: a steer's acceptance lands in a turn the
assembler already holds open, and the
[#2013](#2013) failure mode needs a
*pending* acceptance. Pi keeps reporting a steer its run never read
through the session error path.

There are no CLI, guide, configuration, or user-facing documentation
changes.

## How you verified

- New pi regression: a `turn/start` pi queues behind a live run emits no
turn events until the queue delivers it, then the acceptance lands in
the turn pi opened. Before the change it received `turn/started` +
`turn/input/accepted` + `turn/completed` — the phantom turn.
- New ACP regressions: acceptance is emitted immediately after the turn
opens rather than before it, and a steer dropped by `thread/stop` leaves
the turn with one accepted input instead of two. Both fail before, pass
after.
- New `PiSdkSession` coverage for queued-versus-direct dispatch, and for
a queued follow-up surviving the `agent_end` that continues into it.
- `pnpm exec turbo run typecheck test --filter=@bb/agent-runtime
--filter=bb-plugin-provider-acp --filter=@bb/host-daemon-contract
--force` — 417, 172, and 52 tests passed; typechecks passed.
- `pnpm exec turbo run build typecheck --filter='...[origin/main]'` — 62
tasks passed.
- `git diff --check` — passed.

Fixes #2014

🤖 Generated with [Claude Code](https://claude.com/claude-code)

> AGENT GENERATED: by Claude Opus 5

Co-authored-by: Claude Opus 5 (1M context) <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: user message sent after stopping a thread with backgrounded work is dropped

1 participant