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

Merged
lilleman merged 1 commits from spend-limit into main 2026-08-04 10:28:54 +02:00
3 changed files with 209 additions and 16 deletions
+25 -4
View File
@@ -23,12 +23,18 @@ session, resumes it only when all of these hold:
1. **The process is gone** — no `pid`, or the pid no longer exists. 1. **The process is gone** — no `pid`, or the pid no longer exists.
2. **`~/.claude/jobs/<id>/state.json` says `"state": "blocked"`.** 2. **`~/.claude/jobs/<id>/state.json` says `"state": "blocked"`.**
3. **`detail` names a recoverable cause** — session/weekly/Opus limit, usage or 3. **`detail` names a recoverable cause** — any quota Claude Code reports as
rate limit, API error, overload, or a network/connection failure. `You've hit your <limit>`: session, weekly, Opus, Sonnet, Fable 5, fast,
usage, rate, and the ones that cost money rather than time (monthly or
individual spend limit, usage credit limit, out of usage credits or extra
usage). Plus API errors, overload, and network/connection failures.
4. **`detail` does not name a human** — "input needed", "waiting for", 4. **`detail` does not name a human** — "input needed", "waiting for",
permission, approval, or awaiting your direction/decision. A session parked permission, approval, or awaiting your direction/decision. A session parked
on your decision must stay parked, and this veto wins even when the same on your decision must stay parked, and this veto wins even when the same
detail also names a limit. detail also names a limit. It also covers entitlements only an admin can
change, which no amount of waiting clears: a seat type that excludes usage
credits, an allocation disabled by your admin, a group limit set to $0, a
service disabled for your org.
5. **Any stated reset time has passed.** `detail` carries it verbatim, e.g. 5. **Any stated reset time has passed.** `detail` carries it verbatim, e.g.
`You've hit your session limit · resets 7pm (Europe/Stockholm)`, so the `You've hit your session limit · resets 7pm (Europe/Stockholm)`, so the
watchdog waits for that moment instead of guessing. Day-less times are watchdog waits for that moment instead of guessing. Day-less times are
@@ -39,6 +45,11 @@ session, resumes it only when all of these hold:
push the parsed time-of-day a full day into the future and strand the push the parsed time-of-day a full day into the future and strand the
session. Blocks with no stated reset (API outage, network) are retried on session. Blocks with no stated reset (API outage, network) are retried on
the next tick, with no wait. the next tick, with no wait.
A **spend** block states no reset because none exists: it clears when you
raise the cap, when the month rolls over, or when the subscription window
puts the session back on included quota. So it is neither retried every tick
nor parked for 5h — it is retried hourly until one of those happens.
6. **Attempt caps are not exhausted** — see the circuit breakers below. 6. **Attempt caps are not exhausted** — see the circuit breakers below.
It resumes with `claude --bg --resume <sessionId> --name "<original name>"` It resumes with `claude --bg --resume <sessionId> --name "<original name>"`
@@ -75,6 +86,12 @@ each retry re-sends the session's whole context. During a multi-hour outage
that is the one path that can burn a lot of tokens for nothing. Set that is the one path that can burn a lot of tokens for nothing. Set
`--max-per-run` to a small number instead if you would rather stagger. `--max-per-run` to a small number instead if you would rather stagger.
A *spend* block is the other open-ended one: if you never raise the cap it
retries hourly until the month turns over, one job per session per hour. Those
retries are rejected before the model sees them, so they cost no tokens — but
they do spend the daily budget, so raise `--max-per-day` if you routinely have
several sessions blocked at once.
## What it deliberately does not do ## What it deliberately does not do
- **Interactive sessions** — you are sitting there; you can press enter. - **Interactive sessions** — you are sitting there; you can press enter.
@@ -86,7 +103,7 @@ that is the one path that can burn a lot of tokens for nothing. Set
## Install ## Install
Needs Claude Code (for `claude agents --json` and `claude --bg --resume`) and Needs Claude Code (for `claude agents --json` and `claude --bg --resume`) and
Python 3.9+ — standard library only. Python 3.9+ — standard library only, 3.14+ to run the tests.
Clone anywhere — `~/.claude/session-watchdog` just keeps it travelling with the Clone anywhere — `~/.claude/session-watchdog` just keeps it travelling with the
rest of `~/.claude`. Then add one cron entry with `crontab -e`: rest of `~/.claude`. Then add one cron entry with `crontab -e`:
@@ -123,6 +140,9 @@ root.
## Verify and observe ## Verify and observe
```bash ```bash
# Does it still classify every block correctly?
python3 ~/.claude/session-watchdog/test-resume-stalled-sessions.py
# What would it do right now? Resumes nothing. # What would it do right now? Resumes nothing.
~/.claude/session-watchdog/resume-stalled-sessions.py --dry-run ~/.claude/session-watchdog/resume-stalled-sessions.py --dry-run
@@ -159,6 +179,7 @@ hardcoded. To make one permanent, add it to the crontab line.
| Path | Role | | Path | Role |
| ------------------------------------------ | ------------------------------------------ | | ------------------------------------------ | ------------------------------------------ |
| `resume-stalled-sessions.py` | The watchdog — the only file it needs | | `resume-stalled-sessions.py` | The watchdog — the only file it needs |
| `test-resume-stalled-sessions.py` | Its tests, on real Claude Code block texts |
| `~/.claude/session-watchdog/ledger.json` | Attempt counts and resume timestamps | | `~/.claude/session-watchdog/ledger.json` | Attempt counts and resume timestamps |
| `~/.claude/session-watchdog/.lock` | Held for the duration of a run | | `~/.claude/session-watchdog/.lock` | Held for the duration of a run |
+41 -12
View File
@@ -27,11 +27,17 @@ DEFAULT_PROMPT = (
"transient failure (usage limit, API outage, or network), which has cleared." "transient failure (usage limit, API outage, or network), which has cleared."
) )
RECOVERABLE = re.compile( # Every quota Claude Code can report as "You've hit your <limit> · <how to clear
r"hit your (session|weekly|opus) limit" # it>", including the ones that cost money rather than time.
r"|usage limit" QUOTA_BLOCK = re.compile(
r"|rate.?limit" r"(session|weekly|opus|sonnet|fable \d+|fast|spend|rate.?|usage( credit)?) ?limit"
r"|api error" r"|out of (usage|extra usage)"
r"|usage credits",
re.IGNORECASE,
)
TRANSIENT_BLOCK = re.compile(
r"api (error|unavailable)"
r"|overloaded" r"|overloaded"
r"|connection (error|reset|closed|failure)" r"|connection (error|reset|closed|failure)"
r"|network error" r"|network error"
@@ -41,18 +47,29 @@ RECOVERABLE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# A session parked on a human decision must never be auto-resumed. Vetoes even # A session parked on a human decision must never be auto-resumed, and an
# when the same detail also names a limit. # entitlement someone else controls never clears by waiting. Vetoes even when
# the same detail also names a limit.
NEEDS_HUMAN = re.compile( NEEDS_HUMAN = re.compile(
r"input needed|waiting for|permission|approval" r"input needed|waiting for|permission|approval"
r"|awaiting (your )?(direction|decision|input|answer|go.?ahead)" 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, 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) WEEKLY_LIMIT = re.compile(r"weekly limit", re.IGNORECASE)
SESSION_WINDOW = timedelta(hours=5) SESSION_WINDOW = timedelta(hours=5)
SPEND_WINDOW = timedelta(hours=1)
WEEKLY_WINDOW = timedelta(days=7) WEEKLY_WINDOW = timedelta(days=7)
RESETS_AT = re.compile( RESETS_AT = re.compile(
@@ -160,6 +177,13 @@ def parse_reset(detail, blocked_at):
return target 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): def reset_moment(detail, blocked_at):
"""When the block should have lifted, or None if it can be retried at once. """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 rewritten after the reset already happened, which would otherwise push the
parsed time-of-day a full day into the future. parsed time-of-day a full day into the future.
""" """
if not LIMIT_BLOCK.search(detail): if not QUOTA_BLOCK.search(detail):
return None 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) stated = parse_reset(detail, blocked_at)
bound = blocked_at + window bound = blocked_at + window
return min(stated, bound) if stated else bound return min(stated, bound) if stated else bound
@@ -209,7 +238,7 @@ def candidates(sessions, jobs_dir, now):
continue continue
detail = str(state.get("detail") or "") detail = str(state.get("detail") or "")
if NEEDS_HUMAN.search(detail) or not RECOVERABLE.search(detail): if not resumable(detail):
continue continue
blocked_at = datetime.fromtimestamp(state_file.stat().st_mtime).astimezone() blocked_at = datetime.fromtimestamp(state_file.stat().st_mtime).astimezone()
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Tests for the watchdog: python3 test-resume-stalled-sessions.py"""
import json
import os
import tempfile
import unittest
import uuid
from datetime import datetime, timedelta
from importlib import util
from pathlib import Path
spec = util.spec_from_file_location("watchdog", Path(__file__).with_name("resume-stalled-sessions.py"))
watchdog = util.module_from_spec(spec)
spec.loader.exec_module(watchdog)
# Verbatim from Claude Code's own limit banners, so a wording it really emits
# cannot silently stop matching.
CLEARS_ON_ITS_OWN = [
"You've hit your session limit · resets 7pm (Europe/Stockholm)",
"You've hit your weekly limit · resets Mon 12:00am",
"You've hit your Opus limit · resets 7pm",
"You've hit your Sonnet limit · resets 7pm",
"You've hit your Fable 5 limit · resets 7pm",
"You've hit your fast limit · resets in 12m",
"You've hit your monthly spend limit · raise it at claude.ai/settings/usage?from=cc_cli_limit_message",
"You've hit your individual spend limit · run /usage-credits to raise it, or visit claude.ai/admin-settings/usage",
"You've hit your org's monthly spend limit · ask your admin to raise it at claude.ai/settings/usage",
"You've hit your usage credit limit · raise it at claude.ai/settings/usage",
"You're out of usage credits",
"You're out of extra usage",
"Your org is out of usage · add funds to continue",
"Fable 5 requires usage credits",
"usage limit reached — check plan",
"rate limited — wait and retry",
"API overloaded — wait and retry",
"API unavailable — retry",
"API error — see detail",
"fetch failed",
"socket hang up",
"ECONNRESET",
]
# An entitlement someone else controls, or a decision left to the user: no wait
# clears either, so resuming only burns the daily budget.
STAYS_PARKED = [
"Your seat type doesn't include usage credits",
"Your seat type doesn't include extra usage",
"Your usage allocation has been disabled by your admin",
"Your group's usage limit is set to $0",
"This service is disabled for your org",
"login required — run /login",
"request too large — /compact or trim",
"invalid API request — see detail",
"input needed: which branch should I target?",
"waiting for the migration to be approved",
"needs your decision on the schema",
"You've hit your session limit · waiting for your go-ahead",
]
class Resumable(unittest.TestCase):
def test_recoverable_causes(self):
for detail in CLEARS_ON_ITS_OWN:
with self.subTest(detail=detail):
self.assertTrue(watchdog.resumable(detail))
def test_human_or_permanent_causes(self):
for detail in STAYS_PARKED:
with self.subTest(detail=detail):
self.assertFalse(watchdog.resumable(detail))
class ResetMoment(unittest.TestCase):
def setUp(self):
self.blocked_at = datetime(2026, 8, 4, 9, 0).astimezone()
def test_spend_and_credit_blocks_retry_hourly(self):
for detail in [
"You've hit your monthly spend limit · raise it at claude.ai/settings/usage?from=cc_cli_limit_message",
"You've hit your usage credit limit · raise it at claude.ai/settings/usage",
"You're out of usage credits",
"Your org is out of usage · add funds to continue",
]:
with self.subTest(detail=detail):
self.assertEqual(
watchdog.reset_moment(detail, self.blocked_at),
self.blocked_at + timedelta(hours=1),
)
def test_stated_reset_wins_when_it_falls_inside_the_window(self):
detail = "You've hit your session limit · resets 11am"
self.assertEqual(
watchdog.reset_moment(detail, self.blocked_at),
self.blocked_at.replace(hour=11),
)
def test_unstated_reset_falls_back_to_the_limit_window(self):
self.assertEqual(
watchdog.reset_moment("You've hit your session limit", self.blocked_at),
self.blocked_at + watchdog.SESSION_WINDOW,
)
self.assertEqual(
watchdog.reset_moment("You've hit your weekly limit", self.blocked_at),
self.blocked_at + watchdog.WEEKLY_WINDOW,
)
def test_transient_failures_retry_at_once(self):
self.assertIsNone(watchdog.reset_moment("API error — see detail", self.blocked_at))
class Candidates(unittest.TestCase):
"""The whole path: a spend-limited background job with no process left."""
SPEND = "You've hit your monthly spend limit · raise it at claude.ai/settings/usage?from=cc_cli_limit_message"
def setUp(self):
temp = tempfile.TemporaryDirectory()
self.addCleanup(temp.cleanup)
self.jobs = Path(temp.name)
self.now = datetime.now().astimezone()
def blocked_job(self, detail, blocked_ago):
job_id, session_id = uuid.uuid7().hex[:8], str(uuid.uuid7())
state = self.jobs / job_id / "state.json"
state.parent.mkdir()
state.write_text(json.dumps({"detail": detail, "state": "blocked"}))
stamp = (self.now - blocked_ago).timestamp()
os.utime(state, (stamp, stamp))
return {"cwd": str(self.jobs), "id": job_id, "kind": "background", "sessionId": session_id}
def test_spend_block_is_picked_up_once_its_hour_is_up(self):
session = self.blocked_job(self.SPEND, timedelta(hours=2))
found = watchdog.candidates([session], self.jobs, self.now)
self.assertEqual([job["id"] for job in found], [session["id"]])
def test_fresh_spend_block_waits(self):
session = self.blocked_job(self.SPEND, timedelta(minutes=10))
self.assertEqual(watchdog.candidates([session], self.jobs, self.now), [])
if __name__ == "__main__":
unittest.main()