🗝 KeyzHub
19Keys · community archive
2418 bytes raw
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#!/usr/bin/env python3
"""
Credit guard — turns an exhausted Anthropic balance into a clean, throttled skip.

Watches nothing on its own; it is the shared helper the scheduled loops (the
Mansu Musa weekly cadence, and any future paid-API cadence) call to classify a
failure as "no credits" and rate-limit how often the owner gets pinged about it.
Schedule: none — imported by loop scripts, never run on a timer itself.
Cost: $0 (pure stdlib file I/O; zero model / LLM / network calls).

State: chairman-agent/data/credit_skips.json, one entry per job:
  {"<job>": {"last_skip": iso, "count": n, "last_notify": iso}}
No secret is read, logged, or written here.
"""
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path

CREDIT_MARKER = "credit balance is too low"
SKIPS_FILE = Path(__file__).resolve().parent / "data" / "credit_skips.json"


def is_credit_error(exc):
    """True when an exception is Anthropic's 'credit balance too low' 400."""
    return CREDIT_MARKER in str(exc).lower()


def _now():
    return datetime.now(timezone.utc).isoformat()


def _load():
    try:
        if SKIPS_FILE.exists():
            return json.loads(SKIPS_FILE.read_text())
    except Exception:
        pass
    return {}


def _save(data):
    try:
        SKIPS_FILE.parent.mkdir(parents=True, exist_ok=True)
        SKIPS_FILE.write_text(json.dumps(data, indent=2))
    except Exception:
        pass


def record_skip(job):
    """Record that `job` skipped a run; bumps its counter and last_skip time."""
    data = _load()
    row = data.get(job) or {}
    row["last_skip"] = _now()
    row["count"] = int(row.get("count", 0)) + 1
    data[job] = row
    _save(data)
    return row


def should_notify(job, hours=24):
    """True if `job` has not sent an owner alert within the last `hours`."""
    row = _load().get(job) or {}
    last = row.get("last_notify")
    if not last:
        return True
    try:
        prev = datetime.fromisoformat(last)
    except ValueError:
        return True
    if prev.tzinfo is None:
        prev = prev.replace(tzinfo=timezone.utc)
    return datetime.now(timezone.utc) - prev >= timedelta(hours=hours)


def mark_notified(job):
    """Stamp `job`'s last_notify to now so should_notify throttles for `hours`."""
    data = _load()
    row = data.get(job) or {}
    row["last_notify"] = _now()
    data[job] = row
    _save(data)
    return row