"""OpenCode 图片语境生成运行器。 模块只负责准备固定运行目录、调用公开 CLI 并把不可信输出转为候选 JSON;canonical sidecar 的读取、指纹复核和写入始终由上层元数据服务完成。 """ from __future__ import annotations from loguru import logger import hashlib import io import json import os import re import shutil import signal import socket import stat import subprocess import tempfile import time import uuid from dataclasses import replace from io import BytesIO from pathlib import Path, PurePosixPath from queue import Empty, Queue from threading import Event, Lock, RLock, Semaphore, local from typing import Any, BinaryIO, Callable, Iterator, Mapping from urllib.error import URLError from urllib.request import urlopen from backend.agent_executor import AgentExecutorClient, AgentExecutorError, ExecutorTaskResponse from backend.agent_resume import classify_resume_error, normalize_identifier from backend.config import Settings, validate_agent_concurrency from backend.metadata import MemeContext from backend.model_capability_provider import ( ModelCapabilityProvider, ModelCapabilityProviderError, ModelCapabilityRequest, capability_for_model_provider, normalize_observed_cost, ) from backend.opencode_result_store import OpenCodeResultStore from backend.runtime_execution import AttemptFence, ExecutionBinding, ExecutionBindingError, stable_input_digest from backend.opencode_workspace import ( LocalWorkspaceProvider, ResolvedWorkspace, TrustedWorkspaceContext, WorkspaceCapabilityError, WorkspaceProvider, WorkspaceResolutionError, build_edit_permission_rules, build_external_directory_rules, capability_for_provider, prepare_candidates_for_provider, validate_directory_path, validate_file_path, ) from backend.public_dto import PublicDataError, secret_inventory_from_settings, validate_agent_result from backend.visual_snapshot import ( VisualMatchSnapshotError, validate_visual_match_snapshot_manifest, visual_match_snapshot_manifest, ) try: from jsonschema import Draft202012Validator, FormatChecker except ImportError: # pragma: no cover - 兼容尚未同步依赖的开发环境 Draft202012Validator = None FormatChecker = None # 此配置只定义兼容 OpenAI API 的通用 provider,不保存部署地址、长期密钥或模型选择。 # broker 地址和当前任务的短期 capability 由 executor 在子进程环境中装配。 RUNTIME_OPENCODE_CONFIG: dict[str, Any] = { "$schema": "https://opencode.ai/config.json", "experimental": { "continue_loop_on_deny": True, }, "provider": { "mememeow": { "npm": "@ai-sdk/openai", "name": "MemeMeow OpenAI Responses provider", "options": { "baseURL": "{env:MEMEMEOW_MODEL_BROKER_URL}", "apiKey": "{env:MEMEMEOW_MODEL_CAPABILITY}", }, "models": { "gpt-5.6-luna": { "id": "gpt-5.6-luna", "name": "GPT-5.6 Luna", "family": "gpt-luna", "release_date": "2026-07-09", "status": "active", "reasoning": True, "tool_call": True, "temperature": False, "attachment": True, "interleaved": False, "modalities": { "input": ["text", "image", "pdf"], "output": ["text"], }, "limit": { "context": 1050000, "input": 922000, "output": 128000, }, "cost": { "input": 0.25, "output": 2, "cache_read": 0.02, "cache_write": 0.25, "context_over_200k": { "input": 0.4, "output": 1.8, "cache_read": 0.04, "cache_write": 0.5, }, }, "variants": { "max": {"reasoningEffort": "max"}, }, } }, } }, } # 当前研究任务统一使用 Luna 的 max 变体,避免不同任务因默认推理强度产生质量漂移。 OPENCODE_REASONING_VARIANT = "max" # 诊断日志只保留固定前缀,避免完整 Agent transcript 长期占用 runtime 磁盘。 # 该值不参与 OpenCode 输出读取或业务结果解析,因此不会拒绝大输出。 DIAGNOSTIC_LOG_BYTES = 256 * 1024 STREAM_COPY_CHUNK_BYTES = 64 * 1024 RESULT_FILE_NAME = "result.json.tmp" RESULT_DRAFT_NAME = "result.json.draft" RESULT_DEFAULT_MAX_BYTES = 1024 * 1024 CANDIDATE_MANIFEST_MAX_BYTES = 4 * 1024 * 1024 MAX_CANDIDATE_FILE_BYTES = 64 * 1024 * 1024 MAX_CANDIDATE_ROOT_BYTES = 256 * 1024 * 1024 EXECUTOR_IMAGE_ROOT = Path("/images") _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"(? str: """从 host OpenCode 诊断中移除凭据、路径和多行 transcript。""" 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) return _GENERIC_PATH_PATTERN.sub("[PATH]", value)[:500] def _stream_sample(stream: BinaryIO, limit: int) -> bytes: """读取临时输出的头尾样本,避免大 JSONL 将 provider 错误留在不可见尾部。""" stream.seek(0) head = stream.read(limit) stream.seek(0, os.SEEK_END) size = stream.tell() if size <= limit: return head stream.seek(max(0, size - limit)) return head + b"\n" + stream.read(limit) class OpenCodeError(RuntimeError): """携带稳定错误码的 OpenCode 运行失败。""" def __init__( self, code: str, message: str | None = None, *, session_id: str | None = None, executor_attempt_id: str | None = None, retryable: bool | None = None, http_status: int | None = None, reason_code: str | None = None, process_reaped: bool | None = None, observed_cost: str | None = None, usage_checked_at: str | None = None, reminder_sent: bool = False, termination_reason: str | None = None, termination_signal: str | None = None, check_stage: str | None = None, trigger_reason: str | None = None, ): """保存稳定错误及受信 Executor 返回的有限 attempt 诊断。""" super().__init__(message or code) self.code = code self.session_id = normalize_identifier(session_id, kind="session") self.executor_attempt_id = normalize_identifier(executor_attempt_id, kind="attempt") self.retryable = retryable self.http_status = http_status self.reason_code = reason_code if isinstance(reason_code, str) and re.fullmatch(r"[a-z0-9][a-z0-9_.-]{0,127}", reason_code) else None self.process_reaped = process_reaped if isinstance(process_reaped, bool) else None self.observed_cost = observed_cost if isinstance(observed_cost, str) else None self.usage_checked_at = usage_checked_at if isinstance(usage_checked_at, str) else None self.reminder_sent = bool(reminder_sent) self.termination_reason = termination_reason self.termination_signal = termination_signal self.check_stage = check_stage self.trigger_reason = trigger_reason def _workspace_opencode_config(workspace: ResolvedWorkspace) -> dict[str, Any]: """生成当前 workspace 的无密钥 OpenCode 配置和文件工具权限规则。""" config = json.loads(json.dumps(RUNTIME_OPENCODE_CONFIG, ensure_ascii=False)) config["permission"] = { "external_directory": {path: decision for path, decision in workspace.permission_rules}, "edit": { path: decision for path, decision in build_edit_permission_rules( task_scratch_root=workspace.task_scratch_root, config_file=workspace.config_file, config_dir=workspace.config_dir, draft_path=workspace.draft_path, result_path=workspace.result_path, candidate_root=workspace.candidate_root, ) }, } return config class OpenCodeRunner: """在固定 runtime 中按 slot 受控并行执行 OpenCode,并返回已校验研究结果。""" def __init__( self, settings: Settings, project_root: Path | None = None, *, workspace_provider: WorkspaceProvider | None = None, model_capability_provider: ModelCapabilityProvider | None = None, ): """初始化 runner;应用装配应显式传入 provider,旧直接夹具兼容 local。""" self.settings = settings self.project_root = (project_root or Path(__file__).resolve().parent.parent).resolve() self.runtime_root = (settings.opencode_runtime_root or settings.data_root / "opencode").expanduser().resolve() self.workspace = self.runtime_root / "workspace" self.db_path = self.runtime_root / "opencode.db" self.slots_root = self.runtime_root / "slots" self.lock_path = self.runtime_root / "worker.lock" self.log_root = self.runtime_root / "logs" self.concurrency = validate_agent_concurrency(settings.opencode_concurrency) self._slot_semaphore = Semaphore(self.concurrency) self._slot_ids: Queue[int] = Queue() for slot_id in range(self.concurrency): self._slot_ids.put(slot_id) self._prepare_lock = Lock() self._runtime_ready = False self._closing = Event() self._process: subprocess.Popen[bytes] | None = None self._processes: set[subprocess.Popen[bytes]] = set() # 结果目录创建、活动 task 标记和清理必须共享可重入锁,避免清理与启动交错。 self._process_lock = RLock() self._process_tasks: dict[subprocess.Popen[bytes], str | None] = {} self._active_task_ids: set[str] = set() self._active_executor_task_ids: set[str] = set() self._last_executor_attempt_ids: dict[str, str] = {} self._executor_attempt_context = local() self._analysis_observations: dict[tuple[str, str], dict[str, object]] = {} self._cancelled_task_ids: set[str] = set() # 未显式传入 provider 只为既有开源直接构造调用保留 local 兼容;应用工厂 # 对 non-local scope 必须显式装配 provider,不能在这里猜测路径。 self.workspace_provider: WorkspaceProvider = workspace_provider or LocalWorkspaceProvider( self.runtime_root, image_root=self._configured_image_root(), skill_root=self.project_root / "skills" / "research-meme-context", ) self.model_capability_provider = model_capability_provider self._workspace_by_task: dict[str, ResolvedWorkspace] = {} self._workspace_selectors: dict[str, str] = {} self._workspace_capabilities: dict[str, str] = {} # 进程内 fencing 只做旧回调快速拒绝;跨进程 claim 仍由任务数据库负责。 self._attempt_fence = AttemptFence() self.result_store = OpenCodeResultStore( self.runtime_root / "task-results", result_name=RESULT_FILE_NAME, draft_name=RESULT_DRAFT_NAME, max_bytes=int(getattr(settings, "agent_result_max_bytes", RESULT_DEFAULT_MAX_BYTES)), ) self.executor = AgentExecutorClient( getattr(settings, "agent_executor_url", None), getattr(settings, "agent_executor_token", None), timeout=int(getattr(settings, "agent_executor_request_timeout_seconds", 1810)), ) def resolve_workspace(self, context: TrustedWorkspaceContext) -> ResolvedWorkspace: """从可信任务上下文解析并缓存一次 workspace 描述。""" try: resolved = self.workspace_provider.resolve(context) except (WorkspaceResolutionError, WorkspaceCapabilityError) as exc: raise OpenCodeError(exc.code, str(exc)) from exc except Exception as exc: # noqa: BLE001 - provider 是安全边界,异常统一失败关闭 raise OpenCodeError("opencode_workspace_invalid", "workspace provider 无法解析任务") from exc if not isinstance(resolved, ResolvedWorkspace): raise OpenCodeError("opencode_workspace_invalid", "workspace provider 返回值无效") if context.selector is not None and context.selector != resolved.selector: raise OpenCodeError("opencode_workspace_mismatch", "workspace selector 与可信任务事实不一致") try: validated = self._validate_resolved_workspace(context, resolved) # 候选物化属于 provider 的受信适配层,必须在任何 OpenCode 进程或 # 结果文件副作用前完成;物化后再次校验固定 task 根和 manifest 节点。 prepare_candidates_for_provider(self.workspace_provider, context, validated) materializer = callable(getattr(self.workspace_provider, "prepare_candidates", None)) if validated.candidate_root.exists() or validated.candidate_root.is_symlink(): validate_directory_path(validated.candidate_root, code="opencode_workspace_invalid") else: validate_directory_path(validated.candidate_root, allow_missing_leaf=True, code="opencode_workspace_invalid") if context.visual_match_snapshot is not None: if not materializer and not validated.local: raise WorkspaceResolutionError("visual_candidate_materialization_failed", "外部 workspace 缺少候选物化能力") manifest = self._validate_candidate_manifest(validated, context.visual_match_snapshot) self._validate_candidate_tree(validated, manifest) return validated except WorkspaceResolutionError as exc: raise OpenCodeError(exc.code, str(exc)) from exc except (OSError, TypeError, ValueError) as exc: raise OpenCodeError("opencode_workspace_invalid", "workspace provider 返回值无法校验") from exc @staticmethod def _validate_candidate_manifest(workspace: ResolvedWorkspace, snapshot: Mapping[str, object]) -> dict[str, object]: """在启动外部 Agent 前校验 manifest 文件内容与 Task snapshot 完全一致。""" path = workspace.candidate_manifest_path try: validate_file_path(path, code="visual_candidate_materialization_failed") info = path.lstat() if info.st_size > CANDIDATE_MANIFEST_MAX_BYTES: raise WorkspaceResolutionError("visual_candidate_materialization_failed", "候选 manifest 超过大小限制") descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) try: raw = os.read(descriptor, CANDIDATE_MANIFEST_MAX_BYTES + 1) finally: os.close(descriptor) if len(raw) > CANDIDATE_MANIFEST_MAX_BYTES: raise WorkspaceResolutionError("visual_candidate_materialization_failed", "候选 manifest 超过大小限制") document = json.loads(raw.decode("utf-8")) expected = visual_match_snapshot_manifest(snapshot) validated = validate_visual_match_snapshot_manifest( document, expected_sha256=str(expected["snapshot_sha256"]), ) except WorkspaceResolutionError: raise except (OSError, UnicodeDecodeError, json.JSONDecodeError, RecursionError, TypeError, ValueError, VisualMatchSnapshotError) as exc: raise WorkspaceResolutionError("visual_candidate_materialization_failed", "候选 manifest 无法校验") from exc if validated != expected: raise WorkspaceResolutionError("visual_candidate_materialization_failed", "候选 manifest 与 snapshot 不一致") return validated @staticmethod def _validate_candidate_tree(workspace: ResolvedWorkspace, manifest: Mapping[str, object]) -> None: """按 manifest 校验候选目录,拒绝额外节点、符号链接和可写文件。""" root = workspace.candidate_root try: validate_directory_path(root, code="visual_candidate_materialization_failed") root_info = root.lstat() if root_info.st_mode & (stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH): raise ValueError("visual_candidate_materialization_failed") candidates = manifest.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, Mapping): 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 root.rglob("*"): info = item.lstat() relative = item.relative_to(root).as_posix() if stat.S_ISLNK(info.st_mode) or info.st_mode & (stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH): raise ValueError("visual_candidate_materialization_failed") if stat.S_ISDIR(info.st_mode): if relative not in expected_directories: raise ValueError("visual_candidate_materialization_failed") elif stat.S_ISREG(info.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 root.rglob("*") if item.is_file()) if total_bytes > MAX_CANDIDATE_ROOT_BYTES: raise ValueError("visual_candidate_materialization_failed") for candidate in candidates: 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 = root.joinpath(*PurePosixPath(relative).parts) validate_file_path(candidate_path, code="visual_candidate_materialization_failed") info = candidate_path.lstat() if info.st_size != size_bytes: raise ValueError("visual_candidate_materialization_failed") digest = hashlib.sha256() descriptor = os.open(candidate_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) try: while True: chunk = os.read(descriptor, 1024 * 1024) if not chunk: break digest.update(chunk) finally: os.close(descriptor) if digest.hexdigest() != image_sha256.lower(): raise ValueError("visual_candidate_materialization_failed") except WorkspaceResolutionError: raise except (OSError, TypeError, ValueError) as exc: raise WorkspaceResolutionError("visual_candidate_materialization_failed", "候选文件无法校验") from exc def prepare_candidates_for_task( self, *, task_id: str, attempt_id: str, scope_id: str, snapshot: Mapping[str, object], selector: str | None = None, session_id: str | None = None, resume_of_attempt_id: str | None = None, image_relative_path: str | None = None, ) -> ResolvedWorkspace: """在外部执行前按可信 claim 物化当前 Task 的候选 manifest。""" try: context = TrustedWorkspaceContext( task_id=task_id, attempt_id=attempt_id, scope_id=scope_id, selector=selector, session_id=session_id, resume_of_attempt_id=resume_of_attempt_id, image_relative_path=image_relative_path, visual_match_snapshot=snapshot, ) except WorkspaceResolutionError as exc: raise OpenCodeError(exc.code, str(exc)) from exc return self.resolve_workspace(context) def _validate_resolved_workspace(self, context: TrustedWorkspaceContext, resolved: ResolvedWorkspace) -> ResolvedWorkspace: """固定 provider 输出的结果/DB 布局,并重建服务端权限规则。 provider 可以把图片、metadata 和 Skill 放在容器外部受控挂载,但配置、Task 临时目录和结果路径不能变成新的任意文件写入接口。这里的检查只验证已存在 的输入视图;Task 目录和结果目录在 capability 校验后才创建。 """ def absolute(path: Path) -> Path: """规范化路径而不跟随最终符号链接。""" return Path(os.path.abspath(Path(path).expanduser())) directory = absolute(resolved.directory) scratch = absolute(resolved.task_scratch_root) config_file = absolute(resolved.config_file) config_dir = absolute(resolved.config_dir) images_root = absolute(resolved.images_root) metadata_root = absolute(resolved.metadata_root) skill_root = absolute(resolved.skill_root) candidate_root = absolute(resolved.candidate_root) task_results = absolute(resolved.task_results_root) expected_results = absolute(self.runtime_root / "task-results" / context.task_id) expected_db = absolute(self.db_path) if not isinstance(resolved.selector, str) or resolved.selector != resolved.selector.strip() or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", resolved.selector): raise WorkspaceResolutionError("opencode_workspace_invalid", "workspace selector 无效") if absolute(resolved.db_path) != expected_db: raise WorkspaceResolutionError("opencode_workspace_invalid", "workspace DB 路径不受支持") validate_file_path(resolved.db_path, allow_missing=True, code="opencode_workspace_invalid") if task_results != expected_results: raise WorkspaceResolutionError("opencode_workspace_invalid", "workspace 结果路径不受支持") if scratch != directory / "tasks" / context.task_id: raise WorkspaceResolutionError("opencode_workspace_invalid", "workspace 临时路径不受支持") if candidate_root != directory.parent / "candidates" / context.task_id: raise WorkspaceResolutionError("opencode_workspace_invalid", "workspace 候选路径不受支持") if config_file != scratch / "opencode.json" or config_dir != scratch / ".opencode": raise WorkspaceResolutionError("opencode_workspace_invalid", "workspace 配置路径不受支持") if resolved.local and context.scope_id != "local": raise WorkspaceResolutionError("opencode_workspace_mismatch", "non-local 任务不能使用 local workspace") if resolved.local and resolved.selector != "local": raise WorkspaceResolutionError("opencode_workspace_invalid", "local workspace selector 无效") if not resolved.local and resolved.selector == "local": raise WorkspaceResolutionError("opencode_workspace_invalid", "external workspace selector 保留字无效") for path in (directory, images_root, metadata_root, candidate_root.parent): validate_directory_path(path, code="opencode_workspace_invalid") if candidate_root.exists() or candidate_root.is_symlink(): validate_directory_path(candidate_root, code="opencode_workspace_invalid") if resolved.local and skill_root.is_symlink(): # local provider 为既有 checkout 保留相对 Skill 链接;链接目标仍须是普通目录。 validate_directory_path(skill_root.resolve(strict=True), code="opencode_workspace_invalid") else: validate_directory_path(skill_root, code="opencode_workspace_invalid") for path in (scratch, config_dir, task_results): if path.exists() or path.is_symlink(): validate_directory_path(path, code="opencode_workspace_invalid") validated = replace( resolved, directory=directory, config_file=config_file, config_dir=config_dir, images_root=images_root, metadata_root=metadata_root, skill_root=skill_root, candidate_root=candidate_root, task_scratch_root=scratch, task_results_root=task_results, db_path=expected_db, ) return replace(validated, _permission_rules=build_external_directory_rules(validated)) def workspace_for_task(self, task_id: str) -> str | None: """返回任务最近解析的 opaque selector,供诊断写回。""" with self._process_lock: return self._workspace_selectors.get(task_id) @property def executor_mode(self) -> bool: """判断当前任务是否通过已选定的 executor 执行。""" mode = getattr(self.settings, "agent_runtime_mode", "auto") if mode not in {"auto", "executor", "host"}: raise OpenCodeError("agent_runtime_mode_invalid", "Agent 运行模式不受支持") if mode == "executor": return True return mode == "auto" and self.executor.configured @property def last_executor_attempt_id(self) -> str | None: """返回最近一次当前线程任务的 executor attempt 摘要。""" # 该属性只作为成功写回的诊断补充;真正的绑定事实仍来自 executor 响应。 with self._process_lock: if not self._last_executor_attempt_ids: return None return next(reversed(self._last_executor_attempt_ids.values())) def executor_attempt_id_for(self, task_id: str) -> str | None: """按业务 task 读取最近 attempt,避免并发任务互相串接诊断。""" thread_binding = getattr(self._executor_attempt_context, "binding", None) if isinstance(thread_binding, tuple) and thread_binding[0] == task_id: return thread_binding[1] with self._process_lock: return self._last_executor_attempt_ids.get(task_id) def analysis_observation_for(self, task_id: str, executor_attempt_id: str | None = None) -> dict[str, object] | None: """按当前 executor attempt 取出一次分析摘要,读取后立即清理。""" with self._process_lock: expected_attempt_id = executor_attempt_id or self._last_executor_attempt_ids.get(task_id) if not isinstance(expected_attempt_id, str) or not expected_attempt_id: return None value = self._analysis_observations.pop((task_id, expected_attempt_id), None) return dict(value) if value is not None else None def _remember_executor_attempt(self, task_id: str, executor_attempt_id: str) -> None: """保存任务 attempt 摘要并限制长期运行进程的内存历史。""" self._executor_attempt_context.binding = (task_id, executor_attempt_id) with self._process_lock: self._last_executor_attempt_ids[task_id] = executor_attempt_id while len(self._last_executor_attempt_ids) > 5000: self._last_executor_attempt_ids.pop(next(iter(self._last_executor_attempt_ids))) def _remember_analysis_observation(self, task_id: str, response: ExecutorTaskResponse) -> None: """暂存成功响应的分析摘要,等待 API 在同一次调用中取出并持久化。""" if response.observed_cost is None: return with self._process_lock: thread_binding = getattr(self._executor_attempt_context, "binding", None) expected_attempt_id = thread_binding[1] if isinstance(thread_binding, tuple) and thread_binding[0] == task_id else None response_attempt_id = response.executor_attempt_id or expected_attempt_id if not isinstance(expected_attempt_id, str) or response_attempt_id != expected_attempt_id: return self._analysis_observations[(task_id, expected_attempt_id)] = { "observed_cost": response.observed_cost, "usage_checked_at": response.usage_checked_at, "reminder_sent": response.reminder_sent, "process_reaped": response.process_reaped, } while len(self._analysis_observations) > 5000: self._analysis_observations.pop(next(iter(self._analysis_observations))) @staticmethod def _executor_error_code(code: str, *, health: bool = False) -> str: """把 executor 客户端异常收口为任务服务可识别的稳定错误码。""" if health and code in {"agent_timeout", "agent_executor_unavailable", "agent_executor_http_error"}: return "agent_runtime_unavailable" known = { "agent_analysis_policy_missing", "agent_analysis_policy_invalid", "agent_analysis_usage_unavailable", "agent_analysis_reminder_plugin_unavailable", "agent_maximum_analysis_depth_exceeded", "model_capability_invalid", "model_capability_unavailable", "model_broker_endpoint_invalid", "agent_timeout", "task_interrupted", "service_shutdown", "agent_attempt_metadata_write_failed", "agent_process_failed", "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", "agent_executor_not_configured", "agent_executor_unavailable", "agent_executor_unauthorized", "agent_executor_invalid_response", "opencode_workspace_invalid", "opencode_workspace_mismatch", "opencode_workspace_capability_invalid", "opencode_workspace_capability_expired", "opencode_workspace_capability_unavailable", "opencode_workspace_provider_missing", } return code if code in known else "agent_executor_invalid_response" def _configured_image_root(self) -> Path: """解析设置中的图片根目录,统一相对路径基准以匹配 Compose 挂载。""" configured = Path(self.settings.image_root or self.project_root / "data" / "images").expanduser() if not configured.is_absolute(): configured = self.project_root / configured return configured.resolve() def _link(self, target: Path, source: Path) -> None: """创建或核验相对链接,拒绝覆盖不属于 runtime 的真实目录。""" if target.is_symlink(): if target.resolve() == source.resolve(): return target.unlink() elif target.exists(): raise OpenCodeError("opencode_not_configured", f"runtime 路径冲突:{target}") target.parent.mkdir(parents=True, exist_ok=True) target.symlink_to(os.path.relpath(source, target.parent), target_is_directory=True) def _config_for_runtime(self, workspace: ResolvedWorkspace | None = None) -> dict[str, Any]: """生成当前运行模式的无密钥配置。 生产 executor 只使用 broker/capability 占位符;local/host 夹具继续使用 旧 endpoint/key 环境引用,保证开发模式与已安装 OpenCode 的兼容回归。 """ config = _workspace_opencode_config(workspace) if workspace is not None else json.loads(json.dumps(RUNTIME_OPENCODE_CONFIG, ensure_ascii=False)) production_executor = str(getattr(self.settings, "public_release_profile", "local")).casefold() in {"production", "public", "1", "true", "yes", "on"} and self.executor_mode if not production_executor: options = config.setdefault("provider", {}).setdefault("mememeow", {}).setdefault("options", {}) options["baseURL"] = "{env:MEMEMEOW_OPENCODE_BASE_URL}" options["apiKey"] = "{env:MEMEMEOW_OPENCODE_API_KEY}" return config def _write_workspace_config(self, workspace: ResolvedWorkspace) -> None: """原子写入当前 workspace 的无密钥配置和外部目录权限。""" target = workspace.config_file validate_directory_path(target.parent, create=True, code="opencode_workspace_invalid") validate_file_path(target, allow_missing=True, code="opencode_workspace_invalid") content = json.dumps(self._config_for_runtime(workspace), ensure_ascii=False, indent=2) + "\n" try: if target.read_text(encoding="utf-8") == content: os.chmod(target, 0o600) return except FileNotFoundError: pass temporary = target.with_name(f".{target.name}.tmp.{os.getpid()}.{id(self)}") 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), 0o600) try: with os.fdopen(descriptor, "w", encoding="utf-8") as handle: descriptor = -1 handle.write(content) finally: if descriptor >= 0: os.close(descriptor) os.replace(temporary, target) def _write_runtime_config(self) -> None: """兼容旧诊断入口,写入既有 local workspace 基础配置。""" target = self.workspace / "opencode.json" content = json.dumps(self._config_for_runtime(), ensure_ascii=False, indent=2) + "\n" try: if target.read_text(encoding="utf-8") == content: return except FileNotFoundError: pass temporary = target.with_name(f".{target.name}.tmp.{os.getpid()}.{id(self)}") temporary.write_text(content, encoding="utf-8") os.chmod(temporary, 0o600) os.replace(temporary, target) def prepare_runtime(self) -> None: """准备可复用 workspace,不执行任何包管理器或依赖下载。""" with self._prepare_lock: if self._runtime_ready: return if not self.settings.opencode_model: raise OpenCodeError("opencode_not_configured", "未配置 OpenCode 模型") production = str(getattr(self.settings, "public_release_profile", "local")).casefold() in {"production", "public", "1", "true", "yes", "on"} if production and self.executor_mode: if not getattr(self.settings, "model_broker_url", None): raise OpenCodeError("model_broker_endpoint_invalid", "生产模型 broker 未配置") elif not self.settings.opencode_base_url or not self.settings.opencode_api_key: raise OpenCodeError("opencode_not_configured", "未配置 OpenCode 服务地址或密钥") if self.executor_mode: if not self.executor.configured: raise OpenCodeError("agent_executor_not_configured", "Agent executor 地址或凭据 token 未配置") try: health = self.executor.health() except AgentExecutorError as exc: # 健康探针失败也必须进入任务稳定错误协议,不能把客户端异常 # 直接交给长任务服务并退化成 task_failed。 code = self._executor_error_code(exc.code, health=True) raise OpenCodeError(code, str(exc)[:500], reason_code=exc.reason_code) from exc if not bool(health.get("ready")): raise OpenCodeError("agent_runtime_unavailable", "Agent executor 健康检查未通过") else: executable = self.settings.opencode_executable if not executable or (not Path(executable).is_file() and shutil.which(executable) is None): raise OpenCodeError("opencode_not_configured", "未找到 OpenCode 可执行文件") skills_source = self.project_root / "skills" / "research-meme-context" if not isinstance(self.workspace_provider, LocalWorkspaceProvider): # 外部 provider 已在 resolve 阶段验证自己的只读 Skill 视图, # host 模式不应再要求项目根存在同一份副本。 skills_source = None # 项目自己的 OpenCode 插件依赖与前端依赖隔离;环境变量可覆盖这一默认共享目录。 shared_modules = self.settings.opencode_node_modules or self.project_root / ".opencode" / "node_modules" if (skills_source is not None and not skills_source.is_dir()) or not shared_modules.is_dir() or not (shared_modules / "@ai-sdk" / "openai").is_dir(): raise OpenCodeError("opencode_not_configured", "OpenCode skill、共享 node_modules 或 Responses provider 未预先安装") self.runtime_root.mkdir(parents=True, exist_ok=True) (self.runtime_root / "home").mkdir(parents=True, exist_ok=True) if isinstance(self.workspace_provider, LocalWorkspaceProvider): self.workspace.mkdir(parents=True, exist_ok=True) self.slots_root.mkdir(parents=True, exist_ok=True) self.log_root.mkdir(parents=True, exist_ok=True) if isinstance(self.workspace_provider, LocalWorkspaceProvider): self._write_runtime_config() if not self.executor_mode and isinstance(self.workspace_provider, LocalWorkspaceProvider): skills_source = self.project_root / "skills" / "research-meme-context" self._link(self.workspace / ".opencode" / "skills" / "research-meme-context", skills_source) self._link(self.workspace / "node_modules", shared_modules) self._runtime_ready = True def runtime_probe(self) -> dict[str, object]: """返回共享 runtime、skill 和依赖探针结果,供启动诊断使用。""" if self.executor_mode: error_code: str | None = None try: health = self.executor.health() except AgentExecutorError as exc: health = {} error_code = self._executor_error_code(exc.code, health=True) ready = bool(health.get("ready")) runtime_ready = bool(health.get("runtime_read_write")) images_ready = bool(health.get("images_read_only")) skills_ready = bool(health.get("skills_read_only")) executable_ready = bool(health.get("opencode")) socket_absent = bool(health.get("docker_socket_absent")) return { "mode": "executor", "executor_url_configured": bool(self.executor.url), "executor_token_configured": bool(self.executor.token), "executor_running": ready, "runtime_root_ready": self.runtime_root.is_dir(), "workspace_ready": self.workspace.is_dir(), "executable_ready": executable_ready, "skills_ready": skills_ready, "dependencies_ready": executable_ready, "mounts_ready": runtime_ready and images_ready and skills_ready, "runtime_read_write": runtime_ready, "images_read_only": images_ready, "skill_read_only": skills_ready, "non_root": True, "network_ready": ready, "docker_socket_absent": socket_absent, "concurrency": self.concurrency, "error_code": error_code, "verified": bool(ready and self.executor.configured and runtime_ready and images_ready and skills_ready and executable_ready and socket_absent), } executable = self.settings.opencode_executable executable_ready = bool(executable and (Path(executable).is_file() or shutil.which(executable))) skills_ready = (self.project_root / "skills" / "research-meme-context").is_dir() modules = self.settings.opencode_node_modules or self.project_root / ".opencode" / "node_modules" dependencies_ready = modules.is_dir() and (modules / "@ai-sdk" / "openai").is_dir() return { "runtime_root_ready": self.runtime_root.is_dir(), "workspace_ready": self.workspace.is_dir(), "db_path": self.db_path.name, "executable_ready": executable_ready, "skills_ready": skills_ready, "dependencies_ready": dependencies_ready, "concurrency": self.concurrency, "mode": "host-runtime-slot-lock", "verified": bool(executable_ready and skills_ready and dependencies_ready), } def probe_runtime(self) -> dict[str, object]: """提供兼容命名的 runtime 探针入口,供诊断脚本和设置页调用。""" return self.runtime_probe() def skill_hash(self) -> str: """计算 skill 文件内容哈希,用于把运行时契约写入任务记录。""" source = self.project_root / "skills" / "research-meme-context" digest = hashlib.sha256() for path in sorted((item for item in source.rglob("*") if item.is_file()), key=lambda item: item.as_posix()): digest.update(path.relative_to(source).as_posix().encode("utf-8")) digest.update(path.read_bytes()) return digest.hexdigest() def _agent_reverse_image_url(self) -> str: """返回 Agent 使用的反向图片回调地址,兼容旧版设置夹具。""" return str(getattr(self.settings, "agent_reverse_image_internal_url", None) or self.settings.reverse_image_internal_url) def build_environment( self, slot_id: int | None = None, task_id: str | None = None, callback_token: str | None = None, *, workspace: ResolvedWorkspace | None = None, ) -> dict[str, str]: """构造隔离的 OpenCode 进程环境,供后台任务和交互检查入口共同使用。""" active_workspace = workspace or ResolvedWorkspace( selector="local", directory=self.workspace, config_file=self.workspace / "opencode.json", config_dir=self.workspace / ".opencode", images_root=self._configured_image_root(), metadata_root=self._configured_image_root(), skill_root=self.project_root / "skills" / "research-meme-context", task_scratch_root=self.workspace / "tasks" / (task_id or "interactive"), task_results_root=self.runtime_root / "task-results" / (task_id or "interactive"), db_path=self.db_path, candidate_root=self.runtime_root / "candidates" / (task_id or "interactive"), local=True, ) if self.executor_mode: # API 不启动 OpenCode 子进程;该快照仅供诊断,绝不把 executor token、 # callback 根 secret 或长期模型凭据传给 Agent。候选 manifest 是唯一 # 的本地视觉输入入口。 values = { "OPENCODE_DB": "/runtime/opencode.db", "OPENCODE_CONFIG": str(active_workspace.config_file), "OPENCODE_CONFIG_DIR": str(active_workspace.config_dir), "OPENCODE_DISABLE_PROJECT_CONFIG": "1", "MEMEMEOW_REVERSE_IMAGE_INTERNAL_URL": self._agent_reverse_image_url(), "MEMEMEOW_AGENT_CANDIDATE_MANIFEST": f"/runtime/candidates/{task_id or 'interactive'}/manifest.json", "MEMEMEOW_DATA_ROOT": "/runtime", } if slot_id is not None: values["MEMEMEOW_OPENCODE_SLOT"] = str(slot_id) if task_id: values["MEMEMEOW_AGENT_TASK_ID"] = str(task_id) if callback_token: values["MEMEMEOW_AGENT_CALLBACK_TOKEN"] = callback_token return values # host 回滚模式也必须使用白名单;继承整个 API 环境会把 callback # 根 secret、executor token、数据库配置或其它 scope 配置带进 Agent 子进程。 runtime_root = self.runtime_root environment = { "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"), # 使用 runtime 专属 HOME,避免 host 兼容模式读取调用用户的凭据目录。 "HOME": str(self.runtime_root / "home"), "OPENCODE_DB": str(runtime_root / "opencode.db"), "OPENCODE_CONFIG": str(active_workspace.config_file), "OPENCODE_CONFIG_DIR": str(active_workspace.config_dir), # 禁止向上合并项目根配置,避免任务意外使用其他 provider 或本地凭据。 "OPENCODE_DISABLE_PROJECT_CONFIG": "1", "MEMEMEOW_DATA_ROOT": str(runtime_root), "MEMEMEOW_REVERSE_IMAGE_CACHE_ROOT": str(runtime_root / "reverse_image_cache" / "web_detection"), "MEMEMEOW_REVERSE_IMAGE_INTERNAL_URL": self._agent_reverse_image_url(), # 视觉 Skill 只接收 provider 物化的 task-scoped manifest,不获得 # 数据库、模型运行时或视觉 callback 权限。 "MEMEMEOW_AGENT_CANDIDATE_MANIFEST": str(active_workspace.candidate_manifest_path), } environment["MEMEMEOW_OPENCODE_BASE_URL"] = str(self.settings.opencode_base_url or "") environment["MEMEMEOW_OPENCODE_API_KEY"] = str(self.settings.opencode_api_key or "") shared_modules = self.settings.opencode_node_modules or self.project_root / ".opencode" / "node_modules" environment["NODE_PATH"] = str(shared_modules) if slot_id is not None: environment["MEMEMEOW_OPENCODE_SLOT"] = str(slot_id) if task_id: environment["MEMEMEOW_AGENT_TASK_ID"] = str(task_id) if callback_token: environment["MEMEMEOW_AGENT_CALLBACK_TOKEN"] = callback_token return environment def _acquire_slot(self) -> tuple[int, Any]: """获取进程内 semaphore 和跨进程 slot 文件锁。""" acquired = False while not self._closing.is_set(): if self._slot_semaphore.acquire(timeout=0.2): acquired = True break if not acquired or self._closing.is_set(): # 关闭信号可能与 semaphore 获取同时到达,不能遗留已占用配额。 if acquired: self._slot_semaphore.release() raise OpenCodeError("task_interrupted", "OpenCode runner 正在关闭") try: import fcntl except ImportError: # pragma: no cover - Windows 退化为进程内互斥 if self._closing.is_set(): self._slot_semaphore.release() raise OpenCodeError("task_interrupted", "OpenCode runner 正在关闭") try: slot_id = self._slot_ids.get(timeout=0.2) except Empty as exc: self._slot_semaphore.release() raise OpenCodeError("opencode_slot_unavailable", "无法获取 OpenCode slot") from exc if self._closing.is_set(): self._slot_ids.put(slot_id) self._slot_semaphore.release() raise OpenCodeError("task_interrupted", "OpenCode runner 正在关闭") return slot_id, None while not self._closing.is_set(): try: slot_id = self._slot_ids.get(timeout=0.2) except Empty: continue if self._closing.is_set(): self._slot_ids.put(slot_id) self._slot_semaphore.release() raise OpenCodeError("task_interrupted", "OpenCode runner 正在关闭") lock_path = self.slots_root / f"slot-{slot_id}.lock" try: lock_path.parent.mkdir(parents=True, exist_ok=True) handle = lock_path.open("a+") except OSError as exc: self._slot_ids.put(slot_id) self._slot_semaphore.release() raise OpenCodeError("opencode_slot_unavailable", "无法创建 OpenCode slot 锁") from exc try: fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: handle.close() self._slot_ids.put(slot_id) time.sleep(0.02) continue except OSError as exc: handle.close() self._slot_ids.put(slot_id) self._slot_semaphore.release() raise OpenCodeError("opencode_slot_unavailable", "无法获取 OpenCode slot 锁") from exc return slot_id, handle self._slot_semaphore.release() raise OpenCodeError("task_interrupted", "OpenCode runner 正在关闭") def _release_slot(self, slot_id: int, handle: Any) -> None: """释放跨进程 slot 文件锁和进程内 semaphore。""" if handle is not None: try: import fcntl fcntl.flock(handle.fileno(), fcntl.LOCK_UN) except (ImportError, OSError): pass handle.close() self._slot_ids.put(slot_id) self._slot_semaphore.release() def _track_process(self, process: subprocess.Popen[bytes], task_id: str | None = None) -> None: """登记由当前 runner 管理的进程组,便于并行 shutdown。""" with self._process_lock: self._process = process self._processes.add(process) self._process_tasks[process] = task_id def _mark_task_active(self, task_id: str) -> None: """登记当前进程仍在执行的 task,结果清理时保护其专属目录。""" with self._process_lock: self._active_task_ids.add(task_id) def _unmark_task_active(self, task_id: str) -> None: """移除已结束 task 的活动标记,允许后续保留策略清理产物。""" with self._process_lock: self._active_task_ids.discard(task_id) def _untrack_process(self, process: subprocess.Popen[bytes]) -> None: """从受管理进程集合移除已收束进程。""" with self._process_lock: self._processes.discard(process) self._process_tasks.pop(process, None) self._process = next(iter(self._processes), None) def _take_pre_cancelled(self, task_id: str) -> bool: """消费任务开始前收到的取消标记,阻止取消竞态继续启动外部进程。""" with self._process_lock: if task_id not in self._cancelled_task_ids: return False self._cancelled_task_ids.discard(task_id) return True @staticmethod def _event_session(event: object) -> str | None: """从公开 JSONL 事件中递归找出 session 标识。""" if isinstance(event, dict): for key in ("session_id", "sessionID", "sessionId", "id"): value = event.get(key) if isinstance(value, str) and value and (key != "id" or "session" in str(event.get("type", "")).lower()): return value for value in event.values(): found = OpenCodeRunner._event_session(value) if found: return found elif isinstance(event, list): for value in event: found = OpenCodeRunner._event_session(value) if found: return found return None @staticmethod def _process_error_diagnostic(stdout: bytes, stderr: bytes, *, secrets: tuple[str, ...] = ()) -> str: """从 CLI 的 stderr 或 JSONL error 事件提取有限且可展示的诊断。""" return OpenCodeRunner._process_error_diagnostic_stream(BytesIO(stdout), BytesIO(stderr), secrets=secrets) @staticmethod def _classify_process_failure(stdout: BinaryIO, stderr: BinaryIO) -> str: """按有限错误事件区分 provider、网络和普通进程失败。""" raw = _stream_sample(stdout, STREAM_COPY_CHUNK_BYTES * 4) raw += _stream_sample(stderr, STREAM_COPY_CHUNK_BYTES) text = raw.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" if any(500 <= status <= 599 for status in statuses): return "agent_provider_server_error" if any(token in text for token in ("econnreset", "connection reset", "connection refused", "socket hang up", "network error", "timed out")): return "agent_connection_interrupted" return "agent_process_failed" @staticmethod def _process_http_status(stdout: BinaryIO, stderr: BinaryIO) -> int | None: """从有限进程输出提取 provider HTTP 状态,供脱敏错误摘要使用。""" raw = _stream_sample(stdout, STREAM_COPY_CHUNK_BYTES * 4) raw += _stream_sample(stderr, STREAM_COPY_CHUNK_BYTES) text = raw.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)] return next((status for status in statuses if status == 429 or 500 <= status <= 599), None) @staticmethod def _process_error_diagnostic_stream(stdout: BinaryIO, stderr: BinaryIO, *, secrets: tuple[str, ...] = ()) -> str: """从临时输出文件提取诊断,读取量受控且不影响完整输出处理。""" stderr.seek(0) stderr_text = stderr.read(STREAM_COPY_CHUNK_BYTES).decode("utf-8", errors="replace").strip() if stderr_text: return _redact_runtime_diagnostic(stderr_text, secrets) stdout.seek(0) diagnostic: str | None = None for line in stdout: try: event = json.loads(line) except json.JSONDecodeError: continue if not isinstance(event, dict) or event.get("type") != "error": continue error = event.get("error") data = error.get("data") if isinstance(error, dict) else None if not isinstance(data, dict): continue message = data.get("message") status = data.get("statusCode") if isinstance(message, str) and message.strip(): suffix = f" (HTTP {status})" if isinstance(status, int) else "" diagnostic = _redact_runtime_diagnostic(f"{message.strip()}{suffix}", secrets) return diagnostic[:500] if diagnostic else "OpenCode 进程以非零状态退出" @staticmethod def _last_assistant_text(exported: object) -> str: """仅抽取完成 session 中最后一条 assistant 消息的 text parts。""" messages: list[object] | None = None if isinstance(exported, dict): for key in ("messages", "data"): value = exported.get(key) if isinstance(value, list) and all(isinstance(item, dict) for item in value): messages = value break elif isinstance(exported, list): messages = exported if not messages: raise OpenCodeError("agent_output_invalid_json", "完成 session 中不存在消息") def message_role(item: dict[str, Any]) -> str: info = item.get("info") role = item.get("role") if role is None and isinstance(info, dict): role = info.get("role") return str(role or "").lower() candidates = [item for item in messages if isinstance(item, dict) and message_role(item) == "assistant"] if not candidates: raise OpenCodeError("agent_output_invalid_json", "完成 session 中不存在 assistant 消息") message = candidates[-1] parts = message.get("parts", message.get("content", [])) if isinstance(parts, str): return parts if not isinstance(parts, list): raise OpenCodeError("agent_output_invalid_json", "assistant 消息不包含文本部分") texts: list[str] = [] for part in parts: if isinstance(part, str): texts.append(part) elif isinstance(part, dict) and str(part.get("type", "text")).lower() == "text" and isinstance(part.get("text"), str): texts.append(part["text"]) if not texts: raise OpenCodeError("agent_output_invalid_json", "assistant 消息不包含文本") return "".join(texts) @staticmethod def extract_candidate(text: str) -> dict[str, Any]: """严格解析唯一业务 JSON,仅兼容一个纯 JSON fenced block。""" raw = text.strip() try: value = json.loads(raw) except json.JSONDecodeError: match = re.fullmatch(r"```json\s*\n?([\s\S]*?)\n?```", raw, flags=re.IGNORECASE) if not match: raise OpenCodeError("agent_output_invalid_json", "assistant 输出不是唯一 JSON 对象") from None try: value = json.loads(match.group(1).strip()) except json.JSONDecodeError as exc: raise OpenCodeError("agent_output_invalid_json", "JSON fenced block 无法解析") from exc if not isinstance(value, dict): raise OpenCodeError("agent_output_invalid_json", "候选输出必须是 JSON 对象") return value def validate_candidate(self, candidate: dict[str, Any]) -> dict[str, Any]: """执行输出 schema、字段白名单和敏感数据边界校验。""" required = {"title", "summary", "subjects", "visible_text", "references", "meaning", "keywords", "search_queries", "uncertainties"} try: candidate = validate_agent_result(candidate, secret_inventory=secret_inventory_from_settings(self.settings)) except PublicDataError as exc: raise OpenCodeError("agent_output_schema_invalid", "候选 JSON 超出公开结果边界", reason_code=exc.code) from exc if not required.issubset(candidate): raise OpenCodeError("agent_output_schema_invalid", "候选 JSON 缺少必填字段") schema_path = self.project_root / "skills" / "research-meme-context" / "references" / "output-schema.json" try: schema = json.loads(schema_path.read_text(encoding="utf-8")) if Draft202012Validator is not None: errors = list(Draft202012Validator(schema, format_checker=FormatChecker()).iter_errors(candidate)) if errors: raise ValueError(errors[0].message) except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc: raise OpenCodeError("agent_output_schema_invalid", "候选 JSON 不符合输出 schema") from exc try: context = MemeContext.model_validate(candidate) except Exception as exc: # noqa: BLE001 raise OpenCodeError("agent_output_schema_invalid", "候选 JSON 字段不符合约束") from exc return context.model_dump(mode="json", exclude_none=False) def _terminate(self, process: subprocess.Popen[bytes]) -> None: """终止任务独占的进程组,避免超时后留下子进程。""" if process.poll() is not None: return try: os.killpg(process.pid, signal.SIGTERM) process.wait(timeout=3) except (OSError, subprocess.TimeoutExpired): try: os.killpg(process.pid, signal.SIGKILL) process.wait(timeout=2) except OSError: pass except subprocess.TimeoutExpired: # 无法继续等待时由上层任务状态收束,避免关闭流程无限阻塞。 pass def _terminate_task(self, task_id: str | None) -> None: """终止宿主模式下属于指定 task 的本地进程组,不影响其它任务。""" if not task_id: return with self._process_lock: processes = [process for process, active_task_id in self._process_tasks.items() if active_task_id == task_id] for process in processes: self._terminate(process) @staticmethod def _iter_json_array_values(source: BinaryIO) -> Iterator[object]: """增量解析顶层 JSON 数组,每次只在内存保留一个元素。""" decoder = json.JSONDecoder() text_source = io.TextIOWrapper(source, encoding="utf-8") buffer = "" position = 0 eof = False def fill() -> bool: """补充固定大小文本块,避免读取器一次申请完整响应。""" nonlocal buffer, eof if eof: return False chunk = text_source.read(STREAM_COPY_CHUNK_BYTES) if not chunk: eof = True return False buffer += chunk return True def compact() -> None: """丢弃已消费前缀,避免增量缓冲区随元素数量增长。""" nonlocal buffer, position if position: buffer = buffer[position:] position = 0 def skip_whitespace() -> None: """定位下一个 JSON 符号。""" nonlocal position while True: while position < len(buffer) and buffer[position].isspace(): position += 1 if position < len(buffer) or eof or not fill(): return def consume(expected: str) -> None: """消费一个已知 JSON 符号并校验结构。""" nonlocal position skip_whitespace() if position >= len(buffer): raise json.JSONDecodeError("JSON 响应提前结束", buffer, position) if buffer[position] != expected: raise json.JSONDecodeError("JSON 数组结构无效", buffer, position) position += 1 compact() def decode_value() -> object: """解析单个数组元素;跨块时继续填充而不读取后续元素。""" nonlocal position while True: skip_whitespace() try: value, end = decoder.raw_decode(buffer, position) except json.JSONDecodeError as exc: incomplete = exc.msg.startswith("Unterminated string") or exc.pos >= len(buffer) - 1 if eof or not incomplete or not fill(): raise continue position = end compact() return value skip_whitespace() consume("[") while True: skip_whitespace() if position >= len(buffer): raise json.JSONDecodeError("JSON 数组提前结束", buffer, position) if buffer[position] == "]": consume("]") skip_whitespace() if position < len(buffer) or not eof and fill(): skip_whitespace() if position < len(buffer): raise json.JSONDecodeError("JSON 响应包含额外内容", buffer, position) return yield decode_value() skip_whitespace() if position >= len(buffer): raise json.JSONDecodeError("JSON 数组缺少分隔符", buffer, position) if buffer[position] == ",": position += 1 compact() continue if buffer[position] == "]": continue raise json.JSONDecodeError("JSON 数组缺少分隔符", buffer, position) def _session_messages( self, session_id: str, environment: dict[str, str], task_id: str | None = None, *, workspace: ResolvedWorkspace | None = None, ) -> object: """经临时 loopback server 读取完整 session,规避 export 内联附件的 CLI 截断。""" self.log_root.mkdir(parents=True, exist_ok=True) workspace_directory = workspace.directory if workspace is not None else self.workspace with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: probe.bind(("127.0.0.1", 0)) port = probe.getsockname()[1] server = subprocess.Popen( [ str(self.settings.opencode_executable), "serve", "--hostname", "127.0.0.1", "--port", str(port), ], cwd=workspace_directory, env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) self._track_process(server, task_id) endpoint = f"http://127.0.0.1:{port}/session/{session_id}/message" deadline = time.monotonic() + min(15, self.settings.opencode_timeout_seconds) try: while time.monotonic() < deadline: if server.poll() is not None: raise OpenCodeError("agent_export_failed", "OpenCode session server 未能启动") try: # 响应先流式落到临时文件,避免把完整 transcript 一次性放入内存。 with tempfile.TemporaryFile(mode="w+b", prefix="session-", dir=self.log_root) as payload: with urlopen(endpoint, timeout=1) as response: # noqa: S310 - endpoint 固定为本机 loopback。 shutil.copyfileobj(response, payload, length=STREAM_COPY_CHUNK_BYTES) payload.seek(0) first = payload.read(1) payload.seek(0) if first == b"[": # API 返回数组时只保留最后一条 assistant,丢弃大型工具消息。 assistant_messages: list[dict[str, Any]] = [] for item in self._iter_json_array_values(payload): if not isinstance(item, dict): continue role = item.get("role") info = item.get("info") if role is None and isinstance(info, dict): role = info.get("role") if str(role or "").lower() == "assistant": assistant_messages = [item] return {"messages": assistant_messages} return json.load(payload) except OpenCodeError: raise except (OSError, TimeoutError, URLError, json.JSONDecodeError): time.sleep(0.1) raise OpenCodeError("agent_export_failed", "无法读取 OpenCode session 消息") finally: self._terminate(server) self._untrack_process(server) def map_image_path(self, image: Path) -> Path: """把后端图片路径映射到 executor 的只读 `/images`,拒绝根目录外文件。""" root = self._configured_image_root() if self.executor_mode and root != (self.project_root / "data" / "images").resolve(): raise OpenCodeError("agent_image_root_mismatch", "executor 图片挂载与图片根配置不一致") raw_candidate = image.expanduser() # 先检查原始路径;若先 resolve,指向图片根目录内的符号链接会失去可识别性。 if raw_candidate.is_symlink(): raise OpenCodeError("agent_image_path_forbidden", "图片路径不可作为符号链接读取") candidate = raw_candidate.resolve() try: relative = candidate.relative_to(root) except ValueError as exc: raise OpenCodeError("agent_image_path_forbidden", "图片路径不在受控图片目录内") from exc if not candidate.is_file(): raise OpenCodeError("agent_image_path_forbidden", "图片路径不可作为普通文件读取") return EXECUTOR_IMAGE_ROOT / Path(relative.as_posix()) if self.executor_mode else candidate def map_host_image_path(self, image: Path) -> Path: """兼容诊断和测试入口,返回图片在当前运行时中的路径。""" return self.map_image_path(image) def task_result_paths(self, task_id: str, *, workspace: ResolvedWorkspace | None = None) -> tuple[Path, Path]: """创建任务专属结果目录并返回草稿、最终临时 JSON 路径。""" with self._process_lock: if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", task_id or ""): raise OpenCodeError("agent_result_path_invalid", "任务结果标识非法") root = workspace.task_results_root.parent if workspace is not None else self.runtime_root / "task-results" store = OpenCodeResultStore( root, result_name=RESULT_FILE_NAME, draft_name=RESULT_DRAFT_NAME, max_bytes=int(getattr(self.settings, "agent_result_max_bytes", RESULT_DEFAULT_MAX_BYTES)), ) expected_draft, expected_result = store.paths(task_id) self._ensure_no_symlink_path(self.runtime_root.parent, create=True) self._ensure_no_symlink_path(self.runtime_root, create=True) self._ensure_no_symlink_path(root, create=True) directory = root / task_id self._ensure_no_symlink_path(directory, create=True) self._ensure_no_symlink_path(directory / RESULT_DRAFT_NAME, allow_file=True) self._ensure_no_symlink_path(directory / RESULT_FILE_NAME, allow_file=True) if (directory / RESULT_DRAFT_NAME, directory / RESULT_FILE_NAME) != (expected_draft, expected_result): raise OpenCodeError("agent_result_path_invalid", "结果文件路径不受支持") return expected_draft, expected_result @staticmethod def _ensure_no_symlink_path(path: Path, *, create: bool = False, allow_file: bool = False) -> None: """逐级使用 lstat 检查 runtime 结果路径,拒绝符号链接和节点类型劫持。""" current = Path(path.anchor) if path.is_absolute() else Path() parts = path.parts[1:] if path.is_absolute() else path.parts for part in parts: current = current / part try: info = current.lstat() except FileNotFoundError: if not create: return try: current.mkdir() except FileExistsError: # 目录创建与攻击者替换可能交错,重新 lstat 后继续统一类型校验。 try: info = current.lstat() except OSError as exc: raise OpenCodeError("agent_result_path_invalid", "结果路径无法检查") from exc except OSError as exc: raise OpenCodeError("agent_result_path_invalid", "结果目录无法创建") from exc else: continue except OSError as exc: raise OpenCodeError("agent_result_path_invalid", "结果路径无法检查") from exc if stat.S_ISLNK(info.st_mode): raise OpenCodeError("agent_result_path_invalid", "结果路径包含符号链接") if current == path and allow_file and stat.S_ISREG(info.st_mode): return if not stat.S_ISDIR(info.st_mode): raise OpenCodeError("agent_result_path_invalid", "结果路径包含非目录节点") @staticmethod def _reset_task_result_files(draft_path: Path, result_path: Path) -> None: """清理本次重试的确定结果文件,避免沿用上一次尝试的成功产物。""" OpenCodeRunner._ensure_no_symlink_path(draft_path.parent) for path in (draft_path, result_path): try: info = path.lstat() if stat.S_ISLNK(info.st_mode): raise OpenCodeError("agent_result_path_invalid", "结果文件路径包含符号链接") if stat.S_ISDIR(info.st_mode): raise OpenCodeError("agent_result_path_invalid", "结果文件路径被目录占用") path.unlink(missing_ok=True) except FileNotFoundError: continue except OpenCodeError: raise except OSError as exc: raise OpenCodeError("agent_result_file_unreadable", "无法准备 Agent 结果文件路径") from exc def create_task_result_paths(self, task_id: str, *, workspace: ResolvedWorkspace | None = None) -> tuple[Path, Path]: """兼容调用方名称,创建并返回任务专属结果文件路径。""" return self.task_result_paths(task_id, workspace=workspace) def _read_result_file(self, result_path: Path, *, workspace: ResolvedWorkspace | None = None) -> dict[str, Any]: """读取有限大小的 Agent 结果文件并执行 JSON/schema/业务字段校验。""" # 结果读取只允许任务目录内的固定最终文件,避免该通用入口被拿来读取 runtime 外文件。 result_path = Path(os.path.abspath(Path(result_path).expanduser())) result_root = Path(os.path.abspath((workspace.task_results_root.parent if workspace is not None else self.runtime_root / "task-results"))) try: relative = result_path.relative_to(result_root) except ValueError as exc: raise OpenCodeError("agent_result_path_invalid", "结果文件不在受控任务目录内") from exc if len(relative.parts) != 2 or relative.name != RESULT_FILE_NAME: raise OpenCodeError("agent_result_path_invalid", "结果文件名称或层级非法") limit = int(getattr(self.settings, "agent_result_max_bytes", RESULT_DEFAULT_MAX_BYTES)) # O_NOFOLLOW 只保护最终节点;父级路径也必须逐级确认不是符号链接。 self._ensure_no_symlink_path(result_path.parent) try: info = result_path.lstat() if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): raise OpenCodeError("agent_result_file_unreadable", "Agent 结果文件不是普通文件") size = info.st_size except FileNotFoundError as exc: raise OpenCodeError("agent_result_file_missing", "Agent 未生成结果文件") from exc except OpenCodeError: raise except OSError as exc: raise OpenCodeError("agent_result_file_unreadable", "Agent 结果文件不可读取") from exc if size > limit: raise OpenCodeError("agent_result_file_too_large", "Agent 结果文件超过大小限制") try: descriptor = os.open(result_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) try: payload = os.read(descriptor, limit + 1) finally: os.close(descriptor) except OSError as exc: raise OpenCodeError("agent_result_file_unreadable", "Agent 结果文件不可读取") from exc if len(payload) > limit: raise OpenCodeError("agent_result_file_too_large", "Agent 结果文件超过大小限制") try: value = json.loads(payload.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc: raise OpenCodeError("agent_result_file_invalid_json", "Agent 结果文件不是有效 JSON") from exc if not isinstance(value, dict): raise OpenCodeError( "agent_result_file_schema_invalid", "Agent 结果文件必须是 JSON 对象", reason_code="result_object_required", ) try: return self.validate_candidate(value) except OpenCodeError as exc: raise OpenCodeError("agent_result_file_schema_invalid", str(exc), reason_code=exc.reason_code) from exc def read_result_file(self, result_path: Path, *, workspace: ResolvedWorkspace | None = None) -> dict[str, Any]: """读取并校验任务结果文件,供任务处理器和测试直接调用。""" return self._read_result_file(result_path, workspace=workspace) def cleanup_task_results(self, *, keep_task_id: str | None = None) -> int: """按保留天数和最大任务数清理结果及候选目录。""" roots = (self.runtime_root / "task-results", self.runtime_root / "candidates") with self._process_lock: # 清理逻辑不能让 root 或祖先的符号链接把删除范围带到 runtime 外部。 entries_by_root: list[tuple[Path, list[tuple[Path, float]]]] = [] for root in roots: try: self._ensure_no_symlink_path(root) info = root.lstat() except (FileNotFoundError, OSError, OpenCodeError): continue if not stat.S_ISDIR(info.st_mode): continue try: entries_info: list[tuple[Path, float]] = [] for item in root.iterdir(): try: item_info = item.lstat() except OSError: continue if stat.S_ISDIR(item_info.st_mode) and not stat.S_ISLNK(item_info.st_mode): entries_info.append((item, item_info.st_mtime)) entries_by_root.append((root, entries_info)) except OSError: continue if not entries_by_root: return 0 now = time.time() retention = int(getattr(self.settings, "agent_result_retention_days", 14)) * 86400 active_task_ids = set(self._active_task_ids) active_task_ids.update(task_id for task_id in self._process_tasks.values() if task_id) protected_task_ids = active_task_ids | ({keep_task_id} if keep_task_id else set()) max_tasks = int(getattr(self.settings, "agent_result_max_tasks", 500)) removed_task_ids: set[str] = set() for _root, entries_info in entries_by_root: entries_info = [(item, mtime) for item, mtime in entries_info if item.name not in protected_task_ids] entries_info.sort(key=lambda item: item[1], reverse=True) for index, (entry, mtime) in enumerate(entries_info): if index >= max_tasks or now - mtime > retention: shutil.rmtree(entry, ignore_errors=True) removed_task_ids.add(entry.name) return len(removed_task_ids) def _run_command( self, image: Path, prompt: str, *, slot_id: int | None = None, task_id: str | None = None, result_path: Path | None = None, callback_token: str | None = None, resume_session_id: str | None = None, workspace: ResolvedWorkspace | None = None, ) -> list[str]: """构造宿主模式单图研究 CLI 参数;续跑只能显式传入 session。""" # host 回滚支持开发者直接检查临时图片;业务 API 在提交前仍通过 map_image_path # 验证受控图片根,executor 路径则单独执行同一边界校验。 image_path = image.expanduser().resolve() active_workspace = workspace or ResolvedWorkspace( selector="local", directory=self.workspace, config_file=self.workspace / "opencode.json", config_dir=self.workspace / ".opencode", images_root=self._configured_image_root(), metadata_root=self._configured_image_root(), skill_root=self.project_root / "skills" / "research-meme-context", task_scratch_root=self.workspace / "tasks" / (task_id or "interactive"), task_results_root=self.runtime_root / "task-results" / (task_id or "interactive"), db_path=self.db_path, candidate_root=self.runtime_root / "candidates" / (task_id or "interactive"), local=True, ) executable = str(self.settings.opencode_executable or "opencode") command = [ executable, "run", # 非交互任务无法回答 OpenCode 的外部目录询问;runtime 边界负责限制可见范围。 "--auto", "--dir", str(active_workspace.directory), "--format", "json", "--file", str(image_path), "--model", str(self.settings.opencode_model), "--variant", OPENCODE_REASONING_VARIANT, "--title", f"mememeow-task-{task_id or 'interactive'}", ] if resume_session_id: command.extend(("--session", resume_session_id)) command.append(prompt) return command def run( self, image: Path, progress: Callable[[float | None, str | None], None], *, task_id: str | None = None, reverse_image_policy: str = "forbid", callback_token: str | None = None, resume_session_id: str | None = None, resume_of_attempt_id: str | None = None, processing_config_hash: str | None = None, workspace_context: TrustedWorkspaceContext | None = None, model_capability: str | None = None, analysis_observed_cost: object = None, analysis_policy: dict[str, object] | None = None, ) -> tuple[dict[str, Any], str]: """执行单张图片研究;失败也尽力返回可验证 session 诊断。""" task_id = task_id or uuid.uuid4().hex if analysis_policy is not None and not self.executor_mode: raise OpenCodeError("agent_analysis_policy_invalid", "启用分析用量控制需要 Executor 模式") normalized_observed_cost = None if analysis_policy is not None: try: normalized_observed_cost = normalize_observed_cost(analysis_observed_cost) except ModelCapabilityProviderError as exc: raise OpenCodeError(exc.code, str(exc)) from exc # host 回滚模式也需要 attempt 级诊断标识;executor 模式提交后会以 # 服务端生成的独立 attempt 覆盖该临时值。 local_executor_attempt_id = f"host-attempt-{uuid.uuid4().hex}" self._remember_executor_attempt(task_id, local_executor_attempt_id) if self._take_pre_cancelled(task_id) or self._closing.is_set(): raise OpenCodeError("task_interrupted", "OpenCode runner 正在关闭或任务已取消", executor_attempt_id=local_executor_attempt_id) context = workspace_context or TrustedWorkspaceContext( task_id=task_id, attempt_id=local_executor_attempt_id, scope_id="local", selector="local", session_id=resume_session_id, resume_of_attempt_id=resume_of_attempt_id, ) if workspace_context is not None: if workspace_context.task_id != task_id: raise OpenCodeError("opencode_workspace_mismatch", "workspace 上下文与任务不一致", executor_attempt_id=local_executor_attempt_id) # capability 的 attempt_id 必须与本次 executor 请求使用的独立 attempt # 完全相同;scope/selector/session 等业务事实仍来自调用方 claim。 context = replace(context, task_id=task_id, attempt_id=local_executor_attempt_id) if context.task_id != task_id: raise OpenCodeError("opencode_workspace_mismatch", "workspace 上下文与任务不一致", executor_attempt_id=local_executor_attempt_id) resolved_workspace = self.resolve_workspace(context) if analysis_policy is not None and model_capability is None and self.model_capability_provider is not None: policy_model = analysis_policy.get("model") policy_variant = analysis_policy.get("variant") if not isinstance(policy_model, str) or not isinstance(policy_variant, str): raise OpenCodeError("agent_analysis_policy_invalid", "分析用量策略缺少模型事实", executor_attempt_id=local_executor_attempt_id) request = ModelCapabilityRequest( task_id=task_id, attempt_id=local_executor_attempt_id, scope_id=context.scope_id, model=policy_model, variant=policy_variant, session_id=resume_session_id, resume_of_attempt_id=resume_of_attempt_id, analysis_policy=dict(analysis_policy), observed_cost=normalized_observed_cost, ) try: model_capability = capability_for_model_provider(self.model_capability_provider, request) except ModelCapabilityProviderError as exc: raise OpenCodeError(exc.code, str(exc), executor_attempt_id=local_executor_attempt_id) from exc if analysis_policy is not None and not model_capability: raise OpenCodeError( "model_capability_unavailable", "启用分析用量控制需要当前 attempt 的模型 capability", executor_attempt_id=local_executor_attempt_id, ) try: binding = ExecutionBinding( task_id=task_id, attempt_id=local_executor_attempt_id, scope_id=context.scope_id, workspace_selector=resolved_workspace.selector, input_digest=stable_input_digest( task_id, str(image), reverse_image_policy, processing_config_hash, resume_session_id, ), session_id=resume_session_id, ) except ExecutionBindingError as exc: raise OpenCodeError(exc.code, "OpenCode 执行绑定无效", executor_attempt_id=local_executor_attempt_id) from exc self._attempt_fence.bind(binding) with self._process_lock: # 即使 capability 签发失败,也保留已解析的 opaque selector 供失败诊断和 # claim fencing 写回;目录与 OpenCode 进程仍不会在此处创建。 self._workspace_selectors[task_id] = resolved_workspace.selector while len(self._workspace_selectors) > 5000: self._workspace_selectors.pop(next(iter(self._workspace_selectors))) capability = capability_for_provider(self.workspace_provider, context, resolved_workspace) if self.executor_mode and not resolved_workspace.local and not capability: raise OpenCodeError("opencode_workspace_capability_unavailable", "非 local executor workspace 缺少 capability", executor_attempt_id=local_executor_attempt_id) with self._process_lock: self._workspace_by_task[task_id] = resolved_workspace if capability: self._workspace_capabilities[task_id] = capability # provider 解析在任何 OpenCode 子进程和结果文件副作用前完成;此时才准备 # runtime 和当前 workspace 配置。 try: self.prepare_runtime() if not self.executor_mode: # 外部 provider 的 Task 临时目录和配置只在 capability 已经验证后 # 创建;解析失败不能留下可写任务目录或结果目录。 validate_directory_path(resolved_workspace.task_scratch_root, create=True, code="opencode_workspace_invalid") validate_directory_path(resolved_workspace.config_dir, create=True, code="opencode_workspace_invalid") self._write_workspace_config(resolved_workspace) slot_id, lock_handle = self._acquire_slot() except Exception: with self._process_lock: self._workspace_by_task.pop(task_id, None) self._workspace_capabilities.pop(task_id, None) raise try: with self._process_lock: self._active_task_ids.add(task_id) if self.executor_mode: self._active_executor_task_ids.add(task_id) _draft_path, result_path = self.task_result_paths(task_id, workspace=resolved_workspace) if resume_session_id and not normalize_identifier(resume_session_id, kind="session"): raise OpenCodeError("session_binding_mismatch", "续跑 session 标识无效", executor_attempt_id=local_executor_attempt_id) # 续跑必须保留既有 draft/中间产物;新业务 attempt 才初始化结果文件。 if not resume_session_id: self._reset_task_result_files(_draft_path, result_path) result_runtime_root = self.runtime_root active_image = Path(image) if context.image_relative_path: try: active_image = resolved_workspace.image_path(context.image_relative_path) except WorkspaceResolutionError as exc: raise OpenCodeError(exc.code, str(exc), executor_attempt_id=local_executor_attempt_id) from exc # 保留原始输入路径,供 Agent 读取 GIF 全部帧。 original_image = active_image.expanduser().resolve() if self.executor_mode: if context.image_relative_path: relative_image = context.image_relative_path.replace("\\", "/") else: mapped_image = self.map_image_path(active_image) try: relative_image = mapped_image.relative_to(EXECUTOR_IMAGE_ROOT).as_posix() except ValueError as exc: raise OpenCodeError("agent_image_path_forbidden", "图片路径不在 executor 受控目录内") from exc if self._take_pre_cancelled(task_id): raise OpenCodeError("task_interrupted", "Agent 任务已取消", executor_attempt_id=local_executor_attempt_id) progress(0.1, "正在提交 Agent executor 任务") try: response = self.executor.run( task_id=task_id, image_relative_path=relative_image, reverse_image_policy=reverse_image_policy if reverse_image_policy in {"forbid", "auto"} else "forbid", timeout_seconds=min(int(self.settings.opencode_timeout_seconds), int(getattr(self.settings, "agent_executor_max_timeout_seconds", 1800))), callback_token=callback_token, session_id=resume_session_id, resume_of_attempt_id=resume_of_attempt_id, processing_config_hash=processing_config_hash, visual_snapshot_sha256=( str(context.visual_match_snapshot.get("snapshot_sha256")) if isinstance(context.visual_match_snapshot, Mapping) else None ), executor_attempt_id=local_executor_attempt_id, workspace_selector=None if resolved_workspace.local else resolved_workspace.selector, workspace_capability=capability, model_capability=model_capability, analysis_policy=analysis_policy, shutdown_requested=self._closing.is_set, ) if response.executor_attempt_id: self._remember_executor_attempt(task_id, response.executor_attempt_id) self._remember_analysis_observation(task_id, response) except AgentExecutorError as exc: code = self._executor_error_code(exc.code) if exc.executor_attempt_id: self._remember_executor_attempt(task_id, exc.executor_attempt_id) cancelled_response: ExecutorTaskResponse | None = None if code in {"agent_timeout", "agent_executor_unavailable", "agent_runtime_unavailable", "agent_executor_invalid_response"}: try: cancelled_response = self.executor.cancel(exc.executor_attempt_id or task_id) except AgentExecutorError: pass decision = classify_resume_error(code, session_id=exc.session_id, target_unchanged=True, grant_state="committed") if code == "agent_timeout": raise OpenCodeError( "agent_timeout", "OpenCode 执行超时", session_id=exc.session_id or getattr(cancelled_response, "session_id", None), executor_attempt_id=exc.executor_attempt_id or getattr(cancelled_response, "executor_attempt_id", None), http_status=exc.http_status, reason_code=exc.reason_code, process_reaped=exc.process_reaped if exc.process_reaped is not None else getattr(cancelled_response, "process_reaped", None), observed_cost=exc.observed_cost or getattr(cancelled_response, "observed_cost", None), usage_checked_at=exc.usage_checked_at or getattr(cancelled_response, "usage_checked_at", None), reminder_sent=exc.reminder_sent or getattr(cancelled_response, "reminder_sent", False), termination_reason=exc.termination_reason, termination_signal=exc.termination_signal or getattr(cancelled_response, "termination_signal", None), check_stage=exc.check_stage or getattr(cancelled_response, "check_stage", None), trigger_reason=exc.trigger_reason or getattr(cancelled_response, "trigger_reason", None), ) from exc raise OpenCodeError( code, str(exc)[:500], session_id=exc.session_id or getattr(cancelled_response, "session_id", None), executor_attempt_id=exc.executor_attempt_id or getattr(cancelled_response, "executor_attempt_id", None), retryable=decision.retryable, http_status=exc.http_status, reason_code=exc.reason_code, process_reaped=exc.process_reaped if exc.process_reaped is not None else getattr(cancelled_response, "process_reaped", None), observed_cost=exc.observed_cost or getattr(cancelled_response, "observed_cost", None), usage_checked_at=exc.usage_checked_at or getattr(cancelled_response, "usage_checked_at", None), reminder_sent=exc.reminder_sent or getattr(cancelled_response, "reminder_sent", False), termination_reason=exc.termination_reason, termination_signal=exc.termination_signal or getattr(cancelled_response, "termination_signal", None), check_stage=exc.check_stage or getattr(cancelled_response, "check_stage", None), trigger_reason=exc.trigger_reason or getattr(cancelled_response, "trigger_reason", None), ) from exc if self._take_pre_cancelled(task_id): try: self.executor.cancel(response.executor_attempt_id or task_id) except AgentExecutorError: pass raise OpenCodeError("task_interrupted", "Agent 任务已取消", executor_attempt_id=response.executor_attempt_id or local_executor_attempt_id) if response.status != "succeeded": raise OpenCodeError("agent_executor_invalid_response", "Agent executor 任务未返回成功状态", session_id=response.session_id, executor_attempt_id=response.executor_attempt_id) if not response.session_id: raise OpenCodeError( "agent_output_invalid_json", "Agent executor 未返回 session 标识", executor_attempt_id=response.executor_attempt_id or local_executor_attempt_id, ) progress(0.65, "正在读取研究结果文件") candidate = self._read_result_file(result_path, workspace=resolved_workspace) progress(0.9, "正在校验并写入语境") return candidate, response.session_id resume_instruction = ( "这是同一 OpenCode session 的受控续跑;保留并检查已有草稿和中间产物,只完成尚未完成的工作。" if resume_session_id else "这是首次执行;先建立任务草稿并逐步完成研究。" ) prompt = ( "使用 research-meme-context skill 分析这张表情包;不要使用本地以图搜图。" f"原始图片路径(只读):{json.dumps(str(original_image), ensure_ascii=False)}。" "必须直接通过视觉模型查看输入图片并识别图片文字,禁止调用任何本地文字识别程序、命令或脚本。" "遇到错误时自行尝试可行的替代方案;确认无法解决时,简短说明原因并退出。" f"本任务 reverse_image_policy={reverse_image_policy if reverse_image_policy in {'forbid', 'auto'} else 'forbid'};只能通过项目内部反向图片接口使用能力,绝不读取或请求供应商密钥。" f"{resume_instruction}" f"结果必须写入 {resolved_workspace.result_path}。" f"先在同目录写入 {resolved_workspace.draft_path}," "使用 output-schema.json 校验完整 JSON 对象后,使用同一文件系统的原子 rename/mv 将草稿替换为最终文件。" "不要把业务 JSON 作为 assistant 文本交付,不要写入数据库;完成后简短说明即可。" ) command = self._run_command(active_image, prompt, slot_id=slot_id, task_id=task_id, result_path=result_path, callback_token=callback_token, resume_session_id=resume_session_id, workspace=resolved_workspace) execution_command = command environment = self.build_environment(slot_id, task_id, callback_token, workspace=resolved_workspace) progress(0.1, f"正在启动语境研究(slot {slot_id})") # 由临时文件承接完整事件流,避免按总字节数拒绝合法的大输出,也避免管道缓存堆积在内存。 with ( tempfile.TemporaryFile(mode="w+b", prefix="stdout-", dir=self.log_root) as stdout_stream, tempfile.TemporaryFile(mode="w+b", prefix="stderr-", dir=self.log_root) as stderr_stream, ): process = subprocess.Popen( execution_command, cwd=resolved_workspace.directory, env=environment, stdin=subprocess.DEVNULL, stdout=stdout_stream, stderr=stderr_stream, start_new_session=True, ) self._track_process(process, task_id) timed_out = False interrupted = False try: interrupted = self._take_pre_cancelled(task_id) if interrupted or self._closing.is_set(): self._terminate(process) else: # stdout/stderr 已重定向到临时文件;communicate 只等待进程。 process.communicate(timeout=self.settings.opencode_timeout_seconds) except subprocess.TimeoutExpired: self._terminate(process) timed_out = True finally: self._untrack_process(process) interrupted = interrupted or self._take_pre_cancelled(task_id) log_path = self.log_root / f"{hashlib.sha256(str(image).encode()).hexdigest()[:16]}.jsonl" stdout_stream.flush() stderr_stream.flush() self._write_diagnostic_log(stdout_stream, log_path) session_id = None stdout_stream.seek(0) for line in stdout_stream: if not line.strip(): continue try: event = json.loads(line) except json.JSONDecodeError: # 失败路径只要已有合法 session 就继续保留它;成功路径仍由 # 结果文件/schema 校验收束,不把噪声日志当成 session。 continue found = self._event_session(event) if found: normalized_found = normalize_identifier(found, kind="session") if normalized_found is None: continue if resume_session_id and normalized_found != resume_session_id: raise OpenCodeError("session_binding_mismatch", "OpenCode 返回了不匹配的 session", session_id=normalized_found, executor_attempt_id=local_executor_attempt_id) if session_id and normalized_found != session_id: raise OpenCodeError("session_binding_mismatch", "OpenCode 返回了多个不匹配的 session", session_id=normalized_found, executor_attempt_id=local_executor_attempt_id) session_id = normalized_found if not session_id: session_id = resume_session_id if interrupted: raise OpenCodeError("task_interrupted", "Agent 任务已取消", session_id=session_id, executor_attempt_id=local_executor_attempt_id, process_reaped=process.poll() is not None) if self._closing.is_set() and process.returncode != 0: raise OpenCodeError("service_shutdown", "服务正常关闭,Agent 执行已中断", session_id=session_id, executor_attempt_id=local_executor_attempt_id, process_reaped=process.poll() is not None) if timed_out: raise OpenCodeError("agent_timeout", "OpenCode 执行超时", session_id=session_id, executor_attempt_id=local_executor_attempt_id) if process.returncode != 0: diagnostic = self._process_error_diagnostic_stream( stdout_stream, stderr_stream, secrets=( str(getattr(self.settings, "opencode_api_key", "") or ""), str(getattr(self.settings, "agent_executor_token", "") or ""), callback_token or "", ), ) failure_code = self._classify_process_failure(stdout_stream, stderr_stream) http_status = self._process_http_status(stdout_stream, stderr_stream) decision = classify_resume_error(failure_code, session_id=session_id, target_unchanged=True, grant_state="committed") raise OpenCodeError(failure_code, diagnostic, session_id=session_id, executor_attempt_id=local_executor_attempt_id, retryable=decision.retryable, http_status=http_status) if not session_id: raise OpenCodeError("agent_output_invalid_json", "OpenCode 未返回 session 标识", executor_attempt_id=local_executor_attempt_id) progress(0.65, "正在读取研究结果文件") try: candidate = self._read_result_file(result_path, workspace=resolved_workspace) except OpenCodeError as exc: if resume_session_id or exc.code != "agent_result_file_missing": # 续跑或已有结果文件损坏时必须以文件校验为边界,不能退回 # transcript 解析,否则会绕过副作用和产物完整性判定。 raise # 仅在 host 未生成结果文件时兼容旧 session 读取;executor 任务在上方已直接失败。 try: data = self._session_messages(session_id, environment, task_id, workspace=resolved_workspace) except TypeError: # 保持旧测试/扩展覆写三参数 session reader 的兼容。 data = self._session_messages(session_id, environment, task_id) candidate = self.validate_candidate(self.extract_candidate(self._last_assistant_text(data))) progress(0.9, "正在校验并写入语境") return candidate, session_id finally: self._attempt_fence.release(task_id, local_executor_attempt_id) self.cleanup_task_results(keep_task_id=task_id) self._unmark_task_active(task_id) with self._process_lock: self._active_executor_task_ids.discard(task_id) self._cancelled_task_ids.discard(task_id) self._workspace_by_task.pop(task_id, None) self._workspace_capabilities.pop(task_id, None) self._release_slot(slot_id, lock_handle) @staticmethod def _write_diagnostic_log(source: BinaryIO, target: Path) -> None: """保存有限诊断前缀;完整输出仍只存在于本次运行的临时文件。""" source.seek(0) with target.open("wb") as destination: remaining = DIAGNOSTIC_LOG_BYTES while remaining > 0: chunk = source.read(min(STREAM_COPY_CHUNK_BYTES, remaining)) if not chunk: break destination.write(chunk) remaining -= len(chunk) def shutdown(self) -> None: """终止当前受管理进程,供应用生命周期收束调用。""" self._closing.set() with self._process_lock: executor_business_ids = list(self._active_executor_task_ids) for task_id in executor_business_ids: try: self.executor.cancel(self.executor.attempt_id_for(task_id) or task_id, reason="service_shutdown") except AgentExecutorError as exc: logger.warning("停机中断请求失败:task={} error={}", task_id, exc.code) with self._process_lock: processes = list(self._processes) if self._process is not None and self._process not in processes: processes.append(self._process) for process in processes: self._terminate(process) def cancel(self, task_id: str) -> None: """取消指定研究任务;executor 请求远端取消,host 终止对应本地进程组。""" if not task_id: return with self._process_lock: if len(self._cancelled_task_ids) >= 5000: for stale_id in list(self._cancelled_task_ids - self._active_task_ids): self._cancelled_task_ids.discard(stale_id) if len(self._cancelled_task_ids) < 4000: break self._cancelled_task_ids.add(task_id) if self.executor_mode: try: self.executor.cancel(self.executor.attempt_id_for(task_id) or task_id) except AgentExecutorError: # 数据库任务已被取消时,executor 可能已经重启或忘记任务;不阻塞 API 收束。 return return self._terminate_task(task_id)