fix(host-daemon,db): stop one undeliverable event from wedging every thread - #1321
Open
tymonTe wants to merge 1 commit into
Open
fix(host-daemon,db): stop one undeliverable event from wedging every thread#1321tymonTe wants to merge 1 commit into
tymonTe wants to merge 1 commit into
Conversation
tymonTe
force-pushed
the
fix/daemon-event-queue-wedge
branch
from
August 11, 2026 09:47
4043620 to
f48db20
Compare
krish-pointone
approved these changes
Aug 11, 2026
…thread
Three times today, every thread on the host froze at "waiting" until the
app was restarted. Each time the trigger was a single event the server
could never store, sitting at the head of the daemon's host-wide event
queue.
Codex labels its automatic-compaction traffic with a turn id of its own
making ("auto-compact-N"). bb never opened that turn, so no turn/started
was ever stored for it, and the append refused the event with 409. The
daemon reposted the identical batch on every flush; the rejection was
deterministic, so it could never clear.
1. `event-sink.ts` — a batch the server refuses as `invalid_request`
(400/409, both non-retryable) is no longer reposted verbatim. The
sink bisects it, drops the events that are undeliverable by
construction, and delivers the rest. The server appends a batch in
one transaction and rolls it back entirely on refusal, so nothing was
committed and re-posting halves cannot duplicate. The code check
keeps this narrow: 401 `unauthorized` / `inactive_session` are also
non-retryable but say nothing about the events, so those stay queued
for the session the daemon is about to reopen.
2. `provider-unhandled-event.ts` — `createUnhandledProviderEvent` no
longer scrapes `turnId` out of the raw provider event. Only a turn id
the caller vouched for may scope the event. Callers omit `turnId`
precisely when bb has no active turn, which is exactly when a
provider-minted id is guaranteed wrong.
3. `events.ts` — `provider/unhandled` joins the orphan-droppable turn
event types, as a backstop. It is a diagnostic passthrough; losing
one is a non-event, failing the batch it rode in with is not.
4. The queue-backup tripwire logs at `warn` instead of `debug`. It never
fired in any of the three incidents, so there was no signal short of
noticing the UI had stopped moving.
Fix 1 is the load-bearing one: 2 and 3 close this particular trigger,
but only 1 stops the next unknown orphan event from wedging the host.
Bumps HOST_DAEMON_PROTOCOL_VERSION to 90 so enrolled daemons pick up
fix 1 rather than continuing to wedge on their current build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tymonTe
force-pushed
the
fix/daemon-event-queue-wedge
branch
from
August 11, 2026 18:21
f48db20 to
812dd08
Compare
Author
|
Rebased onto Re-verified on the new base: |
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.
Problem
Every thread on the host freezes at "waiting" and only a full app restart clears it. This has now happened four times on bb-app 0.36.0, each time triggered by a single event the server could never store.
The daemon holds one in-memory event queue for the whole host and reposts it as a single batch. When the head of that queue is an event the server deterministically refuses, the batch can never succeed — so every other thread's
turn/started,item/*andturn/completedevents pile up behind it and never reach the database. The UI reads the database, so every thread looks stuck.From the logs
~/.bb/logs/server.3.log— the first rejection, then the same one repeating verbatim:{"level":40,"time":1786360836499,"eventType":"provider/unhandled","scopeKind":"turn", "threadId":"thr_fpx3vkax5h","turnId":"auto-compact-1", "errorMessage":"Cannot append provider/unhandled for turn auto-compact-1 before turn/started is stored", "errorName":"MissingStoredTurnStartedError","msg":"Rejected daemon event before turn/started"} {"level":40,"time":1786360836616,"eventType":"provider/unhandled","scopeKind":"turn", "threadId":"thr_fpx3vkax5h","turnId":"auto-compact-1", ... }Every occurrence, grouped by the turn that poisoned the queue:
thr_dwmzmanhn5auto-compact-2thr_fpx3vkax5hauto-compact-1thr_sdc5dy277mauto-compact-3thr_qifimqh4a6auto-compact-1Every window ends at a restart, never at a recovery.
During the 13:20 window the server logged no thread activity whatsoever — only the rejections:
The 15:08 occurrence is visible directly in the database. Rows inserted per minute across all threads, spanning that window:
Five minutes in which the whole machine persisted essentially nothing, then instant recovery on restart. Those events are gone: the queue is in-memory, so the restart that clears the wedge also discards everything held behind it, leaving a hole in each affected thread's transcript.
Root cause
A provider-minted turn id is trusted.
createUnhandledProviderEventfalls back to readingturnIdout of the raw provider event when the caller does not supply one:Codex labels its automatic-compaction traffic
auto-compact-N. The stringauto-compactappears nowhere in bb's source — it is entirely provider-minted, and everyprovider/unhandledevent on all four affected threads carriesproviderId: "codex". bb never opened that turn, so it never emitted aturn/startedfor it. Critically, every caller suppliesturnIdfrom bb's own turn registry and omits it only when bb has no active turn — precisely the case where a scraped id is guaranteed wrong.The server hard-rejects the orphan.
resolveDaemonTurnStartDispositionfinds no storedturn/started; the escape hatchORPHAN_DROPPABLE_TURN_EVENT_TYPESheld only the two usage-snapshot types, so it throwsMissingStoredTurnStartedError.The whole batch dies with it.
/session/eventsappends every event in oneimmediatetransaction, so the throw rolls all of them back and returns409 invalid_request.The daemon reposts it forever. The drain loop takes the entire queue as one batch and splices only on success:
The daemon already knows this class of error is permanent —
defaultRetryableForStatus(409)isfalse, andServerResponseError.retryablecarries that verdict — but nothing consults it.Fix
1.
apps/host-daemon/src/event-sink.ts— never repost a batch the server permanently refused. On a non-retryableinvalid_request, the sink bisects the batch, drops the events that are undeliverable by construction, and delivers the rest. Since the server appends in one transaction and rolls back entirely on refusal, nothing was committed and re-posting the halves cannot duplicate. Isolating k bad events costs O(k log n) posts.The
invalid_requestcode check is what keeps this narrow:/session/eventsalso fails non-retryably with401 unauthorizedand401 inactive_session, and those say nothing about the events themselves. Those must stay queued for the session the daemon is about to reopen, not be discarded one at a time — there is a regression test for exactly this.2.
packages/agent-runtime/src/shared/provider-unhandled-event.ts— stop trusting provider turn ids. Only a turn id the caller vouched for scopes the event; the raw-event fallback is gone.3.
packages/db/src/data/events.ts—provider/unhandledbecomes orphan-droppable. A backstop, in the spirit of the existing comment about fork usage snapshots. An unhandled passthrough event is diagnostic only: losing one is a non-event, failing the batch it rode in with is not. Turn-content events still require a storedturn/started, so genuine ordering bugs are still caught.4. The queue-backup tripwire logs at
warn, notdebug. It never once fired in any of the four incidents, so there was no signal short of noticing the UI had stopped moving.Fix 1 is the load-bearing one. Fixes 2 and 3 close this particular trigger; only fix 1 stops the next unknown orphan event from wedging the host.
Note on ordering of fixes 1 and 3
Fix 3 alone repairs already-enrolled daemons: an old daemon talking to a new server stops receiving 409s, so the wedge cannot recur even before it updates. Fix 1 is what makes the daemon resilient to the next unknown case.
Protocol version
Bumped
HOST_DAEMON_PROTOCOL_VERSION99 → 100, matching the convention used by #1224, #1208, #1232, #1314 and #1236 for daemon-behaviour changes. Nothing in the wire schema changed, and both directions are compatible (old daemon + new server is in fact the repair path above) — the bump is here to push fix 1 out to enrolled machines rather than leave them on a build that wedges. Happy to drop it if you would rather not force an update cycle for this.Tests
Written as reproductions first, and confirmed failing against the base commit before the fix:
ignores a provider-supplied turn id the caller did not vouch for@bb/agent-runtimeauto-compact-1scraped from raw paramsdrops orphan provider/unhandled events instead of failing the batch@bb/dbdrops a permanently rejected event instead of retrying it forever@bb/host-daemondelivers events queued behind a permanently rejected event@bb/host-daemonaccepts a batch carrying a provider/unhandled event for a turn bb never started@bb/server/internal/session/eventsroute that produced the 409Plus guards against over-correcting:
keeps events queued when the session, not the batch, is rejected— a 401 must not bisect the queue away.keeps retrying a batch that fails for a retryable reason— 5xx behaviour unchanged.One existing expectation changed:
codex/adapter.test.ts > translateEvent unknown codex notifications fall back to provider/unhandlednow expects thread scope. That path handles notifications which failed schema parsing, so nothing there vouches for the turn id; Codex notifications bb does parse still carry turn scope. Comment in the test explains it.Verification
Rebased onto
d07c1ce28and re-verified there.pnpm exec turbo run teston@bb/db,@bb/agent-runtime,@bb/host-daemon,@bb/host-daemon-contract,@bb/server,@bb/integration-tests:typecheckandlintclean across all of them.The single
@bb/agent-runtimefailure —runtime.process-lifecycle.test.ts > bounds provider stderr while data arrives without a newline— fails identically on unmodifiedorigin/mainand is unrelated to this change.Fixes #1320
🤖 Generated with Claude Code