Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c37d6d78c | ||
|
|
e6bee7ccfe | ||
|
|
cbd9d7536b | ||
|
|
bed8743c94 | ||
|
|
a6f4542203 | ||
|
|
b61b185201 | ||
|
|
86b1207c4d | ||
|
|
bcdc71383e | ||
|
|
2bfaf6f672 | ||
|
|
b8bd19eaf8 | ||
|
|
7a471e39ca | ||
|
|
31ae884fe1 | ||
|
|
1747f07521 | ||
|
|
f4058061e0 |
No files matched your search
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: housekeeping
|
||||
description: The daily sweep of this repo: prunes merged branches, closes spent signal issues and empty milestones, and lists what is stale. Use when asked what housekeeping did, why a branch or issue was closed, how to run the sweep by hand, or what to do with the stale list.
|
||||
---
|
||||
|
||||
# Housekeeping
|
||||
|
||||
Deterministic: `.github/time/housekeeping.py` decides, never a model. Every step is idempotent, so a second run finds nothing to do.
|
||||
|
||||
## Trigger
|
||||
|
||||
- `housekeeping.yml`: daily, a jittered minute after the Astra tick (13:13 UTC), and `workflow_dispatch`. It runs
|
||||
`python3 .github/time/housekeeping.py` with the heartbeat's env, as the `time` user.
|
||||
- `TIME_MODE=live` makes it act. Anything else (the default) is a dry run: it prints `would delete ...` / `would close ...`
|
||||
in the run log and changes and comments nothing.
|
||||
- By hand: Actions → housekeeping → Run workflow, or locally with `GITHUB_REPOSITORY`, `GITHUB_TOKEN`, `TIME_TOKEN` set.
|
||||
|
||||
## What the sweep does
|
||||
|
||||
1. **Branches.** Deletes every branch whose PR merged into the default branch (`time/card-*`, `ceo/*`, `labs/*`, anything
|
||||
with a merged PR; merges made through the MCP keep their head branch, Time's own merges delete it). Never the default
|
||||
branch, `gh-pages`, a protected branch, a branch with an open PR, or a branch that has commits after its merge.
|
||||
2. **Signal issues.** Closes `signal` issues ("want the next version?") older than `TIME_VENTURE_MEASURE_DAYS` (default 7)
|
||||
whose venture has no signups: no 👍 from a person and no early-access issue for its slug. One closing line names the count.
|
||||
Issues with signups are left to the venture loop.
|
||||
3. **Milestones.** Closes milestones with no open issues and at least one closed one. An empty, never-used milestone stays.
|
||||
4. **Stale list.** No action, one comment on the Astra log (#57): `needs-human` untouched for over 3 days, open PRs idle
|
||||
7 days or more, cards parked after their automatic retries, `error` cards older than 2 days. An empty list posts no comment.
|
||||
|
||||
Each run ends with one line on the Astra log: branches deleted, issues closed, milestones closed, stale count. A step that
|
||||
fails is logged and the others still run; the job exits 1.
|
||||
|
||||
## What the steward does with the stale list
|
||||
|
||||
Housekeeping never acts on it. Read it on #57 once a day and for each entry either:
|
||||
|
||||
- **`needs-human` card or PR:** decide it (merge, close, or comment `/time retry` to send it back to the worker) and remove the label.
|
||||
- **Idle PR:** merge it, push it, or close it. A PR nobody wants closes; its branch goes at the next sweep.
|
||||
- **Parked card:** read why it stopped, fix the card (smaller, clearer `DONE:`) or close it.
|
||||
- **`error` card:** comment `/time retry`, or close it if the error is not worth another try.
|
||||
|
||||
## The workflow
|
||||
|
||||
The workflow file is the CEO's merge. It is the heartbeat's job with this step in place of `time.py heartbeat`:
|
||||
|
||||
```yaml
|
||||
name: housekeeping
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 13 * * *" # a few minutes after the Astra tick (13:13)
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: housekeeping
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
sweep:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
sparse-checkout: .github/time
|
||||
persist-credentials: false
|
||||
- run: python3 .github/time/housekeeping.py
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # branch and issue writes go out as the `time` user
|
||||
TIME_MODE: ${{ vars.TIME_MODE }}
|
||||
TIME_VENTURE_MEASURE_DAYS: ${{ vars.TIME_VENTURE_MEASURE_DAYS }}
|
||||
```
|
||||
|
||||
Tests: `python3 .github/time/test_housekeeping.py` (offline fake forge).
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Housekeeping: the daily sweep. Deterministic on purpose: no model decides what to delete or close. stdlib only.
|
||||
|
||||
python3 .github/time/housekeeping.py # daily (housekeeping.yml), or by hand
|
||||
|
||||
Four steps, each idempotent (a second run finds nothing to do) and each logged:
|
||||
1. branches delete every branch whose PR merged into the default branch. Never the default branch, `gh-pages`, a
|
||||
protected branch, a branch with an open PR, or a branch that has commits newer than its merge.
|
||||
2. signals close `signal` issues whose venture's measuring window (TIME_VENTURE_MEASURE_DAYS, default 7) has passed
|
||||
with no signups (no 👍 on the issue, no early-access issue for the venture), with one closing line.
|
||||
3. milestones close milestones with no open issues and at least one closed one.
|
||||
4. stale list, never act: `needs-human` untouched for 3 days, open PRs idle 7 days, cards parked after their
|
||||
automatic retries, cards whose last beat is `error` for 2 days. One comment on the Astra log; none if empty.
|
||||
Then one summary line on the Astra log. Only TIME_MODE=live changes anything; anything else (the default) prints what it
|
||||
would do and writes nothing, comments included. A step that fails is logged and the others still run (exit code 1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
_spec = importlib.util.spec_from_file_location("tm", HERE / "time.py")
|
||||
tm = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(tm)
|
||||
|
||||
NEVER = {"gh-pages"} # besides the default branch and protected ones
|
||||
LOG_TITLE = "Astra log"
|
||||
LOG_FALLBACK = 57 # the Astra log's number here, if it cannot be found by title
|
||||
NEEDS_HUMAN_DAYS, PR_IDLE_DAYS, ERROR_DAYS = 3, 7, 2
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg, flush=True)
|
||||
|
||||
|
||||
def repo() -> str:
|
||||
return f"/repos/{tm.REPO}"
|
||||
|
||||
|
||||
def paged(path: str, pages: int = 40) -> list[dict]:
|
||||
sep = "&" if "?" in path else "?"
|
||||
out: list[dict] = []
|
||||
for page in range(1, pages + 1):
|
||||
batch = tm.gh("GET", f"{path}{sep}limit=50&page={page}")
|
||||
out += batch
|
||||
if len(batch) < 50:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def parse(ts: str | None) -> dt.datetime | None:
|
||||
if not ts:
|
||||
return None
|
||||
try:
|
||||
d = dt.datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return d if d.tzinfo else d.replace(tzinfo=dt.timezone.utc)
|
||||
|
||||
|
||||
def age_days(ts: str | None, now: dt.datetime) -> float:
|
||||
d = parse(ts)
|
||||
return (now - d).total_seconds() / 86400 if d else 0.0
|
||||
|
||||
|
||||
def same_repo(pr: dict) -> bool:
|
||||
return ((pr.get("head") or {}).get("repo") or {}).get("full_name", tm.REPO) == tm.REPO # a fork's branch is not ours
|
||||
|
||||
|
||||
# ---------- 1. branches ----------
|
||||
|
||||
def step_branches(live: bool) -> int:
|
||||
default = tm.default_branch()
|
||||
open_heads = {p["head"]["ref"] for p in paged(f"{repo()}/pulls?state=open") if same_repo(p)}
|
||||
merged: dict[str, dt.datetime | None] = {} # head ref -> when its latest merged PR merged
|
||||
for p in paged(f"{repo()}/pulls?state=closed&sort=recentupdate"):
|
||||
if (p.get("merged") or p.get("merged_at")) and (p.get("base") or {}).get("ref") == default and same_repo(p):
|
||||
at = parse(p.get("merged_at"))
|
||||
ref = p["head"]["ref"]
|
||||
merged[ref] = max([d for d in (merged.get(ref), at) if d], default=None)
|
||||
deleted = 0
|
||||
for b in paged(f"{repo()}/branches"):
|
||||
name = b["name"]
|
||||
if name == default or name in NEVER or b.get("protected") or name not in merged:
|
||||
continue
|
||||
if name in open_heads:
|
||||
log(f"branches: keep {name} (open PR)")
|
||||
continue
|
||||
tip = parse((b.get("commit") or {}).get("timestamp"))
|
||||
if tip and merged[name] and tip > merged[name]:
|
||||
log(f"branches: keep {name} (commits after its merge)")
|
||||
continue
|
||||
if not live:
|
||||
log(f"branches: would delete {name}")
|
||||
continue
|
||||
try:
|
||||
tm.gh("DELETE", f"{repo()}/branches/{urllib.parse.quote(name, safe='')}")
|
||||
except RuntimeError as e:
|
||||
if "-> 404" not in str(e): # already gone is fine
|
||||
raise
|
||||
continue
|
||||
log(f"branches: deleted {name}")
|
||||
deleted += 1
|
||||
return deleted
|
||||
|
||||
|
||||
# ---------- 2. signal issues ----------
|
||||
|
||||
def person(user: dict | None) -> str | None:
|
||||
login = ((user or {}).get("login") or "").lower()
|
||||
if not login or (user or {}).get("type") == "Bot" or login in (tm.REPO.split("/")[0].lower(), tm.TIME_USER.lower(), tm.TIME_BOT.lower()):
|
||||
return None
|
||||
return login
|
||||
|
||||
|
||||
def signup_people() -> dict[str, set[str]]:
|
||||
"""venture slug -> distinct people who opened an early-access issue for it."""
|
||||
out: dict[str, set[str]] = {}
|
||||
for i in paged(f"{repo()}/issues?state=all&labels=signup&type=issues"):
|
||||
m = re.search(r"^###\s*Venture\s*$\n+(.*?)(?=^###\s|\Z)", i.get("body") or "", re.M | re.S)
|
||||
who = person(i.get("user"))
|
||||
if m and who and m.group(1).strip():
|
||||
out.setdefault(m.group(1).strip().lower(), set()).add(who)
|
||||
return out
|
||||
|
||||
|
||||
def step_signals(live: bool, now: dt.datetime) -> int:
|
||||
days = int(float(os.environ.get("TIME_VENTURE_MEASURE_DAYS") or 7))
|
||||
due = [i for i in paged(f"{repo()}/issues?state=open&labels=signal&type=issues")
|
||||
if not i.get("pull_request") and age_days(i.get("created_at"), now) >= days]
|
||||
if not due:
|
||||
return 0
|
||||
signups = signup_people()
|
||||
closed = 0
|
||||
for i in due:
|
||||
n = i["number"]
|
||||
m = re.search(r"[?&]venture=([\w.-]+)", i.get("body") or "")
|
||||
if not m:
|
||||
log(f"signals: keep #{n} (cannot tell which venture it belongs to)")
|
||||
continue
|
||||
people = set(signups.get(m.group(1).lower(), set()))
|
||||
people |= {w for r in paged(f"{repo()}/issues/{n}/reactions") if r.get("content") in ("+1", "👍")
|
||||
and (w := person(r.get("user")))}
|
||||
if people:
|
||||
log(f"signals: keep #{n} ({len(people)} signup(s))")
|
||||
continue
|
||||
if not live:
|
||||
log(f"signals: would close #{n} (0 signups after {days} days)")
|
||||
continue
|
||||
tm.gh("PATCH", f"{repo()}/issues/{n}", {"state": "closed"})
|
||||
tm.comment(n, f"Closed by housekeeping: 0 signups in the {days}-day measuring window.", {"housekeeping": "signal"})
|
||||
log(f"signals: closed #{n}")
|
||||
closed += 1
|
||||
return closed
|
||||
|
||||
|
||||
# ---------- 3. milestones ----------
|
||||
|
||||
def step_milestones(live: bool) -> int:
|
||||
closed = 0
|
||||
for m in paged(f"{repo()}/milestones?state=open"):
|
||||
if int(m.get("open_issues") or 0) or not int(m.get("closed_issues") or 0):
|
||||
continue
|
||||
if not live:
|
||||
log(f"milestones: would close {m.get('title')!r}")
|
||||
continue
|
||||
tm.gh("PATCH", f"{repo()}/milestones/{m['id']}", {"state": "closed"})
|
||||
log(f"milestones: closed {m.get('title')!r}")
|
||||
closed += 1
|
||||
return closed
|
||||
|
||||
|
||||
# ---------- 4. the stale list ----------
|
||||
|
||||
def stale(now: dt.datetime) -> dict[str, list[tuple[int, float, str]]]:
|
||||
"""category -> [(number, age in days, title)]. Read-only."""
|
||||
out: dict[str, list[tuple[int, float, str]]] = {}
|
||||
|
||||
def add(cat: str, number: int, age: float, title: str) -> None:
|
||||
out.setdefault(cat, []).append((number, age, title))
|
||||
for i in paged(f"{repo()}/issues?state=open&labels=needs-human"): # issues and PRs alike
|
||||
age = age_days(i.get("updated_at") or i.get("created_at"), now)
|
||||
if age > NEEDS_HUMAN_DAYS:
|
||||
add(f"`needs-human`, untouched for over {NEEDS_HUMAN_DAYS} days", i["number"], age, i.get("title", ""))
|
||||
for p in paged(f"{repo()}/pulls?state=open"):
|
||||
age = age_days(p.get("updated_at") or p.get("created_at"), now)
|
||||
if age >= PR_IDLE_DAYS:
|
||||
add(f"open PRs idle {PR_IDLE_DAYS} days or more", p["number"], age, p.get("title", ""))
|
||||
for i in paged(f"{repo()}/issues?state=open&labels=parked&type=issues"):
|
||||
add("parked cards", i["number"], age_days(i.get("updated_at") or i.get("created_at"), now), i.get("title", ""))
|
||||
for card in paged(f"{repo()}/issues?state=open&labels=ready&type=issues"): # last beat: error for a while, or parked
|
||||
beats = [b for b in tm.beats_of(paged(f"{repo()}/issues/{card['number']}/comments")) if b["beat"] not in ("blocked", "capped")]
|
||||
last = beats[-1] if beats else None
|
||||
if not last:
|
||||
continue
|
||||
age = age_days(last["at"], now)
|
||||
if last["beat"] == "parked":
|
||||
add("parked cards", card["number"], age, card.get("title", ""))
|
||||
elif last["beat"] == "error" and age > ERROR_DAYS:
|
||||
add(f"`error` cards older than {ERROR_DAYS} days", card["number"], age, card.get("title", ""))
|
||||
for cat, rows in out.items(): # a card can be listed under two names (parked label and beat): once per category
|
||||
out[cat] = list({r[0]: r for r in rows}.values())
|
||||
return out
|
||||
|
||||
|
||||
def stale_text(found: dict[str, list[tuple[int, float, str]]]) -> str:
|
||||
lines = ["🧹 Housekeeping: the stale list (nothing was changed; for the steward)", ""]
|
||||
for cat, rows in found.items():
|
||||
lines.append(f"- {cat}: " + "; ".join(f"#{n} {t[:60]} ({a:.0f}d)" for n, a, t in sorted(rows)))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def log_issue() -> int:
|
||||
for i in paged(f"{repo()}/issues?state=open&labels=astra&type=issues"):
|
||||
if i.get("title") == LOG_TITLE:
|
||||
return i["number"]
|
||||
return LOG_FALLBACK
|
||||
|
||||
|
||||
# ---------- the run ----------
|
||||
|
||||
def main() -> int:
|
||||
live = (os.environ.get("TIME_MODE") or "dry-run").strip() == "live"
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
log(f"housekeeping {now:%Y-%m-%dT%H:%MZ} mode={'live' if live else 'dry-run'}")
|
||||
failed = 0
|
||||
done = {"branches": 0, "signals": 0, "milestones": 0}
|
||||
found: dict = {}
|
||||
for name, step in (("branches", lambda: step_branches(live)), ("signals", lambda: step_signals(live, now)),
|
||||
("milestones", lambda: step_milestones(live))):
|
||||
try:
|
||||
done[name] = step()
|
||||
except Exception as e: # noqa: BLE001 one step must not stop the others
|
||||
failed += 1
|
||||
log(f"{name}: failed: {type(e).__name__}: {str(e)[:200]}")
|
||||
try:
|
||||
found = stale(now)
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed += 1
|
||||
log(f"stale: failed: {type(e).__name__}: {str(e)[:200]}")
|
||||
count = len({r[0] for rows in found.values() for r in rows})
|
||||
if found:
|
||||
log(stale_text(found))
|
||||
line = (f"🧹 Housekeeping: {done['branches']} branch(es) deleted, {done['signals']} signal issue(s) closed, "
|
||||
f"{done['milestones']} milestone(s) closed, {count} stale." + (f" {failed} step(s) failed; see the run log." if failed else ""))
|
||||
log(line)
|
||||
if live:
|
||||
try:
|
||||
target = log_issue()
|
||||
if found:
|
||||
tm.comment(target, stale_text(found), {"housekeeping": "stale", "count": count})
|
||||
tm.comment(target, line, {"housekeeping": "run", **done, "stale": count})
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed += 1
|
||||
log(f"log: failed: {type(e).__name__}: {str(e)[:200]}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -14,5 +14,9 @@
|
||||
"haiku": {"family": "claude-haiku", "default": "claude-haiku-5-5", "label": "Claude Haiku (Anthropic)", "provider": "anthropic", "price": [0.10, 0.50]},
|
||||
"sonnet": {"family": "claude-sonnet", "default": "claude-sonnet-5-5", "label": "Claude Sonnet (Anthropic)", "provider": "anthropic", "price": [2, 10]},
|
||||
"opus": {"family": "claude-opus", "default": "claude-opus-5-5", "label": "Claude Opus (Anthropic)", "provider": "anthropic", "price": [4, 20]},
|
||||
"fable": {"family": "claude-fable", "default": "claude-fable-5-1", "label": "Claude Fable (Anthropic)", "provider": "anthropic", "price": [10, 50]}
|
||||
"fable": {"family": "claude-fable", "default": "claude-fable-5-1", "label": "Claude Fable (Anthropic)", "provider": "anthropic", "price": [10, 50]},
|
||||
"luna": {"family": "codex-luna", "default": "luna", "label": "Codex Luna (OpenAI)", "provider": "codex", "route": "cli"},
|
||||
"terra": {"family": "codex-terra", "default": "terra", "label": "Codex Terra (OpenAI)", "provider": "codex", "route": "cli"},
|
||||
"sol": {"family": "codex-sol", "default": "sol", "label": "Codex Sol (OpenAI)", "provider": "codex", "route": "cli"},
|
||||
"astra": {"family": "codex-astra", "default": "astra", "label": "Codex Astra (OpenAI)", "provider": "codex", "route": "cli"}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline tests for housekeeping.py: a fake forge (like test_time.py's World) that records every write."""
|
||||
import datetime as dt
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
for _k in [k for k in os.environ if k.startswith("TIME_")] + ["ANTHROPIC_API_KEY", "VENICE_API_KEY", "CURSOR_API_KEY"]:
|
||||
os.environ.pop(_k, None)
|
||||
os.environ.update(GITHUB_REPOSITORY="o/r", GITHUB_TOKEN="gh", GITHUB_SERVER_URL="https://forge.test", GITHUB_API_URL="https://forge.test/api/v1")
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hk", Path(__file__).resolve().parent / "housekeeping.py")
|
||||
hk = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(hk)
|
||||
tm = hk.tm
|
||||
|
||||
NOW = dt.datetime.now(dt.timezone.utc)
|
||||
iso = lambda days=0: (NOW - dt.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
LOG = 57
|
||||
|
||||
|
||||
class Forge:
|
||||
def __init__(self, branches=(), prs=(), issues=(), milestones=(), reactions=None, comments=None):
|
||||
self.branches = [{"name": b} if isinstance(b, str) else b for b in branches]
|
||||
self.prs = list(prs)
|
||||
self.issues = {i["number"]: {"state": "open", "labels": [], "created_at": iso(0), "updated_at": iso(0), "body": "", **i} for i in issues}
|
||||
self.milestones = [dict(m) for m in milestones]
|
||||
self.reactions = reactions or {}
|
||||
self.cmts = comments or {}
|
||||
self.writes = [] # every non-GET call
|
||||
self.posted = [] # (issue, body)
|
||||
|
||||
def page(self, rows, path):
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlparse(path).query)
|
||||
p, n = int(q.get("page", ["1"])[0]), int(q.get("limit", ["50"])[0])
|
||||
return rows[(p - 1) * n: p * n]
|
||||
|
||||
def http(self, method, url, token, body=None, timeout=0):
|
||||
path = url.replace(tm.GH, "")
|
||||
route = path.split("?")[0]
|
||||
q = urllib.parse.parse_qs(urllib.parse.urlparse(path).query)
|
||||
if method != "GET":
|
||||
self.writes.append((method, route, body))
|
||||
if route == "/repos/o/r" and method == "GET":
|
||||
return {"default_branch": "main"}
|
||||
if route == "/repos/o/r/branches" and method == "GET":
|
||||
return self.page(self.branches, path)
|
||||
if route.startswith("/repos/o/r/branches/") and method == "DELETE":
|
||||
name = urllib.parse.unquote(route.split("/branches/")[1])
|
||||
self.branches = [b for b in self.branches if b["name"] != name]
|
||||
return {}
|
||||
if route == "/repos/o/r/pulls":
|
||||
want = q.get("state", ["open"])[0]
|
||||
return self.page([p for p in self.prs if p["state"] == want], path)
|
||||
if route == "/repos/o/r/milestones" and method == "GET":
|
||||
return self.page([m for m in self.milestones if m.get("state", "open") == "open"], path)
|
||||
if route.startswith("/repos/o/r/milestones/") and method == "PATCH":
|
||||
for m in self.milestones:
|
||||
if m["id"] == int(route.split("/")[-1]):
|
||||
m.update(body)
|
||||
return {}
|
||||
if route.endswith("/reactions"):
|
||||
return self.page(self.reactions.get(int(route.split("/")[-2]), []), path)
|
||||
if route.endswith("/comments") and method == "GET":
|
||||
return self.page(self.cmts.get(int(route.split("/")[-2]), []), path)
|
||||
if route.endswith("/comments") and method == "POST":
|
||||
self.posted.append((int(route.split("/")[-2]), body["body"]))
|
||||
return {}
|
||||
if route == "/repos/o/r/issues" and method == "GET":
|
||||
rows = [i for i in self.issues.values()
|
||||
if (q.get("state", ["open"])[0] in ("all", i["state"]))
|
||||
and all(l in [x for x in i["labels"]] for l in q.get("labels", [""])[0].split(",") if l)]
|
||||
return self.page([{**i, "labels": [{"name": l} for l in i["labels"]]} for i in rows], path)
|
||||
if route.startswith("/repos/o/r/issues/") and method == "PATCH":
|
||||
self.issues[int(route.split("/")[-1])].update(body)
|
||||
return {}
|
||||
raise AssertionError(f"unexpected {method} {path}")
|
||||
|
||||
def run(self, mode="live", env=None):
|
||||
os.environ.pop("TIME_MODE", None)
|
||||
os.environ.pop("TIME_VENTURE_MEASURE_DAYS", None)
|
||||
if mode:
|
||||
os.environ["TIME_MODE"] = mode
|
||||
os.environ.update(env or {})
|
||||
tm.http = self.http
|
||||
return hk.main()
|
||||
|
||||
|
||||
def pr(n, head, state="open", merged=False, base="main", idle=0, merged_days=1):
|
||||
return {"number": n, "title": f"pr {n}", "state": state, "merged": merged, "merged_at": iso(merged_days) if merged else None,
|
||||
"base": {"ref": base}, "head": {"ref": head, "repo": {"full_name": "o/r"}}, "created_at": iso(idle), "updated_at": iso(idle)}
|
||||
|
||||
|
||||
deleted = lambda f: [urllib.parse.unquote(w[1].split("/branches/")[1]) for w in f.writes if w[0] == "DELETE"]
|
||||
|
||||
|
||||
def test_branches():
|
||||
f = Forge(branches=["main", "gh-pages", "time/card-53", "ceo/protected-paths", "labs/aicharts-fast", "time/card-90", "feature/open", "feature/closed-unmerged"],
|
||||
prs=[pr(1, "time/card-53", "closed", True), pr(2, "ceo/protected-paths", "closed", True), pr(3, "labs/aicharts-fast", "closed", True),
|
||||
pr(4, "main", "closed", True), pr(5, "gh-pages", "closed", True),
|
||||
pr(6, "feature/open", "open"), pr(7, "feature/open", "closed", True), # merged once, reopened as a new PR: keep
|
||||
pr(8, "feature/closed-unmerged", "closed", False)])
|
||||
assert f.run() == 0
|
||||
assert sorted(deleted(f)) == ["ceo/protected-paths", "labs/aicharts-fast", "time/card-53"], deleted(f)
|
||||
kept = {b["name"] for b in f.branches}
|
||||
assert {"main", "gh-pages", "time/card-90", "feature/open", "feature/closed-unmerged"} <= kept
|
||||
assert any("3 branch(es) deleted" in b for _, b in f.posted), f.posted # the summary line on the Astra log
|
||||
# idempotent: a second run has nothing to do
|
||||
f.writes.clear(); f.posted.clear()
|
||||
f.run()
|
||||
assert deleted(f) == [] and any("0 branch(es) deleted" in b for _, b in f.posted)
|
||||
# a branch with commits after its merge is kept
|
||||
f = Forge(branches=[{"name": "time/card-9", "commit": {"timestamp": iso(0)}}], prs=[pr(9, "time/card-9", "closed", True, merged_days=2)])
|
||||
f.run(); assert deleted(f) == []
|
||||
|
||||
|
||||
def sig(n, venture, age):
|
||||
return {"number": n, "title": f"{venture}: want the next version?", "labels": ["signal"], "created_at": iso(age),
|
||||
"body": f"Join: https://forge.test/o/r/issues/new?template=early-access.yml&title=x&venture={venture}"}
|
||||
|
||||
|
||||
def test_signals():
|
||||
f = Forge(issues=[sig(10, "spent", 8), sig(11, "fresh", 2), sig(12, "wanted", 9), sig(13, "asked", 9),
|
||||
{"number": 20, "labels": ["signup"], "user": {"login": "ann"}, "body": "### Venture\n\nasked\n\n### Plan\n\nFree"}],
|
||||
reactions={12: [{"content": "+1", "user": {"login": "bob"}}, {"content": "+1", "user": {"login": "owner"}}]})
|
||||
f.run()
|
||||
st = {n: f.issues[n]["state"] for n in (10, 11, 12, 13)}
|
||||
assert st == {10: "closed", 11: "open", 12: "open", 13: "open"}, st
|
||||
closing = [b for n, b in f.posted if n == 10]
|
||||
assert closing and "0 signups" in closing[0] and not [b for n, b in f.posted if n in (11, 12, 13)]
|
||||
assert any("1 signal issue(s) closed" in b for _, b in f.posted)
|
||||
# the window is TIME_VENTURE_MEASURE_DAYS
|
||||
f = Forge(issues=[sig(10, "spent", 8)]); f.run(env={"TIME_VENTURE_MEASURE_DAYS": "10"})
|
||||
assert f.issues[10]["state"] == "open"
|
||||
|
||||
|
||||
def test_milestones():
|
||||
f = Forge(milestones=[{"id": 1, "title": "done batch", "open_issues": 0, "closed_issues": 3},
|
||||
{"id": 2, "title": "in progress", "open_issues": 1, "closed_issues": 2},
|
||||
{"id": 3, "title": "never used", "open_issues": 0, "closed_issues": 0}])
|
||||
f.run()
|
||||
st = {m["id"]: m.get("state", "open") for m in f.milestones}
|
||||
assert st == {1: "closed", 2: "open", 3: "open"}, st
|
||||
assert any("1 milestone(s) closed" in b for _, b in f.posted)
|
||||
|
||||
|
||||
def test_stale():
|
||||
ready_err = {"created_at": iso(5), "user": {"login": "gitea-actions"}, "body": f"x\n\n{tm.MARK}{json.dumps({'beat': 'error'})} -->"}
|
||||
f = Forge(issues=[{"number": 30, "title": "old human card", "labels": ["needs-human"], "updated_at": iso(4)},
|
||||
{"number": 31, "title": "fresh human card", "labels": ["needs-human"], "updated_at": iso(1)},
|
||||
{"number": 32, "title": "errored card", "labels": ["ready"], "updated_at": iso(5)},
|
||||
{"number": 33, "title": "fresh error", "labels": ["ready"], "updated_at": iso(0)}],
|
||||
comments={32: [ready_err], 33: [{**ready_err, "created_at": iso(0)}]},
|
||||
prs=[pr(40, "feature/idle", "open", idle=7), pr(41, "feature/new", "open", idle=1)])
|
||||
f.run()
|
||||
texts = [b for n, b in f.posted if n == LOG and "stale list" in b]
|
||||
assert len(texts) == 1, f.posted
|
||||
t = texts[0]
|
||||
assert "#30" in t and "#40" in t and "#32" in t, t
|
||||
assert "#31" not in t and "#41" not in t and "#33" not in t, t
|
||||
assert any("3 stale" in b for _, b in f.posted) or any("4 stale" in b for _, b in f.posted), f.posted
|
||||
# an empty list posts no stale comment, only the one summary line
|
||||
f = Forge(); f.run()
|
||||
assert [b for _, b in f.posted if "stale list" in b] == [] and len(f.posted) == 1 and "0 stale" in f.posted[0][1]
|
||||
|
||||
|
||||
def test_dry_run():
|
||||
f = Forge(branches=["main", "time/card-53"], prs=[pr(1, "time/card-53", "closed", True)],
|
||||
issues=[sig(10, "spent", 8), {"number": 30, "labels": ["needs-human"], "updated_at": iso(9)}],
|
||||
milestones=[{"id": 1, "title": "done batch", "open_issues": 0, "closed_issues": 3}])
|
||||
for mode in ("dry-run", None): # explicit and unset
|
||||
assert f.run(mode) == 0
|
||||
assert f.writes == [] and f.posted == [], (f.writes, f.posted)
|
||||
assert {b["name"] for b in f.branches} == {"main", "time/card-53"} and f.issues[10]["state"] == "open"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [test_branches, test_signals, test_milestones, test_stale, test_dry_run]
|
||||
for t in tests:
|
||||
t()
|
||||
print("ok", t.__name__)
|
||||
print(f"{len(tests)} housekeeping tests passed")
|
||||
+348
-6
@@ -1,5 +1,5 @@
|
||||
"""Offline tests for time.py: fake Gitea + fake Cursor, whole card lifecycle. Run: python3 .github/time/test_time.py"""
|
||||
import importlib.util, json, os, sys, datetime as dt
|
||||
import importlib.util, json, os, re, sys, datetime as dt
|
||||
import concurrent.futures as cf
|
||||
|
||||
# The suite assumes a clean environment: a worker's shell inherits the job's TIME_* settings and seat keys, so drop them
|
||||
@@ -114,6 +114,9 @@ class World:
|
||||
if method == "POST" and path.endswith("/merge"):
|
||||
assert body["Do"] == "squash"; self.gh_pr[int(path.split("/")[-2])]["pr"]["merged_at"] = iso(); return {}
|
||||
if method == "GET" and path.endswith(".diff"): return {}
|
||||
if method == "POST" and re.search(r"/pulls/\d+/reviews$", path):
|
||||
if getattr(self, "refuse_reviews", False): raise RuntimeError(f"POST {path} -> 422: approve your own pull is not allowed")
|
||||
self.reviews_posted = getattr(self, "reviews_posted", []) + [(int(path.split("/")[-2]), token, body)]; return {}
|
||||
if "/pulls/" in path:
|
||||
n = int(path.split("/")[5]); d = self.gh_pr[n]
|
||||
if path.endswith("/reviews?limit=100"): return d.get("reviews", [])
|
||||
@@ -217,6 +220,26 @@ tm.pr_diff = lambda n, limit=0: "diff --git a b"
|
||||
w = run(World([card(1, D)], {1: [beat("dispatched", launched=True)]}, [pr]), {"TIME_MODE": "live", "CURSOR_API_KEY": "ck", "TIME_REVIEW": "venice:kimi"})
|
||||
assert w.posted[0][0] == 7 and w.posted[0][1].startswith("**Approve** (kimi)") and "`kimi` reviewed" in w.posted[1][1]
|
||||
|
||||
# 9a a seat with a TIME_SEAT_<SEAT> token reviews as its own user; refused or tokenless, Time comments
|
||||
def seat_run(text, refuse=False, token="kimi-tok"):
|
||||
tm.think = lambda seat, model, effort, system, user: text
|
||||
w = World([card(1, D)], {1: [beat("dispatched", launched=True)]}, [pr]); w.refuse_reviews = refuse
|
||||
if token: os.environ["TIME_SEAT_KIMI"] = token
|
||||
try: return run(w, {"TIME_MODE": "live", "CURSOR_API_KEY": "ck", "TIME_REVIEW": "venice:kimi"})
|
||||
finally: os.environ.pop("TIME_SEAT_KIMI", None)
|
||||
w = seat_run("**Approve** looks right")
|
||||
n, tok, b = w.reviews_posted[0]
|
||||
assert (n, tok, b["event"]) == (7, "kimi-tok", "APPROVED") and b["body"].startswith("**Approve**") and "review by kimi" in b["body"] and "via Time</sub>" in b["body"]
|
||||
assert not [p_ for p_ in w.posted if p_[0] == 7] and any("`kimi` reviewed" in t for _, t in w.posted)
|
||||
assert seat_run("Object: breaks x").reviews_posted[0][2]["event"] == "REQUEST_CHANGES"
|
||||
assert seat_run("Some thoughts, no verdict").reviews_posted[0][2]["event"] == "COMMENT"
|
||||
w = seat_run("**Approve** fine", refuse=True)
|
||||
assert not getattr(w, "reviews_posted", []) and w.posted[0][0] == 7 and w.posted[0][1].startswith("**Approve** fine") and "review by kimi" in w.posted[0][1]
|
||||
w = seat_run("**Approve** fine", token=None)
|
||||
assert not getattr(w, "reviews_posted", []) and w.posted[0][0] == 7
|
||||
os.environ.pop("TIME_SEAT_HAIKU", None); assert tm.seat_token("haiku") is None
|
||||
os.environ["TIME_SEAT_HAIKU"] = "h-tok"; assert tm.seat_token("haiku") == "h-tok"; os.environ.pop("TIME_SEAT_HAIKU")
|
||||
|
||||
# 9b the reviewer's input carries the head commit's statuses under # Checks, before the diff; a failed lookup says so
|
||||
seen = {}
|
||||
tm.think = lambda seat, model, effort, system, user: (seen.update(user=user, system=system), "**Approve** ok")[1]
|
||||
@@ -307,6 +330,47 @@ wm = pr_world(); wm.gh_pr[7]["pr"]["merged_at"] = iso()
|
||||
w = run(wm, K); assert w.issues[1]["state"] == "closed" and w.issues[2]["labels"] == ["ready"]
|
||||
print("ALL OK 3")
|
||||
|
||||
# 13b a ready card with an open PR that closes it adopts the PR: `pr` beat (adopted), a review, no worker, no launch
|
||||
import io, contextlib
|
||||
tm.think = lambda seat, model, effort, system, user: "**Approve** matches the card"
|
||||
tm.pr_diff = lambda n, limit=0: "diff --git a b"
|
||||
def adopt_world(prbody="Summary\n\nCloses #1", state="open", cbody=D, merged_at=None):
|
||||
prd = {"number": 7, "state": state, "merged_at": merged_at, "head": {"sha": "abc", "ref": "codex/x"}, "html_url": "u",
|
||||
"created_at": iso(2), "title": "x", "body": prbody}
|
||||
return World([card(1, cbody)], {}, [prd], None, {7: {"pr": prd, "statuses": [{"status": "success", "context": "ci"}], "files": [".github/time/time.py"]}})
|
||||
KA = {**K, "TIME_REVIEW": "venice:kimi", "TIME_WORKER": "cursor"}
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
w = run(adopt_world(), KA)
|
||||
assert "launches_today=0/5" in out.getvalue()
|
||||
b = tm.beats_of(w.comments[1])
|
||||
assert [x["beat"] for x in b] == ["pr"] and b[0]["pr"] == 7 and b[0]["adopted"] is True and not b[0].get("launched"), b
|
||||
assert w.launched == [] and w.opened == [] and w.comments[7][-1]["body"].startswith("**Approve**"), "reviewed, nothing built"
|
||||
# next tick, automerge on, Approve + green -> merged (engine paths included: TIME_PROTECTED lets them through)
|
||||
w = run(w, {**KA, "TIME_AUTOMERGE": "on", "TIME_PROTECTED": NARROW})
|
||||
assert merged(w) and w.issues[1]["state"] == "closed" and w.launched == []
|
||||
os.environ.pop("TIME_PROTECTED", None)
|
||||
# a ready card with no PR is dispatched as today
|
||||
w = run(World([card(1, D)]), K); assert len(w.launched) == 1 and tm.beats_of(w.comments[1])[0]["beat"] == "dispatched"
|
||||
# a PR that closes another card, a closed PR and a merged one are not adopted
|
||||
for wx in (adopt_world("Closes #11"), adopt_world(state="closed"), adopt_world(merged_at=iso(1))):
|
||||
wx = run(wx, KA); assert len(wx.launched) == 1 and "pr" not in [x["beat"] for x in tm.beats_of(wx.comments[1])]
|
||||
# `PR: #N` in the card adopts without `Closes`; a closed one named there does not
|
||||
w = run(adopt_world("no closing line", cbody=D + "\n\nPR: #7"), KA)
|
||||
assert tm.beats_of(w.comments[1])[0]["adopted"] is True and w.launched == []
|
||||
w = run(adopt_world("no closing line", state="closed", cbody=D + "\n\nPR: #7"), KA); assert len(w.launched) == 1
|
||||
# dry run says what it would adopt, and builds nothing
|
||||
w = run(adopt_world(), {})
|
||||
assert "would adopt open PR #7" in w.posted[0][1] and w.launched == [] and w.comments.get(7) is None
|
||||
# dry run first, then live: the `would-dispatch` beat doesn't stop adoption, and no worker is launched
|
||||
w = run(adopt_world(), {}); n0 = len(w.posted)
|
||||
w = run(w, {}); assert len(w.posted) == n0, "dry run doesn't repeat itself"
|
||||
w = run(w, KA)
|
||||
assert w.launched == [] and tm.beats_of(w.comments[1])[-1]["adopted"] is True and w.comments[7][-1]["body"].startswith("**Approve**")
|
||||
# `PR: #N` must stand alone on its line (quoted or example text doesn't adopt)
|
||||
w = run(adopt_world("no closing line", cbody=D + "\n\nPR: #7 is an example"), KA); assert len(w.launched) == 1
|
||||
print("ALL OK 13b")
|
||||
|
||||
# 14 TIME_WORKER=venice:kimi -> the seat edits on its branch, is refused on workflows, opens a PR that closes the card
|
||||
w = run(World([card(1, D)], {1: [beat("would-dispatch")]}), {**K, "TIME_WORKER": "venice:kimi code", "VENICE_API_KEY": "vk"})
|
||||
assert w.launched == [], "no Cursor launch"
|
||||
@@ -331,6 +395,15 @@ w3 = run(World([card(1, D)], {1: [beat("would-dispatch")]}), {"TIME_MODE": "live
|
||||
assert "VENICE_API_KEY" in w3.posted[-1][1]
|
||||
print("ALL OK 4")
|
||||
|
||||
# 15a a status from one of Time's own workflows is not a check: a heartbeat run cancelled by the concurrency group (a seat's
|
||||
# review wakes heartbeat.yml during a heartbeat) lands on the head as "failure" and must neither block nor earn a merge
|
||||
w = pr_world(checks=[]); w.gh_pr[7]["statuses"] = [{"state": "failure", "context": "heartbeat / tick (pull_request_review)"}, {"state": "success", "context": "ci / test (pull_request)"}]
|
||||
w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert merged(w), "a cancelled heartbeat run is not a failing check"
|
||||
w = pr_world(checks=[]); w.gh_pr[7]["statuses"] = [{"state": "success", "context": "heartbeat / tick (pull_request_review)"}]
|
||||
w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert not merged(w) and "running CI" in w.posted[0][1], "nor does a green one count as CI"
|
||||
w = pr_world(checks=[]); w.gh_pr[7]["statuses"] = [{"state": "pending", "context": "mention / reply (issue_comment)"}, {"state": "success", "context": "ci / test (pull_request)"}]
|
||||
w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert merged(w), "a pending mention run does not hold the merge"
|
||||
|
||||
# 15 the worker's own status can block a merge but never earns one: Time dispatches CI instead, then merges on it
|
||||
w = pr_world(checks=[]); w.gh_pr[7]["statuses"] = [{"state": "success", "context": "time/worker-run"}]
|
||||
w = run(w, {**K, "TIME_AUTOMERGE": "on"}); assert not merged(w), "worker status alone is not green"
|
||||
@@ -431,6 +504,97 @@ else: os.environ["TIME_WORKER"] = _wk
|
||||
os.environ.pop("TIME_AUTOMERGE", None)
|
||||
print("ALL OK 55")
|
||||
|
||||
# 77 the council: TIME_REVIEW lists seats, each votes once (latest wins), TIME_REVIEW_TIEBREAK is called only on a split
|
||||
class CouncilWorld(World):
|
||||
"""Seats post real reviews as their own users: the review a seat's token posts lands in the PR's review list."""
|
||||
LOGINS = {"h-tok": "haiku", "o-tok": "opus", "k-tok": "kimi"}
|
||||
def http(self, method, url, token, body=None, timeout=0):
|
||||
out = super().http(method, url, token, body, timeout)
|
||||
if method == "POST" and re.search(r"/pulls/\d+/reviews$", url):
|
||||
self.vote(self.LOGINS[token], {"APPROVED": "APPROVED", "REQUEST_CHANGES": "REQUEST_CHANGES"}.get(body["event"], "COMMENTED"))
|
||||
return out
|
||||
def vote(self, login, state, h=0):
|
||||
self.gh_pr[7].setdefault("reviews", []).append({"state": state, "user": {"login": login}, "submitted_at": iso(h)})
|
||||
def council(votes_by=None, **env):
|
||||
"""A PR with the standing seats' votes already cast ({login: APPROVED | REQUEST_CHANGES}); returns (world, who the tie-break seat was asked as)."""
|
||||
prd = {"number": 7, "state": "open", "merged_at": None, "head": {"sha": "abc", "ref": "time/card-1"}, "html_url": "u", "created_at": iso(2),
|
||||
"title": "x", "body": "Closes #1"}
|
||||
w = CouncilWorld([card(1, D)], {1: [beat("pr", 1, pr=7)], 7: []}, [], None,
|
||||
{7: {"pr": prd, "statuses": [{"status": "success", "context": "ci"}], "files": ["src/a.py"]}})
|
||||
w.trusted |= {"haiku", "opus", "kimi"}
|
||||
for login, st in (votes_by or {}).items(): w.vote(login, st, 1)
|
||||
return w
|
||||
asked = []
|
||||
def council_think(seat, model, effort, system, user):
|
||||
asked.append(seat); return council_verdicts[seat]
|
||||
council_verdicts = {"kimi": "**Approve** breaks the tie"}
|
||||
def land77(w, last=None, **env):
|
||||
for k in [k for k in os.environ if k.startswith(("TIME_REVIEW", "TIME_SEAT_"))]: os.environ.pop(k)
|
||||
os.environ.update({"TIME_AUTOMERGE": "on", "TIME_REVIEW": "claude:haiku, claude:opus", "TIME_SEAT_HAIKU": "h-tok",
|
||||
"TIME_SEAT_OPUS": "o-tok", "TIME_SEAT_KIMI": "k-tok", **env})
|
||||
tm.http = w.http; tm._trust.clear(); tm._label_ids = None
|
||||
tm.think, tm.pr_diff = council_think, (lambda n, limit=0: "diff --git a b")
|
||||
asked.clear()
|
||||
tm.land(card(1, D), last or {"beat": "pr", "pr": 7})
|
||||
return w
|
||||
TB = {"TIME_REVIEW_TIEBREAK": "venice:kimi"}
|
||||
AP, OB = "APPROVED", "REQUEST_CHANGES"
|
||||
# two seats Approve/Approve -> merge, and the tie-break seat is never called (with or without one configured)
|
||||
for extra in ({}, TB):
|
||||
w = land77(council({"haiku": AP, "opus": AP}), **extra)
|
||||
assert merged(w) and asked == [] and not getattr(w, "reviews_posted", []), "the standing seats agree"
|
||||
# quorum 2 and only one of two standing seats has voted: wait, the tie-break is not called early
|
||||
w = land77(council({"haiku": AP}), TIME_REVIEW_QUORUM="2", **TB)
|
||||
assert not merged(w) and asked == [] and w.posted == []
|
||||
# Approve/Object -> the tie-break seat is called exactly once, and its Approve merges
|
||||
w = land77(council({"haiku": AP, "opus": OB}), **TB)
|
||||
assert asked == ["kimi"] and len(w.reviews_posted) == 1 and w.reviews_posted[0][1] == "k-tok" and merged(w) and not any("split" in t for _, t in w.posted)
|
||||
# ... its Object yields the split message once, with a needs-human beat; the next beat neither calls it again nor repeats
|
||||
council_verdicts["kimi"] = "**Object** still wrong"
|
||||
w = land77(council({"haiku": AP, "opus": OB}), **TB)
|
||||
assert asked == ["kimi"] and not merged(w) and len(w.posted) == 1, w.posted
|
||||
assert "split 1–2" in w.posted[0][1] and "over to a human" in w.posted[0][1] and "needs-human" in w.issues.get(7, {}).get("labels", ["needs-human"])
|
||||
b = tm.beats_of(w.comments[1])[-1]
|
||||
assert b["beat"] == "needs-human" and b["split"] is True
|
||||
land77(w, last=b, **TB)
|
||||
assert asked == [] and len(w.posted) == 1, "the split was said once, the tie-break not called again"
|
||||
council_verdicts["kimi"] = "**Approve** breaks the tie"
|
||||
# Approve/Object with no tie-break configured -> the split goes to a human at once, nobody is called
|
||||
w = land77(council({"haiku": AP, "opus": OB}))
|
||||
assert asked == [] and not merged(w) and "split 1–1" in w.posted[0][1]
|
||||
# a seat that objected and later re-approves: its latest vote counts, so the standing seats agree
|
||||
w = council({"haiku": AP, "opus": OB}); w.vote("opus", AP, 0)
|
||||
w = land77(w, **TB); assert merged(w) and asked == []
|
||||
assert tm.votes(w.gh_pr[7]["pr"], iso(3)) == {"haiku": "approve", "opus": "approve"}
|
||||
w = council({"haiku": AP, "opus": AP}); w.vote("opus", OB, 0)
|
||||
tm.http = w.http; tm._trust.clear()
|
||||
assert tm.verdict(w.gh_pr[7]["pr"], iso(3)) is None and tm.votes(w.gh_pr[7]["pr"], iso(3))["opus"] == "object"
|
||||
# quorum 2 with one Approve and no tie-break configured -> waits (one standing seat so it has voted)
|
||||
w = land77(council({"haiku": AP}), TIME_REVIEW="claude:haiku", TIME_REVIEW_QUORUM="2")
|
||||
assert not merged(w) and w.posted == [] and asked == []
|
||||
w = land77(council({"haiku": AP, "opus": AP}), TIME_REVIEW_QUORUM="2"); assert merged(w)
|
||||
# an objection with no approvals is still an objection (single seat)
|
||||
w = land77(council({"haiku": OB}), TIME_REVIEW="claude:haiku"); assert not merged(w) and "objected" in w.posted[0][1] and asked == []
|
||||
# the single-seat path is today's: one Approve merges, no tie-break asked even when one is configured
|
||||
w = land77(council({"haiku": AP}), TIME_REVIEW="claude:haiku", **TB); assert merged(w) and asked == []
|
||||
# votes are per identity: Time's own comments are one identity (gitea-actions), two of them are one vote
|
||||
w = council(); w.comments[7] = [{"created_at": iso(), "user": {"login": "gitea-actions"}, "body": "**Approve** a"},
|
||||
{"created_at": iso(), "user": {"login": "gitea-actions"}, "body": "**Approve** b"}]
|
||||
tm._trust.clear(); tm.http = w.http
|
||||
assert tm.votes(w.gh_pr[7]["pr"], iso(3)) == {"gitea-actions": "approve"}
|
||||
# review() runs every listed seat, each posts as itself, and returns the seats it ran
|
||||
w = council(); os.environ.update({"TIME_REVIEW": "claude:haiku, claude:opus", "TIME_SEAT_HAIKU": "h-tok", "TIME_SEAT_OPUS": "o-tok"}); tm.http = w.http
|
||||
council_verdicts.update(haiku="**Approve** ok", opus="**Object** no")
|
||||
tm.think = council_think; asked.clear()
|
||||
rv = tm.review(card(1, D), {"number": 7, "title": "x", "body": "", "head": {"sha": "abc"}, "html_url": "u"}, "d")
|
||||
assert asked == ["haiku", "opus"] and rv["seats"] == ["haiku", "opus"] and [r[1] for r in w.reviews_posted] == ["h-tok", "o-tok"]
|
||||
assert tm.review_note(rv) == "`haiku`, `opus` reviewed it on the PR."
|
||||
# seats.json: Codex's ladder is there, on the CLI route
|
||||
assert all(tm.SEATS[x]["route"] == "cli" for x in ("luna", "terra", "sol", "astra")) and "astra" not in tm.mentions("@astra hi")
|
||||
for k in [k for k in os.environ if k.startswith(("TIME_REVIEW", "TIME_SEAT_", "TIME_AUTOMERGE"))]: os.environ.pop(k)
|
||||
tm.think = real_think
|
||||
print("ALL OK 77")
|
||||
|
||||
# 409 on a contents PUT is retried; other errors are not
|
||||
tries = []
|
||||
def flaky(method, url, token, body=None, timeout=0):
|
||||
@@ -459,9 +623,20 @@ class ClaudeWorld(World):
|
||||
def __init__(self, *a, ledger=None, **kw):
|
||||
super().__init__(*a, **kw); self.label_ids["andon"] = 99; self.vars = {}
|
||||
if ledger: self.files[f"time/ledger/{NOW:%Y-%m}.jsonl"] = "".join(json.dumps(r) + "\n" for r in ledger)
|
||||
def http(self, method, url, token, body=None, timeout=0):
|
||||
def http(self, method, url, token, body=None, timeout=0, headers=None):
|
||||
if url.startswith(tm.ANTHROPIC):
|
||||
sent.append((url, token, body)); out = self.venice(body); out["usage"] = {"prompt_tokens": 1000, "completion_tokens": 2000}; return out
|
||||
sent.append((url, token, body))
|
||||
if url.endswith("/messages"): # the native API: the scripted seat speaks OpenAI, so translate both ways
|
||||
assert headers == {"x-api-key": token, "anthropic-version": tm.ANTHROPIC_VERSION}, headers
|
||||
assert "system" in body and body["messages"][0]["role"] == "user" and "temperature" not in body, body
|
||||
oa = {"model": body["model"], "tools": [{"type": "function", "function": {"name": t["name"], "parameters": t["input_schema"]}} for t in body.get("tools") or []],
|
||||
"messages": [{"role": "system", "content": body["system"][0]["text"]}] + [{"role": m["role"], "content": "".join(b.get("text") or "" for b in m["content"])} for m in body["messages"]]}
|
||||
r = self.venice(oa)["choices"][0]["message"]
|
||||
blocks = ([{"type": "text", "text": r["content"]}] if r.get("content") else []) + \
|
||||
[{"type": "tool_use", "id": c["id"], "name": c["function"]["name"], "input": json.loads(c["function"]["arguments"])} for c in r.get("tool_calls") or []]
|
||||
return {"content": blocks, "stop_reason": "tool_use" if r.get("tool_calls") else "end_turn",
|
||||
"usage": {"input_tokens": 1000, "output_tokens": 2000, **getattr(self, "cache_usage", {})}}
|
||||
out = self.venice(body); out["usage"] = {"prompt_tokens": 1000, "completion_tokens": 2000}; return out
|
||||
if "/actions/variables/" in url:
|
||||
self.vars[url.rsplit("/", 1)[1]] = (method, body["value"]); return {}
|
||||
return super().http(method, url, token, body, timeout)
|
||||
@@ -474,15 +649,54 @@ w.venice = lambda body: {"choices": [{"message": {"role": "assistant", "content"
|
||||
tm.think = real_think
|
||||
assert tm.think("sonnet", None, "high", "sys", "u") == "hi"
|
||||
url, tok, body = sent[-1]
|
||||
assert url == "https://api.anthropic.com/v1/chat/completions" and tok == "ak" and body["model"] == "claude-sonnet-5-5"
|
||||
assert url == "https://api.anthropic.com/v1/messages" and tok == "ak" and body["model"] == "claude-sonnet-5-5", (url, body)
|
||||
assert body["system"] == [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}] and body["max_tokens"] == 4000
|
||||
assert body["messages"] == [{"role": "user", "content": [{"type": "text", "text": "u", "cache_control": {"type": "ephemeral"}}]}], body["messages"]
|
||||
assert not {"temperature", "venice_parameters", "reasoning_effort", "top_p", "tools"} & set(body), body
|
||||
assert tm.LEDGER[-1]["in"] == 1000 and tm.LEDGER[-1]["out"] == 2000 and tm.LEDGER[-1]["usd"] == 0.022 and "cached" not in tm.LEDGER[-1] # 1000*2 + 2000*10 per Mtok
|
||||
# cached tokens are priced: a read at a tenth, a write at a quarter more; the ledger line says how many
|
||||
w.cache_usage = {"cache_read_input_tokens": 800, "cache_creation_input_tokens": 100}
|
||||
assert tm.think("sonnet", None, None, "sys", "u") == "hi"
|
||||
assert tm.LEDGER[-1]["in"] == 1900 and tm.LEDGER[-1]["cached"] == 800 and tm.LEDGER[-1]["written"] == 100, tm.LEDGER[-1]
|
||||
assert tm.LEDGER[-1]["usd"] == round((1000 * 2 + 800 * 0.2 + 100 * 2.5 + 2000 * 10) / 1e6, 6), tm.LEDGER[-1]
|
||||
w.cache_usage = {}
|
||||
# TIME_ANTHROPIC_API=compat: the OpenAI-compatible endpoint, as before (no caching)
|
||||
os.environ["TIME_ANTHROPIC_API"] = "compat"
|
||||
assert tm.think("sonnet", None, "high", "sys", "u") == "hi"
|
||||
url, tok, body = sent[-1]
|
||||
assert url == "https://api.anthropic.com/v1/chat/completions" and body["messages"][0]["role"] == "system"
|
||||
assert not {"temperature", "venice_parameters", "reasoning_effort", "top_p"} & set(body), body
|
||||
assert tm.LEDGER[-1]["in"] == 1000 and tm.LEDGER[-1]["out"] == 2000 and tm.LEDGER[-1]["usd"] == 0.022 # 1000*2 + 2000*10 per Mtok
|
||||
os.environ.pop("TIME_ANTHROPIC_API")
|
||||
# the translation itself: tools, tool calls and results, the two cache breakpoints, and a turn's blocks kept verbatim
|
||||
tm._turns.clear()
|
||||
oa_tools = [{"type": "function", "function": {"name": "read_file", "description": "Read", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}}}]
|
||||
rep = tm.from_messages({"content": [{"type": "thinking", "thinking": "hm", "signature": "sig"}, {"type": "text", "text": "Looking."},
|
||||
{"type": "tool_use", "id": "tu1", "name": "read_file", "input": {"path": "a.py"}}],
|
||||
"stop_reason": "tool_use", "usage": {"input_tokens": 10, "output_tokens": 5, "cache_read_input_tokens": 90}})
|
||||
m = rep["choices"][0]["message"]
|
||||
assert m["content"] == "Looking." and m["tool_calls"] == [{"id": "tu1", "type": "function", "function": {"name": "read_file", "arguments": '{"path": "a.py"}'}}]
|
||||
assert rep["usage"] == {"prompt_tokens": 100, "completion_tokens": 5, "cache_read": 90, "cache_write": 0} and rep["choices"][0]["finish_reason"] == "tool_use"
|
||||
req = tm.to_messages({"model": "claude-sonnet-5-5", "max_tokens": 16000, "tools": oa_tools, "messages": [
|
||||
{"role": "system", "content": "rules"}, {"role": "user", "content": "Begin."},
|
||||
{"role": "assistant", "content": m["content"], "tool_calls": m["tool_calls"]},
|
||||
{"role": "tool", "tool_call_id": "tu1", "content": "x = 1"}, {"role": "tool", "tool_call_id": "tu2", "content": "y"}]})
|
||||
assert req["tools"] == [{"name": "read_file", "description": "Read", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}}}]
|
||||
assert req["messages"][1]["content"][0] == {"type": "thinking", "thinking": "hm", "signature": "sig"}, "the turn goes back as the API gave it"
|
||||
assert [b["type"] for b in req["messages"][1]["content"]] == ["thinking", "text", "tool_use"]
|
||||
assert req["messages"][2]["role"] == "user" and [b["tool_use_id"] for b in req["messages"][2]["content"]] == ["tu1", "tu2"], "one user turn carries both results"
|
||||
assert "cache_control" in req["messages"][2]["content"][-1] and "cache_control" not in req["messages"][2]["content"][0]
|
||||
assert "cache_control" not in req["messages"][1]["content"][-1] and tm._turns["tu1"][-1].get("cache_control") is None, "the kept turn is not marked"
|
||||
assert req["system"][0]["cache_control"] == {"type": "ephemeral"} and req["max_tokens"] == 16000 and "temperature" not in req
|
||||
tm._turns.clear()
|
||||
req = tm.to_messages({"model": "claude-sonnet-5-5", "messages": [{"role": "system", "content": "r"}, {"role": "user", "content": "go"},
|
||||
{"role": "assistant", "content": None, "tool_calls": m["tool_calls"]}, {"role": "tool", "tool_call_id": "tu1", "content": "ok"}]})
|
||||
assert req["messages"][1]["content"] == [{"type": "tool_use", "id": "tu1", "name": "read_file", "input": {"path": "a.py"}}], "rebuilt when the turn is not kept"
|
||||
os.environ["VENICE_API_KEY"] = "vk"; w.venice = lambda body: {"choices": [{"message": {"content": "ok"}}]}
|
||||
assert tm.think("kimi", None, None, "s", "u") == "ok" and tm.LEDGER[-1]["usd"] is None and tm.LEDGER[-1]["in"] == 0 # unpriced, no usage
|
||||
# the ledger flushes as one appended commit
|
||||
tm._ledger_files.clear(); tm.flush_ledger()
|
||||
lines = w.files[f"time/ledger/{NOW:%Y-%m}.jsonl"].splitlines()
|
||||
assert len(lines) == 2 and tm.LEDGER == [] and json.loads(lines[0])["usd"] == 0.022
|
||||
assert len(lines) == 4 and tm.LEDGER == [] and json.loads(lines[0])["usd"] == 0.022 and json.loads(lines[1])["cached"] == 800
|
||||
# over the daily cap: Budget before any request
|
||||
sent.clear(); tm._ledger_files.clear()
|
||||
w.files[f"time/ledger/{NOW:%Y-%m}.jsonl"] = json.dumps({"at": iso(), "model": "claude-opus-5-5", "usd": 6}) + "\n"
|
||||
@@ -620,6 +834,134 @@ if _sh.which("node"):
|
||||
assert res.returncode == 0 and "aicharts: all good" in res.stdout, res.stdout[-2000:] + res.stderr[-2000:]
|
||||
print("ALL OK 41")
|
||||
|
||||
# 79 mentions are handoffs: @codex and @claude act as their own Gitea users; a reply that ends with a mention hands on
|
||||
import tempfile as _tf79, subprocess as _sp79, time as _t79 # noqa: E402
|
||||
assert tm.mentions("@claude sonnet draft it") == {"claude": ("claude-sonnet-5-5", None)} and tm.mentions("@claude") == {"claude": (None, None)}
|
||||
assert tm.mentions("@codex high fix it") == {"codex": (None, "high")} and tm.mentions("`@codex x` and\n> @claude y") == {}
|
||||
chain = lambda t: [[(sorted(f), a) for f, a in c] for c in tm.plan(t)]
|
||||
assert chain("@sonnet a @haiku b") == [[(["haiku", "sonnet"], "@sonnet a @haiku b")]], "several seats, no arrow: side by side"
|
||||
assert len(tm.plan("@sonnet a\n@haiku b")) == 2, "a line each: independent chains"
|
||||
assert chain("@claude draft the spec → @codex implement it -> @haiku review") == [[(["claude"], "@claude draft the spec"), (["codex"], "@codex implement it"), (["haiku"], "@haiku review")]]
|
||||
assert tm.handoff("done\n\n@codex now ship it", "claude")[0][0][0] == {"codex": (None, None)} and tm.handoff("done\n@codex more", "codex") == [] and tm.handoff("I'd ask @codex later. Bye", "x") == []
|
||||
|
||||
|
||||
class MWorld(ClaudeWorld):
|
||||
"""ClaudeWorld that keeps who posted what (comments by token, PRs by token) and answers each Claude call from `say`."""
|
||||
def __init__(self, *a, **kw):
|
||||
super().__init__(*a, **kw); self.by, self.prs_made, self.say = [], [], []
|
||||
def venice(self, body):
|
||||
return {"choices": [{"message": {"role": "assistant", "content": self.say.pop(0)}}]}
|
||||
def http(self, method, url, token, body=None, timeout=0, headers=None):
|
||||
path = url.replace(tm.GH, "")
|
||||
if method == "POST" and path.endswith("/comments"): self.by.append((int(path.split("/")[-2]), token, body["body"]))
|
||||
if method == "POST" and path == "/repos/o/r/pulls": self.prs_made.append((token, body))
|
||||
if method == "GET" and path.endswith("/comments?limit=30"): return self.comments.get(int(path.split("/")[-2]), [])
|
||||
return super().http(method, url, token, body, timeout, headers)
|
||||
|
||||
|
||||
bin79 = _tf79.mkdtemp()
|
||||
with open(bin79 + "/codex", "w") as f: # a stand-in for the codex CLI: records how it was called, plays a scripted run
|
||||
f.write('''#!/bin/sh
|
||||
out=""; args=""; while [ $# -gt 0 ]; do case "$1" in -o) out="$2"; shift;; esac; args="$args $1"; shift; done
|
||||
echo "$args" > "$FAKE_LOG"; env > "$FAKE_LOG.env"; cat > "$FAKE_LOG.prompt"
|
||||
[ -n "$FAKE_SLEEP" ] && sleep "$FAKE_SLEEP"
|
||||
[ -n "$FAKE_WRITE" ] && echo hi > "$FAKE_WRITE"
|
||||
echo '{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":10,"output_tokens":20}}'
|
||||
printf '%s' "$FAKE_REPLY" > "$out"
|
||||
''')
|
||||
os.chmod(bin79 + "/codex", 0o755)
|
||||
os.environ.update(PATH=bin79 + os.pathsep + os.environ["PATH"], FAKE_LOG=bin79 + "/log", FAKE_SLEEP="", FAKE_WRITE="", FAKE_REPLY="")
|
||||
clones, pushed = [], []
|
||||
def clone79(token):
|
||||
d = _tf79.mkdtemp(prefix="fake-clone-"); clones.append(token)
|
||||
_sp79.run(["git", "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init"], cwd=d, capture_output=True) if _sp79.run(["git", "init", "-q"], cwd=d).returncode == 0 else None
|
||||
return d
|
||||
tm.codex_clone = clone79
|
||||
tm.codex_push = lambda d, token, branch: pushed.append((token, branch, _sp79.run(["git", "log", "-1", "--format=%an|%s"], cwd=d, capture_output=True, text=True).stdout.strip()))
|
||||
|
||||
def mention79(w, body, login="owner", env=None, number=5):
|
||||
for k in [k for k in os.environ if k.startswith("TIME_")]: os.environ.pop(k)
|
||||
os.environ.update({"ANTHROPIC_API_KEY": "ak", "TIME_SEAT_CLAUDE": "claude-tok", "TIME_SEAT_CODEX": "codex-tok", **(env or {})})
|
||||
tm.LEDGER.clear(); tm._ledger_files.clear(); tm._trust.clear(); tm.http = w.http; tm.think = real_think
|
||||
ev = _tf79.NamedTemporaryFile("w", suffix=".json", delete=False)
|
||||
json.dump({"issue": {"number": number, "title": "T", "body": "b", "user": {"login": "owner"}},
|
||||
"comment": {"user": {"login": login}, "body": body}}, ev); ev.close()
|
||||
os.environ["GITHUB_EVENT_PATH"] = ev.name
|
||||
try: tm.cmd_mention()
|
||||
finally: os.unlink(ev.name)
|
||||
return w
|
||||
|
||||
# a trusted @codex runs the CLI in a checkout, as codex: pushes its commit, opens the PR, posts the result, all with codex's token
|
||||
os.environ.update(FAKE_REPLY="Added hi.txt.", FAKE_WRITE="hi.txt")
|
||||
n0 = len(sent)
|
||||
w = mention79(MWorld([]), "@codex add hi.txt please")
|
||||
assert clones == ["codex-tok"] and len(pushed) == 1 and pushed[0][0] == "codex-tok" and pushed[0][1].startswith("codex/issue-5-"), (clones, pushed)
|
||||
assert pushed[0][2].startswith("codex|codex: T"), "the work is committed as codex"
|
||||
assert len(w.prs_made) == 1 and w.prs_made[0][0] == "codex-tok" and w.prs_made[0][1]["head"] == pushed[0][1] and w.prs_made[0][1]["base"] == "main"
|
||||
assert len(w.by) == 1 and w.by[0][:2] == (5, "codex-tok") and "Added hi.txt." in w.by[0][2] and "Opened PR #42" in w.by[0][2], w.by
|
||||
assert "<sub>codex via Time · codex</sub>" in w.by[0][2] and w.by[0][2].startswith("@owner ") and tm.MARK in w.by[0][2] and len(sent) == n0
|
||||
args = open(bin79 + "/log").read(); assert "exec --sandbox workspace-write --json -C " in args and args.rstrip().endswith("-")
|
||||
assert "add hi.txt please" in open(bin79 + "/log.prompt").read()
|
||||
seen_env = open(bin79 + "/log.env").read(); assert "codex-tok" not in seen_env and "TIME_SEAT" not in seen_env and "ANTHROPIC_API_KEY" not in seen_env
|
||||
assert [(r["model"], r["in"], r["out"], r["usd"], r.get("launch")) for r in tm.LEDGER] == [("codex", 100, 20, None, 1)], tm.LEDGER
|
||||
|
||||
# a stranger's mention does nothing: no run, no clone, no post, no spend
|
||||
clones.clear(); pushed.clear(); n0 = len(sent)
|
||||
w = mention79(MWorld([]), "@codex delete everything @claude too", login="stranger")
|
||||
assert w.by == [] and w.posted == [] and clones == [] and len(sent) == n0 and tm.LEDGER == []
|
||||
|
||||
# a reply that ends by mentioning another seat is a handoff: codex's reply goes to claude, who answers as claude (opus by default)
|
||||
os.environ.update(FAKE_REPLY="Nothing to change.\n\n@claude summarise this for the log", FAKE_WRITE="")
|
||||
pushed.clear(); n0 = len(sent)
|
||||
w = MWorld([]); w.say = ["The summary."]
|
||||
w = mention79(w, "@codex look at the thing")
|
||||
assert [(b[0], b[1]) for b in w.by] == [(5, "codex-tok"), (5, "claude-tok")] and pushed == [] and w.prs_made == [], w.by
|
||||
assert "The summary." in w.by[1][2] and "<sub>claude via Time · claude-opus-5-5</sub>" in w.by[1][2]
|
||||
assert sent[n0][2]["model"] == "claude-opus-5-5" and "Nothing to change." in json.dumps(sent[n0][2]) and "# README.md" in json.dumps(sent[n0][2])
|
||||
assert [r["model"] for r in tm.LEDGER] == ["codex", "claude-opus-5-5"] and tm.LEDGER[1]["usd"] == 0.044 and tm.LEDGER[0].get("launch") == 1
|
||||
|
||||
# an arrow chain runs stage by stage with no human between: claude (sonnet) -> codex -> haiku, each reading what came before
|
||||
os.environ.update(FAKE_REPLY="Implemented the spec.")
|
||||
n0 = len(sent); w = MWorld([]); w.say = ["Spec: do X.", "Reviewed: fine."]
|
||||
w = mention79(w, "@claude sonnet draft the spec → @codex implement it → @haiku review")
|
||||
assert [(b[1]) for b in w.by] == ["claude-tok", "codex-tok", "gh"], [b[1] for b in w.by] # haiku has no seat token: Time posts
|
||||
assert "Spec: do X." in w.by[0][2] and "Implemented the spec." in w.by[1][2] and "Reviewed: fine." in w.by[2][2]
|
||||
assert [s[2]["model"] for s in sent[n0:]] == ["claude-sonnet-5-5", "claude-haiku-5-5"]
|
||||
assert "Spec: do X." in open(bin79 + "/log.prompt").read() and "Implemented the spec." in json.dumps(sent[-1][2])
|
||||
assert [r["model"] for r in tm.LEDGER] == ["claude-sonnet-5-5", "codex", "claude-haiku-5-5"]
|
||||
|
||||
# several seats on separate lines run in parallel (neither waits on the other); no arrow, no ordering promise
|
||||
n0 = len(sent); w = MWorld([]); w.say = ["from sonnet", "from haiku"]
|
||||
w = mention79(w, "@sonnet look\n@haiku look too")
|
||||
assert sorted(s[2]["model"] for s in sent[n0:]) == ["claude-haiku-5-5", "claude-sonnet-5-5"] and len(w.by) == 2
|
||||
|
||||
# a ping-pong between two seats stops at TIME_HANDOFF_MAX runs; a seat never hands off to itself
|
||||
os.environ.update(FAKE_REPLY="again\n@claude your turn")
|
||||
w = MWorld([]); w.say = ["back\n@codex again"] * 5
|
||||
w = mention79(w, "@codex start", env={"TIME_HANDOFF_MAX": "3"})
|
||||
assert [b[1] for b in w.by] == ["codex-tok", "claude-tok", "codex-tok"], [b[1] for b in w.by]
|
||||
os.environ.update(FAKE_REPLY="ok\n@codex more")
|
||||
assert len(mention79(MWorld([]), "@codex go").by) == 1
|
||||
|
||||
# the wall-clock cap trips cleanly: the run is killed, nothing is pushed, one launch is booked, the thread is told
|
||||
os.environ.update(FAKE_SLEEP="30", FAKE_REPLY="never", FAKE_WRITE="late.txt"); pushed.clear()
|
||||
t0 = _t79.monotonic()
|
||||
w = mention79(MWorld([]), "@codex take your time", env={"TIME_SEAT_RUN_MINUTES": "0.03"})
|
||||
assert _t79.monotonic() - t0 < 15 and pushed == [] and w.prs_made == []
|
||||
assert len(w.by) == 1 and w.by[0][1] == "codex-tok" and "wall-clock cap" in w.by[0][2] and "TIME_SEAT_RUN_MINUTES" in w.by[0][2], w.by
|
||||
assert [(r["model"], r.get("launch")) for r in tm.LEDGER] == [("codex", 1)]
|
||||
os.environ.update(FAKE_SLEEP="", FAKE_WRITE="")
|
||||
|
||||
# no token / no key / a spent budget: the seat says so instead of running
|
||||
w = mention79(MWorld([]), "@codex hi", env={"TIME_SEAT_CODEX": ""})
|
||||
assert "no TIME_SEAT_CODEX" in w.by[0][2] and w.by[0][1] == "gh" and tm.LEDGER == []
|
||||
os.environ["TIME_SEAT_CODEX"] = "codex-tok"
|
||||
w2 = MWorld([], ledger=[{"at": iso(), "model": "claude-opus-5-5", "usd": 6}]); clones.clear()
|
||||
w2 = mention79(w2, "@codex hi", env={"TIME_DAILY_USD": "5"})
|
||||
assert "can't answer yet" in w2.by[0][2] and "cap" in w2.by[0][2] and clones == []
|
||||
print("ALL OK 79")
|
||||
|
||||
|
||||
# ---------- venture loop: still GitHub-shaped (Pages, author_association, per_page). Ported in its own card;
|
||||
# until then these run only with TIME_TEST_VENTURE=1 against the old fake.
|
||||
if not os.environ.get("TIME_TEST_VENTURE"):
|
||||
|
||||
+549
-54
@@ -1,6 +1,6 @@
|
||||
"""Time: the clock for a Gitea-native agent factory. stdlib only; runs on Gitea Actions (prod: teahub.xyz).
|
||||
|
||||
python3 .github/time/time.py mention # on issue/comment events: answer @seat [model] [effort]
|
||||
python3 .github/time/time.py mention # on issue/comment events: answer @seat [model] [effort]; @codex / @claude act as their own users
|
||||
python3 .github/time/time.py heartbeat # on schedule: plan specs, dispatch `ready` cards, merge approved PRs, pull the next card
|
||||
|
||||
Ledger = this Gitea repo. Brains = Venice (VENICE_API_KEY) or Claude (ANTHROPIC_API_KEY), per seat in .github/time/seats.json.
|
||||
@@ -21,9 +21,11 @@ import importlib.util
|
||||
import atexit
|
||||
import base64
|
||||
import contextlib
|
||||
import threading
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -44,10 +46,10 @@ MARK = "<!-- time "
|
||||
EFFORTS = {"low": "low", "med": "medium", "medium": "medium", "high": "high", "xhigh": "high", "max": "high"}
|
||||
|
||||
|
||||
def http(method: str, url: str, token: str, body=None, timeout: float = 120.0):
|
||||
def http(method: str, url: str, token: str, body=None, timeout: float = 120.0, headers: dict | None = None):
|
||||
req = urllib.request.Request(url, method=method, data=json.dumps(body).encode() if body is not None else None,
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json",
|
||||
"Accept": "application/json"})
|
||||
"Accept": "application/json", **(headers or {})})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
t = r.read()
|
||||
@@ -167,18 +169,29 @@ def resolve(seat: str, word: str) -> str | None:
|
||||
return min(near, key=lambda i: (len(i), i)) if near else None
|
||||
|
||||
|
||||
CLAUDE_PICK = ("haiku", "sonnet", "opus", "fable") # `@claude sonnet`: which Claude brain; opus without a word
|
||||
AGENT_SEATS = {"claude": "Claude (Anthropic)", "codex": "Codex CLI (OpenAI)"} # seats that are not in seats.json: they post as their own Gitea users
|
||||
|
||||
|
||||
def _plain(text: str) -> str:
|
||||
"""What counts as speech: no code, no quoted lines."""
|
||||
text = re.sub(r"```.*?```|`[^`]*`", "", text, flags=re.S)
|
||||
return "\n".join(l for l in text.splitlines() if not l.lstrip().startswith(">"))
|
||||
|
||||
|
||||
def mentions(text: str) -> dict[str, tuple[str | None, str | None]]:
|
||||
if MARK in text:
|
||||
return {}
|
||||
text = re.sub(r"```.*?```|`[^`]*`", "", text, flags=re.S)
|
||||
text = "\n".join(l for l in text.splitlines() if not l.lstrip().startswith(">"))
|
||||
pat = re.compile(r"(?<![\w@/.-])@(" + "|".join(SEATS) + r")\b((?:[ \t]+[^\s@`]+){0,2})", re.I)
|
||||
text = _plain(text)
|
||||
chat_seats = [k for k, v in SEATS.items() if v.get("route") != "cli"] + list(AGENT_SEATS) # a CLI seat (Codex ladder) has no chat route
|
||||
pat = re.compile(r"(?<![\w@/.-])@(" + "|".join(chat_seats) + r")\b((?:[ \t]+[^\s@`]+){0,2})", re.I)
|
||||
out = {}
|
||||
for m in pat.finditer(text):
|
||||
seat, model, effort = m.group(1).lower(), None, None
|
||||
for w in m.group(2).split():
|
||||
w = w.strip(",.:;!?)(").lower()
|
||||
if model is None and (r := resolve(seat, w)) and not (w in EFFORTS and w not in SEATS[seat].get("aliases", {})):
|
||||
r = (SEATS[w]["default"] if w in CLAUDE_PICK else None) if seat == "claude" else None if seat == "codex" else resolve(seat, w)
|
||||
if model is None and r and not (w in EFFORTS and w not in SEATS.get(seat, {}).get("aliases", {})):
|
||||
model = r
|
||||
elif effort is None and w in EFFORTS:
|
||||
effort = EFFORTS[w]
|
||||
@@ -188,6 +201,38 @@ def mentions(text: str) -> dict[str, tuple[str | None, str | None]]:
|
||||
return out
|
||||
|
||||
|
||||
ARROW = re.compile(r"\s*(?:→|->|=>)\s*")
|
||||
|
||||
|
||||
def plan(text: str) -> list[list[tuple[dict, str]]]:
|
||||
"""The launcher's grammar. One chain per line that mentions a seat; chains are independent (parallel). In a chain an
|
||||
arrow separates stages: `@claude draft the spec → @codex implement it → @haiku review` runs claude, then codex on
|
||||
claude's reply, then haiku. Several seats in one stage, without an arrow, run side by side. A stage is (the seats it
|
||||
mentions, its own words)."""
|
||||
if MARK in text:
|
||||
return []
|
||||
chains = []
|
||||
for line in _plain(text).splitlines():
|
||||
stages = [(found, seg.strip()) for seg in ARROW.split(line) if (found := mentions(seg))]
|
||||
if stages:
|
||||
chains.append(stages)
|
||||
return chains
|
||||
|
||||
|
||||
def handoff(reply: str, me: str) -> list[list[tuple[dict, str]]]:
|
||||
"""A reply that ends by mentioning another seat hands the thread to it: the chains on the reply's last line, minus
|
||||
the seat itself (no one hands off to itself). A mention in the middle of a sentence is just a mention."""
|
||||
lines = [l for l in reply.strip().splitlines() if l.strip()]
|
||||
out = []
|
||||
last = lines[-1] if lines and re.match(r"\s*(?:[-*]\s+|→\s*|->\s*)?@", lines[-1]) else "" # the line starts with the mention
|
||||
for stages in plan(last):
|
||||
stages = [({k: v for k, v in found.items() if k != me}, ask) for found, ask in stages]
|
||||
stages = [s for s in stages if s[0]]
|
||||
if stages:
|
||||
out.append(stages)
|
||||
return out
|
||||
|
||||
|
||||
def provider_of(model: str) -> str:
|
||||
for s in SEATS.values():
|
||||
if model == s["default"] or model in s.get("aliases", {}).values():
|
||||
@@ -201,11 +246,11 @@ def price_of(model: str) -> list | None:
|
||||
if s.get("price") and (model == s["default"] or model in s.get("aliases", {}).values())), None)
|
||||
|
||||
|
||||
def _post(url: str, key: str, body: dict, timeout: float, who: str) -> dict:
|
||||
def _post(url: str, key: str, body: dict, timeout: float, who: str, headers: dict | None = None) -> dict:
|
||||
"""One retry loop for every provider: brief overloads (429, 5xx, Anthropic's 529) and dropped connections back off."""
|
||||
for attempt in range(4):
|
||||
try:
|
||||
return http("POST", url, key, body, timeout=timeout)
|
||||
return http("POST", url, key, body, timeout=timeout, **({"headers": headers} if headers else {}))
|
||||
except (RuntimeError, OSError) as e:
|
||||
transient = isinstance(e, OSError) or re.search(r"-> (429|5\d\d)\b", str(e))
|
||||
if attempt == 3 or not transient:
|
||||
@@ -233,10 +278,93 @@ def anthropic_chat(body: dict, timeout: float) -> dict:
|
||||
return _post(f"{ANTHROPIC}/chat/completions", key, body, timeout, "anthropic")
|
||||
|
||||
|
||||
ANTHROPIC_VERSION = "2023-06-01"
|
||||
_turns: dict[str, list] = {} # tool_use id -> the assistant turn's content blocks as the API gave them (thinking included)
|
||||
|
||||
|
||||
def _args(raw) -> dict:
|
||||
try:
|
||||
out = json.loads(raw or "{}")
|
||||
except ValueError:
|
||||
return {}
|
||||
return out if isinstance(out, dict) else {}
|
||||
|
||||
|
||||
def to_messages(body: dict) -> dict:
|
||||
"""The engine's OpenAI-shaped request as an Anthropic Messages request, with the prompt marked for caching: the tools
|
||||
and system prompt are one breakpoint and the conversation so far is the other, so each step of a worker run pays for
|
||||
the context the step before it already sent at a tenth of the price. An assistant turn that issued tool calls goes
|
||||
back exactly as the API gave it (thinking blocks included), which the API requires; tool results of one turn travel
|
||||
in one user message."""
|
||||
system, msgs = [], []
|
||||
for m in body["messages"]:
|
||||
role, content = m.get("role"), m.get("content")
|
||||
if role == "system":
|
||||
system.append(content or "")
|
||||
elif role == "user":
|
||||
msgs.append({"role": "user", "content": [{"type": "text", "text": content or ""}]})
|
||||
elif role == "assistant":
|
||||
calls = m.get("tool_calls") or []
|
||||
blocks = list(_turns.get(calls[0]["id"]) or []) if calls else []
|
||||
if not blocks:
|
||||
blocks = ([{"type": "text", "text": content}] if content else []) + [
|
||||
{"type": "tool_use", "id": c["id"], "name": c["function"]["name"], "input": _args(c["function"].get("arguments"))}
|
||||
for c in calls]
|
||||
if blocks:
|
||||
msgs.append({"role": "assistant", "content": blocks})
|
||||
elif role == "tool":
|
||||
block = {"type": "tool_result", "tool_use_id": m.get("tool_call_id") or "", "content": str(content or "")}
|
||||
if msgs and msgs[-1]["role"] == "user" and msgs[-1]["content"][-1].get("type") == "tool_result":
|
||||
msgs[-1]["content"].append(block)
|
||||
else:
|
||||
msgs.append({"role": "user", "content": [block]})
|
||||
out = {"model": body["model"], "max_tokens": int(body.get("max_tokens") or 4000), "messages": msgs}
|
||||
if body.get("tools"):
|
||||
out["tools"] = [{"name": t["function"]["name"], "description": t["function"].get("description") or "",
|
||||
"input_schema": t["function"].get("parameters") or {"type": "object", "properties": {}}} for t in body["tools"]]
|
||||
if system:
|
||||
out["system"] = [{"type": "text", "text": "\n\n".join(system), "cache_control": {"type": "ephemeral"}}]
|
||||
if msgs and msgs[-1]["content"]: # the last block: next step's request shares everything up to here
|
||||
msgs[-1]["content"][-1] = {**msgs[-1]["content"][-1], "cache_control": {"type": "ephemeral"}}
|
||||
return out
|
||||
|
||||
|
||||
def from_messages(out: dict) -> dict:
|
||||
"""An Anthropic Messages reply in the OpenAI shape, with the cache split kept in usage for the ledger."""
|
||||
blocks = out.get("content") or []
|
||||
text = "".join(b.get("text") or "" for b in blocks if b.get("type") == "text") or None
|
||||
calls = [{"id": b["id"], "type": "function", "function": {"name": b["name"], "arguments": json.dumps(b.get("input") or {})}}
|
||||
for b in blocks if b.get("type") == "tool_use"]
|
||||
if calls:
|
||||
_turns[calls[0]["id"]] = blocks
|
||||
u = out.get("usage") or {}
|
||||
read, written = int(u.get("cache_read_input_tokens") or 0), int(u.get("cache_creation_input_tokens") or 0)
|
||||
usage = {"prompt_tokens": int(u.get("input_tokens") or 0) + read + written, "completion_tokens": int(u.get("output_tokens") or 0),
|
||||
"cache_read": read, "cache_write": written}
|
||||
msg = {"role": "assistant", "content": text, **({"tool_calls": calls} if calls else {})}
|
||||
return {"choices": [{"message": msg, "finish_reason": out.get("stop_reason")}], "usage": usage}
|
||||
|
||||
|
||||
def anthropic_messages(body: dict, timeout: float) -> dict:
|
||||
"""POST to Anthropic's own Messages API, with prompt caching (the OpenAI-compatible endpoint has none, and a 40-step
|
||||
worker run re-sent its whole context at full price every step: a failed run cost $2.80). Speaks the engine's OpenAI
|
||||
shape on both sides. TIME_ANTHROPIC_API=compat goes back to the compatible endpoint."""
|
||||
key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if not key:
|
||||
raise LookupError("no ANTHROPIC_API_KEY secret on this repo yet")
|
||||
out = _post(f"{ANTHROPIC}/messages", key, to_messages(body), timeout, "anthropic",
|
||||
headers={"x-api-key": key, "anthropic-version": ANTHROPIC_VERSION})
|
||||
return from_messages(out)
|
||||
|
||||
|
||||
def chat(body: dict, timeout: float) -> dict:
|
||||
"""Every model call goes through here: the budget is checked first and the spend booked after."""
|
||||
budget_check()
|
||||
out = (anthropic_chat if provider_of(body["model"]) == "anthropic" else venice_chat)(body, timeout)
|
||||
if provider_of(body["model"]) == "anthropic":
|
||||
native = (os.environ.get("TIME_ANTHROPIC_API") or "messages").strip().lower() != "compat"
|
||||
out = (anthropic_messages if native else anthropic_chat)(body, timeout)
|
||||
else:
|
||||
out = venice_chat(body, timeout)
|
||||
book(body["model"], out.get("usage") or {})
|
||||
return out
|
||||
|
||||
@@ -273,13 +401,17 @@ def ledger_lines(month: str) -> list[dict]:
|
||||
return _ledger_files[month][0]
|
||||
|
||||
|
||||
def book(model: str, usage: dict) -> None:
|
||||
def book(model: str, usage: dict, **extra) -> None:
|
||||
"""One ledger line per call. `in` counts every prompt token; `cached` says how many of them were read from the cache
|
||||
(a tenth of the input price) and `written` how many were written to it (a quarter more than the input price)."""
|
||||
p = price_of(model)
|
||||
i, o = int(usage.get("prompt_tokens") or 0), int(usage.get("completion_tokens") or 0)
|
||||
r, w = int(usage.get("cache_read") or 0), int(usage.get("cache_write") or 0)
|
||||
if not LEDGER: # first line: every entry point (heartbeat, mention, venture) flushes without remembering to
|
||||
atexit.register(flush_ledger)
|
||||
usd = round(((i - r - w) * p[0] + r * p[0] * 0.1 + w * p[0] * 1.25 + o * p[1]) / 1e6, 6) if p else None
|
||||
LEDGER.append({"at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "model": model, "in": i, "out": o,
|
||||
"usd": round((i * p[0] + o * p[1]) / 1e6, 6) if p else None})
|
||||
"usd": usd, **({"cached": r, "written": w} if r or w else {}), **extra})
|
||||
|
||||
|
||||
def flush_ledger() -> None:
|
||||
@@ -309,7 +441,11 @@ def budget() -> tuple[float, float, float, float]:
|
||||
spend(now.strftime("%Y-%m")), float(os.environ.get("TIME_MONTHLY_USD") or 100))
|
||||
|
||||
|
||||
_slack = 1.0 # what budget_check allows over the daily cap: 1.0 for a new launch, TIME_CAP_SLACK inside finishing()
|
||||
_slack = threading.local() # per thread (#75 overlaps workers): what budget_check allows over the daily cap, 1.0 outside finishing()
|
||||
|
||||
|
||||
def slack() -> float:
|
||||
return getattr(_slack, "value", 1.0)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -318,20 +454,19 @@ def finishing():
|
||||
was launched within budget finishes its card, and a PR that is already open gets its review, instead of dying
|
||||
mid-run with the spend so far lost (a card stopped at step 27 of 40 has cost its whole run and produced nothing).
|
||||
A new launch never gets the slack, and the monthly cap is hard everywhere."""
|
||||
global _slack
|
||||
old, _slack = _slack, max(1.0, float(os.environ.get("TIME_CAP_SLACK") or 1.5))
|
||||
old, _slack.value = slack(), max(1.0, float(os.environ.get("TIME_CAP_SLACK") or 1.5))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_slack = old
|
||||
_slack.value = old
|
||||
|
||||
|
||||
def budget_check() -> None:
|
||||
today, day_cap, month, month_cap = budget()
|
||||
if month >= month_cap:
|
||||
raise Budget(f"this month's spend is ${month:.2f} of the ${month_cap:.2f} cap; Time is back in dry-run until a human raises it")
|
||||
if today >= day_cap * _slack:
|
||||
allowance = f" (a run under way may go to ${day_cap * _slack:.2f})" if _slack > 1 else ""
|
||||
if today >= day_cap * slack():
|
||||
allowance = f" (a run under way may go to ${day_cap * slack():.2f})" if slack() > 1 else ""
|
||||
raise Budget(f"today's spend is ${today:.2f} of the ${day_cap:.2f} cap{allowance}; carrying on tomorrow")
|
||||
|
||||
|
||||
@@ -374,7 +509,210 @@ def think(seat: str, model: str | None, effort: str | None, system: str, user: s
|
||||
|
||||
RULES = """You were @mentioned in an issue or PR thread of a small agent factory. Reply as yourself, in the thread.
|
||||
Answer what was asked, concretely and briefly. You can't run code or browse; say so if the ask needs that.
|
||||
Never invent links, numbers or results. If you are only mentioned in passing, reply with exactly NO_REPLY."""
|
||||
Never invent links, numbers or results. If you are only mentioned in passing, reply with exactly NO_REPLY.
|
||||
To hand the thread to another seat (@codex, @claude, @haiku...), end your reply with a last line that starts with its
|
||||
@mention and says what you want of it; Time then runs that seat on your reply. Otherwise don't @mention a seat on your last line."""
|
||||
|
||||
HANDOFF_MAX = 6 # seat runs one trusted comment may set off, handoffs included (TIME_HANDOFF_MAX): a ping-pong ends here
|
||||
|
||||
|
||||
def post_as(issue: int, seat: str, text: str, meta: dict) -> str:
|
||||
"""Post a reply as the seat's own Gitea user when it has a token (`TIME_SEAT_<SEAT>`); else, or if Gitea refuses, as
|
||||
Time. Always marked, so the event our own comment fires is skipped and a handoff is never dispatched twice."""
|
||||
token = seat_token(seat)
|
||||
if token:
|
||||
try:
|
||||
http("POST", f"{GH}/repos/{REPO}/issues/{issue}/comments", token,
|
||||
{"body": f"{text}\n\n{MARK}{json.dumps(meta, separators=(',', ':'))} -->"})
|
||||
return seat
|
||||
except RuntimeError as e:
|
||||
print(f"comment as {seat} refused, commenting as Time: {e}")
|
||||
comment(issue, text, meta)
|
||||
return "time"
|
||||
|
||||
|
||||
def repo_context(issue: dict) -> str:
|
||||
"""What Time already hands a reviewer, for a seat that was only asked: the README, and on a PR its diff and checks."""
|
||||
parts = []
|
||||
try:
|
||||
parts.append("# README.md\n" + base64.b64decode(gh("GET", f"/repos/{REPO}/contents/README.md")["content"]).decode(errors="replace")[:6000])
|
||||
except Exception: # noqa: BLE001 - context is a help, not a gate
|
||||
pass
|
||||
if issue.get("pull_request"):
|
||||
try:
|
||||
pr = gh("GET", f"/repos/{REPO}/pulls/{issue['number']}")
|
||||
parts.append(f"# Checks\n{check_lines((pr.get('head') or {}).get('sha'))}\n\n# Diff\n{pr_diff(issue['number'], 40_000)}")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def codex_clone(token: str) -> str:
|
||||
"""A throwaway checkout of the repo's default branch, cloned as the codex user, with no token left in .git/config."""
|
||||
d = tempfile.mkdtemp(prefix="time-codex-")
|
||||
try:
|
||||
subprocess.run(["git", "clone", "-q", "--depth", "1", _seat_url(token), d], check=True, capture_output=True)
|
||||
subprocess.run(["git", "remote", "set-url", "origin", _git_url(False)], cwd=d, check=True, capture_output=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
raise RuntimeError(f"clone failed: {(e.stderr or b'').decode(errors='replace').replace(token, '***')[-300:]}") from None
|
||||
return d
|
||||
|
||||
|
||||
def _seat_url(token: str) -> str:
|
||||
if SERVER.startswith("file:"):
|
||||
return _git_url(False)
|
||||
return SERVER.replace("://", f"://x-access-token:{token}@", 1) + f"/{REPO}.git"
|
||||
|
||||
|
||||
def codex_push(d: str, token: str, branch: str) -> None:
|
||||
p = subprocess.run(["git", "push", "-q", _seat_url(token), f"HEAD:refs/heads/{branch}"], cwd=d, capture_output=True, text=True)
|
||||
if p.returncode:
|
||||
raise RuntimeError(f"push failed: {(p.stderr or p.stdout).replace(token, '***')[-300:]}")
|
||||
|
||||
|
||||
def run_capped(cmd: list[str], cwd: str, stdin: str, env: dict, seconds: float) -> tuple[int | None, str]:
|
||||
"""Run to the end or to the wall-clock cap, then kill the whole process group. (None, output) means the cap tripped."""
|
||||
p = subprocess.Popen(cmd, cwd=cwd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, start_new_session=True)
|
||||
try:
|
||||
out, _ = p.communicate(stdin, timeout=seconds)
|
||||
return p.returncode, out or ""
|
||||
except subprocess.TimeoutExpired:
|
||||
with contextlib.suppress(ProcessLookupError, PermissionError):
|
||||
os.killpg(p.pid, signal.SIGKILL)
|
||||
out, _ = p.communicate()
|
||||
return None, out or ""
|
||||
|
||||
|
||||
def codex_usage(out: str) -> dict:
|
||||
"""Token counts from `codex exec --json` (its turn.completed events), best effort: a line that isn't JSON is noise."""
|
||||
use = {"prompt_tokens": 0, "completion_tokens": 0, "cache_read": 0}
|
||||
for line in out.splitlines():
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(ev, dict) and ev.get("type") == "turn.completed" and isinstance(ev.get("usage"), dict):
|
||||
u = ev["usage"]
|
||||
use["prompt_tokens"] += int(u.get("input_tokens") or 0)
|
||||
use["completion_tokens"] += int(u.get("output_tokens") or 0)
|
||||
use["cache_read"] += int(u.get("cached_input_tokens") or 0)
|
||||
return use
|
||||
|
||||
|
||||
def codex_seat(issue: dict, prompt: str, effort: str | None) -> tuple[str, str]:
|
||||
"""`@codex <ask>`: a headless `codex exec` in a throwaway checkout, under a wall-clock cap (TIME_SEAT_RUN_MINUTES,
|
||||
default 15), booked as one launch. Its last message is the reply. If it left commits (or a dirty tree, which is
|
||||
committed for it), Time pushes a branch with the codex token and opens the PR as codex; the token never enters
|
||||
codex's environment. Returns (the reply, the model's name)."""
|
||||
token = seat_token("codex")
|
||||
if not token:
|
||||
raise LookupError("no TIME_SEAT_CODEX secret on this repo yet")
|
||||
exe = shutil.which("codex")
|
||||
if not exe:
|
||||
raise LookupError("`codex` is not installed where this workflow runs")
|
||||
budget_check()
|
||||
try:
|
||||
minutes = float(os.environ.get("TIME_SEAT_RUN_MINUTES") or 15)
|
||||
except ValueError:
|
||||
minutes = 15.0
|
||||
used = os.environ.get("TIME_CODEX_MODEL") or "codex"
|
||||
d = codex_clone(token)
|
||||
last = d + ".last"
|
||||
try:
|
||||
git = lambda *a: subprocess.run(["git", *a], cwd=d, capture_output=True, text=True).stdout.strip() # noqa: E731
|
||||
start = git("rev-parse", "HEAD")
|
||||
cmd = [exe, "exec", "--sandbox", "workspace-write", "--json", "-C", d, "-o", last]
|
||||
cmd += (["-m", os.environ["TIME_CODEX_MODEL"]] if os.environ.get("TIME_CODEX_MODEL") else [])
|
||||
cmd += (["-c", f'model_reasoning_effort="{effort}"'] if effort else []) + ["-"]
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if not re.match(r"TIME_|ACTIONS_|GITHUB_TOKEN$|ANTHROPIC_API_KEY$|VENICE_API_KEY$|CURSOR_API_KEY$", k)}
|
||||
code, out = run_capped(cmd, d, prompt, env, minutes * 60)
|
||||
book(used, codex_usage(out), launch=1)
|
||||
if code is None:
|
||||
return f"I hit the {minutes:g}-minute wall-clock cap (`TIME_SEAT_RUN_MINUTES`) and was stopped; nothing was pushed.", used
|
||||
if code != 0:
|
||||
return f"`codex exec` exited {code}; nothing was pushed.\n\n```\n{out[-600:]}\n```", used
|
||||
text = (Path(last).read_text(errors="replace").strip() if os.path.exists(last) else "") or "(codex returned no message)"
|
||||
host = re.sub(r"^\w+://", "", SERVER).split("/")[0] or "localhost"
|
||||
ident = ["-c", "user.name=codex", "-c", f"user.email=codex@users.noreply.{host}"]
|
||||
if git("status", "--porcelain"):
|
||||
git("add", "-A")
|
||||
git(*ident, "commit", "-q", "-m", f"codex: {issue['title'][:60]} (#{issue['number']})")
|
||||
if git("rev-parse", "HEAD") != start:
|
||||
branch = f"codex/issue-{issue['number']}-{dt.datetime.now(dt.timezone.utc):%m%d%H%M%S}"
|
||||
try:
|
||||
codex_push(d, token, branch)
|
||||
pr = http("POST", f"{GH}/repos/{REPO}/pulls", token,
|
||||
{"title": f"codex: {issue['title']}"[:200], "head": branch, "base": default_branch(),
|
||||
"body": f"{text}\n\nRefs #{issue['number']}\n\n<sub>codex via Time · {used}</sub>"})
|
||||
text += f"\n\nOpened PR #{pr['number']} from `{branch}`."
|
||||
except RuntimeError as e:
|
||||
text += f"\n\nI made changes but couldn't publish them: {str(e)[:300]}"
|
||||
return text, used
|
||||
finally:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
with contextlib.suppress(OSError):
|
||||
os.remove(last)
|
||||
|
||||
|
||||
def seat_reply(seat: str, model: str | None, effort: str | None, issue: dict, thread: list[str], author: str,
|
||||
body: str, ask: str) -> tuple[str, str]:
|
||||
"""One seat's answer: (text, the model's name)."""
|
||||
mine = f"\n\nYour part of it: {ask}" if ask and ask != body.strip() else ""
|
||||
user = "\n\n".join(thread) + f"\n\n## You are `{seat}`. Reply to @{author}:\n{body[:4000]}{mine}"
|
||||
if seat == "codex":
|
||||
prompt = (f"You are `codex`, a seat in a small agent factory, working in a fresh checkout of {REPO} (cwd). "
|
||||
"Do what you were asked in the thread below. If it needs code, change the files and leave the work in the "
|
||||
"tree (don't touch .github/workflows); Time publishes it as a pull request under your name. Your last message "
|
||||
"is posted in the thread, so make it a short account of what you did or found. To hand on to another seat, "
|
||||
"end it with a line that starts with its @mention.\n\n" + user)
|
||||
return codex_seat(issue, prompt, effort)
|
||||
if seat == "claude":
|
||||
used = model or SEATS["opus"]["default"]
|
||||
system = (f"You are `claude`, a seat in this factory, running {used}.\n\n" + RULES.replace(
|
||||
"If you are only mentioned in passing, reply with exactly NO_REPLY.", "You were asked directly, so answer."))
|
||||
ctx = repo_context(issue)
|
||||
return think("opus", used, effort, system, (ctx + "\n\n" if ctx else "") + user), used
|
||||
system = (HERE / "seats" / f"{seat}.md").read_text() if (HERE / "seats" / f"{seat}.md").exists() else \
|
||||
f"You are `{seat}`, a seat in this factory. Your brain is {SEATS[seat]['label']}."
|
||||
return think(seat, model, effort, system + "\n\n" + RULES, user), model or SEATS[seat]["default"]
|
||||
|
||||
|
||||
def run_chain(issue: dict, thread: list[str], author: str, body: str, stages: list[tuple[dict, str]], left: list[int]) -> None:
|
||||
"""Run a chain's stages in order; the seats of one stage side by side (one after another here). Each reply joins the
|
||||
thread, so the next stage reads it. After the last stage a reply's own closing mention is a handoff. `left` counts the
|
||||
seat runs still allowed for this comment."""
|
||||
for i, (found, ask) in enumerate(stages):
|
||||
replies, failed = [], False
|
||||
for seat, (model, effort) in found.items():
|
||||
if left[0] <= 0:
|
||||
print(f"handoff limit reached (TIME_HANDOFF_MAX), not running {seat}")
|
||||
return
|
||||
left[0] -= 1
|
||||
try:
|
||||
text, used = seat_reply(seat, model, effort, issue, thread, author, body, ask)
|
||||
except (LookupError, RuntimeError) as e: # no key or token, over budget, a refused clone or call
|
||||
text, used = f"I'm `{seat}`, but I can't answer yet: {e}.", "none"
|
||||
if text.strip().strip(".").upper() == "NO_REPLY":
|
||||
continue
|
||||
if seat in AGENT_SEATS:
|
||||
sig = f"<sub>{seat} via Time · {used}</sub>"
|
||||
post_as(issue["number"], seat, f"@{author} {text}\n\n{sig}", {"seat": seat, "model": used, "effort": effort})
|
||||
else:
|
||||
sig = f"<sub>{seat} · {used}" + (f" · effort {effort}" if effort else "") + " · via Time</sub>"
|
||||
comment(issue["number"], f"@{author} {text}\n\n{sig}", {"seat": seat, "model": used, "effort": effort})
|
||||
print("replied as", seat, used, effort)
|
||||
thread.append(f"**{seat}**: {text[:3000]}")
|
||||
replies.append((seat, text))
|
||||
failed = failed or used == "none"
|
||||
if failed:
|
||||
return # the chain stops where a seat could not answer
|
||||
if i + 1 == len(stages):
|
||||
for seat, text in replies: # the chain's end: a closing mention in a reply is the next hop
|
||||
for nxt in handoff(text, seat):
|
||||
run_chain(issue, thread, author, body, nxt, left)
|
||||
|
||||
|
||||
def cmd_mention() -> int:
|
||||
@@ -388,27 +726,16 @@ def cmd_mention() -> int:
|
||||
if not trusted(author.get("login")): # public repo: strangers can comment, but must not spend the seats' credits
|
||||
print("skip: not a collaborator")
|
||||
return 0
|
||||
found = mentions(src.get("body") or "")
|
||||
if not found:
|
||||
chains = plan(src.get("body") or "")
|
||||
if not chains:
|
||||
print("no seat mentioned")
|
||||
return 0
|
||||
comments = gh("GET", f"/repos/{REPO}/issues/{issue['number']}/comments?limit=30")
|
||||
thread = [f"# {issue['title']}\n{(issue.get('body') or '')[:6000]}"]
|
||||
thread += [f"**{c['user']['login']}**: {(c.get('body') or '')[:3000]}" for c in comments[-20:]]
|
||||
for seat, (model, effort) in found.items():
|
||||
system = (HERE / "seats" / f"{seat}.md").read_text() if (HERE / "seats" / f"{seat}.md").exists() else \
|
||||
f"You are `{seat}`, a seat in this factory. Your brain is {SEATS[seat]['label']}."
|
||||
user = "\n\n".join(thread) + f"\n\n## You are `{seat}`. Reply to @{author['login']}:\n{src.get('body', '')[:4000]}"
|
||||
used = model or SEATS[seat]["default"]
|
||||
try:
|
||||
text = think(seat, model, effort, system + "\n\n" + RULES, user)
|
||||
except LookupError as e:
|
||||
text, used = f"I'm `{seat}`, but I can't answer yet: {e}.", "none"
|
||||
if text.strip().strip(".").upper() == "NO_REPLY":
|
||||
continue
|
||||
sig = f"<sub>{seat} · {used}" + (f" · effort {effort}" if effort else "") + " · via Time</sub>"
|
||||
comment(issue["number"], f"@{author['login']} {text}\n\n{sig}", {"seat": seat, "model": used, "effort": effort})
|
||||
print("replied as", seat, used, effort)
|
||||
left = [int(os.environ.get("TIME_HANDOFF_MAX") or HANDOFF_MAX)] # the trust check above covers every hop: a handoff is the author's ask
|
||||
for stages in chains:
|
||||
run_chain(issue, thread, author["login"], src.get("body") or "", stages, left)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -544,19 +871,62 @@ VERDICT = re.compile(r"^[\s*_>#]*(approve|object)\b", re.I)
|
||||
DEFAULT_PROTECTED = ".github/,.gitea/,deploy,infra/,.env,secret"
|
||||
|
||||
|
||||
def verdict(pr: dict, since: str) -> str | None:
|
||||
"""Latest reviewer verdict after `since`: a comment starting Approve/Object, or a PR review."""
|
||||
def votes(pr: dict, since: str) -> dict[str, str]:
|
||||
"""Each trusted identity's latest verdict after `since`: {login: "approve" | "object"}. A comment starting
|
||||
Approve/Object or a PR review; Time's own comments are one identity (`gitea-actions`), so a seat without its own
|
||||
Gitea user shares one vote with every other such seat."""
|
||||
found = []
|
||||
for c in gh("GET", f"/repos/{REPO}/issues/{pr['number']}/comments?limit=100"):
|
||||
m = VERDICT.match(c.get("body") or "")
|
||||
ok = is_time(c) or trusted((c.get("user") or {}).get("login")) # public repos: strangers can comment
|
||||
login = (c.get("user") or {}).get("login")
|
||||
ok = is_time(c) or trusted(login) # public repos: strangers can comment
|
||||
if m and ok and c["created_at"] >= since:
|
||||
found.append((c["created_at"], len(found), m.group(1).lower())) # list order breaks a same-second tie: the newer comment wins
|
||||
found.append((c["created_at"], len(found), TIME_BOT if is_time(c) else login, m.group(1).lower())) # list order breaks a same-second tie: the newer comment wins
|
||||
for r in gh("GET", f"/repos/{REPO}/pulls/{pr['number']}/reviews?limit=100"):
|
||||
state = {"APPROVED": "approve", "REQUEST_CHANGES": "object", "CHANGES_REQUESTED": "object"}.get(r.get("state"))
|
||||
if state and trusted((r.get("user") or {}).get("login")) and (r.get("submitted_at") or "") >= since:
|
||||
found.append((r["submitted_at"], len(found), state))
|
||||
return max(found)[2] if found else None
|
||||
login = (r.get("user") or {}).get("login")
|
||||
if state and trusted(login) and (r.get("submitted_at") or "") >= since:
|
||||
found.append((r["submitted_at"], len(found), login, state))
|
||||
latest: dict[str, str] = {}
|
||||
for _, _, who, v in sorted(found):
|
||||
latest[who] = v
|
||||
return latest
|
||||
|
||||
|
||||
def standing_voters() -> int:
|
||||
"""How many identities the standing seats vote as: a seat with its own Gitea user (TIME_SEAT_<SEAT>) is one, and every
|
||||
seat that posts as Time shares one. The tie-break waits until that many have voted."""
|
||||
ids = set()
|
||||
for spec in review_specs("TIME_REVIEW"):
|
||||
kind, _, rest = spec.partition(":")
|
||||
if kind == "cursor":
|
||||
continue # a Cursor agent comments on its own schedule; it doesn't hold up the count
|
||||
seat = (rest.split() or [kind])[0] if kind in SEAT_KINDS else kind
|
||||
ids.add(seat if seat_token(seat) else TIME_BOT)
|
||||
return max(1, len(ids))
|
||||
|
||||
|
||||
def quorum() -> int:
|
||||
"""TIME_REVIEW_QUORUM: approvals needed, net of objections (default 1)."""
|
||||
try:
|
||||
return max(1, int(os.environ.get("TIME_REVIEW_QUORUM") or 1))
|
||||
except ValueError:
|
||||
return 1
|
||||
|
||||
|
||||
def tally(cast: dict[str, str]) -> tuple[int, int]:
|
||||
return sum(v == "approve" for v in cast.values()), sum(v == "object" for v in cast.values())
|
||||
|
||||
|
||||
def decide(cast: dict[str, str]) -> str | None:
|
||||
a, o = tally(cast)
|
||||
return "approve" if a - o >= quorum() else "object" if o > a else None
|
||||
|
||||
|
||||
def verdict(pr: dict, since: str) -> str | None:
|
||||
"""The council's verdict after `since`: each trusted identity's latest vote counts once. `approve` when approves
|
||||
minus objects reaches the quorum, `object` when objects exceed approves, else None (no vote yet, a tie, or short of quorum)."""
|
||||
return decide(votes(pr, since))
|
||||
|
||||
|
||||
def touches_protected(pr_number: int) -> list[str]:
|
||||
@@ -578,6 +948,9 @@ def touches_protected(pr_number: int) -> list[str]:
|
||||
|
||||
|
||||
WORKER_CONTEXT = "time/worker-run"
|
||||
# Time's own workflows report on a PR head too: a seat's review wakes heartbeat.yml, and a run the concurrency group
|
||||
# cancels lands on the head as "failure". None of that says anything about the code, so checks() never reads it.
|
||||
OWN_CONTEXTS = ("heartbeat /", "mention /", "venture /", "astra /", "doctor /", "update /", "steward /")
|
||||
|
||||
|
||||
def checks(sha: str) -> str:
|
||||
@@ -588,7 +961,10 @@ def checks(sha: str) -> str:
|
||||
states = gh("GET", f"/repos/{REPO}/commits/{sha}/status").get("statuses") or []
|
||||
latest: dict[str, str] = {}
|
||||
for st in states: # one line per context, newest wins
|
||||
latest.setdefault(st.get("context") or "", (st.get("status") or st.get("state") or "").lower())
|
||||
ctx = st.get("context") or ""
|
||||
if ctx.startswith(OWN_CONTEXTS):
|
||||
continue
|
||||
latest.setdefault(ctx, (st.get("status") or st.get("state") or "").lower())
|
||||
if any(v == "pending" for v in latest.values()):
|
||||
return "pending"
|
||||
if not all(v in ("success", "skipped") for v in latest.values()):
|
||||
@@ -624,9 +1000,27 @@ def land(card: dict, last: dict, revised_at: str | None = None) -> None:
|
||||
return
|
||||
if pr["state"] == "closed" or (os.environ.get("TIME_AUTOMERGE") or "off").strip() != "on":
|
||||
return
|
||||
v = verdict(pr, since)
|
||||
cast = votes(pr, since)
|
||||
v = decide(cast)
|
||||
if v is None:
|
||||
return
|
||||
# Not settled. Wait while nobody has voted, a standing seat has yet to vote, or the split is already reported.
|
||||
if not cast or last.get("split") or len(cast) < standing_voters():
|
||||
return
|
||||
tie = (os.environ.get("TIME_REVIEW_TIEBREAK") or "").strip()
|
||||
a, o = tally(cast)
|
||||
if not tie:
|
||||
if a != o:
|
||||
return # short of the quorum with no objection: wait for more votes
|
||||
else: # the standing seats split, or fell short: the tie-break seat votes, once
|
||||
review_one(card, pr, done_of(card), tie)
|
||||
cast = votes(pr, since)
|
||||
v = decide(cast)
|
||||
a, o = tally(cast)
|
||||
if v != "approve": # the tie-break seat's Object ends in the split message too: one verdict a human reads once
|
||||
label(pr["number"], "needs-human")
|
||||
comment(n, f"⏱️ Time: the council is split {a}–{o} on PR #{pr['number']}; over to a human.",
|
||||
{"beat": "needs-human", "pr": pr["number"], "split": True})
|
||||
return
|
||||
if v == "object":
|
||||
if last["beat"] != "needs-human":
|
||||
label(pr["number"], "needs-human")
|
||||
@@ -1029,6 +1423,26 @@ def linked_prs(prs: list[dict], n: int) -> list[dict]:
|
||||
return [p for p in prs if pat.search(p.get("body") or "")]
|
||||
|
||||
|
||||
def adoptable(prs: list[dict], card: dict) -> dict | None:
|
||||
"""An open PR built outside the worker that already answers this card: one whose body closes it, or the one the card's
|
||||
own body names with `PR: #N`. Closed and merged PRs are never adopted."""
|
||||
is_open = lambda p: p.get("state", "open") == "open" and not p.get("merged_at")
|
||||
found = [p for p in linked_prs(prs, card["number"]) if is_open(p)]
|
||||
if found:
|
||||
return found[0]
|
||||
m = re.search(r"^\s*PR:\s*#(\d+)\s*$", card.get("body") or "", re.M | re.I)
|
||||
if not m:
|
||||
return None
|
||||
num = int(m.group(1))
|
||||
p = next((p for p in prs if p.get("number") == num), None)
|
||||
if p is None: # older than the list tick() reads
|
||||
try:
|
||||
p = gh("GET", f"/repos/{REPO}/pulls/{num}")
|
||||
except Exception: # noqa: BLE001 - a bad number just means nothing to adopt
|
||||
return None
|
||||
return p if is_open(p) else None
|
||||
|
||||
|
||||
def pr_diff(number: int, limit: int = 100_000) -> str:
|
||||
req = urllib.request.Request(f"{GH}/repos/{REPO}/pulls/{number}.diff",
|
||||
headers={"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}"})
|
||||
@@ -1048,12 +1462,76 @@ def check_lines(sha: str | None) -> str:
|
||||
return "(checks unavailable)"
|
||||
|
||||
|
||||
def seat_token(seat: str) -> str | None:
|
||||
"""The Gitea token of the user this seat posts as (`TIME_SEAT_<SEAT>`), or None."""
|
||||
return os.environ.get(f"TIME_SEAT_{seat.upper()}") or None
|
||||
|
||||
|
||||
def post_review(pr_number: int, seat: str, model: str | None, text: str) -> str:
|
||||
"""Post a reviewer's verdict. With the seat's token it is a real review by the seat's own user (Approve ->
|
||||
APPROVED, Object -> REQUEST_CHANGES, else COMMENT); without one, or if Gitea refuses (a seat on its own PR),
|
||||
it is Time's comment as before. Returns "seat" or "time"."""
|
||||
body = f"{text}\n\n<sub>review by {seat} · {model or SEATS[seat]['default']} · via Time</sub>"
|
||||
token = seat_token(seat)
|
||||
if token:
|
||||
m = VERDICT.match(text or "")
|
||||
event = {"approve": "APPROVED", "object": "REQUEST_CHANGES"}.get(m.group(1).lower() if m else "", "COMMENT")
|
||||
try:
|
||||
http("POST", f"{GH}/repos/{REPO}/pulls/{pr_number}/reviews", token, {"body": body, "event": event})
|
||||
return "seat"
|
||||
except RuntimeError as e:
|
||||
print(f"review as {seat} refused, commenting as Time: {e}")
|
||||
gh("POST", f"/repos/{REPO}/issues/{pr_number}/comments", {"body": body})
|
||||
return "time"
|
||||
|
||||
|
||||
SEAT_KINDS = ("venice", "claude", "codex")
|
||||
CODEX_DEFAULT_SEAT = "terra"
|
||||
|
||||
|
||||
def done_of(card: dict) -> str:
|
||||
m = re.search(r"^\s*DONE:\s*(.+)$", card.get("body") or "", re.M | re.I)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
def review_specs(var: str = "TIME_REVIEW") -> list[str]:
|
||||
"""A comma-separated list of seat specs; empty when unset or `off`."""
|
||||
spec = (os.environ.get(var) or "").strip()
|
||||
return [] if spec.lower() in ("", "off") else [x.strip() for x in spec.split(",") if x.strip()]
|
||||
|
||||
|
||||
def review_note(rv: dict) -> str:
|
||||
if rv["review"] == "off":
|
||||
return "No reviewer is set (`TIME_REVIEW`)."
|
||||
seats = rv.get("seats") or ([rv["seat"]] if rv.get("seat") else [])
|
||||
parts = (["A Cursor agent is reviewing it."] if rv["review"] == "cursor" else []) + \
|
||||
([", ".join(f"`{x}`" for x in seats) + " reviewed it on the PR."] if seats else [])
|
||||
return " ".join(parts) or rv["review"]
|
||||
|
||||
|
||||
def review(card: dict, pr: dict, done: str) -> dict:
|
||||
"""TIME_REVIEW: off | venice:<seat> | claude:<seat> [model] [effort] | cursor[:<model id>]"""
|
||||
spec = (os.environ.get("TIME_REVIEW") or "off").strip()
|
||||
if spec in ("", "off"):
|
||||
"""TIME_REVIEW: a comma-separated list of seat specs, each `venice:<seat> | claude:<seat> [model] [effort] | cursor[:<model id>]`
|
||||
(or a Codex seat, once its CLI route lands); every listed seat reviews the PR and posts as itself.
|
||||
Returns what ran: the seats in `seats`."""
|
||||
specs = review_specs("TIME_REVIEW")
|
||||
if not specs:
|
||||
return {"review": "off"}
|
||||
ran = [review_one(card, pr, done, spec) for spec in specs]
|
||||
seats = [r["seat"] for r in ran if r.get("seat")]
|
||||
out = {k: v for r in ran for k, v in r.items() if k != "review"}
|
||||
kinds = [r["review"] for r in ran]
|
||||
out["review"] = "cursor" if "cursor" in kinds else "venice" if seats else kinds[0]
|
||||
if seats:
|
||||
out["seats"] = seats
|
||||
return out
|
||||
|
||||
|
||||
def review_one(card: dict, pr: dict, done: str, spec: str) -> dict:
|
||||
"""One seat reviews the PR and posts its verdict as itself."""
|
||||
kind, _, rest = spec.partition(":")
|
||||
if kind == "codex" or SEATS.get(kind, {}).get("route") == "cli":
|
||||
seat = (rest.split() or [CODEX_DEFAULT_SEAT])[0] if kind == "codex" else kind
|
||||
return {"review": f"`{seat}` reviews through a headless Codex CLI run, which isn't wired in yet (#79); it did not vote"}
|
||||
if kind == "cursor":
|
||||
r = cursor_launch(REVIEW_PROMPT.format(pr=pr["number"], repo=REPO, n=card["number"], title=card["title"], done=done),
|
||||
model=rest.strip() or None, pr_url=pr["html_url"], create_pr=False)
|
||||
@@ -1073,9 +1551,8 @@ def review(card: dict, pr: dict, done: str) -> dict:
|
||||
"(a success there means that context reported success). A pending check is not yet evidence either way.")
|
||||
with finishing(): # the PR is open and paid for: its review runs past the daily cap too, within the slack
|
||||
text = think(seat, model, effort, system, user)
|
||||
gh("POST", f"/repos/{REPO}/issues/{pr['number']}/comments",
|
||||
{"body": f"{text}\n\n<sub>review by {seat} · {model or SEATS[seat]['default']} · via Time</sub>"})
|
||||
return {"review": "venice", "seat": seat}
|
||||
who = post_review(pr["number"], seat, model, text)
|
||||
return {"review": "venice", "seat": seat, "as": who}
|
||||
return {"review": f"unknown spec {spec!r}"}
|
||||
|
||||
|
||||
@@ -1132,6 +1609,22 @@ def tick() -> None:
|
||||
for card, last, done, blocked, capped, revision, revised_at in plan:
|
||||
n, state = card["number"], (last or {}).get("beat")
|
||||
try:
|
||||
# work built outside the worker (Codex, a seat's chat): an open PR that already closes this card is reviewed and
|
||||
# landed like any card PR, instead of launching a worker to build it again. No launch, no worker budget.
|
||||
# `would-dispatch` is only a dry-run marker (nothing was built), so going live still adopts; a dry run doesn't repeat itself
|
||||
if (state in (None, "needs-done") or (state == "would-dispatch" and mode == "live")) and not revision \
|
||||
and (adopt := adoptable(prs, card)):
|
||||
if mode != "live":
|
||||
comment(n, f"⏱️ Time (dry run): would adopt open PR #{adopt['number']}, which already answers this card, "
|
||||
"and review it instead of dispatching a worker.\n\n"
|
||||
"- Goes for real once `TIME_MODE=live`", {"beat": "would-dispatch", "mode": mode, "adopt": adopt["number"]})
|
||||
continue
|
||||
rv = review(card, adopt, done or "")
|
||||
launches_today += 1 if rv.get("review") == "cursor" else 0
|
||||
note = review_note(rv)
|
||||
comment(n, f"⏱️ Time: adopted PR #{adopt['number']}, built outside the worker. {note}",
|
||||
{"beat": "pr", "pr": adopt["number"], "adopted": True, "launched": rv.get("review") == "cursor", **rv})
|
||||
continue
|
||||
if state in (None, "needs-done", "would-dispatch") and not done:
|
||||
if state != "needs-done":
|
||||
comment(n, "⏱️ Time: this is labelled `ready` but has no `DONE:` line, so there's nothing to dispatch "
|
||||
@@ -1194,8 +1687,7 @@ def tick() -> None:
|
||||
pr = found[0]
|
||||
rv = review(card, pr, done or "")
|
||||
launches_today += 1 if rv.get("review") == "cursor" else 0
|
||||
note = {"off": "No reviewer is set (`TIME_REVIEW`).", "cursor": "A Cursor agent is reviewing it.",
|
||||
"venice": f"`{rv.get('seat')}` reviewed it on the PR."}.get(rv["review"], rv["review"])
|
||||
note = review_note(rv)
|
||||
comment(n, f"⏱️ Time: PR #{pr['number']} is up for this card. {note}",
|
||||
{"beat": "pr", "pr": pr["number"], "launched": rv.get("review") == "cursor", **rv})
|
||||
elif state == "dispatched":
|
||||
@@ -1235,7 +1727,9 @@ def cmd_doctor() -> int:
|
||||
row("TIME_MODE", True, os.environ.get("TIME_MODE") or "dry-run (default)")
|
||||
row("TIME_DAILY_CAP", True, os.environ.get("TIME_DAILY_CAP") or "5 (default)")
|
||||
row("TIME_WORKER", True, os.environ.get("TIME_WORKER") or "cursor (default); or claude:<seat> / venice:<seat> for a seat with hands")
|
||||
row("TIME_REVIEW", True, os.environ.get("TIME_REVIEW") or "off (default)")
|
||||
row("TIME_REVIEW", True, os.environ.get("TIME_REVIEW") or "off (default); a comma-separated list of seats, each votes")
|
||||
row("TIME_REVIEW_TIEBREAK", True, os.environ.get("TIME_REVIEW_TIEBREAK") or "none (default); a seat called only when the standing seats split")
|
||||
row("TIME_REVIEW_QUORUM", True, os.environ.get("TIME_REVIEW_QUORUM") or "1 (default); approvals needed, net of objections")
|
||||
today, day_cap, month, month_cap = budget()
|
||||
row("TIME_DAILY_USD", True, os.environ.get("TIME_DAILY_USD") or "5 (default)")
|
||||
row("TIME_CAP_SLACK", True, os.environ.get("TIME_CAP_SLACK") or "1.5 (default)")
|
||||
@@ -1243,6 +1737,7 @@ def cmd_doctor() -> int:
|
||||
row("spend", today < day_cap and month < month_cap, f"${today:.2f} today, ${month:.2f} this month ({ledger_path(dt.datetime.now(dt.timezone.utc).strftime('%Y-%m'))})")
|
||||
row("(optional) ANTHROPIC_API_KEY (Claude seats)", bool(os.environ.get("ANTHROPIC_API_KEY")),
|
||||
"set" if os.environ.get("ANTHROPIC_API_KEY") else "not set")
|
||||
row("TIME_ANTHROPIC_API", True, os.environ.get("TIME_ANTHROPIC_API") or "messages (default, with prompt caching; compat = the OpenAI-compatible endpoint)")
|
||||
row("TIME_AUTOMERGE", True, (os.environ.get("TIME_AUTOMERGE") or "off (default)") + "; merges on Approve + green checks")
|
||||
for name in ("queued", "spec", "needs-human"):
|
||||
row(f"(optional) label `{name}`", name in labels, "exists" if name in labels else "missing (created on first use)")
|
||||
|
||||
+7
-1
@@ -3,6 +3,7 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Security
|
||||
- A commit status from one of Time's own workflows (`heartbeat /`, `mention /`, `venture /`, `astra /`, `doctor /`, `update /`, `steward /`) is not a check: it neither blocks nor earns a merge. A seat's review is a `pull_request_review` event that wakes `heartbeat.yml` during a heartbeat; the concurrency group cancels the run and Gitea records it as a failure on the PR head, which had stopped approved, CI-green PRs from landing.
|
||||
- The worker's own commit status (`time/worker-run`) can fail or delay a merge but never earns one: with no other check on the PR head, Time dispatches the project's CI workflow on the PR branch (`TIME_CI_WORKFLOW`, default `ci.yml`, which must accept `workflow_dispatch`; GitHub skips `pull_request` runs for pushes made with the Actions token) and merges on that. `off` keeps the old "no checks" stop.
|
||||
- `.github/` is protected from auto-merge whatever `TIME_PROTECTED` says.
|
||||
- `labs-aicharts.yml` passes the deploy URL through `env` instead of interpolating it into the shell.
|
||||
@@ -15,12 +16,16 @@
|
||||
- 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
|
||||
- Claude calls go to Anthropic's own Messages API with prompt caching. The tools and system prompt are one cache breakpoint and the conversation so far the other, so each step of a worker run pays for the context the step before already sent at a tenth of the price (the OpenAI-compatible endpoint has no caching: a failed 43-step run cost $2.80, most of it the same context re-sent 43 times). The engine still speaks the OpenAI shape inside; an assistant turn that issued tool calls goes back exactly as the API gave it. The ledger prices cached tokens (`cached`, `written` on the line). `TIME_ANTHROPIC_API=compat` goes back to the old endpoint without a code change.
|
||||
- The second fix round escalates to Kimi's code model (`venice:kimi code`) instead of grok 4.7: still a different model from the first round, at a fraction of the cost. `TIME_VENTURE_ESCALATE` overrides it.
|
||||
|
||||
### Added
|
||||
- Mentions are handoffs (#79). `@codex <ask>` runs a headless `codex exec` in a throwaway checkout and `@claude [haiku|sonnet|opus|fable] <ask>` a Claude seat (opus by default) with the README and, on a PR, its diff and checks; each posts as its own Gitea user (`TIME_SEAT_CODEX`, `TIME_SEAT_CLAUDE`; Time posts for it when the token is missing or refused), signed `<sub>codex via Time · <model></sub>`. Codex's work becomes a branch and a PR opened by `codex` (Time pushes it; the token never enters codex's environment, and neither do Time's other secrets). `codex exec` runs under a wall-clock cap (`TIME_SEAT_RUN_MINUTES`, default 15), is killed at it with nothing pushed, and books one ledger line with `launch: 1` (tokens from `--json`, `usd` null: no price); `TIME_CODEX_MODEL` picks its model. A reply whose last line starts with another seat's mention is a handoff: Time runs that seat on the reply, up to `TIME_HANDOFF_MAX` seat runs per comment (default 6). `@a x → @b y → @c z` is a chain of stages; seats on one stage, or on separate lines, run side by side. Strangers' mentions are still ignored. `mention.yml` needs `TIME_SEAT_CODEX`/`TIME_SEAT_CLAUDE` passed in, a runner with `codex`, and a `timeout-minutes` above the cap.
|
||||
- Council verdict: `TIME_REVIEW` takes a comma-separated list of seats (`claude:haiku, claude:opus`); every seat reviews each PR and posts as itself, and each identity's latest vote counts once (Time's own comments are one identity). `approve` when approves minus objects reaches `TIME_REVIEW_QUORUM` (default 1), `object` when objects exceed approves. `TIME_REVIEW_TIEBREAK` names one seat (e.g. `venice:kimi`) called only when every standing seat has voted and they split; if it doesn't settle it either, one "the council is split N–N; over to a human" comment with a `needs-human` beat. One seat and quorum 1 behave as before. Codex's ladder (`luna`, `terra`, `sol`, `astra`) is in `seats.json` on the CLI route (its review run lands with #79; the `astra` login is the Codex seat, not the planning tick).
|
||||
- Seats review as themselves: with a `TIME_SEAT_<SEAT>` token (one Gitea user per seat, e.g. `TIME_SEAT_KIMI`) the reviewer's verdict is a real Gitea review by that user (Approve → approved, Object → changes requested, prose → comment), so reviews carry an identity and can count. Without a token, or if Gitea refuses (a seat on its own PR), it is Time's comment as before.
|
||||
- Cards can live on another branch. A `Base: <branch>` line in a card (`Base: gh-pages` for a venture) makes the seat worker cut its branch from that base, name it `time/card-N-<base>`, see that branch's files, and open the PR against it; without the line nothing changes. Until now every worker branched from the default branch whatever the card said, which is why the cards on `gh-pages` ran out of turns or opened PRs against `main`.
|
||||
- A launched worker finishes what it starts. The daily spend cap gates launches; a run that began within budget may go on to `TIME_CAP_SLACK` x the cap (default 1.5), and a PR that is already open gets its review on a capped day too. A run stopped mid-way had cost its whole spend and produced nothing. The monthly cap stays hard everywhere; `TIME_CAP_SLACK=1` restores the old stop.
|
||||
- The tick takes the oldest `ready` card first, the order `promote_next` already uses, instead of the newest the API lists first.
|
||||
- The tick takes the oldest `ready` card first, the order `promote_next` already uses, instead of the newest the API lists first, so a card promoted first is built first.
|
||||
- `TIME_PROTECTED` now governs every protected path except the workflow directories (`.github/workflows/`, `.gitea/workflows/`), which stay a human's merge whatever it says. The default still protects all of `.github/`; a repo that trusts its reviewer can set `TIME_PROTECTED=.github/workflows/,.gitea/workflows/,deploy,infra/,.env,secret` so the engine under `.github/time/` merges itself on Approve + green like any other code.
|
||||
- Objections go back to the worker. `/time retry` on a card whose PR is open hands the worker the reviewer's latest comments (marked as the reviewer's, strangers' left out) on the same branch; `open_pr` on an open PR updates its description instead of failing; the card goes back to `dispatched`, so the next beat reviews again and an objection older than the push no longer counts.
|
||||
- A batch's children start in the tick that spawned them (GitHub lists a card made a moment ago only later, so they used to wait a tick).
|
||||
@@ -37,4 +42,5 @@
|
||||
- The studio index at `/ventures/` wears the Time site's blueprint theme (title block, grid paper, type), with each launched venture's laptop screenshot.
|
||||
- The headless-Chrome check fails a launch page where something sits on top of the product (a page fixed to the screen's height lets the next section slide over it).
|
||||
- The board sees the studio's own ventures: pitches that repeat one (by slug, name or an earlier slug) are set aside before the vote, since the week's trending list barely moves from day to day.
|
||||
- Cards adopt an open PR that already closes them. A `ready` card with no beats and an open PR whose body closes it (or a card whose body says `PR: #N`) gets a `pr` beat with `adopted: true` and a review in the same tick, then lands through `land()` on Approve + green like any card PR, instead of launching a worker to build it again. No launch is counted and no worker budget is spent; closed and merged PRs are never adopted; dry-run says what it would adopt. Work built outside the worker (Codex, a seat's chat) no longer waits for a human merge.
|
||||
- Hands-off upgrade support with spec planner, auto-merge, and Venice worker integration
|
||||
@@ -4,7 +4,7 @@ A Gitea-native agent factory, running on Teahub prod (teahub.xyz). The Gitea rep
|
||||
|
||||
- **Cards**: an issue with a `DONE:` line, labelled `ready`.
|
||||
- **Heartbeat** (`heartbeat.yml`, hourly and whenever a card is labelled `ready`): moves cards forward. In `dry-run` (default) it only says what it would do; with `TIME_MODE=live` it dispatches each card to the worker (`TIME_WORKER`: a seat such as `claude:sonnet` or `venice:kimi`, which edits the repo and opens the PR itself, or a Cursor cloud agent), which opens a PR that closes the card. An optional reviewer (`TIME_REVIEW`) checks the PR. At most `TIME_DAILY_CAP` launches a day. **Spend governor:** every model call is booked to `time/ledger/<YYYY-MM>.jsonl` (tokens and dollars); once today's spend reaches `TIME_DAILY_USD` (default 5) or the month's reaches `TIME_MONTHLY_USD` (default 100), Time stops calling models, opens an `andon` issue, and a monthly breach also sets `TIME_MODE=dry-run`. No model decides whether to spend.
|
||||
- **Mentions** (`mention.yml`): `@sonnet`, `@opus`, `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply.
|
||||
- **Mentions** (`mention.yml`): `@sonnet`, `@opus`, `@kimi`, `@grok 4.7`, `@qwen max high` in any issue, PR or comment gets that seat's reply. `@codex <ask>` runs a headless Codex in a checkout and opens a PR as `codex`; `@claude [sonnet] <ask>` answers as `claude`. A reply that ends with another seat's `@mention` hands the thread on: `@claude draft → @codex implement → @haiku review` runs as a chain.
|
||||
- **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/<slug>/`. 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.
|
||||
|
||||
@@ -361,3 +361,104 @@
|
||||
{"at":"2026-10-18T04:14:41Z","model":"claude-sonnet-5-5","in":18986,"out":760,"usd":0.045572}
|
||||
{"at":"2026-10-18T04:16:11Z","model":"claude-haiku-5-5","in":13815,"out":3027,"usd":0.002895}
|
||||
{"at":"2026-10-18T04:16:22Z","model":"claude-haiku-5-5","in":5746,"out":1940,"usd":0.001545}
|
||||
{"at":"2026-10-18T04:26:47Z","model":"claude-sonnet-5-5","in":2728,"out":93,"usd":0.006386}
|
||||
{"at":"2026-10-18T04:26:49Z","model":"claude-sonnet-5-5","in":3392,"out":101,"usd":0.007794}
|
||||
{"at":"2026-10-18T04:26:50Z","model":"claude-sonnet-5-5","in":6396,"out":108,"usd":0.013872}
|
||||
{"at":"2026-10-18T04:26:52Z","model":"claude-sonnet-5-5","in":8797,"out":213,"usd":0.019724}
|
||||
{"at":"2026-10-18T04:27:00Z","model":"claude-sonnet-5-5","in":10642,"out":1044,"usd":0.031724}
|
||||
{"at":"2026-10-18T04:27:04Z","model":"claude-sonnet-5-5","in":11793,"out":253,"usd":0.026116}
|
||||
{"at":"2026-10-18T04:27:11Z","model":"claude-sonnet-5-5","in":12126,"out":651,"usd":0.030762}
|
||||
{"at":"2026-10-18T04:27:31Z","model":"claude-haiku-5-5","in":5399,"out":2909,"usd":0.001994}
|
||||
{"at":"2026-10-18T04:39:54Z","model":"claude-sonnet-5-5","in":2243,"out":26,"usd":0.004746}
|
||||
{"at":"2026-10-18T04:39:55Z","model":"claude-sonnet-5-5","in":2819,"out":113,"usd":0.006768}
|
||||
{"at":"2026-10-18T04:39:57Z","model":"claude-sonnet-5-5","in":5095,"out":101,"usd":0.0112}
|
||||
{"at":"2026-10-18T04:39:59Z","model":"claude-sonnet-5-5","in":7855,"out":104,"usd":0.01675}
|
||||
{"at":"2026-10-18T04:40:01Z","model":"claude-sonnet-5-5","in":10087,"out":79,"usd":0.020964}
|
||||
{"at":"2026-10-18T04:40:02Z","model":"claude-sonnet-5-5","in":34365,"out":102,"usd":0.06975}
|
||||
{"at":"2026-10-18T04:40:05Z","model":"claude-sonnet-5-5","in":37054,"out":136,"usd":0.075468}
|
||||
{"at":"2026-10-18T04:40:06Z","model":"claude-sonnet-5-5","in":40085,"out":80,"usd":0.08097}
|
||||
{"at":"2026-10-18T04:40:49Z","model":"claude-sonnet-5-5","in":67623,"out":4962,"usd":0.184866}
|
||||
{"at":"2026-10-18T04:41:49Z","model":"claude-sonnet-5-5","in":70376,"out":7033,"usd":0.211082}
|
||||
{"at":"2026-10-18T04:41:51Z","model":"claude-sonnet-5-5","in":72287,"out":229,"usd":0.146864}
|
||||
{"at":"2026-10-18T04:43:14Z","model":"claude-sonnet-5-5","in":73842,"out":10120,"usd":0.248884}
|
||||
{"at":"2026-10-18T04:45:05Z","model":"claude-sonnet-5-5","in":75696,"out":15450,"usd":0.305892}
|
||||
{"at":"2026-10-18T04:45:51Z","model":"claude-sonnet-5-5","in":79917,"out":5774,"usd":0.217574}
|
||||
{"at":"2026-10-18T04:46:07Z","model":"claude-sonnet-5-5","in":82584,"out":1747,"usd":0.182638}
|
||||
{"at":"2026-10-18T04:46:28Z","model":"claude-sonnet-5-5","in":83296,"out":2437,"usd":0.190962}
|
||||
{"at":"2026-10-18T04:46:59Z","model":"claude-sonnet-5-5","in":84097,"out":4593,"usd":0.214124}
|
||||
{"at":"2026-10-18T04:47:05Z","model":"claude-sonnet-5-5","in":87773,"out":616,"usd":0.181706}
|
||||
{"at":"2026-10-18T04:47:10Z","model":"claude-sonnet-5-5","in":88420,"out":467,"usd":0.18151}
|
||||
{"at":"2026-10-18T04:47:14Z","model":"claude-sonnet-5-5","in":89051,"out":340,"usd":0.181502}
|
||||
{"at":"2026-10-18T04:47:18Z","model":"claude-sonnet-5-5","in":89624,"out":370,"usd":0.182948}
|
||||
{"at":"2026-10-18T04:47:24Z","model":"claude-sonnet-5-5","in":90024,"out":661,"usd":0.186658}
|
||||
{"at":"2026-10-18T04:47:34Z","model":"claude-sonnet-5-5","in":90605,"out":1296,"usd":0.19417}
|
||||
{"at":"2026-10-18T04:50:11Z","model":"claude-haiku-5-5","in":10125,"out":2282,"usd":0.002154}
|
||||
{"at":"2026-10-18T04:50:26Z","model":"claude-haiku-5-5","in":10121,"out":2848,"usd":0.002436}
|
||||
{"at":"2026-10-18T04:50:46Z","model":"claude-haiku-5-5","in":12621,"out":3932,"usd":0.003228}
|
||||
{"at":"2026-10-18T04:59:19Z","model":"claude-haiku-5-5","in":2520,"out":1939,"usd":0.001221}
|
||||
{"at":"2026-10-18T05:03:52Z","model":"claude-sonnet-5-5","in":2257,"out":109,"usd":0.006731,"cached":0,"written":2253}
|
||||
{"at":"2026-10-18T05:03:55Z","model":"claude-sonnet-5-5","in":4997,"out":225,"usd":0.00956,"cached":2253,"written":2742}
|
||||
{"at":"2026-10-18T05:03:58Z","model":"claude-sonnet-5-5","in":50332,"out":224,"usd":0.116581,"cached":4995,"written":45335}
|
||||
{"at":"2026-10-18T05:04:03Z","model":"claude-sonnet-5-5","in":78131,"out":365,"usd":0.083217,"cached":50330,"written":27799}
|
||||
{"at":"2026-10-18T05:04:10Z","model":"claude-sonnet-5-5","in":82203,"out":578,"usd":0.03159,"cached":78129,"written":4072}
|
||||
{"at":"2026-10-18T05:04:15Z","model":"claude-sonnet-5-5","in":87691,"out":216,"usd":0.032324,"cached":82201,"written":5488}
|
||||
{"at":"2026-10-18T05:04:20Z","model":"claude-sonnet-5-5","in":90518,"out":323,"usd":0.027839,"cached":87689,"written":2827}
|
||||
{"at":"2026-10-18T05:04:23Z","model":"claude-sonnet-5-5","in":92509,"out":91,"usd":0.023995,"cached":90516,"written":1991}
|
||||
{"at":"2026-10-18T05:05:49Z","model":"claude-sonnet-5-5","in":94102,"out":11939,"usd":0.141878,"cached":92507,"written":1593}
|
||||
{"at":"2026-10-18T05:05:54Z","model":"claude-sonnet-5-5","in":106065,"out":492,"usd":0.053651,"cached":94100,"written":11963}
|
||||
{"at":"2026-10-18T05:05:59Z","model":"claude-sonnet-5-5","in":108387,"out":269,"usd":0.029712,"cached":106063,"written":2322}
|
||||
{"at":"2026-10-18T05:06:05Z","model":"claude-sonnet-5-5","in":108739,"out":727,"usd":0.029831,"cached":108385,"written":352}
|
||||
{"at":"2026-10-18T05:06:09Z","model":"claude-sonnet-5-5","in":110175,"out":415,"usd":0.029491,"cached":108737,"written":1436}
|
||||
{"at":"2026-10-18T05:06:21Z","model":"claude-sonnet-5-5","in":111100,"out":1784,"usd":0.042191,"cached":110173,"written":925}
|
||||
{"at":"2026-10-18T05:06:26Z","model":"claude-sonnet-5-5","in":113095,"out":295,"usd":0.030161,"cached":111098,"written":1995}
|
||||
{"at":"2026-10-18T05:06:51Z","model":"claude-sonnet-5-5","in":113647,"out":3968,"usd":0.063683,"cached":113093,"written":552}
|
||||
{"at":"2026-10-18T05:07:09Z","model":"claude-sonnet-5-5","in":117641,"out":2409,"usd":0.056808,"cached":113645,"written":3994}
|
||||
{"at":"2026-10-18T05:07:16Z","model":"claude-sonnet-5-5","in":120756,"out":175,"usd":0.033069,"cached":117639,"written":3115}
|
||||
{"at":"2026-10-18T05:07:19Z","model":"claude-sonnet-5-5","in":120972,"out":105,"usd":0.025745,"cached":120754,"written":216}
|
||||
{"at":"2026-10-18T05:08:12Z","model":"claude-sonnet-5-5","in":121478,"out":252,"usd":0.027983,"cached":120970,"written":506}
|
||||
{"at":"2026-10-18T05:08:40Z","model":"claude-sonnet-5-5","in":122164,"out":222,"usd":0.028234,"cached":121476,"written":686}
|
||||
{"at":"2026-10-18T05:08:49Z","model":"claude-sonnet-5-5","in":122537,"out":176,"usd":0.027129,"cached":122162,"written":373}
|
||||
{"at":"2026-10-18T05:10:03Z","model":"claude-sonnet-5-5","in":123091,"out":1552,"usd":0.041416,"cached":122535,"written":554}
|
||||
{"at":"2026-10-18T05:10:08Z","model":"claude-sonnet-5-5","in":1824,"out":109,"usd":0.005648,"cached":0,"written":1820}
|
||||
{"at":"2026-10-18T05:10:10Z","model":"claude-sonnet-5-5","in":4564,"out":228,"usd":0.009503,"cached":1820,"written":2742}
|
||||
{"at":"2026-10-18T05:10:14Z","model":"claude-sonnet-5-5","in":42414,"out":313,"usd":0.098671,"cached":4562,"written":37850}
|
||||
{"at":"2026-10-18T05:10:17Z","model":"claude-sonnet-5-5","in":43036,"out":157,"usd":0.011611,"cached":42412,"written":622}
|
||||
{"at":"2026-10-18T05:10:21Z","model":"claude-sonnet-5-5","in":43381,"out":153,"usd":0.011003,"cached":43034,"written":345}
|
||||
{"at":"2026-10-18T05:10:23Z","model":"claude-sonnet-5-5","in":43695,"out":108,"usd":0.010545,"cached":43379,"written":314}
|
||||
{"at":"2026-10-18T05:10:26Z","model":"claude-sonnet-5-5","in":44016,"out":142,"usd":0.010965,"cached":43693,"written":321}
|
||||
{"at":"2026-10-18T05:10:30Z","model":"claude-sonnet-5-5","in":44647,"out":166,"usd":0.012044,"cached":44014,"written":631}
|
||||
{"at":"2026-10-18T05:10:35Z","model":"claude-sonnet-5-5","in":45526,"out":336,"usd":0.01449,"cached":44645,"written":879}
|
||||
{"at":"2026-10-18T05:10:39Z","model":"claude-sonnet-5-5","in":46546,"out":257,"usd":0.014229,"cached":45524,"written":1020}
|
||||
{"at":"2026-10-18T05:10:46Z","model":"claude-sonnet-5-5","in":47339,"out":770,"usd":0.018995,"cached":46544,"written":793}
|
||||
{"at":"2026-10-18T05:10:58Z","model":"claude-sonnet-5-5","in":54274,"out":1666,"usd":0.043469,"cached":47337,"written":6935}
|
||||
{"at":"2026-10-18T05:11:07Z","model":"claude-sonnet-5-5","in":56078,"out":169,"usd":0.017058,"cached":54272,"written":1804}
|
||||
{"at":"2026-10-18T05:11:13Z","model":"claude-sonnet-5-5","in":56315,"out":510,"usd":0.016912,"cached":56076,"written":237}
|
||||
{"at":"2026-10-18T05:11:19Z","model":"claude-sonnet-5-5","in":56992,"out":312,"usd":0.016079,"cached":56313,"written":677}
|
||||
{"at":"2026-10-18T05:12:32Z","model":"claude-sonnet-5-5","in":57791,"out":1095,"usd":0.024349,"cached":56990,"written":799}
|
||||
{"at":"2026-10-18T05:12:35Z","model":"claude-sonnet-5-5","in":1868,"out":55,"usd":0.005218,"cached":0,"written":1864}
|
||||
{"at":"2026-10-18T05:12:38Z","model":"claude-sonnet-5-5","in":2063,"out":225,"usd":0.003119,"cached":1864,"written":197}
|
||||
{"at":"2026-10-18T05:12:42Z","model":"claude-sonnet-5-5","in":11971,"out":314,"usd":0.028326,"cached":2061,"written":9908}
|
||||
{"at":"2026-10-18T05:12:46Z","model":"claude-sonnet-5-5","in":13561,"out":216,"usd":0.008533,"cached":11969,"written":1590}
|
||||
{"at":"2026-10-18T05:12:57Z","model":"claude-sonnet-5-5","in":14832,"out":1570,"usd":0.021593,"cached":13559,"written":1271}
|
||||
{"at":"2026-10-18T05:13:04Z","model":"claude-sonnet-5-5","in":16641,"out":299,"usd":0.010483,"cached":14830,"written":1809}
|
||||
{"at":"2026-10-18T05:13:12Z","model":"claude-sonnet-5-5","in":17121,"out":763,"usd":0.012162,"cached":16639,"written":480}
|
||||
{"at":"2026-10-18T05:13:17Z","model":"claude-sonnet-5-5","in":1899,"out":55,"usd":0.005295,"cached":0,"written":1895}
|
||||
{"at":"2026-10-18T05:13:19Z","model":"claude-sonnet-5-5","in":2094,"out":228,"usd":0.003155,"cached":1895,"written":197}
|
||||
{"at":"2026-10-18T05:13:29Z","model":"claude-sonnet-5-5","in":27220,"out":826,"usd":0.071497,"cached":2092,"written":25126}
|
||||
{"at":"2026-10-18T05:13:32Z","model":"claude-sonnet-5-5","in":28865,"out":139,"usd":0.01095,"cached":27218,"written":1645}
|
||||
{"at":"2026-10-18T05:13:53Z","model":"claude-sonnet-5-5","in":29209,"out":2985,"usd":0.036487,"cached":28863,"written":344}
|
||||
{"at":"2026-10-18T05:14:06Z","model":"claude-sonnet-5-5","in":32288,"out":1466,"usd":0.028203,"cached":29207,"written":3079}
|
||||
{"at":"2026-10-18T05:14:12Z","model":"claude-sonnet-5-5","in":33781,"out":599,"usd":0.016184,"cached":32286,"written":1493}
|
||||
{"at":"2026-10-18T05:14:18Z","model":"claude-sonnet-5-5","in":34717,"out":486,"usd":0.01396,"cached":33779,"written":936}
|
||||
{"at":"2026-10-18T05:14:22Z","model":"claude-sonnet-5-5","in":35992,"out":247,"usd":0.012604,"cached":34715,"written":1275}
|
||||
{"at":"2026-10-18T05:14:27Z","model":"claude-sonnet-5-5","in":36419,"out":308,"usd":0.01135,"cached":35990,"written":427}
|
||||
{"at":"2026-10-18T05:14:36Z","model":"claude-sonnet-5-5","in":36809,"out":1491,"usd":0.023172,"cached":36417,"written":390}
|
||||
{"at":"2026-10-18T05:14:41Z","model":"claude-sonnet-5-5","in":38327,"out":73,"usd":0.01189,"cached":36807,"written":1518}
|
||||
{"at":"2026-10-18T05:14:44Z","model":"claude-sonnet-5-5","in":38733,"out":199,"usd":0.010674,"cached":38325,"written":406}
|
||||
{"at":"2026-10-18T05:14:48Z","model":"claude-sonnet-5-5","in":39827,"out":112,"usd":0.011605,"cached":38731,"written":1094}
|
||||
{"at":"2026-10-18T05:14:54Z","model":"claude-sonnet-5-5","in":41728,"out":730,"usd":0.020022,"cached":39825,"written":1901}
|
||||
{"at":"2026-10-18T05:15:03Z","model":"claude-sonnet-5-5","in":42526,"out":828,"usd":0.018624,"cached":41726,"written":798}
|
||||
{"at":"2026-10-18T06:07:52Z","model":"claude-haiku-5-5","in":27464,"out":3009,"usd":0.004937,"cached":0,"written":27460}
|
||||
{"at":"2026-10-18T06:08:00Z","model":"claude-haiku-5-5","in":3962,"out":1507,"usd":0.001249,"cached":0,"written":3958}
|
||||
{"at":"2026-10-18T06:08:07Z","model":"claude-haiku-5-5","in":2754,"out":1220,"usd":0.000954,"cached":0,"written":2750}
|
||||
{"at":"2026-10-18T06:08:18Z","model":"claude-haiku-5-5","in":6134,"out":1788,"usd":0.001661,"cached":0,"written":6130}
|
||||
Reference in new issue
Block a user