Note
Grok responding on behalf of Julius.
Thanks for the detailed write-up. I checked it against main and it's a real bug.
Root cause
ProjectionStore.getLimitRecoveryCandidates(apps/server/src/orchestration-v2/ProjectionStore.ts:3362-3436) joins only the thread's latest executed run (ordered bycompleted_at), and requiresr.status = 'failed'plus a rooterroritem on that same run withfailure.class = 'usage_limit'and a non-nullfailure.resetAtthat's later than the run'scompleted_at.limitRecoveryCommand(apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts:11-25) returnsnullunlessthread.status === "failed"andlastErrorClass === "usage_limit". Arming (limit-arm:) only happens on a scheduler sweep (UsageLimitRecoveryWorker.ts:78-113), so it isn't immediate.- Background-task wake runs aren't gated on limit state.
ClaudeAdapterV2(Adapters/ClaudeAdapterV2.ts:3081+) bufferstask_notificationframes and dispatches continuation runs with no check againstlimitRecoveryor a recentusage_limitfailure. A wake run that ends 2 s after the limit stop becomes the latest executed run, so the thread drops out of the candidate set before the sweep ever arms it. - These wake turns end as
completedbecauseproviderFailureFromResult(ClaudeAdapterV2.ts:2517-2555) returnsnullfor asuccessresult withis_error: false. TheusageLimitedheuristic (ClaudeAdapterV2.ts:6469-6477) only fires when the turn saw a rejected rate-limit event or a rate-limited assistant message. A notification-only turn, or one that just echoes the limit text (your run 7), doesn't meet either condition.
Fix options
- Classify limit-text/429 wake turns as
usage_limit. This is worth doing, but it isn't enough alone. Runs 3-6 and 8 produced no limit signal at all, so they would still becompleted. - Candidate selection uses the most recent
usage_limitfailure with a future reset, skipping later runs that did no work (notification/checkpoint/system_notice only). Recommended. It's adapter-agnostic, it fixes this incident directly, and it also covers the arming race. Both the SQL andlimitRecoveryCommandneed to stop relying onthread.status === "failed"for this case, keyed off the limited run's id. - Hold background wake turns while limited and release them on resume. This is the cleanest behaviour, but it's the biggest change. It also touches the queued-notification/snooze interaction in #16233, so it's better as a follow-up.
Suggested plan: do (2) now and (1) as hardening, and handle (3) together with #16233.
Related: #15665 (a follow-up failure with resetAt: null) is a different symptom from the same "latest run only" rule. #16233, #15130 and #14169 are adjacent but not duplicates. PR #15553 is UI-only and doesn't change arming.
Workaround: after the reset, send any message to the thread. Avoid leaving background subagents running near the limit if you rely on auto-resume.