Files
cleveragents-core/tools/controller/master/__main__.py
T
drew 3ca794be75 feat(controller): autonomous CI status polling — closes the last trial gap
The Phase 2 trial previously required operator-intervention SQL to
advance workflows from AWAITING_CI → REVIEWING (no automated CI
status polling). This commit wires the missing tick so the trial
runs end-to-end without manual help.

Components:
- ``master/forgejo_http.py``: new ``get_ci_status`` callback wraps
  Forgejo's ``/commits/{sha}/status`` combined-status endpoint;
  added to ``ForgejoCallbacks``.
- ``master/ci_status_poll.py`` (NEW): ``run_ci_status_poll_tick``
  scans AWAITING_CI workflows, fetches CI status keyed on the
  latest implementer attempt's ``head_sha_after``, and applies
  state transitions via ``apply_event``. TOCTOU-defended UPDATE
  (``WHERE current_state='AWAITING_CI'``) + per-row exception
  isolation.
- ``master/loop.py``: new ``ci_status_poll_args=(owner, repo,
  get_ci_status)`` kwarg + ``ci_status_poll_interval_s`` config
  (default 60s) + ``MasterTickReport.ci_status_poll`` field.
- ``master/__main__.py``: threads ``callbacks.get_ci_status`` into
  the loop.

State mapping (Forgejo combined-status state → event):
- success / neutral / skipped / warning → ci_green → REVIEWING
- failure / error / cancelled / timed_out / stale →
  ci_red_retry_same_tier → IMPLEMENTING
- pending / queued / in_progress / action_required → no-op (wait)
- None / unknown / fetch failure → no-op (transient)

The ``ci_polling_exhausted`` timeout (default 2h) remains as the
safety net for CI that genuinely never reports.

Tests (+14 in test_master_ci_status_poll.py):
- Happy paths (success→green, failure→red, pending→wait)
- Error paths (callback raises; workflow without head_sha)
- Event row shape (event_type='ci-green'/'ci-red', reason payload)
- Extended state mapping (cancelled, neutral, in_progress)
- Other-repo isolation
- LoopIntegration end-to-end via master_main_loop with safety timer

RUNBOOK updated: removed the manual SQL workaround; added the
autonomous CI poll's tunables.

Total: 726 controller tests pass (+14 net), 0 regressions.

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

229 lines
8.7 KiB
Python

