diff options
| -rwxr-xr-x | scripts/m3_supervise.py | 39 |
1 files changed, 31 insertions, 8 deletions
diff --git a/scripts/m3_supervise.py b/scripts/m3_supervise.py index e5a2315..26dfb4a 100755 --- a/scripts/m3_supervise.py +++ b/scripts/m3_supervise.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """M3 验收监督(加固版):供 Hermes cron 每 5 分钟调用(no_agent)。 -状态机: idle -> phase1(--batch 30 20) -> phase2(--batch 50 100) -> done +状态机: idle -> phase1(--batch 30 20) -> phase2(--batch 50 20) -> done - flock 单实例防重入;抢不到锁静默退出 - 冻结运行: 首次启动把 Release 输出目录递归复制到 scripts/m3_frozen/<stamp>_<commit>/, v2 清单覆盖全部子目录文件 sha256;两阶段都从冻结副本运行,后续构建不会覆盖 @@ -11,7 +11,8 @@ - PASS 判定收紧(全部满足才 PASS,任一不满足即 FAIL/停机): 1) exitfile 退出码 == 0 2) `batch worlds=K days=D` 与固定 PHASES[phase] 完全一致(不用 state 里可篡改值) - 3) `world=` 行: 行数==K、索引恰为 0..K-1 无重复无缺失、seed==42+i + 3) `world=` 行: 行数==K、索引恰为 0..K-1 无重复无缺失(允许流式完成序)、seed==42+i; + 行尾 `elapsed_s=<秒>` 为追加观测字段,不参与判据 4) `batch_summary worlds=K passed=K failed=0` 5) 行 `M3_ACCEPTANCE=PASS` 6) 冻结清单 sha256 全部一致 + commit 绑定;v2 清单另查额外/缺失文件 @@ -45,7 +46,7 @@ STATE_PATH = ROOT / "scripts/m3_state.json" LOCK_PATH = ROOT / "scripts/m3_supervise.lock" LOG_DIR = ROOT / "scripts/m3_logs" FROZEN_ROOT = ROOT / "scripts/m3_frozen" -PHASES = {"phase1": (30, 20), "phase2": (50, 100)} +PHASES = {"phase1": (30, 20), "phase2": (50, 20)} NEXT = {"phase1": "phase2", "phase2": None} FRAG = "LivingVillage.Headless.dll" @@ -239,6 +240,14 @@ def read_exitcode(exitfile): return None +def world_line_verdict(line): + """world 行的判据 token;忽略行尾追加的 `elapsed_s=<秒>` 观测字段。""" + tokens = line.split() + while tokens and re.fullmatch(r"elapsed_s=\d+(\.\d+)?", tokens[-1]): + tokens.pop() + return tokens[-1] if tokens else "" + + def verify_batch(text, worlds, days): """完整批次判据校验(固定 worlds/days 由调用方从 PHASES 取)。 返回问题列表(空=满足全部收紧条件)。""" @@ -263,7 +272,8 @@ def verify_batch(text, worlds, days): if malformed: problems.append(f"malformed world lines: {malformed[:5]}") idxs = [i for i, _ in parsed] - if idxs != list(range(worlds)): + # 流式输出允许完成序非升序,判据是索引集合恰为 0..K-1(无重无缺)。 + if sorted(idxs) != list(range(worlds)): dup = sorted({i for i in idxs if idxs.count(i) > 1}) missing = sorted(set(range(worlds)) - set(idxs)) extra = sorted(set(idxs) - set(range(worlds))) @@ -271,7 +281,7 @@ def verify_batch(text, worlds, days): badseed = [i for i, s in parsed if s != 42 + i] if badseed: problems.append(f"seed binding mismatch (expect seed=42+i): worlds={badseed[:8]}") - bad = [l.split()[0] for l in wlines if not l.split() or l.split()[-1] != "OK"] + bad = [l.split()[0] for l in wlines if world_line_verdict(l) != "OK"] if bad: problems.append(f"non-OK world lines: {bad[:8]}{'...' if len(bad) > 8 else ''}") s = re.search(r"^batch_summary worlds=(\d+) passed=(\d+) failed=(\d+) ", text, re.M) @@ -405,7 +415,7 @@ def decide(state, alive): state["verdict"] = "PASS" if nxt is None: state["phase"] = "done" - return "save", state, f"{phase} PASS (commit={state.get('commit')}, frozen={state.get('frozen')}) -> M3 acceptance complete (30x20 + 50x100)" + return "save", state, f"{phase} PASS (commit={state.get('commit')}, frozen={state.get('frozen')}) -> M3 acceptance complete (30x20 + 50x20)" return f"launch:{nxt}", state, None state["verdict"] = "FAIL" state["phase"] = "failed" @@ -641,6 +651,19 @@ def selftest(): "batch worlds=2 days=1 x\nworld=0 seed=42 a OK\nworld=1 seed=43 a OK\nbatch_summary worlds=2 passed=1 failed=1 x\nM3_ACCEPTANCE=PASS\n", 2, 1))) check("verify missing verdict token", any("M3_ACCEPTANCE=PASS" in p for p in verify_batch( "batch worlds=1 days=1 x\nworld=0 seed=42 a OK\nbatch_summary worlds=1 passed=1 failed=0 x\nM3_ACCEPTANCE=FAIL\n", 1, 1))) + # 流式输出:完成序非升序 + 行尾 elapsed_s 追加字段,均应通过判据。 + check("verify out-of-order streaming worlds", verify_batch( + "batch worlds=3 days=1 x\nworld=2 seed=44 a OK elapsed_s=3.1\nworld=0 seed=42 a OK elapsed_s=1.2\n" + "world=1 seed=43 a OK elapsed_s=2.0\nbatch_summary worlds=3 passed=3 failed=0 x\nM3_ACCEPTANCE=PASS\n", 3, 1) == []) + check("verify elapsed suffix tolerated with progress lines", verify_batch( + "batch worlds=2 days=1 x\n[done 1/2] elapsed=0.9s world=1\nworld=1 seed=43 a OK elapsed_s=0.9\n" + "[done 2/2] elapsed=1.2s world=0\nworld=0 seed=42 a OK elapsed_s=1.2\n" + "batch_summary worlds=2 passed=2 failed=0 x\nM3_ACCEPTANCE=PASS\n", 2, 1) == []) + check("verify elapsed suffix does not mask BAD", any("non-OK" in p for p in verify_batch( + "batch worlds=1 days=1 x\nworld=0 seed=42 a BAD elapsed_s=1.0\n" + "batch_summary worlds=1 passed=0 failed=1 x\nM3_ACCEPTANCE=FAIL\n", 1, 1))) + check("world_line_verdict ignores elapsed", world_line_verdict("world=0 seed=42 a OK elapsed_s=1.0") == "OK" + and world_line_verdict("world=0 seed=42 a BAD elapsed_s=1.0") == "BAD") # ---- 冻结清单(合成目录, v2 递归 + 额外/缺失检测) ---- with tempfile.TemporaryDirectory() as td: @@ -741,8 +764,8 @@ def selftest(): a, s, m = decide(dict(base, phase="phase2", log=str(plog)), lambda pid, st, fr: False) check("phase2 with phase1 log -> FAIL (fixed PHASES)", s["phase"] == "failed" and s["verdict"] == "FAIL") plog2 = td / "p2.log" - plog2.write_text(make_batch_log(50, 100)) - a, s, m = decide(dict(base, phase="phase2", worlds=50, days=100, log=str(plog2)), lambda pid, st, fr: False) + plog2.write_text(make_batch_log(50, 20)) + a, s, m = decide(dict(base, phase="phase2", worlds=50, days=20, log=str(plog2)), lambda pid, st, fr: False) check("phase2 PASS -> done", s["phase"] == "done" and m and "complete" in m) (td / "e.exit").write_text("0\n") tampered = dict(base, worlds=1, days=1) |
