Files
cleveragents-core/tools/mcp_ci_server.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

551 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""MCP server wrapping the project's CI surface — local quality gates
(``tools/local_ci_gate.sh``) and Forgejo CI status fetches.
The motivating problem: a single CI-failure investigation cycle today
burns 2050 KB of worker context just READING gate / pytest / mypy /
ruff / behave output to find the one failing test. The model parses
multi-thousand-line output to extract a file:line:test_name triple.
This MCP returns that triple directly — typically a few hundred
bytes — letting the agent spend its context on the actual fix.
Tools exposed
-------------
``run_local_gate(gate, repo_root=None, fast=False, posargs=None)``
Run ``bash tools/local_ci_gate.sh`` against ``repo_root`` (defaults
to ``/tmp/local_tools/`` when called from a worker; the script's
own repo-root resolution kicks in when omitted). Returns a
structured result with per-gate PASS/FAIL/SKIP plus a parsed
``failures`` array. The raw tail is included so an agent can
fall back to direct inspection if the parser missed something.
``fetch_pr_check_summary(pr)``
Per-check status for the PR's HEAD SHA, via Forgejo's
``/commits/{sha}/statuses`` endpoint (paginated; not the combined
``/status`` summary). Returns a compact array — no log URLs
inlined unless useful — so the agent can decide which check to
drill into without first reading the full Forgejo response.
State
-----
Stateless. Every call either shells out fresh (``run_local_gate``)
or hits Forgejo (``fetch_pr_check_summary``). No caching here — the
underlying state changes too quickly for cache invalidation to be
worth the complexity.
Configuration
-------------
``CI_GATE_SCRIPT`` (default
``/home/drew/repos/cleveragents-core/tools/local_ci_gate.sh``)
Path to the gate wrapper script. Override if the project layout
moves the script or to test against a fork.
``CI_GATE_DEFAULT_REPO_ROOT`` (optional)
Default ``--repo-root`` passed to the gate wrapper. Leave unset
to let the wrapper's own resolution apply (which prefers cwd,
then walks up looking for ``noxfile.py``).
``CI_GATE_TIMEOUT_S`` (default ``1800`` — 30 min)
Per-call subprocess timeout. ``e2e_tests`` and ``coverage_report``
can take 10+ minutes on a cold cache; the default leaves headroom.
The Forgejo tools read the standard ``FORGEJO_PAT``, ``FORGEJO_OWNER``,
``FORGEJO_REPO``, ``FORGEJO_API_BASE`` env vars — same contract as
``tools/dispatch_*.py``.
"""
from __future__ import annotations
import os
import re
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
from mcp.server.fastmcp import FastMCP
# ─── Configuration ─────────────────────────────────────────────────
CI_GATE_SCRIPT = Path(
os.environ.get(
"CI_GATE_SCRIPT",
"/home/drew/repos/cleveragents-core/tools/local_ci_gate.sh",
)
)
CI_GATE_DEFAULT_REPO_ROOT = os.environ.get("CI_GATE_DEFAULT_REPO_ROOT", "").strip()
CI_GATE_TIMEOUT_S = int(os.environ.get("CI_GATE_TIMEOUT_S", "1800"))
# ─── Importing project helpers (sibling .py files, not a package) ──
# ``tools/`` is not a Python package — sibling files are loaded via
# the project's existing ``_loader`` helper which the dispatchers use
# for the same reason. We follow the same pattern so the MCP runs
# either from the project venv or via ``opencode.json``'s explicit
# python path without ``PYTHONPATH`` plumbing.
sys.path.insert(0, str(Path(__file__).parent))
from _diff_aware_gate import parse_failing_scenarios, parse_gate_statuses # noqa: E402
from _mcp_common import ( # noqa: E402
ForgejoCfg, bootstrap_loader, make_main, require_token,
)
load_sibling = bootstrap_loader()
_claim_runtime = load_sibling("_claim_runtime", "_claim_runtime.py")
_review_fetch = load_sibling("_review_fetch", "_review_fetch.py")
_ci_logs = load_sibling("_ci_logs", "_ci_logs.py")
# ─── Server ────────────────────────────────────────────────────────
server = FastMCP("ci")
# ─── Failure parsers ───────────────────────────────────────────────
# Each parser returns ``list[dict]`` shaped uniformly so the agent
# can iterate without per-format knowledge. Common keys:
# ``kind``: "pytest" | "ruff" | "mypy" | "behave"
# ``file``: source file (relative to repo root)
# ``line``: int (line number; missing for whole-test-suite failures)
# ``test``: str (e.g. "tests/foo.py::test_bar"; ruff/mypy omit)
# ``message``: short single-line summary
# Parsers are best-effort; an empty list means "couldn't find any
# structured failures" (not "no failures") — combine with the gate
# status to decide what the agent should do.
# pytest's short summary block:
# FAILED tests/auto_agents/test_x.py::test_y - AssertionError: ...
_PYTEST_FAILED_RE = re.compile(
r"^FAILED\s+(?P<file>[\w./\-]+?)::(?P<test>[\w\[\]:.\-]+)"
r"(?:\s+-\s+(?P<message>.+))?$",
re.MULTILINE,
)
# ruff's standard output:
# path/to/file.py:42:5: E501 line too long (123 > 120)
_RUFF_RE = re.compile(
r"^(?P<file>[\w./\-]+):(?P<line>\d+):\d+:\s+(?P<code>[A-Z]\d+)\s+(?P<message>.+)$",
re.MULTILINE,
)
# mypy / pyright error lines:
# path/to/file.py:42: error: ...
_MYPY_RE = re.compile(
r"^(?P<file>[\w./\-]+):(?P<line>\d+):(?:\d+:)?\s+(?P<level>error|warning):\s+(?P<message>.+)$",
re.MULTILINE | re.IGNORECASE,
)
def _parse_pytest_failures(output: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for m in _PYTEST_FAILED_RE.finditer(output or ""):
key = (m.group("file"), m.group("test"))
if key in seen:
continue
seen.add(key)
out.append(
{
"kind": "pytest",
"file": m.group("file"),
"test": m.group("test"),
"message": (m.group("message") or "").strip()[:200],
}
)
return out
def _parse_ruff_failures(output: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, int, str]] = set()
for m in _RUFF_RE.finditer(output or ""):
try:
line = int(m.group("line"))
except ValueError:
continue
key = (m.group("file"), line, m.group("code"))
if key in seen:
continue
seen.add(key)
out.append(
{
"kind": "ruff",
"file": m.group("file"),
"line": line,
"code": m.group("code"),
"message": m.group("message").strip()[:200],
}
)
return out
def _parse_mypy_failures(output: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
seen: set[tuple[str, int, str]] = set()
for m in _MYPY_RE.finditer(output or ""):
try:
line = int(m.group("line"))
except ValueError:
continue
msg = m.group("message").strip()
key = (m.group("file"), line, msg[:60])
if key in seen:
continue
seen.add(key)
out.append(
{
"kind": "mypy",
"file": m.group("file"),
"line": line,
"level": m.group("level").lower(),
"message": msg[:200],
}
)
return out
def _parse_behave_failures(output: str) -> list[dict[str, Any]]:
"""Wraps the existing ``_diff_aware_gate.parse_failing_scenarios``
so the MCP returns the same shape as the other parsers."""
return [
{
"kind": "behave",
"file": item["path"],
"line": int(item["line"]),
}
for item in parse_failing_scenarios(output)
]
def _extract_all_failures(output: str) -> list[dict[str, Any]]:
"""Run every parser. Each gate produces output in one of these
formats; the parsers that don't match return empty lists."""
return (
_parse_pytest_failures(output)
+ _parse_ruff_failures(output)
+ _parse_mypy_failures(output)
+ _parse_behave_failures(output)
)
# ─── Tools ─────────────────────────────────────────────────────────
@server.tool()
def run_local_gate(
gate: str | None = None,
repo_root: str | None = None,
fast: bool = False,
posargs: list[str] | None = None,
) -> dict[str, Any]:
"""Run ``local_ci_gate.sh`` and return a structured result.
Parameters:
gate: one of ``lint``, ``typecheck``, ``unit_tests``,
``integration_tests``, ``e2e_tests``, ``coverage_report``.
Omit to run the full gate set (or with ``fast=True``, the
cheap-gates subset).
repo_root: passed to the gate wrapper's ``--repo-root``. Omit
to let the wrapper resolve from cwd (looks up for noxfile.py).
The MCP's ``CI_GATE_DEFAULT_REPO_ROOT`` env var supplies the
fallback when neither is set — useful when the gate is run
by a worker whose cwd isn't the project root.
fast: pass ``--fast`` (skips e2e_tests + coverage_report).
Ignored when ``gate`` is set (single-gate runs are inherently
fast).
posargs: extra args after ``--`` (forwarded to nox session.posargs).
Requires ``gate`` to be set; the wrapper rejects pass-through
in multi-gate mode.
Returns:
``{status, gate_statuses, failures, elapsed_s, raw_tail,
command, exit_code}``. ``status`` is ``"pass"`` if exit==0,
``"fail"`` if exit==1, ``"error"`` for argument/setup errors
(exit==2 or subprocess failure). ``raw_tail`` is the last 80
lines of combined output — included so the agent can grep when
the parsers missed something. ``command`` is the exact argv for
reproducibility.
"""
if not CI_GATE_SCRIPT.is_file():
return {
"status": "error",
"error": f"gate wrapper not found at {CI_GATE_SCRIPT}",
"gate_statuses": {},
"failures": [],
"elapsed_s": 0.0,
"raw_tail": "",
"command": [],
"exit_code": -1,
}
cmd: list[str] = ["bash", str(CI_GATE_SCRIPT)]
if gate:
cmd += ["--gate", gate]
elif fast:
cmd += ["--fast"]
effective_repo_root = (repo_root or "").strip() or CI_GATE_DEFAULT_REPO_ROOT
if effective_repo_root:
cmd += ["--repo-root", effective_repo_root]
if posargs:
if not gate:
return {
"status": "error",
"error": "posargs requires `gate` (multi-gate pass-through is undefined)",
"gate_statuses": {},
"failures": [],
"elapsed_s": 0.0,
"raw_tail": "",
"command": cmd,
"exit_code": -1,
}
cmd += ["--"] + [str(a) for a in posargs]
started = time.monotonic()
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=CI_GATE_TIMEOUT_S,
check=False,
)
except subprocess.TimeoutExpired as exc:
return {
"status": "error",
"error": f"gate timed out after {CI_GATE_TIMEOUT_S}s",
"gate_statuses": {},
"failures": [],
"elapsed_s": time.monotonic() - started,
"raw_tail": (exc.stderr or "")[-4000:],
"command": cmd,
"exit_code": -1,
}
elapsed = time.monotonic() - started
combined = (result.stdout or "") + "\n" + (result.stderr or "")
gate_statuses = parse_gate_statuses(combined)
failures = _extract_all_failures(combined)
if result.returncode == 0:
status = "pass"
elif result.returncode == 1:
status = "fail"
else:
status = "error"
return {
"status": status,
"gate_statuses": gate_statuses,
"failures": failures,
"elapsed_s": round(elapsed, 2),
"raw_tail": "\n".join(combined.splitlines()[-80:]),
"command": cmd,
"exit_code": result.returncode,
}
@server.tool()
def fetch_pr_check_summary(pr: int) -> dict[str, Any]:
"""Per-check status for the PR's HEAD SHA.
Wraps Forgejo's paginated ``/commits/{sha}/statuses`` (NOT the
combined ``/status``) via the project's existing
``_review_fetch.fetch_ci_check_detail`` helper, then projects
each status to a compact ``{name, state, url, description}`` row.
Returns:
``{pr, head_sha, checks: [...], complete}`` where ``complete``
is ``True`` if pagination returned all checks and ``False`` if
Forgejo truncated. ``state`` is one of ``success``, ``failure``,
``error``, ``pending``. ``url`` is the per-check log target
(``target_url`` from Forgejo) — agent can open the log via
future ``fetch_check_failure_slice`` (not yet implemented).
"""
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return {
"error": err,
"pr": pr,
"head_sha": None,
"checks": [],
"complete": False,
}
# Resolve PR -> head SHA first.
try:
pr_resp = _claim_runtime.get(
f"/repos/{cfg.owner}/{cfg.repo}/pulls/{int(pr)}", cfg
)
except Exception as exc:
return {
"error": f"PR fetch failed: {exc!r}",
"pr": pr,
"head_sha": None,
"checks": [],
"complete": False,
}
if int(pr_resp.get("status") or 0) != 200:
return {
"error": f"PR fetch returned HTTP {pr_resp.get('status')}",
"pr": pr,
"head_sha": None,
"checks": [],
"complete": False,
}
pr_body = pr_resp.get("body") or {}
head_sha = (pr_body.get("head") or {}).get("sha") or ""
if not head_sha:
return {
"error": "PR has no head SHA (closed/deleted branch?)",
"pr": pr,
"head_sha": None,
"checks": [],
"complete": False,
}
try:
statuses, complete = _review_fetch.fetch_ci_check_detail(cfg, head_sha)
except Exception as exc:
return {
"error": f"check-detail fetch failed: {exc!r}",
"pr": pr,
"head_sha": head_sha,
"checks": [],
"complete": False,
}
# Forgejo's /commits/{sha}/statuses can return multiple entries per
# context (one per push); we keep only the most-recent per name to
# match what the worker actually cares about (current state).
latest_by_name: dict[str, dict[str, Any]] = {}
for s in statuses:
name = s.get("context") or "(unnamed)"
prior = latest_by_name.get(name)
if prior is None or (s.get("created_at") or "") > (
prior.get("_created_at") or ""
):
latest_by_name[name] = {
"name": name,
"state": s.get("status") or s.get("state") or "unknown",
"url": s.get("target_url") or "",
"description": (s.get("description") or "").strip()[:200],
"_created_at": s.get("created_at") or "",
}
checks = [
{k: v for k, v in row.items() if not k.startswith("_")}
for row in sorted(latest_by_name.values(), key=lambda r: r["name"])
]
return {
"pr": pr,
"head_sha": head_sha,
"checks": checks,
"complete": complete,
}
@server.tool()
def fetch_pr_failure_logs(pr: int) -> dict[str, Any]:
"""Per-failing-job log tails for the PR's HEAD SHA.
Wraps the shared :mod:`_ci_logs` cache that the dispatcher uses
at pre-fetch time — calling this tool is FREE (cache hit) after
the dispatcher has already populated it for the current SHA.
Use this when:
- Your prompt's ``## Pre-fetched CI failure logs`` section has
``fetch_error`` for the job you care about (the dispatcher's
live attempt failed; you can retry now from a different
process / network path).
- The dispatcher skipped pre-fetch entirely (CI status was
``pending`` when the prompt was built but has since gone red).
- You want to re-read a log tail without re-loading the prompt
section.
DO NOT use ``bash curl`` or ``webfetch`` against the Forgejo
Actions API for log content — both are blocked by the worker's
permission allowlist AND would bypass the shared per-SHA cache.
Returns:
``{pr, head_sha, failing_jobs: [...], completed, source}``
where each failing job carries
``{context, state, run_id, job_id, log_url, log_tail,
log_bytes_seen, log_truncated, fetch_error}``.
``source`` is one of ``"cache" | "live" | "stale" | "disabled"``.
On error: ``{error: str, pr, head_sha}``.
"""
try:
pr_int = int(pr)
except (TypeError, ValueError):
return {"error": f"pr must be an integer, got {pr!r}", "pr": pr}
if pr_int <= 0:
return {"error": f"pr must be positive, got {pr_int}", "pr": pr_int}
cfg = ForgejoCfg()
err = require_token(cfg, "default")
if err:
return {"error": err, "pr": pr_int, "head_sha": None}
# Resolve PR → head_sha via Forgejo. (Same shape as
# ``fetch_pr_check_summary`` above; could be factored out to
# ``_mcp_common`` in a follow-up — both tools need it.)
try:
pr_resp = _claim_runtime.get(
f"/repos/{cfg.owner}/{cfg.repo}/pulls/{pr_int}", cfg,
)
except Exception as exc: # noqa: BLE001
return {
"error": f"PR fetch failed: {exc!r}",
"pr": pr_int, "head_sha": None,
}
if int(pr_resp.get("status") or 0) != 200:
return {
"error": f"PR fetch returned HTTP {pr_resp.get('status')}",
"pr": pr_int, "head_sha": None,
}
pr_body = pr_resp.get("body") or {}
head_sha = (pr_body.get("head") or {}).get("sha") or ""
if not head_sha:
return {
"error": "PR has no head SHA (closed/deleted branch?)",
"pr": pr_int, "head_sha": None,
}
# Pre-call cache probe so the result can carry an honest
# ``source`` label without instrumenting ``fetch_pr_failure_logs``
# (kept tight for the dispatcher's hot path).
import datetime as _dt
pre_cache = _ci_logs._read_cache(head_sha)
now_dt = _dt.datetime.now(_dt.timezone.utc)
in_backoff = _ci_logs._backoff_active(pre_cache, now_dt)
try:
payload, completed = _ci_logs.fetch_pr_failure_logs(
cfg, head_sha,
)
except Exception as exc: # noqa: BLE001
return {
"error": f"fetch_pr_failure_logs raised: {exc!r}",
"pr": pr_int, "head_sha": head_sha,
}
if _ci_logs.is_disabled():
source = "disabled"
elif in_backoff:
source = "stale"
elif pre_cache is None or not bool(pre_cache.get("completed")):
source = "live"
else:
source = "cache"
return {
"pr": pr_int,
"head_sha": head_sha,
"failing_jobs": payload.get("failing_jobs") or [],
"completed": bool(completed),
"source": source,
}
main = make_main(server, "mcp_ci_server")
if __name__ == "__main__":
sys.exit(main())