Files
codex-app-server/cloud/sandbox.py
T
host c371bbdf1e feat: Cloud Hypervisor 执行器生产级 v1(cloud-hypervisor)
单任务单 VM:API 唤醒(preflight→vmm.ping→vm.create→vm.boot→SSH 就绪)、
暂停/快照/休眠等价/关闭/回收、预热快照池、每任务磁盘 copy、容量与磁盘配额门、
受控 egress fail-closed、VM 事件审计与分类失败;worker/server/autoscaler 接线
(executor=cloud-hypervisor,任务内 LLM 不可自选);默认关闭,不进生产路由。
未做真实 VM 验证:生产主机与本 VM 均无 /dev/kvm、无嵌套虚拟化,验证为 mock +
干跑命令 + 单元测试;真 VM、快照恢复、压测、安全审计见
docs/chv-production-readiness.md。CI 账单故障仍是硬门禁,本提交只到 PR OPEN。
2026-10-30 18:54:16 +00:00

3438 lines
162 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""沙箱执行器:可插拔的任务执行接口。
零第三方依赖,只用 Python 标准库;不重写 agent 逻辑。
- Executor:基类,子类必须实现 execute(task)。
- LocalExecutor:先行实现,复用 host.py 现有 worktree + codex 沙箱,
通过 host.py CLI 子进程做只读查询封装;不直接跑模型。
- DockerExecutor:已接通。docker run --rm,任务 worktree 挂载到
/work(可写)、代码目录挂载到 /app:ro(只读),--cpus/--memory
资源限额,--user 非 root,-e 只传 env_key 名(不带值,key 缺失
时不启动容器);超时与取消时 docker kill + rm -f,不留僵尸
容器。网络白名单不在本类里假装实现(见 cloud/README.md)。
- CloudExecutor:占位,未接通云端执行。
- CloudHypervisorExecutor:生产级 v1,Cloud Hypervisor VM 内经
SSH 调 host.py(API 唤醒流程见该类 docstring:preflight →
vmm.ping → vm.create → vm.boot → SSH 就绪 → 跑任务 →
finally 关 VM;任务内 LLM 绝不能发现/调用它,执行器选择只
在任务创建前由 server 白名单/环境默认值/worker 认领决定)。
"""
import json
import logging
import os
import re
import shutil
import socket
import subprocess
import sys
import threading
import time
from cloud.marker_tools import extract_quota_status as _extract_quota_status
from cloud.marker_tools import extract_automation_creates as \
_extract_automation_creates
from cloud.marker_tools import extract_goal_converts as \
_extract_goal_converts
from cloud.marker_tools import extract_task_cancels as _extract_task_cancels
from cloud.marker_tools import extract_task_costs as _extract_task_costs
from cloud.marker_tools import extract_task_creates as _extract_task_creates
from cloud.marker_tools import extract_task_files as _extract_task_files
from cloud.marker_tools import extract_task_lists as _extract_task_lists
from cloud.marker_tools import extract_task_polls as _extract_task_polls
from cloud.marker_tools import extract_task_retries as _extract_task_retries
from cloud.marker_tools import extract_todo_creates as _extract_todo_creates
from cloud.marker_tools import extract_todo_lists as _extract_todo_lists
from cloud.marker_tools import extract_todo_updates as _extract_todo_updates
from cloud.marker_tools import extract_wait_threads as _extract_wait_threads
from cloud.peer_messages import extract_peer_messages as _extract_peer_messages
MAX_STDOUT_CHARS = 2000
# 任务结果文本上限:2000 字截断的是 host.py 打印的整段 JSON,
# text 超过 2000 字时 JSON 本身被截断,worker 再解析 stdout
# 取 text 必然失败,存库的就变成截断的 JSON(2026-10-30
# 生产审计任务 dbe0c39f… 正是这样在 "PI" 处断掉)。修法:
# 解析出的 text(或非 JSON 的原文本)单独截到 20000 字,
# 超限时带截断标记,绝不把截断的 JSON 当结果存库。
# 状态 / usage / 标记块仍从完整 stdout 解析,不受此截断影响。
MAX_RESULT_CHARS = 20000
_RESULT_TRUNCATED_MARKER = "\n\n[结果已截断:原文超 20000 字,仅显示前 20000 字]"
# 完整结果存库上限:20000 字摘要之外,完整版另存
# result_text_full,上限 200000 字只防无界增长,不作正常截断。
MAX_FULL_STDOUT_CHARS = 200000
def _full_stdout(text):
if text is None:
return ""
text = str(text)
return text[:MAX_FULL_STDOUT_CHARS] if len(text) > MAX_FULL_STDOUT_CHARS else text
# 启动前分段日志(2026-10-29 任务 A):docker run 是阻塞调用,
# 容器输出要等返回才回传,中途卡死时 worker 侧没有任何标记;
# 这里的开始/结束/异常行进 worker 日志(stderr,不缓冲丢行),
# 只记容器名与镜像,绝不记内层命令(内含任务 prompt)。
_DOCKER_LOGGER = logging.getLogger("cloud.docker")
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 goal_task(self, task) -> dict:
"""目标循环(host.py goal):turn→验测→喂回重试。
子类实现;默认不支持(CloudExecutor 的通用 HTTP 协议里
没有 goal/验测概念,调它会如实失败,不静默降级成普通
dispatch)。结果 dict 除 execute() 的字段外另含:
goal_met(True/False/None=无结论)、goal_error。
"""
raise NotImplementedError("该执行器不支持 goal 模式")
def _clean_usage(raw):
"""校验 host.py 带来的用量 dict:非 dict、非整数、或 input /
output / total 全为 0 时返回 None(调用方回落估算,绝不把
0 或脏数据当成真实用量记 actual)。"""
if not isinstance(raw, dict):
return None
keys = ("input_tokens", "cached_input_tokens",
"cache_write_input_tokens", "output_tokens",
"reasoning_output_tokens", "total_tokens", "completions")
try:
out = {k: int(raw.get(k) or 0) for k in keys}
except (TypeError, ValueError):
return None
if out["input_tokens"] <= 0 and out["output_tokens"] <= 0 \
and out["total_tokens"] <= 0:
return None
return out
_USAGE_LINE_RE = re.compile(
r"^usage input=(\d+) cached=(\d+) cache_write=(\d+) output=(\d+)"
r" reasoning=(\d+) total=(\d+) completions=(\d+)\s*$", re.M)
def _parse_goal_usage(stdout):
"""从 host.py goal 的用量行解析用量 dict,无用量行返回 None。"""
m = _USAGE_LINE_RE.search(stdout or "")
if not m:
return None
keys = ("input_tokens", "cached_input_tokens",
"cache_write_input_tokens", "output_tokens",
"reasoning_output_tokens", "total_tokens", "completions")
return {k: int(v) for k, v in zip(keys, m.groups())}
def _parse_goal_output(stdout):
"""解析 host.py goal 的 stdout,返回 (met, goal_error)。
host.py goal 两种结局都 exit 0,结论只看末尾打印的
"goal met/unmet after rounds",不能沿用普通结果解析
(它无 JSON 时一律 completed,会把 unmet 误判成完成)。
met True=达标,False=未达标,None=输出里无结论。
goal_error 取最后一轮的 verify_out(无则取 stdout 尾部)。
"""
if not stdout:
return None, None
if "goal met after rounds" in stdout:
return True, None
if "goal unmet after rounds" in stdout:
last_verify = None
rounds = 0
for line in stdout.splitlines():
if line.startswith("round "):
rounds += 1
if "verify_out=" in line:
last_verify = line.split("verify_out=", 1)[1]
err = "goal verification failed"
if rounds:
err += " after %d rounds" % rounds
detail = (last_verify or "").strip()[:500]
if detail:
err += ": " + detail
return False, err
return None, None
# P0 三件其二:goal 错误签名。permanent = 同代码、同断点、同配置
# 下重试必然再失败(断点丢失、配置缺失、旧语义误用),必须判
# failed 终态进死信,绝不能判 interrupted 进自动恢复;transient =
# 网络/网关抖动,重试有意义,走原有 interrupted 路径。未知错误
# 不分类(None),走原有逻辑,兜底靠 worker 的无进展熔断。
_GOAL_PERMANENT_MARKERS = (
"goal-resume 拒绝",
"KeyError",
"没有会话线程",
"没有 goal 记录",
"provider not found",
"Model provider",
"Missing environment variable",
"任务已存在",
)
_GOAL_TRANSIENT_MARKERS = (
"timed out",
"Timeout",
"429",
"502",
"503",
"RemoteDisconnected",
"IncompleteRead",
"Connection reset",
"connection refused",
)
def _classify_goal_error(stdout, stderr):
"""返回 (kind, snippet):kind 为 permanent/transient/None。
snippet 取命中标记所在行(截 500 字),供终态 error_text 用;
命中多个时 permanent 优先于 transient(永久错误里常混着
网络字样,反过来会把死信误判成可重试)。
"""
combined = (stdout or "") + "\n" + (stderr or "")
for marker in _GOAL_PERMANENT_MARKERS:
if marker in combined:
for line in combined.splitlines():
if marker in line:
return "permanent", line.strip()[:500]
return "permanent", marker
for marker in _GOAL_TRANSIENT_MARKERS:
if marker in combined:
for line in combined.splitlines():
if marker in line:
return "transient", line.strip()[:500]
return "transient", marker
return None, None
def _goal_resume_refusal(stdout):
"""host.py goal-resume 的拒绝行(断点丢失时),无则返回 None。
断点丢失不是可续跑的中断:host-state 里没有任务,任何一次
续跑都会原样失败;调用方必须判 failed 终态,绝不能判
interrupted——否则 worker 自动恢复会 12 秒一轮空转到上限。
"""
for line in (stdout or "").splitlines():
if line.startswith("goal-resume 拒绝:"):
return line.strip()
return None
def _goal_interrupted_reason(stdout):
"""goal interrupted 标记行的原因,无标记返回 None。
host.py run_goal_loop 在回合异常时打印
"goal interrupted after rounds: <原因>" 并记 interrupted;
云端据此把任务标 interrupted(可 /goal-resume 断点续跑),
而不是 terminal 的 failed——异常退出时断点(goal_rounds_done、
线程、worktree)都在盘上,判 failed 会把可续跑的活路断掉。
"""
for line in (stdout or "").splitlines():
if "goal interrupted after rounds" in line:
reason = line.split("goal interrupted after rounds", 1)[1]
return reason.strip().lstrip(":").strip()[:500]
return None
def _goal_stdout_tail(stdout):
"""goal 结果存库的 stdout:结论在末尾,截尾不截头。
普通结果截头(_truncate_stdout)会把末尾的结论行截掉,
goal 输出必须留尾部,否则 met/unmet 结论在库里查不到。
"""
text = str(stdout or "")
if len(text) > MAX_STDOUT_CHARS:
return text[-MAX_STDOUT_CHARS:]
return text
def _truncate_stdout(text):
if text is None:
return ""
return str(text)[:MAX_STDOUT_CHARS]
def _result_text(parsed, stdout_full):
"""执行结果存库用的文本:优先取 host.py JSON 的 text 字段,
取不到时用原文本;截到 MAX_RESULT_CHARS,超限带截断标记。
调用方必须在解析出 parsed 后再调本函数,绝不能先截断
stdout 再解析 JSON(截断的 JSON 解析失败,text 全丢)。
"""
if isinstance(parsed, dict) and parsed.get("text"):
text = str(parsed["text"])
else:
text = str(stdout_full or "")
if len(text) > MAX_RESULT_CHARS:
return text[:MAX_RESULT_CHARS] + _RESULT_TRUNCATED_MARKER
return text
def _decode_output(value):
"""subprocess 输出转 str:bytes 按 utf-8 容错解码,str 原样。"""
if value is None:
return ""
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return str(value)
def _default_runner(cmd, env=None):
"""subprocess.run 的薄封装:捕获输出、文本模式。
env 非空时以它为子进程环境(worker 注入任务级密钥时用);
不传则继承当前进程环境,与旧行为一致。
"""
if env is None:
return subprocess.run(cmd, capture_output=True, text=True)
return subprocess.run(cmd, capture_output=True, text=True, env=env)
# docker -e 只传名不传值的密钥类变量:值由 CLI 子进程环境提供,
# 绝不进 docker argv(见 build_run_command 与 _docker_cli_env)。
_DOCKER_SECRET_ENV_NAMES = frozenset({"SUB2API_API_KEY"})
def _approval_cli_args(approval):
"""console 权限三档 → host.py 顶层全局参数。
请求批准(ask):approvalPolicy=untrusted,敏感操作先问;
帮我批准(auto):approvalPolicy=never,自动批准,仍受
工作区沙箱限制;完全访问(full):approvalPolicy=never
+ sandbox=danger-full-access,不再受工作区限制(危险)。
None 或未知值:不传参数,host.py 走配置文件默认值。
docker 任务的配置沙箱本身就是 danger-full-access,所以
full 档对 docker 任务无变化,变化只在 approvalPolicy。
"""
if approval == "ask":
return ["--approval-policy", "untrusted"]
if approval == "auto":
return ["--approval-policy", "never"]
if approval == "full":
return ["--approval-policy", "never",
"--sandbox", "danger-full-access"]
return []
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, model=None, ignore_host_budget=False):
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
# G9:worker 路径默认忽略 host 预算时由 Worker 同步成
# True;standalone 的 LocalExecutor 缺省 False,预算照常。
self.ignore_host_budget = bool(ignore_host_budget)
# 模型:host.py 的 --model 是顶层全局参数(dispatch/run 共用),
# 不传时 host.py 用配置文件里的 model;传了就覆盖配置。
# worker 的 fallback 靠它把换模型后的第二次尝试真正发到新模型上,
# 不改 host.py 的 dispatch/run 签名。
self.model = model
def _base_command(self, model=None, effort=None, approval=None,
sandbox=None, model_provider=None):
cmd = [sys.executable, self.host_py_path]
if self.config_path is not None:
cmd += ["--config", self.config_path]
# G9:顶层全局参数,必须放在子命令之前(调用方在
# _base_command 之后才追加子命令)。
if getattr(self, "ignore_host_budget", False):
cmd += ["--ignore-host-budget"]
effective = model or self.model
if effective:
cmd += ["--model", effective]
if model_provider:
cmd += ["--model-provider", model_provider]
if effort:
cmd += ["--effort", effort]
cmd += _approval_cli_args(approval)
# 显式沙箱放最后:argparse 里后出现的 --sandbox 覆盖前面
# 的(approval=full 自带 danger-full-access);server 侧
# 已拒绝 plan+full 同存,这里是第二道防线。
if sandbox:
cmd += ["--sandbox", sandbox]
return cmd
def _run_local(self, cmd, extra_env):
"""本地 runner 调用:extra_env 非空时合并进子进程环境。
假 runner 只接受 cmd 时 TypeError:extra_env 是任务级密钥
(如 sub2api key),丢了它跑任务等于跑错网关,必须 loud
fail,绝不静默降级成无 key 运行。
"""
if not extra_env:
return self.runner(cmd)
merged = dict(os.environ)
merged.update(extra_env)
try:
return self.runner(cmd, env=merged)
except TypeError:
raise RuntimeError(
"本地 runner 不支持 env,无法注入任务级环境变量")
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_full = getattr(result, "stdout", "") or ""
status = "failed"
usage = None
task_messages = []
task_creates = []
task_polls = []
wait_threads = []
task_cancels = []
task_retries = []
task_lists = []
task_costs = []
quota_status = []
task_files = []
todo_lists = []
todo_creates = []
todo_updates = []
goal_converts = []
automation_creates = []
turn_id = None
root_turn_id = None
parsed = None
if returncode == 0:
status = "completed"
# JSON 解析必须用完整 stdout:截头后的前 2000 字在
# text 很长时根本不是合法 JSON,旧代码会把 host 的
# failed 误判成 completed;usage 在 JSON 末尾,截头
# 后同样丢。存库的 stdout 是解析出的 text(见
# _result_text),不再是截断的 JSON。
try:
parsed = json.loads(stdout_full)
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"]
# host.py dispatch/run 打印的 JSON 里带 usage 时
# 原样透传给 worker 记 actual;脏数据由
# _clean_usage 挡掉,不进 actual。
usage = _clean_usage(parsed.get("usage"))
# 任务列表显示 turn 用:本轮回合的 turn_id 与
# root_turn_id(dispatch 打印里带,run 打印整
# 个 result 所以也有);取不到时保持 None,
# worker 写回时不覆盖旧值(见 worker 的
# _write_execution_result)。
turn_id = parsed.get("turn_id")
root_turn_id = parsed.get("root_turn_id")
# 互发消息标记块:从完整解析出的 text 里提取(截头
# 版 text 尾部的块会丢),worker 在 completed 轮次
# 后投递;goal 路径不走这里,goal 任务只收不发。
task_messages = _extract_peer_messages(parsed.get("text"))
# 标记工具块:与 task-message 同款从完整 text 里提取,
# worker 在 completed 轮次后执行;goal 路径不走这里。
task_creates = _extract_task_creates(parsed.get("text"))
task_polls = _extract_task_polls(parsed.get("text"))
wait_threads = _extract_wait_threads(parsed.get("text"))
task_cancels = _extract_task_cancels(parsed.get("text"))
task_retries = _extract_task_retries(parsed.get("text"))
task_lists = _extract_task_lists(parsed.get("text"))
task_costs = _extract_task_costs(parsed.get("text"))
quota_status = _extract_quota_status(parsed.get("text"))
task_files = _extract_task_files(parsed.get("text"))
todo_lists = _extract_todo_lists(parsed.get("text"))
todo_creates = _extract_todo_creates(parsed.get("text"))
todo_updates = _extract_todo_updates(parsed.get("text"))
goal_converts = _extract_goal_converts(parsed.get("text"))
automation_creates = _extract_automation_creates(
parsed.get("text"))
else:
host_status = None
else:
host_status = None
stdout = _result_text(parsed, stdout_full)
return {
"executor": "local",
"task_id": task_id,
"status": status,
"stdout": stdout,
"stdout_full": _full_stdout(stdout_full),
"returncode": returncode,
"host_status": host_status,
# host.py 的 RuntimeError(如“任务已存在”)走 traceback 到
# stderr,worker 判断 dispatch 续跑时需要它;execute()
# 的旧结果 dict 不含此字段,保持不变。
"stderr": _truncate_stdout(getattr(result, "stderr", "")),
"usage": usage,
"task_messages": task_messages,
"task_creates": task_creates,
"task_polls": task_polls,
"wait_threads": wait_threads,
"task_cancels": task_cancels,
"task_retries": task_retries,
"task_lists": task_lists,
"task_costs": task_costs,
"quota_status": quota_status,
"task_files": task_files,
"todo_lists": todo_lists,
"todo_creates": todo_creates,
"todo_updates": todo_updates,
"goal_converts": goal_converts,
"automation_creates": automation_creates,
"turn_id": turn_id,
"root_turn_id": root_turn_id,
}
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, seed_dir=None, extra_env=None,
model_provider=None) -> dict:
"""调 host.py dispatch:建 host 任务并跑一轮 turn。
命令:host.py [--config CFG] [--model M] dispatch --name <host 名>
--task <prompt> [--repo <repo>]。repo 取任务的 repo 字段,
没有则取构造时传的 repo,都没有则不传(此时 host.py 用其
配置文件里的 repo,配置里也没有时 host.py 自己报错,
worker 把该报错原样写回 failed,不编造成功)。模型取任务的
model 字段,没有则取构造时传的 model,都没有则不传 --model,
host.py 用配置文件里的 model(fallback 时 worker 会把任务的
model 换成 fallback 模型,第二次 dispatch/run 就跑在新模型上)。
seed_dir 非空时追加 --seed-dir:host.py 在建 worktree 后、
跑 turn 前把暂存附件拷进去(run 续跑不走这条路,不重复拷)。
"""
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(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"),
sandbox=("read-only"
if task.get("plan") else None),
model_provider=model_provider)
cmd += ["dispatch", "--name", self._host_name(task),
"--task", task["prompt"]]
repo = task.get("repo") or self.repo
if repo:
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
return self._result_from_runner(
task["id"], self._run_local(cmd, extra_env))
def fork_task(self, task, source_id, timeout=None, extra_mounts=None,
network=None, extra_env=None) -> dict:
"""调 host.py fork:只分叉 thread 历史与工作区,不跑回合。
fork 成功后 host 任务已存在,worker 再走正常 dispatch→
已存在→run 回退跑首轮(见 worker._run_fork),不直接
在这里跑 turn。timeout/extra_* 是与 DockerExecutor 对齐
的签名,本地忽略。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not source_id:
raise ValueError("fork 缺少源任务")
cmd = self._base_command(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"))
cmd += ["fork", "--task",
self._host_name({"id": source_id}),
"--name", self._host_name(task)]
return self._result_from_runner(
task["id"], self.runner(cmd))
def run_task(self, task, extra_env=None, model_provider=None) -> dict:
"""调 host.py run:在已有 host 任务上再跑一轮 turn。
命令:host.py [--config CFG] [--model M] run --task <host 名>
--prompt <prompt>。worker 只在 dispatch 报“任务已存在”时
用它续跑,不用它做首次派发。模型与 dispatch_task 同规则:
任务的 model 字段优先,fallback 续跑时就是新模型。
"""
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(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"),
sandbox=("read-only"
if task.get("plan") else None),
model_provider=model_provider)
cmd += ["run", "--task", self._host_name(task),
"--prompt", task["prompt"]]
return self._result_from_runner(
task["id"], self._run_local(cmd, extra_env))
def goal_task(self, task, seed_dir=None, extra_env=None,
model_provider=None) -> dict:
"""调 host.py goal:目标循环(turn→验测→喂回重试)。
命令:host.py [--config CFG] [--model M] goal --name <host 名>
--task <goal 或 prompt> [--verify <验收命令>]
[--max-rounds N] [--repo <repo>]。goal 文本缺省用 prompt;
新形态(goal_mode 任务)没有验收命令:--verify 不传,
--max-rounds 传 0(不限轮次,模型宣告完成才结束),
0 是合法值不能用 or 兜底成 3;旧形态仍按原样传验收命令
与轮次。轮次缺省 3(任务行没写时),与 host.py 默认一致;
repo 取任务的 repo 字段,没有则取构造时传的 repo。
seed_dir 非空时追加 --seed-dir(同 dispatch 语义)。
结论解析见 _goal_result_from_runner:host.py goal 两种结局
都 exit 0,不能沿用 _result_from_runner。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
verify = task.get("verify_command")
goal_text = task.get("goal") or task.get("prompt")
if not goal_text:
raise ValueError("任务缺少 prompt")
rounds = task.get("goal_max_rounds")
if rounds is None:
rounds = 3
# P1 轮次上限(与 worker 的 GOAL_MAX_ROUNDS_CAP 同值 100):
# 有限轮次超限直接 loud fail,不静默截断;goal_mode 的 0
#(不限轮次)放行,上界是配额;旧验收形态的 0 同样拒绝
#(0 在旧形态里表示不限轮次,P1 已禁用无上限验收循环)。
if rounds is not None and rounds > 100:
raise ValueError("goal_max_rounds 超过上限 100 轮")
if rounds == 0 and verify and not task.get("goal_mode"):
raise ValueError(
"旧验收形态 goal_max_rounds=0 表示不限轮次,已禁用")
cmd = self._base_command(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"),
model_provider=model_provider)
cmd += ["goal", "--name", self._host_name(task),
"--task", goal_text]
if verify:
cmd += ["--verify", verify]
cmd += ["--max-rounds", str(rounds)]
repo = task.get("repo") or self.repo
if repo:
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
return self._goal_result_from_runner(
task["id"], self._run_local(cmd, extra_env))
def goal_resume_breakpoint_task(self, task,
model_provider=None) -> dict:
"""G10:调 host.py goal-resume(无 --goal-text)断点续跑。
命令:host.py goal-resume --task <host 名>。不传
--goal-text、不传 --max-rounds:host 侧按原任务的
goal/verify_command/goal_max_rounds,从
goal_rounds_done 的下一轮接着跑同一循环,不开新循环、
不新建 worktree、不重置轮次。结论解析与 goal_task 同款。
model_provider 可选但 worker 必须传:首轮 thread 在该
provider 下创建,续跑不带同一 --model-provider 时
thread/resume 报 provider not found(与 Docker 断点
方法同 bug,2026-10-29 probe15 实弹)。(task) 单参
调用仍可用;与 Docker 断点方法的签名不同,不要顺手统一。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
cmd = self._base_command(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"),
model_provider=model_provider)
cmd += ["goal-resume", "--task", self._host_name(task)]
return self._goal_result_from_runner(
task["id"], self.runner(cmd))
def goal_resume_task(self, task, goal_text=None, extra_env=None,
model_provider=None, max_rounds=None) -> dict:
"""调 host.py goal-resume,分两种调用(P1,与 worker、
host 三处一致,绝不能混用):
- 断点续跑:goal_text=None(worker 的 _resume_kind=
"breakpoint" 时传 None)。命令不带 --goal-text、默认
也不带 --max-rounds:host 沿用原 goal + 原 verify_command
+ 原 goal_max_rounds,从断点轮次续跑。传了 --goal-text
就会走 host 的开新目标分支、把原 verify 覆盖成 None,
所以断点调用绝不能顺手把续跑消息塞进 goal_text。
- 开新目标:goal_text 为新目标文本(云端 /continue 带
goal_mode=1 时走这里)。命令带 --goal-text 与
--max-rounds(默认 0,新语义不限轮次,上界是配额)。
不传 --repo:goal-resume 操作已有 host 任务,不新建
worktree。结论解析与 goal_task 同款。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
cmd = self._base_command(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"),
model_provider=model_provider)
cmd += ["goal-resume", "--task", self._host_name(task)]
if goal_text:
cmd += ["--goal-text", goal_text,
"--max-rounds",
str(max_rounds if max_rounds is not None else 0)]
elif max_rounds is not None:
cmd += ["--max-rounds", str(max_rounds)]
return self._goal_result_from_runner(
task["id"], self._run_local(cmd, extra_env))
def _goal_result_from_runner(self, task_id, result):
"""把 host.py goal 的 runner 返回值解析成 goal 结果 dict。
解析用完整 stdout(结论在末尾),存库的 stdout 才截尾;
returncode 非 0 时:已打印 met/unmet 结论的仍判 failed,
无结论的异常退出判 interrupted(断点在盘上,可续跑,
绝不能一律 failed);host 打印了 interrupted 标记时也判
interrupted。为 0 时无结论仍判 failed,绝不把“没打印
结论”当成达标。host 任务已存在时(同任务 /retry 撞名,
goal 没有 run 续跑语义)错误写明“goal 失败请用继续而
非重试”(P1:重试在已存在 host-state 上必失败,断点续
跑才是正确路径,见 worker 的回落逻辑)。
"""
returncode = getattr(result, "returncode", 1)
stdout_full = getattr(result, "stdout", "") or ""
stderr = getattr(result, "stderr", "") or ""
met, goal_error = _parse_goal_output(stdout_full)
interrupted_reason = _goal_interrupted_reason(stdout_full)
refusal = _goal_resume_refusal(stdout_full)
combined = stdout_full + stderr
perm_kind, perm_snippet = _classify_goal_error(
stdout_full, stderr)
if refusal is not None and met is None:
# 断点丢失拒绝:终态 failed,不进自动恢复(见 helper)。
met, goal_error = None, refusal
status = "failed"
elif "已存在" in combined:
met, goal_error = None, (
"host 任务已存在:goal 失败请用继续而非重试——"
"同任务重试会撞已存在 host-state,worker 会先"
"尝试断点续跑,断点续跑仍失败时请用继续(不要"
"重排同一任务)")
status = "failed"
elif interrupted_reason is not None:
# host 侧已记 interrupted:异常中断不是失败,云端跟
# 着记 interrupted,任务可 /goal-resume 断点续跑。
status = "interrupted"
goal_error = (interrupted_reason
or "host goal 异常中断,断点可续跑")
elif (met is None and returncode != 0
and perm_kind == "permanent"):
# P0 三件其二:永久错误(断点 KeyError、配置缺失等)
# 的无结论异常退出——同代码同断点重试必然再失败,
# 判 failed 终态进死信,绝不判 interrupted 空转。
# host 已明言 interrupted 的在上一分支,不受影响。
status = "failed"
goal_error = perm_snippet or "goal 永久错误"
elif returncode != 0:
if met is None:
# 无结论的异常退出(进程被杀、连接断后恢复时抛错
# 等):断点在盘上,记 interrupted 保住续跑活路,
# 绝不能一律 failed——2026-10-28 长 goal 实弹教训。
status = "interrupted"
goal_error = ((stderr.strip()[-500:] if stderr.strip()
else stdout_full.strip()[-500:])
or "host goal 异常退出(无结论),断点可续跑")
else:
status = "failed"
elif met is True:
status = "completed"
elif met is False:
status = "failed"
else:
status = "failed"
goal_error = goal_error or "host goal 输出无结论"
return {
"executor": "local",
"task_id": task_id,
"status": status,
"stdout": _goal_stdout_tail(stdout_full),
"stderr": stderr,
"returncode": returncode,
"host_status": ("goal-met" if met is True else
"goal-unmet" if met is False else None),
"goal_met": met,
"goal_error": goal_error,
"usage": _parse_goal_usage(stdout_full),
}
def popen_task(self, task, kind="dispatch", seed_dir=None,
extra_env=None, model_provider=None):
"""进程组启动 host.py 子进程,返回 Popen(供真取消用)。
start_new_session=True 让子进程自成进程组,worker 取消时
killpg 才能连同子孙一起杀掉。注意:只有默认 runner(真实
subprocess)时才用本方法;单测注入的假 runner 走同步路径,
不走这里。kind=goal 时建 goal 命令、kind=goal-resume 时建
goal-resume 命令(结论解析由 worker 的 _execute_via_process
按 kind 分发到 _goal_result_from_runner)。goal-resume 分流
(P1):_resume_kind="breakpoint" 时不传 --goal-text(断点
续跑,沿用原 goal+verify),其它情况才把本轮 prompt 当
--goal-text(开新目标)。goal 语义与同步 goal_task
一致:新形态(goal_mode)无验收命令时 --verify 不传、
--max-rounds 传 0(0 不能用 or 兜底成 3);旧形态仍传验收
命令与轮次。seed_dir 只对 dispatch/goal 追加(run 续跑与
goal-resume 不传,文件已在 worktree 里)。
"""
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(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"),
sandbox=("read-only"
if task.get("plan") else None),
model_provider=model_provider)
if kind == "run":
cmd += ["run", "--task", self._host_name(task),
"--prompt", task["prompt"]]
elif kind == "goal":
goal_text = task.get("goal") or task.get("prompt")
if not goal_text:
raise ValueError("任务缺少 prompt")
verify = task.get("verify_command")
rounds = task.get("goal_max_rounds")
if rounds is None:
rounds = 3
if rounds is not None and rounds > 100:
raise ValueError("goal_max_rounds 超过上限 100 轮")
if rounds == 0 and verify and not task.get("goal_mode"):
raise ValueError(
"旧验收形态 goal_max_rounds=0 表示不限轮次,已禁用")
cmd += ["goal", "--name", self._host_name(task),
"--task", goal_text]
if verify:
cmd += ["--verify", verify]
cmd += ["--max-rounds", str(rounds)]
repo = task.get("repo") or self.repo
if repo:
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
elif kind == "goal-resume-breakpoint":
# G10 断点:无 --goal-text,不开新循环;host 侧按
# goal_rounds_done 的下一轮接着跑,轮次上限沿用原
# 任务(不传 --max-rounds 时 host.py 取原任务值,
# 新语义原值 0 即不限轮次)。
cmd += ["goal-resume", "--task", self._host_name(task)]
elif kind == "goal-resume":
# P1 分流(与 goal_resume_task 同款契约):
# _resume_kind="breakpoint" 时不传 --goal-text(host
# 沿用原 goal+verify 断点续跑,绝不能用续跑消息覆盖
# 原 verify);其它情况(开新目标)才用 task["prompt"]
#(worker 已换成续跑消息)当 --goal-text,--max-rounds 0。
cmd += ["goal-resume", "--task", self._host_name(task)]
if task.get("_resume_kind") != "breakpoint":
cmd += ["--goal-text", task["prompt"],
"--max-rounds", "0"]
else:
cmd += ["dispatch", "--name", self._host_name(task),
"--task", task["prompt"]]
repo = task.get("repo") or self.repo
if repo:
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if extra_env:
merged = dict(os.environ)
merged.update(extra_env)
return subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True,
start_new_session=True, env=merged)
return subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True,
start_new_session=True)
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_full = getattr(result, "stdout", "") or ""
try:
parsed = json.loads(stdout_full)
except (TypeError, ValueError):
parsed = None
stdout = _result_text(parsed, stdout_full)
return {
"executor": "local",
"task_id": task_id,
"status": "completed" if returncode == 0 else "failed",
"stdout": stdout,
"returncode": returncode,
}
class DockerExecutor(Executor):
"""容器执行器(已接通,命令构造可测,真实 docker 需外部主机)。
设计(与 LocalExecutor 对齐,区别只在“进程跑在哪”):
- 容器内跑的仍是 host.py:代码目录以只读方式挂载到 /app,
容器内命令就是 python3 /app/host.py dispatch/run/status,
不重写 agent 逻辑;任务自己的 worktree 挂载到 /work(可写),
绝不挂载宿主根目录、绝不挂载任何含密钥的目录。
- 容器名:codex-<host 任务名>,host 任务名只保留安全字符,
同一任务每次算出同一名字,取消时才能按名杀容器。
- key:只把 env_key 名以 `-e <名>` 传给 docker(docker 从宿主
进程环境里取值注入容器),命令列表里绝不出现 key 的值;
key 缺失时在调 runner 之前就明确失败,容器根本不启动。
本类只查 env 映射里的存在性与非空,绝不打印、记录 key 的值。
- 资源:--cpus / --memory 可配置,默认 2 核 / 2G,写进文档。
- 清理:docker run --rm,失败也 --rm;超时或取消时先
docker kill 再 docker rm -f,绝不留僵尸容器。
- 网络诚实边界:docker run 默认 bridge 网络;本类不做 egress
白名单——真正的“只放行模型 API”需要宿主防火墙/代理规则,
那是部署项,不是本类假装做到的(见 cloud/README.md)。
构造函数:DockerExecutor(host_py_path=None, config_path=None,
runner=None, image="codex-app-server:latest", cpus="2",
memory="2g", user="1000:1000", worktrees_root=None,
env=None, env_key="XCM_API_KEY", timeout=None)。
host_py_path 缺省时取 cloud/ 上一级的 host.py(与 worker
CLI 的缺省规则一致),以便 DockerExecutor() 无参调用仍可用。
runner 默认是带 timeout 的 subprocess 封装;单测注入的假
runner 可以只接受 cmd 一个参数(本类遇 TypeError 时回退
到只传 cmd 调用),也可以接受 (cmd, timeout=...)。
docker 二进制缺失时 raise RuntimeError("docker not available"),
绝不静默降级为本地执行。
"""
def __init__(self, host_py_path=None, config_path=None, runner=None,
image="codex-app-server:latest", cpus="2", memory="2g",
user="1000:1000", worktrees_root=None, env=None,
env_key="XCM_API_KEY", timeout=None, extra_mounts=None,
model=None, security_opts=None, pids_limit=None,
runtime=None, extra_env=None, ignore_host_budget=False,
readonly_root=False):
if host_py_path is None:
here = os.path.dirname(os.path.abspath(__file__))
host_py_path = os.path.join(os.path.dirname(here), "host.py")
self.host_py_path = host_py_path
self.config_path = config_path
# 模型与 LocalExecutor 同规则:任务的 model 字段优先,没有则
# 取构造时传的 model,都没有则不传 --model(容器内 host.py
# 用配置文件里的 model)。fallback 时 worker 换任务的 model。
self.model = model
# G9:Worker 会把自己的 ignore_host_budget 同步过来。
self.ignore_host_budget = bool(ignore_host_budget)
self.runner = runner if runner is not None else _docker_default_runner
self.image = image
self.cpus = str(cpus)
self.memory = str(memory)
self.user = user
self.worktrees_root = worktrees_root
self._env = env if env is not None else os.environ
self.env_key = env_key
self.timeout = timeout
self.extra_mounts = self._parse_extra_mounts(extra_mounts)
# 外层加固:security_opts 是 --security-opt 的值列表
# (如 apparmor=docker-codex;no-new-privileges 已硬编码,
# 这里传了也不重复);pids_limit 防 fork 炸弹(codex
# 工具链正常用量远低于 512,见 worker CLI 默认值);
# extra_env 是 -e NAME=VALUE 形式的非密钥环境变量
# (如 egress 代理地址——绝不放密钥值,密钥只走 env_key
# 的 -e 名方式)。
self.security_opts = list(security_opts or [])
self.pids_limit = pids_limit
# runtime:docker --runtime(如 runsc,gVisor 沙箱)。
# None 或空串 = 不传 --runtime,走 docker 默认(runc),
# 现有任务行为不变;显式值由 docker 在起容器时校验,
# 未注册的 runtime 会起容器失败(fail closed)。
self.runtime = runtime.strip() if isinstance(runtime, str) else runtime
if not self.runtime:
self.runtime = None
self.extra_env = dict(extra_env or {})
# 严格模式:容器根文件系统只读(/work 挂载仍可写、/tmp 与
# /var/tmp 为 tmpfs,见 build_run_command)。默认关:codex
# 可能往 HOME 等容器内路径写,默认开会改现有任务行为;
# 开启是显式的 worker 旗标 --docker-readonly-root。
self.readonly_root = bool(readonly_root)
@staticmethod
def _parse_extra_mounts(extra_mounts):
"""解析额外挂载,格式 host:container 或 host:container:ro/rw。
安全红线(违反时直接抛错,绝不静默放行):宿主路径必须
是绝对路径且必须已存在(文件挂载时 docker 对不存在路径
会自动建目录,极易把文件挂载变成目录挂载);容器路径
必须是绝对路径,不能是 / 或 /app 本身(那会整体覆盖
只读代码挂载);宿主路径不能是 /、/etc、/root,也不能
在 /etc/codex-app-server 之下(那里放着 key 文件)。
"""
if not extra_mounts:
return []
if isinstance(extra_mounts, str):
extra_mounts = [m for m in extra_mounts.split(",") if m.strip()]
out = []
for spec in extra_mounts:
parts = spec.split(":")
if len(parts) not in (2, 3):
raise ValueError("extra mount 格式必须是 host:container[:ro|rw]: %s" % spec)
host_p, cont_p = parts[0], parts[1]
mode = parts[2] if len(parts) == 3 else "rw"
if mode not in ("ro", "rw"):
raise ValueError("extra mount 模式只能是 ro 或 rw: %s" % spec)
if not os.path.isabs(host_p) or not os.path.isabs(cont_p):
raise ValueError("extra mount 路径必须是绝对路径: %s" % spec)
if host_p in ("/", "/etc", "/root") or host_p.startswith("/etc/codex-app-server"):
raise ValueError("extra mount 拒绝危险宿主路径: %s" % spec)
if cont_p in ("/", "/app"):
raise ValueError("extra mount 拒绝覆盖容器根或 /app 本身: %s" % spec)
if cont_p in ("/tmp", "/var/tmp"):
raise ValueError(
"extra mount 拒绝覆盖 /tmp(容器 /tmp 固定为 tmpfs 隔离): %s" % spec)
if not os.path.exists(host_p):
raise ValueError("extra mount 宿主路径不存在,拒绝挂载: %s" % spec)
out.append((host_p, cont_p, mode))
return out
# -- 名字 / 路径 ---------------------------------------------------
def _host_name(self, task):
raw = str(task.get("id"))
return re.sub(r"[^A-Za-z0-9_.-]", "-", raw)
def container_name(self, task):
"""任务派生的容器名:codex-<安全字符任务名>。"""
return "codex-" + self._host_name(task)
def _app_dir(self):
"""代码目录:host_py_path 所在的目录,挂载到 /app:ro。"""
return os.path.dirname(os.path.abspath(self.host_py_path))
def _worktree_for(self, task):
"""任务自己的 worktree:显式字段优先,否则按派生目录。
显式字段 worktree > cwd;都没有时用 worktrees_root/<host 名>
(未传 worktrees_root 时默认在 host.py 同级的 work/
docker-worktrees 下)。本方法不建目录:docker -v 对不存
在的宿主路径会自动建目录,测试里传显式 worktree 即可
避免任何副作用。
"""
if task.get("worktree"):
return task["worktree"]
if task.get("cwd"):
return task["cwd"]
root = self.worktrees_root
if root is None:
root = os.path.join(self._app_dir(), "work", "docker-worktrees")
return os.path.join(root, self._host_name(task))
# -- 前置检查 ------------------------------------------------------
def _check_docker(self):
if shutil.which("docker") is None:
raise RuntimeError("docker not available")
def _check_key(self):
"""key 门:只查存在性与非空,值本身绝不返回、不打印。"""
if not self.env_key:
return
try:
value = self._env.get(self.env_key)
except Exception:
value = None
if value is None or value == "":
raise RuntimeError(
"Missing environment variable: %s" % self.env_key)
# -- 命令构造 ------------------------------------------------------
def build_run_command(self, task, inner_cmd, extra_mounts=None,
network=None, extra_env=None):
"""构造完整的 docker run 命令(纯函数,可单测断言)。
顺序固定:docker run --rm --name <容器名> --user <用户>
--cpus <核数> --memory <内存> [--cap-drop=ALL
--security-opt=no-new-privileges] [--security-opt <自定义>]
[--pids-limit <n>] [--runtime <runtime>] [--network <网络>]
-v <worktree>:/work -v <代码目录>:/app:ro -w /work
[-e <env_key名>] [-e <NAME=VALUE>...] <镜像> <容器内命令...>。
-e 只传名字,不带 =值(env_key);extra_env 的 -e 带值,
只用于非密钥变量(如 egress 代理地址);密钥类变量
(见 _DOCKER_SECRET_ENV_NAMES)只传名,值由 CLI 子进程
环境提供,绝不进 argv。
extra_mounts 给定时替换构造时的静态挂载(多租户隔离
用:worker 按项目算出挂载后每次调用传入);不给时用
静态挂载。network 给定时容器进该 Docker 网络(多租户
用项目独立网络,容器之间默认 bridge 上互相可见的问题
由此解决);不给时用 docker 默认网络。
降权:容器默认已是非 root 用户,再加 --cap-drop=ALL
与 no-new-privileges(提权类工具调用会被挡下,codex
的常规文件/git 操作不受影响,见多租户实测)。
"""
self._check_docker()
self._check_key()
cmd = ["docker", "run", "--rm",
"--name", self.container_name(task),
"--user", self.user,
"--cpus", self.cpus,
"--memory", self.memory,
"--cap-drop=ALL",
"--security-opt=no-new-privileges"]
if self.readonly_root:
cmd += ["--read-only"]
for opt in self.security_opts:
if opt != "no-new-privileges":
cmd += ["--security-opt", opt]
if self.pids_limit is not None:
cmd += ["--pids-limit", str(self.pids_limit)]
if self.runtime:
cmd += ["--runtime", self.runtime]
if network:
cmd += ["--network", network]
cmd += ["-v", "%s:/work" % self._worktree_for(task),
"-v", "%s:/app:ro" % self._app_dir(),
"-w", "/work"]
# 额外挂载放在基础挂载之后:/app/work 这类子路径挂载会
# 覆盖 /app:ro 的对应子路径,让容器内 host.py 能写
# work/ 和 host-state.json,同时代码主体仍只读。
mounts = self.extra_mounts if extra_mounts is None \
else self._parse_extra_mounts(extra_mounts)
for host_p, cont_p, mode in mounts:
cmd += ["-v", "%s:%s:%s" % (host_p, cont_p, mode)]
# /tmp 固定为 tmpfs:容器内 /tmp 绝不落在宿主 /tmp,也不受
# 额外挂载影响(/tmp 目标的额外挂载已在解析时拒绝)。
# 诚实边界:这是 Docker 外层能做的;内层 codex 的 bwrap
# workspace-write 仍由 codex 二进制控制,本仓库改不了它
# 的 --proc 挂载,宿主级 bwrap 的 /tmp 不隔离问题也不在
# 本仓库的修复范围内——不要把本改动说成“内层沙箱已修”。
cmd += ["--tmpfs", "/tmp", "--tmpfs", "/var/tmp"]
if self.env_key:
cmd += ["-e", self.env_key]
merged_env = dict(self.extra_env)
if extra_env:
merged_env.update(extra_env)
for name, value in sorted(merged_env.items()):
# 密钥类变量只传名不传值:docker 从 CLI 子进程环境里
# 取值(见 _docker_cli_env),值绝不进 docker argv
#(ps/docker inspect 可见 argv,带值等于明文泄露)。
if name in _DOCKER_SECRET_ENV_NAMES:
cmd += ["-e", name]
else:
cmd += ["-e", "%s=%s" % (name, value)]
cmd += [self.image]
cmd += list(inner_cmd)
return cmd
def _docker_cli_env(self, extra_env):
"""docker CLI 子进程环境:extra_env 含密钥名时才需要。
返回合并后的 env(供 -e <名> 按名解析);不含密钥名时
返回 None,保持旧行为(CLI 继承 worker 环境)。
"""
if not extra_env:
return None
if not any(name in _DOCKER_SECRET_ENV_NAMES
for name in extra_env):
return None
merged = dict(os.environ)
merged.update(extra_env)
return merged
def _inner_host_command(self, *args, model=None, effort=None,
approval=None, sandbox=None,
model_provider=None):
"""容器内 host.py 命令:python3 /app/host.py [args...]。
config_path 若已给,容器内用 /app/<basename> 指代它——
调用方须保证配置文件就在代码目录里(host.py 配置约定
本就放在项目目录);不在代码目录时不静默猜路径,
由 _inner_host_command 的调用方在传 config 前自行确认。
这里只做最简单的同名映射,不展开宿主绝对路径进容器。
--model/--effort/--approval-policy/--sandbox 是 host.py
的顶层全局参数,必须放在子命令之前,所以在这里插到
--config 之后、子命令之前。
"""
cmd = ["python3", "/app/host.py"]
if self.config_path is not None:
cmd += ["--config",
"/app/" + os.path.basename(self.config_path)]
# G9:与 LocalExecutor 同款,顶层全局参数,放子命令之前。
if getattr(self, "ignore_host_budget", False):
cmd += ["--ignore-host-budget"]
effective = model or self.model
if effective:
cmd += ["--model", effective]
if model_provider:
cmd += ["--model-provider", model_provider]
if effort:
cmd += ["--effort", effort]
cmd += _approval_cli_args(approval)
if sandbox:
cmd += ["--sandbox", sandbox]
cmd += list(args)
return cmd
# -- runner 调用 / 清理 --------------------------------------------
def _call_runner(self, cmd, timeout=None, env=None):
"""调 runner:优先传 timeout,假 runner 只接受 cmd 时回退。
timeout 参数只影响本次调用(并发下两个任务各用各的
超时);不传时用实例的 self.timeout。绝不能在调用方
临时改 self.timeout 再恢复,那在并发下会互相覆盖。
env 非空时是 docker CLI 子进程的环境(供 -e <名> 按名
解析密钥用):runner 不接受 env 时 loud fail,绝不静默
丢掉密钥跑任务(那样容器内 key 缺失,任务必失败还难查)。
"""
if env is None:
try:
return self.runner(
cmd,
timeout=self.timeout if timeout is None else timeout)
except TypeError:
return self.runner(cmd)
try:
return self.runner(
cmd,
timeout=self.timeout if timeout is None else timeout,
env=env)
except TypeError:
raise RuntimeError(
"docker runner 不支持 env,无法按名注入任务密钥")
def _docker_call_staged(self, task, cmd, timeout=None, env=None):
"""带分段日志的 docker run 调用(2026-10-29 任务 A 补漏)。
goal/goal-resume/goal-resume-breakpoint 不走 _run_in_container
(直接调 _call_runner 保完整 stdout 结论),上一批只给
_run_in_container 加的标记漏了这三条真正在用的路径——
生产实测任务就是 goal 路径,日志里一条 docker_run_ 都没有。
本 helper 统一四条任务路径的开始/结束/异常行;异常只记
类名后原样重抛,各调用点的 kill+failed 处理不变。只记
容器名与镜像,绝不记命令(内含 prompt)。
"""
_t0 = time.time()
_DOCKER_LOGGER.info(
"docker_run_start container=%s image=%s",
self.container_name(task), self.image)
try:
result = self._call_runner(cmd, timeout=timeout, env=env)
except Exception as exc:
_DOCKER_LOGGER.warning(
"docker_run_error container=%s elapsed_ms=%d error=%s",
self.container_name(task),
int((time.time() - _t0) * 1000),
exc.__class__.__name__)
raise
_DOCKER_LOGGER.info(
"docker_run_done container=%s elapsed_ms=%d returncode=%s",
self.container_name(task),
int((time.time() - _t0) * 1000),
getattr(result, "returncode", None))
return result
def list_containers(self, prefix="codex-"):
"""列出名字以 prefix 开头的容器(默认 codex-),失败返回 []。
用 `docker ps -a --format {{.Names}}`,输出按行取容器名;
runner 抛异常或输出不可解析时返回空列表,调用方(worker
启动清理)据此只记日志、不炸掉 worker。
"""
try:
result = self._call_runner(
["docker", "ps", "-a", "--format", "{{.Names}}"])
except Exception:
return []
out = getattr(result, "stdout", "") or ""
names = []
for line in str(out).splitlines():
name = line.strip()
if name and name.startswith(prefix):
names.append(name)
return names
# 任务容器名是 codex-<32 位 hex 任务 id>(见 _container_name)。
# 启动清理只能删这种容器,绝不能按 "codex-" 前缀全删:基础设施
# 容器也叫 codex-*(例如 codex-postgres),按前缀删会把数据库
# 容器当成残留任务容器杀掉(2026-10-26 实测:worker 重启时
# codex-postgres 就是这样被删掉,导致控制面连不上 Postgres)。
_TASK_CONTAINER_RE = re.compile(r"^codex-[0-9a-f]{32}$")
def cleanup_stale_containers(self, prefix="codex-"):
"""清理上一轮残留的任务容器:只删 codex-<32 位 hex 任务 id>。
返回已清理的容器名列表;单个容器清理失败只跳过它(kill
本身尽力而为),列出失败时返回 [],绝不抛异常。
基础设施容器(如 codex-postgres)名字不匹配任务 id 正则,
永远不会被这里删掉。
"""
cleaned = []
for name in self.list_containers(prefix=prefix):
if not self._TASK_CONTAINER_RE.match(name):
continue
try:
if self.kill_container(name):
cleaned.append(name)
except Exception:
continue
return cleaned
def kill_container(self, task_or_name):
"""杀容器:docker kill + docker rm -f,尽力而为不抛异常。
task_or_name 可以是任务 dict(取容器名)或容器名字符串。
返回 True 表示至少 kill 或 rm 其中一步成功执行(runner
未抛异常),False 表示两步都失败或 docker 不存在。
取消路径调用它,绝不只改数据库状态。
"""
name = (self.container_name(task_or_name)
if isinstance(task_or_name, dict) else str(task_or_name))
ok = False
for cmd in (["docker", "kill", name],
["docker", "rm", "-f", name]):
try:
self._call_runner(cmd)
ok = True
except Exception:
continue
return ok
def _docker_timeout_evidence(self, task, exc):
"""超时证据留存(2026-10-30 任务 A 教训的收尾)。
旧超时路径直接 kill+rm:--rm 容器一删,容器日志就没了,
事后只剩一句 'docker run timeout',复发无法定位。修法:
先 kill 不 rm,抓 docker logs 尾 200 行,再 rm;同时
取 TimeoutExpired 自带的 partial stdout/stderr(subprocess
超时时仍保留已产出部分,旧代码直接丢了)。三步都
best-effort,任一步失败都不影响返回 failed 本身。
返回 (partial_stdout, partial_stderr, log_tail) 均为 str。
"""
name = self.container_name(task)
partial_out = _decode_output(getattr(exc, "stdout", ""))
partial_err = _decode_output(getattr(exc, "stderr", ""))
try:
self._call_runner(["docker", "kill", name])
except Exception:
pass
log_tail = ""
try:
res = self._call_runner(
["docker", "logs", "--tail", "200", name])
log_tail = (_decode_output(getattr(res, "stdout", "")) +
_decode_output(getattr(res, "stderr", "")))
except Exception:
pass
try:
self._call_runner(["docker", "rm", "-f", name])
except Exception:
pass
return partial_out, partial_err, log_tail
def _run_in_container(self, task, inner_cmd, timeout=None,
extra_mounts=None, network=None,
extra_env=None):
"""在容器里跑一轮内层命令,返回 LocalExecutor 同款结果 dict。
超时:runner 抛 subprocess.TimeoutExpired(或同名异常)
时先 kill+rm 容器,再返回 failed(stdout 截断 + timeout
说明,returncode 为 None);其它 runner 异常也如实返回
failed,不编造成功。timeout 只影响本次调用,不改实例。
extra_mounts/network/extra_env 只影响本次调用(多租户
按项目传入、egress 代理按任务网络传入)。
"""
task_id = task.get("id") if isinstance(task, dict) else None
if task_id is None:
raise ValueError("任务缺少 id")
cmd = self.build_run_command(task, inner_cmd,
extra_mounts=extra_mounts,
network=network,
extra_env=extra_env)
try:
result = self._docker_call_staged(
task, cmd, timeout=timeout,
env=self._docker_cli_env(extra_env))
except Exception as exc:
if exc.__class__.__name__ == "TimeoutExpired":
p_out, p_err, log_tail = self._docker_timeout_evidence(
task, exc)
evidence = (p_out or log_tail or "")
return {
"executor": "docker", "task_id": task_id,
"status": "failed",
"stdout": _truncate_stdout(
"docker run timeout\n" + evidence)
if evidence else "docker run timeout",
"returncode": None, "host_status": None,
"stderr": _truncate_stdout(p_err),
"container": self.container_name(task),
"container_log_tail": log_tail[-2000:],
"timeout_partial_stdout": p_out[-2000:],
}
return {
"executor": "docker", "task_id": task_id,
"status": "failed",
"stdout": _truncate_stdout(str(exc)),
"returncode": None, "host_status": None, "stderr": "",
"container": self.container_name(task),
}
fake = SimpleNamespaceShim(result)
parsed = _parse_host_result(task_id, fake)
parsed["executor"] = "docker"
parsed["container"] = self.container_name(task)
return parsed
def dispatch_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None,
seed_host_dir=None, model_provider=None) -> dict:
"""容器内调 host.py dispatch:建 host 任务并跑一轮 turn。
seed_host_dir 非空时把暂存目录只读挂载进容器
(/tmp/codex-seed),内层 host.py 用 --seed-dir
/tmp/codex-seed 拷进 worktree;挂载整个目录(不是单个
文件)是为了一包多文件与子目录。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not task.get("prompt"):
raise ValueError("任务缺少 prompt")
inner = self._inner_host_command(
"dispatch", "--name", self._host_name(task),
"--task", task["prompt"], model=task.get("model"),
effort=task.get("effort"), approval=task.get("approval"),
sandbox=("read-only" if task.get("plan") else None),
model_provider=model_provider)
repo = task.get("repo")
if repo:
inner += ["--repo", repo]
mounts = list(extra_mounts or [])
if seed_host_dir:
inner += ["--seed-dir", "/tmp/codex-seed"]
mounts.append("%s:/tmp/codex-seed:ro" % seed_host_dir)
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=mounts or None,
network=network,
extra_env=extra_env)
def fork_task(self, task, source_id, timeout=None, extra_mounts=None,
network=None, extra_env=None) -> dict:
"""容器内调 host.py fork:只分叉,不跑回合(同本地版)。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not source_id:
raise ValueError("fork 缺少源任务")
inner = self._inner_host_command(
"fork", "--task", self._host_name({"id": source_id}),
"--name", self._host_name(task),
model=task.get("model"), effort=task.get("effort"),
approval=task.get("approval"))
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=extra_mounts,
network=network,
extra_env=extra_env)
def run_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None,
model_provider=None) -> dict:
"""容器内调 host.py run:在已有 host 任务上再跑一轮 turn。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not task.get("prompt"):
raise ValueError("任务缺少 prompt")
inner = self._inner_host_command(
"run", "--task", self._host_name(task),
"--prompt", task["prompt"], model=task.get("model"),
effort=task.get("effort"), approval=task.get("approval"),
sandbox=("read-only" if task.get("plan") else None),
model_provider=model_provider)
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=extra_mounts,
network=network,
extra_env=extra_env)
def goal_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None,
seed_host_dir=None, model_provider=None) -> dict:
"""容器内调 host.py goal:目标循环(turn→验测→喂回重试)。
seed_host_dir 语义同 dispatch_task(只读挂载 + 内层
--seed-dir /tmp/codex-seed)。
不走 _run_in_container:它经 _parse_host_result 截头
stdout,而 goal 结论在末尾,截头会把结论截掉;这里直接
调 build_run_command + _call_runner,结论用完整 stdout
解析,存库的 stdout 截尾。超时与异常处理与
_run_in_container 同款(超时先 kill+rm 容器)。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
verify = task.get("verify_command")
goal_text = task.get("goal") or task.get("prompt")
if not goal_text:
raise ValueError("任务缺少 prompt")
# 新形态(goal_mode 任务)没有验收命令:--verify 不传,
# --max-rounds 传 0(不限轮次);0 是合法值不能用 or 兜底。
rounds = task.get("goal_max_rounds")
if rounds is None:
rounds = 3
# P1 轮次上限(与 Local goal_task 同款,不静默截断)。
if rounds is not None and rounds > 100:
raise ValueError("goal_max_rounds 超过上限 100 轮")
if rounds == 0 and verify and not task.get("goal_mode"):
raise ValueError(
"旧验收形态 goal_max_rounds=0 表示不限轮次,已禁用")
inner_args = ["goal", "--name", self._host_name(task),
"--task", goal_text]
if verify:
inner_args += ["--verify", verify]
inner_args += ["--max-rounds", str(rounds)]
inner = self._inner_host_command(
*inner_args, model=task.get("model"),
effort=task.get("effort"), approval=task.get("approval"),
model_provider=model_provider)
repo = task.get("repo")
if repo:
inner += ["--repo", repo]
mounts = list(extra_mounts or [])
if seed_host_dir:
inner += ["--seed-dir", "/tmp/codex-seed"]
mounts.append("%s:/tmp/codex-seed:ro" % seed_host_dir)
cmd = self.build_run_command(task, inner,
extra_mounts=mounts or None,
network=network,
extra_env=extra_env)
try:
result = self._docker_call_staged(
task, cmd, timeout=timeout,
env=self._docker_cli_env(extra_env))
except Exception as exc:
if exc.__class__.__name__ == "TimeoutExpired":
p_out, p_err, log_tail = self._docker_timeout_evidence(
task, exc)
evidence = (p_out or log_tail or "")
return {
"executor": "docker", "task_id": task["id"],
"status": "failed",
"stdout": _truncate_stdout(
"docker run timeout\n" + evidence)
if evidence else "docker run timeout",
"returncode": None, "host_status": None,
"stderr": _truncate_stdout(p_err),
"container": self.container_name(task),
"container_log_tail": log_tail[-2000:],
"timeout_partial_stdout": p_out[-2000:],
"goal_met": None,
"goal_error": ("docker run timeout: " +
evidence[-400:])
if evidence else "docker run timeout",
}
return {
"executor": "docker", "task_id": task["id"],
"status": "failed",
"stdout": _truncate_stdout(str(exc)),
"returncode": None, "host_status": None, "stderr": "",
"container": self.container_name(task),
"goal_met": None, "goal_error": str(exc)[:500],
}
returncode = getattr(result, "returncode", 1)
stdout_full = getattr(result, "stdout", "") or ""
stderr = getattr(result, "stderr", "") or ""
met, goal_error = _parse_goal_output(stdout_full)
interrupted_reason = _goal_interrupted_reason(stdout_full)
combined = stdout_full + stderr
perm_kind, perm_snippet = _classify_goal_error(
stdout_full, stderr)
if "已存在" in combined:
met, goal_error = None, (
"host 任务已存在:goal 失败请用继续而非重试——"
"同任务重试会撞已存在 host-state,worker 会先"
"尝试断点续跑,断点续跑仍失败时请用继续(不要"
"重排同一任务)")
status = "failed"
elif interrupted_reason is not None:
# host 侧已记 interrupted:异常中断不是失败,云端跟
# 着记 interrupted,任务可 /goal-resume 断点续跑。
status = "interrupted"
goal_error = (interrupted_reason
or "host goal 异常中断,断点可续跑")
elif (met is None and returncode != 0
and perm_kind == "permanent"):
# P0 三件其二:永久错误无结论异常退出 → failed 死信
# (同 local 侧注释;host 已明言 interrupted 的在上
# 一分支,不受影响)。
status = "failed"
goal_error = perm_snippet or "goal 永久错误"
elif returncode != 0:
if met is None:
# 无结论的异常退出(进程被杀、连接断后恢复时抛错
# 等):断点在盘上,记 interrupted 保住续跑活路,
# 绝不能一律 failed——2026-10-28 长 goal 实弹教训。
status = "interrupted"
goal_error = ((stderr.strip()[-500:] if stderr.strip()
else stdout_full.strip()[-500:])
or "host goal 异常退出(无结论),断点可续跑")
else:
status = "failed"
elif met is True:
status = "completed"
elif met is False:
status = "failed"
else:
status = "failed"
goal_error = goal_error or "host goal 输出无结论"
return {
"executor": "docker",
"task_id": task["id"],
"status": status,
"stdout": _goal_stdout_tail(stdout_full),
"stderr": stderr,
"returncode": returncode,
"host_status": ("goal-met" if met is True else
"goal-unmet" if met is False else None),
"container": self.container_name(task),
"goal_met": met,
"goal_error": goal_error,
"usage": _parse_goal_usage(stdout_full),
}
def goal_resume_task(self, task, goal_text=None, timeout=None,
extra_mounts=None, network=None,
extra_env=None, model_provider=None,
max_rounds=None) -> dict:
"""容器内调 host.py goal-resume,分两种调用(P1,与 Local
goal_resume_task 同款契约):断点续跑传 goal_text=None
(不带 --goal-text,host 沿用原 goal+verify+轮次),开新
目标才传 goal_text(带 --goal-text 与 --max-rounds,默认 0)。
解析与 goal_task 同款(完整 stdout 结论、存库截尾),但
没有“已存在”分支:goal-resume 本来就是操作已有 host 任务,
不存在 dispatch 撞名问题;returncode 非 0 时:有结论仍
failed,无结论判 interrupted(断点可续跑);rc==0 无结论
仍 failed。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
_resume_args = ["goal-resume", "--task", self._host_name(task)]
if goal_text:
_resume_args += ["--goal-text", goal_text, "--max-rounds",
str(max_rounds
if max_rounds is not None else 0)]
elif max_rounds is not None:
_resume_args += ["--max-rounds", str(max_rounds)]
inner = self._inner_host_command(
*_resume_args,
model=task.get("model"), effort=task.get("effort"),
approval=task.get("approval"),
model_provider=model_provider)
cmd = self.build_run_command(task, inner,
extra_mounts=extra_mounts,
network=network,
extra_env=extra_env)
try:
result = self._docker_call_staged(
task, cmd, timeout=timeout,
env=self._docker_cli_env(extra_env))
except Exception as exc:
if exc.__class__.__name__ == "TimeoutExpired":
p_out, p_err, log_tail = self._docker_timeout_evidence(
task, exc)
evidence = (p_out or log_tail or "")
return {
"executor": "docker", "task_id": task["id"],
"status": "failed",
"stdout": _truncate_stdout(
"docker run timeout\n" + evidence)
if evidence else "docker run timeout",
"returncode": None, "host_status": None,
"stderr": _truncate_stdout(p_err),
"container": self.container_name(task),
"container_log_tail": log_tail[-2000:],
"timeout_partial_stdout": p_out[-2000:],
"goal_met": None,
"goal_error": ("docker run timeout: " +
evidence[-400:])
if evidence else "docker run timeout",
}
return {
"executor": "docker", "task_id": task["id"],
"status": "failed",
"stdout": _truncate_stdout(str(exc)),
"returncode": None, "host_status": None, "stderr": "",
"container": self.container_name(task),
"goal_met": None, "goal_error": str(exc)[:500],
}
returncode = getattr(result, "returncode", 1)
stdout_full = getattr(result, "stdout", "") or ""
stderr = getattr(result, "stderr", "") or ""
met, goal_error = _parse_goal_output(stdout_full)
interrupted_reason = _goal_interrupted_reason(stdout_full)
refusal = _goal_resume_refusal(stdout_full)
perm_kind, perm_snippet = _classify_goal_error(
stdout_full, stderr)
if refusal is not None and met is None:
# 断点丢失拒绝:终态 failed,不进自动恢复(见 helper)。
status = "failed"
goal_error = refusal
elif interrupted_reason is not None:
# host 侧已记 interrupted:异常中断不是失败,云端跟
# 着记 interrupted,任务可 /goal-resume 断点续跑。
status = "interrupted"
goal_error = (interrupted_reason
or "host goal-resume 异常中断,断点可续跑")
elif (met is None and returncode != 0
and perm_kind == "permanent"):
# P0 三件其二:续跑侧永久错误(典型如断点 KeyError)
# → failed 死信;任务 2 的 12 秒空转就是此分支缺失
# 造成的(当时无结论异常退出被判 interrupted)。
status = "failed"
goal_error = perm_snippet or "goal 永久错误"
elif returncode != 0:
if met is None:
# 无结论的异常退出:断点在盘上,记 interrupted
# 保住续跑活路,绝不能一律 failed。
status = "interrupted"
goal_error = ((stderr.strip()[-500:] if stderr.strip()
else stdout_full.strip()[-500:])
or "host goal-resume 异常退出(无结论),"
"断点可续跑")
else:
status = "failed"
elif met is True:
status = "completed"
elif met is False:
status = "failed"
else:
status = "failed"
goal_error = goal_error or "host goal-resume 输出无结论"
return {
"executor": "docker",
"task_id": task["id"],
"status": status,
"stdout": _goal_stdout_tail(stdout_full),
"stderr": stderr,
"returncode": returncode,
"host_status": ("goal-met" if met is True else
"goal-unmet" if met is False else None),
"container": self.container_name(task),
"goal_met": met,
"goal_error": goal_error,
"usage": _parse_goal_usage(stdout_full),
}
def goal_resume_breakpoint_task(self, task, timeout=None,
extra_mounts=None, network=None,
extra_env=None,
model_provider=None) -> dict:
"""G10:容器内调 host.py goal-resume(无 --goal-text)
断点续跑,与 LocalExecutor.goal_resume_breakpoint_task
同语义(从 goal_rounds_done 下一轮接着跑,不开新循环)。
解析与容器内 goal_resume_task 同款;真 Docker 实弹
未做,命令形状由单测覆盖。
model_provider 必须透传:首轮 thread 在该 provider 下
创建,续跑容器不带同一 --model-provider 时 Server 的
provider 表里没有它,thread/resume 报 provider not found
(2026-10-29 probe15 实弹)。
密钥环境也必须透传:密钥类变量只按名进 docker run
命令(-e SUB2API_API_KEY 不带值),值靠 _docker_cli_env
放进 CLI 子进程环境;本方法此前漏传 env,容器内 key
为空,每轮瞬间报 Missing SUB2API_API_KEY、无限空转
(2026-10-29 长任务实弹:断点后 274 轮全失败)。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
inner = self._inner_host_command(
"goal-resume", "--task", self._host_name(task),
model=task.get("model"), effort=task.get("effort"),
approval=task.get("approval"),
model_provider=model_provider)
cmd = self.build_run_command(task, inner,
extra_mounts=extra_mounts,
network=network,
extra_env=extra_env)
try:
result = self._docker_call_staged(
task, cmd, timeout=timeout,
env=self._docker_cli_env(extra_env))
except Exception as exc:
if exc.__class__.__name__ == "TimeoutExpired":
p_out, p_err, log_tail = self._docker_timeout_evidence(
task, exc)
evidence = (p_out or log_tail or "")
return {
"executor": "docker", "task_id": task["id"],
"status": "failed",
"stdout": _truncate_stdout(
"docker run timeout\n" + evidence)
if evidence else "docker run timeout",
"returncode": None, "host_status": None,
"stderr": _truncate_stdout(p_err),
"container": self.container_name(task),
"container_log_tail": log_tail[-2000:],
"timeout_partial_stdout": p_out[-2000:],
"goal_met": None,
"goal_error": ("docker run timeout: " +
evidence[-400:])
if evidence else "docker run timeout",
}
return {
"executor": "docker", "task_id": task["id"],
"status": "failed",
"stdout": _truncate_stdout(str(exc)),
"returncode": None, "host_status": None, "stderr": "",
"container": self.container_name(task),
"goal_met": None, "goal_error": str(exc)[:500],
}
returncode = getattr(result, "returncode", 1)
stdout_full = getattr(result, "stdout", "") or ""
stderr = getattr(result, "stderr", "") or ""
met, goal_error = _parse_goal_output(stdout_full)
interrupted_reason = _goal_interrupted_reason(stdout_full)
refusal = _goal_resume_refusal(stdout_full)
perm_kind, perm_snippet = _classify_goal_error(
stdout_full, stderr)
if refusal is not None and met is None:
# 断点丢失拒绝:终态 failed,不进自动恢复(见 helper)。
status = "failed"
goal_error = refusal
elif interrupted_reason is not None:
# host 侧已记 interrupted:异常中断不是失败,云端跟
# 着记 interrupted,任务可 /goal-resume 断点续跑。
status = "interrupted"
goal_error = (interrupted_reason
or "host goal-resume 异常中断,断点可续跑")
elif (met is None and returncode != 0
and perm_kind == "permanent"):
# P0 三件其二:续跑侧永久错误 → failed 死信(同上)。
status = "failed"
goal_error = perm_snippet or "goal 永久错误"
elif returncode != 0:
if met is None:
# 无结论的异常退出:断点在盘上,记 interrupted
# 保住续跑活路,绝不能一律 failed。
status = "interrupted"
goal_error = ((stderr.strip()[-500:] if stderr.strip()
else stdout_full.strip()[-500:])
or "host goal-resume 异常退出(无结论),"
"断点可续跑")
else:
status = "failed"
elif met is True:
status = "completed"
elif met is False:
status = "failed"
else:
status = "failed"
goal_error = goal_error or "host goal-resume 输出无结论"
return {
"executor": "docker",
"task_id": task["id"],
"status": status,
"stdout": _goal_stdout_tail(stdout_full),
"stderr": stderr,
"returncode": returncode,
"host_status": ("goal-met" if met is True else
"goal-unmet" if met is False else None),
"container": self.container_name(task),
"goal_met": met,
"goal_error": goal_error,
"usage": _parse_goal_usage(stdout_full),
}
def network_gateway(self, network_name):
"""某 Docker 网络的网关 IP(宿主在该网桥上的地址)。
egress 代理跑在宿主上时,容器只能通过网关 IP 访问它
(容器内的 127.0.0.1 是自己,不是宿主)。返回 None
表示查不到(docker 不可用、网络不存在等),调用方
必须按失败处理,绝不能无代理跑容器(那样等于绕过
出口白名单)。网络名为空时查默认 bridge 网络。
"""
name = network_name or "bridge"
try:
result = self._call_runner(
["docker", "network", "inspect", name, "--format",
"{{(index .IPAM.Config 0).Gateway}}"],
timeout=30)
except Exception:
return None
try:
if getattr(result, "returncode", 1) != 0:
return None
gw = (getattr(result, "stdout", "") or "").strip()
except Exception:
return None
return gw or None
def ensure_network(self, name):
"""确保 Docker 网络存在:不存在就创建,已存在就忽略。
返回 True 表示网络可用(创建成功或本来就存在),
False 表示创建失败(调用方继续派发,docker run 时会
因网络不存在而明确失败,不在这里静默降级默认网络——
降级会让隔离悄悄失效,比失败更糟,所以这里只返回
布尔值,是否继续由调用方按失败处理:worker 在租户
模式下网络创建失败时直接让任务 failed,不跑容器)。
"""
if not name:
return False
try:
result = self._call_runner(["docker", "network", "create", name],
timeout=30)
except Exception as exc:
return "already exists" in str(exc)
try:
if getattr(result, "returncode", 1) == 0:
return True
text = (getattr(result, "stderr", "") or "") + \
(getattr(result, "stdout", "") or "")
return "already exists" in text
except Exception:
return False
def execute(self, task) -> dict:
"""只读执行:容器内调 host.py status,不直接跑模型回合。
与 LocalExecutor.execute 对齐:execute 本身只发 status,
跑模型请走 dispatch_task/run_task(worker 派发路径)。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
inner = self._inner_host_command(
"status", "--task", self._host_name(task))
return self._run_in_container(task, inner)
class SimpleNamespaceShim:
"""把 runner 返回值统一成 returncode/stdout/stderr 三字段。"""
def __init__(self, result):
self.returncode = getattr(result, "returncode", 1)
self.stdout = getattr(result, "stdout", "")
self.stderr = getattr(result, "stderr", "")
def _parse_host_result(task_id, fake):
"""LocalExecutor._result_from_runner 的无类版本,供 Docker 用。
host.py 成功时打印 JSON(含 status/text),失败时非 0 退出;
returncode 非 0 一律 failed;为 0 时优先取 JSON 里的 status,
completed 外的状态均按 failed 计。
"""
returncode = getattr(fake, "returncode", 1)
stdout_full = getattr(fake, "stdout", "") or ""
status = "failed"
host_status = None
usage = None
task_messages = []
task_creates = []
task_polls = []
wait_threads = []
task_cancels = []
task_retries = []
task_lists = []
task_costs = []
quota_status = []
task_files = []
todo_lists = []
todo_creates = []
todo_updates = []
goal_converts = []
automation_creates = []
turn_id = None
root_turn_id = None
parsed = None
if returncode == 0:
status = "completed"
# 与 _result_from_runner 同理:JSON 解析用完整 stdout,
# 否则长 text 会把 host 的 failed 误判成 completed,
# 也会丢掉 JSON 末尾的 usage。标记块同样从完整解析
# 出的 text 里提取。存库的 stdout 是解析出的 text,
# 不再是截断的 JSON(见 _result_text)。
try:
parsed = json.loads(stdout_full)
except (TypeError, ValueError):
parsed = None
if isinstance(parsed, dict) and parsed.get("status"):
host_status = parsed["status"]
status = ("completed" if host_status == "completed"
else "failed")
usage = _clean_usage(parsed.get("usage"))
task_messages = _extract_peer_messages(parsed.get("text"))
# 标记工具块:与 task-message 同款从完整 text 里提取,
# worker 在 completed 轮次后执行。
task_creates = _extract_task_creates(parsed.get("text"))
task_polls = _extract_task_polls(parsed.get("text"))
wait_threads = _extract_wait_threads(parsed.get("text"))
task_cancels = _extract_task_cancels(parsed.get("text"))
task_retries = _extract_task_retries(parsed.get("text"))
task_lists = _extract_task_lists(parsed.get("text"))
task_costs = _extract_task_costs(parsed.get("text"))
quota_status = _extract_quota_status(parsed.get("text"))
task_files = _extract_task_files(parsed.get("text"))
todo_lists = _extract_todo_lists(parsed.get("text"))
todo_creates = _extract_todo_creates(parsed.get("text"))
todo_updates = _extract_todo_updates(parsed.get("text"))
goal_converts = _extract_goal_converts(parsed.get("text"))
automation_creates = _extract_automation_creates(
parsed.get("text"))
# 任务列表显示 turn 用:与 Local 版同款语义,取不到
# 时保持 None,worker 写回时不覆盖旧值。
turn_id = parsed.get("turn_id")
root_turn_id = parsed.get("root_turn_id")
stdout = _result_text(parsed, stdout_full)
return {
"executor": "docker",
"task_id": task_id,
"status": status,
"stdout": stdout,
"stdout_full": _full_stdout(stdout_full),
"returncode": returncode,
"host_status": host_status,
"stderr": _truncate_stdout(getattr(fake, "stderr", "")),
"usage": usage,
"task_messages": task_messages,
"task_creates": task_creates,
"task_polls": task_polls,
"wait_threads": wait_threads,
"task_cancels": task_cancels,
"task_retries": task_retries,
"task_lists": task_lists,
"task_costs": task_costs,
"quota_status": quota_status,
"task_files": task_files,
"todo_lists": todo_lists,
"todo_creates": todo_creates,
"todo_updates": todo_updates,
"goal_converts": goal_converts,
"automation_creates": automation_creates,
"turn_id": turn_id,
"root_turn_id": root_turn_id,
}
def _docker_default_runner(cmd, timeout=None, env=None):
"""docker 的默认 runner:subprocess.run,支持 timeout 与 env。
env 非空时与当前进程环境合并后传给 docker CLI 子进程
(-e <名> 的密钥值由此解析,绝不进 argv;只替换不合并会
丢 PATH/HOME,docker CLI 本身都跑不起来)。#88 只给
_call_runner 加了 env 透传却没给默认 runner 加 env 参数,
导致生产 docker 任务一律 loud fail,本补丁补上。
"""
full_env = dict(os.environ)
if env:
full_env.update(env)
return subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout, env=full_env)
class CloudExecutor(Executor):
"""云端执行器:可插拔的通用 HTTP runner 接口(厂商无关)。
本轮不假装接通某个云厂商(ECS、Cloud Run 等专属适配明确
不在范围)。它只做一件事:把任务 POST 到一个配置好的通用
runner endpoint,endpoint 返回任务状态,本类据此返回结果。
配置(只走参数或环境变量,不写文件):
- endpoint:显式参数 > CLOUD_RUNNER_ENDPOINT;未设时 execute
一律 raise RuntimeError,绝不静默降级到 Docker/Local。
- 认证:auth_header 参数或 CLOUD_RUNNER_AUTH_HEADER 给 header
名;token 值只从环境变量取(CLOUD_RUNNER_AUTH_TOKEN_ENV
指定的变量名,默认 CLOUD_RUNNER_AUTH_TOKEN),只放进请求头,
绝不写进日志、错误文本或返回结果。
- 请求体只有 id、prompt、repo_url(task["repo"]),不含任何密钥。
- http_client 供单测注入:http_client(url, payload, headers)
返回带 status 字段的 dict(或具 .status/.json 的对象);
真实路径走 urllib.request。
- dispatch_task/run_task:worker 的 run_one 走 dispatch_task,
这里直接委托给 execute,保证 cloud worker 池能真正派发。
"""
def __init__(self, endpoint=None, http_client=None, auth_header=None,
auth_token_env=None, env=None):
self._env = env if env is not None else os.environ
self.endpoint = endpoint or self._env.get("CLOUD_RUNNER_ENDPOINT")
self.http_client = http_client
self.auth_header = auth_header or self._env.get(
"CLOUD_RUNNER_AUTH_HEADER")
self.auth_token_env = auth_token_env or self._env.get(
"CLOUD_RUNNER_AUTH_TOKEN_ENV") or "CLOUD_RUNNER_AUTH_TOKEN"
def _headers(self):
headers = {"Content-Type": "application/json"}
if self.auth_header:
try:
token = self._env.get(self.auth_token_env)
except Exception:
token = None
if token:
headers[self.auth_header] = token
return headers
def _post(self, payload):
if self.http_client is not None:
return self.http_client(self.endpoint, payload, self._headers())
import urllib.request
req = urllib.request.Request(
self.endpoint,
data=json.dumps(payload).encode("utf-8"),
headers=self._headers(), method="POST")
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def execute(self, task) -> dict:
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not self.endpoint:
raise RuntimeError(
"cloud runner not configured: set CLOUD_RUNNER_ENDPOINT "
"to a generic runner endpoint; no cloud account/runner "
"configured, refusing to silently fall back to "
"Docker/Local")
payload = {
"id": task["id"],
"prompt": task.get("prompt") or "",
"repo_url": task.get("repo"),
}
# 任务带 model 时(worker 每次都设,fallback 时是新模型)
# 才发 model 字段:runner 若认就按它跑,不认则忽略;无模型
# 的任务请求体与以前完全一致,不含任何密钥。
if task.get("model"):
payload["model"] = task["model"]
result = self._post(payload)
if isinstance(result, dict):
status = result.get("status")
stdout = result.get("stdout", "")
else:
status = getattr(result, "status", None)
try:
stdout = result.json().get("stdout", "")
except Exception:
stdout = ""
if status == "completed":
final = "completed"
else:
final = "failed"
return {
"executor": "cloud",
"task_id": task["id"],
"status": final,
"stdout": _truncate_stdout(stdout),
"returncode": 0 if final == "completed" else 1,
"host_status": None,
}
def dispatch_task(self, task) -> dict:
return self.execute(task)
def run_task(self, task) -> dict:
return self.execute(task)
_CHV_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
class ChvFailure(RuntimeError):
"""Cloud Hypervisor 分类失败:RuntimeError 的子类(worker
现有 except Exception 路径照常捕获),另带 kind 字段供
审计与失败分类用,绝不笼统只写 failed。kind 取值见
CloudHypervisorExecutor.preflight / wake_vm 的注释。"""
def __init__(self, kind, message):
super().__init__("chv_failure:%s: %s" % (kind, message))
self.kind = kind
def _parse_memory_bytes(value):
"""内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。
非法值 ValueError,绝不静默按默认算。"""
m = re.match(r"^\s*(\d+)\s*([KMGkmg]?)\s*[Bb]?\s*$",
str(value))
if not m:
raise ValueError("CHV_MEM 格式非法:%r" % (value,))
mult = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3}
return int(m.group(1)) * mult[m.group(2).upper()]
def _unix_socket_api(socket_path, method, path, payload=None):
"""Cloud Hypervisor REST API 的默认传输:标准库 raw HTTP
over Unix socket(无第三方依赖)。返回 (status, body),
body 能解析 JSON 时为 dict,否则为原文本。socket 不存在
或连接失败时 raise RuntimeError(调用方据此判 api 超时
/失败,不编造 200)。"""
if not os.path.exists(socket_path):
raise RuntimeError("api socket 不存在:%s" % socket_path)
body = b""
if payload is not None:
body = json.dumps(payload).encode("utf-8")
req_lines = ["%s /api/v1%s HTTP/1.1" % (method, path),
"Host: localhost",
"Accept: application/json",
"Content-Type: application/json",
"Content-Length: %d" % len(body),
"Connection: close", "", ""]
raw = "\r\n".join(req_lines).encode("utf-8") + body
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(5)
try:
sock.connect(socket_path)
sock.sendall(raw)
chunks = []
while True:
try:
data = sock.recv(65536)
except socket.timeout:
break
if not data:
break
chunks.append(data)
finally:
sock.close()
text = b"".join(chunks).decode("utf-8", errors="replace")
status = 0
if text.startswith("HTTP/"):
try:
status = int(text.split()[1])
except (IndexError, ValueError):
status = 0
body_text = text.split("\r\n\r\n", 1)[1] if "\r\n\r\n" in text \
else ""
try:
parsed = json.loads(body_text) if body_text.strip() else {}
except ValueError:
parsed = body_text
return status, parsed
class CloudHypervisorExecutor(Executor):
"""Cloud Hypervisor 执行器 v1(命名:cloud-hypervisor,绝不用
cloud/ch 缩写——cloud 已是通用 HTTP runner 占位,撞名会让
认领过滤与白名单全部错乱)。
调度层选择,不是任务内工具(补充口径,必读):
- 任务内的 LLM 绝不能发现、调用本执行器:执行器是在任务
创建前由 server 白名单(POST /tasks 的 executor 字段)、
环境默认值(CLOUD_DEFAULT_EXECUTOR)或 worker CLI
--executor 认领决定的。任务内 agent 若能自选沙箱,等
于能从隔离强的执行器降级逃逸到隔离弱的执行器。
- 本模块不提供任何 MCP/工具/skill 给任务内 agent;需要可
发现的是创建任务那一层:POST /tasks 的 executor 可选值、
cloud/README.md 说明、控制台选项(只改文档与白名单,
不新增任务内调用入口)。
客内通道:只选 SSH,不做 vsock 第二套。
- 选 SSH 的理由:客内 host.py 的调用就是一条普通命令,
SSH 直接复用 OpenSSH 的密钥认证与端口转发,云镜像自带
sshd,无需在客内再写一个 vsock agent、也无需在宿主再写
一个 vsock 代理去翻译 host.py 协议。
- 放弃 vsock 的代价:需要运维提供从宿主到客机的 SSH 通路
(tap/桥接 + 端口转发,见下文“VM 生命周期”),且 v1 用
StrictHostKeyChecking=no + BatchMode=yes:客机是临时
启动的、host key 每次都变,严格校验会误杀;生产加固时
应改为预置 known_hosts 钉死客机 key,本 v1 不假装已做。
- 密钥红线:SSH 只传 -i 密钥文件路径,绝不把任何 key 值、
模型 key 值放进 argv、日志或返回结果;客内 host.py 的
模型 key 由客机镜像/cloud-init 预置(运维责任),v1 不
在 SSH 命令里注入任务级 key。
VM 唤醒流程(写进代码,不是只留文档;API 模式为准):
- 主唤醒路径是 API 模式,不是“一条直接启动命令就完事”:
wake_vm() 先 preflight,再经 launcher 以 --api-socket
只启动 VMM 进程(CLI 上不带内核/磁盘配置),等
vmm.ping 通,再 PUT vm.create(VmConfig)+ PUT vm.boot。
build_vm_command() 保留的直接启动命令只是文档里的手动
等价写法(运维手工起 VM 时用),执行器代码里不走它。
- 就绪判定只选 SSH,不做 vsock 第二套:boot 后轮询 SSH
“true”命令,直到 CHV_READY_TIMEOUT(默认 120 秒,
轮询间隔 CHV_READY_INTERVAL,默认 5 秒)内成功才跑
host.py/任务;超时则关 VM 并报失败,绝不在未就绪时
就派任务。vsock 放弃理由同客内通道一节(需客内 agent
+ 宿主代理两套新代码,v1 不拼半吊子)。
- 暂停与快照唤醒是两条独立代码分支:
pause_vm()/resume_vm() 走 ch-remote pause/resume;
restore_vm() 先 ch-remote restore(source_url=...),
再显式 resume_vm()——恢复后的 VM 默认是 paused,不
resume 就不算醒,绝不能只 restore 就返回成功。
- 关闭与清理:任务结束/失败/取消时走 shutdown_vm()/
stop_vm():ch-remote shutdown + 终止登记进程 + 删
socket。dispatch/run/goal 均走“wake → 跑任务 →
finally shutdown”的每任务 VM 生命周期;execute() 是
只读 status,仍走 SSH 直连(要求 VM 已醒,调用方先
wake_vm),不为一次 status 就起一台 VM。
- 清理要能处理三种情况:已死进程(poll() 非 None)、
已关 VM(无登记进程、无 socket,stop 返回 False)、
socket 残留(有 socket、无登记进程,照常删 socket 并
计入清理)。cleanup_stale_vms() 只清 chv- 前缀的
socket,绝不按前缀全删其它执行器的东西。
生产级口径(对齐公开沙箱保障口径,不声称复刻某家内部架构):
- 单任务单 VM:wake → 跑 → finally 关,同一任务不共享 VM;
每任务磁盘 copy 模式独立,租户无共享路径、无东西向互访;
VM 内 root 只能影响本 VM。
- 快速启动:WarmPool(本文件末尾)预热干净快照 VM,acquire
消耗、release 销毁,池空回落冷启动,绝不降级。
- 资源与成本:CPU/内存/磁盘配额门、宿主容量门(CHV_MAX_VMS)、
SSH 超时、goal 100 轮上限、执行器内部不重试;成本与配额
复用 worker 现有 quota 语义,不另起计费。
- 网络与治理:出口默认 fail-closed(必须过 CHV_EGRESS_PROXY
白名单代理);审批仍走任务 approval 与 server merge_gate,
VM 内 agent 不能自批。
- 可观测:VM 生命周期事件经 audit_callback 发出(worker 接线
后转 AuthStore 审计),失败带 ChvFailure.kind 分类;断点
与 task_checkpoints/task_attempts 语义对齐(recycle 保留
两者,丢失临时文件)。
- 默认关闭:server 默认仍 docker、worker 默认仍 local,CHV
只在显式选择且有 CHV worker 时才跑;无 CHV_* 配置时
preflight 直接失败。完整清单见
docs/chv-production-readiness.md。
- worker 层现状:通用路径已能走 dispatch_task/goal_task;
断点续跑(goal-resume-breakpoint)在 worker 层对非
Local 执行器仍明确失败(见 worker.py),本类的
goal_resume_breakpoint_task 已实现,供直接调用与后续
worker 接线,不通过 worker 迂回假装已通。
- 网关现状:worker 通用路径在 sub2api 网关下对非 Local
执行器直接 loud fail(与 CloudExecutor 同款),v1 不
改这条门,所以 CHV + sub2api 组合目前在 worker 层就
明确失败,不宣称生产可用。
配置(只走构造参数或环境变量,不写文件;参数优先):
- CHV_BINARY:cloud-hypervisor 二进制(默认同名 PATH 查找)
- CHV_KERNEL:客机内核路径(必填,文件必须存在)
- CHV_ROOTFS:客机根文件系统镜像路径(必填,文件必须
存在;CHV_IMAGE 是同义别名,仅当 CHV_ROOTFS 未设时用)
- CHV_CPU:vCPU 数(默认 2,命令里写 boot=N)
- CHV_MEM:内存(默认 2G,命令里写 size=2G)
- CHV_STATE_DIR:api-socket 目录(默认 /tmp/chv-vms)
- CHV_SSH_KEY:SSH 私钥文件路径(必填,文件必须存在)
- CHV_SSH_USER:SSH 用户(默认 root)
- CHV_SSH_PORT:宿主侧转发到客机的 SSH 端口(默认 2222)
- CHV_CMDLINE:客机内核命令行(默认
"console=hvc0 root=/dev/vda1 rw")
- CHV_NET:直接启动命令里的 --net 原样透传值(例如
tap=,mac=...,运维按实际 tap 配置给;不给则命令里不带
--net)。API 模式不用它,API 的网络配置走 CHV_NET_JSON。
- CHV_NET_JSON:API 模式 VmConfig 的 net 列表 JSON(例如
'[{"tap":"","mac":"12:34:56:78:90:01"}]',运维按实际
tap 给;不给则 VmConfig 不带 net,客机无网卡、SSH 就
绪必然超时失败——这是 fail-fast 前置条件,不是静默成功)
- CHV_REMOTE:ch-remote 二进制(默认同名 PATH 查找;
preflight 必查,缺失即 fail-fast;stop/cleanup 里调用
失败时尽力而为不炸)
- CHV_API_TIMEOUT:等 vmm.ping 通的上限秒数(默认 30)
- CHV_READY_TIMEOUT:等 guest SSH 就绪的上限秒数(默认 120)
- CHV_READY_INTERVAL:SSH 就绪轮询间隔秒数(默认 5)
前置校验(preflight,执行入口必调,缺一即 fail-fast):
收集全部缺失项一次报出具体缺哪一项,绝不笼统报错:
cloud-hypervisor 与 ch-remote 二进制存在、/dev/kvm 存在
且可读写、CPU 有 vmx/svm 标志、内核与磁盘镜像路径存在
且可读、SSH key 存在且可读。任一项缺失都 raise
RuntimeError,绝不静默回落 Docker/Local。_validate()
即 preflight(旧名保留,供旧调用点用)。
命令构造(build_vm_command/build_ssh_command/
build_vm_config)是纯函数,不做文件存在校验,单测可
直接断言命令与 VmConfig 形状。
真实验证边界(写进 PR 描述,不可删):
未做真实 Cloud Hypervisor 启动验证。当前环境没有可跑真
VM 的宿主:生产主机 124.222.165.170 本身就是 CVM
(systemd-detect-virt=kvm,DMI product=CVM),/dev/kvm
不存在、无 vmx/svm 标志、无 kvm 模块、未装
cloud-hypervisor,无嵌套虚拟化透传;本 VM 容器内同样
无 /dev/kvm。v1 验证只能是 mock runner + 干跑命令构造
+ 单元测试。真 VM 验证需另备带 /dev/kvm 的裸金属或
开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。
"""
def __init__(self, binary=None, kernel=None, rootfs=None,
image=None, cpus=None, memory=None, state_dir=None,
runner=None, launcher=None, ssh_key=None,
ssh_user=None, ssh_port=None,
guest_host_py="/app/host.py", cmdline=None,
net=None, net_json=None, kvm_path="/dev/kvm",
cpuinfo_path="/proc/cpuinfo", env=None,
model=None, ch_remote=None, api_client=None,
sleeper=None, api_timeout=None, ready_timeout=None,
ready_interval=None):
self._env = env if env is not None else os.environ
self.binary = binary or self._env.get("CHV_BINARY") \
or "cloud-hypervisor"
self.kernel = kernel or self._env.get("CHV_KERNEL")
self.rootfs = rootfs or image \
or self._env.get("CHV_ROOTFS") \
or self._env.get("CHV_IMAGE")
self.cpus = str(cpus or self._env.get("CHV_CPU") or "2")
self.memory = memory or self._env.get("CHV_MEM") or "2G"
self.state_dir = state_dir or self._env.get("CHV_STATE_DIR") \
or "/tmp/chv-vms"
self.ssh_key = ssh_key or self._env.get("CHV_SSH_KEY")
self.ssh_user = ssh_user or self._env.get("CHV_SSH_USER") \
or "root"
try:
self.ssh_port = int(ssh_port
or self._env.get("CHV_SSH_PORT")
or 2222)
except (TypeError, ValueError):
raise ValueError("CHV_SSH_PORT 必须是整数")
self.guest_host_py = guest_host_py
self.cmdline = cmdline or self._env.get("CHV_CMDLINE") \
or "console=hvc0 root=/dev/vda1 rw"
self.net = net or self._env.get("CHV_NET")
self.net_json = net_json or self._env.get("CHV_NET_JSON")
self.kvm_path = kvm_path
self.cpuinfo_path = cpuinfo_path
self.ch_remote = ch_remote or self._env.get("CHV_REMOTE") \
or "ch-remote"
self.model = model
# 生产级配置(参数优先于环境变量;见类 docstring 与
# cloud/README.md 的 CHV 一节,默认值全部显式写出):
# - max_vms:本执行器实例同时最多几个 VM(宿主容量门,
# 超限明确拒绝,绝不降级 Docker/Local)
# - disk_mode:copy=每任务复制一份磁盘镜像(默认,慢但
# 文件系统真正独立);reuse=复用基础镜像路径(仅单租户
# 实验用,多租户下磁盘不独立,绝不能用于生产)
# - disk_gb:基础镜像超过该 GB 数即拒绝(磁盘配额门)
# - egress_proxy:客机出口白名单代理地址(必填,除非
# allow_direct_egress 显式开;直连外网默认不给)
# - task_timeout:SSH 单次任务命令超时秒数(None=不限,
# 但 worker 层 task_timeout 仍是外层硬护栏)
# - snapshot_url:快照池的基准快照(无则冷启动)
# - warm_pool_size:预热池目标数(0=关闭预热池)
try:
self.max_vms = int(
self._env.get("CHV_MAX_VMS") or 4)
except ValueError:
raise ValueError("CHV_MAX_VMS 必须是整数")
self.disk_mode = (self._env.get("CHV_DISK_MODE") or "copy")
if self.disk_mode not in ("copy", "reuse"):
raise ValueError(
"CHV_DISK_MODE 只能是 copy 或 reuse:%s"
% self.disk_mode)
disk_gb_raw = self._env.get("CHV_DISK_GB")
self.disk_gb = float(disk_gb_raw) if disk_gb_raw else None
self.egress_proxy = self._env.get("CHV_EGRESS_PROXY")
self.allow_direct_egress = (
self._env.get("CHV_ALLOW_DIRECT_EGRESS") == "1")
task_timeout_raw = self._env.get("CHV_TASK_TIMEOUT")
self.task_timeout = float(task_timeout_raw) \
if task_timeout_raw else None
self.snapshot_url = self._env.get("CHV_SNAPSHOT_URL")
try:
self.warm_pool_size = int(
self._env.get("CHV_WARM_POOL_SIZE") or 0)
except ValueError:
raise ValueError("CHV_WARM_POOL_SIZE 必须是整数")
self.api_client = api_client if api_client is not None \
else _unix_socket_api
self.sleeper = sleeper if sleeper is not None else time.sleep
try:
self.api_timeout = float(
api_timeout if api_timeout is not None
else self._env.get("CHV_API_TIMEOUT") or 30)
self.ready_timeout = float(
ready_timeout if ready_timeout is not None
else self._env.get("CHV_READY_TIMEOUT") or 120)
self.ready_interval = float(
ready_interval if ready_interval is not None
else self._env.get("CHV_READY_INTERVAL") or 5)
except ValueError:
raise ValueError("CHV_*_TIMEOUT/INTERVAL 必须是数字")
if self.ready_interval <= 0:
self.ready_interval = 5
self.audit_callback = None
self._vms_lock = threading.Lock()
self.runner = runner if runner is not None else _default_runner
if launcher is not None:
self.launcher = launcher
else:
def _popen(cmd):
return subprocess.Popen(
cmd, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
self.launcher = _popen
self._vms = {}
# -- 名字 / 路径 ---------------------------------------------------
def _host_name(self, task):
return re.sub(r"[^A-Za-z0-9_.-]", "-",
str(task.get("id")))
def vm_name(self, task_or_name):
"""VM 名:chv-<安全字符任务名>(与 Docker 的 codex- 同款
派生,同一任务每次算出同一名字,清理才能按名找)。
幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理
扫描 socket 文件名时传的就是 chv- 名,二次加前缀会
把 chv-dead 算成 chv-chv-dead、永远清不掉)。"""
raw = task_or_name.get("id") if isinstance(task_or_name, dict) \
else str(task_or_name)
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", raw)
if safe.startswith("chv-"):
return safe
return "chv-" + safe
def api_socket(self, task_or_name):
"""该 VM 的 api-socket 路径:state_dir/<vm 名>.sock。"""
return os.path.join(self.state_dir,
self.vm_name(task_or_name) + ".sock")
# -- 前置校验 ------------------------------------------------------
@staticmethod
def _binary_ok(path):
if "/" in path:
return os.path.isfile(path) and os.access(path, os.X_OK)
return shutil.which(path) is not None
def preflight(self):
"""预检(缺一不可则 fail-fast):收集全部缺失项一次报出
具体缺哪一项,绝不笼统报错;失败为 ChvFailure(带 kind,
供审计与失败分类用),绝不回落 Docker/Local。
检查项与 kind:
- binary_missing:cloud-hypervisor 二进制不存在/不可执行
- binary_missing:ch-remote 二进制不存在/不可执行
- kvm_missing:/dev/kvm 不存在
- kvm_missing:/dev/kvm 不可读/不可写(分开报)
- cpu_flag_missing:/proc/cpuinfo 无 vmx/svm 标志
- kernel_missing:CHV_KERNEL 未设/文件不存在/不可读
- image_missing:CHV_ROOTFS 未设/文件不存在/不可读
- ssh_key_missing:CHV_SSH_KEY 未设/文件不存在/不可读
- egress_not_configured:CHV_EGRESS_PROXY 未设且未显式
允许直连(默认 fail-closed,VM 出口必须过白名单代理)
"""
missing = [] # (kind, 文本) 列表,全部收集完再一次抛
if not self._binary_ok(self.binary):
missing.append(("binary_missing",
"cloud-hypervisor 二进制缺失或不可执行:%s"
% self.binary))
if not self._binary_ok(self.ch_remote):
missing.append(("binary_missing",
"ch-remote 二进制缺失或不可执行:%s"
% self.ch_remote))
if not os.path.exists(self.kvm_path):
missing.append(("kvm_missing",
"/dev/kvm 不存在:%s,宿主无 KVM"
% self.kvm_path))
else:
if not os.access(self.kvm_path, os.R_OK):
missing.append(("kvm_missing",
"/dev/kvm 不可读:%s" % self.kvm_path))
if not os.access(self.kvm_path, os.W_OK):
missing.append(("kvm_missing",
"/dev/kvm 不可写:%s" % self.kvm_path))
cpuinfo_ok = False
try:
with open(self.cpuinfo_path, "r") as fh:
for line in fh:
if line.startswith("flags"):
flags = line.split(":", 1)[1].split()
if "vmx" in flags or "svm" in flags:
cpuinfo_ok = True
break
except OSError:
missing.append(("cpu_flag_missing",
"CPU 信息文件缺失:%s" % self.cpuinfo_path))
else:
if not cpuinfo_ok:
missing.append(("cpu_flag_missing",
"CPU 无虚拟化标志(无 vmx/svm):%s"
% self.cpuinfo_path))
if not self.kernel:
missing.append(("kernel_missing", "CHV_KERNEL 未设"))
elif not os.path.isfile(self.kernel):
missing.append(("kernel_missing",
"内核文件不存在:%s" % self.kernel))
elif not os.access(self.kernel, os.R_OK):
missing.append(("kernel_missing",
"内核文件不可读:%s" % self.kernel))
if not self.rootfs:
missing.append(("image_missing", "CHV_ROOTFS 未设"))
elif not os.path.isfile(self.rootfs):
missing.append(("image_missing",
"磁盘镜像文件不存在:%s" % self.rootfs))
elif not os.access(self.rootfs, os.R_OK):
missing.append(("image_missing",
"磁盘镜像文件不可读:%s" % self.rootfs))
if not self.ssh_key:
missing.append(("ssh_key_missing", "CHV_SSH_KEY 未设"))
elif not os.path.isfile(self.ssh_key):
missing.append(("ssh_key_missing",
"SSH key 文件不存在:%s" % self.ssh_key))
elif not os.access(self.ssh_key, os.R_OK):
missing.append(("ssh_key_missing",
"SSH key 文件不可读:%s" % self.ssh_key))
if not self.egress_proxy and not self.allow_direct_egress:
missing.append(("egress_not_configured",
"CHV_EGRESS_PROXY 未设且未显式允许直连:"
"VM 出口必须过白名单代理,默认不给直连外网"))
if missing:
kind = missing[0][0]
detail = "; ".join(text for _, text in missing)
raise ChvFailure(
kind, "cloud-hypervisor preflight 失败:%s" % detail)
return True
def _validate(self):
"""旧名保留:即 preflight。"""
return self.preflight()
# -- 审计 ----------------------------------------------------------
def _emit(self, event, task, detail="", kind=None):
"""VM 生命周期审计事件:有 audit_callback 时调它
(worker 接线后转 AuthStore 审计,即任务 timeline 的
审计来源),无 callback 时只记日志,绝不因审计失败炸
任务。event 取值:vm_create/vm_boot/vm_ready/vm_pause/
vm_resume/vm_snapshot/vm_restore/vm_shutdown/vm_destroy/
vm_failed/vm_recycled/warm_pool_acquire/warm_pool_release。"""
try:
if self.audit_callback is not None:
self.audit_callback(event, task, detail, kind)
else:
_CHV_LOGGER.info("chv_audit event=%s kind=%s %s",
event, kind, detail)
except Exception:
pass
# -- 命令构造(纯函数,不做文件校验) -------------------------------
def build_vm_command(self, task):
"""构造 cloud-hypervisor 启动命令(干跑可测)。
顺序固定:<二进制> --api-socket <socket> --cpus boot=N
--memory size=M --kernel <内核> --cmdline <命令行>
--disk path=<镜像> [--net <运维透传值>]。
--net 不给时不带:tap/桥接是运维侧配置,本类不编造
一个不存在的 tap 名。
"""
cmd = [self.binary,
"--api-socket", self.api_socket(task),
"--cpus", "boot=%s" % self.cpus,
"--memory", "size=%s" % self.memory,
"--kernel", self.kernel,
"--cmdline", self.cmdline,
"--disk", "path=%s" % self.rootfs]
if self.net:
cmd += ["--net", self.net]
return cmd
def build_ssh_command(self, task, inner_cmd):
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 安全选项
+ user@127.0.0.1 + 客内命令。argv 里只有 key 文件路径,
绝无任何 key 值。egress_proxy 已设时客内命令前加 env
前缀(HTTPS_PROXY/HTTP_PROXY/NO_PROXY,代理地址本身
不是密钥;客机出口只走该白名单代理,见类 docstring)。"""
inner = list(inner_cmd)
if self.egress_proxy:
inner = ["env",
"HTTPS_PROXY=%s" % self.egress_proxy,
"HTTP_PROXY=%s" % self.egress_proxy,
"NO_PROXY=localhost,127.0.0.1"] + inner
return ["ssh", "-p", str(self.ssh_port),
"-i", self.ssh_key,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "BatchMode=yes",
"%s@127.0.0.1" % self.ssh_user] + inner
# -- API 模式:VmConfig / API 调用 / 就绪轮询 -----------------------
def build_vm_config(self, task_disk=None):
"""API 模式的 VmConfig(vm.create 请求体,纯函数):
cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、
kernel{path}、cmdline{args}、disks[{path: 每任务磁盘}]、
rng{/dev/urandom},以及 CHV_NET_JSON 给出的 net 列表
(不给则不带 net——客机无网卡时 SSH 就绪会超时失败,
这是 fail-fast 前置条件,见类 docstring)。
task_disk 不给时用基础镜像路径(仅 lab/reuse 模式)。"""
try:
cpus = int(self.cpus)
except ValueError:
raise ValueError("CHV_CPU 必须是整数:%r" % (self.cpus,))
config = {
"cpus": {"boot_vcpus": cpus, "max_vcpus": cpus},
"memory": {"size": _parse_memory_bytes(self.memory)},
"kernel": {"path": self.kernel},
"cmdline": {"args": self.cmdline},
"disks": [{"path": task_disk or self.rootfs}],
"rng": {"src": "/dev/urandom"},
}
if self.net_json:
try:
net = json.loads(self.net_json)
except ValueError:
raise ValueError("CHV_NET_JSON 不是合法 JSON")
config["net"] = net if isinstance(net, list) else [net]
return config
def _api_call(self, socket_path, method, path, payload=None):
"""API 调用归一化:fake api_client 可返回 (status, body)
或 {"status":..., "body":...} 或带 .status 的对象,本
方法统一成 (status:int, body)。"""
result = self.api_client(socket_path, method, path, payload)
if isinstance(result, tuple) and len(result) == 2:
return int(result[0]), result[1]
if isinstance(result, dict):
status = result.get("status", result.get("status_code"))
body = result.get("body", result)
return (int(status) if status is not None else 0), body
status = getattr(result, "status", None) \
or getattr(result, "status_code", None)
return (int(status) if status is not None else 0), result
def _call_runner(self, cmd, timeout=None):
"""runner 调用:timeout 给定时优先传 timeout,假 runner
只接受 cmd 时 TypeError 回退到只传 cmd(超时护栏丢了,
但绝不因此静默换执行器;worker 层 task_timeout 仍是
外层硬护栏)。"""
if timeout is None:
return self.runner(cmd)
try:
return self.runner(cmd, timeout=timeout)
except TypeError:
return self.runner(cmd)
def _kill_proc(self, proc):
"""终止登记进程:terminate → 等 5 秒 → kill,全部
best-effort,绝不抛异常。"""
if proc is None:
return
try:
proc.terminate()
except Exception:
pass
try:
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
def _active_count(self):
with self._vms_lock:
return len(self._vms)
def _check_capacity(self):
"""宿主容量门:已登记 VM 数达到 max_vms 即明确拒绝
(kind=host_capacity_exceeded,调用方应排队/拒绝,
绝不降级 Docker/Local)。"""
if self._active_count() >= self.max_vms:
raise ChvFailure(
"host_capacity_exceeded",
"宿主 CHV 容量已满:已登记 %d 台,上限 %d,"
"请排队或拒绝本任务" % (self._active_count(),
self.max_vms))
def _check_disk_quota(self):
"""磁盘配额门:CHV_DISK_GB 已设且基础镜像超过该 GB 数
即拒绝(kind=disk_quota_exceeded)。未设则不检查。"""
if self.disk_gb is None or not self.rootfs:
return
try:
size = os.path.getsize(self.rootfs)
except OSError:
return
if size > self.disk_gb * 1024 ** 3:
raise ChvFailure(
"disk_quota_exceeded",
"磁盘镜像 %d 字节超过配额 %.1f GB"
% (size, self.disk_gb))
def _provision_task_disk(self, task):
"""每任务磁盘供给(单任务单 VM 的文件系统独立性):
- copy(默认):把基础镜像复制到
state_dir/<vm>/disk.raw,VmConfig 用该路径;复制
慢但两个任务绝不写同一文件,测试用小文件可直接验证。
- reuse(仅单租户实验):直接返回基础镜像路径,磁盘
不独立,生产禁用(preflight 不拦它,但类 docstring
与就绪清单里明写 reuse 不可用于生产)。
返回每任务磁盘路径。"""
if self.disk_mode == "reuse":
return self.rootfs
task_dir = os.path.join(self.state_dir, self.vm_name(task))
os.makedirs(task_dir, exist_ok=True)
task_disk = os.path.join(task_dir, "disk.raw")
if not os.path.exists(task_disk):
shutil.copyfile(self.rootfs, task_disk)
return task_disk
def wait_api_ready(self, task):
"""等 vmm.ping 通:按次数轮询(api_timeout/1 秒取整,
至少 1 次),不用墙钟死循环——fake sleeper 不推进墙钟
时墙钟循环会空转,次数循环在单测里是确定性的。
超时 raise ChvFailure(kind=api_timeout)。"""
socket_path = self.api_socket(task)
attempts = max(1, int(self.api_timeout))
for attempt in range(attempts):
try:
status, _ = self._api_call(
socket_path, "GET", "/vmm.ping")
if status == 200:
return True
except Exception:
pass
if attempt + 1 < attempts:
self.sleeper(1)
raise ChvFailure(
"api_timeout",
"vmm.ping 超时:%d 次内 API 未通,socket=%s"
% (attempts, socket_path))
def wait_guest_ready(self, task):
"""等 guest SSH 就绪(只选 SSH,见类 docstring):
轮询 ssh … "true",returncode 0 即就绪;次数 =
ready_timeout/ready_interval 取整,至少 1 次。
超时时先关 VM 再 raise ChvFailure(kind=guest_not_ready),
绝不在未就绪时派任务。"""
attempts = max(1, int(self.ready_timeout / self.ready_interval))
for attempt in range(attempts):
try:
result = self._call_runner(
self.build_ssh_command(task, ["true"]))
if getattr(result, "returncode", 1) == 0:
return True
except Exception:
pass
if attempt + 1 < attempts:
self.sleeper(self.ready_interval)
try:
self.stop_vm(task)
except Exception:
pass
raise ChvFailure(
"guest_not_ready",
"guest SSH 就绪超时:%d 次轮询内 sshd 未通,已关 VM"
% attempts)
def _guest_host_command(self, *args, model=None, effort=None,
approval=None, sandbox=None,
model_provider=None):
"""客内 host.py 命令:python3 /app/host.py [顶层全局参数]
[子命令...],顶层参数顺序与 Local/Docker 同款(必须在
子命令之前)。guest_host_py 默认 /app/host.py:客机镜像
须把 host.py 放在该路径(与 Docker 的 /app:ro 同款约定,
只是载体从容器换成 VM 镜像)。"""
cmd = ["python3", self.guest_host_py]
effective = model or self.model
if effective:
cmd += ["--model", effective]
if model_provider:
cmd += ["--model-provider", model_provider]
if effort:
cmd += ["--effort", effort]
cmd += _approval_cli_args(approval)
if sandbox:
cmd += ["--sandbox", sandbox]
cmd += list(args)
return cmd
# -- 生命周期:API 唤醒 / 暂停恢复 / 快照 / 关闭 --------------------
def _launch_api_only(self, task):
"""只启动 VMM 进程(API 模式第一步):CLI 上只带
--api-socket,不带内核/磁盘配置;VM 配置随后走
vm.create 请求体。返回登记的进程。"""
socket_path = self.api_socket(task)
cmd = [self.binary, "--api-socket", socket_path]
proc = self.launcher(cmd)
with self._vms_lock:
self._vms[self.vm_name(task)] = proc
return proc
def _ch_remote(self, task_or_name, *args):
"""ch-remote 调用:[ch-remote --api-socket=<socket> args...]。
returncode 非 0 即 raise RuntimeError(fail-fast),
runner 本身抛异常时原样传播。返回 runner 结果。"""
name = self.vm_name(task_or_name)
socket_path = os.path.join(self.state_dir, name + ".sock")
result = self.runner([self.ch_remote,
"--api-socket=%s" % socket_path]
+ list(args))
if getattr(result, "returncode", 0) not in (0, None):
raise RuntimeError(
"ch-remote %s 失败:returncode=%s"
% (" ".join(args), getattr(result, "returncode")))
return result
def wake_vm(self, task):
"""主唤醒路径(API 模式,dispatch/run/goal 的前置步骤):
preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 →
launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 →
PUT vm.create(VmConfig)→ PUT vm.boot → 等 guest
SSH 就绪。任一步失败都关 VM(stop_vm)再抛
ChvFailure,绝不带着半醒的 VM 去跑任务。
成功返回 {vm, api_socket, state: "running", ready: True,
mode: "cold"},并审计 vm_create/vm_boot/vm_ready。
单任务单 VM:同一任务重复 wake 时先关旧 VM 再起新
的,绝不两个 VM 共用一个任务名。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
self.preflight()
self._check_capacity()
self._check_disk_quota()
name = self.vm_name(task)
old = None
with self._vms_lock:
if name in self._vms:
old = self._vms.pop(name, None)
if old is not None:
self._kill_proc(old)
task_disk = self._provision_task_disk(task)
socket_path = self.api_socket(task)
try:
self._launch_api_only(task)
self.wait_api_ready(task)
status, _ = self._api_call(
socket_path, "PUT", "/vm.create",
self.build_vm_config(task_disk))
if status not in (200, 204):
raise ChvFailure(
"vm_create_failed",
"vm.create 失败:status=%s" % status)
self._emit("vm_create", task,
"vm=%s disk=%s" % (name, task_disk))
status, _ = self._api_call(socket_path, "PUT", "/vm.boot")
if status not in (200, 204):
raise ChvFailure(
"vm_boot_failed",
"vm.boot 失败:status=%s" % status)
self._emit("vm_boot", task, "vm=%s" % name)
self.wait_guest_ready(task)
except Exception:
try:
self.stop_vm(task)
except Exception:
pass
if isinstance(task, dict):
self._emit("vm_failed", task,
"vm=%s wake 失败" % name)
raise
self._emit("vm_ready", task, "vm=%s" % name)
return {"vm": name, "api_socket": socket_path,
"state": "running", "ready": True, "mode": "cold"}
def start_vm(self, task):
"""旧名保留:即 wake_vm(API 模式)。直接启动命令
build_vm_command() 只作手动等价写法,不再是执行器
的启动路径——两条启动路径并存是上一版的半吊子,
本版只认 API 模式。"""
return self.wake_vm(task)
def pause_vm(self, task_or_name):
"""暂停 VM:ch-remote pause。成功审计 vm_pause。"""
self._ch_remote(task_or_name, "pause")
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
self._emit("vm_pause", task,
"vm=%s" % self.vm_name(task_or_name))
return True
def resume_vm(self, task_or_name):
"""唤醒已暂停的 VM:ch-remote resume。成功审计 vm_resume。
注意:这是暂停唤醒的唯一路径,恢复后的 VM 也走它
(见 restore_vm),绝不能只 restore 就算醒。"""
self._ch_remote(task_or_name, "resume")
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
self._emit("vm_resume", task,
"vm=%s" % self.vm_name(task_or_name))
return True
def snapshot_vm(self, task_or_name, snapshot_url):
"""给(已暂停的)VM 做快照:ch-remote snapshot
<source_url>。snapshot_url 为空即 ValueError;成功
审计 vm_snapshot。快照本身不改变 VM 状态(仍 paused,
需 resume 才醒)。"""
if not snapshot_url:
raise ValueError("snapshot_url 不能为空")
self._ch_remote(task_or_name, "snapshot",
"source_url=%s" % snapshot_url)
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
self._emit("vm_snapshot", task,
"vm=%s snapshot=%s"
% (self.vm_name(task_or_name), snapshot_url))
return snapshot_url
def restore_vm(self, task, snapshot_url):
"""快照恢复唤醒:preflight → 只起 VMM(--api-socket)
→ 等 vmm.ping → ch-remote restore → 显式 resume →
等 guest SSH 就绪。恢复后的 VM 默认是 paused,不
resume 就不算醒——本方法绝不省略 resume 那一步。
任一步失败都关 VM 再抛。成功返回 {vm, api_socket,
state: "running", ready: True, mode: "restored"}。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not snapshot_url:
raise ValueError("snapshot_url 不能为空")
if "://" not in snapshot_url:
snapshot_url = "file://%s" % snapshot_url
self.preflight()
self._check_capacity()
name = self.vm_name(task)
try:
self._launch_api_only(task)
self.wait_api_ready(task)
self._ch_remote(task, "restore",
"source_url=%s" % snapshot_url)
self._emit("vm_restore", task,
"vm=%s snapshot=%s" % (name, snapshot_url))
self.resume_vm(task)
self.wait_guest_ready(task)
except Exception:
try:
self.stop_vm(task)
except Exception:
pass
raise
return {"vm": name, "api_socket": self.api_socket(task),
"state": "running", "ready": True, "mode": "restored"}
def hibernate_vm(self, task_or_name, snapshot_url):
"""休眠(CHV 无原生 hibernate 端点,等价实现):
pause + snapshot + shutdown。休眠后文件与 worktree
保留在快照里(快照是暂停时刻的完整状态),临时文件
不在恢复保证内(见 recycle_vm 的说明)。
成功返回 {vm, state: "hibernated", snapshot: ...}。
本方法明写“等价实现”,绝不声称接了原生 hibernate。"""
self.pause_vm(task_or_name)
self.snapshot_vm(task_or_name, snapshot_url)
self.stop_vm(task_or_name)
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
return {"vm": self.vm_name(task_or_name),
"state": "hibernated", "snapshot": snapshot_url}
def wake_from_hibernate(self, task, snapshot_url):
"""从休眠唤醒:即 restore_vm(restore + 显式 resume
+ SSH 就绪),语义与 restore_vm 完全一致,只是名字
说明来源是休眠快照。"""
return self.restore_vm(task, snapshot_url)
def stop_vm(self, task_or_name):
"""停止 VM(尽力而为,不抛异常;三种情况):
- 已死进程:登记进程 poll() 非 None,跳过 terminate
等待,直接删 socket、清登记,返回 True。
- 已关 VM:无登记进程、无 socket,ch-remote 仍尽力
调过一次,返回 False(本来就没有什么可清)。
- socket 残留:有 socket、无登记进程,删 socket 并
返回 True。
ch-remote shutdown 失败不炸(VM 可能已死),但
ch-remote 命令本身仍会发出——测试据此断言关闭路径
确实用了 ch-remote shutdown,而不是只杀进程。"""
name = self.vm_name(task_or_name)
socket_path = os.path.join(self.state_dir, name + ".sock")
with self._vms_lock:
had_proc = name in self._vms
proc = self._vms.get(name)
had = had_proc or os.path.exists(socket_path)
try:
self.runner([self.ch_remote,
"--api-socket=%s" % socket_path, "shutdown"])
except Exception:
pass
if proc is not None:
with self._vms_lock:
self._vms.pop(name, None)
try:
alive = proc.poll() is None
except Exception:
alive = False
if alive:
self._kill_proc(proc)
try:
if os.path.exists(socket_path):
os.remove(socket_path)
except Exception:
pass
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
if had:
self._emit("vm_shutdown", task, "vm=%s" % name)
self._emit("vm_destroy", task, "vm=%s" % name)
_CHV_LOGGER.info("chv_vm_stop vm=%s had=%s", name, had)
return had
def shutdown_vm(self, task_or_name):
"""关闭入口的正式名:即 stop_vm(ch-remote shutdown)。
任务结束/失败/取消时调它。"""
return self.stop_vm(task_or_name)
def cancel_vm(self, task_or_name):
"""取消入口:即 shutdown_vm。worker 的取消路径对
Docker 调 kill_container,对 CHV 调本方法——取消绝不
只改数据库状态,必须真关 VM。"""
return self.shutdown_vm(task_or_name)
def recycle_vm(self, task):
"""回收 VM(任务结束后的销毁语义):
先 shutdown,再返回保留/丢失清单。保留的只有宿主侧
的 task_checkpoints / task_attempts(数据库里的断点与
尝试账本,VM 销毁不影响它们,goal 断点续跑据此接续);
丢失的是客机内的临时文件与未拷出的 worktree 临时物
——recycle 绝不声称“恢复临时文件”。需要拷出的工件
必须在 shutdown 之前由调用方经 SSH 拷出,本方法不
假装替调用方拷了。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
self.shutdown_vm(task)
self._emit("vm_recycled", task,
"vm=%s" % self.vm_name(task))
return {
"vm": self.vm_name(task),
"state": "recycled",
"preserved": ["task_checkpoints", "task_attempts"],
"lost": ["guest /tmp", "guest worktree temp files"],
}
def cleanup_stale_vms(self):
"""清理残留 VM:只清 socket 残留与已死进程对应的 VM。
扫描 state_dir 下 *.sock:登记进程仍活(poll() 为 None)
的跳过;其余一律走 stop_vm。返回已清理的 VM 名列表;
state_dir 不存在时返回 [],绝不抛异常。基础设施或
其它执行器的 socket 不在 state_dir 里,不会被误删;
同目录下非 chv- 前缀的 socket 也跳过(只认本执行器
的 chv- 命名)。
"""
cleaned = []
try:
entries = os.listdir(self.state_dir)
except Exception:
return []
for entry in entries:
if not entry.endswith(".sock"):
continue
name = entry[:-len(".sock")]
if not name.startswith("chv-"):
continue
proc = self._vms.get(name)
if proc is not None:
try:
alive = proc.poll() is None
except Exception:
alive = False
if alive:
continue
if self.stop_vm(name):
cleaned.append(name)
# 登记进程已死但 socket 文件已被删的情况:登记项本身
# 也是残留,顺手清掉登记,不计入 cleaned(没有 socket
# 可删,不算一次清理)。
for name in list(self._vms.keys()):
proc = self._vms.get(name)
try:
dead = proc is not None and proc.poll() is not None
except Exception:
dead = True
if dead:
self._vms.pop(name, None)
return cleaned
# -- 客内执行 ------------------------------------------------------
def _run_guest(self, task, inner_args, **kwargs):
"""SSH 调客内 host.py,返回 Local/Docker 同款结果 dict
(executor 字段改写为 cloud-hypervisor,并附 vm 名与
api-socket)。runner 异常即原样传播,由 worker 侧统一
写回 failed——本类不在这里把异常吞成假成功。"""
self._validate()
ssh_cmd = self.build_ssh_command(
task, self._guest_host_command(*inner_args, **kwargs))
result = self._call_runner(ssh_cmd, timeout=self.task_timeout)
parsed = _parse_host_result(task["id"],
SimpleNamespaceShim(result))
parsed["executor"] = "cloud-hypervisor"
parsed["vm"] = self.vm_name(task)
parsed["api_socket"] = self.api_socket(task)
return parsed
def _goal_result(self, task_id, result):
"""客内 host.py goal 输出解析:结论在末尾,解析规则与
LocalExecutor._goal_result_from_runner 同款语义(met
True=达标 completed,False=未达标 failed,interrupted
标记/无结论异常退出=interrupted 保住断点续跑活路,
永久错误无结论=failed 死信),executor 字段为
cloud-hypervisor。"""
returncode = getattr(result, "returncode", 1)
stdout_full = getattr(result, "stdout", "") or ""
stderr = getattr(result, "stderr", "") or ""
met, goal_error = _parse_goal_output(stdout_full)
interrupted_reason = _goal_interrupted_reason(stdout_full)
refusal = _goal_resume_refusal(stdout_full)
perm_kind, perm_snippet = _classify_goal_error(
stdout_full, stderr)
if refusal is not None and met is None:
status, goal_error = "failed", refusal
elif interrupted_reason is not None:
status = "interrupted"
goal_error = (interrupted_reason
or "host goal 异常中断,断点可续跑")
elif met is None and returncode != 0 \
and perm_kind == "permanent":
status, goal_error = "failed", perm_snippet or "goal 永久错误"
elif returncode != 0 and met is None:
status = "interrupted"
goal_error = ((stderr.strip()[-500:] if stderr.strip()
else stdout_full.strip()[-500:])
or "host goal 异常退出(无结论),断点可续跑")
elif met is True:
status = "completed"
elif met is False:
status = "failed"
else:
status = "failed"
goal_error = goal_error or "host goal 输出无结论"
return {
"executor": "cloud-hypervisor",
"task_id": task_id,
"status": status,
"stdout": _goal_stdout_tail(stdout_full),
"stderr": stderr,
"returncode": returncode,
"host_status": ("goal-met" if met is True else
"goal-unmet" if met is False else None),
"goal_met": met,
"goal_error": goal_error,
"usage": _parse_goal_usage(stdout_full),
}
def _run_guest_goal(self, task, inner_args, **kwargs):
self._validate()
ssh_cmd = self.build_ssh_command(
task, self._guest_host_command(*inner_args, **kwargs))
return self._goal_result(
task["id"],
self._call_runner(ssh_cmd, timeout=self.task_timeout))
def _run_with_vm(self, task, inner_args, goal=False, **kwargs):
"""每任务 VM 生命周期:wake_vm(API 模式 + SSH 就绪)
→ 跑客内命令 → finally shutdown_vm。wake 失败时不跑
任务、直接抛分类失败;跑任务抛异常时 finally 仍关 VM,
异常原样传播(worker 侧统一写回 failed)。本方法不做
内部重试:重试是 worker 的 max_retries 语义,执行器
内部重试会把一次失败放大成多次计费与多次 VM 启动。"""
self.wake_vm(task)
try:
if goal:
return self._run_guest_goal(task, inner_args, **kwargs)
return self._run_guest(task, inner_args, **kwargs)
finally:
try:
self.shutdown_vm(task)
except Exception:
pass
def execute(self, task) -> dict:
"""只读执行:客内 host.py status,不直接跑模型回合
(与 Local/Docker 的 execute 同款语义)。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
return self._run_guest(
task, ("status", "--task", self._host_name(task)))
def dispatch_task(self, task) -> dict:
"""客内 host.py dispatch:建 host 任务并跑一轮 turn。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not task.get("prompt"):
raise ValueError("任务缺少 prompt")
args = ["dispatch", "--name", self._host_name(task),
"--task", task["prompt"]]
repo = task.get("repo")
if repo:
args += ["--repo", repo]
return self._run_with_vm(
task, args, model=task.get("model"),
effort=task.get("effort"), approval=task.get("approval"),
sandbox=("read-only" if task.get("plan") else None))
def run_task(self, task) -> dict:
"""客内 host.py run:在已有 host 任务上再跑一轮 turn。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
if not task.get("prompt"):
raise ValueError("任务缺少 prompt")
return self._run_with_vm(
task, ("run", "--task", self._host_name(task),
"--prompt", task["prompt"]),
model=task.get("model"), effort=task.get("effort"),
approval=task.get("approval"))
def goal_task(self, task) -> dict:
"""客内 host.py goal:目标循环,轮次规则与 Local 同款
(缺省 3 轮,>100 轮 loud fail,旧验收形态 0 轮禁用)。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
goal_text = task.get("goal") or task.get("prompt")
if not goal_text:
raise ValueError("任务缺少 prompt")
verify = task.get("verify_command")
rounds = task.get("goal_max_rounds")
if rounds is None:
rounds = 3
if rounds is not None and rounds > 100:
raise ValueError("goal_max_rounds 超过上限 100 轮")
if rounds == 0 and verify and not task.get("goal_mode"):
raise ValueError(
"旧验收形态 goal_max_rounds=0 表示不限轮次,已禁用")
args = ["goal", "--name", self._host_name(task),
"--task", goal_text]
if verify:
args += ["--verify", verify]
args += ["--max-rounds", str(rounds)]
repo = task.get("repo")
if repo:
args += ["--repo", repo]
return self._run_with_vm(
task, args, goal=True, model=task.get("model"),
effort=task.get("effort"), approval=task.get("approval"))
def goal_resume_task(self, task, goal_text=None,
max_rounds=None) -> dict:
"""客内 host.py goal-resume:goal_text=None 为断点续跑
(不带 --goal-text,沿用原 goal+verify),给 goal_text
为开新目标(带 --goal-text 与 --max-rounds,默认 0)。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
args = ["goal-resume", "--task", self._host_name(task)]
if goal_text:
args += ["--goal-text", goal_text, "--max-rounds",
str(max_rounds if max_rounds is not None else 0)]
elif max_rounds is not None:
args += ["--max-rounds", str(max_rounds)]
return self._run_with_vm(
task, args, goal=True, model=task.get("model"),
effort=task.get("effort"), approval=task.get("approval"))
def goal_resume_breakpoint_task(self, task) -> dict:
"""客内 host.py goal-resume 断点续跑:无 --goal-text、
无 --max-rounds,host 沿用原任务从断点轮次续跑。
注意 worker 层对非 Local 执行器的断点续跑目前仍明确
失败,本方法供直接调用与后续 worker 接线(见类
docstring),不要理解成 worker 路径已通。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
return self._run_with_vm(
task, ("goal-resume", "--task", self._host_name(task)),
goal=True, model=task.get("model"),
effort=task.get("effort"), approval=task.get("approval"))
class WarmPool:
"""快照预热池(快速启动设计,接口与状态机在代码里)。
目标:冷启动(preflight + 起 VMM + create + boot + SSH
就绪)是慢路径;预热池提前把干净 VM 起好并暂停,任务
来时 acquire 直接 resume,省掉冷启动的大头。
隔离红线(绝不妥协):预热条目是一次性的干净快照 VM,
acquire 消耗掉它,release 一律销毁,绝不把一个任务用
过的 VM(带任务文件、带 agent 痕迹)翻出来给下一个
任务/租户复用;池子空了就回落冷启动(wake_vm),绝不
因为池空就降级 Docker/Local。
状态机:cold(未预热)→ prewarming(wake 中)→ warm
(已暂停待分配)→ assigned(已分配给某任务)→
running(已 resume)→ recycled/destroyed(release 后)。
prewarm 的实现:对每个条目调 executor.wake_vm(冷启动)
再 pause_vm,得到 paused 的干净 VM;mock 单测里 wake/
pause 均可注入假实现,真机上这一步需要 KVM 宿主(见
就绪清单“需 KVM 宿主实测”)。snapshot_url 未设时
prewarm 返回 [](无基准快照可预热,不是静默造快照)。
"""
def __init__(self, executor, size=None, snapshot_url=None):
self.executor = executor
self.size = int(size if size is not None
else getattr(executor, "warm_pool_size", 0))
self.snapshot_url = snapshot_url \
if snapshot_url is not None \
else getattr(executor, "snapshot_url", None)
self._entries = {} # vm 名 -> {"state": ..., "task": ...}
def warm_count(self):
return sum(1 for e in self._entries.values()
if e["state"] == "warm")
def prewarm(self, count=None):
"""预热到目标数:已有 warm 条目不重复建;每个新条目
用一个合成任务名 wake + pause(合成任务 id 为
warm-<序号>,绝不与真实任务 id 撞名)。返回新增的
warm VM 名列表。"""
if self.snapshot_url is None:
return []
target = int(count) if count is not None else self.size
added = []
i = 0
while self.warm_count() < target:
i += 1
name = "warm-%d" % i
if name in self._entries:
continue
task = {"id": name}
self._entries[name] = {"state": "prewarming",
"task": task}
try:
self.executor.wake_vm(task)
self.executor.pause_vm(task)
except Exception:
self._entries.pop(name, None)
raise
self._entries[name] = {"state": "warm", "task": task}
added.append(name)
return added
def acquire(self, task):
"""分配一个预热 VM 给任务:有 warm 条目时消耗一个
(状态 assigned,返回 mode=warm + 该条目的合成任务
名,供调用方 resume 后使用);无 warm 条目时返回
mode=cold(调用方回落 wake_vm 冷启动)。"""
for name, entry in list(self._entries.items()):
if entry["state"] == "warm":
entry["state"] = "assigned"
entry["task"] = task
self.executor._emit(
"warm_pool_acquire", task,
"vm=%s" % name)
return {"mode": "warm", "vm": name,
"pool_task": entry["task"]}
return {"mode": "cold", "vm": None, "pool_task": None}
def release(self, task, pool_vm=None):
"""释放:无论 warm 还是 cold 路径,VM 一律销毁
(executor.shutdown_vm),池条目清掉;绝不把用过
的 VM 放回 warm 池。返回 True 表示确实销毁了一个
条目或 VM。"""
destroyed = False
if pool_vm and pool_vm in self._entries:
self._entries.pop(pool_vm, None)
destroyed = True
try:
if self.executor.shutdown_vm(task):
destroyed = True
except Exception:
pass
self.executor._emit(
"warm_pool_release", task,
"vm=%s destroyed=%s" % (pool_vm, destroyed))
return destroyed