feat: 任务书 05,runner 与 tasks 两个模块

runner 用 Docker 跑一条命令并交回结局和资源用量;tasks 读 task.json、准备运行目录、给出正式运行/校验器/轻量检查的运行定义,并从原始产物独立判分。任务目录从参考项目搬到 tasks/circle_packing/,新增 task.json;vocab 的 VERDICTS 删掉"判不了",ProcessResult 的 cancelled 改为"容器从外面被杀了"。

全新上下文审阅后的两处修复:计量 shell 以容器内 root 运行,命令经 setpriv 降权,结果写在容器自己的文件层、结束后 docker cp 取出,并加 no-new-privileges,候选无法伪造计量结果把超时记成正常退出;harness 开跑前删掉旧的 solution.json、metrics.json,旧产物不会被当成本次的值。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 13:55:47 -04:00
parent 6005fb8ec2
commit cf6bc4c844
25 changed files with 3205 additions and 6 deletions
+100
View File
@@ -0,0 +1,100 @@
"""候选代码的轻量静态检查(不执行候选的任何一行代码)。
实验员需要一个便宜的办法确认代码能跑;不给他这个办法,他就会自己跑一遍完整实验
去验证。这个脚本就是那个便宜的办法:只做三件事,全部基于抽象语法树。
1. 入口文件在不在
2. 候选目录下每个 ``*.py`` 能不能 ``ast.parse``
3. 只对入口文件:顶层恰好有一个叫 ``run_packing`` 的函数,且零个必填参数
用法:``python -B check-candidate.py <候选目录> <入口文件相对候选目录的路径> <结果文件>``。
结果写进 <结果文件>,一个 JSON 对象:``{"valid": bool, "reasons": [...]}``。
对候选代码的每一种意见都走"退出码 0 加 reasons";非零退出说明这个脚本自己没跑成。
两条调用约定,改的时候不要破坏:
- **本文件不能放进候选目录。** 第 2 步从候选目录往下扫 ``*.py``,
脚本自己在里面就会把自己也扫进去。宿主把它放在候选目录的上一层。
- **只用标准库,``-B`` 要保留。** 不写 ``__pycache__``:检查本身不该往候选目录里添文件。
"""
import ast
import json
import pathlib
import sys
ENTRY_FUNCTION = "run_packing"
# 两种定义形式都要认,否则 `async def run_packing()` 会被判成"不存在"。
FUNCTION_NODES = (ast.FunctionDef, ast.AsyncFunctionDef)
def has_zero_required_arguments(node) -> bool:
"""判断函数是不是可以零参数调用。
位置参数(含仅位置参数)里没有默认值的那些必须为 0 个;
仅关键字参数里 kw_defaults 为 None 的表示没有默认值,一个都不能有。
"""
positional = len(node.args.posonlyargs) + len(node.args.args)
required_positional = positional - len(node.args.defaults)
keyword_without_default = any(
default is None for default in node.args.kw_defaults
)
return required_positional <= 0 and not keyword_without_default
def main() -> int:
if len(sys.argv) != 4:
print(
"用法: check-candidate.py <候选目录> <入口文件相对路径> <结果文件>",
file=sys.stderr,
)
return 2
root = pathlib.Path(sys.argv[1])
entry_point = sys.argv[2]
reasons: list[str] = []
if not (root / entry_point).is_file():
reasons.append(f"入口文件不存在: {entry_point}")
# 第 3 步挂在第 2 步的 rglob("*.py") 上:入口文件名不以 .py 结尾,
# 循环扫不到它,入口函数那条判定会整个被跳过,却照样报通过。
if not entry_point.endswith(".py"):
reasons.append(
f"入口文件必须以 .py 结尾,否则入口函数检查扫不到它: {entry_point}"
)
for path in root.rglob("*.py"):
relative = str(path.relative_to(root))
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=relative)
except (SyntaxError, ValueError) as error:
# SyntaxError:语法错;ValueError:含空字节或不是 UTF-8(UnicodeDecodeError 是它的子类)。
reasons.append(f"{relative} 解析失败: {error}")
continue
if relative != entry_point:
continue
candidates = [
node
for node in tree.body
if isinstance(node, FUNCTION_NODES) and node.name == ENTRY_FUNCTION
]
if len(candidates) != 1:
reasons.append(
f"{entry_point} 顶层的 {ENTRY_FUNCTION}() 应当恰好有一个定义,"
f"实际有 {len(candidates)} 个"
)
elif not has_zero_required_arguments(candidates[0]):
reasons.append(
f"{entry_point} 的 {ENTRY_FUNCTION}() 必须能零参数调用,"
f"当前有必填参数"
)
with open(sys.argv[3], "w", encoding="utf-8") as handle:
json.dump({"valid": not reasons, "reasons": reasons}, handle)
return 0
if __name__ == "__main__":
sys.exit(main())