Compare commits

..
Author SHA1 Message Date
teahubxyzandClaude Fable 5.1 4f8eda2483 labs/aicharts: fast mode (Clef first, LLM checks) as demo default (#42)
ci / test (push) Successful in 34s
time/worker-run curl -sS -m 15 https://docs.whop.com/.well-known/skills/index.json | head -c 1500; echo; curl -sS -m
labs/aicharts: fast mode (Clef first, LLM checks) as demo default (#42)

Opened 2026-10-14 by a Claude Code session under the owner's login, which is why it read as the operator's PR for three days. Lab-only and reversible: Clef-flash draws first, the LLM checks in parallel, the chart is redrawn only on disagreement; API callers keep the LLM default. The lab's tests pass on the branch (run 652, "aicharts: all good"); its deploy step cannot run from prod, which holds no Cloudflare secrets, so nothing is deployed by this merge.

Squash-merged by the CEO session on the owner's explicit instruction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-18 00:12:54 +00:00
teahubxyzandClaude Fable 5.1 b539fff1b2 heartbeat: per-seat tokens for haiku, sonnet, opus and fable
ci / test (push) Successful in 35s
One Gitea user per seat (model line); the reviewer's verdict becomes a real review by that seat's own user once #76 lands. TIME_SEAT_CLAUDE stays until then as the umbrella.

On the owner's explicit instruction (CEO session).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-17 22:54:56 +00:00
time 0d893d7fc5 ledger: 40 calls, $2.52 2026-10-17 20:24:01 +00:00
teahubxyzandClaude Fable 5.1 5c8254cc50 TIME_PROTECTED governs every protected path except the workflow directories (#81)
ci / test (push) Successful in 34s
TIME_PROTECTED governs every protected path except the workflow directories (#81)

touches_protected() hard-coded all of .github/ and .gitea/ on top of TIME_PROTECTED, so the engine under .github/time/ could never land without a human. Only the workflow directories stay hard-coded; DEFAULT_PROTECTED is unchanged. With this repo's TIME_PROTECTED narrowed, card PRs under .github/time/ with a trusted Approve and green checks merge via the time bot.

CI green on 3b757fa. Squash-merged by the CEO session on the owner's explicit instruction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-17 20:20:12 +00:00
teahubxyzandClaude Fable 5.1 b53016007a heartbeat: 55-minute job, and the claude seat's token for reviews
ci / test (push) Successful in 34s
The job timeout moves from 20 to 55 minutes: workers can overlap inside one run (#75), and at 20 every in-flight worker died together. TIME_SEAT_CLAUDE reaches the heartbeat so the reviewer can post as the `claude` user once #76 lands; until then it is unused.

Both on the owner's explicit instruction (CEO session).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
2026-10-17 20:16:32 +00:00
timeandClaude Fable 5.1 449f89fc38 Reviewer rule wording; no duplicate "Closes #N" in PR bodies (#74)
ci / test (push) Successful in 35s
Reviewer rule wording; no duplicate "Closes #N" in PR bodies (#74)

Reviewer Approve (haiku) on the diff, CI green on 16b369dd. Engine-only change for card #58; squash-merged by the CEO session on the owner's explicit instruction.

Closes #58

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD
Co-authored-by: Time <time@users.noreply.teahub.xyz>
2026-10-17 20:13:25 +00:00
8 changed files with 96 additions and 225 deletions

No files matched your search

+28 -2
View File
@@ -169,7 +169,8 @@ export function attach(chart, { data, schema, endpoint, dataset }) {
const Base = globalThis.HTMLElement ?? class {}; const Base = globalThis.HTMLElement ?? class {};
// <ai-chart endpoint="https://..." dataset="orders" brain="jev"></ai-chart> brain: llm | jev | clef (server default if unset) // <ai-chart endpoint="https://..." dataset="orders" brain="fast"></ai-chart>
// brain: fast (decision model first, LLM checks) | llm | jev | clef (server default if unset)
// Needs Chart.js on the page (window.Chart). Fires "spec" after each answer and "save" when Save is pressed; set // Needs Chart.js on the page (window.Chart). Fires "spec" after each answer and "save" when Save is pressed; set
// element.onSave = async ({ spec, prompt, dataset }) => ... to store charts yourself (the demo stores them in D1). // element.onSave = async ({ spec, prompt, dataset }) => ... to store charts yourself (the demo stores them in D1).
export class AiChart extends Base { export class AiChart extends Base {
@@ -202,7 +203,9 @@ export class AiChart extends Base {
try { try {
await this.load(); await this.load();
const brain = this.getAttribute("brain"); const brain = this.getAttribute("brain");
const { spec, ...info } = await fetchSpec(this.endpoint, { dataset: this.dataset, prompt, prior: this._spec, ...(brain ? { brain } : {}) }); const body = { dataset: this.dataset, prompt, prior: this._spec };
if (brain === "fast") return await this.askFast(body);
const { spec, ...info } = await fetchSpec(this.endpoint, { ...body, ...(brain ? { brain } : {}) });
await this.render(spec, prompt); await this.render(spec, prompt);
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } })); this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } }));
} catch (e) { } catch (e) {
@@ -210,6 +213,29 @@ export class AiChart extends Base {
} }
} }
// brain="fast": a decision model draws first (under a second), an LLM answers in the background, and the chart is
// redrawn only if the LLM's chart differs. Whichever comes back alone still draws.
async askFast(body) {
const ask = ++this._asks || (this._asks = 1);
const current = () => ask === this._asks;
const same = (a, b) => JSON.stringify({ ...a, title: 0 }) === JSON.stringify({ ...b, title: 0 });
const llm = fetchSpec(this.endpoint, { ...body, brain: "llm" }).catch((e) => ({ error: e }));
let drawn = null;
try {
const { spec, ...info } = await fetchSpec(this.endpoint, { ...body, brain: "clef", floor: 0 });
if (!current()) return;
await this.render(spec, body.prompt);
drawn = spec;
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt: body.prompt, info: { ...info, stage: "fast" } } }));
} catch { /* the LLM will answer */ }
const out = await llm;
if (!current() || out.error) { if (!drawn && out.error) throw out.error; return; }
const { spec, ...info } = out;
const changed = !drawn || !same(drawn, spec);
if (changed) await this.render(spec, body.prompt);
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt: body.prompt, info: { ...info, stage: drawn ? (changed ? "corrected" : "confirmed") : "llm" } } }));
}
async render(spec, prompt) { async render(spec, prompt) {
await this.load(); await this.load();
const v = validateSpec(spec, this._schema); const v = validateSpec(spec, this._schema);
+6 -4
View File
@@ -52,16 +52,18 @@ export function questions(info) {
x: choice("Which column goes on the x axis (the categories or time the chart is broken down by)?", axis), x: choice("Which column goes on the x axis (the categories or time the chart is broken down by)?", axis),
measure: choice("Which quantity does the chart measure?", measure), measure: choice("Which quantity does the chart measure?", measure),
agg: choice("How should that quantity be combined within each group?", AGG_MENU), agg: choice("How should that quantity be combined within each group?", AGG_MENU),
series: choice("Should the chart split into one coloured series per value of another column? Pick none unless the request compares groups against each other (\"web vs store\", \"by region over time\").", series: choice("Does the request compare two or more values of a column side by side (\"web vs store\", \"split by channel\", \"each region over time\")? If so, pick that column; it becomes one coloured line or bar per value. Otherwise none.",
{ none: "One series only.", ...Object.fromEntries(text.map((c) => [c.name, `one series per ${c.name}`])) }), { none: "No comparison asked for: a single series.",
...Object.fromEntries(text.map((c) => [c.name, `Compare ${c.name}s (${(info.aggregates[c.name]?.values || []).join(" vs ")}): one series per ${c.name}.`])) }),
order: choice("How should the groups be ordered?", ORDER_MENU), order: choice("How should the groups be ordered?", ORDER_MENU),
top: choice("Does the request limit how many groups to show?", TOP_MENU), top: choice("Does the request limit how many groups to show?", TOP_MENU),
}; };
for (const c of text) { for (const c of text) {
const vals = info.aggregates[c.name]?.values; const vals = info.aggregates[c.name]?.values;
if (vals?.length && vals.length <= 30) if (vals?.length && vals.length <= 30)
q[`only.${c.name}`] = choice(`Does the request restrict the data to one ${c.name}? Pick any unless it names one.`, q[`only.${c.name}`] = choice(`Does the request name exactly one ${c.name} to look at (for example "${vals[0]} sales" or "in the ${vals[0]}")? Pick that value; pick any if it names none, or names several to compare.`,
{ any: `No restriction on ${c.name}.`, ...Object.fromEntries(vals.map((v) => [v, `Only ${c.name} = ${v}.`])) }); { any: `The request names no single ${c.name}.`,
...Object.fromEntries(vals.map((v) => [v, `The request mentions "${v}": keep only rows where ${c.name} is ${v}.`])) });
} }
return q; return q;
} }
+4 -3
View File
@@ -287,8 +287,8 @@ h2{font-size:18px;margin:28px 0 8px}.row{display:flex;flex-wrap:wrap;gap:8px;ali
</style></head><body><main> </style></head><body><main>
<h1>AICharts</h1> <h1>AICharts</h1>
<p class="lede">Ask for a chart in plain words. The model writes a small spec that is checked before anything draws, and never sees the rows: only column names, five sample rows and value ranges. Data: a sample tea shop, 24 months of orders.</p> <p class="lede">Ask for a chart in plain words. The model writes a small spec that is checked before anything draws, and never sees the rows: only column names, five sample rows and value ranges. Data: a sample tea shop, 24 months of orders.</p>
<div class="row"><div class="chips" id="chips"></div><label class="brain">Brain <select id="brain"><option value="">default</option><option value="jev">Jev</option><option value="clef">Clef</option><option value="llm">LLM</option></select></label></div> <div class="row"><div class="chips" id="chips"></div><label class="brain">Brain <select id="brain"><option value="fast" selected>Fast (Clef, LLM checks)</option><option value="clef">Clef only</option><option value="jev">Jev</option><option value="llm">LLM only</option></select></label></div>
<ai-chart dataset="orders"></ai-chart> <ai-chart dataset="orders" brain="fast"></ai-chart>
<div class="meta" id="meta"></div> <div class="meta" id="meta"></div>
<details><summary>Spec</summary><pre id="spec">Ask something first.</pre></details> <details><summary>Spec</summary><pre id="spec">Ask something first.</pre></details>
<h2>Saved charts</h2><ul id="saved"><li>None yet.</li></ul> <h2>Saved charts</h2><ul id="saved"><li>None yet.</li></ul>
@@ -310,7 +310,8 @@ brainSel.onchange = () => brainSel.value ? el.setAttribute("brain", brainSel.val
el.addEventListener("spec", (e) => { el.addEventListener("spec", (e) => {
const { spec, info = {} } = e.detail; const { spec, info = {} } = e.detail;
document.getElementById("spec").textContent = JSON.stringify({ ...info, spec }, null, 2); document.getElementById("spec").textContent = JSON.stringify({ ...info, spec }, null, 2);
const bits = [info.brain && "via " + info.brain + (info.handoff ? " (handed off: " + info.handoff.reason + ")" : ""), const stage = { fast: "first draw", confirmed: "LLM agrees", corrected: "corrected by the LLM" }[info.stage];
const bits = [stage, info.brain && "via " + info.brain + (info.handoff ? " (handed off: " + info.handoff.reason + ")" : ""),
info.ms != null && info.ms + " ms", info.confidence != null && "confidence " + info.confidence].filter(Boolean); info.ms != null && info.ms + " ms", info.confidence != null && "confidence " + info.confidence].filter(Boolean);
meta.replaceChildren(document.createTextNode(bits.join(" · "))); meta.replaceChildren(document.createTextNode(bits.join(" · ")));
for (const alt of info.alternatives || []) { for (const alt of info.alternatives || []) {
-111
View File
@@ -575,115 +575,6 @@ if _sh.which("node"):
assert res.returncode == 0 and "aicharts: all good" in res.stdout, res.stdout[-2000:] + res.stderr[-2000:] assert res.returncode == 0 and "aicharts: all good" in res.stdout, res.stdout[-2000:] + res.stderr[-2000:]
print("ALL OK 41") print("ALL OK 41")
# 41 A heartbeat overlaps priced API workers, instead of running them one by one.
# Barrier fails in a sequential implementation. The per-run cap remains independent.
import threading as _th41, time as _t41 # noqa: E402
_original_worker41 = tm.venice_work
_gate41 = _th41.Barrier(3, timeout=6)
_seen41, _peak41, _active41 = [], 0, 0
_lock41 = _th41.Lock()
def _overlap41(card, done, spec, hands_cls=None, review=None, **kwargs):
global _peak41, _active41
with _lock41:
_active41 += 1
_peak41 = max(_peak41, _active41)
_seen41.append(card["number"])
try:
_gate41.wait()
_t41.sleep(0.03)
return {"pr": 200 + card["number"], "steps": 1, "seat": "sonnet", "model": "claude-sonnet-5-5"}
finally:
with _lock41:
_active41 -= 1
tm.venice_work = _overlap41
tm.LEDGER.clear()
tm._ledger_files.clear()
w41 = run(World([card(n, D) for n in (1, 2, 3)]), {
"TIME_MODE": "live", "TIME_WORKER": "claude:sonnet",
"ANTHROPIC_API_KEY": "test", "TIME_PARALLEL": "3",
"TIME_DAILY_USD": "5", "TIME_MONTHLY_USD": "100"})
assert _peak41 == 3 and set(_seen41) == {1, 2, 3}, (_peak41, _seen41)
assert len([body for _, body in w41.posted if "opened PR #" in body]) == 3, w41.posted
_gate41 = _th41.Barrier(2, timeout=6)
_seen41.clear()
w41 = run(World([card(n, D) for n in (1, 2, 3)]), {
"TIME_MODE": "live", "TIME_WORKER": "claude:sonnet",
"ANTHROPIC_API_KEY": "test", "TIME_PARALLEL": "3", "TIME_DAILY_CAP": "2",
"TIME_DAILY_USD": "5", "TIME_MONTHLY_USD": "100"})
assert set(_seen41) == {1, 2} and len([x for _, x in w41.posted if "opened PR #" in x]) == 2, (w41.posted, _seen41)
tm.venice_work = _original_worker41
# 42 Reservation contention waits and resumes automatically within the heartbeat.
# Events establish ordering; no sleeps or hoped-for scheduling interleavings.
_original_api42, _original_worker42 = tm.anthropic_chat, tm.venice_work
_original_condition42 = tm._BUDGET_CHANGED
_original_ledger42 = list(tm.LEDGER)
_entered42, _release42, _waiting42 = _th41.Event(), _th41.Event(), _th41.Event()
_api_calls42 = []
class _ObservedBudgetCondition42(_th41.Condition):
def wait(self, timeout=None):
_waiting42.set()
return super().wait(timeout)
tm._BUDGET_CHANGED = _ObservedBudgetCondition42(tm._LEDGER_LOCK)
def _wait_for_api42(body, timeout):
_api_calls42.append(body["model"])
_entered42.set()
assert _release42.wait(5), "provider release timed out"
return {"usage": {"prompt_tokens": 10, "completion_tokens": 10}, "choices": []}
_body42 = {"model": "claude-sonnet-5-5", "max_tokens": 16000,
"messages": [{"role": "user", "content": "bounded task"}]}
def _budgeted_worker42(card, done, spec, **kwargs):
tm.chat(_body42, 3)
return {"pr": 200 + card["number"], "steps": 1, "seat": "sonnet", "model": "claude-sonnet-5-5"}
tm.anthropic_chat, tm.venice_work = _wait_for_api42, _budgeted_worker42
tm._ledger_files.clear()
tm.LEDGER[:] = [{"at": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"model": "claude-sonnet-5-5", "in": 0, "out": 0, "usd": 4.75}]
with cf.ThreadPoolExecutor(1) as _outer42:
_run42 = _outer42.submit(run, World([card(n, D) for n in (1, 2)]), {
"TIME_MODE": "live", "TIME_WORKER": "claude:sonnet", "ANTHROPIC_API_KEY": "test",
"TIME_PARALLEL": "2", "TIME_DAILY_USD": "5", "TIME_MONTHLY_USD": "100"})
try:
assert _entered42.wait(5) and _waiting42.wait(5), "first request in flight and second waiting"
assert len(_api_calls42) == 1, _api_calls42
finally:
_release42.set()
w42 = _run42.result(timeout=5)
assert len(_api_calls42) == 2, _api_calls42
assert len([b for _, b in w42.posted if "opened PR #" in b]) == 2, w42.posted
assert not any(tm.beats_of(w42.comments[n])[-1]["beat"] in ("error", "capped", "stuck") for n in (1, 2))
assert abs(tm._RESERVED_USD) < 1e-8, tm._RESERVED_USD
# A call that cannot fit EVEN WITHOUT reservations fails before contacting API.
tm.LEDGER[-1]["usd"] = 0.24
_before42 = len(_api_calls42)
try:
tm.chat(_body42, 3)
raise AssertionError("hard allowance must reject")
except tm.Budget:
pass
assert len(_api_calls42) == _before42 and tm._RESERVED_USD == 0
# An API exception releases its reservation, without swallowing the exception.
tm.LEDGER.clear()
def _failed_api42(body, timeout):
raise RuntimeError("provider failed")
tm.anthropic_chat = _failed_api42
try:
tm.chat(_body42, 3)
raise AssertionError("provider error must propagate")
except RuntimeError as e:
assert str(e) == "provider failed"
assert tm._RESERVED_USD == 0
tm.anthropic_chat, tm.venice_work = _original_api42, _original_worker42
tm._BUDGET_CHANGED = _original_condition42
tm.LEDGER[:] = _original_ledger42
tm._ledger_files.clear()
for _key42 in ("TIME_PARALLEL", "TIME_DAILY_CAP", "TIME_DAILY_USD", "TIME_MONTHLY_USD"):
os.environ.pop(_key42, None)
print("ALL OK 41-42 (API overlap, automatic contention recovery, hard cap, failure cleanup)")
# ---------- venture loop: still GitHub-shaped (Pages, author_association, per_page). Ported in its own card; # ---------- 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. # until then these run only with TIME_TEST_VENTURE=1 against the old fake.
if not os.environ.get("TIME_TEST_VENTURE"): if not os.environ.get("TIME_TEST_VENTURE"):
@@ -1641,5 +1532,3 @@ with cf.ThreadPoolExecutor(2) as ex:
vt2.tm.Hands, vt2.tm.venice_work = real40 vt2.tm.Hands, vt2.tm.venice_work = real40
got = dict((n, (x, y)) for n, x, y in seen40) got = dict((n, (x, y)) for n, x, y in seen40)
assert got[1][0] == "wrote" and got[1][1].startswith("refused") and got[2][0].startswith("refused") and got[2][1] == "wrote", got assert got[1][0] == "wrote" and got[1][1].startswith("refused") and got[2][0].startswith("refused") and got[2][1] == "wrote", got
+11 -104
View File
@@ -19,8 +19,6 @@ from __future__ import annotations
import datetime as dt import datetime as dt
import importlib.util import importlib.util
import atexit import atexit
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
import base64 import base64
import json import json
import os import os
@@ -235,51 +233,10 @@ def anthropic_chat(body: dict, timeout: float) -> dict:
def chat(body: dict, timeout: float) -> dict: def chat(body: dict, timeout: float) -> dict:
"""Reserve a conservative cost estimate; overlap requests that fit. """Every model call goes through here: the budget is checked first and the spend booked after."""
budget_check()
Temporary reservation contention waits for a completing call, rather than out = (anthropic_chat if provider_of(body["model"]) == "anthropic" else venice_chat)(body, timeout)
stranding a dispatched card as capped. Only actual allowance exhaustion book(body["model"], out.get("usage") or {})
rejects the call. This protects ONE process, not other Actions runs.
Provider requests/retries are timeout-bounded; wait releases the lock.
"""
global _RESERVED_USD
model = body["model"]
price = price_of(model)
quote = 0.0
if price:
payload = json.dumps({"messages": body.get("messages"), "tools": body.get("tools")},
ensure_ascii=False, default=str).encode("utf-8")
# Text-only estimate, deliberately pessimistic; not a billing guarantee
# for hidden framing, provider retries, or calls with unknown usage.
max_output = max(0, int(body.get("max_tokens") or 4000))
quote = (len(payload) * price[0] + max_output * price[1]) / 1e6
with _BUDGET_CHANGED:
while True:
budget_check()
day, day_cap, month, month_cap = budget()
if day + quote > day_cap or month + quote > month_cap:
raise Budget("remaining booked-spend allowance cannot cover this model call")
if (day + _RESERVED_USD + quote <= day_cap and
month + _RESERVED_USD + quote <= month_cap):
_RESERVED_USD += quote
break
# Other requests reserve capacity, but this one fits after they
# settle. A spurious wakeup simply rechecks the current allowance.
_BUDGET_CHANGED.wait()
try:
out = (anthropic_chat if provider_of(model) == "anthropic" else venice_chat)(body, timeout)
except BaseException:
with _BUDGET_CHANGED:
_RESERVED_USD = max(0.0, _RESERVED_USD - quote)
_BUDGET_CHANGED.notify_all()
raise
with _BUDGET_CHANGED:
try:
book(model, out.get("usage") or {})
finally:
# Replace reservation with measured spend in ONE locked transition.
_RESERVED_USD = max(0.0, _RESERVED_USD - quote)
_BUDGET_CHANGED.notify_all()
return out return out
@@ -287,9 +244,6 @@ def chat(body: dict, timeout: float) -> dict:
# Deterministic on purpose: arithmetic over usage numbers decides, never a model. # Deterministic on purpose: arithmetic over usage numbers decides, never a model.
LEDGER: list[dict] = [] LEDGER: list[dict] = []
_LEDGER_LOCK = threading.RLock() # cached ledger read may do HTTP; never hold across model/tool work
_BUDGET_CHANGED = threading.Condition(_LEDGER_LOCK)
_RESERVED_USD = 0.0 # in-flight quote, replaced atomically by measured spend
_ledger_files: dict[str, tuple[list[dict], str | None]] = {} # month -> (lines, sha) as read this process _ledger_files: dict[str, tuple[list[dict], str | None]] = {} # month -> (lines, sha) as read this process
@@ -1089,24 +1043,10 @@ def cmd_heartbeat() -> int:
plan_spec(spec) plan_spec(spec)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
print(f"spec #{spec['number']}: {type(e).__name__}: {e}") print(f"spec #{spec['number']}: {type(e).__name__}: {e}")
worker = (os.environ.get("TIME_WORKER") or "cursor").strip() for _ in range(3): # a merge can free the slot for the next card; dispatch it in the same run
parallel = max(1, min(16, int(os.environ.get("TIME_PARALLEL") or 1)))
if seat_worker(worker) and price_of(worker_seat_model(worker)[1]) and parallel > 1:
# Fill slots before invoking the workers, rather than wait for the
# first API call to complete before promoting the second card.
for _ in range(parallel):
if promote_next() is None:
break
tick() tick()
for _ in range(parallel): if promote_next() is None:
if promote_next() is None: break
break
tick()
else:
for _ in range(3): # Cursor/unpriced/one-worker preserve the historical path
tick()
if promote_next() is None:
break
return 0 return 0
@@ -1146,12 +1086,6 @@ def tick() -> None:
plan.append((card, last, m.group(1).strip() if m else None, blocked, capped, revision, revised_at)) plan.append((card, last, m.group(1).strip() if m else None, blocked, capped, revision, revised_at))
print(f"tick {now:%Y-%m-%dT%H:%MZ} mode={mode} cards={len(cards)} launches_today={launches_today}/{cap}") print(f"tick {now:%Y-%m-%dT%H:%MZ} mode={mode} cards={len(cards)} launches_today={launches_today}/{cap}")
# API workers have isolated branches; only budget and receipt accounting
# need a lock. Submit all eligible cards before awaiting any provider.
parallel = max(1, min(16, int(os.environ.get("TIME_PARALLEL") or 1)))
pool = (ThreadPoolExecutor(max_workers=parallel)
if seat_worker(worker) and price_of(worker_seat_model(worker)[1]) and parallel > 1 else None)
pending = {} # future -> (card, capped, provider, revision)
tripped = None tripped = None
for card, last, done, blocked, capped, revision, revised_at in plan: for card, last, done, blocked, capped, revision, revised_at in plan:
n, state = card["number"], (last or {}).get("beat") n, state = card["number"], (last or {}).get("beat")
@@ -1184,14 +1118,10 @@ def tick() -> None:
unlabel(rev["number"], "needs-human") # the objection is being answered unlabel(rev["number"], "needs-human") # the objection is being answered
except RuntimeError as e: except RuntimeError as e:
print(f"#{n}: needs-human label not removed: {str(e)[:100]}") print(f"#{n}: needs-human label not removed: {str(e)[:100]}")
args = (card, done, worker.split(":", 1)[1]) r = venice_work(card, done, worker.split(":", 1)[1], review=revision["feedback"])
kwargs = {"review": revision["feedback"]} if rev else {} else:
if pool is not None: r = venice_work(card, done, worker.split(":", 1)[1])
pending[pool.submit(venice_work, *args, **kwargs)] = (card, capped, prov, bool(rev)) # back to `dispatched`: the next beat reviews the PR again and the new verdict is the one that counts
continue
r = venice_work(*args, **kwargs)
# The next beat reviews the PR. The existing sequential path
# is unchanged for Cursor and one-worker API configurations.
comment(n, f"⏱️ Time: `{r['seat']}` " + ("pushed its answer to" if rev else "opened") + f" PR #{r['pr']} " comment(n, f"⏱️ Time: `{r['seat']}` " + ("pushed its answer to" if rev else "opened") + f" PR #{r['pr']} "
f"({r['steps']} steps, {r['model']}). Review and landing follow on the next beat.", f"({r['steps']} steps, {r['model']}). Review and landing follow on the next beat.",
{"beat": "dispatched", "worker": prov, **({"revised": True} if rev else {}), **r}) {"beat": "dispatched", "worker": prov, **({"revised": True} if rev else {}), **r})
@@ -1244,29 +1174,6 @@ def tick() -> None:
print(f"#{n}: {type(e).__name__}: {e}") print(f"#{n}: {type(e).__name__}: {e}")
comment(n, f"⏱️ Time: the worker call failed: `{str(e)[:300]}`. Comment `/time retry` to try again.", comment(n, f"⏱️ Time: the worker call failed: `{str(e)[:300]}`. Comment `/time retry` to try again.",
{"beat": "error"}) {"beat": "error"})
# Gather by completion, not submission order. A slow seat does not block
# starting or finishing a different card.
for future in as_completed(pending):
card, capped, prov, revised = pending[future]
n = card["number"]
try:
r = future.result()
comment(n, f"⏱️ Time: `{r['seat']}` " + ("pushed its answer to" if revised else "opened") + f" PR #{r['pr']} "
f"({r['steps']} steps, {r['model']}). Review and landing follow on the next beat.",
{"beat": "dispatched", "worker": prov, **({"revised": True} if revised else {}), **r})
except Budget as e:
print(f"#{n}: spend cap reached: {e}")
tripped = tripped or str(e)
if not capped:
comment(n, f"⏱️ Time: {e}. Nothing is lost; this card resumes once there is budget.", {"beat": "capped"})
except LookupError as e:
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
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"})
if pool is not None:
pool.shutdown(wait=True)
if tripped: if tripped:
andon(tripped) andon(tripped)
+6 -1
View File
@@ -24,7 +24,7 @@ jobs:
(startsWith(github.event.comment.body, '**Approve') || startsWith(github.event.comment.body, 'Approve') || (startsWith(github.event.comment.body, '**Approve') || startsWith(github.event.comment.body, 'Approve') ||
startsWith(github.event.comment.body, '**Object') || startsWith(github.event.comment.body, 'Object')))) startsWith(github.event.comment.body, '**Object') || startsWith(github.event.comment.body, 'Object'))))
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 55 # workers can overlap inside one job now (#75); under the hourly cadence, and the concurrency group queues the next run
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
with: with:
@@ -37,6 +37,11 @@ jobs:
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }} VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # user-level secret on teahubxyz (docs/decisions.md on the spine) ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # user-level secret on teahubxyz (docs/decisions.md on the spine)
TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # repo secret: the `time` bot opens and merges PRs (Gitea denies that to the Actions identity) TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # repo secret: the `time` bot opens and merges PRs (Gitea denies that to the Actions identity)
TIME_SEAT_CLAUDE: ${{ secrets.TIME_SEAT_CLAUDE }} # umbrella seat, retired once the per-seat tokens below are in use (#76)
TIME_SEAT_HAIKU: ${{ secrets.TIME_SEAT_HAIKU }} # one Gitea user per seat (model line): a seat's verdict is a real review by its own user (#76)
TIME_SEAT_SONNET: ${{ secrets.TIME_SEAT_SONNET }}
TIME_SEAT_OPUS: ${{ secrets.TIME_SEAT_OPUS }}
TIME_SEAT_FABLE: ${{ secrets.TIME_SEAT_FABLE }}
TIME_TRUSTED: ${{ vars.TIME_TRUSTED }} # org owners etc.; collaborators are trusted without being listed TIME_TRUSTED: ${{ vars.TIME_TRUSTED }} # org owners etc.; collaborators are trusted without being listed
TIME_RUN_IMAGE: node:22-bookworm # the worker's shell runs in a throwaway container, not next to these secrets TIME_RUN_IMAGE: node:22-bookworm # the worker's shell runs in a throwaway container, not next to these secrets
TIME_MODE: ${{ vars.TIME_MODE }} TIME_MODE: ${{ vars.TIME_MODE }}
+1
View File
@@ -18,6 +18,7 @@
- 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. - 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 ### Added
- `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. - 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). - 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).
- Build briefs keep claims to what the code does: no promised features or schedules, and the trending repos are the wave a venture rides, never something it "builds on". Fix rounds take a failing claim off the page rather than building a feature in a hurry. In the first live batch, every fix round was a claim the code didn't back. - Build briefs keep claims to what the code does: no promised features or schedules, and the trending repos are the wave a venture rides, never something it "builds on". Fix rounds take a failing claim off the page rather than building a feature in a hurry. In the first live batch, every fix round was a claim the code didn't back.
+40
View File
@@ -193,3 +193,43 @@
{"at":"2026-10-17T18:27:55Z","model":"claude-sonnet-5-5","in":9230,"out":918,"usd":0.02764} {"at":"2026-10-17T18:27:55Z","model":"claude-sonnet-5-5","in":9230,"out":918,"usd":0.02764}
{"at":"2026-10-17T18:27:59Z","model":"claude-sonnet-5-5","in":10126,"out":226,"usd":0.022512} {"at":"2026-10-17T18:27:59Z","model":"claude-sonnet-5-5","in":10126,"out":226,"usd":0.022512}
{"at":"2026-10-17T18:28:11Z","model":"claude-haiku-5-5","in":2565,"out":1767,"usd":0.00114} {"at":"2026-10-17T18:28:11Z","model":"claude-haiku-5-5","in":2565,"out":1767,"usd":0.00114}
{"at":"2026-10-17T20:20:54Z","model":"claude-sonnet-5-5","in":2158,"out":26,"usd":0.004576}
{"at":"2026-10-17T20:20:56Z","model":"claude-sonnet-5-5","in":2637,"out":163,"usd":0.006904}
{"at":"2026-10-17T20:20:58Z","model":"claude-sonnet-5-5","in":3153,"out":187,"usd":0.008176}
{"at":"2026-10-17T20:21:01Z","model":"claude-sonnet-5-5","in":5843,"out":191,"usd":0.013596}
{"at":"2026-10-17T20:21:06Z","model":"claude-sonnet-5-5","in":6251,"out":341,"usd":0.015912}
{"at":"2026-10-17T20:21:09Z","model":"claude-sonnet-5-5","in":6655,"out":381,"usd":0.01712}
{"at":"2026-10-17T20:21:16Z","model":"claude-sonnet-5-5","in":7036,"out":659,"usd":0.020662}
{"at":"2026-10-17T20:21:20Z","model":"claude-sonnet-5-5","in":7483,"out":397,"usd":0.018936}
{"at":"2026-10-17T20:21:37Z","model":"claude-sonnet-5-5","in":7960,"out":1855,"usd":0.03447}
{"at":"2026-10-17T20:21:49Z","model":"claude-sonnet-5-5","in":10584,"out":1157,"usd":0.032738}
{"at":"2026-10-17T20:21:57Z","model":"claude-sonnet-5-5","in":11307,"out":837,"usd":0.030984}
{"at":"2026-10-17T20:22:04Z","model":"claude-sonnet-5-5","in":16615,"out":513,"usd":0.03836}
{"at":"2026-10-17T20:22:17Z","model":"claude-sonnet-5-5","in":19010,"out":942,"usd":0.04744}
{"at":"2026-10-17T20:22:23Z","model":"claude-sonnet-5-5","in":19216,"out":477,"usd":0.043202}
{"at":"2026-10-17T20:22:33Z","model":"claude-sonnet-5-5","in":19515,"out":970,"usd":0.04873}
{"at":"2026-10-17T20:22:45Z","model":"claude-sonnet-5-5","in":19943,"out":1162,"usd":0.051506}
{"at":"2026-10-17T20:22:47Z","model":"claude-sonnet-5-5","in":22260,"out":116,"usd":0.04568}
{"at":"2026-10-17T20:22:50Z","model":"claude-sonnet-5-5","in":22837,"out":187,"usd":0.047544}
{"at":"2026-10-17T20:22:54Z","model":"claude-sonnet-5-5","in":23284,"out":220,"usd":0.048768}
{"at":"2026-10-17T20:22:56Z","model":"claude-sonnet-5-5","in":24921,"out":165,"usd":0.051492}
{"at":"2026-10-17T20:22:58Z","model":"claude-sonnet-5-5","in":25358,"out":70,"usd":0.051416}
{"at":"2026-10-17T20:23:00Z","model":"claude-sonnet-5-5","in":27711,"out":210,"usd":0.057522}
{"at":"2026-10-17T20:23:04Z","model":"claude-sonnet-5-5","in":31018,"out":347,"usd":0.065506}
{"at":"2026-10-17T20:23:08Z","model":"claude-sonnet-5-5","in":33756,"out":253,"usd":0.070042}
{"at":"2026-10-17T20:23:10Z","model":"claude-sonnet-5-5","in":35236,"out":161,"usd":0.072082}
{"at":"2026-10-17T20:23:16Z","model":"claude-sonnet-5-5","in":37623,"out":401,"usd":0.079256}
{"at":"2026-10-17T20:23:22Z","model":"claude-sonnet-5-5","in":40686,"out":229,"usd":0.083662}
{"at":"2026-10-17T20:23:23Z","model":"claude-sonnet-5-5","in":42769,"out":101,"usd":0.086548}
{"at":"2026-10-17T20:23:26Z","model":"claude-sonnet-5-5","in":45100,"out":186,"usd":0.09206}
{"at":"2026-10-17T20:23:28Z","model":"claude-sonnet-5-5","in":47113,"out":261,"usd":0.096836}
{"at":"2026-10-17T20:23:32Z","model":"claude-sonnet-5-5","in":49280,"out":332,"usd":0.10188}
{"at":"2026-10-17T20:23:34Z","model":"claude-sonnet-5-5","in":51809,"out":190,"usd":0.105518}
{"at":"2026-10-17T20:23:37Z","model":"claude-sonnet-5-5","in":53813,"out":206,"usd":0.109686}
{"at":"2026-10-17T20:23:38Z","model":"claude-sonnet-5-5","in":54057,"out":85,"usd":0.108964}
{"at":"2026-10-17T20:23:40Z","model":"claude-sonnet-5-5","in":54152,"out":114,"usd":0.109444}
{"at":"2026-10-17T20:23:43Z","model":"claude-sonnet-5-5","in":54408,"out":310,"usd":0.111916}
{"at":"2026-10-17T20:23:47Z","model":"claude-sonnet-5-5","in":55850,"out":193,"usd":0.11363}
{"at":"2026-10-17T20:23:49Z","model":"claude-sonnet-5-5","in":58100,"out":213,"usd":0.11833}
{"at":"2026-10-17T20:23:53Z","model":"claude-sonnet-5-5","in":60500,"out":471,"usd":0.12571}
{"at":"2026-10-17T20:23:59Z","model":"claude-sonnet-5-5","in":62407,"out":618,"usd":0.130994}