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.
140 lines
6.3 KiB
Markdown
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`
|