Rate-limited turns flood the channel with duplicate error notices — no backoff/dedup on deliverErrorResult
Summary
A turn that ends rate-limited is delivered to the user channel via deliverErrorResult (container/agent-runner/src/poll-loop.ts) with no backoff, cooldown, or dedup — every retried turn that re-hits the same rate limit produces its own delivered notice. On a real install this produced thousands of duplicate "session limit · resets HH:MM" messages to the same channel over roughly an hour.
Root cause (confirmed against source)
providers/claude.tsclassifies a rejectedrate_limit_eventand yields a providererrorevent carryingclassification: 'rate_limit' | 'quota'(lines ~647-660).- That
errorevent is never consumed.poll-loop.ts's mainfor await (const event of query.events)loop (~line 545) has branches forinit/text/resultonly — noelse if (event.type === 'error')branch exists. The classification is discarded. - The SDK's stream then emits a terminal
resultevent withisError: trueand the rate-limit text inevent.text(per the comment at claude.ts ~625-631: error subtypes carry their message inerrors[]rather thanresult). TheProviderEventresultvariant (providers/types.ts~165) has noclassificationfield at all, so by the time delivery happens the rate-limit vs. quota distinction is structurally unreachable. poll-loop.ts~602-607 unconditionally callsdeliverErrorResult(event.text, routing)wheneverresultBlocks === 0 && event.isError === true && !routing.taskRun— a directwriteMessageOutto the channel, every single time, with no memory of having just sent the identical notice.- Nothing else in the stack fills the gap:
src/host-sweep.tshas real exponential backoff (BACKOFF_BASE_MS * 2^tries,MAX_TRIES=5), but only for stuck/crashed containers — a turn that completes (even as an error) is markedcompletedimmediately (markCompleted(initialBatchIds)fires before dispatch, line 578), so that backoff path never engages.wakeContainer(src/container-runner.ts~134-155) only dedupes concurrent spawns, not repeated rate-limit hits over time.
Impact
Any account that stays rate-limited while messages keep arriving (or the agent keeps retrying/nudging) gets one delivered "error" message per completed turn — which can be many per minute — instead of one notice per incident. Confirmed in production: "thousands of duplicate 'session limit · resets 6:40pm' notifications" delivered to one channel over about an hour.
Suggested fix
At the result/isError delivery site in poll-loop.ts, when the underlying error was rate-limit-classified, suppress delivery of a notice whose text exactly matches the last one already delivered this session (or apply a cooldown). Two ways to make the classification reach that site:
- Add the missing
else if (event.type === 'error')branch to stashevent.classificationin a session-scoped local, read by the subsequentresultbranch; or - Thread
classificationthrough onto theresultProviderEventvariant directly.
A very similar pattern already exists, unmerged, for a different failure mode: PR #3566 (src/wake-failure-notify.ts) implements a per-session failure-threshold + cooldown notifier for wake failures (silent below N consecutive failures, at most one notice per interval, streak clears on success). The same shape — dedupe identical rate-limit notices, one per distinct incident — would fix this.
Confirmed not already covered
Searched open/closed issues and PRs for "rate limit", "backoff", "duplicate notification", deliverErrorResult — nothing files this exact gap. Related but distinct:
- #3077 (merged) — added the
rate_limit/quotasplit that this bug shows is never consulted downstream. - #2759 (merged) — introduced
deliverErrorResultitself (to stop dropping error turns), predating this dedup gap. - #3251 (open) — fixes a heartbeat-stall symptom during rate-limiting; unrelated to delivery flooding.
- #3566 (open) — dedup/cooldown pattern for a different failure mode (wake failures), good prior art for the fix here.
Environment
Observed on a self-hosted install running a fork of main (DawoudIO/nanoclaw, branch community-fixes-2026-08-24); the deliverErrorResult code path and lack of an error-event branch are present unchanged on current main.
Source: nanocoai/nanoclaw