diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 66f6cf7..f0fd1f8 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -1269,6 +1269,24 @@ beat80["body"] = beat80["body"].replace('"live"', '"dry"') assert tm.rank_order() == [] and tm.promote_next() == 10, "a dry ranking moves nothing: oldest first" tm.gh = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("GET x -> 500")) assert tm.rank_order() == [], "the ranking is a preference: an unreadable log never stops promotion" +# a ready card waiting on a person holds no slot (#135): only moving cards count toward TIME_PARALLEL +beat135 = lambda b: {"user": {"login": tm.TIME_BOT}, "created_at": "2026-10-19T03:08:05Z", "body": "x\n"} +ready135 = {20: [beat135("dispatched"), beat135("needs-human")], 21: [beat135("error")], 22: [beat135("pr"), beat135("stuck"), beat135("capped")]} +tm.issues = lambda q: {"state=open&labels=ready": [iss80(n, f"r{n}", labels=["ready"]) for n in ready135], + "state=open&labels=queued": q80, "state=open&labels=astra": []}[q] +tm.gh = lambda method, path, body=None, **k: ready135.get(int(path.split("/issues/")[1].split("/")[0]), []) if "/comments" in path else {"state": "closed"} +par135 = os.environ.get("TIME_PARALLEL"); os.environ["TIME_PARALLEL"] = "3" +moved80.clear() +assert tm.promote_next() == 10 and moved80 == [(10, "ready")], "three ready cards, all waiting on a person: the oldest queued moves" +ready135 = {20: [beat135("dispatched")], 21: [], 22: [beat135("needs-human")]} +assert tm.promote_next() == 10, "two moving (one with no beat yet) and one parked: a slot is free" +ready135[22].append(beat135("dispatched")) +assert tm.promote_next() is None, "a card sent back to the worker is moving again: three moving, no slot" +assert tm.moving(iss80(22, "retried")) and tm.moving(iss80(99, "no comments yet")) +ready135[22].append(beat135("needs-human")) +assert not tm.moving(iss80(22, "parked again")) +if par135 is None: os.environ.pop("TIME_PARALLEL") +else: os.environ["TIME_PARALLEL"] = par135 tm.issues, tm.gh, tm.label, tm.unlabel = real80 print("ALL OK 80") diff --git a/.github/time/time.py b/.github/time/time.py index a3845c1..6a3df1c 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -722,11 +722,26 @@ def rank_order() -> list[int]: return [] +WAITING = ("needs-human", "error", "stuck") # a card whose last real beat is one of these waits on a person (#135) + + +def moving(card: dict) -> bool: + """A `ready` card that holds a slot: its last real beat isn't one that waits on a person. promote_next runs after + tick(), so a card Time has just retried, or one a human sent back with `/time retry`, is moving again by then.""" + real = [b for b in beats_of(gh("GET", f"/repos/{REPO}/issues/{card['number']}/comments?limit=100") or []) + if b["beat"] not in ("blocked", "capped")] + return not real or real[-1]["beat"] not in WAITING + + def promote_next() -> int | None: """Label the best `queued` card whose dependencies are closed `ready`: the router's live ranking first (Astra's - latest), then the oldest; at most TIME_PARALLEL cards in flight.""" + latest), then the oldest; at most TIME_PARALLEL cards in flight. A `ready` card waiting on a person (an objection, + an error or a stall Time won't retry again) keeps its label, so `/time retry` still finds it, but holds no slot: + three of them once stalled the queue for eight hours while a dozen cards sat `queued`.""" ready = issues("state=open&labels=ready") - if len(ready) >= int(os.environ.get("TIME_PARALLEL") or 1): + busy = [c for c in ready if moving(c)] + print(f"promote: ready={len(ready)} moving={len(busy)}") + if len(busy) >= int(os.environ.get("TIME_PARALLEL") or 1): return None queued = issues("state=open&labels=queued") pos = {n: k for k, n in enumerate(rank_order())} diff --git a/CHANGELOG.md b/CHANGELOG.md index ef93644..8989b39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,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 +- 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`. - Sync (`sync.py`, `time/sync.json`, the `sync` workflow): a repo's copies on other forges follow its home, under one rule, one home per repo and one writer per branch. The home's `main` (the `out` branches) goes to every copy fast-forward only; a copy with commits of its own is reported in a `needs-human` issue and never overwritten, unless the `force` dispatch input replaces its history once. A copy's branches matching its `import` patterns (`claude/*` from cloud sessions) are the copy's to write: they come back to the home under the same name with a PR, commit messages in the body (so a `Closes #N` adopts the card), and Time's council reviews and lands them; a branch already in `main`, or one whose PR was closed at the same commit, is left alone. Gitea's own mirrors don't fit: a push mirror force-pushes every branch (it would delete the copy's work branches) and a pull mirror makes the home read-only. Tokens are secrets named in the file, scrubbed from every message; a copy can instead use an SSH deploy key (`ssh_key`: write access to that one repo and nothing else), with the host's keys pinned, never learned on first use. Gitea refuses secret names starting `GITHUB_` or `GITEA_`, so the names start `SYNC_`. The first copy is `github.com/teahubxyz/time`, so cloud sessions clone a current engine; it pushes with the deploy key in `SYNC_GITHUB_KEY`, and GitHub Actions is off on it. The same script syncs Gitea with Gitea, run from whichever side can reach both. - Adaptive budget: spend is paced to each capacity pool instead of one fixed daily cap. `time/pools.json` (kept by the operator) lists the pools: `expiring` credit allows `(balance - spent since as_of) / days to resets_at x TIME_PACE` a day (default 1.0), so a quiet day raises tomorrow's share; a `window` pool (`weekly:mon`, `hours:5`) allows `window_usd` per window and refills at its reset; a `balance` pool keeps `TIME_DAILY_USD`. A call is checked against the pool its provider books to, and `TIME_DAILY_USD` is the fallback for a route with no pool; the monthly cap stays global. Ledger rows carry a `pool` field, `doctor` prints each pool's balance, spend, next reset and today's allowance, and Astra's log line reports spend per pool. It ships with an empty `pools.json`, so nothing changes until the operator fills it in. `pools.json` is read through the API once per run, like the ledger, since every workflow checks out only `.github/time`. - The router (time#68, #72; `router.py`, `time/router.json`): the factory picks its own next work across ventures, features, refinements and optimizations. Astra's seat no longer picks cards in prose; it judges every candidate (each open card with a `DONE:` line that isn't a parent, a human's or the venture loop's own, plus "start a new venture") with a kind, a value 1–5 and a size S/M/L, and code ranks them: `weight[kind] × restless(kind) × age × (value + 0.5 × cards waiting on it) / size`. The weights are the owner's dial per kind (all 1, balanced); a kind not picked lately climbs (`restless`, 0.05 a day for up to 10 days) so none starves; an L is ranked but never queued (split it first). The Astra log shows the ranked table. `mode: dry` posts it and touches nothing; `live` queues from the top while the day's money left covers each card's estimated cost (`cost_usd`), writes the seat's new cards unqueued (candidates the next day), and the heartbeat's `promote_next` promotes queued cards in the live ranking's order instead of oldest first. `off`, or no file, is the planner as before. It starts `dry`. The policy is one function, so a bandit policy (Beth, Jev, Fable's maths) can replace it. Astra also reads the last 14 days of its log instead of the thread's first twenty comments.