Merge origin/main into card #247: a stall's scope meets the planner's Depends on: lines
ci / test (pull_request) Successful in 1m22s
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:
14 files changed
+1207
-55
No files matched your search
+25
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user