"""沙箱执行器:可插拔的任务执行接口。 零第三方依赖,只用 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.marker_tools import extract_task_cancels as _extract_task_cancels from cloud.marker_tools import extract_task_creates as _extract_task_creates 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_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 = [] 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")) 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, "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 --task [--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 --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 --task [--verify <验收命令>] [--max-rounds N] [--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 。不传 --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 任务名只保留安全字符, 同一任务每次算出同一名字,取消时才能按名杀容器。 - 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): 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 {}) @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 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/ (未传 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 ] [--runtime ] [--network <网络>] -v :/work -v <代码目录>:/app:ro -w /work [-e ] [-e ...] <镜像> <容器内命令...>。 -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"] 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)] 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/ 指代它—— 调用方须保证配置文件就在代码目录里(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 = [] 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")) # 任务列表显示 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, "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)