feat(assistant): add opencode launcher and phase 6/7 plans
This commit is contained in:
159
cc-ci-plan/launch-assistant.py
Normal file
159
cc-ci-plan/launch-assistant.py
Normal file
@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
cc-ci assistant launcher — start/resume the assistant session in tmux.
|
||||
|
||||
The assistant is a long-lived helper session that shares the orchestrator's access and can work
|
||||
through assigned plans autonomously, but it is NOT part of the Builder/Adversary loop pair.
|
||||
|
||||
Usage:
|
||||
launch-assistant.py start resume the persistent session (default)
|
||||
launch-assistant.py fresh start a NEW session
|
||||
launch-assistant.py stop kill the tmux session
|
||||
launch-assistant.py status show session state
|
||||
launch-assistant.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"
|
||||
ASSISTANT_SESSION tmux / remote-control session name
|
||||
ASSISTANT_DIR cwd for the assistant
|
||||
ASSISTANT_STARTUP_PROMPT startup nudge / assignment
|
||||
"""
|
||||
|
||||
import os, sys, subprocess, time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
SESSION = os.environ.get("ASSISTANT_SESSION", "cc-ci-assistant")
|
||||
WORKDIR = os.environ.get("ASSISTANT_DIR", "/srv/cc-ci-orch")
|
||||
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_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 = "9db267a4-9b9e-4818-8197-0a42dba443ea"
|
||||
ID_FILE = os.environ.get("ASSISTANT_ID_FILE", f"{LOG_DIR}/.assistant-session-id")
|
||||
STARTUP_PROMPT = os.environ.get("ASSISTANT_STARTUP_PROMPT", (
|
||||
"You are the cc-ci ASSISTANT. Read cc-ci-plan/JOURNAL.md for context, then wait for a "
|
||||
"specific plan/task from the orchestrator or operator. Work autonomously until the assigned "
|
||||
"task is complete, report the result, and then wait for the next assignment."
|
||||
))
|
||||
|
||||
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")
|
||||
|
||||
|
||||
def log(msg):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[assistant {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 _ping(session, msg):
|
||||
subprocess.run(["tmux", "send-keys", "-t", session, "-l", "--", msg], capture_output=True)
|
||||
time.sleep(0.5)
|
||||
submit_key = "C-m" if BACKEND == "opencode" else "Enter"
|
||||
for _ in range(10):
|
||||
subprocess.run(["tmux", "send-keys", "-t", session, submit_key], capture_output=True)
|
||||
time.sleep(1)
|
||||
r = subprocess.run(["tmux", "capture-pane", "-pt", session], capture_output=True, text=True)
|
||||
if msg[:28] not in r.stdout:
|
||||
return
|
||||
|
||||
|
||||
def resume_id():
|
||||
sid = os.environ.get("ASSISTANT_SESSION_ID")
|
||||
if sid:
|
||||
return sid
|
||||
try:
|
||||
v = Path(ID_FILE).read_text().strip()
|
||||
return v or DEFAULT_ID
|
||||
except FileNotFoundError:
|
||||
return DEFAULT_ID
|
||||
|
||||
|
||||
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}")
|
||||
cmd = (
|
||||
f"set -a; . /srv/cc-ci/.testenv; set +a; "
|
||||
f"NO_COLOR=1 {OPENCODE_BIN} attach {OPENCODE_SERVER} --dir {WORKDIR}"
|
||||
)
|
||||
log(f"starting {SESSION} (backend=opencode, model={LOOP_MODEL or 'default'})")
|
||||
log(" 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}")
|
||||
|
||||
if BACKEND == "opencode":
|
||||
time.sleep(8)
|
||||
_ping(SESSION, STARTUP_PROMPT)
|
||||
|
||||
|
||||
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:
|
||||
print("usage: launch-assistant.py start|fresh|stop|status|attach")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,104 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# launch-assistant.sh — start/resume the cc-ci ASSISTANT session in tmux under remote-control.
|
||||
#
|
||||
# The Assistant is a general-purpose, remote-controllable Claude session that shares the cc-ci
|
||||
# workspace + access with the orchestrator, but is NOT on a loop. It sits idle until the orchestrator
|
||||
# (or the operator) hands it a plan/task; it does the task against the workspace, reports back, and
|
||||
# waits for the next one. Modelled on launch-orchestrator.sh.
|
||||
#
|
||||
# Naming: tmux session AND remote-control name are both "cc-ci-assistant" (matching
|
||||
# cc-ci-orch / cc-ci-builder / cc-ci-adv / cc-ci-watchdog).
|
||||
#
|
||||
# Usage:
|
||||
# ./launch-assistant.sh start # resume the persistent assistant session (DEFAULT); creates it on first run
|
||||
# ./launch-assistant.sh fresh # start a NEW assistant session (new id)
|
||||
# ./launch-assistant.sh status # show tmux + remote-control state
|
||||
# ./launch-assistant.sh attach # tmux attach (Ctrl-b d to detach)
|
||||
# ./launch-assistant.sh stop # kill the tmux session (conversation persists on disk)
|
||||
set -euo pipefail
|
||||
|
||||
SESSION="${ASSISTANT_SESSION:-cc-ci-assistant}" # tmux session name == remote-control name
|
||||
WORKDIR="${ASSISTANT_DIR:-/srv/cc-ci}" # same workspace as the orchestrator
|
||||
CLAUDE_BIN="${CLAUDE_BIN:-/home/loops/.local/bin/claude}"
|
||||
# --dangerously-skip-permissions is blocked for root (use the env var there); as a non-root user the
|
||||
# flag works. Mirror launch.sh's detection.
|
||||
if [ "$(id -u)" = "0" ]; then export CLAUDE_DANGEROUSLY_SKIP_PERMISSIONS=1; CLAUDE_FLAGS="${CLAUDE_FLAGS:-}";
|
||||
else CLAUDE_FLAGS="${CLAUDE_FLAGS:---dangerously-skip-permissions}"; fi
|
||||
ASSISTANT_MODEL="${ASSISTANT_MODEL:-sonnet}" # the assistant runs on sonnet (cheaper for task work)
|
||||
REMOTE_CONTROL="${REMOTE_CONTROL:-1}" # 1 => --remote-control (viewable at claude.ai/code)
|
||||
LOG_DIR="${LOG_DIR:-/srv/cc-ci/.cc-ci-logs}"
|
||||
ID_FILE="${ASSISTANT_ID_FILE:-$LOG_DIR/.assistant-session-id}"
|
||||
# Startup brief: defines the assistant's role. Injected as the session's first/next turn. No single quotes.
|
||||
STARTUP_PROMPT="${ASSISTANT_STARTUP_PROMPT-You are the cc-ci ASSISTANT — a general-purpose helper sharing the cc-ci workspace (/srv/cc-ci) and access (ssh cc-ci, .testenv, the plan files) with the orchestrator. You are NOT on a loop and you do NOT supervise the Builder/Adversary loops. Sit idle until the orchestrator or operator hands you a specific plan or task; then do it carefully, report the result, and wait for the next one. Respect single-writer discipline: the loops own the cc-ci product-repo clones (/srv/cc-ci/cc-ci, /srv/cc-ci/cc-ci-adv) — do not edit those unless a task explicitly says to. If you have just (re)launched and have no pending task, briefly confirm you are online and idle, then wait.}"
|
||||
|
||||
log() { printf '[assistant %(%H:%M:%S)T] %s\n' -1 "$*"; }
|
||||
die() { log "ERROR: $*"; exit 1; }
|
||||
session_alive() { tmux has-session -t "$SESSION" 2>/dev/null; }
|
||||
|
||||
preflight() {
|
||||
command -v tmux >/dev/null 2>&1 || die "missing dependency: tmux"
|
||||
command -v "$CLAUDE_BIN" >/dev/null 2>&1 || die "claude CLI not found at $CLAUDE_BIN (set CLAUDE_BIN)"
|
||||
[[ -d "$WORKDIR" ]] || die "workdir not found: $WORKDIR"
|
||||
mkdir -p "$LOG_DIR"
|
||||
# seed a stable session id on first ever launch
|
||||
[[ -f "$ID_FILE" ]] || cat /proc/sys/kernel/random/uuid > "$ID_FILE"
|
||||
}
|
||||
|
||||
sid() { cat "$ID_FILE" 2>/dev/null; }
|
||||
# does a transcript already exist for this id? (project dir derived from WORKDIR, e.g. -srv-cc-ci)
|
||||
have_transcript() {
|
||||
local key; key="$(printf '%s' "$WORKDIR" | sed 's#/#-#g')"
|
||||
[[ -f "$HOME/.claude/projects/$key/$(sid).jsonl" ]]
|
||||
}
|
||||
|
||||
# $1 = resume|fresh
|
||||
start() {
|
||||
local mode="${1:-resume}"
|
||||
preflight
|
||||
if session_alive; then
|
||||
log "$SESSION already running — leaving it (use '$0 stop' first to relaunch)"; return 0
|
||||
fi
|
||||
local rc="" sess="" id; id="$(sid)"
|
||||
[[ "$REMOTE_CONTROL" == "1" ]] && rc="--remote-control '$SESSION'"
|
||||
if [[ "$mode" == "fresh" ]]; then
|
||||
id="$(cat /proc/sys/kernel/random/uuid)"; echo "$id" > "$ID_FILE"
|
||||
sess="--session-id '$id'"; log "starting $SESSION FRESH (new id=$id)"
|
||||
elif have_transcript; then
|
||||
sess="--resume '$id'"; log "starting $SESSION (resume id=$id)"
|
||||
else
|
||||
sess="--session-id '$id'"; log "starting $SESSION (first run, pin id=$id)"
|
||||
fi
|
||||
local prompt_arg=""
|
||||
[[ -n "$STARTUP_PROMPT" ]] && prompt_arg="'$STARTUP_PROMPT'"
|
||||
local model=""; [[ -n "$ASSISTANT_MODEL" ]] && model="--model '$ASSISTANT_MODEL'"
|
||||
tmux new-session -d -s "$SESSION" -c "$WORKDIR" \
|
||||
"$CLAUDE_BIN $sess $model $rc $CLAUDE_FLAGS $prompt_arg"
|
||||
tmux pipe-pane -o -t "$SESSION" "cat >> '$LOG_DIR/$SESSION.log'"
|
||||
log "started. status: $0 status | attach: tmux attach -t $SESSION | id: $id"
|
||||
}
|
||||
|
||||
case "${1:-start}" in
|
||||
start) start resume ;;
|
||||
fresh) start fresh ;;
|
||||
stop) if session_alive; then log "killing $SESSION"; tmux kill-session -t "$SESSION" || true; else log "$SESSION not running"; fi ;;
|
||||
status)
|
||||
if session_alive; then log "$SESSION: RUNNING"; ps -eo pid,etime,args | grep "[r]emote-control $SESSION" || true
|
||||
else log "$SESSION: stopped"; fi
|
||||
log "session id: $(sid) (file: $ID_FILE)" ;;
|
||||
attach) exec tmux attach -t "$SESSION" ;;
|
||||
*)
|
||||
cat <<EOF
|
||||
cc-ci assistant launcher — a remote-controllable, NON-loop helper sharing the orchestrator's workspace.
|
||||
|
||||
$0 start resume (or first-run create) the assistant session in tmux + remote-control (default)
|
||||
$0 fresh start a NEW assistant session (new id)
|
||||
$0 status show tmux + remote-control state and the session id
|
||||
$0 attach tmux attach to the session
|
||||
$0 stop kill the tmux session (conversation persists on disk)
|
||||
|
||||
Env: SESSION=$SESSION WORKDIR=$WORKDIR REMOTE_CONTROL=$REMOTE_CONTROL CLAUDE_BIN=$CLAUDE_BIN
|
||||
The orchestrator hands it plans/tasks; it does them, reports, and waits. Not on a loop.
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
# Thin wrapper — delegates everything to launch-assistant.py in the same directory.
|
||||
exec python3 "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/launch-assistant.py" "$@"
|
||||
|
||||
@ -98,9 +98,13 @@ PHASES = [p.split("|") for p in PHASES_SPEC.split(";")]
|
||||
PHASE_IDX_FILE = os.environ.get("PHASE_IDX_FILE", f"{LOG_DIR}/.phase-idx")
|
||||
|
||||
# Regex patterns for session-state detection
|
||||
ACTIVE_RE = re.compile(r"esc to interrupt|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Running tool")
|
||||
ACTIVE_RE = re.compile(r"esc to interrupt|⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Running tool|▣|Build ·|· \d+")
|
||||
LIMIT_RE = re.compile(r"spend limit|usage limit|limit reached|reached your .*limit|out of (credits|tokens)", re.I)
|
||||
FATAL_RE = re.compile(r"redacted_thinking|blocks cannot be modified|cannot be modified", re.I)
|
||||
RECENT_ACTIVITY_RE = re.compile(r"thinking|inferring|running tool|remote control (active|connecting)|tool call|schedulewake?up", re.I)
|
||||
|
||||
OPENCODE_STALL_IDLE = int(os.environ.get("OPENCODE_STALL_IDLE", 900))
|
||||
OPENCODE_LOG_GRACE = int(os.environ.get("OPENCODE_LOG_GRACE", 180))
|
||||
|
||||
# ── logging ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -127,27 +131,42 @@ def capture_pane(name, lines=40):
|
||||
r = subprocess.run(["tmux", "capture-pane", "-pt", name], capture_output=True, text=True)
|
||||
return "\n".join(r.stdout.splitlines()[-lines:]) if r.returncode == 0 else ""
|
||||
|
||||
def _session_log_path(session):
|
||||
return Path(LOG_DIR) / f"{session}.log"
|
||||
|
||||
def _log_recently_touched(session, age_seconds):
|
||||
try:
|
||||
return (time.time() - _session_log_path(session).stat().st_mtime) <= age_seconds
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def _last_nonempty_line(text):
|
||||
for line in reversed(text.splitlines()):
|
||||
if line.strip():
|
||||
return line.strip()
|
||||
return ""
|
||||
|
||||
def pipe_to_log(session, log_path):
|
||||
subprocess.run(["tmux", "pipe-pane", "-o", "-t", session, f"cat >> '{log_path}'"])
|
||||
|
||||
def ping_session(session, msg, submit_key="Enter"):
|
||||
"""Type a message into a tmux session and submit it.
|
||||
|
||||
submit_key: "Enter" for claude (default); "C-s" for opencode (Ctrl+S sends in opencode TUI).
|
||||
Retries the submit key until the typed prefix is no longer visible in the input area.
|
||||
submit_key: "Enter" for claude; "C-m" for opencode (Ctrl+M = Enter).
|
||||
Retries the submit key until the typed prefix is no longer visible in the content area.
|
||||
opencode renders the input in the content area, so we check more lines.
|
||||
"""
|
||||
if not session_alive(session):
|
||||
return
|
||||
prefix = msg[:28]
|
||||
subprocess.run(["tmux", "send-keys", "-t", session, "-l", "--", msg], capture_output=True)
|
||||
time.sleep(0.5)
|
||||
for _ in range(5):
|
||||
for _ in range(10):
|
||||
subprocess.run(["tmux", "send-keys", "-t", session, submit_key], capture_output=True)
|
||||
time.sleep(1)
|
||||
if prefix not in capture_pane(session, 4):
|
||||
# Check the top 20 lines of content (not just last 4 bottom UI)
|
||||
if prefix not in capture_pane(session, 20):
|
||||
return # message was accepted
|
||||
subprocess.run(["tmux", "send-keys", "-t", session, "C-m"], capture_output=True)
|
||||
time.sleep(0.5)
|
||||
|
||||
# ── phase helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@ -217,38 +236,40 @@ def start_agent(role, session, workdir):
|
||||
|
||||
model_flag = f"--model '{LOOP_MODEL}'" if LOOP_MODEL else ""
|
||||
|
||||
session_cwd = workdir
|
||||
|
||||
if BACKEND == "claude":
|
||||
rc = f"--remote-control '{session}'" if REMOTE_CONTROL else ""
|
||||
cmd = f"{CLAUDE_BIN} {rc} {model_flag} {CLAUDE_FLAGS} \"$(cat '{kf}')\""
|
||||
log(f"starting {session} (backend=claude, phase={pid}, plan={plan}, model={LOOP_MODEL or 'default'})")
|
||||
elif BACKEND == "opencode":
|
||||
# Plain `opencode` (no subcommand) launches the persistent TUI and connects to the
|
||||
# shared server automatically. `opencode attach` requires a TTY and exits in tmux;
|
||||
# the plain TUI works because tmux allocates a PTY for the pane's child process.
|
||||
# --dir pins the working directory; the kickoff is sent via ping_session after startup.
|
||||
# Note: --dir causes opencode to exit immediately (likely a non-git-root issue).
|
||||
# The working directory is set via tmux -c instead; opencode uses that as its cwd.
|
||||
# Attach each TUI to the shared opencode web server so sessions are recorded the same
|
||||
# way as browser-created sessions, including a populated `path` in the DB.
|
||||
# We still pin the visible project root with --dir, while the kickoff instructions use
|
||||
# absolute repo paths for builder/adversary work.
|
||||
session_cwd = "/srv/cc-ci-orch/cc-ci"
|
||||
cmd = (
|
||||
f"set -a; . /srv/cc-ci/.testenv; set +a; "
|
||||
f"NO_COLOR=1 {OPENCODE_BIN} {model_flag}"
|
||||
f"NO_COLOR=1 {OPENCODE_BIN} attach {OPENCODE_SERVER} --dir {session_cwd}"
|
||||
)
|
||||
log(f"starting {session} (backend=opencode, phase={pid}, model={LOOP_MODEL or 'default'})")
|
||||
log(f" visible at http://oc.commoninternet.net (tailnet only)")
|
||||
else:
|
||||
die(f"unknown BACKEND '{BACKEND}' — set LOOP_BACKEND=claude or LOOP_BACKEND=opencode")
|
||||
|
||||
subprocess.run(["tmux", "new-session", "-d", "-s", session, "-c", workdir, cmd])
|
||||
subprocess.run(["tmux", "new-session", "-d", "-s", session, "-c", session_cwd, cmd])
|
||||
pipe_to_log(session, f"{LOG_DIR}/{session}.log")
|
||||
|
||||
# opencode: send a short bootstrap once the TUI is ready (Ctrl+S submits in opencode).
|
||||
# opencode: send a short bootstrap once the TUI is ready.
|
||||
# opencode TUI uses C-m (Ctrl+M = Enter) to submit messages.
|
||||
# The full kickoff lives in the kickoff file; we point to it to stay under send-keys limits.
|
||||
if BACKEND == "opencode":
|
||||
time.sleep(8) # opencode TUI needs more time to connect to the server than 4s
|
||||
time.sleep(12) # opencode TUI needs more time to connect to the server
|
||||
bootstrap = (
|
||||
f"Your full kickoff prompt is in {kf} — read it now with: "
|
||||
f"`cat '{kf}'` — then follow its instructions exactly."
|
||||
)
|
||||
ping_session(session, bootstrap, submit_key="C-s")
|
||||
ping_session(session, bootstrap, submit_key="C-m")
|
||||
|
||||
def start_loops():
|
||||
start_agent("builder", BUILDER_SESSION, BUILDER_DIR)
|
||||
@ -279,7 +300,7 @@ def heal_session(role, session, workdir):
|
||||
start_agent(role, session, workdir)
|
||||
return
|
||||
|
||||
if LIMIT_RE.search(pane):
|
||||
if BACKEND != "opencode" and LIMIT_RE.search(pane):
|
||||
log(f"limit-stall on {role} ({session}) — nudging to resume")
|
||||
ping_session(session,
|
||||
"watchdog: the usage/spend limit appears lifted — RESUME your loop now. "
|
||||
@ -289,10 +310,37 @@ def heal_session(role, session, workdir):
|
||||
# ── stall detection ───────────────────────────────────────────────────────────
|
||||
|
||||
_idle_since: dict[str, float] = {}
|
||||
_limit_nudged_at: dict[str, float] = {}
|
||||
|
||||
def _maybe_nudge_limit(role, session, pane):
|
||||
if not LIMIT_RE.search(pane):
|
||||
return False
|
||||
|
||||
now = time.time()
|
||||
last = _limit_nudged_at.get(session, 0.0)
|
||||
if now - last < 300:
|
||||
return True
|
||||
|
||||
_limit_nudged_at[session] = now
|
||||
log(f"limit-stall on {role} ({session}) — nudging to resume")
|
||||
ping_session(
|
||||
session,
|
||||
"watchdog: the usage/spend limit appears lifted or is about to reset. "
|
||||
"RESUME your loop now. Pull latest, re-read your phase STATUS/REVIEW files, "
|
||||
"and continue from where you stopped; re-arm your loop pacing.",
|
||||
submit_key=_SUBMIT,
|
||||
)
|
||||
return True
|
||||
|
||||
def _parse_waiting_until(pane):
|
||||
"""Extract the epoch timestamp from a WAITING-UNTIL marker, or None."""
|
||||
m = re.search(r"WAITING-UNTIL:\s*(\S+)", pane)
|
||||
if BACKEND == "opencode":
|
||||
line = _last_nonempty_line(pane)
|
||||
if not line.startswith("WAITING-UNTIL:"):
|
||||
return None
|
||||
m = re.search(r"WAITING-UNTIL:\s*(\S+)", line)
|
||||
else:
|
||||
m = re.search(r"WAITING-UNTIL:\s*(\S+)", pane)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
@ -305,12 +353,19 @@ def _parse_waiting_until(pane):
|
||||
def stall_check_one(role, session, workdir):
|
||||
if not session_alive(session):
|
||||
_idle_since[session] = 0.0
|
||||
_limit_nudged_at[session] = 0.0
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
pane = capture_pane(session, 40)
|
||||
|
||||
if ACTIVE_RE.search(pane):
|
||||
if BACKEND == "opencode" and _maybe_nudge_limit(role, session, pane):
|
||||
_idle_since[session] = now
|
||||
return
|
||||
|
||||
if ACTIVE_RE.search(pane) or (BACKEND == "opencode" and (
|
||||
RECENT_ACTIVITY_RE.search(pane) or _log_recently_touched(session, OPENCODE_LOG_GRACE)
|
||||
)):
|
||||
_idle_since[session] = 0.0
|
||||
return
|
||||
|
||||
@ -326,7 +381,8 @@ def stall_check_one(role, session, workdir):
|
||||
return
|
||||
reason = f"past its WAITING-UNTIL by {int(now - until)}s — self-wake did not fire"
|
||||
else:
|
||||
if idle < STALL_IDLE:
|
||||
stall_idle = OPENCODE_STALL_IDLE if BACKEND == "opencode" else STALL_IDLE
|
||||
if idle < stall_idle:
|
||||
return
|
||||
reason = f"idle {int(idle)}s with no WAITING-UNTIL marker"
|
||||
|
||||
@ -405,7 +461,7 @@ def _show_pushed(path):
|
||||
return r.stdout
|
||||
return ""
|
||||
|
||||
_SUBMIT = "C-s" if BACKEND == "opencode" else "Enter"
|
||||
_SUBMIT = "C-m" if BACKEND == "opencode" else "Enter"
|
||||
|
||||
def handoff_check():
|
||||
global _last_sha, _adv_inbox_seen, _builder_inbox_seen
|
||||
|
||||
44
cc-ci-plan/plan-phase6-reconcile-all-mirrors.md
Normal file
44
cc-ci-plan/plan-phase6-reconcile-all-mirrors.md
Normal file
@ -0,0 +1,44 @@
|
||||
# cc-ci Phase 6 — reconcile all enrolled recipe mirrors
|
||||
|
||||
**Status:** QUEUED — runs after Builder/Adversary phase 5 is complete.
|
||||
**Owner:** `cc-ci-assistant` session only. Not a Builder/Adversary loop phase.
|
||||
**This file:** `/srv/cc-ci-orch/cc-ci-plan/plan-phase6-reconcile-all-mirrors.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Bring every enrolled `recipe-maintainers/<recipe>` mirror back into the intended reconciled state
|
||||
without involving the Builder/Adversary loops. This is a post-build operational cleanup pass, not a
|
||||
loop-verification phase.
|
||||
|
||||
## 2. Definition of Done
|
||||
|
||||
- [ ] Enumerate the current enrolled recipe mirrors and the expected upstream source for each.
|
||||
- [ ] For all mirrors, confirm `main` matches upstream `main` at the intended sync point.
|
||||
- [ ] Identify stale open PRs where the change is already in upstream main and close them.
|
||||
- [ ] Identify superseded PRs and replace/close them according to current reconcile behavior.
|
||||
- [ ] Record the final per-recipe reconcile outcome in a written summary for the operator.
|
||||
- [ ] Leave no ad-hoc test branches or temporary working copies behind.
|
||||
|
||||
## 3. Method
|
||||
|
||||
- Use the assistant's own working copies or scratch clones; do not edit the Builder/Adversary loop
|
||||
clones unless explicitly required.
|
||||
- Prefer existing reconcile helpers and documented workflows already used by cc-ci.
|
||||
- Keep changes minimal and operationally reversible.
|
||||
- If a mirror exposes a genuine product bug instead of a routine reconcile issue, record it clearly
|
||||
and stop short of risky improvisation.
|
||||
|
||||
## 4. Deliverables
|
||||
|
||||
- A concise operator-facing summary listing:
|
||||
- every mirror checked
|
||||
- any PRs closed/replaced
|
||||
- any mirror left needing manual attention
|
||||
|
||||
## 5. Guardrails
|
||||
|
||||
- No loop/watchdog changes are part of this phase.
|
||||
- No merges without explicit operator instruction.
|
||||
- No secret values in notes, commits, or summaries.
|
||||
49
cc-ci-plan/plan-phase7-upgrade-three-recipes.md
Normal file
49
cc-ci-plan/plan-phase7-upgrade-three-recipes.md
Normal file
@ -0,0 +1,49 @@
|
||||
# cc-ci Phase 7 — run targeted upgrades on n8n, ghost, and matrix-synapse
|
||||
|
||||
**Status:** QUEUED — runs after phase 6.
|
||||
**Owner:** `cc-ci-assistant` session only. Not a Builder/Adversary loop phase.
|
||||
**This file:** `/srv/cc-ci-orch/cc-ci-plan/plan-phase7-upgrade-three-recipes.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Run the targeted post-build recipe upgrade work for:
|
||||
|
||||
- `n8n`
|
||||
- `ghost`
|
||||
- `matrix-synapse`
|
||||
|
||||
This is operational recipe-maintenance work and should be driven by the assistant, not the
|
||||
Builder/Adversary loops.
|
||||
|
||||
## 2. Definition of Done
|
||||
|
||||
- [ ] Research the current upstream upgrade target for each of the three recipes.
|
||||
- [ ] For each recipe, determine whether an upgrade is actually available and worth opening.
|
||||
- [ ] Run the established upgrade workflow for each upgradeable recipe.
|
||||
- [ ] For each recipe PR opened, verify the expected cc-ci testing path is triggered and record the outcome.
|
||||
- [ ] Summarize all opened PRs, blocked upgrades, and any follow-up needed from the operator.
|
||||
- [ ] Leave the assistant session idle and ready for the next assignment when complete.
|
||||
|
||||
## 3. Method
|
||||
|
||||
- Prefer the existing assistant-accessible upgrade tooling and documented workflows.
|
||||
- Default to the smallest correct per-recipe change set.
|
||||
- Keep one clear record per recipe: checked, upgradeable or not, PR opened or not, verified or blocked.
|
||||
- If a stale cc-ci test is the only blocker, report that explicitly instead of improvising beyond the
|
||||
established workflow.
|
||||
|
||||
## 4. Deliverables
|
||||
|
||||
- A summary covering:
|
||||
- `n8n`
|
||||
- `ghost`
|
||||
- `matrix-synapse`
|
||||
- For each: upstream target, action taken, PR URL if any, verification result, and remaining risk.
|
||||
|
||||
## 5. Guardrails
|
||||
|
||||
- Never merge PRs.
|
||||
- Do not use the Builder/Adversary loop clones as the assistant's scratch workspace.
|
||||
- Record blockers clearly rather than pushing through uncertain migrations.
|
||||
Reference in New Issue
Block a user