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_client 走 http://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 的 allocate 抛 CapacityError("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=True,non_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-prompt 且 max_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 -d 报 all 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 required — task_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:113 用 run_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_meta 从 train.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"\n elapsed: { 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 复盘建议
把 §3 的 dockerd + pool 启动配置固化到一个 bootstrap_env_pool.sh 脚本 ,作为训练前 mandatory pre-flight,而不是依赖手动跑命令的状态。
在 pool_server 自己的 logging 里增加 OSError: Too many open files / address pools fully subnetted 的显式 alert ——目前这两类都是隐藏在 terminal_bench DEBUG 日志里,外部看不见。
在 WorkerPool 里加一个 self-check 端点 :启动后跑一次 §5 脚本里的 round 2 子集(哪怕 task 数量减半),做冷启动 smoke test ,确认 nofile / address pool / capacity 都到位再开放 /allocate 给 trainer。
把 --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>..."
根因 :
terminal_bench/handlers/trial_handler.py:266 的 docker_image_name_prefix = f"tb__{self.task_id}".replace(".", "-") —— 只用 task_id,不带 trial_name 里的 uid ;
部分 task 的 docker-compose.yaml 直接把 helper service 的 container_name 绑定到这个共享 prefix(典型形如 container_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__<helper>);
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_groups(slime/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 /status 的 pending_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.log 和 mock_journalctl.log(这两个看名字是 mock 数据,参考同 task 已有的 mock_lspci_data.txt 风格手写一份)
修完后 pool 不需要重启,下次 docker compose build 会自动 pick up 新文件
cc @<repo maintainers>,欢迎反馈(特别是 §6 的 4 条改动是否合并主线)。
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 复位到可继续后续训练任务的状态。terminal-rl/remote/pool_server.py、terminal-rl/remote/terminal_env.py、terminal-rl/env_client.py1 架构与实现原理
1.1 整体拓扑
关键点:
/var/run/docker.sock直接调度宿主 dockerd,不是 DinD 而是 DooD(Docker out of Docker)。所有 task container 都建在宿主 docker 名下。--network host跨进 18081 端口直接绑宿主,env_client走http://127.0.0.1:18081。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-tasksallocate抛CapacityError("TASK_SLOTS_EXHAUSTED")→ HTTP 429--max-runs-per-taskCapacityError("RUN_SLOTS_EXHAUSTED")→ HTTP 429--max-concurrent-closes_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 /healthzGET /statusPOST /allocate{"task_key", "request_id?"})POST /heartbeatPOST /reset{"lease_id", "task_meta", "run_ctx", "task_timeouts"})POST /exec_tool{"lease_id", "tool_name", "arguments"})POST /evaluatePOST /close1.4 lease 生命周期与单 lease 状态机
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=True,non_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-prompt且max_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 的速度就堆队。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 -d报all predefined address pools have been fully subnetted症状:
reset阶段 stderr 出现:之后该 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):修复:写
/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 容器
nofilesoft limit 1024 — evaluate 高并发下OSError: [Errno 24] Too many open files症状:64 并发 probe 时 18/64 ~ 24/64 的
/evaluate返回 500。pool 容器docker logs里能抓到:同样的 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 并发)。容器内:修复(持久化):重启 pool 容器加
--ulimit nofile=65536:524288,重启后:完整重启命令:见 §5。
教训:DooD 模式下 pool 容器要做大量 subprocess(compose / exec / tmux),
nofile必须显式抬到 65k+。这条最隐蔽:默认 1024 在低并发下从不触发,只在 ≥ 32 并发 evaluate 时突然 500。坑 5:长跑后 dockerd 状态污染 — 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。until [ "$(systemctl is-active docker)" = active ]; do sleep 3; done等。坑 6:
/resetschema 易错(client 侧踩坑)task_meta dict is required—task_meta不能传 task_id 字符串,要传完整sample.metadata字典(即dataset/seta_env_convert/train.jsonl单条记录里metadata字段的全部内容)。KeyError: 'uid'—run_ctx必须含 4 字段{uid, group_index, sample_index, log_dir},缺一不可(terminal_env.py:113用run_ctx.run_identity()拼 trial_name,少字段直接 KeyError)。'NoneType' object has no attribute 'post'—slime.utils.http_utils用 module-level_http_client,调用前必须_http_utils._http_client = httpx.AsyncClient(...)。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} ] }应用:
可用 /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 处差异:
--max-tasks--max-runs-per-task--max-concurrent-closes--ulimit nofile(
--network host与上游一致,但旧的-p 18081:18081是冗余的,host 模式下端口映射 flag 直接被忽略。)3.3 启动前置 cleanup
4 健康度判定指标
启动后判定 pool 是否健康,按下面 4 个指标看:
GET /status .pool.pending_closesGET /status .pool.total_active_runsmax_tasks×max_runs_per_task × 0.7docker network ls | wc -lcat /proc/<pid>/limits里 nofile soft训练侧间接指标(
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 设计原则
env_client.TerminalEnvClient)+ 完全相同的 schema(task_meta从train.jsonl取,run_ctx/task_timeoutsmirrorRunContext.to_payload()/TaskTimeouts.to_payload()),不引入 mock 路径dataset/seta_env/<task>/solution.sh),验证 score=1.0 链路通顺n_samples_per_prompt=4的真实并发剖面),5 阶段(allocate/reset/exec_tool/evaluate/close)逐项统计 ok/fail 数5.2 完整脚本
pool_realsolve_test.py5.3 实测结果(修完 6 个坑后,连续跑 3 次)
3 次独立运行(pool 完全相同状态)数字一致,5 阶段 ×64 lease ×3 轮 = 960 次操作 0 fail。
6 复盘建议
bootstrap_env_pool.sh脚本,作为训练前 mandatory pre-flight,而不是依赖手动跑命令的状态。OSError: Too many open files/address pools fully subnetted的显式 alert——目前这两类都是隐藏在terminal_benchDEBUG 日志里,外部看不见。WorkerPool里加一个 self-check 端点:启动后跑一次 §5 脚本里的 round 2 子集(哪怕 task 数量减半),做冷启动 smoke test,确认 nofile / address pool / capacity 都到位再开放/allocate给 trainer。--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 / 小毛病(持续追加)
7.1 多容器 task 的 helper service
container_name共享 task-level prefix →Conflict500发现于: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抓到):根因:
terminal_bench/handlers/trial_handler.py:266的docker_image_name_prefix=f"tb__{self.task_id}".replace(".", "-")—— 只用 task_id,不带 trial_name 里的 uid;docker-compose.yaml直接把 helper service 的container_name绑定到这个共享 prefix(典型形如container_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__<helper>);n-samples-per-prompt=8同 task 并发 8 个 lease → 第 1 个建好tb__<task>__<helper>,剩下 7 个全 dockerdConflict500。影响范围(dataset 全量 grep):
整个 dataset 1376 个 task 中共 5 个命中此 anti-pattern:
dev-servercontainer_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__dev-serverremote-servercontainer_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__remote-serverworkercontainer_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__workerweb-servercontainer_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__web-serverbastioncontainer_name: ${T_BENCH_TASK_DOCKER_NAME_PREFIX}__bastion其他 1371 个 task 的
client服务都用T_BENCH_TASK_DOCKER_CLIENT_CONTAINER_NAME(lease-unique,来自trial_name.replace(".","-")),不冲突。量化:
_drop_constant_reward_groups(slime/ray/rollout.py:400-451)整组砍掉,不进梯度。对 grad_norm/kl_loss/response_len 全部不敏感。non_trainable_ratio顶多 +5.5%(命中时),远低于 0.85 stop threshold。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 的本质区别:
address pools fully subnetted/Too many open filesConflict. The container name ... already in use当前处置(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 /status的pending_closes攀到 91(vs 健康水位 < 5)。close 队列的 backlog 来源是:每次Conflict500 → 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 一致性错误(极罕见,自愈)
msp60ius,13:36 ± 1 min(与 §7.1 同一时间窗),整个 run-3 启动 +35 min 内出现 1 次。docker logs openclaw_pool_server抓到一条:POST /reset HTTP/1.1 500 Internal Server Error,对应 lease 被 trainer mark FAILED。compose up -d同时读 / 写 image metadata 时偶发的 CAS retry race(buildkit + image store 锁冲突)。和我们这套 pool 的代码、dataset 配置都无关。_drop_constant_reward_groups吸收,0 梯度影响..., retry,下次 reset 重试自动通过;这是 docker 上游级别的偶发,运行时不可预测,没必要为单次 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抓到):buildkit 在
docker compose build阶段尝试为COPY指令算 cache key,但本地文件不存在 → 整个 build 失败 → reset 500。根因:dataset 的
Dockerfile里有COPY <file> <target>,但<file>没被打包进dataset/seta_env/<task>/目录。影响范围(dataset 全量扫描):
整个 dataset 1376 个 task,共 2 个有缺失文件:
COPY sample.log /...sample.logCOPY mock_dmesg.log /var/log/mock_dmesg.logmock_dmesg.logCOPY mock_journalctl.log /var/log/mock_journalctl.logmock_journalctl.log量化:
_drop_constant_reward_groups整组砍掉,不进梯度,对 grad_norm/kl_loss/response_len 全部不敏感grad_norm=0.948,kl_loss=0.012) 完全健康,trainer 通过generate.py:181 → Marking sample FAILED路径正确吸收和 §7.1 / §7.2 的区别:
Conflict. The container name ... already in useconsistency error: data changed during operationfailed to compute cache key: ...: not foundcontainer_name行)当前处置(run-3 持续期):暂不修。同款依靠
_drop_constant_reward_groups自然吸收。可选 patch(需要 dataset 上游补回文件):
sample.log(看 task.yaml 描述给出合适内容;或者从原terminal-bench上游 dataset repo 拉一份 reference)mock_dmesg.log和mock_journalctl.log(这两个看名字是 mock 数据,参考同 task 已有的mock_lspci_data.txt风格手写一份)docker compose build会自动 pick up 新文件cc @<repo maintainers>,欢迎反馈(特别是 §6 的 4 条改动是否合并主线)。