Files
cc-ci-orchestrator/cc-ci-plan/launch.sh
autonomic-bot e68a520d4c Fix watchdog false gate-ping: edge-trigger on NEW claimed-awaiting gate ids, baseline silently
The Adversary got a spurious "gate CLAIMED" ping: STATUS.md keeps historical
"Gate: Mn — CLAIMED, awaiting Adversary" lines after they PASS, and on watchdog restart the
first observation pinged on those already-passed lines. Now track the SET of gate ids on
CLAIMED-awaiting lines and ping only when an id NEWLY appears vs the prior observation, after a
silent baseline. A gate passing (line kept) or evidence edits don't re-ping; restart re-baselines
without pinging. Verified: watchdog restart no longer pings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 06:25:09 +01:00

266 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# launch.sh — start and supervise the two cc-ci autonomous loops + a watchdog.
#
# Model (see plan.md §6 / §6.1): two INDEPENDENT Claude Code sessions —
# • Builder (tmux session: cc-ci-builder) working clone /srv/cc-ci/cc-ci
# • Adversary (tmux session: cc-ci-adv) working clone /srv/cc-ci/cc-ci-adv
# coordinating only through the git repo on git.autonomic.zone.
#
# Each agent self-paces with a `/loop` (ScheduleWakeup) — that handles ITERATION.
# This script's watchdog handles RESILIENCE: it restarts a session that has died
# and stops everything once STATUS.md reports "## DONE".
#
# Usage:
# ./launch.sh start # start both loops + watchdog (idempotent)
# ./launch.sh watchdog # run only the supervision loop in the foreground
# ./launch.sh status # show session + DONE state
# ./launch.sh logs builder|adversary|watchdog # tail a session/log
# ./launch.sh stop # stop both loops + watchdog
#
# Configure via env vars (defaults below). At minimum set CC_CI_REPO once the
# Builder has created the repo, so the watchdog can detect DONE.
set -euo pipefail
# Absolute path to this script, so the watchdog re-invokes it correctly regardless of how it
# was called or what cwd the tmux session uses (a relative $0 breaks once we cd into PLAN_DIR).
SELF="$(readlink -f "${BASH_SOURCE[0]}")"
# ----- config -------------------------------------------------------------
PLAN_DIR="${PLAN_DIR:-/srv/cc-ci/cc-ci-plan}"
CLAUDE_BIN="${CLAUDE_BIN:-claude}"
# Flags for unattended operation in a sandbox. Override if your setup differs.
CLAUDE_FLAGS="${CLAUDE_FLAGS:---dangerously-skip-permissions}"
# REMOTE_CONTROL=1 launches each agent as an INTERACTIVE session with --remote-control,
# viewable/steerable at claude.ai/code (and the Claude mobile app). This is required for
# /loop + ScheduleWakeup to work at all (they are interactive-only — a piped/print-mode
# session cannot self-pace). Set REMOTE_CONTROL=0 for a plain interactive session with no
# remote surface. The box must be logged into the claude.ai account (run `claude` once to
# check `claude auth status`). Each agent gets its own RC session named after its tmux session.
REMOTE_CONTROL="${REMOTE_CONTROL:-1}"
BUILDER_DIR="${BUILDER_DIR:-/srv/cc-ci/cc-ci}" # Builder's repo clone (it creates this)
ADV_DIR="${ADV_DIR:-/srv/cc-ci/cc-ci-adv}" # Adversary's repo clone
WATCH_DIR="${WATCH_DIR:-/srv/cc-ci/.cc-ci-watch}" # tiny clone the watchdog reads STATUS.md from
LOG_DIR="${LOG_DIR:-/srv/cc-ci/.cc-ci-logs}"
CC_CI_REPO="${CC_CI_REPO:-https://git.autonomic.zone/recipe-maintainers/cc-ci.git}" # CI project repo (DONE detection); harmless until the Builder creates it
CC_CI_BRANCH="${CC_CI_BRANCH:-main}"
WATCH_INTERVAL="${WATCH_INTERVAL:-300}" # seconds between HEAVY checks (restart dead loops, DONE)
SIGNAL_INTERVAL="${SIGNAL_INTERVAL:-30}" # seconds between HANDOFF checks (ping the waiting loop)
BUILDER_SESSION="cc-ci-builder"
ADV_SESSION="cc-ci-adv"
WATCHDOG_SESSION="cc-ci-watchdog"
# --------------------------------------------------------------------------
log() { printf '[launch %(%H:%M:%S)T] %s\n' -1 "$*"; }
die() { log "ERROR: $*"; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; }
preflight() {
need tmux
command -v "$CLAUDE_BIN" >/dev/null 2>&1 || die "claude CLI not found (set CLAUDE_BIN)"
[[ -f "$PLAN_DIR/prompts/builder.md" ]] || die "missing $PLAN_DIR/prompts/builder.md"
[[ -f "$PLAN_DIR/prompts/adversary.md" ]] || die "missing $PLAN_DIR/prompts/adversary.md"
mkdir -p "$LOG_DIR"
}
session_alive() { tmux has-session -t "$1" 2>/dev/null; }
# Start one agent loop in its own tmux session, cd'd into its working dir, with
# the kickoff prompt passed to claude as a positional argument (see below for why
# not stdin).
start_agent() {
local session="$1" workdir="$2" prompt_file="$3"
if session_alive "$session"; then
log "$session already running — leaving it"
return 0
fi
mkdir -p "$workdir"
log "starting $session (cwd=$workdir, remote_control=$REMOTE_CONTROL)"
# tmux gives claude a real PTY, so we run claude INTERACTIVELY (required for /loop +
# ScheduleWakeup). The kickoff prompt is passed as a POSITIONAL argument via an inner
# `$(cat ...)` — NOT piped on stdin, because piping forces print/headless mode which
# breaks both interactivity and --remote-control. The `\$(...)` defers to the inner shell
# so the whole multi-line prompt arrives as a single argument.
local rc=""
[[ "$REMOTE_CONTROL" == "1" ]] && rc="--remote-control '$session'"
tmux new-session -d -s "$session" -c "$workdir" \
"$CLAUDE_BIN $rc $CLAUDE_FLAGS \"\$(cat '$prompt_file')\""
# Log the pane WITHOUT redirecting claude's stdout: a `>>log` redirect makes stdout a
# non-tty and drops claude out of interactive/remote-control mode. pipe-pane mirrors the
# live pane to the log file while claude keeps the PTY tmux gave it.
tmux pipe-pane -o -t "$session" "cat >> '$LOG_DIR/$session.log'"
}
start_loops() {
start_agent "$BUILDER_SESSION" "$BUILDER_DIR" "$PLAN_DIR/prompts/builder.md"
start_agent "$ADV_SESSION" "$ADV_DIR" "$PLAN_DIR/prompts/adversary.md"
}
# Returns 0 (true) if the repo's STATUS.md contains a "## DONE" heading.
is_done() {
[[ -n "$CC_CI_REPO" ]] || return 1
if [[ ! -d "$WATCH_DIR/.git" ]]; then
git clone --depth 1 --branch "$CC_CI_BRANCH" "$CC_CI_REPO" "$WATCH_DIR" >/dev/null 2>&1 || return 1
fi
git -C "$WATCH_DIR" fetch --depth 1 origin "$CC_CI_BRANCH" >/dev/null 2>&1 || return 1
git -C "$WATCH_DIR" reset --hard "origin/$CC_CI_BRANCH" >/dev/null 2>&1 || return 1
grep -qE '^##[[:space:]]+DONE' "$WATCH_DIR/STATUS.md" 2>/dev/null
}
# Wake a loop by typing a one-line message into its tmux session (queues if mid-turn).
ping_session() {
local s="$1" msg="$2"
session_alive "$s" || return 0
tmux send-keys -t "$s" -l -- "$msg" 2>/dev/null && { sleep 0.3; tmux send-keys -t "$s" Enter 2>/dev/null; }
}
# Edge-triggered handoff signalling: the moment one loop produces the artifact the other is
# waiting on, ping the waiting loop so it wakes immediately instead of idling out its sleep.
# Reads the loops' local working clones (same host) for the fastest signal; the pinged loop
# still pulls the real state on wake.
#
# IMPORTANT: STATUS.md keeps *historical* gate lines ("Gate: Mn — CLAIMED, awaiting Adversary")
# even after they PASS (the Builder appends "→ Mn PASS"). So we cannot ping on the mere presence
# of "CLAIMED". We track the set of gates that are **claimed-and-awaiting but NOT yet PASS** (by
# gate id), and ping the Adversary ONLY when a gate *newly enters* that set — never on the
# watchdog's first observation (baseline), never when a line is merely edited or marked PASS.
_wd_awaiting="" # current set of unverified-claimed gate ids (newline-separated)
_wd_baselined="" # set once the first observation has established a baseline (no ping then)
_wd_last_review=""
handoff_check() {
local sf="$BUILDER_DIR/STATUS.md" rf="$ADV_DIR/REVIEW.md" cur now added
# Builder -> Adversary: a gate newly CLAIMED & awaiting verification (and not already PASS).
if [[ -f "$sf" ]]; then
# gate ids appearing on any "CLAIMED … awaiting" line. We ping only when an id NEWLY appears
# vs the previous observation, so: a new claim pings; a gate passing (its line is kept, not
# removed) does not re-ping; editing evidence does not ping; watchdog restart re-baselines silently.
now="$(grep -iE 'CLAIMED.*awaiting' "$sf" 2>/dev/null \
| grep -oiE 'M[0-9]+(\.[0-9]+)?' | tr '[:lower:]' '[:upper:]' | sort -u)"
if [[ -n "$_wd_baselined" ]]; then
added="$(comm -13 <(printf '%s\n' "$_wd_awaiting" | sort -u) <(printf '%s\n' "$now" | sort -u) | grep -vE '^$' || true)"
if [[ -n "$added" ]]; then
log "handoff: gate(s) newly awaiting verification: $(echo $added) -> pinging Adversary"
ping_session "$ADV_SESSION" "watchdog ping: the Builder has CLAIMED milestone gate(s) [$(echo $added)] in STATUS.md and is awaiting your verification. Pull and verify now."
fi
fi
_wd_awaiting="$now"; _wd_baselined=1
fi
# Adversary -> Builder: REVIEW.md changed (a verdict/PASS/FAIL or a new finding).
if [[ -f "$rf" ]]; then
cur="$(md5sum "$rf" 2>/dev/null | awk '{print $1}')"
if [[ -n "$cur" && "$cur" != "$_wd_last_review" ]]; then
[[ -n "$_wd_last_review" ]] && {
log "handoff: REVIEW.md changed -> pinging Builder"
ping_session "$BUILDER_SESSION" "watchdog ping: the Adversary updated REVIEW.md (a verdict or finding). Pull and act now — if it PASSes your gate, proceed; if it's a finding, address it."
}
_wd_last_review="$cur"
fi
fi
}
watchdog_loop() {
log "watchdog up (signal=${SIGNAL_INTERVAL}s, heavy=${WATCH_INTERVAL}s, repo=${CC_CI_REPO:-<unset: DONE-detection disabled>})"
local elapsed="$WATCH_INTERVAL" # run a heavy check on the first tick too
while true; do
# Fast path every tick: ping a loop the moment its counterpart hands off.
handoff_check
# Heavy path every WATCH_INTERVAL: DONE detection + restart dead loops.
if (( elapsed >= WATCH_INTERVAL )); then
elapsed=0
if is_done; then
log "STATUS.md reports ## DONE — stopping loops."
stop_loops
log "watchdog exiting (project complete)."
exit 0
fi
if ! session_alive "$BUILDER_SESSION"; then
log "builder session gone — restarting"
start_agent "$BUILDER_SESSION" "$BUILDER_DIR" "$PLAN_DIR/prompts/builder.md"
fi
if ! session_alive "$ADV_SESSION"; then
log "adversary session gone — restarting"
start_agent "$ADV_SESSION" "$ADV_DIR" "$PLAN_DIR/prompts/adversary.md"
fi
fi
sleep "$SIGNAL_INTERVAL"
elapsed=$(( elapsed + SIGNAL_INTERVAL ))
done
}
start_watchdog() {
if session_alive "$WATCHDOG_SESSION"; then
log "watchdog already running"
return 0
fi
log "starting watchdog"
tmux new-session -d -s "$WATCHDOG_SESSION" -c "$PLAN_DIR" \
"exec >>'$LOG_DIR/watchdog.log' 2>&1; '$SELF' watchdog"
}
stop_loops() {
for s in "$BUILDER_SESSION" "$ADV_SESSION"; do
if session_alive "$s"; then log "killing $s"; tmux kill-session -t "$s" || true; fi
done
}
cmd_status() {
for s in "$BUILDER_SESSION" "$ADV_SESSION" "$WATCHDOG_SESSION"; do
if session_alive "$s"; then echo " $s: RUNNING"; else echo " $s: stopped"; fi
done
if [[ -n "$CC_CI_REPO" ]]; then
if is_done; then echo " project: ## DONE"; else echo " project: in progress"; fi
else
echo " project: (CC_CI_REPO unset — DONE-detection disabled)"
fi
}
case "${1:-}" in
start)
preflight
start_loops
start_watchdog
log "started. inspect with: ./launch.sh status | attach: tmux attach -t $BUILDER_SESSION"
;;
watchdog) preflight; watchdog_loop ;;
status) cmd_status ;;
logs)
case "${2:-}" in
builder) tail -f "$LOG_DIR/$BUILDER_SESSION.log" ;;
adversary) tail -f "$LOG_DIR/$ADV_SESSION.log" ;;
watchdog) tail -f "$LOG_DIR/watchdog.log" ;;
*) die "usage: $0 logs builder|adversary|watchdog" ;;
esac
;;
stop)
stop_loops
if session_alive "$WATCHDOG_SESSION"; then log "killing $WATCHDOG_SESSION"; tmux kill-session -t "$WATCHDOG_SESSION" || true; fi
log "stopped."
;;
*)
cat <<EOF
cc-ci loop launcher
$0 start start both loops + watchdog (idempotent)
$0 status show session + DONE state
$0 logs builder|adversary|watchdog tail a log
$0 stop stop everything
$0 watchdog run supervision loop in foreground
Key env vars (current value):
CC_CI_REPO = ${CC_CI_REPO:-<unset — set to enable DONE detection>}
CLAUDE_BIN = $CLAUDE_BIN
CLAUDE_FLAGS = $CLAUDE_FLAGS
REMOTE_CONTROL = $REMOTE_CONTROL (1 = interactive --remote-control, viewable at claude.ai/code)
BUILDER_DIR = $BUILDER_DIR
ADV_DIR = $ADV_DIR
WATCH_INTERVAL = ${WATCH_INTERVAL}s
EOF
;;
esac