research/durable-agent-runtime/pi-durable.md
hermes 686d02755f durable-agent-runtime: add pi-durable deep dive + crash/replay experiments
- 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
2026-10-20 13:20:24 +00:00

22 KiB

pi-durable — Durable Agent Harness

Part of durable-agent-runtime/index Researched 2026-10-20 against earendil-works/pi main (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; requestId makes retries idempotent.

Required invariants (spec §1) worth quoting because they are the design:

  1. One Session commit is atomic across all record and document writes.
  2. A document update is published only after its storage commit succeeds.
  3. All visible progress is durable; there is no volatile publication path.
  4. External effects do not run inside the Session mutation transaction.
  5. Entries and IDs are immutable.
  6. Commit/close observers capture immutable state synchronously; user callbacks run later, off the mutation line.
  7. 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's pi.tool tasks, hands the run to the next generation.
  • pi.tool — one call per task; intent is committed before execute().
  • 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-completed outcome) 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 requestId deduplicates 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 aborted pi.assistant entry and the request is resent from the same committed context, with the same model, thinking level, stream options, and provider sessionId (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 a pi.tool-result with diagnostic code interrupted and 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 an ExecutionEnv per tool call. Built-in NodeExecutionEnv gives 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(), and watchEvents() 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).

  1. Basic chat via faux provider — worked as documented: submit → pi.generation → pi.system + pi.assistant entries; transcript ['pi.user', 'pi.system', 'pi.assistant'].
  2. Crash mid-phase, memo vs no memo — custom task, SIGKILL after committing a memo but before committing the outcome; reopen with resume() + waitForIdle():
    • with runtime.memo("effect", true): rerun logged EFFECT-SKIPPED-BY-MEMO, task completed; effect ran exactly once total.
    • without memo: rerun logged EFFECT-RAN again; effect ran twice total. Confirmed at-least-once phase execution.
  3. Crash mid-tool-call — faux model issues a tool call; tool SIGKILLs the process during execute():
    • replay: "unsafe" (default): on reopen the tool did not rerun; the transcript got a pi.tool-result with diagnostics: [{ 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-RAN printed again) and completed; generation continued.
  4. Cold-open wait race in 1.3.0 — after SIGKILL, calling harness.waitForTask(id) immediately after Harness.open() returned the stored pending record instead of waiting (its type says SettledTask, i.e. terminal). resume() + waitForIdle() + then waitForTask() returned the terminal outcome. Likely a reconciliation race (#live not populated when the wait registers). Observed behavior, reported here as an implementation caveat; needs upstream confirmation before calling it a bug.
  5. Version gap confirmed — installed 1.3.0 contains no sleepUntil, onWake, wake(), restartLimit, or sleeping status, although the README on main documents them. The 1.3.0 SQLite tasks table check constraint only allows pending/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-durable 1.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 of TaskRuntime.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-ai did 4.65 M that week and chord 1.91 M.
  • GitHub: the pi monorepo has 116,619 stars / 14,877 forks (2026-10-20), MIT. packages/durable has 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/**/*.ts files at tag v1.0.0. On main at 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:

  1. Commit-before-publish + requestId at 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.
  2. Effect sandwich for tools. Separate "intent committed" from "effect performed" from "outcome committed"; make destructive tools (bash, deploy, send-message) unsafe by default and surface interrupted to the model instead of retrying.
  3. 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.
  4. Crash-loop guard. A deterministic crash must not resume forever; park after N restarts and expose retryTask.
  5. Content-free telemetry. IDs and control metadata only — a good default for our own traces.
  6. 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/onWake ship in 1.4, and does the DO path become production-grade?
  • Is the source size workable for a reimplementation? At main it 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 of src/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-durable dist-tags (latest 1.3.0), first publish 2026-09-19, 16 versions; downloads last week 387,665.

Vendor/community (reported, not independently verified):

Verified by execution in this research (reproducible):

  • Install @earendil-works/pi-durable@1.3.0 with Node 22.22.2; run faux-provider chat; kill -9 mid-phase with and without runtime.memo; kill -9 inside replay: "safe" and default unsafe tool calls; inspect SQLite schema. Scripts and DBs under /tmp/pi-durable-research/test/ on the research host; results summarized in "Empirical checks" above.