Files
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 lines
38 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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