Files
cleveragents-core/tools/controller/mcp/implementer_builder.py
T
drew 2ad0958cb7 fix(controller): batch O — trial-2 fix: interpolate real attempt_id into prompts
Trial run-2 surfaced the actual blocker: the response-builder MCPs
shared their module-global ``_STATE`` across OpenCode sessions (they're
registered as ``type: local`` in opencode.json, so OpenCode spawns one
subprocess per OpenCode-server lifetime, not per session). The CA2/PD5
cross-session reset I added in the M-batch keyed on
``identity.attempt_id`` — but the prompt template literally read:

  1. ``estimator_start(attempt_id=..., workflow_id=..., ...)``

The ``...`` were placeholder ellipses, not interpolated. So agents
guessed ``attempt_id=1`` every single time. The cross-session reset
compared ``1 == 1``, decided "same attempt — no reset", and the prior
attempt's ``finalized=True`` persisted forever. Every estimator
session after the first failed with ``"response already finalized;
no further mutations allowed"`` on every ``_set_*`` call → no
finalize → 30s worker timeout → workflow STUCK.

Observed in trial run-2: 2 successes (attempts 1, 2), then 15
consecutive failures (attempts 3–17 across all 6 workflows) before
the pickup_guard would have STUCK every workflow.

Two fixes (defense in depth):

1. **Unconditional reset on _start** in all 5 builders. We can't
   distinguish "agent retry in same session" from "new session reusing
   this MCP" reliably — the observable signature is identical. Just
   reset whenever ``_STATE.started`` is True; ``reset_for_new_attempt``
   logs a WARN if the prior state was in-flight so abandoned attempts
   are still visible to ops. The agent's last ``_start`` always wins.

2. **Interpolate real attempt_id + workflow_id + pr_number + head_sha
   into all 5 prompts' Output contract sections**. The agent_runner
   now injects ``attempt_id`` into ``input_payload`` (matching the
   existing ``workspace_dir`` injection pattern). Each ``build_*_prompt``
   reads ``input_payload.get("attempt_id")`` and bakes the concrete
   value into the MCP-call signature shown to the agent, plus a
   ``PASS THESE EXACT VALUES`` directive.

Also strengthened each prompt's ``_finalize`` line with
``**You MUST call this tool — without it the controller times out.**``
so the agent understands the contract is hard, not optional.

Updated tests:
- ``test_double_start_refused`` → ``test_double_start_resets_silently``:
  pins the new permissive-reset behavior + asserts the WARN log fires.
- ``test_implementer_rejects_intra_session_double_start`` updated for
  same reason; now asserts the second _start succeeds + last-wins
  semantics (used_tier == new tier).
- NEW ``test_prompt_interpolates_real_attempt_id`` parametrized over
  all 5 roles: asserts ``attempt_id=42`` + ``workflow_id=7`` appear
  literally in the rendered prompt AND that ``attempt_id=...`` /
  ``workflow_id=...`` placeholders DO NOT.

Total: 795 → 800 tests, 0 regressions.

The model-override files (.md + .txt) are still in place from the
prior turn — that change is orthogonal to this bug fix; OpenCode
ignores the dispatcher's pass-through per the README, and the actual
generation has been on haiku the whole time. The .md frontmatter
change to sonnet will only take effect on the next OpenCode restart.

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

267 lines
10 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
"""
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"
}
_CONFIDENCE_VALUES = {"high", "medium", "low"}
_TIER_VALUES = {0, 1, 2}
@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()
if not sha.strip() or len(sha) < 7:
raise BuilderError(f"sha must look like a git SHA (≥7 chars); 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)}"
)
_STATE.fields["outcome"] = outcome
return ok(outcome=outcome)
return call_with_invariants(_STATE, "implementer_set_outcome", 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 []
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"
)
@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())