226 lines
8.9 KiB
Python
226 lines
8.9 KiB
Python
"""沙箱执行器:可插拔的任务执行接口。
|
||
|
||
零第三方依赖,只用 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 <host 名>
|
||
--task <prompt> [--repo <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 <host 名>
|
||
--prompt <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")
|