Files
MemeMeow/executor/analysis_monitor.py

167 lines
7.1 KiB
Python

# Executor 进程循环中的分析用量控制,负责就绪检查及终止原因判定。
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
import json
import os
from pathlib import Path
import socket
import struct
import time
from executor.analysis_usage import AnalysisUsageError, read_analysis_usage
PLUGIN_VERSION = "1.18.18"
class AnalysisControlError(RuntimeError):
"""携带可公开错误码和受保护原因,交给现有 supervisor 回收进程。"""
def __init__(self, code: str, reason: str):
"""保存控制失败类别和阶段,不包含模型正文。"""
self.code, self.reason = code, reason
super().__init__(reason)
@dataclass
class AnalysisMonitor:
"""当前 attempt 的检查状态;恢复输入保留原策略和最近可信金额。"""
attempt_id: str
policy: dict[str, object]
database: Path
directory: Path
status_path: Path
startup_deadline: float
status_socket: socket.socket | None = None
expected_pid: int | None = None
observed_cost: Decimal = Decimal(0)
usage_checked_at: str | None = None
reminder_sent: bool = False
ready: bool = False
next_check: float = 0
reminder_error: str | None = None
reminder_error_detail: str | None = None
failure_reason: str | None = None
def check(self, session_id: str | None, *, exited: bool = False) -> None:
"""轮询当前 attempt 的就绪及金额;终止原因通过异常交给进程管理者。"""
now = time.monotonic()
if not exited and now < self.next_check:
return
self.next_check = now + 0.25
status = None
if self.status_socket is not None:
status = self._read_socket_status()
elif self.status_path.exists():
try:
status = json.loads(self.status_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, ValueError) as exc:
raise AnalysisControlError("agent_analysis_reminder_plugin_unavailable", "plugin_status_unreadable") from exc
if status is not None:
if (
not isinstance(status, dict)
or status.get("attempt_id") != self.attempt_id
or status.get("plugin_version") != PLUGIN_VERSION
or status.get("policy_version") != self.policy["version"]
):
raise AnalysisControlError("agent_analysis_reminder_plugin_unavailable", "plugin_attempt_binding_mismatch")
self.ready = status.get("ready") is True
self.reminder_sent = self.reminder_sent or status.get("reminder_sent") is True
self.reminder_error = status.get("error") if isinstance(status.get("error"), str) else None
self.reminder_error_detail = status.get("error_detail") if isinstance(status.get("error_detail"), str) else None
if not self.ready and self.reminder_error:
if self.reminder_error not in {
"analysis_plugin_runtime_version_incompatible",
"analysis_plugin_initialization_failed",
}:
raise AnalysisControlError("agent_analysis_reminder_plugin_unavailable", "plugin_error_status_invalid")
raise AnalysisControlError("agent_analysis_reminder_plugin_unavailable", self.reminder_error)
if session_id and status.get("session_id") not in (None, session_id):
raise AnalysisControlError("agent_analysis_usage_unavailable", "plugin_session_binding_mismatch")
if session_id:
try:
usage = read_analysis_usage(
self.database, session_id=session_id, directory=self.directory,
minimum_cost=self.observed_cost,
)
except AnalysisUsageError as exc:
raise AnalysisControlError(exc.code, exc.reason) from exc
self.observed_cost = usage.observed_cost
self.usage_checked_at = usage.checked_at
elif exited or now >= self.startup_deadline:
raise AnalysisControlError("agent_analysis_usage_unavailable", "session_binding_deadline_exceeded")
if self.observed_cost >= Decimal(str(self.policy["termination_cost"])):
raise AnalysisControlError("agent_maximum_analysis_depth_exceeded", "termination_cost_reached")
if not self.ready and (exited or now >= self.startup_deadline):
reason = "process_exited_before_plugin_ready" if exited else "plugin_readiness_deadline_exceeded"
raise AnalysisControlError("agent_analysis_reminder_plugin_unavailable", reason)
def _read_socket_status(self) -> dict[str, object] | None:
"""读取 Executor 创建的状态 socket。
由 AnalysisMonitor.check 轮询调用;无可读连接时返回 None,收到合法状态时返回 JSON 对象。
"""
assert self.status_socket is not None
self.status_socket.setblocking(False)
try:
connection, _address = self.status_socket.accept()
except BlockingIOError:
return None
try:
connection.settimeout(0.5)
peer_pid, _peer_uid, _peer_gid = struct.unpack(
"3i",
connection.getsockopt(
socket.SOL_SOCKET,
socket.SO_PEERCRED,
struct.calcsize("3i"),
),
)
if self.expected_pid is not None and peer_pid != self.expected_pid:
try:
expected_session = os.getsid(self.expected_pid)
peer_session = os.getsid(peer_pid)
except OSError:
return None
if peer_session != expected_session:
return None
if self.expected_pid is None:
self.expected_pid = peer_pid
chunks: list[bytes] = []
try:
while True:
chunk = connection.recv(65536)
if not chunk:
break
chunks.append(chunk)
if sum(map(len, chunks)) > 65536:
raise AnalysisControlError(
"agent_analysis_reminder_plugin_unavailable",
"plugin_status_too_large",
)
except socket.timeout as exc:
raise AnalysisControlError(
"agent_analysis_reminder_plugin_unavailable",
"plugin_status_read_timeout",
) from exc
try:
value = json.loads(b"".join(chunks).decode("utf-8"))
except (UnicodeError, ValueError) as exc:
raise AnalysisControlError(
"agent_analysis_reminder_plugin_unavailable",
"plugin_status_unreadable",
) from exc
if not isinstance(value, dict):
raise AnalysisControlError(
"agent_analysis_reminder_plugin_unavailable",
"plugin_status_unreadable",
)
return value
finally:
connection.close()