"""AppWorld 环境层的离线冒烟:起 1 个容器,跑通一整条会话链路,**一次模型调用都不打**。 跑法(在仓库根目录):: PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop \\ python -m tools.soak.check_appworld --data-root /path/to/appworld 用 `-m` 而不是直接给文件路径:直接跑文件时 `tools` 不在 `sys.path` 上, `from tools.soak.appworld import ...` 会 ImportError。 它验的是环境层自己:容器起得来、题实例化得了、代码执行得了、评测调得通、容器删得干净。 执行的三段代码是写死的,其中一段故意写错——那一段验的是「代码报错不抛异常,错误文本原样 回来」这条行为,因为它是上层循环最依赖的一条,而正常路径验不出来。 退出码 0 表示全绿。任何一步失败都以非零退出并把原始报错打出来,**不做任何降级**。 """ from __future__ import annotations import argparse import asyncio import logging import sys from pathlib import Path from tools.soak.appworld import AppWorldPool #: 冒烟用的题目。取 train 划分的第一条而不是写死一个 ID:题集换过一次(发布的 train 是 #: 90 题,原论文写的是 105 题),写死的 ID 有一天会变成一句「找不到这道题」,而那时看起来 #: 像是环境坏了。 _SPLIT = "train" #: 三段写死的代码。顺序有意义:先确认 API 文档读得到(环境的数据挂载没问题),再确认 #: 有状态执行器跨步保留变量(这是 AppWorld 与 docker-exec 型环境的关键区别),最后确认 #: 报错不会被压成异常。 _PROBES: tuple[tuple[str, str], ...] = ( ("列出可用的 app", "print(apis.api_docs.show_app_descriptions())"), ("定义一个变量", "soak_marker = 6 * 7"), ("读回上一步的变量(验有状态执行器)", "print(soak_marker)"), ("故意写错(验错误不抛异常)", "print(this_name_does_not_exist)"), ) def _line(title: str) -> None: print(f"\n=== {title} ===", flush=True) async def _smoke(data_root: Path, port_base: int) -> int: pool = AppWorldPool(data_root=data_root, size=1, port_base=port_base) _line("池配置") print(f"数据根目录:{data_root}(只读挂载)") print(f"输出目录: {pool.outputs_dir}") print(f"宿主端口: {pool.ports}") async with pool: task_ids = pool.list_task_ids(_SPLIT) task_id = task_ids[0] _line(f"划分 {_SPLIT}") print(f"共 {len(task_ids)} 道题,取第一道:{task_id}") async with pool.session(task_id) as session: _line("初始化") print(f"容器地址:{session.base_url}") print(f"虚拟时间:{session.datetime}") print(f"主管: {session.supervisor}") print(f"题面: {session.instruction.strip()[:400]}") for title, code in _PROBES: _line(f"执行:{title}") print(f"$ {code}") output = await session.execute(code) print(output.strip()[:1200] or "<无输出>") _line("问一次完成没有") print(f"is_done() = {await session.is_done()}(没做题,应为 False)") _line("评测") score = await session.evaluate() print(f"success = {score.success}(没做题,应为 False)") print(f"执行次数 = {score.n_executions}") print(f"detail 的键 = {sorted(score.detail)}") _line("容器残留检查") return await _report_leftovers() async def _report_leftovers() -> int: """确认压测的容器一个都没剩下。剩了就非零退出——它会占着端口让下一次跑直接失败。""" process = await asyncio.create_subprocess_exec( "docker", "ps", "-a", "--filter", "name=polyloop-soak-appworld", "--format", "{{.Names}} {{.Status}}", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await process.communicate() if process.returncode != 0: print(f"docker ps 失败(退出码 {process.returncode}):{stderr.decode(errors='replace')}") return 1 leftovers = stdout.decode(errors="replace").strip() if leftovers: print(f"仍有残留容器:\n{leftovers}") return 1 print("docker ps -a 里没有 polyloop-soak-appworld 开头的容器,干净。") return 0 def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--data-root", type=Path, required=True, help="AppWorld 数据根目录,下面应有 data/datasets 与 data/tasks", ) parser.add_argument( "--port-base", type=int, default=8200, help="容器池起始宿主端口(默认 8200,避开 dissect 的 8100)", ) args = parser.parse_args() # 容器池的告警走 logging,默认级别是 WARNING 且没有 handler,会被静默丢掉——而 # 「清理容器失败」正是这条路上唯一的线索。 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") return asyncio.run(_smoke(args.data_root.resolve(), args.port_base)) if __name__ == "__main__": sys.exit(main())