From bc6f232a563c2d66dd03d6d819e31d16d48336c5 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 12 Oct 2026 18:20:49 -0700 Subject: [PATCH] venture batches: the board's top k as one milestone, launched together (multi-ceremony) (#22) One batch card scouts once, the seats pitch a field, and the board ranks it; the top k (each wanted by two seats) become venture cards in the batch's milestone at 'picked'. Children run the ordinary loop side by side, wait at 'ready' until every sibling is settled or the hold runs out, then launch together. No child left behind: one batch retry for a held child, then it is counted dead in the cohort retro, which closes the card and the milestone. Fix rounds now yield the single worker run per tick to cards still due their first build, so a failing page can't starve its siblings. Claude-Session: https://claude.ai/code/session_01VDkN7ukK1kXkqov8FaZxva Co-authored-by: Claude --- .github/time/test_time.py | 127 ++++++++++++++ .github/time/venture.py | 316 +++++++++++++++++++++++++++++++--- .github/workflows/venture.yml | 8 + CHANGELOG.md | 2 + README.md | 1 + 5 files changed, 434 insertions(+), 20 deletions(-) diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 9798f27..91efef4 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -1026,6 +1026,133 @@ W.files["ventures/q/time.js"] = cfb.client("https://q.yolo.lol/api/") * 3 assert any("no working product" in p for p in vt.check_page(wq, {"pricing": []})) print("ALL OK 34") + +# 39 a batch: one scout, a field of pitches, the board's top 3 become cards in a milestone; built side by side, they wait +# at `ready` and launch together; a child the andon holds gets one batch retry, then is counted dead; the retro closes it +class BWorld(VWorld): + def __init__(self): + super().__init__() + self.milestones = {} + + def http(self, method, url, token, body=None, timeout=0): + base = url.replace(tm.GH, "").partition("?")[0] + if base == "/repos/o/r/milestones": + if method == "POST": + m = len(self.milestones) + 1 + self.milestones[m] = {"number": m, "state": "open", **body} + return self.milestones[m] + return list(self.milestones.values()) + if base.startswith("/repos/o/r/milestones/"): + self.milestones[int(base.rsplit("/", 1)[1])].update(body); return {} + out = super().http(method, url, token, body, timeout) + if base == "/repos/o/r/issues" and method == "POST" and body.get("milestone"): + out["milestone"] = {"number": body["milestone"]} + return out + + +ROUNDS = {} + + +def batch_brain(seat, model, effort, system, user): + if "Pitch ONE venture" in user: + r = ROUNDS[seat] = ROUNDS.get(seat, 0) + 1 + name = f"{seat.capitalize()}{'abc'[r - 1]}" + assert r == 1 or "(pitched today)" in user, "later rounds see the field so far" + return json.dumps({"slug": name.lower(), "name": name, "title": f"{name} tool", "one_liner": "Type it, see it.", + "for_whom": "weekly deck makers", "why_now": "rides storytold/wordcraft", "page": "textarea to slides", + "next": f"{name} Pro"}) + if "voting on the board" in user: + slugs = re.findall(r"^### (\S+) \(by", user, re.M) + return json.dumps({"ranking": slugs[:3], "reason": "the first three"}) + return seat_brain(seat, model, effort, system, user) + + +class PickyWorker(Worker): + """A good worker, except for one venture's page, which never gets pricing right.""" + def __init__(self, sick): + super().__init__() + self.sick = sick + + def __call__(self, card, done, spec, root=""): + slug = re.search(r"ventures/([a-z0-9-]+)/", card["body"]).group(1) + self.bad = 1 if slug == self.sick else 0 + return super().__call__(card, done, spec, root) + + +def btick(env=None): + for k in ("TIME_VENTURE_BATCH", "TIME_VENTURE_BATCH_HOLD_HOURS", "TIME_VENTURE_BATCH_START", "TIME_VENTURE_START"): + os.environ.pop(k, None) + for k in ("TIME_VENTURE", "TIME_VENTURE_FIXES", "TIME_VENTURE_MEASURE_DAYS"): + os.environ.pop(k, None) + os.environ.update({"VENICE_API_KEY": "vk", "TIME_VENTURE": "on", "TIME_VENTURE_BATCH": "3", **(env or {})}) + vt.tm.http, vt.tm.think = W.http, batch_brain + vt.tick() + + +def kids_of(b): + return tm.beats_of(W.comments[b])[-1].get("kids") or [] + + +W = BWorld() +ROUNDS.clear() +vt.browser_check = lambda slug: ([], b"\x89PNG", "checked") +vt._build_on_pages = PickyWorker(sick="kimib") +btick() +batch = next(i for i in W.issues.values() if "venture-batch" in i["labels"]) +b = batch["number"] +assert W.beats(b) == ["scouted", "pitched", "spawned"], W.beats(b) +assert not [i for i in W.issues.values() if i["title"].startswith("Venture 20")], "a batch day makes no single card" +field = tm.beats_of(W.comments[b])[1]["pitches"] +assert len(field) == 6 and len({p["slug"] for p in field}) == 6, [p["slug"] for p in field] # two rounds for k=3 +kids = kids_of(b) +assert [k["slug"] for k in kids] == ["qwena", "kimia", "groka"], kids +assert W.milestones[1]["title"] == batch["title"] and all(W.issues[k["card"]]["milestone"] == {"number": 1} for k in kids) +assert all(f"time-batch:{b}:" in W.issues[k["card"]]["body"] for k in kids) +before = len(W.issues) # a tick that dies after spawning reruns the spawn: no card is made twice +picks = [p for p in field if p["slug"] in {k["slug"] for k in kids}] +assert vt.spawn(b, picks, 1, []) == kids and len(W.issues) == before +# one worker run per tick: the first child built and passed, and holds at ready; its siblings are planned +stages = [W.beats(k["card"])[-1] for k in kids] +assert sorted(stages) == ["planned", "planned", "ready"], stages +assert json.loads(W.files["ventures/ventures.json"])["ventures"][0]["status"] == "building", "ready is not live yet" +btick(); btick() +assert [W.beats(k["card"])[-1] for k in kids] == ["ready"] * 3 and W.beats(b)[-1] == "spawned" +btick() # every child settled: the batch releases and they launch in the same tick +assert W.beats(b)[-1] == "launched" and [W.beats(k["card"])[-1] for k in kids] == ["launched"] * 3 +assert {v["status"] for v in json.loads(W.files["ventures/ventures.json"])["ventures"]} == {"live"} +assert "launch together" in W.text(b) + +# the andon and the batch retry: kimib (kimi's second-round pitch) is the sick one; board picks it with the hold at 0h +W = BWorld() +ROUNDS.clear() +vt._build_on_pages = PickyWorker(sick="groka") +for _ in range(4): + btick({"TIME_VENTURE_BATCH_HOLD_HOURS": "0"}) +b = next(i["number"] for i in W.issues.values() if "venture-batch" in i["labels"]) +kids = {k["slug"]: k["card"] for k in kids_of(b)} +# the hold is 0h: the first ready child released the batch; stragglers launched alone once they passed +assert W.beats(b)[-1] == "launched" and "launch alone" in W.text(b) +assert W.beats(kids["qwena"])[-1] == "launched" and W.beats(kids["kimia"])[-1] == "launched" +for _ in range(6): + if "held" in W.beats(kids["groka"]) and W.beats(kids["groka"]).count("held") >= 1: + break + btick({"TIME_VENTURE_BATCH_HOLD_HOURS": "0"}) +assert W.beats(kids["groka"])[-1] == "held", W.beats(kids["groka"]) +btick({"TIME_VENTURE_BATCH_HOLD_HOURS": "0"}) # the batch, not a human, gives it one more go +assert "Batch #1 retry: no child left behind" in W.text(kids["groka"]) and "needs-human" not in W.issues[kids["groka"]]["labels"] +for _ in range(6): + btick({"TIME_VENTURE_BATCH_HOLD_HOURS": "0"}) +assert W.beats(kids["groka"]).count("held") == 2 and W.beats(kids["groka"])[-1] == "held" +# day 7 (window 0 here): the launched children are measured; the dead one is not waited on; the retro closes the batch +btick({"TIME_VENTURE_BATCH_HOLD_HOURS": "0", "TIME_VENTURE_MEASURE_DAYS": "0"}) +assert W.beats(kids["qwena"])[-1] == "done" and W.beats(kids["kimia"])[-1] == "done" +btick({"TIME_VENTURE_BATCH_HOLD_HOURS": "0", "TIME_VENTURE_MEASURE_DAYS": "0"}) # batches run first: the retro is next tick +assert W.beats(b)[-1] == "done" and W.issues[b]["state"] == "closed" and W.milestones[1]["state"] == "closed" +retro = W.text(b).split("Batch retro")[1] +assert "2 of 3 launched" in retro and "#4" in retro and "after its batch retry" in retro and "killed" in retro, retro +assert W.issues[kids["groka"]]["state"] == "open", "a dead child stays open: a human can still /time retry it" +print("ALL OK 39") + # the worker itself (worker.js), against a fake D1, Workers AI and Pages import shutil as _sh, subprocess as _sp # noqa: E402 if _sh.which("node"): diff --git a/.github/time/venture.py b/.github/time/venture.py index df170a9..a9ffb70 100644 --- a/.github/time/venture.py +++ b/.github/time/venture.py @@ -17,6 +17,8 @@ Backend (optional, CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID): each venture a Signup: a GitHub issue form (.github/ISSUE_TEMPLATE/early-access.yml) prefilled with the venture and the plan. Measure: after 7 days, distinct people who signed up or ๐Ÿ‘'d the signal issue, against the plan's success and kill lines: iterate (a v2 card), kill, or watch one more week and then park. +Batch (TIME_VENTURE_BATCH=k): one card scouts and pitches a field, the board's top k become cards in one milestone, + launched together after a hold, with one batch retry for a held child and a cohort retro (see "batches" below). Nothing is posted outside the repo: launch posts are drafts for the chair. """ from __future__ import annotations @@ -50,7 +52,7 @@ _spec.loader.exec_module(cfb) REPO = tm.REPO PAGES_BRANCH = "gh-pages" FORM = "early-access.yml" -STAGES = ["new", "scouted", "pitched", "picked", "planned", "built", "launched"] +STAGES = ["new", "scouted", "pitched", "picked", "planned", "built", "ready", "launched"] TRUSTED = ("OWNER", "MEMBER", "COLLABORATOR") FIELDS = {"venture": "Venture", "plan": "Plan", "use": "What would you use it for?"} # labels in the issue form @@ -271,10 +273,12 @@ def pitch(repos: list[dict], seats: list[str], studio: str = "- (none yet)") -> return out -def board(pitches: list[dict], seats: list[str], studio: str = "- (none yet)") -> tuple[str | None, dict]: +def votes_for(pitches: list[dict], seats: list[str], studio: str = "- (none yet)") -> dict: + """Every seat ranks up to three pitches at once; Borda 3/2/1. Returns {"votes", "score", "backers"}.""" text = "\n\n".join(f"### {p['slug']} (by {p['by']})\n{p['title']}: {p.get('one_liner', '')}\nFor: {p.get('for_whom', '')}\n" f"Why now: {p.get('why_now', '')}\n{p.get('page', '')}\nNext: {p.get('next', '')}" for p in pitches) score, votes = {p["slug"]: 0 for p in pitches}, {} + backers = {p["slug"]: 0 for p in pitches} results = each(seats, lambda s: ask_json(s, f"You are `{s}`.", VOTE.format(seat=s, pitches=text, have=studio, playbook=PLAYBOOK))) for s, v in zip(seats, results): if not v: @@ -287,12 +291,26 @@ def board(pitches: list[dict], seats: list[str], studio: str = "- (none yet)") - v["ranking"] = seen for pts, slug in zip((3, 2, 1), seen): score[slug] += pts - if not any(score.values()): - return None, {"votes": votes, "score": score} - winner = max(score, key=lambda k: (score[k], -[p["slug"] for p in pitches].index(k))) - if sum(1 for v in votes.values() if winner in v["ranking"]) < 2: - return None, {"votes": votes, "score": score} # no quorum: at least two seats must want it - return winner, {"votes": votes, "score": score} + backers[slug] += 1 + return {"votes": votes, "score": score, "backers": backers} + + +def top(pitches: list[dict], meta: dict, k: int) -> list[str]: + """The k best pitches by Borda, ties to the earlier pitch, each wanted by at least two seats (the quorum).""" + order = [p["slug"] for p in pitches] + ranked = sorted((s for s in order if meta["score"][s] > 0), key=lambda s: (-meta["score"][s], order.index(s))) + return [s for s in ranked if meta["backers"][s] >= 2][:k] + + +def board(pitches: list[dict], seats: list[str], studio: str = "- (none yet)") -> tuple[str | None, dict]: + meta = votes_for(pitches, seats, studio) + order = [p["slug"] for p in pitches] + if not any(meta["score"].values()): + return None, meta + winner = max(order, key=lambda k: (meta["score"][k], -order.index(k))) + if meta["backers"][winner] < 2: + return None, meta # no quorum: at least two seats must want it + return winner, meta def _s(v, n: int = 600) -> str: @@ -1117,6 +1135,15 @@ def rank(stage: str) -> int: return STAGES.index("built") if stage == "held" else (STAGES.index(stage) if stage in STAGES else -1) +def priority(beats: list[dict]) -> float: + """Furthest along first, except that a card in fix rounds waits behind cards still due their first build: the one + worker run per tick is shared, and a page that keeps failing must not starve its siblings (in a batch, or not).""" + st = beats[-1] if beats else {"beat": "new"} + if st["beat"] == "built" and (st.get("problems") or int(st.get("fixes", 0)) or st.get("retry")): + return STAGES.index("planned") - 0.5 + return rank(st["beat"]) + + def first(beats: list[dict], name: str) -> dict: return next((b for b in reversed(beats) if b["beat"] == name), {}) @@ -1192,7 +1219,7 @@ def next_version(w: dict, asks: list[str], card: int) -> int: """Success: a new card that plans and builds the next version on the same page, shaped by what signups asked for.""" v2 = {**w, "version": int(w.get("version", 1)) + 1, "prior": f"ventures/{w['slug']}/index.html", "asks": asks[:12], "page": f"{w.get('page', '')} Next: {w.get('next', '')}"} - for k in ("join", "signal", "card", "url"): + for k in ("join", "signal", "card", "url", "batch"): # a v2 is its own venture, not held for its first batch v2.pop(k, None) c = new_card(f"Venture {today()}: {w['name']} v{v2['version']}") tm.comment(c["number"], f"๐Ÿ“ˆ {w['name']} cleared its success line on #{card}. Planning v{v2['version']} from what signups asked for.", @@ -1324,12 +1351,16 @@ def advance(card: dict, comments: list[dict], beats: list[dict], heavy_ok: bool) res = put_pages(f"ventures/{w['slug']}/shots/sheet.png", sheet, f"venture {w['slug']}: screenshots") ref = (res.get("commit") or {}).get("sha") or PAGES_BRANCH shot = f"https://raw.githubusercontent.com/{REPO}/{ref}/ventures/{w['slug']}/shots/sheet.png" - reg = registry() - since = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - upsert(reg, {"slug": w["slug"], "status": "live", "launched": today(), "card": n, - "signups": count_signups(w, since if int(w.get("version", 1)) > 1 else None)[0]}) - save_registry(reg, f"ventures: launch {w['slug']}") - say(launch_post(w, plan, shot, note), {"beat": "launched", **keep, "shot": shot, "since": since}) + if w.get("batch") and not batch_released(int(w["batch"])): + say(f"โœ… Checks pass. Ready, held for batch #{w['batch']} so its ventures launch together " + f"(at most {batch_hold_hours():g}h after the first of them is ready).", + {"beat": "ready", **keep, "shot": shot, "note": note}) + return "ok" + launch(n, w, plan, keep, shot, note, say) + elif stage == "ready": + if not batch_released(int(st["winner"]["batch"])): + return "wait" + launch(n, st["winner"], st["plan"], keep, st.get("shot"), st.get("note", ""), say) elif stage == "held": try: tm.gh("DELETE", f"/repos/{REPO}/issues/{n}/labels/needs-human") @@ -1355,6 +1386,15 @@ def advance(card: dict, comments: list[dict], beats: list[dict], heavy_ok: bool) return "error" +def launch(n: int, w: dict, plan: dict, keep: dict, shot: str | None, note: str, say) -> None: + reg = registry() + since = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + upsert(reg, {"slug": w["slug"], "status": "live", "launched": today(), "card": n, + "signups": count_signups(w, since if int(w.get("version", 1)) > 1 else None)[0]}) + save_registry(reg, f"ventures: launch {w['slug']}") + say(launch_post(w, plan, shot, note), {"beat": "launched", **keep, "shot": shot, "since": since}) + + def transient(e: Exception) -> bool: """A provider being busy or unreachable (after the engine's own retries), as opposed to a real failure.""" return isinstance(e, OSError) or bool(re.search(r"-> (429|5\d\d)\b|overloaded|rate limit", str(e), re.I)) @@ -1433,6 +1473,222 @@ def refresh_backends(reg: dict, limit: int = 2) -> None: save_registry(reg, "ventures: backends on Cloudflare") +# ---------- batches: one milestone, k ventures at once (multi-ceremony) ---------- +# A batch card (label `venture-batch`) scouts once, has the seats pitch twice over, and lets the board rank the whole +# field; the top k (each wanted by two seats) become child venture cards in the batch's milestone, starting at +# `picked`. Children run the ordinary loop above; the cheap stages move together, builds take one worker run per tick. +# Launch together: a child whose checks pass waits at `ready` until every sibling is settled (ready, launched, done, or +# dead) or the hold runs out, then the batch releases them all and stragglers launch alone when they pass. +# No child left behind: a child the andon holds gets one batch retry (fresh fix rounds); held again, it is counted +# dead, with its reason, in the retro. The retro compares the cohort once every child has been measured. + +BATCH_STAGES = ["new", "scouted", "pitched", "spawned", "launched"] + + +def batch_size() -> int: + return max(0, int(float(env("TIME_VENTURE_BATCH", "0") or 0))) + + +def batch_hold_hours() -> float: + return float(env("TIME_VENTURE_BATCH_HOLD_HOURS", "6")) + + +def batch_cards(state: str = "open", n: int = 30) -> list[dict]: + return [i for i in tm.gh("GET", f"/repos/{REPO}/issues?state={state}&labels=venture-batch&per_page={n}") if "pull_request" not in i] + + +BATCH_BODY = ("A venture batch: one scout pass, the seats pitch, the board ranks the whole field and the top {k} become " + "venture cards in this card's milestone. They are built side by side, launch together, and are measured as " + "a cohort. No child left behind: every child ends with a receipt, launched or dead with its reason.\n\n" + "DONE: a retro on this card comparing the cohort's signups against each plan's lines.") + + +def new_batch(title: str, k: int) -> dict: + return tm.gh("POST", f"/repos/{REPO}/issues", {"title": title, "body": BATCH_BODY.format(k=k), "labels": ["venture-batch"]}) + + +def milestone_for(title: str) -> int: + """The batch's milestone, made once; due at the end of the build day plus the measuring window.""" + for m in tm.gh("GET", f"/repos/{REPO}/milestones?state=all&per_page=100"): + if m["title"] == title: + return m["number"] + due = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=measure_days() + 1)).strftime("%Y-%m-%dT%H:%M:%SZ") + return tm.gh("POST", f"/repos/{REPO}/milestones", + {"title": title, "due_on": due, "description": "A venture batch: its cards are built, launched and measured together."})["number"] + + +def pitch_field(repos: list[dict], seats: list[str], studio: str, k: int) -> list[dict]: + """Enough pitches for the board to choose k from: rounds of one pitch per seat, each round told what is already + pitched so it pitches something else, until there are 2k or three rounds have run.""" + field: list[dict] = [] + for _ in range(3): + pitched = "".join(f"\n- {p.get('name') or p['title']}: {p.get('one_liner', '')} (pitched today)" for p in field) + for p in pitch(repos, seats, studio + pitched): + names = set().union(set(), *[_forms(q.get(x)) for q in field for x in ("slug", "name", "title")]) + if repeats(p, names): + continue # the same idea again, from another seat or another round + while p["slug"] in {q["slug"] for q in field}: + p["slug"] += "-2" + field.append(p) + if len(field) >= 2 * k: + break + return field + + +def child_marker(batch: int, slug: str) -> str: + return f"" + + +def spawn(n: int, picks: list[dict], milestone: int, repos: list[dict]) -> list[dict]: + """A venture card per pick, in the milestone, already at `picked`. Safe to rerun: a pick that already has its card + (found by the marker in the card's body) is not made twice.""" + existing = venture_cards("all", 100) + kids = [] + for w in picks: + mark = child_marker(n, w["slug"]) + card = next((c for c in existing if mark in (c.get("body") or "")), None) + if not card: + card = tm.gh("POST", f"/repos/{REPO}/issues", + {"title": f"Venture {w.get('name') or w['title']} (batch #{n})", "labels": ["venture"], + "milestone": milestone, "body": f"{CARD_BODY}\n\nPart of batch #{n}.\n\n{mark}"}) + w = {**w, "batch": n, "rides": rides(repos, w)} + tm.comment(card["number"], f"๐Ÿ—ณ๏ธ Picked by batch #{n}'s board: **{w.get('name') or w['title']}** (`{w['slug']}`).", + {"venture": True, "beat": "picked", "winner": w}) + kids.append({"card": card["number"], "slug": w["slug"]}) + return kids + + +def batch_released(b: int) -> bool: + issue = tm.gh("GET", f"/repos/{REPO}/issues/{b}") + return issue.get("state") == "closed" or stage_of(tm.beats_of(comments_of(b))) in ("launched", "done") + + +def child_state(kid: dict, retried: list[int]) -> dict: + """Where one child stands: its stage, whether it is settled for the launch, and if it is dead, why.""" + issue = tm.gh("GET", f"/repos/{REPO}/issues/{kid['card']}") + beats = tm.beats_of(comments_of(kid["card"])) + st = beats[-1] if beats else {"beat": "new"} + out = {**kid, "stage": st["beat"], "st": st, "ready_at": first(beats, "ready").get("at")} + if st["beat"] == "done" and st.get("stuck"): + out["dead"] = f"stuck at `{st['stuck']}`" + elif st["beat"] == "held" and kid["card"] in retried: + out["dead"] = "held by the andon after its batch retry: " + "; ".join(st.get("problems", []))[:300] + elif issue.get("state") == "closed" and st["beat"] != "done": + out["dead"] = "closed by hand" + out["settled"] = bool(out.get("dead")) or st["beat"] in ("ready", "launched", "done") + return out + + +def leave_none_behind(n: int, states: list[dict], retried: list[int]) -> bool: + """A child the andon holds gets one more go, on the batch: fresh fix rounds. Held again, child_state counts it dead. + Appends to `retried`; True when it retried anyone.""" + did = False + for c in states: + if c["stage"] == "held" and c["card"] not in retried: + try: + tm.gh("DELETE", f"/repos/{REPO}/issues/{c['card']}/labels/needs-human") + except RuntimeError: + pass + keep = {x: c["st"][x] for x in ("winner", "plan") if x in c["st"]} + tm.comment(c["card"], f"๐Ÿ” Batch #{n} retry: no child left behind. Fresh fix rounds; held again, it is counted " + f"dead in the batch retro.", {"venture": True, "beat": "built", **keep, "fixes": 0, "retry": True}) + retried.append(c["card"]) + did = True + return did + + +def retro(n: int, kids: list[dict], say) -> None: + reg = {v["slug"]: v for v in registry()["ventures"]} + rows, shipped = [], 0 + for k in kids: + v, st = reg.get(k["slug"], {}), k["st"] + plan = st.get("plan") or {} + lines = f"โ‰ฅ{plan['success_signups']} / โ‰ค{plan['kill_signups']}" if "success_signups" in plan else "-" + outcome = k.get("dead") or st.get("outcome") or v.get("status") or k["stage"] + shipped += 1 if v.get("launched") else 0 + rows.append(f"| [{v.get('name') or k['slug']}]({v.get('url', '')}) | #{k['card']} | {v.get('signups', st.get('signups', 0))} " + f"| {lines} | {outcome} |") + say(f"๐Ÿงพ Batch retro: {shipped} of {len(kids)} launched.\n\n| venture | card | signups | success / kill | outcome |\n" + f"|---|---|---|---|---|\n" + "\n".join(rows), {"beat": "done", "kids": [{"card": k["card"], "slug": k["slug"]} for k in kids]}) + + +def advance_batch(card: dict, comments: list[dict], beats: list[dict]) -> str: + n = card["number"] + st = beats[-1] if beats else {"beat": "new"} + stage, k = st["beat"], int(st.get("k") or batch_size() or 3) + say = lambda text, meta: tm.comment(n, text, {"venture": True, "k": k, **meta}) + print(f"batch #{n} stage={stage}") + try: + if stage == "new": + repos = scout() + say("๐Ÿ”ญ Batch scout: the repos that gained the most stars this week.\n\n" + radar(repos), {"beat": "scouted", "repos": repos}) + elif stage == "scouted": + reg = registry() + field = [p for p in pitch_field(st["repos"], seat_list("TIME_VENTURE_PITCH", "qwen,kimi,grok"), have(reg), k) + if not repeats(p, taken(reg))] + if not field: + raise RuntimeError("no fresh pitches") + text = "\n\n".join(f"**{p.get('name') or p['title']}** (`{p['slug']}`) by `{p['by']}`: {p.get('one_liner', '')}" for p in field) + say(f"๐Ÿ’ก The field, {len(field)} pitches:\n\n{text}", {"beat": "pitched", "repos": st["repos"], "pitches": field}) + elif stage == "pitched": + meta = votes_for(st["pitches"], seat_list("TIME_VENTURE_BOARD", "grok,kimi,qwen"), have(registry())) + slugs = top(st["pitches"], meta, k) + if not slugs: + raise RuntimeError(f"no pitch reached a quorum of two seats (Borda {meta['score']})") + picks = [next(p for p in st["pitches"] if p["slug"] == s) for s in slugs] + kids = spawn(n, picks, milestone_for(card["title"]), st["repos"]) + reasons = "\n".join(f"- `{s}`: {v.get('ranking')} ({v.get('reason', '')})" for s, v in meta["votes"].items()) + listing = "\n".join(f"- #{c['card']} `{c['slug']}` (Borda {meta['score'][c['slug']]})" for c in kids) + short = f" (only {len(kids)} reached the quorum)" if len(kids) < k else "" + say(f"๐Ÿ—ณ๏ธ The board ranks the field; the top {len(kids)}{short} start now:\n\n{listing}\n\n{reasons}", + {"beat": "spawned", "kids": kids, "retried": [], "score": meta["score"]}) + elif stage in ("spawned", "launched"): + retried = list(st.get("retried", [])) + states = [child_state(c, retried) for c in st["kids"]] + if leave_none_behind(n, states, retried): + say(f"๐Ÿ” No child left behind: retrying {', '.join(f'#{r}' for r in retried if r not in st.get('retried', []))} " + f"for the batch.", {**{x: st[x] for x in ("kids", "score") if x in st}, "beat": stage, "retried": retried}) + return "ok" + if stage == "spawned": + ready = [c["ready_at"] for c in states if c["ready_at"]] + waited = ready and age_days(min(ready)) * 24 >= batch_hold_hours() + if all(c["settled"] for c in states) or waited: + live = [c for c in states if c["stage"] in ("ready", "launched", "done") and not c.get("dead")] + rest = [c for c in states if not c["settled"]] + dead = [c for c in states if c.get("dead")] + text = f"๐Ÿš€ Batch release: {len(live)} venture(s) launch together: " + ", ".join(f"#{c['card']}" for c in live) + if rest: + text += f"\n\nThe hold ran out ({batch_hold_hours():g}h); still in flight, they launch alone when they pass: " \ + + ", ".join(f"#{c['card']} (`{c['stage']}`)" for c in rest) + if dead: + text += "\n\nDead: " + "; ".join(f"#{c['card']} {c['dead']}" for c in dead) + say(text, {"beat": "launched", "kids": st["kids"], "retried": retried}) + return "ok" + return "wait" + elif stage == "launched": + if not all(c.get("dead") or c["stage"] == "done" for c in states): + return "wait" + retro(n, states, say) + tm.gh("PATCH", f"/repos/{REPO}/issues/{n}", {"state": "closed"}) + if card.get("milestone"): + tm.gh("PATCH", f"/repos/{REPO}/milestones/{card['milestone']['number']}", {"state": "closed"}) + else: + for m in tm.gh("GET", f"/repos/{REPO}/milestones?state=open&per_page=100"): + if m["title"] == card["title"]: + tm.gh("PATCH", f"/repos/{REPO}/milestones/{m['number']}", {"state": "closed"}) + return "ok" + except Exception as e: # noqa: BLE001 + if transient(e): + return "error" + errs = int(st.get("errors", 0)) + 1 + if errs >= 3: + say(f"๐Ÿงญ Batch stuck at `{stage}` after 3 tries: {str(e)[:300]}. Closing.", {"beat": "done", "stuck": stage}) + tm.gh("PATCH", f"/repos/{REPO}/issues/{n}", {"state": "closed"}) + else: + say(f"โš ๏ธ batch {stage} failed ({errs}/3): `{str(e)[:300]}`. Retrying next tick.", {**st, "errors": errs}) + return "error" + + # ---------- the tick ---------- def tick() -> int: @@ -1446,11 +1702,31 @@ def tick() -> int: refresh_backends(reg) except Exception as e: # noqa: BLE001 chores never block the cards print("chores failed:", e) - fresh = [] + fresh, yes = [], ("1", "true", "yes") if env("TIME_VENTURE", "off") == "on": - titles = {c["title"] for c in venture_cards("all")} - if f"Venture {today()}" not in titles or env("TIME_VENTURE_START", "").lower() in ("1", "true", "yes"): - fresh.append(new_card(next_title(titles))) # once a day on the schedule; any time someone asks for one + asked_batch = env("TIME_VENTURE_BATCH_START", "").lower() in yes + if batch_size() or asked_batch: # batches replace the daily single card + titles = {c["title"] for c in batch_cards("all")} + if f"Batch {today()}" not in titles or asked_batch: + t, j = f"Batch {today()}", 2 + while t in titles: + t, j = f"Batch {today()} ({j})", j + 1 + fresh.append(new_batch(t, batch_size() or 3)) + else: + titles = {c["title"] for c in venture_cards("all")} + if f"Venture {today()}" not in titles or env("TIME_VENTURE_START", "").lower() in yes: + fresh.append(new_card(next_title(titles))) # once a day on the schedule; any time someone asks for one + if env("TIME_VENTURE_START", "").lower() in yes and (batch_size() or asked_batch): + fresh.append(new_card(next_title({c["title"] for c in venture_cards("all")}))) + # batches first: they are cheap between their own stages, and a release lets ready children launch in this tick + bfresh = [c for c in fresh if "venture-batch" in c["labels"]] + fresh = [c for c in fresh if "venture-batch" not in c["labels"]] + blisted = batch_cards() + for b in blisted + [c for c in bfresh if c["number"] not in {x["number"] for x in blisted}]: + for _ in range(len(BATCH_STAGES)): + cm = comments_of(b["number"]) + if advance_batch(b, cm, tm.beats_of(cm)) != "ok" or stage_of(tm.beats_of(comments_of(b["number"]))) in ("spawned", "done"): + break heavy_left, skip = 1, set() while time.monotonic() - t0 < budget: due = [] @@ -1466,7 +1742,7 @@ def tick() -> int: due.append((c, cm, b)) if not due: break - due.sort(key=lambda x: -rank(stage_of(x[2]))) # the card furthest along first + due.sort(key=lambda x: -priority(x[2])) # the card furthest along first; fix rounds yield to first builds c, cm, b = due[0] out = advance(c, cm, b, heavy_ok=heavy_left > 0 and time.monotonic() - t0 < heavy_until) if out == "heavy": diff --git a/.github/workflows/venture.yml b/.github/workflows/venture.yml index a59f176..24f0791 100644 --- a/.github/workflows/venture.yml +++ b/.github/workflows/venture.yml @@ -2,6 +2,7 @@ name: venture # The venture loop: trending repos -> pitches -> board -> launch plan -> launch page on Pages -> 7 days of signups. # Every 20 minutes it advances whatever is due (cheap when nothing is); a new venture card starts once a day, # or right away: Actions -> venture -> Run workflow -> tick "Start a new venture now" (gh workflow run venture.yml -f start=true). +# Batches: TIME_VENTURE_BATCH=3 (or Run workflow -> batch) runs k ventures at once in one milestone. # Off until TIME_VENTURE=on. Signups come in through .github/ISSUE_TEMPLATE/early-access.yml. on: schedule: @@ -12,6 +13,10 @@ on: description: "Start a new venture now, even if today's has already run" type: boolean default: false + batch: + description: "Start a venture batch now: one scout, the board's top k, launched together (k = TIME_VENTURE_BATCH, or 3)" + type: boolean + default: false permissions: contents: write issues: write @@ -39,6 +44,9 @@ jobs: TIME_VENTURE_AI_MODEL: ${{ vars.TIME_VENTURE_AI_MODEL }} TIME_VENTURE: ${{ vars.TIME_VENTURE }} TIME_VENTURE_START: ${{ inputs.start && '1' || '' }} + TIME_VENTURE_BATCH_START: ${{ inputs.batch && '1' || '' }} + TIME_VENTURE_BATCH: ${{ vars.TIME_VENTURE_BATCH }} # k; set, a batch replaces the daily single card + TIME_VENTURE_BATCH_HOLD_HOURS: ${{ vars.TIME_VENTURE_BATCH_HOLD_HOURS }} # launch-together hold (default 6) TIME_VENTURE_PITCH: ${{ vars.TIME_VENTURE_PITCH }} TIME_VENTURE_BOARD: ${{ vars.TIME_VENTURE_BOARD }} TIME_VENTURE_CHAIR: ${{ vars.TIME_VENTURE_CHAIR }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 180398f..e5a5e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - The Venice worker's shell no longer sees secrets: its environment drops tokens and keys, its checkout keeps no token in `.git/config`, and the workflows check out without persisted credentials. ### Added +- Venture batches (multi-ceremony): one batch card scouts once, the seats pitch a field (rounds until there are 2k pitches), and the board ranks the whole field; the top k, each wanted by two seats, become venture cards in the batch's own milestone. They run the ordinary loop side by side and launch together: a child that passes its checks waits at `ready` until every sibling is settled or the hold (`TIME_VENTURE_BATCH_HOLD_HOURS`, 6) runs out; stragglers then launch alone. No child left behind: a held child gets one batch retry, and held again it is counted dead with its reason. A retro on the batch card compares the cohort's signups against each plan's lines, then the card and milestone close. Set `TIME_VENTURE_BATCH=3` to make batches the daily default, or Actions โ†’ venture โ†’ Run workflow โ†’ "batch". +- Fix rounds yield the one worker run per tick to cards still due their first build, so a page that keeps failing can't starve the others. - Accounts for ventures, with passkeys: each venture's backend can sign people up and in (Face ID, Touch ID, Windows Hello, a phone), keep a 30-day session, and list what each person saved. No passwords, no email, nothing to configure; accounts live in the venture's own database on its own domain. `time.js` gains `Time.signUp`, `Time.signIn`, `Time.signOut`, `Time.user` and `Time.mine`, and builds are told to use them for the paid tier's promise without ever putting sign-in in front of the free product. - Venture backends use Gemma 4 (`@cf/google/gemma-4-26b-a4b-it`) by default and read both the older `{response}` and the OpenAI-style answer from Workers AI (the first default had been retired). - Ventures can be backed by Cloudflare: with `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, each venture gets its own Worker, D1 database and Workers AI, and lives at `.` (or workers.dev) while its page stays on GitHub Pages. The page's `time.js` gives share links and a small model, and degrades to null so products still work without it. Existing ventures are backfilled a couple per tick; a new worker redeploys every venture; the doctor checks the token. diff --git a/README.md b/README.md index 477d294..3cd106e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A GitHub-native agent factory. GitHub is the ledger, GitHub-hosted runners are t - **Mentions** (`mention.yml`): `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply (Venice). - **Hands-off** (`TIME_AUTOMERGE=on`): label an issue `spec` and Time splits it into cards that run one at a time. A card's PR merges itself once the reviewer approves and checks are green, then the next card starts. Objections, red checks and protected paths (workflows, deploy, secrets) go to a human. - **Venture loop** (`venture.yml`, every 20 min once `TIME_VENTURE=on`): one venture card a day, or one right away from Actions โ†’ venture โ†’ Run workflow โ†’ "Start a new venture now". It scouts the repos that gained the most stars this week; seats pitch; the board votes (Borda, quorum of two); every board seat writes a launch plan (positioning, first users, pricing, channels, 7-day success and kill lines) and one seat synthesizes them into `LAUNCH.md`; the worker builds the product as the page on `gh-pages` (the tool fills the first screen under a slim header with the one signup button; pricing, limits and the why sit below); deterministic checks and headless Chrome (phone and desktop) gate it, with two fix rounds before an andon; GitHub Pages ships it at `/ventures//`. Signups go through an issue form (`.github/ISSUE_TEMPLATE/early-access.yml`), each one thanked on its issue; after 7 days the count of distinct people decides: build v2, kill, or watch a week more. The studio index lives at `/ventures/`. Launch posts are drafts for a human; nothing is posted outside the repo. +- **Batches** (`TIME_VENTURE_BATCH=3`, or Run workflow โ†’ "batch"): one scout pass, a field of pitches, and the board's top k become venture cards in one milestone, built side by side and launched together. A held child gets one batch retry before it's counted dead; a retro compares the cohort once every child is measured. - **Backends** (optional, `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`): each venture also gets its own Cloudflare Worker, D1 database and Workers AI, serving its Pages folder at `.`. The page talks to it through `time.js` (share links that save a creation, a rate-limited small model, and accounts with passkeys: no passwords, no email); every call degrades gracefully, so products still work on Pages alone. Code: `.github/time/cloudflare.py` and `cloudflare/worker.js`. - **Doctor** (`doctor.yml`, manual): runs the tests and checks label, settings and keys. - **Update** (`update.yml`, weekly or manual): opens a PR with the latest engine from teahubxyz/time, so every project stays on one Time instead of drifting copies.