@parcha/pi-durable-disk 0.1.0-beta.12 → 0.1.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,150 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.1.0-beta.13 (2026-10-14)
6
+
7
+ **pi-durable 1.1**
8
+ - The peer ranges of `@earendil-works/pi-durable` and `@earendil-works/chord` widen from `~1.0.4` to `>=1.0.4 <1.2.0`:
9
+ the package installs beside pi-durable 1.1 and still beside 1.0.4. It is developed against 1.1.0, and a CI job
10
+ (`pi-durable-disk-pi-floor`) runs its whole suite again on pi-durable, chord and pi-ai 1.0.4, the lowest the range
11
+ accepts. The conformance minimum follows the installed version (23 cases on 1.0.4, 24 on 1.1), and the scan-order
12
+ control is skipped, with its reason, where the installed `Storage` contract has no order.
13
+ - pi-durable 1.1.0 requires a `Storage` to honour `order` on its conversation, entry, task and submission scans and to
14
+ continue a cursor in the order it was returned with. The store's scans are pi's `SqliteStorage` behind the fenced
15
+ database, so they do: pi-durable 1.1.0's storage conformance suite (24 cases, one of them the scans in either order)
16
+ passes on both profiles, and a control shows the runner fails a storage that drops `order`.
17
+ - What the package reads of pi's internals is unchanged in 1.1.0: `durable_metadata.next_seq` for the seal, and the
18
+ `retry` and `poll` checkpoints of generation and compaction for parking. Each is read off what the installed pi wrote:
19
+ the seal test commits through pi's storage, a park test waits on a real retry, and a new test waits on a real
20
+ deferred answer (pi's poll phase), so a version that moved either shape fails a test rather than this sentence.
21
+ - The Docker runtime image installs pi-durable, pi-ai and chord 1.1.0.
22
+
23
+ **A dead client's private directories**
24
+ - Each client holds delegations on its own private directories too, `.archil/client-<clientId>` and its `unlinked/`, which the
25
+ control API lists with no path. A client that dies (its FUSE daemon killed under a running instance, a lost host) leaves them
26
+ orphaned after the run's own delegation is revoked and its directory deleted. They block no run (they are on the client's
27
+ own directories, never a run's), but nothing revoked them, so they piled up and resurfaced in listings: a scratch disk held
28
+ 96, from 86 clients, all from the live suites' daemon kills. Revoking them is hygiene.
29
+ - `revoke` (and so `takeOver`), the supervisor's takeover in `ensureRunning` (an orphaned holder or an expired lease), and
30
+ `deleteRunTree` now also revoke those private-directory delegations, from the same listing, and only for the clients they
31
+ are already revoking on the run: never as a sweep. One `Disk.exec` (`stat`) confirms each inode is the client's own private
32
+ directory, and a client that holds anything else (it may hold another run) keeps everything but the run's delegation.
33
+ Best effort: without `exec`, or when it or a revoke fails, they stay, and the run's revoke never fails for them. The run's
34
+ own delegations, which every count and decision uses, are unchanged, and `revoke` still returns only those.
35
+ - `revokeBestEffort` revokes what `revoke` revokes and skips every revoke that fails (`revoke` stops at the first); only the
36
+ listing can fail it. `deleteRunTree` uses it, and the live suites' cleanups revoke through it (by path or by inode, and the
37
+ holders' private directories) before they delete a run's tree; the Docker, Daytona and lifecycle suites matched by path
38
+ only. The run suite's daemon-kill and host-loss tests check that no delegation of a killed client is left.
39
+ - New exports: `revokeBestEffort`, `revokeCompanions`, `companionsOf`.
40
+ - **`beforeResume`: an awaited step before the run resumes**
41
+ - `openDurableRun` and an app (`AppOptions`) take an optional `beforeResume(run)`, awaited once per incarnation after the
42
+ Harness is open (its registry installed) and the run is live, and before the Harness resumes. No resumed task takes a
43
+ step until it settles, and what it admits (a submission, a write) is committed before anything a resumed task's steps
44
+ commit. It sees the open Harness, the open's context, the generation, the run record and the claim.
45
+ - It also gets the store the Harness was opened over, read-only, and its head: `storage` has `scanConversations`,
46
+ `scanTasks`, `scanDocuments` and `document` on a frozen object of its own, with no `commit` or `mintId` even through a
47
+ cast, and `head` is the store's last committed sequence once the Harness is open. A recorder snapshots every document
48
+ at `head` in the hook; until the hook commits, the current state is the state at `head`, which is how a document that
49
+ keeps no history (`latest`, session and task documents) reads.
50
+ - Use: a host reconciling what a resumed tool depends on (a browser, a mount) before the task that uses it continues;
51
+ `onStep` cannot, since it is synchronous and gets no Harness.
52
+ - A rejection fails the open like any open step (`before-resume`), and the claim is released.
53
+ - New exports: the `BeforeResume` and `StoreReads` types.
54
+ - **`localRun(root)`: a run on a plain local directory, one machine**
55
+ - New export `localRun(root)` returns `openDurableRun`'s `acquire` and `claimDir` for a run in a directory of this
56
+ machine: the claim is the directory (made when missing), with no mount; `claimDir` is `openClaimDir(root, { fstype:
57
+ null })`. Its barrier refuses once fenced and syncs nothing (SQLite makes the store's commits durable; `work/` is the
58
+ OS's to flush). It is for one machine and says so: only the owner lock keeps a second process off the run.
59
+ - `mountToken` is optional: required only with the default `acquire` (refused with `INVALID_ARGUMENT` before anything
60
+ mounts when missing); a replaced `acquire` is handed an empty token.
61
+ - A local run needs Linux, for now, and the README says why: the run's root is held open through `/proc/self/fd`
62
+ (`openClaimDir`), and the default environment needs `setpriv` (`noNewPrivs`) and `/proc/self/fd` (`confineFiles`).
63
+ macOS has neither.
64
+ - Installs beside pi-durable 1.2: the peer ranges of `@earendil-works/pi-durable` and `@earendil-works/chord` widen to `>=1.0.4 <1.3.0`, so the package installs beside 1.0.4, 1.1 and 1.2. It is still developed against 1.1.0; CI runs its whole suite at 1.2.0 (`pi-1-2`) and at 1.0.4 (`pi-durable-disk-pi-floor`).
65
+ - pi-durable 1.2 reads a tool result's `output` and sends the model an empty result for one that says `content`, with no error (1.0.4 and 1.1 read `content`). The package's own source builds no tool result. The test apps' tools (the lifecycle app, the acceptance app, the live instance) and the paid-effect example give their text through `api.output()` and name neither key, which every version reads; tests assert the model gets a tool's text, not an empty result.
66
+ - pi-durable 1.2 fails a Session on any error a `Storage` throws, except its own `StorageRequestError` (an invalid request) and a read its caller cancelled. The store is pi's `SqliteStorage` behind the fenced database and the lease's commit observer, neither of which rewraps an error, so an invalid read (a cursor that is not one, an unknown conversation) fails only that call. A fenced commit still fails the Session: on 1.2 later operations refuse with `SessionFailed`, its `cause` the fenced error, where 1.0.4 and 1.1 said the Session was poisoned.
67
+ - pi-durable 1.2's `bash` answers a nonzero exit with an error result where 1.0.4 and 1.1 threw. The workspace barrier runs after it either way, with the command's writes on disk before the error result commits.
68
+ - The floor job measured a mixed family: its install left pi-durable 1.0.4 at the root loading chord and pi-ai 1.1.0, and the version guard checked only what the package resolved. The guard now also checks chord and pi-ai as the installed pi-durable loads them, and reads `PI_EXPECT_VERSION` beside `PDA_EXPECT_PI_DURABLE`; the job installs one copy of each family package through `scripts/pi-leg.mjs`.
69
+ - The test helpers that signal a process (`killQuietly`, `alive`) refuse a pid of 0 or below: a test that never learned its child's pid passed 0 from its `finally`, and `kill(0)` signalled the test runner's whole process group. A test removes a directory a run still writes into without racing its heartbeat (from #251).
70
+ - `@parcha/pi-durable-disk/lease` exports the four types its own signatures name: `AcquireOptions`, `PersistRecord` and `ClaimDir` (in `OpenRunLeaseOptions`) and `Unmounted` (in `Claim`). A host that imports only this entry can now write the type of every option it passes to `openRunLease`. They are type-only exports, so the entry's bundle still leaves out the supervisor, the host drivers, the CLI and the app contract. A test checks that every type of the package the entry's declarations name is exported by the entry.
71
+ - **A run on a local directory ends the commands a killed instance left running, before it resumes**
72
+ - pi makes each command the leader of its own process group, so a SIGKILL of the instance does not reach its commands,
73
+ and a local run has no unit cgroup to end them: a command kept running, and writing in `work/`, beside the next
74
+ incarnation. Now a local run's environment marks each command it starts (`PI_DURABLE_DISK_COMMANDS=<its token>` in the
75
+ command's environment) and records each of its commands' groups (the leader and every descendant it has seen, each by
76
+ pid, start ticks and boot) under `<root>/commands/`: another environment's commands in the same process, or the app's
77
+ own detached helpers, are never its to record. Each walk follows every recorded process still alive, so a background
78
+ worker whose leader exited, and the children it starts later, are recorded too, and the next open, holding the owner lock, kills what is
79
+ still the recorded process, waits until it is gone and clears the records, before the Harness opens (a new open step,
80
+ `commands`, after `layout`).
81
+ - A kill reaches a process only when its pid, start ticks and boot all match a record, and never pid 1 or below
82
+ (`kill(-1)` would reach every process the user may signal), the opening process or its group. Only the instance writes records; a command that forged one could already kill what it names (it runs as
83
+ the same user, or with `runAs` cannot write the instance's 0700 directory), so a record grants nothing.
84
+ - What is still alive at the reap's deadline (a process in an uninterruptible wait, or one it may not signal) refuses the
85
+ open: `HeldError` with holder `commands` (`COMMANDS_ALIVE`, exit 76), naming each pid and its command line and saying
86
+ what to do (end them, then open the run again). The run never resumes beside it, its record stays, and the next open
87
+ tries again. A record that cannot be written (a full disk) is said once on
88
+ stderr and the recorder keeps trying. A command started within the last walk's interval (about 0.1 s) before the kill
89
+ can be missed.
90
+ - A claim opts in with `Claim.commands` (the directory); `localRun`'s claim sets it. A mount's run is unchanged: its host
91
+ ends commands with the unit's cgroup or the container.
92
+ - **Stopping an instance never signals pid 1 or below, whatever its handle or state file says, nor does the lease watchdog**
93
+ - `localHost`'s `stop` of a development-mode (`child`) handle signals the handle's pid as a group. A handle comes back
94
+ from `run.json` with whatever was written there: pid 1 with pid 1's real start ticks made it `kill(-1)`, every process
95
+ the supervisor's user may signal, and pid 0 (or a pid that is not an integer) with no start ticks made it `kill(-0)`,
96
+ the supervisor's own group. It now signals only an integer pid above 1 whose handle carries start ticks.
97
+ - The Daytona launcher's `stop` had the first of those: a state file naming pid 1 with its real start ticks made it
98
+ SIGTERM pid 1 and `kill(-1, SIGKILL)`. Its liveness check now takes only an integer pid above 1 with start ticks.
99
+ - The lease watchdog's sweep killed a direct child's process group unless it was the instance's own or 0 and below; a
100
+ child in group 1 (possible in a container whose init's group is in the instance's session) made it `kill(-1)`. A
101
+ child in group 1 is now killed alone.
102
+ - **A tool may write in the temp directory it was given**
103
+ - `archilEnv`'s confinement made all of `<root>/tmp/` read-only to file operations, including the directories its own
104
+ `createTempDir` (and `createTempFile`) hands out, so a tool that writes into the temp dir it asked for was refused
105
+ `outside_work`: pi-vision's `read` of any media file failed on a disk run. A directory `createTempDir` returns is now
106
+ writable, for the run's environments; the rest of `tmp/` (another run's directories, `tmp/` itself) stays read-only.
107
+ - The tool may remove that directory (`env.remove`, as vision does when it is done), and its grant goes with it.
108
+ - It is made through a held handle of `tmp/` that does not follow a link, and granted by name under the canonical `tmp/`:
109
+ a `tmp/` a command replaced with a link is refused, and a directory swapped for a link grants nothing beyond itself.
110
+ - **The self-fence watchdog starts in a host launched with `--input-type=module`**
111
+ - The watchdog's worker is eval'd CommonJS and inherited the host's Node flags. Under `--input-type=module` (a host that
112
+ runs inline ESM through `-e` or stdin) Node read it as ESM, it threw `require is not defined`, and the run died at
113
+ open. The worker now starts with no inherited flags (`execArgv: []`); it needs none of the host's.
114
+ - An example's test can no longer hang past its timeout: `runExample` waits for the example's output only until the timeout
115
+ and a 2 s grace, then drops the pipes a process outside the example's group still holds and reports the run as timed out.
116
+ The package's tests run with `--test-timeout=240000`, so a test that hangs fails with its name.
117
+ - **An example, `audit-across-crashes`: the store is the record**
118
+ - A run on a local directory is killed twice in the middle of a tool call. A recorder in `beforeResume` snapshots every
119
+ task, document and transcript entry at each incarnation's `head`, and the example prints a per-generation account (who
120
+ asked what, who answered, which calls were cut and which generation settled them as `interrupted`) read from those
121
+ snapshots alone. No disk, no key: `node examples/audit-across-crashes/main.ts` after the build, with pi-ai's faux model.
122
+ A test runs it in `npm test`.
123
+ - **An examples index and the conventions every new example follows**
124
+ - `examples/README.md` lists the examples with what each shows, what it needs, and whether CI runs it, and states how a
125
+ new one is written: one directory and one property per example, self-contained imports, `node examples/<name>/main.ts`
126
+ on a local directory with pi-ai's faux model (no key, no network), one closing `ok: ...` line, and a test under
127
+ `test/examples/` that runs it on every change.
128
+ - `test/examples/conventions.test.ts` checks what code can: each example's files, its imports (read from the parse tree),
129
+ its test and its row in the index. `test/examples/_run-example.ts` runs an example from a scratch directory as its own
130
+ process group and reaps the group whatever ends it, and `assertExampleOk` fails a run that exited 0 while a
131
+ process it started still held its output; the quickstart's test uses both.
132
+ - **A quickstart on one machine: no disk, no key**
133
+ - The README opens with a durable chat on a directory of this machine (`localRun`) and pi-ai's faux model: one process
134
+ is killed (`SIGKILL`) after its third answer, a second opens the same run and answers the rest, and the transcript read
135
+ back from the store has every message answered once. It is `examples/01-durable-chat/on-a-directory.mjs`.
136
+ - The stranger test installs the packed package and its peers in an empty directory, runs the README's script and
137
+ expects that line, so the quickstart a reader copies is the one that is checked.
138
+ - The README's bundle sizes for the two entries are re-measured: about 60 KB for `@parcha/pi-durable-disk/lease` against about 620 KB for the root entry (it said 52 KB and 493 KB), with the esbuild command that produces them.
139
+ - **The README starts with a path chooser and states how a run on a local directory fails**
140
+ - "Choose your path" maps what a reader has (Linux and Node only, Docker and an Archil account, a Linux host with FUSE, an
141
+ app of their own) to the quickstart or section to start with, and points to the examples index.
142
+ - The failure table gains the rows for a run on a local directory (`localRun`): a crash or SIGKILL (the next open ends
143
+ the commands the killed instance left running, then resumes), a power loss (the store holds, `work/` may be older), a
144
+ second process on the machine (refused, exit 76), a process of the killed incarnation that will not die (the open is
145
+ refused, exit 76, naming it), and another machine reaching the directory (unsupported: no fence).
146
+ The layout names a local run's `commands/`.
147
+
3
148
  ## 0.1.0-beta.12, 2026-10-09
4
149
 
5
150
  Renamed from `@parcha/pi-durable-archil`. The package is now `@parcha/pi-durable-disk` and its command is `pi-durable-disk`, for branding:
package/README.md CHANGED
@@ -14,6 +14,18 @@ release names no other backend and promises none.
14
14
  pi-durable assumes two things it does not provide: exactly one live process per store, and a store that outlives the
15
15
  machine. A Cloudflare Durable Object gives it both. This package gives it both on an Archil disk you mount on any Linux host.
16
16
 
17
+ ## Choose your path
18
+
19
+ | You have | Start with | What you see |
20
+ |---|---|---|
21
+ | Linux and Node 22.19 or later, nothing else | [Quickstart on one machine](#quickstart-on-one-machine-no-disk-no-key) | A durable chat on a directory of this machine, killed with `SIGKILL` and finished by a second process. One machine and no fence: the directory is the claim |
22
+ | Docker (macOS or Linux) and an Archil account | [Quickstart with Docker](#quickstart-with-docker) | A host killed in the middle of a paid charge, and the run resumed on another, every instance in a container |
23
+ | A Linux host with FUSE and an Archil account | [Quickstart on a Linux host](#quickstart-on-a-linux-host-kill-a-host-watch-the-run-resume-on-another), then [Setting up a host for production](#setting-up-a-host-for-production) | The production shape: systemd units, the supervisor, and a lost host's run back in about a second |
24
+ | An app or a host of your own | [The app](#the-app) and [Library](#library) | `openDurableRun`, `localRun`, the supervisor and host drivers, `serve`, parking and `fork` from your code |
25
+
26
+ [examples/](examples/README.md) lists the examples, one property each, with what each needs: several run with no disk
27
+ and no key.
28
+
17
29
  ## How it maps to a Durable Object
18
30
 
19
31
  | Durable Object | This package | Supplied by |
@@ -48,10 +60,12 @@ machine. A Cloudflare Durable Object gives it both. This package gives it both o
48
60
  - An Archil account, a disk, and an API key for the supervisor, all from the Archil console. Create the disk in the region
49
61
  nearest the host (any size; its id looks like `dsk-0123456789abcdef` and its region like `aws-us-east-1`). Hosts and
50
62
  instances never hold the API key.
51
- - `@earendil-works/pi-durable` and `@earendil-works/chord`, `^1.0.4`, as peer dependencies.
63
+ - `@earendil-works/pi-durable` and `@earendil-works/chord`, `>=1.0.4 <1.3.0`, as peer dependencies. The package is developed against 1.1.0, and CI runs its whole suite on 1.0.4 too, the lowest version the range accepts, and on 1.2.
64
+
65
+ Not covered yet: a Kubernetes host driver (the `HostDriver` interface is below) and running the loop across machines
66
+ where FUSE is unavailable. Without Archil, a run can live on a plain directory of one machine
67
+ ([A run on a local directory](#a-run-on-a-local-directory)).
52
68
 
53
- Not covered yet: a Kubernetes host driver (the `HostDriver` interface is below) and running the loop where FUSE is
54
- unavailable.
55
69
 
56
70
  ## Install
57
71
 
@@ -66,6 +80,114 @@ git clone https://github.com/Parcha-ai/agentrun.git && cd agentrun && npm ci --i
66
80
  cd packages/pi-durable-disk
67
81
  ```
68
82
 
83
+ ## Quickstart on one machine: no disk, no key
84
+
85
+ The fastest look, on Linux with Node 22.19 or later: a durable chat on a directory of this machine, with
86
+ [`localRun`](#a-run-on-a-local-directory) instead of a disk and pi-ai's faux model instead of a key. One process answers the
87
+ first messages and is killed (`SIGKILL`) after its third answer; a second opens the same run and answers the rest, and the
88
+ transcript, read back from the store, has every message answered once. The directory is the claim, so this guards one
89
+ machine only; the quickstarts below put the run on a disk that outlives the machine.
90
+
91
+ In an empty directory, install the package and its peers, save this as `chat.mjs`
92
+ (it is [examples/01-durable-chat/on-a-directory.mjs](examples/01-durable-chat/on-a-directory.mjs)):
93
+
94
+ ```sh
95
+ npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
96
+ ```
97
+
98
+ ```js
99
+ // A durable chat on a directory of this machine: no disk, no key (pi-ai's faux model answers). One process answers the
100
+ // first messages and is killed (SIGKILL) after its third answer; a second opens the same run and answers the rest, and
101
+ // the transcript, read back from the store, has every message answered once. One machine: the directory is the claim.
102
+ import { spawn } from "node:child_process";
103
+ import { rmSync } from "node:fs";
104
+ import { join } from "node:path";
105
+ import { createInterface } from "node:readline";
106
+ import { fileURLToPath } from "node:url";
107
+ import { BACKGROUND_CONTEXT as ctx } from "@earendil-works/chord/context";
108
+ import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
109
+ import { createRegistry } from "@earendil-works/pi-durable";
110
+ import { localRun, openDurableRun } from "@parcha/pi-durable-disk";
111
+
112
+ const MESSAGES = ["Where does your transcript live?", "What happens if this process is killed now?", "Who answers after that?", "What was my first question?", "Are you the same process?", "Say goodbye."];
113
+ const root = join(process.cwd(), "chat-run");
114
+ const text = (message) => (typeof message.content === "string" ? message.content : message.content.map((part) => part.text ?? "").join(""));
115
+
116
+ if (process.argv[2] === "instance") {
117
+ let generation = 0;
118
+ const faux = fauxProvider();
119
+ faux.setResponses(Array.from({ length: 100 }, () => (context) => {
120
+ const asked = context.messages.filter((message) => message.role === "user");
121
+ return fauxAssistantMessage(`Answer ${asked.length}, by generation ${generation}: "${text(asked.at(-1))}"`);
122
+ }));
123
+ const models = createModels();
124
+ models.setProvider(faux.provider);
125
+ const run = await openDurableRun({ disk: "local", region: "local", id: "chat" }, { ...localRun(root), harness: { models, registry: createRegistry() } });
126
+ generation = run.generation;
127
+ const model = faux.getModel();
128
+ const chat = await run.harness.root(ctx, { agent: { model: { provider: model.provider, modelId: model.id } } });
129
+ const answers = async () => (await chat.entries({}, 100, undefined, ctx)).items.reverse().filter((entry) => entry.kind === "pi.assistant");
130
+ const before = (await answers()).length;
131
+ console.log(`generation ${generation} opened the run in ${root} (${before} answered before)`);
132
+ for (const [i, content] of MESSAGES.entries()) {
133
+ // The request id makes a message a no-op once it was admitted: a new generation walks the list from the top.
134
+ await (await chat.submit({ type: "input", content, requestId: `message-${i + 1}` }, ctx)).wait(ctx);
135
+ if (i >= before) console.log(`generation ${generation} answered message ${i + 1}`);
136
+ await new Promise((resolve) => setTimeout(resolve, 200));
137
+ }
138
+ for (const answer of await answers()) console.log(` ${text(answer.model[0])}`);
139
+ await run.setStatus("done");
140
+ await run.release();
141
+ process.exit(0);
142
+ }
143
+
144
+ // The supervisor: an instance, killed after its third answer, then another on the same directory (a fresh one each time).
145
+ // It reads whole lines, and waits for `close` (the process gone and all its output read), not `exit`.
146
+ rmSync(root, { recursive: true, force: true });
147
+ const start = () => spawn(process.execPath, [fileURLToPath(import.meta.url), "instance"], { stdio: ["ignore", "pipe", "inherit"] });
148
+ const eachLine = (child, onLine) =>
149
+ createInterface({ input: child.stdout }).on("line", (line) => {
150
+ console.log(line);
151
+ onLine(line);
152
+ });
153
+ const first = start();
154
+ eachLine(first, (line) => {
155
+ if (/answered message 3\b/.test(line)) first.kill("SIGKILL");
156
+ });
157
+ await new Promise((resolve) => first.on("close", resolve));
158
+ console.log("the first process was killed (SIGKILL); a second opens the same run");
159
+ const output = [];
160
+ const second = start();
161
+ eachLine(second, (line) => output.push(line));
162
+ const code = await new Promise((resolve) => second.on("close", resolve));
163
+ const answered = output.flatMap((line) => {
164
+ const match = /^ {2}Answer (\d+), by generation (\d+)/.exec(line);
165
+ return match ? [{ n: Number(match[1]), generation: Number(match[2]) }] : [];
166
+ });
167
+ const once = MESSAGES.every((_, i) => answered.filter((a) => a.n === i + 1).length === 1) && answered.length === MESSAGES.length;
168
+ const generations = [...new Set(answered.map((a) => a.generation))].sort();
169
+ if (code !== 0 || !once || generations.length < 2) {
170
+ console.error(`not ok: exit ${code}, answers ${JSON.stringify(answered)}`);
171
+ process.exit(1);
172
+ }
173
+ console.log(`ok: ${MESSAGES.length} messages answered once each, by generations ${generations.join(" and ")}, from the store in ${root}`);
174
+ ```
175
+
176
+ Then run it (about five seconds; it writes the run under `./chat-run/`):
177
+
178
+ ```sh
179
+ node chat.mjs
180
+ ```
181
+
182
+ It ends with:
183
+
184
+ ```
185
+ Answer 1, by generation 1: "Where does your transcript live?"
186
+ ...
187
+ Answer 6, by generation 2: "Say goodbye."
188
+ ok: 6 messages answered once each, by generations 1 and 2, from the store in <this directory>/chat-run
189
+ ```
190
+
69
191
  ## Quickstart with Docker
70
192
 
71
193
  You need Docker (Docker Desktop, OrbStack or Colima on macOS, Docker Engine on Linux), Node 22.19 or later, and an Archil
@@ -140,7 +262,7 @@ Each example's README lists the exact commands the demo runs, so you can run the
140
262
 
141
263
  An instance is `pi-durable-disk run --app <module> ...`: `openDurableRun` with the app's options. The module is an ES
142
264
  module whose default export is a function of where the run lives and returns pi's Harness options without `env` (the run
143
- builds `env` on its claim: the workspace is `<root>/work`), plus an optional `onOpen`:
265
+ builds `env` on its claim: the workspace is `<root>/work`), plus an optional `beforeResume` and an optional `onOpen`:
144
266
 
145
267
  ```ts
146
268
  import { BACKGROUND_CONTEXT as ctx } from "@earendil-works/chord/context";
@@ -159,6 +281,10 @@ export default async function app(where: AppContext): Promise<AppOptions> {
159
281
  return {
160
282
  models,
161
283
  registry,
284
+ async beforeResume({ harness, context, generation }) {
285
+ // Awaited once per incarnation, after the Harness is open and before it resumes: no resumed task takes a step
286
+ // until this settles, and what it admits commits before their steps do. Reconcile here what a resumed tool depends on.
287
+ },
162
288
  async onOpen(run) {
163
289
  // Called once per incarnation, after the run is open and resumed. run.generation is 1 on a first start and
164
290
  // higher on every resume. run.harness is pi-durable's Harness.
@@ -176,7 +302,11 @@ rerun. Any other tool is an effect: its intent is committed before it starts, an
176
302
  was `interrupted`, never run again. `run.setStatus("done" | "failed" | "paused" | "sleeping", detail)` records the run's
177
303
  status in `run.json`; `done` and `failed` run the durability barrier first. `run.release()` closes the Harness, kills the
178
304
  run's commands, runs the barrier, seals `run.json` with the store's last sequence, and unmounts. A module that is missing
179
- or throws, or an `onOpen` that rejects, is `AppError` (exit 1, which the unit retries).
305
+ or throws, or an `onOpen` that rejects, is `AppError` (exit 1, which the unit retries). A `beforeResume` that rejects
306
+ fails the open (its step is `before-resume`) and releases the claim. Besides the Harness, the hook gets `storage`, the
307
+ store the Harness was opened over as four reads (`scanConversations`, `scanTasks`, `scanDocuments`, `document`) with no
308
+ way to a write, and `head`, the store's last committed sequence: a recorder snapshots every document at `head` there.
309
+ Until the hook commits, the current state is the state at `head`, which is how a document that keeps no history reads.
180
310
 
181
311
  ## The CLI
182
312
 
@@ -409,10 +539,22 @@ mount token reaches the journal, and that nothing is left behind. It needs syste
409
539
  | A second instance on the same host | The owner lock (an exclusive SQLite lock on `owner.lock`) is refused, exit 76 | none |
410
540
  | Two supervisors race | The mount admits one claimant; the other instance exits 76 | none |
411
541
  | The control API lists the run's holder without a path (it resolves paths best-effort) | The supervisor maps pathless delegations' inodes to run directories with one `Disk.exec` per pass (`find -inum`) and counts one on the run's directory as the run's: a live one keeps the run healthy, an orphaned one is revoked and the run restarted. Where `exec` is unavailable or fails, the pass is `CONTROL_API_FAILED` and starts nothing (the supervisor never forces); a dead holder then stays until Archil lists its path again or an operator revokes it by client and inode | no second instance; nothing started over a holder it cannot see |
542
+ | A client dies holding its own private directories (`.archil/client-<clientId>` and its `unlinked/`, which the control API lists with no path and on no run directory) | They block no run. Every revoke that fences or deletes a run (`revoke`, `takeOver`, the supervisor's takeover, `deleteRunTree`) also revokes, from the same listing, the private-directory delegations of the clients it revokes on the run, confirmed by one `Disk.exec` (`stat`), unless the client holds anything else; best effort, so one left stays for an operator to revoke by client and inode | none: they hold no run directory |
412
543
  | The store is behind its `run.json` seal | `STORE_BEHIND_SEAL`: `run.json` is marked failed and the instance exits 65; the supervisor does not restart it | none started |
413
544
  | SIGTERM (a deploy) | The instance stops taking submissions and drains for up to `--drain-timeout`, writes `sleeping` with its wake (now; the deadline when everything only waits; null when idle and woken by requests), then closes, kills its commands, barriers, seals and unmounts, exit 0; the supervisor starts it again at the wake | work cut at the drain's deadline resumes on the next open: safe tools rerun, unsafe ones report the interruption |
414
545
  | Everything the run does is a retry or poll wait longer than the park threshold | The instance writes `sleeping` with `wakeAt` (the deadline), releases and exits 0; the supervisor starts it at `wakeAt` and pi's timer sleeps whatever is left; a `run.json` write that fails is a fence (exit 75) and the lease path resumes the run | nothing is lost: the wait is a checkpoint in the store |
415
546
 
547
+ **On a local directory** (`localRun`) nothing is mounted or delegated and no supervisor watches the run, so the rows above
548
+ about revocation, takeover and the lease's replacement do not apply. These do:
549
+
550
+ | Failure | What happens | Effects |
551
+ |---|---|---|
552
+ | The instance crashes or is SIGKILLed | Its owner lock goes with the process. The next open of the directory (by the app, or whatever restarts it) takes the run, ends the commands the killed incarnation left running (its `commands` step, before the Harness opens), and `resume()`s from the last commit | running tasks go back to pending; effects in flight are `interrupted`, never rerun; no command of the killed incarnation still runs once the run resumes. pi makes each command the leader of its own process group, so the SIGKILL does not reach them and a local run has no unit cgroup: the instance records each command's group under `commands/` (by pid, start ticks and boot) and the next open kills what still matches. A command started within about 0.1 s before the kill can be missed |
553
+ | A process the killed incarnation's commands started does not die at the next open (an uninterruptible wait, or one the run's user may not signal) | The open is refused: `HeldError` `COMMANDS_ALIVE`, exit 76, naming each pid and its command line; the records stay | none: the run does not resume beside it. End those processes (`kill -9` as their owner, or wait for them) and open the run again |
554
+ | Power loss or an OS crash | The store's commits are durable (SQLite at `synchronous = FULL`); `work/` is the operating system's to flush, since the claim's barrier syncs nothing | the store resumes from its last commit; files the agent wrote in `work/` can be older than what the store recorded |
555
+ | A second process opens the run on this machine | The owner lock is refused (`HeldError`, exit 76) | none |
556
+ | A process on another machine reaches the directory (a network share) | Nothing stops it: a local directory has no fence | unsupported, since two writers can corrupt the store. A directory another machine can reach needs a disk with a claim and a fence |
557
+
416
558
  What a revoke cannot do: no fence reaches a third party. A paid API that already has a request has it. The package
417
559
  guarantees that this runtime does not start an effect twice; an effect your model decides to retry is a new call, so give a
418
560
  paid API an idempotency key from your own business key.
@@ -442,7 +584,8 @@ to 6.3 s with a 6 s test lease (90 to 120 s with the defaults).
442
584
  - **The file tools stay inside `work/`.** pi's `read`, `write` and `edit` run in the loop's process, not in a command
443
585
  sandbox, so every file operation of the environment resolves its path to the file the kernel would reach (`..`, absolute
444
586
  paths, symlinks, dangling links, a missing tail) and refuses it unless it lies in `work/` (reads may also use `tmp/`, where
445
- the bash tool tells the model its long output went, and `readRoots` the app names). An agent cannot `write
587
+ the bash tool tells the model its long output went, and `readRoots` the app names; writes may also go inside a directory
588
+ the environment's `createTempDir` returned, for the tool it was handed to). An agent cannot `write
446
589
  ../store/run.sqlite`, `../run.json` or `../owner.lock`: the call fails with a `PathOutsideWorkError` the model sees as
447
590
  a failed tool result. A path is opened one component at a time from a held handle of `work/`, without following links,
448
591
  so a link a command swaps in after the check is refused too. This covers the file tools only; a command still runs as
@@ -473,6 +616,9 @@ to 6.3 s with a 6 s test lease (90 to 120 s with the defaults).
473
616
  tmp/ pi's temp files and long-output spills, so a path a result names still exists after a takeover
474
617
  ```
475
618
 
619
+ A run on a local directory (`localRun`) has the same layout, plus `commands/`: the process groups of the commands its
620
+ instance started, which the next open ends before it resumes (a local run has no unit cgroup to end them).
621
+
476
622
  The mountpoint is the same absolute path on every host of a deployment, so paths inside the run are identical wherever
477
623
  it resumes.
478
624
 
@@ -484,9 +630,9 @@ its objects, and remove its token users (`supervise --id ID --sweep-tokens --tok
484
630
 
485
631
  **Revoke before you delete.** Deleting the objects of a run through the S3 API while a delegation on the path is still
486
632
  held, even an orphaned one left by a killed host, does not take effect: the same run id came back with its old store when a
487
- supervisor next started it. `deleteRunTree(control, id)` does it in order: it revokes the run's delegations, deletes, and
488
- requires the prefix to be empty. Doing it by hand, revoke the delegation first, delete, then list the prefix and check it is
489
- empty.
633
+ supervisor next started it. `deleteRunTree(control, id)` does it in order: it revokes the run's delegations and its holders' private directories,
634
+ deletes, and requires the prefix to be empty. Doing it by hand, revoke first (`revoke(control, id)` takes the same set), delete,
635
+ then list the prefix and check it is empty.
490
636
 
491
637
  ## Library
492
638
 
@@ -502,8 +648,35 @@ A host that owns its own pi-durable Harness and store connections imports the na
502
648
  self-fence, the seal and the release, without a store or a Harness), `storeHead`, the store's fence and pragma helpers
503
649
  (`Fence`, `FencedDatabase`, `assertPragmas`, `PROFILE_PRAGMAS`, `openArchilStore`), and the typed errors and exit codes. It
504
650
  leaves out the supervisor, the host drivers, the CLI and the app contract, and the package declares `"sideEffects": false`, so
505
- a bundler keeps only what you use: a bundle of the app with `await import("@parcha/pi-durable-disk/lease")` is about 52 KB
506
- minified against about 493 KB for `await import("@parcha/pi-durable-disk")` (measured with esbuild, pi-durable inlined).
651
+ a bundler keeps only what you use: a bundle of the app with `await import("@parcha/pi-durable-disk/lease")` is about 60 KB
652
+ minified against about 620 KB for `await import("@parcha/pi-durable-disk")` (esbuild `--bundle --minify --platform=node
653
+ --format=esm`, which inlines pi-durable, chord and their dependencies).
654
+
655
+ ### A run on a local directory
656
+
657
+ `localRun(root)` returns the `acquire` and `claimDir` that put a run on a plain directory of this machine, with no mount
658
+ and no mount token:
659
+
660
+ ```ts
661
+ import { localRun, openDurableRun } from "@parcha/pi-durable-disk";
662
+
663
+ const run = await openDurableRun({ disk: "local", region: "local", id: "run-1" }, {
664
+ ...localRun("/var/runs/run-1"), // made when it is missing; work/ and store/ inside it
665
+ harness: { models, registry },
666
+ });
667
+ ```
668
+
669
+ It is for one machine (a laptop, a CI job, a test), and says so: the claim is the directory itself, so nothing but this
670
+ machine's owner lock keeps a second process off the run (a second open while one holds it is refused), and nothing at
671
+ all keeps out a process on another machine that can reach the directory. Its barrier refuses once the run is fenced,
672
+ like a mount's, but syncs nothing: the store's commits are made durable by SQLite, and the files the agent wrote in
673
+ `work/` are the operating system's to flush, so a power loss can lose what it had not written yet. `mountToken` is
674
+ required only with the default `acquire`; without either, the open is refused (`INVALID_ARGUMENT`) before anything mounts.
675
+
676
+ It needs Linux, for now. The run's root is held open through `/proc/self/fd` (`openClaimDir`), and the default
677
+ environment confines commands with `setpriv` (util-linux, `noNewPrivs`) and the file tools through `/proc/self/fd`
678
+ (`confineFiles`). macOS has neither, and turning the environment's two off does not help: the open still holds the root
679
+ through `/proc/self/fd`. A portable claim directory is what a local run on macOS would need.
507
680
 
508
681
  ## Tests
509
682
 
package/dist/app.d.ts CHANGED
@@ -11,6 +11,11 @@ export interface AppContext {
11
11
  }
12
12
  /** pi's HarnessOptions without `env` (registry, models, settings, conversationCreated, now, onReport), plus the hooks below. */
13
13
  export type AppOptions = OpenDurableRunOptions["harness"] & {
14
+ /**
15
+ * Awaited once per incarnation after the Harness is open and before it resumes (`OpenDurableRunOptions.beforeResume`):
16
+ * no resumed task takes a step until it settles. A rejection fails the open (exit 1, retried).
17
+ */
18
+ beforeResume?: OpenDurableRunOptions["beforeResume"];
14
19
  /**
15
20
  * Called once per incarnation after the run is open and resumed; `run.generation` tells a first start from a resume.
16
21
  * Work that must keep the instance up belongs in pi's tasks and tools: parking sees only those.
package/dist/app.js CHANGED
@@ -8,8 +8,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
8
8
  };
9
9
  // The app an instance hosts (`pi-durable-disk run --app <module>`): an ES module whose default export is a function
10
10
  // of where the run lives that returns pi's Harness options (without `env`, which the run builds on its claim), an
11
- // optional `onOpen` for the app to submit or resume work once the run is open and resumed, how `serve` creates the root
12
- // conversation, and how a parking run records its wake.
11
+ // optional `beforeResume` for what must settle before the run resumes, an optional `onOpen` for the app to submit or
12
+ // resume work once the run is open and resumed, how `serve` creates the root conversation, and how a parking run records
13
+ // its wake.
13
14
  import { resolve } from "node:path";
14
15
  import { pathToFileURL } from "node:url";
15
16
  import { PdaError } from "./errors.js";
package/dist/claim.d.ts CHANGED
@@ -92,6 +92,12 @@ export interface Claim {
92
92
  readonly fenced: boolean;
93
93
  /** Mark the claim lost (the store's facade calls this on an I/O-class error); later barriers throw at once. */
94
94
  markFenced(cause?: unknown): void;
95
+ /**
96
+ * Where the run's environment records the commands it starts, so that the next open of this claim ends what a killed
97
+ * incarnation left running (`reapCommands`, before the Harness opens). Set by a claim that nothing else cleans up after,
98
+ * as a local run's; a mount's host ends them with its unit's cgroup or its container.
99
+ */
100
+ readonly commands?: string;
95
101
  /** `archil sync`: every pending write on the mount is durable when it resolves. A failure fences the claim. */
96
102
  barrier(): Promise<{
97
103
  ms: number;
@@ -174,8 +180,29 @@ export type PathlessResolver = (pathless: readonly Delegation[]) => Promise<Read
174
180
  * removes them.
175
181
  */
176
182
  export declare function pathlessResolver(control: Pick<ControlApi, "exec">): PathlessResolver;
177
- /** Revoke every delegation on the run through the control API. The old holder's next fsync returns EIO. */
183
+ /**
184
+ * The delegations `held`'s clients also hold on their own private directories (`privateDirs`: inode to the client whose
185
+ * private directory it is). A killed client leaves them orphaned, and nothing ties them to a run: they block no run, but
186
+ * they pile up and resurface in listings. A client that holds anything else beyond `held` may hold another run, so none
187
+ * of its delegations is a companion.
188
+ */
189
+ export declare function companionsOf(all: readonly Delegation[], held: readonly Delegation[], privateDirs: ReadonlyMap<number, string>): Delegation[];
190
+ /**
191
+ * Revoke the companions of `held` (`companionsOf`): only for clients the caller is already revoking because it fences or
192
+ * deletes a run, never as a sweep. From `all` or a fresh listing. Best effort, and hygiene only: a companion blocks no
193
+ * run, so one left behind (or a failed listing or `exec`) costs a listing entry. Returns the ones revoked.
194
+ */
195
+ export declare function revokeCompanions(control: Pick<ControlApi, "listDelegations" | "revokeDelegation" | "exec">, held: readonly Delegation[], all?: readonly Delegation[]): Promise<Delegation[]>;
196
+ /**
197
+ * Revoke every delegation on the run through the control API, then its holders' private directories
198
+ * (`revokeCompanions`). The old holder's next fsync returns EIO. Returns the run's own delegations.
199
+ */
178
200
  export declare function revoke(control: ControlApi, id: string): Promise<Delegation[]>;
201
+ /**
202
+ * Revoke what `revoke` revokes, skipping every revoke that fails: for cleanup, where deleting the tree next checks what
203
+ * is left. Only the listing (and a pathless resolution) can fail it. Returns the run's own delegations.
204
+ */
205
+ export declare function revokeBestEffort(control: Pick<ControlApi, "listDelegations" | "revokeDelegation" | "exec">, id: string): Promise<Delegation[]>;
179
206
  /** Revoke through the control API, then mount plainly; if the API path fails, mount with `--force`. */
180
207
  export declare function takeOver(control: ControlApi, opts: AcquireOptions): Promise<Claim>;
181
208
  /**
package/dist/claim.js CHANGED
@@ -195,18 +195,89 @@ async function runDirsByInode(control, inodes) {
195
195
  }
196
196
  return names;
197
197
  }
198
- /** Revoke every delegation on the run through the control API. The old holder's next fsync returns EIO. */
198
+ /** A client's private directories on the disk: `.archil/client-<clientId>` and its `unlinked/`. */
199
+ const PRIVATE_DIR = /^\/*\.archil\/client-([A-Za-z0-9_-]+)(\/unlinked)?$/;
200
+ /**
201
+ * The delegations `held`'s clients also hold on their own private directories (`privateDirs`: inode to the client whose
202
+ * private directory it is). A killed client leaves them orphaned, and nothing ties them to a run: they block no run, but
203
+ * they pile up and resurface in listings. A client that holds anything else beyond `held` may hold another run, so none
204
+ * of its delegations is a companion.
205
+ */
206
+ export function companionsOf(all, held, privateDirs) {
207
+ const clients = new Set(held.map((d) => d.clientId));
208
+ const listed = new Set(held.map((d) => `${d.clientId}/${d.inodeId}`));
209
+ const others = all.filter((d) => clients.has(d.clientId) && !listed.has(`${d.clientId}/${d.inodeId}`));
210
+ const own = (d) => (d.path ? PRIVATE_DIR.exec(d.path)?.[1] : privateDirs.get(d.inodeId)) === d.clientId;
211
+ const holdsMore = new Set(others.filter((d) => !own(d)).map((d) => d.clientId));
212
+ return others.filter((d) => !holdsMore.has(d.clientId));
213
+ }
214
+ /** The inodes of `clients`' private directories, by one `exec` (`stat`); none without `exec`. */
215
+ async function privateDirInodes(control, clients) {
216
+ const asked = clients.filter((c) => /^[A-Za-z0-9_-]+$/.test(c));
217
+ const dirs = new Map();
218
+ if (!control.exec || asked.length === 0)
219
+ return dirs;
220
+ const paths = asked.flatMap((c) => [`.archil/client-${c}`, `.archil/client-${c}/unlinked`]);
221
+ const r = await control.exec(`stat -c '%i %n' -- ${paths.join(" ")} 2>/dev/null; true`);
222
+ for (const line of String(r.stdout ?? "").split("\n")) {
223
+ const m = /^(\d+) (\S+)$/.exec(line);
224
+ const client = m && PRIVATE_DIR.exec(m[2])?.[1];
225
+ if (m && client && asked.includes(client))
226
+ dirs.set(Number(m[1]), client);
227
+ }
228
+ return dirs;
229
+ }
230
+ /**
231
+ * Revoke the companions of `held` (`companionsOf`): only for clients the caller is already revoking because it fences or
232
+ * deletes a run, never as a sweep. From `all` or a fresh listing. Best effort, and hygiene only: a companion blocks no
233
+ * run, so one left behind (or a failed listing or `exec`) costs a listing entry. Returns the ones revoked.
234
+ */
235
+ export async function revokeCompanions(control, held, all) {
236
+ if (held.length === 0)
237
+ return [];
238
+ const listing = all ?? (await control.listDelegations().catch(() => []));
239
+ const clients = [...new Set(held.map((d) => d.clientId))];
240
+ const listed = new Set(held.map((d) => `${d.clientId}/${d.inodeId}`));
241
+ if (!listing.some((d) => clients.includes(d.clientId) && !listed.has(`${d.clientId}/${d.inodeId}`)))
242
+ return [];
243
+ const dirs = await privateDirInodes(control, clients).catch(() => new Map());
244
+ const revoked = [];
245
+ for (const d of companionsOf(listing, held, dirs)) {
246
+ await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).then(() => revoked.push(d), () => { });
247
+ }
248
+ return revoked;
249
+ }
250
+ /**
251
+ * Revoke every delegation on the run through the control API, then its holders' private directories
252
+ * (`revokeCompanions`). The old holder's next fsync returns EIO. Returns the run's own delegations.
253
+ */
199
254
  export async function revoke(control, id) {
200
255
  const path = runPath(id);
256
+ let all;
257
+ let held;
201
258
  try {
202
- const held = await findDelegations(control, id);
259
+ all = await control.listDelegations();
260
+ held = await matchDelegations(all, id, pathlessResolver(control));
203
261
  for (const d of held)
204
262
  await control.revokeDelegation(d);
205
- return held;
206
263
  }
207
264
  catch (err) {
208
265
  throw new ClaimError("CONTROL_API_FAILED", `revoking ${path} failed`, { cause: err });
209
266
  }
267
+ await revokeCompanions(control, held, all);
268
+ return held;
269
+ }
270
+ /**
271
+ * Revoke what `revoke` revokes, skipping every revoke that fails: for cleanup, where deleting the tree next checks what
272
+ * is left. Only the listing (and a pathless resolution) can fail it. Returns the run's own delegations.
273
+ */
274
+ export async function revokeBestEffort(control, id) {
275
+ const all = await control.listDelegations();
276
+ const held = await matchDelegations(all, id, pathlessResolver(control));
277
+ for (const d of held)
278
+ await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).catch(() => { });
279
+ await revokeCompanions(control, held, all);
280
+ return held;
210
281
  }
211
282
  /** Revoke through the control API, then mount plainly; if the API path fails, mount with `--force`. */
212
283
  export async function takeOver(control, opts) {
package/dist/cli.js CHANGED
@@ -232,7 +232,7 @@ async function runInstance(values) {
232
232
  log("app failed", { code: err.code, message: err.message });
233
233
  return exitCodeFor(err);
234
234
  }
235
- const { onOpen, root: rootOptions, wake: appWake, ...harness } = app;
235
+ const { onOpen, beforeResume, root: rootOptions, wake: appWake, ...harness } = app;
236
236
  const holder = JSON.parse(process.env.PDA_HOLDER ?? "{}");
237
237
  const cgroup = watchdogOwnsCgroup(holder);
238
238
  if (cgroup.warning)
@@ -261,6 +261,7 @@ async function runInstance(values) {
261
261
  ownCgroup: cgroup.owns,
262
262
  lease: Object.fromEntries(Object.entries(lease).filter(([, v]) => v !== undefined)),
263
263
  onStep: (step, t) => void (steps[step] = Math.round(t)),
264
+ ...(beforeResume ? { beforeResume } : {}),
264
265
  });
265
266
  }
266
267
  catch (err) {