Router: answer haiku's review of the optimization candidates
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:
4 files changed
+67
-16
No files matched your search
+15
-9
@@ -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); "
|
||||
|
||||
+10
-5
@@ -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:<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,
|
||||
# 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:<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 "")}
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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:<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.
|
||||
- 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`.
|
||||
|
||||
Reference in new issue
Block a user