2ad0958cb7
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>
151 lines
4.8 KiB
Python
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())
|