Router: a card that waits is never picked; Astra gets TIME_WORKER (#185)
ci / test (push) Successful in 37s
sync / sync (push) Successful in 4s

Router: a card that waits is never picked; Astra gets TIME_WORKER (#185)

An open Depends on #N, or the seat's blocked note, keeps a card out of the picks and after the movable cards in the promotion order. astra.yml passes TIME_WORKER and TIME_PACE.

Closes #184.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
This commit was merged in pull request #185.
This commit is contained in:
teahubxyzandClaude Opus 5.5 committed 2026-10-19 13:55:03 +00:00
1 parent 8fdb2f9469
commit bc1da90c33
5 files changed
+39 -13

No files matched your search

+3
View File
@@ -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
View File
@@ -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('|', '/')} | "
+16 -6
View File
@@ -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]
+2
View File
@@ -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