Docker sandbox real test + cloud merge status endpoint

- DockerExecutor: extra mounts (host.py work/state, repo, codex-home
  at same paths), worker --docker-extra-mounts, container user
  configurable; Dockerfile copies codex-resources beside the codex
  binary and puts bwrap on PATH (previous /usr/local/lib layout
  made codex report bubblewrap unavailable inside the container).
- Real Docker task on external host: triple added in ~18s, merged
  inside a container, main repo now add+mul2+triple, --rm cleanup.
  Honest boundary: this host/kernel forbids unprivileged user
  namespaces, so bwrap fails on host and in containers; production
  mode here is Docker isolation + inner danger-full-access, not
  double sandboxing.
- Cloud merge status: POST /tasks/{id}/merge syncs status only
  (merge itself stays with host.py merge); completed -> merged,
  maintainer+ (approve_merge), other states and repeat calls 409,
  audit merge_task. Tests: cloud 223, host 27, all passing.
This commit is contained in:
host committed 2026-10-26 02:46:10 +08:00
1 parent 631580beab
commit 07a8eaaa73
9 files changed
+1082 -37

No files matched your search

+76 -5
View File
@@ -48,6 +48,12 @@ interrupted,不用执行结果覆盖它。
可注入:runner(假 CLI,见 sandbox.LocalExecutor)与 env 映射
均可注入,单测不跑真实模型回合、不联网、不登录任何服务。
执行器选择:Worker 默认用 LocalExecutor;传入 DockerExecutor
(或 CLI --executor docker)时,派发走容器内 host.py,取消时
按任务派生的容器名 docker kill,不只改数据库状态;本机无
docker 时 DockerExecutor 在调 runner 前就明确报错,任务标
failed 且 error_text 含 docker 缺失,绝不静默降级为本地执行。
"""
import argparse
@@ -66,7 +72,7 @@ from cloud.db import connect as _db_connect
from cloud.logging_config import get_logger, log_event, setup_logging
from cloud.models import ModelRouter, is_fallbackable, record_fallback
from cloud.queue import budget_exceeded, ready_tasks
from cloud.sandbox import LocalExecutor
from cloud.sandbox import DockerExecutor, LocalExecutor
from cloud.sandbox import _default_runner as _sandbox_default_runner
# 与 server.TERMINAL_STATUSES 对齐:这些状态的任务不再被认领或取消。
@@ -189,6 +195,7 @@ class Worker:
self.process_factory = process_factory
self._running_proc = None
self._running_task_id = None
self._running_container = None
self._loop_stop = False
self.logger = get_logger("cloud.worker")
@@ -419,6 +426,18 @@ class Worker:
pass
self._running_proc = None
self._running_task_id = None
# Docker 取消:按任务派生的容器名真杀,不只改数据库状态。
# 同步路径(runner 阻塞在 docker run 里)下 _running_proc
# 为空,此时靠 _running_container 杀容器;kill 尽力而为,
# 失败也不影响后续的状态标记。
if self._running_task_id == task_id and \
self._running_container is not None and \
hasattr(self.executor, "kill_container"):
try:
self.executor.kill_container(self._running_container)
except Exception:
pass
self._running_container = None
conn = _db_connect(self.db_path)
try:
cur = conn.execute(
@@ -451,6 +470,11 @@ class Worker:
return task is not None and task["status"] == "interrupted"
# -- 缺 key 门 ------------------------------------------------------
def _executor_name(self):
"""执行器名:DockerExecutor -> docker,其它 -> local。"""
if isinstance(self.executor, DockerExecutor):
return "docker"
return "local"
def key_available(self):
"""只查存在性:名在环境里且值非空即 True;值本身绝不返回。"""
try:
@@ -557,7 +581,7 @@ class Worker:
return {"dispatched": True, "task_id": task_id,
"host_name": self.executor._host_name(task)
if hasattr(self.executor, "_host_name") else task_id,
"status": "failed", "executor": "local",
"status": "failed", "executor": self._executor_name(),
"stdout": "", "error": error, "events": [],
"cost_record_id": None}
@@ -565,6 +589,15 @@ class Worker:
fallback_count = 0
result = None
error = None
# Docker 同步路径:dispatch 阻塞在 docker run 里时,取消
# 线程靠 _running_container 按名杀容器(见 cancel())。
if hasattr(self.executor, "container_name"):
try:
self._running_container = \
self.executor.container_name(task)
self._running_task_id = task_id
except Exception:
self._running_container = None
try:
if self._use_popen():
result = self._execute_via_process(task)
@@ -585,6 +618,12 @@ class Worker:
"status": "failed", "stdout": "", "returncode": None,
"host_status": None, "stderr": ""}
error = str(exc)
# 同步路径执行结束:清掉取消用的容器标记(取消路径里
# 若已清过,这里再清一次无害)。
self._running_container = None
if self._running_task_id == task_id and \
self._running_proc is None:
self._running_task_id = None
# 取消优先:执行期间被 cancel 的任务保持 interrupted,
# 绝不用执行结果覆盖它(见模块 docstring 的取消说明)。
@@ -599,7 +638,7 @@ class Worker:
return {"dispatched": True, "task_id": task_id,
"host_name": self.executor._host_name(task)
if hasattr(self.executor, "_host_name") else task_id,
"status": "interrupted", "executor": "local",
"status": "interrupted", "executor": self._executor_name(),
"stdout": (result or {}).get("stdout", ""),
"error": "cancelled", "events": [],
"cost_record_id": None}
@@ -649,7 +688,7 @@ class Worker:
return {"dispatched": True, "task_id": task_id,
"host_name": self.executor._host_name(task)
if hasattr(self.executor, "_host_name") else task_id,
"status": final, "executor": "local",
"status": final, "executor": self._executor_name(),
"stdout": stdout, "error": error, "events": events,
"cost_record_id": cost_id}
@@ -719,6 +758,21 @@ class Worker:
return all_results
def _build_cli_executor(args, host_py):
"""CLI 的执行器构造:local 时返回 None(Worker 默认 LocalExecutor),
docker 时返回 DockerExecutor(env_key 名用 Worker 的解析规则,
值本身绝不从这里传——DockerExecutor 只查存在性,-e 只传名)。"""
if getattr(args, "executor", "local") != "docker":
return None
return DockerExecutor(
host_py, config_path=args.config, image=args.docker_image,
cpus=args.docker_cpus, memory=args.docker_memory,
user=args.docker_user, worktrees_root=args.docker_worktrees_root,
env_key=resolve_env_key(args.env_key, args.codex_config),
timeout=args.docker_timeout,
extra_mounts=args.docker_extra_mounts)
def main(argv=None):
"""worker CLI:默认单次 run_all;--loop 进常驻循环。
@@ -737,6 +791,22 @@ def main(argv=None):
parser.add_argument("--model", default=None)
parser.add_argument("--max-turns", type=int, default=None)
parser.add_argument("--max-tasks", type=int, default=None)
parser.add_argument("--executor", default="local",
choices=["local", "docker"],
help="执行器:local 调 host.py 子进程;docker"
" 调 DockerExecutor(无 docker 时任务"
"明确失败,不静默降级)")
parser.add_argument("--docker-image", default="codex-app-server:latest")
parser.add_argument("--docker-cpus", default="2")
parser.add_argument("--docker-memory", default="2g")
parser.add_argument("--docker-user", default="1000:1000")
parser.add_argument("--docker-worktrees-root", default=None)
parser.add_argument("--docker-timeout", type=float, default=None)
parser.add_argument("--docker-extra-mounts", default=None,
help="docker 额外挂载,逗号分隔 host:container[:ro|rw],"
"用于容器内 host.py 写 work/、host-state.json,"
"以及挂载原仓库和 codex-home(同路径挂载,"
"容器内配置无需改路径)")
parser.add_argument("--loop", action="store_true",
help="常驻循环,每 --interval 秒跑一轮")
parser.add_argument("--interval", type=int, default=300)
@@ -750,7 +820,8 @@ def main(argv=None):
worker = Worker(args.db, host_py, config_path=args.config,
repo=args.repo, env_key=args.env_key,
codex_config_path=args.codex_config, model=args.model,
max_turns=args.max_turns)
max_turns=args.max_turns,
executor=_build_cli_executor(args, host_py))
# CLI 起 worker 前先建表/迁移:全新库直接起循环不应因缺表而炸。
try:
from cloud.server import init_db as _init_db