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

213 lines
22 KiB
Markdown

# pi-durable — Durable Agent Harness
> Part of [[durable-agent-runtime/index|Durable Agent Runtime]]
> 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|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):
```text
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 `SIGKILL`s 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`](https://github.com/earendil-works/pi/blob/main/packages/durable/README.md) — user-facing API, storage, errors, examples. Accessed 2026-10-20.
- [`packages/durable/docs/spec.md`](https://github.com/earendil-works/pi/blob/main/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`](https://github.com/earendil-works/pi/blob/main/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`](https://github.com/earendil-works/pi/blob/main/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`](https://github.com/earendil-works/pi/blob/main/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):
- [Earendil Engineering, "Pi Durable" (2026-10-01)](https://earendil.com/posts/pi-durable/) — 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)](https://www.explainx.ai/blog/earendil-pi-1-durable-minimal-harness-2026) — secondary coverage; useful only for reception framing.
- [MindStudio, "Pi Durable: What It Is and Why It Matters"](https://www.mindstudio.ai/blog/pi-durable-long-running-agents) — secondary summary; not used for factual claims.
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.