Files
cleveragents-core/tools/controller/worker/agent_runner.py
T
drew f57d9f9478 fix(controller): batch L — round-4 trial-blockers (A1, P1, P3, P4, P5, T5)
Round-4 adversarial review found 5 trial-blockers + 1 silent-debt
item the post-round-3 deep pass missed. All fixed.

A1 — pre-clone the workspace so the agent has a worktree to operate on
``worker/__main__.py``: the agent_runner closure now constructs a
``PerPRWorkspace`` from input_payload.owner/repo/pr_number + the
FORGEJO_URL+FORGEJO_TOKEN env vars. Pre-flight:
- ``workspace.ensure_present()`` creates the dir skeleton.
- ``workspace.clone_if_absent()`` clones the repo into
  ``{workspace_dir}/worktree/`` if not already present (idempotent).
- ``workspace.fetch_and_validate(head_sha, head_ref)`` refreshes +
  verifies the workspace is at the expected head. ``StaleInputError``
  → ``WorkerError(outcome='stale-input')`` so the master re-prefetches
  without burning a pickup. ``RuntimeError`` → ``worker-internal-error``.
Previously the agent saw an empty workspace_dir + had no repo.

P1 — partial-write defense in the canonical-output poller
``worker/agent_runner.py:_wait_for_canonical_output`` now polls each
path with a two-pass quiescence check (size stable + content parses
as JSON) before returning. Partial writes (agent crashed mid-flush)
are skipped + the polling loop continues. The previous
``f.read().strip()`` returned partial JSON which then tripped
``ContractValidationError`` → ``worker-internal-error`` with no
record of WHICH path; now logs source path on every read.

P3 — TOCTOU defense in promote_discovered
``master/promote.py``: the UPDATE now filters
``current_state='DISCOVERED'``. If a concurrent reconciliation
moved the row off DISCOVERED between SELECT and UPDATE, rowcount=0
+ we skip the event-row write. No duplicate audit entry; no
overwriting a pause-by-label-removal.

P4 — explicit tuple-length validation in reconciliation_args + discovery_args
``master/loop.py``: previously a 6-tuple silently fell into the
``else`` 4-tuple unpack, raised ValueError("too many values"), got
swallowed by the per-iter ``except Exception``, and reconciliation
silently died forever. Now: ``elif n == 4`` + ``else: raise TypeError``.
The TypeError still hits the per-iter except (so the loop doesn't
crash) but ``logger.exception`` surfaces the actionable message in
journald. Operator sees "reconciliation_args must be a 4- or 5-tuple;
got length 6" instead of zero indication.

P5 — --tick-interval CLI flag preserves other config fields
``master/__main__.py``: replaced the manual ``MasterConfig(...)``
rebuild (which dropped reconciliation/ci_poll/discovery intervals)
with ``dataclasses.replace(cfg_loop, tick_interval_s=args.tick_interval)``.
Operators who pass --tick-interval no longer silently revert the
other intervals to defaults.

T5 — scheduler._commit_escalation uses safe_json_dumps
``master/scheduler.py``: the escalation event row's payload was the
only call site that bypassed safe_json_dumps. Now consistent — a
future contributor adding a datetime/Decimal field won't trip raw
json.dumps at runtime.

Tests (+4 net):
- ``test_worker_agent_runner.py::test_partial_write_not_read``: pins
  P1 (truncated fallback file + valid MCP output → MCP wins).
- ``test_master_promote.py::test_toctou_state_change_between_select_and_update``:
  pins P3 (steal state via monkey-patch → no double-promotion, no
  extra event row).
- ``test_master_loop.py::test_reconciliation_args_wrong_length_logs_not_silent``:
  pins P4 (6-tuple → logged error, not silent forever).
- ``test_entry_points.py::test_tick_interval_flag_preserves_other_cfg_fields``:
  pins P5 (env-set non-default intervals survive --tick-interval).

Total: 711 controller tests pass (+4 net), 0 regressions.

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

471 lines
17 KiB
Python

