Router: a card that waits is never picked; Astra gets TIME_WORKER #185
No files matched your search
@@ -76,6 +76,9 @@ For every candidate give:
|
||||
it matters today; 1: nice to have, stale, or a duplicate of another card)
|
||||
- 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
|
||||
is still open is already held back by code.
|
||||
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.
|
||||
|
||||
+17
-7
@@ -15,7 +15,9 @@ refinement or optimization.
|
||||
- restless: a kind the factory hasn't picked for a while climbs, 1 + rate x days (at most 10 days), so none starves.
|
||||
- age: an old card climbs a little, 1 + 0.02 x days open (at most 20 days).
|
||||
|
||||
An L is ranked but never queued: a card is what one worker session finishes, so it is split first. The policy is
|
||||
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.
|
||||
"""
|
||||
@@ -93,15 +95,18 @@ def candidates(issues: list[dict]) -> list[dict]:
|
||||
for i in issues:
|
||||
for d in set(re.findall(r"Depends on #(\d+)", i.get("body") or "")):
|
||||
waits[int(d)] = waits.get(int(d), 0) + 1
|
||||
open_n = {i.get("number") for i in issues}
|
||||
out = []
|
||||
for i in issues:
|
||||
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
|
||||
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": True,
|
||||
"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),
|
||||
"created": str(i.get("created_at") or "")})
|
||||
"created": str(i.get("created_at") or ""),
|
||||
**({"why_not": "waits on " + ", ".join(f"#{d}" for d in deps)} if deps else {})})
|
||||
return out
|
||||
|
||||
|
||||
@@ -139,6 +144,9 @@ def score(c: dict, a: dict, cfg: dict, last: dict[str, str], today: dt.date) ->
|
||||
"restless": round(restless, 2), "age": round(age, 2), "score": round(s, 3), "judged": bool(a)}
|
||||
if size == "L" and out["actionable"]:
|
||||
out["actionable"], out["why_not"] = False, "L: split it into cards a worker finishes in one session first"
|
||||
blocked = _txt(a.get("blocked"), 120) if isinstance(a.get("blocked"), str) else ""
|
||||
if blocked.lower() not in ("", "no", "none", "false", "-", "n/a") and out["actionable"]:
|
||||
out["actionable"], out["why_not"] = False, f"blocked: {blocked}" # the seat knows what no Depends line says
|
||||
return out
|
||||
|
||||
|
||||
@@ -165,8 +173,10 @@ def pick(ranked: list[dict], cfg: dict, left_usd: float, max_n: int) -> list[dic
|
||||
|
||||
|
||||
def order(ranked: list[dict]) -> list[int]:
|
||||
"""Card numbers best first: the heartbeat promotes queued cards in this order."""
|
||||
return [r["n"] for r in ranked if r["n"] is not None]
|
||||
"""Card numbers best first, the ones that can move before the ones that wait: the heartbeat promotes queued cards in
|
||||
this order (and still skips a card whose dependency is open)."""
|
||||
cards = [r for r in ranked if r["n"] is not None]
|
||||
return [r["n"] for r in cards if r["actionable"]] + [r["n"] for r in cards if not r["actionable"]]
|
||||
|
||||
|
||||
def table(ranked: list[dict], picked: list[dict], mode: str, limit: int = 15) -> str:
|
||||
@@ -174,8 +184,8 @@ def table(ranked: list[dict], picked: list[dict], mode: str, limit: int = 15) ->
|
||||
mark = "queued today" if mode == "live" else "would queue"
|
||||
rows = []
|
||||
for k, r in enumerate(ranked[:limit], 1):
|
||||
note = (f"**{mark}**" if r["key"] in chosen else "lined up" if r["lined_up"]
|
||||
else r.get("why_not", "") if not r["actionable"] else "")
|
||||
note = (f"**{mark}**" if r["key"] in chosen else "; ".join(x for x in ("lined up" if r["lined_up"] else "",
|
||||
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"]
|
||||
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('|', '/')} | "
|
||||
|
||||
@@ -1151,11 +1151,12 @@ assert rt.guess_kind("Fix it", "", {"kind:optimization"}) == "optimization" and
|
||||
iss80 = lambda n, title, body="DONE: x", labels=(), created="2026-10-18T00:00:00Z": {
|
||||
"number": n, "title": title, "body": body, "labels": [{"name": l} for l in labels], "created_at": created}
|
||||
open80 = [iss80(1, "Engine feature"), iss80(2, "Fix the docs", labels=["queued"]), iss80(3, "Parent", labels=["planned"]),
|
||||
iss80(4, "No done line", body="just an idea"), iss80(5, "Cheaper reviews", body="Depends on #1\nDONE: z"),
|
||||
iss80(4, "No done line", body="just an idea"), iss80(5, "Cheaper reviews", body="Depends on #40\nDONE: z"),
|
||||
iss80(6, "A human's", labels=["needs-human"]), iss80(7, "Huge rewrite", created="2026-10-01T00:00:00Z"),
|
||||
iss80(8, "Huebrain: want the next version?", labels=["signal"])]
|
||||
iss80(8, "Huebrain: want the next version?", labels=["signal"]), iss80(9, "After the feature", body="Depends on #1\nDONE: q")]
|
||||
cands80 = rt.candidates(open80)
|
||||
assert [c["n"] for c in cands80] == [1, 2, 5, 7] and cands80[0]["unblocks"] == 1 and cands80[1]["lined_up"], cands80
|
||||
assert [c["n"] for c in cands80] == [1, 2, 5, 7, 9] and cands80[0]["unblocks"] == 1 and cands80[1]["lined_up"], cands80
|
||||
assert cands80[2]["actionable"] and not cands80[4]["actionable"] and cands80[4]["why_not"] == "waits on #1", "a closed dependency is done; an open one waits"
|
||||
# scoring: the seat's value and size, the owner's weight per kind, restless per kind, age per card
|
||||
today80 = dt.date(2026, 10, 19)
|
||||
assess80 = {"#1": {"kind": "feature", "value": 4, "size": "M", "why": "w"}, "#2": {"kind": "refinement", "value": 5, "size": "s"},
|
||||
@@ -1164,11 +1165,19 @@ assess80 = {"#1": {"kind": "feature", "value": 4, "size": "M", "why": "w"}, "#2"
|
||||
live80 = rt.config('{"mode": "live"}')
|
||||
r80 = rt.rank(cands80 + [dict(rt.NEW_VENTURE)], assess80, live80, {}, today80)
|
||||
s80 = {r["key"]: r for r in r80}
|
||||
assert [r["key"] for r in r80] == ["#2", "#1", "#5", "#7", "venture:new"], [(r["key"], r["score"]) for r in r80]
|
||||
assert [r["key"] for r in r80] == ["#2", "#1", "#5", "#7", "#9", "venture:new"], [(r["key"], r["score"]) for r in r80]
|
||||
assert s80["#1"]["restless"] == 1.5 and abs(s80["#1"]["score"] - 1.5 * 1.02 * 4.5 / 2) < 0.002, s80["#1"] # never picked: 1.5
|
||||
assert s80["#7"]["actionable"] is False and "split" in s80["#7"]["why_not"] and s80["#7"]["age"] == 1.36, s80["#7"]
|
||||
assert s80["venture:new"]["actionable"] is False and s80["venture:new"]["n"] is None and s80["#2"]["size"] == "S"
|
||||
assert rt.order(r80) == [2, 1, 5, 7]
|
||||
assert rt.order(r80) == [2, 1, 5, 7, 9]
|
||||
# the seat can say a card is blocked by what no Depends line shows (a PR that can't merge): never picked, promoted last
|
||||
b80 = rt.rank(cands80, {**assess80, "#1": {"kind": "feature", "value": 5, "size": "S", "blocked": "PR #75 can't merge"}}, live80, {}, today80)
|
||||
sb80 = {r["key"]: r for r in b80}
|
||||
assert b80[0]["key"] == "#1" and sb80["#1"]["actionable"] is False and sb80["#1"]["why_not"] == "blocked: PR #75 can't merge"
|
||||
assert rt.order(b80) == [2, 5, 1, 7, 9] and [r["key"] for r in rt.pick(b80, live80, 10.0, 2)] == ["#5"], "the top scorer waits"
|
||||
assert all(rt.score(cands80[0], {"blocked": x}, live80, {}, today80)["actionable"] for x in ("", "none", "No", False, None, 0))
|
||||
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")
|
||||
# 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]
|
||||
@@ -1202,7 +1211,8 @@ astra.tm.think = lambda seat, a, b, system, user: (asked80.append((seat, system,
|
||||
assert astra.router_tick(rt.config('{"mode": "dry"}'), "opus", 57, "ctx", [], 2, 2) == 0
|
||||
assert not labelled80 and not made80, "dry: nothing queued, nothing created"
|
||||
n80, text80, meta80 = said80[-1]
|
||||
assert n80 == 57 and meta80["router"] == "dry" and meta80["would"] == [1, 5] and meta80["queued"] == 0 and meta80["order"] == [2, 1, 5, 7]
|
||||
assert n80 == 57 and meta80["router"] == "dry" and meta80["would"] == [1, 5] and meta80["queued"] == 0 and meta80["order"] == [2, 1, 5, 7, 9]
|
||||
assert "blocked:" in asked80[0][1], "the seat is told it can mark a card blocked"
|
||||
assert "Dry run" in text80 and "Write a thing" in text80 and "| 1 | #2 Fix the docs" in text80 and "the note" in text80
|
||||
assert "### #4" not in asked80[0][2] and "Astra log" not in asked80[0][2] and "venture:new" in asked80[0][2] and "ctx" in asked80[0][2]
|
||||
assert asked80[0][0] == "opus" and "judge each one" in asked80[0][1]
|
||||
|
||||
@@ -32,3 +32,5 @@ jobs:
|
||||
TIME_SPINE: ${{ vars.TIME_SPINE }}
|
||||
TIME_DAILY_USD: ${{ vars.TIME_DAILY_USD }}
|
||||
TIME_MONTHLY_USD: ${{ vars.TIME_MONTHLY_USD }}
|
||||
TIME_WORKER: ${{ vars.TIME_WORKER }} # the router's money left is the pool that takes the worker's route (#175)
|
||||
TIME_PACE: ${{ vars.TIME_PACE }} # and that pool's allowance is paced the same as in the heartbeat
|
||||
@@ -17,6 +17,7 @@
|
||||
- The Venice worker's shell no longer sees secrets: its environment drops tokens and keys, its checkout keeps no token in `.git/config`, and the workflows check out without persisted credentials.
|
||||
|
||||
### Changed
|
||||
- The router never picks a card that waits, and promotes it after the ones that can move. A card waits when its `Depends on #N` card is still open, or when the seat marks it `blocked` with what it waits on (a PR that can't merge, a human): the router's first live run queued #84, whose PR #75 can't merge against main, because the seat scored it top while writing that it was blocked. The table shows what a card waits on. `astra.yml` now passes `TIME_WORKER` and `TIME_PACE`, without which Astra's money left fell back to the daily cap.
|
||||
- The router's money left is the worker's pool (#175): live mode queues against the allowance left in the pool that takes `TIME_WORKER`'s route (about $8/day from `anthropic-credits` while the credit lasts), or the daily cap's remainder without one, never more than the month has left; it was `TIME_DAILY_USD` minus today's spend, money the worker couldn't spend. The Astra line and the seat's prompt show the figure and its source. The seat also sees the cards closed in the last 14 days and is told not to write them again (it proposed the housekeeping label fix two days running after #137 had landed), and a new card whose title is an open or lately closed card's, or contains or is contained in one of four words or more, is not written; the log lists it under "Not written".
|
||||
- The lead runs the day's venture alone, and fable is out. One seat, `TIME_VENTURE_LEAD` (opus), does a research round (the top six trending repos with the start of their READMEs, the rest as a list, and every studio venture with its status and signups), lists `TIME_VENTURE_FIELD` candidates with a verdict each, and picks one; no pitches, no vote. A pick that repeats a studio venture gets one more go, told so. The lead writes the launch plan alone (no synthesis) and judges the build; it hands the launch-post drafts to its helper, `TIME_VENTURE_HELPER` (sonnet). The chair and the judge default to opus (they were fable, the dearest seat, on calls opus does as well). Batches keep the board, and `TIME_VENTURE_LEAD=board` brings it back for the daily venture.
|
||||
- One venture a day, built for quality. The day's card starts only once every other venture card is launched or held, so one venture at a time gets the day's worker runs and budget; the seats pitch a field of `TIME_VENTURE_FIELD` (6) in rounds, each told what is already pitched, and the board picks one from it (it was one pitch per seat); `claude:opus` builds (`BUILDER`, and `TIME_VENTURE_WORKER` on teahubxyz/time); and the judge scores the product 1–10 as a stranger would. A page under the bar (`TIME_VENTURE_BAR`, 8) gets a polish round with the judge's top three changes; fix and polish rounds share `TIME_VENTURE_FIXES` (now 3); polish never takes the last round, which stays free to fix whatever a polish broke; then a page under the bar launches with its score in the launch post, so quality never holds a launch for a human, only a broken page does. An open batch card runs only while batches are on (`TIME_VENTURE_BATCH`) or once it has Time's beats: batch #26, migrated from GitHub with none, had started a three-venture batch on a single-venture day.
|
||||
|
||||
Reference in new issue
Block a user