Router: optimization candidates from the ledger, CI and parked cards #199
No files matched your search
+83
-12
@@ -14,9 +14,12 @@ one issue, "Astra log", so a human can read the day's reasoning in one place and
|
||||
|
||||
The router (time/router.json, `mode`: off | dry | live; router.py): instead of picking cards in prose, the seat judges
|
||||
every candidate (its kind, its value 1-5, its size) and code ranks them with the owner's weights per kind, a restless
|
||||
boost for kinds not picked lately, and the card's age. The log shows the ranked table. `dry` writes it and queues
|
||||
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),
|
||||
boost for kinds not picked lately, and the card's age. The candidates include the optimizations the factory's own
|
||||
records show (#171): the ledger's spend by model and entry point, workflows whose runs keep failing, and cards parked
|
||||
with their retries spent. Those have no card yet, so a picked one is written as one (unqueued) instead of being queued,
|
||||
and a source a card already names is not proposed again, open or closed lately. The log shows the ranked table. `dry`
|
||||
writes it and queues 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.
|
||||
`off`, or no router.json, is the planner as before.
|
||||
@@ -79,6 +82,10 @@ For every candidate give:
|
||||
- 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
|
||||
is still open is already held back by code.
|
||||
A candidate whose key starts `opt:` is not a card yet: it is something the factory's own records show (a route that took
|
||||
over half the week's spend, a workflow that keeps failing, a card parked with its retries spent), with a title and body
|
||||
already drafted. Judge it like the rest; if it is picked it is written as a card for a worker to take later, never
|
||||
worked on today, so judge whether that work is worth a card at all.
|
||||
You may also write up to {max_new} new cards for work the board is missing; they are candidates from tomorrow on.
|
||||
Never write one that posts outside the repo or touches workflows, secrets or variables, and never one for work that is
|
||||
already a candidate or is in the list of cards closed lately: that work is done or under way.
|
||||
@@ -201,6 +208,55 @@ def closed_lately(days: int = 14) -> list[dict]:
|
||||
return []
|
||||
|
||||
|
||||
def ledger_week(days: int = router.OPT_DAYS) -> list[dict]:
|
||||
"""The ledger lines the router reads (#171): this month's file, and the previous one when the window straddles it.
|
||||
router.ledger_candidates keeps the last `days` days of them."""
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
months = sorted({now.strftime("%Y-%m"), (now - dt.timedelta(days=days)).strftime("%Y-%m")})
|
||||
try:
|
||||
return [r for m in months for r in tm.ledger_lines(m) if isinstance(r, dict)]
|
||||
except Exception as e: # noqa: BLE001 a source, not a dependency: unreadable is no candidates from it
|
||||
print(f"ledger unreadable: {str(e)[:120]}")
|
||||
return []
|
||||
|
||||
|
||||
def action_runs(limit: int = 50) -> list[dict]:
|
||||
"""The newest workflow runs, from the endpoint `tea_actions` reads (`/actions/runs`; Gitea wraps them in
|
||||
`workflow_runs`)."""
|
||||
try:
|
||||
got = tm.gh("GET", f"/repos/{tm.REPO}/actions/runs?limit={limit}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"actions runs unreadable: {str(e)[:120]}")
|
||||
return []
|
||||
return got if isinstance(got, list) else (got or {}).get("workflow_runs") or []
|
||||
|
||||
|
||||
def parked_cards() -> list[dict]:
|
||||
"""The `ready` cards whose last real beat parked them, with what the router needs of each: the beat and when it was
|
||||
written, whether a PR of theirs is open, and how many of #78's automatic retries are left. A card someone trusted
|
||||
has typed `/time retry` on since that beat is left out: the next heartbeat dispatches it (it clears `last` exactly
|
||||
as a spare automatic retry does), so proposing "retry or split" for it would be a card for work already under way."""
|
||||
auto_max = env_int("TIME_AUTO_RETRY", 1)
|
||||
out: list[dict] = []
|
||||
try:
|
||||
prs = tm.gh("GET", f"/repos/{tm.REPO}/pulls?state=open&limit=50") or []
|
||||
for card in tm.issues("state=open&labels=ready"):
|
||||
cs = tm.gh("GET", f"/repos/{tm.REPO}/issues/{card['number']}/comments?limit=100") or []
|
||||
beats = [b for b in tm.beats_of(cs) if b.get("beat") not in ("blocked", "capped")]
|
||||
if not beats or beats[-1].get("beat") not in ("error", "stuck"):
|
||||
continue
|
||||
if tm.retry_requested(cs, beats[-1]["at"]):
|
||||
continue # a person already asked for the retry: the next heartbeat runs it, so it needs no card
|
||||
# an automatic retry is one run of consecutive `auto_retry` beats, counted as the heartbeat counts it
|
||||
used = sum(1 for k, b in enumerate(beats) if b.get("auto_retry") and not (k and beats[k - 1].get("auto_retry")))
|
||||
out.append({"n": card["number"], "title": card.get("title") or "", "beat": beats[-1]["beat"],
|
||||
"at": beats[-1]["at"], "retries_left": max(0, auto_max - used),
|
||||
"open_pr": any(p.get("state", "open") == "open" for p in tm.linked_prs(prs, card["number"]))})
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"parked cards unreadable: {str(e)[:120]}")
|
||||
return out
|
||||
|
||||
|
||||
def _norm(title: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", " ", (title or "").lower()).strip()
|
||||
|
||||
@@ -221,12 +277,17 @@ def repeat_of(title: str, known: list[dict]) -> int | None:
|
||||
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]
|
||||
closed = closed_lately()
|
||||
today_d = dt.datetime.now(dt.timezone.utc).date()
|
||||
cands = router.candidates(open_issues) + [dict(router.NEW_VENTURE)]
|
||||
# what no one wrote a card for (#171): the ledger, the Actions runs and the parked cards, each proposed once. The
|
||||
# cards closed lately name their sources too: a week's spend outlives the card written about it, so keying the open
|
||||
# ones alone would propose the same finding again the day its card closed.
|
||||
cands += router.opt_candidates(ledger_week(), action_runs(), parked_cards(), today_d, router.named(open_issues + closed))
|
||||
listing = "\n\n".join(f"### {c['key']}: {c['title']}\nlabels: {', '.join(c['labels']) or '-'}; opened "
|
||||
f"{c['created'][:10] or '-'}; cards waiting on it: {c['unblocks']}\n{c['body'][:400]}" for c in cands)
|
||||
today, day_cap, month, month_cap = tm.budget()
|
||||
left, source = money_left()
|
||||
closed = closed_lately()
|
||||
done = "\n".join(f"- #{i['number']} {i['title']}" for i in closed[:50]) or "- (none)"
|
||||
user = (f"Factory context (the spine's AGENTS.md and the newest decisions):\n{context}\n\nSpend: ${today:.2f} of "
|
||||
f"${day_cap:.2f} today, ${month:.2f} of ${month_cap:.2f} this month out of pocket (credit-paid spend excluded); "
|
||||
@@ -239,7 +300,7 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
|
||||
tm.comment(log, f"🔭 Astra: {e}. No ranking today.", {"beat": "astra", "router": cfg["mode"], "queued": 0, "capped": True})
|
||||
return 0
|
||||
assess = {str(a.get("key") or "").strip(): a for a in out.get("assess") or [] if isinstance(a, dict)}
|
||||
ranked = router.rank(cands, assess, cfg, router.last_picked(tm.beats_of(thread)), dt.datetime.now(dt.timezone.utc).date())
|
||||
ranked = router.rank(cands, assess, cfg, router.last_picked(tm.beats_of(thread)), today_d)
|
||||
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
|
||||
sent = {r["key"] for r in to_cloud}
|
||||
@@ -249,10 +310,14 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
|
||||
n = repeat_of(str(c["title"]), open_issues + closed)
|
||||
(repeats.append((c, n)) if n else new.append(c))
|
||||
new = new[:max_new]
|
||||
queued, found = [r for r in picks if r["n"] is not None], [r for r in picks if r["n"] is None]
|
||||
live, created = cfg["mode"] == "live", []
|
||||
if live:
|
||||
for r in picks:
|
||||
for r in queued:
|
||||
tm.label(r["n"], "queued") # the heartbeat promotes it, in ranked order
|
||||
for r in found:
|
||||
# a candidate with no card (an optimization the records showed): it becomes a card, never worked on in place
|
||||
r["written"] = tm.new_issue(r["title"][:200], r["body"] + "\n\n<sub>card written by Astra</sub>", [])["number"]
|
||||
for r in to_cloud:
|
||||
tm.label(r["n"], router.CLOUD) # the Mac's launcher starts a cloud session for it
|
||||
for c in new:
|
||||
@@ -270,26 +335,32 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
|
||||
if unjudged:
|
||||
lines.append(f"_{unjudged} candidate(s) the seat didn't judge are scored at value 3, size M._")
|
||||
if live:
|
||||
lines.append(("Queued: " + ", ".join(f"#{r['n']}" for r in picks)) if picks else "Queued nothing today.")
|
||||
lines.append(("Queued: " + ", ".join(f"#{r['n']}" for r in queued)) if queued else "Queued nothing today.")
|
||||
if found:
|
||||
lines.append("Written from the factory's own records (candidates from tomorrow): "
|
||||
+ ", ".join(f"#{r['written']} ({r['key']})" for r in found))
|
||||
if to_cloud:
|
||||
lines.append("To the cloud lane: " + ", ".join(f"#{r['n']}" for r in to_cloud))
|
||||
if created:
|
||||
lines.append("New cards (candidates from tomorrow): " + ", ".join(f"#{n}" for n in created))
|
||||
else:
|
||||
lines.append("Dry run: nothing queued or created. Live, it would queue: "
|
||||
+ (", ".join(f"#{r['n']}" for r in picks) or "nothing") + "."
|
||||
+ (", ".join(f"#{r['n']}" for r in queued) or "nothing") + "."
|
||||
+ (" And write a card for: " + ", ".join(r["key"] for r in found) + "." if found else "")
|
||||
+ (" To the cloud lane: " + ", ".join(f"#{r['n']}" for r in to_cloud) + "." if to_cloud else ""))
|
||||
if new:
|
||||
lines.append("It would write: " + "; ".join(router._txt(c["title"], 100) for c in new) + ".")
|
||||
if repeats:
|
||||
lines.append("Not written, repeats of existing cards: "
|
||||
+ "; ".join(f"{router._txt(c['title'], 80)} (#{n})" for c, n in repeats) + ".")
|
||||
meta = {"beat": "astra", "router": cfg["mode"], "queued": len(picks) if live else 0, "order": router.order(ranked),
|
||||
"picked": [{"n": r["n"], "kind": r["kind"]} for r in picks] if live else [],
|
||||
"would": [r["n"] for r in picks] if not live else [], "new": created,
|
||||
meta = {"beat": "astra", "router": cfg["mode"], "queued": len(queued) if live else 0, "order": router.order(ranked),
|
||||
"picked": [{"n": r.get("written", r["n"]), "kind": r["kind"]} for r in picks] if live else [],
|
||||
"would": [r["n"] for r in queued] if not live else [], "new": created,
|
||||
"opt": [{"key": r["key"], "card": r.get("written")} for r in found],
|
||||
"cloud": [r["n"] for r in to_cloud] if live else [], "would_cloud": [r["n"] for r in to_cloud] if not live else []}
|
||||
tm.comment(log, "\n".join(lines), meta)
|
||||
print(f"astra router {cfg['mode']}: picks={[r['n'] for r in picks]} new={created} ranked={len(ranked)}")
|
||||
print(f"astra router {cfg['mode']}: picks={[r['n'] for r in queued]} opt={[r['key'] for r in found]} "
|
||||
f"new={created} ranked={len(ranked)}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+171
-5
@@ -5,8 +5,8 @@ judgments into one ranked list, in arithmetic anyone can check, and picks from t
|
||||
promotes queued cards in the same order instead of oldest first.
|
||||
|
||||
A candidate is an open card a worker can take (a `DONE:` line; not a parent, not a human's, not the venture loop's
|
||||
own), or work with no card yet that the factory could start (a new venture). Each has a kind: venture, feature,
|
||||
refinement or optimization.
|
||||
own), or work with no card yet that the factory could start (a new venture, or an optimization the factory's own
|
||||
records show: see below). Each has a kind: venture, feature, refinement or optimization.
|
||||
|
||||
score = weight[kind] x restless(kind) x age x (value + 0.5 x unblocks) / size
|
||||
|
||||
@@ -21,6 +21,12 @@ worker. A launcher signed in to the owner's claude.ai account starts a Claude Co
|
||||
session credit pays); the session's `claude/card-N-*` branch comes home through sync.py as a PR, sync relabels the card
|
||||
`ready`, and the heartbeat adopts and reviews it like any card PR. `per_day: 0` (the default) is no cloud lane.
|
||||
|
||||
Optimization candidates (#171): the router reads three of the factory's own records over the last week and proposes the
|
||||
work no one wrote a card for — the ledger (a route or model that takes over half the week's spend, a model whose spend
|
||||
per call is double its own median), the Actions runs (a workflow that keeps failing or being cancelled) and the parked
|
||||
cards (an `error` or `stuck` beat Time will not retry again). Each finding is a synthetic candidate the seat judges like
|
||||
any other; live, a picked one is written as a card, never worked on in place. See the section below.
|
||||
|
||||
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
|
||||
@@ -121,6 +127,161 @@ def candidates(issues: list[dict]) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
# ---------- optimization candidates from the factory's own records (#171) ----------
|
||||
# Three sources, each read by arithmetic over the last week, so optimization work no longer waits for a human to notice
|
||||
# it. Every finding is a synthetic candidate: key `opt:<source>:<id>`, kind optimization, a drafted title and body, and
|
||||
# no card (`n` is None). The seat judges it like any other candidate; live, a picked one is written as a card (unqueued,
|
||||
# a candidate from tomorrow) and never worked on in place. A source a card already names is not proposed again, open or
|
||||
# closed lately: the week the ledger reads outlives the card written about it, so keying open cards alone would propose
|
||||
# the same spend again the day its card closed.
|
||||
|
||||
OPT = "opt:"
|
||||
OPT_DAYS = 7 # the window the ledger and the runs are read over
|
||||
OPT_MIN_USD = 0.25 # a week's spend under this is pennies: nothing to optimize
|
||||
OPT_SHARE = 0.5 # a group over this share of the week's spend is a candidate
|
||||
OPT_SKEW = 2.0 # a model whose spend per call is this many times its own median is a candidate
|
||||
OPT_CALLS = 4 # fewer calls than this say nothing about a model's median
|
||||
OPT_FAILS = 2 # failed or cancelled runs of one workflow that make a candidate
|
||||
OPT_PARKED_DAYS = 1 # a parked card is a candidate once its beat is older than this, in whole days
|
||||
BAD_RUN = ("failure", "cancelled", "canceled")
|
||||
NAMES = re.compile(rf"^\s*Router:\s*({OPT}\S+)", re.M)
|
||||
|
||||
|
||||
def _recent(at, today: dt.date, days: int) -> bool:
|
||||
"""Is this timestamp inside the last `days` days? By the day, like the rest of the router's arithmetic."""
|
||||
return str(at or "")[:10] >= (today - dt.timedelta(days=days)).isoformat()
|
||||
|
||||
|
||||
def _median(xs: list[float]) -> float:
|
||||
s, n = sorted(xs), len(xs)
|
||||
return 0.0 if not n else s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2
|
||||
|
||||
|
||||
def _opt(source: str, ident: str, title: str, body: list[str], created: str,
|
||||
done: str = "PR open that does the above, tests green.") -> dict:
|
||||
"""One synthetic candidate. The body names its own key, which is how a source is proposed once and not every day,
|
||||
and carries the `DONE:` line the card is written with. A key holds no spaces: `named` reads it back as one word."""
|
||||
word = re.sub(r"\s+", "-", str(ident).strip()) or "unknown"
|
||||
key = f"{OPT}{source}:{word}"
|
||||
return {"key": key, "n": None, "title": _txt(title, 120),
|
||||
"body": "\n".join(body) + f"\n\nRouter: {key}\n\nDONE: {done}",
|
||||
"labels": [], "lined_up": False, "actionable": True, "kind": "optimization", "unblocks": 0,
|
||||
"created": created or ""}
|
||||
|
||||
|
||||
def ledger_candidates(rows: list[dict], today: dt.date, days: int = OPT_DAYS) -> list[dict]:
|
||||
"""The week's spend, grouped by model and by entry point (a line's `entry` where it has one, else `pool`, the route
|
||||
it booked to). Two questions are worth a card: a group that took over half of what the factory spent, and a model
|
||||
whose spend per call is double its own median (a few heavy calls hiding behind a cheap average). A dimension with
|
||||
one group says nothing — everything went through it by definition — pennies are left alone (OPT_MIN_USD), and one
|
||||
group yields at most one candidate."""
|
||||
week = [r for r in rows or [] if isinstance(r, dict) and _recent(r.get("at"), today, days)]
|
||||
total = sum(_num(r.get("usd"), 0.0, 0.0, 1e6) for r in week)
|
||||
calls: dict[tuple[str, str], list[float]] = {}
|
||||
for r in week:
|
||||
for dim, name in (("model", r.get("model")), ("entry", r.get("entry") or r.get("pool"))):
|
||||
if name:
|
||||
calls.setdefault((dim, _txt(name, 60)), []).append(_num(r.get("usd"), 0.0, 0.0, 1e6))
|
||||
alone = {dim for dim in ("model", "entry") if len([k for k in calls if k[0] == dim]) < 2}
|
||||
out = []
|
||||
for (dim, name), usd in sorted(calls.items()):
|
||||
spent, n = sum(usd), len(usd)
|
||||
what = "the model" if dim == "model" else "the entry point"
|
||||
if spent < OPT_MIN_USD:
|
||||
continue
|
||||
if total and spent > OPT_SHARE * total and dim not in alone:
|
||||
out.append(_opt("ledger", f"{dim}:{name}", f"Why did {name} cost ${spent:.2f} of the week's ${total:.2f}?", [
|
||||
f"- Over the last {days} days {name} ({what}) booked ${spent:.2f} of the ${total:.2f} the factory spent "
|
||||
f"on models, over {n} call(s): more than half of the bill.",
|
||||
"- Read what those calls are and make them cheaper: a shorter system prompt, the prompt cache, a smaller "
|
||||
"seat for the easy half of the work, or simply fewer calls.",
|
||||
"- Change no behaviour the factory depends on, and say in the PR what the same week would have cost."],
|
||||
today.isoformat()))
|
||||
continue
|
||||
per, mid = spent / n, _median(usd)
|
||||
if dim == "model" and n >= OPT_CALLS and mid > 0 and per >= OPT_SKEW * mid:
|
||||
out.append(_opt("ledger", f"{dim}:{name}", f"Why does {name} cost ${per:.4f} a call against a ${mid:.4f} median?", [
|
||||
f"- {name} made {n} call(s) in the last {days} days for ${spent:.2f}: ${per:.4f} a call against a median "
|
||||
f"of ${mid:.4f} a call, so a few heavy calls carry the bill.",
|
||||
"- Find the heavy ones (the longest prompts, the longest answers) and cut what they send or ask for; the "
|
||||
"cheap calls are already cheap.",
|
||||
"- Say in the PR which calls they were and what they cost before and after."],
|
||||
today.isoformat()))
|
||||
return out
|
||||
|
||||
|
||||
def _workflow(run: dict) -> str:
|
||||
"""Which workflow a run belongs to: its file's name, else the workflow's name, else its id."""
|
||||
path = str(run.get("path") or "").strip().rsplit("/", 1)[-1]
|
||||
return _txt(path or run.get("name") or run.get("workflow_id") or "unknown", 60)
|
||||
|
||||
|
||||
def run_candidates(runs, today: dt.date, days: int = OPT_DAYS) -> list[dict]:
|
||||
"""Actions runs as `/actions/runs` gives them (the shape `tea_actions` reads: the list, or `{"workflow_runs": [...]}`).
|
||||
A workflow with OPT_FAILS or more failed or cancelled runs in the window is one candidate, with the failure count
|
||||
and the last run's id, which is where to start reading."""
|
||||
rows = runs.get("workflow_runs") if isinstance(runs, dict) else runs
|
||||
bad: dict[str, list[dict]] = {}
|
||||
for r in rows or []:
|
||||
if not isinstance(r, dict) or str(r.get("conclusion") or "").strip().lower() not in BAD_RUN:
|
||||
continue
|
||||
if _recent(r.get("created_at") or r.get("run_started_at") or r.get("started_at") or r.get("updated_at"), today, days):
|
||||
bad.setdefault(_workflow(r), []).append(r)
|
||||
out = []
|
||||
for wf, rs in sorted(bad.items()):
|
||||
if len(rs) < OPT_FAILS:
|
||||
continue
|
||||
last = max(rs, key=lambda r: (str(r.get("created_at") or ""), str(r.get("id") or "")))
|
||||
out.append(_opt("ci", wf, f"Why does {wf} keep failing? {len(rs)} runs in {days} days", [
|
||||
f"- `{wf}` has {len(rs)} failed or cancelled run(s) in the last {days} days; the last one is run "
|
||||
f"`{last.get('id')}`. A workflow that fails this often is either broken or flaky, and either way the factory "
|
||||
"reads its red as noise.",
|
||||
"- Read that run, find the one cause, and fix it in the code the workflow runs (a test, a script, a missing "
|
||||
"guard), not by making the check weaker.",
|
||||
"- Workflow files are a human's: if the change belongs in `.github/workflows/`, write the exact change in the "
|
||||
"PR and the commit message and stop at the code."],
|
||||
today.isoformat()))
|
||||
return out
|
||||
|
||||
|
||||
def parked_candidates(parked: list[dict], today: dt.date, days: int = OPT_PARKED_DAYS) -> list[dict]:
|
||||
"""Cards parked by an `error` or `stuck` beat (as astra.parked_cards reads them): one candidate each, to retry it by
|
||||
hand or split it. A card with an open PR belongs to the review path, and one whose automatic retries (#78) are not
|
||||
spent is Time's own to retry; neither is proposed, nor is one whose beat is not yet older than `days` whole days —
|
||||
at the default that means a card parked today or yesterday waits, and one parked the day before is a candidate."""
|
||||
out = []
|
||||
for p in parked or []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
n, at, beat = p.get("n"), str(p.get("at") or ""), str(p.get("beat") or "")
|
||||
if n is None or beat not in ("error", "stuck") or p.get("open_pr"):
|
||||
continue
|
||||
if _num(p.get("retries_left"), 0.0) > 0 or _recent(at, today, days):
|
||||
continue
|
||||
out.append(_opt("parked", str(n), f"Retry or split #{n}: {_txt(p.get('title'), 70)}", [
|
||||
f"- #{n} has been parked since {at[:10] or 'a while ago'}: its last beat was `{beat}` and Time's automatic "
|
||||
"retries are spent, so it sits still with no PR of its own and no one waiting on it.",
|
||||
f"- Read the beats on #{n}, find what stopped the worker, and either fix that so a retry goes through, or "
|
||||
"split the card into ones a worker finishes in one session.",
|
||||
f"- Leave #{n} open: the work here is getting it moving again."],
|
||||
at or today.isoformat(),
|
||||
done=f"#{n} is moving again (a retry that goes through, or the cards that replace it), tests green."))
|
||||
return out
|
||||
|
||||
|
||||
def named(issues: list[dict]) -> set[str]:
|
||||
"""The sources these cards already name (`Router: opt:<source>:<id>` in the body): proposed once, not every day.
|
||||
Astra passes the open cards and the ones closed lately, so a source isn't proposed again the day its card closes
|
||||
while the window the finding came from is still the same week."""
|
||||
return {m for i in issues for m in NAMES.findall(i.get("body") or "")}
|
||||
|
||||
|
||||
def opt_candidates(ledger: list[dict], runs, parked: list[dict], today: dt.date, known=frozenset()) -> list[dict]:
|
||||
"""Every optimization the three sources show today, minus the ones a card already names (`known`)."""
|
||||
found = ledger_candidates(ledger, today) + run_candidates(runs, today) + parked_candidates(parked, today)
|
||||
return [c for c in found if c["key"] not in known]
|
||||
|
||||
|
||||
def last_picked(beats: list[dict]) -> dict[str, str]:
|
||||
"""The day each kind was last picked, from Astra's live beats."""
|
||||
out: dict[str, str] = {}
|
||||
@@ -208,9 +369,14 @@ def table(ranked: list[dict], picked: list[dict], mode: str, limit: int = 15, cl
|
||||
rows = []
|
||||
for k, r in enumerate(ranked[:limit], 1):
|
||||
lane = "cloud lane" if CLOUD in r.get("labels", ()) else "lined up" if r["lined_up"] else ""
|
||||
note = (f"**{mark}**" if r["key"] in chosen else ("**to the cloud today**" if mode == "live" else "**would go to the cloud**")
|
||||
if r["key"] in sent else "; ".join(x for x in (lane, r.get("why_not", "") if not r["actionable"] else "") if x))
|
||||
name = f"{r['key']} {r['title']}" if r["n"] is not None else r["title"]
|
||||
if r["key"] in chosen: # a candidate with no card is written as one instead of being queued
|
||||
note = f"**written as #{r['written']}**" if r.get("written") else \
|
||||
f"**{mark}**" if r["n"] is not None else "**would be written as a card**"
|
||||
elif r["key"] in sent:
|
||||
note = "**to the cloud today**" if mode == "live" else "**would go to the cloud**"
|
||||
else:
|
||||
note = "; ".join(x for x in (lane, r.get("why_not", "") if not r["actionable"] else "") if x)
|
||||
name = f"{r['key']} {r['title']}" if r["n"] is not None or r["key"].startswith(OPT) else r["title"]
|
||||
rows.append(f"| {k} | {_txt(name, 80).replace('|', '/')} | {r['kind']} | {r['value']:g} | {r['size']} | "
|
||||
f"{r['unblocks']} | {r['restless']:g} | {r['score']:g} | {_txt(r['why'], 140).replace('|', '/')} | "
|
||||
f"{_txt(note, 90).replace('|', '/')} |")
|
||||
|
||||
@@ -1226,6 +1226,8 @@ 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)}
|
||||
astra.tm.ledger_lines = lambda month: [] # the optimization sources (#171) are the next section's; none of them here
|
||||
astra.tm.gh = lambda method, path, body=None, **k: [] if "/comments" in path or "/pulls" in path else {"workflow_runs": []}
|
||||
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 +1333,159 @@ else: os.environ["TIME_PARALLEL"] = par135
|
||||
tm.issues, tm.gh, tm.label, tm.unlabel = real80
|
||||
print("ALL OK 80")
|
||||
|
||||
# 171 optimization candidates the factory finds itself: the ledger, the Actions runs and the parked cards. Each source
|
||||
# is a synthetic candidate the seat judges like any other; live, a picked one is written as a card, never worked on in place.
|
||||
today171 = dt.date(2026, 10, 19)
|
||||
led171 = [{"at": "2026-10-16T09:00:00Z", "model": "sonnet", "usd": 2.0}] + \
|
||||
[{"at": "2026-10-17T09:00:00Z", "model": "haiku", "usd": 0.1}] * 3
|
||||
one171 = rt.ledger_candidates(led171, today171)
|
||||
assert [c["key"] for c in one171] == ["opt:ledger:model:sonnet"], one171
|
||||
assert one171[0]["title"] == "Why did sonnet cost $2.00 of the week's $2.30?" and one171[0]["kind"] == "optimization"
|
||||
assert one171[0]["n"] is None and one171[0]["actionable"] and not one171[0]["lined_up"] and one171[0]["created"] == "2026-10-19"
|
||||
assert "Router: opt:ledger:model:sonnet" in one171[0]["body"] and rt.DONE.search(one171[0]["body"]), one171[0]["body"]
|
||||
assert "$2.00 of the $2.30" in one171[0]["body"] and "3 call(s)" not in one171[0]["body"]
|
||||
# by entry point too (`entry`, else the `pool` the call booked to); a dimension with one group says nothing
|
||||
pooled171 = [dict(r, pool="anthropic" if r["model"] == "sonnet" else "venice") for r in led171]
|
||||
assert [c["key"] for c in rt.ledger_candidates(pooled171, today171)] == ["opt:ledger:entry:anthropic", "opt:ledger:model:sonnet"]
|
||||
assert [c["key"] for c in rt.ledger_candidates([dict(r, entry="heartbeat") for r in led171], today171)] == ["opt:ledger:model:sonnet"]
|
||||
assert rt.ledger_candidates(led171[:1], today171) == [], "one model, every dollar: nothing to compare it with"
|
||||
assert rt.ledger_candidates([{"at": "2026-10-16T09:00:00Z", "model": m, "usd": 0.01} for m in ("a", "b", "b")], today171) == [], "pennies"
|
||||
assert rt.ledger_candidates([dict(r, at="2026-10-01T09:00:00Z") for r in led171], today171) == [], "older than the window"
|
||||
assert rt.ledger_candidates([], today171) == [] and rt.ledger_candidates([{"at": "2026-10-16T09:00:00Z"}], today171) == []
|
||||
# a model whose spend per call is double its own median: a few heavy calls behind a cheap average
|
||||
skew171 = [{"at": "2026-10-16T09:00:00Z", "model": "sonnet", "usd": u} for u in (0.01, 0.01, 0.01, 0.01, 1.0)]
|
||||
assert [c["key"] for c in rt.ledger_candidates(skew171, today171)] == ["opt:ledger:model:sonnet"]
|
||||
assert "a $0.0100 median" in rt.ledger_candidates(skew171, today171)[0]["title"]
|
||||
assert rt.ledger_candidates(skew171[:4], today171) == [], "four cheap calls: no median worth a card"
|
||||
assert rt._median([3.0, 1.0]) == 2.0 and rt._median([3.0, 1.0, 2.0]) == 2.0 and rt._median([]) == 0.0
|
||||
# the Actions runs: two or more failed or cancelled runs of one workflow, with the count and the last run's id
|
||||
run171 = lambda i, wf, c, at: {"id": i, "path": f".gitea/workflows/{wf}", "conclusion": c, "created_at": at}
|
||||
runs171 = {"workflow_runs": [run171(51, "ci.yml", "failure", "2026-10-18T10:00:00Z"), run171(52, "ci.yml", "cancelled", "2026-10-19T10:00:00Z"),
|
||||
run171(53, "heartbeat.yml", "failure", "2026-10-19T10:00:00Z"), run171(54, "ci.yml", "success", "2026-10-19T11:00:00Z"),
|
||||
run171(55, "old.yml", "failure", "2026-10-01T10:00:00Z"), run171(56, "old.yml", "failure", "2026-10-02T10:00:00Z")]}
|
||||
ci171 = rt.run_candidates(runs171, today171)
|
||||
assert [c["key"] for c in ci171] == ["opt:ci:ci.yml"], [c["key"] for c in ci171]
|
||||
assert ci171[0]["title"] == "Why does ci.yml keep failing? 2 runs in 7 days" and "run `52`" in ci171[0]["body"], ci171[0]
|
||||
assert "2 failed or cancelled run(s)" in ci171[0]["body"] and "Router: opt:ci:ci.yml" in ci171[0]["body"]
|
||||
assert rt.run_candidates(runs171["workflow_runs"], today171) == ci171, "the list and the wrapped shape read the same"
|
||||
assert [c["key"] for c in rt.run_candidates([{"id": 60, "name": "sync", "conclusion": "failure", "created_at": "2026-10-18T10:00:00Z"}] * 2,
|
||||
today171)] == ["opt:ci:sync"], "no path: the workflow's name"
|
||||
spaced171 = rt.run_candidates([{"id": 61, "name": "CI build", "conclusion": "failure", "updated_at": "2026-10-18T10:00:00Z"}] * 2, today171)
|
||||
assert [c["key"] for c in spaced171] == ["opt:ci:CI-build"] and rt.named([iss80(82, "x", body=spaced171[0]["body"])]) == {"opt:ci:CI-build"}
|
||||
assert rt.run_candidates(None, today171) == [] and rt.run_candidates([{"junk": 1}, "junk"], today171) == []
|
||||
# parked cards: an `error` or `stuck` beat older than a day, no open PR, and #78's automatic retries spent
|
||||
park171 = lambda n, title, beat="error", at="2026-10-16T08:00:00Z", left=0, pr=False: {
|
||||
"n": n, "title": title, "beat": beat, "at": at, "retries_left": left, "open_pr": pr}
|
||||
parked171 = [park171(11, "A stuck card", beat="stuck"), park171(12, "Retries left", left=1), park171(13, "Has a PR", pr=True),
|
||||
park171(14, "Parked today", at="2026-10-19T08:00:00Z"), park171(15, "Moving", beat="dispatched")]
|
||||
p171 = rt.parked_candidates(parked171, today171)
|
||||
assert [c["key"] for c in p171] == ["opt:parked:11"], [c["key"] for c in p171]
|
||||
assert p171[0]["title"] == "Retry or split #11: A stuck card" and p171[0]["created"] == "2026-10-16T08:00:00Z"
|
||||
assert "last beat was `stuck`" in p171[0]["body"] and "DONE: #11 is moving again" in p171[0]["body"]
|
||||
assert rt.parked_candidates([], today171) == [] and rt.parked_candidates(["junk", {"n": None}], today171) == []
|
||||
# "older than a day" by the day, like the rest of the router's arithmetic: parked today or yesterday still waits
|
||||
day171 = lambda d: [park171(16, "Parked then", at=(today171 - dt.timedelta(days=d)).isoformat() + "T08:00:00Z")]
|
||||
assert rt.parked_candidates(day171(0), today171) == [] and rt.parked_candidates(day171(1), today171) == [], "today, yesterday"
|
||||
assert [c["key"] for c in rt.parked_candidates(day171(2), today171)] == ["opt:parked:16"], "the day before yesterday is older than a day"
|
||||
# no duplicates: a source a card already names is not proposed again
|
||||
seen171 = [iss80(80, "Why did sonnet cost so much?", body="- look at it\n\nRouter: opt:ledger:model:sonnet\n\nDONE: cheaper")]
|
||||
assert rt.named(seen171) == {"opt:ledger:model:sonnet"} and rt.named([iss80(81, "x")]) == set()
|
||||
assert [c["key"] for c in rt.opt_candidates(led171, runs171, parked171, today171)] == \
|
||||
["opt:ledger:model:sonnet", "opt:ci:ci.yml", "opt:parked:11"]
|
||||
assert [c["key"] for c in rt.opt_candidates(led171, runs171, parked171, today171, rt.named(seen171))] == ["opt:ci:ci.yml", "opt:parked:11"]
|
||||
assert rt.opt_candidates(led171, runs171, parked171, today171,
|
||||
{"opt:ledger:model:sonnet", "opt:ci:ci.yml", "opt:parked:11"}) == [], "all three already have cards"
|
||||
# a card closed lately names its source too: the week the finding came from outlives the card written about it, so the
|
||||
# same spend is not proposed again the day its card closes. The title carries the dollar amount, so repeat_of can't see it
|
||||
shut171 = [iss80(79, "Why did sonnet cost $1.80 of the week's $2.10?", body="- older numbers\n\nRouter: opt:ledger:model:sonnet\n\nDONE: x")]
|
||||
assert rt.named(shut171) == {"opt:ledger:model:sonnet"} and rt.named(seen171 + shut171) == {"opt:ledger:model:sonnet"}
|
||||
assert [c["key"] for c in rt.opt_candidates(led171, runs171, parked171, today171, rt.named(shut171))] == ["opt:ci:ci.yml", "opt:parked:11"]
|
||||
assert astra.repeat_of(one171[0]["title"], shut171) is None, "a different amount is a different title: the key is what catches it"
|
||||
# the table shows a synthetic candidate by its key, and what would become of it
|
||||
r171 = rt.rank(rt.opt_candidates(led171, runs171, parked171, today171), {}, live80, {}, today171)
|
||||
assert "| opt:ci:ci.yml Why does ci.yml keep failing" in rt.table(r171, [], "dry")
|
||||
assert "**would be written as a card**" in rt.table(r171, r171[:1], "dry")
|
||||
r171[0]["written"] = 99
|
||||
assert "**written as #99**" in rt.table(r171, r171[:1], "live")
|
||||
# the cloud lane never takes a synthetic candidate, however it ranks: there is no card to label `cloud` and no card for a
|
||||
# cloud session to build. Nor is it in the promotion order, for the same reason
|
||||
cl171 = rt.config('{"mode": "live", "cloud": {"per_day": 2, "sizes": ["S", "M"]}}')
|
||||
top171 = rt.rank(rt.opt_candidates(led171, runs171, parked171, today171) + rt.candidates([iss80(40, "A real card")]),
|
||||
{"opt:ledger:model:sonnet": {"kind": "optimization", "value": 5, "size": "M"},
|
||||
"#40": {"kind": "feature", "value": 1, "size": "M"}}, cl171, {}, today171)
|
||||
assert top171[0]["key"] == "opt:ledger:model:sonnet" and top171[0]["n"] is None, [(r["key"], r["score"]) for r in top171]
|
||||
assert [r["key"] for r in rt.pick_cloud(top171, cl171, 0)] == ["#40"], "the opt: candidate at the top is skipped, not labelled None"
|
||||
assert rt.order(top171) == [40] and "to the cloud" not in rt.table(top171, [], "live", cloud=rt.pick_cloud(top171, cl171, 0)).split("| #40")[0]
|
||||
|
||||
# Astra gathers the three sources and, live, writes the picked one as an unqueued card; dry writes nothing
|
||||
said171, labelled171, made171, asked171 = [], [], [], []
|
||||
beat171 = lambda b, h, **extra: {"user": {"login": astra.tm.TIME_BOT}, "created_at": iso(h),
|
||||
"body": "x\n<!-- time " + json.dumps({"beat": b, **extra}) + " -->"}
|
||||
beats171 = [beat171("dispatched", 96, launched=True), beat171("error", 90),
|
||||
beat171("dispatched", 84, launched=True, auto_retry=True), beat171("error", 72, auto_retry=True)]
|
||||
led171live = [{"at": iso(48), "model": "sonnet", "usd": 2.0}] + [{"at": iso(48), "model": "haiku", "usd": 0.1}] * 3
|
||||
runs171live = [run171(51, "ci.yml", "failure", iso(48)), run171(52, "ci.yml", "cancelled", iso(24))]
|
||||
card171 = [iss80(30, "A parked card", labels=["ready"])]
|
||||
astra.tm.issues = lambda q: [] if q.startswith("state=closed") else card171
|
||||
astra.tm.gh = lambda method, path, body=None, **k: beats171 if "/comments" in path else [] if "/pulls" in path \
|
||||
else {"workflow_runs": runs171live}
|
||||
astra.tm.ledger_lines = lambda month: led171live if month == NOW.strftime("%Y-%m") else []
|
||||
astra.tm.pools = lambda: []
|
||||
astra.tm.budget = lambda: (1.0, 15.0, 10.0, 100.0)
|
||||
astra.tm.comment = lambda n, text, meta: said171.append((n, text, meta))
|
||||
astra.tm.label = lambda n, name: labelled171.append((n, name))
|
||||
astra.tm.new_issue = lambda title, body, labels: made171.append((title, body, labels)) or {"number": 200 + len(made171)}
|
||||
assert [p["n"] for p in astra.parked_cards()] == [30] and astra.parked_cards()[0]["retries_left"] == 0, astra.parked_cards()
|
||||
assert astra.parked_cards()[0]["beat"] == "error" and not astra.parked_cards()[0]["open_pr"]
|
||||
# a `/time retry` someone trusted typed after that beat clears `last` for the next heartbeat, exactly as a spare
|
||||
# automatic retry does, so the card is not proposed: Time is about to run it
|
||||
astra.tm.trusted = lambda login: login == "owner"
|
||||
retried171 = lambda who, h: beats171 + [{"user": {"login": who}, "created_at": iso(h), "body": "/time retry"}]
|
||||
gh171 = lambda comments: (lambda method, path, body=None, **k: comments if "/comments" in path else [] if "/pulls" in path
|
||||
else {"workflow_runs": runs171live})
|
||||
astra.tm.gh = gh171(retried171("owner", 60))
|
||||
assert astra.parked_cards() == [], "the retry is Time's own to run"
|
||||
astra.tm.gh = gh171(retried171("mallory", 60))
|
||||
assert [p["n"] for p in astra.parked_cards()] == [30], "a stranger on a public repo can't re-spend the worker"
|
||||
astra.tm.gh = gh171(retried171("owner", 96))
|
||||
assert [p["n"] for p in astra.parked_cards()] == [30], "a retry older than the beat it would answer is spent"
|
||||
astra.tm.gh = gh171(beats171)
|
||||
assert len(astra.ledger_week()) == 4 and [r["id"] for r in astra.action_runs()] == [51, 52]
|
||||
keys171 = ["opt:ledger:model:sonnet", "opt:ci:ci.yml", "opt:parked:30"]
|
||||
reply171 = {"note": "the records", "assess": [{"key": k, "kind": "optimization", "value": 5, "size": "S"} for k in keys171]}
|
||||
astra.tm.think = lambda seat, a, b, system, user: (asked171.append(user), json.dumps(reply171))[1]
|
||||
assert astra.router_tick(rt.config('{"mode": "dry"}'), "opus", 57, "ctx", [], 2, 2) == 0
|
||||
assert not made171 and not labelled171, "dry: no card written, nothing queued"
|
||||
text171, meta171 = said171[-1][1], said171[-1][2]
|
||||
assert "And write a card for: opt:parked:30, opt:ledger:model:sonnet." in text171, text171
|
||||
assert "**would be written as a card**" in text171 and meta171["queued"] == 0 and meta171["would"] == []
|
||||
assert meta171["opt"] == [{"key": "opt:parked:30", "card": None}, {"key": "opt:ledger:model:sonnet", "card": None}]
|
||||
assert all(k in asked171[-1] for k in keys171) and "Retry or split #30" in asked171[-1], "the seat judges them like any candidate"
|
||||
assert astra.router_tick(rt.config('{"mode": "live"}'), "opus", 57, "ctx", [], 2, 2) == 0
|
||||
assert [m[0] for m in made171] == ["Retry or split #30: A parked card", "Why did sonnet cost $2.00 of the week's $2.30?"], made171
|
||||
assert made171[0][2] == [] and labelled171 == [], "written unqueued; #30 itself is never worked on in place"
|
||||
assert "Router: opt:parked:30" in made171[0][1] and "card written by Astra" in made171[0][1] and rt.DONE.search(made171[0][1])
|
||||
text171, meta171 = said171[-1][1], said171[-1][2]
|
||||
assert "Written from the factory's own records (candidates from tomorrow): #201 (opt:parked:30), #202 (opt:ledger:model:sonnet)" in text171
|
||||
assert "**written as #201**" in text171 and "Queued nothing today." in text171
|
||||
assert meta171["opt"] == [{"key": "opt:parked:30", "card": 201}, {"key": "opt:ledger:model:sonnet", "card": 202}]
|
||||
assert meta171["picked"] == [{"n": 201, "kind": "optimization"}, {"n": 202, "kind": "optimization"}] and meta171["queued"] == 0
|
||||
# the card Astra wrote names its source, so the same source is not proposed again
|
||||
card171.append(iss80(31, "Retry or split #30: A parked card", body=made171[0][1], labels=["queued"]))
|
||||
made171.clear()
|
||||
assert astra.router_tick(rt.config('{"mode": "live"}'), "opus", 57, "ctx", [], 2, 2) == 0
|
||||
assert [m[0] for m in made171] == ["Why did sonnet cost $2.00 of the week's $2.30?", "Why does ci.yml keep failing? 2 runs in 7 days"]
|
||||
assert said171[-1][2]["opt"] == [{"key": "opt:ledger:model:sonnet", "card": 201}, {"key": "opt:ci:ci.yml", "card": 202}]
|
||||
# and still not proposed once that card closes: #30 is parked on the same beat, so the finding hasn't changed
|
||||
shut171e = card171.pop() # the card Astra wrote for opt:parked:30 is closed now, not open
|
||||
astra.tm.issues = lambda q: [shut171e] if q.startswith("state=closed") else card171
|
||||
made171.clear()
|
||||
assert astra.router_tick(rt.config('{"mode": "live"}'), "opus", 57, "ctx", [], 2, 2) == 0
|
||||
assert [m[0] for m in made171] == ["Why did sonnet cost $2.00 of the week's $2.30?", "Why does ci.yml keep failing? 2 runs in 7 days"]
|
||||
assert "opt:parked:30" not in said171[-1][1] and said171[-1][2]["opt"] == [{"key": "opt:ledger:model:sonnet", "card": 201},
|
||||
{"key": "opt:ci:ci.yml", "card": 202}]
|
||||
print("ALL OK 171")
|
||||
|
||||
# 82 adaptive budget: each route is paced to its capacity pool; no pool, no change
|
||||
import io, contextlib
|
||||
real_pools = tm.pools
|
||||
|
||||
@@ -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 proposes optimization work the factory's own records show, instead of waiting for a human to notice it (#171; `router.py`, `astra.py`). Three sources, read by arithmetic over the last seven days: the ledger (a model or an entry point that booked over half the week's spend, where there is a second one to compare it with; a model whose spend per call is double its own median, so a few heavy calls carry the bill; pennies are left alone), the Actions runs (a workflow with two or more failed or cancelled runs, with the count and the last run's id) and the parked cards (an `error` or `stuck` beat older than a day — by the day, so one parked today or yesterday still waits — with no open PR, #78's automatic retries spent and no trusted `/time retry` the next heartbeat will act on: "retry or split #N"). Each finding is a candidate with no card yet, keyed `opt:<source>:<id>` and kind optimization, with a title and body already drafted; Astra's seat judges it like any other candidate. Live, a picked one is written as a card (unqueued, a candidate from tomorrow) and never worked on in place; dry, the ranked table shows it. The cloud lane never takes one, however it ranks: there is no card to label `cloud`. A source a card already names (`Router: opt:<source>:<id>` in the body) is not proposed again, whether that card is open or was closed in the last fortnight — the week a finding came from outlives the card written about it, so keying the open ones alone would propose the same spend again the day its card closed.
|
||||
- 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.
|
||||
- A card waiting on a person holds no `ready` slot (#135). `promote_next` counts toward `TIME_PARALLEL` only the `ready` cards that are moving; one whose last real beat is `needs-human`, `error` or `stuck` (an objection, or a failure Time won't retry again) keeps its label, so `/time retry` still finds it, but frees its slot. Three such cards held every slot twice, the second time for ten hours on 2026-10-19 while 22 cards sat `queued`. The run log prints `promote: ready=N moving=M`.
|
||||
|
||||
Reference in new issue
Block a user