Skip to content

Move provider retry policy into the provider-retry plugin - #1623

Merged
ymichael merged 7 commits into
mainfrom
bb/investigate-provider-rate-limit-failure-thr_ck4myt6kaw
Aug 18, 2026
Merged

Move provider retry policy into the provider-retry plugin#1623
ymichael merged 7 commits into
mainfrom
bb/investigate-provider-rate-limit-failure-thr_ck4myt6kaw

Conversation

@ymichael

@ymichael ymichael commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • keep provider rate-limit detection, retry classification, timing, scheduling, cancellation, continuation wording, and manual recovery inside the provider-retry plugin
  • remove the rate-limit-specific server recovery service, HTTP routes, public SDK methods, and core bb thread retry command
  • use ordinary SDK surfaces to read the thread, environment, and relevant event history, then send an ordinary hidden continuation turn
  • extend the existing generic threads.events.list query with typed event filters, newest-first ordering, and an exclusive backward cursor
  • keep filtered event reads on the existing (thread_id, type, sequence) index with bounded per-type reads and a global ordered merge
  • prevent late Claude background, sidechain, and bridge-error drain events from manufacturing an unaccepted provider-only turn after a terminal failure

Ownership boundary

The server has no provider-rate-limit recovery policy and exposes no special failed-turn continuation API. It continues to own the ordinary thread/event/send contracts that every SDK consumer can use.

The provider-retry plugin reconstructs the latest accepted failed request from the public event stream, classifies normalized provider events, preserves the original execution settings, schedules automatic retries, and exposes bb provider-retry retry. Before release it re-inspects the event history and then uses the existing threads.send({ mode: "start" }) API with an agent-only Please continue. turn.

The plugin now fetches the latest request directly, searches only rate-limit observations for the current provider state, and pages forward through the six relevant lifecycle/provider types beginning at that request. Ordinary failures therefore do not read prior request history. Failure classification uses one-pass request/acceptance/completion indexes instead of rescanning the event array for every request.

The generic event reader handles multi-type filters as one bounded index-backed read per type, then merges those already-sorted pages to preserve global asc/desc, cursor, and limit semantics.

The Claude provider plugin owns the provider translation fix. After a terminal failure it suppresses provider-only turn starts while late background or bridge events drain, and clears that suppression when a new accepted request begins.

Contract and data changes

  • no recovery-specific plugin API
  • extends the existing generic threads.events.list SDK/server contract with beforeSeq, order, and typed types
  • preserves the existing default ascending behavior and afterSeq pagination
  • keeps @get-bb/plugin-sdk at 0.4.8 and regenerates its bundled declarations/templates
  • no database schema, migration, or persisted-data change; filtering and ordering use the existing event indexes
  • removes the old provider-specific server/SDK/CLI recovery contract
  • bumps the host-daemon protocol because Claude event translation semantics changed across that boundary

Root cause

The retry plugin correctly scheduled the original accepted rate-limited failure. While Claude background output drained, the Claude translator opened a synthetic turn with no client request or accepted input. That transition cancelled the scheduled retry and made recovery observe the wrong turn.

Validation

  • reproduced the original accepted-request -> terminal 429 -> late background-drain sequence on a new isolated dev-server thread; the reported thread was not modified
  • rebased onto origin/main (bf3d67ae5); PR head is 2fbf9fdf3
  • post-rebase Turbo typechecks passed for all eight affected packages: @bb/domain, @bb/server-contract, @bb/sdk, @bb/db, @get-bb/plugin-sdk, @bb/templates, @bb/server, and bb-plugin-provider-retry
  • post-rebase targeted tests passed (663 total): database 391/391, plugin SDK 99/99, SDK 91/91, server contract 57/57, and provider-retry 25/25
  • the npm package-content/version guard passes with @get-bb/plugin-sdk at 0.4.8 because that version has not been published
  • full GitHub CI passed: checks/lint, packages, server, integration, app, and Linux/macOS package smoke
  • git diff --check passed

AGENT GENERATED: by GPT-5

@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

I am the SlopCop. I am reviewing this pull request now.

