项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

1002 行
38 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""Rerun failed/errored tasks from an existing run directory.
Usage (via CLI):
python -m bench_env.run --rerun runs/20260411_043131 --env-url http://localhost:3000
python -m bench_env.run --rerun runs/20260411_043131 --rerun-scope all --suite wechat
python -m bench_env.run --rerun runs/20260411_043131 --task-ids wechat.TaskA,alipay.TaskB
Flow:
1. Resolve run directory, load meta.json + results.jsonl
2. Identify tasks to rerun (scope + suite/task-ids filters)
3. Recover RunnerConfig from meta.json, merge CLI overrides
4. Run to a temporary directory using existing Serial/ParallelRunner
5. Merge new results back into the original run directory
6. Regenerate summary.json + errors.jsonl, clean up temp
"""
from __future__ import annotations
import argparse
import json
import logging
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any
from bench_env.config import RunnerConfig
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Run directory resolution
# ---------------------------------------------------------------------------
def resolve_run_dir(path: str | Path) -> Path:
"""解析 run 目录路径。
支持:
- 直接传含 meta.json 的目录
- 传父目录(自动找最新的含 meta.json 的子目录)
"""
p = Path(path).resolve()
if (p / "meta.json").exists():
return p
subdirs = [d for d in p.iterdir() if d.is_dir() and (d / "meta.json").exists()]
if len(subdirs) == 1:
return subdirs[0]
if len(subdirs) > 1:
return max(subdirs, key=lambda d: d.name)
raise FileNotFoundError(f"No meta.json found in {p} or its subdirectories")
# ---------------------------------------------------------------------------
# Result loading & filtering
# ---------------------------------------------------------------------------
from bench_env.metrics import (
load_jsonl as load_results,
result_is_error as _is_error_result,
result_key,
)
def identify_rerun_tasks(
results: list[dict],
scope: str,
suite_filter: list[str] | None = None,
task_ids_filter: list[str] | None = None,
split_task_ids: frozenset[str] | set[str] | None = None,
repeat_n: int = 1,
) -> list[dict]:
"""从 results 中筛选需要重跑的条目。
1. 先按 suite / task_ids / split 缩小范围 (AND between filters)
2. 再按 scope 过滤状态
3. pass@k (repeat_n > 1) 时,如果某个 task 的任意 trial 被选中,
自动扩展为重跑该 task 的所有 trial
Returns:
需要重跑的 result entries。
"""
# Step 1: scope filter on full result set
candidates = list(results)
# Step 2: narrow by suite / task_ids / split (all AND)
if suite_filter:
suite_set = set(suite_filter)
candidates = [r for r in candidates if r.get("suite") in suite_set]
if task_ids_filter:
id_set = set(task_ids_filter)
candidates = [
r for r in candidates
if r.get("id") in id_set
or any(r.get("id", "").startswith(f"{tid}_i") for tid in id_set)
]
if split_task_ids is not None:
from bench_env.splits import base_task_id
candidates = [r for r in candidates if base_task_id(r.get("id", "")) in split_task_ids]
# Step 3: status filter
if scope == "error":
selected = [r for r in candidates if _is_error_result(r)]
elif scope == "failed":
selected = [r for r in candidates
if not r.get("is_success") and not _is_error_result(r)]
elif scope == "all":
selected = list(candidates)
else:
raise ValueError(f"Unknown rerun scope: {scope}")
# Step 4: pass@k expansion — if any trial of a task is selected,
# include ALL trials of that task
if repeat_n > 1 and selected:
selected_task_ids = {r.get("id") for r in selected}
selected = [
r for r in results
if r.get("id") in selected_task_ids
]
return selected
# ---------------------------------------------------------------------------
# Config recovery
# ---------------------------------------------------------------------------
def _collect_cli_overrides(args: argparse.Namespace) -> dict[str, Any]:
"""从 CLI args 中收集显式传入的参数(非 None 的值)作为 overrides。
argparse 中大部分可覆盖参数 default=None,非 None 即表示用户显式传入。
对于 store_true 的布尔 flag(默认 False),只在用户显式传入时覆盖。
对于有非 None argparse 默认值的参数(如 --parallel 默认 1),只有值与默认值
不同时才视为显式覆盖,避免静默覆盖 meta.json 中的原始配置。
"""
overrides: dict[str, Any] = {}
# 非布尔参数且 argparse default=None:非 None 即显式传入
nullable_mappings: dict[str, str] = {
"env_url": "env_url",
"model_base_url": "model_base_url",
"model_name": "model_name",
"proxy": "proxy",
"judge_model": "judge_model",
"judge_base_url": "judge_base_url",
"judge_api_key": "judge_api_key",
"device_serial": "device_serial",
"temperature": "temperature",
"top_p": "top_p",
"max_tokens": "max_tokens",
"max_steps": "max_steps",
}
# 有非 None argparse 默认值的参数:只有值与默认值不同时才视为显式覆盖
# (注意:用户显式传入与默认值相同的值无法区分,但此场景罕见且无害)
_ARGPARSE_DEFAULTS: dict[str, tuple[str, Any]] = {
# cli_attr -> (config_name, argparse_default)
"model_api_key": ("model_api_key", ""),
"parallel": ("parallel", 1),
"processes": ("processes", 1),
"num_browsers": ("num_browsers", 0),
"loop_detect": ("loop_detect", 0),
"screenshot_scale": ("screenshot_scale", 1.0),
"infer_timeout": ("infer_timeout", 300.0),
}
for cli_name, config_name in nullable_mappings.items():
val = getattr(args, cli_name, None)
if val is not None:
overrides[config_name] = val
for cli_name, (config_name, default) in _ARGPARSE_DEFAULTS.items():
val = getattr(args, cli_name, None)
if val is not None and val != default:
overrides[config_name] = val
# store_true 布尔 flagargparse 默认 False,只在 True 时覆盖
# (用户无法通过 CLI 将 True→False,但这种 rerun 需求极罕见)
bool_flags: dict[str, str] = {
"headless": "headless",
"quiet": "quiet",
"monitor": "monitor",
"no_stream": "no_stream",
"no_save_trajectory": "no_save_trajectory",
}
for cli_name, config_name in bool_flags.items():
if getattr(args, cli_name, False):
overrides[config_name] = True
# max_steps_explicit: track if user explicitly set --max-steps
if getattr(args, "max_steps", None) is not None:
overrides["max_steps_explicit"] = True
# task_instructions: CLI passes a file path string; parse to dict so it
# can be placed directly into RunnerConfig kwargs via from_meta overrides.
ti_value = getattr(args, "task_instructions", None)
if ti_value is not None:
p = Path(str(ti_value))
if not p.exists():
raise FileNotFoundError(f"--task-instructions file not found: {p}")
data = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(
f"--task-instructions {p} must be a JSON object {{task_id: instruction}}"
)
parsed = {str(k): str(v) for k, v in data.items()}
overrides["task_instructions"] = parsed or None
return overrides
def build_rerun_config(
meta: dict,
cli_args: argparse.Namespace,
rerun_task_ids: list[str],
tmp_runs_dir: Path,
) -> RunnerConfig:
"""从 meta.json 恢复 RunnerConfig,合并 CLI 覆盖。"""
overrides = _collect_cli_overrides(cli_args)
# 强制设置 rerun 专用字段
overrides["task_ids"] = rerun_task_ids
overrides["task_id"] = None
overrides["runs_dir"] = tmp_runs_dir
config = RunnerConfig.from_meta(meta, overrides)
return config
# ---------------------------------------------------------------------------
# Merge logic (adapted from the legacy patch_run helper script)
# ---------------------------------------------------------------------------
def _task_dir_name(task_id: str, trial_id: int, repeat_n: int) -> str:
"""Reproduce the trajectory dir name from task_id + trial_id."""
safe = task_id.replace(".", "_").replace("/", "_").replace(" ", "_")
if repeat_n > 1:
return f"{safe}_t{trial_id}"
return safe
def _remove_path(path: Path) -> None:
"""Remove a file, symlink, or directory."""
if path.is_symlink() or path.is_file():
path.unlink()
elif path.exists():
shutil.rmtree(path)
def _regenerate_summary(run_dir: Path, results: list[dict],
repeat_n: int, pass_k: list[int] | None) -> dict:
"""Regenerate summary.json from results."""
meta_path = run_dir / "meta.json"
meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.exists() else {}
from bench_env.metrics import write_summary_json
return write_summary_json(
run_dir,
results,
repeat_n=repeat_n,
pass_k=pass_k,
start_time=meta.get("start_time"),
)
def _regenerate_errors(run_dir: Path, results: list[dict]) -> None:
"""Regenerate errors.jsonl from results."""
from bench_env.metrics import write_errors_jsonl
write_errors_jsonl(run_dir, results)
def merge_results(
target_dir: Path,
source_dir: Path,
rerun_keys: set[str],
repeat_n: int,
pass_k: list[int] | None,
) -> dict:
"""将 source 的新结果合并回 target。
1. 按 (task_id, trial_id) key 替换旧结果
2. 复制新 trajectory 覆盖旧的
3. 备份原文件为 .bak
4. 重新生成 summary.json + errors.jsonl
Returns:
The regenerated summary dict.
"""
target_results_path = target_dir / "results.jsonl"
source_results_path = source_dir / "results.jsonl"
old_results = load_results(target_results_path) if target_results_path.exists() else []
new_results = load_results(source_results_path) if source_results_path.exists() else []
# Build source lookup
source_map: dict[str, dict] = {}
for r in new_results:
source_map[result_key(r)] = r
# Merge: replace matching entries, keep the rest
merged: list[dict] = []
patched_keys: set[str] = set()
for r in old_results:
key = result_key(r)
if key in source_map:
merged.append(source_map[key])
patched_keys.add(key)
else:
merged.append(r)
# Add any new results not present in old (defensive)
for key, r in source_map.items():
if key not in patched_keys:
merged.append(r)
# Backup original files
for fname in ["results.jsonl", "errors.jsonl", "summary.json"]:
src = target_dir / fname
if src.exists():
# Shared run directories may allow content writes but reject metadata
# updates (mtime/mode) for files owned by another user.
shutil.copyfile(src, src.with_suffix(f".{fname.split('.')[-1]}.bak"))
# Write merged results
with open(target_results_path, "w", encoding="utf-8") as f:
for r in merged:
f.write(json.dumps(r, ensure_ascii=False, default=str) + "\n")
# Patch trajectories
target_traj = target_dir / "trajectory"
source_traj = source_dir / "trajectory"
traj_count = 0
if target_traj.exists() and source_traj.exists():
for key in rerun_keys:
parts = key.rsplit("__t", 1)
task_id = parts[0]
trial_id = int(parts[1]) if len(parts) > 1 else 0
dir_name = _task_dir_name(task_id, trial_id, repeat_n)
src = source_traj / dir_name
dst = target_traj / dir_name
if src.exists():
if dst.exists() or dst.is_symlink():
_remove_path(dst)
shutil.copytree(src, dst)
traj_count += 1
logger.info(f"[MERGE] {len(source_map)} results replaced, {traj_count} trajectory dirs updated")
# Regenerate summary + errors
summary = _regenerate_summary(target_dir, merged, repeat_n, pass_k)
_regenerate_errors(target_dir, merged)
return summary
# ---------------------------------------------------------------------------
# Rerun orchestration
# ---------------------------------------------------------------------------
async def run_rerun(args: argparse.Namespace) -> int:
"""完整的 rerun 编排入口。"""
from bench_env.runner import SerialRunner, ParallelRunner, MultiProcessRunner
from bench_env.logger import configure_logging
# 1. Resolve run directory
try:
run_dir = resolve_run_dir(args.rerun)
except FileNotFoundError as e:
print(f"[ERROR] {e}")
return 2
# 2. Load meta + results
meta_path = run_dir / "meta.json"
meta = json.loads(meta_path.read_text(encoding="utf-8"))
results_path = run_dir / "results.jsonl"
if not results_path.exists():
print(f"[ERROR] results.jsonl not found in {run_dir}")
return 2
old_results = load_results(results_path)
if not old_results:
print("[INFO] results.jsonl is empty, nothing to rerun")
return 0
# 3. Identify rerun tasks
scope = getattr(args, "rerun_scope", "error")
suite_filter = _parse_comma_list(getattr(args, "suite", None))
task_ids_filter = _parse_comma_list(getattr(args, "task_ids", None))
# Also accept --task-id (singular) as filter
single_task_id = getattr(args, "task_id", None)
if single_task_id and not task_ids_filter:
task_ids_filter = [single_task_id]
# CLI --split overrides meta; otherwise inherit from the original run.
split_spec = getattr(args, "split", None) or meta.get("split")
split_task_ids = None
if split_spec:
from bench_env.splits import resolve_split
split_task_ids = frozenset(resolve_split(split_spec))
repeat_n = meta.get("repeat_n", 1)
rerun_entries = identify_rerun_tasks(
old_results, scope,
suite_filter=suite_filter,
task_ids_filter=task_ids_filter,
split_task_ids=split_task_ids,
repeat_n=repeat_n,
)
if not rerun_entries:
print(f"[INFO] No tasks to rerun (scope={scope})")
return 0
# Collect unique task IDs and rerun keys
rerun_task_ids = sorted(set(r.get("id") for r in rerun_entries))
rerun_keys = {result_key(r) for r in rerun_entries}
# 4. Print rerun plan
scope_label = {"error": "errored", "failed": "failed", "all": "all"}
print(f"\n{'=' * 60}")
print(f" RERUN PLAN")
print(f"{'=' * 60}")
print(f" Source: {run_dir}")
print(f" Scope: {scope_label.get(scope, scope)}")
print(f" Tasks: {len(rerun_task_ids)} tasks, {len(rerun_entries)} episodes")
if suite_filter:
print(f" Suite: {', '.join(suite_filter)}")
if task_ids_filter:
print(f" Task IDs: {', '.join(task_ids_filter)}")
if split_spec:
print(f" Split: {split_spec} ({len(split_task_ids)} ids)")
print(f"{'=' * 60}\n")
for tid in rerun_task_ids:
trials = [r for r in rerun_entries if r.get("id") == tid]
statuses = []
for r in trials:
if _is_error_result(r):
statuses.append("error")
elif r.get("is_success"):
statuses.append("success")
else:
statuses.append("failed")
if repeat_n > 1:
print(f" {tid} ({len(trials)} trials: {', '.join(statuses)})")
else:
print(f" {tid} [{statuses[0]}]")
print()
# 5. Build config (clean stale temp dir first to prevent merging old residuals)
tmp_runs_dir = run_dir / ".rerun_tmp"
_cleanup_tmp(tmp_runs_dir)
config = build_rerun_config(meta, args, rerun_task_ids, tmp_runs_dir)
# Validate env_url for sim mode
if config.device == "sim" and not config.env_url:
print("[ERROR] --env-url is required for simulator mode")
return 2
# 6. Run
try:
if config.processes > 1:
runner = await MultiProcessRunner.from_config(config)
elif config.parallel > 1:
runner = await ParallelRunner.from_config(config)
else:
runner = await SerialRunner.from_config(config)
await runner.run()
except Exception as e:
logger.exception(f"Rerun execution failed: {e}")
print(f"[ERROR] Rerun execution failed: {e}")
# Still try to merge whatever results were produced
pass
# 7. Find the temp run output directory
tmp_run_dir = _find_latest_run_dir(tmp_runs_dir)
if tmp_run_dir is None:
print("[ERROR] No rerun results produced")
_cleanup_tmp(tmp_runs_dir)
return 1
# 8. Merge results back
pass_k = meta.get("pass_k")
summary = merge_results(run_dir, tmp_run_dir, rerun_keys, repeat_n, pass_k)
# 9. Update meta.json with rerun history
_append_rerun_history(run_dir, scope, rerun_task_ids)
# 10. Cleanup temp
_cleanup_tmp(tmp_runs_dir)
# 11. Print summary
print(f"\n{'=' * 60}")
print(f" RERUN COMPLETE")
print(f"{'=' * 60}")
print(f" Success: {summary.get('success', 0)}")
print(f" Failed: {summary.get('failed', 0)}")
print(f" Error: {summary.get('error', 0)}")
print(f" SR: {summary.get('success_rate', 0):.1%}")
print(f" Output: {run_dir}")
print(f"{'=' * 60}\n")
return 0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _parse_comma_list(value: str | None) -> list[str] | None:
if not value:
return None
parts = [p.strip() for p in str(value).split(",")]
return [p for p in parts if p] or None
def _find_latest_run_dir(tmp_runs_dir: Path) -> Path | None:
"""Find the most recent timestamped run directory under tmp_runs_dir."""
if not tmp_runs_dir.exists():
return None
candidates = [
d for d in tmp_runs_dir.iterdir()
if d.is_dir() and (d / "results.jsonl").exists()
]
if not candidates:
return None
return max(candidates, key=lambda d: d.name)
def _cleanup_tmp(tmp_dir: Path) -> None:
"""Remove temporary rerun directory."""
try:
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
except Exception as e:
logger.warning(f"Failed to cleanup temp dir {tmp_dir}: {e}")
def _append_rerun_history(run_dir: Path, scope: str, task_ids: list[str]) -> None:
"""Append rerun record to meta.json."""
meta_path = run_dir / "meta.json"
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
history = meta.get("rerun_history", [])
history.append({
"timestamp": datetime.now().isoformat(),
"scope": scope,
"tasks": task_ids,
"count": len(task_ids),
})
meta["rerun_history"] = history
meta_path.write_text(
json.dumps(meta, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
except Exception as e:
logger.warning(f"Failed to update meta.json rerun_history: {e}")
def _append_prune_history(run_dir: Path, orphan_task_ids: list[str],
removed_episodes: int) -> None:
"""Append prune record to meta.json."""
meta_path = run_dir / "meta.json"
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
history = meta.get("prune_history", [])
history.append({
"timestamp": datetime.now().isoformat(),
"orphan_tasks": orphan_task_ids,
"task_count": len(orphan_task_ids),
"removed_episodes": removed_episodes,
})
meta["prune_history"] = history
meta_path.write_text(
json.dumps(meta, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
except Exception as e:
logger.warning(f"Failed to update meta.json prune_history: {e}")
def _append_resume_history(run_dir: Path, task_ids: list[str]) -> None:
"""Append resume record to meta.json."""
meta_path = run_dir / "meta.json"
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
history = meta.get("resume_history", [])
history.append({
"timestamp": datetime.now().isoformat(),
"tasks": task_ids,
"count": len(task_ids),
})
meta["resume_history"] = history
meta_path.write_text(
json.dumps(meta, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
except Exception as e:
logger.warning(f"Failed to update meta.json resume_history: {e}")
# ---------------------------------------------------------------------------
# Resume orchestration
# ---------------------------------------------------------------------------
async def run_resume(args: argparse.Namespace) -> int:
"""续跑编排入口:运行被中断时尚未执行的 pending 任务。
与 rerun 的区别:
- rerun:重新运行已有结果但状态为 error/failed/all 的任务(替换旧结果)
- resume:运行从未执行过的任务(追加新结果),用于恢复被中断的 run
逻辑:
1. 从 meta.json 重建原始任务列表(保留相同 seed,确保 sample_n 实例一致)
2. 对比 results.jsonl,找出完全没有任何 trial 被记录的任务
3. 运行这些 pending 任务到临时目录
4. 追加新结果到原目录(不覆盖已有结果)
5. 重新生成 summary.json + errors.jsonl
"""
from bench_env.runner import SerialRunner, ParallelRunner, MultiProcessRunner
from bench_env import factory
# 1. 解析 run 目录
try:
run_dir = resolve_run_dir(args.resume)
except FileNotFoundError as e:
print(f"[ERROR] {e}")
return 2
# 2. 读取 meta.json + results.jsonl
meta_path = run_dir / "meta.json"
if not meta_path.exists():
print(f"[ERROR] meta.json not found in {run_dir}")
return 2
meta = json.loads(meta_path.read_text(encoding="utf-8"))
results_path = run_dir / "results.jsonl"
existing_results: list[dict] = []
if results_path.exists():
existing_results = load_results(results_path)
repeat_n = meta.get("repeat_n", 1)
# 3. 从 meta 重建原始任务列表(相同 seed → 相同实例)
# 特殊情形:若原 run 的 task_id / task_ids filter 指向的 task 已全部被删除,
# factory.load_tasks 会抛 ValueError("No tasks found")factory.py:258)。
# 此时没有任务可以 resume —— 不视为 error,优雅退出 0,让用户可以继续跑 prune
# 清理 orphan。保留 filter 本身(不剥离)是为了忠实于原 run 的用户意图,
# 避免"只想跑 1 个 task 的 run"被误扩展为"整个 suite"。
try:
base_config = RunnerConfig.from_meta(meta)
all_tasks = factory.load_tasks(base_config)
except ValueError as e:
if "No tasks found" in str(e):
print(
f"[INFO] Nothing to resume: the original run's task filter "
f"(task_id / task_ids / suite in meta.json) no longer matches "
f"any task in current code. If the run has orphan entries, "
f"use --prune to clean them up."
)
return 0
print(f"[ERROR] Failed to reconstruct original task list from meta.json: {e}")
return 2
except Exception as e:
print(f"[ERROR] Failed to reconstruct original task list from meta.json: {e}")
return 2
# 3b. Optional --split filter (AND with whatever meta.json already restricted).
split_spec = getattr(args, "split", None)
if split_spec:
from bench_env.splits import resolve_split, base_task_id
split_task_ids = frozenset(resolve_split(split_spec))
all_tasks = [t for t in all_tasks if base_task_id(t.id) in split_task_ids]
if not all_tasks:
print(f"[INFO] Nothing to resume after --split {split_spec}")
return 0
# 4. 分类所有任务
# - pending: 所有 trial 均无记录(从未运行)
# - partial: 有部分 trial 记录但不完整(run 中途被中断)
# 两类均需重跑:pending 追加,partial 全部 trial 替换(保证 pass@k 数据完整)
recorded_keys = {result_key(r) for r in existing_results}
pending_task_ids: list[str] = [] # 从未跑过
partial_task_ids: list[str] = [] # 跑了一部分
for task in all_tasks:
trial_keys = [f"{task.id}__t{t}" for t in range(repeat_n)]
recorded_count = sum(1 for k in trial_keys if k in recorded_keys)
if recorded_count == 0:
pending_task_ids.append(task.id)
elif recorded_count < repeat_n:
partial_task_ids.append(task.id)
# recorded_count == repeat_n → 完整,跳过
resume_task_ids = pending_task_ids + partial_task_ids
if not resume_task_ids:
total_eps = len(all_tasks) * repeat_n
print(f"[INFO] Run appears complete — {len(existing_results)}/{total_eps} episodes recorded.")
return 0
# 5. 打印 resume 计划
total_eps = len(all_tasks) * repeat_n
print(f"\n{'=' * 60}")
print(f" RESUME PLAN")
print(f"{'=' * 60}")
print(f" Source: {run_dir}")
print(f" Total: {len(all_tasks)} tasks × {repeat_n} trials = {total_eps} episodes")
print(f" Recorded: {len(existing_results)} episodes already done")
print(f" Pending: {len(pending_task_ids)} tasks (never started)")
if partial_task_ids:
print(f" Partial: {len(partial_task_ids)} tasks (incomplete trials → full rerun)")
print(f"{'=' * 60}\n")
for tid in pending_task_ids:
trials_label = f" × {repeat_n} trials" if repeat_n > 1 else ""
print(f" {tid}{trials_label} [pending]")
for tid in partial_task_ids:
done = sum(1 for k in recorded_keys if k.startswith(f"{tid}__t"))
print(f" {tid} [{done}/{repeat_n} trials done → rerun all]")
print()
# 6. 构建 resume 专用 config
tmp_runs_dir = run_dir / ".resume_tmp"
_cleanup_tmp(tmp_runs_dir)
overrides = _collect_cli_overrides(args)
overrides["task_ids"] = resume_task_ids
overrides["task_id"] = None
overrides["runs_dir"] = tmp_runs_dir
config = RunnerConfig.from_meta(meta, overrides)
if config.device == "sim" and not config.env_url:
print("[ERROR] --env-url is required for simulator mode")
return 2
# 7. 运行
try:
if config.processes > 1:
runner = await MultiProcessRunner.from_config(config)
elif config.parallel > 1:
runner = await ParallelRunner.from_config(config)
else:
runner = await SerialRunner.from_config(config)
await runner.run()
except Exception as e:
logger.exception(f"Resume execution failed: {e}")
print(f"[ERROR] Resume execution failed: {e}")
# 8. 找到临时目录下的输出
tmp_run_dir = _find_latest_run_dir(tmp_runs_dir)
if tmp_run_dir is None:
print("[ERROR] No resume results produced")
_cleanup_tmp(tmp_runs_dir)
return 1
# 9. 合并回原目录
# - pending 任务的结果:追加(merge_results 的 defensive add 分支)
# - partial 任务的旧 trial 结果:被新结果替换(merge_results 的 replace 分支)
# rerun_keys = 所有本次运行的 (task_id, trial_id) 组合,用于 trajectory 替换
pass_k = meta.get("pass_k")
rerun_keys: set[str] = set()
for tid in resume_task_ids:
for t in range(repeat_n):
rerun_keys.add(f"{tid}__t{t}")
summary = merge_results(run_dir, tmp_run_dir, rerun_keys, repeat_n, pass_k)
# 10. 更新 meta.json resume 历史
_append_resume_history(run_dir, resume_task_ids)
# 11. 清理临时目录
_cleanup_tmp(tmp_runs_dir)
# 12. 打印最终摘要
print(f"\n{'=' * 60}")
print(f" RESUME COMPLETE")
print(f"{'=' * 60}")
print(f" Resumed: {len(pending_task_ids)} pending + {len(partial_task_ids)} partial tasks")
print(f" Success: {summary.get('success', 0)}")
print(f" Failed: {summary.get('failed', 0)}")
print(f" Error: {summary.get('error', 0)}")
print(f" SR: {summary.get('success_rate', 0):.1%}")
print(f" Output: {run_dir}")
print(f"{'=' * 60}\n")
return 0
# ---------------------------------------------------------------------------
# Prune (registry / split orphans)
# ---------------------------------------------------------------------------
async def run_prune(args: argparse.Namespace) -> int:
"""Prune results.jsonl entries outside the current valid task set.
Valid set = ``{task_ids produced by factory.load_tasks(meta)}``
intersected with ``--split`` whitelist (if given).
Anything else in results.jsonl is pruned:
- Task classes deleted/renamed in code (classic "orphan")
- Tasks not in the requested split (if ``--split`` passed)
Flow:
1. Resolve run dir, load meta.json + results.jsonl
2. Rebuild current task_ids via factory.load_tasks (mirrors resume)
3. If --split given, intersect current_task_ids with split base ids
4. Compute prune entries (respecting optional --suite / --task-ids filters)
5. Print plan; if --dry-run, stop here
6. Backup results/errors/summary to .bak
7. Rewrite results.jsonl without pruned entries, remove their trajectory dirs
8. Regenerate summary.json + errors.jsonl
9. Append prune_history to meta.json
"""
from bench_env import factory
# 1. Resolve run dir (accepts either --prune or the legacy --prune-orphans)
target = getattr(args, "prune", None) or getattr(args, "prune_orphans", None)
try:
run_dir = resolve_run_dir(target)
except FileNotFoundError as e:
print(f"[ERROR] {e}")
return 2
# 2. Load meta + results
meta_path = run_dir / "meta.json"
if not meta_path.exists():
print(f"[ERROR] meta.json not found in {run_dir}")
return 2
meta = json.loads(meta_path.read_text(encoding="utf-8"))
results_path = run_dir / "results.jsonl"
if not results_path.exists():
print(f"[INFO] results.jsonl not found in {run_dir}, nothing to prune")
return 0
existing_results = load_results(results_path)
if not existing_results:
print("[INFO] results.jsonl is empty, nothing to prune")
return 0
repeat_n = meta.get("repeat_n", 1)
# 3. Rebuild current task_ids
# Prune's job is "enumerate every task_id the current code can produce and
# diff against results.jsonl". Any narrowing filter frozen into meta.json
# from the original run is a liability here — if the original filter now
# matches nothing (deleted task, deleted suite, filter_difficulty no longer
# covered, etc.), factory.load_tasks raises and prune fails on the exact
# case it should handle. So strip every narrowing field from a meta copy.
# Keep sample_n / sample_seed so sampled instance ids (_iN) match
# results.jsonl deterministically.
# RunnerConfig.from_meta overrides only apply when value is non-None
# (config.py:272), so mutate the copy directly.
meta_for_enum = dict(meta)
# All narrowing fields consumed by factory.load_tasks — mirrors the full
# set at factory.py:167-171 (_apply_task_filters) plus factory.py:229
# (filter_has_answer_fields) plus the early task_id/task_ids/suite filters.
# filter_mode is the AND/OR combinator for these lists; with every list set
# to None, _apply_task_filters short-circuits and filter_mode is moot.
# `split` is also wiped so we get the full registry here; the split filter
# is then applied as a single explicit layer below (CLI arg > meta default).
for field in (
"task_id", "task_ids", "suite",
"filter_difficulty", "filter_objective", "filter_composition",
"filter_scope", "filter_capabilities",
"filter_has_answer_fields",
"split",
):
meta_for_enum[field] = None
try:
base_config = RunnerConfig.from_meta(meta_for_enum)
all_tasks = factory.load_tasks(base_config)
except Exception as e:
print(f"[ERROR] Failed to reconstruct task list from meta.json: {e}")
return 2
current_task_ids = {t.id for t in all_tasks}
# 3b. CLI --split overrides meta; otherwise inherit from the original run.
# Intersect valid set with the resolved whitelist — anything outside
# (registry ∩ split) becomes a prune candidate.
split_spec = getattr(args, "split", None) or meta.get("split")
split_task_ids: frozenset[str] | None = None
if split_spec:
from bench_env.splits import resolve_split, base_task_id
split_task_ids = frozenset(resolve_split(split_spec))
current_task_ids = {tid for tid in current_task_ids if base_task_id(tid) in split_task_ids}
# 4. Compute orphan entries (with optional filters)
suite_filter = _parse_comma_list(getattr(args, "suite", None))
task_ids_filter = _parse_comma_list(getattr(args, "task_ids", None))
single_task_id = getattr(args, "task_id", None)
if single_task_id and not task_ids_filter:
task_ids_filter = [single_task_id]
suite_set = set(suite_filter) if suite_filter else None
tid_filter_set = set(task_ids_filter) if task_ids_filter else None
def _matches_user_task_filter(result_id: str) -> bool:
"""Match behavior mirrors rerun/factory: exact id OR base-id for _iN instances."""
if tid_filter_set is None:
return True
if result_id in tid_filter_set:
return True
return any(result_id.startswith(f"{tid}_i") for tid in tid_filter_set)
orphan_entries: list[dict] = []
for r in existing_results:
tid = r.get("id", "")
if tid in current_task_ids:
continue
if suite_set and r.get("suite") not in suite_set:
continue
if not _matches_user_task_filter(tid):
continue
orphan_entries.append(r)
if not orphan_entries:
print("[INFO] No entries to prune — results.jsonl already matches the valid task set")
return 0
orphan_task_ids = sorted({r.get("id") for r in orphan_entries})
# 5. Print plan
dry_run = bool(getattr(args, "dry_run", False))
print(f"\n{'=' * 60}")
print(f" PRUNE {'(DRY RUN)' if dry_run else ''}")
print(f"{'=' * 60}")
print(f" Source: {run_dir}")
print(f" Pruning: {len(orphan_task_ids)} tasks, {len(orphan_entries)} episodes")
if split_spec:
print(f" Split: {split_spec} ({len(split_task_ids)} ids — valid = registry ∩ split)")
else:
print(f" Valid: registry ({len(current_task_ids)} ids)")
if suite_filter:
print(f" Suite: {', '.join(suite_filter)}")
if task_ids_filter:
print(f" Task IDs: {', '.join(task_ids_filter)}")
print(f"{'=' * 60}")
for tid in orphan_task_ids:
ep_count = sum(1 for r in orphan_entries if r.get("id") == tid)
suffix = f" × {ep_count} episodes" if ep_count > 1 else ""
print(f" {tid}{suffix}")
print()
if dry_run:
print("[DRY RUN] No files modified. Re-run without --dry-run to apply.")
return 0
# 6. Backup
for fname in ["results.jsonl", "errors.jsonl", "summary.json"]:
src = run_dir / fname
if src.exists():
# Shared run directories may allow content writes but reject metadata
# updates (mtime/mode) for files owned by another user.
shutil.copyfile(src, src.with_suffix(f".{fname.split('.')[-1]}.bak"))
# 7. Rewrite results.jsonl without orphans
orphan_keys = {result_key(r) for r in orphan_entries}
kept_results = [r for r in existing_results if result_key(r) not in orphan_keys]
with open(results_path, "w", encoding="utf-8") as f:
for r in kept_results:
f.write(json.dumps(r, ensure_ascii=False, default=str) + "\n")
# Remove orphan trajectory directories
traj_root = run_dir / "trajectory"
traj_removed = 0
if traj_root.exists():
for r in orphan_entries:
tid = r.get("id")
trial = int(r.get("trial_id", 0))
dir_name = _task_dir_name(tid, trial, repeat_n)
d = traj_root / dir_name
if d.exists() or d.is_symlink():
_remove_path(d)
traj_removed += 1
logger.info(
f"[PRUNE] Removed {len(orphan_entries)} entries, {traj_removed} trajectory dirs"
)
# 8. Regenerate summary + errors
pass_k = meta.get("pass_k")
summary = _regenerate_summary(run_dir, kept_results, repeat_n, pass_k)
_regenerate_errors(run_dir, kept_results)
# 9. Record in meta.json
_append_prune_history(run_dir, orphan_task_ids, len(orphan_entries))
# 10. Print final summary
print(f"\n{'=' * 60}")
print(f" PRUNE COMPLETE")
print(f"{'=' * 60}")
print(f" Removed: {len(orphan_task_ids)} tasks, {len(orphan_entries)} episodes")
print(f" Trajectories cleaned: {traj_removed}")
print(f" Remaining: {summary.get('total_episodes', 0)} episodes")
print(f" Output: {run_dir}")
print(f"{'=' * 60}\n")
return 0
# Backward-compatible alias: callers that imported run_prune_orphans still work.
run_prune_orphans = run_prune