- new pi-durable.md: architecture (session/entry/document/task/submission), effect-sandwich semantics, structured concurrency, storage backends, maturity/adoption, taxonomy placement, implications for hermes-vm - empirical tests on @earendil-works/pi-durable 1.3.0 (Node 22.22.2): kill -9 mid-phase (memo vs plain), kill -9 mid-tool (safe vs unsafe), SQLite schema; noted cold-open waitForTask race and the main-vs-1.3.0 version gap (sleepUntil/onWake/restartLimit/sleeping not published) - taxonomy: durable harness library added as a new primitive family - sources + next-steps updated
22 KiB
pi-durable — Durable Agent Harness
Part of durable-agent-runtime/index Researched 2026-10-20 against
earendil-works/pimain (48c8f32, 2026-10-20) and the published npm package@earendil-works/pi-durable@1.3.0(2026-10-15). Status: experimental, API changes between releases.
TL;DR
@earendil-works/pi-durable is an MIT-licensed TypeScript library that turns an agent harness into a durable state machine. Conversations, transcript entries, tool calls, model turns, and application state are committed to storage before they are published; a new process opening the same storage resumes unfinished work from per-task checkpoints.
It is not a sandbox, VM, or durable-object platform. It is the layer above the substrate: storage-agnostic (memory / SQLite / JSONL / Cloudflare Durable Object SQLite) and execution-environment-agnostic (local Node, remote VM, container). That makes it the clearest example so far of a distinct layer in this topic's taxonomy: the durable harness library, orthogonal to where compute runs.
The important caveat: durability here is at-least-once execution of phases, not exactly-once external effects. Effects must be memoized or made idempotent by the application. Tool calls get an explicit replay policy (safe / unsafe); an interrupted unsafe tool is reported to the model as interrupted, never silently repeated.
Where it sits
| Layer | Examples | pi-durable's relationship |
|---|---|---|
| Durable object / actor platform | celld, rivet, Cloudflare Durable Objects | pi-durable can run on a DO (SQLite-backed) or Node; it does not provide the platform |
| Sandbox / VM runtime | hypeman, E2B, Amp orbs | pi-durable does not isolate tools; an ExecutionEnv can point at one |
| Durable harness library | pi-durable, Temporal-style durable execution applied to agents | This is the new layer: commits the loop's state, not the compute |
| Harness / loop library | pi, nanocodex, Codex, Code Mode | pi-durable is the durable variant of the harness itself |
The convergence thesis in durable-agent-runtime/analysis — compute is disposable, state is durable elsewhere — still holds. pi-durable sharpens it: the harness process is also disposable; the conversation + task graph is the durable object.
Architecture
Record model
The normative spec (packages/durable/docs/spec.md, ~5,600 lines) defines six primitives:
- Session — owns one mutation line: conversations, entries, tasks, submissions, documents. Every commit is atomic across all of them.
- Conversation — a transcript scope; can fork another at an entry. Root conversation is created on first use and found again after restart.
- Entry — one immutable transcript record:
pi.user,pi.assistant,pi.tool-result,pi.system,pi.reset,pi.compaction, plus application kinds. Entries are never mutated or reused. - Document — mutable typed JSON state stored next to the transcript, implemented on Chord (
@earendil-works/chord) as an operation log with periodic complete bases. Built-ins:pi.agent,pi.provider,pi.live,pi.inbox,pi.usage. - Task — a durable state machine with an explicit phase checkpoint. Every step commits before moving on.
- Submission — an awaitable input or write;
requestIdmakes retries idempotent.
Required invariants (spec §1) worth quoting because they are the design:
- One Session commit is atomic across all record and document writes.
- A document update is published only after its storage commit succeeds.
- All visible progress is durable; there is no volatile publication path.
- External effects do not run inside the Session mutation transaction.
- Entries and IDs are immutable.
- Commit/close observers capture immutable state synchronously; user callbacks run later, off the mutation line.
- A storage error is fatal to the open Session — it publishes nothing and must be reopened.
Task model
A task is defineTask({ name, version, initial, phases, abort, migrate? }). Each phase receives (task, runtime, context) and ends by committing a replacement checkpoint, a waiting state, a sleep, or a terminal outcome. Recovery never resumes a phase mid-way: it reruns the phase from its top, so post-wait work belongs in the next phase and its inputs in the checkpoint. On main, a task sleeps by ending its phase (runtime.sleepUntil(...) or returning { status: "sleeping", until, checkpoint }); the published 1.3.0 still has the older runtime.sleep(), which waits inside a phase — removed in [Unreleased] precisely because a restart reran the phase and restarted the wait (see version gap below).
Task statuses in the spec: pending, running, waiting (parked on other tasks, allSettled or failFast), completing (outcome decided, owned work still draining), terminal. Main adds sleeping; published 1.3.0 does not.
Built-in tasks:
pi.generation— prepares the system prompt, calls the model, owns the round'spi.tooltasks, hands the run to the next generation.pi.tool— one call per task; intent is committed beforeexecute().pi.compaction— summarizes older entries; runs as a task, can run in background while the conversation continues.
Usage/cost is not a task: it is the built-in pi.usage document, incremented by generation, tools, and compaction, with subtree accounting for owned conversations.
Structured concurrency
Tasks and conversations form one ownership tree (spec §5.5):
- Every task names its owner: its conversation or another task. Owner edges are immutable.
- Abort flows down. A live owner's cancellation intent (abort mark or held non-
completedoutcome) marks all ordinary owned work; abort handlers run bottom-up so each task undoes its own effects. - Owned work does not outlive its owner's finish. A task that finishes while owned work is live becomes
completing; terminal only after the subtree drains. - Background work is a boundary: it survives the parent's abort and does not keep the parent busy (used for persistent subagents).
- This is the same shape as structured concurrency in languages (Kotlin/Nursery), applied to agent tasks and subagent conversations.
Durability semantics — the part that matters
The core pattern is the effect sandwich (spec §5.2):
commit intent phase
perform external effect
commit outcome or next phase
Reopening in an intent phase means the effect may have happened. The handler must retry safely, poll an external handle, or record interruption. There is no magic.
Concrete guarantees, per mechanism:
- Submissions: a
requestIddeduplicates admission within a conversation. A client can retry after a crash and get the original submission back. This is the cleanest exactly-once primitive in the package. - Model turns: streaming partials are committed at most every
settings.progress.partialIntervalMs(default 100 ms). On recovery, a leftover partial is converted to an abortedpi.assistantentry and the request is resent from the same committed context, with the same model, thinking level, stream options, and providersessionId(prompt cache/session affinity). So: no token-level resume; duplicate model spend is possible; the partial answer survives in the transcript as aborted. - Tool calls: intent (final arguments + replay policy) is committed before
execute().replay: "safe"→ the call reruns on recovery.- default
unsafe→ it does not rerun; the model receives api.tool-resultwith diagnostic codeinterruptedand the durable partial output committed so far. - This is a deliberate product decision, not a limitation to paper over.
- Application state:
runtime.memo(name, value)is a first-writer-wins durable memo. A rerun sees the memo and can skip the effect. Memos disappear when the task becomes terminal. - Code Mode: scripts run deterministically (virtual clock, seeded randomness, UTC) and every tool result is journaled before delivery. On restart the script reruns and receives journaled results in order; a changed path, sandbox fingerprint, or expired deadline fails the call instead.
- Compaction: durable task; a manual/background summary is placed at a turn boundary; a summary that would cut before the current context settles
stale. - Storage errors: fatal to the Session. Nothing after the error publishes; reopen to recover. Custom storage backends are responsible for retrying their own transient failures.
Storage and deployment
Shipped backends (README §Storage):
- Memory — tests only.
- SQLite (Node
node:sqlite) — WAL,synchronous = NORMAL: commits survive process crashes; the newest commit may be lost on power/host failure. - JSONL — append-only file per record, optional
fsync; keeps the whole history in memory too, so use SQLite for long-running agents. - Cloudflare Durable Object SQLite — commits are the DO's storage transactions. Main's README adds
onWake/wake()so the DO arms its own alarm and hibernates while tasks sleep (unreleased in 1.3.0).
Constraints:
- One process owns a storage at a time. No cross-process locking. Other clients attach to that process (views/watches over an application-chosen transport).
- Node
>= 22.19, ESM, TypeScript types shipped. - Portable SQLite/JSONL cores avoid Node APIs so they run on Bun or in Workers with a small adapter.
Observed SQLite schema in a 1.3.0 test database (9 tables): conversations, entries, tasks, submissions, documents, document_revisions, record_ids, durable_metadata, durable_schema. Entries and task records are JSON in TEXT columns with json_valid checks; indexes cover conversation/kind/status lookups and document addresses. This is a compact, inspectable store — a transcript can be reconstructed with plain SQL.
Extensions, tools, environment
- Registry: extensions are named bundles of tools, prompt sections, hooks, wrappers, and tasks. Conversations store extension/tool names, never code, so they survive restarts and pick up whatever the new process installs. Registry changes are live; an in-flight call finishes on the code it started with.
- Tools: TypeBox-validated arguments; three result channels —
output(model),structuredOutput(programs/nested calls),details(UI). Nested tool calls (api.executeTool) are their own tasks with a key for replay reattachment. - Code Mode: model writes JavaScript in a QuickJS sandbox; tool calls become nested durable calls; journaled replay as above.
- Environment:
env({ cwd, conversationId })builds anExecutionEnvper tool call. Built-inNodeExecutionEnvgives full host FS/shell. A remote/container env can put tools on another machine. No sandbox is included by default — isolation is entirely the env implementation's job. - Observability:
pi.durable.*OpenTelemetry-style spans/events/metrics with content-free attributes (IDs and control metadata only, never messages/arguments/results).watch(),viewState(), andwatchEvents()expose committed state for multi-client UIs.
Empirical checks (run 2026-10-20)
Environment: Node 22.22.2, fresh install of pi-durable@1.3.0 + pi-ai@1.3.0 + chord@1.3.0 (91 packages, 111 MB node_modules).
- Basic chat via faux provider — worked as documented: submit →
pi.generation→pi.system+pi.assistantentries; transcript['pi.user', 'pi.system', 'pi.assistant']. - Crash mid-phase, memo vs no memo — custom task,
SIGKILLafter committing a memo but before committing the outcome; reopen withresume()+waitForIdle():- with
runtime.memo("effect", true): rerun loggedEFFECT-SKIPPED-BY-MEMO, task completed; effect ran exactly once total. - without memo: rerun logged
EFFECT-RANagain; effect ran twice total. Confirmed at-least-once phase execution.
- with
- Crash mid-tool-call — faux model issues a tool call; tool
SIGKILLs the process duringexecute():replay: "unsafe"(default): on reopen the tool did not rerun; the transcript got api.tool-resultwithdiagnostics: [{ severity: "error", code: "interrupted", message: "Tool slow was interrupted and may have partially run" }]; generation continued with the next model turn.replay: "safe": the tool reran (TOOL-RANprinted again) and completed; generation continued.
- Cold-open wait race in 1.3.0 — after
SIGKILL, callingharness.waitForTask(id)immediately afterHarness.open()returned the stored pending record instead of waiting (its type saysSettledTask, i.e. terminal).resume()+waitForIdle()+ thenwaitForTask()returned the terminal outcome. Likely a reconciliation race (#livenot populated when the wait registers). Observed behavior, reported here as an implementation caveat; needs upstream confirmation before calling it a bug. - Version gap confirmed — installed 1.3.0 contains no
sleepUntil,onWake,wake(),restartLimit, orsleepingstatus, although the README onmaindocuments them. The 1.3.0 SQLitetaskstable check constraint only allowspending/running/waiting/completing/terminal. Do not build against main's README thinking it is the published API.
Claims vs. guarantees
- "Committed before anything is shown" is directionally true but not literal: streamed partials are committed every ~100 ms, and the spec's own footgun list says a crash may lose the current uncommitted throttle window. Committed state is durable; the last 100 ms of visible tokens may not be.
- "Pick up where it stopped" means from the last committed phase boundary, not from an arbitrary instruction. A phase reruns from its top; side effects after the checkpoint repeat unless memoized/idempotent.
- "Exactly-once" applies to submission admission (
requestId), not to tool effects, model calls, or webhooks. There is no outbox/transactional-messaging layer. - Failure detection is fast: a storage error fails the whole Session; code errors fail only their unit (task phase faults the task, tool throws → error result, hook throws → reported and skipped, commit callback throws → rollback).
- Crash loops:
settings.restartLimit(default 5) parks a task that crashes 5 times without a new checkpoint — added on main, not in 1.3.0. This is a necessary guard for deterministic crashes; without it, a poison task resumes forever.
Non-goals (spec §13)
No whole-Session DOM; no visible-undurable publication; no independent event journal; no session-scoped rewindable documents; no automatic checkpoint heuristic; no automatic third-party view mounting; no CRDT/offline multi-writer merge; no SQL translation of arbitrary Chord ops; no JSONL global compaction or corruption repair; no forced termination of non-cooperative extension code inside one process.
Maturity and adoption
pi-durable1.0.0 shipped 2026-10-01; latest published 1.3.0 on 2026-10-15. 16 npm versions in ~4 weeks;[Unreleased]already contains breaking changes (removal ofTaskRuntime.sleep(), schema v2 migration that cannot roll back to 1.3.0).- npm: 387,665 downloads in the week ending 2026-10-18; 740,046 since first publish (2026-09-19). For context,
pi-aidid 4.65 M that week andchord1.91 M. - GitHub: the
pimonorepo has 116,619 stars / 14,877 forks (2026-10-20), MIT.packages/durablehas 175 path commits as of that date, the oldest returned by the API from 2026-09-18 (the package was split out of an earlier "Pico" package); 1.0.0 released 2026-10-01. - Vendor claim: ~15,000 lines of source excluding tests (~150k GPT tokens / ~250k Claude tokens); storage backends ~3,000 lines. That was accurate at launch: we count 17,662 raw lines / 16,412 non-blank across 60
src/**/*.tsfiles at tagv1.0.0. Onmainat 2026-10-20 it is 26,028 raw lines / 24,342 non-blank across 84 files — roughly +47% in 19 days. - Language: TypeScript only. The launch FAQ says a Rust port is "very easy" and "not ruled out", but not planned — relevant to our Rust-core preference.
Why this matters for the taxonomy
pi-durable demonstrates that durability can be a property of the harness library, not only of the substrate. celld/rivet answer "where does state live and how does compute wake?"; pi-durable answers "what must the loop commit, in what order, and what happens on reopen?" The two are composable: the package's natural production shape is one Session per Cloudflare Durable Object (or one Node process) with SQLite as the commit log.
It also imports durable-execution vocabulary into agent infrastructure without pretending to be Temporal:
- effect sandwich ≈ activity with a durable intent record
- memo ≈ idempotency key with storage
- submission
requestId≈ client-supplied idempotency key - code-mode journal ≈ deterministic replay of a workflow
- task ownership tree ≈ structured concurrency / cancellation scopes
Implications for our stack (hermes-vm)
What to copy into Hermes-style tooling, regardless of language:
- Commit-before-publish +
requestIdat every API boundary. Kanban/webhook/task admission should be idempotent by a caller-supplied key; if our webhook path can be retried without one, it can duplicate work — worth verifying against the current handler. - Effect sandwich for tools. Separate "intent committed" from "effect performed" from "outcome committed"; make destructive tools (
bash, deploy, send-message)unsafeby default and surfaceinterruptedto the model instead of retrying. - Task ownership tree with bottom-up abort. The semantic we'd want behind
delegate_task: a parent abort should cascade to children, and a parent should not report done while children run. - Crash-loop guard. A deterministic crash must not resume forever; park after N restarts and expose
retryTask. - Content-free telemetry. IDs and control metadata only — a good default for our own traces.
- Compaction as a durable task, not an in-memory step that can be lost mid-summary.
What not to adopt: another runtime. hermes-vm already is the durable host (systemd, SQLite, kanban, session DBs); pi-durable solves a problem we have only if we build a multi-client, long-running agent service in TypeScript. If we do, the package itself (not a reimplementation) is the fastest path: SQLite on the VM, NodeExecutionEnv, and the CodingTools/Code Mode extensions.
If we want the semantics in a Rust core + Python adapters, pi-durable is a strong reference design but not a dependency: the spec is the deliverable, and the storage contract (9 tables, atomic commit, JSON records) is small enough to reimplement.
Open questions
- No Postgres/KV adapter shipped; the vendor says the interface is small, but no reference implementation exists. A multi-node deployment would need one plus a leader/lease model — explicitly out of scope today.
- Multi-client transport is application-owned:
watch()gives committed operations, not a wire protocol. How much work is a real Slack/Web UI adapter? - Security: the default Node env is full host access. There is no permission model, no per-tool sandbox, no policy engine beyond hooks. How does this compare with hypeman/E2B for untrusted tools?
- Cost control: model requests are resent after interruption; no token-level resume. How much does this cost at 100k-token contexts? No published benchmarks for storage or recovery latency (bench scripts exist:
bench:storage,bench:scheduler-fanout,bench:document-append, etc.). - Does
restartLimit/sleeping/onWakeship in 1.4, and does the DO path become production-grade? - Is the source size workable for a reimplementation? At
mainit is 26k raw lines, but much of that is TypeScript types and exhaustive JSDoc; the storage/task core is materially smaller. Worth a focused read ofsrc/harness/scheduler.ts(108 KB) before committing to "small enough to reimplement".
Sources
Primary (code/spec, highest trust):
packages/durable/README.md— user-facing API, storage, errors, examples. Accessed 2026-10-20.packages/durable/docs/spec.md— normative specification: terms/invariants (§1), records (§2), documents (§3), transactions (§4), tasks/effect sandwich/structured concurrency/sleep (§5), submissions (§6), extensions/tools (§7), built-in tasks (§8), views (§9), storage contract (§10), backends (§11), footguns (§12), non-goals (§13).packages/durable/CHANGELOG.md— release history; 1.0.0 2026-10-01 → 1.3.0 2026-10-15; unreleased breaking changes.packages/durable/package.json— version 1.3.0, MIT, Node >= 22.19, deps (pi-ai, chord, pi-codemode, pi-telemetry, TypeBox, Photon, diff).packages/chord/README.md— the document-state substrate: objects, references, observable JSON, deltas, hot reload, SQLite adapters.- Source tree inspection:
src/harness(scheduler 108 KB, tool 41 KB, generation 34 KB),src/session(transaction 42 KB),src/storage(memory 33 KB, sqlite, jsonl),src/codemode,src/tools,src/env. - GitHub API: repo metadata (116,619 stars, MIT) and commit history for
packages/durable(175 path commits, oldest 2026-09-18; package split out of "Pico"), accessed 2026-10-20. - npm registry API:
@earendil-works/pi-durabledist-tags (latest 1.3.0), first publish 2026-09-19, 16 versions; downloads last week 387,665.
Vendor/community (reported, not independently verified):
- Earendil Engineering, "Pi Durable" (2026-10-01) — launch post: positioning ("harness that runs anywhere"), architecture tour, multi-client/multiplayer claims, ~15k LOC claim, FAQ on TypeScript/Rust.
- explainx.ai, "Pi 1.0 + Durable" (Oct 2026) — secondary coverage; useful only for reception framing.
- MindStudio, "Pi Durable: What It Is and Why It Matters" — secondary summary; not used for factual claims.
Verified by execution in this research (reproducible):
- Install
@earendil-works/pi-durable@1.3.0with Node 22.22.2; run faux-provider chat; kill -9 mid-phase with and withoutruntime.memo; kill -9 insidereplay: "safe"and defaultunsafetool calls; inspect SQLite schema. Scripts and DBs under/tmp/pi-durable-research/test/on the research host; results summarized in "Empirical checks" above.