单任务单 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。
3438 lines
162 KiB
Python
3438 lines
162 KiB
Python
"""沙箱执行器:可插拔的任务执行接口。
|
||
|
||
零第三方依赖,只用 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
|