Reconcile daemon turns the server lost track of on explicit stop - #3502
Open
SawyerHood wants to merge 5 commits into
Open
Reconcile daemon turns the server lost track of on explicit stop#3502SawyerHood wants to merge 5 commits into
SawyerHood wants to merge 5 commits into
Conversation
SawyerHood
added this pull request to stack #3504
September 11, 2026 17:30
SawyerHood
force-pushed
the
bb/3462-fix-stop-leaving-threads-stuck-thr_vdv2rx6c7w
branch
from
September 11, 2026 17:40
7b1bae0 to
9bb6c72
Compare
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
force-pushed
the
bb/3462-fix-stop-leaving-threads-stuck-thr_vdv2rx6c7w
branch
from
September 11, 2026 21:41
a84cf29 to
5a30d7d
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, andbb thread stopprintedstoppedwithout 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-upturn.submitas a competing turn, but the server settled that refusal asrun.failed, moving the thread toerrorwhile a root turn it had already stored (turn/startedwith noturn/completed) was still running. Fromerrororidle, an explicit stop sendsintent: "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.succeededis illegal fromerror, 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.stopresult gains optionalactiveTurnRetained; newCOMPETING_TURN_ERROR_CODE(competing_turn).HOST_DAEMON_PROTOCOL_VERSION203 → 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: areleasethat keeps an active turn reportsactiveTurnRetained: true(the release race guard itself is unchanged). Runtime and dispatcher competing-turn refusals carry thecompeting_turncode (CompetingTurnErrorin@bb/agent-runtime, mapped ingetErrorCode).apps/server(thread-lifecycle.ts):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.turn.submitrefused withcompeting_turnwhile the thread has an uncompleted stored root turn recordsclient/turn/rejectedbut 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-sdk0.4.84 → 0.4.85: main published 0.4.84, and this layer changes the SDK's bundled type declarations (activeTurnRetainedand thethreads.stopdeclaration), 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.requireStoppedpath) 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), andrequireStoppedcallers get any failure.threads.stopin the SDK declarations, and the daemon test that keeps such a turn onreleaseis renamed to say it reports the turn as retained.errorin 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). Swappingthread-lifecycle.tsback tomainmakes the six behavioral tests fail and leaves the two negative controls passing.Daemon:
command-dispatch.test.tsrelease-race test now expectsactiveTurnRetainedand checks the follow-up interrupt clears the turn;command-dispatch-support.test.tscovers 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; arequireStoppedcaller is rejected when the escalated interrupt fails. Withthread-lifecycle.tsswapped 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 lintandturbo run testfor@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 onorigin/mainand on this branch: a real Codex turn ran a foregroundsleep; the row was flipped toerror(the state main leaves after a competing-turn refusal) andbb thread stopissued. On main the CLI printedstopped, nothing was interrupted, the thread was stillerror60 s later, the nexttellwas refused (command_failed+system/error) and the thread stayederrorafter the turn finished. On this branch the same stop loggedHost daemon kept an active turn on release; interrupting it for the explicit stop, the turn completedinterrupted, the thread wasidlewithin a second and the nexttellwas answered. A second thread flipped toidlemid-turn: on main the refusedtellmoved it toerrorpermanently; here it recordedclient/turn/rejectedreasoncompeting_turn, stayedactive, and settledidlewhen the real turn completed. Under a documented delay relay around the real app-server (holdsturn/started400 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
errorwas stopped withbb thread stop, the turn completedinterruptedand the thread wasidlein the same second, and the nexttellwas answered; a second thread flipped toidlemid-turn had itstellrecorded ascompeting_turnwith nosystem/error, stayedactive, settledidlewhen the real turn completed, and answered a furthertell.Fixes #3462
🤖 Generated with Claude Code