The cloud lane: cloud sessions build cards on the cloud-session credit (#197)
ci / test (push) Successful in 45s
sync / sync (push) Successful in 5s

The cloud lane: cloud sessions build cards on the cloud-session credit (#197)

Astra labels the best M cards `cloud` (up to 3 in the lane); the launcher on hb-5 starts a claude --cloud session per card on the GitHub copy; sync brings the branch home as a PR that closes the card and moves it cloud -> ready; the heartbeat adopts and the council lands it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
This commit was merged in pull request #197.
This commit is contained in:
teahubxyzandClaude Opus 5.5 committed 2026-10-19 16:46:15 +00:00
1 parent 608510afde
commit 1a2ae6bc7e
6 files changed
+133 -16

No files matched your search

+15 -5
View File
@@ -240,7 +240,10 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
return 0 return 0
assess = {str(a.get("key") or "").strip(): a for a in out.get("assess") or [] if isinstance(a, dict)} 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()) 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 = [], [] new, repeats = [], []
for c in [c for c in out.get("new") or [] if isinstance(c, dict) and c.get("title")]: 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) 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: if live:
for r in picks: for r in picks:
tm.label(r["n"], "queued") # the heartbeat promotes it, in ranked order 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: for c in new:
body = str(c.get("body") or "") body = str(c.get("body") or "")
if not router.DONE.search(body): 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"] w = cfg["weights"]
head = (f"🔭 Astra ({seat}), router `{cfg['mode']}`, spend ${today:.2f} of ${day_cap:.2f} today, ${left:.2f} left to " head = (f"🔭 Astra ({seat}), router `{cfg['mode']}`, spend ${today:.2f} of ${day_cap:.2f} today, ${left:.2f} left to "
f"queue against ({source}). Weights: " f"queue against ({source}). Weights: "
+ ", ".join(f"{k} {w[k]:g}" for k in router.KINDS) + f"; restless {cfg['restless']:g}/day.") + ", ".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"]), ""] + (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: if unjudged:
lines.append(f"_{unjudged} candidate(s) the seat didn't judge are scored at value 3, size M._") lines.append(f"_{unjudged} candidate(s) the seat didn't judge are scored at value 3, size M._")
if live: if live:
lines.append(("Queued: " + ", ".join(f"#{r['n']}" for r in picks)) if picks else "Queued nothing today.") 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: if created:
lines.append("New cards (candidates from tomorrow): " + ", ".join(f"#{n}" for n in created)) lines.append("New cards (candidates from tomorrow): " + ", ".join(f"#{n}" for n in created))
else: else:
lines.append("Dry run: nothing queued or created. Live, it would queue: " 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: if new:
lines.append("It would write: " + "; ".join(router._txt(c["title"], 100) for c in new) + ".") lines.append("It would write: " + "; ".join(router._txt(c["title"], 100) for c in new) + ".")
if repeats: 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) + ".") + "; ".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), 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 [], "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) 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)}") print(f"astra router {cfg['mode']}: picks={[r['n'] for r in picks]} new={created} ranked={len(ranked)}")
return 0 return 0
+31 -7
View File
@@ -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. - 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). - 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 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 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 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") KINDS = ("venture", "feature", "refinement", "optimization")
SIZES = {"S": 1, "M": 2, "L": 4} SIZES = {"S": 1, "M": 2, "L": 4}
MODES = ("off", "dry", "live") 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 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 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) 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", 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.", "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 {} w = got.get("weights") if isinstance(got.get("weights"), dict) else {}
c = got.get("cost_usd") if isinstance(got.get("cost_usd"), dict) else {} c = got.get("cost_usd") if isinstance(got.get("cost_usd"), dict) else {}
mode = str(got.get("mode", "off")).strip().lower() 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", return {"mode": mode if mode in MODES else "off",
"weights": {k: _num(w.get(k), 1.0) for k in KINDS}, "weights": {k: _num(w.get(k), 1.0) for k in KINDS},
"restless": _num(got.get("restless"), DEFAULTS["restless"], 0.0, 1.0), "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]: 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)) 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]: 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.""" """From the top: cards not yet lined up, while the day's money left covers their size's estimated cost."""
out, spent = [], 0.0 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"]] 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: def table(ranked: list[dict], picked: list[dict], mode: str, limit: int = 15, cloud: list[dict] = ()) -> str:
chosen = {r["key"] for r in picked} chosen, sent = {r["key"] for r in picked}, {r["key"] for r in cloud}
mark = "queued today" if mode == "live" else "would queue" mark = "queued today" if mode == "live" else "would queue"
rows = [] rows = []
for k, r in enumerate(ranked[:limit], 1): 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 "", lane = "cloud lane" if CLOUD in r.get("labels", ()) else "lined up" if r["lined_up"] else ""
r.get("why_not", "") if not r["actionable"] else "") if x)) 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"] 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']} | " 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('|', '/')} | " f"{r['unblocks']} | {r['restless']:g} | {r['score']:g} | {_txt(r['why'], 140).replace('|', '/')} | "
+37 -2
View File
@@ -32,6 +32,7 @@ import fnmatch
import importlib.util import importlib.util
import json import json
import os import os
import re
import subprocess import subprocess
import sys import sys
import tempfile 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 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: 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 """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).""" 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 return None
log = git("log", "--reverse", "--format=%s%n%n%b%n---", f"refs/home/{base}..{ref}", cwd=work).stdout.strip() 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] 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\n<sub>sync.py: edit this branch on {copy}; it is the copy's to write, and the " f"Commits:\n\n{log[:6000]}\n\n<sub>sync.py: edit this branch on {copy}; it is the copy's to write, and the "
f"next sync brings the change here.</sub>") f"next sync brings the change here.</sub>")
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: def sync_repo(entry: dict, home_url: str, force: bool = False) -> dict:
+46
View File
@@ -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 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 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)) 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"])]), 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") {}, live80, {}, today80), [], "live")
# a kind picked yesterday is less restless; the owner's weight lifts a kind # 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] assert "DONE:" in made80[0][1] and "card written by Astra" in made80[0][1]
meta80 = said80[-1][2] meta80 = said80[-1][2]
assert meta80["picked"] == [{"n": 1, "kind": "feature"}, {"n": 5, "kind": "optimization"}] and meta80["new"] == [91] and meta80["queued"] == 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")) 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"] 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; # 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") 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 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) 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 # 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"}]}, 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] "file://" + home83)["copies"][0]
+1
View File
@@ -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. - 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 ### 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/<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. - 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`. - 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. - 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.
+3 -2
View File
@@ -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", "mode": "live",
"weights": {"venture": 1, "feature": 1, "refinement": 1, "optimization": 1}, "weights": {"venture": 1, "feature": 1, "refinement": 1, "optimization": 1},
"restless": 0.05, "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"]}
} }