#!/usr/bin/env python3 """Implementer response-builder MCP server. Spawned per-attempt by the worker controller. Validates each builder call against ImplementerOutputV1 and enforces outcome-specific invariants at finalize: - outcome='resolved' → requires ≥1 commit AND ≥1 file modified - outcome='rebase-failed' → no extra requirements; controller routes to CONFLICT_RESOLVING - outcome='blocked' → requires ≥1 entry in blockers - outcome='noop' → forbids commits + files + blockers - outcome='competence-failure' → no extra requirements - outcome='dispute-reviewer' → T5-4 + T5-9 (dispute-at-any-tier). Requires all four dispute fields (set via ``implementer_set_dispute``). Forbids commits + files + blockers — the implementer isn't fixing anything, it's contesting the reviewer's claim. Per-tier cap (1 dispute per tier) is enforced at the controller (outcomes mapper), not here — subsequent disputes at the same tier downgrade to competence-failure to force escalation. - outcome='verified-clean' → T5-11 fast-success path. Use when the prior attempt was a ``conflict_resolver`` whose commits already cover everything and no further code changes are needed. Forbids commits + files + blockers (same as noop). Routes the workflow to AWAITING_CI for CI re-verification instead of escalating. Picking this outcome when CI is actually red (or when there's genuine code work missing) will surface as a competence-failure on the next cycle. - outcome='ci-not-ready' → CI for the PR head has no verdict yet (still pending) and there is nothing to fix. Forbids commits + files + blockers (same as noop). Routes the workflow to AWAITING_CI to wait for the verdict instead of STUCK. Exists for the RUN_CI_LOCAL on-demand-CI path. - outcome='ci-infra-failure' → the CI failure is NOT a code failure: the failing job's log carries no verdict (no ##[error], test summary, Traceback or exit code — it stops mid-run), the signature of a hard process kill (OOM / pod eviction). Nothing to fix. Forbids commits + files + blockers (same as noop). Routes IMPLEMENTING → DISCOVERED so the CI-freshness gate reruns CI under its bounded budget instead of dead-ending at blocked → STUCK. """ from __future__ import annotations import os import sys from datetime import datetime, timezone from pathlib import Path from typing import Any from mcp.server.fastmcp import FastMCP _REPO_ROOT = Path(__file__).resolve().parents[3] if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) from tools._mcp_common import make_main # noqa: E402 from tools.controller.contracts.v1 import ImplementerOutputV1 # noqa: E402 from tools.controller.mcp._builder_base import ( # noqa: E402 BuilderError, BuilderState, call_with_invariants, finalize_and_emit, ok, ) server = FastMCP("implementer-builder") _STATE = BuilderState() _OUTCOME_VALUES = { "resolved", "rebase-failed", "noop", "blocked", "competence-failure", "dispute-reviewer", "verified-clean", "ci-not-ready", "ci-infra-failure", } _CONFIDENCE_VALUES = {"high", "medium", "low"} _TIER_VALUES = {0, 1, 2} # T5-9 (2026-05-19): dispute available at any tier. The MCP no longer # enforces a tier floor — trial-5 data showed even haiku produced # credible structured disputes when given the right evidence-required # contract. Per-tier cap is enforced upstream in the outcomes mapper # (one dispute per tier; a second is downgraded to competence-failure # so the workflow escalates rather than ping-pongs). _DISPUTE_EVIDENCE_MIN_CHARS = 200 import re as _re_mod # noqa: E402 (kept local; consistent with record_commit) _SHA_RE = _re_mod.compile(r"[0-9a-f]{7,40}") # ─── CI re-trigger (ci-infra-failure path) ─────────────────────────── # # A CI job hard-killed by OOM / pod eviction produces no verdict — # nothing in the diff to fix. The implementer emits # outcome='ci-infra-failure', but that alone only shuffles workflow # state; CI never re-runs unless a new commit is pushed. # ``implementer_retrigger_ci`` lets the agent kick a fresh CI run. # Forgejo 15.x has no Actions rerun API, so the re-trigger is an # empty-commit push — the exact mechanism the controller's CI-freshness # gate already uses (tools/controller/master/ci_rerun.py). def _load_ci_rerun() -> Any: """Load ``ci_rerun.py`` as a standalone module. ``ci_rerun`` imports only the stdlib, but importing it through the ``tools.controller.master`` package would execute that package's heavy ``__init__`` (the whole master orchestration layer) inside this lightweight response-builder MCP. Loading the file directly sidesteps that. Returns the module, or None if it cannot be loaded — the MCP must still start; the tool then reports the failure. """ import importlib.util path = _REPO_ROOT / "tools" / "controller" / "master" / "ci_rerun.py" mod_name = "_controller_ci_rerun_standalone" try: spec = importlib.util.spec_from_file_location(mod_name, path) if spec is None or spec.loader is None: return None mod = importlib.util.module_from_spec(spec) # Register in sys.modules BEFORE exec: ci_rerun.py defines a # @dataclass, and (Python 3.12+) the dataclass machinery looks # the module up via sys.modules[cls.__module__] — an unregistered # module makes that an AttributeError. sys.modules[mod_name] = mod spec.loader.exec_module(mod) return mod except Exception: # noqa: BLE001 — MCP must start even if this fails sys.modules.pop(mod_name, None) return None _ci_rerun = _load_ci_rerun() def _retrigger_ci(owner: str, repo: str, pr_branch: str) -> dict[str, Any]: """Re-trigger CI for ``owner/repo``'s ``pr_branch`` by pushing an empty commit. Resolves the Forgejo base URL + token from the env the worker passed down. Returns ``{"ok": bool, "new_head_sha": str|None, "error": str|None}`` and never raises — a failed re-trigger is non-fatal for the agent. """ token = ( os.environ.get("FORGEJO_TOKEN") or os.environ.get("GITEA_TOKEN") or "" ).strip() base = os.environ.get("FORGEJO_URL", "").strip().rstrip("/") if not base: # Fall back to FORGEJO_API_BASE with the ``/api/v1`` suffix # stripped (explicit endswith, not rstrip char-set — see PD4 in # worker/__main__.py). api = os.environ.get("FORGEJO_API_BASE", "").strip().rstrip("/") if api.endswith("/api/v1"): api = api[: -len("/api/v1")] base = api if not token: return { "ok": False, "new_head_sha": None, "error": "no Forgejo token in env (FORGEJO_TOKEN / GITEA_TOKEN)", } if not base or "://" not in base: return { "ok": False, "new_head_sha": None, "error": f"no usable Forgejo base URL in env (got {base!r})", } if _ci_rerun is None: return { "ok": False, "new_head_sha": None, "error": "ci_rerun helper module could not be loaded", } try: result = _ci_rerun.trigger_ci_rerun_via_empty_commit( owner=owner, repo=repo, pr_branch=pr_branch, https_remote=f"{base}/{owner}/{repo}.git", token=token, ) except Exception as exc: # noqa: BLE001 — _retrigger_ci must never raise # trigger_ci_rerun_via_empty_commit is contracted never to # raise; this is belt-and-suspenders so a contract regression # there can't crash the MCP request. return { "ok": False, "new_head_sha": None, "error": f"CI re-trigger raised unexpectedly: {exc}", } return { "ok": result.ok, "new_head_sha": result.new_head_sha, "error": result.error, } @server.tool() def implementer_start( workflow_id: int, attempt_id: int, pr_number: int, tier: int ) -> dict[str, Any]: """Initialize the implementer session. MUST be the first tool called.""" args = { "workflow_id": workflow_id, "attempt_id": attempt_id, "pr_number": pr_number, "tier": tier, } def body() -> dict[str, Any]: # OpenCode reuses local MCP subprocesses across sessions. Any # state in _STATE is from a prior session and must be cleared. # We can't distinguish "agent retry in same session" from "new # session reusing this MCP" reliably — both have the same # observable signature — so we just reset whenever _start is # called with state present. ``reset_for_new_attempt`` logs a # WARN if there's an in-flight (started-but-not-finalized) # carryover so abandoned attempts are still visible to ops. # # (Earlier code keyed on identity.attempt_id but the agent # passes whatever the prompt told it to — if the prompt has # ``attempt_id=...`` as a literal placeholder the agent guesses # 1 every time, the comparison always matches, and reset never # fires → stale state persists permanently.) if _STATE.started: _STATE.reset_for_new_attempt() if tier not in _TIER_VALUES: raise BuilderError( f"invalid tier {tier!r}; expected one of {sorted(_TIER_VALUES)}" ) _STATE.started = True _STATE.started_at = datetime.now(timezone.utc) _STATE.identity = { "workflow_id": workflow_id, "attempt_id": attempt_id, "pr_number": pr_number, "tier": tier, } _STATE.fields["used_tier"] = tier return ok(tier=tier) return call_with_invariants(_STATE, "implementer_start", body, args) @server.tool() def implementer_record_file_modified( path: str, lines_added: int = 0, lines_deleted: int = 0 ) -> dict[str, Any]: """Record one file the implementer modified.""" args = {"path": path, "lines_added": lines_added, "lines_deleted": lines_deleted} def body() -> dict[str, Any]: _STATE.require_started() _STATE.require_not_finalized() if not path.strip(): raise BuilderError("path must be non-empty") if lines_added < 0 or lines_deleted < 0: raise BuilderError("lines_added/lines_deleted must be ≥ 0") files = _STATE.fields.setdefault("files_touched", []) if path not in files: files.append(path) return ok(files_touched_count=len(files)) return call_with_invariants(_STATE, "implementer_record_file_modified", body, args) @server.tool() def implementer_record_commit(sha: str, message: str) -> dict[str, Any]: """Record a git commit the implementer pushed.""" args = {"sha": sha, "message_len": len(message)} def body() -> dict[str, Any]: _STATE.require_started() _STATE.require_not_finalized() # A-1 fix (2026-05-19): tighter SHA validation. Pre-fix only # ``len(sha)>=7`` was checked — agent could pass arbitrary # 7+ char strings and the controller's ``head_sha_advanced`` # check would treat the hallucination as a real push, routing # to AWAITING_CI; ci_status_poll then 404s on the fake SHA; # workflow wastes 2h until ci_poll_exhaustion fires. # Real git SHAs are exactly 7-40 lowercase hex chars. import re as _re if not sha.strip() or not _re.fullmatch(r"[0-9a-f]{7,40}", sha.strip()): raise BuilderError( f"sha must be 7-40 lowercase hex chars (a git SHA); got {sha!r}" ) if not message.strip(): raise BuilderError("commit message must be non-empty") commits = _STATE.fields.setdefault("commit_shas", []) if sha not in commits: commits.append(sha) return ok(commits_count=len(commits)) return call_with_invariants(_STATE, "implementer_record_commit", body, args) @server.tool() def implementer_add_blocker(description: str) -> dict[str, Any]: """Add a blocker explanation. Only valid when outcome will be 'blocked'.""" args = {"description": description} def body() -> dict[str, Any]: _STATE.require_started() _STATE.require_not_finalized() if not description.strip(): raise BuilderError("blocker description must be non-empty") current_outcome = _STATE.fields.get("outcome") if current_outcome and current_outcome != "blocked": raise BuilderError( f"cannot add blocker with outcome={current_outcome!r}; " "set outcome='blocked' first" ) blockers = _STATE.fields.setdefault("blockers", []) blockers.append(description) return ok(blockers_count=len(blockers)) return call_with_invariants(_STATE, "implementer_add_blocker", body, args) @server.tool() def implementer_set_outcome(outcome: str) -> dict[str, Any]: args = {"outcome": outcome} def body() -> dict[str, Any]: _STATE.require_started() _STATE.require_not_finalized() if outcome not in _OUTCOME_VALUES: raise BuilderError( f"invalid outcome {outcome!r}; expected one of {sorted(_OUTCOME_VALUES)}" ) # T5-9: dispute-reviewer accepted at any tier. The per-tier cap # is enforced at the controller side (outcomes mapper), so the # MCP just validates the structural requirements (set_dispute # must be called before finalize). _STATE.fields["outcome"] = outcome return ok(outcome=outcome) return call_with_invariants(_STATE, "implementer_set_outcome", body, args) @server.tool() def implementer_set_dispute( disputed_review_id: int, disputed_blocker_index: int, dispute_evidence: str, verified_at_sha: str, ) -> dict[str, Any]: """Record an evidence-bearing dispute against a prior reviewer's blocking_issue. Required when ``outcome='dispute-reviewer'``. Available at any tier (T5-9). ``dispute_evidence`` must be at least ``_DISPUTE_EVIDENCE_MIN_CHARS`` characters — bound by the controller to force concrete file:line citations + quoted bytes rather than hand-waving rebuttals. ``verified_at_sha`` is the commit the implementer read while preparing the rebuttal (lowercase hex, 7-40 chars). Per-tier cap (1 dispute per tier) is enforced by the controller's outcomes mapper; the MCP only validates structural requirements. """ args = { "disputed_review_id": disputed_review_id, "disputed_blocker_index": disputed_blocker_index, "dispute_evidence_len": len(dispute_evidence or ""), "verified_at_sha": verified_at_sha, } def body() -> dict[str, Any]: _STATE.require_started() _STATE.require_not_finalized() if not isinstance(disputed_review_id, int) or disputed_review_id < 0: raise BuilderError( f"disputed_review_id must be a non-negative int; " f"got {disputed_review_id!r}" ) if not isinstance(disputed_blocker_index, int) or disputed_blocker_index < 0: raise BuilderError( f"disputed_blocker_index must be a non-negative int (0-based); " f"got {disputed_blocker_index!r}" ) evidence = (dispute_evidence or "").strip() if len(evidence) < _DISPUTE_EVIDENCE_MIN_CHARS: raise BuilderError( f"dispute_evidence must be ≥ {_DISPUTE_EVIDENCE_MIN_CHARS} " f"chars after stripping (got {len(evidence)}); " "include concrete file:line citations + quoted bytes" ) sha = (verified_at_sha or "").strip() if not _SHA_RE.fullmatch(sha): raise BuilderError( f"verified_at_sha must be 7-40 lowercase hex chars; " f"got {verified_at_sha!r}" ) _STATE.fields["disputed_review_id"] = disputed_review_id _STATE.fields["disputed_blocker_index"] = disputed_blocker_index _STATE.fields["dispute_evidence"] = evidence _STATE.fields["verified_at_sha"] = sha return ok( disputed_review_id=disputed_review_id, disputed_blocker_index=disputed_blocker_index, dispute_evidence_chars=len(evidence), ) return call_with_invariants(_STATE, "implementer_set_dispute", body, args) @server.tool() def implementer_set_confidence(confidence: str) -> dict[str, Any]: args = {"confidence": confidence} def body() -> dict[str, Any]: _STATE.require_started() _STATE.require_not_finalized() if confidence not in _CONFIDENCE_VALUES: raise BuilderError( f"invalid confidence {confidence!r}; expected one of {sorted(_CONFIDENCE_VALUES)}" ) _STATE.fields["confidence"] = confidence return ok(confidence=confidence) return call_with_invariants(_STATE, "implementer_set_confidence", body, args) @server.tool() def implementer_retrigger_ci( owner: str, repo: str, pr_branch: str ) -> dict[str, Any]: """Re-trigger CI for this PR by pushing an empty commit to its head branch. Call this exactly once, immediately before emitting ``outcome='ci-infra-failure'``: a CI job hard-killed by OOM / pod eviction produced no verdict, and a fresh run is the only way to get one. Forgejo has no Actions rerun API — advancing HEAD with an empty commit fires a fresh ``pull_request`` run. Pass ``owner`` / ``repo`` / ``pr_branch`` from the PR context in this prompt. Returns ``retriggered=True`` + the new head SHA on success. A failed re-trigger is NON-fatal — emit ``ci-infra-failure`` anyway; the controller's CI-rerun budget is the backstop. The empty re-trigger commit is CI machinery, not a fix: do NOT record it with ``implementer_record_commit``. """ args = {"owner": owner, "repo": repo, "pr_branch": pr_branch} def body() -> dict[str, Any]: _STATE.require_started() _STATE.require_not_finalized() for name, val in ( ("owner", owner), ("repo", repo), ("pr_branch", pr_branch), ): if not isinstance(val, str) or not val.strip(): raise BuilderError(f"{name} must be a non-empty string") # Once per session. call_with_invariants records THIS call into # _STATE.audit before body() runs, so a count > 1 means a prior # call already pushed an empty commit + kicked a fresh CI run. # Re-triggering again would only pile junk commits on the PR — # refuse benignly so the agent still proceeds to ci-infra-failure. # The audit is wiped by reset_for_new_attempt, so the next # attempt (a fresh session) gets its own re-trigger. if ( sum( 1 for e in _STATE.audit if e.get("tool") == "implementer_retrigger_ci" ) > 1 ): return ok( retriggered=False, new_head_sha=None, error=( "CI already re-triggered once this session; a fresh " "run is in flight — proceed to ci-infra-failure" ), ) result = _retrigger_ci(owner.strip(), repo.strip(), pr_branch.strip()) return ok( retriggered=result["ok"], new_head_sha=result.get("new_head_sha"), error=result.get("error"), ) return call_with_invariants(_STATE, "implementer_retrigger_ci", body, args) def _check_outcome_invariants(state: BuilderState) -> None: """Outcome-specific finalize check.""" outcome = state.fields.get("outcome") files = state.fields.get("files_touched") or [] commits = state.fields.get("commit_shas") or [] blockers = state.fields.get("blockers") or [] tier = state.fields.get("used_tier") if outcome == "resolved": if not commits: raise BuilderError( "outcome='resolved' requires ≥1 commit; " "call implementer_record_commit at least once" ) if not files: raise BuilderError( "outcome='resolved' requires ≥1 file modified; " "call implementer_record_file_modified at least once" ) elif outcome == "blocked": if not blockers: raise BuilderError( "outcome='blocked' requires ≥1 blocker; " "call implementer_add_blocker at least once" ) elif outcome == "noop": if commits or files or blockers: raise BuilderError( "outcome='noop' forbids commits/files/blockers; " f"got {len(commits)} commits, {len(files)} files, " f"{len(blockers)} blockers" ) elif outcome == "verified-clean": # T5-11: post-conflict-resolution fast-success path. Same # invariants as noop (no new work) but routes to AWAITING_CI # rather than competence-failure → escalation. The # ``verified-clean`` semantic is "I checked the resolver's # commits and they cover everything; CI should re-run to # confirm." If the agent has anything to record (commits / # files / blockers), it should be using a different outcome. if commits or files or blockers: raise BuilderError( "outcome='verified-clean' forbids commits/files/blockers — " "use 'resolved' if you pushed, 'blocked' if you found " "issues; " f"got {len(commits)} commits, {len(files)} files, " f"{len(blockers)} blockers" ) elif outcome == "ci-not-ready": # RUN_CI_LOCAL: CI for the PR head has no verdict yet and there # is nothing to fix. Same no-work invariant as noop; routes to # AWAITING_CI to wait for the verdict rather than STUCK. if commits or files or blockers: raise BuilderError( "outcome='ci-not-ready' forbids commits/files/blockers — " "use 'resolved' if you pushed a fix, 'blocked' if CI is " "red and you genuinely cannot fix it; " f"got {len(commits)} commits, {len(files)} files, " f"{len(blockers)} blockers" ) elif outcome == "ci-infra-failure": # The CI failure is a hard-kill (OOM / pod eviction) with no # verdict in the log — nothing to fix. Same no-work invariant # as noop; routes IMPLEMENTING → DISCOVERED so the CI-freshness # gate reruns CI under its bounded budget rather than STUCK. if commits or files or blockers: raise BuilderError( "outcome='ci-infra-failure' forbids commits/files/blockers " "— use 'resolved' if you pushed a fix for a real CI " "failure, 'blocked' if CI is red with a real verdict you " "genuinely cannot fix; " f"got {len(commits)} commits, {len(files)} files, " f"{len(blockers)} blockers" ) elif outcome == "dispute-reviewer": # T5-9: any tier may dispute. No tier-floor check. if commits or files or blockers: raise BuilderError( "outcome='dispute-reviewer' forbids commits/files/blockers — " "the implementer is contesting the reviewer's claim, not " "fixing code; " f"got {len(commits)} commits, {len(files)} files, " f"{len(blockers)} blockers" ) missing = [ f for f in ( "disputed_review_id", "disputed_blocker_index", "dispute_evidence", "verified_at_sha", ) if f not in state.fields ] if missing: raise BuilderError( f"outcome='dispute-reviewer' requires dispute fields " f"{missing}; call implementer_set_dispute(...) before " "finalize" ) @server.tool() def implementer_finalize(output_path: str | None = None) -> dict[str, Any]: """Validate state + emit ImplementerOutputV1 JSON to ``output_path`` (per-attempt path from the controller's prompt). Falls back to env var / stdout when ``output_path`` is None.""" def body() -> dict[str, Any]: _STATE.require_started() _STATE.require_not_finalized() missing = [ f for f in ("outcome", "confidence", "used_tier") if f not in _STATE.fields ] if missing: raise BuilderError( f"missing required fields: {missing}. Call the matching " "setters before finalize." ) started = _STATE.started_at wallclock = ( (datetime.now(timezone.utc) - started).total_seconds() if started else 0.0 ) _STATE.fields["output_version"] = "V1" _STATE.fields["wallclock_seconds"] = wallclock _STATE.fields.setdefault("files_touched", []) _STATE.fields.setdefault("commit_shas", []) _STATE.fields.setdefault("blockers", []) return finalize_and_emit( _STATE, ImplementerOutputV1, output_path=output_path, extra_required_check=_check_outcome_invariants, ) _STATE.record("implementer_finalize", {"output_path": output_path}) try: return body() except BuilderError as e: return {"error": str(e), "tool": "implementer_finalize"} @server.tool() def implementer_state() -> dict[str, Any]: return { "started": _STATE.started, "finalized": _STATE.finalized, "identity": dict(_STATE.identity), "fields_set": sorted(_STATE.fields.keys()), "files_count": len(_STATE.fields.get("files_touched") or []), "commits_count": len(_STATE.fields.get("commit_shas") or []), "blockers_count": len(_STATE.fields.get("blockers") or []), "audit_entries": len(_STATE.audit), } main = make_main(server, "implementer-builder") if __name__ == "__main__": raise SystemExit(main())