"""Production agent_runner adapter — spawns the role-builder MCP +
the OpenCode session + returns the parsed worker output.
The injected ``agent_runner`` parameter to ``run_one_attempt``
(``runner.py``) is what production wires to this module. Tests use a
synthetic agent_runner.
Lifecycle per attempt (plan v9 per-attempt subprocess model):
1. Spawn the matching role-builder MCP subprocess (one of
``tools/controller/mcp/{role}_builder.py``) with stdout captured
so we can read the finalize'd JSON.
2. Tell the OpenCode session to run the role's agent, pointed at the
spawned MCP server (path passed via env / args).
3. Wait for the OpenCode session to complete + the MCP subprocess
to emit one JSON line on stdout.
4. Strict-parse against the V1 output contract for the role.
5. Cleanup: terminate the MCP subprocess (in case it didn't exit
cleanly after finalize).
The actual OpenCode session call is parameterized as
``run_opencode_session`` so this module is testable without a live
OpenCode. Production wires it to ``tools/_opencode_worker.run_session_blocking``.
"""
from __future__ import annotations
import logging
import os
import signal
import subprocess
import sys
import threading
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..contracts.parse import ContractValidationError, strict_parse
from ..contracts.v1 import (
ConflictResolverOutputV1,
EstimatorOutputV1,
ImplementerOutputV1,
ReviewerOutputV1,
SummarizerOutputV1,
)
from .runner import WorkerError, WorkerLostLock
from .session_sidecar import (
WorkerSession,
now_iso,
read_proc_starttime,
write_sidecar,
)
logger = logging.getLogger(__name__)
# Module path of each role's MCP server (Python -m runnable).
ROLE_TO_MCP_MODULE: dict[str, str] = {
"implementer": "tools.controller.mcp.implementer_builder",
"reviewer": "tools.controller.mcp.reviewer_builder",
"estimator": "tools.controller.mcp.estimator_builder",
"conflict_resolver": "tools.controller.mcp.conflict_resolver_builder",
"summarizer": "tools.controller.mcp.summarizer_builder",
}
# The Pydantic model each role's output is parsed against.
ROLE_TO_OUTPUT_MODEL: dict[str, type] = {
"implementer": ImplementerOutputV1,
"reviewer": ReviewerOutputV1,
"estimator": EstimatorOutputV1,
"conflict_resolver": ConflictResolverOutputV1,
"summarizer": SummarizerOutputV1,
}
# Callable signature for the injected OpenCode session runner.
# Inputs: role, mcp_subprocess (so the session can spawn-as-child or
# communicate via stdio), input_payload (already validated).
# Returns: nothing — the MCP subprocess is what emits the canonical
# JSON on its own stdout. The session is just the driver.
#
# Production wires this to a thin wrapper around
# ``_opencode_worker.run_session_blocking``.
OpenCodeSessionRunner = Callable[..., None]
@dataclass
class MCPSpawnResult:
"""Captured MCP subprocess + its stdout reader thread."""
process: subprocess.Popen
stdout_buffer: list[str] # mutable; appended by reader thread
reader_thread: threading.Thread
def production_agent_runner(
*,
attempt_id: int,
role: str,
tier: int | None,
input_payload: dict[str, Any],
instance_id: str,
lost_lock_check: Callable[[], bool],
run_opencode_session: OpenCodeSessionRunner,
python_executable: str = sys.executable,
mcp_startup_timeout_s: float = 5.0,
finalize_timeout_s: float = 30.0,
workspace_dir: Path | None = None,
opencode_server_url: str = "",
) -> dict[str, Any]:
"""The production agent_runner used by run_one_attempt.
Maps the role → matching MCP module + output contract; spawns
the MCP as a subprocess; calls the injected ``run_opencode_session``
to drive the LLM session; reads the MCP's emitted JSON; strict-
parses; returns the parsed dict.
Tests can call this directly with a synthetic ``run_opencode_session``
that pokes the MCP's stdin (or otherwise simulates a session). The
integration with ``_opencode_worker.run_session_blocking`` is the
production wiring.
Raises:
ValueError: unknown role.
WorkerError: MCP didn't start / didn't emit valid JSON /
strict-parse failed twice.
WorkerLostLock: ``lost_lock_check`` returned True during the run.
"""
if role not in ROLE_TO_MCP_MODULE:
raise ValueError(f"unknown role {role!r}; cannot select MCP")
mcp_module = ROLE_TO_MCP_MODULE[role]
model_class = ROLE_TO_OUTPUT_MODEL[role]
# Production: per-attempt canonical output path. MCP's
# finalize_and_emit writes the canonical JSON HERE rather than
# to stdout (which is shared with the FastMCP JSON-RPC transport).
import tempfile
out_fd, out_path = tempfile.mkstemp(
prefix=f"controller-canonical-{attempt_id}-", suffix=".json",
)
os.close(out_fd)
spawn = _spawn_mcp_subprocess(
mcp_module,
python_executable=python_executable,
startup_timeout_s=mcp_startup_timeout_s,
canonical_output_path=out_path,
)
# Phase 1k++ (N1): write a per-attempt sidecar so the startup
# janitor can detect orphaned MCP subprocesses + workspaces if
# this worker crashes. The starttime is read AFTER spawn so it
# reflects the actual /proc/{pid}/stat field 22 the janitor
# later compares against (defends against PID reuse).
sidecar_path: Path | None = None
if workspace_dir is not None:
# Phase 1k+++ (real-run fix): patch the input_payload's
# workspace_dir placeholder with the real path BEFORE the
# OpenCode session sees it. Without this, the prompt builder
# renders the literal placeholder string ("<worker-injected>")
# into the agent's prompt — the agent then has no idea where
# to clone the repo. We mutate a shallow copy so the caller's
# dict isn't side-effected.
input_payload = dict(input_payload)
input_payload["workspace_dir"] = str(workspace_dir)
try:
sidecar_path = Path(workspace_dir) / "worker.session"
write_sidecar(sidecar_path, WorkerSession(
opencode_server_url=opencode_server_url,
session_id="", # populated by the session adapter if/when known
subprocess_pid=spawn.process.pid,
spawned_by_controller_pid=os.getpid(),
instance_id=instance_id,
spawned_at=now_iso(),
subprocess_starttime=read_proc_starttime(spawn.process.pid),
))
except OSError as exc:
logger.warning(
"sidecar write failed for attempt_id=%s: %s — janitor "
"won't be able to detect orphans for this attempt",
attempt_id, exc,
)
sidecar_path = None
try:
# Hand the OpenCode session the MCP we just spawned + the
# input payload. The session's job: drive the LLM through
# its tool-calling sequence; the MCP captures the assembled
# state + emits canonical JSON via its finalize() tool.
try:
run_opencode_session(
role=role, tier=tier, input_payload=input_payload,
mcp_process=spawn.process,
attempt_id=attempt_id, instance_id=instance_id,
lost_lock_check=lost_lock_check,
)
except WorkerLostLock:
raise
except Exception as exc:
# Wrap so the runner's outer handler classifies properly.
raise WorkerError(
f"OpenCode session raised: {exc}",
outcome="worker-internal-error",
) from exc
if lost_lock_check():
raise WorkerLostLock("lost lock during MCP session")
# Wait for the MCP to emit its JSON to the canonical output
# file. Also poll the per-role fallback path under workspace_dir
# — used during the trial phase where the response-builder
# MCPs aren't wired into OpenCode and the agent writes its
# JSON via direct bash/edit.
fallback_paths: list[str] = []
if workspace_dir is not None:
fallback_paths.append(
str(Path(workspace_dir) / f"{role}_output.json")
)
canonical = _wait_for_canonical_output(
spawn, out_path, timeout_s=finalize_timeout_s,
fallback_paths=fallback_paths,
)
if canonical is None:
raise WorkerError(
f"role={role!r} did not emit canonical output within "
f"{finalize_timeout_s}s (looked at MCP path {out_path} "
f"+ fallback paths {fallback_paths})",
outcome="worker-internal-error",
)
try:
parsed = strict_parse(model_class, canonical)
except ContractValidationError as exc:
raise WorkerError(
f"MCP emitted JSON that failed {model_class.__name__} "
f"strict-parse: {exc}",
outcome="contract-violation",
) from exc
return parsed.model_dump()
finally:
_terminate_mcp_subprocess(spawn)
# Clean up the canonical output file (best-effort).
try:
os.unlink(out_path)
except OSError:
pass
# Sidecar cleanup: the attempt completed (success or failure),
# so the janitor shouldn't see it as orphaned. Best-effort —
# if removal fails, the janitor's PID-alive check will skip
# it cleanly on the next sweep.
if sidecar_path is not None:
try:
sidecar_path.unlink()
except OSError:
pass
# ─── MCP subprocess lifecycle ────────────────────────────────────────
def _spawn_mcp_subprocess(
mcp_module: str, *, python_executable: str, startup_timeout_s: float,
canonical_output_path: str,
) -> MCPSpawnResult:
"""Spawn ``python -m {mcp_module}`` with stdout captured via a
background reader thread.
``canonical_output_path`` is the file the MCP writes its canonical
JSON to (via the CONTROLLER_CANONICAL_OUTPUT_PATH env var).
Stdout is still captured (for transport/debug logging) but the
authoritative output goes to the file.
"""
repo_root = str(Path(__file__).resolve().parents[3])
env = dict(os.environ)
# Prepend repo_root to PYTHONPATH so ``-m tools.controller.mcp....`` works.
existing_pp = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = (
f"{repo_root}{os.pathsep}{existing_pp}" if existing_pp else repo_root
)
env["CONTROLLER_CANONICAL_OUTPUT_PATH"] = canonical_output_path
proc = subprocess.Popen(
[python_executable, "-m", mcp_module],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
cwd=repo_root,
)
stdout_buffer: list[str] = []
reader_thread = threading.Thread(
target=_drain_stream_into_list,
args=(proc.stdout, stdout_buffer),
name=f"mcp-stdout-{proc.pid}",
daemon=True,
)
reader_thread.start()
# Best-effort startup check: did the process die immediately?
# Don't actually wait the full timeout — production OpenCode sessions
# are long-running; we just verify the subprocess is alive.
if proc.poll() is not None:
raise WorkerError(
f"MCP subprocess died immediately (returncode={proc.returncode})",
outcome="worker-internal-error",
)
return MCPSpawnResult(
process=proc, stdout_buffer=stdout_buffer,
reader_thread=reader_thread,
)
def _drain_stream_into_list(stream, target: list[str]) -> None:
"""Read lines from ``stream`` into ``target`` until EOF.
Runs in a background thread. Each line (without trailing newline)
is appended. Robust against the stream closing mid-read.
"""
try:
for line in iter(stream.readline, ""):
if not line:
break
target.append(line.rstrip("\n"))
except (ValueError, OSError):
# Stream closed; thread exits.
pass
def _wait_for_canonical_output(
spawn: MCPSpawnResult, out_path: str, *, timeout_s: float,
fallback_paths: list[str] | None = None,
) -> str | None:
"""Poll the canonical-output file. Returns its content on first
non-empty read, or None on timeout.
``fallback_paths`` are additional paths to poll alongside
``out_path``. Used during the trial phase to support direct
file-write output from agents that can't call the MCP (the
response-builder MCPs aren't wired into OpenCode yet — see
prompts.py for the contract the agent follows).
The MCP writes the file atomically (open + write + fsync + close)
in its finalize_and_emit. We poll until the file exists + has
content OR the subprocess has been gone long enough to indicate
it's not going to write.
"""
import json as _json
import os as _os
import time
paths_to_poll = [out_path] + list(fallback_paths or [])
def _try_read_stable(p: str) -> str | None:
"""Read ``p`` only if size is stable across two stats AND the
content parses as JSON. Defends against partial writes (agent
crashed mid-flush; the file has truncated text). Returns the
content string on success; None if the file is missing, empty,
in-flight, or unparseable.
Round-4 P1 fix: the previous version did ``f.read().strip()``
and returned anything non-empty — a half-written file's
partial JSON would then trip ``ContractValidationError`` →
``worker-internal-error`` with no record of WHICH path.
"""
try:
sz1 = _os.stat(p).st_size
except (FileNotFoundError, OSError):
return None
if sz1 == 0:
return None
# Give the writer a moment in case the flush is still landing.
time.sleep(0.02)
try:
sz2 = _os.stat(p).st_size
except (FileNotFoundError, OSError):
return None
if sz1 != sz2:
return None # size still moving; wait for the next poll
try:
with open(p, "r", encoding="utf-8") as f:
content = f.read().strip()
except (FileNotFoundError, OSError):
return None
if not content:
return None
# Validate it parses as JSON — partial writes often produce
# syntactically broken content that strict_parse would later
# reject with a confusing error far from the read site.
try:
_json.loads(content)
except (TypeError, ValueError):
return None
return content
def _try_read_any() -> tuple[str | None, str | None]:
"""Returns (content, source_path) of the first stable+valid
path, or (None, None) if nothing's ready."""
for p in paths_to_poll:
content = _try_read_stable(p)
if content:
return content, p
return None, None
start = time.monotonic()
last_source: str | None = None
while True:
content, source = _try_read_any()
if content:
last_source = source
logger.debug(
"canonical output read from %s (%d bytes)",
source, len(content),
)
return content
# Subprocess gone + file still empty → give it one more brief
# window for the final flush to land, then give up.
if spawn.process.poll() is not None:
time.sleep(0.1)
content, source = _try_read_any()
if source:
logger.debug(
"canonical output read from %s after subprocess exit",
source,
)
return content
if time.monotonic() - start > timeout_s:
logger.warning(
"canonical output timeout after %.1fs (looked at: %s); "
"last successful read source: %s",
timeout_s, paths_to_poll, last_source,
)
return None
time.sleep(0.05)
def _terminate_mcp_subprocess(spawn: MCPSpawnResult) -> None:
"""Send SIGTERM + 2s grace + SIGKILL to ensure cleanup. Drain
the reader thread."""
proc = spawn.process
if proc.poll() is None:
try:
proc.terminate()
except ProcessLookupError:
pass
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
try:
proc.kill()
except ProcessLookupError:
pass
try:
proc.wait(timeout=2.0)
except subprocess.TimeoutExpired:
pass
# Reader thread will exit on its own when stdout closes.
spawn.reader_thread.join(timeout=2.0)
__all__ = [
"MCPSpawnResult",
"OpenCodeSessionRunner",
"ROLE_TO_MCP_MODULE",
"ROLE_TO_OUTPUT_MODEL",
"production_agent_runner",
]