Gate source-file downloads and verify registry paths and content hashes. Stream multipart uploads with bounded limits, supervise OpenCode jobs with explicit result markers and timeouts, and preserve managed endpoint security settings. Document the trusted-client boundary and add focused regression coverage. Validation: PYTHONPATH=<staged-tree> python -m unittest discover -s tests (182 tests)
332 lines
12 KiB
Python
332 lines
12 KiB
Python
"""Shared process supervision for OpenCode-backed MCP jobs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
|
|
class RuntimeLeaseError(RuntimeError):
|
|
"""Raised when another manager already owns a runtime directory."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProcessOutcome:
|
|
job_id: str
|
|
returncode: int
|
|
cancelled: bool = False
|
|
timed_out: bool = False
|
|
error: str = ""
|
|
|
|
|
|
@dataclass
|
|
class _ProcessEntry:
|
|
job_id: str
|
|
process: subprocess.Popen[Any]
|
|
deadline: float | None
|
|
on_complete: Callable[[ProcessOutcome], None]
|
|
on_callback_error: Callable[[ProcessOutcome, Exception], None] | None = None
|
|
completing: bool = False
|
|
|
|
|
|
class _RuntimeLease:
|
|
def __init__(self, runtime_root: Path) -> None:
|
|
runtime_root.mkdir(parents=True, exist_ok=True)
|
|
self.path = runtime_root / ".manager.lock"
|
|
self.handle = self.path.open("a+b")
|
|
try:
|
|
self._lock()
|
|
except OSError as exc:
|
|
self.handle.close()
|
|
raise RuntimeLeaseError(f"MCP runtime is already in use: {runtime_root}") from exc
|
|
|
|
def _lock(self) -> None:
|
|
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
import msvcrt
|
|
|
|
self.handle.seek(0)
|
|
if self.handle.read(1) == b"":
|
|
self.handle.write(b"0")
|
|
self.handle.flush()
|
|
self.handle.seek(0)
|
|
msvcrt.locking(self.handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
return
|
|
import fcntl
|
|
|
|
fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
|
|
def close(self) -> None:
|
|
if self.handle.closed:
|
|
return
|
|
try:
|
|
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
import msvcrt
|
|
|
|
self.handle.seek(0)
|
|
msvcrt.locking(self.handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
else:
|
|
import fcntl
|
|
|
|
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
|
finally:
|
|
self.handle.close()
|
|
|
|
|
|
class ProcessSupervisor:
|
|
"""Own subprocesses, enforce deadlines, and deliver one completion callback."""
|
|
|
|
def __init__(
|
|
self,
|
|
runtime_root: str | Path,
|
|
*,
|
|
popen_factory: Callable[..., subprocess.Popen[Any]] = subprocess.Popen,
|
|
poll_interval_seconds: float = 0.1,
|
|
) -> None:
|
|
self.runtime_root = Path(runtime_root).expanduser().resolve()
|
|
self._lease = _RuntimeLease(self.runtime_root)
|
|
self._popen_factory = popen_factory
|
|
self._poll_interval = max(0.01, float(poll_interval_seconds))
|
|
self._entries: dict[str, _ProcessEntry] = {}
|
|
self._lock = threading.RLock()
|
|
self._condition = threading.Condition(self._lock)
|
|
self._callbacks_in_progress = 0
|
|
self._wake = threading.Event()
|
|
self._closed = False
|
|
self._thread = threading.Thread(target=self._monitor, name="mcp-job-supervisor", daemon=True)
|
|
self._thread.start()
|
|
|
|
def start(
|
|
self,
|
|
job_id: str,
|
|
args: list[str],
|
|
*,
|
|
cwd: str | Path,
|
|
log_path: str | Path,
|
|
timeout_seconds: float,
|
|
on_complete: Callable[[ProcessOutcome], None],
|
|
on_callback_error: Callable[[ProcessOutcome, Exception], None] | None = None,
|
|
) -> subprocess.Popen[Any]:
|
|
with self._lock:
|
|
if self._closed:
|
|
raise RuntimeError("process supervisor is closed")
|
|
if job_id in self._entries:
|
|
raise ValueError(f"job is already supervised: {job_id}")
|
|
log = Path(log_path)
|
|
log.parent.mkdir(parents=True, exist_ok=True)
|
|
log_file = log.open("ab")
|
|
kwargs: dict[str, Any] = {
|
|
"cwd": str(cwd),
|
|
"stdout": log_file,
|
|
"stderr": subprocess.STDOUT,
|
|
}
|
|
if os.name == "nt": # pragma: no cover - exercised on Windows
|
|
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
else:
|
|
kwargs["start_new_session"] = True
|
|
try:
|
|
process = self._popen_factory(args, **kwargs)
|
|
finally:
|
|
log_file.close()
|
|
deadline = time.monotonic() + float(timeout_seconds) if timeout_seconds > 0 else None
|
|
self._entries[job_id] = _ProcessEntry(
|
|
job_id,
|
|
process,
|
|
deadline,
|
|
on_complete,
|
|
on_callback_error,
|
|
)
|
|
self._wake.set()
|
|
return process
|
|
|
|
def is_running(self, job_id: str | None = None) -> bool:
|
|
with self._lock:
|
|
if job_id is None:
|
|
return bool(self._entries)
|
|
return job_id in self._entries
|
|
|
|
def refresh(self) -> None:
|
|
for entry in self._snapshot():
|
|
code = entry.process.poll()
|
|
if code is not None:
|
|
self._complete_naturally(entry.job_id, code)
|
|
elif entry.deadline is not None and time.monotonic() >= entry.deadline:
|
|
self._complete_stopped(entry.job_id, timed_out=True, grace_seconds=1.0)
|
|
deadline = time.monotonic() + 1.0
|
|
with self._condition:
|
|
while self._callbacks_in_progress and time.monotonic() < deadline:
|
|
self._condition.wait(deadline - time.monotonic())
|
|
|
|
def cancel(self, job_id: str, *, grace_seconds: float = 5.0) -> bool:
|
|
return self._complete_stopped(job_id, cancelled=True, grace_seconds=grace_seconds)
|
|
|
|
def close(self) -> None:
|
|
with self._lock:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
job_ids = list(self._entries)
|
|
for job_id in job_ids:
|
|
self._complete_stopped(job_id, cancelled=True, grace_seconds=0.2)
|
|
self._wake.set()
|
|
if threading.current_thread() is not self._thread:
|
|
self._thread.join(timeout=1)
|
|
self._lease.close()
|
|
|
|
def _snapshot(self) -> list[_ProcessEntry]:
|
|
with self._lock:
|
|
return [entry for entry in self._entries.values() if not entry.completing]
|
|
|
|
def _begin_completion(self, job_id: str) -> _ProcessEntry | None:
|
|
with self._lock:
|
|
entry = self._entries.get(job_id)
|
|
if entry is None or entry.completing:
|
|
return None
|
|
entry.completing = True
|
|
self._callbacks_in_progress += 1
|
|
return entry
|
|
|
|
def _complete_naturally(self, job_id: str, returncode: int) -> bool:
|
|
entry = self._begin_completion(job_id)
|
|
if entry is None:
|
|
return False
|
|
self._finish(entry, ProcessOutcome(job_id=job_id, returncode=int(returncode)))
|
|
return True
|
|
|
|
def _complete_stopped(
|
|
self,
|
|
job_id: str,
|
|
*,
|
|
cancelled: bool = False,
|
|
timed_out: bool = False,
|
|
grace_seconds: float,
|
|
) -> bool:
|
|
entry = self._begin_completion(job_id)
|
|
if entry is None:
|
|
return False
|
|
returncode, error = self._terminate_process(entry.process, grace_seconds=grace_seconds)
|
|
self._finish(
|
|
entry,
|
|
ProcessOutcome(
|
|
job_id=job_id,
|
|
returncode=returncode,
|
|
cancelled=cancelled,
|
|
timed_out=timed_out,
|
|
error=error,
|
|
),
|
|
)
|
|
return True
|
|
|
|
def _terminate_process(self, process: subprocess.Popen[Any], *, grace_seconds: float) -> tuple[int, str]:
|
|
error = ""
|
|
if process.poll() is None:
|
|
try:
|
|
if os.name != "nt":
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
else: # pragma: no cover - exercised on Windows
|
|
process.terminate()
|
|
except (OSError, ProcessLookupError):
|
|
try:
|
|
process.terminate()
|
|
except OSError as exc:
|
|
error = str(exc)
|
|
try:
|
|
process.wait(timeout=max(0, grace_seconds))
|
|
except subprocess.TimeoutExpired:
|
|
try:
|
|
if os.name != "nt":
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
else: # pragma: no cover - exercised on Windows
|
|
process.kill()
|
|
except (OSError, ProcessLookupError):
|
|
try:
|
|
process.kill()
|
|
except OSError as exc:
|
|
error = error or str(exc)
|
|
try:
|
|
process.wait(timeout=1)
|
|
except subprocess.TimeoutExpired:
|
|
error = error or "process did not exit after SIGKILL"
|
|
code = process.poll()
|
|
return (int(code) if code is not None else -9), error
|
|
|
|
def _finish(self, entry: _ProcessEntry, outcome: ProcessOutcome) -> None:
|
|
with self._lock:
|
|
current = self._entries.get(entry.job_id)
|
|
if current is not entry:
|
|
return
|
|
self._entries.pop(entry.job_id, None)
|
|
try:
|
|
entry.on_complete(outcome)
|
|
except Exception as exc:
|
|
if entry.on_callback_error is not None:
|
|
try:
|
|
entry.on_callback_error(outcome, exc)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
with self._condition:
|
|
self._callbacks_in_progress -= 1
|
|
self._condition.notify_all()
|
|
self._wake.set()
|
|
|
|
def _monitor(self) -> None:
|
|
while True:
|
|
with self._lock:
|
|
if self._closed and not self._entries:
|
|
return
|
|
self.refresh()
|
|
self._wake.wait(self._poll_interval)
|
|
self._wake.clear()
|
|
|
|
|
|
def extract_final_answer(path: str | Path, *, limit: int = 12000) -> str:
|
|
"""Extract only assistant text events, preferring the final-answer phase."""
|
|
log_path = Path(path)
|
|
if not log_path.is_file():
|
|
return ""
|
|
last_text = ""
|
|
final_text = ""
|
|
with log_path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
for raw in handle:
|
|
try:
|
|
event = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if event.get("type") != "text":
|
|
continue
|
|
part = event.get("part") or {}
|
|
text = str(part.get("text") or "")
|
|
if not text:
|
|
continue
|
|
last_text = text
|
|
phase = ((part.get("metadata") or {}).get("openai") or {}).get("phase")
|
|
if phase == "final_answer":
|
|
final_text = text
|
|
text = final_text or last_text
|
|
if limit <= 0 or len(text) <= limit:
|
|
return text if limit > 0 else ""
|
|
first_line, separator, remainder = text.partition("\n")
|
|
if not separator or len(first_line) + 1 >= limit:
|
|
return text[:limit]
|
|
tail_size = limit - len(first_line) - 1
|
|
return first_line + "\n" + remainder[-tail_size:]
|
|
|
|
|
|
def parse_marked_result(text: str, *, success_marker: str, failure_marker: str) -> tuple[bool, str, str]:
|
|
"""Return protocol success, public text, and an error string."""
|
|
value = str(text or "").strip()
|
|
first, _, remainder = value.partition("\n")
|
|
if first.strip() == success_marker:
|
|
return True, remainder.strip(), ""
|
|
if first.strip() == failure_marker:
|
|
error = remainder.strip() or f"OpenCode returned {failure_marker}"
|
|
return False, "", error
|
|
return False, "", "OpenCode final answer did not include the required result marker"
|