diff --git a/.github/time/astra.py b/.github/time/astra.py index 2c678e6..01e1202 100644 --- a/.github/time/astra.py +++ b/.github/time/astra.py @@ -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: diff --git a/.github/time/test_time.py b/.github/time/test_time.py index c0eb560..0f8f6d0 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -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 diff --git a/.github/time/time.py b/.github/time/time.py index cd4acc2..17b28b3 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -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/.jsonl in the repo; TIME_MONTHLY_USD caps it before every call, and each call is paced to the +Spend = time/ledger/.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/.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" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7963ca7..9f1776b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - The Venice worker's shell no longer sees secrets: its environment drops tokens and keys, its checkout keeps no token in `.git/config`, and the workflows check out without persisted credentials. ### Changed +- The monthly cap (`TIME_MONTHLY_USD`) counts the owner's own money only. Spend that an `expiring` pool's prepaid credit paid for (its routes, from its `as_of` until it lapses, up to the balance it had) is left out of the month, since the credit's own balance limits it; spend from before `as_of`, past the balance or the lapse, or on a route with no pool counts as before. With `anthropic-credits` pacing Claude at about $8/day, the $100 cap would have tripped around 2026-10-26 and put Time in dry-run for five days with credit left to lapse on 2026-11-08. `budget()`'s month, the cap's refusal, the andon, `doctor` and Astra's line all say "out of pocket". - The router never picks a card that waits, and promotes it after the ones that can move. A card waits when its `Depends on #N` card is still open, or when the seat marks it `blocked` with what it waits on (a PR that can't merge, a human): the router's first live run queued #84, whose PR #75 can't merge against main, because the seat scored it top while writing that it was blocked. The table shows what a card waits on. `astra.yml` now passes `TIME_WORKER` and `TIME_PACE`, without which Astra's money left fell back to the daily cap. - The router's money left is the worker's pool (#175): live mode queues against the allowance left in the pool that takes `TIME_WORKER`'s route (about $8/day from `anthropic-credits` while the credit lasts), or the daily cap's remainder without one, never more than the month has left; it was `TIME_DAILY_USD` minus today's spend, money the worker couldn't spend. The Astra line and the seat's prompt show the figure and its source. The seat also sees the cards closed in the last 14 days and is told not to write them again (it proposed the housekeeping label fix two days running after #137 had landed), and a new card whose title is an open or lately closed card's, or contains or is contained in one of four words or more, is not written; the log lists it under "Not written". - The lead runs the day's venture alone, and fable is out. One seat, `TIME_VENTURE_LEAD` (opus), does a research round (the top six trending repos with the start of their READMEs, the rest as a list, and every studio venture with its status and signups), lists `TIME_VENTURE_FIELD` candidates with a verdict each, and picks one; no pitches, no vote. A pick that repeats a studio venture gets one more go, told so. The lead writes the launch plan alone (no synthesis) and judges the build; it hands the launch-post drafts to its helper, `TIME_VENTURE_HELPER` (sonnet). The chair and the judge default to opus (they were fable, the dearest seat, on calls opus does as well). Batches keep the board, and `TIME_VENTURE_LEAD=board` brings it back for the daily venture.