From 790c6d6eac2a73d64b0ee5039b38d5be0a27c025 Mon Sep 17 00:00:00 2001 From: Time Date: Sun, 18 Oct 2026 07:09:07 +0000 Subject: [PATCH 1/6] card #78: kept .github/time/time.py (generated) --- .github/time/time.py | 48 +++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/.github/time/time.py b/.github/time/time.py index 08f1f11..a8cc506 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -1098,10 +1098,11 @@ def worker_seat_model(spec: str) -> tuple[str, str, str | None]: return seat, model or SEATS[seat].get("aliases", {}).get("code") or SEATS[seat]["default"], effort -def venice_work(card: dict, done: str, spec: str, hands_cls=None, review: str | None = None) -> dict: +def venice_work(card: dict, done: str, spec: str, hands_cls=None, review: str | None = None, steps: int | None = None) -> dict: """Run one card with a seat (Venice or Claude) as the worker. Returns {pr, steps, seat, model}. The name is historical; venture.py and the tests call it. TIME_WORKER_FALLBACK (default `grok build` for a Venice worker, - `off` for Claude, since a fallback should not silently cross providers) takes over if the model stays overloaded.""" + `off` for Claude, since a fallback should not silently cross providers) takes over if the model stays overloaded. + `steps` replaces the TIME_WORKER_STEPS default (an automatic retry gets half as many again).""" seat, model, effort = worker_seat_model(spec) fallback = (os.environ.get("TIME_WORKER_FALLBACK") or ("grok build" if provider_of(model) == "venice" else "off")).strip() fb_model = worker_seat_model(fallback)[1] if fallback.lower() != "off" else None @@ -1116,7 +1117,7 @@ def venice_work(card: dict, done: str, spec: str, hands_cls=None, review: str | system += REVISE_RULES.format(branch=hands.branch, review=review or "(no review comments were found; check the PR)") msgs = [{"role": "system", "content": system}, {"role": "user", "content": "Begin. Look at the repository first."}] - for _ in range(int(os.environ.get("TIME_WORKER_STEPS") or 40)): + for _ in range(steps or int(os.environ.get("TIME_WORKER_STEPS") or 40)): body = {"model": model, "temperature": 0.2, "max_tokens": int(os.environ.get("TIME_WORKER_MAX_TOKENS") or 16000), "messages": msgs, "tools": WORKER_TOOLS, "venice_parameters": {"include_venice_system_prompt": False}} @@ -1347,6 +1348,8 @@ def tick() -> None: cards = sorted(issues("state=open&labels=ready"), key=lambda c: c["number"]) # oldest first, like promote_next prs = gh("GET", f"/repos/{REPO}/pulls?state=all&sort=recentupdate&limit=50") launches_today = 0 + auto_max = int(os.environ.get("TIME_AUTO_RETRY") or 1) # a parked card's automatic retries, all told (0 = a human's call) + auto_used: dict[int, int] = {} plan = [] for card in cards: cs = gh("GET", f"/repos/{REPO}/issues/{card['number']}/comments?limit=100") @@ -1356,7 +1359,9 @@ def tick() -> None: real = [b for b in beats if b["beat"] not in ("blocked", "capped")] last = real[-1] if real else None blocked = any(b["beat"] == "blocked" and (not last or b["at"] >= last["at"]) for b in beats) - revision = None + revision, auto = None, False + # an automatic retry is one run of consecutive `auto_retry` beats (the dispatch beats and, if it failed, the error) + auto_used[card["number"]] = sum(1 for i, b in enumerate(real) if b.get("auto_retry") and not (i and real[i - 1].get("auto_retry"))) if last and retry_requested(cs, last["at"]): last = None # the card's PR is still open: this retry answers the review rather than starting over @@ -1364,14 +1369,18 @@ def tick() -> None: if open_now: since = max((b["at"] for b in beats if b.get("revised")), default="") revision = {"pr": open_now[0], "feedback": reviewer_feedback(open_now[0]["number"], since)} + elif last and last["beat"] in ("error", "stuck") and mode == "live": + # a parked card is Time's to retry, within TIME_AUTO_RETRY; a card with an open PR is left to the review path + if auto_used[card["number"]] < auto_max and not any(p.get("state", "open") == "open" for p in linked_prs(prs, card["number"])): + last, auto = None, True revised_at = max((b["at"] for b in beats if b.get("revised")), default=None) m = re.search(r"^\s*DONE:\s*(.+)$", card.get("body") or "", re.M | re.I) capped = any(b["beat"] == "capped" and (not last or b["at"] >= last["at"]) for b in beats) - plan.append((card, last, m.group(1).strip() if m else None, blocked, capped, revision, revised_at)) + plan.append((card, last, m.group(1).strip() if m else None, blocked, capped, revision, revised_at, auto)) print(f"tick {now:%Y-%m-%dT%H:%MZ} mode={mode} cards={len(cards)} launches_today={launches_today}/{cap}") tripped = None - for card, last, done, blocked, capped, revision, revised_at in plan: + for card, last, done, blocked, capped, revision, revised_at, auto in plan: n, state = card["number"], (last or {}).get("beat") try: # work built outside the worker (Codex, a seat's chat): an open PR that already closes this card is reviewed and @@ -1411,7 +1420,8 @@ def tick() -> None: if seat_worker(worker): prov = provider_of(worker_seat_model(worker)[1]) comment(n, f"⏱️ Time: {worker_name} is " + (f"answering the review on PR #{rev['number']}" if rev else "working on this") - + f" now, on branch `{card_branch(card)}`.", {"beat": "dispatched", "launched": True, "worker": prov}) + + f" now, on branch `{card_branch(card)}`." + (" Retrying on my own, resuming the branch." if auto else ""), + {"beat": "dispatched", "launched": True, "worker": prov, **({"auto_retry": True} if auto else {})}) launches_today += 1 if rev: try: @@ -1419,12 +1429,16 @@ def tick() -> None: except RuntimeError as e: print(f"#{n}: needs-human label not removed: {str(e)[:100]}") r = venice_work(card, done, worker.split(":", 1)[1], review=revision["feedback"]) + elif auto: # the second pass continues the first one's branch, with half as many turns again + r = venice_work(card, done, worker.split(":", 1)[1], + steps=int(int(os.environ.get("TIME_WORKER_STEPS") or 40) * 1.5)) else: r = venice_work(card, done, worker.split(":", 1)[1]) # back to `dispatched`: the next beat reviews the PR again and the new verdict is the one that counts comment(n, f"⏱️ Time: `{r['seat']}` " + ("pushed its answer to" if rev else "opened") + f" PR #{r['pr']} " f"({r['steps']} steps, {r['model']}). Review and landing follow on the next beat.", - {"beat": "dispatched", "worker": prov, **({"revised": True} if rev else {}), **r}) + {"beat": "dispatched", "worker": prov, **({"revised": True} if rev else {}), + **({"auto_retry": True} if auto else {}), **r}) continue prompt = BUILD_PROMPT.format(n=n, repo=REPO, title=card["title"], body=(card.get("body") or "")[:20000], base=default_branch(), done=done) @@ -1436,10 +1450,11 @@ def tick() -> None: r = cursor_launch(prompt, model=worker_model) launches_today += 1 link = f" ([agent]({r['agent_url']}))" if r.get("agent_url") else "" - comment(n, f"⏱️ Time: dispatched to a Cursor cloud agent{link}. " + (f"It answers the review on PR #{rev['number']}; " + comment(n, f"⏱️ Time: dispatched to a Cursor cloud agent{link}. " + ("Retrying on my own, resuming the branch. " if auto else "") + (f"It answers the review on PR #{rev['number']}; " "I'll review again once the branch moves." if rev else f"It will open a PR that closes #{n}; " "I'll link it here when it appears."), - {"beat": "dispatched", "launched": True, **r, "model": worker_model, **({"revised": True} if rev else {})}) + {"beat": "dispatched", "launched": True, **r, "model": worker_model, **({"revised": True} if rev else {}), + **({"auto_retry": True} if auto else {})}) continue if state in ("pr", "needs-human", "no-checks", "checks-failed"): land(card, last, revised_at) @@ -1459,8 +1474,10 @@ def tick() -> None: age_h = (now - dt.datetime.fromisoformat(last["at"].replace("Z", "+00:00"))).total_seconds() / 3600 if age_h > stuck_h: comment(n, f"⏱️ Time: dispatched {age_h:.0f}h ago and no PR closing #{n} yet. Check the agent" - f"{' (' + last['agent_url'] + ')' if last.get('agent_url') else ''}; comment `/time retry` to " - "dispatch again.", {"beat": "stuck"}) + f"{' (' + last['agent_url'] + ')' if last.get('agent_url') else ''}. " + + ("I'll dispatch again on my own next beat; comment `/time retry` to force one." + if mode == "live" and auto_used[n] < auto_max else "Comment `/time retry` to dispatch again."), + {"beat": "stuck"}) except Budget as e: # out of money: say so once per card, pull the andon cord once per tick print(f"#{n}: spend cap reached: {e}") tripped = tripped or str(e) @@ -1471,8 +1488,11 @@ def tick() -> None: comment(n, f"⏱️ Time: can't go further on this card: {e}. I'll carry on once it's added.", {"beat": "blocked"}) except Exception as e: # noqa: BLE001 one card must not stop the others print(f"#{n}: {type(e).__name__}: {e}") - comment(n, f"⏱️ Time: the worker call failed: `{str(e)[:300]}`. Comment `/time retry` to try again.", - {"beat": "error"}) + again = mode == "live" and auto_used.get(n, 0) + (1 if auto else 0) < auto_max + comment(n, f"⏱️ Time: the worker call failed: `{str(e)[:300]}`. " + + (f"I'll retry on my own next beat (TIME_AUTO_RETRY={auto_max} times in all); comment `/time retry` for one more." + if again else "Comment `/time retry` to try again."), + {"beat": "error", **({"auto_retry": True} if auto else {})}) if tripped: andon(tripped) -- 2.54.0 From 5c46d27564982eddad34b263a470b7b49ef12a19 Mon Sep 17 00:00:00 2001 From: Time Date: Sun, 18 Oct 2026 07:09:14 +0000 Subject: [PATCH 2/6] card #78: kept .github/time/time.py (generated) --- .github/time/time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/time/time.py b/.github/time/time.py index a8cc506..bce9fce 100644 --- a/.github/time/time.py +++ b/.github/time/time.py @@ -1490,7 +1490,7 @@ def tick() -> None: print(f"#{n}: {type(e).__name__}: {e}") again = mode == "live" and auto_used.get(n, 0) + (1 if auto else 0) < auto_max comment(n, f"⏱️ Time: the worker call failed: `{str(e)[:300]}`. " - + (f"I'll retry on my own next beat (TIME_AUTO_RETRY={auto_max} times in all); comment `/time retry` for one more." + + ("I'll retry on my own next beat (TIME_AUTO_RETRY times in all); comment `/time retry` for one more." if again else "Comment `/time retry` to try again."), {"beat": "error", **({"auto_retry": True} if auto else {})}) if tripped: -- 2.54.0 From 4a41ff3063b70e66c707c2c6e18c53e7467394b4 Mon Sep 17 00:00:00 2001 From: Time Date: Sun, 18 Oct 2026 07:09:37 +0000 Subject: [PATCH 3/6] card #78: kept .github/time/test_time.py (generated) --- .github/time/test_time.py | 61 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 91bd1b7..e8a6398 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -149,7 +149,7 @@ def beat(state, h=0, **kw): def run(world, env): for k in ("TIME_MODE", "CURSOR_API_KEY", "TIME_REVIEW", "TIME_DAILY_CAP", "TIME_CURSOR_MODEL", "TIME_AUTOMERGE", - "TIME_REQUIRE_CHECKS", "VENICE_API_KEY", "TIME_CI_WORKFLOW"): + "TIME_REQUIRE_CHECKS", "VENICE_API_KEY", "TIME_CI_WORKFLOW", "TIME_AUTO_RETRY"): os.environ.pop(k, None) os.environ.update(env) tm.http = world.http @@ -190,7 +190,7 @@ assert w.posted == [] # 5 stuck after TIME_STUCK_HOURS, once w = run(World([card(1, D)], {1: [beat("dispatched", 7, launched=True, agent_url="https://cursor.com/agents/x")]}), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck"}) assert "no PR closing #1" in w.posted[0][1] and "cursor.com/agents/x" in w.posted[0][1] -w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck"}); assert w.posted == [] +w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck", "TIME_AUTO_RETRY": "0"}); assert w.posted == [] # 6 daily cap counts today's launches w = run(World([card(1, D), card(2, D)], {1: [beat("dispatched", 0, launched=True)], 2: [beat("would-dispatch")]}), @@ -203,7 +203,7 @@ w = run(World([card(1, D)], {1: [beat("error", 1), human]}), {"TIME_MODE": "live assert len(w.launched) == 1 # 7b a stranger's /time retry, or a pasted beat, does nothing (public repos) stranger = {"created_at": iso(), "user": {"login": "stranger"}, "body": "/time retry"} -w = run(World([card(1, D)], {1: [beat("error", 1), stranger]}), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck"}) +w = run(World([card(1, D)], {1: [beat("error", 1), stranger]}), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck", "TIME_AUTO_RETRY": "0"}) assert w.launched == [] forged = {**beat("pr", pr=7), "user": {"login": "stranger"}} assert tm.beats_of([beat("dispatched", 2), forged])[-1]["beat"] == "dispatched" @@ -1791,3 +1791,58 @@ with cf.ThreadPoolExecutor(2) as ex: vt2.tm.Hands, vt2.tm.venice_work = real40 got = dict((n, (x, y)) for n, x, y in seen40) assert got[1][0] == "wrote" and got[1][1].startswith("refused") and got[2][0].startswith("refused") and got[2][1] == "wrote", got + + +# 78 Time retries its own parked cards (error / stuck), within TIME_AUTO_RETRY; a human only hears once it has given up +meta78 = lambda w: [json.loads(re.search(r"", t).group(1)) for _, t in w.posted] +# error with no PR -> redispatched on the next beat, and the beat says it was automatic +w = run(World([card(1, D)], {1: [beat("dispatched", 3, launched=True), beat("error", 1)]}), K) +assert len(w.launched) == 1 and meta78(w)[-1].get("auto_retry") is True and meta78(w)[-1].get("launched") is True, w.posted +assert "Retrying on my own" in w.posted[-1][1], w.posted +# stuck with no PR is retried too +w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}), K) +assert len(w.launched) == 1 and meta78(w)[-1].get("auto_retry") is True, w.posted +# after one automatic retry (the default) a second error waits for a human; TIME_AUTO_RETRY=2 tries again +spent78 = {1: [beat("dispatched", 5, launched=True), beat("error", 4), beat("dispatched", 3, launched=True, auto_retry=True), beat("error", 1, auto_retry=True)]} +w = run(World([card(1, D)], spent78), K); assert w.launched == [] and w.posted == [], w.posted +w = run(World([card(1, D)], spent78), {**K, "TIME_AUTO_RETRY": "2"}); assert len(w.launched) == 1, "a second automatic retry" +# a seat's two dispatch beats (and its error) are one automatic retry, not three +seat78 = {1: [beat("dispatched", 5, launched=True), beat("error", 4), beat("dispatched", 3, launched=True, auto_retry=True), + beat("dispatched", 2, auto_retry=True, pr=9), beat("stuck", 1)]} +w = run(World([card(1, D)], seat78), {**K, "TIME_AUTO_RETRY": "2"}); assert len(w.launched) == 1 +w = run(World([card(1, D)], seat78), K); assert w.launched == [] and w.posted == [] +# a human's /time retry still works after the automatic ones are spent +w = run(World([card(1, D)], {1: spent78[1] + [human]}), K); assert len(w.launched) == 1 +# stuck with an open linked PR is reviewed, not redispatched +w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}, [pr]), K) +assert w.launched == [] and meta78(w)[-1]["beat"] == "pr" and "PR #7" in w.posted[-1][1], w.posted +# a closed PR does not hold the retry back +w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}, [{**pr, "state": "closed"}]), K) +assert len(w.launched) == 1 +# dry-run retries nothing +w = run(World([card(1, D)], {1: [beat("error", 1)]}), {"TIME_MODE": "dry-run", "CURSOR_API_KEY": "ck"}); assert w.launched == [] and w.posted == [] +# the messages say what Time will do +w = run(World([card(1, D)], {1: [beat("dispatched", 7, launched=True)]}), K) +assert w.posted[0][1].split("\n\n")[0].endswith("I'll dispatch again on my own next beat; comment `/time retry` to force one."), w.posted +w = run(World([card(1, D)], {1: [beat("dispatched", 7, launched=True)]}), {**K, "TIME_AUTO_RETRY": "0"}) +assert "I'll dispatch again" not in w.posted[0][1] and "`/time retry`" in w.posted[0][1], w.posted +w = run(World([card(1, D)], {1: [beat("would-dispatch")]}), K) +_hb = w.http +def failing78(method, path, body=None, *a, **k): + if "api.cursor.com" in path: raise RuntimeError("boom") + return _hb(method, path, body, *a, **k) +w.launched.clear(); w.posted.clear(); w.comments[1] = [beat("would-dispatch")] +tm.http = failing78; tm.cmd_heartbeat(); tm.http = w.http +assert meta78(w)[-1]["beat"] == "error" and "I'll retry on my own next beat (TIME_AUTO_RETRY times in all); comment `/time retry` for one more." in w.posted[-1][1], w.posted +# a venice: worker on an automatic retry gets half as many turns again; a first dispatch gets the plain default +seen78, real_vw78 = [], tm.venice_work +tm.venice_work = lambda card, done, spec, hands_cls=None, review=None, **kw: (seen78.append(kw), {"pr": 42, "steps": 3, "seat": "kimi", "model": "m"})[1] +KS = {**K, "TIME_WORKER": "venice:kimi code", "VENICE_API_KEY": "vk", "TIME_WORKER_STEPS": "40"} +w = run(World([card(1, D)], {1: [beat("dispatched", 3, launched=True), beat("error", 1)]}), KS) +assert seen78 == [{"steps": 60}], seen78 +assert "Retrying on my own, resuming the branch." in w.posted[0][1] and all(m.get("auto_retry") is True for m in meta78(w)), w.posted +assert len(meta78(w)) == 2 and meta78(w)[0]["launched"] is True +seen78.clear(); w = run(World([card(1, D)], {1: [beat("would-dispatch")]}), KS); assert seen78 == [{}], seen78 +tm.venice_work = real_vw78 +for k in ("TIME_WORKER", "TIME_WORKER_STEPS", "TIME_AUTO_RETRY"): os.environ.pop(k, None) +print("ALL OK 78") -- 2.54.0 From 4352afc49a792277bc1e2be5bb2a642e8b3f64a7 Mon Sep 17 00:00:00 2001 From: Time Date: Sun, 18 Oct 2026 07:09:46 +0000 Subject: [PATCH 4/6] card #78: kept .github/time/test_time.py (generated) --- .github/time/test_time.py | 108 +++++++++++++++++++------------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/.github/time/test_time.py b/.github/time/test_time.py index e8a6398..db79502 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -834,6 +834,60 @@ if _sh.which("node"): assert res.returncode == 0 and "aicharts: all good" in res.stdout, res.stdout[-2000:] + res.stderr[-2000:] print("ALL OK 41") +# 78 Time retries its own parked cards (error / stuck), within TIME_AUTO_RETRY; a human only hears once it has given up +meta78 = lambda w: [json.loads(re.search(r"", t).group(1)) for _, t in w.posted] +# error with no PR -> redispatched on the next beat, and the beat says it was automatic +w = run(World([card(1, D)], {1: [beat("dispatched", 3, launched=True), beat("error", 1)]}), K) +assert len(w.launched) == 1 and meta78(w)[-1].get("auto_retry") is True and meta78(w)[-1].get("launched") is True, w.posted +assert "Retrying on my own" in w.posted[-1][1], w.posted +# stuck with no PR is retried too +w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}), K) +assert len(w.launched) == 1 and meta78(w)[-1].get("auto_retry") is True, w.posted +# after one automatic retry (the default) a second error waits for a human; TIME_AUTO_RETRY=2 tries again +spent78 = {1: [beat("dispatched", 5, launched=True), beat("error", 4), beat("dispatched", 3, launched=True, auto_retry=True), beat("error", 1, auto_retry=True)]} +w = run(World([card(1, D)], spent78), K); assert w.launched == [] and w.posted == [], w.posted +w = run(World([card(1, D)], spent78), {**K, "TIME_AUTO_RETRY": "2"}); assert len(w.launched) == 1, "a second automatic retry" +# a seat's two dispatch beats (and its error) are one automatic retry, not three +seat78 = {1: [beat("dispatched", 5, launched=True), beat("error", 4), beat("dispatched", 3, launched=True, auto_retry=True), + beat("dispatched", 2, auto_retry=True, pr=9), beat("stuck", 1)]} +w = run(World([card(1, D)], seat78), {**K, "TIME_AUTO_RETRY": "2"}); assert len(w.launched) == 1 +w = run(World([card(1, D)], seat78), K); assert w.launched == [] and w.posted == [] +# a human's /time retry still works after the automatic ones are spent +w = run(World([card(1, D)], {1: spent78[1] + [human]}), K); assert len(w.launched) == 1 +# stuck with an open linked PR is reviewed, not redispatched +w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}, [pr]), K) +assert w.launched == [] and meta78(w)[-1]["beat"] == "pr" and "PR #7" in w.posted[-1][1], w.posted +# a closed PR does not hold the retry back +w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}, [{**pr, "state": "closed"}]), K) +assert len(w.launched) == 1 +# dry-run retries nothing +w = run(World([card(1, D)], {1: [beat("error", 1)]}), {"TIME_MODE": "dry-run", "CURSOR_API_KEY": "ck"}); assert w.launched == [] and w.posted == [] +# the messages say what Time will do +w = run(World([card(1, D)], {1: [beat("dispatched", 7, launched=True)]}), K) +assert w.posted[0][1].split("\n\n")[0].endswith("I'll dispatch again on my own next beat; comment `/time retry` to force one."), w.posted +w = run(World([card(1, D)], {1: [beat("dispatched", 7, launched=True)]}), {**K, "TIME_AUTO_RETRY": "0"}) +assert "I'll dispatch again" not in w.posted[0][1] and "`/time retry`" in w.posted[0][1], w.posted +w = run(World([card(1, D)], {1: [beat("would-dispatch")]}), K) +_hb = w.http +def failing78(method, path, body=None, *a, **k): + if "api.cursor.com" in path: raise RuntimeError("boom") + return _hb(method, path, body, *a, **k) +w.launched.clear(); w.posted.clear(); w.comments[1] = [beat("would-dispatch")] +tm.http = failing78; tm.cmd_heartbeat(); tm.http = w.http +assert meta78(w)[-1]["beat"] == "error" and "I'll retry on my own next beat (TIME_AUTO_RETRY times in all); comment `/time retry` for one more." in w.posted[-1][1], w.posted +# a venice: worker on an automatic retry gets half as many turns again; a first dispatch gets the plain default +seen78, real_vw78 = [], tm.venice_work +tm.venice_work = lambda card, done, spec, hands_cls=None, review=None, **kw: (seen78.append(kw), {"pr": 42, "steps": 3, "seat": "kimi", "model": "m"})[1] +KS = {**K, "TIME_WORKER": "venice:kimi code", "VENICE_API_KEY": "vk", "TIME_WORKER_STEPS": "40"} +w = run(World([card(1, D)], {1: [beat("dispatched", 3, launched=True), beat("error", 1)]}), KS) +assert seen78 == [{"steps": 60}], seen78 +assert "Retrying on my own, resuming the branch." in w.posted[0][1] and all(m.get("auto_retry") is True for m in meta78(w)), w.posted +assert len(meta78(w)) == 2 and meta78(w)[0]["launched"] is True +seen78.clear(); w = run(World([card(1, D)], {1: [beat("would-dispatch")]}), KS); assert seen78 == [{}], seen78 +tm.venice_work = real_vw78 +for k in ("TIME_WORKER", "TIME_WORKER_STEPS", "TIME_AUTO_RETRY"): os.environ.pop(k, None) +print("ALL OK 78") + # ---------- venture loop: still GitHub-shaped (Pages, author_association, per_page). Ported in its own card; # until then these run only with TIME_TEST_VENTURE=1 against the old fake. if not os.environ.get("TIME_TEST_VENTURE"): @@ -1792,57 +1846,3 @@ vt2.tm.Hands, vt2.tm.venice_work = real40 got = dict((n, (x, y)) for n, x, y in seen40) assert got[1][0] == "wrote" and got[1][1].startswith("refused") and got[2][0].startswith("refused") and got[2][1] == "wrote", got - -# 78 Time retries its own parked cards (error / stuck), within TIME_AUTO_RETRY; a human only hears once it has given up -meta78 = lambda w: [json.loads(re.search(r"", t).group(1)) for _, t in w.posted] -# error with no PR -> redispatched on the next beat, and the beat says it was automatic -w = run(World([card(1, D)], {1: [beat("dispatched", 3, launched=True), beat("error", 1)]}), K) -assert len(w.launched) == 1 and meta78(w)[-1].get("auto_retry") is True and meta78(w)[-1].get("launched") is True, w.posted -assert "Retrying on my own" in w.posted[-1][1], w.posted -# stuck with no PR is retried too -w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}), K) -assert len(w.launched) == 1 and meta78(w)[-1].get("auto_retry") is True, w.posted -# after one automatic retry (the default) a second error waits for a human; TIME_AUTO_RETRY=2 tries again -spent78 = {1: [beat("dispatched", 5, launched=True), beat("error", 4), beat("dispatched", 3, launched=True, auto_retry=True), beat("error", 1, auto_retry=True)]} -w = run(World([card(1, D)], spent78), K); assert w.launched == [] and w.posted == [], w.posted -w = run(World([card(1, D)], spent78), {**K, "TIME_AUTO_RETRY": "2"}); assert len(w.launched) == 1, "a second automatic retry" -# a seat's two dispatch beats (and its error) are one automatic retry, not three -seat78 = {1: [beat("dispatched", 5, launched=True), beat("error", 4), beat("dispatched", 3, launched=True, auto_retry=True), - beat("dispatched", 2, auto_retry=True, pr=9), beat("stuck", 1)]} -w = run(World([card(1, D)], seat78), {**K, "TIME_AUTO_RETRY": "2"}); assert len(w.launched) == 1 -w = run(World([card(1, D)], seat78), K); assert w.launched == [] and w.posted == [] -# a human's /time retry still works after the automatic ones are spent -w = run(World([card(1, D)], {1: spent78[1] + [human]}), K); assert len(w.launched) == 1 -# stuck with an open linked PR is reviewed, not redispatched -w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}, [pr]), K) -assert w.launched == [] and meta78(w)[-1]["beat"] == "pr" and "PR #7" in w.posted[-1][1], w.posted -# a closed PR does not hold the retry back -w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}, [{**pr, "state": "closed"}]), K) -assert len(w.launched) == 1 -# dry-run retries nothing -w = run(World([card(1, D)], {1: [beat("error", 1)]}), {"TIME_MODE": "dry-run", "CURSOR_API_KEY": "ck"}); assert w.launched == [] and w.posted == [] -# the messages say what Time will do -w = run(World([card(1, D)], {1: [beat("dispatched", 7, launched=True)]}), K) -assert w.posted[0][1].split("\n\n")[0].endswith("I'll dispatch again on my own next beat; comment `/time retry` to force one."), w.posted -w = run(World([card(1, D)], {1: [beat("dispatched", 7, launched=True)]}), {**K, "TIME_AUTO_RETRY": "0"}) -assert "I'll dispatch again" not in w.posted[0][1] and "`/time retry`" in w.posted[0][1], w.posted -w = run(World([card(1, D)], {1: [beat("would-dispatch")]}), K) -_hb = w.http -def failing78(method, path, body=None, *a, **k): - if "api.cursor.com" in path: raise RuntimeError("boom") - return _hb(method, path, body, *a, **k) -w.launched.clear(); w.posted.clear(); w.comments[1] = [beat("would-dispatch")] -tm.http = failing78; tm.cmd_heartbeat(); tm.http = w.http -assert meta78(w)[-1]["beat"] == "error" and "I'll retry on my own next beat (TIME_AUTO_RETRY times in all); comment `/time retry` for one more." in w.posted[-1][1], w.posted -# a venice: worker on an automatic retry gets half as many turns again; a first dispatch gets the plain default -seen78, real_vw78 = [], tm.venice_work -tm.venice_work = lambda card, done, spec, hands_cls=None, review=None, **kw: (seen78.append(kw), {"pr": 42, "steps": 3, "seat": "kimi", "model": "m"})[1] -KS = {**K, "TIME_WORKER": "venice:kimi code", "VENICE_API_KEY": "vk", "TIME_WORKER_STEPS": "40"} -w = run(World([card(1, D)], {1: [beat("dispatched", 3, launched=True), beat("error", 1)]}), KS) -assert seen78 == [{"steps": 60}], seen78 -assert "Retrying on my own, resuming the branch." in w.posted[0][1] and all(m.get("auto_retry") is True for m in meta78(w)), w.posted -assert len(meta78(w)) == 2 and meta78(w)[0]["launched"] is True -seen78.clear(); w = run(World([card(1, D)], {1: [beat("would-dispatch")]}), KS); assert seen78 == [{}], seen78 -tm.venice_work = real_vw78 -for k in ("TIME_WORKER", "TIME_WORKER_STEPS", "TIME_AUTO_RETRY"): os.environ.pop(k, None) -print("ALL OK 78") -- 2.54.0 From 8ee49f048f7290a5839eb4a4b5fb323ccb897423 Mon Sep 17 00:00:00 2001 From: Time Date: Sun, 18 Oct 2026 07:09:52 +0000 Subject: [PATCH 5/6] card #78: kept .github/time/test_time.py (generated) --- .github/time/test_time.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/time/test_time.py b/.github/time/test_time.py index db79502..04a8b94 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -835,39 +835,40 @@ if _sh.which("node"): print("ALL OK 41") # 78 Time retries its own parked cards (error / stuck), within TIME_AUTO_RETRY; a human only hears once it has given up +K78 = {**K, "TIME_WORKER": "cursor"} meta78 = lambda w: [json.loads(re.search(r"", t).group(1)) for _, t in w.posted] # error with no PR -> redispatched on the next beat, and the beat says it was automatic -w = run(World([card(1, D)], {1: [beat("dispatched", 3, launched=True), beat("error", 1)]}), K) +w = run(World([card(1, D)], {1: [beat("dispatched", 3, launched=True), beat("error", 1)]}), K78) assert len(w.launched) == 1 and meta78(w)[-1].get("auto_retry") is True and meta78(w)[-1].get("launched") is True, w.posted assert "Retrying on my own" in w.posted[-1][1], w.posted # stuck with no PR is retried too -w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}), K) +w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}), K78) assert len(w.launched) == 1 and meta78(w)[-1].get("auto_retry") is True, w.posted # after one automatic retry (the default) a second error waits for a human; TIME_AUTO_RETRY=2 tries again spent78 = {1: [beat("dispatched", 5, launched=True), beat("error", 4), beat("dispatched", 3, launched=True, auto_retry=True), beat("error", 1, auto_retry=True)]} -w = run(World([card(1, D)], spent78), K); assert w.launched == [] and w.posted == [], w.posted -w = run(World([card(1, D)], spent78), {**K, "TIME_AUTO_RETRY": "2"}); assert len(w.launched) == 1, "a second automatic retry" +w = run(World([card(1, D)], spent78), K78); assert w.launched == [] and w.posted == [], w.posted +w = run(World([card(1, D)], spent78), {**K78, "TIME_AUTO_RETRY": "2"}); assert len(w.launched) == 1, "a second automatic retry" # a seat's two dispatch beats (and its error) are one automatic retry, not three seat78 = {1: [beat("dispatched", 5, launched=True), beat("error", 4), beat("dispatched", 3, launched=True, auto_retry=True), beat("dispatched", 2, auto_retry=True, pr=9), beat("stuck", 1)]} -w = run(World([card(1, D)], seat78), {**K, "TIME_AUTO_RETRY": "2"}); assert len(w.launched) == 1 -w = run(World([card(1, D)], seat78), K); assert w.launched == [] and w.posted == [] +w = run(World([card(1, D)], seat78), {**K78, "TIME_AUTO_RETRY": "2"}); assert len(w.launched) == 1 +w = run(World([card(1, D)], seat78), K78); assert w.launched == [] and w.posted == [] # a human's /time retry still works after the automatic ones are spent -w = run(World([card(1, D)], {1: spent78[1] + [human]}), K); assert len(w.launched) == 1 +w = run(World([card(1, D)], {1: spent78[1] + [human]}), K78); assert len(w.launched) == 1 # stuck with an open linked PR is reviewed, not redispatched -w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}, [pr]), K) +w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}, [pr]), K78) assert w.launched == [] and meta78(w)[-1]["beat"] == "pr" and "PR #7" in w.posted[-1][1], w.posted # a closed PR does not hold the retry back -w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}, [{**pr, "state": "closed"}]), K) +w = run(World([card(1, D)], {1: [beat("dispatched", 8, launched=True), beat("stuck", 1)]}, [{**pr, "state": "closed"}]), K78) assert len(w.launched) == 1 # dry-run retries nothing w = run(World([card(1, D)], {1: [beat("error", 1)]}), {"TIME_MODE": "dry-run", "CURSOR_API_KEY": "ck"}); assert w.launched == [] and w.posted == [] # the messages say what Time will do -w = run(World([card(1, D)], {1: [beat("dispatched", 7, launched=True)]}), K) +w = run(World([card(1, D)], {1: [beat("dispatched", 7, launched=True)]}), K78) assert w.posted[0][1].split("\n\n")[0].endswith("I'll dispatch again on my own next beat; comment `/time retry` to force one."), w.posted -w = run(World([card(1, D)], {1: [beat("dispatched", 7, launched=True)]}), {**K, "TIME_AUTO_RETRY": "0"}) +w = run(World([card(1, D)], {1: [beat("dispatched", 7, launched=True)]}), {**K78, "TIME_AUTO_RETRY": "0"}) assert "I'll dispatch again" not in w.posted[0][1] and "`/time retry`" in w.posted[0][1], w.posted -w = run(World([card(1, D)], {1: [beat("would-dispatch")]}), K) +w = run(World([card(1, D)], {1: [beat("would-dispatch")]}), K78) _hb = w.http def failing78(method, path, body=None, *a, **k): if "api.cursor.com" in path: raise RuntimeError("boom") @@ -878,7 +879,7 @@ assert meta78(w)[-1]["beat"] == "error" and "I'll retry on my own next beat (TIM # a venice: worker on an automatic retry gets half as many turns again; a first dispatch gets the plain default seen78, real_vw78 = [], tm.venice_work tm.venice_work = lambda card, done, spec, hands_cls=None, review=None, **kw: (seen78.append(kw), {"pr": 42, "steps": 3, "seat": "kimi", "model": "m"})[1] -KS = {**K, "TIME_WORKER": "venice:kimi code", "VENICE_API_KEY": "vk", "TIME_WORKER_STEPS": "40"} +KS = {**K78, "TIME_WORKER": "venice:kimi code", "VENICE_API_KEY": "vk", "TIME_WORKER_STEPS": "40"} w = run(World([card(1, D)], {1: [beat("dispatched", 3, launched=True), beat("error", 1)]}), KS) assert seen78 == [{"steps": 60}], seen78 assert "Retrying on my own, resuming the branch." in w.posted[0][1] and all(m.get("auto_retry") is True for m in meta78(w)), w.posted -- 2.54.0 From dde6f19f355776b5539fa43f9d7cb1155a365367 Mon Sep 17 00:00:00 2001 From: Time Date: Sun, 18 Oct 2026 07:09:58 +0000 Subject: [PATCH 6/6] card #78: kept CHANGELOG.md (generated) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 605c48d..38dadd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - The second fix round escalates to Kimi's code model (`venice:kimi code`) instead of grok 4.7: still a different model from the first round, at a fraction of the cost. `TIME_VENTURE_ESCALATE` overrides it. ### Added +- Time retries its own parked cards. A card parked on `error` or `stuck` with no open PR is dispatched again on the next beat, up to `TIME_AUTO_RETRY` times (default 1; 0 leaves it to a human), instead of waiting for a human `/time retry`; the beats carry `auto_retry`, a seat worker resumes the card's branch with half as many turns again (`TIME_WORKER_STEPS` x 1.5), and a card with an open PR still goes to review. Dry-run retries nothing. The `stuck` and `error` comments say what Time will do next. - Council verdict: `TIME_REVIEW` takes a comma-separated list of seats (`claude:haiku, claude:opus`); every seat reviews each PR and posts as itself, and each identity's latest vote counts once (Time's own comments are one identity). `approve` when approves minus objects reaches `TIME_REVIEW_QUORUM` (default 1), `object` when objects exceed approves. `TIME_REVIEW_TIEBREAK` names one seat (e.g. `venice:kimi`) called only when every standing seat has voted and they split; if it doesn't settle it either, one "the council is split N–N; over to a human" comment with a `needs-human` beat. One seat and quorum 1 behave as before. Codex's ladder (`luna`, `terra`, `sol`, `astra`) is in `seats.json` on the CLI route (its review run lands with #79; the `astra` login is the Codex seat, not the planning tick). - Seats review as themselves: with a `TIME_SEAT_` token (one Gitea user per seat, e.g. `TIME_SEAT_KIMI`) the reviewer's verdict is a real Gitea review by that user (Approve → approved, Object → changes requested, prose → comment), so reviews carry an identity and can count. Without a token, or if Gitea refuses (a seat on its own PR), it is Time's comment as before. - Cards can live on another branch. A `Base: ` line in a card (`Base: gh-pages` for a venture) makes the seat worker cut its branch from that base, name it `time/card-N-`, see that branch's files, and open the PR against it; without the line nothing changes. Until now every worker branched from the default branch whatever the card said, which is why the cards on `gh-pages` ran out of turns or opened PRs against `main`. -- 2.54.0