Files
codex-app-server/cloud/sandbox.py
host 41e8c98512 feat: cloud 门禁/超时/成本/沙箱收敛为单一判定源
- 新增标准库叶子层 timeouts/gates/quota/sandbox_policy:
  审批 3600 与单轮 600 各有名常量与环境覆盖层,已建项
  expires_at 永不重算;目录/控制/合并状态门禁统一判定,
  目录违规无任何覆盖参数;配额取数与比较唯一来源
  (USD 先于无价、命中均为 >=、无价只计 actual 行);
  /tmp 隔离矩阵与失联清理判定纯函数化。
- 调用点委托:server/worker/automations/auto_memory 的
  配额 SQL 与比较、server/tree/marker 的目录解析、
  marker 的控制门、sandbox 的任务容器名正则、两处 300 秒
  陈旧阈值默认值均收敛,别名与错误文案不变。
- 统一两个 P0 口径分歧:服务端无价计数由全部 usd IS NULL
  行改为 actual-only;显式不限额用户在服务端准入亦不受
  无价上限约束(与 worker 运行时一致)。
- 不新增门禁种类与 record 模式(主线无此实现);不改
  skill_seed/skills/SKILL.md;不改 v1.1 契约与分支保护。
- 新增 test_arch_converge.py 18 项。
2026-11-03 02:49:25 +00:00

2188 lines
104 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""沙箱执行器:可插拔的任务执行接口。
零第三方依赖,只用 Python 标准库;不重写 agent 逻辑。
- Executor:基类,子类必须实现 execute(task)。
- LocalExecutor:先行实现,复用 host.py 现有 worktree + codex 沙箱,
通过 host.py CLI 子进程做只读查询封装;不直接跑模型。
- DockerExecutor:已接通。docker run --rm,任务 worktree 挂载到
/work(可写)、代码目录挂载到 /app:ro(只读),--cpus/--memory
资源限额,--user 非 root,-e 只传 env_key 名(不带值,key 缺失
时不启动容器);超时与取消时 docker kill + rm -f,不留僵尸
容器。网络白名单不在本类里假装实现(见 cloud/README.md)。
- CloudExecutor:占位,未接通云端执行。
"""
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import time
from cloud.sandbox_policy import TASK_CONTAINER_RE as _POLICY_TASK_RE
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
def tmp_isolation_status():
"""各层 /tmp 隔离现状的唯一口径(README 与单测都以它为准)。
返回 {docker: bool, host_bwrap: bool, ...}:
- docker=True:DockerExecutor 给每个任务容器挂 --tmpfs /tmp
与 --tmpfs /var/tmp(容器随 --rm 消失,/tmp 也随之消失,
绝不落宿主 /tmp;见 build_run_command 的顺序注释)。
- host_bwrap=False:宿主级 codex workspace-write(bwrap)
的挂载由 codex 二进制内部决定,host.py 只传
sandboxPolicy 类型、没有任何挂载旋钮,所以本仓库改不
了它;2026-10-26 宿主实测 agent 写 /tmp 落到宿主 /tmp,
本轮未在宿主重测、也不宣称已修复。需要 /tmp 隔离的
任务请走 docker 执行器,不要走宿主级沙箱 worker。
本函数是状态宣告,不是修复开关:调用方绝不能拿它的
docker=True 去给宿主级任务背书。
"""
return {
"docker": True,
"host_bwrap": False,
"docker_detail": "per-container tmpfs /tmp + /var/tmp, removed with --rm",
"host_bwrap_detail": "bwrap mounts are owned by the codex binary;"
" host.py passes only a sandboxPolicy type"
" and has no mount knob; /tmp is shared"
" with the host (2026-10-26 host test)",
}
# 任务结果文本上限: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]
# /tmp 固定为 tmpfs,且必须放在所有 -v 挂载之前:父挂载
# 在前、子挂载在后是 Linux 挂载顺序的基本要求——附件
# seed 以 /tmp/codex-seed:ro 挂进容器(见 dispatch_task),
# 它是 /tmp 之下的子挂载,只有 tmpfs 先挂、seed 后挂,
# seed 才不会被后挂的 tmpfs 遮掉。旧顺序把 --tmpfs 放在
# 全部 -v 之后,本 VM 无 docker、未在真实 docker 上证实
# 旧顺序确实遮挡(docker 可能按父子关系重排),所以这里
# 写为顺序加严而非“已证实 bug 修复”;单测只断言顺序,
# 不宣称旧顺序一定失效。
# 容器内 /tmp 绝不落在宿主 /tmp,也不受额外挂载影响
# (/tmp 目标的额外挂载已在解析时拒绝)。
# 诚实边界:这是 Docker 外层能做的;内层 codex 的 bwrap
# workspace-write 仍由 codex 二进制控制,本仓库改不了它
# 的 --proc 挂载,宿主级 bwrap 的 /tmp 不隔离问题也不在
# 本仓库的修复范围内——不要把本改动说成“内层沙箱已修”。
cmd += ["--tmpfs", "/tmp", "--tmpfs", "/var/tmp"]
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,同时代码主体仍只读;seed 的
# /tmp/codex-seed:ro 同样在此后挂载,压在 tmpfs 之上。
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)]
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)。
# 正则收敛到 cloud/sandbox_policy.py(类属性别名保留)。
_TASK_CONTAINER_RE = _POLICY_TASK_RE
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)