Router: a card that waits is never picked; Astra gets TIME_WORKER (#185)
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:
1 parent
8fdb2f9469
commit
bc1da90c33
5 files changed
+39
-13
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
|
||||
Reference in new issue
Block a user