The resume that must NOT happen
The four artifact classes are all read-out or render-out — none of them change the outside world. The real landmine is the side-effect step: a commit, an email send, a file write, a payment, a DB INSERT. Persisting the intent protected incoming data from loss; it does nothing to undo an action that already left the building.
| Step kind | On a cut |
|---|---|
| read-only (fetch / search / generate-request) | just redo it; worst case is a duplicate |
| write / side-effect (commit, email, payment, INSERT) | redo it and it happens twice |
The uncertain state decides
When tool_call_started was logged but tool_result_persisted is missing, the step is not "didn't run" — it's "don't know if it ran." For a read-only step that's fine: re-run it, up to its attempt cap. Until 2026-09-25 a side-effect step got a stricter rule than "stop when uncertain": the loop never ran one on its own at all. On next or resume, any unfinished side-effect step — a first run as much as an uncertain one — STOPped and surfaced to Dad/Pippa: "may I do this?" or "did this happen?" Since then it stops only in the uncertain state, the rule the Council locked (the v10 note below tells why). The step's side_effect flag, set at plan time, is still the whole switch.
The R8 lock, kept verbatim: "it's not about how cleverly you resume — it's about when you stop resuming. Read-only: freely. Side-effect: STOP. Concurrency: one atomic lease."
side_effect: bool so the loop can tell the difference between a cheap retry and a double-send.
The unblock action is the durable confirmation path. It doesn't globally disable the guard — it authorizes the next resume of that specific blocked step exactly once. When the step actually starts, the confirmation is consumed. If it cuts again, the guard blocks again instead of assuming the side effect is safe to replay.
There is one standing authorization. A scheduled task's own turn is Dad's instruction written ahead of time: when the conversation's latest message is a [SCHEDULED TASK] turn and the task was created during that run, its side-effect steps may proceed, and the loop records a side_effect_auto_confirmed event so the authorization is on the record. A task left over from an earlier run gets no such pass. Since 2026-09-26 the pass is narrower still: it re-runs an uncertain side-effect step only when the JSONL shows the step's call never went out.
One atomic lease stops the double-run
The schema carries a lease_until column, but a column alone is not mutual exclusion. If two vessels — WebUI Pippa and cron Pippa — pick the same task, both think they own it and both run the same side-effect step. The lease must be acquired atomically: one SQL statement that claims the task only if nobody else holds it (see the code block). SQLite WAL gives the transaction; that single statement is the claim. A claim is not the whole story, though: an atomic lease guarantees one holder at a time, not one runner. A worker that outlives its lease (a stalled call, a Mac that slept) can still finish its step after someone else has taken the task. The rest of the design narrows that case without closing it: the lease is long enough for real work, and a side-effect step left running without output is blocked rather than re-run. The lease is not renewed while a conversation is live; the checkpoint wrapper only treats an expired lease as held when it decides where a result lands, and lease_until runs out on its own clock. A model-driven step holds its lease for up to fifteen minutes, because the model does real work between next and commit; a sixty-second lease used to expire mid-step and quietly drop the guard. (Since turns lost their length cap, the checkpoint wrapper also treats a step's lease as held while its conversation is live, for deciding where results land; the row itself still expires on its clock. The v10 note has the detail.)