time: Cursor worker, doctor, skill (.github/time/time.py)

This commit is contained in:
teahubxyz committed 2026-10-10 19:56:32 -07:00
1 parent ca667b7f20
commit 7a0557738b
1 file changed
+224 -31
+224 -31
View File
@@ -151,42 +151,235 @@ def cmd_mention() -> int:
return 0
# ---------- worker: Cursor cloud agents ----------
# Docs: Cloud Agents API (POST /v1/agents, GET /v1/models). The agent opens a PR that says "Closes #N";
# Time finds that PR on GitHub, so tracking never depends on fields of Cursor's responses.
CURSOR = os.environ.get("CURSOR_API_BASE", "https://api.cursor.com")
def cursor(method: str, path: str, body=None):
key = os.environ.get("CURSOR_API_KEY")
if not key:
raise LookupError("no CURSOR_API_KEY secret on this repo yet")
return http(method, f"{CURSOR}{path}", key, body, timeout=60)
def repo_url() -> str:
return f"https://github.com/{REPO}"
def default_branch() -> str:
return gh("GET", f"/repos/{REPO}").get("default_branch") or "main"
BUILD_PROMPT = """You are the worker for card #{n} in {repo}: "{title}".
{body}
How to finish:
- Make the change described above, small and focused. Follow AGENTS.md and any repo skills if present.
- Run the project's tests or checks if they exist.
- Open one pull request into `{base}`. Its description MUST contain the line `Closes #{n}` and a short summary
of what changed and how you checked it.
- The card's finish line is: DONE: {done}
"""
REVIEW_PROMPT = """Review pull request #{pr} in {repo} for card #{n} ("{title}"). Do NOT push commits.
Check the diff against the card and its finish line (DONE: {done}): correctness, tests, scope, anything risky.
Leave one PR comment that starts with **Approve** or **Object**, then your reasons as a short list with file:line."""
def cursor_launch(prompt: str, *, model: str | None, pr_url: str | None = None, create_pr: bool = True) -> dict:
r = {"url": repo_url()}
if pr_url:
r["prUrl"] = pr_url
else:
r["startingRef"] = default_branch()
body = {"prompt": {"text": prompt}, "repos": [r], "autoCreatePR": create_pr}
if model:
body["model"] = {"id": model}
out = cursor("POST", "/v1/agents", body)
agent = out.get("agent") or out
return {"agent_id": agent.get("id"), "agent_url": agent.get("url") or agent.get("webUrl"),
"status": (out.get("run") or {}).get("status") or agent.get("status")}
# ---------- heartbeat ----------
def beats_of(comments: list[dict]) -> list[dict]:
"""Time's own state comments on a card, oldest first, as {meta..., at}."""
out = []
for c in comments:
b = c.get("body") or ""
if MARK in b and '"beat":' in b:
try:
meta = json.loads(b.split(MARK, 1)[1].rsplit("-->", 1)[0])
except ValueError:
continue
out.append({**meta, "at": c["created_at"]})
return out
def retry_requested(comments: list[dict], since: str) -> bool:
return any(c["created_at"] > since and c["user"].get("type") != "Bot" and "/time retry" in (c.get("body") or "")
for c in comments)
def linked_prs(prs: list[dict], n: int) -> list[dict]:
pat = re.compile(rf"\b(close[sd]?|fix(e[sd])?|resolve[sd]?)\s+#{n}\b", re.I)
return [p for p in prs if pat.search(p.get("body") or "")]
def pr_diff(number: int, limit: int = 100_000) -> str:
req = urllib.request.Request(f"{GH}/repos/{REPO}/pulls/{number}",
headers={"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
"Accept": "application/vnd.github.diff"})
with urllib.request.urlopen(req, timeout=60) as r:
return r.read().decode(errors="replace")[:limit]
def review(card: dict, pr: dict, done: str) -> dict:
"""TIME_REVIEW: off | venice:<seat> [model] [effort] | cursor[:<model id>]"""
spec = (os.environ.get("TIME_REVIEW") or "off").strip()
if spec in ("", "off"):
return {"review": "off"}
kind, _, rest = spec.partition(":")
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)
return {"review": "cursor", **r}
if kind == "venice":
words = rest.split()
seat = words[0] if words else "kimi"
model, effort = mentions(f"@{rest}").get(seat, (None, None)) if seat in SEATS else (None, None)
diff = pr_diff(pr["number"])
user = (f"# Card #{card['number']}: {card['title']}\n{(card.get('body') or '')[:4000]}\n\n"
f"# PR #{pr['number']}: {pr['title']}\n{(pr.get('body') or '')[:3000]}\n\n# Diff\n{diff}")
system = (f"You are `{seat}`, reviewing a pull request made by another agent. "
+ REVIEW_PROMPT.format(pr=pr["number"], repo=REPO, n=card["number"], title=card["title"], done=done)
.replace("Leave one PR comment that", "Your reply is posted as the PR comment. It"))
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}
return {"review": f"unknown spec {spec!r}"}
def cmd_heartbeat() -> int:
mode = os.environ.get("TIME_MODE") or "dry-run"
mode = (os.environ.get("TIME_MODE") or "dry-run").strip()
cap = int(os.environ.get("TIME_DAILY_CAP") or 5)
today = dt.datetime.now(dt.timezone.utc).date().isoformat()
issues = gh("GET", f"/repos/{REPO}/issues?state=open&labels=ready&per_page=50")
issues = [i for i in issues if "pull_request" not in i]
sent_today, todo = 0, []
for i in issues:
cs = gh("GET", f"/repos/{REPO}/issues/{i['number']}/comments?per_page=100")
beats = [c for c in cs if MARK in (c.get("body") or "") and '"beat":' in c["body"]]
sent_today += sum(1 for c in beats if c["created_at"].startswith(today))
if beats:
continue # already picked up once; the next step is the worker's, not the clock's
done = re.search(r"^\s*DONE:\s*(.+)$", i.get("body") or "", re.M | re.I)
todo.append((i, done.group(1).strip() if done else None))
print(f"tick {today} mode={mode} ready={len(issues)} new={len(todo)} sent_today={sent_today}/{cap}")
for i, done in todo:
if sent_today >= cap:
print("daily cap reached; stopping")
break
if not done:
comment(i["number"], "⏱️ Time: this is labelled `ready` but has no `DONE:` line, so there's nothing to "
"dispatch against yet. Add one (e.g. `DONE: send to @review`) and I'll pick it up.",
{"beat": "needs-done", "mode": mode})
elif mode == "dry-run":
comment(i["number"], f"⏱️ Time (dry run): would dispatch this to the worker now.\n\n- DONE: {done}\n"
"- Worker: not wired yet (set `TIME_MODE=live` once a worker exists)",
{"beat": "would-dispatch", "mode": mode})
else:
comment(i["number"], "⏱️ Time: live mode is set but no worker is wired yet, so nothing was dispatched.",
{"beat": "no-worker", "mode": mode})
sent_today += 1
stuck_h = float(os.environ.get("TIME_STUCK_HOURS") or 6)
worker_model = (os.environ.get("TIME_CURSOR_MODEL") or "").strip() or None
now = dt.datetime.now(dt.timezone.utc)
today = now.date().isoformat()
cards = [i for i in gh("GET", f"/repos/{REPO}/issues?state=open&labels=ready&per_page=50") if "pull_request" not in i]
prs = gh("GET", f"/repos/{REPO}/pulls?state=all&sort=updated&direction=desc&per_page=50")
launches_today = 0
plan = []
for card in cards:
cs = gh("GET", f"/repos/{REPO}/issues/{card['number']}/comments?per_page=100")
beats = beats_of(cs)
launches_today += sum(1 for b in beats if b.get("launched") and b["at"].startswith(today))
real = [b for b in beats if b["beat"] != "blocked"] # "blocked" = a key was missing; resume once it's added
last = real[-1] if real else None
blocked = any(b["beat"] == "blocked" and (not last or b["at"] >= last["at"]) for b in beats)
if last and retry_requested(cs, last["at"]):
last = None
m = re.search(r"^\s*DONE:\s*(.+)$", card.get("body") or "", re.M | re.I)
plan.append((card, last, m.group(1).strip() if m else None, blocked))
print(f"tick {now:%Y-%m-%dT%H:%MZ} mode={mode} cards={len(cards)} launches_today={launches_today}/{cap}")
for card, last, done, blocked in plan:
n, state = card["number"], (last or {}).get("beat")
try:
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 "
"against yet. Add one (e.g. `DONE: PR open, tests green`) and I'll pick it up.",
{"beat": "needs-done", "mode": mode})
continue
if state in (None, "needs-done") and mode != "live":
comment(n, f"⏱️ Time (dry run): would dispatch this to the worker now.\n\n- DONE: {done}\n"
f"- Worker: Cursor cloud agent{f' ({worker_model})' if worker_model else ''}, opens a PR that closes #{n}\n"
"- Goes for real once `TIME_MODE=live`", {"beat": "would-dispatch", "mode": mode})
continue
if state in (None, "needs-done", "would-dispatch") and mode == "live":
if launches_today >= cap:
print(f"#{n}: daily cap reached")
continue
r = cursor_launch(BUILD_PROMPT.format(n=n, repo=REPO, title=card["title"], body=(card.get("body") or "")[:20000],
base=default_branch(), done=done), model=worker_model)
launches_today += 1
link = f" ([agent]({r['agent_url']}))" if r.get("agent_url") else ""
comment(n, f"⏱️ Time: dispatched to a Cursor cloud agent{link}. It will open a PR that closes #{n}; "
"I'll link it here when it appears.", {"beat": "dispatched", "launched": True, **r, "model": worker_model})
continue
if state in ("dispatched", "stuck"):
found = linked_prs(prs, n)
if found:
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"])
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":
age_h = (now - dt.datetime.fromisoformat(last["at"].replace("Z", "+00:00"))).total_seconds() / 3600
if age_h > stuck_h:
comment(n, f"⏱️ Time: dispatched {age_h:.0f}h ago and no PR closing #{n} yet. Check the agent"
f"{' (' + last['agent_url'] + ')' if last.get('agent_url') else ''}; comment `/time retry` to "
"dispatch again.", {"beat": "stuck"})
except LookupError as e: # a key is missing: say so once, plainly
if not blocked:
comment(n, f"⏱️ Time: can't go further on this card: {e}. I'll carry on once it's added.", {"beat": "blocked"})
except Exception as e: # noqa: BLE001 one card must not stop the others
print(f"#{n}: {type(e).__name__}: {e}")
comment(n, f"⏱️ Time: the worker call failed: `{str(e)[:300]}`. Comment `/time retry` to try again.",
{"beat": "error"})
return 0
# ---------- doctor ----------
def cmd_doctor() -> int:
lines, ok = [], True
def row(name: str, good: bool, detail: str) -> None:
nonlocal ok
ok &= good or name.startswith("(optional)")
lines.append(f"| {'✅' if good else '⚠️'} | {name} | {detail} |")
labels = [l["name"] for l in gh("GET", f"/repos/{REPO}/labels?per_page=100")]
row("label `ready`", "ready" in labels, "exists" if "ready" in labels else "missing: `gh label create ready`")
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_REVIEW", True, os.environ.get("TIME_REVIEW") or "off (default)")
try:
ids = [m["id"] for m in http("GET", f"{VENICE}/models?type=text", os.environ["VENICE_API_KEY"])["data"]] \
if os.environ.get("VENICE_API_KEY") else None
row("(optional) VENICE_API_KEY (seats)", ids is not None, f"works, {len(ids)} models" if ids else "not set")
except Exception as e: # noqa: BLE001
row("(optional) VENICE_API_KEY (seats)", False, f"set but failing: {str(e)[:120]}")
try:
models = cursor("GET", "/v1/models")
items = models.get("models") or models.get("data") or models.get("items") or (models if isinstance(models, list) else [])
names = [m.get("id") if isinstance(m, dict) else str(m) for m in items]
row("CURSOR_API_KEY (worker)", True, f"works; models: {', '.join(n for n in names[:12] if n) or 'listed'}")
except LookupError:
row("CURSOR_API_KEY (worker)", False, "not set: `gh secret set CURSOR_API_KEY`")
except Exception as e: # noqa: BLE001
row("CURSOR_API_KEY (worker)", False, f"set but failing: {str(e)[:160]}")
report = "## Time doctor\n\n| | check | detail |\n|---|---|---|\n" + "\n".join(lines) + \
"\n\nThe Cursor GitHub app must also be installed on this repo for cloud agents to open PRs (not checkable from here)."
print(report)
if os.environ.get("GITHUB_STEP_SUMMARY"):
Path(os.environ["GITHUB_STEP_SUMMARY"]).write_text(report)
return 0 if ok else 1
if __name__ == "__main__":
sys.exit({"mention": cmd_mention, "heartbeat": cmd_heartbeat}[sys.argv[1]]())
sys.exit({"mention": cmd_mention, "heartbeat": cmd_heartbeat, "doctor": cmd_doctor}[sys.argv[1]]())