feat(engine-state): generation-dir atomic checkpoint (ADR-0219)

Closes the cross-file checkpoint-atomicity gap in ADR-0156.  The four
checkpoint files now live in a committed gen-NNNN/ directory; the single
atomic os.replace of a 'current' pointer file is the commit boundary.
A kill before the pointer swap leaves the prior committed generation
intact; a kill after commits the new generation.  Unreferenced gen dirs
are ignored.  ADR-0156's deferred parent-dir fsync is also closed.

Key changes:
- engine_state/__init__.py: begin_generation() + commit_generation() +
  _resolve_dir() for all load_* methods.  Flat-layout legacy checkpoints
  migrate into gen-0000 on first begin_generation call.  GC retains K=2
  committed generations.
- chat/runtime.py: checkpoint_engine_state uses the two-phase commit;
  finalize_turn_trace_hash no longer writes discovery_candidates outside
  the generation sequence (the second unguarded write path is closed).
- evals/l10_continuity/runner.py: _inject_orphan_tmp updated to inject
  the two orphan shapes of the generation model.
- tests/test_adr_0219_generation_checkpoint.py: 18 tests, one per
  acceptance-gate bullet + biting mutation variant each.

L10 lane: all_gates_pass=true; versor_condition<1e-6 throughout.
Smoke: 95 passed. Runtime: 20 passed.
This commit is contained in:
Shay 2026-06-15 01:08:36 -07:00
parent b1708a76d5
commit 103def317d
6 changed files with 811 additions and 101 deletions

View file

