summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xscripts/m3_supervise.py39
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)