Router: answer haiku's review of the optimization candidates
ci / test (pull_request) Successful in 44s
heartbeat / tick (pull_request_review) Successful in 10s

Two of the four points hold and are fixed; two don't, and nothing changed for
them. The review was read from the diff alone, and the two it gets wrong are
both about code the diff doesn't carry.

**The cloud lane (holds as a test, not as a bug).** `pick_cloud` already skips a
candidate with no card: `router.py:333` reads `if r["n"] is not None and
r["actionable"] and ...`, and so does `order()` at `router.py:357`. Both guards
came with the cloud lane in #197, for `venture:new`, which has `n: None` for the
same reason an `opt:` candidate does; this card added a second kind of cardless
candidate and inherited them. So `tm.label(None, "cloud")` cannot happen and the
table cannot say "to the cloud today" for an `opt:` row. No code changed. The
test the review asks for is worth having and is now there: an `opt:` candidate
judged value 5 ranks first (3.75 against 0.765) under `per_day: 2, sizes:
["S", "M"]`, and the cloud lane takes only the real card behind it. Dropping
the `n is not None` from `pick_cloud` fails it.

**The parked window (holds as wording only).** The arithmetic is already what
the review proposes. `_recent(at, today, 1)` is `at >= today - 1 day`, and a
parked card is a candidate when that is false, i.e. when `at < today - 1 day` —
the review's own suggested fix. In whole days that is "parked the day before
yesterday or earlier", which is what "older than a day" means at day
granularity, so the threshold matches the card. What was wrong was the
docstring's "nor is one parked only today": it also holds back one parked
yesterday. `parked_candidates` now says so, and the boundary is pinned by test
(0 and 1 days yield nothing, 2 yields the candidate). No code changed.

**A closed card no longer lets its source come back (fixed).** This was real.
`named` was given only the open cards, so the day an optimization card closed
the same finding was proposed again — the ledger's window is seven days and
outlives the card written about it, and `repeat_of` cannot catch it because the
title carries the dollar amount, which moves. `router_tick` now keys the cards
closed lately too (`named(open_issues + closed)`; `closed_lately()` moved above
the candidate gathering, no extra call). Suppression lasts as long as
`closed_lately`'s fortnight, by which time the seven-day window no longer holds
the week the card was about, so a finding that is still true comes back as a
fresh question rather than never.

**`parked_cards` ignored a human retry (fixed).** The fourth point arrived
truncated at "astra.py, `parked_c", so I audited the function instead of
guessing at it, and it had a gap of its own: it checked #78's automatic retries
but not `/time retry`. The heartbeat clears a parked card's `last` for either
one (`time.py:1613` for the typed retry, `time.py:1622` for the automatic), so a
card someone trusted had already asked to retry was still proposed as "retry or
split #N" — a card for work the next heartbeat was about to do. It now skips
those, reusing `tm.retry_requested` so the rule is the heartbeat's, and the
tests cover the three cases that distinguish it: a trusted retry after the beat
holds the card back, a stranger's does not, and neither does one older than the
beat it would answer.

Audited and left alone: `open_pr` stays on `linked_prs`, not `adoptable`. It
looks like the broader check, but a card whose last beat is `error` is never
adopted (`time.py:1637` adopts only from `None`/`needs-done`/`would-dispatch`),
so a `PR: #N` line on a parked card does not put it in the review path, and
using `adoptable` would have hidden a genuinely stuck card. `linked_prs` is also
exactly what the heartbeat uses for this same decision at `time.py:1622`.

`python3 .github/time/test_time.py`, `test_seed.py` and `test_housekeeping.py`
are green. Each fix fails its new check when reverted.

Closes #171

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqK2e5z4sMp2BKghDt1D8w
This commit is contained in:
Claude committed 2026-10-19 22:10:29 +00:00
1 parent cf38e08677
commit 31f382d0e9
4 files changed
+67 -16

No files matched your search