"""Master controller entry point — runnable via systemd.
Usage:
python -m tools.controller.master \
--owner=cleveragents \
--repo=cleveragents-core \
--opencode-url=http://localhost:4096
Reads ``CLEVERAGENTS_DB_URL`` (default in-memory SQLite — operators
MUST set this for production). Wires the production callbacks via
``forgejo_http.build_callbacks`` + ``_claim_runtime``.
Per plan v9 the master is a singleton per (owner, repo). For v1 the
singleton constraint is enforced by deployment (one systemd unit per
repo). Lease-based multi-master HA is deferred per the plan.
"""
from __future__ import annotations
import argparse
import logging
import os
import signal
import sys
import threading
from ..db import build_engine, create_all
from .backfill import run_startup_backfill
from .discovery import run_discovery
from .forgejo_cfg import ControllerForgejoConfig, from_environment
from .forgejo_http import build_callbacks
from .loop import MasterConfig, master_main_loop
from .prefetch import PrefetchDataCallbacks, make_prefetch_callback
def _setup_logging(level: str) -> None:
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
def build_cfg_stub(owner: str, repo: str) -> ControllerForgejoConfig:
"""Build the controller-owned ForgejoConfig from env + the
deployment-specified (owner, repo).
Phase 1k+: replaced sys.path injection + ``tools/_mcp_common.ForgejoCfg``
mutation with a tight ``ControllerForgejoConfig`` owned by
``controller/master/forgejo_cfg.py``. The deps now flow inward
(controller depends on nothing in ``tools/*`` other than the
HTTP helper imports inside ``forgejo_http.py``).
"""
return from_environment(owner, repo)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
parser.add_argument("--owner", required=True,
help="Forgejo owner (org/user) — e.g. cleveragents")
parser.add_argument("--repo", required=True,
help="Forgejo repo name — e.g. cleveragents-core")
parser.add_argument("--opencode-url", default=os.environ.get(
"CONTROLLER_OPENCODE_URL", "http://localhost:4096",
), help="OpenCode server URL")
parser.add_argument("--log-level", default=os.environ.get(
"CONTROLLER_LOG_LEVEL", "INFO",
))
parser.add_argument("--tick-interval", type=float, default=None,
help="Override CONTROLLER_MASTER_TICK_INTERVAL_S")
parser.add_argument("--discovery-only-once", action="store_true",
help="Run discovery once + exit (for smoke testing)")
parser.add_argument("--skip-backfill", action="store_true",
help="Skip the startup backfill sweep "
"(for tests + warm restarts)")
parser.add_argument("--no-opt-in-label", action="store_true",
help="Disable the opt-in label gate (manage all "
"PRs/issues). DEFAULT is to manage ONLY "
"entities carrying CONTROLLER_OPT_IN_LABEL "
"(default 'controller-managed').")
args = parser.parse_args(argv)
require_opt_in_label = not args.no_opt_in_label
_setup_logging(args.log_level)
logger = logging.getLogger(__name__)
db_url = os.environ.get("CLEVERAGENTS_DB_URL")
if not db_url:
logger.error(
"CLEVERAGENTS_DB_URL not set. For production set to a "
"Postgres URL; for local dev sqlite:///./controller.db"
)
return 2
engine = build_engine(db_url)
create_all(engine)
# Wire Forgejo callbacks.
cfg = build_cfg_stub(args.owner, args.repo)
callbacks = build_callbacks(cfg)
if args.discovery_only_once:
report = run_discovery(
engine, owner=args.owner, repo=args.repo,
list_prs=callbacks.list_prs, list_issues=callbacks.list_issues,
require_opt_in_label=require_opt_in_label,
)
logger.info(
"discovery: PRs=%d issues=%d new=%d existing-skipped=%d",
report.prs_seen, report.issues_seen,
report.new_workflows, report.existing_skipped,
)
return 0
# Stop event wired to SIGTERM/SIGINT for clean systemd shutdown.
stop = threading.Event()
def _on_signal(signum, _frame):
logger.info("received signal %d; setting stop event", signum)
stop.set()
signal.signal(signal.SIGTERM, _on_signal)
signal.signal(signal.SIGINT, _on_signal)
cfg_loop = MasterConfig()
if args.tick_interval is not None:
# R-round4 P5: previously this rebuild dropped reconcile /
# ci_poll / discovery intervals — operators who passed
# --tick-interval silently reverted those to defaults. Use
# dataclasses.replace to preserve every other field.
import dataclasses as _dc
cfg_loop = _dc.replace(cfg_loop, tick_interval_s=args.tick_interval)
logger.info(
"master starting: owner=%s repo=%s opencode=%s db=%s tick=%.1fs",
args.owner, args.repo, args.opencode_url, db_url,
cfg_loop.tick_interval_s,
)
# Phase 1k++ (N6): parser-coverage check runs BEFORE backfill +
# main loop so strict-mode failure exits 2 without wasting a
# Forgejo round-trip + without dependent code paths firing. The
# check is about deployment readiness, not run-state.
from ..ci_summary_parsers._registry import validate_parser_coverage
coverage = validate_parser_coverage()
real = sum(1 for v in coverage.values() if v)
total = len(coverage)
stubs = [name for name, is_real in coverage.items() if not is_real]
if stubs:
logger.warning(
"CI parser coverage: %d/%d real (%d stub: %s). Stub-parser "
"gates fall back to raw_log_excerpt; implementers see the "
"log but not structured findings.",
real, total, len(stubs), sorted(stubs),
)
else:
logger.info("CI parser coverage: %d/%d real (no stubs)", real, total)
strict = os.environ.get(
"CONTROLLER_STRICT_PARSER_COVERAGE", "0",
).lower() in {"1", "true", "yes"}
if strict and stubs:
logger.error(
"CONTROLLER_STRICT_PARSER_COVERAGE=1 and %d stub parsers "
"remain (%s); refusing to start. Either implement them or "
"unset the env var.",
len(stubs), sorted(stubs),
)
return 2
# Run backfill BEFORE entering the main loop so existing PRs are
# known by the time the first tick fires.
if not args.skip_backfill:
try:
run_startup_backfill(
engine, owner=args.owner, repo=args.repo,
list_prs=callbacks.list_prs,
list_issues=callbacks.list_issues,
require_opt_in_label=require_opt_in_label,
)
except Exception:
logger.exception(
"startup backfill raised; continuing to main loop "
"(discovery tick will retry)"
)
if require_opt_in_label:
from .label_gate import opt_in_label_name
logger.info(
"controller opt-in label gate ENABLED: only managing "
"PRs/issues with label %r", opt_in_label_name(),
)
else:
logger.info(
"controller opt-in label gate DISABLED (--no-opt-in-label): "
"managing all PRs/issues"
)
# Phase 1k+++ (real-run): wire the production prefetch callback
# so the master loop actually schedules attempts. Without this
# the loop would tick + reconcile but never enqueue anything.
prefetch_data = PrefetchDataCallbacks(
get_pr_details=callbacks.get_pr_details,
get_pr_diff=callbacks.get_pr_diff,
list_pr_reviews=callbacks.list_pr_reviews,
list_pr_comments=callbacks.list_pr_comments,
)
prefetch_cb = make_prefetch_callback(engine, prefetch_data)
master_main_loop(
engine, config=cfg_loop, stop_event=stop,
reconciliation_args=(
args.owner, args.repo,
callbacks.get_pr_state, callbacks.get_issue_state,
{"require_opt_in_label": require_opt_in_label},
),
prefetch=prefetch_cb,
merging_args=(args.owner, args.repo, callbacks.merge_pr),
discovery_args=(
args.owner, args.repo,
callbacks.list_prs, callbacks.list_issues,
{"require_opt_in_label": require_opt_in_label},
),
ci_status_poll_args=(
args.owner, args.repo, callbacks.get_ci_status,
),
)
return 0
if __name__ == "__main__":
raise SystemExit(main())