Skip to content

Hold messages for threads that await user interaction - #2129

Open
SawyerHood wants to merge 2 commits into
mainfrom
bb/fix-1650-blocked-thread-messages
Open

Hold messages for threads that await user interaction#2129
SawyerHood wants to merge 2 commits into
mainfrom
bb/fix-1650-blocked-thread-messages

Conversation

@SawyerHood

@SawyerHood SawyerHood commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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 on hasPendingThreadInteraction did it: POST /threads/:id/send (every bb thread tell mode, including --mode queue since #112) threw 409 awaiting_user_interaction before anything was persisted, and queueParentSystemMessage returned false silently 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.html

What changed

  • packages/db: new deferred_thread_messages table (migration 0105_deferred_thread_messages, snapshot regenerated with drizzle-kit) with a data module; migrate.test.ts rewinds drop it.
  • apps/server/src/services/threads/deferred-thread-messages.ts (new): the zod payload schema (send rows carry the original SendMessageRequest; parent-system rows carry input + taxonomy) and deferThreadMessage.
  • apps/server/src/services/threads/thread-send-request.ts (new): acceptThreadSendRequest is now the single send policy used by the route and by the flush: queue (queue-if-active on an active thread, compaction), else defer when the thread awaits interaction and mode !== "start", else send. Sender thread and attachment references are validated before a message is held, so a bad request still fails fast. flushDeferredThreadMessages delivers rows in arrival order in the mode the sender asked for (a steer-if-active tell steers into the resumed turn; a parent notice goes through queueParentSystemMessage), 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 new lifecycleDedupers.deferredThreadMessageFlush.
  • A held row whose request can no longer be honored at flush time (its sender thread was deleted, its attachment is gone: an ApiError 400/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; createApp wires it. A deferred-thread-message-flush periodic sweep re-drives rows a restart or a stopping thread left behind.
  • queueParentSystemMessage defers instead of returning false when the parent is blocked.
  • createQueuedMessageForThread and queuedMessagePayloadFromSendRequest moved from the route file to services/threads/queued-messages.ts unchanged, so the service can reuse them.
  • Contract/SDK/CLI: /send now returns { ok: true, delivery: "sent" | "queued" | "deferred" } (additive; start keeps its 409). ThreadSendResult follows, so @get-bb/plugin-sdk bumps to 0.4.11 via scripts/bump-plugin-sdk.mjs; the provider-retry plugin's test mocks return delivery: "sent" to match. bb thread tell prints "awaiting user interaction; message held and delivers once the interaction settles" and --json carries delivery; an older server that only reports ok keeps 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.
  • No host daemon wire change, so no HOST_DAEMON_PROTOCOL_VERSION bump.

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

  • New apps/server/test/threads/deferred-thread-messages.test.ts (7 tests, real SQLite through the harness): a worker tell to a blocked active thread returns delivery: "deferred" and steers in after the answer is delivered through the real interactive.resolve command path; mode=start keeps 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 a stopping thread 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.
  • Fail-before on origin/main server sources (git checkout origin/main -- <server src files>, new modules removed): public-thread-interactions.test.ts fails with AssertionError: expected 409 to be 200; the new test file fails with Cannot find module '../../src/services/threads/thread-send-request.js'. The new deleted-sender test also fails against the previous revision of this branch with AssertionError: 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.
  • Updated the Fix pending interaction queue send guard #112 regression (public-thread-interactions.test.ts): auto on a blocked thread is deferred, queue-if-active on a blocked active thread queues, start and queued-message send still 409. Updated { ok: true } assertions in public-thread-data, internal-events-tool-calls, plugin-sdk, the CLI thread tell tests (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 --continue over the whole workspace: 74/74 tasks pass (the earlier revision failed bb-plugin-provider-retry#typecheck on the widened ThreadSendResult). 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 except internal-skill-trees.test.ts (pre-existing local umask 0664 vs 0644 failure, passes in CI).
  • Live repro on my dev instance with the report's steps (claude-code haiku, native AskUserQuestion), on the revised branch: two bb thread tells to the blocked thread returned delivery: "deferred" (CLI printed the held-message line), a mode=start send returned 409 awaiting_user_interaction, deferred_thread_messages held two rows. After bb thread interactions answer, both rows delivered as steers into the resumed turn (client/turn/requested seq 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 queue and a real child's completion notice (14 held rows delivered in order).

Fixes #1650

AGENT GENERATED: by Claude Opus 5

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 at 2ff85986e, 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", so 0105_snapshot.json matches schema.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 is internal-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.
  • eslint and prettier --check on 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), then pnpm exec vitest run test/threads/deferred-thread-messages.test.ts test/public/public-thread-interactions.test.ts in apps/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 (queueParentSystemMessage still 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)

  • Orchestrator thr_jfcrwfpak2 parked on pint_w3eur8n8j4 ("Proceed?"). While blocked: 11 steer-if-active sends (bb thread tell, raw curl, SDK), one --mode auto, two --mode queue, and one mode=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" (--json carries delivery); --mode queue returned delivery: "queued" and showed in bb thread queue list; mode=start returned HTTP 409 awaiting_user_interaction.
  • Spawned a real child (thr_a2nxkwgvwq, parent = orchestrator, "Reply only with ok."). After it went idle a parent-system row (child-completed) appeared in deferred_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, and client/turn/requested rows seq 21-38 carried all 12 tells in arrival order (target.kind=steer/auto) plus the initiator=system, systemMessageKind=child-completed notice; the two queued tells drained as new-turn (seq 63, 83) once the thread went idle. The model's follow-up reply: "Thread thr_a2nxkwgvwq completed 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.md says "--mode start is still rejected"; bb thread tell only exposes steer/queue/auto, so that sentence describes the raw API mode: "start", not a CLI flag. Doc nit.
  • Version skew: an older CLI (for example the daemon-bundled bb from a prior release, reached via BB_CLI re-exec) against this server prints "Thread X steered" for a message that was actually held, because it ignores delivery. CLIs ship with the server, so this only matters mid-upgrade.
  • A held row that fails at flush with a non-terminal error (409 environment unavailable, 422 plugin mention, 502 host away) retries on every sweep with a warn log and holds later rows for that thread, matching the queued-message sweep's behavior; 400/404 rows are dropped with a warn.
  • If an interaction is registered in the microseconds between the flush's pending check and the send, the row is re-deferred at the tail of the thread's held list, so order relative to later rows can invert in that narrow race.

AGENT GENERATED: by Claude Opus 5

SawyerHood and others added 2 commits August 21, 2026 02:30
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
SawyerHood marked this pull request as ready for review August 21, 2026 03:15
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.

Messages to a thread blocked on AskUserQuestion are dropped, and only the sender is told

1 participant