Files
cleveragents-core/scripts/opencode-builder.sh
T
drew 593d142f6f feat(auto-agents): Tier 2 deterministic review/implementer dispatchers
Replaces the long-running pr-review-supervisor / implementation-
supervisor LLM polling loops with host-level Python dispatchers that
own queueing, claim ownership, watchdogs, and SQLite telemetry. The
LLM workers retain sole responsibility for review judgment and code
generation; Python owns only orchestration. The hard merge invariant
is unaffected — these dispatchers do not touch master.

Driver surface
- _opencode_worker.run_session_blocking — outcome-agnostic OpenCode
  session lifecycle (completed / timeout / transport-error). Used
  directly by reviewer / implementer dispatchers whose workers do
  not emit the conflict-driver JSON exit schema.
  run_worker_blocking is now a thin wrapper that adds the
  conflict-specific JSON-outcome classification.
- _dispatch_runtime — shared Python runtime (work-group polling,
  claim helpers, dispatch loop, telemetry). Pre-checks issue labels
  before claiming and refuses when any auto/claimed-* is already
  present, distinguishing already-claimed vs labels-fetch-failed
  vs claim-failed terminal states. Frozen DispatchConfig.
- dispatch_review.py / dispatch_implementer.py — per-pipeline work
  groups, prompts, and CLIs. Each refuses startup when a competing
  AUTO-REV-SUP / AUTO-IMP-SUP legacy supervisor is live on the
  same OpenCode server (override:
  {REVIEW,IMPLEMENTER}_DISPATCHER_ALLOW_SUPERVISOR_COEXIST=1).
- _loader.py — shared sibling-module loader; replaces the three
  duplicated copies in the dispatcher entry points.

Operational hardening
- run_outer_loop tracks consecutive cycle exceptions against
  cycle_failure_budget (default 5, env-tunable per driver) and
  exits 2 on exhaustion for supervisor-driven restart.
- _sanitize_release_detail strips control bytes and neutralises
  triple-backtick fences before quoting worker raw_response in
  Forgejo claim-release comments.
- scripts/opencode-builder.sh: OPENCODE_BUILDER_SERVER_ONLY=1 keeps
  only the OpenCode HTTP API up so the Python dispatchers own
  queue orchestration without auto-agents running concurrently.

Telemetry
- _forgejo_cache.py schema v4: dispatch_review_cycles,
  dispatch_implementer_cycles. One row per cycle with cycle_id,
  driver, candidates_count, claims_acquired, swept_count,
  processed_count, terminal_state, worker_outcome, session_id,
  worker_wallclock_seconds, raw.
- .opencode/telemetry/server.py wires the new tables into
  /api/cycles?driver=dispatch_review|dispatch_implementer and
  surfaces a composite terminal_state/worker_outcome 24h breakdown
  so dashboards can distinguish session-level vs work-level
  outcomes.

Tests
- 31 new tests in tests/auto_agents/test_dispatch_runtime.py
  covering: candidate priority/dedup, claim/release labels,
  foreign-claim refusal, same-kind-claim refusal,
  labels-fetch-failed terminal state, sanitization, supervisor
  coexistence guard (pass/refuse/override/unreachable-server),
  session timeout / transport-error propagation,
  JSON-vs-no-JSON worker exits, cycle failure budget exit and
  reset, heartbeat cadence, end-to-end --once --dry-run /
  --status CLI smoke, and full prompt-snapshot tests for
  _review_prompt and _implementation_prompt (PR-fix + issue-impl).
- test_telemetry_schema.py asserts schema v4 and the presence of
  the two new dispatcher cycle tables.

338 auto_agents tests pass (was 322 before Tier 2). Conflict driver
regression suite unchanged. Dispatchers run cleanly under
--status / --once --dry-run with no Forgejo or OpenCode HTTP traffic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 23:12:09 -04:00

278 lines
10 KiB
Bash

