Files
cleveragents-core/tools/controller/mcp/implementer_builder.py
T
drew a6986008ee feat(controller): trial-5 batch — dispute path, merge pipeline split, conflict-resolver hardening
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>
2026-05-19 17:54:07 -04:00

423 lines
18 KiB
Python

#!/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.
"""
from __future__ import annotations
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",
}
_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}")
@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); "
f"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)
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 == "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())