5.5 KiB
5.5 KiB
T2.1 — Write-ahead intent
| Field | Value |
|---|---|
| Phase | P2 — Durability hard parts |
| Size | L — over 3 days |
| Status | Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | T2.2, T6.4 |
Goal
Three intent records around every external effect, so a crash can be classified rather than guessed at.
Facts (inlined — no spec read needed)
1. append Intent{Pending} ──► commit + fsync
◄── window A: crash here, the call was never issued
2. append Intent{Dispatched} ──► commit + fsync
3. perform the call
◄── window B: crash here, the call may have landed
4. append outcome, Intent{Committed} ──► commit + fsync
- Three records, not two. Two cannot separate the windows: a crash before the
call and a crash after it both leave a lone
Pendingwith no outcome, which makes every interrupted effect maximally suspicious and pushes recoverable work intoIndeterminate. The second fsync buys the distinction, is paid only on steps with external effects, and is small next to the call it guards. - Appending after the fact records history but does not make a failed step resumable — the dangerous window is before the record exists.
- Restart classification, from the last committed intent state:
| Last state | Meaning | Resolution |
|---|---|---|
Pending |
the call was never issued | retry freely, whatever the effect class |
Dispatched |
the call may have been issued | by effect class (T2.2) |
Committed |
outcome already recorded | nothing to do |
- Intents always commit with
Durability::Immediate. Batching them defeats their only purpose. - Tool calls need this more than model calls. A model call is a metered read; a tool call writes files, pushes commits, and touches the world.
- The intent record is also what decides
CancelledvsIndeterminateon a cancelled attempt (T0.2, T1.1).
Steps
- Define
IntentState { Pending, Dispatched, Committed }and anIntentIdscoped to(BranchKey, AttemptNo, step). Include the idempotency key or request id the effect class will need at recovery (T2.2). - Wrap every external effect in the four-step sequence above. Each append is its
own
EventLog::commitwithImmediatedurability — three separate fsyncs, by design. - Make the wrapper the only way to reach a tool. If a call site can issue an effect without an intent, the protocol is decorative.
- Build the crash harness: a test hook that aborts the process at a named point.
Two named points minimum — after
Pendingcommit, and afterDispatchedcommit but before outcome commit. - On restart, scan for the newest intent per
(BranchKey, AttemptNo)and classify per the table. Emit the classification as a log event so the decision itself is recorded. - Assert the fsync count on the intent path in a test — a batching "optimization" introduced later must fail loudly.
Acceptance
- Crash harness kills the process in both windows: between
PendingandDispatched, and betweenDispatchedand outcome-commit. - Restart classifies the first as "never sent" and the second as "may have been
sent". A two-phase implementation cannot pass this — both crashes leave one
Pendingwith no outcome and are indistinguishable. That is the point of the test.
Verify
Harness: a real child-process kill (kill -9), not a panic. Named abort
points compiled under a test-hooks feature. A recording tool that appends to a
file outside the database, so "did the call actually happen" is observable
independently of what the log claims.
Integration test — tests/it_intent_windows.rs:
- Window A: arm the abort after the
Pendingcommit, beforeDispatched. Kill. Reopen.- Assert the last intent state is
Pending. - Assert restart classifies it "never sent".
- Assert the side-channel file is empty — the call really did not happen.
- Assert the last intent state is
- Window B: arm the abort after the
Dispatchedcommit, before the outcome commit. Kill. Reopen.- Assert the last intent state is
Dispatched. - Assert restart classifies it "may have been sent".
- Assert the last intent state is
- Assert the two classifications differ. A two-phase implementation makes them identical, which is the whole point of the test.
- Count fsyncs on the intent path for one guarded effect; assert exactly 3 commits, so a later batching "optimization" fails loudly.
- Bypass audit: assert no tool can be invoked except through the intent wrapper — a compile-level guard if the tool handle is only constructible inside it.
Command: cargo test -p durability --features test-hooks intent_windows -- --test-threads=1
False pass:
- Simulating the crash with
panic!and catching the unwind. Destructors run and buffered writes flush — precisely what a real crash does not do. - Asserting only that both windows "recover". Both recover under a two-phase implementation too; they just recover identically. Step 3 is the test.
- Trusting the log to tell you whether the call happened. Step 1's side-channel file is the independent witness.
Traps
- Batching the two pre-call fsyncs into one commit. It compiles, it is faster, and it deletes the distinction the task exists to create.
- Recording the intent after dispatch "since that is when we know the request id". Derive the request id before dispatch instead.
Background (not required to do this task): rust-agentic-sys.md §8.4 · rust-agentic-task.md