@parcha/pi-durable-disk 0.0.0-stage → 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 +329 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +695 -2
- package/bin/archil-scoped +165 -0
- package/dist/app.d.ts +40 -0
- package/dist/app.js +47 -0
- package/dist/claim.d.ts +222 -0
- package/dist/claim.js +722 -0
- package/dist/cli.d.ts +64 -0
- package/dist/cli.js +664 -0
- package/dist/commands.d.ts +67 -0
- package/dist/commands.js +299 -0
- package/dist/confine.d.ts +91 -0
- package/dist/confine.js +341 -0
- package/dist/env.d.ts +129 -0
- package/dist/env.js +608 -0
- package/dist/errors.d.ts +47 -0
- package/dist/errors.js +55 -0
- package/dist/fork.d.ts +41 -0
- package/dist/fork.js +181 -0
- package/dist/hosts/daytona-launch.d.ts +52 -0
- package/dist/hosts/daytona-launch.js +270 -0
- package/dist/hosts/daytona.d.ts +147 -0
- package/dist/hosts/daytona.js +368 -0
- package/dist/hosts/docker.d.ts +103 -0
- package/dist/hosts/docker.js +362 -0
- package/dist/hosts/local-host.d.ts +71 -0
- package/dist/hosts/local-host.js +356 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +17 -0
- package/dist/lease.d.ts +7 -0
- package/dist/lease.js +6 -0
- package/dist/local-run.d.ts +10 -0
- package/dist/local-run.js +63 -0
- package/dist/park.d.ts +92 -0
- package/dist/park.js +229 -0
- package/dist/run.d.ts +290 -0
- package/dist/run.js +785 -0
- package/dist/serve.d.ts +101 -0
- package/dist/serve.js +393 -0
- package/dist/status.d.ts +123 -0
- package/dist/status.js +264 -0
- package/dist/store.d.ts +103 -0
- package/dist/store.js +217 -0
- package/dist/supervise.d.ts +340 -0
- package/dist/supervise.js +528 -0
- package/dist/watchdog.d.ts +39 -0
- package/dist/watchdog.js +137 -0
- package/package.json +87 -4
package/README.md
CHANGED
|
@@ -1,3 +1,696 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @parcha/pi-durable-disk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A durable pi host on a network disk. It runs [pi-durable](https://www.npmjs.com/package/@earendil-works/pi-durable) agents with each
|
|
4
|
+
run's state on a disk that outlives the machine. Each run is one directory on the disk that holds pi's SQLite store and the
|
|
5
|
+
agent's workspace. One process at a time holds that directory (a second claim is refused), every commit is on the disk before
|
|
6
|
+
anything is shown, a host that is lost or hung is fenced by revoking its claim, and a stateless supervisor restarts the run on
|
|
7
|
+
another host, where pi-durable resumes from the last commit and never repeats a tool call whose intent it had already committed.
|
|
8
|
+
|
|
9
|
+
**The disk.** The package needs two things from the disk: an exclusive claim on a run's directory, so that a second mount is
|
|
10
|
+
refused, and a fence, so that revoking the claim makes the old holder's writes fail. [Archil](https://archil.com) gives both, which
|
|
11
|
+
makes it the supported disk and the only one in this release. A disk without both cannot carry the package's guarantees. This
|
|
12
|
+
release names no other backend and promises none.
|
|
13
|
+
|
|
14
|
+
pi-durable assumes two things it does not provide: exactly one live process per store, and a store that outlives the
|
|
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
|
+
|
|
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
|
+
|
|
29
|
+
## How it maps to a Durable Object
|
|
30
|
+
|
|
31
|
+
| Durable Object | This package | Supplied by |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| One live instance per id | One exclusive mount of `runs/<id>/`; a second mount is refused | the Archil server |
|
|
34
|
+
| A stale instance is fenced | The supervisor revokes the claim; the old holder's next fsync fails with EIO, its SQLite commit fails, pi poisons the session, the instance kills its commands and exits 75 | Archil, pi and this package |
|
|
35
|
+
| Knowing an instance is gone | A lease (a heartbeat in `run.json`), Archil's orphan flag for a dead client, and the host driver's own status | this package |
|
|
36
|
+
| Storage bound to the instance | pi's `SqliteStorage` at `runs/<id>/store/run.sqlite`, on the claimed mount, at `synchronous = FULL` | pi |
|
|
37
|
+
| A write is durable before the response leaves | `commit()` resolves after an Archil fsync, pi publishes only after the commit, and an effect starts only after its intent is committed | pi and Archil |
|
|
38
|
+
| The input gate | pi's single commit line | pi |
|
|
39
|
+
| Eviction and resume | Any host that claims the directory reopens the store and `resume()`s | pi and this package |
|
|
40
|
+
| Alarms | On the same host, the host's process supervisor restarts the instance; across hosts, `supervise`. pi's own retry and poll waits stay in process | this package |
|
|
41
|
+
| A container to exec in | Tools run as child processes of the instance, against the run's `work/` directory on the same mount | the host |
|
|
42
|
+
| Hibernate and wake on request | The instance parks (releases the claim) while everything waits, and `requestRun` starts it again on a request: `serve`, parking, below | this package |
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
- Either Docker, on any operating system: each instance then runs in a container of this package's image
|
|
47
|
+
(`docker/Dockerfile`, see [Quickstart with Docker](#quickstart-with-docker)), and nothing else below applies to the
|
|
48
|
+
machine; or the following on a Linux host.
|
|
49
|
+
- Linux with systemd and FUSE (`/dev/fuse`, `libfuse2`, which the archil package depends on, and `fusermount` from `fuse3`,
|
|
50
|
+
which cleans a dead mount), `setpriv` (util-linux) and `sudo`, near the disk's region: a commit is one round trip to the
|
|
51
|
+
region (3 to 6 ms p50 from a VM next to it, measured).
|
|
52
|
+
- Node 22.19 or later, to run the package and its unit tests.
|
|
53
|
+
- The [`archil` client](https://docs.archil.com/mounting/linux) 0.8.40 or later (0.8.42 tested):
|
|
54
|
+
`curl -s https://archil.com/install | sh`.
|
|
55
|
+
- Root for the mounts: every `archil` verb needs it on client 0.8.42. The package reaches root only through
|
|
56
|
+
`sudo -n` of `bin/archil-scoped` (below), so the instance and the agent's tools run unprivileged.
|
|
57
|
+
- `/proc` that shows the instance root's processes: acquire's stale-daemon cleanup needs `/proc` visibility of root
|
|
58
|
+
processes, so mount `/proc` without `hidepid`, or run the instance where it can see them. Otherwise an archil daemon
|
|
59
|
+
left on a run's mountpoint fails every acquire there with `MOUNT_FAILED` (exit 1) until something else ends it.
|
|
60
|
+
- An Archil account, a disk, and an API key for the supervisor, all from the Archil console. Create the disk in the region
|
|
61
|
+
nearest the host (any size; its id looks like `dsk-0123456789abcdef` and its region like `aws-us-east-1`). Hosts and
|
|
62
|
+
instances never hold the API key.
|
|
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)).
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
From a checkout (this package is `packages/pi-durable-disk` of the repository; the root build compiles `dist/`):
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
git clone https://github.com/Parcha-ai/agentrun.git && cd agentrun && npm ci --ignore-scripts && npm run build
|
|
80
|
+
cd packages/pi-durable-disk
|
|
81
|
+
```
|
|
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
|
+
|
|
191
|
+
## Quickstart with Docker
|
|
192
|
+
|
|
193
|
+
You need Docker (Docker Desktop, OrbStack or Colima on macOS, Docker Engine on Linux), Node 22.19 or later, and an Archil
|
|
194
|
+
account (the free plan is enough): from the Archil console, a disk for scratch runs and an API key. This machine needs no
|
|
195
|
+
root, no FUSE and no archil client: each instance of a run is a container of this package's image, and that container is
|
|
196
|
+
the instance's machine.
|
|
197
|
+
|
|
198
|
+
```sh
|
|
199
|
+
git clone https://github.com/Parcha-ai/agentrun.git && cd agentrun
|
|
200
|
+
export ARCHIL_API_KEY=... # Archil console, API keys; only the supervisor on this machine reads it
|
|
201
|
+
export ARCHIL_DISK=dsk-... # the disk's id
|
|
202
|
+
export ARCHIL_REGION=aws-us-east-1 # the disk's region
|
|
203
|
+
packages/pi-durable-disk/examples/docker-quickstart.sh
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The script prints each command before it runs it:
|
|
207
|
+
1. it checks Node, Docker and the three variables, and says whether the Docker daemon applies AppArmor;
|
|
208
|
+
2. it installs the workspace (`npm ci`), builds this package, and builds the image from `docker/Dockerfile`: Node 24, the
|
|
209
|
+
archil client checked by sha256, FUSE and this package;
|
|
210
|
+
3. it runs example 02 with `--host docker`. An agent charges six invoices through a fake paid API, and host A's container
|
|
211
|
+
is killed (`docker kill`) while the third charge is in flight. Host B's container takes over and finishes;
|
|
212
|
+
4. it cleans up: the run's containers, its directory on the disk, its token users, and the tarball it packed.
|
|
213
|
+
|
|
214
|
+
It ends with a table of what the paid API received against what the run recorded: each charge was received once, and the
|
|
215
|
+
charge that was in flight is recorded `interrupted` and was not sent again. A first run takes about two minutes, most of it
|
|
216
|
+
building the image (826 MB, 250 MB of it the archil client; `docker image rm pi-durable-disk:local` removes it). Later
|
|
217
|
+
runs take about 15 seconds. Measured on Linux (Docker Engine 29 on Ubuntu 24.04); the macOS case (Docker Desktop,
|
|
218
|
+
OrbStack, Colima, and the arm64 image on Apple silicon) is unverified until the script has been run on a Mac. Then try
|
|
219
|
+
`packages/pi-durable-disk/examples/docker-quickstart.sh freeze`: host A's container is frozen (`docker pause`), and once
|
|
220
|
+
the lease expires host B's supervisor stops it and takes over. From `packages/pi-durable-disk`, `node
|
|
221
|
+
examples/01-durable-chat/demo.ts --host docker` runs a chat that continues on the other host.
|
|
222
|
+
|
|
223
|
+
To run your own app the same way:
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
npx pi-durable-disk supervise --host docker --image pi-durable-disk:local \
|
|
227
|
+
--disk "$ARCHIL_DISK" --region "$ARCHIL_REGION" --id my-run --create --app ./my-app.ts --every 30s
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Your app's directory (or `--app-root DIR`) is mounted read-only into each container. Its imports of
|
|
231
|
+
`@parcha/pi-durable-disk`, `@earendil-works/pi-durable`, `chord` and `pi-ai` resolve to the image's copies, and a
|
|
232
|
+
`node_modules` in that directory is hidden. If your app needs other packages, build an image `FROM` this one that
|
|
233
|
+
installs them under `/opt/pda`.
|
|
234
|
+
|
|
235
|
+
## Quickstart on a Linux host: kill a host, watch the run resume on another
|
|
236
|
+
|
|
237
|
+
This runs example 02, from `packages/pi-durable-disk` of a checkout, on one machine that plays two hosts: "host A" and "host B" are two mount roots, each
|
|
238
|
+
with its own FUSE client, which to Archil are two machines. It needs the requirements above, a user with passwordless `sudo`,
|
|
239
|
+
a scratch disk and an API key. It uses pi-ai's faux model, so it needs no model key.
|
|
240
|
+
|
|
241
|
+
```sh
|
|
242
|
+
# 1. The archil wrapper, root-owned (root executes it), and one mount root per host. Once per machine.
|
|
243
|
+
sudo install -D -o root -g root -m 0755 bin/archil-scoped /usr/local/lib/pi-durable-disk/archil-scoped
|
|
244
|
+
for root in /mnt/archil-a /mnt/archil-b; do sudo mkdir -p $root/runs && sudo chown "$USER" $root/runs; done
|
|
245
|
+
|
|
246
|
+
# 2. Your disk and API key. The key stays in the supervisor's environment; no instance ever sees it.
|
|
247
|
+
export ARCHIL_API_KEY=... # from the Archil console
|
|
248
|
+
export ARCHIL_DISK=dsk-... # the disk id or name
|
|
249
|
+
export ARCHIL_REGION=aws-us-east-1
|
|
250
|
+
|
|
251
|
+
# 3. Run it: an agent charges six invoices, host A is killed while the third charge is in flight, host B takes over.
|
|
252
|
+
node examples/02-paid-effect/demo.ts kill
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
It takes about 15 seconds and ends with a table of what the fake paid API received against what the run recorded. The
|
|
256
|
+
cut charge is reported `interrupted` and was received once. (Node prints "ExperimentalWarning: SQLite is an experimental
|
|
257
|
+
feature": pi's store uses `node:sqlite`. It is harmless.) Next: `node examples/02-paid-effect/demo.ts freeze` (the host's
|
|
258
|
+
mount hangs instead of dying) and `node examples/01-durable-chat/demo.ts` (a chat that continues on the other host).
|
|
259
|
+
Each example's README lists the exact commands the demo runs, so you can run them by hand.
|
|
260
|
+
|
|
261
|
+
## The app
|
|
262
|
+
|
|
263
|
+
An instance is `pi-durable-disk run --app <module> ...`: `openDurableRun` with the app's options. The module is an ES
|
|
264
|
+
module whose default export is a function of where the run lives and returns pi's Harness options without `env` (the run
|
|
265
|
+
builds `env` on its claim: the workspace is `<root>/work`), plus an optional `beforeResume` and an optional `onOpen`:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import { BACKGROUND_CONTEXT as ctx } from "@earendil-works/chord/context";
|
|
269
|
+
import { createModels } from "@earendil-works/pi-ai/models";
|
|
270
|
+
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
|
|
271
|
+
import { createRegistry } from "@earendil-works/pi-durable";
|
|
272
|
+
import { ArchilCodingTools } from "@parcha/pi-durable-disk";
|
|
273
|
+
import type { AppContext, AppOptions } from "@parcha/pi-durable-disk";
|
|
274
|
+
|
|
275
|
+
export default async function app(where: AppContext): Promise<AppOptions> {
|
|
276
|
+
// where = { ref: { disk, region, id }, root, work, store }: known before anything mounts.
|
|
277
|
+
const models = createModels();
|
|
278
|
+
models.setProvider(openaiProvider()); // reads OPENAI_API_KEY from the instance's environment
|
|
279
|
+
const registry = createRegistry(); // every extension the run's tasks need
|
|
280
|
+
registry.install(ArchilCodingTools); // pi's read, write, edit and bash, with `read` declared replay-safe
|
|
281
|
+
return {
|
|
282
|
+
models,
|
|
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
|
+
},
|
|
288
|
+
async onOpen(run) {
|
|
289
|
+
// Called once per incarnation, after the run is open and resumed. run.generation is 1 on a first start and
|
|
290
|
+
// higher on every resume. run.harness is pi-durable's Harness.
|
|
291
|
+
const agent = { model: { provider: "openai", modelId: "gpt-6-sol" } };
|
|
292
|
+
const root = await run.harness.root(ctx, { agent });
|
|
293
|
+
await root.submit({ type: "input", content: "Summarize work/notes.md", requestId: "job-1" }, ctx); // a retry is a no-op
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`openDurableRun` installs the registry before `Harness.open`, because a recovered task resumes only when an extension
|
|
300
|
+
of its name is installed. A tool that only reads can be marked `replay: "safe"` (`markReadOnlySafe`): after a crash it is
|
|
301
|
+
rerun. Any other tool is an effect: its intent is committed before it starts, and after a crash the model is told the call
|
|
302
|
+
was `interrupted`, never run again. `run.setStatus("done" | "failed" | "paused" | "sleeping", detail)` records the run's
|
|
303
|
+
status in `run.json`; `done` and `failed` run the durability barrier first. `run.release()` closes the Harness, kills the
|
|
304
|
+
run's commands, runs the barrier, seals `run.json` with the store's last sequence, and unmounts. A module that is missing
|
|
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.
|
|
310
|
+
|
|
311
|
+
## The CLI
|
|
312
|
+
|
|
313
|
+
`pi-durable-disk` is on the PATH after an npm install (`node dist/cli.js` in a checkout).
|
|
314
|
+
|
|
315
|
+
| Command | What it does |
|
|
316
|
+
|---|---|
|
|
317
|
+
| `run --disk D --region R --id ID --app MODULE` | The instance a host driver starts: claim the run, open it, resume it. `--token-stdin` reads the mount token from stdin. Lease flags: `--heartbeat-ms`, `--lease-expiry-ms`, `--lease-margin-ms`. `--on-sigterm resume` (default) or `pause`. `--serve PORT`, `--park-threshold`, `--park-idle`, `--drain-timeout` (below). `--run-as USER` runs the agent's commands as USER when the instance runs as root (in a container) |
|
|
318
|
+
| `supervise --disk D --region R (--id ID ... \| --all) [--every 30s] --app MODULE` | One decision per run, once or in a loop: nothing to do, start, or revoke and start; `--app` is the module each started instance runs. `--create` makes the run directory. `--check` proves this host's fence first. `--sweep-tokens` removes expired token users. `--host docker --image IMAGE` starts each instance in a container (`--app-root`, `--fleet`, `--name-prefix`, `--run-as`, `--docker-arg`, `--apparmor`). Prints one JSON line per decision |
|
|
319
|
+
| `status --disk D --region R --id ID` | `run.json` over the S3 API, the run's delegations and the holder's state |
|
|
320
|
+
| `release --id ID` | Unmount the run's mount on this host (a dead mount is cleaned) |
|
|
321
|
+
| `fork --id A --new-id B` | Copy a released, sealed run into a new run (see Serve, parking and fork) |
|
|
322
|
+
|
|
323
|
+
The supervisor passes the app on (`--app MODULE`, made absolute), other flags for the instance as `--run-arg=--heartbeat-ms=2000`,
|
|
324
|
+
and the instance's environment as `--env KEY=VALUE`. The API key is read from the environment variable named by
|
|
325
|
+
`--api-key-env` (default `ARCHIL_API_KEY`) by the supervisor commands only; `run` never needs it.
|
|
326
|
+
|
|
327
|
+
An instance's exit code tells the host's process supervisor what to do:
|
|
328
|
+
|
|
329
|
+
| Exit | Meaning | Restarted? |
|
|
330
|
+
|---|---|---|
|
|
331
|
+
| 0 | Drained on SIGTERM: sealed `sleeping` (due now, the default) or `paused`, unmounted | by the supervisor at its next tick, if sleeping |
|
|
332
|
+
| 1 | The app module failed to load or `onOpen` rejected, or a mount or CLI call failed | yes, within systemd's start limit |
|
|
333
|
+
| 2 | Usage error | no |
|
|
334
|
+
| 65 | Data error: the store is behind its seal (an acknowledged commit is missing), or `run.json` is unreadable. `run.json` says `failed` | never |
|
|
335
|
+
| 70 | The store's head cannot be read (pi's schema moved). `run.json` says `failed` | never |
|
|
336
|
+
| 75 | Fenced: the claim was revoked, its mount failed or left the mount table (`CLAIM_UNMOUNTED`), or the lease lapsed | never in place; the supervisor decides |
|
|
337
|
+
| 76 | Held: another client holds the claim (a delegation, live or orphaned; the supervisor revokes orphans), or another process on this host holds the run's owner lock | never in place |
|
|
338
|
+
|
|
339
|
+
## Host drivers and the supervisor
|
|
340
|
+
|
|
341
|
+
`ensureRunning(ref, host, { control })` is the whole supervisor: a function you call (from a timer, before you talk to a
|
|
342
|
+
run, or as `supervise --every 30s`). It holds no state, and it must run in a different process from any instance, because a
|
|
343
|
+
process with files on a hung mount cannot even fork. For one run it reads `run.json` over the S3 API and the delegations
|
|
344
|
+
over the control API:
|
|
345
|
+
|
|
346
|
+
| `run.json` and the delegation | Decision |
|
|
347
|
+
|---|---|
|
|
348
|
+
| `done` or `failed` | nothing (a failed run is never restarted) |
|
|
349
|
+
| `paused`, or `sleeping` with `wakeAt` later | nothing, unless the caller passes `demand` |
|
|
350
|
+
| no delegation on `runs/<id>` | start an instance (nothing while a start is in its grace) |
|
|
351
|
+
| every delegation orphaned (the holder's client is gone, seen in 0.2 s) | revoke, start (nothing while a start is in its grace) |
|
|
352
|
+
| a delegation still checking out | nothing: a mount is in progress |
|
|
353
|
+
| held, heartbeat within 90 s | healthy |
|
|
354
|
+
| held, heartbeat older than 90 s | nothing while a start is in its grace; then stop the holder if the driver can reach it, revoke, start |
|
|
355
|
+
|
|
356
|
+
Every start first writes a mark, `runs/<id>/start.json`: the generation the new instance will write into `run.json`, and
|
|
357
|
+
when. Until `run.json` reaches that generation or the start grace passes (`--start-grace`, `startGraceMs`; default the
|
|
358
|
+
lease expiry, 0 turns it off), a tick neither revokes nor starts. An instance takes about a second from its start to its
|
|
359
|
+
first `run.json` write (node boot, mount, open), more on a busy host, and a tick inside that window would otherwise see an
|
|
360
|
+
expired lease and fence the instance it just started. The mark is in the run's directory, so supervisors in other
|
|
361
|
+
processes see it too. A start that writes nothing within the grace has failed: the next tick revokes and starts again.
|
|
362
|
+
|
|
363
|
+
A run whose starts keep failing (each instance ends before its first `run.json` write: 76 when another client holds the
|
|
364
|
+
run, 1 on `MOUNT_FAILED`) backs off: the next start records the failure in its mark (`failures`, `lastExit`) and gets
|
|
365
|
+
the grace doubled per consecutive failure, up to `--start-backoff-max` (`startBackoffMaxMs`, default 10 min). With the
|
|
366
|
+
default 90 s grace that is starts at 0, 1.5, 4.5, 10.5 and 20.5 minutes, then one every 10 minutes, where a short tick
|
|
367
|
+
would start one per tick. `lastExit` is what the supervisor saw of the failed start (`no-delegation`, `held`,
|
|
368
|
+
`orphaned`); the instance's own exit code is in its host's log. `supervise` prints one `start-backoff` line per step.
|
|
369
|
+
The first `run.json` write at a start's generation resets the count.
|
|
370
|
+
|
|
371
|
+
Revoking a holder that is alive is safe: its next fsync fails and it exits 75, and nothing it writes after the revoke
|
|
372
|
+
reaches the new owner. No wait follows a revoke, because the fence is the server's, not a clock. Two supervisors racing
|
|
373
|
+
on one run are safe: the mount admits one claimant and the other instance exits 76.
|
|
374
|
+
|
|
375
|
+
A host driver is three calls, and nothing else in the package knows which compute runs an instance:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
export interface HostDriver {
|
|
379
|
+
// The handle is JSON; the instance writes it into run.json. `attempt` (the generation the instance will open) lets a
|
|
380
|
+
// driver key a start, so a retry of the same attempt finds the instance it already started.
|
|
381
|
+
start(ref: RunRef, mountToken: string, attempt?: { attempt: number }): Promise<HostHandle>;
|
|
382
|
+
status(handle: HostHandle): Promise<"running" | "stopped" | "failed" | "gone" | "unknown">;
|
|
383
|
+
stop(handle: HostHandle): Promise<void>;
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`localHost()` runs instances on this machine as systemd transient units with `KillMode=control-group`, so stopping or
|
|
388
|
+
restarting the unit kills every command the instance started, pi's detached ones included. The unit restarts on failure,
|
|
389
|
+
except after exit 65, 70, 75 or 76. `localHost({ mode: "child" })` runs a detached child process instead, for development,
|
|
390
|
+
without that guarantee.
|
|
391
|
+
|
|
392
|
+
`daytonaHost({ client, snapshot, ... })` runs each instance in a Daytona sandbox of its own (library only: `supervise`
|
|
393
|
+
starts local units, so pass the driver to `ensureRunning`). The box comes from a snapshot that holds Node, the archil client
|
|
394
|
+
(`libfuse2`), this package, the run user and the sudoers drop-in, or from a default image that the caller's `prepare` installs
|
|
395
|
+
them into. A container sandbox has no systemd (PID 1 is the Daytona daemon), so an in-box launcher keeps the instance
|
|
396
|
+
alive in place and never restarts it after exit 0, 65, 70, 75 or 76. The mount token is uploaded to a root-only file and
|
|
397
|
+
handed to the instance on stdin, never put in the sandbox's environment, which Daytona stores and returns on every read.
|
|
398
|
+
Status comes from the sandbox state plus the launcher's own word, and `stop` drains the instance, then deletes the box. The
|
|
399
|
+
archil client needs the sandbox's egress to Archil's mount servers (Daytona's Tier 3 or 4, full egress; a domain allowlist
|
|
400
|
+
cannot mount). Where the kernel refuses every unmount (a Sysbox box), the claim's release checks the delegation in, moves the
|
|
401
|
+
mount aside (`archil-scoped retire`) and kills its daemon. Measured live on Daytona's hosted `us` region, about 6 ms from
|
|
402
|
+
`aws-us-east-1`: a box takes about 15 s to start an instance, an exclusive mount 142 ms and a commit 8.55 ms p50; a
|
|
403
|
+
power-off, a freeze of the instance and its FUSE daemon, and a network partition were each taken over on another box (the
|
|
404
|
+
power-off through the orphaned delegation, the freeze and the partition through the lease, the partitioned box stopped by
|
|
405
|
+
the driver), and the thawed instance exited 75. A driver for another compute (a Kubernetes pod, another sandbox product) is the same three calls.
|
|
406
|
+
|
|
407
|
+
`dockerHost({ image })` (`supervise --host docker --image IMAGE`) runs each instance in a container of its own; one
|
|
408
|
+
container is one incarnation.
|
|
409
|
+
- **Start.** `docker create` named `pda-<run>-<disk key>-g<attempt>` (the disk key is 8 hex digits of the disk and region,
|
|
410
|
+
so equal run ids on two disks never share a container) and labeled `pda.fleet`, `pda.run`, `pda.disk` and `pda.region`.
|
|
411
|
+
A retry of the same attempt adopts a running container that carries all four labels (the supervisor then removes the
|
|
412
|
+
token it minted for that start) and replaces a dead one; a container by that name with other labels is refused.
|
|
413
|
+
Flags: `--device /dev/fuse --cap-add SYS_ADMIN` for the mount, `--security-opt no-new-privileges`, `--restart no` (the
|
|
414
|
+
supervisor decides every start; `dockerArgs` with another policy are refused), and `--security-opt apparmor=unconfined` only where the Docker daemon applies
|
|
415
|
+
AppArmor (`docker info` lists it; Docker Engine on Ubuntu and Debian), because Docker's default profile denies mount(2).
|
|
416
|
+
The supervisor prints that decision and its reason once. Docker Desktop and OrbStack apply no AppArmor and get no option.
|
|
417
|
+
- **The mount token** is copied into the created container as a root-only file, which the entrypoint removes before the
|
|
418
|
+
instance starts. It never appears in `docker inspect`, an argument or an environment variable; the API key stays with the
|
|
419
|
+
supervisor.
|
|
420
|
+
- **Inside the container** the instance runs as root, so the archil verbs need no sudo, and the agent's commands run as the
|
|
421
|
+
image's user `pda` (uid 1500, `run --run-as`) under no_new_privs, with every capability set empty. `work/` and the files
|
|
422
|
+
pi's write and edit tools create there belong to `pda`; the run's store, `run.json` and the archil daemon (which holds the
|
|
423
|
+
token) stay root's, and Archil enforces their modes.
|
|
424
|
+
- **Status** comes from `docker inspect`: exit 0 is stopped, any other exit (75 and 76 included) failed, and a paused
|
|
425
|
+
container is running, so the lease decides. **Stop** is `docker stop` (the instance drains), then `docker rm`.
|
|
426
|
+
- **Parking and the drain** are the local driver's: the instance gets `--park-threshold` (60 s, `parkThresholdMs`; null
|
|
427
|
+
or 0 off) and a `--drain-timeout` of the stop timeout less a close reserve. A parked instance exits 0 and its container
|
|
428
|
+
stays exited until the supervisor starts the next one at the wake.
|
|
429
|
+
- **A dead container takes its mount with it**: the mount lives in the container's mount namespace, nothing is left on the
|
|
430
|
+
host, and the next supervisor tick revokes the orphaned claim and starts a new container.
|
|
431
|
+
|
|
432
|
+
Measured with Docker Engine 29 on Ubuntu 24.04, about 3 ms from `aws-us-east-1`: a killed container is replaced in about
|
|
433
|
+
1 s and the run resumes about 2 s after the kill; a frozen one is replaced 7 to 8 s after the freeze with a 6 s test
|
|
434
|
+
lease, and when thawed it exits 75 within 0.5 s. A SQLite FULL commit from a container has the same p50 as from the host
|
|
435
|
+
(about 6 ms); a pi commit, through that store, about 11 ms.
|
|
436
|
+
|
|
437
|
+
Mount tokens are reusable with a 24 h TTL (`--token-ttl`), one token user per start attempt, named
|
|
438
|
+
`pda-<run id>-g<attempt>-<time>` so a user can be traced to its run. They are not single-use: the archil client
|
|
439
|
+
re-authenticates 5 minutes after the mount and every 5 minutes after, and the control plane rejects a spent single-use
|
|
440
|
+
token for good, after which every write on the mount fails. An instance whose token expires is fenced at its next refresh
|
|
441
|
+
(exit 75) and the supervisor restarts the run with a fresh token. Each supervise pass removes the token users of the runs it
|
|
442
|
+
supervises that no live mount needs: the run holds no delegation, its `run.json` says released, and the user is older than
|
|
443
|
+
`--token-grace` (15 minutes). Removing a token user under a live mount kills that mount's claim, so a running run keeps its
|
|
444
|
+
users. `supervise --sweep-tokens` also removes the expired users of any run, and with no `--id` cleans every released run under
|
|
445
|
+
the token prefix.
|
|
446
|
+
|
|
447
|
+
### Serve, parking and fork
|
|
448
|
+
|
|
449
|
+
`pi-durable-disk run --serve PORT` (0: any free port; `--serve-host`, default 127.0.0.1; any other address needs
|
|
450
|
+
`--serve-token-file`, see Security, and a wildcard such as 0.0.0.0 also needs `--serve-url`, the address clients reach) puts
|
|
451
|
+
a small HTTP front on the open run, so a client talks to the run, not to a host:
|
|
452
|
+
|
|
453
|
+
| Request | What it does |
|
|
454
|
+
|---|---|
|
|
455
|
+
| `POST /submit {requestId, content, conversationId?, whenBusy?, wait?}` | pi's `submit`. pi deduplicates `requestId` per conversation in the run's store, so a retry that reaches the next incarnation, even on another host, gets the same submission, and with `wait: true` its answer. The same `requestId` on another conversation is a new submission there |
|
|
456
|
+
| `POST /abort {submissionId? \| conversationId?}` | pi's abort |
|
|
457
|
+
| `GET /events` | pi's agent events as server-sent events |
|
|
458
|
+
| `GET /status` | the run's status |
|
|
459
|
+
|
|
460
|
+
The instance writes its address into `run.json` (`holder.serve`) and answers 503 (`OPENING`, `PARKING`, `RELEASED`) while it
|
|
461
|
+
opens, parks or is gone. The app module may add `root` (the options for pi's `root()`, which the serve root conversation
|
|
462
|
+
gets) and `wake`. `requestRun(ref, { method, path, body }, { host, ensure, token? })` is the client, the equivalent of a
|
|
463
|
+
Durable Object stub's `fetch`: it reads `run.json` over S3 and sends to the running holder while its lease is fresh (a
|
|
464
|
+
request whose holder's lease lapses meanwhile is dropped: a frozen instance accepts connections and never answers).
|
|
465
|
+
Otherwise it calls `ensureRunning` with demand, waits for the instance it started, or for the one the supervisor reports
|
|
466
|
+
`starting`, to write its generation, and retries until `timeoutMs`.
|
|
467
|
+
|
|
468
|
+
**Parking.** With `--park-threshold` (the local and docker drivers pass 60 s; `supervise --park-threshold 0` turns it off) the instance
|
|
469
|
+
classifies pi's tasks after every commit. When everything that could run only sleeps in a retry or a deferred-poll wait longer
|
|
470
|
+
than the threshold, it writes `run.json` `sleeping` with `wakeAt` (the deadline), releases the claim and exits 0, and the
|
|
471
|
+
supervisor starts it at `wakeAt`. With `--serve`, a run with no live work also parks after `--park-idle` (default: the
|
|
472
|
+
threshold) with `wakeAt` null, so only a request wakes it. Open requests and event streams keep the instance up; a blocked task
|
|
473
|
+
(one no definition can run) does not. An app's `wake` hook replaces how the wake is recorded: a refusal keeps the instance up
|
|
474
|
+
through the wait (pi's own timer ends it), while a failed `run.json` write is a fence (exit 75, resumed after the lease). On
|
|
475
|
+
SIGTERM the instance stops taking submissions and drains for up to `--drain-timeout` (the local driver: its stop timeout minus
|
|
476
|
+
the smaller of 5 s and half of it), then writes its wake (now, the wait's deadline, or null when idle) and releases. Work cut
|
|
477
|
+
at the deadline resumes on the next open: safe tools rerun, unsafe ones report the interruption. The parking follows the
|
|
478
|
+
lifecycle of Rivet's pi-durable host (rivet-dev/agents, Apache-2.0); no code was copied.
|
|
479
|
+
|
|
480
|
+
**Fork.** `pi-durable-disk fork --id A --new-id B` (or `fork(ref, newId, { control, mountRoot })` on the supervisor side)
|
|
481
|
+
copies a released, sealed run (paused, sleeping, done or failed, with no delegation) under two short exclusive mounts of its
|
|
482
|
+
own into a new run that starts `paused` at generation 0 with the source's `sealedSeq`. Its first open is generation 1, and a
|
|
483
|
+
lossy copy is refused (`STORE_BEHIND_SEAL`). The source is only read (store, `run.json` and workspace stay byte-identical;
|
|
484
|
+
only the claim probe `.claim` is rewritten by the mount); `run.json`, `owner.lock`, the supervisor's start mark
|
|
485
|
+
`start.json` and `tmp/` are not copied. A fork owns the new run's directory only while it holds that directory's mount: one
|
|
486
|
+
that fails after that empties the directory through its own mount and removes it, and one that lost the directory to another
|
|
487
|
+
fork or start leaves it alone.
|
|
488
|
+
|
|
489
|
+
### Setting up a host for production
|
|
490
|
+
|
|
491
|
+
The Quickstart runs the supervisor as your own user. On a production host the instance and the agent's tools run as an
|
|
492
|
+
unprivileged user and only the archil client runs as root:
|
|
493
|
+
|
|
494
|
+
1. **A dedicated run user** (here `pda`) that owns `<mountRoot>/runs` (default `/mnt/archil/runs`), so mountpoints are
|
|
495
|
+
created and removed without root. The run user must be able to execute `node` and read the package and the app module:
|
|
496
|
+
a Node under your home directory (nvm) is usually not readable by another user, so install Node system-wide.
|
|
497
|
+
2. **The archil wrapper, root-owned**, because root executes it. It starts `archil mount` in its own systemd scope, outside
|
|
498
|
+
the instance's unit, so a restart in place finds its mount and claim still held, and it takes the mount token as one
|
|
499
|
+
line on stdin:
|
|
500
|
+
`sudo install -D -o root -g root -m 0755 bin/archil-scoped /usr/local/lib/pi-durable-disk/archil-scoped`
|
|
501
|
+
(from an npm install the file is `node_modules/@parcha/pi-durable-disk/bin/archil-scoped`, and `ARCHIL_SCOPED`, exported by
|
|
502
|
+
the package, is its path). Point the driver at it: `localHost({ archil: "/usr/local/lib/pi-durable-disk/archil-scoped",
|
|
503
|
+
user: "pda" })`, or `supervise --archil /usr/local/lib/pi-durable-disk/archil-scoped --user pda`.
|
|
504
|
+
3. **A sudoers drop-in for exactly that wrapper and `fusermount -u`.** It keeps no environment: the token travels on the
|
|
505
|
+
wrapper's stdin, and sudo logs every variable it preserves. Install it as `/etc/sudoers.d/pi-durable-disk`, mode 0440,
|
|
506
|
+
after `visudo -cf` accepts it:
|
|
507
|
+
|
|
508
|
+
```
|
|
509
|
+
pda ALL=(root) NOPASSWD: /usr/local/lib/pi-durable-disk/archil-scoped, /usr/bin/fusermount -u /mnt/archil/runs/*
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
4. **The supervisor runs as root** (it creates units, stops them, and writes each start's token under
|
|
513
|
+
`/run/pi-durable-disk`, root-only), with `--user pda`, so instances never run as root; `localHost` refuses to start
|
|
514
|
+
them as root without a named user. Give it the API key from a root-only environment file that its own systemd unit
|
|
515
|
+
loads (`EnvironmentFile=`), never through `sudo`: sudo logs the command line and the variables it preserves.
|
|
516
|
+
5. **Check the host once.** `pi-durable-disk supervise --check --disk D --region R` proves the fence on this host (a
|
|
517
|
+
second exclusive mount is refused, a revoked mount's fsync fails) and warns if the wrapper is not root-owned.
|
|
518
|
+
|
|
519
|
+
`examples/verify-production-path.sh` performs and checks steps 1 to 4 on a disposable root machine (`setup`, `run`,
|
|
520
|
+
`teardown`, or `all`; `plan` prints every step without running it): it creates the run user, the wrapper, the drop-in and
|
|
521
|
+
the key file, runs example 02 with the supervisor as root and `--user`, and checks that the instances never run as root,
|
|
522
|
+
that the run user can sudo the wrapper and nothing else, that `no_new_privs` takes even that away from a command, that no
|
|
523
|
+
mount token reaches the journal, and that nothing is left behind. It needs systemd; do not run it on a shared machine.
|
|
524
|
+
|
|
525
|
+
## The failure table
|
|
526
|
+
|
|
527
|
+
| Failure | What happens | Effects |
|
|
528
|
+
|---|---|---|
|
|
529
|
+
| The instance crashes or is OOM-killed, host alive | The unit restarts it in place on the same claim; the unit's cgroup kills the previous incarnation's commands; reopen, `resume()` | running tasks go back to pending; effects in flight are `interrupted`, never rerun; at most one cut model request is sent again |
|
|
530
|
+
| The host is lost (power-off, VM loss, pre-emption) | The client's sockets close and Archil flags the delegation orphaned in 0.2 s; the supervisor revokes (tens of ms) and starts an instance elsewhere | every acknowledged commit is there; unflushed workspace writes are lost; no effect whose intent was committed runs again |
|
|
531
|
+
| The host hangs or is partitioned (a zombie) | It is not orphaned, so the lease expires at 90 s; the supervisor stops the host if its driver can, revokes, and starts elsewhere | the zombie's next commit fails (`SQLITE_IOERR_FSYNC`) and it exits 75; a call it already has in flight may complete, but it cannot start a new one, because a new call needs an intent commit |
|
|
532
|
+
| A zombie that never commits again | Its lease watchdog (a worker thread, so a main thread stuck in a FUSE request does not matter) kills its commands at 75 s without touching the mount | an in-flight command runs at most until the self-fence |
|
|
533
|
+
| The mount is taken away under a live instance (an operator's `umount -l`, a cleanup's `fusermount -u`) | The instance writes `run.json` and creates its directories through the directory it opened at acquire, and checks before each write that the run's path is still that mount; at the next heartbeat the path names the local directory under the mountpoint, so it exits 75 (`CLAIM_UNMOUNTED`) | nothing of its own is written to the local disk; as host lost |
|
|
534
|
+
| A run's mountpoint holds entries and nothing is mounted on it (files written by path after a mount vanished) | acquire moves the directory aside whole to `<mountRoot>/.stray/<run>-<tag>` (root-owned, 0700), puts an empty one in its place, logs one JSON line on stderr with the entry count (never a name or a byte of the files), and mounts; `MOUNTPOINT_NOT_EMPTY` when it cannot | nothing in the moved directory is read or brought into the run |
|
|
535
|
+
| A stale archil daemon on the mountpoint with no mount (a client that failed at a token refresh drops its mount and keeps its process and control socket) | acquire kills it (`archil-scoped stale`) before mounting; archil's refusal ("an older Archil process is still running", "Failed to bind control socket") is `MOUNT_FAILED` (exit 1), not 76 | none |
|
|
536
|
+
| The FUSE daemon dies, the loop lives | I/O returns `ENOTCONN`, the claim is fenced, the instance exits 75 and the unit does not restart; the supervisor finds the claim orphaned, revokes it and starts an instance | as host lost |
|
|
537
|
+
| Archil is unavailable or its write buffer is full | The commit fails, pi poisons the session, the instance exits 75; the supervisor retries | nothing is published past the last durable commit |
|
|
538
|
+
| The mount token expires mid-run | I/O fails at the next 5 minute refresh and the instance exits 75; the supervisor restarts it with a fresh token | nothing started past the last commit |
|
|
539
|
+
| A second instance on the same host | The owner lock (an exclusive SQLite lock on `owner.lock`) is refused, exit 76 | none |
|
|
540
|
+
| Two supervisors race | The mount admits one claimant; the other instance exits 76 | none |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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
|
+
|
|
558
|
+
What a revoke cannot do: no fence reaches a third party. A paid API that already has a request has it. The package
|
|
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
|
|
560
|
+
paid API an idempotency key from your own business key.
|
|
561
|
+
|
|
562
|
+
Measured on a VM about 3 ms from `aws-us-east-1`, archil 0.8.42: acquire 222 ms p50, release 139 ms, takeover by revoke
|
|
563
|
+
252 ms, commit 3.75 to 5.63 ms p50, a killed host's run back to its first commit in 1 to 1.5 s, a hung host replaced in 6
|
|
564
|
+
to 6.3 s with a 6 s test lease (90 to 120 s with the defaults).
|
|
565
|
+
|
|
566
|
+
## Security
|
|
567
|
+
|
|
568
|
+
- **The mount token goes on the wrapper's stdin.** It is never in argv or in an environment that `sudo` sees. Some setup
|
|
569
|
+
guides pass the mount token with `sudo --preserve-env=ARCHIL_MOUNT_TOKEN archil mount ...`; sudo logs every variable it
|
|
570
|
+
preserves to the journal and `auth.log`, so that form leaves the token readable to anyone who can read those logs. The wrapper reads
|
|
571
|
+
one line on stdin and sets `ARCHIL_MOUNT_TOKEN` only for the exec of `archil`. The sudoers drop-in must never use
|
|
572
|
+
`env_keep` for it. The local driver hands the token to the instance on stdin from a root-only file that is emptied once
|
|
573
|
+
the unit has started; the instance reads it and closes stdin, so no process of the run's user can reopen it.
|
|
574
|
+
- **Tokens cover the whole disk.** A leaked one could mount or force-take any run on that disk until it expires, so the
|
|
575
|
+
supervisor mints one per start attempt, removes it after the instance releases, and sweeps expired ones. The tenant
|
|
576
|
+
boundary is the disk (Archil's own advice): give each tenant its own disk.
|
|
577
|
+
- **The API key stays with the supervisor.** Hosts and instances never hold it, and the local driver never copies its own
|
|
578
|
+
environment into a unit.
|
|
579
|
+
- **No privilege for the agent.** The instance, pi's tools and the agent's commands run as an unprivileged user. Every
|
|
580
|
+
command starts under `setpriv --no-new-privs`, so a command cannot use the run user's sudo rule (sudo refuses: "The no new
|
|
581
|
+
privileges flag is set"). An app that turns this off (`archilEnv(claim, { noNewPrivs: false })`) or runs commands another
|
|
582
|
+
way must confine them itself. Commands run next to the FUSE client on the host that holds the claim: if the agent is not
|
|
583
|
+
trusted with the host, sandbox its commands (for example with bubblewrap) and keep the FUSE client outside the sandbox.
|
|
584
|
+
- **The file tools stay inside `work/`.** pi's `read`, `write` and `edit` run in the loop's process, not in a command
|
|
585
|
+
sandbox, so every file operation of the environment resolves its path to the file the kernel would reach (`..`, absolute
|
|
586
|
+
paths, symlinks, dangling links, a missing tail) and refuses it unless it lies in `work/` (reads may also use `tmp/`, where
|
|
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
|
|
589
|
+
../store/run.sqlite`, `../run.json` or `../owner.lock`: the call fails with a `PathOutsideWorkError` the model sees as
|
|
590
|
+
a failed tool result. A path is opened one component at a time from a held handle of `work/`, without following links,
|
|
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
|
|
592
|
+
the run user and can reach anything that user can, which is what an app's own command sandbox is for. `archilEnv(claim, {
|
|
593
|
+
confineFiles: false })` turns it off where `/proc/self/fd` is unavailable.
|
|
594
|
+
- **`--serve` authenticates only with a bearer token.** On 127.0.0.1 (the default) the token is optional. A non-loopback
|
|
595
|
+
`--serve-host` requires `--serve-token-file`, a file of mode 0600 holding the token (never argv or the environment); every
|
|
596
|
+
request must send `authorization: Bearer <token>`, and it is compared in constant time. The file is readable by the run
|
|
597
|
+
user, so by the agent's own commands: it guards the network, not the agent. Put TLS in front of anything that leaves the
|
|
598
|
+
machine.
|
|
599
|
+
- **In a container** (`dockerHost`) the instance runs as root and holds `CAP_SYS_ADMIN` (with AppArmor unconfined where the
|
|
600
|
+
daemon applies AppArmor), which `archil mount` needs. The agent's commands do not: they run as uid 1500 with every
|
|
601
|
+
capability set empty, the bounding set included, and no_new_privs, so `mount`, `unshare` and `nsenter` fail from a
|
|
602
|
+
command. With `fs.protected_hardlinks` on (the live suite checks it), a command cannot hard-link the store or `run.json`
|
|
603
|
+
into `work/` either. Anything passed with `--env` is visible to `docker inspect` on the machine and to the agent's
|
|
604
|
+
commands, so never pass a secret there that the agent must not read.
|
|
605
|
+
- **A revocation is the most dangerous operation.** It happens only for an orphaned client, a dead host or an expired
|
|
606
|
+
lease, and every takeover writes the new `generation` and the previous holder into `run.json` and the supervisor's log.
|
|
607
|
+
|
|
608
|
+
## Layout on the disk
|
|
609
|
+
|
|
610
|
+
```
|
|
611
|
+
<disk>/runs/<id>/ the claimed subtree, mounted at <mountRoot>/runs/<id> on every host (default /mnt/archil)
|
|
612
|
+
run.json status, generation, holder, heartbeat, seal
|
|
613
|
+
owner.lock the same-host owner lock
|
|
614
|
+
store/run.sqlite pi's store (exclusive profile: no -shm file)
|
|
615
|
+
work/ the workspace the tools write
|
|
616
|
+
tmp/ pi's temp files and long-output spills, so a path a result names still exists after a takeover
|
|
617
|
+
```
|
|
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
|
+
|
|
622
|
+
The mountpoint is the same absolute path on every host of a deployment, so paths inside the run are identical wherever
|
|
623
|
+
it resumes.
|
|
624
|
+
|
|
625
|
+
### Removing a run
|
|
626
|
+
|
|
627
|
+
A run is the subtree `runs/<id>/` of the disk. To remove one: let its instance release (or stop it), check that nothing
|
|
628
|
+
holds a delegation on it (`pi-durable-disk status` lists them; an orphaned one is revoked through the control API), delete
|
|
629
|
+
its objects, and remove its token users (`supervise --id ID --sweep-tokens --token-grace 0s` on a run that is done).
|
|
630
|
+
|
|
631
|
+
**Revoke before you delete.** Deleting the objects of a run through the S3 API while a delegation on the path is still
|
|
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
|
|
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.
|
|
636
|
+
|
|
637
|
+
## Library
|
|
638
|
+
|
|
639
|
+
`openDurableRun(ref, options)` is what `run` calls: mount, verify the claim, take the owner lock, write `run.json`, open
|
|
640
|
+
the store, check the seal, open and resume the Harness, keep the lease. `acquire`, `ensureRunning`, `localHost`,
|
|
641
|
+
`archilEnv`, `openArchilStore` and the typed errors (`FencedError`, `HeldError`, `StoreBehindSealError`) are exported
|
|
642
|
+
for apps that compose their own lifecycle, and so are serve (`serveRun`, `requestRun`), parking (`watchParking`, `drain`,
|
|
643
|
+
`busyState`, and `leaseParkTarget` for a Harness a host opened over `openRunLease`) and `fork`. The `.d.ts` files in `dist/`
|
|
644
|
+
are the reference.
|
|
645
|
+
|
|
646
|
+
A host that owns its own pi-durable Harness and store connections imports the narrow entry instead:
|
|
647
|
+
`@parcha/pi-durable-disk/lease` holds `openRunLease` (the claim, the owner lock, `run.json` with its heartbeat, the lease
|
|
648
|
+
self-fence, the seal and the release, without a store or a Harness), `storeHead`, the store's fence and pragma helpers
|
|
649
|
+
(`Fence`, `FencedDatabase`, `assertPragmas`, `PROFILE_PRAGMAS`, `openArchilStore`), and the typed errors and exit codes. It
|
|
650
|
+
leaves out the supervisor, the host drivers, the CLI and the app contract, and the package declares `"sideEffects": false`, so
|
|
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.
|
|
680
|
+
|
|
681
|
+
## Tests
|
|
682
|
+
|
|
683
|
+
```sh
|
|
684
|
+
npm test # unit tests: no Archil, no network, no model
|
|
685
|
+
npm run typecheck
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
The package and its unit tests run on Node 22.19 or later.
|
|
689
|
+
|
|
690
|
+
The live suites (`npm run test:live:claim`, `:env`, `:run`, `:store`, `:supervise`) mount a real scratch disk, and
|
|
691
|
+
`npm run test:live:docker` runs the Docker driver's takeover test against this machine's Docker daemon. See
|
|
692
|
+
`test/live/README.md` for what they need and what they clean up.
|
|
693
|
+
|
|
694
|
+
## License
|
|
695
|
+
|
|
696
|
+
Apache-2.0, Parcha Labs, Inc. See `LICENSE` and `NOTICE`.
|