I will check security, code quality, performance, architecture, duplication, and the relevant end-to-end path. I will post one final review with the results.

Comment thread packages/agent-runtime/src/claude-code/translate-message.ts Outdated
Comment thread plugins/provider-retry/server.ts Outdated

@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 summary: Claude can hit a usage limit while a background agent still sends final events. Those events could hide the failed user request and cancel its scheduled retry. This change keeps recovery tied to the latest accepted user turn. It also suppresses most false turns from late Claude events.

I found two issues:

  1. P2 — Active and idle events inspect every thread. The retry plugin now calls the recovery SDK on every normal transition. This adds two loopback calls and several synchronous SQLite reads to most turns. The active and idle handlers should inspect only threads that already have a retry entry.

  2. P2 — Bridge errors bypass the new suppression rule. The rule covers SDK messages, but the bridge-error envelope uses another path. A failed result followed by a stream error can still create a false, unaccepted turn and duplicate error output. The common Claude error path should honor the same suppression state.

I found no security issue. The retry checks remain scoped to one thread and require the exact accepted request before reuse.

I also checked architecture and duplication. The database helper has no existing equivalent. Its location in @bb/db is correct. I found no stale names or duplicate recovery policy.

Validation passed for the changed paths:

  • Claude adapter: 152 tests.
  • Server recovery: 15 tests.
  • Provider retry plugin: 21 tests.
  • Host daemon contract: 50 tests.
  • Type checks: all four affected packages.
  • GitHub CI: all required checks pass.

I did not run a browser test. This path needs a real Claude subscription limit and has no browser-only route.

@ymichael
ymichael force-pushed the bb/investigate-provider-rate-limit-failure-thr_ck4myt6kaw branch 2 times, most recently from afe4a63 to 210c4e9 Compare August 17, 2026 23:14
@ymichael ymichael changed the title Preserve provider retries during Claude background drain Move provider retry policy into the provider-retry plugin Aug 17, 2026
@ymichael

Copy link
Copy Markdown
Collaborator Author

Redesign update: provider-specific retry recovery has been removed from the server, public SDK, and core CLI. The plugin now owns classification, scheduling, timing, cancellation, continuation text, and its manual retry command. Core exposes only the provider-neutral in-process experimental_failedTurnContinuation lifecycle primitive.

This also eliminates the review's loopback-call concern. The Claude bridge-error/background-drain suppression remains in place and is covered by the latest tests. The branch has been rebased onto current origin/main; the PR body now describes the new ownership boundary and live dev verification.

@ymichael
ymichael force-pushed the bb/investigate-provider-rate-limit-failure-thr_ck4myt6kaw branch 4 times, most recently from 5bb74e3 to 10990e5 Compare August 18, 2026 07:09
@ymichael

Copy link
Copy Markdown
Collaborator Author

Correction to my earlier redesign note: the experimental_failedTurnContinuation API has now been removed as well. The final branch adds no plugin-only core API.

provider-retry reconstructs eligibility from the existing thread event stream and starts the hidden continuation through the ordinary threads.send SDK method. The server-specific recovery service, routes, SDK methods, and bb thread retry command are deleted. Claude terminal-drain suppression remains provider-local.

The branch is rebased onto current origin/main, the PR body describes the final boundary, and the affected typecheck/test suites pass locally.

@ymichael
ymichael force-pushed the bb/investigate-provider-rate-limit-failure-thr_ck4myt6kaw branch from 10990e5 to 5266130 Compare August 18, 2026 16:52
@ymichael

ymichael commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

Improved the generic threads.events.list surface and rebased the PR onto current origin/main.

The API now supports an exclusive beforeSeq cursor, order: "asc" | "desc", and a typed non-empty types filter while preserving existing defaults. Provider retry uses those generic controls to request only its six relevant lifecycle/provider event types newest-first, page backward, and stop after it has the latest request plus current-provider rate-limit state. It then restores ascending order for the existing classifier.

There is no recovery-specific API, database migration, or new index. The published plugin SDK declarations were regenerated and the SDK version is now 0.4.9.

