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>
570 lines
21 KiB
Python
570 lines
21 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 .legacy_adapter import adapt_to_v1
|
|
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,
|
|
)
|
|
|
|
# Patch the input_payload so the prompt builder can interpolate
|
|
# real values (workspace_dir + attempt_id) into the agent's
|
|
# prompt. Without these, the prompt has literal "..." placeholders
|
|
# in the MCP tool-call signatures — the agent then guesses
|
|
# ``attempt_id=1`` every time, the cross-session reset can't tell
|
|
# sessions apart, stale MCP state persists, and every attempt
|
|
# after the first fails with "already finalized". (Real bug
|
|
# observed in trial run-2.) We mutate a shallow copy so the
|
|
# caller's dict isn't side-effected.
|
|
input_payload = dict(input_payload)
|
|
input_payload["attempt_id"] = attempt_id
|
|
if workspace_dir is not None:
|
|
input_payload["workspace_dir"] = str(workspace_dir)
|
|
|
|
# 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:
|
|
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
|
|
|
|
# CA1 stale-file cleanup: the per-PR workspace dir is reused across
|
|
# attempts on the same PR, so a prior attempt's
|
|
# ``{workspace_dir}/{role}_output.json`` would be picked up by the
|
|
# poller as if it were this attempt's output. Wipe BOTH the MCP
|
|
# canonical path (defensive) and the fallback paths before the
|
|
# session starts.
|
|
fallback_paths: list[str] = []
|
|
if workspace_dir is not None:
|
|
fallback_paths.append(
|
|
str(Path(workspace_dir) / f"{role}_output.json")
|
|
)
|
|
for stale in [out_path] + fallback_paths:
|
|
try:
|
|
os.unlink(stale)
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError as exc:
|
|
# PD-R2-7: a stale file we can't remove is a worse failure
|
|
# mode than aborting — the poller would read it as if it
|
|
# were this attempt's output. Hard-fail so the attempt
|
|
# surfaces the underlying permissions/filesystem bug.
|
|
raise WorkerError(
|
|
f"could not unlink stale output file {stale} before "
|
|
f"session: {exc}",
|
|
outcome="worker-internal-error",
|
|
) from exc
|
|
|
|
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.
|
|
#
|
|
# Trial-path: legacy-style agents emit a single JSON object
|
|
# as their final response message; the OpenCode worker
|
|
# extracts it into ``SessionResult.parsed_json``. We hand
|
|
# the opencode_session adapter a callback that writes that
|
|
# JSON to the canonical-output file so the post-session poller
|
|
# picks it up uniformly with the MCP + file-write channels.
|
|
import json as _json_mod
|
|
|
|
import time as _time
|
|
_session_start = _time.monotonic()
|
|
|
|
def _capture_inline_json(parsed: dict) -> None:
|
|
# CA7: skip if we lost the lock — file written after lock-loss
|
|
# would poison a subsequent attempt on this same workspace.
|
|
if lost_lock_check():
|
|
logger.info(
|
|
"inline-output capture skipped (lost lock) "
|
|
"for attempt_id=%s role=%s", attempt_id, role,
|
|
)
|
|
return
|
|
# PD3: if the MCP already wrote canonical V1 to out_path,
|
|
# do NOT overwrite. The MCP path is authoritative; the
|
|
# inline-chat path is a fallback for agents that don't call
|
|
# the MCP. Inspect any existing file at out_path first.
|
|
for p in [out_path] + list(fallback_paths):
|
|
try:
|
|
with open(p, "r", encoding="utf-8") as f:
|
|
existing = f.read().strip()
|
|
except (FileNotFoundError, OSError):
|
|
continue
|
|
if not existing:
|
|
continue
|
|
try:
|
|
existing_obj = _json_mod.loads(existing)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if (
|
|
isinstance(existing_obj, dict)
|
|
and existing_obj.get("output_version") == "V1"
|
|
):
|
|
logger.debug(
|
|
"MCP already emitted V1 to %s; skipping inline "
|
|
"capture (would overwrite authoritative output)",
|
|
p,
|
|
)
|
|
return
|
|
# Adapt the legacy-shape JSON to the V1 contract before
|
|
# writing. Without this, strict_parse against the V1
|
|
# model rejects the legacy shape (missing output_version,
|
|
# different outcome enum, etc.).
|
|
wallclock = _time.monotonic() - _session_start
|
|
adapted = adapt_to_v1(
|
|
role, parsed, tier=tier, wallclock_seconds=wallclock,
|
|
)
|
|
# PD10: atomic write via .tmp + os.replace so the poller
|
|
# never sees a half-written file.
|
|
tmp_path = f"{out_path}.tmp"
|
|
try:
|
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
f.write(_json_mod.dumps(adapted))
|
|
f.write("\n")
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp_path, out_path)
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"inline-output capture write failed (%s): canonical "
|
|
"poller will fall through to other channels",
|
|
exc,
|
|
)
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|
|
|
|
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,
|
|
inline_output_callback=_capture_inline_json,
|
|
)
|
|
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")
|
|
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",
|
|
]
|