+15 -9
View File
@@ -17,9 +17,9 @@ every candidate (its kind, its value 1-5, its size) and code ranks them with the
boost for kinds not picked lately, and the card's age. The candidates include the optimizations the factory's own 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 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, 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 an open card already names is not proposed again. The log shows the ranked table. `dry` writes it and queues and a source a card already names is not proposed again, open or closed lately. The log shows the ranked table. `dry`
nothing; `live` queues from the top while the day's money left covers each card's estimated cost (the allowance left writes it and queues nothing; `live` queues from the top while the day's money left covers each card's estimated cost
in the pool that takes the worker's route, else the daily cap's remainder, never more than the month has left), (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 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.
`off`, or no router.json, is the planner as before. `off`, or no router.json, is the planner as before.
@@ -233,16 +233,20 @@ def action_runs(limit: int = 50) -> list[dict]:
def parked_cards() -> list[dict]: 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 """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.""" 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) auto_max = env_int("TIME_AUTO_RETRY", 1)
out: list[dict] = [] out: list[dict] = []
try: try:
prs = tm.gh("GET", f"/repos/{tm.REPO}/pulls?state=open&limit=50") or [] prs = tm.gh("GET", f"/repos/{tm.REPO}/pulls?state=open&limit=50") or []
for card in tm.issues("state=open&labels=ready"): for card in tm.issues("state=open&labels=ready"):
beats = [b for b in tm.beats_of(tm.gh("GET", f"/repos/{tm.REPO}/issues/{card['number']}/comments?limit=100") or []) cs = tm.gh("GET", f"/repos/{tm.REPO}/issues/{card['number']}/comments?limit=100") or []
if b.get("beat") not in ("blocked", "capped")] 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"): if not beats or beats[-1].get("beat") not in ("error", "stuck"):
continue 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 # 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"))) 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"], out.append({"n": card["number"], "title": card.get("title") or "", "beat": beats[-1]["beat"],
@@ -273,15 +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: 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.""" """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] 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() today_d = dt.datetime.now(dt.timezone.utc).date()
cands = router.candidates(open_issues) + [dict(router.NEW_VENTURE)] 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 # what no one wrote a card for (#171): the ledger, the Actions runs and the parked cards, each proposed once. The
cands += router.opt_candidates(ledger_week(), action_runs(), parked_cards(), today_d, router.named(open_issues)) # 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 " 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) 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() today, day_cap, month, month_cap = tm.budget()
left, source = money_left() left, source = money_left()
closed = closed_lately()
done = "\n".join(f"- #{i['number']} {i['title']}" for i in closed[:50]) or "- (none)" 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 " 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); " f"${day_cap:.2f} today, ${month:.2f} of ${month_cap:.2f} this month out of pocket (credit-paid spend excluded); "
+10 -5
View File
@@ -131,7 +131,9 @@ def candidates(issues: list[dict]) -> list[dict]:
# Three sources, each read by arithmetic over the last week, so optimization work no longer waits for a human to notice # 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 # 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, # 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 an open card already names is not proposed again. # 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 = "opt:"
OPT_DAYS = 7 # the window the ledger and the runs are read over OPT_DAYS = 7 # the window the ledger and the runs are read over
@@ -140,7 +142,7 @@ OPT_SHARE = 0.5 # a group over this share of the week's spend is a can
OPT_SKEW = 2.0 # a model whose spend per call is this many times its own median 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_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_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 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") BAD_RUN = ("failure", "cancelled", "canceled")
NAMES = re.compile(rf"^\s*Router:\s*({OPT}\S+)", re.M) NAMES = re.compile(rf"^\s*Router:\s*({OPT}\S+)", re.M)
@@ -245,7 +247,8 @@ def run_candidates(runs, today: dt.date, days: int = OPT_DAYS) -> list[dict]:
def parked_candidates(parked: list[dict], today: dt.date, days: int = OPT_PARKED_DAYS) -> list[dict]: 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 """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 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 parked only today.""" 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 = [] out = []
for p in parked or []: for p in parked or []:
if not isinstance(p, dict): if not isinstance(p, dict):
@@ -267,12 +270,14 @@ def parked_candidates(parked: list[dict], today: dt.date, days: int = OPT_PARKED
def named(issues: list[dict]) -> set[str]: def named(issues: list[dict]) -> set[str]:
"""The sources open cards already name (`Router: opt:<source>:<id>` in the body): proposed once, not every day.""" """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 "")} 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]: 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 an open card already names.""" """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) 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] return [c for c in found if c["key"] not in known]
+41 -1
View File
@@ -1383,7 +1383,11 @@ 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 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 "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) == [] assert rt.parked_candidates([], today171) == [] and rt.parked_candidates(["junk", {"n": None}], today171) == []
# no duplicates: a source an open card already names is not proposed again # "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")] 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 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)] == \ assert [c["key"] for c in rt.opt_candidates(led171, runs171, parked171, today171)] == \
@@ -1391,12 +1395,27 @@ assert [c["key"] for c in rt.opt_candidates(led171, runs171, parked171, today171
assert [c["key"] for c in rt.opt_candidates(led171, runs171, parked171, today171, rt.named(seen171))] == ["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, assert rt.opt_candidates(led171, runs171, parked171, today171,
{"opt:ledger:model:sonnet", "opt:ci:ci.yml", "opt:parked:11"}) == [], "all three already have cards" {"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 # 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) 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 "| 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") assert "**would be written as a card**" in rt.table(r171, r171[:1], "dry")
r171[0]["written"] = 99 r171[0]["written"] = 99
assert "**written as #99**" in rt.table(r171, r171[:1], "live") 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 # Astra gathers the three sources and, live, writes the picked one as an unqueued card; dry writes nothing
said171, labelled171, made171, asked171 = [], [], [], [] said171, labelled171, made171, asked171 = [], [], [], []
@@ -1418,6 +1437,19 @@ 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)} 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 [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"] 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] 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"] 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]} reply171 = {"note": "the records", "assess": [{"key": k, "kind": "optimization", "value": 5, "size": "S"} for k in keys171]}
@@ -1444,6 +1476,14 @@ made171.clear()
assert astra.router_tick(rt.config('{"mode": "live"}'), "opus", 57, "ctx", [], 2, 2) == 0 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 [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}] 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") print("ALL OK 171")
# 82 adaptive budget: each route is paced to its capacity pool; no pool, no change # 82 adaptive budget: each route is paced to its capacity pool; no pool, no change
+1 -1
View File
@@ -28,7 +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. - 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 ### 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, with no open PR and #78's automatic retries spent: "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. A source an open card already names (`Router: opt:<source>:<id>` in the body) is not proposed again. - 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. - 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. - 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`. - 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`.