Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc36cf193b | ||
|
|
a20873cadb | ||
|
|
6226620d69 | ||
|
|
b55c25ab40 | ||
|
|
4ca917d6e5 |
No files matched your search
+52
-8
@@ -19,6 +19,9 @@ nothing; `live` queues from the top while the day's money left covers each card'
|
||||
in the pool that takes the worker's route, else the daily cap's remainder, never more than the month has left),
|
||||
creates the seat's new cards unqueued (they are candidates the next day; never a repeat of an open or lately closed
|
||||
card), and the heartbeat promotes queued cards in the ranked order.
|
||||
Ventures (TIME_VENTURE_MODE=router, the loop's TIME_VENTURE): "start a new venture" is actionable, and every live venture
|
||||
with signups is a "v2 of <venture>" candidate. A picked new venture dispatches venture.yml with start=true (never queued
|
||||
as a card); a picked v2 posts the venture's open v2 card, else writes one (Base: gh-pages) and queues it.
|
||||
`off`, or no router.json, is the planner as before.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -218,10 +221,46 @@ def repeat_of(title: str, known: list[dict]) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def venture_registry() -> dict:
|
||||
"""ventures/ventures.json on the Pages branch; unreadable, there are no ventures to iterate on."""
|
||||
try:
|
||||
f = tm.gh("GET", f"/repos/{tm.REPO}/contents/ventures/ventures.json?ref=gh-pages")
|
||||
return json.loads(base64.b64decode(f.get("content") or "").decode(errors="replace"))
|
||||
except Exception as e: # noqa: BLE001 context, not a dependency
|
||||
print(f"venture registry unreadable: {str(e)[:120]}")
|
||||
return {}
|
||||
|
||||
|
||||
def start_venture(r: dict, open_issues: list[dict]) -> str:
|
||||
"""A picked venture candidate. A new venture: venture.yml is dispatched with start=true (the loop in router mode then
|
||||
starts one card; it is never queued as a card here). A v2: the venture's existing v2 card, else a new one on gh-pages
|
||||
(no dispatch: that would start a different, new venture). Returns what happened, for the log."""
|
||||
if r["key"] == router.NEW_VENTURE["key"]:
|
||||
try:
|
||||
tm.gh("POST", f"/repos/{tm.REPO}/actions/workflows/venture.yml/dispatches",
|
||||
{"ref": tm.default_branch(), "inputs": {"start": "true"}})
|
||||
except RuntimeError as e:
|
||||
return f"new venture: dispatching venture.yml failed ({str(e)[:100]})"
|
||||
return "new venture: dispatched venture.yml with start=true"
|
||||
v = f"v{r['next_v']}"
|
||||
for i in open_issues:
|
||||
t = (i.get("title") or "").lower()
|
||||
if re.search(rf"\b{v}\b", t) and (r["name"].lower() in t or r["slug"] in t):
|
||||
return f"{r['name']} {v}: already on #{i['number']}, posted that card"
|
||||
n = tm.new_issue(f"{r['name']} {v}: build the next version from what signups asked for",
|
||||
f"Build {v} of **{r['name']}** in ventures/{r['slug']}/ from what its signups asked for (its signal issue "
|
||||
f"and early-access issues). {r['body']}\n\nBase: gh-pages\n\nRead the folder first and keep what works.\n\n"
|
||||
f"DONE: PR open against gh-pages with {v} of ventures/{r['slug']}/, checks green.\n\n"
|
||||
"<sub>card written by Astra</sub>", [])["number"]
|
||||
tm.label(n, "queued")
|
||||
return f"{r['name']} {v}: no card yet, wrote and queued #{n}"
|
||||
|
||||
|
||||
def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict], max_ready: int, max_new: int) -> int:
|
||||
"""The seat judges every candidate; router.py ranks them; live queues from the top, dry only shows the ranking."""
|
||||
open_issues = [i for i in tm.issues("state=open") if i.get("title") != LOG_TITLE]
|
||||
cands = router.candidates(open_issues) + [dict(router.NEW_VENTURE)]
|
||||
mode = (os.environ.get("TIME_VENTURE_MODE") or "").strip().lower() # the loop's TIME_VENTURE, passed on by astra.yml
|
||||
cands = router.candidates(open_issues) + [router.new_venture(mode)] + router.v2_candidates(venture_registry())
|
||||
listing = "\n\n".join(f"### {c['key']}: {c['title']}\nlabels: {', '.join(c['labels']) or '-'}; opened "
|
||||
f"{c['created'][:10] or '-'}; cards waiting on it: {c['unblocks']}\n{c['body'][:400]}" for c in cands)
|
||||
today, day_cap, month, month_cap = tm.budget()
|
||||
@@ -246,10 +285,13 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
|
||||
n = repeat_of(str(c["title"]), open_issues + closed)
|
||||
(repeats.append((c, n)) if n else new.append(c))
|
||||
new = new[:max_new]
|
||||
live, created = cfg["mode"] == "live", []
|
||||
live, created, started = cfg["mode"] == "live", [], []
|
||||
if live:
|
||||
for r in picks:
|
||||
tm.label(r["n"], "queued") # the heartbeat promotes it, in ranked order
|
||||
if r["n"] is None:
|
||||
started.append(start_venture(r, open_issues)) # a venture: dispatched or written, never queued as itself
|
||||
else:
|
||||
tm.label(r["n"], "queued") # the heartbeat promotes it, in ranked order
|
||||
for c in new:
|
||||
body = str(c.get("body") or "")
|
||||
if not router.DONE.search(body):
|
||||
@@ -264,22 +306,24 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
|
||||
if unjudged:
|
||||
lines.append(f"_{unjudged} candidate(s) the seat didn't judge are scored at value 3, size M._")
|
||||
if live:
|
||||
lines.append(("Queued: " + ", ".join(f"#{r['n']}" for r in picks)) if picks else "Queued nothing today.")
|
||||
queued = [f"#{r['n']}" for r in picks if r["n"] is not None]
|
||||
lines.append(("Queued: " + ", ".join(queued)) if queued else "Queued nothing today.")
|
||||
lines += [f"Venture: {x}" for x in started]
|
||||
if created:
|
||||
lines.append("New cards (candidates from tomorrow): " + ", ".join(f"#{n}" for n in created))
|
||||
else:
|
||||
lines.append("Dry run: nothing queued or created. Live, it would queue: "
|
||||
+ (", ".join(f"#{r['n']}" for r in picks) or "nothing") + ".")
|
||||
+ (", ".join(f"#{r['n']}" if r["n"] is not None else r["title"] for r in picks) or "nothing") + ".")
|
||||
if new:
|
||||
lines.append("It would write: " + "; ".join(router._txt(c["title"], 100) for c in new) + ".")
|
||||
if repeats:
|
||||
lines.append("Not written, repeats of existing cards: "
|
||||
+ "; ".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) - len(started) if live else 0, "started": started, "order": router.order(ranked),
|
||||
"picked": [{"n": r["n"], "kind": r["kind"]} for r in picks] if live else [],
|
||||
"would": [r["n"] for r in picks] if not live else [], "new": created}
|
||||
"would": [r["n"] for r in picks if r["n"] is not None] if not live else [], "new": created}
|
||||
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)}" + "".join(f" | {x}" for x in started))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+24
-1
@@ -38,7 +38,30 @@ DONE = re.compile(r"^\s*DONE:", re.M | re.I)
|
||||
NEW_VENTURE = {"key": "venture:new", "n": None, "title": "Start a new venture: research, pick, build and launch one page",
|
||||
"body": "The venture loop (venture.py): one venture, researched and picked by the lead, built and judged.",
|
||||
"labels": [], "lined_up": False, "actionable": False, "kind": "venture", "unblocks": 0, "created": "",
|
||||
"why_not": "not wired to the router yet; the venture loop is paused (TIME_VENTURE=off)"}
|
||||
"why_not": "the venture loop isn't in router mode (TIME_VENTURE=router, and Astra needs TIME_VENTURE_MODE=router)"}
|
||||
|
||||
|
||||
def new_venture(mode: str) -> dict:
|
||||
""""Start a new venture": actionable only when Astra is told the loop is in router mode (the loop then starts a card
|
||||
only when asked). Its size and cost come from the seat like any card's."""
|
||||
return {**NEW_VENTURE, "actionable": True, "why_not": ""} if mode == "router" else dict(NEW_VENTURE)
|
||||
|
||||
|
||||
def v2_candidates(reg: dict) -> list[dict]:
|
||||
"""A "v2 of <venture>" for every live venture with signups: the studio's next version of what people asked for."""
|
||||
out = []
|
||||
for v in reg.get("ventures") or []:
|
||||
try:
|
||||
signups = int(v.get("signups") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if v.get("status") != "live" or signups <= 0 or not v.get("slug"):
|
||||
continue
|
||||
name = _txt(v.get("name") or v["slug"], 60)
|
||||
out.append({**NEW_VENTURE, "key": f"venture:v2:{v['slug']}", "title": f"v2 of {name} ({signups} signups)",
|
||||
"body": _txt(f"{v.get('one_liner', '')} Next: {v.get('next', '')}", 400), "actionable": True, "why_not": "",
|
||||
"slug": v["slug"], "name": name, "next_v": int(v.get("version") or 1) + 1})
|
||||
return out
|
||||
|
||||
|
||||
def _num(v, default: float, lo: float = 0.0, hi: float = 100.0) -> float:
|
||||
|
||||
@@ -1193,7 +1193,7 @@ beats80 = [{"beat": "astra", "router": "live", "at": "2026-10-17T13:13:00Z", "pi
|
||||
{"beat": "astra", "router": "live", "at": "2026-10-18T13:13:00Z", "picked": [{"n": 5, "kind": "feature"}, "junk"]}]
|
||||
assert rt.last_picked(beats80) == {"feature": "2026-10-18"}, "live picks only"
|
||||
t80 = rt.table(r80, rt.pick(r80, live80, 10.0, 2), "dry")
|
||||
assert "| 1 | #2 Fix the docs |" in t80 and "lined up" in t80 and "**would queue**" in t80 and "split" in t80 and "not wired" in t80
|
||||
assert "| 1 | #2 Fix the docs |" in t80 and "lined up" in t80 and "**would queue**" in t80 and "split" in t80 and "router mode" in t80
|
||||
assert "**queued today**" in rt.table(r80, rt.pick(r80, live80, 10.0, 2), "live")
|
||||
# Astra's router tick: dry shows the ranking and touches nothing; live queues the picks and writes the new cards unqueued
|
||||
aspec = importlib.util.spec_from_file_location("astra80", Path(__file__).resolve().parent / "astra.py")
|
||||
@@ -1264,6 +1264,72 @@ astra.tm.pools, astra.tm.allowance, astra.tm.pool_spent = real175[:3]
|
||||
if real175[4] is None: os.environ.pop("TIME_WORKER", None)
|
||||
else: os.environ["TIME_WORKER"] = real175[4]
|
||||
assert astra.parse("x {bad} y {\"a\": 1}") == {"a": 1} and astra.parse("") == {}
|
||||
# 170 router mode: "start a new venture" is actionable only when Astra is told the loop is in router mode; v2 of a live
|
||||
# venture with signups is a candidate; a picked venture dispatches venture.yml once (new) or writes/posts a v2 card, never queued
|
||||
assert rt.new_venture("router")["actionable"] and not rt.new_venture("on")["actionable"] and "TIME_VENTURE_MODE" in rt.new_venture("")["why_not"]
|
||||
reg170 = {"ventures": [{"slug": "loot-drop", "name": "Loot Drop", "status": "live", "signups": 3, "one_liner": "x", "next": "y"},
|
||||
{"slug": "nobody", "name": "Nobody", "status": "live", "signups": 0}, {"slug": "dead", "status": "killed", "signups": 9},
|
||||
{"slug": "bad", "status": "live", "signups": "many"}]}
|
||||
v2s170 = rt.v2_candidates(reg170)
|
||||
assert [c["title"] for c in v2s170] == ["v2 of Loot Drop (3 signups)"] and v2s170[0]["kind"] == "venture" and v2s170[0]["actionable"], v2s170
|
||||
assert rt.v2_candidates({}) == []
|
||||
real170 = (astra.tm.gh, astra.tm.default_branch, astra.venture_registry, os.environ.get("TIME_VENTURE_MODE"))
|
||||
astra.tm.issues = lambda q: closed80 if q.startswith("state=closed") else open80 + [iss80(57, "Astra log", labels=["astra"])]
|
||||
calls170 = []
|
||||
astra.tm.gh = lambda method, path, body=None, **k: calls170.append((method, path, body)) or {}
|
||||
astra.tm.default_branch = lambda: "main"
|
||||
astra.venture_registry = lambda: reg170
|
||||
live170 = rt.config('{"mode": "live"}')
|
||||
def tick170(assess, mode="router"):
|
||||
for lst in (said80, labelled80, made80, calls170): lst.clear()
|
||||
if mode: os.environ["TIME_VENTURE_MODE"] = mode
|
||||
else: os.environ.pop("TIME_VENTURE_MODE", None)
|
||||
reply = {"note": "n", "assess": [dict(v, key=k) for k, v in assess.items()], "new": []}
|
||||
astra.tm.think = lambda seat, a, b, system, user: json.dumps(reply)
|
||||
assert astra.router_tick(live170, "opus", 57, "ctx", [], 1, 0) == 0
|
||||
new170 = {"venture:new": {"kind": "venture", "value": 5, "size": "S"}}
|
||||
tick170(new170)
|
||||
assert calls170 == [("POST", f"/repos/{astra.tm.REPO}/actions/workflows/venture.yml/dispatches",
|
||||
{"ref": "main", "inputs": {"start": "true"}})], calls170
|
||||
assert not made80 and not labelled80, "a new venture is never queued as a card"
|
||||
assert "Venture: new venture: dispatched venture.yml with start=true" in said80[-1][1] and said80[-1][2]["queued"] == 0, said80[-1]
|
||||
assert said80[-1][2]["picked"] == [{"n": None, "kind": "venture"}]
|
||||
tick170(new170, mode="on")
|
||||
assert not calls170 and not made80, "not in router mode: not actionable, nothing dispatched"
|
||||
assert "router mode" in said80[-1][1]
|
||||
v2a170 = {"venture:new": {"kind": "venture", "value": 1, "size": "S"}, "venture:v2:loot-drop": {"kind": "venture", "value": 5, "size": "S"}}
|
||||
tick170(v2a170)
|
||||
assert not calls170, "a v2 starts no new venture"
|
||||
assert [m[0] for m in made80] == ["Loot Drop v2: build the next version from what signups asked for"] and "Base: gh-pages" in made80[0][1]
|
||||
assert "DONE:" in made80[0][1] and labelled80 == [(91, "queued")] and "Loot Drop v2: no card yet, wrote and queued #91" in said80[-1][1]
|
||||
open80.append(iss80(77, "Venture 2026-10-19: Loot Drop v2", body="x", labels=["venture"]))
|
||||
tick170(v2a170)
|
||||
open80.pop()
|
||||
assert not calls170 and not made80 and "Loot Drop v2: already on #77" in said80[-1][1], said80[-1][1]
|
||||
astra.tm.gh, astra.tm.default_branch, astra.venture_registry = real170[:3]
|
||||
if real170[3] is None: os.environ.pop("TIME_VENTURE_MODE", None)
|
||||
else: os.environ["TIME_VENTURE_MODE"] = real170[3]
|
||||
# the loop: TIME_VENTURE=router makes no daily card and starts one only on TIME_VENTURE_START; off starts nothing
|
||||
vspec170 = importlib.util.spec_from_file_location("vt170", Path(__file__).resolve().parent / "venture.py")
|
||||
vt170 = importlib.util.module_from_spec(vspec170); vspec170.loader.exec_module(vt170)
|
||||
cards170 = []
|
||||
vt170.registry = lambda: {"ventures": []}
|
||||
vt170.ack_signups = vt170.refresh_daily = vt170.refresh_backends = lambda reg: None
|
||||
vt170.venture_cards = vt170.batch_cards = lambda state="open", n=30: []
|
||||
vt170.new_card = lambda title: cards170.append(title) or {"number": len(cards170), "labels": [{"name": "venture"}]}
|
||||
vt170.is_due = lambda *a: False
|
||||
vt170.comments_of = lambda n: []
|
||||
def loop170(mode, start=""):
|
||||
cards170.clear()
|
||||
for k, v in (("TIME_VENTURE", mode), ("TIME_VENTURE_START", start)):
|
||||
if v: os.environ[k] = v
|
||||
else: os.environ.pop(k, None)
|
||||
assert vt170.tick() == 0
|
||||
loop170("router"); assert cards170 == [], "router: no daily card"
|
||||
loop170("router", "1"); assert cards170 == [f"Venture {vt170.today()}"], cards170
|
||||
loop170("off", "1"); assert cards170 == [], "off: the start input does nothing"
|
||||
loop170("on"); assert cards170 == [f"Venture {vt170.today()}"], "on: the daily card, as before"
|
||||
os.environ.pop("TIME_VENTURE", None); os.environ.pop("TIME_VENTURE_START", None)
|
||||
# the heartbeat promotes queued cards in the live ranking's order, oldest first without one
|
||||
real80 = (tm.issues, tm.gh, tm.label, tm.unlabel)
|
||||
moved80 = []
|
||||
|
||||
@@ -39,6 +39,9 @@ Measure: after 7 days, distinct people who signed up or 👍'd the signal issue,
|
||||
lines: iterate (a v2 card), kill, or watch one more week and then park.
|
||||
Batch (TIME_VENTURE_BATCH=k, off by default): 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).
|
||||
TIME_VENTURE=router (a third mode beside on / off): no daily card on the schedule; a card starts only when asked
|
||||
(TIME_VENTURE_START, the dispatch input start=true), which is how Astra's router starts a venture. The tick still
|
||||
advances and measures the open venture cards, as under off.
|
||||
Nothing is posted outside the repo: launch posts are drafts for the chair.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -2025,6 +2028,9 @@ def tick() -> int:
|
||||
except Exception as e: # noqa: BLE001 chores never block the cards
|
||||
print("chores failed:", e)
|
||||
fresh, yes = [], ("1", "true", "yes")
|
||||
if env("TIME_VENTURE", "off") == "router" and env("TIME_VENTURE_START", "").lower() in yes:
|
||||
# router mode: no daily card; Astra's pick (venture.yml dispatched with start=true) starts one, the tick below advances it
|
||||
fresh.append(new_card(next_title({c["title"] for c in venture_cards("all")})))
|
||||
if env("TIME_VENTURE", "off") == "on":
|
||||
asked_batch = env("TIME_VENTURE_BATCH_START", "").lower() in yes
|
||||
if batch_size() or asked_batch: # batches replace the daily single card
|
||||
|
||||
Reference in new issue
Block a user