Bash scripts are now one-liner wrappers: exec python3 <script>.py "$@" All logic lives in the Python scripts (pure stdlib, no deps). launch.py — loops + watchdog: Full port of launch.sh: phase sequencing, start/stop/status/logs/watchdog, handoff signalling, stall detection, heal_session, heal_orchestrator. Cleaner structure: config block → helpers → phase/kickoff/agent/healing/ handoff/watchdog/main. LOOP_BACKEND + LOOP_MODEL switches throughout. launch-orchestrator.py — orchestrator session: claude path: --resume <id> preserved (conversation survives reboots). opencode path: run --attach --title (no --resume; STARTUP_PROMPT orients the new session; reads JOURNAL.md for context). STARTUP_PROMPT updated to reference JOURNAL.md on startup. launch-upgrader.py — one-shot upgrade job: LOOP_BACKEND / LOOP_MODEL take precedence over UPGRADER_BACKEND / UPGRADER_MODEL. Both claude and opencode paths supported. cc-ci-plan/JOURNAL.md — new orchestrator handoff file: Persistent across conversation resets. Documents the handoff format and carries the current session's summary: migration complete, phase 5 in progress (V3/V7 PASS), phase 4 deferred, open items for next session. AGENTS.md: step 1 on startup = read JOURNAL.md; step 5 = append on handoff. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
199 lines
9.0 KiB
Python
199 lines
9.0 KiB
Python
#!/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 claude (default) | opencode — also accepts UPGRADER_BACKEND
|
|
LOOP_MODEL model flag (overrides UPGRADER_MODEL)
|
|
UPGRADER_MODEL sonnet (default for claude) | tinfoil/deepseek-v4-pro (opencode example)
|
|
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
|
|
"""
|
|
|
|
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).
|
|
BACKEND = os.environ.get("LOOP_BACKEND", os.environ.get("UPGRADER_BACKEND", "claude"))
|
|
MODEL = os.environ.get("LOOP_MODEL", os.environ.get("UPGRADER_MODEL", "sonnet"))
|
|
|
|
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")
|
|
|
|
UPGRADER_ARGS = os.environ.get("UPGRADER_ARGS", "")
|
|
|
|
# ── 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)
|
|
|
|
# ── 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)
|
|
|
|
kf = Path(LOG_DIR) / f".kickoff-{SESSION}.txt"
|
|
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>'}')")
|
|
|
|
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}")
|
|
cmd = (
|
|
f"set -a; . /srv/cc-ci/.testenv; set +a; "
|
|
f"{OPENCODE_BIN} {model_flag} run --attach '{OPENCODE_SERVER}' "
|
|
f"--title '{SESSION}' \"$(cat '{kf}')\""
|
|
)
|
|
log(f" visible at http://oc.commoninternet.net (tailnet only)")
|
|
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")
|
|
|
|
# ── main ──────────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
cmd = sys.argv[1] if len(sys.argv) > 1 else "start"
|
|
|
|
if cmd == "start":
|
|
start("use-or-create")
|
|
elif cmd == "fresh":
|
|
start("fresh")
|
|
elif cmd == "stop":
|
|
if session_alive():
|
|
log(f"killing {SESSION}")
|
|
kill_session()
|
|
else:
|
|
log(f"{SESSION} not running")
|
|
elif cmd == "status":
|
|
if session_alive():
|
|
busy = "busy" if session_busy() else "idle/finishing"
|
|
log(f"{SESSION}: RUNNING ({busy})")
|
|
subprocess.run(
|
|
f"ps -eo pid,etime,args | grep '[r]emote-control {SESSION}' || true",
|
|
shell=True)
|
|
else:
|
|
log(f"{SESSION}: stopped")
|
|
log(f"backend: {BACKEND} model: {MODEL} args: '{UPGRADER_ARGS or '<none>'}'")
|
|
elif cmd == "attach":
|
|
os.execvp("tmux", ["tmux", "attach", "-t", SESSION])
|
|
else:
|
|
print(f"""cc-ci upgrader launcher — one-shot weekly recipe-upgrade job
|
|
|
|
launch-upgrader.py start use-or-create (leave busy run alone, else start fresh)
|
|
launch-upgrader.py fresh always kill existing + start fresh
|
|
launch-upgrader.py stop kill the session
|
|
launch-upgrader.py status show session state
|
|
launch-upgrader.py attach tmux attach
|
|
|
|
Backend: {BACKEND} (LOOP_BACKEND or UPGRADER_BACKEND env var)
|
|
Model: {MODEL} (LOOP_MODEL or UPGRADER_MODEL env var)
|
|
Args: {UPGRADER_ARGS or '<none>'} (UPGRADER_ARGS env var, passed to /upgrade-all)
|
|
|
|
claude: viewable at claude.ai/code
|
|
opencode: viewable at http://oc.commoninternet.net server={OPENCODE_SERVER}
|
|
""")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|