ADR-0146 specified write-temp+rename for the engine-state checkpoint to prevent corruption on mid-write process termination. The W-008 implementation used Path.write_text directly, which truncates the target before writing — SIGINT/SIGKILL between truncate and write left a partial / empty file, breaking reboot recovery (or worse, silently restoring half-state). - engine_state._atomic_write_text: NamedTemporaryFile in target dir, flush + fsync, os.replace (atomic same-FS rename), best-effort cleanup of temp on failure - All three EngineStateStore.save_* methods route through the helper - Content bytes unchanged → round-trip regression guard passes Pinned by tests/test_adr_0156_atomic_checkpoint.py (9 tests): atomic create / overwrite / parent-mkdir, failed-replace preserves prior target, failed-replace cleans temp, temp lives in target dir (same-FS atomicity requirement), store-level failure preservation, round-trip content regression guard. CLI lanes: smoke (67) + cognition (120+1 skip) green. Out of scope (next L10b chunks): reboot_event audit entry (W-024), revision-mismatch warning on load (W-023), parent-dir fsync, cross- process locking.
163 lines
5.1 KiB
Python
163 lines
5.1 KiB
Python
"""Shape B engine-state persistence (ADR-0146).
|
|
|
|
engine_state/ is the mutable checkpoint directory for per-session engine
|
|
state that must survive reboot. It is NOT append-only (unlike substrate-
|
|
state); each checkpoint overwrites the previous.
|
|
|
|
Layout:
|
|
engine_state/recognizers.jsonl -- one DerivedRecognizer per line
|
|
engine_state/discovery_candidates.jsonl -- one DiscoveryCandidate per line
|
|
engine_state/manifest.json -- schema_version, git revision, turn_count
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Sequence
|
|
|
|
from recognition.anti_unifier import DerivedRecognizer
|
|
from teaching.discovery import DiscoveryCandidate
|
|
|
|
|
|
def _atomic_write_text(target: Path, content: str, *, encoding: str = "utf-8") -> None:
|
|
"""ADR-0156 (W-022) — atomic checkpoint write.
|
|
|
|
Write ``content`` to a temp file in the same directory as ``target``,
|
|
fsync it, then ``os.replace`` it into place. Same-directory rename is
|
|
atomic on POSIX (and on Windows since Python 3.3 via ``os.replace``).
|
|
A SIGINT/SIGKILL between ``write`` and ``replace`` leaves the prior
|
|
target file fully intact; an interrupted write leaves an orphan
|
|
temp file that is cleaned up on the next ``save_*`` call.
|
|
|
|
ADR-0146 §"File Operations and Invariants" specified this behavior;
|
|
pre-W-022 the writers used ``Path.write_text`` directly, which
|
|
truncated the target before writing and corrupted the checkpoint
|
|
on mid-write process termination.
|
|
"""
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
# NamedTemporaryFile with delete=False so we can rename out of its handle.
|
|
# ``dir=target.parent`` keeps the rename on the same filesystem (atomic).
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w",
|
|
encoding=encoding,
|
|
dir=str(target.parent),
|
|
prefix=f".{target.name}.",
|
|
suffix=".tmp",
|
|
delete=False,
|
|
) as fh:
|
|
tmp_path = Path(fh.name)
|
|
try:
|
|
fh.write(content)
|
|
fh.flush()
|
|
os.fsync(fh.fileno())
|
|
except BaseException:
|
|
try:
|
|
tmp_path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
raise
|
|
try:
|
|
os.replace(tmp_path, target)
|
|
except BaseException:
|
|
try:
|
|
tmp_path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
raise
|
|
|
|
_SCHEMA_VERSION = 1
|
|
_DEFAULT_DIR = (
|
|
Path(os.environ["CORE_ENGINE_STATE_DIR"])
|
|
if os.environ.get("CORE_ENGINE_STATE_DIR")
|
|
else Path(__file__).parents[1] / "engine_state"
|
|
)
|
|
|
|
|
|
def _git_revision() -> str:
|
|
try:
|
|
return (
|
|
subprocess.run(
|
|
["git", "rev-parse", "--short=12", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
).stdout.strip()
|
|
or "unknown"
|
|
)
|
|
except Exception:
|
|
return "unknown"
|
|
|
|
|
|
class EngineStateStore:
|
|
def __init__(self, path: Path | None = None) -> None:
|
|
self.path = path or _DEFAULT_DIR
|
|
|
|
def save_recognizers(self, recognizers: Sequence[DerivedRecognizer]) -> None:
|
|
lines = [r.to_json() for r in recognizers]
|
|
_atomic_write_text(
|
|
self.path / "recognizers.jsonl",
|
|
"\n".join(lines) + ("\n" if lines else ""),
|
|
)
|
|
|
|
def load_recognizers(self) -> list[DerivedRecognizer]:
|
|
p = self.path / "recognizers.jsonl"
|
|
if not p.exists():
|
|
return []
|
|
return [
|
|
DerivedRecognizer.from_json(line)
|
|
for line in p.read_text(encoding="utf-8").splitlines()
|
|
if line.strip()
|
|
]
|
|
|
|
def save_discovery_candidates(
|
|
self,
|
|
candidates: Sequence[DiscoveryCandidate],
|
|
) -> None:
|
|
lines = [
|
|
json.dumps(c.as_dict(), sort_keys=True, separators=(",", ":"))
|
|
for c in candidates
|
|
]
|
|
_atomic_write_text(
|
|
self.path / "discovery_candidates.jsonl",
|
|
"\n".join(lines) + ("\n" if lines else ""),
|
|
)
|
|
|
|
def load_discovery_candidates(self) -> list[DiscoveryCandidate]:
|
|
p = self.path / "discovery_candidates.jsonl"
|
|
if not p.exists():
|
|
return []
|
|
return [
|
|
DiscoveryCandidate.from_dict(json.loads(line))
|
|
for line in p.read_text(encoding="utf-8").splitlines()
|
|
if line.strip()
|
|
]
|
|
|
|
def save_manifest(self, turn_count: int) -> None:
|
|
manifest = {
|
|
"schema_version": _SCHEMA_VERSION,
|
|
"turn_count": turn_count,
|
|
"written_at_revision": _git_revision(),
|
|
}
|
|
_atomic_write_text(
|
|
self.path / "manifest.json",
|
|
json.dumps(manifest, sort_keys=True, indent=2),
|
|
)
|
|
|
|
def load_manifest(self) -> dict | None:
|
|
p = self.path / "manifest.json"
|
|
if not p.exists():
|
|
return None
|
|
content = p.read_text(encoding="utf-8").strip()
|
|
if not content:
|
|
return None
|
|
return json.loads(content)
|
|
|
|
def exists(self) -> bool:
|
|
return (self.path / "manifest.json").exists()
|
|
|
|
|
|
__all__ = ["EngineStateStore"]
|