feat(upgrader): LOOP_TIER=go|zen config — switch weekly upgrade to OpenCode ZEN

GO subscription hit a monthly usage limit (resets in 9 days). Add LOOP_TIER
env (default 'zen') to launch-upgrader.py + launch-report.py so the opencode
backend can use either subscription: zen→opencode/glm-5.2, go→opencode-go/glm-5.2.
The tier selects the default model, the usage-limit probe endpoint, and the
auth.json key. The systemd timer reads LOOP_TIER from /srv/cc-ci/upgrader.env.
This commit is contained in:
autonomic-bot
2026-07-13 17:11:26 +00:00
parent 1e40d50181
commit 1ec1aa8f1d
2 changed files with 49 additions and 29 deletions

View File

@ -2,8 +2,8 @@
"""cc-ci recipe-report launcher — one-shot agent that runs /recipe-report after the weekly upgrade.
The report agent's model is configured SEPARATELY from the upgrader (REPORT_BACKEND/REPORT_MODEL),
but defaults to the same OpenCode Go subscription on glm-5.2. The model default tracks the backend
(opencode→opencode-go/glm-5.2, claude→opus).
but defaults to the same OpenCode subscription on glm-5.2 as the upgrader. The model default tracks
the backend+tier (opencode+zen→opencode/glm-5.2, opencode+go→opencode-go/glm-5.2, claude→opus).
Usage:
launch-report.py start [DATE] use-or-create the session; runs /recipe-report [DATE]
@ -11,7 +11,8 @@ Usage:
launch-report.py stop kill the session
launch-report.py status show session state
Env: REPORT_BACKEND (default opencode), REPORT_MODEL (default opencode-go/glm-5.2 | opus for claude),
Env: REPORT_BACKEND (default opencode), REPORT_MODEL (default tracks tier),
LOOP_TIER / REPORT_TIER (default 'zen'; 'go' or 'zen'; only for opencode),
OPENCODE_SHARE (1=attach web server + public --share link), REPORT_SESSION, REPORT_DIR.
"""
import os, subprocess, sys, time
@ -22,7 +23,9 @@ SESSION = os.environ.get("REPORT_SESSION", "cc-ci-report")
WORKDIR = os.environ.get("REPORT_DIR", "/srv/cc-ci")
LOG_DIR = os.environ.get("LOG_DIR", "/srv/cc-ci/.cc-ci-logs")
BACKEND = os.environ.get("REPORT_BACKEND", "opencode")
_DEFAULT_MODEL = "opencode-go/glm-5.2" if BACKEND == "opencode" else "opus"
TIER = os.environ.get("LOOP_TIER", os.environ.get("REPORT_TIER", "zen"))
_TIER_MODEL = {"go": "opencode-go/glm-5.2", "zen": "opencode/glm-5.2"}
_DEFAULT_MODEL = _TIER_MODEL.get(TIER, _TIER_MODEL["zen"]) if BACKEND == "opencode" else "opus"
MODEL = os.environ.get("REPORT_MODEL", _DEFAULT_MODEL)
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
CLAUDE_FLAGS = os.environ.get("CLAUDE_FLAGS", "--dangerously-skip-permissions")
@ -84,7 +87,7 @@ def start(mode, date):
)
else:
die(f"unknown REPORT_BACKEND '{BACKEND}'")
log(f"starting {SESSION} (backend={BACKEND}, model={MODEL}, date={date or 'today'})")
log(f"starting {SESSION} (backend={BACKEND}, tier={TIER}, model={MODEL}, date={date or 'today'})")
subprocess.run(["tmux", "new-session", "-d", "-s", SESSION, "-c", cwd, cmd])
subprocess.run(["tmux", "pipe-pane", "-o", "-t", SESSION, f"cat >> '{LOG_DIR}/{SESSION}.log'"])
if BACKEND == "opencode":
@ -99,13 +102,13 @@ def start(mode, date):
LU = "/srv/cc-ci/cc-ci-plan/launch-upgrader.py"
rf = Path(LOG_DIR) / f".kickoff-{SESSION}-resume.txt"
rf.write_text(
"The opencode-go usage limit has reset (or the report stalled). CONTINUE generating and "
"The opencode usage limit has reset (or the report stalled). CONTINUE generating and "
"publishing this week's public Recipe Report per /recipe-report: survey → write the spec "
"JSON → render with recipe-report.py → publish. Do NOT hand-write HTML (render() owns all "
"formatting). When the page is live, print 'RECIPE REPORT COMPLETE' and go idle.")
wenv = {"HOME": os.environ.get("HOME") or "/home/loops",
"UPGRADER_SESSION": SESSION, "UPGRADER_DIR": cwd, "LOG_DIR": LOG_DIR,
"UPGRADER_BACKEND": "opencode", "UPGRADER_MODEL": MODEL,
"UPGRADER_BACKEND": "opencode", "UPGRADER_MODEL": MODEL, "LOOP_TIER": TIER,
"OPENCODE_BIN": OPENCODE_BIN, "OPENCODE_SERVER": OPENCODE_SERVER,
"OPENCODE_SHARE": "1" if OPENCODE_SHARE else "0",
"UPGRADER_DONE_MARKER": "RECIPE REPORT COMPLETE",

View File

@ -15,10 +15,14 @@ Usage:
Env:
LOOP_BACKEND opencode (default) | claude — also accepts UPGRADER_BACKEND
LOOP_MODEL model flag (overrides UPGRADER_MODEL); default tracks backend —
opencode→opencode-go/glm-5.2, claude→sonnet
UPGRADER_MODEL provider/model for opencode, e.g. opencode-go/glm-5.2 (OpenCode Go
subscription) or tinfoil/deepseek-v4-pro; sonnet etc. for claude
LOOP_TIER opencode subscription tier: "zen" (OpenCode ZEN, default) or "go"
(OpenCode Go). Selects the default model + the usage-limit probe
endpoint/key. Go hit a monthly limit; ZEN is the working alternative.
Only affects the opencode backend (ignored for claude).
LOOP_MODEL model flag (overrides UPGRADER_MODEL); default tracks backend+tier —
opencode+zen→opencode/glm-5.2, opencode+go→opencode-go/glm-5.2, claude→sonnet
UPGRADER_MODEL provider/model for opencode, e.g. opencode/glm-5.2 (OpenCode ZEN),
opencode-go/glm-5.2 (OpenCode Go), or tinfoil/deepseek-v4-pro; sonnet for claude
UPGRADER_ARGS extra args passed to /upgrade-all (e.g. "n8n ghost", "--dry-run")
claude backend:
@ -38,11 +42,22 @@ WORKDIR = os.environ.get("UPGRADER_DIR", "/srv/cc-ci")
LOG_DIR = os.environ.get("LOG_DIR", "/srv/cc-ci/.cc-ci-logs")
# LOOP_BACKEND / LOOP_MODEL take precedence (unified control from the operator).
# Default backend+model is the OpenCode Go subscription on glm-5.2; override either via env.
# The model default tracks the backend so an explicit `LOOP_BACKEND=claude` (without a model)
# still gets a sensible claude model rather than a glm id.
# LOOP_TIER selects the OpenCode subscription: "zen" (default) or "go". Go hit a monthly
# usage limit; ZEN is the working alternative. Set LOOP_TIER=go when the Go limit resets.
# Only affects the opencode backend; claude ignores the tier.
BACKEND = os.environ.get("LOOP_BACKEND", os.environ.get("UPGRADER_BACKEND", "opencode"))
_DEFAULT_MODEL = "opencode-go/glm-5.2" if BACKEND == "opencode" else "sonnet"
TIER = os.environ.get("LOOP_TIER", os.environ.get("UPGRADER_TIER", "zen"))
_TIER_CFG = {
"go": {"model": "opencode-go/glm-5.2", "endpoint": "https://opencode.ai/zen/go/v1/chat/completions",
"auth": "opencode-go", "label": "OpenCode Go"},
"zen": {"model": "opencode/glm-5.2", "endpoint": "https://opencode.ai/zen/v1/chat/completions",
"auth": "opencode", "label": "OpenCode ZEN"},
}
if BACKEND == "opencode" and TIER not in _TIER_CFG:
print(f"[upgrader] ERROR: unknown LOOP_TIER '{TIER}' — use 'go' or 'zen'", flush=True)
sys.exit(1)
_tier = _TIER_CFG.get(TIER, _TIER_CFG["zen"])
_DEFAULT_MODEL = _tier["model"] if BACKEND == "opencode" else "sonnet"
MODEL = os.environ.get("LOOP_MODEL", os.environ.get("UPGRADER_MODEL", _DEFAULT_MODEL))
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
@ -173,7 +188,8 @@ def start(mode="use-or-create"):
kf.write_text(build_kickoff())
model_flag = f"--model '{MODEL}'" if MODEL else ""
log(f"starting {SESSION} (backend={BACKEND}, model={MODEL}, args='{UPGRADER_ARGS or '<none>'}')")
tier_note = f", tier={TIER} ({_tier['label']})" if BACKEND == "opencode" else ""
log(f"starting {SESSION} (backend={BACKEND}{tier_note}, model={MODEL}, args='{UPGRADER_ARGS or '<none>'}')")
if BACKEND == "claude":
if not shutil.which(CLAUDE_BIN):
@ -201,13 +217,13 @@ def start(mode="use-or-create"):
subprocess.run(["tmux", "pipe-pane", "-o", "-t", SESSION,
f"cat >> '{LOG_DIR}/{SESSION}.log'"])
log(f"started. attach: tmux attach -t {SESSION} log: {LOG_DIR}/{SESSION}.log")
# For the opencode backend, spawn a watchdog that auto-resumes the run if the opencode-go
# For the opencode backend, spawn a watchdog that auto-resumes the run if the opencode
# usage-limit (429) stalls it mid-run (it does NOT self-resume). See watchdog().
if BACKEND == "opencode" and os.environ.get("UPGRADER_WATCHDOG", "1") == "1":
_spawn_watchdog()
# ── opencode stall-detect + auto-resume watchdog ────────────────────────────────
# The opencode-go subscription enforces a rolling usage-limit (HTTP 429 + retry-after). When it
# The opencode subscription (Go or ZEN tier) enforces a rolling usage-limit (HTTP 429 + retry-after). When it
# trips mid-run, the `opencode run` agent loop ENDS and does NOT self-resume. This watchdog detects
# the stall (the session log stops growing), waits out the limit, and resumes the SAME session —
# context preserved — via `opencode run -s <id> --continue`. Standalone: launch-upgrader.py {resume|watchdog}.
@ -219,7 +235,7 @@ CHECK_EVERY = int(os.environ.get("UPGRADER_CHECK_SEC", "180")) # watchdog pol
# cc-ci-report session with its own marker + resume prompt via these env vars).
DONE_MARKER = os.environ.get("UPGRADER_DONE_MARKER", "UPGRADE RUN COMPLETE")
RESUME_FILE = os.environ.get("UPGRADER_RESUME_FILE") # optional path to a custom resume prompt
GO_ENDPOINT = "https://opencode.ai/zen/go/v1/chat/completions"
LIMIT_ENDPOINT = _tier["endpoint"] # tier-aware: zen or go
AUTH_JSON = os.path.expanduser("~/.local/share/opencode/auth.json")
LOG_FILE = f"{LOG_DIR}/{SESSION}.log"
@ -279,20 +295,20 @@ def _session_idle_min():
# Back-compat alias (older callers / the report watchdog import this name).
_log_idle_min = _session_idle_min
def _go_key():
def _limit_key():
try:
return (_json.load(open(AUTH_JSON)).get("opencode-go") or {}).get("key")
return (_json.load(open(AUTH_JSON)).get(_tier["auth"]) or {}).get("key")
except Exception:
return None
def _limit_retry_after():
"""0 if the opencode-go endpoint is available (HTTP 200); else the 429 retry-after seconds."""
key = _go_key()
"""0 if the tier's opencode endpoint is available (HTTP 200); else the 429 retry-after seconds."""
key = _limit_key()
if not key:
return 0
body = _json.dumps({"model": (MODEL or "").split("/")[-1] or "glm-5.2", "max_tokens": 8,
"messages": [{"role": "user", "content": "hi"}]}).encode()
req = _ureq.Request(GO_ENDPOINT, data=body, method="POST",
req = _ureq.Request(LIMIT_ENDPOINT, data=body, method="POST",
headers={"Authorization": "Bearer " + key, "content-type": "application/json"})
try:
_ureq.urlopen(req, timeout=20).read(); return 0
@ -369,7 +385,7 @@ def resume(reason="manual"):
kf.write_text(open(RESUME_FILE).read()) # caller-supplied (e.g. the report job)
else:
kf.write_text(
"The opencode-go usage limit has reset (or the run stalled). You were mid-way through the weekly "
"The opencode usage limit has reset (or the run stalled). You were mid-way through the weekly "
"cc-ci /upgrade-all run. CONTINUE from where you left off — do NOT start over. Process the enrolled "
"recipes not yet done this week, alphabetically; SKIP ones already done (their PRs exist — extend, "
"never duplicate). Per recipe: run /recipe-upgrade in DEFAULT mode via a subagent, verify with "
@ -400,8 +416,8 @@ def _spawn_watchdog():
wsess = f"{SESSION}-watchdog"
wlog = f"{LOG_DIR}/{SESSION}-watchdog.log"
env = {"HOME": os.environ.get("HOME") or os.path.expanduser("~"),
"UPGRADER_SESSION": SESSION, "UPGRADER_DIR": WORKDIR, "LOG_DIR": LOG_DIR,
"UPGRADER_BACKEND": "opencode", "UPGRADER_MODEL": MODEL,
"UPGRADER_SESSION": SESSION, "UPGRADER_DIR": WORKDIR, "LOG_DIR": LOG_DIR,
"UPGRADER_BACKEND": "opencode", "UPGRADER_MODEL": MODEL, "LOOP_TIER": TIER,
"OPENCODE_BIN": OPENCODE_BIN, "OPENCODE_SERVER": OPENCODE_SERVER,
"OPENCODE_SHARE": "1" if OPENCODE_SHARE else "0"}
for k in ("UPGRADER_RESUME_FILE", "UPGRADER_DONE_MARKER", "UPGRADER_STALL_MIN", "UPGRADER_CHECK_SEC"):
@ -420,7 +436,7 @@ def watchdog():
also freezes, so log-idle alone would take 15min to notice a run that died in 5. We detect
it directly: no live `opencode run` proc for the session + not completed ⇒ resume promptly.
(b) LOG-IDLE — a proc is alive but wedged (no output > STALL_MIN); resume after confirming.
Either way, wait out an opencode-go usage-limit (429) first rather than hammering. Exits when the
Either way, wait out an opencode usage-limit (429) first rather than hammering. Exits when the
model prints DONE_MARKER, or after MAX_RESUMES consecutive resumes fail to get a live proc going
(truly broken — hand back to the hourly supervisor / operator). Spawned by start()/resume()."""
MAX_RESUMES = int(os.environ.get("UPGRADER_MAX_RESUMES", "20"))
@ -485,7 +501,7 @@ def main():
shell=True)
else:
log(f"{SESSION}: stopped")
log(f"backend: {BACKEND} model: {MODEL} args: '{UPGRADER_ARGS or '<none>'}'")
log(f"backend: {BACKEND} tier: {TIER} model: {MODEL} args: '{UPGRADER_ARGS or '<none>'}'")
elif cmd == "attach":
os.execvp("tmux", ["tmux", "attach", "-t", SESSION])
elif cmd == "resume":
@ -504,6 +520,7 @@ def main():
launch-upgrader.py watchdog watch + auto-resume the opencode run across usage-limit (429) stalls
Backend: {BACKEND} (LOOP_BACKEND or UPGRADER_BACKEND env var)
Tier: {TIER} (LOOP_TIER or UPGRADER_TIER env var; 'go' or 'zen'; only for opencode)
Model: {MODEL} (LOOP_MODEL or UPGRADER_MODEL env var)
Args: {UPGRADER_ARGS or '<none>'} (UPGRADER_ARGS env var, passed to /upgrade-all)