deep-research workflow: rate-limited verify votes are now reported honestly (#69883) but never retried — bounded retry-with-backoff recovers them (working patch included)
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)
- Script-level (the patch above): smallest change, only touches the deep-research template. Caveat: real backoff depends on
setTimeoutexisting in the sandbox. - Harness-level (better): have the workflow runtime's
agent()retry terminal API errors with bounded escalating backoff before resolvingnull. 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.