Files
cleveragents-core/tools/launch_fork.sh
T
drew eb01eb0172 feat(controller): dual-mode launcher (fork/prod) + DB-mode validator
Adds the operator surface for switching the controller pipeline between
the personal fork (drew/cleveragents-core) and the canonical repo
(cleveragents/cleveragents-core) via a MODE env + --prod CLI flag,
backed by safety primitives that make a wrong-mode launch loud rather
than silent.

run-controller-state-machine-pipeline.sh: --prod flag and MODE env
(primary home: .devcontainer/.env) select fork vs prod. After resolving
MODE, the launcher auto-sources the matching overlay file
(.devcontainer/.env.{fork,prod}) and asserts MODE didn't drift during
the source step. The drift assertion uses a readonly snapshot under an
obscure variable name so a stray ``MODE=fork`` in .env.prod aborts the
launch with a clear bash error rather than silently demoting the run.
CONTROLLER_RUN_DIR_ROOT now overrides the trial /tmp path so prod can
use a persistent /var/lib/cleveragents/run dir.

tools/launch_prod.sh (new): sibling to launch_fork.sh with the opposite
safety primitive — affirmative GET /repos/{owner}/{repo} that asserts
the target is non-fork, exists, isn't archived, and the bot has push.
On any failure, no env is exported. Honors HAL_* aliases for parity
with launch_fork.sh and prints a hard-to-miss PROD-MODE banner.

tools/controller/deploy/validate_db_mode.py (new): stamps a _mode_marker
table on each SQLite db (controller DB + telemetry cache) on first use,
asserts a match on every subsequent launch, and moves mismatched files
aside as <name>.<prior-mode>.bak.<ts> — never deletes. The --adopt flag
lets an operator grandfather in already-good pre-marker data without
losing history. Wired into the launcher's startup sequence before
OpenCode and the master start.

tools/_cache_path.py (new): single source of truth for the per-(owner,
repo) Forgejo cache file convention. .opencode/telemetry/server.py and
the launcher both delegate here so the dual-source-truth drift risk is
eliminated. tools/_pipeline_cache.py and tools/controller/db/models.py
documented as not owning the _mode_marker table so future migrations
leave it alone.

.opencode/telemetry/server.py: hosts the llm_activity scraper as a
background subprocess thread (60s cadence, --since-hours 1 in steady
state, full backfill on first tick). Re-homes the cost-telemetry data
path after the pr_state_warmer was retired by the controller migration
— without this the Cost tab freezes when the warmer's loop is gone.
Subprocess (not in-process) for isolation; failures swallowed.

opencode.json: local-claude provider's baseURL now reads
{env:LOCAL_PROXY_URL} instead of the literal http://127.0.0.1:3456/v1,
matching the apiKey pattern already in use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:25:24 -04:00

