Router: money left is the worker pool's allowance; no repeat cards (#181)
Router: money left is the worker pool's allowance; no repeat cards (#181) money_left(): the allowance left in the pool that takes TIME_WORKER's route, else the daily cap's remainder, capped by the month's. The seat sees cards closed in the last 14 days; a new card repeating an open or lately closed one is not written. Closes #175. 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 #181.
This commit is contained in:
1 parent
2efe67c9a9
commit
805e20d695
3 files changed
+105
-8
No files matched your search
+62
-7
@@ -15,8 +15,10 @@ one issue, "Astra log", so a human can read the day's reasoning in one place and
|
||||
The router (time/router.json, `mode`: off | dry | live; router.py): instead of picking cards in prose, the seat judges
|
||||
every candidate (its kind, its value 1-5, its size) and code ranks them with the owner's weights per kind, a restless
|
||||
boost for kinds not picked lately, and the card's age. The log shows the ranked table. `dry` writes it and queues
|
||||
nothing; `live` queues from the top while the day's money left covers each card's estimated cost, creates the seat's
|
||||
new cards unqueued (they are candidates the next day), and the heartbeat promotes queued cards in the ranked order.
|
||||
nothing; `live` queues from the top while the day's money left covers each card's estimated cost (the allowance left
|
||||
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.
|
||||
`off`, or no router.json, is the planner as before.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -75,7 +77,8 @@ For every candidate give:
|
||||
- size: S (well under one worker session), M (one session), L (more than one: it must be split first)
|
||||
- why: one short sentence
|
||||
You may also write up to {max_new} new cards for work the board is missing; they are candidates from tomorrow on.
|
||||
Never write one that posts outside the repo or touches workflows, secrets or variables.
|
||||
Never write one that posts outside the repo or touches workflows, secrets or variables, and never one for work that is
|
||||
already a candidate or is in the list of cards closed lately: that work is done or under way.
|
||||
|
||||
Answer with JSON only, no prose around it:
|
||||
{{"note": "<3-6 sentences: what happened yesterday and what matters today>",
|
||||
@@ -173,6 +176,45 @@ def parse(raw: str) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def money_left() -> tuple[float, str]:
|
||||
"""What the router may queue against today, and where it comes from (#175): the allowance left in the pool that takes
|
||||
the worker's route (as budget_check sees it), or the daily cap's remainder without one; either way no more than
|
||||
what's left of the monthly cap."""
|
||||
today, day_cap, month, month_cap = tm.budget()
|
||||
spec = (os.environ.get("TIME_WORKER") or "cursor").strip()
|
||||
p = tm.pool_for(tm.provider_of(tm.worker_seat_model(spec)[1])) if tm.seat_worker(spec) else None
|
||||
left, source = (tm.allowance(p) - tm.pool_spent(p), f"pool {p['name']}") if p else (day_cap - today, "daily cap")
|
||||
return max(0.0, min(left, month_cap - month)), source
|
||||
|
||||
|
||||
def closed_lately(days: int = 14) -> list[dict]:
|
||||
"""Cards closed (done, or dropped) in the last `days` days, newest first: the seat sees them so it doesn't write a
|
||||
card for work already finished (it proposed the housekeeping label fix two days running after #137 had landed)."""
|
||||
since = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
try:
|
||||
return [i for i in tm.issues(f"state=closed&since={since}") if i.get("title") != LOG_TITLE]
|
||||
except Exception as e: # noqa: BLE001 context, not a dependency
|
||||
print(f"closed cards unreadable: {str(e)[:120]}")
|
||||
return []
|
||||
|
||||
|
||||
def _norm(title: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", " ", (title or "").lower()).strip()
|
||||
|
||||
|
||||
def repeat_of(title: str, known: list[dict]) -> int | None:
|
||||
"""The open or lately closed card a proposed title repeats, if any: the same words, or the whole of one title (four
|
||||
words or more) inside the other. Not a similarity score: sibling cards share most of their words ("Tool: havre_check",
|
||||
"Tool: havre_new") and are not repeats. Paraphrases are the seat's to avoid, with the closed list in front of it."""
|
||||
t = _norm(title)
|
||||
for i in known:
|
||||
k = _norm(i.get("title", ""))
|
||||
short, long_ = sorted((t, k), key=len)
|
||||
if t and k and (t == k or (len(short.split()) >= 4 and short in long_)):
|
||||
return i.get("number")
|
||||
return None
|
||||
|
||||
|
||||
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]
|
||||
@@ -180,8 +222,13 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
|
||||
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()
|
||||
left, source = money_left()
|
||||
closed = closed_lately()
|
||||
done = "\n".join(f"- #{i['number']} {i['title']}" for i in closed[:50]) or "- (none)"
|
||||
user = (f"Factory context (the spine's AGENTS.md and the newest decisions):\n{context}\n\nSpend: ${today:.2f} of "
|
||||
f"${day_cap:.2f} today, ${month:.2f} of ${month_cap:.2f} this month.\n\nCandidates ({len(cands)}):\n\n{listing}")
|
||||
f"${day_cap:.2f} today, ${month:.2f} of ${month_cap:.2f} this month; ${left:.2f} left to queue against ({source})."
|
||||
f"\n\nCards closed in the last 14 days (done or dropped; don't write these again):\n{done}"
|
||||
f"\n\nCandidates ({len(cands)}):\n\n{listing}")
|
||||
try:
|
||||
out = parse(tm.think(seat, None, None, ROUTER.format(max_new=max_new), user))
|
||||
except tm.Budget as e:
|
||||
@@ -189,8 +236,12 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
|
||||
return 0
|
||||
assess = {str(a.get("key") or "").strip(): a for a in out.get("assess") or [] if isinstance(a, dict)}
|
||||
ranked = router.rank(cands, assess, cfg, router.last_picked(tm.beats_of(thread)), dt.datetime.now(dt.timezone.utc).date())
|
||||
picks = router.pick(ranked, cfg, max(0.0, day_cap - today), max_ready)
|
||||
new = [c for c in out.get("new") or [] if isinstance(c, dict) and c.get("title")][:max_new]
|
||||
picks = router.pick(ranked, cfg, left, max_ready)
|
||||
new, repeats = [], []
|
||||
for c in [c for c in out.get("new") or [] if isinstance(c, dict) and c.get("title")]:
|
||||
n = repeat_of(str(c["title"]), open_issues + closed)
|
||||
(repeats.append((c, n)) if n else new.append(c))
|
||||
new = new[:max_new]
|
||||
live, created = cfg["mode"] == "live", []
|
||||
if live:
|
||||
for r in picks:
|
||||
@@ -202,7 +253,8 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
|
||||
created.append(tm.new_issue(str(c["title"])[:200], body + "\n\n<sub>card written by Astra</sub>", [])["number"])
|
||||
unjudged = sum(1 for r in ranked if not r["judged"])
|
||||
w = cfg["weights"]
|
||||
head = (f"🔭 Astra ({seat}), router `{cfg['mode']}`, spend ${today:.2f} of ${day_cap:.2f} today. Weights: "
|
||||
head = (f"🔭 Astra ({seat}), router `{cfg['mode']}`, spend ${today:.2f} of ${day_cap:.2f} today, ${left:.2f} left to "
|
||||
f"queue against ({source}). Weights: "
|
||||
+ ", ".join(f"{k} {w[k]:g}" for k in router.KINDS) + f"; restless {cfg['restless']:g}/day.")
|
||||
lines = [head, "", str(out.get("note") or "(no note)"), "", router.table(ranked, picks, cfg["mode"]), ""]
|
||||
if unjudged:
|
||||
@@ -216,6 +268,9 @@ def router_tick(cfg: dict, seat: str, log: int, context: str, thread: list[dict]
|
||||
+ (", ".join(f"#{r['n']}" 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),
|
||||
"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}
|
||||
|
||||
@@ -1190,7 +1190,8 @@ assert "**queued today**" in rt.table(r80, rt.pick(r80, live80, 10.0, 2), "live"
|
||||
aspec = importlib.util.spec_from_file_location("astra80", Path(__file__).resolve().parent / "astra.py")
|
||||
astra = importlib.util.module_from_spec(aspec); aspec.loader.exec_module(astra)
|
||||
said80, labelled80, made80, asked80 = [], [], [], []
|
||||
astra.tm.issues = lambda q: open80 + [iss80(57, "Astra log", labels=["astra"])]
|
||||
closed80 = []
|
||||
astra.tm.issues = lambda q: closed80 if q.startswith("state=closed") else open80 + [iss80(57, "Astra log", labels=["astra"])]
|
||||
astra.tm.budget = lambda: (1.0, 15.0, 10.0, 100.0)
|
||||
astra.tm.comment = lambda n, text, meta: said80.append((n, text, meta))
|
||||
astra.tm.label = lambda n, name: labelled80.append((n, name))
|
||||
@@ -1212,6 +1213,46 @@ meta80 = said80[-1][2]
|
||||
assert meta80["picked"] == [{"n": 1, "kind": "feature"}, {"n": 5, "kind": "optimization"}] and meta80["new"] == [91] and meta80["queued"] == 2
|
||||
astra.tm.think = lambda *a: (_ for _ in ()).throw(astra.tm.Budget("today's spend is over the cap"))
|
||||
assert astra.router_tick(rt.config('{"mode": "live"}'), "opus", 57, "ctx", [], 2, 2) == 0 and said80[-1][2]["capped"]
|
||||
# money left (#175): the allowance left in the pool that takes the worker's route, else the daily cap's remainder;
|
||||
# never more than the month has left
|
||||
real175 = (astra.tm.pools, astra.tm.allowance, astra.tm.pool_spent, astra.tm.budget, os.environ.get("TIME_WORKER"))
|
||||
astra.tm.pools = lambda: [{"name": "anthropic-credits", "kind": "expiring", "routes": ["anthropic"]}]
|
||||
astra.tm.allowance = lambda p, now=None: 3.0
|
||||
astra.tm.pool_spent = lambda p, now=None: 1.0
|
||||
os.environ["TIME_WORKER"] = "claude:sonnet"
|
||||
assert astra.money_left() == (2.0, "pool anthropic-credits"), astra.money_left()
|
||||
astra.tm.budget = lambda: (1.0, 15.0, 99.5, 100.0)
|
||||
assert astra.money_left() == (0.5, "pool anthropic-credits"), "the month's remainder caps it"
|
||||
astra.tm.budget = real175[3]
|
||||
os.environ["TIME_WORKER"] = "venice:kimi"
|
||||
assert astra.money_left() == (14.0, "daily cap"), "a route with no pool: the daily cap's remainder"
|
||||
os.environ.pop("TIME_WORKER")
|
||||
assert astra.money_left() == (14.0, "daily cap"), "the Cursor worker has no seat route"
|
||||
# live with $0.50 left in the pool: the M doesn't fit, the S behind it does; the header and the seat both see the figure
|
||||
os.environ["TIME_WORKER"] = "claude:sonnet"
|
||||
astra.tm.pool_spent = lambda p, now=None: 2.5
|
||||
astra.tm.think = lambda seat, a, b, system, user: (asked80.append((seat, system, user)), "Sure:\n" + json.dumps(reply80))[1]
|
||||
labelled80.clear(); made80.clear()
|
||||
assert astra.router_tick(rt.config('{"mode": "live"}'), "opus", 57, "ctx", [], 2, 2) == 0
|
||||
assert labelled80 == [(5, "queued")], labelled80
|
||||
assert "$0.50 left to queue against (pool anthropic-credits)" in said80[-1][1] and "$0.50 left to queue against" in asked80[-1][2]
|
||||
# the seat sees the cards closed lately, and a new card repeating an open or lately closed one is not written
|
||||
closed80[:] = [iss80(137, "A label filter is checked on every row (housekeeping listed all 59 open issues as parked)")]
|
||||
reply80["new"] = [{"title": "A label filter is checked on every row", "body": "again"}, {"title": "Fix the docs!", "body": "x"},
|
||||
{"title": "Write a thing", "body": "do it"}]
|
||||
labelled80.clear(); made80.clear()
|
||||
assert astra.router_tick(rt.config('{"mode": "live"}'), "opus", 57, "ctx", [], 2, 2) == 0
|
||||
assert [m[0] for m in made80] == ["Write a thing"], made80
|
||||
assert "#137 A label filter is checked on every row" in asked80[-1][2] and "closed lately" in asked80[-1][1]
|
||||
assert "Not written, repeats of existing cards: A label filter is checked on every row (#137); Fix the docs! (#2)." in said80[-1][1], said80[-1][1]
|
||||
assert astra.repeat_of("Write a thing", open80 + closed80) is None and astra.repeat_of("", open80) is None
|
||||
assert astra.repeat_of("Tool: havre_new", [iss80(100, "Tool: havre_check")]) is None, "siblings are not repeats"
|
||||
assert astra.repeat_of("Anthem: SKILL.md, /llms.txt and a skills index", [iss80(112, "Glitztype: SKILL.md, /llms.txt and a skills index")]) is None
|
||||
astra.tm.issues = lambda q: (_ for _ in ()).throw(RuntimeError("GET x -> 500"))
|
||||
assert astra.closed_lately() == [], "the closed list is context: unreadable, it is empty"
|
||||
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("") == {}
|
||||
# the heartbeat promotes queued cards in the live ranking's order, oldest first without one
|
||||
real80 = (tm.issues, tm.gh, tm.label, tm.unlabel)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
- 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.
|
||||
|
||||
### Changed
|
||||
- The router's money left is the worker's pool (#175): live mode queues against the allowance left in the pool that takes `TIME_WORKER`'s route (about $8/day from `anthropic-credits` while the credit lasts), or the daily cap's remainder without one, never more than the month has left; it was `TIME_DAILY_USD` minus today's spend, money the worker couldn't spend. The Astra line and the seat's prompt show the figure and its source. The seat also sees the cards closed in the last 14 days and is told not to write them again (it proposed the housekeeping label fix two days running after #137 had landed), and a new card whose title is an open or lately closed card's, or contains or is contained in one of four words or more, is not written; the log lists it under "Not written".
|
||||
- The lead runs the day's venture alone, and fable is out. One seat, `TIME_VENTURE_LEAD` (opus), does a research round (the top six trending repos with the start of their READMEs, the rest as a list, and every studio venture with its status and signups), lists `TIME_VENTURE_FIELD` candidates with a verdict each, and picks one; no pitches, no vote. A pick that repeats a studio venture gets one more go, told so. The lead writes the launch plan alone (no synthesis) and judges the build; it hands the launch-post drafts to its helper, `TIME_VENTURE_HELPER` (sonnet). The chair and the judge default to opus (they were fable, the dearest seat, on calls opus does as well). Batches keep the board, and `TIME_VENTURE_LEAD=board` brings it back for the daily venture.
|
||||
- One venture a day, built for quality. The day's card starts only once every other venture card is launched or held, so one venture at a time gets the day's worker runs and budget; the seats pitch a field of `TIME_VENTURE_FIELD` (6) in rounds, each told what is already pitched, and the board picks one from it (it was one pitch per seat); `claude:opus` builds (`BUILDER`, and `TIME_VENTURE_WORKER` on teahubxyz/time); and the judge scores the product 1–10 as a stranger would. A page under the bar (`TIME_VENTURE_BAR`, 8) gets a polish round with the judge's top three changes; fix and polish rounds share `TIME_VENTURE_FIXES` (now 3); polish never takes the last round, which stays free to fix whatever a polish broke; then a page under the bar launches with its score in the launch post, so quality never holds a launch for a human, only a broken page does. An open batch card runs only while batches are on (`TIME_VENTURE_BATCH`) or once it has Time's beats: batch #26, migrated from GitHub with none, had started a three-venture batch on a single-venture day.
|
||||
- The venture loop runs on Teahub with Claude seats. Fable chairs (synthesizes `LAUNCH.md`, writes the launch drafts, judges the build); haiku, sonnet and opus pitch, vote and write plans; `claude:sonnet` builds and `claude:opus` takes the second fix round. `TIME_WORKER` is no longer coerced to a Venice seat, which is how a Claude-configured factory kept spending Venice credits on venture builds; any `claude:` or `venice:` spec is taken as given. The port to Gitea: the scout asks GitHub's public search unauthenticated (the forge has no repository search, and no token of ours goes to GitHub); venture, batch and signal cards are listed through `tm.issues` (Gitea's `pull_request: null` had kept every card out) and created with label ids; reactions answered with null and Gitea's 50-row pages are handled; a `/time retry` counts from a trusted login (Gitea has no `author_association`); signups are the form's Venture field, or the prefilled `Early access: <name>` title on a body the form wrote when the field was left empty, never the owner, Time's users or a seat; there is no Pages API to call, and each venture is served at `https://<slug>.<TIME_VENTURE_PAGES_DOMAIN>/` (`yolopreneur.lol`). The domain's root is the Pages branch's `ventures/`, whose index is the hand-made yolopreneur.lol portfolio reading `ventures.json`: the loop writes `ventures.json` (now with `shot`, `sheet` and `source` at launch, which the portfolio shows) and writes its own list page only where the page is its own, instead of replacing the site's front page on its first save. Links point at the forge (`/src/branch/`, `/raw/commit/`). Astra never queues the loop's own issues.
|
||||
|
||||
Reference in new issue
Block a user