Budget: the monthly cap counts out-of-pocket money; credit is limited by its pool (#193)
ci / test (push) Successful in 37s
sync / sync (push) Successful in 5s
time/worker-run sed -n 845,885p .github/time/test_time.py; tail -5 .github/time/test_time.py

Budget: the monthly cap counts out-of-pocket money; credit is limited by its pool (#193)

credit_covered(): what an expiring pool's prepaid credit paid this month, within its balance and before it lapses, is left out of the month TIME_MONTHLY_USD caps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
This commit was merged in pull request #193.
This commit is contained in:
teahubxyzandClaude Opus 5.5 committed 2026-10-19 16:03:08 +00:00
1 parent 5b1843521f
commit 7e7d2d569d
4 files changed
+53 -7

No files matched your search

+2 -1
View File
@@ -229,7 +229,8 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
closed = closed_lately()
done = "\n".join(f"- #{i['number']} {i['title']}" for i in closed[:50]) or "- (none)"
user = (f"Factory context (the spine's AGENTS.md and the newest decisions):\n{context}\n\nSpend: ${today:.2f} of "
f"${day_cap:.2f} today, ${month:.2f} of ${month_cap:.2f} this month; ${left:.2f} left to queue against ({source})."
f"${day_cap:.2f} today, ${month:.2f} of ${month_cap:.2f} this month out of pocket (credit-paid spend excluded); "
f"${left:.2f} left to queue against ({source})."
f"\n\nCards closed in the last 14 days (done or dropped; don't write these again):\n{done}"
f"\n\nCandidates ({len(cands)}):\n\n{listing}")
try:
+19 -1
View File
@@ -757,7 +757,7 @@ w = claude_env(ClaudeWorld([card(1, D)], {1: [beat("would-dispatch")]}, ledger=[
assert sent == [] and w.opened == [] and "a run under way may go to" not in w.posted[-1][1], "a new launch never gets the slack"
sent.clear()
w = claude_env(ClaudeWorld([card(1, D)], {1: [beat("would-dispatch")]}, ledger=[{"at": iso(), "model": "claude-opus-5-5", "usd": 99.99}]), {"TIME_WORKER": "claude:sonnet", "TIME_DAILY_USD": "500"})
assert len(sent) == 1 and w.opened == [] and "this month's spend" in w.posted[-1][1], "the monthly cap is hard inside a run too"
assert len(sent) == 1 and w.opened == [] and "this month's out-of-pocket spend" in w.posted[-1][1], "the monthly cap is hard inside a run too"
# the review of an open PR runs on a capped day (within the slack), and says so if even the slack is spent
tm.think = real_think # the real one: the budget check lives in chat(), under it
tm.pr_diff = lambda n, limit=0: "diff --git a b"
@@ -1344,6 +1344,24 @@ with contextlib.redirect_stdout(out): tm.cmd_doctor()
assert "pool `credits` | expiring, $30.00; spent $1.00 since reset; next reset " in out.getvalue() and "allowance today $3.00" in out.getvalue(), out.getvalue()
assert "pool `sub` | window, $2.00; spent $0.00" in out.getvalue()
assert tm.pool_spent(credit) == 1 and tm.pool_spent(sub_) == 0
# the monthly cap guards the owner's own money: spend the credit pays for is left out of the month, within its balance
month82 = NOW.strftime("%Y-%m")
fresh82(spent_row(5), spent_row(2, "codex"))
assert tm.credit_covered(month82) == 5 and tm.budget(NOW)[2] == 2, (tm.credit_covered(month82), tm.budget(NOW))
os.environ["TIME_MONTHLY_USD"], os.environ["TIME_DAILY_USD"] = "4", "100" # the monthly cap alone
assert not refused("codex"), "$7 booked, $5 of it credit: $2 of the owner's money against a $4 cap"
fresh82(spent_row(29), spent_row(4), spent_row(1, "codex"))
assert tm.credit_covered(month82) == 30 and tm.budget(NOW)[2] == 4 and refused("codex"), "past the $30 balance it is the owner's money again"
os.environ.pop("TIME_MONTHLY_USD")
tomorrow82 = (day0 + dt.timedelta(days=1)).strftime("%Y-%m-%d")
tm.pools = lambda: [dict(credit, as_of=tomorrow82)]
fresh82(spent_row(5)); assert tm.credit_covered(month82) == 0, "spend from before the balance was read isn't covered"
tm.pools = lambda: [dict(credit, resets_at=day0.strftime("%Y-%m-%d"))]
fresh82(spent_row(5)); assert tm.credit_covered(month82) == 0 and tm.budget(NOW)[2] == 5, "lapsed credit covers nothing"
tm.pools = lambda: [sub_, {"name": "card", "kind": "balance", "routes": ["anthropic"]}]
fresh82(spent_row(5), spent_row(1, "venice")); assert tm.credit_covered(month82) == 0 and tm.budget(NOW)[2] == 6, "only expiring credit is prepaid"
tm.pools = lambda: []
fresh82(spent_row(5)); assert tm.budget(NOW)[2] == 5, "no pools: every dollar, as before"
tm.pools = real_pools; os.environ.pop("TIME_DAILY_USD"); tm.LEDGER.clear(); tm._ledger_files.clear()
# pools.json is read through the API (the workflows check out only .github/time), once per run
import base64 as _b82
+31 -5
View File
@@ -4,7 +4,8 @@
python3 .github/time/time.py heartbeat # on schedule: plan specs, dispatch `ready` cards, merge approved PRs, pull the next card
Ledger = this Gitea repo. Brains = Venice (VENICE_API_KEY) or Claude (ANTHROPIC_API_KEY), per seat in .github/time/seats.json.
Spend = time/ledger/<YYYY-MM>.jsonl in the repo; TIME_MONTHLY_USD caps it before every call, and each call is paced to the
Spend = time/ledger/<YYYY-MM>.jsonl in the repo; TIME_MONTHLY_USD caps the owner's own money before every call (what
prepaid credit in an expiring pool pays for doesn't count: the pool's balance limits that), and each call is paced to the
capacity pool it books to (time/pools.json), or to TIME_DAILY_USD when no pool takes its route.
Loop guards: Time's own comments (author gitea-actions) never trigger replies or count as verdicts; the heartbeat
has a daily cap and a concurrency group. Unlike GitHub, Gitea does fire events for the Actions token's own writes,
@@ -404,9 +405,13 @@ def spend(prefix: str) -> float:
def budget(now: dt.datetime | None = None) -> tuple[float, float, float, float]:
"""(today's spend, daily cap, this month's out-of-pocket spend, monthly cap). The monthly cap guards the owner's own
money, so spend that prepaid credit paid for (credit_covered) is left out of the month; the credit's own balance
limits it instead. Without pools the month is every dollar booked, as before."""
now = now or dt.datetime.now(dt.timezone.utc)
month = now.strftime("%Y-%m")
return (spend(now.strftime("%Y-%m-%d")), float(os.environ.get("TIME_DAILY_USD") or 5),
spend(now.strftime("%Y-%m")), float(os.environ.get("TIME_MONTHLY_USD") or 100))
max(0.0, spend(month) - credit_covered(month)), float(os.environ.get("TIME_MONTHLY_USD") or 100))
_slack = threading.local() # per thread (#75 overlaps workers): what budget_check allows over the daily cap, 1.0 outside finishing()
@@ -509,10 +514,30 @@ def allowance(p: dict, now: dt.datetime | None = None) -> float:
return float(os.environ.get("TIME_DAILY_USD") or 5)
def credit_covered(month: str) -> float:
"""Dollars booked in `month` ("2026-10") that an `expiring` pool's prepaid credit paid for: per pool, what its routes
booked from its `as_of` until it lapses (`resets_at`), within the month, up to the balance it still had at the month's
start. Spend from before `as_of` isn't covered (the balance was read after it), nor spend past the balance or the
lapse, nor any route without a pool: all of that is the owner's money, which TIME_MONTHLY_USD caps."""
m0 = f"{month}-01"
m1 = f"{int(month[:4]) + (month[5:] == '12')}-{int(month[5:]) % 12 + 1:02d}-01"
covered = 0.0
for p in pools():
if p.get("kind") != "expiring" or not p.get("as_of") or not p.get("resets_at"):
continue
start, end = max(m0, p["as_of"]), min(m1, p["resets_at"])
if start >= end:
continue
left = p["balance_usd"] - (pool_spend(p, p["as_of"], m0) if p["as_of"] < m0 else 0.0)
covered += min(max(0.0, left), pool_spend(p, start, end))
return covered
def budget_check(route: str | None = None, now: dt.datetime | None = None) -> None:
today, day_cap, month, month_cap = budget(now)
if month >= month_cap:
raise Budget(f"this month's spend is ${month:.2f} of the ${month_cap:.2f} cap; Time is back in dry-run until a human raises it")
raise Budget(f"this month's out-of-pocket spend is ${month:.2f} of the ${month_cap:.2f} cap (credit-paid spend "
"excluded); Time is back in dry-run until a human raises it")
if (p := pool_for(route)):
spent, limit = pool_spent(p, now), allowance(p, now)
if spent >= limit * slack():
@@ -543,7 +568,7 @@ def andon(reason: str) -> None:
flipped = "\n\nThe monthly cap tripped, so I created the Actions variable `TIME_MODE=dry-run`. Raise the cap, then set it back to `live`."
except RuntimeError as e2:
flipped = f"\n\nThe monthly cap tripped, but I couldn't set `TIME_MODE=dry-run`: `{str(e2)[:200]}`. Please do it by hand."
new_issue(f"Spend cap reached: ${today:.2f} today / ${month:.2f} this month",
new_issue(f"Spend cap reached: ${today:.2f} today / ${month:.2f} out of pocket this month",
f"⏱️ Time stopped calling models: {reason}.\n\n- Daily cap (`TIME_DAILY_USD`): ${day_cap:.2f}\n"
f"- Monthly cap (`TIME_MONTHLY_USD`): ${month_cap:.2f}\n- Ledger: `time/ledger/<YYYY-MM>.jsonl`"
f"{flipped}\n\nClose this once it's handled.", ["andon"])
@@ -1742,7 +1767,8 @@ def cmd_doctor() -> int:
row("TIME_DAILY_USD", True, os.environ.get("TIME_DAILY_USD") or "5 (default)")
row("TIME_CAP_SLACK", True, os.environ.get("TIME_CAP_SLACK") or "1.5 (default)")
row("TIME_MONTHLY_USD", True, os.environ.get("TIME_MONTHLY_USD") or "100 (default)")
row("spend", today < day_cap and month < month_cap, f"${today:.2f} today, ${month:.2f} this month ({ledger_path(dt.datetime.now(dt.timezone.utc).strftime('%Y-%m'))})")
row("spend", today < day_cap and month < month_cap, f"${today:.2f} today, ${month:.2f} this month out of pocket, credit-paid "
f"spend excluded ({ledger_path(dt.datetime.now(dt.timezone.utc).strftime('%Y-%m'))})")
now = dt.datetime.now(dt.timezone.utc)
for p in pools():
win = p["kind"] == "window"