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.