Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
458ac0484e | ||
|
|
de3857aa93 | ||
|
|
a5249ce8db |
No files matched your search
@@ -20,7 +20,8 @@ def fake_git_commit(branch, files, message, *, base=None, append=None):
|
||||
"""Pushes land in whichever fake world tm.http is bound to, as the contents API's writes used to."""
|
||||
w = getattr(tm.http, "__self__", None)
|
||||
if w is None: return "sha"
|
||||
if not files and not append: w.branches = getattr(w, "branches", []) + [branch]
|
||||
if not files and not append:
|
||||
w.branches = getattr(w, "branches", []) + [branch]; w.bases = {**getattr(w, "bases", {}), branch: base}
|
||||
for fp, data in (files or {}).items():
|
||||
if hasattr(w, "writes"): w.writes.append((fp, "s" if fp in w.files else None))
|
||||
if data is None: w.files.pop(fp, None)
|
||||
@@ -62,6 +63,7 @@ class World:
|
||||
tc("write_file", {"path": ".github/workflows/evil.yml", "content": "nope"}),
|
||||
tc("open_pr", {"title": "Set x to 2", "summary": "Changed app.py; ran nothing."})]
|
||||
assert body["tools"] and body["messages"][0]["role"] == "system"
|
||||
self.venice_system = body["messages"][0]["content"]
|
||||
if self.turn <= len(script):
|
||||
return {"choices": [{"message": {"role": "assistant", "content": None, "tool_calls": [script[self.turn - 1]]}}]}
|
||||
return {"choices": [{"message": {"role": "assistant", "content": "done"}}]}
|
||||
@@ -312,6 +314,14 @@ assert w.branches == ["time/card-1"] and w.files["app.py"] == "x = 2\n" and w.wr
|
||||
assert ".github/workflows/evil.yml" not in w.files
|
||||
assert len(w.opened) == 1 and w.opened[0]["head"] == "time/card-1" and "Closes #1" in w.opened[0]["body"]
|
||||
assert "opened PR #42" in w.posted[-1][1] and "kimi-k2-7-code" in w.posted[-1][1]
|
||||
# 14b a card with a `Base: <branch>` line is built on a branch cut from that base, named for it, and its PR targets it
|
||||
w = run(World([card(1, "Base: gh-pages\n\n" + D)], {1: [beat("would-dispatch")]}), {**K, "TIME_WORKER": "venice:kimi code", "VENICE_API_KEY": "vk"})
|
||||
assert w.branches == ["time/card-1-gh-pages"] and w.bases == {"time/card-1-gh-pages": "gh-pages"}, (w.branches, getattr(w, "bases", None))
|
||||
assert w.opened[0]["head"] == "time/card-1-gh-pages" and w.opened[0]["base"] == "gh-pages", w.opened
|
||||
assert "cut from `gh-pages`" in w.venice_system and "targets `gh-pages`" in w.venice_system
|
||||
assert "on branch `time/card-1-gh-pages`" in w.posted[0][1], w.posted[0]
|
||||
assert tm.card_base({"body": "Base: `release/1.x`\nDONE: x"}) == "release/1.x" and tm.card_base({"body": "rebase: no\nDONE: x"}) is None
|
||||
assert tm.card_branch({"number": 5, "body": D}) == "time/card-5" and w.bases.get("time/card-1") is None
|
||||
# then the normal path links the PR and reviews it
|
||||
w2 = run(World([card(1, D)], {1: [beat("dispatched", launched=True, worker="venice", pr=42)]},
|
||||
[{"number": 42, "title": "Set x to 2", "body": "Closes #1", "html_url": "u"}]), K)
|
||||
@@ -497,6 +507,41 @@ w = claude_env(ClaudeWorld([card(1, D)], {1: [beat("would-dispatch")]}, ledger=o
|
||||
assert w.vars == {"TIME_MODE": ("PUT", "dry-run")} and "dry-run" in next(i for i in w.issues.values() if "andon" in i["labels"])["body"]
|
||||
print("ALL OK 51")
|
||||
|
||||
# 57 the daily cap gates launches, not the run under way: a worker launched within budget finishes up to TIME_CAP_SLACK x
|
||||
# the cap (default 1.5), and a PR that is open still gets its review; a new launch and the monthly cap see no slack
|
||||
near = [{"at": iso(), "model": "claude-opus-5-5", "usd": 4.99}] # each scripted step books $0.022, so step 2 crosses $5
|
||||
sent.clear()
|
||||
w = claude_env(ClaudeWorld([card(1, D)], {1: [beat("would-dispatch")]}, ledger=near), {"TIME_WORKER": "claude:sonnet"})
|
||||
assert len(w.opened) == 1 and len(sent) == 5 and "opened PR #42" in w.posted[-1][1], "the run finished under the slack"
|
||||
sent.clear()
|
||||
w = claude_env(ClaudeWorld([card(1, D)], {1: [beat("would-dispatch")]}, ledger=near), {"TIME_WORKER": "claude:sonnet", "TIME_CAP_SLACK": "1"})
|
||||
assert w.opened == [] and len(sent) == 1 and "$5.00 cap" in w.posted[-1][1] and '"beat":"capped"' in w.posted[-1][1].replace(" ", ""), "no slack: the old stop"
|
||||
os.environ.pop("TIME_CAP_SLACK"); sent.clear()
|
||||
w = claude_env(ClaudeWorld([card(1, D)], {1: [beat("would-dispatch")]}, ledger=[{"at": iso(), "model": "claude-opus-5-5", "usd": 5.5}]), {"TIME_WORKER": "claude:sonnet"})
|
||||
assert sent == [] and w.opened == [] and "a run under way may go to" not in w.posted[-1][1], "a new launch never gets the slack"
|
||||
sent.clear()
|
||||
w = claude_env(ClaudeWorld([card(1, D)], {1: [beat("would-dispatch")]}, ledger=[{"at": iso(), "model": "claude-opus-5-5", "usd": 99.99}]), {"TIME_WORKER": "claude:sonnet", "TIME_DAILY_USD": "500"})
|
||||
assert len(sent) == 1 and w.opened == [] and "this month's spend" in w.posted[-1][1], "the monthly cap is hard inside a run too"
|
||||
# the review of an open PR runs on a capped day (within the slack), and says so if even the slack is spent
|
||||
tm.think = real_think # the real one: the budget check lives in chat(), under it
|
||||
tm.pr_diff = lambda n, limit=0: "diff --git a b"
|
||||
def review_world(usd):
|
||||
wr = ClaudeWorld([card(1, D)], {1: [beat("dispatched", launched=True)]}, [pr], ledger=[{"at": iso(), "model": "claude-opus-5-5", "usd": usd}])
|
||||
wr.venice = lambda body: {"choices": [{"message": {"content": "**Approve** fine"}}]}
|
||||
return wr
|
||||
w = claude_env(review_world(6), {"TIME_REVIEW": "venice:kimi", "VENICE_API_KEY": "vk"})
|
||||
assert w.posted[0][0] == 7 and w.posted[0][1].startswith("**Approve**") and "`kimi` reviewed" in w.posted[1][1], w.posted
|
||||
w = claude_env(review_world(8), {"TIME_REVIEW": "venice:kimi", "VENICE_API_KEY": "vk"})
|
||||
assert w.posted[0][0] == 1 and "may go to $7.50" in w.posted[0][1] and '"beat":"capped"' in w.posted[0][1].replace(" ", ""), w.posted
|
||||
os.environ.pop("VENICE_API_KEY", None); os.environ.pop("TIME_CAP_SLACK", None)
|
||||
# the tick takes the oldest ready card first, whatever order the API lists them in
|
||||
wo = ClaudeWorld([card(9, D), card(4, D)], {9: [beat("would-dispatch")], 4: [beat("would-dispatch")]})
|
||||
_lab = wo.labelled
|
||||
wo.labelled = lambda name: sorted(_lab(name), key=lambda i: -i["number"])
|
||||
w = claude_env(wo, {"TIME_WORKER": "claude:sonnet", "TIME_DAILY_CAP": "1"})
|
||||
assert [p[0] for p in w.posted][:1] == [4] and len(w.opened) == 1, w.posted
|
||||
print("ALL OK 57")
|
||||
|
||||
# TIME_WORKER=claude:sonnet: same hands, Claude's endpoint, the seat's label in the PR footer and the beats
|
||||
sent.clear()
|
||||
w = claude_env(ClaudeWorld([card(1, D)], {1: [beat("would-dispatch")]}), {"TIME_WORKER": "claude:sonnet"})
|
||||
|
||||
+57
-14
@@ -20,6 +20,7 @@ import datetime as dt
|
||||
import importlib.util
|
||||
import atexit
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -308,12 +309,30 @@ 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()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def finishing():
|
||||
"""Inside this block a model call may run past the daily cap, up to TIME_CAP_SLACK x it (default 1.5): a worker that
|
||||
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))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_slack = 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:
|
||||
raise Budget(f"today's spend is ${today:.2f} of the ${day_cap:.2f} cap; carrying on tomorrow")
|
||||
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")
|
||||
|
||||
|
||||
def andon(reason: str) -> None:
|
||||
@@ -676,7 +695,7 @@ WORKER_RULES = """You are the worker for card #{n} in {repo}: "{title}".
|
||||
{body}
|
||||
|
||||
You have hands: list_files, read_file, write_file (one file per call, full content), run (shell on a fresh checkout of
|
||||
your branch), open_pr. Work on the branch `{branch}`. Only two things reach it: write_file, and files a run lists in
|
||||
your branch), open_pr. Work on the branch `{branch}`.{base_note} Only two things reach it: write_file, and files a run lists in
|
||||
`keep` (for generated files, e.g. a sample built by a script). Anything else a run does is thrown away. Keep the change small and focused.
|
||||
Read before you write. Always `run` the project's tests or checks before opening the PR (that run is the PR's check). When it is done and checked, call open_pr once
|
||||
with a short summary; the PR must close the card, and Time adds that line for you. Do not touch .github/workflows or .gitea/workflows.
|
||||
@@ -801,11 +820,28 @@ def git_commit(branch: str, files: dict[str, bytes | None], message: str, *, bas
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
BASE_LINE = re.compile(r"^\s*Base:\s*`?([A-Za-z0-9_./+-]+)`?\s*$", re.M | re.I)
|
||||
|
||||
|
||||
def card_base(card: dict) -> str | None:
|
||||
"""The branch a card's work is cut from and its PR targets, from a `Base: <branch>` line in the body (a venture that
|
||||
lives on `gh-pages`, say). None means the default branch, as before."""
|
||||
m = BASE_LINE.search(card.get("body") or "")
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def card_branch(card: dict) -> str:
|
||||
"""`time/card-N`, or `time/card-N-<base>` for a card on another branch: a branch cut from the default branch before
|
||||
the card said otherwise is never resumed by mistake, and the name says where the work lives."""
|
||||
base = card_base(card)
|
||||
return f"time/card-{card['number']}" + (f"-{base}" if base else "")
|
||||
|
||||
|
||||
class Hands:
|
||||
def __init__(self, n: int, branch: str):
|
||||
self.n, self.branch, self.pr, self.steps = n, branch, None, 0
|
||||
def __init__(self, n: int, branch: str, base: str | None = None):
|
||||
self.n, self.branch, self.base, self.pr, self.steps = n, branch, base, None, 0
|
||||
self.label = "a seat" # venice_work names the seat for the PR footer
|
||||
git_commit(branch, {}, f"card #{n}: branch") # already there: resume on it (nothing to commit, push is a no-op)
|
||||
git_commit(branch, {}, f"card #{n}: branch", base=base) # already there: resume on it (nothing to commit, push is a no-op)
|
||||
self.checkout = None
|
||||
|
||||
def _checkout(self) -> str:
|
||||
@@ -883,7 +919,7 @@ class Hands:
|
||||
gh("PATCH", f"/repos/{REPO}/pulls/{old['number']}", {"title": a["title"][:200], "body": body}, as_time=True)
|
||||
self.pr = old["number"]
|
||||
return f"updated PR #{self.pr}"
|
||||
pr = gh("POST", f"/repos/{REPO}/pulls", {"title": a["title"][:200], "head": self.branch, "base": default_branch(),
|
||||
pr = gh("POST", f"/repos/{REPO}/pulls", {"title": a["title"][:200], "head": self.branch, "base": self.base or default_branch(),
|
||||
"body": body}, as_time=True)
|
||||
self.pr = pr["number"]
|
||||
return f"opened PR #{self.pr}"
|
||||
@@ -910,10 +946,13 @@ def venice_work(card: dict, done: str, spec: str, hands_cls=None, review: str |
|
||||
seat, model, effort = worker_seat_model(spec)
|
||||
fallback = (os.environ.get("TIME_WORKER_FALLBACK") or ("grok build" if provider_of(model) == "venice" else "off")).strip()
|
||||
fb_model = worker_seat_model(fallback)[1] if fallback.lower() != "off" else None
|
||||
hands = (hands_cls or Hands)(card["number"], f"time/card-{card['number']}")
|
||||
base = card_base(card)
|
||||
hands = (hands_cls or Hands)(card["number"], card_branch(card), **({"base": base} if base else {}))
|
||||
hands.label = SEATS[seat]["label"]
|
||||
base_note = (f" It is cut from `{base}` and your pull request targets `{base}`, not the default branch: that is where "
|
||||
"this card's files live.") if base else ""
|
||||
system = WORKER_RULES.format(n=card["number"], repo=REPO, title=card["title"],
|
||||
body=(card.get("body") or "")[:20000], branch=hands.branch, done=done)
|
||||
body=(card.get("body") or "")[:20000], branch=hands.branch, done=done, base_note=base_note)
|
||||
if review is not None: # answering a review: same branch, and the reviewer's words go in marked as theirs
|
||||
system += REVISE_RULES.format(branch=hands.branch, review=review or "(no review comments were found; check the PR)")
|
||||
msgs = [{"role": "system", "content": system},
|
||||
@@ -925,13 +964,15 @@ def venice_work(card: dict, done: str, spec: str, hands_cls=None, review: str |
|
||||
if effort:
|
||||
body["reasoning_effort"] = effort
|
||||
try:
|
||||
msg = chat(body, timeout=300)["choices"][0]["message"]
|
||||
with finishing(): # launched within budget: this run may finish past the daily cap (TIME_CAP_SLACK)
|
||||
msg = chat(body, timeout=300)["choices"][0]["message"]
|
||||
except RuntimeError as e:
|
||||
if isinstance(e, Budget) or not fb_model or model == fb_model or not re.search(r"-> (429|5\d\d)\b", str(e)):
|
||||
raise
|
||||
print(f"{model} stays overloaded; the fallback {fb_model} takes over")
|
||||
model = body["model"] = fb_model
|
||||
msg = chat(body, timeout=300)["choices"][0]["message"]
|
||||
with finishing():
|
||||
msg = chat(body, timeout=300)["choices"][0]["message"]
|
||||
msgs.append({k: v for k, v in msg.items() if k in ("role", "content", "tool_calls")})
|
||||
calls = msg.get("tool_calls") or []
|
||||
if not calls:
|
||||
@@ -1030,7 +1071,8 @@ def review(card: dict, pr: dict, done: str) -> dict:
|
||||
.replace("Leave one PR comment that", "Your reply is posted as the PR comment. It")
|
||||
+ "\nThe checks listed under # Checks are the head commit's real statuses: evidence you can rely on "
|
||||
"(a success there means that context reported success). A pending check is not yet evidence either way.")
|
||||
text = think(seat, model, effort, system, user)
|
||||
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}
|
||||
@@ -1060,7 +1102,7 @@ def tick() -> None:
|
||||
f"Cursor cloud agent{f' ({worker_model})' if worker_model else ''}"
|
||||
now = dt.datetime.now(dt.timezone.utc)
|
||||
today = now.date().isoformat()
|
||||
cards = issues("state=open&labels=ready")
|
||||
cards = sorted(issues("state=open&labels=ready"), key=lambda c: c["number"]) # oldest first, like promote_next
|
||||
prs = gh("GET", f"/repos/{REPO}/pulls?state=all&sort=recentupdate&limit=50")
|
||||
launches_today = 0
|
||||
plan = []
|
||||
@@ -1111,7 +1153,7 @@ def tick() -> None:
|
||||
if seat_worker(worker):
|
||||
prov = provider_of(worker_seat_model(worker)[1])
|
||||
comment(n, f"⏱️ Time: {worker_name} is " + (f"answering the review on PR #{rev['number']}" if rev else "working on this")
|
||||
+ f" now, on branch `time/card-{n}`.", {"beat": "dispatched", "launched": True, "worker": prov})
|
||||
+ f" now, on branch `{card_branch(card)}`.", {"beat": "dispatched", "launched": True, "worker": prov})
|
||||
launches_today += 1
|
||||
if rev:
|
||||
try:
|
||||
@@ -1196,6 +1238,7 @@ def cmd_doctor() -> int:
|
||||
row("TIME_REVIEW", True, os.environ.get("TIME_REVIEW") or "off (default)")
|
||||
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)")
|
||||
row("TIME_MONTHLY_USD", True, os.environ.get("TIME_MONTHLY_USD") or "100 (default)")
|
||||
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")),
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
- 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
|
||||
- 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.
|
||||
- `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).
|
||||
|
||||
Reference in new issue
Block a user