core/docs/decisions/ADR-0219-generation-checkpoint-atomicity.md
Shay 103def317d 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.
2026-06-15 02:01:52 -07:00

6.3 KiB

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.
  • 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