Skip to content

Add retry action for stopped unaccepted requests - #3030

Open
ymichael wants to merge 6 commits into
mainfrom
bb/fix-github-issue-2370-thr_c6vfueyv5g
Open

Add retry action for stopped unaccepted requests#3030
ymichael wants to merge 6 commits into
mainfrom
bb/fix-github-issue-2370-thr_c6vfueyv5g

Conversation

@ymichael

@ymichael ymichael commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Human comments

What was wrong

The broad busy-turn and pending-start message-loss paths from #2370 are already covered by #2379, #2816, #2884, and #2886. One recovery gap remained: if a provider never acknowledged or started the latest dispatched request, a manual Stop could release the orphaned runtime, but the visible original request had no supported way to run again. The existing retry endpoint only admitted failed threads, so recovery required retyping the request and could strand messages parked behind it. See the investigation report.

What changed

  • Treat the latest unresolved dispatched request as explicitly retryable only after a later manual Stop, while the thread is idle and writable, and only if the provider never accepted, started, rejected, or completed it. The startup watchdog remains diagnostic and does not affect eligibility.
  • Expose that request ID in ThreadResponse and add a destructive Retry request action below the original user message. The action reuses the existing retry path, including retry IDs/attempts and duplicate-delivery safeguards.
  • Preserve queued messages, IDs, groups, taxonomy, and arrival order; they remain parked until retry recovery permits delivery.
  • Document the recovery behavior in the CLI guide and SDK-facing contracts. Existing failed-turn retry behavior remains unchanged.
  • Add focused server, UI, and integration regressions, including a deterministic scripted provider that delays the opening turn without invoking a paid provider.

There is no database migration or new lifecycle event. The public plugin backend contract change bumps @get-bb/plugin-sdk from 0.4.43 to 0.4.44. HOST_DAEMON_PROTOCOL_VERSION remains 179 because this changes the server HTTP response and UI/SDK behavior, not any server/daemon payload, result, default, or meaning.

How you verified

  • Red/green regression coverage: the stopped-unaccepted retry cases and message action fail without the implementation and pass with it.
  • pnpm exec turbo run test --filter=@bb/server -- --run test/threads/turn-stopped-unaccepted-retry.test.ts test/threads/turn-failed-retry.test.ts — 20 tests passed.
  • pnpm exec turbo run test --filter=@bb/app -- --run src/components/thread/timeline/MessageActionBar.test.tsx src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx — 71 tests passed.
  • pnpm exec turbo run test --filter=@bb/integration-tests -- --run fake/recovery/stopped-unaccepted-request-recovery.test.ts — 1 test passed with in-memory SQLite.
  • pnpm exec turbo run typecheck --filter=@bb/app --filter=@bb/server --filter=@bb/server-contract --filter=@bb/sdk --filter=@bb/integration-tests --filter=bb-plugin-scripted-echo-provider --filter=@bb/cli — 12 tasks passed.
  • pnpm exec turbo run build --filter=@bb/app --filter=@bb/server --filter=@bb/cli — 7 tasks passed.
  • node .github/workflows/check-plugin-sdk-version.mjs and the npm version guard passed; 0.4.44 is unpublished and ready for the publish job.
  • pnpm exec turbo run test --filter=@get-bb/plugin-sdk -- --run src/__tests__/version.test.ts — 1 test passed.
  • pnpm exec turbo run test typecheck --filter=@bb/demo-server --force — demo contract fixture typecheck and 9 tests passed.
  • pnpm exec turbo run typecheck --force — all 86 workspace packages passed after auditing the remaining contract consumers.
  • Plugin Guide maintenance: regenerated sdk-public-api.json; combined SDK/Guide/app/docs tests and typechecks passed (including 3,852 app tests and the API sync test), and bb plugin build plugins/plugin-api-docs succeeded.
  • oxfmt --check on every changed file and git diff --check origin/main...HEAD passed.
  • Browser QA with the deterministic provider reproduced an opening request that never started, parked a follow-up, released the orphan runtime with Stop, and verified that Retry request delivered the original and parked follow-up once, in order, without falsely marking the thread failed.

Fixes #2370

@slopcop — tagged for visibility and review.

AGENT GENERATED

@bb-slop-cop

bb-slop-cop Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🚨 SLOP COP 🚨 · review

