Dispatch queue rework: scheduled sends, concurrency limits, one plugin checkpoint - #2779
Conversation
66dcccc to
e5a5da7
Compare
| @@ -0,0 +1,13 @@ | |||
| DROP TABLE `deferred_thread_messages`;--> statement-breakpoint | |||
There was a problem hiding this comment.
🚨 slopcop/review — This migration deletes durable queued notices.
The table can contain parent notices that wait on interactions. This drop deletes those rows during an upgrade.
Migrate the rows before this drop. Add a migration test with existing data.
| daemonSessionId: args.sessionId, | ||
| hostId: args.hostId, | ||
| }); | ||
| // A dispatch that could not reach an absent host is waiting on the queue like |
There was a problem hiding this comment.
🚨 slopcop/review — A reconnect cannot release this wait.
A failed drain stores host-offline with no sendAt. No sweep selects this wait, and this callback starts no drain.
Clear waits for this host here. Then drain each affected thread.
| // `pending → starting` flip is committed here, inside the lock, and the | ||
| // next handler in line sees it. A follow-up has no transition this side of | ||
| // the send transaction, so it has nothing to commit. | ||
| ...(firstDispatch |
There was a problem hiding this comment.
🚨 slopcop/review — Warm starts can exceed the concurrency limit.
Only a pending thread commits admission under the lock. Two idle threads can both observe one free slot and start.
Reserve every start-turn admission under the same lock.
| return waitDecision(limits.global, "all hosts"); | ||
| } | ||
|
|
||
| // Null whenever the environment is not chosen yet, which is the normal |
There was a problem hiding this comment.
🚨 slopcop/review — Cold starts bypass the per-host limit.
New managed threads have no environment yet, so context.host is null. Their start context already names a host.
Pass the intended host into the hook.
| * `thread-busy`, so among themselves they keep strict FIFO order, which is | ||
| * what makes today's queue behaviour unchanged. | ||
| */ | ||
| function drainableQueuedThreadMessage() { |
There was a problem hiding this comment.
🚨 slopcop/review — Failed rows can retry forever.
This query treats a null wait as eligible but ignores failureReason. A failed due row enters this claim every sweep.
Exclude failed rows until a user action clears the failure.
| const queued = db.transaction( | ||
| (tx) => { | ||
| const now = Date.now(); | ||
| for (const claim of args.claims) { |
There was a problem hiding this comment.
🚨 slopcop/review — A requeue can split a message group.
The release clears every claimed row, but the new wait updates only the lead. A later sweep can dispatch the tail alone.
Store one atomic wait state for the complete group.
| }); | ||
| const chain = retryChain(failed.request); | ||
| const originalRequestId = chain.originalRequestId; | ||
| if (hasQueuedRetryFor(deps, { threadId: thread.id, originalRequestId })) { |
There was a problem hiding this comment.
🚨 slopcop/review — Retry deduplication has a race.
This read and the later insert are separate. Two clients can queue the same failed turn twice.
Add a partial unique index. Insert atomically and map conflicts to retry_already_queued.
| const attempt = chain.attemptNumber + 1; | ||
| const outcome = await attemptDispatch(deps, { | ||
| thread, | ||
| payload: { |
There was a problem hiding this comment.
🚨 slopcop/review — A retry does not preserve the original request.
The retry copies input only. It drops inputGroups and resolves current execution defaults.
Copy all saved prompt groups and execution fields from the original request.
| } | ||
|
|
||
| await applySettings(); | ||
| settings.onChange(() => { |
There was a problem hiding this comment.
🚨 slopcop/review — A limit change does not release blocked work.
Changing zero to unlimited updates settings only. No thread can finish, so no lifecycle event requests a recheck.
Call recheck after the settings take effect.
| */ | ||
| const admitted: { value: PendingThreadAdmission | null } = { value: null }; | ||
|
|
||
| if (!sendNow && hasMessageDispatchHooks()) { |
There was a problem hiding this comment.
🚨 slopcop/review — Send now bypasses reject hooks.
The !sendNow condition skips the full hook chain. A content-policy plugin cannot reject this dispatch.
Run the chain and bypass only wait results.
| export async function runRequestedQueueDrain( | ||
| deps: QueueDrainDeps, | ||
| ): Promise<void> { | ||
| for (const row of listQueuedThreadMessagesWithPluginWait(deps.db)) { |
There was a problem hiding this comment.
🚨 slopcop/review — One free slot can scan the complete plugin queue.
Each capacity event rechecks every held row. With one slot per event, N queued rows need about N² hook calls.
Use scoped rechecks or stop when capacity becomes full.
| db: DbQueryConnection, | ||
| ): QueuedThreadMessageRow[] { | ||
| return db | ||
| .select() |
There was a problem hiding this comment.
🚨 slopcop/review — The orphan sweep loads complete prompts every ten seconds.
This list returns full rows for every plugin wait. The sweep needs only identifiers and the holder.
Add a narrow query for those columns.
| onValidationError: (msg) => new ApiError(400, "invalid_request", msg), | ||
| }); | ||
|
|
||
| get(publicApiRoutes.queue.list, (context, query) => { |
There was a problem hiding this comment.
🚨 slopcop/review — Global queue reads are unbounded.
This route returns every complete queued prompt. A large workspace can create a large database read and response.
Add cursor pagination, a limit, and a payloadKind filter.
| } | ||
| if (payload === null) return; | ||
| const delivered = payload; | ||
| for (const [id, plugin] of loaded) { |
There was a problem hiding this comment.
🚨 slopcop/review — Plugin event work is unbounded.
Each event starts another asynchronous handler. A slow handler can retain an unbounded number of complete event entries.
Use a bounded per-plugin queue with timeout and overflow rules.
| @@ -0,0 +1,188 @@ | |||
| // bb-plugin-concurrency-limit — admission control for thread dispatches. | |||
There was a problem hiding this comment.
🚨 slopcop/review — This file violates the no-comment rule.
The change adds thousands of non-directive code-comment lines. Some comments already describe behavior that the implementation does not provide.
Remove non-directive comments. Keep durable rationale in tests and documentation.
There was a problem hiding this comment.
🚨 SLOP COP 🚨 · review
Plain-English summary
This PR puts every message through one dispatch checkpoint. It adds scheduled sends, queue waits, retries, concurrency limits, and related user tools.
Review result
I found ten high-severity defects and five medium-severity defects. I posted one line comment for each defect.
I used a comment-only review. I did not approve the pull request or request changes.
High severity
- The migration deletes saved deferred notices without a data transfer.
- A host reconnect does not release
host-offlinemessages. - Concurrent warm starts can exceed the configured thread limit.
- Cold starts can bypass the per-host limit.
- Failed queue rows can enter each automatic drain again.
- A requeue can split a grouped prompt.
- Two clients can queue the same retry.
- A retry can lose prompt groups and original execution values.
- A limit change can leave existing work blocked.
- Send now can bypass a plugin reject decision.
Medium severity
- Each capacity event can recheck the complete plugin queue.
- The orphan sweep loads full prompt bodies every ten seconds.
- The global queue route has no result limit or pagination.
- Plugin event handlers have no concurrency or memory bound.
- The change adds many non-directive comments, contrary to the repository rule.
Architecture
The single dispatch checkpoint removes several separate wait paths. This is a useful consolidation.
The new recheck and orphan paths still use broad full-row scans. Narrow queries and scoped drain requests will give the queue a clearer contract.
Verification
- Three GPT-5.6 workers reviewed security, quality, architecture, and performance.
- A final GPT-5.6 gate checked every candidate against the exact 282-file diff.
- Focused runs passed 25 concurrency tests, 13 provider-retry tests, 10 schedule-time tests, and 43 database tests.
- Some larger test runs did not complete because the build host reported file-access delays and timeouts.
- The source CLI created a scheduled thread with
pendingstatus and one queued message. - Doobie showed the prompt, schedule time, live countdown, Send now, Edit, and Delete actions.
- Delete removed the queue card. I deleted the local QA thread after the test.
- The browser reported no application error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dispatch_holds table (migration 0109) with CAS-safe data module and partial live-row indexes; domain module for hold kinds, holder identity (user | plugin:<id> | core:<mechanism>), release kinds, inline/retry payloads, and report updates; system/dispatch-hold thread event reusing the provisioning transcript entry schema; 'plugin' added to callerExecutionInputSourceValues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ps, core adoption holdUntil on create/send requests with a held delivery outcome; hold service appending/broadcasting system/dispatch-hold events; release and cancel with exactly-once dispatch via the release CAS; due-timer and orphaned-plugin sweeps under durable-intent-retry; derived held display status and liveDispatchHoldCount on ThreadResponse; hold routes (list/get/release/cancel/update). Held creation persists the cold-start context on the hold row so scheduled spawns survive restarts. Core wait paths converge onto the substrate: reprovision-parked turns become core:reprovision tracking holds (dispatch timing unchanged) and background releases that hit an offline host re-park as core:host-offline, released on daemon socket open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
threads.holds list/get/release/cancel/update and holdUntil on spawn/send in the SDK; bb thread holds / release / cancel-hold and --hold-until on spawn and tell with duration or full-timestamp parsing (bare dates rejected: Date.parse reads them as UTC midnight); guide template and bb-cli skill updated in the same change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
system/dispatch-hold timeline row (collapsed per hold like provisioning, reason + transcript lines); combined pending region with held-dispatch cards above the queued stack (countdown, stale tint, Release now / Cancel / inline edit); held banner with cancel-to-draft and delete-thread offer on never-started threads; thread-list clock badge via the derived held status; live refresh over the existing queue-changed stream. Mechanical held/dispatch-hold arms in mobile to keep exhaustive switches compiling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Send later… in the thread composer plus-menu: presets and freeform time in a plugin composer banner, submitted through a plugin RPC that sends the draft with holdUntil so core owns all hold UI afterward. Registered as a builtin (default disabled). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bb.experimental_dispatch.gate/release/report with typed per-stage contexts and decisions (proceed-with-amendments / hold / reject) and dispatch.held/released/cancelled observe events; ordered runner (install order with a dispatchGateOrder app-setting override) under one server-wide lock with a 10s decision box — throw, timeout, or invalid amendment fails the operation naming the plugin; amendments accumulate, reject short-circuits, holds collect across a full pass so execution is final before parking. Wired at thread.create (409 dispatch_rejected), turn.submit (inline + queue drain via the single gated path), and hold release re-evaluation (user release skips the owning gate; re-holds paced per thread). Plugin-amended fields carry plugin provenance and are never remembered as project defaults; original input recorded on the turn event when rewritten. pluginInputs (8KB cap) flows request -> queued row -> gate context. Grouped GET /threads/count. Plugin SDK 0.4.18 with api_to_audit.md entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
threads.count() over the grouped count route and pluginInputs on
spawn/send in the SDK; bb thread count with grouped table output,
repeatable --plugin-input <pluginId>=<json> (later flag wins per id,
omitted entirely when unused), and --provider auto:<pluginId>[:entryId]
mapping to an omitted provider plus pluginInputs[pluginId] = { entry }
- the convention router gates read. Guide template and bb-cli skill
updated in the same change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gates thread.create and turn.submit against global, per-host, and per-provider caps (string settings, empty = unlimited) with child threads exempt by default - the workflows parent/child deadlock documented in the plan. Tally = seeded threads.count baseline + event-observed occupancy + 30s in-flight proceeds, reconciled every 60s so missed events self-correct. Frees release the oldest live hold for the freed scope; core re-runs the gate on release so an unwarranted release safely re-holds. Optional CPU/RAM thresholds via the plugin's own host worker, sampled on server poll and read only from cache so the gate never awaits I/O. Registered builtin, default disabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
system/dispatch-hold, system/queue-state, and system/plugin-note are deleted outright - emission never existed on main, so no production database can contain them; dev threads holding them 500 until wiped. The parked stored spelling dies with them. threadEventSchema narrows on the daemon event batch, folded into the flattened single bump at 175 (main+1); the four stacked version blocks collapse to one and a pre-existing failing version pin is fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ctly The composer banner duplicated the queue row's narration with the same Cancel; it goes end to end - app entry, banner component, stories, and its RPC pair, whose only consumer the banner was (the CLI already read public queue surfaces). provider-retry is server-only again. The queue drawer's height estimate treated every row as one line; rows with a wait or failure line clipped 14px under the scroll fade at rest. The estimate now counts two-line rows with browser-measured constants (including 2px of fade sentinels inside the scroll box) and a regression test pins 88px plain vs 104px waiting - the wait line is fully visible at rest, proven in Ladle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ds.retry
bb.experimental_hooks.on('message.dispatch') replaces the staged
dispatch.gate - flat namespace mirroring bb.events, one typed key map,
one taxonomy documented on both: events are announcements whose returns
are ignored; hooks are questions whose answers core acts on. The wait
verdict's time field is sendAt, the one scheduling word. Queue events
rename to the subject.state grammar: message.queued and
message.dispatched, pairing with the hook key by tense.
turn.failed becomes an honest bb.events announcement carrying ids and
failure facts, and retrying becomes data: sdk.threads.retry routes a
by-reference row through the ordinary dispatch attempt (one live retry
per turn, attempt numbers from the chain, reason persisted on the row -
migration 0111), with POST /threads/:id/retry and bb thread retry
[--turn] [--send-at] for humans. The turn-failed pseudo-gate, its
inverted fail-closed machinery, the stage map, and the per-stage
generics are deleted; provider-retry is an event listener calling
threads.retry with the same math.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The retry_reason migration folds into the single branch migration (0110_daffy_shadowcat.sql), deleting two stacked snapshots. The deferred_thread_messages drop and all queue columns verified present; db migration replay and server suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Core owns re-draining and the clock; plugins own every other wait condition and tell core when to re-ask. bb.experimental_hooks .requestDrain() resolves when the walk is scheduled - resolving on completion could deadlock a caller holding the evaluation lock - and the walk keeps queue order, claim CAS, pacing, and coalescing. The freed-capacity signal and its lifecycle fanout are deleted; the concurrency limiter wakes core from its own four-event listener. Also fixes a real ordering bug the new test exposed: the cross-thread wait queries sorted by random nanoid ids, so 'queue order' was a lie; they now order by createdAt, sortKey, id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pair to on, in the model's own vocabulary: on answers the question core asks; recheck asks core to pose it again - to every hook, about every waiting message. No arguments by design: re-asks are idempotent (whoever still objects re-queues the row), which is what makes an over-broad recheck safe; a scoped overload stays additive if a consumer ever measures the need. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
on and recheck operate on the same keyed question: on answers it, recheck(hook) re-poses it. One legal value today; the union widens additively when a second hook ships, instead of breaking the call signature then. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…omments Land the dispatch-queue rework on main's newer trunk: - HOST_DAEMON_PROTOCOL_VERSION resolves to 176 (main's 175 + 1) with the branch narration reduced to the one thing a daemon observes: the three branch-only `system/*` event types leaving `threadEventSchema`. Main deleted the historical narration block, so the file keeps main's shape. - @get-bb/plugin-sdk bumped 0.4.30 -> 0.4.31 via scripts/bump-plugin-sdk.mjs (main had moved past the branch's stale 0.4.33 lineage); bundled types, sdk-public-api.json and the plugin registry snapshots regenerated. - thread-create uses main's resolveManagedBaseBranchForCreate shape; #2616 removed resolveManagedNamedBaseBranchSpec and the originKind arm. - Composer queue UI adopts main's "follow-up" wording (#2695) while keeping the branch's queue rows, wait lines and visuals. - The bb-cli and bb-plugin-authoring skills were restructured on main into references/; the branch's CLI surface (`--send-at`, queued delivery, `bb thread count`, `bb thread retry`) and plugin surface (`bb.experimental_hooks`, `message.queued`/`message.dispatched`/ `turn.failed`, the new SDK type exports) are documented there instead. - apps/app comments removed to satisfy main's `bb/no-comments` rule (#2624). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reverts #2695's follow-ups copy on the queue card - this branch made the queue a real concept (typed waits, scheduling, retries), so its chrome says Queue / queued message N again. The follow-up composer vocabulary, which predates the rename and names typing a subsequent message rather than a queued row, is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Main's outline-reads speedup took 0110; ours regenerates as 0111_known_morph.sql with the same contents. SDK inventory rebuilt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urns A failed turn has two shapes core previously conflated. An input the provider ACCEPTED before failing is already in its conversation — no provider rolls an errored turn back — so re-sending the original blocks asked the same question twice in a row. An input the provider never took died at the door, and its typed rejection code (rate_limited, auth_required) reached the server as the command errorCode only to be buried in event text nothing classified, leaving door-rejected rate limits invisible to every retry policy. The retry now decides what to send by the turn's own acceptance record: never-accepted inputs are re-sent verbatim as before, accepted turns are continued with an agent-only "Please continue." — the semantics the old provider-retry plugin's input-not-accepted gate enforced before the rewrite dropped it. turn.failed carries the acceptance fact as inputAccepted, and buildTurnFailedEvent falls back to the typed code on the client/turn/rejected row when no provider/error classified the failure, so a door-rejected rate limit is now the same rate-limit category a mid-stream one reports. Server-side only: the runtime already surfaced the typed code as the command errorCode, so no daemon or wire change is involved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Host-offline rows were stranded forever: the wait is written with no sendAt, the due sweep selects only scheduled rows, and the reconnect handler no-oped behind a comment claiming a sweep covered it. The daemon socket opening now drains that host's host-offline waits — the release signal the wait never had. Migration 0111 dropped deferred_thread_messages outright, deleting any messages held behind a pending interaction at upgrade time. It now copies the rows into a standalone legacy table (idempotently, so replayed chains survive), and a startup backfill delivers them through today's dispatch checkpoint — full execution resolution, correct typed waits — dropping the table once it is empty. The old migration's hash is registered as compatible so databases that already ran the DROP keep booting. Grouped rows could split: a requeue writes the new wait on the lead only, and both claim paths computed groups over an eligibility-filtered list whose holes let a tail dispatch alone (or staple an unrelated row on). Groups are now partitioned over all live rows; the idle drain takes the first fully-eligible group, and claiming a head claims its batch. A retry now replays the failed attempt's execution tuple and input groups instead of silently adopting a changed thread override, without letting the replayed model rewrite the user's sticky override on either dispatch path, and an in-process guard closes the check-then-insert race that let two concurrent retries queue the same turn. A cold start's per-host pool was invisible: context.host was null before an environment existed and listRunning reported no host for starting threads. Both now resolve the machine from the thread's start intent. The plugin-wait walkers ship id/thread/holder projections instead of full prompt bodies every ten seconds, and the comment audit's confirmed falsehoods — the reconnect claim, "only caller", the wait-holder and countThreads rationales, the pending-context write timing, the retry-cap off-by-one, the provider-retry CLI mechanism, scheduled-send's stale hold vocabulary, and the SDK's phantom listRunning fields — now say what the code does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dedup gaps A first message that lost its admission race could vanish while its sender was told it went: a drain's claimed row was consumed before the pending → preparing CAS, and a lost CAS still returned "dispatched" with nothing sent. Consumption and the flip now share one transaction, so a lost flip rolls the consumption back, and a lost admission re-decides the message against the thread as it is now — queued behind the winner or refused for a thread that is gone. A start context consumed by the winner counts as the same loss rather than an internal error. Retry deduplication read only unclaimed rows through a full payload scan; a retry claimed by the due sweep and sitting in a hook pass was invisible to a second retry of the same turn. One targeted query on the retry column, claimed rows included, replaces it. Persisted turn requests now refuse a retry marker carrying one of its two keys, at the stored-event boundary where the discriminated unions the base schema feeds cannot carry the refinement. The experimental_submit audit entry drops its pre-queue hold vocabulary, the PR body states listRunning's warm-follow-up boundary and the retry's acceptance split, and the legacy backfill says why it is at-least-once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2f4c24d to
04f46c6
Compare
## Human comments ## What was wrong The durable queue stored prompt grouping separately from per-row wait eligibility. Scheduled and plugin-requested sweeps enumerated eligible row references and then claimed by one row ID. When a grouped prompt was postponed, only its lead row received the new wait, so a later row could retain an earlier deadline; after the pacing window expired, a later sweep could claim that follower alone, sever the group, and send an incomplete prompt. This follows up on [the unified dispatch queue landing](#2779). ## What changed Queue persistence now accepts a whole-group eligibility check and evaluates it inside the same immediate transaction that claims the rows. Automatic scheduled and plugin-requested drains claim the complete group even when a sweep begins from a follower, and requeueing applies the same wait state to every claimed group member. Explicit Send now behavior remains unchanged. There are no wire, schema, CLI, SDK, guide, or documentation contract changes, so no host-daemon protocol bump is needed. ## How you verified The new production service/DB regression failed before the fix by recording a turn containing only `tail` while leaving `lead` queued, and passes after the fix. - `pnpm exec turbo run test --filter=@bb/server -- --run test/threads/requested-queue-drain.test.ts` — 6 passed - `pnpm exec turbo run test --filter=@bb/db -- --run test/data/queued-thread-messages.test.ts` — 30 passed - `pnpm exec turbo run typecheck --filter=@bb/server` - `pnpm exec turbo run typecheck --filter=@bb/db` - `pnpm exec turbo run build --filter=@bb/server` > AGENT GENERATED
## Human comments ## What was wrong The durable queue introduced in #2779 records a background dispatch failure on the queued row. Automatic selectors did not exclude that failed state, so later sweeps could keep running plugin hooks and dispatch preparation without an explicit retry. There was also a grouped-message race: even after filtering failed rows from the selectors, a clean member could enter the atomic claim and pull a failed member from the same group back into an automatic attempt. ## What changed The shared database selectors exclude terminally failed rows from automatic drains. The atomic grouped-claim boundary now also requires every group member to have no failure whenever an automatic eligibility callback is present. This closes both the clean-member bypass and a failure recorded after selection. Explicit Send now claims without that automatic callback, so users can still retry failed messages deliberately. Failed rows remain visible through the API and UI. Normal temporary conditions such as a busy thread, future schedule, provisioning, pending interaction, plugin wait, disconnected host, and host-command timeout do not enter the failed state and still recover automatically. There are no schema, migration, host-daemon protocol, CLI, configuration, SDK, or documentation changes. ## How you verified - Added a production database/server sweep regression that failed before the fix with three hook attempts instead of one, then passed after the fix while proving the failed row remained explicitly claimable by ID. - Added grouped scheduled and requested-plugin service regressions. Before the transactional guard, each incorrectly invoked the dispatch hook once; afterward, both leave the two-row group untouched with zero attempts. - `pnpm exec turbo run test typecheck build --filter=@bb/db --filter=@bb/server --force` on the rebased tree — DB 442 tests passed, server 2124 tests passed, and typechecks/builds passed. Fixes #2779 > AGENT GENERATED
Human comments
What was wrong
bb had no way to put anything between "a user asked for work" and "bb runs it": no scheduled sends, no concurrency control, and four parallel single-purpose parking mechanisms (
dispatch_holdsnever existed on main, butdeferred_thread_messages, the reprovision-parked turn, and the drain-only queue each solved one blocked-message case with its own machinery). Rate-limit retries were faked byplugins/provider-retryre-reading the whole event log (~450 lines) and injecting a synthetic "Please continue." message, so the provider never saw the same conversation twice. There was no plugin surface that could defer, refuse, or reschedule a dispatch.What changed
The model: a send is always a dispatch attempt. If nothing blocks it, it dispatches directly — the happy path is byte-for-byte unchanged and allocates nothing (test-guarded: a stock install registers zero hooks). If something blocks it, the message queues as a row carrying a typed
waitingOn(time|thread-busy|provisioning|interaction|host-offline|plugin), and core re-attempts when conditions change (due sweep, thread-idle drain, workspace-ready, interaction-settled, pluginrecheck, Send-now, orphan sweep).deferred_thread_messagesis deleted; its cases are queue rows. Threads gain a canonicalpendingstatus (created, first message never dispatched) — scheduled spawns do zero provisioning until due.Plugin API (all
experimental_):bb.experimental_hooks.on("message.dispatch", h)— one checkpoint, run identically for fresh sends, drained rows, and steers, returningproceed | wait(reason, sendAt?) | reject(message); fail-closed (10s box, plugin named).bb.experimental_hooks.recheck(hook)asks core to re-pose the question. Newbb.events:message.queued,message.dispatched,turn.failed(ids + failure facts). Public API:sendAton send/create,threads.retry({turnRequestId?, sendAt?})(re-dispatch decided by the provider's acceptance record: an input the provider never accepted is re-sent verbatim, an accepted turn is continued with an agent-only nudge; the original execution tuple is replayed and no user message is duplicated),threads.listRunning()(exact for cold admissions inside a hook — apending → startingflip commits before the evaluation lock releases; a warm follow-up'sidle → activeflip lands in the send transaction just after it, so a burst of follow-ups to distinct idle threads can briefly under-report),threads.count().Plugins: new
scheduled-send("Send later…" via the composer's own submit pipeline + dialog; off by default) andconcurrency-limit(global + per-host caps as one pure hook overlistRunning; off by default);provider-retryrewritten to aturn.failedlistener callingthreads.retry—recovery.tsand the synthetic continue are deleted (plugin: 1,427 → ~300 lines).UI/CLI: queued rows render their wait (kind icon, reason, live countdown, retry attempt, failure reason) with Send-now/Edit/Cancel; sidebar shows a clock for threads with waiting work and the error glyph when a queued dispatch failed;
bb thread queuegains wait columns; new--send-aton spawn/tell,bb thread count,bb thread retry; guide + skills updated alongside.Wire:
HOST_DAEMON_PROTOCOL_VERSION→ 176 (thread-event schema changes ride the daemon event batch). One drizzle migration (0110) on main's chain: queue wait columns + partial indexes,threads.pending_start_context, and thedeferred_thread_messagesdrop. Plugin SDK 0.4.31 withdocs/api_to_audit.mdentries for every newexperimental_member.How you verified
Repo-wide
turbo run typecheck(82/82) andturbo run lint --filter=@bb/app(0 errors) at every layer; final battery across 15 packages (~7,900 tests: server 2094, app 453 files, cli, db incl. migration-replay +EXPLAIN QUERY PLANindex pins, domain, contracts, plugin-sdk, thread-view, client-core, three plugins, templates, plugin-api-map api-sync). Exit-criteria tests:--send-atsurvives restarts; exactly-once dispatch under racing drains; orphaned plugin waits clear; retries re-issue the original turn with no duplicated message; hook exactness (attempt N+1'slistRunningsees attempt N's admission — load-bearing test fails if the commit moves outside the lock); zero-hook installs byte-identical. Integration suite: failures on the loaded build machine were verified as environment flakes — the same suite on a clean origin/main worktree failed 4× more files under identical load; all failing files pass in isolation.