Files
cleveragents-core/tools/dispatch_review.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

487 lines
21 KiB
Python

#!/usr/bin/env python3
"""Deterministic dispatcher for PR review workers.
Replaced the long-running ``pr-review-supervisor.md`` polling session
with a host-level Python loop. The legacy supervisor agent file was
deleted on 2026-05-09 (see ``CHANGELOG.md`` and
``docs/development/auto-agents-tier-2-3-plan.md`` for the rationale)
and this dispatcher is now the only orchestrator for reviewer work.
It keeps review *judgment* in ``pr-review-worker``; this file owns
queue polling, pre-dispatch claims, worker watchdogs, cycle telemetry,
and (since 2026-05-07) every Forgejo read and write the worker used
to make.
The worker now consumes pre-fetched PR metadata, CI status, existing
reviews, PR comments, linked issues, and the unified diff from its
prompt — all gathered by this dispatcher in a single pre-dispatch pass.
After the worker session completes, this dispatcher parses the worker's
structured-JSON verdict and POSTs the review (or Tier 1F escalation)
back to Forgejo.
The motivation is documented in CHANGELOG under "Move review submission
out of the worker (2026-05-07)".
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
from pathlib import Path
from typing import Any
_TOOLS_DIR = str(Path(__file__).resolve().parent)
if _TOOLS_DIR not in sys.path:
sys.path.insert(0, _TOOLS_DIR)
from _loader import load_sibling as _load_sibling # noqa: E402 type: ignore[import-not-found]
_dispatch = _load_sibling("_dispatch_runtime", "_dispatch_runtime.py")
_review_pipeline = _load_sibling("_review_pipeline", "_review_pipeline.py")
_review_fetch = _load_sibling("_review_fetch", "_review_fetch.py")
_claim_runtime = _load_sibling("_claim_runtime", "_claim_runtime.py")
_pr_clone = _load_sibling("_pr_clone", "_pr_clone.py")
_pr_diff = _load_sibling("_pr_diff", "_pr_diff.py")
_review_prompt_mod = _load_sibling("_review_prompt", "_review_prompt.py")
_review_context = _load_sibling("_review_context", "_review_context.py")
_block_store = _load_sibling("_block_store", "_block_store.py")
DRIVER_NAME = "dispatch_review.py"
CLAIM_KIND = "reviewer"
_logger = logging.getLogger("dispatch_review")
# Backwards-compatible aliases. The diff fetch / section rendering
# helpers live in :mod:`_pr_diff` (the Phase 0 substrate rename of
# the formerly review-only diff module so the implementer
# dispatcher can share them) and the prompt factory +
# pre-dispatch fetch coordinator in :mod:`_review_prompt` so this
# driver can stay under the project's 500-line per-file budget.
_DEFAULT_DIFF_MAX_BYTES = _pr_diff.DEFAULT_DIFF_MAX_BYTES
_DIFF_BEGIN_MARKER = _pr_diff.DIFF_BEGIN_MARKER
_DIFF_END_MARKER = _pr_diff.DIFF_END_MARKER
_DIFF_REDACTED_MARKER = _pr_diff.DIFF_REDACTED_MARKER
_fetch_pr_diff = _pr_diff.fetch_pr_diff
_fetch_pr_diff_detailed = _pr_diff.fetch_pr_diff_detailed
_build_diff_section = _pr_diff.build_diff_section
_build_diff_section_full = _pr_diff.build_diff_section_full
_build_clone_section = _pr_diff.build_clone_section
_diff_section_skipped = _pr_diff.diff_section_skipped
_OUTPUT_CONTRACT = _review_prompt_mod.OUTPUT_CONTRACT
_fetch_review_context = _review_prompt_mod.fetch_review_context
_review_prompt = _review_prompt_mod.build_review_prompt
# ─── post_session_action: parse worker JSON and POST to Forgejo ─────────────
def _build_post_session_action(group_name: str):
"""Return a closure suitable for ``WorkGroup.post_session_action``.
The closure captures ``review_type`` (derived from the group name)
so :func:`_review_pipeline.finalize_review` knows which output-
contract branch to expect. The prompt-time
``request_changes_count`` and freshly fetched ``head_sha`` are
threaded through ``item["_dispatcher_review_context"]`` (set by
:func:`_review_prompt`) so the post-session action does NOT have
to re-fetch the review list a second time.
Each WorkGroup gets its own closure (rather than a single shared
function) because the closure binds the group's review_type at
construction time. Constructing one per group is cheap (six
closures total) and keeps the call site readable.
"""
review_type = {
"addressed_changes_ci_passing": "re_review",
"addressed_changes_ci_failing": "re_review",
"no_active_review_ci_passing": "first_review",
"no_active_review_ci_failing": "first_review",
"missing_ci_checks": "ci_flag",
}[group_name]
def _action(
cfg: Any,
item: dict[str, Any],
parsed_json: dict[str, Any] | None,
raw_response: str,
terminal_state: str,
*,
session_context: Any | None = None,
**_legacy_kwargs: Any,
) -> dict[str, Any]:
# ``session_context`` is the dispatcher's standard context
# carrier (a :class:`_dispatch_runtime.SessionContext`
# dataclass). The reviewer's closure captures
# ``review_type`` / ``group_name`` at construction time so
# it does NOT need to read ``session_context.work_group_name``;
# the timing fields are already recorded by the runtime in
# the cycle archive (``worker_wallclock_seconds``), so the
# reviewer post-session hook neither needs nor re-emits them.
# The argument is still named explicitly (rather than absorbed
# into a single ``**_kwargs``) so a typo at the dispatcher's
# call site cannot silently swallow a future required field.
# ``_legacy_kwargs`` absorbs the four pre-``SessionContext``
# flat kwargs (``work_group_name`` / ``session_started_at`` /
# ``session_completed_at`` / ``session_wallclock_seconds``)
# for back-compat with direct test invocations.
del session_context # Reviewer hook does not consume the dataclass.
# Read the freshly fetched head_sha, the pre-fetched
# request_changes_count, the pre-fetched PR comments, and
# the aggregate data_complete flag from the item the prompt
# builder stamped. Falls back to listing snapshot defaults
# when the prompt builder did not run (dry-run / direct
# unit test).
# Coerce the carrier into a typed ReviewContext. Tests that
# build dict literals continue to work via the dispatch in
# :func:`_review_context.coerce_review_context`. When the
# carrier is missing entirely (dry-run / direct unit-test
# invocation that didn't run the prompt builder) we fall
# back to listing-snapshot defaults derived from ``item``.
# Strict mode is enabled so a typo at the producer site
# (``{"head_shaa": "abc"}`` instead of ``head_sha``) raises
# immediately rather than silently defaulting and breaking
# the freshness check one cycle later. Production producers
# construct ``ReviewContext`` directly so strict mode only
# validates the dict path; legacy fixtures that pass dict
# literals must use real field names.
review_context = _review_context.coerce_review_context(
item.get("_dispatcher_review_context"), strict=True
)
# Hoist the type annotations OUT of the if/else branches so the
# variable is union-typed across both. The dataclass-present
# branch always gets a concrete int from the dataclass; the
# absent-context branch passes None to mean "we did not pre-fetch
# this at prompt-build time, fall back to listing-snapshot
# defaults" (a semantically distinct signal from "we counted
# zero REQUEST_CHANGES at prompt time"). finalize_review handles
# both: the threshold check uses ``isinstance(int) and >= threshold``
# so 0 and None behave identically below the threshold, but a
# future Tier-1F refinement that DOES want the zero-vs-absent
# distinction can read the dataclass field explicitly instead of
# inferring intent from a None.
head_sha: str
request_changes_count: int | None
pr_comments: list[dict[str, Any]] | None
data_complete: bool
clone_handle: Any = None
if review_context is not None:
head_sha = review_context.head_sha
request_changes_count = review_context.request_changes_count
pr_comments = review_context.pr_comments or None
data_complete = review_context.data_complete
clone_handle = review_context.clone_handle
else:
head = item.get("head") if isinstance(item.get("head"), dict) else {}
head_sha = str(head.get("sha") or item.get("head_sha") or "")
request_changes_count = None
pr_comments = None
data_complete = True
try:
return _review_pipeline.finalize_review(
cfg,
item,
parsed_json,
raw_response,
terminal_state,
review_type=review_type,
head_sha=head_sha,
request_changes_count=request_changes_count,
pr_comments=pr_comments,
data_complete=data_complete,
)
finally:
# Clean up the pre-cloned worktree regardless of how
# the review submission ended (submitted, failed,
# stale, raised). The handle is None when pre-clone
# was disabled or when it failed and the dispatcher
# fell through to the legacy fallback path.
if clone_handle is not None:
try:
clone_handle.cleanup(cfg)
except Exception as exc: # noqa: BLE001
_logger.warning(
"pre-cloned worktree cleanup raised for PR #%s: %s",
item.get("number"),
exc,
)
return _action
def _make_work_group(name: str, script_name: str) -> Any:
return _dispatch.WorkGroup(
name=name,
script_name=script_name,
item_kind="pr",
claim_kind=CLAIM_KIND,
worker_agent="pr-review-worker",
tag_prefix="AUTO-REV",
prompt_factory=_review_prompt,
post_session_action=_build_post_session_action(name),
)
WORK_GROUPS = [
_make_work_group(
"addressed_changes_ci_passing", "list_prs_addressed_changes_ci_passing"
),
_make_work_group(
"no_active_review_ci_passing", "list_prs_no_active_review_ci_passing"
),
_make_work_group(
"addressed_changes_ci_failing", "list_prs_addressed_changes_ci_failing"
),
_make_work_group(
"no_active_review_ci_failing", "list_prs_no_active_review_ci_failing"
),
_make_work_group("missing_ci_checks", "list_prs_missing_ci_checks"),
]
_ALLOW_NONREVIEWER_PAT_ENV = "REVIEW_DISPATCHER_ALLOW_NONREVIEWER_PAT"
def _is_nonreviewer_pat_bypass_enabled() -> bool:
return os.environ.get(_ALLOW_NONREVIEWER_PAT_ENV, "").lower() in (
"1",
"true",
"yes",
)
def load_config(*, dry_run: bool = False) -> Any:
# Require ``FORGEJO_REVIEWER_PAT`` so the dispatcher cannot
# silently authenticate as the merge-bot identity (which would
# post reviews from the same user that authored the PR and
# void the branch-protection ``reviewer != author`` rule). The
# bypass env var keeps dev / test workflows that share a single
# PAT across both bot identities working — at the cost of
# skipping the ``/user`` identity verification below.
if _is_nonreviewer_pat_bypass_enabled():
token = _dispatch.load_secret("FORGEJO_REVIEWER_PAT", "GITEA_TOKEN")
else:
token = _dispatch.load_secret("FORGEJO_REVIEWER_PAT")
return _dispatch.DispatchConfig(
token=token,
forgejo_url=_dispatch.derive_forgejo_url(),
owner=os.environ.get("FORGEJO_OWNER", _dispatch.REPO_OWNER),
repo=os.environ.get("FORGEJO_REPO", _dispatch.REPO_NAME),
server_url=os.environ.get(
"OPENCODE_SERVER_URL", "http://127.0.0.1:4096"
).rstrip("/"),
lock_path=_dispatch.resolve_lock_or_heartbeat(
"REVIEW_DISPATCHER_LOCK_PATH", "review-dispatcher.lock"
),
heartbeat_path=_dispatch.resolve_lock_or_heartbeat(
"REVIEW_DISPATCHER_HEARTBEAT_PATH", "review-dispatcher.heartbeat"
),
cycle_interval_seconds=int(
os.environ.get("REVIEW_DISPATCHER_CYCLE_SECONDS", "300")
),
# Default cap of 2 items per cycle. The legacy LLM
# supervisor used ``max_workers=4`` but its workers blocked
# on Forgejo round-trips, so wall-clock throughput was
# lower than the headline number suggests. The
# deterministic dispatcher pre-fetches all data outside
# the worker session, so each session is meaningfully
# shorter AND saturates the model context. Two items per
# cycle is the conservative bump (still well under the
# 4-worker ceiling the supervisor used) that takes back
# roughly half the parallelism we lost in the migration.
# Operators with constrained capacity (CI runners, model
# rate-limits) can still pin it to 1 via the env var;
# operators with extra headroom can lift it further.
max_items_per_cycle=int(
os.environ.get("REVIEW_DISPATCHER_MAX_ITEMS_PER_CYCLE", "2")
),
worker_timeout_seconds=int(
os.environ.get("REVIEW_DISPATCHER_WORKER_TIMEOUT_SECONDS", "1800")
),
claim_ttl_seconds=int(
os.environ.get("REVIEW_DISPATCHER_CLAIM_TTL_SECONDS", "1800")
),
api_retries=int(os.environ.get("REVIEW_DISPATCHER_API_RETRIES", "3")),
request_timeout_s=int(
os.environ.get("REVIEW_DISPATCHER_REQUEST_TIMEOUT_S", "30")
),
script_timeout_seconds=int(
os.environ.get("REVIEW_DISPATCHER_SCRIPT_TIMEOUT_SECONDS", "120")
),
table_name="dispatch_review_cycles",
dry_run=dry_run,
cycle_failure_budget=int(
os.environ.get("REVIEW_DISPATCHER_CYCLE_FAILURE_BUDGET", "5")
),
linked_issue_policy=_review_fetch.normalize_linked_issue_policy(
os.environ.get("REVIEW_DISPATCHER_LINKED_ISSUE_POLICY"),
),
)
def _read_reviewer_username() -> str | None:
"""Resolve the expected reviewer-bot login from env / .env file."""
explicit = os.environ.get("FORGEJO_REVIEWER_USERNAME")
if explicit:
return explicit.strip() or None
fallback = _dispatch._read_dotenv_value("FORGEJO_REVIEWER_USERNAME")
if fallback:
return fallback.strip() or None
return None
def assert_reviewer_identity(cfg: Any) -> None:
"""Refuse to start when the configured PAT does not actually
authenticate as the expected reviewer-bot identity.
The check is the only thing standing between an operator who
accidentally exported the merge-bot PAT in the
``FORGEJO_REVIEWER_PAT`` slot and a fleet of self-approving
reviews that void the branch-protection ``reviewer != author``
rule. We:
1. Skip entirely when ``REVIEW_DISPATCHER_ALLOW_NONREVIEWER_PAT=1``
so dev / fork-mode workflows that share one PAT across both
bot identities can still run. The bypass also covers the
:func:`load_config` env-var requirement, so the two are
coordinated.
2. Refuse when ``FORGEJO_REVIEWER_USERNAME`` is unset (we cannot
verify identity without a target login). The operator should
either configure the username or set the bypass.
3. Call ``GET /user`` with the PAT. Any non-200 / 401 / 403 /
network error / missing ``login`` field is a hard refusal:
the alternative is silently posting reviews from a misconfigured
account.
4. Refuse on login mismatch.
Refusals raise ``SystemExit`` with a single descriptive message
so the launcher / systemd unit can surface the failure cleanly
without traceback noise.
"""
if _is_nonreviewer_pat_bypass_enabled():
_logger.warning(
"%s skipping /user reviewer-identity check because %s is set",
DRIVER_NAME,
_ALLOW_NONREVIEWER_PAT_ENV,
)
return
expected = _read_reviewer_username()
if not expected:
raise SystemExit(
f"{DRIVER_NAME}: FORGEJO_REVIEWER_USERNAME is not set; cannot "
f"verify the reviewer-bot identity. Either configure it "
f"(recommended) or set {_ALLOW_NONREVIEWER_PAT_ENV}=1 to bypass "
f"the identity check entirely."
)
try:
response = _claim_runtime.get("/user", cfg)
except Exception as exc: # noqa: BLE001 — refuse on any network failure
raise SystemExit(
f"{DRIVER_NAME}: failed to call /user for reviewer-identity "
f"verification ({type(exc).__name__}: {exc}). Refusing to start."
)
status = int(response.get("status") or 0)
if status in (401, 403):
raise SystemExit(
f"{DRIVER_NAME}: /user returned HTTP {status}"
f"FORGEJO_REVIEWER_PAT is invalid, expired, or lacks read scope. "
f"Refusing to start."
)
if status != 200:
raise SystemExit(
f"{DRIVER_NAME}: /user returned HTTP {status} — cannot verify "
f"reviewer identity. Refusing to start."
)
body = response.get("body")
actual = body.get("login") if isinstance(body, dict) else None
if not isinstance(actual, str) or not actual:
raise SystemExit(
f"{DRIVER_NAME}: /user response missing 'login' field. Refusing to start."
)
if actual != expected:
raise SystemExit(
f"{DRIVER_NAME}: PAT identity mismatch — expected "
f"FORGEJO_REVIEWER_USERNAME={expected!r}, but /user returned "
f"{actual!r}. The configured PAT does not authenticate as the "
f"reviewer-bot. Refusing to start."
)
_logger.info("%s verified reviewer identity: login=%s", DRIVER_NAME, actual)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--once", action="store_true", help="run one cycle and exit")
parser.add_argument("--status", action="store_true", help="print config and exit")
parser.add_argument(
"--dry-run", action="store_true", help="claim nothing and do not dispatch"
)
args = parser.parse_args()
_dispatch._configure_logging("REVIEW_DISPATCHER_LOG_LEVEL")
cfg = load_config(dry_run=args.dry_run)
if args.status:
_dispatch.json_line(_dispatch.status_payload(cfg, driver_name=DRIVER_NAME))
return 0
if not args.dry_run:
# Reviewer-bot identity check: refuse to start if the
# configured PAT does not authenticate as the expected
# reviewer login. This is the only guard against an operator
# who accidentally exported the merge-bot PAT in the
# ``FORGEJO_REVIEWER_PAT`` slot and ends up with a fleet of
# self-approving reviews that void the branch-protection
# ``reviewer != author`` rule.
assert_reviewer_identity(cfg)
# Worktree janitor (R3, 2026-05-16): sweep stale + corrupted
# review worktrees BEFORE the main loop starts a new cycle.
# The previous run's SIGTERM teardown frequently leaves
# orphaned worktrees that the next cycle's
# ``git worktree add`` would collide with — the janitor +
# the in-prepare auto-prune-on-failure together remove both
# the orphan dir and the mirror's stale bookkeeping.
try:
_pr_clone.prune_orphan_worktrees(cfg, kind="review")
except Exception as exc: # noqa: BLE001
_logger.warning(
"review worktree janitor raised at startup; continuing: %s: %s",
type(exc).__name__,
exc,
)
# Block-store janitor: drop every expired row from the
# cross-process content block store. Best-effort, never
# raises (the janitor itself swallows OS/DB errors).
try:
removed = _block_store.janitor()
if removed:
_logger.info(
"block store janitor: removed %s expired rows at startup",
removed,
)
except Exception as exc: # noqa: BLE001
_logger.warning(
"block store janitor raised at startup; continuing: %s: %s",
type(exc).__name__,
exc,
)
if args.once:
_dispatch.json_line(
_dispatch.run_one_cycle(
cfg,
WORK_GROUPS,
driver_name=DRIVER_NAME,
sweep_claim_kind=CLAIM_KIND,
)
)
return 0
_dispatch.run_outer_loop(
cfg,
WORK_GROUPS,
driver_name=DRIVER_NAME,
sweep_claim_kind=CLAIM_KIND,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())