Files
rune/scripts/check_cron_engine_parity.py
Nick 4204667f0a feat(cron_engine): close PARTIAL gaps — dichotomy wave
Name-preserving GO port of the 14 audit-missing fns (to_dict, run_task
method+module, fire_on_task_complete, start/stop, _run_loop, _execute_task,
_log_execution/_log_error with 5MB rotation, get_cron_engine singleton family;
_parse_cron_field/_should_run carried as mirror-commented parseCronField/
shouldRun) + Python-exact ordered _save/_load (insertion order, ensure_ascii,
CronTask(**data) TypeError semantics), shlex.split port with oracle ValueError
messages, isoformat/pyISO parity, subprocess error-message translation
(FileNotFoundError/PermissionError/TimeoutExpired str(e) forms, ExitError →
returncode value). Phase B folded: injectable clock/sleep/exec/recordFailure
seams, ordered-JSON truth decoding, snapshot to_dict (no live aliasing).

Truth: 46-case truth_gap.json via the shared harness + wrapper shims
(frozen clock, hermetic <CE_HOME> rows, stopper-time _run_loop, neutralized
record_failure) — --verify byte-identical, fresh re-freeze byte-identical.
Battery: legacy replay 25/25, gap replay 46/46, 5 tests green, gofmt/vet/
build clean. Live parity 3/3 scenarios (31 ops) oracle-vs-GO identical;
CLI byte-identical both pins. Audit: cron_engine 27/27 FULL (83/192).
CI cron-engine gate extended (replay-gap + TestGapParity). Evidence:
verification/ce-*.
2026-10-14 02:14:01 +01:00

