Files
cleveragents-core/tools/controller/mcp/summarizer_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

151 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""Summarizer response-builder MCP server.
Spawned per-summarization-attempt (one per "implementer attempt aged
out of verbatim window"). The summarizer reads the prior summary +
the newly-aged-out implementer attempt, produces an updated
1-paragraph synthesis.
Lightweight; one main setter + finalize.
"""
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 SummarizerOutputV1 # noqa: E402
from tools.controller.mcp._builder_base import ( # noqa: E402
BuilderError,
BuilderState,
call_with_invariants,
finalize_and_emit,
ok,
)
server = FastMCP("summarizer-builder")
_STATE = BuilderState()
@server.tool()
def summarizer_start(
workflow_id: int, attempt_id: int,
) -> dict[str, Any]:
"""Initialize the summarizer session. RECOMMENDED first call.
Setters auto-start without this for backward compat, but calling
this explicitly is the contract the prompt advertises + lets the
builder detect cross-session reuse."""
args = {"workflow_id": workflow_id, "attempt_id": attempt_id}
def body() -> dict[str, Any]:
# Unconditional reset on _start — see implementer_builder.py.
if _STATE.started:
_STATE.reset_for_new_attempt()
_STATE.started = True
_STATE.started_at = datetime.now(timezone.utc)
_STATE.identity = {
"workflow_id": workflow_id, "attempt_id": attempt_id,
}
return ok()
return call_with_invariants(_STATE, "summarizer_start", body, args)
@server.tool()
def summarizer_set_summary(text: str) -> dict[str, Any]:
"""Set the synthesis text. 50-2000 chars (mirrors SummarizerOutputV1)."""
args = {"text_len": len(text)}
def body() -> dict[str, Any]:
_STATE.require_not_finalized()
if not _STATE.started:
_STATE.started = True
_STATE.started_at = datetime.now(timezone.utc)
if not 50 <= len(text) <= 2000:
raise BuilderError(
f"summary must be 50-2000 chars; got {len(text)}"
)
_STATE.fields["summary"] = text
return ok(summary_len=len(text))
return call_with_invariants(_STATE, "summarizer_set_summary", body, args)
@server.tool()
def summarizer_set_covers_through_attempt(attempt_number: int) -> dict[str, Any]:
"""Record which prior attempt number this summary's coverage ends at."""
args = {"attempt_number": attempt_number}
def body() -> dict[str, Any]:
_STATE.require_not_finalized()
if not _STATE.started:
_STATE.started = True
_STATE.started_at = datetime.now(timezone.utc)
if attempt_number < 1:
raise BuilderError(
f"covers_through_attempt must be ≥1; got {attempt_number}"
)
_STATE.fields["covers_through_attempt"] = attempt_number
return ok(covers_through_attempt=attempt_number)
return call_with_invariants(
_STATE, "summarizer_set_covers_through_attempt", body, args
)
@server.tool()
def summarizer_finalize(output_path: str | None = None) -> dict[str, Any]:
"""Validate + emit SummarizerOutputV1 JSON to ``output_path``
(per-attempt path from the controller's prompt)."""
def body() -> dict[str, Any]:
_STATE.require_started()
_STATE.require_not_finalized()
missing = [
f for f in ("summary", "covers_through_attempt")
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
return finalize_and_emit(
_STATE, SummarizerOutputV1, output_path=output_path,
)
_STATE.record("summarizer_finalize", {"output_path": output_path})
try:
return body()
except BuilderError as e:
return {"error": str(e), "tool": "summarizer_finalize"}
@server.tool()
def summarizer_state() -> dict[str, Any]:
return {
"started": _STATE.started,
"finalized": _STATE.finalized,
"fields_set": sorted(_STATE.fields.keys()),
"audit_entries": len(_STATE.audit),
}
main = make_main(server, "summarizer-builder")
if __name__ == "__main__":
raise SystemExit(main())