deep-research workflow: rate-limited verify votes are now reported honestly (#69883) but never retried — bounded retry-with-backoff recovers them (working patch included)

Status Open
Reported on v2.1.237
Maintainer reply None cached
Activity 0 comments · opened Aug 20, 2026

Environment: Claude Code 2.1.237, built-in deep-research workflow.

Context

#69883 reported two bugs in the verify phase: (1) no rate-limit handling in the 75-agent verify fan-out, and (2) infra failures misreported as "all claims refuted." Bug 2 was fixed — the shipped workflow now carries the three-state verdict (survives / isRefuted / unverified, with a comment referencing that issue), and an all-abstain run reports as an infrastructure failure instead of a research finding. Thank you — that fix is real and it works.

Bug 1 never landed, and #69883 was closed by the stale bot before it could. This issue is the retry half, with a field-tested patch. Related open report: #65731 (deep-research frequently hits "Server is temporarily limiting requests").

What still happens

When the verify burst (up to 25 claims × 3 votes) trips the server-side throttle, the affected agent() calls resolve null after the harness's internal retries give up. The three-state verdict now files those claims honestly under unverified — but the data is still lost: the tokens already spent on Scope/Search/Fetch produce no adjudicated findings for those claims, and the user's remedy is "re-run the whole thing."

What we've been running instead

A fork of the workflow (deep-research-resilient) in daily use since late July. Every agent() call is routed through a ragent() wrapper: up to 5 attempts with escalating backoff (5/10/15/20s). In practice the limiter usually cools within one or two backoff windows, and votes that would have landed unverified come back as real verdicts. The three-state verdict remains the second line of defence for whatever still fails.

const RETRY_TRIES = 5
const RETRY_BASE_MS = 5000
const sleep = (ms) => {
  // the workflow sandbox is a bare ECMAScript realm — feature-detect timers;
  // without them, degrade to immediate retries (the concurrency gate still
  // spaces requests, and the limiter may have cooled by the next attempt)
  try { if (typeof setTimeout === "function") return new Promise(r => setTimeout(r, ms)) } catch {}
  return Promise.resolve()
}
async function ragent(prompt, opts) {
  for (let attempt = 0; attempt < RETRY_TRIES; attempt++) {
    try {
      // (prompt, opts) kept identical across attempts on purpose — varying
      // them would change the resume cache key and break same-run resumption
      const r = await agent(prompt, opts)
      if (r) return r
    } catch { /* terminal error this attempt → back off and retry */ }
    if (attempt < RETRY_TRIES - 1) await sleep(RETRY_BASE_MS * (attempt + 1))
  }
  return null // three-state verdict then reports this as unverified, not refuted
}

Call sites just swap agent(ragent(. Optionally, retryRecovered / retryExhausted counters in the final stats make transient-but-recovered failures distinguishable from genuinely dead calls.

Suggested fix (two options)

  1. Script-level (the patch above): smallest change, only touches the deep-research template. Caveat: real backoff depends on setTimeout existing in the sandbox.
  2. Harness-level (better): have the workflow runtime's agent() retry terminal API errors with bounded escalating backoff before resolving null. That fixes every built-in workflow at once (bughunter, etc.), and the runtime has real timers. Bounded matters — #64328 documents the failure mode of unbounded 429 retries — so: fixed attempt cap, escalating delay, and count each retry against the run's agent/budget caps.

Repro

Same as #69883: run deep-research on any broad question yielding ≥ ~20 extracted claims, on a day the server-side limiter is active. Claims land in unverified[] with erroredVotes: 3 despite the sources having fetched fine.

View original on GitHub ↗