diff --git a/.github/time/astra.py b/.github/time/astra.py index ff13905..cee431f 100644 --- a/.github/time/astra.py +++ b/.github/time/astra.py @@ -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 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 an open card already names is not proposed again. 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), +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. @@ -233,16 +233,20 @@ def action_runs(limit: int = 50) -> 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 - 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) 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"): - beats = [b for b in tm.beats_of(tm.gh("GET", f"/repos/{tm.REPO}/issues/{card['number']}/comments?limit=100") or []) - if b.get("beat") not in ("blocked", "capped")] + 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"], @@ -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: """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 - cands += router.opt_candidates(ledger_week(), action_runs(), parked_cards(), today_d, router.named(open_issues)) + # 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); " diff --git a/.github/time/router.py b/.github/time/router.py index 5521bf8..2aa7b66 100644 --- a/.github/time/router.py +++ b/.github/time/router.py @@ -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 # it. Every finding is a synthetic candidate: key `opt::`, 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 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_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_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 +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) @@ -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]: """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 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 = [] for p in parked or []: 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]: - """The sources open cards already name (`Router: opt::` in the body): proposed once, not every day.""" + """The sources these cards already name (`Router: opt::` 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 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) return [c for c in found if c["key"] not in known] diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 2424508..498cafe 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -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 "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) == [] -# 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")] 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)] == \ @@ -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 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 = [], [], [], [] @@ -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)} 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]} @@ -1444,6 +1476,14 @@ 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e25139b..a139784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. ### 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::` 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::` 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::` 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::` 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/` (the PR's own base) pushed as one commit by Time, `card #N: merge `; 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`.