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 {};
// <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
// element.onSave = async ({ spec, prompt, dataset }) => ... to store charts yourself (the demo stores them in D1).
export class AiChart extends Base {
@@ -202,7 +203,9 @@ export class AiChart extends Base {
try {
await this.load();
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);
this.dispatchEvent(new CustomEvent("spec", { detail: { spec, prompt, info } }));
} 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) {
await this.load();
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),
measure: choice("Which quantity does the chart measure?", measure),
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\").",
{ none: "One series only.", ...Object.fromEntries(text.map((c) => [c.name, `one series per ${c.name}`])) }),
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: "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),
top: choice("Does the request limit how many groups to show?", TOP_MENU),
};
for (const c of text) {
const vals = info.aggregates[c.name]?.values;
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.`,
{ any: `No restriction on ${c.name}.`, ...Object.fromEntries(vals.map((v) => [v, `Only ${c.name} = ${v}.`])) });
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: `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;
}
+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>
<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>
<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>
<ai-chart dataset="orders"></ai-chart>
<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" brain="fast"></ai-chart>
<div class="meta" id="meta"></div>
<details><summary>Spec</summary><pre id="spec">Ask something first.</pre></details>
<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) => {
const { spec, info = {} } = e.detail;
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);
meta.replaceChildren(document.createTextNode(bits.join(" · ")));
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:]
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;
# until then these run only with TIME_TEST_VENTURE=1 against the old fake.
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
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
+11 -104
View File
@@ -19,8 +19,6 @@ from __future__ import annotations
import datetime as dt
import importlib.util
import atexit
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
import base64
import json
import os
@@ -235,51 +233,10 @@ def anthropic_chat(body: dict, timeout: float) -> dict:
def chat(body: dict, timeout: float) -> dict:
"""Reserve a conservative cost estimate; overlap requests that fit.
Temporary reservation contention waits for a completing call, rather than
stranding a dispatched card as capped. Only actual allowance exhaustion
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()
"""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)
book(body["model"], out.get("usage") or {})
return out
@@ -287,9 +244,6 @@ def chat(body: dict, timeout: float) -> dict:
# Deterministic on purpose: arithmetic over usage numbers decides, never a model.
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
@@ -1089,24 +1043,10 @@ def cmd_heartbeat() -> int:
plan_spec(spec)
except Exception as e: # noqa: BLE001
print(f"spec #{spec['number']}: {type(e).__name__}: {e}")
worker = (os.environ.get("TIME_WORKER") or "cursor").strip()
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
for _ in range(3): # a merge can free the slot for the next card; dispatch it in the same run
tick()
for _ in range(parallel):
if promote_next() is None:
break
tick()
else:
for _ in range(3): # Cursor/unpriced/one-worker preserve the historical path
tick()
if promote_next() is None:
break
if promote_next() is None:
break
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))
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
for card, last, done, blocked, capped, revision, revised_at in plan:
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
except RuntimeError as e:
print(f"#{n}: needs-human label not removed: {str(e)[:100]}")
args = (card, done, worker.split(":", 1)[1])
kwargs = {"review": revision["feedback"]} if rev else {}
if pool is not None:
pending[pool.submit(venice_work, *args, **kwargs)] = (card, capped, prov, bool(rev))
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.
r = venice_work(card, done, worker.split(":", 1)[1], review=revision["feedback"])
else:
r = venice_work(card, done, worker.split(":", 1)[1])
# back to `dispatched`: the next beat reviews the PR again and the new verdict is the one that counts
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.",
{"beat": "dispatched", "worker": prov, **({"revised": True} if rev else {}), **r})
@@ -1244,29 +1174,6 @@ def tick() -> None:
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"})
# 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:
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, '**Object') || startsWith(github.event.comment.body, 'Object'))))
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:
- uses: actions/checkout@v5
with:
@@ -37,6 +37,11 @@ jobs:
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)
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_RUN_IMAGE: node:22-bookworm # the worker's shell runs in a throwaway container, not next to these secrets
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.
### 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.
- 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.
+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: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-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}