Hold messages for threads that await user interaction - #2129
Open
SawyerHood wants to merge 2 commits into
Open
Conversation
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>
SawyerHood
marked this pull request as ready for review
August 21, 2026 03:15
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 (migration0105_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, keeps the rest on failure, and drops rows for archived/deleted threads. Flushes are serialized per thread through a newlifecycleDedupers.deferredThreadMessageFlush.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. Stopping threads and unavailable environments (409), 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.11 viascripts/bump-plugin-sdk.mjs; 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. Guide (bb-guide-threads.md), the bb-cli skill, and the manual runbook tell agents and testers the message is held, not rejected.HOST_DAEMON_PROTOCOL_VERSIONbump.Difference from PR #1699 (same issue): that PR routes blocked sends into the thread queue (delivered only when the thread is next idle) and adds a second durable store just for parent notices, deletes each row before trying to deliver it, and is 26+ commits behind main with a colliding migration index and plugin-sdk version. This PR holds both kinds in one table, delivers a held tell in the mode the sender asked for as soon as the interaction settles, and never deletes a row before delivery succeeds.
How you verified
apps/server/test/threads/deferred-thread-messages.test.ts(7 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.git checkout origin/main -- <server src files>, new modules removed):public-thread-interactions.test.tsfails withAssertionError: expected 409 to be 200; the new test file fails withCannot find module '../../src/services/threads/thread-send-request.js'. The new deleted-sender test also fails against the previous revision of this branch withAssertionError: expected [] to deeply equal [ 'from the user' ](the user's message stayed stuck behind the dead sender's row). All 7 pass on the branch.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 typecheck --continueover the whole workspace: 74/74 tasks pass (the earlier revision failedbb-plugin-provider-retry#typecheckon the widenedThreadSendResult).pnpm exec turbo run test lint --filter=@bb/server --filter=bb-plugin-provider-retry --filter=@bb/cli --filter=@bb/db --filter=@bb/sdk --filter=@bb/server-contract: all pass exceptinternal-skill-trees.test.ts(pre-existing local umask 0664 vs 0644 failure, passes in CI).bb thread tells to the blocked thread returneddelivery: "deferred"(CLI printed the held-message line), amode=startsend returned 409awaiting_user_interaction,deferred_thread_messagesheld two rows. Afterbb thread interactions answer, both rows delivered as steers into the resumed turn (client/turn/requestedseq 21 and 23,target.kind=steer), the table was empty, the dev log showed two "Delivered deferred thread message" lines and no failures, and the model replied "Task A and Task B are both complete". The earlier revision's live run additionally covered--mode queueand a real child's completion notice (14 held rows delivered in order).Fixes #1650
Independent verification
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.