I’m reviewing this pull request now under the SlopCop guidelines. I’m checking security, code quality and architecture (including duplication/refactoring opportunities), performance, and the stopped-request retry flow end to end before posting the consolidated review.

): Promise<RetryTurnResponse> {
const { request, thread } = args;
const failed = requireFailedTurn(deps, {
const failed = requireRetryableTurn(deps, {

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/reviewP1: A stopped retry can become permanently stuck after a transient wait clears.

This newly admitted stopped-request path can reach attemptDispatch and create a retry row waiting on host-offline or provisioning. When recovery clears that wait to null, isOrdinaryTurnEndQueuedMessage classifies the retry as an ordinary row, so the still-active manual-stop pause prevents the automatic claim. That pause only clears after a later root turn starts, but this retry is the turn that must start it.

I reproduced this through the app: Retry created an immutable attempt-2 card on an offline host; after the host wait was cleared, the row remained queued with waitingOn: null and the original Retry action was gone. Please exempt retry payloads from the manual-stop pause (or atomically record explicit restart intent), and add a retry → transient wait → wait-clears regression.

if (
row.kind === "conversation" &&
row.role === "user" &&
row.initiator === "user" &&

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/reviewP1: The UI can attach Retry to the wrong message—or omit it—because it ignores the server’s request ID.

The server permits the latest stopped, unaccepted request regardless of initiator, but this helper selects only the last pending initiator === "user" row and never compares retryableStoppedTurnRequestId. If an agent-originated request is the stalled request, an older pending user bubble can receive the button even though clicking it retries the newer agent request; with no older user bubble, no action appears.

Please carry request identity/sequence onto timeline rows and match the DTO’s exact request ID, or deliberately restrict server eligibility to user-initiated requests and document/test that contract.

) {
return null;
}
const pending = loadFailedTurn(db, thread.id);

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/reviewP2: This adds an unbounded event-history scan to every ordinary idle-thread detail response.

toThreadResponseFromThread now calls this helper for every idle thread. loadFailedTurn uses the legacy-compatible “latest request” query; SQLite’s plan chooses events_thread_sequence_idx (thread_id=?) and scans backward from the thread tail until it finds a request. A long completed turn therefore pays O(events since request) just to conclude there is no stopped retry, on the server event loop.

Please make the cheap, type-indexed manual-stop check happen before loading the request, or consolidate this into one index-friendly request-aware query/persisted eligibility marker.

onForkMessage: props.onForkMessage,
onEditMessage: props.onEditMessage,
onRetryMessage: props.onRetryMessage,
retryMessagePending: props.retryMessagePending ?? false,

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/reviewP3: Toggling one retry button rerenders every loaded conversation row.

retryMessagePending lives in the shared static renderer context consumed by each ConversationRowContent. The false → true → false mutation cycle therefore invalidates all loaded user and assistant rows twice, including Markdown-heavy history when timeline windowing is off.

Please localize this pending state to the retryable row/action (for example, a narrow context or small retry-action component) so unrelated timeline rows keep their memoization.

@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 gives people a recovery button when they manually stop a request that the AI provider never acknowledged. Clicking “Retry request” reuses the existing retry/queue machinery so the original prompt can run again and later queued messages can continue in order.

The direction is solid and the focused happy-path coverage is good, but I found two correctness gaps and two performance issues:

  1. P1 — retries can get permanently stuck after a transient wait. If the retry first waits on an offline host or provisioning, clearing that wait turns it into an “ordinary” queue row that is still blocked by the manual-stop pause. The pause cannot clear until a new root turn starts, while this retry is the row that needs to start it. The UI has already removed the Retry action, leaving only manual Send now as recovery.
  2. P1 — the UI can label the wrong message as retryable. The server returns the exact retryable request ID and allows agent-initiated requests, but the timeline ignores that ID and heuristically picks the last pending user-initiated bubble. An agent-originated stalled request can therefore produce no button or put the button under an older user message while retrying the newer request.
  3. P2 — idle-thread reads gain an unbounded event scan. Every idle thread detail response now loads the last request before checking for a manual stop. Because the legacy-compatible query’s SQLite plan uses the thread/sequence index, cost grows with all events after the last request even for normal completed threads.
  4. P3 — retry pending state invalidates every loaded conversation row. The shared row-renderer context changes twice per retry, rerendering unrelated user and Markdown-heavy assistant rows when large timelines are loaded.

Security review found no actionable vulnerability. Retry still requires a writable thread, validates the exact eligible request, re-enters attachment validation and dispatch-policy hooks, and preserves the original execution tuple.

Architecturally, I’d consolidate stopped-request eligibility and the existing manual-stop queue-pause policy into one request-aware database classifier; both currently interpret the same event history independently, which helped create the first inconsistency. I found no stale retryFailedTurn names or other significant duplication.

Verification on 567a01fdc01187085ecf925f3993883db62822f5: 20 server tests, 71 app tests, the deterministic recovery integration test, the 12-task cross-package typecheck, and git diff --check all passed. In browser QA with doobie, Retry produced the attempt-2 queue card; after simulating the offline-host wait clearing, the row remained queued with waitingOn: null and the Retry button remained absent, confirming finding 1 end to end.

The PR advanced during review to d4aaf9207; that delta only normalizes demo/mobile fixtures and bumps the Plugin SDK version, so these findings still apply.

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.

tell --mode auto injects into busy turn instead of queueing: provider error retry loop, status flips to error while turn continues

1 participant