a6986008ee
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>
423 lines
18 KiB
Python
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())
|