Files
cleveragents-core/tools/dispatch_review.py
T
drew 2658deee94 feat(auto-agents): PR State Warmer substrate + supporting infra
Adds a long-lived sidecar (pr_state_warmer.py) that polls Forgejo's
/pulls endpoint every 30s and writes the full PR snapshot to a
shared SQLite store, eliminating the dispatcher's per-cycle
cold-cache stalls (24-30s rebuilds on flaky cycles) and the silent
50-PR pagination cap on the legacy single-page fetch.

Substrate
- tools/_pr_state_cache.py  — SQLite store with (owner, repo) PK,
  WAL mode, additive v2→v3 migration (comments_refreshed_updated_at),
  bounded fcntl.flock migration lock, threading.Lock for per-process
  init, @_with_reheal decorator (catches OperationalError no-such-
  table + DatabaseError corruption with file quarantine), atomic
  TEMP-table chunking for >32k seen-set, _normalize_updated_at to
  canonicalize Forgejo tz-marker drift
- tools/pr_state_warmer.py — poll/upsert/vanish/comments-refresh
  loop with fcntl.flock singleton (rejects second warmer), bounded
  comments-refresh cap, persistent deferral via SQL pending query,
  PermissionError-tolerant lock setup, cold-start log suppression
- tools/_pr_classification_cache.py — three-layer fall-through
  (warmer cache → list cache → live fetch) with staleness gate
  (PR_STATE_WARMER_STALE_AFTER_S floored at 30s in prod)

Comments cache hardening
- Bot-filter at write time drops bot status/claim/release/sentinel
  while preserving **Implementation Attempt** markers (94.6%
  reduction on bot-heavy PRs like #30's 19k-comment thread)
- _normalize_since_cursor strips microsecond precision before
  building ?since= query (fixes the live-observed Forgejo HTTP 422
  bug on PRs #25 + #28); handles uppercase Z, lowercase z, ±HH:MM
  offsets (including non-zero like +05:30), naive ISO
- Lazy migration of legacy null-key by_author entries on _read_cache
- _newest_cursor walks tail-back skipping malformed entries

Supporting infrastructure (cumulative dmpipeline-v2 work)
- Telemetry server: SSE live tail, run-sessions enumeration,
  cost/token tracking, app.js UI rewrite with collapsible sections
- MCP servers (mcp_ci_server, mcp_forgejo_server, mcp_git_server,
  mcp_handoff_server, mcp_graphify_server) for opencode worker
  context access
- Live log writer (tools/live_log_writer.py) — SSE-streaming
  dispatcher event log
- Tier-dispatcher escalation flow with prompts trimmed for budget
- Shared bot-logins resolver (tools/_bot_logins.py) replacing two
  drift-prone copies
- token_usage_audit.py for opencode cost analysis

Tests
- 2259 passing across 65 changed/new files
- New suites: test_pr_state_cache, test_pr_state_warmer,
  test_pr_state_warmer_integration, test_pr_classification_cache,
  test_pr_list_cache_backoff, test_mcp_* (5 servers),
  test_live_log_writer_sse, test_telemetry_run_sessions,
  test_review_post_ready_label
- Test_pr_comments_cache expanded with bot-filter coverage,
  cursor-normalization regression pins, format-drift, atomicity,
  failed-comments-not-stamped (silent-data-loss class)
- Parametrized @_with_reheal coverage across 7 wrapped APIs
- Real fault-inject atomicity test for chunked mark_vanished path
  via Connection wrapper class
- Subprocess-based singleton flock test (cross-process contract)
- Event-driven SIGTERM-mid-poll test (no fixed-sleep flake)

Architecture notes
- Schema v3 migration is additive (ALTER ADD COLUMN); v0/v1 still
  need destructive rebuild because pre-v2 column shape lacks
  owner/repo. Cross-process drop-table-ping-pong prevented by the
  fcntl migration lock + per-process _initialized flag.
- Comments-refresh deferral is persistent via
  comments_refreshed_updated_at column — survives warmer restart,
  picks up next cycle even if PR didn't change again. Replaces
  in-memory changed_numbers list.
- Rollback path: PR_STATE_WARMER_PREFER=0 bypasses the warmer
  cache and reverts to live-fetch behavior. PR_STATE_CACHE_DISABLE=1
  short-circuits the warmer process at startup.

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

473 lines
20 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. "
f"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())