Validation on rebased commit a3ca11ddb: 8/8 affected typecheck tasks and 9/9 affected test tasks passed (2,413 tests total, including all 1,721 server tests and 23 provider-retry tests).

@ymichael
ymichael force-pushed the bb/investigate-provider-rate-limit-failure-thr_ck4myt6kaw branch from 5266130 to a3ca11d Compare August 18, 2026 17:02

@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

I am the SlopCop. I am reviewing this pull request now.

I will check security, code quality, performance, architecture, duplicate code, and the end-to-end path. I will post one final review.

threadId: string,
candidate: RecoveryCandidate,
): Promise<void> {
await this.bb.sdk.threads.send({

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 — P1: Keep the retry check and append atomic

inspectProviderRetry() checks failed request A. This normal send has no expected request or event sequence.

A user can finish request B between these calls. The thread becomes idle, so this code starts a stale Please continue. turn.

The normal send also records a user action. It can ask for permission, change prompt history, and replace a newer sticky model.

The removed server path repeated the candidate check inside one immediate transaction. Add a generic conditional system send with the same atomic check.

The server must keep the system initiator and must not change the user's current settings.

// 0.x releases and is intentionally vacuous for them until a future 1.0.
// Rebuildable artifacts still rebuild on the exact sdkVersion-differs trigger.
export const PLUGIN_SDK_VERSION = "0.4.8";
export const PLUGIN_SDK_VERSION = "0.4.9";

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 — P1: Do not remove public methods in a compatible SDK release

This patch removes rateLimitRecovery and continueAfterRateLimit from the public plugin SDK. It changes version 0.4.8 to 0.4.9.

The contract above says all 0.x minor and patch releases are compatible. An installed 0.4.8 plugin still loads, then gets a runtime TypeError.

Keep deprecated compatibility methods until a compatibility-major release. Otherwise, make a real major transition with a migration path.

Comment thread packages/db/src/data/events.ts Outdated
Comment thread plugins/provider-retry/src/recovery.ts Outdated

@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 summary: This change moves provider retry decisions from the main server into the Provider retry plugin.

The plugin now reads normal thread events and sends a hidden continuation turn after a provider limit. Claude also ignores late events after failure.

I found four P1 issues:

  1. The retry check and send are not atomic. A newer user turn can finish between them, then the stale retry starts unwanted work.
  2. The patch removes two public plugin SDK methods in a compatible 0.4.9 release. Existing 0.4.8 plugins can load and fail at runtime.
  3. SQLite selects the full thread sequence index for the new filtered reverse query. A sparse 300,000-row check took 861 ms.
  4. A failure without rate-limit history reads the complete matching history. The classifier then does quadratic work across prior requests.

The first issue also changes authority. The retry now looks like user work, can request permission, and can replace a newer sticky model.

I found no separate data exposure issue. I also found no duplicate recovery policy or stale recovery name.

The policy belongs in the plugin. The server still needs a generic atomic system-send operation, not provider-specific retry policy.

Validation:

  • Provider retry passed 23 tests.
  • The Claude provider passed 262 tests.
  • The affected type checks passed.
  • All required GitHub checks pass.
  • The development server and browser smoke test passed.
  • The plugin detail page loaded without browser errors.
  • The scheduler ran, and bb provider-retry status --json returned an empty list.
  • The filtered descending event route and exclusive beforeSeq cursor worked on a real smoke thread.
  • A real subscription reset was not available, so I did not test that external event.
  • One local full database run had an unrelated migration test timeout while other tests used the host. GitHub and isolated database checks passed.

I left this as a comment review. I did not approve the pull request or request changes through GitHub.

@ymichael
ymichael force-pushed the bb/investigate-provider-rate-limit-failure-thr_ck4myt6kaw branch 2 times, most recently from ba1ef7b to d4e457c Compare August 18, 2026 18:32
@ymichael
ymichael force-pushed the bb/investigate-provider-rate-limit-failure-thr_ck4myt6kaw branch from d4e457c to 2fbf9fd Compare August 18, 2026 18:54
@ymichael
ymichael merged commit 1222451 into main Aug 18, 2026
10 checks passed
@ymichael
ymichael deleted the bb/investigate-provider-rate-limit-failure-thr_ck4myt6kaw branch August 18, 2026 19:02
ymichael added a commit that referenced this pull request Aug 19, 2026
Main commit 1222451 moved provider retry policy into the provider-retry
plugin (those parts merged cleanly) and taught the claude translator not to
manufacture an unaccepted provider-only turn from late SDK drain output
after a terminal failure. That translator fix lived in the deleted
event-translation.ts/task-translation callback; re-implemented in the delta
translator's turn mirror:

- ClaudeThreadDialectState.suppressUnacceptedTurnStart arms on a failed
  result boundary and clears when a turn actually opens (mirrorOpenTurn) or
  a real accepted input arrives (acceptInput), matching main's onTurnStart
  and emitCanonicalTurnInputAccepted clears.
- While suppressed (flag set, no open turn, no pending accepted input):
  conversation_reset, compacting status, assistant messages, stream events,
  and draining bridge errors translate to nothing; an api-retry notice stays
  a thread-scoped provider error; a hard rate-limit rejection surfaces its
  snapshot without opening a turn or arming the deferred rejection; and a
  task_started may not materialize a new task
  (TranslateClaudeTaskMessageArgs.turnStartSuppressed — the delta analogue
  of main's ensureTurnStarted returning undefined).

Both regression tests from the old event-translation suite are ported to
the delta harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ymichael added a commit that referenced this pull request Aug 19, 2026
Main commit 1222451 moved provider retry policy into the provider-retry
plugin (those parts merged cleanly) and taught the claude translator not to
manufacture an unaccepted provider-only turn from late SDK drain output
after a terminal failure. That translator fix lived in the deleted
event-translation.ts/task-translation callback; re-implemented in the delta
translator's turn mirror:

- ClaudeThreadDialectState.suppressUnacceptedTurnStart arms on a failed
  result boundary and clears when a turn actually opens (mirrorOpenTurn) or
  a real accepted input arrives (acceptInput), matching main's onTurnStart
  and emitCanonicalTurnInputAccepted clears.
- While suppressed (flag set, no open turn, no pending accepted input):
  conversation_reset, compacting status, assistant messages, stream events,
  and draining bridge errors translate to nothing; an api-retry notice stays
  a thread-scoped provider error; a hard rate-limit rejection surfaces its
  snapshot without opening a turn or arming the deferred rejection; and a
  task_started may not materialize a new task
  (TranslateClaudeTaskMessageArgs.turnStartSuppressed — the delta analogue
  of main's ensureTurnStarted returning undefined).

Both regression tests from the old event-translation suite are ported to
the delta harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ymichael added a commit that referenced this pull request Aug 19, 2026
Main commit 1222451 moved provider retry policy into the provider-retry
plugin (those parts merged cleanly) and taught the claude translator not to
manufacture an unaccepted provider-only turn from late SDK drain output
after a terminal failure. That translator fix lived in the deleted
event-translation.ts/task-translation callback; re-implemented in the delta
translator's turn mirror:

- ClaudeThreadDialectState.suppressUnacceptedTurnStart arms on a failed
  result boundary and clears when a turn actually opens (mirrorOpenTurn) or
  a real accepted input arrives (acceptInput), matching main's onTurnStart
  and emitCanonicalTurnInputAccepted clears.
- While suppressed (flag set, no open turn, no pending accepted input):
  conversation_reset, compacting status, assistant messages, stream events,
  and draining bridge errors translate to nothing; an api-retry notice stays
  a thread-scoped provider error; a hard rate-limit rejection surfaces its
  snapshot without opening a turn or arming the deferred rejection; and a
  task_started may not materialize a new task
  (TranslateClaudeTaskMessageArgs.turnStartSuppressed — the delta analogue
  of main's ensureTurnStarted returning undefined).

Both regression tests from the old event-translation suite are ported to
the delta harness.

Co-Authored-By: Claude Fable 5 <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.

1 participant