Skip to content

Dispatch queue rework: scheduled sends, concurrency limits, one plugin checkpoint - #2779

Merged
ymichael merged 69 commits into
mainfrom
bb/plan-grilling-skill-usage-thr_d5ej4e2gtv
Sep 1, 2026
Merged

Dispatch queue rework: scheduled sends, concurrency limits, one plugin checkpoint#2779
ymichael merged 69 commits into
mainfrom
bb/plan-grilling-skill-usage-thr_d5ej4e2gtv

Conversation

@ymichael

@ymichael ymichael commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

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_holds never existed on main, but deferred_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 by plugins/provider-retry re-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, plugin recheck, Send-now, orphan sweep). deferred_thread_messages is deleted; its cases are queue rows. Threads gain a canonical pending status (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, returning proceed | wait(reason, sendAt?) | reject(message); fail-closed (10s box, plugin named). bb.experimental_hooks.recheck(hook) asks core to re-pose the question. New bb.events: message.queued, message.dispatched, turn.failed (ids + failure facts). Public API: sendAt on 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 — a pending → starting flip commits before the evaluation lock releases; a warm follow-up's idle → active flip 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) and concurrency-limit (global + per-host caps as one pure hook over listRunning; off by default); provider-retry rewritten to a turn.failed listener calling threads.retryrecovery.ts and 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 queue gains wait columns; new --send-at on 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 the deferred_thread_messages drop. Plugin SDK 0.4.31 with docs/api_to_audit.md entries for every new experimental_ member.

How you verified

Repo-wide turbo run typecheck (82/82) and turbo 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 PLAN index pins, domain, contracts, plugin-sdk, thread-view, client-core, three plugins, templates, plugin-api-map api-sync). Exit-criteria tests: --send-at survives 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's listRunning sees 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.

AGENT GENERATED

@ymichael
ymichael force-pushed the bb/plan-grilling-skill-usage-thr_d5ej4e2gtv branch 2 times, most recently from 66dcccc to e5a5da7 Compare August 31, 2026 23:42
@ymichael

Copy link
Copy Markdown
Collaborator Author

@slopcop

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 SLOP COP 🚨 · review

I am the SlopCop. I started a security, code quality, performance, architecture, and end-to-end review.

@@ -0,0 +1,13 @@
DROP TABLE `deferred_thread_messages`;--> statement-breakpoint

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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.

Comment thread apps/server/src/ws/daemon-protocol.ts Outdated
daemonSessionId: args.sessionId,
hostId: args.hostId,
});
// A dispatch that could not reach an absent host is waiting on the queue like

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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.

Comment thread plugins/concurrency-limit/server.ts Outdated
return waitDecision(limits.global, "all hosts");
}

// Null whenever the environment is not chosen yet, which is the normal

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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 })) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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.

Comment thread plugins/concurrency-limit/server.ts Outdated
}

await applySettings();
settings.onChange(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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.

Comment thread plugins/concurrency-limit/server.ts Outdated
@@ -0,0 +1,188 @@
// bb-plugin-concurrency-limit — admission control for thread dispatches.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 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.

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

Copy link
Copy Markdown
Contributor

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 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-offline messages.
  • 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 pending status 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.

@ymichael

ymichael commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

@slopcop

ymichael and others added 9 commits September 1, 2026 08:47
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>
ymichael and others added 22 commits September 1, 2026 08:48
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>
@ymichael
ymichael force-pushed the bb/plan-grilling-skill-usage-thr_d5ej4e2gtv branch from 2f4c24d to 04f46c6 Compare September 1, 2026 15:51
@ymichael
ymichael merged commit 49dadc3 into main Sep 1, 2026
15 checks passed
@ymichael
ymichael deleted the bb/plan-grilling-skill-usage-thr_d5ej4e2gtv branch September 1, 2026 16:08
ymichael added a commit that referenced this pull request Sep 1, 2026
## 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
ymichael added a commit that referenced this pull request Sep 1, 2026
## 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
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.

1 participant