Hold messages for threads that await user interaction - #2129
Merged
Conversation
SawyerHood
marked this pull request as ready for review
August 21, 2026 03:15
SawyerHood
force-pushed
the
bb/fix-1650-blocked-thread-messages
branch
from
August 24, 2026 23:44
369b625 to
5c8bbca
Compare
A thread blocked on an AskUserQuestion, a command approval, or a plugin input request cannot take a prompt. The /send route refused every mode with 409 and persisted nothing, and queueParentSystemMessage returned false silently, so bb thread tell reports and child-completed notices addressed to a blocked orchestrator vanished with no trace on the recipient side (#1650). Hold them in a new deferred_thread_messages table instead. Sends (every mode but start) return { ok: true, delivery: "deferred" }, parent system messages are stored with their taxonomy, and a settle hook on the pending-interaction lifecycle plus a periodic sweep deliver them in arrival order and in the requested mode once the thread unblocks. The send policy moves into acceptThreadSendRequest so the route and the flush share one decision; createQueuedMessageForThread moves next to the rest of the queue service. The CLI prints the held outcome, and the guide and bb-cli skill tell agents not to resend. Co-Authored-By: Claude <noreply@anthropic.com>
The provider-retry plugin's send mocks still returned { ok: true }, which
no longer satisfies the widened SendMessageResponse and failed the
workspace typecheck.
A deferred row whose request can no longer be honored (its sender thread
was deleted, its attachment is gone) used to be retried on every sweep
and, because flushes stop at the first error to keep arrival order,
blocked every later held message for that thread. A 400/404 from the
send pipeline is now terminal for that row: it is deleted with a warn
log and the flush continues. Stopping threads (409), plugin mentions
(422), absent hosts (502) and timeouts still retry.
Also update the manual runbook line that still described tells as
rejected while a thread awaits user interaction.
Co-Authored-By: Claude <noreply@anthropic.com>
runDeferredThreadMessageSweep drove every thread that held a row, and the flush treated every non-400/404 error as transient. A thread that failed while a message was held (a provider exit or a host-daemon restart while an AskUserQuestion is open interrupts the interaction and applies run.failed) sits in `error` until somebody retries it, and a steer refuses with 409 `thread_not_writable` there. The sweep ticks every ten seconds, so one held tell produced one full send-pipeline call and one warn per tick, forever. That is the pattern #1789 removed from the queued-message sweep, whose selection this one claimed to match but did not: listIdleThreadsWithQueuedMessages filters to idle threads with a live environment. The sweep now visits only threads that can act on their rows. A thread in `error`, `starting` or `stopping` is left alone: its rows are not lost, they wait for the status change (a user retry, a start that lands, a stop that finishes) and the tick after it delivers them. Threads whose rows can never deliver — archived, deleted, or an environment that is gone and so is never reprovisioned — are listed separately and their rows dropped once with a warn naming the reason, instead of living until somebody archives the thread. The flush applies the same gone-environment rule, so the settle path and the sweep agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The send route answers `{ ok: true, delivery }`, but the app's mutation
discarded the response, so a `deferred` send — the server holding the
message until the thread's open question or approval settles — was
treated as an accepted turn: the thread was flipped to active with a
working indicator for a turn that never started, and, with realtime
down, the timeline was invalidated and the refetch dropped the optimistic
message row, so the message looked lost.
The mutation now returns the response (an older server that answers a
bare `{ ok: true }` reads as `sent`), and a `deferred` delivery restores
the thread record, keeps the optimistic row — it is the only place the
held message is visible until the real event replaces it — and leaves the
timeline queries alone. The composer says the message is held and must
not be sent again.
Note on reachability: every composer the app renders is already swapped
out for the pending interaction, verified live against a Claude Code
thread parked on a native AskUserQuestion and a Codex thread parked on
the ask-user-question plugin's interaction. This closes the window where
a send and an interaction register at the same moment, and stops the app
ignoring a field of the contract it consumes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SawyerHood
force-pushed
the
bb/fix-1650-blocked-thread-messages
branch
from
August 25, 2026 01:43
5c8bbca to
4645b23
Compare
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.
What was wrong
A thread parked on a pending user interaction (an
AskUserQuestion, a command approval, a plugin input request) cannot take a prompt, and the server turned that into a drop with no recipient-side trace. Two guards keyed onhasPendingThreadInteractiondid it:POST /threads/:id/send(everybb thread tellmode, including--mode queuesince #112) threw 409awaiting_user_interactionbefore anything was persisted, andqueueParentSystemMessagereturnedfalsesilently for child-completed/failed notices. A blocked orchestrator therefore never heard that its workers reported or finished, and only the sender saw the 409. Issue: #1650. Report: https://get-bb.github.io/reports/issues/1650.htmlWhat changed
packages/db: newdeferred_thread_messagestable (migration0108_deferred_thread_messages, snapshot regenerated with drizzle-kit) with a data module;migrate.test.tsrewinds drop it.apps/server/src/services/threads/deferred-thread-messages.ts(new): the zod payload schema (sendrows carry the originalSendMessageRequest;parent-systemrows carry input + taxonomy) anddeferThreadMessage.apps/server/src/services/threads/thread-send-request.ts(new):acceptThreadSendRequestis now the single send policy used by the route and by the flush: queue (queue-if-activeon an active thread, compaction), else defer when the thread awaits interaction andmode !== "start", else send. Sender thread and attachment references are validated before a message is held, so a bad request still fails fast.flushDeferredThreadMessagesdelivers rows in arrival order in the mode the sender asked for (asteer-if-activetell steers into the resumed turn; a parent notice goes throughqueueParentSystemMessage), deletes a row only after delivery, and keeps the rest on failure. Flushes are serialized per thread through a newlifecycleDedupers.deferredThreadMessageFlush.listIdleThreadsWithQueuedMessagesdoes (A thread whose environment no longer exists still reports idle, and the message queue accepts sends into it that can never be delivered #1789).listThreadIdsWithDeliverableDeferredThreadMessagesselects visible threads in statusidleoractivewith a live environment. A thread inerror,startingorstoppingis deliberately absent: it refuses every send, and its state only changes when a user retries it, a start lands, or a stop finishes, so its rows wait and the tick after the change delivers them. Driving it on every tick would re-run the send pipeline and log a failure every ten seconds for as long as the thread sat there.listThreadIdsWithUndeliverableDeferredThreadMessagesselects the threads whose rows can never deliver — archived, deleted, or an environment that is gone and so is never reprovisioned — and the flush drops those rows once with a warn naming the reason, instead of leaving them until somebody archives the thread. The flush applies the same gone-environment rule, so the settle path and the sweep agree.ApiError400/404 from the send pipeline) is dropped with a warn log and the flush continues, so it cannot head-of-line block the thread's later held messages. Plugin mentions (422), absent hosts (502) and host timeouts still keep the row and retry on the next settle or sweep.PendingInteractionLifecycle.setThreadInteractionSettledListener: one settle hook (resolving/resolved/interrupted) that schedules a flush;createAppwires it. Adeferred-thread-message-flushperiodic sweep re-drives rows a restart or a stopping thread left behind.queueParentSystemMessagedefers instead of returningfalsewhen the parent is blocked.createQueuedMessageForThreadandqueuedMessagePayloadFromSendRequestmoved from the route file toservices/threads/queued-messages.tsunchanged, so the service can reuse them./sendnow returns{ ok: true, delivery: "sent" | "queued" | "deferred" }(additive;startkeeps its 409).ThreadSendResultfollows, so@get-bb/plugin-sdkbumps to 0.4.17; the provider-retry plugin's test mocks returndelivery: "sent"to match.bb thread tellprints "awaiting user interaction; message held and delivers once the interaction settles" and--jsoncarriesdelivery; an older server that only reportsokkeeps the old wording.deliveryinstead of discarding the response. Adeferredsend restores the thread record rather than showing a working indicator for a turn that never started, keeps the optimistic message row instead of letting a refetch drop it, and tells the user the message is held and must not be sent again. Every composer the app renders is already swapped out for the pending interaction, so this closes the window where a send and an interaction register at the same moment.bb-guide-threads.md), the bb-cli skill, and the manual runbook tell agents and testers the message is held, not rejected, and that a held message waits for a thread that failed while it was held and delivers when the thread is retried.HOST_DAEMON_PROTOCOL_VERSIONbump.How you verified
apps/server/test/threads/deferred-thread-messages.test.ts(9 tests, real SQLite through the harness): a worker tell to a blocked active thread returnsdelivery: "deferred"and steers in after the answer is delivered through the realinteractive.resolvecommand path;mode=startkeeps its 409; a missing sender thread is rejected before anything is held; a child-completed notice to a blocked parent is held and flushes with its taxonomy; three held messages deliver in arrival order and a sweep while still blocked delivers nothing; a row survives astoppingthread and the sweep delivers it once idle; a held tell whose sender is deleted before the flush is dropped and the user's message behind it still delivers; a thread that errors while a message is held logs at most one delivery failure and none across five further sweep ticks, then delivers the row as a steer afterrun.preparing+run.started; a thread whose environment is gone has its rows dropped once with a single warn. The last two fail on the previous revision with 6 warns instead of 1, and with the row never dropped.thread-runtime-cache-owner.test.tsasserts adeferreddelivery leaves the thread record idle, keeps the optimistic row, and does not invalidate the timeline (it fails on the previous revision, which marked the timeline stale so the refetch dropped the row);thread-runtime-mutations.test.tsxasserts the mutation returns the server'sdelivery.public-thread-interactions.test.ts):autoon a blocked thread is deferred,queue-if-activeon a blocked active thread queues,startand queued-message send still 409. Updated{ ok: true }assertions inpublic-thread-data,internal-events-tool-calls,plugin-sdk, the CLIthread telltests (plus new held/legacy-server cases), the SDK test, the app mutation mock, and the provider-retry plugin send mocks.pnpm exec turbo run build typecheck lintover the whole workspace: 92/92 tasks pass.node scripts/check-provider-literal-ratchet.mjs --base origin/main,node .github/workflows/check-plugin-sdk-version.mjs, andnode packages/plugin-sdk/scripts/check-npm-version-guard.mjsall pass.pnpm exec turbo run test --forcefor@bb/server(211 files / 2016 tests),@bb/app(431 / 3355),@bb/cli(50 / 477),@bb/db(28 / 409),@bb/client-core(20 / 239),@bb/sdk(6 / 96),@bb/server-contract(7 / 60),@bb/templates(6 / 41) andbb-plugin-provider-retry(2 / 26): all pass.claude-haiku-4-5with the native AskUserQuestion, and codex with the ask-user-question plugin's interaction). Held messages:bb thread tellprinted the held line,--jsonreturneddelivery: "deferred",curlwithmode=startreturned 409awaiting_user_interaction, and answering the question delivered every held row with a "Delivered deferred thread message" line and no failures.errorwith the row held. Before the fix the server logged the same 409 delivery failure every ten seconds — 9 warns in 90 seconds, the row never moved. After the fix, on the same database and the same thread, 0 warns in 90 seconds and the row still held; amode=startretry returneddelivery: "sent"and the held row delivered within ten seconds. Archiving a thread with a held row dropped it once withreason: "thread_archived"and did not repeat.deliveryhandling is therefore defence in depth for the send/ask race, not a reproduced user-facing bug.Independent verification
Rebased onto
main(7655bad4c) on 2026-08-24: migration renumbered to 0108, SDK bump moved to 0.4.17,buildExecutionOptionscall ported to the movedqueued-messages.ts. The figures below describe the pre-rebase head369b625d9.Verified round 2 (head
369b625d9) in a separate worktree by an independent agent.Commands run
git fetch origin main && git fetch origin bb/fix-1650-blocked-thread-messages && git checkout -b verify-1650-r2 FETCH_HEAD;git merge --no-commit origin/main(main at2ff85986e, two commits past the PR base): clean merge, no conflicts.pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build.cd packages/db && pnpm exec drizzle-kit generate: "No schema changes, nothing to migrate", so0105_snapshot.jsonmatchesschema.ts(not hand-edited).pnpm exec turbo run typecheck --continue(whole workspace): 74/74 pass.pnpm exec turbo run typecheck test --continue --force --filter=@bb/server --filter=@bb/cli --filter=@bb/db --filter=@bb/sdk --filter=@bb/server-contract --filter=bb-plugin-provider-retry --filter=@bb/templates: server 195/196 test files pass (the one failure isinternal-skill-trees.test.ts, the known local umask 0664-vs-0644 difference; it passes in CI), cli 48/48, db 29/29, sdk 6/6, server-contract 7/7, provider-retry 2/2, templates 6/6.eslintandprettier --checkon the changed source/test files: clean.Fail-before / pass-after
git checkout origin/main -- apps/server/src/routes/threads/actions.ts apps/server/src/services/threads/parent-system-messages.ts apps/server/src/server.ts apps/server/src/services/interactions/pending-interactions.ts(new modules and db layer left in place so the tests compile), thenpnpm exec vitest run test/threads/deferred-thread-messages.test.ts test/public/public-thread-interactions.test.tsinapps/server: 7 failed / 25 passed. Failing assertions:AssertionError: expected 409 to be 200(x5, the held sends),expected 409 to be 400(sender validation before the guard),expected false to be true(queueParentSystemMessagestill returned false for a blocked parent).git checkout HEAD -- <same files>and rerun: 32/32 pass.Repro on the fixed branch (own dev instance, ports 17012/25012/33012, claude-code haiku, native AskUserQuestion)
thr_jfcrwfpak2parked onpint_w3eur8n8j4("Proceed?"). While blocked: 11steer-if-activesends (bb thread tell, rawcurl, SDK), one--mode auto, two--mode queue, and onemode=start. Results: every steer/auto returned{"ok":true,"delivery":"deferred"}and the CLI printed "awaiting user interaction; message held and delivers once the interaction settles" (--jsoncarriesdelivery);--mode queuereturneddelivery: "queued"and showed inbb thread queue list;mode=startreturned HTTP 409awaiting_user_interaction.thr_a2nxkwgvwq, parent = orchestrator, "Reply only with ok."). After it went idle aparent-systemrow (child-completed) appeared indeferred_thread_messages(14 rows total).bb thread interactions answer ...: within ~14 s the table was empty, the dev log had 14 "Delivered deferred thread message" lines and no "failed"/"Dropped" lines, andclient/turn/requestedrows seq 21-38 carried all 12 tells in arrival order (target.kind=steer/auto) plus theinitiator=system,systemMessageKind=child-completednotice; the two queued tells drained asnew-turn(seq 63, 83) once the thread went idle. The model's follow-up reply: "Threadthr_a2nxkwgvwqcompleted successfully. All worker reports received (A, C, D, E, F, G tasks done, plus probes and debug reports)." On main the same steps lose every one of these (report section 4a).CI: all checks green on
369b625d9(Checks, Package Smoke x2, Tests app-1/2/3, integration, packages, server, version checks).Residual risks / notes (none blocking)
qa/manual-runbook.mdsays "--mode startis still rejected";bb thread tellonly exposessteer/queue/auto, so that sentence describes the raw APImode: "start", not a CLI flag. Doc nit.bbfrom a prior release, reached viaBB_CLIre-exec) against this server prints "Thread X steered" for a message that was actually held, because it ignoresdelivery. CLIs ship with the server, so this only matters mid-upgrade.Fixes #1650