refactor: rewrite launchers as Python; add orchestrator JOURNAL.md
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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
e0e5bf6e64
commit
bca51071bd
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
cc-ci orchestrator launcher — start/resume the orchestrator session in tmux.
|
||||
|
||||
The orchestrator is the long-lived supervisory session: it watches the Builder/Adversary
|
||||
loops, reads their logs/STATUS, edits the plan/prompts, restarts stuck loops, and owns
|
||||
the VM-level fallback. It is SEPARATE from the loops that launch.py manages.
|
||||
|
||||
Usage:
|
||||
launch-orchestrator.py start resume the persistent session (default)
|
||||
launch-orchestrator.py fresh start a NEW session (no --resume)
|
||||
launch-orchestrator.py stop kill the tmux session (conversation persists on disk)
|
||||
launch-orchestrator.py status show session state
|
||||
launch-orchestrator.py attach tmux attach to the session
|
||||
|
||||
Env:
|
||||
LOOP_BACKEND claude (default) | opencode
|
||||
LOOP_MODEL model flag, e.g. "sonnet" or "tinfoil/deepseek-v4-pro"
|
||||
|
||||
claude backend:
|
||||
CLAUDE_BIN claude
|
||||
REMOTE_CONTROL 1 (viewable at claude.ai/code)
|
||||
ORCH_SESSION_ID override the resume id (else read from $ID_FILE)
|
||||
ORCH_ID_FILE $LOG_DIR/.orchestrator-session-id
|
||||
ORCH_STARTUP_PROMPT startup nudge injected as the first turn after --resume
|
||||
|
||||
opencode backend:
|
||||
OPENCODE_BIN /home/loops/.local/bin/opencode
|
||||
OPENCODE_SERVER http://127.0.0.1:4096
|
||||
(no --resume equivalent; STARTUP_PROMPT is sent as the initial message;
|
||||
the session title in the web UI is the SESSION name)
|
||||
"""
|
||||
|
||||
import os, sys, subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ── config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
SESSION = os.environ.get("ORCH_SESSION", "cc-ci-orchestrator-vm")
|
||||
WORKDIR = os.environ.get("ORCH_DIR", "/srv/cc-ci")
|
||||
LOG_DIR = os.environ.get("LOG_DIR", "/srv/cc-ci/.cc-ci-logs")
|
||||
|
||||
BACKEND = os.environ.get("LOOP_BACKEND", "claude")
|
||||
LOOP_MODEL = os.environ.get("LOOP_MODEL", "")
|
||||
|
||||
# claude-specific
|
||||
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"
|
||||
DEFAULT_ID = "c746050a-af11-409d-87ba-c05268e2e5d1"
|
||||
ID_FILE = os.environ.get("ORCH_ID_FILE", f"{LOG_DIR}/.orchestrator-session-id")
|
||||
STARTUP_PROMPT = os.environ.get("ORCH_STARTUP_PROMPT", (
|
||||
"STARTUP (auto-launch): you are the cc-ci orchestrator, just (re)launched, likely after a "
|
||||
"reboot. Do your AGENTS.md On-startup routine NOW: read cc-ci-plan/REBOOTS.md and run "
|
||||
"cc-ci-plan/launch.py status, then send a proactive PushNotification that you are online "
|
||||
"with the current phase and reboot count, and confirm cc-ci-loops.service brought the loops "
|
||||
"+ watchdog back (relaunch with RESUME_PHASE=1 cc-ci-plan/launch.py start if not). "
|
||||
"Also read cc-ci-plan/JOURNAL.md for recent context before resuming supervision."
|
||||
))
|
||||
|
||||
# opencode-specific
|
||||
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")
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def log(msg):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[orchestrator {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 resume_id():
|
||||
sid = os.environ.get("ORCH_SESSION_ID")
|
||||
if sid:
|
||||
return sid
|
||||
try:
|
||||
v = Path(ID_FILE).read_text().strip()
|
||||
return v or DEFAULT_ID
|
||||
except FileNotFoundError:
|
||||
return DEFAULT_ID
|
||||
|
||||
# ── launch ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def start(mode="resume"):
|
||||
import shutil
|
||||
if not shutil.which("tmux"):
|
||||
die("tmux not found")
|
||||
Path(LOG_DIR).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if session_alive():
|
||||
log(f"{SESSION} already running — leaving it (use 'stop' first to relaunch)")
|
||||
return
|
||||
|
||||
model_flag = f"--model '{LOOP_MODEL}'" if LOOP_MODEL else ""
|
||||
|
||||
if BACKEND == "claude":
|
||||
if not shutil.which(CLAUDE_BIN):
|
||||
die(f"claude CLI not found — set CLAUDE_BIN (currently: {CLAUDE_BIN})")
|
||||
if not Path(ID_FILE).exists():
|
||||
Path(ID_FILE).write_text(DEFAULT_ID)
|
||||
|
||||
rc = f"--remote-control '{SESSION}'" if REMOTE_CONTROL else ""
|
||||
resume = f"--resume '{resume_id()}'" if mode == "resume" else ""
|
||||
prompt = f"'{STARTUP_PROMPT}'" if STARTUP_PROMPT else ""
|
||||
cmd = f"{CLAUDE_BIN} {resume} {rc} {model_flag} {CLAUDE_FLAGS} {prompt}"
|
||||
detail = f"resume={resume_id()}" if mode == "resume" else "fresh"
|
||||
log(f"starting {SESSION} (backend=claude, {detail}, model={LOOP_MODEL or 'default'})")
|
||||
|
||||
elif BACKEND == "opencode":
|
||||
if not Path(OPENCODE_BIN).exists():
|
||||
die(f"opencode not found at {OPENCODE_BIN}")
|
||||
# No --resume equivalent in opencode; STARTUP_PROMPT orients the new session.
|
||||
# The session title in the web UI identifies it as the orchestrator.
|
||||
prompt = STARTUP_PROMPT or (
|
||||
"You are the cc-ci orchestrator. Read /srv/cc-ci/AGENTS.md and "
|
||||
"cc-ci-plan/JOURNAL.md for context, then resume supervising the loops."
|
||||
)
|
||||
cmd = (
|
||||
f"set -a; . /srv/cc-ci/.testenv; set +a; "
|
||||
f"{OPENCODE_BIN} {model_flag} run --attach '{OPENCODE_SERVER}' "
|
||||
f"--title '{SESSION}' '{prompt}'"
|
||||
)
|
||||
log(f"starting {SESSION} (backend=opencode, model={LOOP_MODEL or 'default'})")
|
||||
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}")
|
||||
|
||||
# ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else "start"
|
||||
|
||||
if cmd == "start":
|
||||
start("resume")
|
||||
elif cmd == "fresh":
|
||||
start("fresh")
|
||||
elif cmd == "stop":
|
||||
if session_alive():
|
||||
log(f"killing {SESSION}")
|
||||
subprocess.run(["tmux", "kill-session", "-t", SESSION])
|
||||
else:
|
||||
log(f"{SESSION} not running")
|
||||
elif cmd == "status":
|
||||
state = "RUNNING" if session_alive() else "stopped"
|
||||
log(f"{SESSION}: {state}")
|
||||
subprocess.run(
|
||||
f"ps -eo pid,etime,args | grep '[r]emote-control {SESSION}' || true",
|
||||
shell=True)
|
||||
if BACKEND == "claude":
|
||||
log(f"resume id: {resume_id()} (file: {ID_FILE})")
|
||||
log(f"backend: {BACKEND} model: {LOOP_MODEL or '<default>'}")
|
||||
elif cmd == "attach":
|
||||
os.execvp("tmux", ["tmux", "attach", "-t", SESSION])
|
||||
else:
|
||||
backend_note = (
|
||||
"claude: --resume preserves conversation across reboots; viewable at claude.ai/code\n"
|
||||
" opencode: fresh session each launch (no --resume); viewable at http://oc.commoninternet.net"
|
||||
)
|
||||
print(f"""cc-ci orchestrator launcher
|
||||
|
||||
launch-orchestrator.py start resume the persistent session (default)
|
||||
launch-orchestrator.py fresh start a new session (no --resume)
|
||||
launch-orchestrator.py stop kill the tmux session
|
||||
launch-orchestrator.py status show session state
|
||||
launch-orchestrator.py attach tmux attach
|
||||
|
||||
Backend: {BACKEND} (LOOP_BACKEND env var)
|
||||
Model: {LOOP_MODEL or '<backend default>'} (LOOP_MODEL env var)
|
||||
Session: {SESSION} cwd={WORKDIR}
|
||||
{backend_note}
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user