Resume spend-limit and usage-credit blocks, retried hourly

This commit is contained in:
2026-08-04 09:40:53 +02:00
parent a6e03dec72
commit ad9d51d399
3 changed files with 209 additions and 16 deletions
+41 -12
View File
@@ -27,11 +27,17 @@ DEFAULT_PROMPT = (
"transient failure (usage limit, API outage, or network), which has cleared."
)
RECOVERABLE = re.compile(
r"hit your (session|weekly|opus) limit"
r"|usage limit"
r"|rate.?limit"
r"|api error"
# Every quota Claude Code can report as "You've hit your <limit> · <how to clear
# it>", including the ones that cost money rather than time.
QUOTA_BLOCK = re.compile(
r"(session|weekly|opus|sonnet|fable \d+|fast|spend|rate.?|usage( credit)?) ?limit"
r"|out of (usage|extra usage)"
r"|usage credits",
re.IGNORECASE,
)
TRANSIENT_BLOCK = re.compile(
r"api (error|unavailable)"
r"|overloaded"
r"|connection (error|reset|closed|failure)"
r"|network error"
@@ -41,18 +47,29 @@ RECOVERABLE = re.compile(
re.IGNORECASE,
)
# A session parked on a human decision must never be auto-resumed. Vetoes even
# when the same detail also names a limit.
# A session parked on a human decision must never be auto-resumed, and an
# entitlement someone else controls never clears by waiting. Vetoes even when
# the same detail also names a limit.
NEEDS_HUMAN = re.compile(
r"input needed|waiting for|permission|approval"
r"|awaiting (your )?(direction|decision|input|answer|go.?ahead)"
r"|needs? (your )?(direction|decision|input)",
r"|needs? (your )?(direction|decision|input)"
r"|seat type does ?n.t include"
r"|disabled by your admin"
r"|disabled for your org"
r"|limit is set to \$0",
re.IGNORECASE,
)
LIMIT_BLOCK = re.compile(r"(session|weekly|opus|usage|rate.?) ?limit", re.IGNORECASE)
# Money, not time: cleared by raising the cap, by the month rolling over, or by
# the subscription window putting the session back on included quota. None of
# those announce themselves, so poll rather than wait out a window.
SPEND_BLOCK = re.compile(
r"spend limit|usage credit|out of (usage|extra usage)|usage credits", re.IGNORECASE
)
WEEKLY_LIMIT = re.compile(r"weekly limit", re.IGNORECASE)
SESSION_WINDOW = timedelta(hours=5)
SPEND_WINDOW = timedelta(hours=1)
WEEKLY_WINDOW = timedelta(days=7)
RESETS_AT = re.compile(
@@ -160,6 +177,13 @@ def parse_reset(detail, blocked_at):
return target
def resumable(detail):
"""True when `detail` names a cause that clears without anyone acting."""
if NEEDS_HUMAN.search(detail):
return False
return bool(QUOTA_BLOCK.search(detail) or TRANSIENT_BLOCK.search(detail))
def reset_moment(detail, blocked_at):
"""When the block should have lifted, or None if it can be retried at once.
@@ -167,9 +191,14 @@ def reset_moment(detail, blocked_at):
rewritten after the reset already happened, which would otherwise push the
parsed time-of-day a full day into the future.
"""
if not LIMIT_BLOCK.search(detail):
if not QUOTA_BLOCK.search(detail):
return None
window = WEEKLY_WINDOW if WEEKLY_LIMIT.search(detail) else SESSION_WINDOW
if WEEKLY_LIMIT.search(detail):
window = WEEKLY_WINDOW
elif SPEND_BLOCK.search(detail):
window = SPEND_WINDOW
else:
window = SESSION_WINDOW
stated = parse_reset(detail, blocked_at)
bound = blocked_at + window
return min(stated, bound) if stated else bound
@@ -209,7 +238,7 @@ def candidates(sessions, jobs_dir, now):
continue
detail = str(state.get("detail") or "")
if NEEDS_HUMAN.search(detail) or not RECOVERABLE.search(detail):
if not resumable(detail):
continue
blocked_at = datetime.fromtimestamp(state_file.stat().st_mtime).astimezone()