a6986008ee
T5-1 reviewer feedback rendered full-body to the implementer
T5-4/9 implementer dispute path — dispute-at-any-tier with per-tier cap,
OPERATOR_ATTENTION state on stalemate, pr-review-worker-dispute agent
T5-5 reviewer BLOCKING ISSUE EVIDENCE RULE + 5-step validation
T5-7 merge step split into a singleton process — impl/review masters write
APPROVED and stop; merge_drive owns APPROVED -> MERGING -> MERGED
T5-10 merge process is fully deterministic; base conflicts bounce to the
controller's CONFLICT_RESOLVING (LLM); conflict_drive sidecar retired
T5-11 implementer fast success path — verified-clean outcome so a no-op
after conflict resolution doesn't force busywork
T5-12 conflict-resolver permissions fixed across all paths (/tmp/** glob)
T5-13 conflict-resolver PR-intent prehydration (title/body/comments)
Adds tools/_controller_db_bridge.py so merge_drive reads the controller DB
directly (Option B), plus APPROVED + OPERATOR_ATTENTION states, the
dispute/verified-clean events, and the V1 contract fields backing them.
Reviewer model: baseline -> sonnet, dispute -> opus.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
247 lines
9.7 KiB
Python
247 lines
9.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').")
|
|
# T5-7 (2026-05-19): the controller-internal MERGING handler was
|
|
# retired by T5-10 in favor of ``tools/merge_drive.py`` acting as
|
|
# the singleton merge process via the controller-DB bridge. Run
|
|
# exactly one ``merge_drive.py`` per (owner, repo) with
|
|
# ``CLEVERAGENTS_DB_URL`` set; this controller process never
|
|
# touches MERGING/APPROVED workflows. The pre-T5-10 ``--enable-merging``
|
|
# flag was removed to prevent operators from accidentally racing
|
|
# two writers against the same Forgejo merge POST.
|
|
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)
|
|
|
|
# T5-7 + T5-10: controller-internal MERGING handler retired in
|
|
# favor of tools/merge_drive.py acting as the singleton merge
|
|
# process via the controller-DB bridge. ``merging_args=None``
|
|
# disables the master's MERGING handler entirely.
|
|
merging_args = None
|
|
logger.info(
|
|
"MERGING handler DISABLED (T5-7/T5-10): merge_drive.py owns "
|
|
"the merge step via the controller-DB bridge; this master "
|
|
"writes workflows to APPROVED and stops."
|
|
)
|
|
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=merging_args,
|
|
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())
|