"""沙箱执行器:可插拔的任务执行接口。 零第三方依赖,只用 Python 标准库;不重写 agent 逻辑。 - Executor:基类,子类必须实现 execute(task)。 - LocalExecutor:先行实现,复用 host.py 现有 worktree + codex 沙箱, 通过 host.py CLI 子进程做只读查询封装;不直接跑模型。 - DockerExecutor:占位,未接通容器执行。 - CloudExecutor:占位,未接通云端执行。 """ import json import re import shutil import subprocess import sys MAX_STDOUT_CHARS = 2000 class Executor: """执行器基类。 task 是 dict,含 id、project_id、repo、prompt、branch(可选)。 execute(task) -> dict,至少含 executor、task_id、status、stdout。 """ def execute(self, task) -> dict: raise NotImplementedError("子类必须实现 execute(task)") def _truncate_stdout(text): if text is None: return "" return str(text)[:MAX_STDOUT_CHARS] def _default_runner(cmd): """subprocess.run 的薄封装:捕获输出、文本模式。""" return subprocess.run(cmd, capture_output=True, text=True) class LocalExecutor(Executor): """本地执行器:调 host.py CLI 子进程做执行封装。 真正跑模型回合的 dispatch/run 由控制面在确认模型 key 供给后才 调用,本模块只做执行器骨架和 CLI 调用封装。因此 execute() 在此 只发 host.py 的只读命令(例如 status),绝不在 execute 里直接 跑模型回合;需要跑模型时,控制面另行调用 host.py dispatch/run。 构造函数:LocalExecutor(host_py_path, config_path=None, runner=None)。 runner 默认是 subprocess.run 的薄封装;单测可注入假 runner,假 runner 返回 CompletedProcess 风格的 result 对象(含 returncode、 stdout),本类只读这两个字段,不依赖其它字段。 execute() 之外另有 dispatch_task()/run_task():它们是 worker 派发时调 host.py 真实子命令的入口——dispatch 对应 host.py 的 dispatch(建任务并跑一轮 turn),run 对应 host.py 的 run (在已有 host 任务上再跑一轮 turn)。两者都只发真实存在的 host.py 子命令,不编造命令名;execute() 本身仍只发只读 status, 绝不在 execute 里直接跑模型回合(见 test_queue_sandbox 的断言)。 """ def __init__(self, host_py_path, config_path=None, runner=None, repo=None): self.host_py_path = host_py_path self.config_path = config_path self.runner = runner if runner is not None else _default_runner self.repo = repo def _base_command(self): cmd = [sys.executable, self.host_py_path] if self.config_path is not None: cmd += ["--config", self.config_path] return cmd def _command(self, task_id): cmd = self._base_command() cmd += ["status", "--task", str(task_id)] return cmd def _result_from_runner(self, task_id, result): """把 runner 返回值统一成 execute() 同款结果 dict。 host.py dispatch/run 成功时打印 JSON(含 status/text), 失败时以 SystemExit(1) 退出并打印错误文本。因此:returncode 非 0 一律 failed;returncode 为 0 时优先取 JSON 里的 status (completed 外的状态,如 timeout,均按失败计,worker 侧再 判断是否可 fallback),取不到 JSON 时按 completed 计。 """ returncode = getattr(result, "returncode", 1) stdout = _truncate_stdout(getattr(result, "stdout", "")) status = "failed" if returncode == 0: status = "completed" try: parsed = json.loads(stdout) except (TypeError, ValueError): parsed = None if isinstance(parsed, dict) and parsed.get("status"): status = ("completed" if parsed["status"] == "completed" else "failed") host_status = parsed["status"] else: host_status = None else: host_status = None return { "executor": "local", "task_id": task_id, "status": status, "stdout": stdout, "returncode": returncode, "host_status": host_status, # host.py 的 RuntimeError(如“任务已存在”)走 traceback 到 # stderr,worker 判断 dispatch 续跑时需要它;execute() # 的旧结果 dict 不含此字段,保持不变。 "stderr": _truncate_stdout(getattr(result, "stderr", "")), } def _host_name(self, task): """cloud 任务 id -> host 任务名:只保留安全字符。 server 生成的 id 本就是 uuid hex,走这一步只是防御自定义 id; 同一 id 每次算出同一名字,dispatch 失败后的 run 续跑才能 找到同一个 host 任务。 """ raw = str(task.get("id")) return re.sub(r"[^A-Za-z0-9_.-]", "-", raw) def dispatch_task(self, task) -> dict: """调 host.py dispatch:建 host 任务并跑一轮 turn。 命令:host.py [--config CFG] dispatch --name --task [--repo ]。repo 取任务的 repo 字段, 没有则取构造时传的 repo,都没有则不传(此时 host.py 用其 配置文件里的 repo,配置里也没有时 host.py 自己报错, worker 把该报错原样写回 failed,不编造成功)。 """ if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not task.get("prompt"): raise ValueError("任务缺少 prompt") cmd = self._base_command() cmd += ["dispatch", "--name", self._host_name(task), "--task", task["prompt"]] repo = task.get("repo") or self.repo if repo: cmd += ["--repo", repo] return self._result_from_runner( task["id"], self.runner(cmd)) def run_task(self, task) -> dict: """调 host.py run:在已有 host 任务上再跑一轮 turn。 命令:host.py [--config CFG] run --task --prompt 。worker 只在 dispatch 报“任务已存在”时 用它续跑,不用它做首次派发。 """ if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not task.get("prompt"): raise ValueError("任务缺少 prompt") cmd = self._base_command() cmd += ["run", "--task", self._host_name(task), "--prompt", task["prompt"]] return self._result_from_runner( task["id"], self.runner(cmd)) def execute(self, task) -> dict: task_id = task.get("id") if isinstance(task, dict) else None if task_id is None: raise ValueError("任务缺少 id") result = self.runner(self._command(task_id)) returncode = getattr(result, "returncode", 1) stdout = _truncate_stdout(getattr(result, "stdout", "")) return { "executor": "local", "task_id": task_id, "status": "completed" if returncode == 0 else "failed", "stdout": stdout, "returncode": returncode, } class DockerExecutor(Executor): """容器执行器(占位,未接通)。 本机无 docker(shutil.which("docker") 为 None)时 raise RuntimeError("docker not available"),绝不静默降级为本地执行。 有 docker 时也只返回占位失败,不真跑容器。 后续接线:容器挂载(把任务 worktree 以只读/受限写挂载进容器)、 网络隔离(默认无外网,仅放行模型 API 所需出口)、资源限额与 容器回收,届时再替换本占位实现。 """ def execute(self, task) -> dict: task_id = task.get("id") if isinstance(task, dict) else None if task_id is None: raise ValueError("任务缺少 id") if shutil.which("docker") is None: raise RuntimeError("docker not available") return { "executor": "docker", "task_id": task_id, "status": "failed", "stdout": "docker executor not implemented: " "container mount and network isolation pending", "returncode": None, } class CloudExecutor(Executor): """云端执行器(占位,未接通)。 execute(task) 一律 raise RuntimeError,绝不假装已接通云端。 接通前提:真实云账号 + 预算上限配置,届时由控制面注入凭证与 限额后再替换本占位实现。 """ def execute(self, task) -> dict: raise RuntimeError( "cloud executor not connected: missing cloud account " "and budget limit")