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.
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:
begin_generation()— allocategen-NNNN/, return(gen_num, gen_dir).- Write all checkpoint files into
gen_dirviaEngineStateStore(gen_dir).save_*. 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):
- Flat files are copied into
gen-0000/(fsync-ed). currentis written pointing togen-0000.- 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. Allload_*methods updated to use_resolve_dir().exists()checks both layouts.save_*methods unchanged (write relative toself.path, so they naturally write into a gen dir when instantiated asEngineStateStore(gen_dir)).chat/runtime.py:checkpoint_engine_stateuses the two-phase commit.finalize_turn_trace_hashno longer callssave_discovery_candidates.evals/l10_continuity/runner.py:_inject_orphan_tmpupdated to inject both orphan shapes (unreferenced gen dir + torncurrenttemp 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-6throughout (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.*.tmporphan 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