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

140 lines
6.3 KiB
Markdown

# 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`