mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-17 04:41:01 +08:00
2008 lines
106 KiB
Python
2008 lines
106 KiB
Python
"""受控 OpenCode executor HTTP 服务。
|
||
|
||
该模块运行在 ``mememeow-agent-runtime`` 容器内,是后端与 OpenCode 之间的
|
||
唯一执行边界。它只接受固定字段的研究任务,不接受 shell、任意命令、任意
|
||
环境变量或任意工作目录;任务结果仍通过共享 ``/runtime/task-results`` 文件
|
||
协议交付给后端。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import os
|
||
import re
|
||
import signal
|
||
import socket
|
||
import stat
|
||
import subprocess
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
from concurrent.futures import Future
|
||
from dataclasses import dataclass, field
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from pathlib import Path, PurePosixPath
|
||
from typing import Any
|
||
from decimal import Decimal
|
||
from loguru import logger
|
||
from urllib.parse import unquote, urlsplit
|
||
from opentelemetry import metrics, trace
|
||
from runtime_telemetry import TelemetryConfig, extract_carrier, protected_content, start_telemetry, trace_carrier
|
||
from executor.sqlite_telemetry import SQLiteTelemetry
|
||
|
||
try:
|
||
from backend.opencode import RUNTIME_OPENCODE_CONFIG
|
||
except ModuleNotFoundError: # pragma: no cover - Agent 镜像只复制 executor 公共快照
|
||
from executor.runtime_opencode_config import RUNTIME_OPENCODE_CONFIG
|
||
from executor.model_capability import (
|
||
MODEL_BROKER_URL_ENV,
|
||
MODEL_CAPABILITY_FIELD,
|
||
MODEL_CAPABILITY_ENV,
|
||
ModelCapabilityError,
|
||
validate_model_broker_url,
|
||
validate_model_capability,
|
||
validate_model_name,
|
||
)
|
||
try:
|
||
from backend.opencode_workspace import (
|
||
SELECTOR_RE,
|
||
WorkspaceCapabilityError,
|
||
WorkspaceCapabilitySigner,
|
||
build_edit_permission_rules,
|
||
validate_directory_path,
|
||
validate_file_path,
|
||
)
|
||
except ModuleNotFoundError as exc: # pragma: no cover - Agent 镜像只复制 executor 快照
|
||
if exc.name != "backend":
|
||
raise
|
||
from executor.opencode_workspace import (
|
||
SELECTOR_RE,
|
||
WorkspaceCapabilityError,
|
||
WorkspaceCapabilitySigner,
|
||
build_edit_permission_rules,
|
||
validate_directory_path,
|
||
validate_file_path,
|
||
)
|
||
from executor.agent_limits import validate_agent_concurrency
|
||
from executor.analysis_policy import AnalysisPolicyError, parse_analysis_policy
|
||
from executor.analysis_monitor import AnalysisMonitor, AnalysisControlError
|
||
from executor.attempt_diagnostics import AttemptOutput, configure_executor_diagnostics, stream_sample as _stream_sample
|
||
from executor.process_supervisor import ProcessSupervisor
|
||
from executor.database_maintenance import DatabaseMaintenance
|
||
from executor.result_store import ExecutorResultStore, ExecutorResultStoreError
|
||
from executor.task_queue import ExecutionQueue
|
||
from executor.token import ExecutorTokenError, ensure_token_file, read_token_file
|
||
try:
|
||
from backend.public_dto import PublicDataError, secret_inventory_from_mapping, validate_agent_result
|
||
except ModuleNotFoundError as exc: # pragma: no cover - Agent 镜像只复制 executor 快照
|
||
if exc.name != "backend":
|
||
raise
|
||
from executor.public_dto import PublicDataError, secret_inventory_from_mapping, validate_agent_result
|
||
try:
|
||
from backend.visual_snapshot import VisualMatchSnapshotError, validate_visual_match_snapshot_manifest
|
||
except ModuleNotFoundError as exc: # pragma: no cover - Agent 镜像只复制无状态协议快照
|
||
if exc.name not in {"backend", "backend.visual_snapshot"}:
|
||
raise
|
||
try:
|
||
from executor.visual_snapshot import VisualMatchSnapshotError, validate_visual_match_snapshot_manifest
|
||
except ModuleNotFoundError:
|
||
# 最小验证镜像可能只复制 executor 公共快照;此时加载同一份无状态校验
|
||
# 实现,避免 executor 因可选视觉 manifest 文件缺失而无法启动。
|
||
class VisualMatchSnapshotError(ValueError):
|
||
"""最小 executor 镜像中的视觉 manifest 校验错误。"""
|
||
|
||
def validate_visual_match_snapshot_manifest(value: object, *, expected_sha256: str | None = None) -> dict[str, object]:
|
||
"""在无完整 backend 源码时拒绝无法验证的视觉 manifest。"""
|
||
del value, expected_sha256
|
||
raise VisualMatchSnapshotError("visual_match_snapshot_invalid")
|
||
|
||
TASK_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
|
||
SESSION_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,254}\Z")
|
||
CONFIG_HASH_RE = re.compile(r"[0-9a-fA-F]{64}\Z")
|
||
RESULT_FILE_NAME = "result.json.tmp"
|
||
RESULT_DRAFT_NAME = "result.json.draft"
|
||
ATTEMPT_METADATA_NAME = ".executor-attempts.json"
|
||
RUNTIME_ROOT = Path(os.getenv("MEMEMEOW_EXECUTOR_RUNTIME_ROOT", "/runtime"))
|
||
IMAGE_ROOT = Path(os.getenv("MEMEMEOW_EXECUTOR_IMAGE_ROOT", "/images"))
|
||
WORKSPACE = RUNTIME_ROOT / "workspace"
|
||
RESULT_ROOT = RUNTIME_ROOT / "task-results"
|
||
LOG_ROOT = RUNTIME_ROOT / "logs"
|
||
ANALYSIS_SOCKET_ROOT = RUNTIME_ROOT / "analysis-sockets"
|
||
SKILL_ROOT = Path(os.getenv("MEMEMEOW_EXECUTOR_SKILL_ROOT", "/skills/research-meme-context"))
|
||
WORKSPACE_ROOT = Path(os.getenv("MEMEMEOW_EXECUTOR_WORKSPACE_ROOT", str(RUNTIME_ROOT / "workspaces")))
|
||
DEFAULT_MAX_RESULT_BYTES = 1024 * 1024
|
||
MAX_CANDIDATE_FILE_BYTES = 64 * 1024 * 1024
|
||
MAX_CANDIDATE_ROOT_BYTES = 256 * 1024 * 1024
|
||
# session 只解析完整 JSONL 行;单行超过上限时跳过,避免异常输出占满内存。
|
||
SESSION_CAPTURE_LINE_BYTES = 1024 * 1024
|
||
ALLOWED_REQUEST_FIELDS = frozenset(
|
||
{
|
||
"task_id",
|
||
"business_task_id",
|
||
"executor_attempt_id",
|
||
"resume_of_attempt_id",
|
||
"session_id",
|
||
"processing_config_hash",
|
||
"image_relative_path",
|
||
"reverse_image_policy",
|
||
"timeout_seconds",
|
||
"wait",
|
||
"callback_token",
|
||
"trace_context",
|
||
"trace_attributes",
|
||
"workspace_selector",
|
||
"workspace_capability",
|
||
"visual_snapshot_sha256",
|
||
"analysis_policy",
|
||
MODEL_CAPABILITY_FIELD,
|
||
}
|
||
)
|
||
REQUIRED_RESULT_FIELDS = frozenset(
|
||
{"title", "summary", "subjects", "visible_text", "references", "meaning", "keywords", "search_queries", "uncertainties"}
|
||
)
|
||
TASK_HISTORY_LIMIT = 5000
|
||
_EXECUTOR_ERROR_CODES = frozenset(
|
||
{
|
||
"agent_analysis_policy_missing",
|
||
"agent_analysis_policy_invalid",
|
||
"agent_analysis_usage_unavailable",
|
||
"agent_analysis_reminder_plugin_unavailable",
|
||
"agent_maximum_analysis_depth_exceeded",
|
||
"agent_timeout",
|
||
"task_interrupted",
|
||
"agent_process_failed",
|
||
"agent_executor_internal_error",
|
||
"unknown_execution",
|
||
"agent_output_invalid_json",
|
||
"agent_result_file_missing",
|
||
"agent_result_file_unreadable",
|
||
"agent_result_file_too_large",
|
||
"agent_result_file_invalid_json",
|
||
"agent_result_file_schema_invalid",
|
||
"agent_result_path_invalid",
|
||
"agent_image_path_forbidden",
|
||
"agent_timeout_limit_exceeded",
|
||
"agent_runtime_unavailable",
|
||
"opencode_not_configured",
|
||
"invalid_task",
|
||
"invalid_reverse_image_policy",
|
||
"agent_backpressure",
|
||
"task_exists",
|
||
"agent_provider_rate_limited",
|
||
"agent_provider_server_error",
|
||
"agent_connection_interrupted",
|
||
"session_binding_mismatch",
|
||
"session_not_resumable",
|
||
"opencode_workspace_invalid",
|
||
"opencode_workspace_mismatch",
|
||
"opencode_workspace_capability_invalid",
|
||
"opencode_workspace_capability_expired",
|
||
"opencode_workspace_capability_unavailable",
|
||
"model_capability_invalid",
|
||
"model_capability_unavailable",
|
||
"model_broker_endpoint_invalid",
|
||
"model_name_invalid",
|
||
"visual_candidate_materialization_failed",
|
||
"visual_match_snapshot_invalid",
|
||
}
|
||
)
|
||
_SECRET_PATTERNS = (
|
||
# 分开处理 JSON 字符串值和普通 header/日志格式,保留原有引号结构。
|
||
re.compile(r"(?i)(authorization\s*[\"']?\s*:\s*[\"']?bearer\s+)[^\s,;}\]\"']+"),
|
||
re.compile(r"(?i)([\"']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password)[\"']?\s*:\s*)([\"'])(.*?)\2"),
|
||
re.compile(r"(?i)([\"']?(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password)[\"']?\s*[:=]\s*)[^\s,;}\]\"']+"),
|
||
)
|
||
_PATH_PATTERN = re.compile(r"(?:/runtime|/images|/skills|/app|[A-Za-z]:\\)[^\s,;]+")
|
||
_GENERIC_PATH_PATTERN = re.compile(r"(?<![A-Za-z0-9:/])/(?:[^\s,;:'\"()\[\]{}]+/)+[^\s,;:'\"()\[\]{}]+")
|
||
|
||
|
||
class _ProcessFailure(RuntimeError):
|
||
"""保存 OpenCode 进程失败的稳定错误码和受保护诊断。"""
|
||
|
||
def __init__(
|
||
self,
|
||
code: str,
|
||
message: str,
|
||
*,
|
||
http_status: int | None = None,
|
||
analysis_diagnostic: dict[str, object] | None = None,
|
||
):
|
||
"""初始化进程失败;诊断只在受信 Executor 调用链中传递。"""
|
||
super().__init__(message)
|
||
self.code = code
|
||
self.http_status = http_status
|
||
self.analysis_diagnostic = analysis_diagnostic
|
||
|
||
|
||
class _ResultFileValidationFailure(RuntimeError):
|
||
"""携带结果文件内部原因码的稳定 executor 异常。"""
|
||
|
||
def __init__(self, code: str, *, reason_code: str | None = None) -> None:
|
||
"""初始化结果文件失败;外部错误码仍保持原有协议。"""
|
||
super().__init__(code)
|
||
self.reason_code = reason_code
|
||
|
||
|
||
def _env_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
||
"""读取并限制 executor 的整数配置,非法值使用安全默认值。"""
|
||
try:
|
||
value = int(os.getenv(name, str(default)))
|
||
except (TypeError, ValueError):
|
||
return default
|
||
return max(minimum, min(value, maximum))
|
||
|
||
|
||
def _env_agent_concurrency(name: str, default: int) -> int:
|
||
"""读取 executor 的 Agent 运行并发,只校验正整数。"""
|
||
raw = os.getenv(name)
|
||
if raw is None:
|
||
value = default
|
||
else:
|
||
try:
|
||
value = int(raw)
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError("agent_concurrency_invalid") from exc
|
||
return validate_agent_concurrency(value)
|
||
|
||
|
||
def _json_error(code: str, message: str, *, reason_code: str | None = None) -> dict[str, object]:
|
||
"""构造不包含本地路径、命令或秘密的稳定错误响应。"""
|
||
error: dict[str, object] = {"error": code, "message": message}
|
||
if isinstance(reason_code, str) and re.fullmatch(r"[a-z0-9][a-z0-9_.-]{0,127}", reason_code):
|
||
error["reason_code"] = reason_code
|
||
return error
|
||
|
||
|
||
def _redact_diagnostic(value: str, secrets: tuple[str, ...] = ()) -> str:
|
||
"""从上游诊断中移除已知密钥和常见凭据格式,保留有限可读错误。"""
|
||
value = value.splitlines()[0] if value.splitlines() else value
|
||
for secret in secrets:
|
||
if secret:
|
||
value = value.replace(secret, "[REDACTED]")
|
||
for pattern in _SECRET_PATTERNS:
|
||
if pattern.groups == 3:
|
||
value = pattern.sub(r"\1\2[REDACTED]\2", value)
|
||
else:
|
||
value = pattern.sub(r"\1[REDACTED]", value)
|
||
value = _PATH_PATTERN.sub("[PATH]", value)
|
||
value = _GENERIC_PATH_PATTERN.sub("[PATH]", value)
|
||
return value[:500]
|
||
|
||
|
||
def _safe_json(value: Any) -> bytes:
|
||
"""序列化受控响应,避免响应中出现非 JSON 值。"""
|
||
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||
|
||
|
||
def _session_id_from_event(value: Any) -> str | None:
|
||
"""从 OpenCode JSONL 事件递归提取 session 标识。"""
|
||
if isinstance(value, dict):
|
||
for key in ("session_id", "sessionID", "sessionId"):
|
||
candidate = value.get(key)
|
||
if isinstance(candidate, str) and candidate:
|
||
return candidate
|
||
for nested in value.values():
|
||
found = _session_id_from_event(nested)
|
||
if found:
|
||
return found
|
||
elif isinstance(value, list):
|
||
for nested in value:
|
||
found = _session_id_from_event(nested)
|
||
if found:
|
||
return found
|
||
return None
|
||
|
||
|
||
def _diagnostic(stdout: bytes, stderr: bytes, *, secrets: tuple[str, ...] = ()) -> str:
|
||
"""从有限输出中提取安全诊断,不返回完整 transcript 或密钥。"""
|
||
text = stderr[:2048].decode("utf-8", errors="replace").strip()
|
||
if text:
|
||
return _redact_diagnostic(text, secrets)
|
||
for line in stdout.splitlines()[:128]:
|
||
try:
|
||
event = json.loads(line)
|
||
except (TypeError, json.JSONDecodeError):
|
||
continue
|
||
if isinstance(event, dict) and event.get("type") == "error":
|
||
error = event.get("error")
|
||
data = error.get("data") if isinstance(error, dict) else None
|
||
message = data.get("message") if isinstance(data, dict) else None
|
||
if isinstance(message, str) and message.strip():
|
||
return _redact_diagnostic(message.strip(), secrets)
|
||
return "OpenCode 进程执行失败"
|
||
|
||
|
||
def _relative_image_path(value: object) -> Path:
|
||
"""验证图片相对路径,拒绝绝对路径、父级跳转和符号链接入口。"""
|
||
if not isinstance(value, str) or not value or len(value) > 512 or "\\" in value or re.match(r"^[A-Za-z]:", value):
|
||
raise ValueError("agent_image_path_forbidden")
|
||
raw_parts = value.split("/")
|
||
if any(not part or part in {".", ".."} for part in raw_parts) or any(ord(character) < 0x20 for character in value):
|
||
raise ValueError("agent_image_path_forbidden")
|
||
normalized = PurePosixPath(value)
|
||
if normalized.is_absolute() or not normalized.parts:
|
||
raise ValueError("agent_image_path_forbidden")
|
||
return Path(*normalized.parts)
|
||
|
||
|
||
@dataclass
|
||
class TaskState:
|
||
"""一个 executor attempt 的受控状态和取消句柄。"""
|
||
|
||
task_id: str
|
||
business_task_id: str
|
||
executor_attempt_id: str
|
||
image_relative_path: str
|
||
reverse_image_policy: str
|
||
timeout_seconds: int
|
||
processing_config_hash: str | None = None
|
||
session_id: str | None = None
|
||
resume_of_attempt_id: str | None = None
|
||
is_resume: bool = False
|
||
callback_token: str | None = field(default=None, repr=False)
|
||
workspace_selector: str = "local"
|
||
workspace_capability: str | None = field(default=None, repr=False)
|
||
model_capability: str | None = field(default=None, repr=False)
|
||
workspace_directory: Path | None = field(default=None, repr=False)
|
||
images_root: Path | None = field(default=None, repr=False)
|
||
metadata_root: Path | None = field(default=None, repr=False)
|
||
skill_root: Path | None = field(default=None, repr=False)
|
||
candidate_root: Path | None = field(default=None, repr=False)
|
||
visual_snapshot_sha256: str | None = field(default=None, repr=False)
|
||
task_scratch_root: Path | None = field(default=None, repr=False)
|
||
config_file: Path | None = field(default=None, repr=False)
|
||
config_dir: Path | None = field(default=None, repr=False)
|
||
status: str = "queued"
|
||
created_at: float = field(default_factory=time.time)
|
||
started_at: float | None = None
|
||
completed_at: float | None = None
|
||
process_reaped: bool = True
|
||
error: dict[str, object] | None = None
|
||
result_path: str | None = None
|
||
process: subprocess.Popen[bytes] | None = field(default=None, repr=False)
|
||
cancel_event: threading.Event = field(default_factory=threading.Event, repr=False)
|
||
cancel_reason: str | None = None
|
||
done: threading.Event = field(default_factory=threading.Event, repr=False)
|
||
analysis_policy: dict[str, object] | None = field(default=None, repr=False)
|
||
observed_cost: str | None = None
|
||
usage_checked_at: str | None = None
|
||
reminder_sent: bool = False
|
||
database_maintenance: dict[str, object] | None = None
|
||
trace_context: dict[str, str] = field(default_factory=dict, repr=False)
|
||
trace_attributes: dict[str, str] = field(default_factory=dict, repr=False)
|
||
|
||
def public(self) -> dict[str, object]:
|
||
"""返回 API 可见状态,不暴露进程对象和 executor 本地实现细节。"""
|
||
return {
|
||
"task_id": self.business_task_id,
|
||
"business_task_id": self.business_task_id,
|
||
"executor_attempt_id": self.executor_attempt_id,
|
||
"status": self.status,
|
||
"image_relative_path": self.image_relative_path,
|
||
"reverse_image_policy": self.reverse_image_policy,
|
||
"session_id": self.session_id,
|
||
"resume_of_attempt_id": self.resume_of_attempt_id,
|
||
"processing_config_hash": self.processing_config_hash,
|
||
"is_resume": self.is_resume,
|
||
"result_path": self.result_path,
|
||
"error": self.error,
|
||
"created_at": self.created_at,
|
||
"started_at": self.started_at,
|
||
"completed_at": self.completed_at,
|
||
"process_reaped": self.process_reaped,
|
||
"workspace_selector": self.workspace_selector,
|
||
"observed_cost": self.observed_cost,
|
||
"usage_checked_at": self.usage_checked_at,
|
||
"reminder_sent": self.reminder_sent,
|
||
"database_maintenance": self.database_maintenance,
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class WorkspaceLayout:
|
||
"""executor 内部已验证的 selector 目录布局。"""
|
||
|
||
selector: str
|
||
directory: Path
|
||
images_root: Path
|
||
metadata_root: Path
|
||
skill_root: Path
|
||
candidate_root: Path
|
||
task_scratch_root: Path
|
||
config_file: Path
|
||
config_dir: Path
|
||
local: bool = False
|
||
|
||
@property
|
||
def candidate_manifest_path(self) -> Path:
|
||
"""返回当前 executor Task 固定的候选 manifest 路径。"""
|
||
return self.candidate_root / "manifest.json"
|
||
|
||
|
||
class Executor:
|
||
"""在容器内管理固定 OpenCode 任务接口的并发执行器。"""
|
||
|
||
def __init__(self) -> None:
|
||
"""初始化共享目录、认证配置和有限并发池。"""
|
||
# Agent 运行身份创建的 runtime 文件不应继承镜像默认 umask 的 group/other 位。
|
||
os.umask(0o077)
|
||
self.token_file = os.getenv("MEMEMEOW_AGENT_EXECUTOR_TOKEN_FILE", "").strip()
|
||
# 环境变量兼容显式 host/测试夹具,但空白 token 必须和 token 文件一样失败关闭。
|
||
self.token = os.getenv("MEMEMEOW_AGENT_EXECUTOR_TOKEN", "").strip()
|
||
self.token_error: str | None = None
|
||
if not self.token and self.token_file:
|
||
try:
|
||
self.token = ensure_token_file(self.token_file)
|
||
except ExecutorTokenError as exc:
|
||
self.token_error = str(exc)
|
||
try:
|
||
self.model = validate_model_name(os.getenv("MEMEMEOW_OPENCODE_MODEL", ""))
|
||
except ModelCapabilityError:
|
||
self.model = ""
|
||
self.release_profile = os.getenv("MEMEMEOW_PUBLIC_RELEASE_PROFILE", "local").strip().casefold()
|
||
self.model_broker_url = os.getenv(MODEL_BROKER_URL_ENV, "").strip()
|
||
self.legacy_base_url = os.getenv("MEMEMEOW_OPENCODE_BASE_URL", "").strip()
|
||
self.legacy_api_key = os.getenv("MEMEMEOW_OPENCODE_API_KEY", "").strip()
|
||
self.opencode_executable = os.getenv("MEMEMEOW_OPENCODE_EXECUTABLE", "opencode")
|
||
self.max_workers = _env_agent_concurrency("MEMEMEOW_OPENCODE_CONCURRENCY", 1)
|
||
self.max_timeout = _env_int("MEMEMEOW_AGENT_EXECUTOR_MAX_TIMEOUT_SECONDS", 1800, 1, 7200)
|
||
self.max_result_bytes = _env_int("MEMEMEOW_AGENT_RESULT_MAX_BYTES", DEFAULT_MAX_RESULT_BYTES, 1024, 16 * 1024 * 1024)
|
||
capability_key = os.getenv("MEMEMEOW_WORKSPACE_CAPABILITY_KEY", os.getenv("MEMEMEOW_AGENT_WORKSPACE_CAPABILITY_KEY", ""))
|
||
self.capability_signer = WorkspaceCapabilitySigner(capability_key)
|
||
# workspace root 只允许由部署预装配;executor 不因请求 selector 创建新的
|
||
# scope 目录。未配置时仍保留旧 local 请求兼容路径。
|
||
self.workspace_root = Path(os.getenv("MEMEMEOW_EXECUTOR_WORKSPACE_ROOT", str(WORKSPACE_ROOT)))
|
||
self.lock = threading.RLock()
|
||
self.tasks: dict[str, TaskState] = {}
|
||
# queue 只负责线程池生命周期;任务状态、attempt 和结果事实仍由 Executor
|
||
# 控制面维护,避免 HTTP 入口直接持有线程池细节。
|
||
self.pool = ExecutionQueue(self.max_workers, thread_name_prefix="mememeow-opencode")
|
||
self.process_supervisor = ProcessSupervisor()
|
||
self.database_maintenance = DatabaseMaintenance()
|
||
self.futures: dict[str, Future[None]] = {}
|
||
self.ready_error: str | None = None
|
||
self._prepare_runtime()
|
||
|
||
@property
|
||
def model_broker_configured(self) -> bool:
|
||
"""判断生产模型 broker endpoint 是否已配置且格式有效。"""
|
||
try:
|
||
validate_model_broker_url(self.model_broker_url)
|
||
except ModelCapabilityError:
|
||
return False
|
||
return True
|
||
|
||
@property
|
||
def model_configured(self) -> bool:
|
||
"""判断当前 profile 是否具备模型连接材料。
|
||
|
||
生产 executor 只能使用 broker endpoint 和任务 capability;local profile
|
||
保留旧 endpoint/key 夹具兼容,但该兼容值不会进入生产配置。
|
||
"""
|
||
if not self.model:
|
||
return False
|
||
if self.release_profile in {"production", "public", "1", "true", "yes", "on"}:
|
||
return self.model_broker_configured
|
||
return self.model_broker_configured or bool(self.legacy_base_url and self.legacy_api_key)
|
||
|
||
def _prepare_runtime(self) -> None:
|
||
"""创建共享 runtime 目录并确保 executor 以非 root 可写方式启动。"""
|
||
try:
|
||
for path in (RUNTIME_ROOT, WORKSPACE, RESULT_ROOT, LOG_ROOT, ANALYSIS_SOCKET_ROOT, RUNTIME_ROOT / "candidates", RUNTIME_ROOT / "home"):
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
database_path = RUNTIME_ROOT / "opencode.db"
|
||
database_path.touch(exist_ok=True)
|
||
os.chmod(database_path, stat.S_IRUSR | stat.S_IWUSR)
|
||
if any(not os.access(path, os.R_OK | os.W_OK) for path in (RUNTIME_ROOT, WORKSPACE, RESULT_ROOT, LOG_ROOT)):
|
||
self.ready_error = "runtime_not_writable"
|
||
return
|
||
if self.token_error:
|
||
self.ready_error = self.token_error
|
||
elif not self.token:
|
||
self.ready_error = "executor_token_not_configured"
|
||
elif not self.model_configured:
|
||
self.ready_error = "model_broker_not_configured"
|
||
elif not shutil_which(self.opencode_executable):
|
||
self.ready_error = "opencode_executable_missing"
|
||
except OSError:
|
||
self.ready_error = "runtime_not_ready"
|
||
|
||
def health(self) -> dict[str, object]:
|
||
"""返回真实 executor 健康状态,供 Compose 和后端探针使用。"""
|
||
runtime_ok = RUNTIME_ROOT.is_dir() and os.access(RUNTIME_ROOT, os.R_OK | os.W_OK)
|
||
workspace_ok = WORKSPACE.is_dir() and os.access(WORKSPACE, os.R_OK | os.W_OK)
|
||
result_ok = RESULT_ROOT.is_dir() and os.access(RESULT_ROOT, os.R_OK | os.W_OK)
|
||
logs_ok = LOG_ROOT.is_dir() and os.access(LOG_ROOT, os.R_OK | os.W_OK)
|
||
image_ok = IMAGE_ROOT.is_dir() and os.access(IMAGE_ROOT, os.R_OK) and not os.access(IMAGE_ROOT, os.W_OK)
|
||
skill_ok = SKILL_ROOT.is_dir() and os.access(SKILL_ROOT, os.R_OK) and not os.access(SKILL_ROOT, os.W_OK)
|
||
executable_ok = bool(shutil_which(self.opencode_executable))
|
||
socket_absent = not Path("/var/run/docker.sock").exists()
|
||
token_ok = bool(self.token)
|
||
if self.token_file:
|
||
try:
|
||
token_ok = hmac.compare_digest(read_token_file(self.token_file), self.token)
|
||
except ExecutorTokenError:
|
||
token_ok = False
|
||
ready = bool(not self.ready_error and token_ok and runtime_ok and workspace_ok and result_ok and logs_ok and image_ok and skill_ok and executable_ok and socket_absent)
|
||
return {
|
||
"status": "ok" if ready else "degraded",
|
||
"ready": ready,
|
||
"executor": "mememeow-agent-executor",
|
||
"opencode": executable_ok,
|
||
"runtime_read_write": runtime_ok and workspace_ok and result_ok and logs_ok,
|
||
"images_read_only": image_ok,
|
||
"skills_read_only": skill_ok,
|
||
"docker_socket_absent": socket_absent,
|
||
"token_configured": token_ok,
|
||
"opencode_configured": self.model_configured,
|
||
"model_broker_configured": self.model_broker_configured,
|
||
"capacity": self.max_workers,
|
||
"queued": self._queued_count(),
|
||
"error": self.ready_error,
|
||
}
|
||
|
||
def _queued_count(self) -> int:
|
||
"""返回尚未开始的任务数量,调用方可在锁外使用。"""
|
||
with self.lock:
|
||
return sum(1 for task in self.tasks.values() if task.status == "queued")
|
||
|
||
@staticmethod
|
||
def _checked_directory(path: Path, *, create: bool = False, missing_code: str = "opencode_workspace_invalid") -> Path:
|
||
"""在 executor 边界逐级拒绝符号链接、文件和越界目录。"""
|
||
try:
|
||
return validate_directory_path(path, create=create, code=missing_code)
|
||
except Exception as exc: # noqa: BLE001 - executor 对外只返回稳定错误码
|
||
raise ValueError(missing_code) from exc
|
||
|
||
def _workspace_layout(self, *, selector: str | None, business_task_id: str) -> WorkspaceLayout:
|
||
"""把 selector 解析到固定布局;未知 selector 不创建任何 scope 目录。"""
|
||
if selector is None:
|
||
directory = self._checked_directory(WORKSPACE)
|
||
images_root = self._checked_directory(IMAGE_ROOT)
|
||
metadata_root = images_root
|
||
skill_root = self._checked_directory(SKILL_ROOT)
|
||
base = directory
|
||
local = True
|
||
else:
|
||
if not isinstance(selector, str) or not SELECTOR_RE.fullmatch(selector):
|
||
raise ValueError("opencode_workspace_invalid")
|
||
if selector == "local":
|
||
directory = self._checked_directory(WORKSPACE)
|
||
images_root = self._checked_directory(IMAGE_ROOT)
|
||
metadata_root = images_root
|
||
skill_root = self._checked_directory(SKILL_ROOT)
|
||
local = True
|
||
task_scratch = directory / "tasks" / business_task_id
|
||
candidate_root = RUNTIME_ROOT / "candidates" / business_task_id
|
||
return WorkspaceLayout(
|
||
selector="local",
|
||
directory=directory,
|
||
images_root=images_root,
|
||
metadata_root=metadata_root,
|
||
skill_root=skill_root,
|
||
candidate_root=candidate_root,
|
||
task_scratch_root=task_scratch,
|
||
config_file=task_scratch / "opencode.json",
|
||
config_dir=task_scratch / ".opencode",
|
||
local=True,
|
||
)
|
||
root = self._checked_directory(self.workspace_root, missing_code="opencode_workspace_invalid")
|
||
base = root / selector
|
||
self._checked_directory(base, missing_code="opencode_workspace_invalid")
|
||
directory = self._checked_directory(base / "workspace")
|
||
images_root = self._checked_directory(base / "images")
|
||
metadata_root = self._checked_directory(base / "metadata")
|
||
skill_root = self._checked_directory(base / "skills")
|
||
local = False
|
||
task_scratch = directory / "tasks" / business_task_id
|
||
# 只检查祖先;创建由 _run 在锁定任务后完成,校验失败不会留下目录。
|
||
tasks_root = directory / "tasks"
|
||
try:
|
||
tasks_root.lstat()
|
||
except FileNotFoundError:
|
||
pass
|
||
else:
|
||
self._checked_directory(tasks_root)
|
||
config_dir = task_scratch / ".opencode"
|
||
candidate_parent = (base / "candidates") if not local else (base.parent / "candidates")
|
||
self._checked_directory(candidate_parent, create=True)
|
||
candidate_root = candidate_parent / business_task_id
|
||
if candidate_root.exists() or candidate_root.is_symlink():
|
||
self._checked_directory(candidate_root)
|
||
return WorkspaceLayout(
|
||
selector=selector or "local",
|
||
directory=directory,
|
||
images_root=images_root,
|
||
metadata_root=metadata_root,
|
||
skill_root=skill_root,
|
||
candidate_root=candidate_root,
|
||
task_scratch_root=task_scratch,
|
||
config_file=task_scratch / "opencode.json",
|
||
config_dir=config_dir,
|
||
local=local,
|
||
)
|
||
|
||
def _verify_workspace_capability(self, values: dict[str, object]) -> None:
|
||
"""校验 selector capability 的签名、受众、期限和 attempt 绑定。"""
|
||
selector = values.get("workspace_selector")
|
||
capability = values.get("workspace_capability")
|
||
if selector is None:
|
||
if capability is not None:
|
||
raise ValueError("opencode_workspace_mismatch")
|
||
return
|
||
if selector == "local" and capability is None:
|
||
# 显式 local 仍兼容旧单用户调用;非 local selector 永远需要签名材料。
|
||
return
|
||
if not isinstance(capability, str) or not capability:
|
||
raise ValueError("opencode_workspace_capability_unavailable")
|
||
if not self.capability_signer.configured:
|
||
raise ValueError("opencode_workspace_capability_unavailable")
|
||
try:
|
||
self.capability_signer.verify(
|
||
capability,
|
||
task_id=str(values["business_task_id"]),
|
||
attempt_id=str(values["executor_attempt_id"]),
|
||
selector=str(selector),
|
||
session_id=str(values["session_id"]) if isinstance(values.get("session_id"), str) else None,
|
||
resume_of_attempt_id=str(values["resume_of_attempt_id"]) if isinstance(values.get("resume_of_attempt_id"), str) else None,
|
||
)
|
||
except WorkspaceCapabilityError as exc:
|
||
raise ValueError(exc.code) from exc
|
||
|
||
def _verify_task_workspace_capability(self, task: TaskState) -> None:
|
||
"""在排队任务真正启动前再次验证 capability,避免长队列越过有效期。"""
|
||
if task.workspace_selector == "local":
|
||
return
|
||
if not task.workspace_capability or not self.capability_signer.configured:
|
||
raise RuntimeError("opencode_workspace_capability_unavailable")
|
||
try:
|
||
self.capability_signer.verify(
|
||
task.workspace_capability,
|
||
task_id=task.business_task_id,
|
||
attempt_id=task.executor_attempt_id,
|
||
selector=task.workspace_selector,
|
||
session_id=task.session_id,
|
||
resume_of_attempt_id=task.resume_of_attempt_id,
|
||
)
|
||
except WorkspaceCapabilityError as exc:
|
||
raise RuntimeError(exc.code) from exc
|
||
|
||
def _prune_history_locked(self) -> None:
|
||
"""限制内存中的终态历史,避免长期运行的 executor 被任务标识耗尽内存。"""
|
||
overflow = len(self.tasks) - TASK_HISTORY_LIMIT
|
||
if overflow <= 0:
|
||
return
|
||
terminal = sorted(
|
||
(task for task in self.tasks.values() if task.status in {"succeeded", "failed", "cancelled"}),
|
||
key=lambda task: (task.completed_at or task.created_at, task.created_at, task.task_id),
|
||
)
|
||
for task in terminal[:overflow]:
|
||
self.tasks.pop(task.executor_attempt_id, None)
|
||
self.futures.pop(task.executor_attempt_id, None)
|
||
|
||
def _attempt_metadata_path(self, business_task_id: str) -> Path:
|
||
"""返回任务专属 attempt 元数据路径,并验证业务标识可安全拼接。"""
|
||
if not TASK_ID_RE.fullmatch(business_task_id):
|
||
raise ValueError("agent_result_path_invalid")
|
||
root_info = RESULT_ROOT.lstat()
|
||
if stat.S_ISLNK(root_info.st_mode) or not stat.S_ISDIR(root_info.st_mode):
|
||
raise ValueError("agent_result_path_invalid")
|
||
result_dir = RESULT_ROOT / business_task_id
|
||
if result_dir.is_symlink() or (result_dir.exists() and not result_dir.is_dir()):
|
||
raise ValueError("agent_result_path_invalid")
|
||
return result_dir / ATTEMPT_METADATA_NAME
|
||
|
||
def _metadata_signature(self, attempts: list[dict[str, object]]) -> str:
|
||
"""使用 executor token 对可恢复 attempt 事实做完整性签名。"""
|
||
raw = json.dumps(attempts, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||
return hmac.new(self.token.encode("utf-8"), raw, hashlib.sha256).hexdigest()
|
||
|
||
def _load_attempt_metadata(self, business_task_id: str) -> list[dict[str, object]]:
|
||
"""读取并验证 runtime 中持久化的 attempt 绑定,损坏时返回空集合。"""
|
||
try:
|
||
path = self._attempt_metadata_path(business_task_id)
|
||
metadata = path.lstat()
|
||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode) or metadata.st_size > 256 * 1024:
|
||
return []
|
||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||
try:
|
||
raw = os.read(descriptor, 256 * 1024 + 1)
|
||
finally:
|
||
os.close(descriptor)
|
||
document = json.loads(raw.decode("utf-8"))
|
||
attempts = document.get("attempts") if isinstance(document, dict) else None
|
||
signature = document.get("signature") if isinstance(document, dict) else None
|
||
if not isinstance(attempts, list) or not isinstance(signature, str):
|
||
return []
|
||
values = [item for item in attempts if isinstance(item, dict)]
|
||
if len(values) != len(attempts) or not hmac.compare_digest(signature, self._metadata_signature(values)):
|
||
return []
|
||
return values[-32:]
|
||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError, RecursionError):
|
||
return []
|
||
|
||
def _persist_attempt_metadata(self, task: TaskState) -> None:
|
||
"""原子保存终态 attempt 的最小绑定事实,供 executor 重启后恢复。"""
|
||
# 同一业务任务的两个 attempt 可能几乎同时收束;锁住读-改-写序列,
|
||
# 避免后完成的 attempt 覆盖前一个失败事实,导致重启后无法续跑。
|
||
with self.lock:
|
||
self._persist_attempt_metadata_locked(task)
|
||
|
||
def _persist_attempt_metadata_locked(self, task: TaskState) -> None:
|
||
"""在 executor 锁内原子保存 attempt 元数据,调用者负责持有锁。"""
|
||
path = self._attempt_metadata_path(task.business_task_id)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
existing = self._load_attempt_metadata(task.business_task_id)
|
||
entry: dict[str, object] = {
|
||
"business_task_id": task.business_task_id,
|
||
"executor_attempt_id": task.executor_attempt_id,
|
||
"image_relative_path": task.image_relative_path,
|
||
"reverse_image_policy": task.reverse_image_policy,
|
||
"processing_config_hash": task.processing_config_hash,
|
||
"visual_snapshot_sha256": task.visual_snapshot_sha256,
|
||
"workspace_selector": task.workspace_selector,
|
||
"session_id": task.session_id,
|
||
"resume_of_attempt_id": task.resume_of_attempt_id,
|
||
"status": task.status,
|
||
"error": task.error,
|
||
"created_at": task.created_at,
|
||
"completed_at": task.completed_at,
|
||
"process_reaped": task.process_reaped,
|
||
"analysis_policy": task.analysis_policy,
|
||
"observed_cost": task.observed_cost,
|
||
"usage_checked_at": task.usage_checked_at,
|
||
"reminder_sent": task.reminder_sent,
|
||
"database_maintenance": task.database_maintenance,
|
||
}
|
||
values = [item for item in existing if item.get("executor_attempt_id") != task.executor_attempt_id]
|
||
values.append(entry)
|
||
document = {"attempts": values[-32:], "signature": self._metadata_signature(values[-32:])}
|
||
raw = _safe_json(document)
|
||
descriptor, temporary_name = tempfile.mkstemp(prefix=".executor-attempts.", dir=path.parent)
|
||
try:
|
||
with os.fdopen(descriptor, "wb") as handle:
|
||
handle.write(raw)
|
||
os.chmod(temporary_name, stat.S_IRUSR | stat.S_IWUSR)
|
||
os.replace(temporary_name, path)
|
||
except Exception:
|
||
try:
|
||
os.unlink(temporary_name)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
|
||
def _persisted_resume_source(self, values: dict[str, object]) -> TaskState | None:
|
||
"""从签名元数据恢复失败 attempt,供 executor 服务重启后续跑。"""
|
||
business_task_id = str(values["business_task_id"])
|
||
session_id = values.get("session_id")
|
||
resume_of = values.get("resume_of_attempt_id")
|
||
if not isinstance(session_id, str):
|
||
return None
|
||
for entry in reversed(self._load_attempt_metadata(business_task_id)):
|
||
if (
|
||
entry.get("business_task_id") != business_task_id
|
||
or entry.get("session_id") != session_id
|
||
or entry.get("image_relative_path") != values.get("image_relative_path")
|
||
or entry.get("reverse_image_policy") != values.get("reverse_image_policy")
|
||
or entry.get("processing_config_hash") != values.get("processing_config_hash")
|
||
or entry.get("visual_snapshot_sha256") != values.get("visual_snapshot_sha256")
|
||
or entry.get("workspace_selector", "local") != (values.get("workspace_selector") or "local")
|
||
or (resume_of is not None and entry.get("executor_attempt_id") != resume_of)
|
||
):
|
||
continue
|
||
if entry.get("status") != "failed" or entry.get("process_reaped") is not True or not isinstance(entry.get("executor_attempt_id"), str):
|
||
continue
|
||
error = entry.get("error") if isinstance(entry.get("error"), dict) else {}
|
||
if error.get("error") not in {
|
||
"service_shutdown",
|
||
"agent_provider_rate_limited",
|
||
"agent_provider_server_error",
|
||
"agent_connection_interrupted",
|
||
"agent_process_failed",
|
||
}:
|
||
continue
|
||
return TaskState(
|
||
task_id=business_task_id,
|
||
business_task_id=business_task_id,
|
||
executor_attempt_id=str(entry["executor_attempt_id"]),
|
||
image_relative_path=str(entry["image_relative_path"]),
|
||
reverse_image_policy=str(entry["reverse_image_policy"]),
|
||
timeout_seconds=int(values["timeout_seconds"]),
|
||
processing_config_hash=values.get("processing_config_hash") if isinstance(values.get("processing_config_hash"), str) else None,
|
||
visual_snapshot_sha256=values.get("visual_snapshot_sha256") if isinstance(values.get("visual_snapshot_sha256"), str) else None,
|
||
session_id=session_id,
|
||
workspace_selector=str(values.get("workspace_selector") or "local"),
|
||
status="failed",
|
||
error={str(key): value for key, value in error.items()},
|
||
created_at=float(entry.get("created_at")) if isinstance(entry.get("created_at"), (int, float)) else time.time(),
|
||
completed_at=float(entry.get("completed_at")) if isinstance(entry.get("completed_at"), (int, float)) else time.time(),
|
||
process_reaped=True,
|
||
analysis_policy=entry.get("analysis_policy"),
|
||
observed_cost=entry.get("observed_cost"),
|
||
usage_checked_at=entry.get("usage_checked_at"),
|
||
)
|
||
return None
|
||
|
||
def _validate_request(self, payload: object) -> dict[str, object]:
|
||
"""校验固定任务字段并返回业务任务与 attempt 的规范化参数。"""
|
||
if not isinstance(payload, dict):
|
||
raise ValueError("invalid_task")
|
||
unknown = set(payload) - ALLOWED_REQUEST_FIELDS
|
||
if unknown:
|
||
raise ValueError("invalid_task")
|
||
task_id = payload.get("task_id")
|
||
if not isinstance(task_id, str) or not TASK_ID_RE.fullmatch(task_id):
|
||
raise ValueError("agent_result_path_invalid")
|
||
business_task_id = payload.get("business_task_id", task_id)
|
||
if not isinstance(business_task_id, str) or not TASK_ID_RE.fullmatch(business_task_id) or business_task_id != task_id:
|
||
raise ValueError("invalid_task")
|
||
raw_attempt_id = payload.get("executor_attempt_id")
|
||
# 旧客户端没有 attempt 字段时使用业务 ID 作为兼容别名;新客户端总是
|
||
# 发送独立 attempt,因而终态历史不会被恢复请求复用。
|
||
executor_attempt_id = raw_attempt_id if raw_attempt_id is not None else business_task_id
|
||
if not isinstance(executor_attempt_id, str) or not TASK_ID_RE.fullmatch(executor_attempt_id):
|
||
raise ValueError("invalid_task")
|
||
raw_resume_of = payload.get("resume_of_attempt_id")
|
||
if raw_resume_of is not None and (
|
||
not isinstance(raw_resume_of, str) or not TASK_ID_RE.fullmatch(raw_resume_of)
|
||
):
|
||
raise ValueError("invalid_task")
|
||
session_id = payload.get("session_id")
|
||
if session_id is not None and (not isinstance(session_id, str) or not SESSION_ID_RE.fullmatch(session_id)):
|
||
raise ValueError("session_binding_mismatch")
|
||
if raw_resume_of is not None and session_id is None:
|
||
raise ValueError("session_binding_mismatch")
|
||
processing_config_hash = payload.get("processing_config_hash")
|
||
if processing_config_hash is not None and (
|
||
not isinstance(processing_config_hash, str) or not CONFIG_HASH_RE.fullmatch(processing_config_hash)
|
||
):
|
||
raise ValueError("invalid_task")
|
||
visual_snapshot_sha256 = payload.get("visual_snapshot_sha256")
|
||
if visual_snapshot_sha256 is not None and (
|
||
not isinstance(visual_snapshot_sha256, str) or not CONFIG_HASH_RE.fullmatch(visual_snapshot_sha256)
|
||
):
|
||
raise ValueError("visual_match_snapshot_invalid")
|
||
relative = _relative_image_path(payload.get("image_relative_path"))
|
||
raw_selector = payload.get("workspace_selector")
|
||
if raw_selector is None or raw_selector == "local":
|
||
image = IMAGE_ROOT / relative
|
||
try:
|
||
current = IMAGE_ROOT
|
||
for part in relative.parts:
|
||
current = current / part
|
||
if current.is_symlink():
|
||
raise ValueError("agent_image_path_forbidden")
|
||
root = IMAGE_ROOT.resolve()
|
||
candidate = image.resolve(strict=True)
|
||
candidate.relative_to(root)
|
||
if not candidate.is_file():
|
||
raise ValueError("agent_image_path_forbidden")
|
||
except (OSError, RuntimeError, ValueError) as exc:
|
||
if isinstance(exc, ValueError) and str(exc) == "agent_image_path_forbidden":
|
||
raise
|
||
raise ValueError("agent_image_path_forbidden") from exc
|
||
policy = payload.get("reverse_image_policy", "forbid")
|
||
if policy not in {"forbid", "auto"}:
|
||
raise ValueError("invalid_reverse_image_policy")
|
||
raw_timeout = payload.get("timeout_seconds", self.max_timeout)
|
||
if isinstance(raw_timeout, bool):
|
||
raise ValueError("invalid_task")
|
||
try:
|
||
timeout = int(raw_timeout)
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError("invalid_task") from exc
|
||
if timeout < 1 or timeout > self.max_timeout:
|
||
raise ValueError("agent_timeout_limit_exceeded")
|
||
wait = payload.get("wait", True)
|
||
if not isinstance(wait, bool):
|
||
raise ValueError("invalid_task")
|
||
callback_token = payload.get("callback_token")
|
||
if callback_token is not None and (
|
||
not isinstance(callback_token, str)
|
||
or len(callback_token) > 4096
|
||
or any(character.isspace() or ord(character) < 0x20 for character in callback_token)
|
||
):
|
||
raise ValueError("invalid_task")
|
||
workspace_selector = raw_selector
|
||
if workspace_selector is not None and (not isinstance(workspace_selector, str) or not SELECTOR_RE.fullmatch(workspace_selector)):
|
||
raise ValueError("opencode_workspace_invalid")
|
||
workspace_capability = payload.get("workspace_capability")
|
||
if workspace_capability is not None and (not isinstance(workspace_capability, str) or not workspace_capability or len(workspace_capability) > 4096):
|
||
raise ValueError("opencode_workspace_capability_invalid")
|
||
model_capability = payload.get(MODEL_CAPABILITY_FIELD)
|
||
if model_capability is not None:
|
||
try:
|
||
model_capability = validate_model_capability(model_capability)
|
||
except ModelCapabilityError as exc:
|
||
raise ValueError(str(exc)) from exc
|
||
elif self.release_profile in {"production", "public", "1", "true", "yes", "on"} or self.model_broker_configured:
|
||
raise ValueError("model_capability_unavailable")
|
||
values = {
|
||
"task_id": business_task_id,
|
||
"business_task_id": business_task_id,
|
||
"executor_attempt_id": executor_attempt_id,
|
||
"resume_of_attempt_id": raw_resume_of,
|
||
"session_id": session_id,
|
||
"processing_config_hash": processing_config_hash.lower() if isinstance(processing_config_hash, str) else None,
|
||
"visual_snapshot_sha256": visual_snapshot_sha256.lower() if isinstance(visual_snapshot_sha256, str) else None,
|
||
"image_relative_path": relative.as_posix(),
|
||
"reverse_image_policy": str(policy),
|
||
"timeout_seconds": timeout,
|
||
"wait": wait,
|
||
"callback_token": callback_token,
|
||
"workspace_selector": workspace_selector,
|
||
"workspace_capability": workspace_capability,
|
||
MODEL_CAPABILITY_FIELD: model_capability,
|
||
"trace_context": {key: value for key, value in (payload.get("trace_context") or {}).items() if key in {"traceparent", "tracestate"} and isinstance(value, str) and len(value) <= 512} if isinstance(payload.get("trace_context"), dict) else {},
|
||
"trace_attributes": {key: value for key, value in (payload.get("trace_attributes") or {}).items() if key in {"account.id", "scope.id"} and isinstance(value, str) and len(value) <= 128} if isinstance(payload.get("trace_attributes"), dict) else {},
|
||
}
|
||
self._verify_workspace_capability(values)
|
||
policy = payload.get("analysis_policy")
|
||
if policy is not None:
|
||
try:
|
||
parsed = parse_analysis_policy(policy)
|
||
except AnalysisPolicyError as exc:
|
||
raise ValueError(exc.code) from exc
|
||
if parsed.model != self.model or parsed.variant != "max":
|
||
raise ValueError("agent_analysis_policy_invalid")
|
||
if self.release_profile in {"production", "public", "1", "true", "yes", "on"} and not self.model_broker_configured:
|
||
raise ValueError("model_broker_endpoint_invalid")
|
||
if model_capability is not None and not self.model_broker_configured:
|
||
raise ValueError("model_broker_endpoint_invalid")
|
||
values["analysis_policy"] = parsed.model_dump(mode="json")
|
||
layout = self._workspace_layout(selector=workspace_selector if isinstance(workspace_selector, str) else None, business_task_id=business_task_id)
|
||
image = layout.images_root / relative
|
||
try:
|
||
current = layout.images_root
|
||
for part in relative.parts:
|
||
current = current / part
|
||
info = current.lstat()
|
||
if stat.S_ISLNK(info.st_mode):
|
||
raise ValueError("agent_image_path_forbidden")
|
||
info = image.lstat()
|
||
if not stat.S_ISREG(info.st_mode):
|
||
raise ValueError("agent_image_path_forbidden")
|
||
except (OSError, ValueError) as exc:
|
||
if isinstance(exc, ValueError) and str(exc) == "agent_image_path_forbidden":
|
||
raise
|
||
raise ValueError("agent_image_path_forbidden") from exc
|
||
values["workspace_layout"] = layout
|
||
return values
|
||
|
||
def _resume_source(self, values: dict[str, object]) -> TaskState | None:
|
||
"""查找与续跑 session 完全绑定的失败 attempt,拒绝跨任务猜测。"""
|
||
session_id = values.get("session_id")
|
||
if not isinstance(session_id, str):
|
||
return None
|
||
business_task_id = values["business_task_id"]
|
||
resume_of = values.get("resume_of_attempt_id")
|
||
matches = [
|
||
task
|
||
for task in self.tasks.values()
|
||
if task.session_id == session_id
|
||
and task.business_task_id == business_task_id
|
||
and task.image_relative_path == values["image_relative_path"]
|
||
and task.reverse_image_policy == values["reverse_image_policy"]
|
||
and task.processing_config_hash == values.get("processing_config_hash")
|
||
and task.visual_snapshot_sha256 == values.get("visual_snapshot_sha256")
|
||
and task.workspace_selector == (values.get("workspace_selector") or "local")
|
||
and (resume_of is None or task.executor_attempt_id == resume_of)
|
||
]
|
||
if not matches:
|
||
persisted = self._persisted_resume_source(values)
|
||
if persisted is not None:
|
||
return persisted
|
||
# 同一 session 若属于其它业务任务,明确返回绑定错误;完全未知的
|
||
# session 则返回不可续跑,避免把最近会话当作恢复目标。
|
||
if any(task.session_id == session_id for task in self.tasks.values()):
|
||
raise RuntimeError("session_binding_mismatch")
|
||
raise RuntimeError("session_not_resumable")
|
||
source = max(matches, key=lambda task: (task.completed_at or task.created_at, task.created_at))
|
||
if source.status != "failed":
|
||
raise RuntimeError("session_not_resumable")
|
||
if source.process_reaped is not True or not source.done.is_set():
|
||
raise RuntimeError("session_not_resumable")
|
||
source_error = (source.error or {}).get("error")
|
||
if source_error not in {
|
||
"service_shutdown",
|
||
"agent_provider_rate_limited",
|
||
"agent_provider_server_error",
|
||
"agent_connection_interrupted",
|
||
"agent_process_failed",
|
||
}:
|
||
raise RuntimeError("session_not_resumable")
|
||
return source
|
||
|
||
def submit(self, payload: object) -> tuple[TaskState, bool]:
|
||
"""创建固定研究任务并交给受限线程池,返回状态和同步等待标记。"""
|
||
values = self._validate_request(payload)
|
||
with self.lock:
|
||
self._prune_history_locked()
|
||
if not self.health().get("ready"):
|
||
raise RuntimeError("agent_runtime_unavailable")
|
||
if not self.model_configured:
|
||
raise RuntimeError("opencode_not_configured")
|
||
existing = self.tasks.get(values["executor_attempt_id"])
|
||
if existing is not None:
|
||
raise RuntimeError("task_exists")
|
||
same_business = [
|
||
task
|
||
for task in self.tasks.values()
|
||
if task.business_task_id == values["business_task_id"]
|
||
and task.executor_attempt_id != values["executor_attempt_id"]
|
||
and (task.status in {"queued", "running"} or task.process_reaped is not True)
|
||
]
|
||
if same_business:
|
||
# 同一业务 Task 只能有一个活动 attempt;未确认回收的旧进程更严格地
|
||
# 收束为 unknown_execution,避免其晚到结果污染新 attempt。
|
||
if not any(task.status in {"queued", "running"} for task in same_business) and any(task.process_reaped is not True for task in same_business):
|
||
raise RuntimeError("unknown_execution")
|
||
raise RuntimeError("task_exists")
|
||
source = self._resume_source(values) if values.get("session_id") else None
|
||
if source is not None and source.analysis_policy != values.get("analysis_policy"):
|
||
raise RuntimeError("agent_analysis_policy_invalid")
|
||
task = TaskState(
|
||
task_id=str(values["business_task_id"]),
|
||
business_task_id=str(values["business_task_id"]),
|
||
executor_attempt_id=str(values["executor_attempt_id"]),
|
||
image_relative_path=str(values["image_relative_path"]),
|
||
reverse_image_policy=str(values["reverse_image_policy"]),
|
||
timeout_seconds=int(values["timeout_seconds"]),
|
||
processing_config_hash=values.get("processing_config_hash") if isinstance(values.get("processing_config_hash"), str) else None,
|
||
visual_snapshot_sha256=values.get("visual_snapshot_sha256") if isinstance(values.get("visual_snapshot_sha256"), str) else None,
|
||
session_id=values.get("session_id") if isinstance(values.get("session_id"), str) else None,
|
||
workspace_selector=str(values.get("workspace_selector") or "local"),
|
||
workspace_capability=values.get("workspace_capability") if isinstance(values.get("workspace_capability"), str) else None,
|
||
model_capability=values.get(MODEL_CAPABILITY_FIELD) if isinstance(values.get(MODEL_CAPABILITY_FIELD), str) else None,
|
||
workspace_directory=(values.get("workspace_layout").directory if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None),
|
||
images_root=(values.get("workspace_layout").images_root if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None),
|
||
metadata_root=(values.get("workspace_layout").metadata_root if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None),
|
||
skill_root=(values.get("workspace_layout").skill_root if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None),
|
||
candidate_root=(values.get("workspace_layout").candidate_root if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None),
|
||
task_scratch_root=(values.get("workspace_layout").task_scratch_root if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None),
|
||
config_file=(values.get("workspace_layout").config_file if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None),
|
||
config_dir=(values.get("workspace_layout").config_dir if isinstance(values.get("workspace_layout"), WorkspaceLayout) else None),
|
||
resume_of_attempt_id=source.executor_attempt_id if source else None,
|
||
is_resume=source is not None,
|
||
callback_token=values.get("callback_token") if isinstance(values.get("callback_token"), str) else None,
|
||
result_path=f"task-results/{values['business_task_id']}/{RESULT_FILE_NAME}",
|
||
analysis_policy=values.get("analysis_policy"),
|
||
observed_cost=source.observed_cost if source else None,
|
||
trace_context=values["trace_context"],
|
||
trace_attributes=values["trace_attributes"],
|
||
)
|
||
self.tasks[task.executor_attempt_id] = task
|
||
self.futures[task.executor_attempt_id] = self.pool.submit(self._run, task)
|
||
return task, bool(values["wait"])
|
||
|
||
@staticmethod
|
||
def _workspace_permission_rules(task: TaskState) -> dict[str, str]:
|
||
"""生成 OpenCode 外部目录的最后匹配优先规则。"""
|
||
images = task.images_root or IMAGE_ROOT
|
||
metadata = task.metadata_root or IMAGE_ROOT
|
||
skills = task.skill_root or SKILL_ROOT
|
||
scratch = task.task_scratch_root or (WORKSPACE / "tasks" / task.business_task_id)
|
||
candidate_root = task.candidate_root or (RUNTIME_ROOT / "candidates" / task.business_task_id)
|
||
result_dir = RESULT_ROOT / task.business_task_id
|
||
subtree = lambda path: f"{Path(os.path.abspath(path)).as_posix()}/**"
|
||
return {
|
||
"*": "deny",
|
||
subtree(skills): "allow",
|
||
subtree(images): "allow",
|
||
subtree(metadata): "allow",
|
||
subtree(candidate_root): "allow",
|
||
subtree(scratch): "allow",
|
||
# 外部文件工具先检查结果目录的 ``<dir>/*`` 父级模式;写入范围
|
||
# 由同一配置中的 ``edit`` 规则收窄到两个固定结果文件。
|
||
str(result_dir.absolute()) + "/*": "allow",
|
||
str((result_dir / RESULT_DRAFT_NAME).absolute()): "allow",
|
||
str((result_dir / RESULT_FILE_NAME).absolute()): "allow",
|
||
}
|
||
|
||
@staticmethod
|
||
def _workspace_edit_permission_rules(task: TaskState) -> dict[str, str]:
|
||
"""生成只允许 Task 临时数据和两个结果文件的编辑规则。"""
|
||
scratch = task.task_scratch_root or (WORKSPACE / "tasks" / task.business_task_id)
|
||
candidate_root = task.candidate_root or (RUNTIME_ROOT / "candidates" / task.business_task_id)
|
||
config_file = task.config_file or scratch / "opencode.json"
|
||
config_dir = task.config_dir or scratch / ".opencode"
|
||
result_dir = RESULT_ROOT / task.business_task_id
|
||
return dict(
|
||
build_edit_permission_rules(
|
||
task_scratch_root=scratch,
|
||
config_file=config_file,
|
||
config_dir=config_dir,
|
||
draft_path=result_dir / RESULT_DRAFT_NAME,
|
||
result_path=result_dir / RESULT_FILE_NAME,
|
||
candidate_root=candidate_root,
|
||
)
|
||
)
|
||
|
||
def _prepare_task_workspace(self, task: TaskState) -> None:
|
||
"""创建当前 Task 临时目录并原子写入不含密钥的 workspace 配置。"""
|
||
scratch = task.task_scratch_root or (WORKSPACE / "tasks" / task.business_task_id)
|
||
config_file = task.config_file or scratch / "opencode.json"
|
||
config_dir = task.config_dir or scratch / ".opencode"
|
||
self._checked_directory(scratch.parent, create=True)
|
||
self._checked_directory(scratch, create=True)
|
||
self._checked_directory(scratch / "home", create=True)
|
||
self._checked_directory(config_dir, create=True)
|
||
try:
|
||
validate_file_path(config_file, allow_missing=True, code="opencode_workspace_invalid")
|
||
except Exception as exc: # noqa: BLE001 - 配置路径是 executor 安全边界
|
||
raise RuntimeError("opencode_workspace_invalid") from exc
|
||
config = json.loads(json.dumps(RUNTIME_OPENCODE_CONFIG, ensure_ascii=False))
|
||
if task.analysis_policy is not None:
|
||
config["plugin"] = [["file:///opt/mememeow/plugins/analysis-reminder.mjs", {
|
||
"attempt_id": task.executor_attempt_id,
|
||
"policy": task.analysis_policy,
|
||
"session_id": task.session_id,
|
||
"socket_path": str(ANALYSIS_SOCKET_ROOT / f"{task.executor_attempt_id}.sock"),
|
||
}]]
|
||
config["permission"] = {
|
||
"external_directory": self._workspace_permission_rules(task),
|
||
"edit": self._workspace_edit_permission_rules(task),
|
||
}
|
||
content = json.dumps(config, ensure_ascii=False, indent=2) + "\n"
|
||
temporary = config_file.with_name(f".{config_file.name}.tmp.{os.getpid()}.{id(task)}")
|
||
try:
|
||
validate_file_path(temporary, allow_missing=True, code="opencode_workspace_invalid")
|
||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), stat.S_IRUSR | stat.S_IWUSR)
|
||
try:
|
||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||
descriptor = -1
|
||
handle.write(content)
|
||
finally:
|
||
if descriptor >= 0:
|
||
os.close(descriptor)
|
||
except (OSError, ValueError) as exc:
|
||
raise RuntimeError("opencode_workspace_invalid") from exc
|
||
try:
|
||
os.replace(temporary, config_file)
|
||
except OSError as exc:
|
||
raise RuntimeError("opencode_workspace_invalid") from exc
|
||
|
||
def _validate_candidate_view(self, task: TaskState) -> None:
|
||
"""在启动 OpenCode 前复核候选根、manifest 和只读权限。"""
|
||
candidate_root = task.candidate_root or (RUNTIME_ROOT / "candidates" / task.business_task_id)
|
||
expected_sha256 = task.visual_snapshot_sha256
|
||
if not expected_sha256:
|
||
if candidate_root.exists() or candidate_root.is_symlink():
|
||
self._checked_directory(candidate_root, missing_code="visual_candidate_materialization_failed")
|
||
return
|
||
self._checked_directory(candidate_root, missing_code="visual_candidate_materialization_failed")
|
||
manifest = candidate_root / "manifest.json"
|
||
try:
|
||
root_info = candidate_root.lstat()
|
||
if root_info.st_mode & (stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH):
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
validate_file_path(manifest, code="visual_candidate_materialization_failed")
|
||
info = manifest.lstat()
|
||
if info.st_size > 4 * 1024 * 1024:
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
descriptor = os.open(manifest, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||
try:
|
||
raw = os.read(descriptor, 4 * 1024 * 1024 + 1)
|
||
finally:
|
||
os.close(descriptor)
|
||
if len(raw) > 4 * 1024 * 1024:
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
document = json.loads(raw.decode("utf-8"))
|
||
validated = validate_visual_match_snapshot_manifest(document, expected_sha256=expected_sha256)
|
||
candidates = validated.get("candidates")
|
||
if not isinstance(candidates, list):
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
expected_files = {"manifest.json"}
|
||
expected_directories: set[str] = set()
|
||
declared_bytes = 0
|
||
for candidate in candidates:
|
||
if not isinstance(candidate, dict):
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
relative = candidate.get("relative_path")
|
||
if not isinstance(relative, str):
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
size_bytes = candidate.get("size_bytes")
|
||
if not isinstance(size_bytes, int) or isinstance(size_bytes, bool) or size_bytes < 0 or size_bytes > MAX_CANDIDATE_FILE_BYTES:
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
declared_bytes += size_bytes
|
||
if declared_bytes > MAX_CANDIDATE_ROOT_BYTES:
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
expected_files.add(relative)
|
||
parts = PurePosixPath(relative).parts
|
||
expected_directories.update("/".join(parts[:index]) for index in range(1, len(parts)))
|
||
for item in candidate_root.rglob("*"):
|
||
node = item.lstat()
|
||
relative = item.relative_to(candidate_root).as_posix()
|
||
if stat.S_ISLNK(node.st_mode) or node.st_mode & (stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH):
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
if stat.S_ISDIR(node.st_mode):
|
||
if relative not in expected_directories:
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
elif stat.S_ISREG(node.st_mode):
|
||
if relative not in expected_files:
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
else:
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
total_bytes = sum(item.lstat().st_size for item in candidate_root.rglob("*") if item.is_file())
|
||
if total_bytes > MAX_CANDIDATE_ROOT_BYTES:
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
for candidate in candidates:
|
||
if not isinstance(candidate, dict):
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
relative = candidate.get("relative_path")
|
||
image_sha256 = candidate.get("image_sha256")
|
||
size_bytes = candidate.get("size_bytes")
|
||
if not isinstance(relative, str) or not isinstance(image_sha256, str) or not isinstance(size_bytes, int) or isinstance(size_bytes, bool) or size_bytes < 0 or size_bytes > MAX_CANDIDATE_FILE_BYTES:
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
candidate_path = candidate_root.joinpath(*PurePosixPath(relative).parts)
|
||
validate_file_path(candidate_path, code="visual_candidate_materialization_failed")
|
||
candidate_info = candidate_path.lstat()
|
||
if candidate_info.st_size != size_bytes:
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
digest = hashlib.sha256()
|
||
candidate_descriptor = os.open(candidate_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||
try:
|
||
while True:
|
||
chunk = os.read(candidate_descriptor, 1024 * 1024)
|
||
if not chunk:
|
||
break
|
||
digest.update(chunk)
|
||
finally:
|
||
os.close(candidate_descriptor)
|
||
if digest.hexdigest() != image_sha256.lower():
|
||
raise ValueError("visual_candidate_materialization_failed")
|
||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, RecursionError, TypeError, ValueError, VisualMatchSnapshotError) as exc:
|
||
raise RuntimeError("visual_candidate_materialization_failed") from exc
|
||
|
||
def _task_environment(self, task: TaskState) -> dict[str, str]:
|
||
"""构造 OpenCode 最小环境白名单,不继承 executor 容器中的无关变量。"""
|
||
scratch = task.task_scratch_root or (WORKSPACE / "tasks" / task.business_task_id)
|
||
values = {
|
||
"PATH": os.getenv("PATH", "/usr/local/bin:/usr/bin:/bin"),
|
||
# HOME 使用当前 Task 的独立目录,避免多个 Agent 共享可写配置和缓存。
|
||
"HOME": str(scratch / "home"),
|
||
# 提醒和终止共同读取任务专属数据库中的主 session 金额。
|
||
"OPENCODE_DB": str(scratch / "opencode.db" if task.analysis_policy is not None else RUNTIME_ROOT / "opencode.db"),
|
||
"OPENCODE_CONFIG": str(task.config_file or scratch / "opencode.json"),
|
||
"OPENCODE_CONFIG_DIR": str(task.config_dir or scratch / ".opencode"),
|
||
"OPENCODE_DISABLE_PROJECT_CONFIG": "1",
|
||
MODEL_BROKER_URL_ENV: self.model_broker_url or self.legacy_base_url,
|
||
# 生产值来自本次 task 的短期 capability;local 旧夹具才使用旧 key。
|
||
MODEL_CAPABILITY_ENV: task.model_capability or self.legacy_api_key,
|
||
"MEMEMEOW_OPENCODE_SLOT": "0",
|
||
"MEMEMEOW_AGENT_TASK_ID": task.business_task_id,
|
||
"MEMEMEOW_AGENT_EXECUTOR_ATTEMPT_ID": task.executor_attempt_id,
|
||
"MEMEMEOW_AGENT_TRACEPARENT": trace_carrier().get("traceparent", ""),
|
||
**({"MEMEMEOW_AGENT_CALLBACK_TOKEN": task.callback_token} if task.callback_token else {}),
|
||
"MEMEMEOW_REVERSE_IMAGE_INTERNAL_URL": os.getenv("MEMEMEOW_AGENT_REVERSE_IMAGE_INTERNAL_URL", ""),
|
||
"MEMEMEOW_AGENT_CANDIDATE_MANIFEST": str((task.candidate_root or (RUNTIME_ROOT / "candidates" / task.business_task_id)) / "manifest.json"),
|
||
"MEMEMEOW_DATA_ROOT": str(RUNTIME_ROOT),
|
||
"MEMEMEOW_REVERSE_IMAGE_CACHE_ROOT": str(RUNTIME_ROOT / "reverse_image_cache" / "serpapi_google_lens"),
|
||
# 依赖位于镜像只读目录,避免在 runtime volume 内创建 node_modules 链接。
|
||
"NODE_PATH": "/opt/mememeow/node_modules",
|
||
}
|
||
return values
|
||
|
||
@staticmethod
|
||
def _process_directory(task: TaskState) -> Path:
|
||
"""返回 OpenCode 当前任务的工作目录;分析策略任务固定使用专属 scratch。"""
|
||
if task.analysis_policy is not None:
|
||
return task.task_scratch_root or (WORKSPACE / "tasks" / task.business_task_id)
|
||
return task.workspace_directory or WORKSPACE
|
||
|
||
def _sandbox_command(self, task: TaskState, command: list[str]) -> list[str]:
|
||
"""返回已经完成路径校验的 OpenCode 命令,不创建 Linux namespace。
|
||
|
||
图片分析任务继续使用独立 scratch、任务数据库、候选 manifest 和 OpenCode
|
||
文件权限规则;容器边界由 Compose 提供。保留该方法作为统一执行入口,避免
|
||
调用方重新组装已经校验的命令。
|
||
"""
|
||
del task
|
||
return command
|
||
|
||
def _prompt(self, task: TaskState) -> str:
|
||
"""生成唯一业务 prompt;调用方不能覆盖路径、命令或提示词。"""
|
||
result_dir = RESULT_ROOT / task.task_id
|
||
image = (task.images_root or IMAGE_ROOT) / _relative_image_path(task.image_relative_path)
|
||
resume_instruction = (
|
||
"这是同一 OpenCode session 的受控续跑;保留并检查已有草稿和中间产物,只完成尚未完成的工作。"
|
||
if task.is_resume
|
||
else "这是首次执行;先建立任务草稿并逐步完成研究。"
|
||
)
|
||
return (
|
||
"使用 research-meme-context skill 分析这张表情包;先读取本任务固定候选 manifest,"
|
||
"候选只作为参考证据;只通过项目内部接口使用可选反向图片能力。"
|
||
f"原始图片路径(只读):{json.dumps(str(image.absolute()), ensure_ascii=False)}。"
|
||
"必须直接通过视觉模型查看输入图片并识别图片文字,禁止调用任何本地文字识别程序、命令或脚本。"
|
||
f"本任务 reverse_image_policy={task.reverse_image_policy}。"
|
||
f"{resume_instruction}"
|
||
f"结果必须写入 {result_dir / RESULT_FILE_NAME};先写 {result_dir / RESULT_DRAFT_NAME},"
|
||
"使用 output-schema.json 校验后通过同一文件系统的原子 rename/mv 替换最终文件。"
|
||
"不要把业务 JSON 作为 assistant 文本交付,不要写入数据库。"
|
||
)
|
||
|
||
def _run(self, task: TaskState) -> None:
|
||
"""执行 attempt,并将线程入口异常保存为明确终态及诊断后继续抛出。"""
|
||
started_at = time.monotonic()
|
||
try:
|
||
self._run_with_telemetry(task)
|
||
except Exception as error:
|
||
reason = _redact_diagnostic(f"{type(error).__name__}: {error}", secret_inventory_from_mapping(os.environ) + (self.token, task.callback_token or "", task.workspace_capability or "", task.model_capability or ""))
|
||
logger.error("agent_executor_internal_error: task={} attempt={} exception={} reason={}", task.business_task_id, task.executor_attempt_id, type(error).__name__, reason)
|
||
with self.lock:
|
||
if not task.done.is_set():
|
||
task.status = "failed"
|
||
task.error = _json_error("agent_executor_internal_error", reason)
|
||
task.completed_at = time.time()
|
||
try:
|
||
self._persist_attempt_metadata_locked(task)
|
||
finally:
|
||
task.done.set()
|
||
raise
|
||
finally:
|
||
if TelemetryConfig.from_env("mememeow-executor").enabled:
|
||
meter = metrics.get_meter("mememeow.executor")
|
||
meter.create_counter("agent_attempts", unit="{attempt}").add(1, {"result": task.status})
|
||
meter.create_histogram("agent_attempt_duration", unit="s").record(time.monotonic() - started_at)
|
||
|
||
def _run_with_telemetry(self, task: TaskState) -> None:
|
||
"""绑定当前 attempt 的观测上下文,业务结果由既有执行流程提供。"""
|
||
if not TelemetryConfig.from_env("mememeow-executor").enabled:
|
||
self._run_observed_task(task)
|
||
return
|
||
attributes = {**task.trace_attributes, "task.id": task.business_task_id, "attempt.id": task.executor_attempt_id, "workspace.selector": task.workspace_selector, "business.image_relative_path": task.image_relative_path}
|
||
started_at = time.time()
|
||
parent = extract_carrier(task.trace_context)
|
||
links = [trace.Link(trace.get_current_span(parent).get_span_context())] if trace.get_current_span(parent).get_span_context().is_valid else []
|
||
with trace.get_tracer("mememeow.executor").start_as_current_span("agent.attempt", context=extract_carrier({}), links=links, attributes=attributes) as span:
|
||
observer = SQLiteTelemetry(database=Path(self._task_environment(task)["OPENCODE_DB"]), directory=self._process_directory(task), session=lambda: task.session_id, attributes=attributes, context=trace.set_span_in_context(span), started_at=started_at, secrets=(self.token, self.legacy_api_key, task.callback_token or "", task.workspace_capability or "", task.model_capability or ""))
|
||
observer.start()
|
||
try:
|
||
self._run_observed_task(task)
|
||
finally:
|
||
observer.close()
|
||
span.set_attribute("attempt.result", task.status)
|
||
span.set_attribute("attempt.reason", protected_content(str(task.error or {}), observer.secrets))
|
||
|
||
def _run_observed_task(self, task: TaskState) -> None:
|
||
"""执行 OpenCode 任务并更新状态,失败时保存脱敏后的有限诊断。"""
|
||
with self.lock:
|
||
if task.done.is_set():
|
||
return
|
||
process: subprocess.Popen[bytes] | None = None
|
||
stdout = b""
|
||
stderr = b""
|
||
timed_out = False
|
||
analysis_socket: socket.socket | None = None
|
||
output = AttemptOutput()
|
||
monitor = None
|
||
database: Path | None = None
|
||
final_status = "failed"
|
||
final_error: dict[str, object] | None = None
|
||
started = time.monotonic()
|
||
diagnostic_secrets = secret_inventory_from_mapping(os.environ) + (
|
||
self.legacy_api_key, self.token, task.callback_token or "",
|
||
task.workspace_capability or "", task.model_capability or "",
|
||
task.session_id or "",
|
||
)
|
||
try:
|
||
with self.lock:
|
||
if task.cancel_event.is_set():
|
||
final_status = "cancelled"
|
||
final_error = _json_error("task_interrupted", "任务已取消")
|
||
return
|
||
self._verify_task_workspace_capability(task)
|
||
task.status = "running"
|
||
task.started_at = time.time()
|
||
self._validate_candidate_view(task)
|
||
self._prepare_task_workspace(task)
|
||
result_dir = RESULT_ROOT / task.task_id
|
||
if result_dir.is_symlink() or (result_dir.exists() and not result_dir.is_dir()):
|
||
raise RuntimeError("agent_result_path_invalid")
|
||
result_dir.mkdir(parents=True, exist_ok=True)
|
||
# 续跑必须保留原 attempt 的草稿和已验证结果,首次 attempt 才清理
|
||
# 任务目录中的两个受控交付文件。
|
||
if not task.is_resume:
|
||
for filename in (RESULT_FILE_NAME, RESULT_DRAFT_NAME):
|
||
path = result_dir / filename
|
||
if path.exists() or path.is_symlink():
|
||
path.unlink()
|
||
env = self._task_environment(task)
|
||
diagnostic_secrets += secret_inventory_from_mapping(env)
|
||
image_root = task.images_root or IMAGE_ROOT
|
||
image = image_root / _relative_image_path(task.image_relative_path)
|
||
try:
|
||
self._checked_directory(image_root)
|
||
current = image_root
|
||
for part in _relative_image_path(task.image_relative_path).parts:
|
||
current = current / part
|
||
info = current.lstat()
|
||
if stat.S_ISLNK(info.st_mode):
|
||
raise ValueError("agent_image_path_forbidden")
|
||
info = image.lstat()
|
||
if not stat.S_ISREG(info.st_mode):
|
||
raise ValueError("agent_image_path_forbidden")
|
||
except (OSError, ValueError) as exc:
|
||
if isinstance(exc, ValueError) and str(exc) == "agent_image_path_forbidden":
|
||
raise
|
||
raise ValueError("agent_image_path_forbidden") from exc
|
||
process_directory = self._process_directory(task)
|
||
if task.analysis_policy is not None:
|
||
ANALYSIS_SOCKET_ROOT.mkdir(parents=True, exist_ok=True)
|
||
socket_path = ANALYSIS_SOCKET_ROOT / f"{task.executor_attempt_id}.sock"
|
||
if socket_path.exists() or socket_path.is_symlink():
|
||
socket_path.unlink()
|
||
analysis_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||
analysis_socket.bind(str(socket_path))
|
||
os.chmod(socket_path, stat.S_IRUSR | stat.S_IWUSR)
|
||
analysis_socket.listen(8)
|
||
command = [
|
||
self.opencode_executable,
|
||
"run",
|
||
"--auto",
|
||
"--dir",
|
||
str(process_directory),
|
||
"--format",
|
||
"json",
|
||
"--file",
|
||
str(image),
|
||
"--model",
|
||
self.model,
|
||
"--variant",
|
||
"max",
|
||
"--title",
|
||
f"mememeow-task-{task.task_id}",
|
||
]
|
||
if task.session_id:
|
||
command.extend(("--session", task.session_id))
|
||
command.append(self._prompt(task))
|
||
command = self._sandbox_command(task, command)
|
||
output.phase = "process_start"
|
||
with output.capture(LOG_ROOT, task.task_id) as (out, err):
|
||
database = Path(env["OPENCODE_DB"])
|
||
self.database_maintenance.acquire(database)
|
||
process = subprocess.Popen(command, cwd=process_directory, env=env, stdin=subprocess.DEVNULL, stdout=out, stderr=err, start_new_session=True)
|
||
output.phase = "process_running"
|
||
with self.lock:
|
||
task.process = process
|
||
# 进程启动后,只有父进程确认 waitpid 收束才允许该 attempt 作为续跑源。
|
||
task.process_reaped = False
|
||
deadline = time.monotonic() + task.timeout_seconds
|
||
monitor = None
|
||
if task.analysis_policy is not None:
|
||
scratch = task.task_scratch_root or (WORKSPACE / "tasks" / task.business_task_id)
|
||
monitor = AnalysisMonitor(
|
||
attempt_id=task.executor_attempt_id, policy=task.analysis_policy,
|
||
database=Path(env["OPENCODE_DB"]), directory=process_directory,
|
||
status_path=scratch / f"analysis-{task.executor_attempt_id}.json",
|
||
# 主 session 可能在 OpenCode 完成冷启动和首个模型请求后才出现。
|
||
# 使用任务自身的总期限等待绑定,避免把合法的慢启动误判为用量不可用。
|
||
startup_deadline=deadline,
|
||
status_socket=analysis_socket,
|
||
observed_cost=Decimal(task.observed_cost or "0"),
|
||
)
|
||
monitor.expected_pid = process.pid
|
||
while process.poll() is None:
|
||
if task.cancel_event.is_set():
|
||
reaped = self.process_supervisor.terminate(process).reaped
|
||
with self.lock:
|
||
task.process_reaped = reaped
|
||
if not reaped:
|
||
raise _ProcessFailure("unknown_execution", "无法确认 OpenCode 进程已终止")
|
||
break
|
||
if time.monotonic() >= deadline:
|
||
reaped = self.process_supervisor.terminate(process).reaped
|
||
with self.lock:
|
||
task.process_reaped = reaped
|
||
if not reaped:
|
||
raise _ProcessFailure("unknown_execution", "无法确认 OpenCode 进程已终止")
|
||
timed_out = True
|
||
break
|
||
if monitor is not None:
|
||
output.phase = "analysis_monitor"
|
||
self._check_analysis(task, monitor, process, out)
|
||
output.phase = "process_running"
|
||
time.sleep(0.05)
|
||
if process.poll() is not None:
|
||
with self.lock:
|
||
task.process_reaped = True
|
||
out.flush()
|
||
err.flush()
|
||
# session 必须从完整 stdout 流解析;采样只保留给有限错误诊断。
|
||
output.phase = "session_capture"
|
||
self._capture_session(task, out)
|
||
if monitor is not None and not timed_out and not task.cancel_event.is_set():
|
||
output.phase = "analysis_monitor"
|
||
self._check_analysis(task, monitor, process, out, exited=True)
|
||
stdout = _stream_sample(out, 256 * 1024)
|
||
stderr = _stream_sample(err, 16 * 1024)
|
||
if task.cancel_event.is_set():
|
||
output.phase = "process_cancelled"
|
||
raise RuntimeError("task_interrupted")
|
||
if timed_out:
|
||
output.phase = "process_timeout"
|
||
raise _ProcessFailure("agent_timeout", "OpenCode 执行超时")
|
||
if process.returncode != 0:
|
||
output.phase = "process_exit"
|
||
code, http_status = self._classify_process_failure(stdout, stderr)
|
||
raise _ProcessFailure(
|
||
code,
|
||
_diagnostic(stdout, stderr, secrets=(self.legacy_api_key, self.token, task.callback_token or "")),
|
||
http_status=http_status,
|
||
)
|
||
if not task.session_id:
|
||
raise RuntimeError("agent_output_invalid_json")
|
||
output.phase = "result_validation"
|
||
self._validate_result_file(RESULT_ROOT / task.task_id / RESULT_FILE_NAME)
|
||
with self.lock:
|
||
if task.cancel_event.is_set():
|
||
raise RuntimeError("task_interrupted")
|
||
final_status = "succeeded"
|
||
except _ProcessFailure as exc:
|
||
with self.lock:
|
||
# 未确认进程已回收时,取消/超时也不能伪装成可安全重试的普通失败。
|
||
code = exc.code
|
||
if task.cancel_event.is_set() and code != "unknown_execution":
|
||
code = "task_interrupted"
|
||
final_status = "cancelled" if code == "task_interrupted" else "failed"
|
||
error: dict[str, object] = {
|
||
"error": code,
|
||
"message": _redact_diagnostic(exc.args[0], (self.legacy_api_key, self.token, task.callback_token or "")),
|
||
}
|
||
if exc.analysis_diagnostic is not None:
|
||
error["analysis_diagnostic"] = exc.analysis_diagnostic
|
||
if exc.http_status is not None:
|
||
error["http_status"] = exc.http_status
|
||
final_error = error
|
||
except RuntimeError as exc:
|
||
code, _, detail = str(exc).partition(":")
|
||
reason_code = getattr(exc, "reason_code", None)
|
||
with self.lock:
|
||
# 取消与子进程自然退出可能同时发生;持锁后以取消为最终事实,避免把
|
||
# 用户明确取消的任务误记为普通进程失败。
|
||
if process is not None and process.poll() is not None:
|
||
task.process_reaped = True
|
||
if task.process_reaped is not True:
|
||
code, detail = "unknown_execution", "无法确认 OpenCode 进程已终止"
|
||
elif task.cancel_event.is_set():
|
||
code, detail = "task_interrupted", ""
|
||
if code not in _EXECUTOR_ERROR_CODES:
|
||
code = "agent_process_failed"
|
||
final_status = "cancelled" if code == "task_interrupted" else "failed"
|
||
fallback = {
|
||
"agent_timeout": "OpenCode 执行超时",
|
||
"task_interrupted": "任务已取消",
|
||
}.get(code, "任务执行失败")
|
||
final_error = _json_error(
|
||
code,
|
||
_redact_diagnostic(detail, (self.legacy_api_key, self.token, task.callback_token or "")) if detail else fallback,
|
||
reason_code=reason_code,
|
||
)
|
||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||
with self.lock:
|
||
if process is not None and process.poll() is not None:
|
||
task.process_reaped = True
|
||
code = "unknown_execution" if task.process_reaped is not True else "task_interrupted" if task.cancel_event.is_set() else "agent_process_failed"
|
||
final_status = "cancelled" if code == "task_interrupted" else "failed"
|
||
final_error = _json_error(code, "任务已取消" if code == "task_interrupted" else "无法确认 OpenCode 进程已终止" if code == "unknown_execution" else _redact_diagnostic(str(exc), (self.legacy_api_key, self.token, task.callback_token or "")))
|
||
except Exception as exc: # noqa: BLE001 - 任务必须以终态收束,不能留下永久 running
|
||
with self.lock:
|
||
if process is not None and process.poll() is not None:
|
||
task.process_reaped = True
|
||
code = "unknown_execution" if task.process_reaped is not True else "task_interrupted" if task.cancel_event.is_set() else "agent_process_failed"
|
||
final_status = "cancelled" if code == "task_interrupted" else "failed"
|
||
final_error = _json_error(code, "任务已取消" if code == "task_interrupted" else "无法确认 OpenCode 进程已终止" if code == "unknown_execution" else _redact_diagnostic(str(exc), (self.legacy_api_key, self.token, task.callback_token or "")))
|
||
finally:
|
||
if process is not None:
|
||
task.process_reaped = self.process_supervisor.terminate(process).reaped
|
||
if not task.process_reaped:
|
||
final_status = "failed"
|
||
final_error = _json_error("unknown_execution", "无法确认 OpenCode 进程已终止")
|
||
if analysis_socket is not None:
|
||
analysis_socket.close()
|
||
socket_path = ANALYSIS_SOCKET_ROOT / f"{task.executor_attempt_id}.sock"
|
||
try:
|
||
socket_path.unlink()
|
||
except FileNotFoundError:
|
||
pass
|
||
if database is not None:
|
||
task.database_maintenance = self.database_maintenance.release(database, process_reaped=task.process_reaped)
|
||
diagnostic = dict(task.database_maintenance)
|
||
if "message" in diagnostic:
|
||
diagnostic["message"] = _redact_diagnostic(str(diagnostic["message"]), diagnostic_secrets)
|
||
task.database_maintenance = diagnostic
|
||
logger.bind(attempt_id=task.executor_attempt_id, phase="database_checkpoint", **diagnostic).log(
|
||
"WARNING" if diagnostic["status"] in {"failed", "skipped"} else "INFO",
|
||
"OpenCode 数据库收尾:attempt={};结果={}", task.executor_attempt_id, diagnostic,
|
||
)
|
||
with self.lock:
|
||
task.process = None
|
||
if task.cancel_event.is_set() and (final_error or {}).get("error") != "unknown_execution":
|
||
if task.cancel_reason != "service_shutdown":
|
||
final_status = "cancelled"
|
||
final_error = _json_error("task_interrupted", "任务已取消")
|
||
elif (final_error or {}).get("error") == "task_interrupted":
|
||
final_status = "failed"
|
||
final_error = _json_error("service_shutdown", "服务正常关闭,Agent 执行已中断")
|
||
task.status = final_status
|
||
task.error = final_error
|
||
task.completed_at = time.time()
|
||
try:
|
||
self._persist_attempt_metadata(task)
|
||
except (OSError, ValueError, TypeError) as exc:
|
||
task.status = "failed"
|
||
task.error = _json_error("agent_attempt_metadata_write_failed", "无法保存 executor attempt 恢复信息")
|
||
logger.error("attempt 恢复信息保存失败:attempt={} error={}", task.executor_attempt_id, type(exc).__name__)
|
||
task.done.set()
|
||
if task.status in {"failed", "cancelled"}:
|
||
secrets = diagnostic_secrets + (task.session_id or "",)
|
||
redact = lambda value: _redact_diagnostic(str(value), secrets)
|
||
output.log_failure(
|
||
redact=redact, task_id=task.business_task_id, attempt_id=task.executor_attempt_id,
|
||
resume=task.is_resume, status=task.status,
|
||
error_code=(task.error or {}).get("error"),
|
||
error_message=redact((task.error or {}).get("message", "")),
|
||
return_code=process.poll() if process is not None else None,
|
||
process_reaped=task.process_reaped, timed_out=timed_out,
|
||
duration_ms=int((time.monotonic() - started) * 1000),
|
||
failure_reason=redact(monitor.failure_reason or "") if monitor else None,
|
||
plugin_ready=monitor.ready if monitor else None,
|
||
plugin_error=redact(monitor.reminder_error or "") if monitor else None,
|
||
plugin_error_detail=redact(monitor.reminder_error_detail or "") if monitor else None,
|
||
)
|
||
|
||
def _check_analysis(self, task: TaskState, monitor: AnalysisMonitor, process: subprocess.Popen[bytes], stdout: Any, *, exited: bool = False) -> None:
|
||
"""检查冻结 attempt 的金额和插件,触发时确认进程回收后发布稳定错误。"""
|
||
if not exited and time.monotonic() < monitor.next_check:
|
||
return
|
||
try:
|
||
if task.session_id is None:
|
||
# 独立打开文件描述符,读取位置不能改变子进程的 stdout 写入位置。
|
||
with open(f"/proc/self/fd/{stdout.fileno()}", "rb") as snapshot:
|
||
self._capture_session(task, snapshot)
|
||
monitor.check(task.session_id, exited=exited)
|
||
except (AnalysisControlError, RuntimeError) as exc:
|
||
code = exc.code if isinstance(exc, AnalysisControlError) else "agent_analysis_usage_unavailable"
|
||
reason = exc.reason if isinstance(exc, AnalysisControlError) else str(exc)
|
||
monitor.failure_reason = reason
|
||
termination = self.process_supervisor.terminate(process)
|
||
reaped = termination.reaped
|
||
signal_name: str | None = None
|
||
if termination.returncode in {-15, 143}:
|
||
signal_name = "SIGTERM"
|
||
elif termination.returncode in {-9, 137}:
|
||
signal_name = "SIGKILL"
|
||
elif termination.reaped and termination.returncode is not None:
|
||
signal_name = "already_exited"
|
||
diagnostic = {
|
||
"threshold_cost": str(monitor.policy.get("termination_cost")),
|
||
"final_observed_cost": str(monitor.observed_cost),
|
||
"check_stage": "analysis_monitor",
|
||
"termination_signal": signal_name,
|
||
"process_reaped": reaped,
|
||
"trigger_reason": code,
|
||
}
|
||
with self.lock:
|
||
task.process_reaped = reaped
|
||
logger.bind(
|
||
attempt_id=task.executor_attempt_id, policy=task.analysis_policy,
|
||
observed_cost=str(monitor.observed_cost), phase=reason,
|
||
process_reaped=reaped, trigger=code,
|
||
).error(
|
||
"Agent 分析用量控制终止:attempt={};策略={};观测金额={};原因={};阶段={};进程回收={}",
|
||
task.executor_attempt_id, task.analysis_policy, str(monitor.observed_cost), code, reason, reaped,
|
||
)
|
||
public_code = code if reaped else "unknown_execution"
|
||
message = "超过最大分析程度" if public_code == "agent_maximum_analysis_depth_exceeded" else f"{code}:{reason}" if reaped else f"进程回收未确认;触发原因={code};阶段={reason}"
|
||
raise _ProcessFailure(public_code, message, analysis_diagnostic=diagnostic) from exc
|
||
finally:
|
||
with self.lock:
|
||
task.observed_cost = str(monitor.observed_cost)
|
||
task.usage_checked_at = monitor.usage_checked_at
|
||
task.reminder_sent = monitor.reminder_sent
|
||
|
||
def _capture_session(self, task: TaskState, stdout: Any) -> None:
|
||
"""从完整临时 stdout 流逐行绑定 session,避免采样截断 JSONL。"""
|
||
found_session: str | None = None
|
||
stdout.seek(0)
|
||
while True:
|
||
line = stdout.readline(SESSION_CAPTURE_LINE_BYTES + 1)
|
||
if not line:
|
||
break
|
||
# BufferedIO.readline(size) 可能只返回超长行的前缀;丢弃该行剩余内容,
|
||
# 让后续 JSONL 行仍能被解析,同时把单行内存限制在固定上限附近。
|
||
if len(line) > SESSION_CAPTURE_LINE_BYTES:
|
||
while line and not line.endswith(b"\n"):
|
||
line = stdout.readline(SESSION_CAPTURE_LINE_BYTES + 1)
|
||
continue
|
||
try:
|
||
candidate = _session_id_from_event(json.loads(line))
|
||
except (UnicodeDecodeError, json.JSONDecodeError, RecursionError):
|
||
continue
|
||
if candidate and SESSION_ID_RE.fullmatch(candidate):
|
||
if task.session_id and candidate != task.session_id:
|
||
raise RuntimeError("session_binding_mismatch")
|
||
if found_session and candidate != found_session:
|
||
raise RuntimeError("session_binding_mismatch")
|
||
found_session = candidate
|
||
if found_session:
|
||
with self.lock:
|
||
task.session_id = found_session
|
||
|
||
@staticmethod
|
||
def _classify_process_failure(stdout: bytes, stderr: bytes) -> tuple[str, int | None]:
|
||
"""按有限输出区分 provider 网关、网络和普通进程暂态错误。"""
|
||
text = (stdout[:256 * 1024] + stderr[:16 * 1024]).decode("utf-8", errors="replace").lower()
|
||
statuses = [int(match) for match in re.findall(r"(?:statuscode|status|http)[^0-9]{0,8}(\d{3})", text)]
|
||
if any(status == 429 for status in statuses):
|
||
return "agent_provider_rate_limited", 429
|
||
server_status = next((status for status in statuses if 500 <= status <= 599), None)
|
||
if server_status is not None:
|
||
return "agent_provider_server_error", server_status
|
||
if any(token in text for token in ("econnreset", "connection reset", "connection refused", "socket hang up", "network error", "timed out")):
|
||
return "agent_connection_interrupted", None
|
||
return "agent_process_failed", None
|
||
|
||
def _validate_result_file(self, path: Path) -> None:
|
||
"""验证结果文件位置、大小和基本 JSON 结构;完整 schema 由后端复核。"""
|
||
try:
|
||
ExecutorResultStore(RESULT_ROOT, filename=RESULT_FILE_NAME, max_bytes=self.max_result_bytes).read(
|
||
path,
|
||
required_fields=set(REQUIRED_RESULT_FIELDS),
|
||
validator=lambda value: validate_agent_result(value, secret_inventory=secret_inventory_from_mapping(os.environ)),
|
||
)
|
||
except ExecutorResultStoreError as exc:
|
||
raise _ResultFileValidationFailure(exc.code, reason_code=exc.reason_code) from exc
|
||
|
||
@staticmethod
|
||
def _terminate(process: subprocess.Popen[bytes]) -> bool:
|
||
"""终止任务进程组并返回父进程是否已被 waitpid 确认回收。"""
|
||
return ProcessSupervisor().terminate(process).reaped
|
||
|
||
def cancel(self, task_id: str, *, reason: str = "task_interrupted") -> TaskState:
|
||
"""取消指定任务;只终止该任务进程,不影响 executor 或其他任务。"""
|
||
if reason not in {"task_interrupted", "service_shutdown"}:
|
||
raise ValueError("invalid_interruption_reason")
|
||
with self.lock:
|
||
task = self.tasks.get(task_id)
|
||
if task is None:
|
||
raise KeyError(task_id)
|
||
if task.status in {"succeeded", "failed", "cancelled"}:
|
||
return task
|
||
# 用户取消优先;后续停机通知不能改变已经收到的取消原因。
|
||
if task.cancel_reason is None or reason == "task_interrupted":
|
||
task.cancel_reason = reason
|
||
task.cancel_event.set()
|
||
was_queued = task.status == "queued"
|
||
process = task.process
|
||
if process is None and was_queued:
|
||
task.status = "failed" if task.cancel_reason == "service_shutdown" else "cancelled"
|
||
task.error = _json_error(task.cancel_reason, "服务正常关闭,尚未开始执行" if task.cancel_reason == "service_shutdown" else "任务已取消")
|
||
task.completed_at = time.time()
|
||
try:
|
||
self._persist_attempt_metadata_locked(task)
|
||
except (OSError, ValueError, TypeError) as exc:
|
||
task.status = "failed"
|
||
task.error = _json_error("agent_attempt_metadata_write_failed", "无法保存 executor attempt 恢复信息")
|
||
logger.error("attempt 恢复信息保存失败:attempt={} error={}", task.executor_attempt_id, type(exc).__name__)
|
||
task.done.set()
|
||
# 运行任务由执行线程终止并完成数据库维护,再统一发布最终状态。
|
||
return task
|
||
|
||
def close(self) -> None:
|
||
"""停止线程池并取消仍未结束的任务。"""
|
||
with self.lock:
|
||
ids = [task.executor_attempt_id for task in self.tasks.values() if task.status not in {"succeeded", "failed", "cancelled"}]
|
||
for task_id in ids:
|
||
self.cancel(task_id)
|
||
self.pool.shutdown(wait=False, cancel_futures=True)
|
||
|
||
|
||
def shutil_which(executable: str) -> str | None:
|
||
"""在不导入 shell 的情况下检查固定 OpenCode 可执行文件。"""
|
||
if not executable or "/" in executable:
|
||
path = Path(executable)
|
||
return str(path) if path.is_file() and os.access(path, os.X_OK) else None
|
||
for directory in os.getenv("PATH", "").split(os.pathsep):
|
||
candidate = Path(directory) / executable
|
||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||
return str(candidate)
|
||
return None
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
"""executor HTTP 路由,只公开健康、任务提交、状态和取消接口。"""
|
||
|
||
server: "ExecutorHTTPServer"
|
||
|
||
def setup(self) -> None:
|
||
"""为请求体读取设置有限超时,避免慢速连接长期占用线程。"""
|
||
super().setup()
|
||
self.connection.settimeout(30)
|
||
|
||
def log_message(self, _format: str, *_args: object) -> None:
|
||
"""禁止将认证头、任务输入或 OpenCode 诊断写入访问日志。"""
|
||
return
|
||
|
||
def _authorized(self) -> bool:
|
||
"""使用固定 Bearer token 验证内部 API 调用。"""
|
||
expected = self.server.executor.token
|
||
value = self.headers.get("Authorization", "")
|
||
supplied = value[7:].strip() if value.startswith("Bearer ") else ""
|
||
return bool(expected and supplied and hmac.compare_digest(supplied, expected))
|
||
|
||
def _send(self, status: int, payload: object) -> None:
|
||
"""发送统一 JSON 响应并关闭连接。"""
|
||
body = _safe_json(payload)
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.send_header("Cache-Control", "no-store")
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def _read_json(self) -> object:
|
||
"""读取有限大小的 JSON 请求体,拒绝 multipart、流式 shell 等其他协议。"""
|
||
if self.headers.get_content_type() != "application/json":
|
||
raise ValueError("invalid_task")
|
||
try:
|
||
length = int(self.headers.get("Content-Length", "-1"))
|
||
except ValueError as exc:
|
||
raise ValueError("invalid_task") from exc
|
||
if length < 0 or length > 64 * 1024:
|
||
raise ValueError("invalid_task")
|
||
try:
|
||
raw = self.rfile.read(length)
|
||
return json.loads(raw.decode("utf-8"))
|
||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc:
|
||
raise ValueError("invalid_task") from exc
|
||
|
||
def do_GET(self) -> None: # noqa: N802
|
||
"""处理健康和任务状态查询。"""
|
||
path = urlsplit(self.path).path.rstrip("/") or "/"
|
||
if path == "/health":
|
||
if not self._authorized():
|
||
self._send(401, _json_error("executor_unauthorized", "executor 认证失败"))
|
||
return
|
||
self._send(200, self.server.executor.health())
|
||
return
|
||
if not self._authorized():
|
||
self._send(401, _json_error("executor_unauthorized", "executor 认证失败"))
|
||
return
|
||
match = re.fullmatch(r"/v1/tasks/([^/]+)", path)
|
||
if match:
|
||
task_id = unquote(match.group(1))
|
||
with self.server.executor.lock:
|
||
task = self.server.executor.tasks.get(task_id)
|
||
if task is None:
|
||
self._send(404, _json_error("task_not_found", "任务不存在"))
|
||
else:
|
||
self._send(200, task.public())
|
||
return
|
||
self._send(404, _json_error("not_found", "接口不存在"))
|
||
|
||
def do_POST(self) -> None: # noqa: N802
|
||
"""处理固定研究任务提交和任务取消。"""
|
||
if not self._authorized():
|
||
self._send(401, _json_error("executor_unauthorized", "executor 认证失败"))
|
||
return
|
||
path = urlsplit(self.path).path.rstrip("/") or "/"
|
||
cancel_match = re.fullmatch(r"/v1/tasks/([^/]+)/(cancel|interrupt-for-shutdown)", path)
|
||
if cancel_match:
|
||
try:
|
||
reason = "service_shutdown" if cancel_match.group(2) == "interrupt-for-shutdown" else "task_interrupted"
|
||
task = self.server.executor.cancel(unquote(cancel_match.group(1)), reason=reason)
|
||
except KeyError:
|
||
self._send(404, _json_error("task_not_found", "任务不存在"))
|
||
return
|
||
self._send(200, task.public())
|
||
return
|
||
if path != "/v1/tasks":
|
||
self._send(404, _json_error("not_found", "接口不存在"))
|
||
return
|
||
try:
|
||
payload = self._read_json()
|
||
task, wait = self.server.executor.submit(payload)
|
||
except ValueError as exc:
|
||
code = str(exc)
|
||
messages = {
|
||
"agent_image_path_forbidden": "图片路径不在受控图片目录内",
|
||
"agent_result_path_invalid": "任务标识非法",
|
||
"invalid_reverse_image_policy": "反向图片策略无效",
|
||
"agent_analysis_policy_missing": "启用分析用量控制但缺少冻结策略",
|
||
"agent_analysis_policy_invalid": "分析用量策略无效",
|
||
"agent_timeout_limit_exceeded": "任务超时超过 executor 上限",
|
||
"session_binding_mismatch": "续跑 session 与任务事实不匹配",
|
||
"opencode_workspace_invalid": "workspace selector 或目录无效",
|
||
"opencode_workspace_mismatch": "workspace capability 与任务事实不匹配",
|
||
"opencode_workspace_capability_invalid": "workspace capability 无效",
|
||
"opencode_workspace_capability_expired": "workspace capability 已过期",
|
||
"opencode_workspace_capability_unavailable": "workspace capability 未配置",
|
||
"model_capability_invalid": "模型 capability 无效",
|
||
"model_capability_unavailable": "模型 capability 未配置",
|
||
"model_broker_endpoint_invalid": "模型 broker 未配置",
|
||
"model_name_invalid": "模型标识无效",
|
||
"visual_match_snapshot_invalid": "视觉候选 snapshot 无效",
|
||
}
|
||
if code not in messages and code not in {"invalid_task"}:
|
||
code = "invalid_task"
|
||
self._send(400, _json_error(code, messages.get(code, "任务请求无效")))
|
||
return
|
||
except RuntimeError as exc:
|
||
code = str(exc)
|
||
if code not in {
|
||
"agent_backpressure",
|
||
"task_exists",
|
||
"agent_runtime_unavailable",
|
||
"opencode_not_configured",
|
||
"session_binding_mismatch",
|
||
"session_not_resumable",
|
||
"unknown_execution",
|
||
"opencode_workspace_invalid",
|
||
"opencode_workspace_mismatch",
|
||
"opencode_workspace_capability_invalid",
|
||
"opencode_workspace_capability_expired",
|
||
"opencode_workspace_capability_unavailable",
|
||
"model_capability_invalid",
|
||
"model_capability_unavailable",
|
||
"model_broker_endpoint_invalid",
|
||
"model_name_invalid",
|
||
"agent_analysis_policy_missing",
|
||
"agent_analysis_policy_invalid",
|
||
"visual_match_snapshot_invalid",
|
||
"visual_candidate_materialization_failed",
|
||
}:
|
||
code = "agent_runtime_unavailable"
|
||
status = {
|
||
"agent_backpressure": 429,
|
||
"task_exists": 409,
|
||
"session_binding_mismatch": 409,
|
||
"session_not_resumable": 409,
|
||
"unknown_execution": 409,
|
||
"agent_runtime_unavailable": 503,
|
||
"opencode_not_configured": 503,
|
||
}.get(code, 503)
|
||
self._send(status, _json_error(code, "任务当前无法执行"))
|
||
return
|
||
if wait:
|
||
task.done.wait(task.timeout_seconds + 5)
|
||
status = 200 if task.status in {"succeeded", "failed", "cancelled"} else 202
|
||
self._send(status, task.public())
|
||
|
||
|
||
class ExecutorHTTPServer(ThreadingHTTPServer):
|
||
"""带 executor 状态对象的线程化 HTTP 服务。"""
|
||
|
||
daemon_threads = True
|
||
allow_reuse_address = True
|
||
|
||
def __init__(self, address: tuple[str, int], executor: Executor):
|
||
"""绑定固定 loopback 监听地址供 Compose 内网转发。"""
|
||
self.executor = executor
|
||
super().__init__(address, Handler)
|
||
|
||
|
||
def main() -> None:
|
||
"""启动 executor;端口只由容器内部环境配置,不发布到宿主机。"""
|
||
configure_executor_diagnostics(Path(os.getenv("MEMEMEOW_EXECUTOR_DIAGNOSTICS_ROOT", str(RUNTIME_ROOT / "executor-logs"))))
|
||
host = os.getenv("MEMEMEOW_AGENT_EXECUTOR_HOST", "0.0.0.0")
|
||
port = _env_int("MEMEMEOW_AGENT_EXECUTOR_PORT", 8277, 1, 65535)
|
||
telemetry = start_telemetry("mememeow-executor")
|
||
executor = Executor()
|
||
server = ExecutorHTTPServer((host, port), executor)
|
||
try:
|
||
server.serve_forever(poll_interval=0.2)
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
executor.close()
|
||
server.server_close()
|
||
if telemetry is not None:
|
||
telemetry.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|