Part II Chapter 13: Commit First, Show Later — pi-durable

Chapter guide: The closing extra of Part II (book number 42; read it after Part II Chapter 12). This chapter reads
packages/durable: the atomic commit line insession/session.ts, task checkpoints inharness/scheduler.ts, the replay rule inharness/tool.ts, and the durability ladder instorage/. It answers the question Part II Chapter 5 left open at its last line: when the process dies mid-tool-call, who finishes the job? The package's README declares itself Experimental: the API changes without notice between releases. This chapter describes thev1.0.0snapshot only and makes no stability promises.
After Part II Chapter 5, Xiao A could explain how AgentSession outlives a restart: every message is appended to JSONL synchronously, and malformed lines are skipped on read. He flipped back to that chapter's boundary table and pointed at one row. "_persist has no fsync, and bad lines are silently skipped — so what if the process dies exactly halfway through a tool call? The session file has 'the model asked to call this tool' but no result. Who cleans that up after a restart?"
"Part II Chapter 5's answer is the tolerant one: unreadable lines are skipped, and the interrupted turn stays in the history for the host to deal with," Lao Z said. "pi-durable takes the other road: every step is atomically persisted before anything becomes visible, and after a restart a task's checkpoint either resumes the work or explicitly decides it must not be re-run. This chapter walks that 'persist first, show later' kernel."
This chapter is anchored to upstream tag v1.0.0 of the pi repository (commit a13d35a742c6ef8462812a28fbe1d8c8b7431c32), @earendil-works/pi-durable 1.0.0. The package's normative document calls itself Pico5 while the published package is pi-durable: docs/spec.md is the contract, src/ is its implementation. This chapter treats src/ as confirmed fact and uses the spec to check wording. Note the dependency list — pi-ai, chord, diff, typebox, and nothing else. It does not depend on pi-agent-core; coding-agent uses it only from src/experimental/, which the build excludes. It is a second host kernel alongside Agent + loop + AgentSession, not Part II Chapter 5's chain in disguise.
The map: conversations are logs, runs are tasks
The README's Quick Start gives the minimal skeleton: Harness.open(storage, { models, registry }, context) opens a Session over a storage backend; root() returns the root conversation; submit({ type: "input", content }) durably admits your input; submission.wait(context) resolves once it is answered. One answered question looks like this in storage:
submit(input) → pi.user entry
pi.generation task → pi.assistant (tool calls)
pi.tool task × n → pi.tool-result × n (owned by the generation, which waits for them)
pi.generation task → pi.assistant (answer) → submission doneFour concepts carry the design: an Entry is an immutable transcript record; a Commit is an atomic write; a Document is typed JSON state stored beside the transcript (pi.agent, pi.live, pi.inbox, pi.usage); a Task is a durable state machine with checkpoints. The core rule opens the spec: every change goes through one atomic commit line, and only committed state is observable. There is no "show it first, persist it later" path.
Metaphor: the ledger and the counter
Part II Chapter 5's
AgentSessionis a cashier jotting each sale into a notebook as it happens, skipping a line it can no longer read;pi-durableis a bank counter: every transaction is stamped into the general ledger before the customer sees any result. Two ways to "outlive a restart" — one tolerant, one strict.
One commit line, and its three failures
The kernel entry is #runCommit() in src/session/session.ts. Read its branches one by one:
// commit pipeline: callback → settle into an atomic write batch → Storage.commit → adopt → publish.
// (session.ts, structured summary, not verbatim source)
result = await change(tx); // callback throws → settleFailure(), drafts dropped, nothing happened
const writes = await tx.settleSuccess();
if (writes.length === 0) { tx.discard(); return result; } // empty batch = not committed
seq = await this.#storage.commit(writes, withoutAbortSignal(context));
// once Storage admits the batch, caller cancellation no longer interrupts settlement
// storage throws: only StorageRejected (promised no durable effect) avoids poisoning; anything else sets #poison
documents = tx.adopt(seq); // storage committed but adoption failed → also poison: memory now lags durable state
this.#publish(seq, writes, documents, context);Together with src/errors.ts, three error classes give three recovery answers:
| Error | Meaning | Correct recovery |
|---|---|---|
ReadAfterWrite | a transaction read a table after its first table write — a deterministic programming error | move the reads before the writes; this is a code defect, not a runtime condition |
StorageRejected | storage rejected the whole batch before any durable effect | the same Session may continue safely; no reopen |
| any other storage failure | the outcome is uncertain | the Session is poisoned: close and reopen — memory and durable state may now differ |
Poisoning shows the design's temperament best: rather than keep running with "we don't know whether that write landed," the Session stops. Compare Part II Chapter 5's SessionManager — over there, bad lines are silently skipped and writes are best-effort; over here, uncertainty stops the machine. Both philosophies live inside the same Pi 1.0 snapshot; when reading source, keep clear which promise belongs to which runtime.
The transaction layer adds one usage-side constraint: table reads must happen before the first table write (read everything first, then write), document drafts may keep editing inside the batch, and commit arguments are borrowed until Storage.commit() settles and belong to storage afterwards. All of this is checkable in session/transaction.ts and spec §4.
Tasks: state machine, effect sandwich, checkpoints
defineTask (src/tasks.ts) declares a task: input, checkpoint, result, and handler phases. The scheduler (src/harness/scheduler.ts) persists pending | running | waiting | completing | terminal, plus faulted and orphaned that only it writes. Five semantics worth remembering:
- The effect sandwich: an external effect sits between two commits — commit the intent checkpoint, run the effect, commit the outcome. Reopening in an intent phase means the effect may have happened; the replay policy decides what happens next (next section).
- No progress ⇒ faulted: a step callback that neither changes the checkpoint nor produces a result gets the task marked
faulted— an explicit failure beats silent spinning. - Reopen reconciliation: tasks that were
runningat death return topending, with checkpoint and abort mark preserved;waitingstays waiting on its children;completingis re-evaluated. - A missing definition only blocks: after a restart, if some task kind's definition is not installed, the scheduler reports a derived blocked reason (
missing_taskand friends) and the task never terminalizes; installing it later resumes the work. This is what lets the registry and the storage be deployed separately. - The ownership tree: tasks own child tasks and owned conversations; abort flows bottom-up through owned work first, and tasks created with
background: trueform boundaries that survive their parent's abort.faulted/orphanedare the scheduler's factual records, not business outcomes.
The README's Child Tasks section shows waiting: { on, policy } (a checkout owning four payments with failFast); the runnable counterpart is test/examples/24-child-tasks.ts.
Conversations and submissions: busy, queued, idempotent
admitSubmission in src/harness/submissions.ts is the single admission core. Its rules read off the source:
- A submission carrying
requestIdfirst looks for an existing record in this conversation: same ID and shape returns the original submission — retrying a submit does not submit twice; same ID with a different shape throws. This is the safe-retry hook after a crash. - When the conversation is busy (
pi.live.runis present),whenBusysplits the paths: default follow-up queues intopi.inbox;steerinserts after the current tool round and joins the running work;rejectthrowsConversationBusyand writes no record. - An idle input appends
pi.user, records it as placed, and startspi.generation; an idle write appends its entry and settlesdone. wait()performs its check and its registration inside one pass on the commit line, so no settling can slip between two reads.
The observability half matches the README's Watching section: viewState() returns the conversation's structural view as read-only Chord state; watch() delivers the exact operations of each commit; a consumer that falls more than 100 frames behind receives one whole-view replacement (MAX_PENDING_WATCH_FRAMES = 100 in observation.ts); streamed partial answers use a 100 ms throttle with at most one commit in flight; tool-output commits are spaced by at least max(100ms, written bytes / 100KiB/s). These limit commit frequency, not crash data loss: scheduling delays, in-flight writes and storage durability affect recovery, while observers see only committed state.
Replay safety: the heart of this chapter
src/harness/tool.ts turns a tool call into a task with exactly two checkpoints: { phase: "call" } and { phase: "execute", arguments, replay }. The call phase resolves the tool, validates arguments, runs beforeTool hooks — and then, the key step — atomically commits the durable intent (final arguments plus replay policy) before executing:
// harness/tool.ts (v1.0.0, excerpt): commit the durable intent before running
const intent = { phase: "execute", arguments: final, replay: tool.replay ?? "unsafe" } as const;
return { status: "running", checkpoint: intent };If the process dies inside execution, the reopened task enters the execute phase, and the whole decision is one line:
if (replay === "safe" && tool?.replay === "safe") {
// stored intent and current declaration both say safe:
// clear what the interrupted attempt published, then rerun from scratch
}
// otherwise: an `interrupted` error result for the model —
// "Tool <name> was interrupted and may have partially run", with the committed partial outputBoth conditions must hold: the intent stores the policy as of then, while the current registry says whether the tool is safe now — tightening a policy after the fact stops old intents from replaying. Before a rerun, the interrupted attempt's published progress is cleared so reporting starts fresh. Anything not safe yields interrupted: the model learns explicitly that the call may have partially run, and decides what to do.
One counterintuitive fact: the built-in read/write/edit/bash tools never declare replay (a grep over src/tools/ finds none), and the default is unsafe — after a crash-reopen they always end as interrupted and are never re-executed. The README's subagent example shows the other pole: a subagent tool declares replay: "safe" and pairs it with scanning for an existing owned child by ownerTaskId and deriving the requestId from api.taskId, so a rerun finds the same child and never double-delivers the task. "safe" is not a label slapped onto a tool — it is a promise backed by a whole idempotence design.
The error codes the Harness writes itself are checkable against tool.ts and spec §7.3: tool_unavailable, invalid_arguments, blocked, interrupted, aborted, tool_error. The first four are decided before execution or during recovery; tool_error is the tool's own failure; aborted records cancellation intent. Default output limits match coding-agent's: 2000 lines / 50 KiB (truncate.ts).
None of this is paper reasoning: test/harness-tools-recovery.test.ts walks unsafe-interrupted-after-intent, both-policies-safe rerun, rerun under the current cwd, interrupted-before-phase-commit, progress cleared before a safe rerun, and more; generation recovery (committed partials settling as aborted, retry backoff resuming, deferred polling, no_model after reopen) lives in test/harness-generation-recovery.test.ts; the task-level crash matrix is in test/harness-tasks-recovery.test.ts.
The durability ladder
| Backend | Durability promise (as the snapshot states it) | Notes |
|---|---|---|
MemoryStorage | nothing persisted | demos and tests |
SQLite (openNodeSqliteStorage) | journal_mode = WAL, synchronous = NORMAL: commits survive process crashes; the newest may be lost on power or host failure | one SQL transaction per Session commit |
JSONL (openNodeJsonlStorage) | append-only directory: sidecars land first (fsync off by default; pass { fsync: true } to flush before each commit marker), and the main marker lands last — only a successful marker means committed | recovery removes torn final lines and unconfirmed sidecar tails; no global compaction in the initial implementation |
All three share two hard boundaries: one process owns a storage at a time — there is no cross-process locking; and an uncertain storage failure poisons the Session until you reopen. The README also records a cost note: the package root loads TypeBox, about 23 MB peak RSS unbundled, about 4 MB tree-shaken.
Read it against Part II Chapter 5
| Dimension | Part II Chapter 5's AgentSession | pi-durable |
|---|---|---|
| persistence model | synchronous appendFileSync per event, lazily created file | one atomic commit line; only committed state is observable |
| bad-state policy | tolerant reads: malformed lines skipped | uncertain writes: poison the Session and reopen |
| interrupted turn | stays in history; the host copes | task checkpoints resume it, or the replay rule yields interrupted |
| relation to the loop | the product layer wiring Agent's sockets | depends on no pi-agent-core; carries its own generation/tool/compaction tasks |
| standing | the shipped CLI's actual session kernel | experimental independent kernel, used by the experimental host |
Both roads answer the same question — "what lets a session outlive a process restart" — and reveal two engineering temperaments: one trusts append-and-tolerate, the other insists on atomic-and-explicit. Understanding Pi 1.0's design space for sessions needs both chapters.
Not covered here: Chord itself (the CRDT/operation semantics in packages/chord), the src/experimental/durable/ host application, durability proofs under power loss (the README states the newest commit may be lost), and forcibly terminating non-cooperative extension code (a spec §13 non-goal). pi-durable self-declares as experimental — after any upgrade, every symbol and ordering in this chapter must be re-verified.
Looking back
Chapter 10 in the Concepts part split Memory into "what to store, when to fetch, what happens when it's lost"; Part II Chapter 5 gave AgentSession's answer — append-only JSONL, tolerant reads, the host as the last line of defense. This chapter gave the other answer: a single atomic commit line, stop-on-uncertainty, the effect sandwich with an explicit replay rule. Both answer "why does a session outlive the process," and they differ exactly where it matters — in bad conditions. Different crash points produce different recovery behaviors, and those behaviors are read off the source, not promised by documentation.
Source Walkthrough
- Start at
#runCommitinsrc/session/session.ts; mark the five paths — callback failure, empty write batch,StorageRejected, other storage failure, failed adoption — and their aftermaths; compare against the three comments insrc/errors.ts. - Read the
callphase insrc/harness/tool.ts: from resolution, validation, andbeforeToolto the{ phase: "execute", replay }intent commit, and name the line where "persist first, then execute" happens. - Follow the
executephase's replay decision: why both the stored intent and the current declaration must saysafe, and why progress is cleared before a rerun. Then grepreplayundersrc/tools/and state what "all four built-ins default to unsafe" means. - Read
admitSubmission: for each ofrequestIdrepeat, busy-plus-follow-up, busy-plus-steer, busy-plus-reject, and idle write, predict exactly what lands in storage; compare with the README's Busy Conversations section. - Open
test/examples/13-recovery.tsandtest/harness-tools-recovery.test.ts, run one with the faux provider, and watch how different crash points produceinterrupted, rerun, or resume. - Compare the commit order in
src/storage/jsonl/storage.ts(sidecar → optional fsync → main marker) with the PRAGMA settings insrc/storage/sqlite/node.ts, and write out each backend's true promise under "process crash" and "power failure." - Read the reopen reconciliation in
src/harness/scheduler.ts: whererunning → pending,waitingpreserved, andcompletingre-evaluated appear in code, and why a missing definition blocks instead of terminalizing.
