#!/usr/bin/env python3 """ cc-ci upgrader launcher — one-shot weekly recipe-upgrade job agent. The upgrader runs /upgrade-all to completion, then stops and stays idle so the run + summary remain viewable in the web UI. The next weekly run starts a fresh session (start clears any idle/finished session). Usage: launch-upgrader.py start use-or-create: leave an in-flight run alone, else start fresh launch-upgrader.py fresh always kill any existing session and start fresh launch-upgrader.py stop kill the session launch-upgrader.py status show session state launch-upgrader.py attach tmux attach to the session Env: LOOP_BACKEND opencode (default) | claude — also accepts UPGRADER_BACKEND 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: CLAUDE_BIN, CLAUDE_FLAGS, REMOTE_CONTROL opencode backend: OPENCODE_BIN, OPENCODE_SERVER, OPENCODE_SHARE (1=attach to web server + public --share link) """ import os, sys, subprocess, re from datetime import datetime from pathlib import Path # ── config ──────────────────────────────────────────────────────────────────── SESSION = os.environ.get("UPGRADER_SESSION", "cc-ci-upgrader") 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). # 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")) 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") CLAUDE_FLAGS = os.environ.get("CLAUDE_FLAGS", "--dangerously-skip-permissions") REMOTE_CONTROL = os.environ.get("REMOTE_CONTROL", "1") == "1" OPENCODE_BIN = os.environ.get("OPENCODE_BIN", "/home/loops/.local/bin/opencode") OPENCODE_SERVER = os.environ.get("OPENCODE_SERVER", "http://127.0.0.1:4096") # Web visibility for the opencode backend: attach the session to the shared opencode # web server (viewable at http://oc.commoninternet.net, tailnet-only) AND optionally # create a public opencode.ai --share link. Default both on so the run is monitorable. OPENCODE_SHARE = os.environ.get("OPENCODE_SHARE", "1") == "1" UPGRADER_ARGS = os.environ.get("UPGRADER_ARGS", "") # First step of the weekly run: reclaim STALE docker images on the cc-ci server BEFORE the run so a # heavy run can't fill the disk mid-flight (root cause of the 2026-07-03 stall — 100% ENOSPC killed # lasuite-drive + wedged the run). "Stale" = unused by any container AND older than PRERECLAIM_UNTIL, # so recently-built/pulled images (the ones this week's tests will reuse) are KEPT — we only evict # leftovers from prior weeks. Best-effort; never fails the run. PRERECLAIM = os.environ.get("UPGRADER_PRERECLAIM", "1") == "1" PRERECLAIM_UNTIL = os.environ.get("UPGRADER_PRERECLAIM_UNTIL", "168h") # 7d: older than one run ago PRERECLAIM_HOST = os.environ.get("UPGRADER_PRERECLAIM_HOST", "cc-ci") # ── helpers ─────────────────────────────────────────────────────────────────── def log(msg): ts = datetime.now().strftime("%H:%M:%S") print(f"[upgrader {ts}] {msg}", flush=True) def die(msg): log(f"ERROR: {msg}") sys.exit(1) def session_alive(): return subprocess.run( ["tmux", "has-session", "-t", SESSION], capture_output=True ).returncode == 0 def session_busy(): """True while a turn is actively in flight (not idle/finished/wedged).""" r = subprocess.run(["tmux", "capture-pane", "-pt", SESSION], capture_output=True, text=True) pane = r.stdout if r.returncode == 0 else "" return bool(re.search(r"esc to interrupt|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Running tool", pane)) def kill_session(): subprocess.run(["tmux", "kill-session", "-t", SESSION], capture_output=True) def _watchdog_alive(): return subprocess.run(["tmux", "has-session", "-t", f"{SESSION}-watchdog"], capture_output=True).returncode == 0 def prereclaim_cc_ci(): """Weekly-run step 0: prune STALE (unused AND older than PRERECLAIM_UNTIL) docker images on the cc-ci server so the run has disk headroom. Keeps recent images (reused this week); only clears prior-weeks' leftovers. Best-effort — a reclaim failure must never abort the run.""" if not PRERECLAIM: return filt = f"--filter until={PRERECLAIM_UNTIL}" remote = (f"docker image prune -af {filt} 2>&1 | tail -1; " f"docker builder prune -af {filt} >/dev/null 2>&1 || true; " f"df -h / | tail -1") log(f" step 0: pre-reclaim stale docker images on {PRERECLAIM_HOST} (unused & >{PRERECLAIM_UNTIL})") try: r = subprocess.run(["ssh", "-o", "ConnectTimeout=15", PRERECLAIM_HOST, remote], capture_output=True, text=True, timeout=900) out = (r.stdout or r.stderr or "").strip() for ln in out.splitlines(): if ln.strip(): log(f" {ln.strip()}") except Exception as e: log(f" pre-reclaim skipped (non-fatal): {e}") # ── kickoff prompt ──────────────────────────────────────────────────────────── def build_kickoff(): args_note = f" with arguments: {UPGRADER_ARGS}" if UPGRADER_ARGS else "" return f"""\ *** cc-ci UPGRADER — weekly recipe-upgrade job *** You are the cc-ci Upgrader: a ONE-SHOT job agent, NOT a perpetual loop. Run the recipe-upgrade sequence to completion, then STOP. Your cwd is {WORKDIR}; reach the CI server with `ssh cc-ci`; creds are in {WORKDIR}/.testenv; skills in {WORKDIR}/.claude/skills/. DO THIS: 1. Invoke the /upgrade-all skill in DEFAULT mode{args_note} (read {WORKDIR}/.claude/skills/upgrade-all/SKILL.md for the full procedure). It surveys every enrolled recipe and, for each upgradeable one, runs /recipe-upgrade in DEFAULT mode — recipe PR only, verified by posting `!testme` on the PR (results visible in the PR, iterate up to 3x). A genuinely stale test gets an explanatory PR COMMENT, never a test edit. 2. Process recipes via per-recipe SUBAGENTS so your own context stays light. If your context usage climbs (~80%), run /compact before continuing. 3. Write + push the weekly summary (the PR list is the actionable output for the operator). 4. WHEN THE RUN IS COMPLETE: STOP. Print the final summary (lead with the PR list) and an `UPGRADE RUN COMPLETE` line, then go idle. Do NOT loop, do NOT re-run, and do NOT kill your own session — leave it up so the operator can review the output in the web UI. Next week's run starts a fresh session (the launcher clears this idle one). GUARDRAILS: NEVER merge any PR. NEVER weaken a test. DEFAULT mode only — do NOT pass --with-tests (updating cc-ci tests is the operator's per-recipe opt-in). Single-writer: dedicated branches + separate clones, never push main, never touch the build loops' /cc-ci /cc-ci-adv clones. The shared Swarm is stateful — go sequentially. """ # ── launch ──────────────────────────────────────────────────────────────────── def start(mode="use-or-create"): import shutil if not shutil.which("tmux"): die("tmux not found") Path(LOG_DIR).mkdir(parents=True, exist_ok=True) if session_alive(): if mode == "use-or-create" and session_busy(): log(f"{SESSION} already running a job (busy) — leaving it") return log(f"{SESSION} exists but idle/stale (or fresh requested) — killing it first") kill_session() import time; time.sleep(1) # Step 0 of the weekly run: clear STALE cc-ci docker images so a heavy run can't run the disk # out mid-flight (root cause of the 2026-07-03 stall). Only for the actual upgrade run. if SESSION == "cc-ci-upgrader": prereclaim_cc_ci() # Unique-name invariant: the run we are about to launch becomes THE 'cc-ci-upgrader' in the # web UI; every older run gets an archive title. Also snapshot existing ids so the new # session can be pinned unambiguously after launch. _archive_stale_titles() _prev_rows = _server_get("/session") or [] _prev_rows = _prev_rows if isinstance(_prev_rows, list) else _prev_rows.get("data", []) _prev_ids = {s.get("id") for s in _prev_rows} try: STATE_SID_FILE.unlink() except OSError: pass kf = Path(LOG_DIR) / f".kickoff-{SESSION}.txt" kf.write_text(build_kickoff()) model_flag = f"--model '{MODEL}'" if MODEL else "" 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 ''}')") if BACKEND == "claude": if not shutil.which(CLAUDE_BIN): die(f"claude CLI not found — set CLAUDE_BIN (currently: {CLAUDE_BIN})") rc = f"--remote-control '{SESSION}'" if REMOTE_CONTROL else "" cmd = f"{CLAUDE_BIN} {rc} {model_flag} {CLAUDE_FLAGS} \"$(cat '{kf}')\"" elif BACKEND == "opencode": if not Path(OPENCODE_BIN).exists(): die(f"opencode not found at {OPENCODE_BIN}") # NOTE: -m/--model and --attach/--title/--share are flags on the `run` SUBCOMMAND, # so they must come AFTER `run` (a global `opencode --model X run` is ignored). share_flag = "--share" if OPENCODE_SHARE else "" cmd = ( f"set -a; . /srv/cc-ci/.testenv; set +a; " f"{OPENCODE_BIN} run {model_flag} {share_flag} --attach '{OPENCODE_SERVER}' " f"--title '{SESSION}' \"$(cat '{kf}')\"" ) log(f" attached to {OPENCODE_SERVER} → http://oc.commoninternet.net (tailnet only)" + (" +public --share link (printed in the session)" if OPENCODE_SHARE else "")) else: die(f"unknown LOOP_BACKEND '{BACKEND}' — use 'claude' or 'opencode'") subprocess.run(["tmux", "new-session", "-d", "-s", SESSION, "-c", WORKDIR, cmd]) 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") if BACKEND == "opencode": _pin_new_session(_prev_ids) # 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 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 --continue`. Standalone: launch-upgrader.py {resume|watchdog}. import json as _json, urllib.request as _ureq, time as _time STALL_MIN = float(os.environ.get("UPGRADER_STALL_MIN", "15")) # log-idle minutes ⇒ stalled CHECK_EVERY = int(os.environ.get("UPGRADER_CHECK_SEC", "180")) # watchdog poll cadence # Generic so the SAME watchdog also covers the report job (launch-report.py points it at the # 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 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" def _server_get(path): try: with _ureq.urlopen(OPENCODE_SERVER + path, timeout=15) as r: return _json.load(r) except Exception: return None STATE_SID_FILE = Path(LOG_DIR) / f".{SESSION}-session-id" def _server_patch(path, body): req = _ureq.Request(OPENCODE_SERVER + path, method="PATCH", headers={"Content-Type": "application/json"}, data=_json.dumps(body).encode()) try: with _ureq.urlopen(req, timeout=15) as r: return r.status except Exception: return None def _db_created_ms(sid): """Session creation time from the opencode sqlite DB — the /session API rows carry NO time fields, which is exactly how the 2026-08-04 watchdog resumed the WRONG (old, giant) session: sorting on a missing key degraded to server list order.""" try: import sqlite3 db = sqlite3.connect("file:" + os.path.expanduser( "~/.local/share/opencode/opencode.db") + "?mode=ro", uri=True) row = db.execute("SELECT time_created FROM session WHERE id=?", (sid,)).fetchone() db.close() return row[0] if row else 0 except Exception: return 0 def _archive_stale_titles(title=None, label=None): """Rename every existing top-level session with the given canonical title to a dated archive title, so EXACTLY ONE session ever carries the canonical name (the one the next launch creates). Keeps the run trivially findable in the opencode web UI and makes the title lookup in _session_id() unambiguous. Old runs stay browsable under '