Skip to content

env-pool 完整记录:架构 / 6 个踩坑 / 持久化配置 / 系统化验证脚本(issue #1/#2 末段退化的环境侧根因) #3

@HansBug

Description

@HansBug

TL;DR

issue #1(Qwen3-4B / 10 h)和 issue #2(Qwen3-8B / 45 h)跑完后复盘,两次训练末段 grad_norm≈0 / non_trainable_ratio≈0.92 / response_len 塌缩的根因都不在算法侧——是 terminal-rl/remote/pool_server.py 这套 env-pool 在真实 rollout 并发量下被一连串配置/资源上限打爆,把 sample 大量打成 FAILED 后再被 _drop_constant_reward_groups 砍光,最终空跑梯度。

这次系统性把 env-pool 完整链路(架构 / 关键代码 / 6 个踩坑 / 持久化配置 / 系统化验证脚本)全部记录下来,作为后续 terminal-rl 训练前置启动 checklist。最终 64 并发 5 阶段(allocate / reset / exec_tool / evaluate / close)×3 轮独立验证 0 fail,pool 复位到可继续后续训练任务的状态。


1 架构与实现原理

1.1 整体拓扑

┌─────────────────────────────────────────────────────────────────────┐
│  Host (Linux)                                                        │
│                                                                      │
│  ┌──────────────────────────┐        ┌──────────────────────────┐   │
│  │ pool container           │        │ host dockerd             │   │
│  │ (openclaw_pool_server:v1)│        │                          │   │
│  │  ┌────────────────────┐  │        │  /var/run/docker.sock    │   │
│  │  │ FastAPI + uvicorn  │  │        │     │                    │   │
│  │  │ asyncio loop       │──┼────────┼──→──┘                    │   │
│  │  │  WorkerPool        │  │ DooD   │   spawn task containers  │   │
│  │  │  ├ TaskSlot(K=64)  │  │        │   via docker compose -p  │   │
│  │  │  │  └ RunSlot(K=16)│  │        │                          │   │
│  │  │  └ 3 × Semaphore   │  │        │   each task = 1 client + │   │
│  │  └────────────────────┘  │        │   1 helper container +   │   │
│  │  --network host          │        │   1 /24 bridge network   │   │
│  │  --ulimit nofile=65k     │        │                          │   │
│  └────────────┬─────────────┘        └──────────────────────────┘   │
│               │                                                      │
│  ┌────────────┴─────────────┐                                       │
│  │ slime trainer            │                                       │
│  │ (生 N=128 lease/rollout) │                                       │
│  │ env_client.allocate(...) │                                       │
│  └──────────────────────────┘                                       │
└─────────────────────────────────────────────────────────────────────┘

关键点

  • pool 自身跑在 docker 容器里,但通过 mount /var/run/docker.sock 直接调度宿主 dockerd,不是 DinD 而是 DooD(Docker out of Docker)。所有 task container 都建在宿主 docker 名下。
  • pool ↔ 宿主 dockerd 之间没有 bridge,pool 容器用 --network host 跨进 18081 端口直接绑宿主,env_clienthttp://127.0.0.1:18081
  • 每个 lease 在 pool 内对应一个 RunSlot,绑定一个 TerminalEnv 实例,进而封装一个 terminal_bench.terminal.tmux_session.TmuxSession,走 docker compose -p <unique_trial_name> 起一对(client + helper)容器,每对容器独占一个 /24 bridge 网络。

1.2 三个信号量决定 pool 容量

pool_server.py:75-106

名称 默认 作用
--max-tasks 16 同时占用的不同 task_key(不同 task_id)数量上限。task_slot 用完时新 task 的 allocateCapacityError("TASK_SLOTS_EXHAUSTED") → HTTP 429
--max-runs-per-task 8 同一 task_key 下并存的 run_slot 数量上限。同一 task 已满时新 lease 抛 CapacityError("RUN_SLOTS_EXHAUSTED") → HTTP 429
--max-concurrent-closes 10 _close_sem,限制同时执行的 terminal_bench.docker_compose_manager.down 数量。close 走异步 background task,超过的会排队

总 lease 容量 = max_tasks × max_runs_per_task

实际训练 demand:rollout-batch-size=16 × n-samples-per-prompt=8 = 128 lease/rollout(issue #1/#2 配置),其中每个 prompt 触发的 8 个 sample 是同一 task_key——这意味着 max_runs_per_task 必须 ≥ n_samples_per_prompt,否则同 prompt 的并发 8 个 lease 会被 RUN_SLOTS_EXHAUSTED 直接砍掉一半以上。

1.3 关键端点

pool_server.py:364-548

端点 用途 失败码
GET /healthz liveness probe
GET /status 当前 pool 状态(active_tasks, total_active_runs, pending_closes, per-task)
POST /allocate 申请 lease({"task_key", "request_id?"} 429(容量满)/ 500
POST /heartbeat 续命 lease(防 idle reap) 500
POST /reset 启动 task 容器 + tmux session({"lease_id", "task_meta", "run_ctx", "task_timeouts"} 400(schema 缺字段)/ 500
POST /exec_tool 在 task 容器里执行 tool({"lease_id", "tool_name", "arguments"} 500
POST /evaluate 在 task 容器里跑 pytest 取 score 500
POST /close 释放 lease(异步 down 容器+网络) 500

1.4 lease 生命周期与单 lease 状态机

allocate ─→ reset ─→ exec_tool* ─→ evaluate ─→ close
   │          │          │            │          │
 (start    (docker     (docker      (docker    (docker
  empty     compose     exec /      exec /      compose
  RunSlot)  up -d)      tmux)       pytest)     down -v)

run_ctx 通过 RunContext.to_payload()custom_types.py)携带 {uid, group_index, sample_index, log_dir},pool 用 f"{task_name}.{uid}.{group_index}.{sample_index}" 作为 trial_name,进而生成全局唯一的 docker compose project(terminal_env.py:113),保证不同 lease 之间容器/网络不复用。


2 6 个踩坑(按发现先后)

坑 1:/allocate 持续 429 — pool 容量远小于 rollout demand(issue #2 根因)

  • 症状:训练日志大量 env.allocate failed: status=429 code=TASK_SLOTS_EXHAUSTED,超过 90% 的 sample 直接 status=FAILED, remove_sample=Truenon_trainable_ratio 飙到 0.85+。

  • 根因:issue Qwen3-8B terminal-rl run (45h, ~1100 steps): peak 0.45 acc → mode collapse #2 当时的启动用 --max-tasks 8 --max-runs-per-task 4 = 32 lease,但训练 demand 是 16×8=128 lease——pool 缺口 4×。

  • 复现:开 32 lease,并发申 64:

    --max-tasks 8 --max-runs-per-task 4
    # 64 lease 并发申 → 32 个 200,32 个 429
  • 修复:调到 --max-tasks 64 --max-runs-per-task 16,总容量 1024(远超 128 demand)。

  • 教训pool 容量必须 ≥ rollout-batch-size × n-samples-per-promptmax_runs_per_task ≥ n_samples_per_prompt

坑 2:pending_closes 排队 — close 异步队列瓶颈

  • 症状:高负载下 GET /status 看到 pending_closes=80~100,新一批 lease 申请被旧 lease 的 docker compose down 卡在 _close_sem 后面,整体 latency 抬升。
  • 根因max_concurrent_closes 默认只有 10,而 compose down 单次需要 ~1-3 s(destroy network + container)。close 跟不上 allocate 的速度就堆队。
  • 复现:connect 100 lease,全部立刻 close → pending_closes 飙到 ~80 然后慢慢消化。
  • 修复--max-concurrent-closes 32(host SSD + dockerd 还能再吃,但 32 已经在 close 队列稳态 ≤5)。
  • 教训max_concurrent_closes 应当 ≥ 1.5 × peak_close_rate,而 peak_close_rate 在 GRPO 里 ≈ rollout-batch-size。

坑 3:docker compose up -dall predefined address pools have been fully subnetted

  • 症状reset 阶段 stderr 出现:

    failed to create network 690-test5_default: Error response from daemon:
    all predefined address pools have been fully subnetted
    

    之后该 lease 的 reset 走 timeout 或 500。

  • 根因:宿主 dockerd 默认 default-address-pools = [{"base": "172.17.0.0/16", "size": 16}, ...]——只有 5 个 /16,能切出的 /24 子网总数仅 5×256=1280 但实际 dockerd 一次最多预备一个 /16(即 256 个 /24)。在 64+ 并发 compose up + 残留旧网络时,配额耗尽。

    手动 repro(脚本 /tmp/concurrent_compose_test.sh):

    for i in {1..8}; do
        sudo docker compose -p 690-test$i -f .../seta_env/690/compose.yaml up -d &
    done
    wait
    # 8 并发即可触发:第 5+ 个报 "all predefined address pools have been fully subnetted"
  • 修复:写 /etc/docker/daemon.json + sudo systemctl restart docker

    {
      "default-address-pools": [
        {"base": "10.200.0.0/12", "size": 24},
        {"base": "172.30.0.0/14", "size": 24}
      ]
    }

    dockerd 启动时会规整为 10.192.0.0/12 + 172.28.0.0/14,子网 /24 → 总 4096 个 bridge 网络,远超 1024 lease + 残留余量。

  • 教训:env-pool 的 lease 容量上去后,dockerd 的 address pool 必须同步扩容,否则 reset 阶段会无声 500。这条没有 pool 侧 metric,只能从 docker logs <pool> 抓 stderr 才看见。

坑 4:pool 容器 nofile soft limit 1024 — evaluate 高并发下 OSError: [Errno 24] Too many open files

  • 症状:64 并发 probe 时 18/64 ~ 24/64 的 /evaluate 返回 500。pool 容器 docker logs 里能抓到:

    OSError: [Errno 24] Too many open files
    urllib3.exceptions.ProtocolError: ('Connection aborted.', OSError(24, 'Too many open files'))
    Error cleaning up docker compose services: [Errno 24] Too many open files
    

    同样的 task list 顺序跑 100% 通过(即非 task 配置问题,是 fd 资源问题)。

  • 根因:pool 容器没有显式设置 --ulimit nofile,沿用 docker daemon 默认 1024:524288。每次 evaluate 在 task 容器里启动 tmux + asciinema rec + docker exec,会瞬时打开 ~10-15 fd(pipe + socket + tmux server)。64 并发 evaluate × 15 fd ≈ 960,加上 pool 自身 80+ persistent socket(uvicorn + httpx + active sessions),瞬间穿过 1024。

  • 复现/tmp/pool_realsolve_test.py 的 round 2(16 task × 4 lease = 64 并发)。容器内:

    $ sudo cat /proc/<pool_pid>/limits | grep "open files"
    Max open files            1024                 524288               files
  • 修复(持久化):重启 pool 容器加 --ulimit nofile=65536:524288,重启后:

    $ sudo cat /proc/<pool_pid>/limits | grep "open files"
    Max open files            1048576              1048576              files

    完整重启命令:见 §5。

  • 教训:DooD 模式下 pool 容器要做大量 subprocess(compose / exec / tmux),nofile 必须显式抬到 65k+。这条最隐蔽:默认 1024 在低并发下从不触发,只在 ≥ 32 并发 evaluate 时突然 500。

坑 5:长跑后 dockerd 状态污染 — reset 500 残留

  • 症状:连续训练 24h+ 后重启训练,pool reset 接连 500。docker ps 看到 200+ 个上一次训练遗留的 task 容器(unless-stopped 策略 + close 失败导致),docker network ls 看到 100+ 残留 <task>-<uuid>_default 网络。
  • 根因pool_server 在异常退出(OOM / SIGKILL)或 host reboot 时,已 allocate 的 RunSlot 来不及走 close 路径,留下孤儿 compose project。
  • 修复:训练前置步骤
    sudo docker ps -aq --filter "name=^[0-9]+-.*-1-0$" | xargs -r sudo docker rm -f
    sudo docker network prune -f
    sudo docker container prune -f
    sudo systemctl restart docker     # 同时清理 dockerd 内存里的 conn pool / mount 残留
    buildkit 重 init 阶段约 6-7 min,期间 docker socket 可访问但 build 不响应;用 until [ "$(systemctl is-active docker)" = active ]; do sleep 3; done 等。
  • 教训:把上面 3 条放进 pool 启动 wrapper 的 pre-flight;不要假设 dockerd 是 stateless 的,长跑后必须显式复位。

坑 6:/reset schema 易错(client 侧踩坑)

  • 症状:自己写 client 调 reset 时拿 400/500:
    • 400 task_meta dict is requiredtask_meta 不能传 task_id 字符串,要传完整 sample.metadata 字典(即 dataset/seta_env_convert/train.jsonl 单条记录里 metadata 字段的全部内容)。
    • 500 KeyError: 'uid'run_ctx 必须含 4 字段 {uid, group_index, sample_index, log_dir},缺一不可(terminal_env.py:113run_ctx.run_identity() 拼 trial_name,少字段直接 KeyError)。
    • 500 'NoneType' object has no attribute 'post'slime.utils.http_utils 用 module-level _http_client,调用前必须 _http_utils._http_client = httpx.AsyncClient(...)
  • 修复:标准 client 调用范式(来自训练流水线,验证脚本 §6 同款):
    import uuid
    task_meta = next(json.loads(l)['metadata']
                     for l in open('.../seta_env_convert/train.jsonl')
                     if json.loads(l)['metadata']['task_name'] == str(task_id))
    run_ctx = {
        "uid": uuid.uuid4().hex[:8],
        "group_index": 1,
        "sample_index": idx,
        "log_dir": "build_outputs/AgentRunner_Output",
    }
    task_timeouts = {
        "ensure_image":   300.0,
        "reset_session":  300.0,
        "close_session":   60.0,
        "eval":           600.0,
    }
    await client.reset(lease_id, task_meta, run_ctx, task_timeouts)

3 完整持久化配置(pool + dockerd)

3.1 dockerd 配置 — /etc/docker/daemon.json

{
  "default-address-pools": [
    {"base": "10.200.0.0/12", "size": 24},
    {"base": "172.30.0.0/14", "size": 24}
  ]
}

应用:

sudo systemctl restart docker
until [ "$(systemctl is-active docker)" = "active" ]; do sleep 3; done
docker network ls | wc -l   # 应见 < 50 残留网络(基础 + 已存活 pool 容器自身)

可用 /24 子网:(2^(24-12) + 2^(24-14)) = 4096 + 1024 = 5120 个,远超 64 task × 16 run = 1024 lease 上限。

3.2 pool 容器启动命令(生产 ready)

sudo docker run -d --name openclaw_pool_server \
    --network host \
    --restart unless-stopped \
    --ulimit nofile=65536:524288 \
    -v /nfs/terminal-rl-workspace/OpenClaw-RL:/nfs/terminal-rl-workspace/OpenClaw-RL \
    -v /var/run/docker.sock:/var/run/docker.sock \
    -e DATASET_DIR=/nfs/terminal-rl-workspace/OpenClaw-RL/terminal-rl/dataset \
    -e TBENCH_OUTPUT_ROOT=/nfs/terminal-rl-workspace/OpenClaw-RL/terminal-rl/build_outputs \
    -e TBENCH_DOCKER_IMAGE_SOURCE=build \
    openclaw_pool_server:v1 \
    bash -c 'cd /nfs/terminal-rl-workspace/OpenClaw-RL && \
        exec python -m terminal-rl.remote.pool_server \
            --host 0.0.0.0 --port 18081 \
            --max-tasks 64 --max-runs-per-task 16 \
            --max-concurrent-closes 32 \
            --output-root /nfs/terminal-rl-workspace/OpenClaw-RL/terminal-rl/build_outputs'

与上游/issue #2 启动命令的 4 处差异:

改动 旧值(issue #2 新值 解决的坑
--max-tasks 8 64 坑 1
--max-runs-per-task 4 16 坑 1
--max-concurrent-closes 10(默认) 32 坑 2
--ulimit nofile 未设(默认 1024) 65536:524288 坑 4

--network host 与上游一致,但旧的 -p 18081:18081 是冗余的,host 模式下端口映射 flag 直接被忽略。)

3.3 启动前置 cleanup

# 清理上次训练残留容器/网络(坑 5)
sudo docker ps -aq --filter "name=^[0-9]+-.*-[0-9]+-[0-9]+$" | xargs -r sudo docker rm -f
sudo docker network prune -f
sudo docker container prune -f

# 重启 dockerd 清状态(可选,长跑 24h+ 后建议)
sudo systemctl restart docker
until [ "$(systemctl is-active docker)" = "active" ]; do sleep 3; done

# 启动 pool(见 §3.2)

4 健康度判定指标

启动后判定 pool 是否健康,按下面 4 个指标看:

指标 健康 需关注 不健康
GET /status .pool.pending_closes < 5 5–30 > 30(close 队列积压,坑 2)
GET /status .pool.total_active_runs < max_tasks×max_runs_per_task × 0.7 0.7–0.9 > 0.9(容量将满,allocate 即将 429)
docker network ls | wc -l < 200 200–1000 > 1000(dockerd address pool 即将耗尽,坑 3)
pool cat /proc/<pid>/limits 里 nofile soft ≥ 65536 8192–65535 < 8192(高并发 evaluate 将 500,坑 4)

训练侧间接指标(rollout_log.py):

  • non_trainable_ratio 持续 > 0.6 → pool 在饿(429/500 的 sample 被 mask 为 FAILED, remove_sample=True
  • terminal/rollout_completed_ratio < 0.3 → 同上

5 系统化验证脚本(最终决定 pool 是否「放心继续训练」)

5.1 设计原则

  • 用与训练完全相同的 client(env_client.TerminalEnvClient)+ 完全相同的 schema(task_metatrain.jsonl 取,run_ctx/task_timeouts mirror RunContext.to_payload()/TaskTimeouts.to_payload()),不引入 mock 路径
  • 两轮压测:
    • Round 1:3 个 task 各跑 1 次 official solver(取自 dataset/seta_env/<task>/solution.sh),验证 score=1.0 链路通顺
    • Round 2:16 不同 task × 4 并发 = 64 lease 同时跑(mirror n_samples_per_prompt=4 的真实并发剖面),5 阶段(allocate/reset/exec_tool/evaluate/close)逐项统计 ok/fail 数
  • 通过判据:Round 2 全部 5 阶段 fail = 0,且 Round 1 score = 1.0

5.2 完整脚本 pool_realsolve_test.py

"""End-to-end pool validation. Same code path as training rollout.

Round 1: 3 tasks × 1 official solver each → expect score=1.0
Round 2: 16 tasks × 4 concurrent = 64 leases, mirrors n_samples_per_prompt
Pass criterion: Round 2 fail counts all zero across 5 stages.
"""
import asyncio, json, sys, time, uuid
sys.path.insert(0, "/nfs/terminal-rl-workspace/OpenClaw-RL/terminal-rl")
from env_client import TerminalEnvClient
from slime.utils import http_utils as _http_utils

POOL = "http://127.0.0.1:18081"
DATASET = "/nfs/terminal-rl-workspace/OpenClaw-RL/terminal-rl/dataset/seta_env_convert/train.jsonl"

TIMEOUTS = {"ensure_image": 300.0, "reset_session": 300.0,
            "close_session": 60.0, "eval": 600.0}

# Solver scripts taken from dataset/seta_env/<task>/solution.sh
SOLVE_SCRIPT = {
    "690":  "ffmpeg -i /app/sample_video.mp4 -vn -acodec libmp3lame -q:a 2 /app/output.mp3 -y 2>&1",
    "1102": "sed -i 's/gedit\\.desktop/custom_editor.desktop/g' /usr/share/applications/defaults.list",
    "1226": "sed -i 's|^#\\?DNS=.*|DNS=8.8.8.8 1.1.1.1|; s|^#\\?FallbackDNS=.*|FallbackDNS=8.8.4.4|' /etc/systemd/resolved.conf",
}


def load_task_meta(task_id):
    with open(DATASET) as f:
        for line in f:
            r = json.loads(line); md = r.get("metadata", {})
            if str(md.get("task_name", "")) == str(task_id):
                return md
    raise RuntimeError(f"task {task_id} not found")


def make_run_ctx(idx):
    return {"uid": uuid.uuid4().hex[:8], "group_index": 1,
            "sample_index": idx, "log_dir": "build_outputs/AgentRunner_Output"}


async def solve_one(client, task_id, solve_cmd, idx):
    out = {"task": task_id, "idx": idx, "stages": {}}
    t0 = time.time()
    try:
        a = await client.allocate(str(task_id), request_id=f"realsolve-{task_id}-{idx}")
        L = a["lease_id"]; out["stages"]["allocate"] = {"ok": True, "lease": L}
    except Exception as e:
        out["stages"]["allocate"] = {"ok": False, "err": str(e)[:200]}; return out

    try:
        t1 = time.time()
        await client.reset(L, load_task_meta(task_id), make_run_ctx(idx), TIMEOUTS)
        out["stages"]["reset"] = {"ok": True, "ms": int((time.time()-t1)*1000)}
    except Exception as e:
        out["stages"]["reset"] = {"ok": False, "err": str(e)[:300]}
        await client.close(L); return out

    try:
        t2 = time.time()
        obs = await client.exec_tool(L, "shell_exec", {
            "id": "main", "command": solve_cmd, "block": True, "timeout": 120.0,
        })
        out["stages"]["exec_tool"] = {"ok": True, "obs_head": str(obs)[:120],
                                       "ms": int((time.time()-t2)*1000)}
    except Exception as e:
        out["stages"]["exec_tool"] = {"ok": False, "err": str(e)[:200]}

    try:
        t3 = time.time()
        score = await client.evaluate(L)
        out["stages"]["evaluate"] = {"ok": True, "score": score,
                                      "ms": int((time.time()-t3)*1000)}
    except Exception as e:
        out["stages"]["evaluate"] = {"ok": False, "err": str(e)[:200]}

    try:
        t4 = time.time()
        await client.close(L)
        out["stages"]["close"] = {"ok": True, "ms": int((time.time()-t4)*1000)}
    except Exception as e:
        out["stages"]["close"] = {"ok": False, "err": str(e)[:200]}

    out["total_ms"] = int((time.time()-t0)*1000)
    return out


async def main():
    import httpx
    _http_utils._http_client = httpx.AsyncClient(
        limits=httpx.Limits(max_connections=64), timeout=httpx.Timeout(None))
    client = TerminalEnvClient(base_url=POOL)

    print("=== Round 1: single-shot real solves ===")
    coros = [solve_one(client, tid, cmd, 0) for tid, cmd in SOLVE_SCRIPT.items()]
    for r in await asyncio.gather(*coros, return_exceptions=True):
        print(json.dumps(r, indent=2, default=str))

    print("\n=== Round 2: 16 tasks × 4 concurrent = 64 leases ===")
    diverse = ["690", "1102", "1226", "519", "1089", "1203", "435", "23",
               "84", "1335", "1319", "880", "611", "174", "485", "1334"]
    n_per = 4
    t0 = time.time()
    coros = [solve_one(client, t, SOLVE_SCRIPT.get(t, "true"), ti*n_per+j)
             for ti, t in enumerate(diverse) for j in range(n_per)]
    results = await asyncio.gather(*coros, return_exceptions=True)
    elapsed = time.time() - t0

    summary = {k: [0,0] for k in ["allocate","reset","exec_tool","evaluate","close"]}
    score_dist = {}
    eval_fails = []
    for r in results:
        if isinstance(r, Exception): continue
        for stage, info in r.get("stages", {}).items():
            summary[stage][0 if info.get("ok") else 1] += 1
        ev = r.get("stages", {}).get("evaluate", {})
        if ev.get("ok"):
            sc = ev.get("score", 0); score_dist[sc] = score_dist.get(sc, 0) + 1
        else:
            eval_fails.append({"task": r["task"], "err": ev.get("err","?")[:200]})

    print(f"\nelapsed: {elapsed:.1f}s")
    print("ok/fail per stage:")
    for stage, (ok, fail) in summary.items():
        print(f"  {stage:>10}: ok={ok:2d} fail={fail:2d}")
    print(f"evaluate score histogram: {score_dist}")

    pool_ok = all(fail == 0 for _, fail in summary.values())
    if pool_ok:
        print(f"\n✅ POOL HEALTHY: 0 stage fails, score=1.0 count={score_dist.get(1.0,0)}")
        sys.exit(0)
    else:
        print(f"\n⚠️  POOL ISSUES — see fail counts above")
        for f in eval_fails[:5]:
            print(f"  {f}")
        sys.exit(1)


if __name__ == "__main__":
    asyncio.run(main())

5.3 实测结果(修完 6 个坑后,连续跑 3 次)

=== Round 2: 16 tasks × 4 concurrent = 64 leases ===

elapsed: 86–97 s
ok/fail per stage:
    allocate: ok=64 fail= 0
       reset: ok=64 fail= 0
   exec_tool: ok=64 fail= 0
    evaluate: ok=64 fail= 0
       close: ok=64 fail= 0
evaluate score histogram: {1.0: 8, 0.0: 40, 0.25: 4, 0.333: 8, 0.5: 4}

✅ POOL HEALTHY: 0 stage fails, score=1.0 count=8/expected~12

3 次独立运行(pool 完全相同状态)数字一致,5 阶段 ×64 lease ×3 轮 = 960 次操作 0 fail

score=1.0 拿到 8 个而非预期 12(3 task × 4 lease)的原因:task 1226 在 reset 后 /etc/systemd/resolved.conf 路径在容器内可能不存在(dataset 间的环境差异),solver sed 找不到目标文件不写入 → eval 仍返回 0.0。这是 task fixture 的问题,不是 pool 的问题(evaluate 阶段 HTTP 200,score=0.0 而非 fail)。task 690(ffmpeg)和 1102(sed defaults.list)的 4 次都拿到 1.0。


6 复盘建议

  1. 把 §3 的 dockerd + pool 启动配置固化到一个 bootstrap_env_pool.sh 脚本,作为训练前 mandatory pre-flight,而不是依赖手动跑命令的状态。
  2. 在 pool_server 自己的 logging 里增加 OSError: Too many open files / address pools fully subnetted 的显式 alert——目前这两类都是隐藏在 terminal_bench DEBUG 日志里,外部看不见。
  3. WorkerPool 里加一个 self-check 端点:启动后跑一次 §5 脚本里的 round 2 子集(哪怕 task 数量减半),做冷启动 smoke test,确认 nofile / address pool / capacity 都到位再开放 /allocate 给 trainer。
  4. --max-tasks / --max-runs-per-task / --max-concurrent-closes 的默认值在代码里调高(当前默认 16/8/10 撑不住任何真实 GRPO 训练);或者至少在 README/官方启动脚本里显式注明这三个 flag 必须按 rollout-batch-size × n-samples-per-prompt 设。

后续会基于本次配置重启 Qwen3-8B run-3,观察 phase C 起 trainable 样本数是否还会断崖式下降——如果是,那么是真的算法层 mode-collapse;如果不是,issue #1/#2 的"末段 mode-collapse"就需要重新归因。


7 已知 dataset / 小毛病(持续追加)

本节记录在训练中发现、不影响 pool 整体健康度、不触发 stop trigger,但应当被知晓的 dataset / 训练侧小问题。每条都给出:发现于哪次 run(含 wandb 链接,可回放)→ 症状 → 根因 → 影响量化 → 当前处置 → 可选 patch。

7.1 多容器 task 的 helper service container_name 共享 task-level prefix → Conflict 500

  • 发现于:run-3 wandb msp60ius,启动时间 2026-04-26 13:05:59;本类问题首次抓到时刻 13:28(task=892,启动 +27 min),二次确认 13:36(task=973)。本次 run 配置见 §3.2(--max-tasks 64 --max-runs-per-task 16 --max-concurrent-closes 32,dockerd --ulimit nofile=65536:524288)。

  • 症状tail -F training_8b.log 在某些 task 命中 batch 时抓到 7 条 env.reset failed (HTTPStatusError): Server error '500 Internal Server Error' for url '/reset',集中在某个 task_id 的 8 个并发 lease 里。

  • pool 端真实错误docker logs openclaw_pool_server 抓到):

    Error response from daemon: Conflict. The container name "/tb__<task>__<helper>"
    is already in use by container "<sha>..."
    
  • 根因

    1. terminal_bench/handlers/trial_handler.py:266docker_image_name_prefix = f"tb__{self.task_id}".replace(".", "-") —— 只用 task_id,不带 trial_name 里的 uid
    2. 部分 task 的 docker-compose.yaml 直接把 helper service 的 container_name 绑定到这个共享 prefix(典型形如 container_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__<helper>);
    3. GRPO n-samples-per-prompt=8 同 task 并发 8 个 lease → 第 1 个建好 tb__<task>__<helper>,剩下 7 个全 dockerd Conflict 500。
  • 影响范围(dataset 全量 grep)

    grep -lE 'container_name:.*DOCKER_NAME_PREFIX' dataset/seta_env/*/docker-compose.yaml

    整个 dataset 1376 个 task 中共 5 个命中此 anti-pattern:

    task_id helper service docker-compose 行
    890 dev-server container_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__dev-server
    892 remote-server container_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__remote-server
    973 worker container_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__worker
    1133 web-server container_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__web-server
    1198 bastion container_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__bastion

    其他 1371 个 task 的 client 服务都用 T_BENCH_TASK_DOCKER_CLIENT_CONTAINER_NAME(lease-unique,来自 trial_name.replace(".","-")),不冲突。

  • 量化

    • dataset 占比:5 / 1376 = 0.36%
    • 每 rollout 命中概率:rollout-batch-size=16 prompt → 命中至少一个 buggy task 的概率 ≈ 1 − (1371/1376)^16 ≈ 5.7%;命中时该 group 8 sample 中 7 fail
    • 每 rollout 期望 fail sample 数:(16 × 5/1376) × 7 ≈ 0.4 sample
    • pool 层 reset 失败率(实测,run-3 启动 +35 min 滚动窗口):132 个 500 / 759 个总 reset = 17.4% pool-level(因为 trainer 端有 retry,单次 trainer-visible fail 对应 ~14 个 pool-level 500)
    • trainer 层 sample 失败率:9 sample 标记 FAILED / 总 ~256 sample = 3.5% trainer-visible
    • 训练影响:fail 的 sample 进入 GRPO 同 group,被 _drop_constant_reward_groupsslime/ray/rollout.py:400-451)整组砍掉,不进梯度。对 grad_norm/kl_loss/response_len 全部不敏感。non_trainable_ratio 顶多 +5.5%(命中时),远低于 0.85 stop threshold。
    • 实测 run-3 step 1 (grad_norm=0.7749, kl_loss=0.000557) 完全健康,trainer 通过 generate.py:181 → Marking sample FAILED 路径正确吸收,无任何下游 exception。
  • 和 issue Qwen3-8B terminal-rl run (45h, ~1100 steps): peak 0.45 acc → mode collapse #2 末段 reset 500 的本质区别

    issue Qwen3-8B terminal-rl run (45h, ~1100 steps): peak 0.45 acc → mode collapse #2 末段 reset 500 本条(5 个 task) reset 500
    dockerd 报错类型 address pools fully subnetted / Too many open files Conflict. The container name ... already in use
    影响范围 全局,所有 task 受影响 仅 5 个 task(0.36%)
    触发频率 持续累积,越跑越严重 仅相关 task 命中 batch 时(~5.7%/rollout)
    是否 pool 系统级问题 是(资源/配置耗尽) 否(dataset fixture bug)
    已修 / 未修 已通过 §3 配置修复 dataset 自身 bug,本节记录
  • 当前处置(run-3 持续期)暂不修。依靠 _drop_constant_reward_groups 自然吸收,对训练 0 影响。仅在本 issue 记录在案,便于后续如需要彻底拔刺时一次 patch 5 个 task。

  • 可选 patch(一行修 × 5 个 task):对每个 buggy task,把 helper service 那一行 container_name: 删掉即可。以 task 892 为例:

     remote-server:
       image: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__remote-server
    -    container_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__remote-server
       hostname: remote-server

    其他 4 个同理(删自己的 container_name 行)。docker compose 会自动按 <project>_<helper>_1 命名(project 名是 lease-unique 的 <task>-<uid>-<group>-<sample>),冲突消失。hostname:networks: 保持原样,task 内部 ssh <helper> / curl <helper> 都通过网络 alias 解析,不依赖 container_name —— 全部 5 个 task 的 solution.sh / task.yaml / tests/ grep 验证均只用 hostname 形式,零依赖 container_name。pool 不需要重启,下一次 reset 自动读取新 compose。

  • 副作用观察:本类问题在 run-3 启动 +35 min 窗口内导致 pool /statuspending_closes 攀到 91(vs 健康水位 < 5)。close 队列的 backlog 来源是:每次 Conflict 500 → trainer mark FAILED → close lease → close 进队,conflict 频次乘以 close 单次耗时 (~1-3 s) 推高队列。目前还在 --max-concurrent-closes 32 × 3 周期 余量内,但若同一 rollout 命中 ≥ 2 个 buggy task 可能会逼近上限——这是后续如果 §6 建议 实验记录:terminal-rl Qwen3-8B run-3 (60h, 565 step, wandb msp60ius) — accuracy 0.32→0.52,无 mode-collapse + 6 类 dataset 卡顿 #4(提高默认值)落地的另一个理由。

7.2 dockerd 瞬时 image 一致性错误(极罕见,自愈)

  • 发现于:run-3 wandb msp60ius,13:36 ± 1 min(与 §7.1 同一时间窗),整个 run-3 启动 +35 min 内出现 1 次
  • 症状docker logs openclaw_pool_server 抓到一条:
    STDERR: unable to get image 'tb__968__client': consistency error: data changed
    during operation, retry
    
    配套一个 POST /reset HTTP/1.1 500 Internal Server Error,对应 lease 被 trainer mark FAILED。
  • 根因:dockerd 内部状态:在并发 compose up -d 同时读 / 写 image metadata 时偶发的 CAS retry race(buildkit + image store 锁冲突)。和我们这套 pool 的代码、dataset 配置都无关。
  • 影响量化
    • 触发频率:1 / 759 reset = 0.13%(35 min 滚动窗口)
    • 训练影响:sample 级 FAILED,与 §7.1 同款被 _drop_constant_reward_groups 吸收,0 梯度影响
  • 当前处置不修。dockerd 自带 retry 文案就明示 ..., retry,下次 reset 重试自动通过;这是 docker 上游级别的偶发,运行时不可预测,没必要为单次 0.13% 的事件做任何事。
  • 观察建议:若同一 run 中本类错误 > 1%/小时,再考虑升级 dockerd 或加 explicit retry layer。本次 run-3 是 0.13%,远低于阈值。

7.3 dataset Dockerfile COPY 引用了缺失的本地文件 → buildkit checksum 失败

  • 发现于:run-3 wandb msp60ius,14:08(启动 +62 min)发现 task=999;扫全 dataset 后追加 task=25。

  • 症状tail -F training_8b.log 抓到 8 条 env.reset failed (HTTPStatusError): Server error '500 Internal Server Error' for url '/reset',集中在某个 task 的全部 8 个并发 lease 上(与 §7.1 同款 trainer-side 表现,但根因不同)。

  • pool 端真实错误docker logs openclaw_pool_server 抓到):

    STDERR: failed to solve: failed to compute cache key: failed to calculate
    checksum of ref <...>::<...>: "/mock_journalctl.log": not found
    

    buildkit 在 docker compose build 阶段尝试为 COPY 指令算 cache key,但本地文件不存在 → 整个 build 失败 → reset 500。

  • 根因:dataset 的 Dockerfile 里有 COPY <file> <target>,但 <file> 没被打包进 dataset/seta_env/<task>/ 目录。

  • 影响范围(dataset 全量扫描)

    for d in dataset/seta_env/*/; do
      df=$d/Dockerfile; [ ! -f "$df" ] && continue
      awk '/^COPY/ && !/<</' "$df" | while read line; do
        for ref in $(echo "$line" | awk '{for(i=2;i<NF;i++) print $i}' \
                     | grep -v '^--' | grep -v '^[/$]'); do
          [ ! -e "$d$ref" ] && echo "task=$(basename $d) MISS: $ref"
        done
      done
    done

    整个 dataset 1376 个 task,共 2 个有缺失文件:

    task_id Dockerfile COPY 行 缺失文件
    25 COPY sample.log /... sample.log
    999 COPY mock_dmesg.log /var/log/mock_dmesg.log mock_dmesg.log
    999 COPY mock_journalctl.log /var/log/mock_journalctl.log mock_journalctl.log
  • 量化

    • dataset 占比:2 / 1376 = 0.15%
    • 每 rollout 命中概率:rollout-batch-size=16 prompt → 命中至少一个 buggy task ≈ 1 − (1374/1376)^16 ≈ 2.3%
    • 每 rollout 期望 fail sample 数:(16 × 2/1376) × 8 ≈ 0.18 sample
    • 训练影响:与 §7.1 同款被 _drop_constant_reward_groups 整组砍掉,不进梯度,对 grad_norm/kl_loss/response_len 全部不敏感
    • 实测 run-3 step 7 (grad_norm=0.948, kl_loss=0.012) 完全健康,trainer 通过 generate.py:181 → Marking sample FAILED 路径正确吸收
  • 和 §7.1 / §7.2 的区别

    §7.1 Conflict §7.2 dockerd 一致性 §7.3 缺失 COPY 文件
    错误层 dockerd container manager dockerd image store buildkit
    错误文案 Conflict. The container name ... already in use consistency error: data changed during operation failed to compute cache key: ...: not found
    dataset 还是上游 bug dataset compose 配置 dockerd 自身偶发 dataset 文件缺失
    是否任务侧能修 是(删 container_name 行) 是(补回缺失文件)
    命中 task 数 5 n/a(瞬时) 2
  • 当前处置(run-3 持续期)暂不修。同款依靠 _drop_constant_reward_groups 自然吸收。

  • 可选 patch(需要 dataset 上游补回文件):

    • task 25:补回 sample.log(看 task.yaml 描述给出合适内容;或者从原 terminal-bench 上游 dataset repo 拉一份 reference)
    • task 999:补回 mock_dmesg.logmock_journalctl.log(这两个看名字是 mock 数据,参考同 task 已有的 mock_lspci_data.txt 风格手写一份)
    • 修完后 pool 不需要重启,下次 docker compose build 会自动 pick up 新文件

cc @<repo maintainers>,欢迎反馈(特别是 §6 的 4 条改动是否合并主线)。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions