Router: record each pick's outcome, so a learning policy has data (#203)
ci / test (push) Successful in 43s
sync / sync (push) Successful in 6s

Co-authored-by: Time (CEO lane) <time@users.noreply.teahub.xyz>
This commit was merged in pull request #203.
This commit is contained in:
time authored and time committed 2026-10-19 21:53:46 +00:00
1 parent 59a26c1f3f
commit 4814d45280
8 files changed
+458 -20

No files matched your search

+10 -4
View File
@@ -1,6 +1,6 @@
---
name: housekeeping
description: The daily sweep of this repo: prunes merged branches, closes spent signal issues and empty milestones, and lists what is stale. Use when asked what housekeeping did, why a branch or issue was closed, how to run the sweep by hand, or what to do with the stale list.
description: The daily sweep of this repo: prunes merged branches, closes spent signal issues and empty milestones, records what the router's picks turned into, and lists what is stale. Use when asked what housekeeping did, why a branch or issue was closed, where the router's outcomes come from, how to run the sweep by hand, or what to do with the stale list.
---
# Housekeeping
@@ -24,11 +24,17 @@ Deterministic: `.github/time/housekeeping.py` decides, never a model. Every step
whose venture has no signups: no 👍 from a person and no early-access issue for its slug. One closing line names the count.
Issues with signups are left to the venture loop.
3. **Milestones.** Closes milestones with no open issues and at least one closed one. An empty, never-used milestone stays.
4. **Stale list.** No action, one comment on the Astra log (#57): `needs-human` untouched for over 3 days, open PRs idle
4. **Router outcomes.** For each card the router picked in live mode (Astra's `picked` beats), one line in
`time/ledger/router-outcomes.jsonl` once the card closes: merged or not, its PR, days from pick to close, the spend
booked while its worker ran, and, for a venture pick, the venture's signups at day 7. A pick whose card is still open
waits for a later sweep, and one already in the file is never written twice. The file's fields are documented in
`time/ledger/README.md`; Astra's log summarises it per kind over the ranked table. The data is for a learning policy
(a restless bandit) to read later; nothing uses it to pick today.
5. **Stale list.** No action, one comment on the Astra log (#57): `needs-human` untouched for over 3 days, open PRs idle
7 days or more, cards parked after their automatic retries, `error` cards older than 2 days. An empty list posts no comment.
Each run ends with one line on the Astra log: branches deleted, issues closed, milestones closed, stale count. A step that
fails is logged and the others still run; the job exits 1.
Each run ends with one line on the Astra log: branches deleted, issues closed, milestones closed, picks settled, stale
count. A step that fails is logged and the others still run; the job exits 1.
## What the steward does with the stale list
+17 -2
View File
@@ -18,7 +18,8 @@ boost for kinds not picked lately, and the card's age. The log shows the ranked
nothing; `live` queues from the top while the day's money left covers each card's estimated cost (the allowance left
in the pool that takes the worker's route, else the daily cap's remainder, never more than the month has left),
creates the seat's new cards unqueued (they are candidates the next day; never a repeat of an open or lately closed
card), and the heartbeat promotes queued cards in the ranked order.
card), and the heartbeat promotes queued cards in the ranked order. Over the table the log shows what each kind's past
picks returned (picks, merged share, median days, median $), from the outcomes housekeeping records (#172).
`off`, or no router.json, is the planner as before.
"""
from __future__ import annotations
@@ -153,6 +154,18 @@ def router_config() -> dict:
return router.config(None)
def outcomes() -> list[dict]:
"""The settled picks housekeeping has recorded (#172), for the summary per kind over the table. Context, not a
dependency: unreadable, the table goes up without it."""
try:
f = tm.gh("GET", f"/repos/{tm.REPO}/contents/{router.OUTCOMES}")
return router.read_outcomes(base64.b64decode(f.get("content") or "").decode(errors="replace"))
except Exception as e: # noqa: BLE001
if "-> 404" not in str(e):
print(f"router outcomes unreadable: {str(e)[:120]}")
return []
def recent(log: int, days: int = 14) -> list[dict]:
"""The log's comments of the last `days` days, oldest first (Gitea pages from the oldest, so a plain limit=20
read the thread's first twenty comments forever)."""
@@ -266,7 +279,9 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
f"queue against ({source}). Weights: "
+ ", ".join(f"{k} {w[k]:g}" for k in router.KINDS) + f"; restless {cfg['restless']:g}/day."
+ (f" Cloud lane: {in_lane} in it, up to {cfg['cloud']['per_day']}." if cfg["cloud"]["per_day"] else ""))
lines = [head, "", str(out.get("note") or "(no note)"), "", router.table(ranked, picks, cfg["mode"], cloud=to_cloud), ""]
settled = router.outcome_lines(outcomes()) # what each kind has returned, over the table (#172)
lines = [head, "", str(out.get("note") or "(no note)"), ""] + (settled + [""] if settled else []) \
+ [router.table(ranked, picks, cfg["mode"], cloud=to_cloud), ""]
if unjudged:
lines.append(f"_{unjudged} candidate(s) the seat didn't judge are scored at value 3, size M._")
if live:
+151 -10
View File
@@ -2,21 +2,26 @@
python3 .github/time/housekeeping.py # daily (housekeeping.yml), or by hand
Four steps, each idempotent (a second run finds nothing to do) and each logged:
Five 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.
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.
4. stale list, never act: `needs-human` untouched for 3 days, open PRs idle 7 days, cards parked after their
4. outcomes append one line to `time/ledger/router-outcomes.jsonl` per router pick that has settled (#172): merged
or not, its PR, days from pick to close, the spend booked while its worker ran, and a venture's signups
at day 7. A pick whose card is still open is in flight and waits for a later sweep.
5. stale list, never act: `needs-human` untouched for 3 days, open PRs idle 7 days, cards parked after their
automatic retries, cards whose last beat is `error` for 2 days. One comment on the Astra log; none if empty.
Then one summary line on the Astra log. Only TIME_MODE=live changes anything; anything else (the default) prints what it
would do and writes nothing, comments included. A step that fails is logged and the others still run (exit code 1).
"""
from __future__ import annotations
import base64
import datetime as dt
import importlib.util
import json
import os
import re
import sys
@@ -27,11 +32,16 @@ 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)
_rspec = importlib.util.spec_from_file_location("router", HERE / "router.py")
router = importlib.util.module_from_spec(_rspec)
_rspec.loader.exec_module(router)
NEVER = {"gh-pages"} # besides the default branch and protected ones
LOG_TITLE = "Astra log"
LOG_FALLBACK = 57 # the Astra log's number here, if it cannot be found by title
NEEDS_HUMAN_DAYS, PR_IDLE_DAYS, ERROR_DAYS = 3, 7, 2
VENTURES = "ventures/ventures.json" # the venture registry (venture.py writes it), on the Pages branch
PAGES_BRANCH = "gh-pages"
def log(msg: str) -> None:
@@ -68,6 +78,29 @@ def age_days(ts: str | None, now: dt.datetime) -> float:
return (now - d).total_seconds() / 86400 if d else 0.0
def stamp(ts: str | dt.datetime | None) -> str:
"""A timestamp written the way the ledger writes them, so one string comparison orders beats, ledger lines and
windows alike whatever offset the API sent."""
d = ts if isinstance(ts, dt.datetime) else parse(ts)
return d.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if d else ""
def measure_days() -> int:
"""The venture measuring window: how long a signal issue is given, and the day a pick's signups are counted on."""
return int(float(os.environ.get("TIME_VENTURE_MEASURE_DAYS") or 7))
def repo_file(path: str, ref: str = "") -> str | None:
"""A file in the repo, read through the API (the workflow checks out only `.github/time`). None if there is none."""
try:
f = tm.gh("GET", f"{repo()}/contents/{urllib.parse.quote(path)}" + (f"?ref={ref}" if ref else ""))
except RuntimeError as e:
if "-> 404" in str(e):
return None
raise
return base64.b64decode(f.get("content") or "").decode(errors="replace")
def labelled(name: str, query: str = "state=open") -> list[dict]:
"""Issues (and PRs) that carry `name`. Each row is checked too: Gitea ignores a filter on a label the repo does not
have and returns everything, which on the first live run listed every open issue as a parked card."""
@@ -124,19 +157,19 @@ def person(user: dict | None) -> str | None:
return login
def signup_people() -> dict[str, set[str]]:
"""venture slug -> distinct people who opened an early-access issue for it."""
def signup_people(until: str = "9999") -> dict[str, set[str]]:
"""venture slug -> distinct people who opened an early-access issue for it before `until` (all of them by default)."""
out: dict[str, set[str]] = {}
for i in labelled("signup", "state=all&type=issues"):
m = re.search(r"^###\s*Venture\s*$\n+(.*?)(?=^###\s|\Z)", i.get("body") or "", re.M | re.S)
who = person(i.get("user"))
if m and who and m.group(1).strip():
if m and who and m.group(1).strip() and stamp(i.get("created_at")) < until:
out.setdefault(m.group(1).strip().lower(), set()).add(who)
return out
def step_signals(live: bool, now: dt.datetime) -> int:
days = int(float(os.environ.get("TIME_VENTURE_MEASURE_DAYS") or 7))
days = measure_days()
due = [i for i in labelled("signal", "state=open&type=issues")
if not i.get("pull_request") and age_days(i.get("created_at"), now) >= days]
if not due:
@@ -181,7 +214,114 @@ def step_milestones(live: bool) -> int:
return closed
# ---------- 4. the stale list ----------
# ---------- 4. the router's outcomes ----------
# What a pick turned into, so a learning policy (a restless bandit) has rewards to read. The ledger has no card field,
# so a card's spend is the dollars booked while its worker ran: each `dispatched` beat until the next beat on the card
# (the chosen proxy of the two the card offered; review and planning calls on the card are not counted).
def run_windows(beats: list[dict], settled: str) -> list[tuple[str, str]]:
"""The card's worker runs as [start, end) in UTC: a `dispatched` beat until the next beat on the card, the last one
until the card settled."""
at = [stamp(b.get("at")) for b in beats]
out = [(at[k], next((s for s in at[k + 1:] if s), settled)) for k, b in enumerate(beats)
if b.get("beat") == "dispatched" and at[k]]
return [(a, z) for a, z in out if a < z]
def months(since: str, until: str) -> list[str]:
"""The ledger months a span touches, `2026-10` first."""
out, m = [], since[:7]
while m <= until[:7] and len(out) < 24:
out.append(m)
m = f"{int(m[:4]) + (m[5:] == '12')}-{int(m[5:]) % 12 + 1:02d}"
return out
def window_usd(windows: list[tuple[str, str]]) -> float:
"""Dollars the ledger booked inside any of these windows."""
if not windows:
return 0.0
rows = [r for m in months(min(a for a, _ in windows), max(z for _, z in windows)) for r in tm.ledger_lines(m)]
return sum(r.get("usd") or 0 for r in rows if any(a <= str(r.get("at") or "") < z for a, z in windows))
def ventures() -> list[dict]:
"""The venture registry on the Pages branch. Read-only here; unreadable is an empty studio."""
try:
reg = json.loads(repo_file(VENTURES, PAGES_BRANCH) or "{}")
except (ValueError, RuntimeError) as e:
log(f"outcomes: venture registry unreadable: {str(e)[:120]}")
return []
return [v for v in (reg.get("ventures") if isinstance(reg, dict) else None) or [] if isinstance(v, dict)]
def signups_by(v: dict, until: str) -> int:
"""Distinct people (not bots, not the owner) who asked for early access for this venture or 👍'd its signal issue
before `until`. The slug match is the signal step's, so a signup that named the venture some other way is missed."""
people = set(signup_people(until).get(str(v.get("slug") or "").lower(), set()))
if v.get("signal"):
people |= {w for r in paged(f"{repo()}/issues/{v['signal']}/reactions") if r.get("content") in ("+1", "👍")
and stamp(r.get("created_at")) < until and (w := person(r.get("user")))}
return len(people)
def day_after(day: str, days: int) -> str:
"""Midnight `days` days after a date, as a ledger timestamp; "" when the date is not one."""
try:
return (dt.date.fromisoformat(day[:10]) + dt.timedelta(days=days)).strftime("%Y-%m-%dT00:00:00Z")
except ValueError:
return ""
def step_outcomes(live: bool, now: dt.datetime) -> int:
"""One line per settled pick in `router.OUTCOMES`, appended in one commit. A pick already in the file is never
written twice; a pick whose card is still open, or whose venture has not reached day 7, waits for a later sweep."""
picks = router.picks(tm.beats_of(paged(f"{repo()}/issues/{log_issue()}/comments")))
if not picks:
return 0
have = {r["n"] for r in router.read_outcomes(repo_file(router.OUTCOMES))}
rows, registry = [], None
for n, p in sorted(picks.items()):
if n in have:
continue
try:
card = tm.gh("GET", f"{repo()}/issues/{n}")
except RuntimeError as e:
if "-> 404" not in str(e):
raise
log(f"outcomes: skip #{n} (no such card)")
continue
if (card.get("state") or "open") != "closed":
log(f"outcomes: keep #{n} (still in flight)")
continue
settled = stamp(card.get("closed_at")) or stamp(card.get("updated_at")) or stamp(now)
beats = tm.beats_of(paged(f"{repo()}/issues/{n}/comments"))
signups = None
if p["kind"] == "venture":
registry = ventures() if registry is None else registry
v = next((x for x in registry if x.get("card") == n), None)
day7 = day_after(str(v.get("launched") or v.get("planned") or p["picked"]), measure_days()) if v else ""
if day7 and day7 > stamp(now):
log(f"outcomes: keep #{n} (its venture's day {measure_days()} is {day7[:10]})")
continue
signups = signups_by(v, day7 or "9999") if v else None
row = router.outcome(p, card, beats, window_usd(run_windows(beats, settled)), signups)
if row:
rows.append(row)
log(f"outcomes: #{n} settled ({'merged' if row['merged'] else 'not merged'}, {row['days']} day(s), "
f"${row['usd'] or 0:.2f}" + (f", {row['signups']} signup(s)" if row["signups"] is not None else "") + ")")
if not rows:
return 0
if not live:
log(f"outcomes: would record {len(rows)} settled pick(s)")
return 0
tm.git_commit(tm.default_branch(), {}, f"router: {len(rows)} settled pick(s)",
append={router.OUTCOMES: "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in rows).encode()})
log(f"outcomes: recorded {len(rows)} settled pick(s) in {router.OUTCOMES}")
return len(rows)
# ---------- 5. the stale list ----------
def stale(now: dt.datetime) -> dict[str, list[tuple[int, float, str]]]:
"""category -> [(number, age in days, title)]. Read-only."""
@@ -235,10 +375,10 @@ def main() -> int:
now = dt.datetime.now(dt.timezone.utc)
log(f"housekeeping {now:%Y-%m-%dT%H:%MZ} mode={'live' if live else 'dry-run'}")
failed = 0
done = {"branches": 0, "signals": 0, "milestones": 0}
done = {"branches": 0, "signals": 0, "milestones": 0, "outcomes": 0}
found: dict = {}
for name, step in (("branches", lambda: step_branches(live)), ("signals", lambda: step_signals(live, now)),
("milestones", lambda: step_milestones(live))):
("milestones", lambda: step_milestones(live)), ("outcomes", lambda: step_outcomes(live, now))):
try:
done[name] = step()
except Exception as e: # noqa: BLE001 one step must not stop the others
@@ -253,7 +393,8 @@ def main() -> int:
if found:
log(stale_text(found))
line = (f"🧹 Housekeeping: {done['branches']} branch(es) deleted, {done['signals']} signal issue(s) closed, "
f"{done['milestones']} milestone(s) closed, {count} stale." + (f" {failed} step(s) failed; see the run log." if failed else ""))
f"{done['milestones']} milestone(s) closed, {done['outcomes']} router pick(s) settled, {count} stale."
+ (f" {failed} step(s) failed; see the run log." if failed else ""))
log(line)
if live:
try:
+97
View File
@@ -26,6 +26,10 @@ waits: on a card still open (its `Depends on #N`), or on whatever the seat says
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
over Astra's table. Nothing here reads them back into a pick yet; `score` is still the whole policy.
"""
from __future__ import annotations
@@ -202,6 +206,99 @@ def order(ranked: list[dict]) -> list[int]:
return [r["n"] for r in cards if r["actionable"]] + [r["n"] for r in cards if not r["actionable"]]
# ---------- outcomes: what a pick turned into (#172) ----------
# A learning policy needs rewards. Housekeeping settles each live pick into one line here; these functions are the
# shape of a line, the arithmetic over the lines, and nothing else. No pick reads them yet.
OUTCOMES = "time/ledger/router-outcomes.jsonl" # under time/ledger/, the one path CI ignores: the daily append is not code
FIELDS = ("n", "kind", "picked", "settled", "merged", "pr", "days", "usd", "signups")
def picks(beats: list[dict]) -> dict[int, dict]:
"""Every card the router picked in live mode, from Astra's beats: card number -> {n, kind, picked (the day)}. A
card picked twice (it came back after a park) keeps its first pick; that is when the factory spent the choice."""
out: dict[int, dict] = {}
for b in beats:
if b.get("router") != "live":
continue
day = str(b.get("at") or "")[:10]
for p in b.get("picked") or []:
n = p.get("n") if isinstance(p, dict) else None
if isinstance(n, int) and n not in out:
out[n] = {"n": n, "kind": p.get("kind") if p.get("kind") in KINDS else "feature", "picked": day}
return out
def span(since: str, until: str) -> int | None:
"""Whole days from one date to another, None when either is not a date."""
try:
return (dt.date.fromisoformat(until[:10]) - dt.date.fromisoformat(since[:10])).days
except ValueError:
return None
def outcome(p: dict, card: dict, beats: list[dict], usd: float | None, signups: int | None) -> dict | None:
"""One line for a pick, or None while its card is still open: a pick in flight has no outcome yet. Merged or not
and the PR number come from the card's own beats (a `merged` beat is a landing; any beat naming a `pr` is the PR
the card had), `days` counts the pick's day to the card's close, `usd` and `signups` are the caller's."""
if (card.get("state") or "open") != "closed":
return None
settled = str(card.get("closed_at") or card.get("updated_at") or "")[:10]
merged = next((b for b in beats if b.get("beat") == "merged"), None)
pr = (merged or {}).get("pr") or next((b.get("pr") for b in reversed(beats) if b.get("pr")), None)
return {"n": p["n"], "kind": p["kind"], "picked": p["picked"], "settled": settled, "merged": bool(merged),
"pr": int(pr) if pr else None, "days": span(p["picked"], settled),
"usd": round(usd, 4) if usd is not None else None, "signups": signups}
def read_outcomes(raw: str | None) -> list[dict]:
"""The outcomes log as rows, oldest first. A line that is not an outcome is skipped: the file is append-only and
one bad line must not hide the rest."""
out = []
for line in (raw or "").splitlines():
try:
row = json.loads(line)
except ValueError:
continue
if isinstance(row, dict) and isinstance(row.get("n"), int):
out.append(row)
return out
def median(xs: list[float]) -> float | None:
s = sorted(xs)
if not s:
return None
return float(s[len(s) // 2]) if len(s) % 2 else (s[len(s) // 2 - 1] + s[len(s) // 2]) / 2
def summary(rows: list[dict]) -> dict[str, dict]:
"""Per kind, what its settled picks returned: how many, the merged share, the median days from pick to close and
the median dollars. A kind with no settled pick is left out."""
out = {}
for k in KINDS:
mine = [r for r in rows if r.get("kind") == k]
if mine:
out[k] = {"picks": len(mine), "merged": sum(1 for r in mine if r.get("merged")) / len(mine),
"days": median([r["days"] for r in mine if isinstance(r.get("days"), (int, float))]),
"usd": median([r["usd"] for r in mine if isinstance(r.get("usd"), (int, float))])}
return out
def outcome_lines(rows: list[dict]) -> list[str]:
"""The header over Astra's table: one line per kind, so the owner sees what a kind has returned before changing
its weight. No settled pick yet, no header."""
got = summary(rows)
if not got:
return []
out = [f"Settled picks so far ({len(rows)} in `{OUTCOMES}`):"]
for k, s in got.items():
days = f"{s['days']:g} day(s) median" if s["days"] is not None else "days unknown"
usd = f"${s['usd']:.2f} median" if s["usd"] is not None else "$ unknown"
out.append(f"- {k}: {s['picks']} pick(s), {s['merged'] * 100:.0f}% merged, {days}, {usd}")
return out
def table(ranked: list[dict], picked: list[dict], mode: str, limit: int = 15, cloud: list[dict] = ()) -> str:
chosen, sent = {r["key"] for r in picked}, {r["key"] for r in cloud}
mark = "queued today" if mode == "live" else "would queue"
+101 -4
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
"""Offline tests for housekeeping.py: a fake forge (like test_time.py's World) that records every write."""
import base64
import datetime as dt
import importlib.util
import json
@@ -23,13 +24,15 @@ LOG = 57
class Forge:
def __init__(self, branches=(), prs=(), issues=(), milestones=(), reactions=None, comments=None, repo_labels=None):
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]
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]
self.reactions = reactions or {}
self.cmts = comments or {}
self.files = dict(files or {}) # "<path>" (or "<path>@<ref>") -> text, as the contents API serves it
self.commits = [] # (branch, message, {path: what was appended}): Time's writes are pushes
# the repo's labels; by default the ones its issues carry. Gitea ignores a filter on any other label.
self.repo_labels = set(repo_labels) if repo_labels is not None else {l for i in self.issues.values() for l in i["labels"]}
self.writes = [] # every non-GET call
@@ -72,6 +75,17 @@ class Forge:
if route.endswith("/comments") and method == "POST":
self.posted.append((int(route.split("/")[-2]), body["body"]))
return {}
if route.startswith("/repos/o/r/contents/") and method == "GET":
path, ref = urllib.parse.unquote(route.split("/contents/", 1)[1]), q.get("ref", [""])[0]
text = self.files.get(f"{path}@{ref}" if ref else path)
if text is None:
raise RuntimeError(f"GET {route} -> 404: not found")
return {"content": base64.b64encode(text.encode()).decode(), "sha": "sha"}
if route.startswith("/repos/o/r/issues/") and route.rsplit("/", 1)[1].isdigit() and method == "GET":
i = self.issues.get(int(route.rsplit("/", 1)[1]))
if i is None:
raise RuntimeError(f"GET {route} -> 404: not found")
return {**i, "labels": [{"name": l} for l in i["labels"]]}
if route == "/repos/o/r/issues" and method == "GET":
rows = [i for i in self.issues.values()
if (q.get("state", ["open"])[0] in ("all", i["state"]))
@@ -82,6 +96,10 @@ class Forge:
return {}
raise AssertionError(f"unexpected {method} {path}")
def push(self, branch, files, message, *, base=None, append=None):
self.commits.append((branch, message, {p: d.decode() for p, d in (append or {}).items()}))
return "sha"
def run(self, mode="live", env=None):
os.environ.pop("TIME_MODE", None)
os.environ.pop("TIME_VENTURE_MEASURE_DAYS", None)
@@ -89,6 +107,8 @@ class Forge:
os.environ["TIME_MODE"] = mode
os.environ.update(env or {})
tm.http = self.http
tm.git_commit = self.push
tm._ledger_files.clear() # the ledger is read through the API and cached per process, not per world
return hk.main()
@@ -184,18 +204,95 @@ def test_unknown_label():
assert [n for n, _ in f.posted] == [LOG], "the summary goes to the Astra log, found by its own label"
def beat(meta, days=0):
return {"created_at": iso(days), "user": {"login": "gitea-actions"}, "body": f"x\n\n{tm.MARK}{json.dumps(meta)} -->"}
def ledger(*rows):
"""The month files the ledger lines below fall in (a window can straddle a month end)."""
out = {}
for at, usd in rows:
out.setdefault(f"time/ledger/{at[:7]}.jsonl", "")
out[f"time/ledger/{at[:7]}.jsonl"] += json.dumps({"at": at, "model": "m", "usd": usd}) + "\n"
return out
ASTRA_LOG = {"number": LOG, "title": "Astra log", "labels": ["astra"]}
def test_outcomes():
"""A merged pick and an unmerged one each settle into one line; a pick still open produces none."""
picked = beat({"beat": "astra", "router": "live", "picked": [{"n": 1, "kind": "feature"}, {"n": 2, "kind": "refinement"},
{"n": 3, "kind": "feature"}]}, 4)
f = Forge(issues=[ASTRA_LOG,
{"number": 1, "title": "merged card", "state": "closed", "closed_at": iso(1)},
{"number": 2, "title": "dropped card", "state": "closed", "closed_at": iso(2)},
{"number": 3, "title": "still going"}],
comments={LOG: [picked],
1: [beat({"beat": "dispatched"}, 4), beat({"beat": "pr", "pr": 90}, 3), beat({"beat": "merged", "pr": 90}, 1)],
2: [beat({"beat": "dispatched"}, 2.5), beat({"beat": "stuck", "pr": 91}, 2.2)]},
files=ledger((iso(3.5), 0.2), (iso(3.2), 0.05), (iso(2.4), 0.4), (iso(0.5), 9.0))) # the last: no run window holds it
assert f.run() == 0
assert len(f.commits) == 1 and f.commits[0][0] == "main" and "2 settled pick(s)" in f.commits[0][1], f.commits
rows = [json.loads(l) for l in f.commits[0][2][hk.router.OUTCOMES].splitlines()]
assert [r["n"] for r in rows] == [1, 2], rows
assert rows[0] == {"n": 1, "kind": "feature", "picked": iso(4)[:10], "settled": iso(1)[:10], "merged": True,
"pr": 90, "days": 3, "usd": 0.25, "signups": None}, rows[0]
assert rows[1]["merged"] is False and rows[1]["pr"] == 91 and rows[1]["days"] == 2, rows[1]
assert rows[1]["usd"] == 0.4, "only what was booked while its own worker ran"
assert any("2 router pick(s) settled" in b for _, b in f.posted), f.posted
# idempotent: a pick already in the file is never written twice, and #3 settles once it closes
f.files[hk.router.OUTCOMES] = f.commits[0][2][hk.router.OUTCOMES]
f.commits.clear()
assert f.run() == 0 and f.commits == [], "nothing new to settle"
f.issues[3].update(state="closed", closed_at=iso(0))
assert f.run() == 0 and [json.loads(l)["n"] for l in f.commits[0][2][hk.router.OUTCOMES].splitlines()] == [3]
def test_outcomes_venture():
"""A venture pick carries the venture's signups at day 7; before day 7 the pick keeps waiting."""
picked = beat({"beat": "astra", "router": "live", "picked": [{"n": 5, "kind": "venture"}, {"n": 6, "kind": "venture"}]}, 10)
reg = {"ventures": [{"slug": "huebrain", "name": "Huebrain", "card": 5, "signal": 8, "launched": iso(9)[:10]},
{"slug": "later", "name": "Later", "card": 6, "signal": 9, "launched": iso(2)[:10]}]}
f = Forge(issues=[ASTRA_LOG,
{"number": 5, "title": "launch huebrain", "state": "closed", "closed_at": iso(8)},
{"number": 6, "title": "launch later", "state": "closed", "closed_at": iso(1)},
{"number": 20, "labels": ["signup"], "user": {"login": "ann"}, "created_at": iso(8),
"body": "### Venture\n\nhuebrain\n\n### Plan\n\nFree"},
{"number": 21, "labels": ["signup"], "user": {"login": "bob"}, "created_at": iso(1),
"body": "### Venture\n\nhuebrain\n\n### Plan\n\nFree"}],
comments={LOG: [picked], 5: [beat({"beat": "pr", "pr": 70}, 9), beat({"beat": "merged", "pr": 70}, 8)]},
reactions={8: [{"content": "+1", "user": {"login": "cid"}, "created_at": iso(8)},
{"content": "+1", "user": {"login": "dee"}, "created_at": iso(0)}]},
files={f"ventures/ventures.json@{hk.PAGES_BRANCH}": json.dumps(reg)})
assert f.run() == 0
rows = [json.loads(l) for l in f.commits[0][2][hk.router.OUTCOMES].splitlines()]
assert [r["n"] for r in rows] == [5], "#6's venture is 2 days old: its day 7 is still ahead"
assert rows[0]["signups"] == 2, "ann and cid came in by day 7; bob and dee came later"
assert rows[0]["merged"] is True and rows[0]["usd"] == 0.0, "no worker run on the card: no spend of its own"
# no registry entry for the card: the line goes in with no count rather than waiting forever
f = Forge(issues=[ASTRA_LOG, {"number": 5, "title": "launch huebrain", "state": "closed", "closed_at": iso(8)}],
comments={LOG: [beat({"beat": "astra", "router": "live", "picked": [{"n": 5, "kind": "venture"}]}, 10)]})
assert f.run() == 0
assert json.loads(f.commits[0][2][hk.router.OUTCOMES])["signups"] is None
def test_dry_run():
f = Forge(branches=["main", "time/card-53"], prs=[pr(1, "time/card-53", "closed", True)],
issues=[sig(10, "spent", 8), {"number": 30, "labels": ["needs-human"], "updated_at": iso(9)}],
issues=[ASTRA_LOG, sig(10, "spent", 8), {"number": 30, "labels": ["needs-human"], "updated_at": iso(9)},
{"number": 1, "title": "merged card", "state": "closed", "closed_at": iso(1)}],
comments={LOG: [beat({"beat": "astra", "router": "live", "picked": [{"n": 1, "kind": "feature"}]}, 4)],
1: [beat({"beat": "merged", "pr": 90}, 1)]},
milestones=[{"id": 1, "title": "done batch", "open_issues": 0, "closed_issues": 3}])
for mode in ("dry-run", None): # explicit and unset
assert f.run(mode) == 0
assert f.writes == [] and f.posted == [], (f.writes, f.posted)
assert f.writes == [] and f.posted == [] and f.commits == [], (f.writes, f.posted, f.commits)
assert {b["name"] for b in f.branches} == {"main", "time/card-53"} and f.issues[10]["state"] == "open"
if __name__ == "__main__":
tests = [test_branches, test_signals, test_milestones, test_stale, test_unknown_label, test_dry_run]
tests = [test_branches, test_signals, test_milestones, test_outcomes, test_outcomes_venture, test_stale,
test_unknown_label, test_dry_run]
for t in tests:
t()
print("ok", t.__name__)
+58
View File
@@ -1226,6 +1226,14 @@ astra.tm.budget = lambda: (1.0, 15.0, 10.0, 100.0)
astra.tm.comment = lambda n, text, meta: said80.append((n, text, meta))
astra.tm.label = lambda n, name: labelled80.append((n, name))
astra.tm.new_issue = lambda title, body, labels: made80.append((title, body, labels)) or {"number": 90 + len(made80)}
import base64 as _b8172 # noqa: E402
settled80 = [] # the outcomes log (#172); empty, so nothing is summarised over the table
def gh80(method, path, body=None, **k):
"""The outcomes log is the only file this world serves; anything else is a fresh repo's 404."""
if rt.OUTCOMES not in path:
raise RuntimeError(f"{method} {path} -> 404: not found")
return {"content": _b8172.b64encode("".join(json.dumps(r) + "\n" for r in settled80).encode()).decode()}
astra.tm.gh = gh80
reply80 = {"note": "the note", "assess": [dict(assess80[k], key=k) for k in assess80] + ["junk"],
"new": [{"title": "Write a thing", "body": "do it"}, {"no": "title"}]}
astra.tm.think = lambda seat, a, b, system, user: (asked80.append((seat, system, user)), "Sure:\n" + json.dumps(reply80))[1]
@@ -1331,6 +1339,56 @@ else: os.environ["TIME_PARALLEL"] = par135
tm.issues, tm.gh, tm.label, tm.unlabel = real80
print("ALL OK 80")
# 172 the router's outcomes: a live pick settles into one line (housekeeping writes it; test_housekeeping.py checks the
# sweep), and Astra's log summarises what each kind has returned over the table
beats172 = [{"beat": "astra", "router": "live", "at": "2026-10-17T13:13:00Z", "picked": [{"n": 1, "kind": "feature"}, {"n": 2, "kind": "venture"}]},
{"beat": "astra", "router": "dry", "at": "2026-10-18T13:13:00Z", "picked": [{"n": 3, "kind": "feature"}]},
{"beat": "astra", "router": "live", "at": "2026-10-18T13:13:00Z",
"picked": [{"n": 1, "kind": "refinement"}, {"n": 4, "kind": "nonsense"}, "junk"]}]
assert rt.picks(beats172) == {1: {"n": 1, "kind": "feature", "picked": "2026-10-17"},
2: {"n": 2, "kind": "venture", "picked": "2026-10-17"},
4: {"n": 4, "kind": "feature", "picked": "2026-10-18"}}, rt.picks(beats172)
assert rt.picks([{"beat": "astra", "router": "dry", "picked": [{"n": 9, "kind": "feature"}]}]) == {}, "a dry run picked nothing"
p172 = rt.picks(beats172)[1]
beats1 = [{"beat": "dispatched", "at": "2026-10-17T14:00:00Z"}, {"beat": "pr", "pr": 90, "at": "2026-10-17T15:00:00Z"},
{"beat": "merged", "pr": 90, "at": "2026-10-19T09:00:00Z"}]
row172 = rt.outcome(p172, {"state": "closed", "closed_at": "2026-10-19T09:00:05Z"}, beats1, 1.25, None)
assert row172 == {"n": 1, "kind": "feature", "picked": "2026-10-17", "settled": "2026-10-19", "merged": True,
"pr": 90, "days": 2, "usd": 1.25, "signups": None}, row172
assert tuple(row172) == rt.FIELDS, "the line's shape"
assert rt.outcome(p172, {"state": "open"}, beats1, 1.0, None) is None, "a pick in flight has no outcome yet"
drop172 = rt.outcome(p172, {"state": "closed", "closed_at": "2026-10-18T09:00:00Z"}, [{"beat": "stuck", "pr": 91, "at": "x"}], None, None)
assert drop172["merged"] is False and drop172["pr"] == 91 and drop172["days"] == 1 and drop172["usd"] is None
assert rt.outcome(p172, {"state": "closed", "closed_at": "x"}, [], 0.0, None)["days"] is None, "no close date, no span"
# the summary per kind: picks, merged share, median days, median $
rows172 = [row172, dict(drop172, n=5), {"n": 6, "kind": "feature", "merged": True, "days": 4, "usd": 3.0},
{"n": 7, "kind": "venture", "merged": False, "days": 9, "usd": 0.5, "signups": 2}]
got172 = rt.summary(rows172)
assert got172["feature"] == {"picks": 3, "merged": 2 / 3, "days": 2.0, "usd": 2.125}, got172["feature"]
assert got172["venture"] == {"picks": 1, "merged": 0.0, "days": 9.0, "usd": 0.5} and "refinement" not in got172, got172
assert rt.median([]) is None and rt.median([3, 1]) == 2 and rt.median([5, 1, 3]) == 3
assert rt.summary([{"n": 8, "kind": "feature"}])["feature"] == {"picks": 1, "merged": 0.0, "days": None, "usd": None}
lines172 = rt.outcome_lines(rows172)
assert lines172[0] == "Settled picks so far (4 in `time/ledger/router-outcomes.jsonl`):", lines172
assert lines172[1] == "- venture: 1 pick(s), 0% merged, 9 day(s) median, $0.50 median", lines172
assert lines172[2] == "- feature: 3 pick(s), 67% merged, 2 day(s) median, $2.12 median", lines172 # KINDS order, as the weights
assert rt.outcome_lines([]) == [] and rt.read_outcomes(None) == [] and rt.read_outcomes("") == []
assert rt.read_outcomes("not json\n" + "".join(json.dumps(r) + "\n" for r in rows172) + '{"no": "number"}\n') == rows172
# Astra puts the summary over the table, and goes without it when nothing has settled
astra.tm.issues = lambda q: closed80 if q.startswith("state=closed") else open80 + [iss80(57, "Astra log", labels=["astra"])]
astra.tm.think = lambda seat, a, b, system, user: "Sure:\n" + json.dumps(reply80)
settled80[:] = rows172
assert astra.router_tick(rt.config('{"mode": "dry"}'), "opus", 57, "ctx", [], 2, 2) == 0
assert "Settled picks so far (4 in" in said80[-1][1] and "- feature: 3 pick(s)" in said80[-1][1], said80[-1][1]
assert "| 1 | #2 Fix the docs |" in said80[-1][1], "the table still follows"
settled80.clear()
assert astra.router_tick(rt.config('{"mode": "dry"}'), "opus", 57, "ctx", [], 2, 2) == 0
assert "Settled picks" not in said80[-1][1], "nothing settled yet: no header"
astra.tm.gh = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("GET x -> 500"))
assert astra.outcomes() == [], "the outcomes log is context: unreadable, the table goes up without it"
astra.tm.gh = gh80
print("ALL OK 172")
# 82 adaptive budget: each route is paced to its capacity pool; no pool, no change
import io, contextlib
real_pools = tm.pools
+1
View File
@@ -28,6 +28,7 @@
- The second fix round escalates to a different model from the first round: `claude:opus` with the Claude seats (it was `venice:kimi code`, and grok 4.7 before that). `TIME_VENTURE_ESCALATE` overrides it.
### Added
- The router records what each pick turned into (#172), so a learning policy (a restless bandit) has rewards to read. Housekeeping's daily sweep settles every card the router picked in live mode into one line of `time/ledger/router-outcomes.jsonl` — `{"n", "kind", "picked", "settled", "merged", "pr", "days", "usd", "signups"}` — once the card closes: merged or not and its PR from the card's own beats, the days from pick to close, the dollars the ledger booked while the card's worker ran (each `dispatched` beat until the next beat on the card: the ledger has no card field, so this is the runs'-window proxy, not the card's exact bill), and, for a venture pick, the venture's signups at day 7 (that pick waits until day 7 has passed). A pick whose card is still open is in flight and produces nothing; one already in the file is never written twice. Over the ranked table Astra's log now shows one line per kind — picks, merged share, median days, median $ — so the owner can see what a kind returns before changing its weight. The file sits under `time/ledger/`, the one path CI ignores, since the sweep appends to it daily. No policy change: `router.score` still makes every pick.
- A cloud PR's revision comes from the cloud, and Time reviews it again. The branch of a cloud-lane PR is the copy's to write, so `/time retry` (the API worker pushing to it) would be overwritten by the next sync. When the reviewer objects instead, the launcher on hb-5 starts one more cloud session on the same branch with the review in its prompt (once; then a human). When the copy's branch moves under a PR that is open and already reviewed, sync.py posts a `dispatched` beat marked `revised` on each card the PR closes and takes `needs-human` off the PR, so the heartbeat reviews the new head and a verdict from before the push no longer counts. A PR not reviewed yet gets nothing; its first review sees the new head.
- The cloud lane: Claude Code cloud sessions build cards on the owner's cloud-session credit, beside the API worker. With `cloud: {"per_day": N, "sizes": ["M"]}` in `time/router.json`, Astra's live run labels the best-ranked actionable cards of those sizes `cloud`, until N cards are in the lane, before the API worker's picks; a `cloud` card is lined up, so it is never queued for the API worker. A launcher on hb-5 (a user agent in the desktop session, where the claude.ai sign-in lives; it reads the public issue list and holds no Teahub credentials) starts one `claude --cloud` session per card, with the card as the prompt, on the GitHub copy. The session pushes a `claude/card-N-*` branch; sync.py brings it home as a PR that closes the card (adding `Closes #N` when the commits don't), moves the card from `cloud` to `ready` with a note that carries no beat, and the heartbeat adopts the PR, so the council reviews and lands it like any card PR. A card the commits close leaves the lane too, whatever the branch is called. `per_day: 0`, the default, is no lane.
- Time brings a PR branch up to date with its base (#123). An approved PR Gitea reports as not `mergeable` (or whose merge call answers 409) gets `git merge origin/<base>` (the PR's own base) pushed as one commit by Time, `card #N: merge <base>`; CI runs on the new head and landing follows on a later beat (a `ci-dispatched` or `updated` beat now reaches `land()` again). A conflict is not guessed at: the merge is aborted, the branch left as it was, and the conflicting files and `<<<<<<<` blocks (trimmed) are posted on the card and the PR with the `needs-human` label; `/time retry` hands them to the worker as the reviewer's text. Known limit: the worker's `write_file` of a resolved file is a plain commit, not the merge, so Time's next try can conflict again.
+23
View File
@@ -30,6 +30,29 @@ Time stops calling models. The next time the heartbeat has a card to run, it ope
Humans should never edit these files. The caps rely on them being exact, and Time only ever appends. A hand edit can hide spend or break the next append.
## The router's outcomes (`router-outcomes.jsonl`)
Not spend, but it lives here for the same reason: Time appends to it daily and CI ignores this folder. One JSON line per
router pick that has settled, written by housekeeping (`.github/time/housekeeping.py`, step 4), so a learning policy has
rewards to read. Fields:
- `n`, `kind`: the card and the kind it was picked as.
- `picked`, `settled`: the day Astra picked it, and the day the card closed.
- `merged`, `pr`: whether its PR landed, and which PR.
- `days`: whole days from `picked` to `settled`.
- `usd`: the dollars this ledger booked while the card's worker ran (each `dispatched` beat until the next beat on the
card). The ledger has no card field, so this is a proxy: review and planning calls on the card are not in it, and two
cards whose runs overlap are each charged what was booked in their own window.
- `signups`: for a venture pick, the venture's signups at day 7 (`TIME_VENTURE_MEASURE_DAYS`); `null` for any other kind.
```json
{"n":168,"kind":"feature","picked":"2026-10-17","settled":"2026-10-19","merged":true,"pr":190,"days":2,"usd":1.25,"signups":null}
```
A pick is written once, when its card closes, and never rewritten. Astra's daily log shows one summary line per kind over
the ranked table (picks, merged share, median days, median $). No pick reads the file yet: `router.score` is still the
whole policy.
## Pools (`time/pools.json`)
The operator keeps one entry per capacity pool; a route with no entry is paced by `TIME_DAILY_USD`. Time reads the file through the API once per run. Fields: `name`, `kind`, `routes`, and by kind: