diff --git a/.github/time/astra.py b/.github/time/astra.py
index 01e1202..47d0fb2 100644
--- a/.github/time/astra.py
+++ b/.github/time/astra.py
@@ -240,7 +240,10 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
return 0
assess = {str(a.get("key") or "").strip(): a for a in out.get("assess") or [] if isinstance(a, dict)}
ranked = router.rank(cands, assess, cfg, router.last_picked(tm.beats_of(thread)), dt.datetime.now(dt.timezone.utc).date())
- picks = router.pick(ranked, cfg, left, max_ready)
+ in_lane = sum(1 for i in open_issues if router.CLOUD in router.labels_of(i))
+ to_cloud = router.pick_cloud(ranked, cfg, in_lane) # the cloud lane takes its cards first: its credit is separate
+ sent = {r["key"] for r in to_cloud}
+ picks = router.pick([r for r in ranked if r["key"] not in sent], cfg, left, max_ready)
new, repeats = [], []
for c in [c for c in out.get("new") or [] if isinstance(c, dict) and c.get("title")]:
n = repeat_of(str(c["title"]), open_issues + closed)
@@ -250,6 +253,8 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
if live:
for r in picks:
tm.label(r["n"], "queued") # the heartbeat promotes it, in ranked order
+ for r in to_cloud:
+ tm.label(r["n"], router.CLOUD) # the Mac's launcher starts a cloud session for it
for c in new:
body = str(c.get("body") or "")
if not router.DONE.search(body):
@@ -259,17 +264,21 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
w = cfg["weights"]
head = (f"🔭 Astra ({seat}), router `{cfg['mode']}`, spend ${today:.2f} of ${day_cap:.2f} today, ${left:.2f} left to "
f"queue against ({source}). Weights: "
- + ", ".join(f"{k} {w[k]:g}" for k in router.KINDS) + f"; restless {cfg['restless']:g}/day.")
- lines = [head, "", str(out.get("note") or "(no note)"), "", router.table(ranked, picks, cfg["mode"]), ""]
+ + ", ".join(f"{k} {w[k]:g}" for k in router.KINDS) + f"; restless {cfg['restless']:g}/day."
+ + (f" Cloud lane: {in_lane} in it, up to {cfg['cloud']['per_day']}." if cfg["cloud"]["per_day"] else ""))
+ lines = [head, "", str(out.get("note") or "(no note)"), "", router.table(ranked, picks, cfg["mode"], cloud=to_cloud), ""]
if unjudged:
lines.append(f"_{unjudged} candidate(s) the seat didn't judge are scored at value 3, size M._")
if live:
lines.append(("Queued: " + ", ".join(f"#{r['n']}" for r in picks)) if picks else "Queued nothing today.")
+ if to_cloud:
+ lines.append("To the cloud lane: " + ", ".join(f"#{r['n']}" for r in to_cloud))
if created:
lines.append("New cards (candidates from tomorrow): " + ", ".join(f"#{n}" for n in created))
else:
lines.append("Dry run: nothing queued or created. Live, it would queue: "
- + (", ".join(f"#{r['n']}" for r in picks) or "nothing") + ".")
+ + (", ".join(f"#{r['n']}" for r in picks) or "nothing") + "."
+ + (" To the cloud lane: " + ", ".join(f"#{r['n']}" for r in to_cloud) + "." if to_cloud else ""))
if new:
lines.append("It would write: " + "; ".join(router._txt(c["title"], 100) for c in new) + ".")
if repeats:
@@ -277,7 +286,8 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
+ "; ".join(f"{router._txt(c['title'], 80)} (#{n})" for c, n in repeats) + ".")
meta = {"beat": "astra", "router": cfg["mode"], "queued": len(picks) if live else 0, "order": router.order(ranked),
"picked": [{"n": r["n"], "kind": r["kind"]} for r in picks] if live else [],
- "would": [r["n"] for r in picks] if not live else [], "new": created}
+ "would": [r["n"] for r in picks] if not live else [], "new": created,
+ "cloud": [r["n"] for r in to_cloud] if live else [], "would_cloud": [r["n"] for r in to_cloud] if not live else []}
tm.comment(log, "\n".join(lines), meta)
print(f"astra router {cfg['mode']}: picks={[r['n'] for r in picks]} new={created} ranked={len(ranked)}")
return 0
diff --git a/.github/time/router.py b/.github/time/router.py
index e48153c..8809d4f 100644
--- a/.github/time/router.py
+++ b/.github/time/router.py
@@ -15,6 +15,12 @@ refinement or optimization.
- restless: a kind the factory hasn't picked for a while climbs, 1 + rate x days (at most 10 days), so none starves.
- age: an old card climbs a little, 1 + 0.02 x days open (at most 20 days).
+The cloud lane (`cloud` in time/router.json): each day the best-ranked actionable cards of the lane's sizes (M by
+default: the bigger ones) are labelled `cloud`, up to `per_day` cards in the lane at once, instead of going to the API
+worker. A launcher signed in to the owner's claude.ai account starts a Claude Code cloud session for each (the cloud-
+session credit pays); the session's `claude/card-N-*` branch comes home through sync.py as a PR, sync relabels the card
+`ready`, and the heartbeat adopts and reviews it like any card PR. `per_day: 0` (the default) is no cloud lane.
+
An L is ranked but never queued: a card is what one worker session finishes, so it is split first. Nor is a card that
waits: on a card still open (its `Depends on #N`), or on whatever the seat says blocks it (a PR that can't merge, a
human). Cards that can move come first in the promotion order, the ones that wait after them. The policy is
@@ -30,10 +36,12 @@ import re
KINDS = ("venture", "feature", "refinement", "optimization")
SIZES = {"S": 1, "M": 2, "L": 4}
MODES = ("off", "dry", "live")
-DEFAULTS = {"mode": "off", "weights": dict.fromkeys(KINDS, 1.0), "restless": 0.05, "cost_usd": {"S": 0.5, "M": 1.5, "L": 4.0}}
+DEFAULTS = {"mode": "off", "weights": dict.fromkeys(KINDS, 1.0), "restless": 0.05, "cost_usd": {"S": 0.5, "M": 1.5, "L": 4.0},
+ "cloud": {"per_day": 0, "sizes": ["M"]}}
LOOP = {"venture", "venture-batch", "signal", "signup"} # the venture loop's own cards: it runs them itself
HELD = {"needs-human", "andon", "astra", "planned"} | LOOP # a human's, an alarm, Astra's log, parents, the loop's
-LINED_UP = {"queued", "ready"} # already moving: ranked (for promotion), never queued again
+LINED_UP = {"queued", "ready", "cloud"} # already moving: ranked (for promotion), never queued again
+CLOUD = "cloud" # a card in the cloud lane: a cloud session builds it
DONE = re.compile(r"^\s*DONE:", re.M | re.I)
NEW_VENTURE = {"key": "venture:new", "n": None, "title": "Start a new venture: research, pick, build and launch one page",
"body": "The venture loop (venture.py): one venture, researched and picked by the lead, built and judged.",
@@ -62,10 +70,13 @@ def config(raw: str | None) -> dict:
w = got.get("weights") if isinstance(got.get("weights"), dict) else {}
c = got.get("cost_usd") if isinstance(got.get("cost_usd"), dict) else {}
mode = str(got.get("mode", "off")).strip().lower()
+ cl = got.get("cloud") if isinstance(got.get("cloud"), dict) else {}
+ sizes = [s for s in (cl.get("sizes") if isinstance(cl.get("sizes"), list) else DEFAULTS["cloud"]["sizes"]) if s in ("S", "M")]
return {"mode": mode if mode in MODES else "off",
"weights": {k: _num(w.get(k), 1.0) for k in KINDS},
"restless": _num(got.get("restless"), DEFAULTS["restless"], 0.0, 1.0),
- "cost_usd": {s: _num(c.get(s), DEFAULTS["cost_usd"][s]) for s in SIZES}}
+ "cost_usd": {s: _num(c.get(s), DEFAULTS["cost_usd"][s]) for s in SIZES},
+ "cloud": {"per_day": int(_num(cl.get("per_day"), 0, 0, 10)), "sizes": sizes}}
def labels_of(issue: dict) -> set[str]:
@@ -156,6 +167,18 @@ def rank(cands: list[dict], assess: dict[str, dict], cfg: dict, last: dict[str,
return sorted(scored, key=lambda r: (-r["score"], r["n"] if r["n"] is not None else 10**9))
+def pick_cloud(ranked: list[dict], cfg: dict, in_lane: int) -> list[dict]:
+ """From the top: actionable cards of the lane's sizes, not lined up, until the lane holds `per_day` cards (those
+ still labelled `cloud` count). The cloud-session credit pays for these, so no money check."""
+ want, out = cfg["cloud"]["per_day"] - in_lane, []
+ for r in ranked:
+ if len(out) >= want:
+ break
+ if r["n"] is not None and r["actionable"] and not r["lined_up"] and r["size"] in cfg["cloud"]["sizes"]:
+ out.append(r)
+ return out
+
+
def pick(ranked: list[dict], cfg: dict, left_usd: float, max_n: int) -> list[dict]:
"""From the top: cards not yet lined up, while the day's money left covers their size's estimated cost."""
out, spent = [], 0.0
@@ -179,13 +202,14 @@ def order(ranked: list[dict]) -> list[int]:
return [r["n"] for r in cards if r["actionable"]] + [r["n"] for r in cards if not r["actionable"]]
-def table(ranked: list[dict], picked: list[dict], mode: str, limit: int = 15) -> str:
- chosen = {r["key"] for r in picked}
+def table(ranked: list[dict], picked: list[dict], mode: str, limit: int = 15, cloud: list[dict] = ()) -> str:
+ chosen, sent = {r["key"] for r in picked}, {r["key"] for r in cloud}
mark = "queued today" if mode == "live" else "would queue"
rows = []
for k, r in enumerate(ranked[:limit], 1):
- note = (f"**{mark}**" if r["key"] in chosen else "; ".join(x for x in ("lined up" if r["lined_up"] else "",
- r.get("why_not", "") if not r["actionable"] else "") if x))
+ lane = "cloud lane" if CLOUD in r.get("labels", ()) else "lined up" if r["lined_up"] else ""
+ note = (f"**{mark}**" if r["key"] in chosen else ("**to the cloud today**" if mode == "live" else "**would go to the cloud**")
+ if r["key"] in sent else "; ".join(x for x in (lane, r.get("why_not", "") if not r["actionable"] else "") if x))
name = f"{r['key']} {r['title']}" if r["n"] is not None else r["title"]
rows.append(f"| {k} | {_txt(name, 80).replace('|', '/')} | {r['kind']} | {r['value']:g} | {r['size']} | "
f"{r['unblocks']} | {r['restless']:g} | {r['score']:g} | {_txt(r['why'], 140).replace('|', '/')} | "
diff --git a/.github/time/sync.py b/.github/time/sync.py
index 767c5aa..9b8c873 100644
--- a/.github/time/sync.py
+++ b/.github/time/sync.py
@@ -32,6 +32,7 @@ import fnmatch
import importlib.util
import json
import os
+import re
import subprocess
import sys
import tempfile
@@ -133,6 +134,30 @@ def landed(work: str, ref: str, base: str) -> bool:
return git("merge-base", "--is-ancestor", ref, base, cwd=work, check=False).returncode == 0
+CARD_BRANCH = re.compile(r"^claude/card-(\d+)(?:\b|-)") # a cloud session building card N (router.py's cloud lane)
+
+
+def card_of(branch: str) -> int | None:
+ m = CARD_BRANCH.match(branch)
+ return int(m.group(1)) if m else None
+
+
+def hand_back(repo: str, card: int, pr: int) -> bool:
+ """A cloud-lane card's branch came home as `pr`: the card goes from `cloud` to `ready`, so the heartbeat adopts the PR
+ (it closes the card) and the council reviews and lands it like any card PR. Only a card on this repo still in the
+ lane moves; the note carries no beat, so the card still reads as never dispatched and is adopted, not rebuilt."""
+ if repo != tm.REPO:
+ return False
+ issue = tm.gh("GET", f"/repos/{repo}/issues/{card}")
+ if issue.get("state") != "open" or not tm.has_label(issue, "cloud"):
+ return False
+ tm.unlabel(card, "cloud")
+ tm.label(card, "ready")
+ tm.comment(card, f"☁️ The cloud session's branch came home as PR #{pr}. The heartbeat reviews and lands it like any "
+ f"card PR.", {"cloud": "home", "pr": pr})
+ return True
+
+
def ensure_pr(repo: str, work: str, branch: str, base: str, copy: str, where: str) -> int | None:
"""A PR on the home for an imported branch, unless one is open for it, it is already in the base, or a PR for it
was closed at this same commit (a human or the council said no; a new push brings it back)."""
@@ -146,10 +171,20 @@ def ensure_pr(repo: str, work: str, branch: str, base: str, copy: str, where: st
return None
log = git("log", "--reverse", "--format=%s%n%n%b%n---", f"refs/home/{base}..{ref}", cwd=work).stdout.strip()
title = (log.splitlines() or [branch])[0][:200]
- body = (f"From {copy}: `{branch}` ({where}), brought in by sync.py. Time's council reviews it like any PR.\n\n"
+ card = card_of(branch)
+ closes = f"Closes #{card}\n\n" if card and not re.search(rf"\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s+#{card}\b", log, re.I) else ""
+ body = (f"{closes}From {copy}: `{branch}` ({where}), brought in by sync.py. Time's council reviews it like any PR.\n\n"
f"Commits:\n\n{log[:6000]}\n\nsync.py: edit this branch on {copy}; it is the copy's to write, and the "
f"next sync brings the change here.")
- return tm.gh("POST", f"/repos/{repo}/pulls", {"head": branch, "base": base, "title": title, "body": body})["number"]
+ n = tm.gh("POST", f"/repos/{repo}/pulls", {"head": branch, "base": base, "title": title, "body": body})["number"]
+ # the card the branch is named for, and any the commits close (a cloud session may name its own branch)
+ named = {card} if card else set()
+ for c in sorted(named | {int(x) for x in re.findall(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b", log, re.I)}):
+ try:
+ hand_back(repo, c, n)
+ except Exception as e: # noqa: BLE001 the PR is home either way; a human can relabel the card
+ print(f"hand_back #{c}: {scrub(str(e))[:200]}")
+ return n
def sync_repo(entry: dict, home_url: str, force: bool = False) -> dict:
diff --git a/.github/time/test_time.py b/.github/time/test_time.py
index c95e63d..bb08a9e 100644
--- a/.github/time/test_time.py
+++ b/.github/time/test_time.py
@@ -1186,6 +1186,17 @@ sb80 = {r["key"]: r for r in b80}
assert b80[0]["key"] == "#1" and sb80["#1"]["actionable"] is False and sb80["#1"]["why_not"] == "blocked: PR #75 can't merge"
assert rt.order(b80) == [2, 5, 1, 7, 9] and [r["key"] for r in rt.pick(b80, live80, 10.0, 2)] == ["#5"], "the top scorer waits"
assert all(rt.score(cands80[0], {"blocked": x}, live80, {}, today80)["actionable"] for x in ("", "none", "No", False, None, 0))
+# the cloud lane: off by default; with per_day, the best actionable cards of its sizes, until the lane is full
+assert rt.config(None)["cloud"] == {"per_day": 0, "sizes": ["M"]} and rt.pick_cloud(r80, live80, 0) == []
+cl80 = rt.config('{"mode": "live", "cloud": {"per_day": 2, "sizes": ["M", "L", "x"]}}')
+assert cl80["cloud"] == {"per_day": 2, "sizes": ["M"]}, cl80["cloud"]
+r80c = rt.rank(cands80, assess80, cl80, {}, today80)
+assert [r["key"] for r in rt.pick_cloud(r80c, cl80, 0)] == ["#1"], "the M at the top; #2 is lined up, #5 is an S, #7 an L, #9 waits"
+assert rt.pick_cloud(r80c, cl80, 2) == [] and rt.pick_cloud(r80c, cl80, 5) == [], "a full lane takes nothing"
+assert [r["key"] for r in rt.pick_cloud(r80c, rt.config('{"cloud": {"per_day": 3, "sizes": ["S", "M"]}}'), 1)] == ["#1", "#5"]
+assert rt.candidates([iss80(11, "In the lane", labels=["cloud"])])[0]["lined_up"], "a card in the cloud lane is never queued again"
+assert "**to the cloud today**" in rt.table(r80c, [], "live", cloud=rt.pick_cloud(r80c, cl80, 0))
+assert "cloud lane" in rt.table(rt.rank(rt.candidates([iss80(11, "In the lane", labels=["cloud"])]), {}, cl80, {}, today80), [], "live")
assert "lined up; waits on #1" in rt.table(rt.rank(rt.candidates(open80[:1] + [iss80(9, "q", body="Depends on #1\nDONE: q", labels=["queued"])]),
{}, live80, {}, today80), [], "live")
# a kind picked yesterday is less restless; the owner's weight lifts a kind
@@ -1231,6 +1242,16 @@ assert labelled80 == [(1, "queued"), (5, "queued")] and [m[0] for m in made80] =
assert "DONE:" in made80[0][1] and "card written by Astra" in made80[0][1]
meta80 = said80[-1][2]
assert meta80["picked"] == [{"n": 1, "kind": "feature"}, {"n": 5, "kind": "optimization"}] and meta80["new"] == [91] and meta80["queued"] == 2
+labelled80.clear(); made80.clear()
+assert astra.router_tick(rt.config('{"mode": "live", "cloud": {"per_day": 1}}'), "opus", 57, "ctx", [], 2, 2) == 0
+assert labelled80 == [(5, "queued"), (1, "cloud")], "the cloud lane takes the M (#1); the API worker gets the S behind it"
+assert said80[-1][2]["cloud"] == [1] and "To the cloud lane: #1" in said80[-1][1] and "Cloud lane: 0 in it, up to 1." in said80[-1][1]
+open80.append(iss80(12, "Already in the lane", labels=["cloud"])); labelled80.clear()
+assert astra.router_tick(rt.config('{"mode": "live", "cloud": {"per_day": 1}}'), "opus", 57, "ctx", [], 2, 2) == 0
+assert (1, "cloud") not in labelled80 and said80[-1][2]["cloud"] == [], "a full lane takes nothing more"
+open80.pop(); labelled80.clear(); made80.clear()
+assert astra.router_tick(rt.config('{"mode": "dry", "cloud": {"per_day": 1}}'), "opus", 57, "ctx", [], 2, 2) == 0
+assert not labelled80 and said80[-1][2]["would_cloud"] == [1] and "To the cloud lane: #1." in said80[-1][1]
astra.tm.think = lambda *a: (_ for _ in ()).throw(astra.tm.Budget("today's spend is over the cap"))
assert astra.router_tick(rt.config('{"mode": "live"}'), "opus", 57, "ctx", [], 2, 2) == 0 and said80[-1][2]["capped"]
# money left (#175): the allowance left in the pool that takes the worker's route, else the daily cap's remainder;
@@ -1453,6 +1474,31 @@ sy.tm.new_issue = lambda title, body, labels: flagged83.append((title, body, lab
sy.flag("o/r", "github", "main"); sy.flag("o/r", "github", "main")
assert len(flagged83) == 1 and "force" in flagged83[0][1] and flagged83[0][2] == ["needs-human"], "one issue per diverged branch, a human's"
assert sync83(force=True)["out"] == {"main": "forced"} and g83("rev-parse", "main", cwd=copy83) == g83("rev-parse", "main", cwd=home83)
+# a cloud-lane card's branch comes home: the PR closes the card, and the card goes from `cloud` to `ready`, with a note
+# that carries no beat (so the heartbeat adopts the PR instead of building the card again)
+assert sy.card_of("claude/card-7-cloud-lane") == 7 and sy.card_of("claude/card-12") == 12 and sy.card_of("claude/fix") is None
+assert sy.card_of("claude/card-7x") is None
+cards83 = {7: {"state": "open", "labels": [{"name": "cloud"}]}, 8: {"state": "open", "labels": [{"name": "queued"}]}}
+moves83, notes83 = [], []
+real_gh83 = sy.tm.gh
+sy.tm.gh = lambda m, path, body=None, **k: (dict(cards83[int(path.rsplit("/", 1)[1])]) if "/issues/" in path else gh83(m, path, body, **k))
+sy.tm.label, sy.tm.unlabel = (lambda n, name: moves83.append(("+", n, name))), (lambda n, name: moves83.append(("-", n, name)))
+sy.tm.comment = lambda n, text, meta: notes83.append((n, text, meta))
+g83("checkout", "-q", "main", cwd=cw83); g83("fetch", "-q", "origin", cwd=cw83); g83("reset", "-q", "--hard", "origin/main", cwd=cw83)
+g83("checkout", "-qb", "claude/card-7-cloud", cwd=cw83); commit83(cw83, "g.txt", "Build card seven"); g83("push", "-q", "origin", "claude/card-7-cloud", cwd=cw83)
+g83("checkout", "-qb", "claude/card-8-busy", cwd=cw83); commit83(cw83, "h.txt", "Card eight\n\nCloses #8"); g83("push", "-q", "origin", "claude/card-8-busy", cwd=cw83)
+c83 = sync83()
+pr7 = next(b for b in made83 if b["head"] == "claude/card-7-cloud"); pr8 = next(b for b in made83 if b["head"] == "claude/card-8-busy")
+assert pr7["body"].startswith("Closes #7\n") and not pr8["body"].startswith("Closes"), "added only where the commits don't close it"
+assert moves83 == [("-", 7, "cloud"), ("+", 7, "ready")], moves83
+assert len(notes83) == 1 and notes83[0][0] == 7 and "beat" not in notes83[0][2] and f"PR #{notes83[0][2]['pr']}" in notes83[0][1]
+assert sy.hand_back("x/y", 7, 1) is False and not sy.hand_back("o/r", 8, 1), "another repo's card, or one not in the lane, stays"
+cards83[9] = {"state": "open", "labels": [{"name": "cloud"}]}; moves83.clear()
+g83("checkout", "-qb", "claude/session-abc123", cwd=cw83); commit83(cw83, "i.txt", "Nine, from a session that named its own branch\n\nCloses #9")
+g83("push", "-q", "origin", "claude/session-abc123", cwd=cw83)
+sync83()
+assert moves83 == [("-", 9, "cloud"), ("+", 9, "ready")], "a card the commits close leaves the lane too"
+sy.tm.gh = real_gh83
# a copy whose token isn't set is skipped; tokens go in the URL and never into anything printed
c83 = sy.sync_repo({"repo": "o/r", "copies": [{"name": "gh", "url": "https://github.com/o/r.git", "token": "NO_SUCH_TOKEN_83"}]},
"file://" + home83)["copies"][0]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f1776b..13ef0d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +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 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`.
- 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.
diff --git a/time/router.json b/time/router.json
index fdaf752..78f78bf 100644
--- a/time/router.json
+++ b/time/router.json
@@ -1,7 +1,8 @@
{
- "_about": "The router's dials (.github/time/router.py). mode: off (Astra plans as before), dry (Astra posts the ranking on the Astra log and queues nothing), live (Astra queues from the top and the heartbeat promotes queued cards in ranked order). weights: the owner's dial per kind; all 1 is balanced, 2 doubles a kind, 0 shuts it out. restless: how fast a kind not picked lately climbs, per day, for at most 10 days. cost_usd: the estimated spend of an S, M or L card, which Astra checks against the day's money left before it queues one.",
+ "_about": "The router's dials (.github/time/router.py). mode: off (Astra plans as before), dry (Astra posts the ranking on the Astra log and queues nothing), live (Astra queues from the top and the heartbeat promotes queued cards in ranked order). weights: the owner's dial per kind; all 1 is balanced, 2 doubles a kind, 0 shuts it out. restless: how fast a kind not picked lately climbs, per day, for at most 10 days. cost_usd: the estimated spend of an S, M or L card, which Astra checks against the day's money left before it queues one. cloud: the cloud lane; per_day cards (of these sizes) are labelled `cloud` and built by Claude Code cloud sessions that the launcher on hb-5 starts (the owner's cloud-session credit pays, until 2026-11-04); 0 turns the lane off.",
"mode": "live",
"weights": {"venture": 1, "feature": 1, "refinement": 1, "optimization": 1},
"restless": 0.05,
- "cost_usd": {"S": 0.5, "M": 1.5, "L": 4}
+ "cost_usd": {"S": 0.5, "M": 1.5, "L": 4},
+ "cloud": {"per_day": 3, "sizes": ["M"]}
}