Skip to content

Queue messages sent to a thread that awaits user interaction - #1699

Open
SawyerHood wants to merge 4 commits into
mainfrom
bb/investigate-1650-thr_waardahvgj
Open

Queue messages sent to a thread that awaits user interaction#1699
SawyerHood wants to merge 4 commits into
mainfrom
bb/investigate-1650-thr_waardahvgj

Conversation

@SawyerHood

Copy link
Copy Markdown
Collaborator

Fixes #1650

Problem

A thread blocked on AskUserQuestion or an approval refused every send with 409 awaiting_user_interaction. The server persisted nothing, so only the sender learned that the message never arrived. An orchestrator held a question open for hours and concluded that its worker had gone silent. Child turn notifications to a blocked parent had the same gap: queueParentSystemMessage returned false with no log and no retry.

Change

  • POST /threads/:id/send: when an active thread awaits user interaction, queue the message instead of returning 409. queue-if-active and manual-compaction sends still queue. start still hits the existing conflict errors. The response now reports the outcome: { ok, delivery: "sent" } or { ok, delivery: "queued", queuedReason }.
  • Queued messages auto-send through the existing queued-message-auto-send path when the thread is next idle, and they stay visible in the thread queue meanwhile.
  • bb thread tell prints the queued outcome (... is awaiting user interaction; message queued and delivers when the thread is next idle) and includes delivery/queuedReason in --json.
  • Parent system messages (child completed/failed/interrupted, needs-attention, ownership) sent to a blocked parent now defer in server memory and flush when the parent's interaction settles, through a new PendingInteractionLifecycle.setThreadInteractionSettledListener.
  • SDK threads.send returns SendMessageResponse; guide and bb-cli skill document the queued outcome.

The 409 error text is unchanged on purpose; the sibling change for #1655 owns that surface.

Tests

  • public-thread-interactions.test.ts: a steer-if-active send with a senderThreadId to a blocked active thread returns delivery: "queued" and stores one queued row with the sender; start still returns 409 and adds no row. (Replaces the PR Fix pending interaction queue send guard #112 assertion that queueing was refused.)
  • New parent-system-messages-deferred.test.ts: a parent system message to a blocked parent defers, then lands as a client/turn/requested system event after the interaction settles.
  • CLI: bb thread tell human and --json output for the queued outcome.
  • Ran with Turbo: typecheck for all packages; tests for @bb/server, @bb/cli, @bb/sdk, @bb/server-contract, and the touched @bb/app test. The only failing server test (internal-skill-trees file mode 420 vs 436) also fails on main in this environment.

AGENT GENERATED: by Claude Opus 5

SawyerHood and others added 3 commits August 17, 2026 17:24
A thread blocked on AskUserQuestion or an approval refused every send with
409 and persisted nothing, so only the sender learned that the message never
arrived. The send route now queues such messages (and reports the queued
outcome), and parent system messages defer and flush when the interaction
settles.

Fixes #1650

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@bb-slop-cop

bb-slop-cop Bot commented Aug 17, 2026

Copy link
Copy Markdown

🚨 slopcop/review — I am SlopCop, and I am reviewing this pull request now.

I will check security, code quality, performance, architecture, and the end-to-end behavior.

// messages wait here and flush when the parent's interactions settle. Server
// memory only: a restart interrupts every pending interaction and the parent
// runtime is gone, so there is no later unblock to flush into.
const deferredParentSystemMessages = new Map<

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Deferred messages disappear after a server restart, and this queue has no limit.

The comment says that a restart interrupts every pending interaction. However, PendingInteractionLifecycle.start() interrupts only plugin interactions. Provider approvals and questions remain in SQLite. If a child finishes before a restart, this Map loses the child result. The parent can then wait for a result that BB already received.

This Map also has no item, byte, or age limit. A long approval can retain unlimited full prompt values. The flush then sends each entry separately and can cause a large database and host-command load.

Please use the durable queued-message path and preserve the system-message fields. A dedicated SQLite queue also works. Please add a restart test and a bounded-flush test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bdcb910. Confirmed: start() interrupts only plugin interactions, so provider approvals survive a restart and the in-memory map would lose the child result. Deferred parent system messages now persist in a new deferred_parent_system_messages table (migration 0099). The settle listener flushes them, and a deferred-parent-system-message-flush periodic sweep re-drives rows a restart left behind, so nothing depends on process memory. Tests: parent-system-messages-deferred.test.ts covers settle-flush, sweep-while-blocked (row stays), and sweep-after-unblock (restart case). I did not add a size cap: the store is SQLite, and child notifications already batch upstream; each flush sends rows in order and logs failures.

args: { payload: SendMessageRequest; thread: Thread },
): SendQueuedReason | null {
const { payload, thread } = args;
if (thread.status !== "active" || payload.mode === "start") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — Idle threads with plugin input requests still reject messages instead of queueing them.

requestPluginInteraction() permits an idle thread. This guard returns before it checks for a pending interaction when the thread is idle. sendThreadMessage() then returns 409, and BB stores nothing. The new CLI and guide text says that a target awaiting user interaction will queue the message.

Please check the pending interaction first for each non-start mode. When an idle interaction settles, request queued-message delivery. Please add an idle plugin-interaction regression test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bdcb910. Confirmed: requestPluginInteraction() allows an idle thread, and the old guard returned before the pending check. The route now checks the pending interaction first for every non-start mode, and createQueuedMessageForThread and sendNextQueuedMessageIfPresent skip auto-send while an interaction blocks the thread. When the interaction settles on an idle thread, the settle listener requests a queued-message auto-send. Regression test: "queues sends to an idle thread blocked by a plugin input request" in public-thread-interactions.test.ts, plus the blocked-idle auto send in the existing test now expects a queued row that does not auto-send.

@bb-slop-cop bb-slop-cop Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 SLOP COP 🚨 · review

Plain English: This change stops messages from disappearing while a thread waits for a question or approval. User messages enter the existing queue and send after the thread becomes idle. Child results use a new temporary memory queue. The API, SDK, and CLI now report whether BB sent or queued the message.

I found two medium problems:

  1. The parent-message queue loses child results after a server restart. Provider questions and approvals remain in SQLite, but the new process Map disappears. The same map has no size or age limit. A long interaction can consume unlimited memory and cause a large flush load.

  2. Idle threads with plugin input requests still return 409. The pending-interaction check runs only after the active-status guard. This behavior conflicts with the new CLI and guide text, and BB still stores nothing.

The existing durable queued-message service is the best refactor target. It can remove the duplicate temporary queue if it also stores system-message fields. I found no other important duplication.

I found no security issue. Request validation, sender attribution, attachment checks, permission resolution, and dispatch controls remain intact.

Validation passed:

  • Turbo type checks passed for @bb/server, @bb/cli, @bb/sdk, and @bb/server-contract.
  • The focused server tests passed: 26 tests.
  • The CLI tests passed: 10 tests.
  • The SDK tests passed: 90 tests.
  • A live server at the pull request SHA returned delivery: "queued" and stored messages during a plugin question.

The browser check could not finish because the first Vite dependency optimization did not complete. The live API path and queue state completed successfully.

…le threads

Address SlopCop review on #1699:
- Deferred parent system messages now live in a SQLite table with a
  periodic sweep, so a server restart does not lose them and the queue is
  not unbounded process memory.
- The send route checks for a pending interaction before the status guard,
  so an idle thread blocked by a plugin input request queues too. Queued
  auto-send skips blocked threads and resumes when the interaction settles.

Co-Authored-By: Claude <noreply@anthropic.com>
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