core/engine_state/__init__.py
Shay fbff161a2e
feat(W-023): revision-mismatch warning on engine-state load (L10b.2, ADR-0157) (#283)
* feat(W-022): ratify-proposal workflow_dispatch for mobile ratification

Adds .github/workflows/ratify-proposal.yml — a manually triggered
workflow that lets the operator ratify engine-authored proposals from
the GitHub mobile app without needing terminal access.

Inputs: proposal_id (required), review_date (default: today UTC),
operator_note (optional).  Runs `core teaching review --accept`,
commits the updated corpus + proposal log to main, and posts a
job summary with the accepted chain_id.

Shared CONTEMPLATION_ENABLED kill switch disables the entire
learning-arc loop (contemplation + ratification) with one toggle.

ADR-0155 / ADR-0057

* feat(W-023): revision-mismatch warning on engine-state load (L10b.2, ADR-0157)

ADR-0146 §Risks line 127 specified that load_manifest() should compare
written_at_revision against the current git SHA and warn if they differ,
but never refuse to load (reboot is recovery, not control flow).

- EngineStateStore.load_manifest() emits RuntimeWarning when stored and
  current revisions are both known and do not match
- Suppresses warning when either side is "unknown" (offline/packaged builds)
- Always returns the manifest; no state is cleared or rejected
- Pinned by 8 tests covering match, mismatch, unknown suppression, and
  missing/empty manifest edge cases

ADR-0156 §Out of scope closes; L10b.3 (reboot_event audit entry, W-024) remains.
2026-05-25 19:56:07 -07:00

178 lines
5.9 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
import warnings
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
manifest = json.loads(content)
# W-023 / ADR-0157 — revision-mismatch warning per ADR-0146 §Risks line 127.
# Never refuse to load; reboot is recovery, not control flow.
stored_rev = manifest.get("written_at_revision", "unknown")
current_rev = _git_revision()
if stored_rev not in ("unknown", "") and current_rev not in ("unknown", "") and stored_rev != current_rev:
warnings.warn(
f"engine_state checkpoint was written at revision {stored_rev!r} "
f"but the current revision is {current_rev!r}. "
"State may be stale after a code change. "
"Clear engine_state/ if you observe unexpected behaviour.",
RuntimeWarning,
stacklevel=2,
)
return manifest
def exists(self) -> bool:
return (self.path / "manifest.json").exists()
__all__ = ["EngineStateStore"]