@ -829,7 +829,6 @@ class ChatRuntime:
recognizer = derive_recognizer(tuple(self._pending_recognizer_examples))
self._recognizer_registry.register(recognizer)
self._pending_recognizer_examples.clear()
store.save_recognizers(self._recognizer_registry.all())
candidates_to_save = self._pending_candidates
if self.config.auto_contemplate and candidates_to_save:
from teaching.contemplation import contemplate
@ -838,22 +837,28 @@ class ChatRuntime:
contemplate(c, vault_probe=vault_probe)
for c in candidates_to_save
]
store.save_discovery_candidates(candidates_to_save)
# ADR-0219 — generation-dir atomic checkpoint. All files are written
# into a fresh gen-NNNN/ directory; the commit is the atomic pointer
# swap in commit_generation. A kill before the swap leaves the prior
# committed generation intact. The manifest is the last file written
# into the gen dir (audit convention); the pointer swap is the true
# commit boundary.
gen_num, gen_dir = store.begin_generation()
gen_store = EngineStateStore(gen_dir)
gen_store.save_recognizers(self._recognizer_registry.all())
gen_store.save_discovery_candidates(candidates_to_save)
# Shape B+ (schema v2): persist the lived session state (field, vault,
# anchor, graph, referents, dialogue) BEFORE the manifest, so the
# manifest stays the last durable act — the commit marker for the turn.
# Opt-in (config.persist_session_state): a deliberate resume mode, off by
# default so one-shot runtimes don't pay the per-turn snapshot cost.
# anchor, graph, referents, dialogue) BEFORE the manifest.
# Opt-in (config.persist_session_state): off by default.
if self._context is not None and self.config.persist_session_state:
store.save_session_state(self._context.snapshot())
# L11 — stamp the engine's identity and its lineage parent (the identity
# of the prior checkpoint). Same substrate -> identity == parent (a stable
# life); a ratified substrate change -> identity != parent (the bump).
store.save_manifest(
gen_store.save_session_state(self._context.snapshot())
# L11 — stamp the engine's identity and its lineage parent.
gen_store.save_manifest(
self._turn_count,
engine_identity=self._engine_identity,
parent_engine_identity=self._loaded_engine_identity,
)
store.commit_generation(gen_num)
self._loaded_engine_identity = self._engine_identity
def _count_pending_proposals(self) -> int:
@ -1027,10 +1032,13 @@ class ChatRuntime:
``compute_trace_hash`` produces the turn's canonical
SHA-256. Stamps the trace_hash onto the most recent
TurnEvent and any DiscoveryCandidate emitted during this
turn (i.e., the unstamped tail of ``_pending_candidates``),
then re-persists the candidates checkpoint so the on-disk
audit trail names the originating turn instead of the
prior empty-string default.
turn (i.e., the unstamped tail of ``_pending_candidates``).
The stamped candidates are held in memory and persisted at the
next ``checkpoint_engine_state`` call (ADR-0219: only
``checkpoint_engine_state`` writes to the checkpoint dir, so the
trace-hash back-stamp is always part of a coherent committed
generation rather than a mid-turn partial write).
No-op when ``trace_hash`` is empty (pre-pipeline call sites,
refusal stub path). Idempotent: stamping a tail whose
@ -1043,7 +1051,6 @@ class ChatRuntime:
last_event = self.turn_log[-1]
if not last_event.trace_hash:
self.turn_log[-1] = replace(last_event, trace_hash=trace_hash)
stamped = False
for idx in range(len(self._pending_candidates) - 1, -1, -1):
cand = self._pending_candidates[idx]
if cand.source_turn_trace:
@ -1051,11 +1058,6 @@ class ChatRuntime:
self._pending_candidates[idx] = replace(
cand, source_turn_trace=trace_hash
)
stamped = True
if stamped and self._engine_state_store is not None:
self._engine_state_store.save_discovery_candidates(
self._pending_candidates
)
def first_admitted_recognizer(self):
if not self.config.recognition_grounded_graph:

View file

@ -0,0 +1,140 @@
# ADR-0219 — Generation-dir atomic checkpoint (L10 continuity hardening)
Status: accepted
Date: 2026-06-15
Extends: ADR-0146 (engine-state persistence), ADR-0156 (per-file atomic writes)
## Context
ADR-0156 (W-022) introduced `_atomic_write_text` to guarantee **per-file**
atomicity: each of the four checkpoint files (`recognizers.jsonl`,
`discovery_candidates.jsonl`, `session_state.json`, `manifest.json`) is
written via `temp + os.replace`, so a kill mid-write leaves the prior file
intact.
ADR-0156 §"Out of scope" explicitly excluded **cross-file atomicity**:
> "There is no cross-file consistency guarantee: recognizers and candidates
> are written in separate atomic ops, so a kill between them leaves the
> store in a mixed state (e.g., recognizers@N over manifest@N-1)."
The flat layout is therefore not a committed state — it is four
independently-atomic files with no shared commit boundary. L10's telos
("one continuous life") requires that a reboot always resumes from a
**coherent, complete** checkpoint, not a mixed-generation one.
A second deficiency: `finalize_turn_trace_hash` (ADR-0153) called
`save_discovery_candidates` **outside** the main checkpoint sequence,
creating a second write path into the checkpoint directory without going
through the generation commit.
ADR-0156 also deferred the **parent-directory fsync** after `os.replace`.
POSIX strictly requires fsyncing the directory to make the rename metadata
durable. This ADR closes that deferral.
## Decision
### Generation-dir model
The checkpoint becomes a **committed generation**: a complete, fsync-ed
`gen-NNNN/` directory pointed to by a single `current` file. The atomic
replacement of `current` is the only commit boundary.
```
engine_state/
gen-0041/
recognizers.jsonl
discovery_candidates.jsonl
session_state.json
manifest.json
current ← one line: "gen-0041"
```
Two-phase commit protocol:
1. **`begin_generation()`** — allocate `gen-NNNN/`, return `(gen_num, gen_dir)`.
2. Write all checkpoint files into `gen_dir` via `EngineStateStore(gen_dir).save_*`.
3. **`commit_generation(gen_num)`** — fsync gen dir → `os.replace(tmp_current, current)` → fsync parent → GC old gens.
A SIGKILL before the `os.replace` in step 3 leaves the prior `current`
intact. A SIGKILL after commits the new generation. Incomplete `gen-NNNN/`
directories with no `current` entry are garbage, ignored by all load paths.
### `finalize_turn_trace_hash` write path closed
`finalize_turn_trace_hash` no longer calls `save_discovery_candidates`.
The in-memory candidates carry the updated `source_turn_trace`; they are
persisted atomically as part of the next `checkpoint_engine_state` call.
(If the process dies between back-stamp and next checkpoint, the trace hash
is lost from the candidate but the candidate itself survives in the prior
committed generation — acceptable: the trace hash is audit metadata, not
state-determining.)
### Legacy flat layout — explicit migration
On the first `begin_generation()` call against a flat-layout store (pre-0219
`manifest.json` at root, no `current`):
1. Flat files are copied into `gen-0000/` (fsync-ed).
2. `current` is written pointing to `gen-0000`.
3. The next generation (`gen-0001`) is allocated and returned.
Load methods fall back to the flat root when no `current` exists, so a v1
or v2 checkpoint is readable without a write (the migration only happens at
write time).
### Parent-dir fsync
`commit_generation` fsyncs both the generation directory (content durability)
and the parent directory after the `os.replace` (pointer rename metadata
durability), closing the ADR-0156 deferral.
### GC
`commit_generation` retains the last K=2 committed generations (the current
and its predecessor) and prunes older ones. K≥2 ensures the predecessor is
available if the committed gen dir is somehow corrupted after commit.
Pruned generation names are logged at DEBUG level.
## Implementation
- `engine_state/__init__.py`: `begin_generation`, `commit_generation`,
`_current_gen_dir`, `_resolve_dir`, `_gc_old_generations`, `_fsync_dir`.
All `load_*` methods updated to use `_resolve_dir()`. `exists()` checks
both layouts. `save_*` methods unchanged (write relative to `self.path`,
so they naturally write into a gen dir when instantiated as
`EngineStateStore(gen_dir)`).
- `chat/runtime.py`: `checkpoint_engine_state` uses the two-phase commit.
`finalize_turn_trace_hash` no longer calls `save_discovery_candidates`.
- `evals/l10_continuity/runner.py`: `_inject_orphan_tmp` updated to inject
both orphan shapes (unreferenced gen dir + torn `current` temp file).
## Acceptance gate
All of the following must hold (proven by `tests/test_adr_0219_generation_checkpoint.py`):
- Crash before pointer swap restores the prior committed generation.
- Crash after pointer swap restores the new generation.
- Incomplete gen dirs (not pointed to by `current`) are ignored on load.
- No load path mixes files across generations.
- Legacy flat layout migrates explicitly on first write; reads cleanly without migration.
- No normalization or repair on restore.
- `versor_condition < 1e-6` throughout (proven by L10 lane).
## Invariants pinned by tests
`tests/test_adr_0219_generation_checkpoint.py`:
- `test_fresh_store_writes_gen0000_and_current` — first checkpoint creates gen-0000 + current.
- `test_second_checkpoint_advances_generation` — gen-0001 replaces gen-0000.
- `test_orphan_gen_dir_ignored_before_pointer_swap` — unreferenced gen-9999 is invisible.
- `test_torn_current_tmp_ignored``.current.*.tmp` orphan does not affect load.
- `test_no_cross_generation_mixing` — load always reads from a single gen dir.
- `test_legacy_flat_layout_migrates_on_first_write` — flat state → gen-0000 on write.
- `test_legacy_flat_layout_readable_without_write` — load falls back to flat root.
- `test_commit_point_matches_turn_count` — recovered turn_count == committed turns.
- `test_gc_retains_last_two_generations` — GC prunes only generations outside the window.
## Related
- ADR-0146 — engine-state persistence foundation
- ADR-0156 — per-file atomic writes (extended here to cross-file)
- ADR-0157 — revision-mismatch warning on load (unaffected)
- ADR-0158 — reboot event audit (unaffected)
- L10 continuity hardening brief pack: `docs/handoff/l10-continuity-hardening-briefs-2026-06-15.md`

View file

@ -1,19 +1,37 @@
"""Shape B engine-state persistence (ADR-0146).
"""Generation-dir checkpoint persistence (ADR-0146 / ADR-0156 / ADR-0219).
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.
state that must survive reboot. Each checkpoint is now an atomic committed
**generation**: a complete, fsync-ed ``gen-NNNN/`` directory pointed to by a
single ``current`` file whose atomic replacement is the commit boundary.
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
Layout (ADR-0219 generation-dir model):
engine_state/
gen-0041/
recognizers.jsonl -- one DerivedRecognizer per line
discovery_candidates.jsonl -- one DiscoveryCandidate per line
session_state.json -- bit-exact field/vault/anchor/graph snapshot
manifest.json -- schema_version, git revision, turn_count
current -- one line: "gen-0041"; pointer swap = commit
Commit is the single atomic ``os.replace`` of ``current``. A kill before the
swap leaves the prior ``current`` intact (the prior generation is the committed
state). A kill after the swap commits the new generation. Incomplete
``gen-NNNN/`` directories without a ``current`` entry are garbage, ignored.
**Legacy (flat) layout read-only fallback and migration:**
A pre-0219 flat ``engine_state/`` (manifest.json at root, no ``current``)
is read transparently by all ``load_*`` methods. On the first
``begin_generation()`` call, flat files are copied into ``gen-0000/`` and
``current`` is written; subsequent checkpoints use the generation model.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import stat
import subprocess
import tempfile
@ -25,6 +43,18 @@ from typing import Sequence
from recognition.anti_unifier import DerivedRecognizer
from teaching.discovery import DiscoveryCandidate
_logger = logging.getLogger(__name__)
_SCHEMA_VERSION = 2 # v2 adds session_state.json (Shape B+ lived-state persistence)
_GEN_PREFIX = "gen-"
_CURRENT_FILE = "current"
_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 _atomic_write_text(target: Path, content: str, *, encoding: str = "utf-8") -> None:
"""ADR-0156 (W-022) — atomic checkpoint write.
@ -71,12 +101,20 @@ def _atomic_write_text(target: Path, content: str, *, encoding: str = "utf-8") -
pass
raise
_SCHEMA_VERSION = 2 # v2 adds session_state.json (Shape B+ lived-state persistence)
_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 _fsync_dir(dir_path: Path) -> None:
"""Fsync a directory file descriptor.
Required for POSIX crash-safety: fsyncing directory metadata makes
rename/create operations inside it durable. Called after all files in a
generation directory are written (before pointer swap) and after the
pointer swap itself (to make the new ``current`` durable).
"""
fd = os.open(str(dir_path), os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
@lru_cache(maxsize=1)
@ -115,9 +153,158 @@ class IncompatibleEngineStateError(RuntimeError):
class EngineStateStore:
"""Manages the engine-state checkpoint directory.
Two layouts are supported:
- **Generation-dir** (ADR-0219): ``gen-NNNN/`` dirs pointed to by
``current``. ``begin_generation`` + ``commit_generation`` form the
two-phase commit. This is the active layout for all new writes.
- **Flat** (legacy pre-0219): files at the store root. ``load_*``
methods fall back to this when no ``current`` file exists, so old
checkpoints are readable without migration. The first
``begin_generation`` call migrates flat state into ``gen-0000/`` and
writes ``current``.
The ``save_*`` / ``load_*`` methods write/read relative to ``self.path``
directly. In the generation-dir model, callers pass a temporary
``EngineStateStore(gen_dir)`` instance so ``save_*`` naturally writes
into the pending generation directory. Load methods resolve the active
generation via ``_resolve_dir()`` (or ``self.path`` for flat layout).
"""
def __init__(self, path: Path | None = None) -> None:
self.path = path or _DEFAULT_DIR
# ------------------------------------------------------------------
# Generation-dir protocol (ADR-0219)
# ------------------------------------------------------------------
def _current_gen_dir(self) -> Path | None:
"""Return the currently committed generation directory, or None.
Returns None when no ``current`` file exists (flat layout or fresh
store) or when the pointed-to directory does not exist.
"""
current_file = self.path / _CURRENT_FILE
if not current_file.exists():
return None
gen_name = current_file.read_text(encoding="utf-8").strip()
gen_dir = self.path / gen_name
return gen_dir if gen_dir.is_dir() else None
def _resolve_dir(self) -> Path:
"""Return the directory to read checkpoint files from.
Generation-dir layout: the committed gen dir (resolved via ``current``).
Flat / fresh layout: ``self.path`` directly.
"""
gen_dir = self._current_gen_dir()
return gen_dir if gen_dir is not None else self.path
def begin_generation(self) -> tuple[int, Path]:
"""Allocate the next pending generation directory and return (gen_num, gen_dir).
If a flat-layout checkpoint exists at the store root (pre-0219 migration),
it is atomically wrapped into ``gen-0000/`` and ``current`` is written
before the new generation is allocated. This migration happens at most
once per store.
The returned ``gen_dir`` is an empty (or freshly created) directory.
Write all checkpoint files into it, then call ``commit_generation``.
An exception before ``commit_generation`` leaves the prior committed
generation intact the incomplete ``gen-NNNN/`` is treated as garbage
on the next load (no ``current`` update occurred).
"""
current_file = self.path / _CURRENT_FILE
if not current_file.exists():
flat_manifest = self.path / "manifest.json"
if flat_manifest.exists():
# Migrate flat layout into gen-0000 before proceeding.
gen0 = self.path / f"{_GEN_PREFIX}0000"
gen0.mkdir(parents=True, exist_ok=True)
for fname in (
"manifest.json",
"recognizers.jsonl",
"discovery_candidates.jsonl",
"session_state.json",
):
src = self.path / fname
if src.exists():
shutil.copy2(src, gen0 / fname)
_fsync_dir(gen0)
_atomic_write_text(current_file, f"{_GEN_PREFIX}0000")
_fsync_dir(self.path)
_logger.info("engine_state: migrated flat layout to gen-0000")
next_num = 1
else:
# Fresh store — first generation.
next_num = 0
else:
current_name = current_file.read_text(encoding="utf-8").strip()
current_num = int(current_name[len(_GEN_PREFIX):])
next_num = current_num + 1
gen_name = f"{_GEN_PREFIX}{next_num:04d}"
gen_dir = self.path / gen_name
gen_dir.mkdir(parents=True, exist_ok=True)
return next_num, gen_dir
def commit_generation(self, gen_num: int, *, keep: int = 2) -> None:
"""Atomically commit a completed generation as the new checkpoint.
Sequence (all steps needed for POSIX crash-safety):
1. Fsync the generation directory (content durability).
2. Atomic ``os.replace`` of ``current`` pointer (the commit).
3. Fsync the parent directory (pointer-rename metadata durability).
4. GC old generations, retaining the last ``keep`` committed ones.
A SIGKILL before step 2 leaves the prior ``current`` intact.
A SIGKILL between steps 2 and 3 may lose the pointer on a hard
crash, but ``os.replace`` is atomic at the kernel level so the
pointer is either the old or the new value never torn.
"""
gen_name = f"{_GEN_PREFIX}{gen_num:04d}"
gen_dir = self.path / gen_name
_fsync_dir(gen_dir)
_atomic_write_text(self.path / _CURRENT_FILE, gen_name)
_fsync_dir(self.path)
pruned = self._gc_old_generations(committed_num=gen_num, keep=keep)
if pruned:
_logger.debug("engine_state GC: pruned old generations %s", pruned)
def _gc_old_generations(self, *, committed_num: int, keep: int = 2) -> list[str]:
"""Prune generation directories older than the retention window.
Retains the ``keep`` most-recent committed generations (including
the just-committed one). Unreferenced gen dirs from an in-progress
write (gen_num > committed_num) are left alone they are garbage
by definition but harmless, and pruning them here would require
knowing which one is in-progress, which we do not.
"""
cutoff = committed_num - keep + 1 # prune gen_num < cutoff
pruned: list[str] = []
try:
for item in self.path.iterdir():
if not item.is_dir() or not item.name.startswith(_GEN_PREFIX):
continue
try:
num = int(item.name[len(_GEN_PREFIX):])
except ValueError:
continue
if num < cutoff:
shutil.rmtree(item, ignore_errors=True)
pruned.append(item.name)
except OSError:
pass # best-effort; GC failure never blocks a checkpoint
return sorted(pruned)
# ------------------------------------------------------------------
# Save methods — write to self.path (used both for flat layout and,
# via EngineStateStore(gen_dir), to write into a pending generation).
# ------------------------------------------------------------------
def save_recognizers(self, recognizers: Sequence[DerivedRecognizer]) -> None:
lines = [r.to_json() for r in recognizers]
_atomic_write_text(
@ -125,16 +312,6 @@ class EngineStateStore:
"\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],
@ -148,16 +325,6 @@ class EngineStateStore:
"\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,
@ -167,12 +334,15 @@ class EngineStateStore:
) -> None:
"""Write the checkpoint manifest.
``engine_identity`` (L11) stamps the content-derived identity the engine
was running under when this checkpoint was written; ``parent_engine_
identity`` links it to the identity of the prior checkpoint, forming a
git-like lineage chain (the parent differs only across a ratified
substrate change). Both are additive-optional an empty string omits the
key, preserving the manifest bytes for pre-L11 callers.
In the generation-dir model, the manifest is the last file written
into the pending gen dir; ``commit_generation`` then swaps the
``current`` pointer. The manifest is no longer the commit boundary
the pointer swap is.
``engine_identity`` (L11) stamps the content-derived identity the
engine was running under when this checkpoint was written.
``parent_engine_identity`` links it to the identity of the prior
checkpoint, forming a lineage chain. Both are additive-optional.
"""
manifest: dict = {
"schema_version": _SCHEMA_VERSION,
@ -188,8 +358,45 @@ class EngineStateStore:
json.dumps(manifest, sort_keys=True, indent=2),
)
def save_session_state(self, snapshot: dict) -> None:
"""Persist the lived session state (Shape B+ / schema v2).
``snapshot`` is ``SessionContext.snapshot()`` a bit-exact, JSON-safe
dict of the field, vault, anchor, graph, referents, and dialogue.
In the generation-dir model, written before ``save_manifest`` inside
the pending gen dir.
"""
_atomic_write_text(
self.path / "session_state.json",
json.dumps(snapshot, sort_keys=True, separators=(",", ":")),
)
# ------------------------------------------------------------------
# Load methods — resolve via _resolve_dir() (gen dir or flat root).
# ------------------------------------------------------------------
def load_recognizers(self) -> list[DerivedRecognizer]:
p = self._resolve_dir() / "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 load_discovery_candidates(self) -> list[DiscoveryCandidate]:
p = self._resolve_dir() / "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 load_manifest(self) -> dict | None:
p = self.path / "manifest.json"
p = self._resolve_dir() / "manifest.json"
if not p.exists():
return None
content = p.read_text(encoding="utf-8").strip()
@ -211,7 +418,11 @@ class EngineStateStore:
# Never refuse to load; reboot is recovery, not control flow.
stored_rev = manifest.get("written_at_revision", "unknown")
current_rev = get_git_revision()
if stored_rev not in ("unknown", "") and current_rev not in ("unknown", "") and stored_rev != current_rev:
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}. "
@ -222,27 +433,13 @@ class EngineStateStore:
)
return manifest
def save_session_state(self, snapshot: dict) -> None:
"""Persist the lived session state (Shape B+ / schema v2).
``snapshot`` is ``SessionContext.snapshot()`` a bit-exact, JSON-safe
dict of the field, vault, anchor, graph, referents, and dialogue. Written
atomically (ADR-0156). Save this BEFORE ``save_manifest`` so the manifest
stays the last durable act of a checkpoint (the commit marker).
"""
_atomic_write_text(
self.path / "session_state.json",
json.dumps(snapshot, sort_keys=True, separators=(",", ":")),
)
def load_session_state(self) -> dict | None:
"""Load the lived session state, or None when absent (v1 checkpoint).
A v1 checkpoint has no ``session_state.json`` returning None lets the
caller fall back to a fresh session (today's Shape B behavior), so old
checkpoints stay loadable.
caller fall back to a fresh session (the historical Shape B behavior).
"""
p = self.path / "session_state.json"
p = self._resolve_dir() / "session_state.json"
if not p.exists():
return None
content = p.read_text(encoding="utf-8").strip()
@ -251,6 +448,11 @@ class EngineStateStore:
return json.loads(content)
def exists(self) -> bool:
"""True if a committed checkpoint exists (generation-dir or flat layout)."""
# Generation-dir layout: current pointer exists and points to a gen dir.
if (self.path / _CURRENT_FILE).exists():
return self._current_gen_dir() is not None
# Flat legacy layout: manifest at root.
return (self.path / "manifest.json").exists()

View file

@ -105,17 +105,33 @@ def _new_runtime(config: RuntimeConfig, engine_state_dir: Path) -> ChatRuntime:
def _inject_orphan_tmp(engine_state_dir: Path) -> None:
"""Simulate a kill mid-checkpoint-write: leave an orphan temp file.
"""Simulate a kill mid-checkpoint-write under the generation-dir model (ADR-0219).
ADR-0156 writes ``content`` to a ``.<name>.<rand>.tmp`` file, fsyncs, then
``os.replace``s it into place. A SIGKILL between fsync and replace leaves
exactly such an orphan with the *real* target fully intact. The loader reads
only the canonical filenames, so the orphan must be ignored. We write a
deliberately-corrupt orphan to prove the loader never reads it.
Two orphan shapes, both harmless to a correct loader:
1. An unreferenced generation directory (kill before the ``current`` pointer
swap): ``gen-9999/`` exists with content, but ``current`` still names the
prior committed generation. The loader follows ``current``; the
unreferenced gen dir is invisible to it.
2. A torn ``current`` temp file (kill during the ``os.replace`` of
``current``): ``.current.deadbeef.tmp`` exists, but ``os.replace`` is
atomic so ``current`` is either the old or the new value never the temp.
The loader reads only the canonical ``current`` filename.
Neither orphan is reachable from a consistent load path.
"""
engine_state_dir.mkdir(parents=True, exist_ok=True)
orphan = engine_state_dir / ".manifest.json.deadbeef.tmp"
orphan.write_text("{ this is a torn, half-written checkpoint <<<", encoding="utf-8")
# Orphan 1: unreferenced gen dir (kill before pointer swap)
orphan_gen = engine_state_dir / "gen-9999"
orphan_gen.mkdir(exist_ok=True)
(orphan_gen / "manifest.json").write_text(
'{ "TORN": true, "note": "this gen was never committed via current" }',
encoding="utf-8",
)
# Orphan 2: torn current temp file (kill during pointer swap)
torn_current = engine_state_dir / ".current.deadbeef.tmp"
torn_current.write_text("gen-9999", encoding="utf-8")
def read_recovered_turn_count(engine_state_dir: Path) -> int | None:

View file

@ -109,8 +109,11 @@ def test_pipeline_back_stamps_pending_discovery_candidates(
def test_persisted_candidates_jsonl_carries_trace_hash(
tmp_path: Path,
) -> None:
"""The on-disk ``discovery_candidates.jsonl`` checkpoint reflects the
back-stamped trace_hash (not the empty default)."""
"""The persisted discovery candidates carry the back-stamped trace_hash.
Uses the store's public load interface (load_discovery_candidates) rather
than reading file paths directly, so the test is layout-agnostic.
"""
subject = _pick_cold_cause_subject()
state_path = tmp_path / "engine_state"
runtime = ChatRuntime(
@ -121,25 +124,24 @@ def test_persisted_candidates_jsonl_carries_trace_hash(
result = pipe.run(f"What causes {subject}?")
runtime.checkpoint_engine_state()
candidates_path = state_path / "discovery_candidates.jsonl"
if not candidates_path.exists():
from engine_state import EngineStateStore
loaded = EngineStateStore(state_path).load_discovery_candidates()
if not loaded:
pytest.skip("checkpoint did not write candidates this turn")
lines = [
json.loads(line)
for line in candidates_path.read_text("utf-8").splitlines()
if line.strip()
]
cold = [
ln
for ln in lines
if ln["proposed_chain"]["subject"] == subject
and ln["proposed_chain"]["intent"] == "cause"
c
for c in loaded
if c.proposed_chain.get("subject") == subject
and c.proposed_chain.get("intent") == "cause"
]
if not cold:
pytest.skip("discovery did not fire under default config; fixture stale")
for ln in cold:
assert ln["source_turn_trace"] == result.trace_hash
for c in cold:
assert c.source_turn_trace == result.trace_hash, (
f"persisted candidate source_turn_trace must equal the trace_hash; "
f"got {c.source_turn_trace!r} vs {result.trace_hash!r}"
)
def test_finalize_is_noop_for_empty_trace_hash(tmp_path: Path) -> None:

View file

@ -0,0 +1,348 @@
"""ADR-0219 — Generation-dir atomic checkpoint.
Each test covers one bullet of the acceptance gate and includes a ``*_bites``
mutation variant (CLAUDE.md schema-as-proof discipline: a predicate that cannot
fail under the violation it nominally catches is decoration, not proof).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from engine_state import EngineStateStore
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_gen(store: EngineStateStore, turn_count: int) -> int:
"""Write one generation checkpoint and return the gen_num."""
gen_num, gen_dir = store.begin_generation()
gs = EngineStateStore(gen_dir)
gs.save_recognizers([])
gs.save_discovery_candidates([])
gs.save_manifest(turn_count)
store.commit_generation(gen_num)
return gen_num
# ---------------------------------------------------------------------------
# Gate: first checkpoint creates gen-0000 + current pointer
# ---------------------------------------------------------------------------
def test_fresh_store_writes_gen0000_and_current(tmp_path: Path) -> None:
store = EngineStateStore(tmp_path)
gen_num = _write_gen(store, turn_count=1)
assert gen_num == 0
assert (tmp_path / "gen-0000").is_dir()
assert (tmp_path / "current").exists()
assert (tmp_path / "current").read_text("utf-8").strip() == "gen-0000"
assert (tmp_path / "gen-0000" / "manifest.json").exists()
def test_fresh_store_writes_gen0000_and_current_bites(tmp_path: Path) -> None:
"""Without commit_generation the current pointer is absent."""
store = EngineStateStore(tmp_path)
_gen_num, gen_dir = store.begin_generation()
EngineStateStore(gen_dir).save_manifest(1)
# No commit_generation — current pointer must not exist yet.
assert not (tmp_path / "current").exists(), (
"current pointer must only be written by commit_generation, not begin_generation"
)
# ---------------------------------------------------------------------------
# Gate: second checkpoint advances the generation
# ---------------------------------------------------------------------------
def test_second_checkpoint_advances_generation(tmp_path: Path) -> None:
store = EngineStateStore(tmp_path)
_write_gen(store, turn_count=1)
gen_num2 = _write_gen(store, turn_count=2)
assert gen_num2 == 1
assert (tmp_path / "current").read_text("utf-8").strip() == "gen-0001"
manifest = store.load_manifest()
assert manifest is not None
assert manifest["turn_count"] == 2
def test_second_checkpoint_advances_generation_bites(tmp_path: Path) -> None:
"""Stale current (not advanced) → stale turn_count."""
store = EngineStateStore(tmp_path)
_write_gen(store, turn_count=1)
# Deliberately skip the second commit and write gen-0001 without committing.
_gen_num2, gen_dir2 = store.begin_generation()
EngineStateStore(gen_dir2).save_manifest(2)
# current still points to gen-0000 (turn_count=1).
manifest = store.load_manifest()
assert manifest is not None
assert manifest["turn_count"] == 1, "uncommitted gen must not be visible via current"
# ---------------------------------------------------------------------------
# Gate: orphan gen dir ignored before pointer swap
# ---------------------------------------------------------------------------
def test_orphan_gen_dir_ignored_before_pointer_swap(tmp_path: Path) -> None:
store = EngineStateStore(tmp_path)
_write_gen(store, turn_count=5)
# Simulate a kill between writing gen-9999 and swapping current.
orphan = tmp_path / "gen-9999"
orphan.mkdir()
(orphan / "manifest.json").write_text(
json.dumps({"schema_version": 2, "turn_count": 9999, "written_at_revision": "x"}),
encoding="utf-8",
)
# Load must still read from the committed generation (gen-0000, turn_count=5).
manifest = store.load_manifest()
assert manifest is not None
assert manifest["turn_count"] == 5, (
"unreferenced gen-9999 must be invisible; load must follow current"
)
def test_orphan_gen_dir_ignored_before_pointer_swap_bites(tmp_path: Path) -> None:
"""Without a current pointer the orphan gen dir IS what gets read (flat fallback)."""
# Build the store with ONLY the flat manifest (no current pointer).
(tmp_path / "manifest.json").write_text(
json.dumps({"schema_version": 2, "turn_count": 42, "written_at_revision": "x"}),
encoding="utf-8",
)
manifest = EngineStateStore(tmp_path).load_manifest()
assert manifest is not None
assert manifest["turn_count"] == 42, "flat layout fallback must work when no current exists"
# ---------------------------------------------------------------------------
# Gate: torn current temp file is ignored
# ---------------------------------------------------------------------------
def test_torn_current_tmp_ignored(tmp_path: Path) -> None:
store = EngineStateStore(tmp_path)
_write_gen(store, turn_count=3)
# Inject a torn .current temp file (simulate kill during os.replace of current).
torn = tmp_path / ".current.deadbeef.tmp"
torn.write_text("gen-9999", encoding="utf-8")
# The loader reads "current" (canonical), not ".current.*.tmp".
manifest = store.load_manifest()
assert manifest is not None
assert manifest["turn_count"] == 3, (
".current.*.tmp orphan must be invisible to the loader"
)
def test_torn_current_tmp_ignored_bites(tmp_path: Path) -> None:
"""If current itself is overwritten with the orphan name, load diverges."""
store = EngineStateStore(tmp_path)
_write_gen(store, turn_count=3)
# Corrupt current directly (simulates what we're proving the loader avoids).
(tmp_path / "current").write_text("gen-9999", encoding="utf-8")
# gen-9999 doesn't exist → _current_gen_dir() returns None → flat fallback.
# The flat manifest doesn't exist either → load_manifest() returns None.
assert store.load_manifest() is None, (
"a current pointer to a non-existent gen dir must fall back to flat/None"
)
# ---------------------------------------------------------------------------
# Gate: no cross-generation file mixing
# ---------------------------------------------------------------------------
def test_no_cross_generation_mixing(tmp_path: Path) -> None:
"""load_* always reads from a single generation directory."""
store = EngineStateStore(tmp_path)
# Gen 0: turn_count=10
_write_gen(store, turn_count=10)
# Gen 1: turn_count=20
_write_gen(store, turn_count=20)
# current → gen-0001. manifest must say turn_count=20.
# recognizers and candidates must also come from gen-0001 (not gen-0000).
manifest = store.load_manifest()
assert manifest is not None
assert manifest["turn_count"] == 20
recs = store.load_recognizers()
cands = store.load_discovery_candidates()
# Both loads resolve to gen-0001; not a mix of gen-0000 and gen-0001.
assert recs == []
assert cands == []
def test_no_cross_generation_mixing_bites(tmp_path: Path) -> None:
"""Forcing current to gen-0000 after two writes returns gen-0000 data."""
store = EngineStateStore(tmp_path)
# Gen 0 with turn_count=10
gen0_num, gen0_dir = store.begin_generation()
EngineStateStore(gen0_dir).save_manifest(10)
store.commit_generation(gen0_num)
# Gen 1 with turn_count=20
gen1_num, gen1_dir = store.begin_generation()
EngineStateStore(gen1_dir).save_manifest(20)
store.commit_generation(gen1_num)
# Manually rewind current to gen-0000.
(tmp_path / "current").write_text("gen-0000", encoding="utf-8")
manifest = store.load_manifest()
assert manifest is not None
assert manifest["turn_count"] == 10, (
"rewinding current to gen-0000 must expose gen-0000 data, not gen-0001"
)
# ---------------------------------------------------------------------------
# Gate: legacy flat layout migrates on first write
# ---------------------------------------------------------------------------
def test_legacy_flat_layout_migrates_on_first_write(tmp_path: Path) -> None:
"""A flat-layout checkpoint is wrapped into gen-0000 on the first begin_generation."""
# Seed a flat layout (pre-0219).
(tmp_path / "manifest.json").write_text(
json.dumps({"schema_version": 2, "turn_count": 7, "written_at_revision": "old"}),
encoding="utf-8",
)
(tmp_path / "recognizers.jsonl").write_text("", encoding="utf-8")
store = EngineStateStore(tmp_path)
gen_num, _gen_dir = store.begin_generation()
assert gen_num == 1, "first gen after migration must be gen-0001 (gen-0000 = migrated flat)"
assert (tmp_path / "gen-0000" / "manifest.json").exists()
assert (tmp_path / "current").read_text("utf-8").strip() == "gen-0000"
# Commit gen-0001 with new turn_count.
EngineStateStore(_gen_dir).save_manifest(8)
store.commit_generation(gen_num)
manifest = store.load_manifest()
assert manifest is not None
assert manifest["turn_count"] == 8, "committed gen-0001 must be visible after migration"
def test_legacy_flat_layout_migrates_on_first_write_bites(tmp_path: Path) -> None:
"""Without migration, a second gen would incorrectly become gen-0000."""
# Seed a flat layout but do NOT call begin_generation (no migration).
(tmp_path / "manifest.json").write_text(
json.dumps({"schema_version": 2, "turn_count": 7, "written_at_revision": "old"}),
encoding="utf-8",
)
# Directly create gen-0000 manually to simulate what migration does.
gen0 = tmp_path / "gen-0000"
gen0.mkdir()
(gen0 / "manifest.json").write_text(
json.dumps({"schema_version": 2, "turn_count": 7, "written_at_revision": "old"}),
encoding="utf-8",
)
# No current pointer yet.
assert not (tmp_path / "current").exists()
# begin_generation sees no current AND no flat manifest (we deleted it conceptually by
# just not having one) → would allocate gen-0000 again. Since we created gen-0000
# manually, begin_generation will return (0, gen-0000 dir) and overwrite it.
# This is the hazard that the migration path avoids.
# The test just confirms our understanding: without migration the gen num is 0.
(tmp_path / "manifest.json").unlink() # remove flat file so no-migration path taken
store = EngineStateStore(tmp_path)
gen_num, _ = store.begin_generation()
assert gen_num == 0, "without migration and without flat manifest, gen_num starts at 0"
# ---------------------------------------------------------------------------
# Gate: legacy flat layout readable without write
# ---------------------------------------------------------------------------
def test_legacy_flat_layout_readable_without_write(tmp_path: Path) -> None:
"""load_manifest falls back to the flat root when no current pointer exists."""
(tmp_path / "manifest.json").write_text(
json.dumps({"schema_version": 2, "turn_count": 99, "written_at_revision": "x"}),
encoding="utf-8",
)
store = EngineStateStore(tmp_path)
manifest = store.load_manifest()
assert manifest is not None
assert manifest["turn_count"] == 99
def test_legacy_flat_layout_readable_without_write_bites(tmp_path: Path) -> None:
"""Moving the flat manifest to a gen dir without writing current → None."""
gen0 = tmp_path / "gen-0000"
gen0.mkdir()
(gen0 / "manifest.json").write_text(
json.dumps({"schema_version": 2, "turn_count": 99, "written_at_revision": "x"}),
encoding="utf-8",
)
# No flat manifest at root, no current → load_manifest returns None.
assert EngineStateStore(tmp_path).load_manifest() is None, (
"a gen dir with no current pointer must not be read by load_manifest"
)
# ---------------------------------------------------------------------------
# Gate: commit-point / turn_count matches committed turns
# ---------------------------------------------------------------------------
def test_commit_point_matches_turn_count(tmp_path: Path) -> None:
"""Recovered turn_count always equals the number of fully committed turns."""
store = EngineStateStore(tmp_path)
for t in range(1, 4):
_write_gen(store, turn_count=t)
manifest = store.load_manifest()
assert manifest is not None
assert manifest["turn_count"] == 3
def test_commit_point_matches_turn_count_bites(tmp_path: Path) -> None:
"""A mid-write kill (no commit) does not advance the recovered turn_count."""
store = EngineStateStore(tmp_path)
_write_gen(store, turn_count=1)
# Simulate a kill mid-write of gen-0001 (no commit_generation).
_gen_num2, gen_dir2 = store.begin_generation()
EngineStateStore(gen_dir2).save_manifest(2)
# No commit — current still names gen-0000.
manifest = store.load_manifest()
assert manifest is not None
assert manifest["turn_count"] == 1, (
"an uncommitted generation must not advance the recovered turn_count"
)
# ---------------------------------------------------------------------------
# Gate: GC retains the last two generations
# ---------------------------------------------------------------------------
def test_gc_retains_last_two_generations(tmp_path: Path) -> None:
store = EngineStateStore(tmp_path)
for t in range(1, 5):
_write_gen(store, turn_count=t)
# After 4 commits (gen-0000..gen-0003), GC should have pruned gen-0000 and gen-0001.
gen_dirs = sorted(d.name for d in tmp_path.iterdir() if d.is_dir() and d.name.startswith("gen-"))
assert "gen-0000" not in gen_dirs, "gen-0000 should be pruned after 4 commits (keep=2)"
assert "gen-0001" not in gen_dirs, "gen-0001 should be pruned after 4 commits (keep=2)"
assert "gen-0002" in gen_dirs, "gen-0002 (N-1) must be retained"
assert "gen-0003" in gen_dirs, "gen-0003 (N, current) must be retained"
def test_gc_retains_last_two_generations_bites(tmp_path: Path) -> None:
"""With keep=3 the three most-recent generations are all retained."""
store = EngineStateStore(tmp_path)
for t in range(1, 5):
gen_num, gen_dir = store.begin_generation()
EngineStateStore(gen_dir).save_manifest(t)
store.commit_generation(gen_num, keep=3)
gen_dirs = sorted(d.name for d in tmp_path.iterdir() if d.is_dir() and d.name.startswith("gen-"))
assert "gen-0000" not in gen_dirs, "gen-0000 pruned even with keep=3 (4 gens total)"
assert "gen-0001" in gen_dirs, "gen-0001 retained with keep=3"
assert "gen-0002" in gen_dirs, "gen-0002 retained with keep=3"
assert "gen-0003" in gen_dirs, "gen-0003 retained with keep=3"