diff --git a/app/harness/workspace.py b/app/harness/workspace.py index 3598b2b..c385dc1 100644 --- a/app/harness/workspace.py +++ b/app/harness/workspace.py @@ -64,6 +64,21 @@ def _now_iso() -> str: return datetime.now(UTC).isoformat() +def _atomic_write_json(path: Path, data: dict) -> None: + """原子写 JSON:tmp + os.replace(对齐 checkpoint.py 范式,防半截损坏)。 + + 先写同目录临时文件,再 os.replace 原子替换目标;替换阶段崩溃不会留下半截 + JSON,原文件保持完好。 + + 参数: + path: 目标 JSON 文件路径。 + data: 待序列化的字典。 + """ + tmp = path.with_name(path.name + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) + + # --------------------------------------------------------------------------- # Workspace 核心函数 # --------------------------------------------------------------------------- @@ -108,7 +123,7 @@ def _scaffold_workspace( }, "history": [], } - (workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2)) + _atomic_write_json(workspace_dir / "manifest.json", manifest) def init_workspace( @@ -286,7 +301,7 @@ def update_manifest(workspace_dir: Path, **version_updates: str) -> None: raise KeyError(f"无效的 manifest current 字段: {invalid}") manifest = load_manifest(workspace_dir) manifest["current"].update(version_updates) - (workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2)) + _atomic_write_json(workspace_dir / "manifest.json", manifest) def record_run(workspace_dir: Path, run_id: str) -> Path: @@ -315,9 +330,7 @@ def record_run(workspace_dir: Path, run_id: str) -> Path: "questions": current["questions"], } ) - (workspace_dir / "manifest.json").write_text( - json.dumps(manifest, ensure_ascii=False, indent=2) - ) + _atomic_write_json(workspace_dir / "manifest.json", manifest) run_dir = workspace_dir / "runs" / run_id # exist_ok:同 run_id 重跑时 run 目录已存在不应崩溃 @@ -369,7 +382,7 @@ def update_best( "run_id": run_id, "epoch": epoch, } - (workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2)) + _atomic_write_json(workspace_dir / "manifest.json", manifest) logger.info("Best 已更新: val_acc={}, run={}, epoch={}", val_acc, run_id, epoch) diff --git a/tests/unit/test_harness_workspace.py b/tests/unit/test_harness_workspace.py index f0fa2a2..6ad79e1 100644 --- a/tests/unit/test_harness_workspace.py +++ b/tests/unit/test_harness_workspace.py @@ -259,6 +259,33 @@ def test_update_manifest_valid(store_dir: Path, workspace_dir: Path) -> None: assert manifest["current"]["skills"] == "skills/v2" +def test_update_manifest_is_atomic( + store_dir: Path, workspace_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """写 manifest 途中崩溃不产生半截 JSON(原子写:os.replace 失败也不损原文件)。""" + from app.harness import workspace as ws + + init_workspace( + workspace_dir, + store_dir, + questions="benchmarks/Video-MME", + skills_version="v1", + prompts_version="v1", + ) + original = (workspace_dir / "manifest.json").read_text() + + def _boom(_src: object, _dst: object) -> None: + raise OSError("crash during replace") + + monkeypatch.setattr(ws.os, "replace", _boom) + with pytest.raises(OSError, match="crash during replace"): + update_manifest(workspace_dir, skills="skills/v2") + + # 原 manifest 未被破坏(内容不变且仍是合法 JSON) + assert (workspace_dir / "manifest.json").read_text() == original + assert json.loads((workspace_dir / "manifest.json").read_text()) + + # --------------------------------------------------------------------------- # record_run # ---------------------------------------------------------------------------