eb1d81828f
A CI job hard-killed by OOM / pod eviction produces no verdict — nothing in the diff to fix. The implementer correctly emits outcome=ci-infra-failure, but that alone only shuffles workflow state; CI never re-runs, so every retry reads the same dead run and the workflow loops until the _MAX_CI_INFRA_FAILURE backstop STUCKs it (observed live on PR #39 in run-3). New ``implementer_retrigger_ci(owner, repo, pr_branch)`` MCP tool on the implementer response-builder lets the agent kick a fresh CI run. Forgejo 15.x has no Actions rerun API, so it reuses the controller's existing mechanism — ``ci_rerun.trigger_ci_rerun_via_empty_commit`` — an empty commit that advances the PR head SHA, which is the unambiguous fresh-state signal for CI (and a stale review). - ci_rerun.py is loaded standalone (importlib by file path, registered in sys.modules before exec so its @dataclass resolves) — the response-builder MCP must not pull the heavy tools.controller.master package. - _retrigger_ci resolves the Forgejo base URL + token from env (FORGEJO_URL / FORGEJO_API_BASE, FORGEJO_TOKEN / GITEA_TOKEN) and never raises. - Once per session: a second implementer_retrigger_ci call is refused benignly so a looping agent cannot pile junk commits on the PR. - The implementer prompt's ci-infra-failure block now instructs the agent to call the tool before emitting the outcome. 10 new tests (test_mcp_builders.py, test_worker_prompts.py); full controller suite (1192) green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
670 lines
27 KiB
Python
670 lines
27 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.
|
|
- 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())
|