#!/usr/bin/env bash
#
# opencode-builder.sh
#
# Launches an OpenCode server, creates a session with the auto-agents
# agent, and continuously sends "continue" messages in an infinite loop.
#
# Signal handling:
# Ctrl+C once — finishes the in-flight request, then stops the loop
# Ctrl+C twice — (within 2 seconds) kills the request and exits immediately
#
set -uo pipefail
# ── Configuration (override via environment) ──────────────────────────────
PORT="${OPENCODE_PORT:-4096}"
HOST="${OPENCODE_HOST:-127.0.0.1}"
BASE="http://${HOST}:${PORT}"
AGENT="auto-agents"
PROMPT="Complete the current project's milestones up to and including v3.7.0 to a production ready state"
SERVER_ONLY="${OPENCODE_BUILDER_SERVER_ONLY:-0}"
HEALTH_TIMEOUT=60 # seconds to wait for the server to become healthy
MAX_IDLE_PER_MINUTE=10
# ── Internal state ────────────────────────────────────────────────────────
SERVER_PID=""
CURL_PID=""
SESSION_ID=""
STOP_LOOP=false
LAST_SIGINT=0
OWN_SERVER=false
N=0
IDLE_EVENTS_FILE=""
# ── Helpers ───────────────────────────────────────────────────────────────
die() { printf "ERROR: %s\n" "$*" >&2; exit 1; }
log() { printf "[%s] %s\n" "$(date +%H:%M:%S)" "$*"; }
check_opencode_running() {
# Check if port is open and responding as OpenCode server
if curl -sf "${BASE}/global/health" >/dev/null 2>&1; then
return 0 # OpenCode server is running
else
return 1 # No OpenCode server detected
fi
}
cleanup() {
if [[ -n "$IDLE_EVENTS_FILE" ]]; then
rm -f "$IDLE_EVENTS_FILE" 2>/dev/null || true
fi
if $OWN_SERVER && [[ -n "$SERVER_PID" ]] && kill -0 "$SERVER_PID" 2>/dev/null; then
log "Stopping opencode server (PID $SERVER_PID)..."
kill "$SERVER_PID" 2>/dev/null
wait "$SERVER_PID" 2>/dev/null || true
elif ! $OWN_SERVER; then
log "Leaving existing OpenCode server running (not managed by this script)"
fi
}
handle_sigint() {
local now
now=$(date +%s)
# Second Ctrl+C within 2 seconds → force-quit
if $STOP_LOOP && (( now - LAST_SIGINT <= 2 )); then
printf "\nForce quit!\n"
[[ -n "$CURL_PID" ]] && kill "$CURL_PID" 2>/dev/null || true
cleanup
exit 130
fi
# First Ctrl+C → graceful stop after current request
STOP_LOOP=true
LAST_SIGINT=$now
printf "\nWill stop after the current request completes. Ctrl+C again within 2s to force-quit.\n"
}
trap handle_sigint INT
trap cleanup EXIT
# ── Idle-event tracking ───────────────────────────────────────────────────
init_idle_tracking() {
IDLE_EVENTS_FILE="/tmp/opencode-builder-idle-events-${SESSION_ID}.txt"
: > "$IDLE_EVENTS_FILE" 2>/dev/null || true
}
record_idle_event() {
local now
now=$(date +%s)
echo "$now" >> "$IDLE_EVENTS_FILE"
}
count_idle_events_last_minute() {
local now cutoff
now=$(date +%s)
cutoff=$((now - 60))
if [[ -f "$IDLE_EVENTS_FILE" ]]; then
awk -v c="$cutoff" '$1 > c {count++} END {print count+0}' "$IDLE_EVENTS_FILE" 2>/dev/null
else
echo 0
fi
}
# ── Session lifecycle ─────────────────────────────────────────────────────
create_session() {
log "Creating session..."
SESSION_ID=$(
curl -sf -X POST "${BASE}/session" \
-H "Content-Type: application/json" \
-d '{"title":"auto-agents"}' \
| jq -r '.id'
) || die "Failed to create session"
[[ -n "$SESSION_ID" && "$SESSION_ID" != "null" ]] || die "Session ID is empty or null"
log "Session ID: ${SESSION_ID}"
init_idle_tracking
}
delete_session() {
local sid="$1"
if [[ -n "$sid" ]]; then
curl -sf -X DELETE "${BASE}/session/${sid}" >/dev/null 2>&1 || true
fi
}
restart_session() {
log "Session appears broken (>${MAX_IDLE_PER_MINUTE} idle events/min). Restarting..."
local old_sid="$SESSION_ID"
SESSION_ID=""
N=0
delete_session "$old_sid"
create_session
send_message "$INIT_BODY"
log "Initial prompt complete after restart."
}
# ── Question handling ─────────────────────────────────────────────────────
# Queries the OpenCode /question endpoint and rejects any pending questions
# that belong to the supplied session. Returns the number of questions that
# were successfully dismissed.
dismiss_pending_questions() {
local sid="$1"
local questions_json dismissed qid
questions_json=$(curl -sf "${BASE}/question" 2>/dev/null) || return 1
dismissed=0
for qid in $(echo "$questions_json" | jq -r --arg sid "$sid" '.[] | select(.sessionID == $sid) | .id'); do
log "Dismissing question $qid"
if curl -sf -X POST "${BASE}/question/${qid}/reject" >/dev/null 2>&1; then
dismissed=$((dismissed + 1))
fi
done
echo "$dismissed"
}
# ── Preflight checks ─────────────────────────────────────────────────────
for cmd in curl jq opencode; do
command -v "$cmd" &>/dev/null || die "'$cmd' is required but not found in PATH"
done
# ── Start the opencode server ─────────────────────────────────────────────
# Check if OpenCode server is already running
if check_opencode_running; then
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ ⚠️ WARNING: OpenCode server already running on ${HOST}:${PORT}"
echo "│ 🔗 Connecting to existing server instead of starting new one"
echo "│ 💡 The script will NOT manage this server's lifecycle"
echo "└─────────────────────────────────────────────────────────────────┘"
echo ""
log "Using existing OpenCode server at ${HOST}:${PORT}"
OWN_SERVER=false
else
# Start our own server as before
log "Starting opencode server on ${HOST}:${PORT}..."
opencode serve --port "$PORT" --hostname "$HOST" &
SERVER_PID=$!
OWN_SERVER=true
log "Waiting for server to become healthy (up to ${HEALTH_TIMEOUT}s)..."
healthy=false
for (( i = 1; i <= HEALTH_TIMEOUT; i++ )); do
if curl -sf "${BASE}/global/health" >/dev/null 2>&1; then
healthy=true
break
fi
kill -0 "$SERVER_PID" 2>/dev/null || die "Server process exited unexpectedly"
sleep 1
done
$healthy || die "Server did not become healthy within ${HEALTH_TIMEOUT}s"
log "Server is ready."
fi
if [[ "$SERVER_ONLY" == "1" || "$SERVER_ONLY" == "true" || "$SERVER_ONLY" == "yes" ]]; then
log "Server-only mode enabled; not launching auto-agents or LLM supervisors."
log "Use tools/dispatch_review.py and tools/dispatch_implementer.py as the deterministic dispatchers."
while ! $STOP_LOOP; do
sleep 5
done
exit 0
fi
# ── Create a session ──────────────────────────────────────────────────────
create_session
# ── send_message ──────────────────────────────────────────────────────────
# Sends a JSON body to POST /session/:id/message and blocks until the LLM
# responds. curl is run in a subprocess that ignores SIGINT (SIG_IGN is
# inherited across exec), so a single Ctrl+C cannot kill the in-flight
# request. The parent re-waits whenever its own trap handler interrupts
# the `wait` builtin.
send_message() {
local body=$1
( trap '' INT
exec curl -sf -X POST "${BASE}/session/${SESSION_ID}/message" \
-H "Content-Type: application/json" \
-d "$body" \
-o /dev/null
) &
CURL_PID=$!
# Keep waiting until curl actually exits — `wait` can return early when
# our SIGINT handler fires, so we loop on kill -0 to re-wait.
while kill -0 "$CURL_PID" 2>/dev/null; do
wait "$CURL_PID" 2>/dev/null || true
done
CURL_PID=""
}
# ── Send the initial prompt ──────────────────────────────────────────────
INIT_BODY=$(jq -nc --arg a "$AGENT" --arg t "$PROMPT" \
'{agent: $a, parts: [{type: "text", text: $t}]}')
log "Sending initial prompt (agent: ${AGENT})..."
log "Prompt: ${PROMPT}"
echo ""
send_message "$INIT_BODY"
log "Initial prompt complete."
# ── Continue loop ─────────────────────────────────────────────────────────
CONT_BODY=$(jq -nc --arg a "$AGENT" '{agent: $a, parts: [{type: "text", text: "continue with your mainloop, monitor the supervisors, keep them alive, and sleep in an infinite loop."}]}')
while ! $STOP_LOOP; do
N=$((N + 1))
# Check idle-event rate in the last minute
idle_count=$(count_idle_events_last_minute)
if (( idle_count > MAX_IDLE_PER_MINUTE )); then
restart_session
continue
fi
log "── iteration #${N} ──"
# Inspect messages: if a question is waiting for this session, dismiss it;
# otherwise issue the standard continue command.
dismissed=0
if dismissed_raw=$(dismiss_pending_questions "$SESSION_ID"); then
dismissed="$dismissed_raw"
fi
if (( dismissed > 0 )); then
log "Dismissed ${dismissed} pending question(s)."
record_idle_event
else
send_message "$CONT_BODY"
log "continue #${N} complete."
record_idle_event
fi
done
echo ""
log "Loop stopped. Shutting down."
exit 0