Merge origin/main into card #247: a stall's scope meets the planner's Depends on: lines
ci / test (pull_request) Successful in 1m22s

Six commits landed on main while this card was open (#283 the router's loud
no-op, #284/#244 the planner's coarse cards, #289/#277 a card branch is never
rewound, #292/#80 the steward inside Time). Only `.github/time/time.py`
conflicted; CHANGELOG.md, README.md, SKILL.md and test_time.py merged with both
sides' lines.

- `DEPENDS`: main's `re.compile(r"Depends on:?[ \t]*#(\d+)", re.I)` (#244) is the
  one definition. This card had added a narrower `Depends on #(\d+)` of its own
  further down the file, which as the later binding would have won and stopped
  the new planner's `Depends on: #N` lines from being read at all — and broken
  main's own assertion that `DEPENDS` matches both forms. Removed; `deps_of` now
  reads both, so cards filed before #244 still wait on the right ones.
- `promote_next`: this card's body is kept, since `waits_on` is the inline
  dependency check main edited, lifted out and given the `known` cache and the
  log line. Main's only change there was swapping that `re.findall` for
  `DEPENDS.findall`, which `deps_of` now does.
- `WAITING` gains `parked`. Main's #277 added a fourth way for a card to wait on
  a person: its branch is gone from under an open PR and someone must push it
  back. It keeps its `ready` label, so without this it would have held a
  TIME_PARALLEL slot until a human arrived — exactly the stall #135 removed and
  this card scopes. Now it frees its slot, holds only the cards that wait on it,
  and its spec gets the andon note, with the PR named from the `parked` beat's
  own `pr`. Tested in the #135 block of test_time.py.

test_time.py (66 blocks, test_steward.py included), test_seed.py and
test_housekeeping.py all pass. test_delegate.py fails identically on
origin/main (`World.http()` takes no `headers`): pre-existing, untouched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAqjv18qYU68gMq5nsc1Xq
This commit is contained in:
Claude committed 2026-10-29 19:53:30 +00:00
commit 2f99c21c24
14 files changed
+1207 -55

No files matched your search

+25 -3
View File
@@ -5,7 +5,7 @@ to work on next. It queues; it never spends on its own beyond its one call.
Luna is Time's deterministic tick (no model); Sol is the hourly heartbeat that executes cards with a seat; Astra is the
slow outer loop that orients them. Astra labels its picks `queued`, never `ready`: the heartbeat's promote_next moves
one queued card at a time to `ready` (oldest first, `Depends on #N` respected), so Astra's work lines up behind
one queued card at a time to `ready` (oldest first, `Depends on: #N` respected), so Astra's work lines up behind
whatever is already queued instead of running beside it on the same files. Guardrails are code, not prompt: at most
TIME_ASTRA_MAX_READY cards queued and TIME_ASTRA_MAX_NEW cards created per run; an existing issue is only queued if it
has a `DONE:` line and none of `queued`/`ready`/`needs-human`/`andon`; an open `andon` issue means Astra writes its note
@@ -22,6 +22,8 @@ card), and the heartbeat promotes queued cards in the ranked order. Over the tab
picks returned (picks, merged share, median days, median $), from the outcomes housekeeping records (#172). The spine's
own open cards (`TIME_SPINE`, `teahubxyz/teahub`) are ranked beside Time's, keyed `teahub#N` and marked "spine: not
queued": a worker here builds in this repo only, so their value only tells the owner what the platform needs next (#173).
A reply that judges nothing, or next to nothing, is a loud no-op rather than a flat ranking (#211): the day's comment
says what came back and the router queues nothing, sends nothing to the cloud lane and writes no cards.
`off`, or no router.json, is the planner as before.
"""
from __future__ import annotations
@@ -82,7 +84,7 @@ For every candidate give:
- size: S (well under one worker session), M (one session), L (more than one: it must be split first)
- why: one short sentence
- blocked: only when it can't move today, what it waits on (a PR that can't merge, a human, something outside the
repo); leave it out otherwise. A blocked card is never queued, however it scores. A card whose `Depends on #N` card
repo); leave it out otherwise. A blocked card is never queued, however it scores. A card whose `Depends on: #N` card
is still open is already held back by code.
A candidate keyed `{spine_key}#N` is a card of the spine, the platform's own repo: judge its value like any other (it
tells the owner what the platform needs next), but no worker here can build it, so code never queues it.
@@ -239,6 +241,16 @@ def closed_lately(days: int = 14) -> list[dict]:
return []
JUDGED_SHARE = 0.25 # below this share of the candidates judged, the reply is not a ranking at all (#211)
def judged_enough(judged: int, total: int) -> bool:
"""Whether the seat's reply judged enough of the board to rank it. A reply with no `assess` scores every candidate at
the unjudged default (value 3, size M), which is a flat table, not a ranking: on 2026-10-20 (log #57) 37 of 37 came
back unjudged, every score 1.911, and the router still queued from the top and sent three cards to the cloud lane."""
return judged > 0 and judged >= total * JUDGED_SHARE
def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict], max_ready: int, max_new: int) -> int:
"""The seat judges every candidate; router.py ranks them; live queues from the top, dry only shows the ranking."""
open_issues = [i for i in tm.issues("state=open") if i.get("title") != LOG_TITLE]
@@ -255,11 +267,21 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
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:
out = parse(tm.think(seat, None, None, ROUTER.format(max_new=max_new, spine_key=SPINE_KEY), user))
raw = tm.think(seat, None, None, ROUTER.format(max_new=max_new, spine_key=SPINE_KEY), user)
except tm.Budget as e:
tm.comment(log, f"🔭 Astra: {e}. No ranking today.", {"beat": "astra", "router": cfg["mode"], "queued": 0, "capped": True})
return 0
out = parse(raw)
assess = {str(a.get("key") or "").strip(): a for a in out.get("assess") or [] if isinstance(a, dict)}
judged = sum(1 for c in cands if c["key"] in assess) # a key that matches no candidate judged nothing
# the raw reply's size and stop reason, so a reply cut off by the output limit is visible as the cause (#211)
print(f"astra router {cfg['mode']}: reply {len(raw)} chars, stop {getattr(raw, 'stop', '') or '-'}, "
f"judged {judged} of {len(cands)}")
if not judged_enough(judged, len(cands)):
said = f"judged {judged} of {len(cands)}" if judged else "returned no assessments"
tm.comment(log, f"🔭 Astra: the judge {said} ({router._txt(raw, 200) or 'empty'}); nothing queued or sent today.",
{"beat": "astra", "router": cfg["mode"], "queued": 0, "unjudged": True})
return 0
ranked = router.rank(cands, assess, cfg, router.last_picked(tm.beats_of(thread)), dt.datetime.now(dt.timezone.utc).date())
in_lane = sum(1 for i in open_issues if router.CLOUD in router.labels_of(i))
to_cloud = router.pick_cloud(ranked, cfg, in_lane) # the cloud lane takes its cards first: its credit is separate
+13 -1
View File
@@ -4,7 +4,8 @@
Six steps, each idempotent (a second run finds nothing to do) and each logged:
1. branches delete every branch whose PR merged into the default branch. Never the default branch, `gh-pages`, a
protected branch, a branch with an open PR, or a branch that has commits newer than its merge.
protected branch, a branch with an open PR, a branch that has commits newer than its merge, or a branch
whose tip is not the commit its merged PR landed (work that is not in the default branch yet).
2. signals close `signal` issues whose venture's measuring window (TIME_VENTURE_MEASURE_DAYS, default 7) has passed
with no signups (no 👍 on the issue, no early-access issue for the venture), with one closing line.
3. milestones close milestones with no open issues and at least one closed one.
@@ -121,11 +122,14 @@ def step_branches(live: bool) -> int:
default = tm.default_branch()
open_heads = {p["head"]["ref"] for p in paged(f"{repo()}/pulls?state=open") if same_repo(p)}
merged: dict[str, dt.datetime | None] = {} # head ref -> when its latest merged PR merged
landed: dict[str, set[str]] = {} # head ref -> the head sha(s) its merged PR(s) landed
for p in paged(f"{repo()}/pulls?state=closed&sort=recentupdate"):
if (p.get("merged") or p.get("merged_at")) and (p.get("base") or {}).get("ref") == default and same_repo(p):
at = parse(p.get("merged_at"))
ref = p["head"]["ref"]
merged[ref] = max([d for d in (merged.get(ref), at) if d], default=None)
if (sha := (p.get("head") or {}).get("sha")):
landed.setdefault(ref, set()).add(sha)
deleted = 0
for b in paged(f"{repo()}/branches"):
name = b["name"]
@@ -138,6 +142,14 @@ def step_branches(live: bool) -> int:
if tip and merged[name] and tip > merged[name]:
log(f"branches: keep {name} (commits after its merge)")
continue
# The tip must be the very commit a merged PR landed. A branch sitting on anything else holds work the default
# branch does not have, and deleting it loses that work: the open-PR list above is the first guard, this one
# holds even when that list comes back short or soft-empty (#277, which cost #249 and #212 their branches). An
# unknown tip, or an unknown merged head, is "not the same commit", so the branch stays.
head = (b.get("commit") or {}).get("id")
if not head or head not in landed.get(name, set()):
log(f"branches: keep {name} (tip {(head or '?')[:8]} is not the commit its merged PR landed)")
continue
if not live:
log(f"branches: would delete {name}")
continue
+7 -6
View File
@@ -23,10 +23,10 @@ session credit pays); the session's `claude/card-N-*` branch comes home through
`ready`, and the heartbeat adopts and reviews it like any card PR. `per_day: 0` (the default) is no cloud lane.
An L is ranked but never queued: a card is what one worker session finishes, so it is split first. Nor is a card that
waits: on a card still open (its `Depends on #N`), or on whatever the seat says blocks it (a PR that can't merge, a
human). Cards that can move come first in the promotion order, the ones that wait after them. The policy is
`score`; a bandit policy (Beth, Jev, Fable's restless-bandit maths) can replace it without touching how candidates are
gathered or how picks are queued.
waits: on a card still open (a `Depends on: #N` line per card it waits on), or on whatever the seat says blocks it (a
PR that can't merge, a human). Cards that can move come first in the promotion order, the ones that wait after them.
The policy is `score`; a bandit policy (Beth, Jev, Fable's restless-bandit maths) can replace it without touching how
candidates are gathered or how picks are queued.
Outcomes (#172): a bandit learns from rewards, so every live pick's outcome is recorded once the card settles — one
line in `time/ledger/router-outcomes.jsonl`, written by the daily housekeeping sweep, and one summary line per kind
@@ -52,6 +52,7 @@ HELD = {"needs-human", "andon", "astra", "planned"} | LOOP # a human's, an a
LINED_UP = {"queued", "ready", "cloud"} # already moving: ranked (for promotion), never queued again
CLOUD = "cloud" # a card in the cloud lane: a cloud session builds it
DONE = re.compile(r"^\s*DONE:", re.M | re.I)
DEPENDS = re.compile(r"Depends on:?[ \t]*#(\d+)", re.I) # one line per card waited on; the planner writes them (#244)
SPINE_WHY_NOT = "spine: not queued (Time builds in this repo only)"
NEW_VENTURE = {"key": "venture:new", "n": None, "title": "Start a new venture: research, pick, build and launch one page",
"body": "The venture loop (venture.py): one venture, researched and picked by the lead, built and judged.",
@@ -114,7 +115,7 @@ def candidates(issues: list[dict]) -> list[dict]:
"""Every open card a worker could take, lined-up ones included (ranked, so the heartbeat promotes in order)."""
waits: dict[int, int] = {}
for i in issues:
for d in set(re.findall(r"Depends on #(\d+)", i.get("body") or "")):
for d in set(DEPENDS.findall(i.get("body") or "")):
waits[int(d)] = waits.get(int(d), 0) + 1
open_n = {i.get("number") for i in issues}
out = []
@@ -122,7 +123,7 @@ def candidates(issues: list[dict]) -> list[dict]:
labels, body = labels_of(i), i.get("body") or ""
if labels & HELD or not DONE.search(body):
continue
deps = sorted({int(d) for d in re.findall(r"Depends on #(\d+)", body)} & open_n) # a closed dependency is done
deps = sorted({int(d) for d in DEPENDS.findall(body)} & open_n) # a closed dependency is done
out.append({"key": f"#{i['number']}", "n": i["number"], "title": _txt(i.get("title"), 120), "body": body[:500],
"labels": sorted(labels), "lined_up": bool(labels & LINED_UP), "actionable": not deps,
"kind": guess_kind(i.get("title") or "", body, labels), "unblocks": waits.get(i["number"], 0),
+435
View File
@@ -0,0 +1,435 @@
"""Steward: the factory's repair tick, inside Time. A block that is only reported is a status page; this one repairs.
python3 .github/time/steward.py # every 6 hours (steward.yml) and on workflow_dispatch
Two parts, in order.
1. Deterministic, free, no model: merge card PRs that carry a trusted Approve and green checks and that `touches_protected`
does not hold (the same tests `land()` applies, TIME_AUTOMERGE included); close an `andon` whose cause has healed (a
spend window that has refilled -- a daily cap past midnight UTC, a pool back under its allowance -- or a key that is
now set); re-dispatch the heartbeat if its latest run failed.
2. Only for what is left and is a real block: one call to the steward seat (TIME_STEWARD, default `claude:opus`; `off`
disables the whole tick) with the repair charter. It books to the ledger like every other call. It repairs with the
tools below (labels, the few variables it may touch, a heartbeat retry, a comment that hands a card back to its
worker) and may escalate to the operator only for money beyond the caps or a human-only step. Code checks that, not
the prompt: `t_set_variable` and `t_escalate` refuse anything else whatever the charter says.
One entry per run goes to the Astra log (#57), in Astra's thread and style: Merged / Fixed / Left, with the reason for
anything left. A comment the steward leaves on a card carries `{"steward": true}` and no `beat`, so it cannot change the
card's state; the one exception is the `merged` beat of a PR it merged, which is the beat `land()` writes for the same act.
"""
from __future__ import annotations
import datetime as dt
import importlib.util
import json
import os
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py")
tm = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(tm)
LOG_TITLE = "Astra log" # astra.py's thread: one place a human reads the factory's day
DEFAULT_SEAT = "claude:opus"
HEARTBEAT = "heartbeat.yml"
STEWARD_LABELS = {"ready", "queued", "needs-human"}
# the only variables the steward may set, and the values it may set them to; never money (the caps), merge policy or trust
STEWARD_VARS = {"TIME_MODE": r"live|dry-run", "TIME_PARALLEL": r"[1-9]", "TIME_STUCK_HOURS": r"\d{1,3}", "TIME_DAILY_CAP": r"\d{1,3}"}
PARKED = ("error", "stuck", "blocked") # a card whose last beat says its worker stopped
# the reason sentences budget_check writes into an andon's body. The title says "... / $9.00 out of pocket this month"
# whichever cap tripped, so only the reason tells a daily breach from a monthly one.
MONTHLY = re.compile(r"month's (?:[\w-]+ )?spend is")
POOL = re.compile(r"\bpool (\S+) has spent\b")
DAILY = "today's spend is"
CHARTER = """You are the steward of a small, fully AI-run software factory (Time). The deterministic part of this run has
already merged what could be merged and closed what had healed. What follows is what is left, as JSON.
Your job, in order, for each block: name it; repair it with the tools you have; escalate only if it cannot be repaired.
- Tools: `label` (ready / queued / needs-human), `comment` (on a card or PR: hand a card back to its worker with the reason,
or leave a note the next run reads), `set_variable` (TIME_MODE, TIME_PARALLEL, TIME_STUCK_HOURS, TIME_DAILY_CAP only),
`dispatch_heartbeat` (a retry of the clock), `escalate`.
- Escalate to the operator ONLY for money beyond the caps (the caps are never yours to raise) or a human-only step: a login, a
payment, a legal signature. A failing check, an objection, a parked card, a conflict, a wrong label is yours: fix it or hand
the card back to its worker. "Needs a human" about something you could have repaired is the failure this seat exists to end.
- Never touch workflows, secrets, the spend caps, TIME_AUTOMERGE, TIME_PROTECTED or TIME_TRUSTED, and never merge anything.
- Be brief. When you are done, reply with 2-4 sentences: what you found, what you did, what you left and why."""
TOOLS = [
{"type": "function", "function": {"name": "label", "description": "Add or remove one label on a card or PR.",
"parameters": {"type": "object", "properties": {"number": {"type": "integer"}, "name": {"type": "string", "enum": sorted(STEWARD_LABELS)},
"action": {"type": "string", "enum": ["add", "remove"]}}, "required": ["number", "name", "action"]}}},
{"type": "function", "function": {"name": "comment", "description": "Comment on a card or PR as Time. Use it to hand a card back to its worker.",
"parameters": {"type": "object", "properties": {"number": {"type": "integer"}, "text": {"type": "string"}}, "required": ["number", "text"]}}},
{"type": "function", "function": {"name": "set_variable", "description": "Set one Actions variable: " + ", ".join(sorted(STEWARD_VARS)) + ".",
"parameters": {"type": "object", "properties": {"name": {"type": "string", "enum": sorted(STEWARD_VARS)}, "value": {"type": "string"}},
"required": ["name", "value"]}}},
{"type": "function", "function": {"name": "dispatch_heartbeat", "description": "Run the heartbeat workflow once, now.",
"parameters": {"type": "object", "properties": {}, "required": []}}},
{"type": "function", "function": {"name": "escalate", "description": "Open one issue for the operator. Only for money beyond the caps or a human-only step.",
"parameters": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["money", "human-only"]},
"title": {"type": "string"}, "reason": {"type": "string", "description": "What is blocked, what you tried, what exactly the human must do."}},
"required": ["kind", "title", "reason"]}}},
]
class Report:
"""What this run did: the three sections of the log entry."""
def __init__(self):
self.merged: list[str] = []
self.fixed: list[str] = []
self.left: list[dict] = [] # {"what", "why", "ask", "card", "since"}: ask = a real block the model may repair
self.escalated: list[str] = []
def leave(self, what: str, why: str, ask: bool = False, card: int | None = None, since: str = "") -> None:
self.left.append({"what": what, "why": why, "ask": ask, "card": card, "since": since})
def log_entry(seat: str, spend: tuple[float, float], rep: Report, note: str) -> tuple[str, dict]:
"""The log comment, in Astra's style: a header with the spend, the run's note, then Merged / Fixed / Left."""
def section(name: str, items: list[str]) -> list[str]:
return [f"{name}: none"] if not items else [f"{name}:"] + [f"- {i}" for i in items]
lines = [f"🧭 Steward ({seat}), spend ${spend[0]:.2f} of ${spend[1]:.2f} today:", "", note, ""]
lines += section("Merged", rep.merged) + section("Fixed", rep.fixed)
lines += section("Left", [f"{l['what']}: {l['why']}" for l in rep.left])
if rep.escalated:
lines += section("Escalated to the operator", rep.escalated)
meta = {"beat": "steward", "merged": len(rep.merged), "fixed": len(rep.fixed), "left": len(rep.left), "escalated": len(rep.escalated)}
return "\n".join(lines), meta
def log_issue() -> int:
"""Astra's thread, or a fresh one in Astra's words: the steward writes into the same log a human already reads."""
for i in tm.issues("state=open"):
if i["title"] == LOG_TITLE:
return i["number"]
return tm.new_issue(LOG_TITLE, "Astra, the daily planner, writes one comment here per day: what it read, what it "
"queued, and why. Object in a reply; Astra reads this thread too.", ["astra"])["number"]
def say(number: int, text: str, **meta) -> None:
tm.comment(number, f"🧭 Steward: {text}", {"steward": True, **meta})
# ---------- 1. deterministic: no model call, no money ----------
def card_of(pr: dict) -> int | None:
"""The card a PR answers: `Closes #N` in its body (sync.py puts one on every branch it brings in), or its branch
`time/card-N`. A PR that answers no card is nobody's to merge here."""
m = re.search(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)", pr.get("body") or "", re.I) \
or re.match(r"time/card-(\d+)", (pr.get("head") or {}).get("ref") or "")
return int(m.group(1)) if m else None
def merge_ready(rep: Report) -> None:
"""Merge every card PR that `land()` would merge. What it would not merge is left, with the reason."""
automerge = (os.environ.get("TIME_AUTOMERGE") or "off").strip() == "on"
for listed in tm.gh("GET", f"/repos/{tm.REPO}/pulls?state=open&limit=50"):
n = card_of(listed)
if n is None or listed.get("state", "open") != "open":
continue
what = f"PR #{listed['number']} (card #{n})"
try:
comments = tm.gh("GET", f"/repos/{tm.REPO}/issues/{n}/comments?limit=100")
beats = tm.beats_of(comments)
last = beats[-1]["at"] if beats else ""
pr = tm.gh("GET", f"/repos/{tm.REPO}/pulls/{listed['number']}")
if pr.get("merged_at") or pr.get("state", "open") != "open":
continue
# the same window land() reads: a verdict from before the worker answered a review no longer counts
revised = max((b["at"] for b in beats if b.get("revised")), default="")
cast = tm.votes(pr, max(pr["created_at"], revised))
v = tm.decide(cast)
if v == "object":
rep.leave(what, "a reviewer objected", ask=True, card=n, since=last)
elif v is None:
rep.leave(what, "no trusted verdict yet" if not cast else "the council is split or short of the quorum",
ask=bool(cast), card=n, since=last)
elif (hit := tm.touches_protected(pr["number"])):
rep.leave(what, f"approved, but it touches protected paths ({', '.join(hit[:5])}): a human merges this one")
elif not automerge:
rep.leave(what, "approved, but TIME_AUTOMERGE is not on")
else:
_merge(pr, n, what, beats, rep)
except Exception as e: # noqa: BLE001 one PR must not stop the others or the log
rep.leave(what, f"could not be checked: {type(e).__name__}: {str(e)[:160]}", ask=True)
def _merge(pr: dict, n: int, what: str, beats: list[dict], rep: Report) -> None:
"""An approved, unprotected card PR: merge it on green, or leave it with what is in the way (land()'s order)."""
last = beats[-1] if beats else {}
if pr.get("mergeable") is False: # the heartbeat merges its base in; the steward only says what it sees
rep.leave(what, "approved, but it no longer merges cleanly", ask=True, card=n, since=last.get("at", ""))
return
c = tm.checks(pr["head"]["sha"])
need = (os.environ.get("TIME_REQUIRE_CHECKS") or "on").strip() != "off"
if c == "pending":
rep.leave(what, "approved; checks are pending")
elif c == "none" and need:
if last.get("beat") in ("no-checks", "ci-dispatched"):
rep.leave(what, "approved, but there are still no checks and I only merge on green", ask=True, card=n, since=last.get("at", ""))
elif tm.dispatch_ci(pr):
say(n, f"approved; running CI on PR #{pr['number']} before merging.", beat="ci-dispatched", pr=pr["number"])
rep.fixed.append(f"dispatched CI on {what}, which had none")
else:
rep.leave(what, "approved, but it has no checks and there is no CI workflow to dispatch", ask=True)
elif c == "fail":
rep.leave(what, "approved, but checks are failing", ask=True, card=n, since=last.get("at", ""))
else:
tm.gh("POST", f"/repos/{tm.REPO}/pulls/{pr['number']}/merge", {"Do": "squash", "delete_branch_after_merge": True}, as_time=True)
if tm.gh("GET", f"/repos/{tm.REPO}/issues/{n}")["state"] == "open":
tm.gh("PATCH", f"/repos/{tm.REPO}/issues/{n}", {"state": "closed"})
# the one steward comment that keeps a beat: it is the `merged` beat land() writes for the same act, so the card reads as done
tm.comment(n, f"⏱️ Time: approved and green, merged PR #{pr['number']}. Card done.", {"beat": "merged", "pr": pr["number"], "steward": True})
rep.merged.append(what)
def healed(andon: dict) -> str | None:
"""Why this andon's cause is gone, or None when it is not (or cannot be told to be).
A spend window that has refilled and a key that is now set are facts the code can read. The monthly cap is money: the
operator raises it, nobody else."""
text = f"{andon.get('title') or ''}\n{andon.get('body') or ''}"
today, day_cap = tm.budget()[:2]
if MONTHLY.search(text): # monthly: a human raises the cap and sets TIME_MODE back; not mine to close
return None
if (m := POOL.search(text)): # a capacity pool refills on its own schedule
p = next((p for p in tm.pools() if p.get("name") == m.group(1)), None)
if p and tm.pool_spent(p) < tm.allowance(p):
return f"pool {p['name']} has ${tm.allowance(p) - tm.pool_spent(p):.2f} of its allowance again"
return None
if DAILY in text: # budget_check's daily reason: it heals itself at midnight UTC
if (andon.get("created_at") or "9")[:10] < dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d") and today < day_cap:
return f"it was a daily cap, and today's spend is ${today:.2f} of ${day_cap:.2f} after midnight UTC"
return None
keys = re.findall(r"\bno ([A-Z][A-Z0-9_]+) secret\b", text)
if keys and all(os.environ.get(k) for k in keys):
return f"{', '.join(keys)} {'is' if len(keys) == 1 else 'are'} set now"
return None
def close_healed(rep: Report) -> None:
"""Close each open `andon` whose cause has healed, saying why on the issue."""
for a in tm.issues("state=open&labels=andon"):
why = healed(a)
if not why:
continue
try:
say(a["number"], f"closing this andon: {why}.", healed=True)
tm.gh("PATCH", f"/repos/{tm.REPO}/issues/{a['number']}", {"state": "closed"})
rep.fixed.append(f"closed andon #{a['number']}: {why}")
except Exception as e: # noqa: BLE001
rep.leave(f"andon #{a['number']}", f"healed ({why}) but I could not close it: {str(e)[:160]}", ask=True)
def still_open(rep: Report) -> None:
"""Every andon the deterministic part did not close, with what it waits on. Money is named as money: the model is not
asked to repair a cap it may not raise."""
spoken = {l["what"] for l in rep.left}
for a in tm.issues("state=open&labels=andon"):
what = f"andon #{a['number']}"
if what in spoken:
continue
text = f"{a.get('title') or ''}\n{a.get('body') or ''}"
if MONTHLY.search(text):
rep.leave(what, "the monthly cap is money: the operator raises it, and sets TIME_MODE back to live")
elif POOL.search(text):
rep.leave(what, "a capacity pool is spent for now; it refills on its own schedule")
elif DAILY in text:
rep.leave(what, "a daily cap, which heals after midnight UTC")
else:
rep.leave(what, "its cause has not healed", ask=True)
def heartbeat_failed() -> dict | None:
"""The heartbeat's latest run if it failed, else None. Raises when the forge cannot list runs."""
out = tm.gh("GET", f"/repos/{tm.REPO}/actions/runs?limit=50")
runs = out.get("workflow_runs") if isinstance(out, dict) else out
# Gitea gives the workflow as `path`, bare or as `.github/workflows/heartbeat.yml@refs/heads/main`; `name` is its `name:`
mine = [r for r in runs or [] if HEARTBEAT in str(r.get("path") or "") or r.get("name") == "heartbeat"]
if not mine:
return None
latest = max(mine, key=lambda r: r.get("id") or 0)
# only `failure`: a run the concurrency group cancelled, or one still going, is not a broken clock
return latest if str(latest.get("conclusion") or latest.get("status") or "").lower() == "failure" else None
def dispatch_heartbeat() -> None:
tm.gh("POST", f"/repos/{tm.REPO}/actions/workflows/{HEARTBEAT}/dispatches", {"ref": tm.default_branch()})
def retry_heartbeat(rep: Report) -> None:
"""A clock that stopped is the factory stopped: one dispatch, this run."""
try:
run = heartbeat_failed()
except Exception as e: # noqa: BLE001 the forge may not list runs at all; that is not a block to repair
rep.leave("heartbeat", f"I could not read its runs: {str(e)[:160]}")
return
if not run:
return
try:
dispatch_heartbeat()
rep.fixed.append(f"the heartbeat's latest run (#{run.get('id')}) failed; dispatched it once")
except Exception as e: # noqa: BLE001
rep.leave("heartbeat", f"its latest run (#{run.get('id')}) failed and I could not dispatch it: {str(e)[:160]}", ask=True)
def parked_cards(rep: Report) -> None:
"""A `ready` card whose last beat says its worker stopped is a block the steward can hand back."""
for card in tm.issues("state=open&labels=ready"):
try:
cs = tm.gh("GET", f"/repos/{tm.REPO}/issues/{card['number']}/comments?limit=100")
real = [b for b in tm.beats_of(cs) if b["beat"] not in ("capped",)]
if real and real[-1]["beat"] in PARKED:
rep.leave(f"card #{card['number']}", f"parked on `{real[-1]['beat']}`", ask=True, card=card["number"], since=real[-1]["at"])
except Exception as e: # noqa: BLE001
print(f"card #{card['number']}: {e}")
def already_handled(rep: Report) -> None:
"""A block the steward has already spoken on since the card's last beat goes back to waiting: no second model call,
and no second comment, for the same thing. The card moves, or a human does."""
for item in rep.left:
if not (item["ask"] and item["card"]):
continue
cs = tm.gh("GET", f"/repos/{tm.REPO}/issues/{item['card']}/comments?limit=100")
if any(tm.is_time(c) and '"steward":true' in (c.get("body") or "") and c["created_at"] > item["since"] for c in cs):
item["ask"], item["why"] = False, item["why"] + " (the steward has handed this back already; it waits for the card to move)"
# ---------- 2. the model, for what is left ----------
class Tools:
"""The steward's hands. Every fence is here, not in the charter: a prompt cannot talk its way past code."""
def __init__(self, rep: Report):
self.rep = rep
def call(self, name: str, a: dict) -> str:
try:
return getattr(self, f"t_{name}")(a)
except AttributeError:
return f"unknown tool {name}"
except Exception as e: # noqa: BLE001 the model reads the error and tries something else
return f"error: {type(e).__name__}: {str(e)[:300]}"
def t_label(self, a: dict) -> str:
n, name = int(a["number"]), str(a["name"])
if name not in STEWARD_LABELS:
return f"refused: the steward may only use {', '.join(sorted(STEWARD_LABELS))}"
(tm.label if a.get("action") == "add" else tm.unlabel)(n, name)
self.rep.fixed.append(f"{a.get('action')} label `{name}` on #{n}")
return "done"
def t_comment(self, a: dict) -> str:
say(int(a["number"]), str(a["text"])[:3000])
self.rep.fixed.append(f"commented on #{a['number']}")
return "done"
def t_set_variable(self, a: dict) -> str:
name, value = str(a["name"]), str(a["value"])
if name not in STEWARD_VARS or not re.fullmatch(STEWARD_VARS[name], value):
return f"refused: allowed variables are {', '.join(f'{k} ({v})' for k, v in STEWARD_VARS.items())}"
if name == "TIME_MODE" and value == "live" and tm.budget()[2] >= tm.budget()[3]:
return "refused: the monthly cap is reached; raising it is the operator's"
path = f"/repos/{tm.REPO}/actions/variables/{name}"
try:
tm.gh("PUT", path, {"value": value})
except RuntimeError as e: # Gitea: PUT updates an existing variable, POST creates it
if "-> 404" not in str(e):
raise
tm.gh("POST", path, {"value": value})
self.rep.fixed.append(f"set variable {name}={value}")
return "done"
def t_dispatch_heartbeat(self, a: dict) -> str:
dispatch_heartbeat()
self.rep.fixed.append("dispatched the heartbeat")
return "dispatched"
def t_escalate(self, a: dict) -> str:
kind = str(a.get("kind"))
if kind not in ("money", "human-only"):
return "refused: escalate only for `money` beyond the caps or a `human-only` step (a login, a payment, a legal signature)"
title = f"Steward: {str(a['title'])[:150]}"
if any(i["title"] == title for i in tm.issues("state=open&labels=needs-human")):
return "already open"
tm.new_issue(title, f"🧭 The steward cannot repair this and hands it to the operator ({kind}).\n\n{str(a['reason'])[:3000]}\n\n"
"Close this once it's handled.", ["needs-human"])
self.rep.escalated.append(f"{kind}: {title}")
return "opened"
def ask_steward(spec: str, blocks: list[dict], context: dict, rep: Report) -> str:
"""One conversation with the steward seat. Books to the ledger through tm.chat; a cap or a missing key ends it politely."""
seat, model, effort = tm.worker_seat_model(spec)
user = "Blocks:\n" + json.dumps(blocks, indent=1) + "\n\nContext:\n" + json.dumps(context, indent=1)
msgs = [{"role": "system", "content": CHARTER}, {"role": "user", "content": user}]
tools, last = Tools(rep), ""
for _ in range(int(os.environ.get("TIME_STEWARD_STEPS") or 8)):
body = {"model": model, "temperature": 0.2, "max_tokens": 4000, "messages": msgs, "tools": TOOLS,
"venice_parameters": {"include_venice_system_prompt": False}}
if effort:
body["reasoning_effort"] = effort
try:
msg = tm.chat(body, timeout=240)["choices"][0]["message"]
except (tm.Budget, LookupError) as e: # out of money, or no key: the deterministic part still gets its log entry
return f"No model call: {e}."
msgs.append({k: v for k, v in msg.items() if k in ("role", "content", "tool_calls")})
last = msg.get("content") or last
calls = msg.get("tool_calls") or []
if not calls:
break
for c in calls:
try:
args = json.loads(c["function"].get("arguments") or "{}")
except ValueError:
args = {}
msgs.append({"role": "tool", "tool_call_id": c["id"], "content": tools.call(c["function"]["name"], args)})
return re.sub(r"<think>.*?</think>", "", last, flags=re.S).strip()[:1200] or "(the steward gave no summary)"
def context() -> dict:
"""What the seat needs to judge a block without reading the whole forge."""
today, day_cap, month, month_cap = tm.budget()
return {"mode": os.environ.get("TIME_MODE") or "dry-run", "automerge": os.environ.get("TIME_AUTOMERGE") or "off",
"spend": {"today": round(today, 2), "daily_cap": day_cap, "month": round(month, 2), "monthly_cap": month_cap},
"open_andons": [{"n": i["number"], "title": i["title"], "body": (i.get("body") or "")[:500]} for i in tm.issues("state=open&labels=andon")],
"needs_human": [{"n": i["number"], "title": i["title"]} for i in tm.issues("state=open&labels=needs-human")]}
# ---------- the tick ----------
def tick() -> int:
spec = (os.environ.get("TIME_STEWARD") or DEFAULT_SEAT).strip()
if spec.lower() == "off":
print("steward: off")
return 0
rep = Report()
for step in (merge_ready, close_healed, retry_heartbeat, parked_cards, still_open):
try:
step(rep)
except Exception as e: # noqa: BLE001 a forge hiccup in one step must not skip the rest or the log
rep.leave(step.__name__, f"{type(e).__name__}: {str(e)[:160]}")
try:
already_handled(rep)
except Exception as e: # noqa: BLE001
print(f"steward: {e}")
blocks = [l for l in rep.left if l["ask"]]
note = "Nothing needed a model call."
if blocks:
note = ask_steward(spec, [{"what": b["what"], "why": b["why"]} for b in blocks], context(), rep)
spend = tm.budget()
text, meta = log_entry(spec, (spend[0], spend[1]), rep, note)
tm.comment(log_issue(), text, meta)
print(f"steward: merged={len(rep.merged)} fixed={len(rep.fixed)} left={len(rep.left)} asked={bool(blocks)}")
return 0
if __name__ == "__main__":
sys.exit(tick())
+28 -5
View File
@@ -24,8 +24,11 @@ LOG = 57
class Forge:
def __init__(self, branches=(), prs=(), issues=(), milestones=(), reactions=None, comments=None, repo_labels=None, files=None):
self.branches = [{"name": b} if isinstance(b, str) else b for b in branches]
def __init__(self, branches=(), prs=(), issues=(), milestones=(), reactions=None, comments=None, repo_labels=None,
files=None, soft_open=False):
self.soft_open = soft_open # Gitea answers `pulls?state=open` with null, as it does other empty lists
# a branch the forge lists carries its tip: `sha-<name>`, the sha a PR from that head merges, unless given
self.branches = [{"name": b, "commit": {"id": f"sha-{b}"}} if isinstance(b, str) else b for b in branches]
self.prs = list(prs)
self.issues = {i["number"]: {"state": "open", "labels": [], "created_at": iso(0), "updated_at": iso(0), "body": "", **i} for i in issues}
self.milestones = [dict(m) for m in milestones]
@@ -59,6 +62,8 @@ class Forge:
return {}
if route == "/repos/o/r/pulls":
want = q.get("state", ["open"])[0]
if want == "open" and self.soft_open:
return None
return self.page([p for p in self.prs if p["state"] == want], path)
if route == "/repos/o/r/milestones" and method == "GET":
return self.page([m for m in self.milestones if m.get("state", "open") == "open"], path)
@@ -112,9 +117,10 @@ class Forge:
return hk.main()
def pr(n, head, state="open", merged=False, base="main", idle=0, merged_days=1):
def pr(n, head, state="open", merged=False, base="main", idle=0, merged_days=1, sha=None):
return {"number": n, "title": f"pr {n}", "state": state, "merged": merged, "merged_at": iso(merged_days) if merged else None,
"base": {"ref": base}, "head": {"ref": head, "repo": {"full_name": "o/r"}}, "created_at": iso(idle), "updated_at": iso(idle)}
"base": {"ref": base}, "head": {"ref": head, "sha": sha or f"sha-{head}", "repo": {"full_name": "o/r"}},
"created_at": iso(idle), "updated_at": iso(idle)}
deleted = lambda f: [urllib.parse.unquote(w[1].split("/branches/")[1]) for w in f.writes if w[0] == "DELETE"]
@@ -140,6 +146,22 @@ def test_branches():
f.run(); assert deleted(f) == []
def test_branches_keeps_work_the_default_branch_lacks():
"""#277: the open-PR list is not the only guard. Gitea can answer `pulls?state=open` with null (it does that for
other empty lists), and a card branch that also had an earlier merged PR from the same head was deleted; the next
retry cut it again from `main`, so the open PR's diff went empty. A tip that is not the commit its merged PR landed
is kept whatever that list says."""
f = Forge(branches=[{"name": "time/card-244", "commit": {"id": "sha-the-new-round"}}],
prs=[pr(248, "time/card-244", "closed", True), pr(249, "time/card-244", "open")], soft_open=True)
assert f.run() == 0
assert deleted(f) == [] and [b["name"] for b in f.branches] == ["time/card-244"], deleted(f)
assert any("0 branch(es) deleted" in b for _, b in f.posted), f.posted
# the guard is that precise: a branch still sitting on the commit its PR merged holds nothing `main` lacks, so a
# soft-empty page does not keep it from being swept
f = Forge(branches=["time/card-53"], prs=[pr(1, "time/card-53", "closed", True)], soft_open=True)
f.run(); assert deleted(f) == ["time/card-53"]
def sig(n, venture, age):
return {"number": n, "title": f"{venture}: want the next version?", "labels": ["signal"], "created_at": iso(age),
"body": f"Join: https://forge.test/o/r/issues/new?template=early-access.yml&title=x&venture={venture}"}
@@ -321,7 +343,8 @@ def test_dry_run():
if __name__ == "__main__":
tests = [test_branches, test_signals, test_milestones, test_outcomes, test_outcomes_venture, test_stale,
tests = [test_branches, test_branches_keeps_work_the_default_branch_lacks, test_signals, test_milestones,
test_outcomes, test_outcomes_venture, test_stale,
test_unknown_label, test_superseded, test_dry_run]
for t in tests:
t()
+291
View File
@@ -0,0 +1,291 @@
"""Offline tests for steward.py: a fake forge, no network, no model. Run: python3 .github/time/test_steward.py
(test_time.py runs this file too, so the one CI command covers it.)"""
import datetime as dt, importlib.util, json, os, urllib.parse
from pathlib import Path
for _k in [k for k in os.environ if k.startswith("TIME_")] + ["ANTHROPIC_API_KEY", "VENICE_API_KEY", "CURSOR_API_KEY"]:
os.environ.pop(_k, None)
os.environ.update(GITHUB_REPOSITORY="o/r", GITHUB_TOKEN="gh", GITHUB_SERVER_URL="https://forge.test", GITHUB_API_URL="https://forge.test/api/v1")
spec = importlib.util.spec_from_file_location("steward", Path(__file__).resolve().parent / "steward.py")
st = importlib.util.module_from_spec(spec); spec.loader.exec_module(st)
tm = st.tm
NOW = dt.datetime.now(dt.timezone.utc)
iso = lambda h=0: (NOW - dt.timedelta(hours=h)).strftime("%Y-%m-%dT%H:%M:%SZ")
BOT = {"login": "gitea-actions"}
# the andon bodies tm.andon() writes: the title names both figures whichever cap tripped, so only the reason sentence
# (budget_check's) tells a daily breach from a monthly one or a pool's
CAP_TITLE = "Spend cap reached: $5.20 today / $9.00 out of pocket this month"
CAPS = "\n\n- Daily cap (`TIME_DAILY_USD`): $5.00\n- Monthly cap (`TIME_MONTHLY_USD`): $100.00"
DAILY = "⏱️ Time stopped calling models: today's spend is $5.20 of the $5.00 cap; carrying on tomorrow." + CAPS
MONTHLY = ("⏱️ Time stopped calling models: this month's out-of-pocket spend is $100.10 of the $100.00 cap (credit-paid "
"spend excluded); Time is back in dry-run until a human raises it." + CAPS)
POOLED = "⏱️ Time stopped calling models: pool anthropic-credits has spent $8.10 of its $8.00 allowance; carrying on when it refills." + CAPS
class Forge:
def __init__(self):
self.prs, self.comments, self.reviews, self.files, self.statuses = {}, {}, {}, {}, {}
self.issues = {57: {"number": 57, "title": "Astra log", "body": "", "state": "open", "labels": ["astra"], "created_at": iso(100)}}
self.merged, self.dispatched, self.runs, self.ci, self.vars, self.chats = [], [], [], [], {}, 0
self.trusted = {"rev"}
def pr(self, n, card, files=("app.py",), status="success", approve=True, mergeable=True, body=None):
self.prs[n] = {"number": n, "state": "open", "merged_at": None, "created_at": iso(3), "mergeable": mergeable, "body": body or f"Closes #{card}",
"head": {"ref": f"time/card-{card}", "sha": f"sha{n}"}}
self.files[n] = list(files)
self.statuses[f"sha{n}"] = [{"context": "ci / test", "status": status}] if status else []
self.issues.setdefault(card, {"number": card, "title": f"card {card}", "body": "", "state": "open", "labels": [], "created_at": iso(5)})
self.comments.setdefault(card, [])
if approve:
self.comments[n] = [{"body": "**Approve**\nlooks right", "user": {"login": "rev"}, "created_at": iso(1)}]
def add_issue(self, n, title, body, labels, created=0):
self.issues[n] = {"number": n, "title": title, "body": body, "state": "open", "labels": labels, "created_at": iso(created)}
def gh(self, method, path, body=None, *, as_time=None):
base = "/repos/o/r"
p, _, q = path.partition("?")
q = urllib.parse.parse_qs(q)
seg = p[len(base):].strip("/").split("/")
if method == "GET" and seg[0] == "pulls" and len(seg) == 1:
return [dict(x) for x in self.prs.values() if x["state"] == "open"]
if seg[0] == "pulls" and len(seg) == 2 and method == "GET":
return dict(self.prs[int(seg[1])])
if seg[0] == "pulls" and seg[-1] == "files":
return [{"filename": f} for f in self.files[int(seg[1])]]
if seg[0] == "pulls" and seg[-1] == "reviews":
return self.reviews.get(int(seg[1]), [])
if seg[0] == "pulls" and seg[-1] == "merge":
n = int(seg[1]); assert body["Do"] == "squash"
self.prs[n].update(merged_at=iso(), state="closed"); self.merged.append(n); return {}
if seg[0] == "commits":
return {"statuses": self.statuses[seg[1]]}
if seg[0] == "collaborators":
if seg[1] in self.trusted: return {}
raise RuntimeError(f"GET {path} -> 404: no")
if seg[0] == "issues" and len(seg) == 1 and method == "GET":
out = [i for i in self.issues.values() if i["state"] == q.get("state", ["open"])[0]
and all(l in i["labels"] for l in q.get("labels", [""])[0].split(",") if l)]
return [dict(i, labels=[{"name": l} for l in i["labels"]]) for i in out]
if seg[0] == "issues" and len(seg) == 1 and method == "POST":
n = max(self.issues) + 1
self.add_issue(n, body["title"], body["body"], [self.lname(l) for l in body["labels"]])
return {"number": n}
if seg[0] == "issues" and seg[-1] == "comments":
n = int(seg[1])
if method == "POST":
self.comments.setdefault(n, []).append({"body": body["body"], "user": BOT, "created_at": iso()}); return {}
return self.comments.get(n, [])
if seg[0] == "issues" and len(seg) == 2:
n = int(seg[1])
if method == "PATCH": self.issues[n].update(body); return {}
return dict(self.issues[n])
if seg[0] == "issues" and seg[2] == "labels":
n = int(seg[1])
if method == "POST": self.issues[n]["labels"] += [self.lname(l) for l in body["labels"]]
else: self.issues[n]["labels"].remove(self.lname(int(seg[3])))
return {}
if seg[0] == "labels":
if method == "POST": return {"id": 99}
return [{"name": n, "id": i} for i, n in enumerate(["ready", "queued", "needs-human"], 1)]
if seg[0] == "actions" and seg[1] == "runs":
return {"workflow_runs": self.runs}
if seg[0] == "actions" and seg[1] == "workflows":
if seg[2] == "ci.yml": self.ci.append(body["ref"]); return {}
self.dispatched.append(seg[2]); return {}
if seg[0] == "actions" and seg[1] == "variables":
if method == "PUT" and seg[2] not in self.vars: raise RuntimeError(f"PUT {p} -> 404: not found") # Gitea: PUT updates, POST creates
self.vars[seg[2]] = body["value"]; return {}
if p == base:
return {"default_branch": "main"}
raise AssertionError(f"unmocked {method} {path}")
def lname(self, x):
return x if isinstance(x, str) else {1: "ready", 2: "queued", 3: "needs-human"}.get(x, f"label{x}")
def log(self):
return [c["body"] for c in self.comments.get(57, [])]
def world(**env):
f = Forge()
tm.gh = f.gh
tm._trust.clear(); tm._label_ids = None
for k in [k for k in os.environ if k.startswith("TIME_")] + ["VENICE_API_KEY", "ANTHROPIC_API_KEY"]: os.environ.pop(k, None)
os.environ.update({"TIME_AUTOMERGE": "on", **env})
tm.budget = lambda: (1.0, 5.0, 10.0, 100.0)
tm.pools = lambda: []
def no_model(body, timeout): f.chats += 1; raise AssertionError("the deterministic part must not call a model")
tm.chat = no_model
return f
def run():
assert st.tick() == 0
def beat_of(comment):
return json.loads(comment.split(tm.MARK, 1)[1].rsplit("-->", 1)[0])
# 1. a green, approved, unprotected card PR is merged without a model call; the log says so
f = world()
f.pr(59, 12)
run()
assert f.merged == [59] and f.chats == 0, (f.merged, f.chats)
assert f.issues[12]["state"] == "closed", "the card closes with its PR"
assert any(beat_of(c["body"])["beat"] == "merged" for c in f.comments[12]), "the card gets its merged beat, as land() writes it"
entry = f.log()
assert len(entry) == 1, "one entry per run"
assert "Merged:\n- PR #59 (card #12)" in entry[0] and "Fixed: none" in entry[0] and "Left: none" in entry[0], entry[0]
assert beat_of(entry[0]) == {"beat": "steward", "merged": 1, "fixed": 0, "left": 0, "escalated": 0}
# ... but not on a stranger's Approve, without an approval, on red checks, with TIME_AUTOMERGE off, or on a PR that closes no card
f = world(); f.pr(60, 13); f.comments[60] = [{"body": "Approve", "user": {"login": "stranger"}, "created_at": iso(1)}]; run()
assert f.merged == [] and "no trusted verdict yet" in f.log()[0]
f = world(); f.pr(60, 13, approve=False); run()
assert f.merged == [] and "no trusted verdict yet" in f.log()[0]
f = world(); f.pr(60, 13, status="failure")
tm.chat = lambda body, timeout: {"choices": [{"message": {"role": "assistant", "content": "Checks fail on #13; handed back."}}], "usage": {}}
run()
assert f.merged == [] and "checks are failing" in f.log()[0] and "handed back" in f.log()[0]
f = world(TIME_AUTOMERGE="off"); f.pr(60, 13); run()
assert f.merged == [] and "TIME_AUTOMERGE is not on" in f.log()[0]
f = world(); f.pr(61, 14, body="no link"); f.prs[61]["head"]["ref"] = "feature/x"; run()
assert f.merged == [] and "PR #61" not in f.log()[0], "only card PRs are the steward's"
f = world(); f.pr(60, 13, status="pending"); run()
assert f.merged == [] and "checks are pending" in f.log()[0] and f.chats == 0, "waiting on CI is not a block"
# a PR brought in from the copy on its own branch is a card PR: sync.py puts `Closes #N` in the body
f = world(); f.pr(62, 15, body="Closes #15\n\nFrom the copy: `claude/card-15-x`, brought in by sync.py.")
f.prs[62]["head"]["ref"] = "claude/card-15-x"; run()
assert f.merged == [62] and st.card_of(f.prs[62]) == 15
# an approved PR that no longer merges cleanly is left for the heartbeat's base merge, not merged
f = world(); f.pr(63, 16, mergeable=False)
tm.chat = lambda body, timeout: {"choices": [{"message": {"role": "assistant", "content": "The branch is behind; the heartbeat merges it."}}], "usage": {}}
run()
assert f.merged == [] and "no longer merges cleanly" in f.log()[0]
# no checks yet: one CI dispatch, then it waits (no second dispatch, no merge)
f = world(); f.pr(60, 13, status=None); run()
assert f.merged == [] and f.ci == ["time/card-13"] and "dispatched CI" in f.log()[0]
# 2. a protected PR is left, with the reason, and no model is asked
f = world(); f.pr(64, 17, files=("app.py", ".github/workflows/ci.yml")); run()
assert f.merged == [] and f.chats == 0
assert "Left:\n- PR #64 (card #17): approved, but it touches protected paths (.github/workflows/ci.yml): a human merges this one" in f.log()[0], f.log()[0]
# 3. andons: a healed daily cap closes, an unhealed monthly one does not
f = world()
f.add_issue(70, CAP_TITLE, DAILY, ["andon"], created=30)
f.add_issue(71, CAP_TITLE, MONTHLY, ["andon"], created=30)
f.add_issue(72, CAP_TITLE, DAILY, ["andon"], created=0) # tripped today: not healed yet
run()
assert f.issues[70]["state"] == "closed" and f.issues[71]["state"] == "open" and f.issues[72]["state"] == "open", [f.issues[n]["state"] for n in (70, 71, 72)]
assert f.chats == 0
assert "closed andon #70" in f.log()[0] and "andon #71: the monthly cap is money" in f.log()[0] and "andon #72: a daily cap" in f.log()[0], f.log()[0]
# a daily andon from yesterday is not healed while today is over the cap again
f = world(); tm.budget = lambda: (5.5, 5.0, 10.0, 100.0)
f.add_issue(70, CAP_TITLE, DAILY, ["andon"], created=30); run()
assert f.issues[70]["state"] == "open" and "a daily cap" in f.log()[0]
# a pool breach closes once the pool is back under its allowance, and not before
POOL = {"name": "anthropic-credits", "kind": "window", "routes": ["anthropic"], "resets_at": "hours:5"}
f = world(); tm.pools = lambda: [POOL]; tm.pool_spent = lambda p, now=None: 2.0; tm.allowance = lambda p, now=None: 8.0
f.add_issue(70, CAP_TITLE, POOLED, ["andon"], created=1); run()
assert f.issues[70]["state"] == "closed" and "$6.00 of its allowance again" in f.log()[0], f.log()[0]
f = world(); tm.pools = lambda: [POOL]; tm.pool_spent = lambda p, now=None: 8.1; tm.allowance = lambda p, now=None: 8.0
f.add_issue(70, CAP_TITLE, POOLED, ["andon"], created=1); run()
assert f.issues[70]["state"] == "open" and "a capacity pool is spent for now" in f.log()[0], f.log()[0]
# a missing key that is set now
f = world(VENICE_API_KEY="k")
f.add_issue(73, "Venice key missing", "Time can't go further: no VENICE_API_KEY secret on this repo yet.", ["andon"]); run()
assert f.issues[73]["state"] == "closed" and "VENICE_API_KEY is set now" in f.log()[0]
f = world(); f.add_issue(73, "Venice key missing", "no VENICE_API_KEY secret on this repo yet", ["andon"])
tm.chat = lambda body, timeout: {"choices": [{"message": {"role": "assistant", "content": "A key is a human-only step."}}], "usage": {}}
run(); assert f.issues[73]["state"] == "open"
# 4. the heartbeat: a failed latest run gets one dispatch; a green one, or another workflow's failure, none
HB = lambda i, c: {"id": i, "path": ".github/workflows/heartbeat.yml@refs/heads/main", "name": "heartbeat", "status": c, "conclusion": c}
f = world(); f.runs = [HB(1, "success"), HB(2, "failure")]; run()
assert f.dispatched == ["heartbeat.yml"] and "dispatched it once" in f.log()[0] and f.chats == 0, f.dispatched
f = world(); f.runs = [HB(1, "failure"), HB(2, "success")]; run()
assert f.dispatched == [], "only the latest run counts"
f = world(); f.runs = [HB(1, "success"), {"id": 5, "path": ".github/workflows/ci.yml@refs/heads/main", "name": "ci", "status": "failure"}]; run()
assert f.dispatched == []
f = world(); f.runs = [HB(1, "cancelled")]; run()
assert f.dispatched == [], "a run the concurrency group cancelled is not a failure"
# 5. the log entry format
rep = st.Report(); rep.merged.append("PR #1 (card #2)"); rep.fixed.append("closed andon #3"); rep.leave("PR #4 (card #5)", "a reviewer objected")
text, meta = st.log_entry("claude:opus", (1.234, 5), rep, "Note.")
assert text.splitlines()[0] == "🧭 Steward (claude:opus), spend $1.23 of $5.00 today:"
assert text.splitlines()[2:] == ["Note.", "", "Merged:", "- PR #1 (card #2)", "Fixed:", "- closed andon #3", "Left:", "- PR #4 (card #5): a reviewer objected"], text.splitlines()
assert meta == {"beat": "steward", "merged": 1, "fixed": 1, "left": 1, "escalated": 0}
# 6. what is left and blocking: one model call, booked through tm.chat, with the charter; its tools are fenced
f = world(); f.pr(80, 20, approve=False); f.comments[80] = [{"body": "**Object**\nno test", "user": {"login": "rev"}, "created_at": iso(1)}]
f.add_issue(20, "card 20", "", ["ready"])
calls = []
def steward_model(body, timeout):
calls.append(body)
if len(calls) == 1:
tc = lambda i, name, args: {"id": f"c{i}", "type": "function", "function": {"name": name, "arguments": json.dumps(args)}}
return {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": [
tc(1, "comment", {"number": 20, "text": "The reviewer wants a test; please add one."}),
tc(2, "set_variable", {"name": "TIME_AUTOMERGE", "value": "on"}),
tc(3, "set_variable", {"name": "TIME_MODE", "value": "live"}),
tc(4, "escalate", {"kind": "boredom", "title": "x", "reason": "y"}),
tc(5, "label", {"number": 80, "name": "andon", "action": "add"})]}}], "usage": {}}
return {"choices": [{"message": {"role": "assistant", "content": "PR #80 was objected to; I handed card #20 back with the reviewer's point."}}], "usage": {}}
tm.chat = steward_model
run()
assert len(calls) == 2 and calls[0]["model"] == "claude-opus-5-5"
assert "ONLY for money" in calls[0]["messages"][0]["content"] and "a reviewer objected" in calls[0]["messages"][1]["content"]
tool_results = [m["content"] for m in calls[1]["messages"] if m["role"] == "tool"]
assert tool_results[0] == "done" and tool_results[1].startswith("refused") and tool_results[2] == "done" and tool_results[3].startswith("refused") and tool_results[4].startswith("refused"), tool_results
assert f.vars == {"TIME_MODE": "live"}, f.vars
assert "-> 404" not in "".join(tool_results), "the first set_variable on a fresh repo creates the variable"
st.Tools(st.Report()).t_set_variable({"name": "TIME_MODE", "value": "dry-run"}); assert f.vars == {"TIME_MODE": "dry-run"}, "an existing one is updated"
assert any('"steward":true' in c["body"] and "The reviewer wants a test" in c["body"] for c in f.comments[20])
assert not any('"beat"' in c["body"] for c in f.comments[20]), "a steward comment carries no beat: it cannot move the card"
assert "handed card #20 back" in f.log()[0] and "Left:\n- PR #80 (card #20): a reviewer objected" in f.log()[0]
# the steward has spoken on card #20 since its last beat: the next run does not ask again
tm.chat = lambda body, timeout: (_ for _ in ()).throw(AssertionError("asked twice"))
run(); assert "the steward has handed this back already" in f.log()[1]
# a parked `ready` card is a block the model is asked about
f = world(); f.add_issue(21, "card 21", "", ["ready"])
f.comments[21] = [{"body": f"x\n\n{tm.MARK}{json.dumps({'beat': 'error'})} -->", "user": BOT, "created_at": iso(2)}]
asked = []
tm.chat = lambda body, timeout: (asked.append(body), {"choices": [{"message": {"role": "assistant", "content": "Retried card #21."}}], "usage": {}})[1]
run()
assert "card #21" in asked[0]["messages"][1]["content"] and "parked on `error`" in f.log()[0]
# real money or a human-only step: one issue for the operator, once
f = world(); f.add_issue(75, "odd", "not a cap or a key", ["andon"])
def esc(body, timeout):
if not any(m["role"] == "tool" for m in body["messages"]):
return {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": [{"id": "e", "type": "function", "function": {"name": "escalate",
"arguments": json.dumps({"kind": "human-only", "title": "Log in to the registrar", "reason": "Domain renewal needs a login."})}}]}}], "usage": {}}
return {"choices": [{"message": {"role": "assistant", "content": "Needs a login."}}], "usage": {}}
tm.chat = esc; run()
assert any(i["title"] == "Steward: Log in to the registrar" and "needs-human" in i["labels"] for i in f.issues.values()) and "Escalated to the operator:" in f.log()[0]
assert st.Tools(st.Report()).t_escalate({"kind": "human-only", "title": "Log in to the registrar", "reason": "y"}) == "already open"
# over the cap: the call is refused by the ledger's governor and the log says so
f = world(); f.add_issue(75, "odd", "not a cap or a key", ["andon"])
def capped(body, timeout): raise tm.Budget("today's spend is $5.00 of the $5.00 cap")
tm.chat = capped; run()
assert "No model call: today's spend" in f.log()[0]
# no key for the seat: the same, and the deterministic part still gets its entry
f = world(); f.pr(90, 30); f.add_issue(75, "odd", "not a cap or a key", ["andon"])
def nokey(body, timeout): raise LookupError("no ANTHROPIC_API_KEY secret on this repo yet")
tm.chat = nokey; run()
assert f.merged == [90] and "No model call: no ANTHROPIC_API_KEY" in f.log()[0]
# TIME_STEWARD=off disables the whole tick; a seat spec is read from it
f = world(TIME_STEWARD="off"); f.pr(59, 12); run()
assert f.merged == [] and f.log() == []
assert tm.worker_seat_model("claude:opus")[:2] == ("opus", "claude-opus-5-5")
assert tm.worker_seat_model(st.DEFAULT_SEAT)[1] == "claude-opus-5-5"
print("ALL OK steward")
+185 -5
View File
@@ -16,10 +16,15 @@ real_think = tm.think # some checks stub it; the Claude checks need the real on
real_git_commit = tm.git_commit
def fake_git_commit(branch, files, message, *, base=None, append=None):
"""Pushes land in whichever fake world tm.http is bound to, as the contents API's writes used to."""
def fake_git_commit(branch, files, message, *, base=None, append=None, create=True):
"""Pushes land in whichever fake world tm.http is bound to, as the contents API's writes used to. Every branch is
taken as present unless the world lists it in `gone` (a branch deleted on the forge): writing to one of those with
`create=False` raises, as the real push does, and `create=True` cuts it from the base again."""
w = getattr(tm.http, "__self__", None)
if w is None: return "sha"
if branch in getattr(w, "gone", ()):
if not create: raise tm.BranchGone(f"branch `{branch}` is not on the forge", branch)
w.gone = [b for b in w.gone if b != branch]
if not files and not append:
w.branches = getattr(w, "branches", []) + [branch]; w.bases = {**getattr(w, "bases", {}), branch: base}
for fp, data in (files or {}).items():
@@ -323,7 +328,8 @@ spec_body = "Build orbit v0\n\n- [ ] Add login page — DONE: PR open, tests gre
w = run(World([], issues={1: {"number": 1, "title": "spec", "body": spec_body, "state": "open", "labels": ["spec"]}}), K)
made = [n for n in w.issues if n != 1]
assert made == [2, 3, 4] and w.issues[1]["labels"] == ["planned"], w.issues
assert "Depends on #2" in w.issues[3]["body"] and "DONE: README updated" in w.issues[4]["body"]
assert "Depends on: #2" in w.issues[3]["body"] and "DONE: README updated" in w.issues[4]["body"]
assert "Depends on: none" in w.issues[2]["body"], w.issues[2]["body"] # every card says what it waits on (#244)
assert w.issues[2]["labels"] == ["ready"] and w.issues[3]["labels"] == ["queued"] and len(w.launched) == 1
assert "Add login page" in w.launched[0]["prompt"]["text"]
# section-style spec also parses
@@ -332,6 +338,38 @@ assert [c["title"] for c in tm.parse_spec("intro\n### One\nchange a\nDONE: x\n##
w = run(World([], issues={1: {"number": 1, "title": "s", "body": "make it nice", "state": "open", "labels": ["spec"]}}), K)
assert "couldn't find cards" in w.posted[0][1] and w.issues[1]["labels"] == []
# 12a the steps a card waits on: the seat's `depends_on`, the step before it when the seat said nothing (#244)
assert tm.plan_deps({}, 1) == [] and tm.plan_deps({}, 4) == [3], "no answer: the cards come in build order"
assert tm.plan_deps({"depends_on": []}, 4) == [] and tm.plan_deps({"depends_on": "none"}, 4) == []
assert tm.plan_deps({"depends_on": [1, "#2", 2]}, 4) == [1, 2] and tm.plan_deps({"depends": 1}, 2) == [1]
assert tm.plan_deps({"depends_on": [4, 5]}, 4) == [], "a card can only wait on a card already filed"
# both forms of the line are read, so cards filed before #244 still wait
assert tm.DEPENDS.findall("Depends on #7") == ["7"] and tm.DEPENDS.findall("Depends on: #8") == ["8"]
# 12b a seat's plan: 4-6 coarse cards, each with its own `Depends on:` line, so the free ones build in parallel (#244)
plan = [{"title": "Schema and migration", "body": "add the tables", "done": "migration runs", "depends_on": []},
{"title": "Invoice endpoints", "body": "port them", "done": "endpoints green", "depends_on": [1]},
{"title": "Refund endpoints", "body": "port them", "done": "endpoints green", "depends_on": "#1"},
{"title": "Admin page", "body": "one page", "done": "page renders", "depends_on": [2, 3]},
{"title": "Docs", "body": "write them", "done": "README updated", "depends_on": "none"}]
asked = {}
def fake_plan(seat, model, effort, system, user):
asked.update(seat=seat, system=system, user=user)
return "Here they are:\n" + json.dumps(plan) + "\nHope that helps."
tm.think = fake_plan
w = run(World([], issues={1: {"number": 1, "title": "Port billing", "body": "move billing off the old stack",
"state": "open", "labels": ["spec"]}}), {**K, "VENICE_API_KEY": "vk"})
tm.think = real_think
assert "4 to 6" in asked["system"] and "depends_on" in asked["system"] and "Port billing" in asked["user"], asked
assert sorted(n for n in w.issues if n != 1) == [2, 3, 4, 5, 6] and w.issues[1]["labels"] == ["planned"], w.issues
assert "Depends on: none" in w.issues[2]["body"] and "Depends on: #2" in w.issues[3]["body"]
assert "Depends on: #3\nDepends on: #4" in w.issues[5]["body"], w.issues[5]["body"] # one line per card
assert "Depends on: none" in w.issues[6]["body"] and "Part of #1 (step 5/5)" in w.issues[6]["body"]
assert "nothing blocks #2, #6" in w.posted[0][1], w.posted
# the two cards that wait on nothing are promoted and dispatched in the same run; the rest stay queued
assert [n for n in (2, 3, 4, 5, 6) if w.issues[n]["labels"] == ["ready"]] == [2, 6] and len(w.launched) == 2
assert [n for n in (3, 4, 5) if w.issues[n]["labels"] == ["queued"]] == [3, 4, 5]
# 13 landing: Approve from a trusted reviewer + green checks -> squash merge, card closed, next card dispatched
def pr_world(verdict_body="**Approve** looks right", who="gitea-actions", checks=None, files=None, extra=None):
prd = {"number": 7, "state": "open", "merged_at": None, "head": {"sha": "abc", "ref": "time/card-1"}, "html_url": "u", "created_at": iso(2)}
@@ -868,6 +906,99 @@ assert real_git_commit("time/card-9", {"src/a.py": None}, "rm") and "src/a.py" n
tm.SERVER, tm.REPO, tm.default_branch = _saved
print("ALL OK 53")
# 277 an open card PR's branch is never reset to its base: not by a retry, not by a write, not by a merge.
# #249 and #212 lost their work that way: the branch went missing, Hands cut a new one from `main` and pushed it, so
# the PR's head became a commit already in `main` and its diff went empty.
_g277 = _gtf.mkdtemp()
_gsp.run(["git", "init", "-q", "--bare", "-b", "main", f"{_g277}/o/r.git"], check=True)
_gsp.run(["git", "clone", "-q", f"{_g277}/o/r.git", f"{_g277}/seed"], check=True, capture_output=True)
def g277(*a, cwd=f"{_g277}/seed", check=True):
return _gsp.run(["git", *a], cwd=cwd, check=check, env=_env, capture_output=True, text=True).stdout.strip()
def write277(path, text, message):
Path(f"{_g277}/seed/{path}").write_text(text); g277("add", "-A"); g277("commit", "-qm", message)
bare277 = f"{_g277}/o/r.git"
tip277 = lambda ref: g277("rev-parse", "--verify", "--quiet", ref, cwd=bare277, check=False) # "" when there is no such branch
write277("README.md", "hi\n", "seed"); g277("push", "-q", "origin", "HEAD:main")
g277("checkout", "-qb", "time/card-1"); write277("work.txt", "the card's work\n", "card #1: work")
g277("push", "-q", "origin", "time/card-1")
work277 = tip277("time/card-1")
g277("checkout", "-q", "main"); write277("squash.txt", "someone else's card\n", "squash of #248")
g277("push", "-q", "origin", "main")
_saved = tm.SERVER, tm.REPO, tm.default_branch
tm.SERVER, tm.REPO, tm.default_branch, tm.git_commit = f"file://{_g277}", "o/r", lambda: "main", real_git_commit
tm.http = World([], prs=[{"number": 9, "state": "open", "head": {"ref": "time/card-1"}}]).http
try:
# the branch is still there: resuming it leaves its tip exactly where the worker left it
tm.Hands(1, "time/card-1")
assert tip277("time/card-1") == work277, "a resume moves nothing"
assert "squash.txt" not in g277("ls-tree", "--name-only", "time/card-1", cwd=bare277), "the base was not merged in"
# the branch is gone while the PR is open: the card is parked, nothing is pushed, the PR keeps its diff
g277("push", "-q", "origin", "--delete", "time/card-1")
try:
tm.Hands(1, "time/card-1"); raise AssertionError("a gone branch under an open PR must not be recreated")
except tm.BranchGone as e:
assert e.pr == 9 and e.branch == "time/card-1" and "PR #9" in str(e), (e.pr, str(e))
assert tip277("time/card-1") == "", "nothing was pushed"
# a write mid-run cannot recreate it either
g277("push", "-q", "origin", f"{work277}:refs/heads/time/card-1")
h277 = tm.Hands(1, "time/card-1")
g277("push", "-q", "origin", "--delete", "time/card-1")
try:
h277.call("write_file", {"path": "app.py", "content": "x = 2\n"}); raise AssertionError("a write recreated the branch")
except tm.BranchGone:
pass
assert tip277("time/card-1") == "", "nothing was pushed"
# update_branch says the same: no branch, no merge of the base into it
try:
tm.update_branch("time/card-1", "main", "card #1: merge main"); raise AssertionError("update_branch recreated the branch")
except tm.BranchGone as e:
assert e.branch == "time/card-1" and "nothing to merge" in str(e), str(e)
assert tip277("time/card-1") == ""
# a card's first run has no PR to protect, so the branch is cut from the base as before
tm.http = World([]).http
tm.Hands(2, "time/card-2")
assert tip277("time/card-2") == tip277("main") and tip277("time/card-1") == ""
# the branch comes back while Time is cutting a new one from the base (a racing worker, sync bringing it home):
# the push is rejected and Time says so, instead of replaying the base over work it has never seen
race277 = []
def racing_base(): # called when git_commit reaches for the base, just before it clones it
if not race277:
race277.append(g277("push", "-q", "origin", f"{work277}:refs/heads/time/card-3"))
return "main"
tm.default_branch = racing_base
try:
real_git_commit("time/card-3", {"app.py": b"x = 9\n"}, "card #3: a")
raise AssertionError("a base-only tip landed on a branch that had come back")
except RuntimeError as e:
assert "appeared on the forge" in str(e), str(e)
assert tip277("time/card-3") == work277, "the branch kept the work it came back with"
tm.default_branch = lambda: "main"
# a forge Time cannot reach is an error, never a branch cut from a base it could not read either
tm.SERVER = f"file://{_g277}/nowhere"
for kw in ({}, {"create": False}):
try:
real_git_commit("time/card-1", {}, "branch", **kw); raise AssertionError("an unreachable forge passed")
except tm.BranchGone:
raise AssertionError("an unreachable forge is not a missing branch")
except RuntimeError as e:
assert "clone of branch time/card-1 failed" in str(e), str(e)
finally:
tm.SERVER, tm.REPO, tm.default_branch = _saved
tm.git_commit = fake_git_commit
# the same gone branch through a whole beat: the card is parked with the reason, the cord is pulled, no worker runs
w = objected_world(); w.gone = ["time/card-1"]; w.label_ids.update(parked=98, andon=99)
w = run(w, KV)
b277 = tm.beats_of(w.comments[1])[-1]
assert b277["beat"] == "parked" and b277["pr"] == 7 and b277["branch"] == "time/card-1", b277
assert "#277" in w.posted[-1][1] and "`/time retry`" in w.posted[-1][1], w.posted[-1][1]
assert "parked" in w.issues[1]["labels"] and w.turn == 0 and w.opened == [], (w.issues[1]["labels"], w.turn)
andons277 = [i for i in w.issues.values() if "andon" in i["labels"]]
assert len(andons277) == 1 and andons277[0]["title"] == "Card #1: its branch `time/card-1` is gone", andons277
w.posted.clear()
run(w, KV) # a parked card waits for a human: nothing is said twice and no retry is launched
assert w.posted == [] and len([i for i in w.issues.values() if "andon" in i["labels"]]) == 1, w.posted
print("ALL OK 277")
# writes go out as Time's own user, except comments and statuses (Gitea refuses the Actions identity the rest)
_seen, _real_http, _gt = [], tm.http, os.environ.get("GITHUB_TOKEN")
tm.http = lambda method, url, token, body=None, timeout=0: _seen.append(token) or {}
@@ -937,7 +1068,7 @@ else:
# from greet to coach mode. No dependencies, so this runs the suite without an install; nothing reaches Whop.
if _sh.which("node"):
tm_dir = str(Path(__file__).resolve().parents[2] / "taskmaster")
res = _sp.run(["node", "--test"], cwd=tm_dir, capture_output=True, text=True, timeout=120)
res = _sp.run(["node", "--test", "--test-reporter=tap"], cwd=tm_dir, capture_output=True, text=True, timeout=120)
assert res.returncode == 0 and "# fail 0" in res.stdout, res.stdout[-3000:] + res.stderr[-2000:]
print("ALL OK taskmaster")
else:
@@ -1294,6 +1425,10 @@ assert "**to the cloud today**" in rt.table(r80c, [], "live", cloud=rt.pick_clou
assert "cloud lane" in rt.table(rt.rank(rt.candidates([iss80(11, "In the lane", labels=["cloud"])]), {}, cl80, {}, today80), [], "live")
assert "lined up; waits on #1" in rt.table(rt.rank(rt.candidates(open80[:1] + [iss80(9, "q", body="Depends on #1\nDONE: q", labels=["queued"])]),
{}, live80, {}, today80), [], "live")
# the planner's line, one per card waited on, and the older form without the colon (#244)
w244 = rt.candidates(open80[:2] + [iss80(9, "q", body="Depends on: #1\nDepends on #2\nDONE: q")])
assert [c["why_not"] for c in w244 if c["n"] == 9] == ["waits on #1, #2"] and not [c for c in w244 if c["n"] == 9][0]["actionable"]
assert [c["unblocks"] for c in w244 if c["n"] == 1] == [1], "a card waited on unblocks one"
# a kind picked yesterday is less restless; the owner's weight lifts a kind
r80b = rt.rank(cands80, assess80, rt.config('{"weights": {"optimization": 4}}'), {"refinement": "2026-10-18"}, today80)
assert {r["key"]: r for r in r80b}["#2"]["restless"] == 1.05 and r80b[0]["key"] == "#5", [(r["key"], r["score"]) for r in r80b]
@@ -1443,6 +1578,11 @@ assert tm.promote_next() is None, "a card sent back to the worker is moving agai
assert tm.moving(iss80(22, "retried")) and tm.moving(iss80(99, "no comments yet"))
ready135[22].append(beat135("needs-human"))
assert not tm.moving(iss80(22, "parked again"))
# a `parked` beat (#277: the branch is gone from under an open PR) is a wait on a person too, so it holds no slot
ready135[22].append(beat135("parked"))
assert tm.stall_of(iss80(22, "its branch is gone"))["beat"] == "parked" and not tm.moving(iss80(22, "x"))
moved80.clear()
assert tm.promote_next() == 10 and moved80 == [(10, "ready")], "a card waiting for its branch back frees its slot"
if par135 is None: os.environ.pop("TIME_PARALLEL")
else: os.environ["TIME_PARALLEL"] = par135
tm.issues, tm.gh, tm.label, tm.unlabel = real80
@@ -1631,6 +1771,41 @@ assert rt.superseded([iss80(48, "A", body="Port of #12")], [iss80(139, "B", body
assert rt.superseded([iss80(48, "A", body="Port of #12")], [iss80(48, "A", body="Port of #12")]) == [], "a card never supersedes itself"
print("ALL OK 198")
# 211 a judge that returns nothing is a loud no-op, never a flat ranking. On 2026-10-20 (log #57) the seat answered with
# no usable JSON: 37 of 37 candidates unjudged, every score 1.911, and the router still queued from that flat table, sent
# three cards to the cloud lane and promoted "start a new venture" to #1. Below a quarter of the board judged, the day's
# comment says what came back and nothing is queued, sent or created; the raw reply's size and stop reason go to the run
# log, so a reply cut off by the output limit is visible as the cause.
assert not astra.judged_enough(0, 37) and not astra.judged_enough(9, 37) and astra.judged_enough(10, 37), "a quarter"
assert astra.judged_enough(30, 37) and astra.judged_enough(1, 1) and not astra.judged_enough(0, 0)
cut211 = 'Here you go, but I ran out of room: {"note": "n"}'
astra.tm.think = lambda seat, a, b, system, user: astra.tm.Reply(cut211, "length")
labelled80.clear(); made80.clear()
out211 = io.StringIO()
with contextlib.redirect_stdout(out211):
assert astra.router_tick(rt.config('{"mode": "live", "cloud": {"per_day": 1}}'), "opus", 57, "ctx", [], 2, 2) == 0
assert not labelled80 and not made80, "nothing queued, nothing sent to the cloud lane, nothing created"
assert said80[-1][1] == f"🔭 Astra: the judge returned no assessments ({cut211}); nothing queued or sent today.", said80[-1][1]
assert said80[-1][2] == {"beat": "astra", "router": "live", "queued": 0, "unjudged": True}, said80[-1][2]
assert f"reply {len(cut211)} chars, stop length, judged 0 of 6" in out211.getvalue(), out211.getvalue()
assert "| 1 |" not in said80[-1][1] and "venture:new" not in said80[-1][1], "no table: nothing was judged"
# a key that matches no candidate judged nothing either, and one card of six is still under the quarter
for reply, said in [({"note": "n", "assess": [{"key": "#404", "value": 5}]}, "returned no assessments"),
({"note": "n", "assess": [dict(assess80["#1"], key="#1")]}, "judged 1 of 6")]:
astra.tm.think = lambda seat, a, b, system, user, r=reply: json.dumps(r)
labelled80.clear()
assert astra.router_tick(rt.config('{"mode": "live"}'), "opus", 57, "ctx", [], 2, 2) == 0
assert not labelled80 and said80[-1][2]["unjudged"] and f"the judge {said} (" in said80[-1][1], said80[-1][1]
# partial judging over the floor keeps today's behaviour: the unjudged rows score at value 3 / size M and the note says how many
astra.tm.think = lambda seat, a, b, system, user: json.dumps(
{"note": "two of six", "assess": [dict(assess80[k], key=k) for k in ("#1", "#5")], "new": []})
labelled80.clear(); made80.clear()
assert astra.router_tick(rt.config('{"mode": "live"}'), "opus", 57, "ctx", [], 2, 2) == 0
assert labelled80 and said80[-1][2]["queued"] == len(labelled80) and "unjudged" not in said80[-1][2], said80[-1][2]
assert "4 candidate(s) the seat didn't judge are scored at value 3, size M." in said80[-1][1], said80[-1][1]
assert "| 1 |" in said80[-1][1] and "two of six" in said80[-1][1], "a ranking, as before"
print("ALL OK 211")
# 82 adaptive budget: each route is paced to its capacity pool; no pool, no change
import io, contextlib
real_pools = tm.pools
@@ -2173,7 +2348,7 @@ class Worker:
return {"pr": -1, "steps": 7, "seat": "qwen", "model": "qwen3-coder"}
def vgit(branch, files, message, *, base=None, append=None):
def vgit(branch, files, message, *, base=None, append=None, create=True):
"""The Pages branch takes pushes (the Actions token's contents writes are refused); they land in W.files."""
assert branch == "gh-pages", branch
for fp, data in (files or {}).items():
@@ -2961,3 +3136,8 @@ assert got[1][0] == "wrote" and got[1][1].startswith("refused") and got[2][0].st
_dj = importlib.util.spec_from_file_location("dojo_run", Path(__file__).resolve().parents[2] / "dojo" / "run.py")
dojo = importlib.util.module_from_spec(_dj); _dj.loader.exec_module(dojo)
assert dojo.selftest(tm) == 11
# the steward's own suite, last: it loads its own copy of time.py and clears TIME_* on the way in, so the one CI
# command covers it without any check above seeing its environment
_sw = importlib.util.spec_from_file_location("test_steward", Path(__file__).resolve().parent / "test_steward.py")
steward_suite = importlib.util.module_from_spec(_sw); _sw.loader.exec_module(steward_suite)
+133 -25
View File
@@ -591,6 +591,24 @@ def budget_check(route: str | None = None, now: dt.datetime | None = None) -> No
raise Budget(f"today's spend is ${today:.2f} of the ${day_cap:.2f} cap{more}; carrying on tomorrow")
def pull_cord(title: str, body: str, prefix: str | None = None) -> None:
"""Pull the cord: one open `andon` issue per breach, so a human sees it once however many beats hit it. Two breaches
are the same one when their titles start with `prefix` (the whole title by default)."""
if any((i.get("title") or "").startswith(prefix or title) for i in issues("state=open&labels=andon")):
return
new_issue(title, body, ["andon"])
def park(n: int, text: str, meta: dict) -> None:
"""Stop a card for a human: the `parked` label and a `parked` beat, both of which the daily sweep lists. Time's
automatic retries answer `error` and `stuck` only, so a parked card waits until someone comments `/time retry`."""
try:
label(n, "parked")
except RuntimeError as e: # the label is the sweep's; the beat is what stops the card
print(f"#{n}: parked label not added: {str(e)[:120]}")
comment(n, text, {"beat": "parked", **meta})
def andon(reason: str) -> None:
"""Pull the cord once: one open `andon` issue per breach. A monthly breach also flips TIME_MODE to dry-run; a daily
one heals itself at midnight UTC, so the mode stays."""
@@ -611,10 +629,10 @@ 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} out of pocket this month",
pull_cord(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"])
f"{flipped}\n\nClose this once it's handled.", "Spend cap")
CUT_OFF = ("length", "max_tokens") # the stop reason of a reply that ran into max_tokens instead of finishing
@@ -750,8 +768,15 @@ def cursor_launch(prompt: str, *, model: str | None, pr_url: str | None = None,
# Cascade: parse the spec deterministically first; only ask a seat when it isn't already card-shaped.
CARD_LINE = re.compile(r"^\s*[-*]\s*(?:\[ \]\s*)?(?P<title>.+?)\s+(?:—|--|-|\|)\s*DONE:\s*(?P<done>.+)$", re.I)
PLAN_PROMPT = """Split this spec into small cards, one pull request each, in build order.
Reply with ONLY a JSON list: [{"title": "...", "body": "what to change, as a short list", "done": "what finished means"}]"""
DEPENDS = re.compile(r"Depends on:?[ \t]*#(\d+)", re.I) # what a card waits on; the router reads it too (#184)
PLAN_PROMPT = """Split this spec into 4 to 6 cards, in build order. A card is one pull request that one worker session
finishes, so each one is shippable on its own: a module and its tests, a group of endpoints, a schema and its
migration. Fewer, coarser cards than you think: never a checklist of one-line chores, and never more than 6.
Reply with ONLY a JSON list, one object per card:
[{"title": "...", "body": "what to change, as a short list", "done": "what finished means",
"depends_on": [positions in this list (1 = the first card) of the cards this one can't start without]}]
Use "depends_on": [] for a card that can start at once, and name a card only when the work truly needs it: cards that
wait on nothing are built in parallel."""
def parse_spec(body: str) -> list[dict]:
@@ -768,6 +793,17 @@ def parse_spec(body: str) -> list[dict]:
return cards
def plan_deps(card: dict, step: int) -> list[int]:
"""The steps of a plan that one of its cards waits on: its `depends_on` (a number, a list, `#2`, `"none"`), or the
step before it when the seat said nothing, since a spec's cards come in build order. A card can only wait on one
already filed, so a reference to itself or to a later step is dropped."""
raw = next((card[k] for k in ("depends_on", "depends") if k in card), None)
if raw is None:
return [step - 1] if step > 1 else []
items = raw if isinstance(raw, list) else [raw]
return sorted({int(d) for x in items for d in re.findall(r"\d+", str(x)) if 1 <= int(d) < step})
def plan_spec(spec: dict) -> None:
n = spec["number"]
cards = parse_spec(spec.get("body") or "")
@@ -784,14 +820,19 @@ def plan_spec(spec: dict) -> None:
{"beat": "plan-failed"})
unlabel(n, "spec")
return
made, prev = [], None
made, free = [], []
for i, c in enumerate(cards, 1):
body = (c.get("body") or "").strip() + f"\n\nPart of #{n} (step {i}/{len(cards)})." + \
(f" Depends on #{prev}." if prev else "") + f"\n\nDONE: {c['done']}"
prev = new_issue(c["title"], body.strip(), ["queued"])["number"]
made.append(prev)
comment(n, "⏱️ Time: planned " + ", ".join(f"#{m}" for m in made) + ". They run one at a time: each starts when "
"the one before it merges.", {"beat": "planned", "cards": made})
deps = [made[s - 1] for s in plan_deps(c, i)]
waits = "\n".join(f"Depends on: #{d}" for d in deps) or "Depends on: none" # one line per card it waits on
body = (c.get("body") or "").strip() + f"\n\nPart of #{n} (step {i}/{len(cards)}).\n{waits}\n\nDONE: {c['done']}"
made.append(new_issue(c["title"], body.strip(), ["queued"])["number"])
if not deps:
free.append(made[-1])
free_s = ", ".join(f"#{m}" for m in free)
start = f"nothing blocks {free_s}, so Time starts there, up to TIME_PARALLEL cards at a time." if free else \
"every card waits on another one, so none can start: a `Depends on:` line needs fixing."
comment(n, "⏱️ Time: planned " + ", ".join(f"#{m}" for m in made) + f". Each card says what it waits on: {start}",
{"beat": "planned", "cards": made})
unlabel(n, "spec")
label(n, "planned")
@@ -812,14 +853,16 @@ def rank_order() -> list[int]:
return []
WAITING = ("needs-human", "error", "stuck") # a card whose last real beat is one of these waits on a person (#135)
DEPENDS = re.compile(r"Depends on #(\d+)")
PART_OF = re.compile(r"Part of #(\d+)")
# A card whose last real beat is one of these waits on a person (#135). `parked` joined them with #277: a card whose
# branch is gone under an open pull request waits for someone to push it back, which is as human a wait as an objection.
WAITING = ("needs-human", "error", "stuck", "parked")
PART_OF = re.compile(r"Part of #(\d+)") # the spec a card belongs to; DEPENDS (the planner's lines) is above
def stall_of(card: dict) -> dict | None:
"""The beat a `ready` card is parked at when it waits on a person: an objection or a protected path
(`needs-human`), a failed call (`error`) or a stall Time won't retry again (`stuck`); None while it is moving.
(`needs-human`), a failed call (`error`), a stall Time won't retry again (`stuck`) or a branch that is gone from
under an open pull request (`parked`, #277); None while it is moving.
`blocked` (a key is missing) and `capped` (out of budget) are neither progress nor a stall, so they don't count."""
real = [b for b in beats_of(gh("GET", f"/repos/{REPO}/issues/{card['number']}/comments?limit=100") or [])
if b["beat"] not in ("blocked", "capped")]
@@ -833,7 +876,8 @@ def moving(card: dict) -> bool:
def deps_of(card: dict) -> list[int]:
"""The cards a card waits on: every `Depends on #N` in its body, without repeats."""
"""The cards a card waits on: every `Depends on #N` in its body, one line per card since #244 (`Depends on: #N`,
and `Depends on: none` for a card that waits on nothing), without repeats."""
return sorted({int(d) for d in DEPENDS.findall(card.get("body") or "")})
@@ -903,7 +947,8 @@ def parallel() -> int:
def promote_next() -> int | None:
"""Label the best `queued` card whose dependencies are closed `ready`: the router's live ranking first (Astra's
latest), then the oldest; at most TIME_PARALLEL cards in flight. A `ready` card waiting on a person (an objection,
an error or a stall Time won't retry again) keeps its label, so `/time retry` still finds it, but holds no slot:
an error, a stall Time won't retry again, or a branch gone from under its pull request) keeps its label, so
`/time retry` still finds it, but holds no slot:
three of them once stalled the queue for eight hours while a dozen cards sat `queued`. A stall reaches no further
than that (#247): it holds the cards that wait on it, every other card is promoted as usual, and the spec hears
once what Time is waiting on."""
@@ -923,7 +968,7 @@ def promote_next() -> int | None:
pos = {n: k for k, n in enumerate(rank_order())}
known = {c["number"]: c for c in ready + queued} # open rows already in hand
for card in sorted(queued, key=lambda i: (pos.get(i["number"], len(pos)), i["number"])):
if open_deps := waits_on(card, known):
if open_deps := waits_on(card, known): # waits_on reads the planner's `Depends on:` lines through DEPENDS
print(f"promote: #{card['number']} waits on " + ", ".join(f"#{d}" for d in open_deps))
continue
try:
@@ -1274,12 +1319,24 @@ def _git_url(token: bool) -> str:
return f"{SERVER}/{REPO}.git"
class BranchGone(RuntimeError):
"""A branch Time was told to write to is not on the forge, and Time will not make it from the base. The work a card
branch holds lives only there, so recreating it from the base would rewind an open pull request to an empty diff
(#277): the card is parked for a human instead. `branch` and `pr` say which, when the raiser knows."""
def __init__(self, message: str, branch: str = "", pr: int | None = None):
super().__init__(message)
self.branch, self.pr = branch, pr
def git_commit(branch: str, files: dict[str, bytes | None], message: str, *, base: str | None = None,
append: dict[str, bytes] | None = None) -> str:
append: dict[str, bytes] | None = None, create: bool = True) -> str:
"""Commit files (None deletes; `append` adds bytes to the end) to branch and push; returns the new sha.
Gitea 1.24 refuses the Actions token's API writes in the pre-receive hook (no GITEA_ACTION_PERM on the internal
push), but git over HTTP with the same token has write. So every write Time makes is a push. A missing branch is
made from `base` (default branch); no files and a missing branch just creates it."""
made from `base` (default branch); no files and a missing branch just creates it. `create=False` means the branch
must be there already: a branch an open pull request points at is never remade from the base (#277), and a clone
that failed for any other reason is raised rather than answered with base-only history."""
token = os.environ.get("GITHUB_TOKEN") or ""
url = _git_url(True)
d = tempfile.mkdtemp(prefix="time-git-")
@@ -1290,10 +1347,24 @@ def git_commit(branch: str, files: dict[str, bytes | None], message: str, *, bas
err = (p.stderr or p.stdout)[-400:]
raise RuntimeError(f"git {args[0]} failed: {err.replace(token, '***') if token else err}")
return p
fresh = False
try:
if git("clone", "-q", "--depth", "1", "--branch", branch, url, ".", check=False).returncode:
if (c := git("clone", "-q", "--depth", "1", "--branch", branch, url, ".", check=False)).returncode:
# Only a branch that is not on the forge at all is cut from the base, and only when the caller allows it.
# A branch that is there and still would not clone is a transient failure, and ls-remote failing says
# nothing either way: both are raised, because base-only history pushed over a card branch rewinds it (#277).
ls = git("ls-remote", "--exit-code", "--heads", url, f"refs/heads/{branch}", check=False)
if ls.returncode != 2: # 0: it is on the forge and the clone still failed; anything else: ls-remote failed
err = f"{c.stderr or c.stdout}{ls.stderr or ''}"[-400:]
raise RuntimeError(f"git clone of branch {branch} failed"
+ (" while the branch is on the forge" if not ls.returncode else "")
+ f": {err.replace(token, '***') if token else err}")
if not create:
raise BranchGone(f"branch `{branch}` is not on the forge, and Time does not remake it from "
f"`{base or default_branch()}`", branch)
git("clone", "-q", "--depth", "1", "--branch", base or default_branch(), url, ".")
git("checkout", "-q", "-b", branch)
fresh = True
git("remote", "set-url", "origin", _git_url(False)) # the token must not stay in .git/config
paths = []
for rel, data in list((files or {}).items()) + [(k, ("+", v)) for k, v in (append or {}).items()]:
@@ -1315,6 +1386,12 @@ def git_commit(branch: str, files: dict[str, bytes | None], message: str, *, bas
p = git("push", "-q", url, f"HEAD:refs/heads/{branch}", check=attempt == 1)
if not p.returncode:
break
if fresh:
# The branch appeared while Time was cutting a new one from the base. Ours is that branch's history,
# not its own, so rebasing onto it would replay the base over someone's work: raise and let the next
# beat resume the branch that is there now.
raise RuntimeError(f"branch {branch} appeared on the forge while Time was creating it from "
f"{base or default_branch()}; nothing was pushed")
# someone pushed first: replay ours on theirs once (appends to the ledger rebase cleanly)
git("-c", "user.name=Time", "-c", f"user.email=time@users.noreply.{host}", "pull", "-q", "--rebase", url, branch)
return git("rev-parse", "HEAD").stdout.strip()
@@ -1342,7 +1419,12 @@ def update_branch(branch: str, base: str, message: str) -> tuple[str | None, str
raise RuntimeError(f"git {args[0]} failed: {err.replace(token, '***') if token else err}")
return p
try:
git("clone", "-q", "--branch", branch, url, ".")
if git("clone", "-q", "--branch", branch, url, ".", check=False).returncode:
# Never a fallback to the base here either (#277): a merge needs the branch's own history, and a branch
# that has gone missing under an open PR is a human's to restore.
if git("ls-remote", "--exit-code", "--heads", url, f"refs/heads/{branch}", check=False).returncode == 2:
raise BranchGone(f"branch `{branch}` is not on the forge, so there is nothing to merge `{base}` into", branch)
git("clone", "-q", "--branch", branch, url, ".") # not a missing branch: raise with git's own words
git("remote", "set-url", "origin", _git_url(False)) # the token must not stay in .git/config
# two PRs that each add a CHANGELOG line at the same spot conflict, and keeping both sides' lines is the answer:
# git's union driver does that (the repo's .gitattributes says the same, but an old branch may lack it). Its
@@ -1393,9 +1475,23 @@ class Hands:
def __init__(self, n: int, branch: str, base: str | None = None):
self.n, self.branch, self.base, self.pr, self.steps = n, branch, base, None, 0
self.label = "a seat" # venice_work names the seat for the PR footer
git_commit(branch, {}, f"card #{n}: branch", base=base) # already there: resume on it (nothing to commit, push is a no-op)
try:
git_commit(branch, {}, f"card #{n}: branch", base=base, create=False) # already there: resume on it (nothing to commit, push is a no-op)
except BranchGone:
# The branch is not on the forge. If a pull request still points at it, that PR's work lived there and
# only there: cutting a fresh branch from the base would reset the PR to an empty diff (#277), which is
# how #249 and #212 lost theirs. Park the card instead; a branch with no open PR is a card's first run.
if (pr := open_pr_for(branch)):
raise BranchGone(f"branch `{branch}` is gone from the forge while PR #{pr['number']} is open, so the "
f"work it held is not there to resume", branch, pr["number"]) from None
git_commit(branch, {}, f"card #{n}: branch", base=base)
self.checkout = None
def _commit(self, files: dict[str, bytes | None], message: str) -> str:
"""A write on the card's branch. The branch exists by now (__init__ saw to it), so a write never makes it: one
deleted under a running card is parked, not answered with a branch cut from the base (#277)."""
return git_commit(self.branch, files, message, create=False)
def _checkout(self) -> str:
d = tempfile.mkdtemp(prefix="time-worker-")
subprocess.run(["git", "clone", "-q", "--depth", "1", "--branch", self.branch, _git_url(True), d], check=True, capture_output=True)
@@ -1428,7 +1524,7 @@ class Hands:
"app.js) and write each in its own call.")
if a["path"].startswith((".github/workflows", ".gitea/workflows")):
return "refused: workflow files are a human's to change"
git_commit(self.branch, {a["path"]: a["content"].encode()}, a.get("message") or f"card #{self.n}: update {a['path']}")
self._commit({a["path"]: a["content"].encode()}, a.get("message") or f"card #{self.n}: update {a['path']}")
self.checkout = None
return f"wrote {a['path']}"
if name == "run":
@@ -1463,7 +1559,7 @@ class Hands:
keep[rel] = data
kept.append(f"{rel}: kept")
if keep: # one commit for the lot
git_commit(self.branch, keep, f"card #{self.n}: kept {', '.join(keep)} (generated)")
self._commit(keep, f"card #{self.n}: kept {', '.join(keep)} (generated)")
sha = subprocess.run(["git", "rev-parse", "HEAD"], cwd=self.checkout, capture_output=True, text=True).stdout.strip()
# the worker's own test run is recorded as a commit status; land() sees it but never merges on it alone.
gh("POST", f"/repos/{REPO}/statuses/{sha}", {"state": "success" if p.returncode == 0 else "failure",
@@ -1931,6 +2027,18 @@ def tick() -> None:
except LookupError as e: # a key is missing: say so once, plainly
if not blocked:
comment(n, f"⏱️ Time: can't go further on this card: {e}. I'll carry on once it's added.", {"beat": "blocked"})
except BranchGone as e: # the branch holding this card's work is gone: park it, never rebuild it from the base
print(f"#{n}: {e}")
where = f" PR #{e.pr}" if e.pr else " its pull request"
text = (f"⏱️ Time: {e}. I did not remake it from `{card_base(card) or default_branch()}`: that would "
f"reset{where} to the base and the work on it would be gone (#277). So this card is parked.\n\n"
f"- Push `{e.branch}` back (its commits are still in the pull request's own history on the forge), "
"then comment `/time retry`\n- Or close the pull request and retry to start the card over")
park(n, text, {"pr": e.pr, "branch": e.branch})
pull_cord(f"Card #{n}: its branch `{e.branch}` is gone",
f"⏱️ Time parked card #{n}: {e}.\n\nNothing was pushed and the pull request was left as it is. "
"Restore the branch (the commits are in the pull request's history), then comment `/time retry` "
f"on #{n}.\n\nClose this once it's handled.", f"Card #{n}: its branch")
except Exception as e: # noqa: BLE001 one card must not stop the others
print(f"#{n}: {type(e).__name__}: {e}")
again = mode == "live" and auto_used.get(n, 0) + (1 if auto else 0) < auto_max