Skip to content

Reconcile daemon turns the server lost track of on explicit stop - #3502

Open
SawyerHood wants to merge 5 commits into
mainfrom
bb/3462-fix-stop-leaving-threads-stuck-thr_vdv2rx6c7w
Open

Reconcile daemon turns the server lost track of on explicit stop#3502
SawyerHood wants to merge 5 commits into
mainfrom
bb/3462-fix-stop-leaving-threads-stuck-thr_vdv2rx6c7w

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Human comments

What was wrong

A thread could wedge with every send refused as Refusing to start a competing turn … while the app showed it idle or errored, and bb thread stop printed stopped without changing anything (#3462). Traced on four production Codex threads from 2026-09-01 (server log plus the persisted event sequences): the Codex bridge's zero-work settlement (#2580, candidate fix #2639) completed the spawn turn synthetically before the real turn opened, the server went idle, a follow-up send was dispatched, and the real turn then started as an unrequested root turn. The daemon correctly refused the follow-up turn.submit as a competing turn, but the server settled that refusal as run.failed, moving the thread to error while a root turn it had already stored (turn/started with no turn/completed) was still running. From error or idle, an explicit stop sends intent: "release", which the daemon deliberately skips when a turn is active, and that skipped release returned the same result as a completed one, so Stop was a silent no-op until the daemon turn ended. When it ended, run.succeeded is illegal from error, so the thread stayed errored (three of the four still are).

This PR repairs the server and daemon sides so a provider turn the server lost track of can no longer wedge a thread or be reported as a successful stop. The Codex settlement race itself is fixed in the stacked #3503 (Ratul Sarna's #2639 rebased onto this branch); this layer stands on its own because any provider can produce an unrequested turn (the August occurrences were Claude Code).

What changed

  • packages/host-daemon-contract: thread.stop result gains optional activeTurnRetained; new COMPETING_TURN_ERROR_CODE (competing_turn). HOST_DAEMON_PROTOCOL_VERSION 203 → 204 (rebased over Fix provider session identity mixups during concurrent thread starts #3496, Machine providers: durable lifecycle and shared plugin APIs #3274 and Unify plugin icon registration and provider artwork rendering #3513, which moved main to 203).
  • apps/host-daemon: a release that keeps an active turn reports activeTurnRetained: true (the release race guard itself is unchanged). Runtime and dispatcher competing-turn refusals carry the competing_turn code (CompetingTurnError in @bb/agent-runtime, mapped in getErrorCode).
  • apps/server (thread-lifecycle.ts):
    • Explicit stops (stop route, context clear) that get a retained release re-activate the thread from the daemon's report (run.started), mark it stopping, and interrupt it through the normal stop path, so the stored turn is completed as interrupted and the thread settles idle. Archived/deleted rows that cannot re-activate get a direct interrupt. Automatic release paths are unchanged.
    • A turn.submit refused with competing_turn while the thread has an uncompleted stored root turn records client/turn/rejected but keeps the thread active instead of failing the run; the running turn's completion settles it. Refusals without a stored running turn, and other error codes, still fail the run as before.
  • @get-bb/plugin-sdk 0.4.84 → 0.4.85: main published 0.4.84, and this layer changes the SDK's bundled type declarations (activeTurnRetained and the threads.stop declaration), so the npm version guard requires a new version. The guard passes at this head and at Correlate Codex turn dispatches with the turn the turn/start response names #3503's.
  • Explicit stops (the stop route, context clear, and machine preservation's requireStopped path) are one single-flight operation per thread that re-reads the row each round: interrupt a live turn, release an idle runtime, and after a retained release re-activate and interrupt. A stop that joins a pending operation waits for all of it, including the escalated interrupt, instead of returning when the release resolves. Command failures go back to each caller: the route keeps its policy (a failed release that is not host-unavailable is a 5xx, a failed interrupt leaves the thread stopping), and requireStopped callers get any failure.
  • An explicit stop wins over running work: a turn that starts while the stop's release is in flight is interrupted too, and the call returns after it settles. This is documented in the thread guide and on threads.stop in the SDK declarations, and the daemon test that keeps such a turn on release is renamed to say it reports the turn as retained.
  • The in-flight stop guard counts holders and tracks releases separately, so a pending awaited release no longer suppresses an interrupt requested through the non-awaited path, and concurrent awaited stops no longer drop each other's claim.
  • Deviation from the issue's patch: instead of a bare interrupt against an idle/error row, the server reconciles its status to the daemon's report first and reuses the existing stop/finalize machinery, and it also fixes the settlement that produced the false error in the first place.

How you verified

  • New server tests (public-thread-stop-runtime.test.ts: retained-turn stop from idle and from error, context clear, concurrent stops sharing one release and one interrupt, failed escalated interrupt; thread-send-dispatch.test.ts: competing-turn settlement plus two negative controls). Swapping thread-lifecycle.ts back to main makes the six behavioral tests fail and leaves the two negative controls passing.

  • Daemon: command-dispatch.test.ts release-race test now expects activeTurnRetained and checks the follow-up interrupt clears the turn; command-dispatch-support.test.ts covers the error-code mapping.

  • Stop-race regressions in public-thread-stop-runtime.test.ts: a turn that starts during the release is interrupted and the stop waits for it; a stop that joins a pending release waits for the single escalated interrupt; an interrupt requested through the non-awaited path while a release is pending is still dispatched; a requireStopped caller is rejected when the escalated interrupt fails. With thread-lifecycle.ts swapped back to the previous layer head, the last three fail; the first passed there too (that behavior already existed) and is now pinned.

  • On the rebased head: turbo run typecheck lint and turbo run test for @bb/server (2573 passed; four unrelated suites timed out while a dependency install competed for CPU and passed in isolation, 112/112), @bb/host-daemon (608), @bb/agent-runtime (327), @bb/host-daemon-contract (56), @bb/sdk (107), @bb/templates (44); plugin SDK npm version guard PASS (0.4.85, re-run after main published 0.4.84); changed-file format check clean; EAP codename scan clean for the push range.

  • Live dev app with real Codex (codex CLI 0.154.0, ChatGPT device-auth login in an isolated scratch CODEX_HOME, isolated dev store), same scenario on origin/main and on this branch: a real Codex turn ran a foreground sleep; the row was flipped to error (the state main leaves after a competing-turn refusal) and bb thread stop issued. On main the CLI printed stopped, nothing was interrupted, the thread was still error 60 s later, the next tell was refused (command_failed + system/error) and the thread stayed error after the turn finished. On this branch the same stop logged Host daemon kept an active turn on release; interrupting it for the explicit stop, the turn completed interrupted, the thread was idle within a second and the next tell was answered. A second thread flipped to idle mid-turn: on main the refused tell moved it to error permanently; here it recorded client/turn/rejected reason competing_turn, stayed active, and settled idle when the real turn completed. Under a documented delay relay around the real app-server (holds turn/started 400 ms), main and this branch both reproduce the initial trigger naturally (synthetic accepted-and-completed turn followed by an unrequested real root turn on the same Codex thread), which is what the stacked Correlate Codex turn dispatches with the turn the turn/start response names #3503 removes. The earlier Claude Code run showed the same server-side behavior.

  • Re-run with real Codex on this rebased head (5a30d7d, isolated dev store, no relay): a running foreground turn with the row flipped to error was stopped with bb thread stop, the turn completed interrupted and the thread was idle in the same second, and the next tell was answered; a second thread flipped to idle mid-turn had its tell recorded as competing_turn with no system/error, stayed active, settled idle when the real turn completed, and answered a further tell.

Fixes #3462

🤖 Generated with Claude Code

AGENT GENERATED

SawyerHood and others added 4 commits September 11, 2026 21:16
A thread could wedge with every send refused as a competing turn while
the app showed it idle or errored, and `bb thread stop` reported success
without changing anything (#3462).

Root cause, traced on four production Codex threads from 2026-09-01: the
Codex bridge settled the spawn turn as a synthetic zero-work completion
250 ms after `turn/start` responded, before the real turn opened. The
server went idle, a follow-up send was dispatched, and the real turn then
started as an unrequested root turn. The daemon correctly refused the
follow-up `turn.submit` as a competing turn, but the server settled that
refusal as `run.failed`, moving the thread to `error` while a root turn
it had already stored was still running. From `error` or `idle` an
explicit stop sends `intent: "release"`, which the daemon deliberately
skips when a turn is active, and the release result was indistinguishable
from a completed release, so the stop was a silent no-op until the daemon
turn ended. When it did, `run.succeeded` is illegal from `error`, so the
thread stayed errored.

The Codex zero-work settlement race that started the sequence is tracked
separately in #2580 (candidate fix #2639); this change does not touch the
bridge. It repairs the server and daemon sides so any provider turn the
server lost track of can no longer wedge a thread or be reported as a
successful stop.

Changes:
- Daemon: `thread.stop` with `intent: "release"` now reports
  `activeTurnRetained: true` when it keeps an active turn. Runtime and
  dispatcher refusals carry the `competing_turn` error code
  (`CompetingTurnError`, `COMPETING_TURN_ERROR_CODE`).
- Server: explicit stops (stop route, context clear) that get a retained
  release re-activate the thread from the daemon's report, mark it
  stopping, and interrupt it through the normal stop path, so the stored
  turn is completed as interrupted and the thread settles idle. Automatic
  release paths and the daemon's release race guard are unchanged.
- Server: a `turn.submit` refused with `competing_turn` while the thread
  has an uncompleted stored root turn records the rejection but keeps the
  thread active instead of failing the run; the running turn's completion
  settles it.
- Protocol version 199 -> 200 for the new optional result field and error
  code.

Verification: new server tests for idle/error retained-turn stops,
context clear, concurrent stops, a failed escalated interrupt, and the
competing-turn settlement with negative controls; all six behavioral
tests fail on the previous lifecycle implementation. Daemon dispatch and
error-code tests updated. Full @bb/server (2426), @bb/host-daemon (591),
@bb/agent-runtime (318) and @bb/host-daemon-contract (51) suites pass.
Live dev-app check with Codex: a running turn whose thread was flipped
to error/idle is interrupted by `bb thread stop` and the next send
succeeds; a send refused as competing keeps the thread active and it
settles idle when the daemon turn completes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The test helper returned the queued command as the RPC union, so the
caller's requestId read failed typecheck in CI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An explicit stop (the stop route, context clear, and machine preservation)
is now one single-flight operation per thread that re-reads the thread row
each round: it interrupts a live turn, releases an idle runtime, and after a
release the daemon declined it re-activates the thread and interrupts the
retained turn. A concurrent stop joins the whole operation, so it no longer
returns when a pending release resolves while the escalated interrupt is
still outstanding. Command failures are returned to each caller, and callers
that require the thread stopped get the failure of the escalated interrupt.

An explicit stop wins over work that is still running: a turn that starts
while the stop's release is in flight is interrupted too. This is documented
in the thread guide and the SDK declaration and covered by a regression.

The stop guard now counts holders and tracks releases separately, so a
pending awaited release no longer suppresses an interrupt requested through
the non-awaited path, and concurrent awaited stops no longer drop each
other's claim.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@SawyerHood
SawyerHood force-pushed the bb/3462-fix-stop-leaving-threads-stuck-thr_vdv2rx6c7w branch from a84cf29 to 5a30d7d Compare September 11, 2026 21:41
main published 0.4.84, and this layer changes the SDK's bundled type
declarations (the thread.stop result's activeTurnRetained field and the
threads.stop declaration), so the npm version guard requires a new version.

Co-Authored-By: Claude Code <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.

bb thread stop reports success but leaves a stray daemon turn, so every send is refused ("Refusing to start a competing turn")

1 participant