474 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
# tools/launch_fork.sh — Pin fork-mode env for the auto-agents pipeline.
#
# Why this exists
# ---------------
#
# The auto-agents pipeline has three complementary host-level entry
# points:
#
# 1. `scripts/dispatchers-launcher.sh` — runs the deterministic
# Python dispatchers (`tools/dispatch_review.py` and
# `tools/dispatch_implementer.py`) that own all reviewer /
# implementer orchestration. They read `FORGEJO_*` env vars
# directly and invoke the LLM workers (`pr-review-worker`,
# `implementation-worker`) per work item via OpenCode HTTP.
# These daemons replaced the legacy `pr-review-supervisor` /
# `implementation-supervisor` LLM agents that were
# decommissioned 2026-05-09.
#
# 2. `scripts/opencode-builder.sh` — boots the OpenCode server (or
# attaches to one already running on `127.0.0.1:4096`) and starts
# the `auto-agents` watchdog session. The watchdog reads
# dispatcher heartbeat files and logs WARNINGs; it does NOT
# restart anything. The OpenCode server it boots is also what
# the dispatchers above use for their HTTP worker invocations.
# Pass `OPENCODE_BUILDER_SERVER_ONLY=1` to skip the watchdog
# session and run server-only.
#
# 3. `tools/merge_drive.py` — the deterministic merge driver that
# enforces the train-build CI invariant. It reads `FORGEJO_OWNER`,
# `FORGEJO_REPO`, `FORGEJO_API_BASE`, and `GITEA_TOKEN` directly.
#
# Both entry points fall through to the canonical
# `cleveragents/cleveragents-core` when env vars aren't set, so a
# fork-mode test launched without a preamble will SILENTLY operate
# against the canonical repo. This script is the source-able preamble
# that pins everything to the operator's fork in one place.
#
# What gets exported (canonical names per auto-agents.md)
# -------------------------------------------------------
#
# Required by the auto-agents env contract:
# GIT_USER_NAME — git user.name for HAL9000's commits
# GIT_USER_EMAIL — git user.email for HAL9000's commits
# FORGEJO_PAT — HAL9000 PAT (LLM worker + driver identity)
# FORGEJO_USERNAME — HAL9000's login (resolved via whoami)
# FORGEJO_PASSWORD — HAL9000 web password (CI-log scraping)
# FORGEJO_REVIEWER_PAT — HAL9001 PAT (umbrella-PR auto-approver)
# FORGEJO_REVIEWER_USERNAME — HAL9001's login (resolved via whoami)
# FORGEJO_REVIEWER_PASSWORD — HAL9001 web password
#
# Driver-side compat:
# GITEA_TOKEN — synthesized from FORGEJO_PAT for the
# deterministic Python tools that still
# read this name (merge_drive.py,
# verify_invariant.py, audit_branch_protection.py,
# setup_*.py, etc.). They authenticate as
# HAL9000 — same identity as production runs.
# Pre-export GITEA_TOKEN before sourcing if
# you want to use a different identity (e.g.
# drew's admin PAT for one-time setup).
#
# Optional / driver-side:
# FORGEJO_URL — base URL (default: https://git.cleverthis.com)
# FORGEJO_OWNER — fork owner (default: drew)
# FORGEJO_REPO — fork repo (default: cleveragents-core)
# FORGEJO_API_BASE — derived from FORGEJO_URL
# FORGEJO_DEFAULT_BRANCH — default: master
# FORGEJO_ORG — org-label fallback only (default: cleveragents)
# CA_MAX_PARALLEL_WORKERS — historical knob; the deterministic
# dispatchers don't read it today, but
# the value is still re-exported so any
# future opt-in worker-pool implementation
# picks it up without operator changes.
# (Originally a supervisor-pool size for
# the now-decommissioned LLM supervisors.)
#
# Resolution priority for each name:
# 1. value already in the environment
# 2. canonical name in .devcontainer/.env or .env
# 3. HAL_* alias in .devcontainer/.env (PAT/password)
# 4. derive via API whoami (USERNAME only) or hard-coded default
#
# Usage
# -----
#
# source tools/launch_fork.sh
# tools/preflight_phase01.sh
# .venv/bin/python3 tools/merge_drive.py --status
# scripts/opencode-builder.sh
#
# # Override fork target:
# FORK_OWNER=otheruser FORK_REPO=myfork source tools/launch_fork.sh
#
# Safety guarantees
# -----------------
#
# - Validates the fork via a single read-only `GET /repos/<owner>/<repo>`
# (must exist, must be a fork, calling user must have push).
# - All resolved values stay in LOCAL variables until validation passes.
# On any failure, the parent shell's environment is left untouched —
# no FORGEJO_*, no GIT_*, no GITEA_TOKEN leakage.
# - A `printenv FORGEJO_PASSWORD | head -c 4` style probe in the parent
# shell is the operator's audit primitive; values are never echoed
# verbatim in the banner.
#
# Exit / return codes
# -------------------
#
# 0 — env exported, fork validated
# 1 — validation failed; nothing exported
# 2 — argument error / missing prerequisite
# Detect sourced vs executed once, up front. ${BASH_SOURCE[0]} == ${0}
# means "executed directly"; anything else means "sourced".
__lf_sourced=0
if [[ "${BASH_SOURCE[0]:-}" != "${0}" ]]; then
__lf_sourced=1
fi
# Wrap all logic in a function so we can `return` at any point without
# falling through into the export block. The function only exports
# anything on its successful path; failure paths print an error and
# return non-zero.
__lf_main() {
local script_path="${BASH_SOURCE[0]:-$0}"
local repo_root
repo_root="$(cd "$(dirname "$script_path")/.." && pwd)"
# ─── Defaults (overridable via FORK_* env vars) ───────────────────
local fork_owner="${FORK_OWNER:-drew}"
local fork_repo="${FORK_REPO:-cleveragents-core}"
local fork_url="${FORK_URL:-https://git.cleverthis.com}"
local fork_api_base="${FORK_API_BASE:-${fork_url%/}/api/v1}"
local fork_default_branch="${FORK_DEFAULT_BRANCH:-master}"
# ─── Step 0 — pick a python3 we can rely on ───────────────────────
local py
if [[ -x "$repo_root/.venv/bin/python3" ]]; then
py="$repo_root/.venv/bin/python3"
else
py="$(command -v python3 || true)"
fi
if [[ -z "$py" ]]; then
echo "ERROR: python3 not found (needed for fork validation and JSON parsing)" >&2
return 2
fi
# ─── Step 1 — small .env reader ───────────────────────────────────
# Reads KEY=value or KEY="value" from the fork-mode overlay file
# (`.devcontainer/.env.fork` preferred, repo-root `.env.fork` as
# fallback), then the shared env files. First match wins. Single-quoted
# values are also handled.
__lf_resolve_from_env() {
local key="$1"
local val=""
for f in \
"$repo_root/.devcontainer/.env.fork" \
"$repo_root/.env.fork" \
"$repo_root/.devcontainer/.env" \
"$repo_root/.env"; do
[[ -f "$f" ]] || continue
val="$(awk -v k="$key" -F= '
$0 ~ "^[[:space:]]*"k"[[:space:]]*=" {
# Strip key + leading whitespace and `=`
sub("^[[:space:]]*"k"[[:space:]]*=[[:space:]]*", "")
# Strip optional surrounding quotes
if (substr($0,1,1) == "\"" || substr($0,1,1) == "'\''") {
q = substr($0,1,1)
sub("^"q, "")
sub(q".*$", "")
} else {
# Unquoted: stop at first whitespace or `#`
sub(/[[:space:]#].*$/, "")
}
print
exit
}' "$f" 2>/dev/null || true)"
if [[ -n "$val" ]]; then
printf '%s' "$val"
return 0
fi
done
return 1
}
# Resolve <CANONICAL_NAME> with fallback to one or more aliases.
# Echoes the resolved value and returns 0; returns 1 if nothing found.
__lf_resolve_with_aliases() {
local canonical="$1"; shift
local existing="${!canonical:-}"
if [[ -n "$existing" ]]; then
printf '%s' "$existing"
return 0
fi
local v
if v="$(__lf_resolve_from_env "$canonical")"; then
printf '%s' "$v"
return 0
fi
local alias
for alias in "$@"; do
if v="$(__lf_resolve_from_env "$alias")"; then
printf '%s' "$v"
return 0
fi
done
return 1
}
# Helper: resolve a Forgejo login from a PAT via /api/v1/user.
__lf_whoami() {
local token="$1"
[[ -n "$token" ]] || return 1
curl -sf -H "Authorization: token $token" "$fork_api_base/user" 2>/dev/null \
| "$py" -c 'import json,sys
try:
print(json.load(sys.stdin).get("login") or "")
except Exception:
pass' 2>/dev/null || true
}
# ─── Step 2 — resolve every value into local variables ────────────
# Primary bot (HAL9000) PAT + password.
local resolved_pat
resolved_pat="$(__lf_resolve_with_aliases FORGEJO_PAT HAL_9000_API_KEY)" || resolved_pat=""
local resolved_pwd
resolved_pwd="$(__lf_resolve_with_aliases FORGEJO_PASSWORD HAL_9000_FORGEJO_PASSWORD)" || resolved_pwd=""
# GITEA_TOKEN is synthesized from FORGEJO_PAT (HAL9000) by default — that's
# the production-fidelity identity for the deterministic Python tools.
# Operators who want to use a different identity (e.g. drew's admin PAT
# for one-time fork setup) can pre-export GITEA_TOKEN before sourcing.
local resolved_gitea="${GITEA_TOKEN:-$resolved_pat}"
if [[ -z "$resolved_gitea" ]]; then
echo "ERROR: cannot resolve a token for GITEA_TOKEN — neither GITEA_TOKEN" >&2
echo " nor FORGEJO_PAT (or HAL_9000_API_KEY) is set." >&2
return 2
fi
# Reviewer bot (HAL9001) PAT + password.
local resolved_rev_pat
resolved_rev_pat="$(__lf_resolve_with_aliases FORGEJO_REVIEWER_PAT HAL_9001_API_KEY)" || resolved_rev_pat=""
local resolved_rev_pwd
resolved_rev_pwd="$(__lf_resolve_with_aliases FORGEJO_REVIEWER_PASSWORD HAL_9001_FORGEJO_PASSWORD)" || resolved_rev_pwd=""
# Git identity for HAL9000's commits. Defaults match Jeff's confirmed
# production env (`CleverThis` / `hal9000@cleverthis.com`) — also the
# author of every existing HAL9000 commit on the canonical repo.
local resolved_git_name
resolved_git_name="$(__lf_resolve_with_aliases GIT_USER_NAME)" || resolved_git_name="CleverThis"
local resolved_git_email
resolved_git_email="$(__lf_resolve_with_aliases GIT_USER_EMAIL)" || resolved_git_email="hal9000@cleverthis.com"
# CA_MAX_PARALLEL_WORKERS is optional; we pass it through if it exists.
local resolved_workers
resolved_workers="$(__lf_resolve_with_aliases CA_MAX_PARALLEL_WORKERS)" || resolved_workers=""
# FORGEJO_USERNAME / FORGEJO_REVIEWER_USERNAME come from .env or whoami.
local resolved_user
resolved_user="$(__lf_resolve_with_aliases FORGEJO_USERNAME)" || resolved_user=""
if [[ -z "$resolved_user" && -n "$resolved_pat" ]]; then
resolved_user="$(__lf_whoami "$resolved_pat")"
fi
local resolved_rev_user
resolved_rev_user="$(__lf_resolve_with_aliases FORGEJO_REVIEWER_USERNAME)" || resolved_rev_user=""
if [[ -z "$resolved_rev_user" && -n "$resolved_rev_pat" ]]; then
resolved_rev_user="$(__lf_whoami "$resolved_rev_pat")"
fi
# ─── Step 3 — required-var validation ─────────────────────────────
# The auto-agents agent will exit on startup if any of these are
# missing, so we surface that failure here instead of letting it crash
# 30 minutes into a run.
local -a missing=()
[[ -n "$resolved_pat" ]] || missing+=("FORGEJO_PAT (or HAL_9000_API_KEY)")
[[ -n "$resolved_pwd" ]] || missing+=("FORGEJO_PASSWORD (or HAL_9000_FORGEJO_PASSWORD)")
[[ -n "$resolved_user" ]] || missing+=("FORGEJO_USERNAME (no whoami match — PAT invalid?)")
[[ -n "$resolved_rev_pat" ]] || missing+=("FORGEJO_REVIEWER_PAT (or HAL_9001_API_KEY)")
[[ -n "$resolved_rev_pwd" ]] || missing+=("FORGEJO_REVIEWER_PASSWORD (or HAL_9001_FORGEJO_PASSWORD)")
[[ -n "$resolved_rev_user" ]] || missing+=("FORGEJO_REVIEWER_USERNAME (no whoami match — reviewer PAT invalid?)")
if (( ${#missing[@]} > 0 )); then
echo "ERROR: required env vars per .opencode/agents/auto-agents.md missing or unresolvable:" >&2
local m
for m in "${missing[@]}"; do
echo " - $m" >&2
done
echo "Refusing to launch fork-mode pipeline; nothing exported." >&2
return 1
fi
# ─── Step 4 — validate fork repo + driver permission ─────────────
local validation_json
if ! validation_json="$(
GITEA_TOKEN="$resolved_gitea" \
FORK_OWNER="$fork_owner" \
FORK_REPO="$fork_repo" \
FORK_API_BASE="$fork_api_base" \
"$py" - <<'PY'
import json
import os
import sys
import urllib.error
import urllib.request
token = os.environ["GITEA_TOKEN"]
owner = os.environ["FORK_OWNER"]
repo = os.environ["FORK_REPO"]
base = os.environ["FORK_API_BASE"].rstrip("/")
url = f"{base}/repos/{owner}/{repo}"
req = urllib.request.Request(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
body = json.loads(resp.read())
except urllib.error.HTTPError as e:
msg = e.read().decode("utf-8", "replace")[:200]
print(f"ERROR: GET {url} returned HTTP {e.code}: {msg}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"ERROR: GET {url} failed: {e!r}", file=sys.stderr)
sys.exit(1)
problems: list[str] = []
if not body.get("fork"):
problems.append(
f"target {owner}/{repo} is not a fork (fork={body.get('fork')!r}); "
"refusing to launch fork-mode pipeline against a non-fork."
)
parent_full = (body.get("parent") or {}).get("full_name")
perms = body.get("permissions") or {}
if not perms.get("push"):
problems.append(
f"calling user lacks push permission on {owner}/{repo} "
f"(permissions={perms!r}); driver cannot push train branches."
)
if problems:
for p in problems:
print(f"ERROR: {p}", file=sys.stderr)
sys.exit(1)
print(json.dumps({
"owner": owner,
"repo": repo,
"full_name": body.get("full_name"),
"parent_full_name": parent_full,
"default_branch": body.get("default_branch"),
}))
PY
)"; then
echo "ERROR: fork validation failed; nothing exported." >&2
return 1
fi
# ─── Step 5 — commit resolved values to the parent shell ─────────
# Driver-side
export GITEA_TOKEN="$resolved_gitea"
export FORGEJO_OWNER="$fork_owner"
export FORGEJO_REPO="$fork_repo"
export FORGEJO_URL="$fork_url"
export FORGEJO_API_BASE="$fork_api_base"
export FORGEJO_DEFAULT_BRANCH="$fork_default_branch"
export FORGEJO_ORG="${FORGEJO_ORG:-cleveragents}"
# Auto-agents env contract (8 required + 1 optional)
export GIT_USER_NAME="$resolved_git_name"
export GIT_USER_EMAIL="$resolved_git_email"
export FORGEJO_PAT="$resolved_pat"
export FORGEJO_USERNAME="$resolved_user"
export FORGEJO_PASSWORD="$resolved_pwd"
export FORGEJO_REVIEWER_PAT="$resolved_rev_pat"
export FORGEJO_REVIEWER_USERNAME="$resolved_rev_user"
export FORGEJO_REVIEWER_PASSWORD="$resolved_rev_pwd"
if [[ -n "$resolved_workers" ]]; then
export CA_MAX_PARALLEL_WORKERS="$resolved_workers"
fi
# G11 (2026-05-15): default the implementer estimator ON for fork-mode runs
# so the dispatcher exercises the new confidence-driven tier selection
# rather than the Tier-0 short-circuit. Operators can opt out for a single
# cycle by pre-exporting IMPLEMENTER_ESTIMATOR_ENABLED=0 before sourcing.
export IMPLEMENTER_ESTIMATOR_ENABLED="${IMPLEMENTER_ESTIMATOR_ENABLED:-1}"
# Merge driver is normally silent on idle cycles (only WARN/ERROR surface
# at default INFO level). Fork-mode runs are operator-driven debugging
# sessions where the cycle-by-cycle "what did the driver check / why did
# it skip this PR" trace is the load-bearing signal. Default to DEBUG so
# the loop-merge log is useful out of the box; pre-export
# MERGE_DRIVER_LOG_LEVEL=INFO before sourcing if you want production-style
# quiet output for a one-off test.
export MERGE_DRIVER_LOG_LEVEL="${MERGE_DRIVER_LOG_LEVEL:-DEBUG}"
# Phase 2 of the .drew/planning/fix list_prs_by_filter.md cutover
# (2026-05-16): reviewer dispatcher uses the Python-side delta-cached
# PR enumeration (`_pr_classification_cache.refresh_then_filter`)
# for the 5 work-group filters instead of subprocessing the
# `list_prs_*.ts` scripts. Eliminates the recurring 120s
# subprocess.TimeoutExpired on `list_prs_missing_ci_checks.ts`.
# Default-ON in fork-mode; pre-export `=0` for emergency rollback
# to the legacy TS-script path.
export REVIEW_DISPATCHER_USE_PYTHON_FILTERS="${REVIEW_DISPATCHER_USE_PYTHON_FILTERS:-1}"
# ─── Step 6 — banner ─────────────────────────────────────────────
local parent_full
parent_full="$(printf '%s' "$validation_json" | "$py" -c '
import json,sys
d=json.load(sys.stdin)
print(d.get("parent_full_name") or "(none)")')"
# Identity check on the synthesized GITEA_TOKEN (typically same as
# FORGEJO_PAT, but operators can override with an admin PAT).
local gitea_login
gitea_login="$(__lf_whoami "$resolved_gitea")"
local gitea_note=""
if [[ "$resolved_gitea" == "$resolved_pat" ]]; then
gitea_note="(== FORGEJO_PAT)"
else
gitea_note="(operator-overridden)"
fi
echo "=================================================================="
echo " Fork-mode env locked for the auto-agents pipeline"
echo " ----------------------------------------------------------------"
echo " Repo target"
echo " FORGEJO_OWNER = $FORGEJO_OWNER"
echo " FORGEJO_REPO = $FORGEJO_REPO"
echo " FORGEJO_URL = $FORGEJO_URL"
echo " FORGEJO_API_BASE = $FORGEJO_API_BASE"
echo " FORGEJO_DEFAULT_BRANCH = $FORGEJO_DEFAULT_BRANCH"
echo " FORGEJO_ORG = $FORGEJO_ORG (org-label fallback only)"
echo " Fork parent = $parent_full"
echo
echo " Identities (production-fidelity: HAL9000 + HAL9001 only)"
echo " FORGEJO_PAT -> ${FORGEJO_USERNAME} (driver + LLM workers, ${#FORGEJO_PAT} chars)"
echo " FORGEJO_PASSWORD = set (${#FORGEJO_PASSWORD} chars)"
echo " FORGEJO_REVIEWER_PAT -> ${FORGEJO_REVIEWER_USERNAME} (umbrella approver, ${#FORGEJO_REVIEWER_PAT} chars)"
echo " FORGEJO_REVIEWER_PASSWORD = set (${#FORGEJO_REVIEWER_PASSWORD} chars)"
echo " GITEA_TOKEN -> ${gitea_login:-<unknown>} $gitea_note"
echo
echo " Git author for HAL9000 commits"
echo " GIT_USER_NAME = $GIT_USER_NAME"
echo " GIT_USER_EMAIL = $GIT_USER_EMAIL"
if [[ -n "${CA_MAX_PARALLEL_WORKERS:-}" ]]; then
echo
echo " Parallelism"
echo " CA_MAX_PARALLEL_WORKERS = $CA_MAX_PARALLEL_WORKERS"
fi
echo "=================================================================="
return 0
}
__lf_main "$@"
__lf_rc=$?
if [[ $__lf_sourced -eq 0 && $__lf_rc -eq 0 ]]; then
echo
echo "WARNING: this script was EXECUTED, not sourced. The exports above"
echo "will not persist into your shell. Re-run as:"
echo
echo " source tools/launch_fork.sh"
echo
fi
# Bash treats `return` outside a function as an error when EXECUTED, so
# we have to branch on sourced-vs-executed here. When sourced we MUST
# `return` (an `exit` would terminate the parent shell). When executed
# we MUST `exit`.
if [[ $__lf_sourced -eq 1 ]]; then
return $__lf_rc
else
exit $__lf_rc
fi