234 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""check_cron_engine_parity.py — LIVE parity for the cron_engine GO port
(#2479 dichotomy gap-close): drives the Python oracle (src/rune/
cron_engine.py) and the GO binary through IDENTICAL scenarios and diffs
their JSON outputs — beyond frozen-truth replay, this exercises the two
implementations LIVE (real subprocesses, real files) under a pinned
clock + hermetic HOME.
Python side mirrors the Go binary's `scenario` mode exactly (op set,
output shape). Paths are tokenized to <HOME> before comparison.
Usage: python3 scripts/check_cron_engine_parity.py <go-binary>
(build first: cd src/go/cron-engine && go build -o /tmp/cron-engine .)
"""
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "src"))
os.environ.setdefault("RUNE_CRON_ENGINE_CORE", "python")
from rune import cron_engine as ce # noqa: E402
import rune.subsystem_health as _SH # noqa: E402
_SH.record_failure = lambda *a, **k: True # hermetic: no ledger writes
FROZEN_NOW = "2026-10-13T09:20:00"
def frozen_clock(iso):
from datetime import datetime, timezone
base = datetime.fromisoformat(iso)
class FrozenDatetime(datetime):
@classmethod
def now(cls, tz=None):
return base.replace(tzinfo=tz) if tz is not None else base
return FrozenDatetime
class StopTime:
def __init__(self, engine):
self._e = engine
def sleep(self, _s):
if self._e is not None:
self._e.running = False
def run_python(sc):
ce.datetime = frozen_clock(sc["frozen_now"])
home = tempfile.mkdtemp(prefix="ce-live-py-")
os.environ["HOME"] = home
storage = os.path.join(home, ".rune", "cron.json")
logp = os.path.join(home, ".rune", "cron.log")
e = ce.CronEngine.__new__(ce.CronEngine)
e.storage_path = storage
e.tasks = {}
e.running = False
e.thread = None
e._last_run_minute = {}
e._load()
def rd(p):
return open(p).read() if os.path.exists(p) else None
def dump():
return {
"running": e.running,
"storage_path": e.storage_path,
"log_path": logp,
"tasks": [{"name": n, "task": t.to_dict()} for n, t in e.tasks.items()],
"_last_run_minute": dict(e._last_run_minute), # snapshot (no live aliasing)
}
steps = []
for op in sc["ops"]:
k = op["op"]
if k == "add":
t = e.add_task(op["name"], op["expr"], op["command"], op.get("description", ""))
steps.append({"op": "add", "task": t.to_dict()})
elif k == "enable":
steps.append({"op": "enable", "ok": e.enable_task(op["name"])})
elif k == "disable":
steps.append({"op": "disable", "ok": e.disable_task(op["name"])})
elif k == "remove":
steps.append({"op": "remove", "ok": e.remove_task(op["name"])})
elif k == "run":
steps.append({"op": "run", "result": e.run_task(op["name"])})
elif k == "fire":
steps.append({"op": "fire", "fired": e.fire_on_task_complete()})
elif k == "loop":
real = ce.time
ce.time = StopTime(e)
try:
e._run_loop()
finally:
ce.time = real
steps.append({"op": "loop"})
elif k == "start":
real = ce.time
ce.time = StopTime(e)
try:
e.start()
e.stop()
finally:
ce.time = real
steps.append({"op": "start+stop"})
elif k == "stop":
e.stop()
steps.append({"op": "stop"})
elif k == "save":
e._save()
steps.append({"op": "save", "ok": True})
elif k == "load":
e._load()
steps.append({"op": "load", "ok": True})
elif k == "storage":
steps.append({"op": "storage", "bytes": rd(storage)})
elif k == "log":
steps.append({"op": "log", "bytes": rd(logp)})
elif k == "state":
steps.append({"op": "state", "state": dump()})
else:
raise ValueError(k)
return {"home": home, "out": {"steps": steps, "final": dump()}}
def run_go(binary, sc_path):
r = subprocess.run([binary, "scenario", str(sc_path)],
capture_output=True, text=True, timeout=120)
if r.returncode != 0:
raise RuntimeError(f"go scenario failed: {r.stderr}")
# the binary's temp home differs per run — recover it from final.storage_path
out = json.loads(r.stdout)
home = os.path.dirname(os.path.dirname(out["final"]["storage_path"]))
return {"home": home, "out": out}
def tokenize(obj, home):
if isinstance(obj, str):
return obj.replace(home, "<HOME>")
if isinstance(obj, list):
return [tokenize(x, home) for x in obj]
if isinstance(obj, dict):
return {k: tokenize(v, home) for k, v in obj.items()}
return obj
SCENARIOS = [
# 1: lifecycle + run envelopes + storage/log bytes
{"frozen_now": FROZEN_NOW, "ops": [
{"op": "add", "name": "z-first", "expr": "on_complete", "command": "echo one"},
{"op": "add", "name": "a-second", "expr": "*/5 * * * *", "command": "echo '中文 twø'", "description": "d-ü"},
{"op": "add", "name": "bad", "expr": "* * * * *", "command": "nope-cmd-xyz"},
{"op": "add", "name": "quoted", "expr": "* * * * *", "command": "echo 'unterminated"},
{"op": "state"},
{"op": "run", "name": "z-first"},
{"op": "run", "name": "bad"},
{"op": "run", "name": "quoted"},
{"op": "run", "name": "ghost"},
{"op": "disable", "name": "a-second"},
{"op": "fire"},
{"op": "storage"},
{"op": "log"},
{"op": "state"},
]},
# 2: scheduler loop — matching, non-matching, dedup, disabled
{"frozen_now": FROZEN_NOW, "ops": [
{"op": "add", "name": "match", "expr": "* * * * *", "command": "echo m"},
{"op": "add", "name": "nomatch", "expr": "5 4 * * *", "command": "echo n"},
{"op": "add", "name": "off", "expr": "* * * * *", "command": "echo o"},
{"op": "disable", "name": "off"},
{"op": "loop"},
{"op": "loop"},
{"op": "storage"},
{"op": "log"},
{"op": "state"},
]},
# 3: persistence round-trip + start/stop + remove
{"frozen_now": FROZEN_NOW, "ops": [
{"op": "add", "name": "keep", "expr": "0 9 * * 1", "command": "echo k"},
{"op": "add", "name": "drop", "expr": "0 9 * * 1", "command": "echo d"},
{"op": "remove", "name": "drop"},
{"op": "run", "name": "keep"},
{"op": "storage"},
{"op": "start"},
{"op": "state"},
{"op": "stop"},
]},
]
def main():
binary = sys.argv[1] if len(sys.argv) > 1 else "/tmp/cron-engine"
ok = 0
for i, sc in enumerate(SCENARIOS, 1):
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
json.dump(sc, f)
sc_path = f.name
try:
py = run_python(sc)
go = run_go(binary, sc_path)
finally:
os.unlink(sc_path)
pyj = tokenize(py["out"], py["home"])
goj = tokenize(go["out"], go["home"])
if pyj == goj:
ok += 1
print(f"scenario {i}: LIVE PARITY OK ({len(sc['ops'])} ops)")
else:
print(f"scenario {i}: LIVE PARITY FAILED")
pj, gj = json.dumps(pyj, indent=1, sort_keys=True), json.dumps(goj, indent=1, sort_keys=True)
for pl, gl in zip(pj.splitlines(), gj.splitlines()):
if pl != gl:
print(f" py: {pl}")
print(f" go: {gl}")
break
return 1
print(f"LIVE PARITY: {ok}/{len(SCENARIOS)} scenarios identical (oracle vs GO)")
return 0
if __name__ == "__main__":
sys.exit(main())