@parcha/pi-durable-disk 0.1.0-beta.13 → 0.1.0-beta.15
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 +262 -0
- package/README.md +281 -47
- package/dist/app.d.ts +5 -1
- package/dist/claim.d.ts +51 -3
- package/dist/claim.js +157 -17
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +112 -47
- package/dist/commands.d.ts +4 -1
- package/dist/commands.js +48 -90
- package/dist/confine.d.ts +36 -7
- package/dist/confine.js +125 -18
- package/dist/env.d.ts +7 -22
- package/dist/env.js +25 -60
- package/dist/errors.d.ts +2 -1
- package/dist/fork.d.ts +23 -0
- package/dist/fork.js +103 -3
- package/dist/hosts/attempt-env.d.ts +3 -0
- package/dist/hosts/attempt-env.js +15 -0
- package/dist/hosts/daytona-launch.d.ts +3 -1
- package/dist/hosts/daytona-launch.js +35 -12
- package/dist/hosts/daytona.d.ts +2 -2
- package/dist/hosts/daytona.js +48 -5
- package/dist/hosts/docker.d.ts +2 -2
- package/dist/hosts/docker.js +22 -5
- package/dist/hosts/local-host.d.ts +9 -3
- package/dist/hosts/local-host.js +59 -22
- package/dist/index.d.ts +35 -16
- package/dist/index.js +27 -16
- package/dist/internals.d.ts +3 -0
- package/dist/internals.js +6 -0
- package/dist/lease.d.ts +4 -1
- package/dist/lease.js +2 -0
- package/dist/local-run.js +3 -2
- package/dist/place.d.ts +190 -0
- package/dist/place.js +526 -0
- package/dist/process-table.d.ts +38 -0
- package/dist/process-table.js +158 -0
- package/dist/run.d.ts +61 -10
- package/dist/run.js +69 -33
- package/dist/status.d.ts +19 -3
- package/dist/status.js +25 -13
- package/dist/store.d.ts +14 -2
- package/dist/store.js +16 -4
- package/dist/supervise.d.ts +43 -1
- package/dist/supervise.js +26 -1
- package/dist/watchdog.d.ts +3 -1
- package/dist/watchdog.js +43 -12
- package/dist/with-claim.d.ts +63 -0
- package/dist/with-claim.js +325 -0
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ machine. A Cloudflare Durable Object gives it both. This package gives it both o
|
|
|
19
19
|
| You have | Start with | What you see |
|
|
20
20
|
|---|---|---|
|
|
21
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
|
+
| Linux and Node 22.19 or later, and an app module | `pi-durable-disk run --dir PATH --app MODULE` ([The CLI](#the-cli)) | Your app, or example 01's (`examples/01-durable-chat/app.ts`), as an instance on a directory of this machine: `kill -9` it and run the same command again, and it resumes. One machine and no fence |
|
|
23
|
+
| macOS and Node 22.19 or later, for development | The same [quickstart](#quickstart-on-one-machine-no-disk-no-key) and `run --dir` | The same runs, development grade: commands keep your rights, and the file tools' confinement holds against the agent's paths but not a command racing it ([On macOS](#on-macos)). Use Linux or Docker when the agent is not trusted |
|
|
22
24
|
| 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
25
|
| 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
26
|
| 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 |
|
|
@@ -60,7 +62,7 @@ and no key.
|
|
|
60
62
|
- An Archil account, a disk, and an API key for the supervisor, all from the Archil console. Create the disk in the region
|
|
61
63
|
nearest the host (any size; its id looks like `dsk-0123456789abcdef` and its region like `aws-us-east-1`). Hosts and
|
|
62
64
|
instances never hold the API key.
|
|
63
|
-
- `@earendil-works/pi-durable` and `@earendil-works/chord`, `>=1.0.4 <1.
|
|
65
|
+
- `@earendil-works/pi-durable` and `@earendil-works/chord`, `>=1.0.4 <1.4.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 and 1.3.
|
|
64
66
|
|
|
65
67
|
Not covered yet: a Kubernetes host driver (the `HostDriver` interface is below) and running the loop across machines
|
|
66
68
|
where FUSE is unavailable. Without Archil, a run can live on a plain directory of one machine
|
|
@@ -70,9 +72,11 @@ where FUSE is unavailable. Without Archil, a run can live on a plain directory o
|
|
|
70
72
|
## Install
|
|
71
73
|
|
|
72
74
|
```sh
|
|
73
|
-
npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
|
|
75
|
+
npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable@~1.2.0 @earendil-works/pi-ai@~1.2.0 @earendil-works/chord@~1.2.0
|
|
74
76
|
```
|
|
75
77
|
|
|
78
|
+
The pi packages are pinned to 1.2: pi 1.3.0 pins its own packages to exact versions, and this release's ranges stop below 1.3 until the next one.
|
|
79
|
+
|
|
76
80
|
From a checkout (this package is `packages/pi-durable-disk` of the repository; the root build compiles `dist/`):
|
|
77
81
|
|
|
78
82
|
```sh
|
|
@@ -82,17 +86,18 @@ cd packages/pi-durable-disk
|
|
|
82
86
|
|
|
83
87
|
## Quickstart on one machine: no disk, no key
|
|
84
88
|
|
|
85
|
-
The fastest look, on Linux with Node 22.19 or later: a durable chat on a
|
|
86
|
-
[`localRun`](#a-run-on-a-local-directory) instead of a disk and pi-ai's faux model
|
|
87
|
-
first messages and is killed (`SIGKILL`) after its third answer; a second
|
|
88
|
-
transcript, read back from the store, has every message answered once.
|
|
89
|
-
machine only; the quickstarts below put the run on a disk that outlives
|
|
89
|
+
The fastest look, on Linux with Node 22.19 or later (or on macOS, [for development](#on-macos)): a durable chat on a
|
|
90
|
+
directory of this machine, with [`localRun`](#a-run-on-a-local-directory) instead of a disk and pi-ai's faux model
|
|
91
|
+
instead of a key. One process answers the first messages and is killed (`SIGKILL`) after its third answer; a second
|
|
92
|
+
opens the same run and answers the rest, and the transcript, read back from the store, has every message answered once.
|
|
93
|
+
The directory is the claim, so this guards one machine only; the quickstarts below put the run on a disk that outlives
|
|
94
|
+
the machine.
|
|
90
95
|
|
|
91
96
|
In an empty directory, install the package and its peers, save this as `chat.mjs`
|
|
92
97
|
(it is [examples/01-durable-chat/on-a-directory.mjs](examples/01-durable-chat/on-a-directory.mjs)):
|
|
93
98
|
|
|
94
99
|
```sh
|
|
95
|
-
npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
|
|
100
|
+
npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable@~1.2.0 @earendil-works/pi-ai@~1.2.0 @earendil-works/chord@~1.2.0
|
|
96
101
|
```
|
|
97
102
|
|
|
98
103
|
```js
|
|
@@ -262,7 +267,18 @@ Each example's README lists the exact commands the demo runs, so you can run the
|
|
|
262
267
|
|
|
263
268
|
An instance is `pi-durable-disk run --app <module> ...`: `openDurableRun` with the app's options. The module is an ES
|
|
264
269
|
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
|
|
270
|
+
builds `env` on its claim: the workspace is `<root>/work`), plus an optional `beforeResume` and an optional `onOpen`.
|
|
271
|
+
|
|
272
|
+
The factory runs once the claim is held: the run's directory is mounted, its owner lock taken, `store/`, `work/` and
|
|
273
|
+
`tmp/` made, and the commands a killed incarnation left running ended (`openDurableRun`'s `afterClaim`). This
|
|
274
|
+
incarnation's `run.json` comes after it. So `where.root` is the live directory whenever app code runs, and a factory may
|
|
275
|
+
write under it (a recorder's spool, a `signals/` directory). Before, the factory ran before the mount, and its writes
|
|
276
|
+
landed in the bare mountpoint and were lost. The Harness opens after the factory, with what it returns. Claim options, if
|
|
277
|
+
one ever needs them, are static (CLI flags or the environment) and never returned by the factory, since the claim is
|
|
278
|
+
taken before the factory runs. A module that does not load, or a factory that throws, fails the open with `APP_FAILED`
|
|
279
|
+
(exit 1), and the claim is let go. Such a start publishes nothing: a new run still has no `run.json`, and a resumed run
|
|
280
|
+
keeps its previous one. A supervisor therefore counts it a failed start and backs off (`ensureRunning`), never starting
|
|
281
|
+
it again on every tick.
|
|
266
282
|
|
|
267
283
|
```ts
|
|
268
284
|
import { BACKGROUND_CONTEXT as ctx } from "@earendil-works/chord/context";
|
|
@@ -273,7 +289,7 @@ import { ArchilCodingTools } from "@parcha/pi-durable-disk";
|
|
|
273
289
|
import type { AppContext, AppOptions } from "@parcha/pi-durable-disk";
|
|
274
290
|
|
|
275
291
|
export default async function app(where: AppContext): Promise<AppOptions> {
|
|
276
|
-
// where = { ref: { disk, region, id }, root, work, store }:
|
|
292
|
+
// where = { ref: { disk, region, id }, root, work, store }: the claim is held, so root is the live run directory.
|
|
277
293
|
const models = createModels();
|
|
278
294
|
models.setProvider(openaiProvider()); // reads OPENAI_API_KEY from the instance's environment
|
|
279
295
|
const registry = createRegistry(); // every extension the run's tasks need
|
|
@@ -281,9 +297,11 @@ export default async function app(where: AppContext): Promise<AppOptions> {
|
|
|
281
297
|
return {
|
|
282
298
|
models,
|
|
283
299
|
registry,
|
|
284
|
-
async beforeResume({ harness, context, generation }) {
|
|
300
|
+
async beforeResume({ harness, context, generation, previous }) {
|
|
285
301
|
// Awaited once per incarnation, after the Harness is open and before it resumes: no resumed task takes a step
|
|
286
302
|
// until this settles, and what it admits commits before their steps do. Reconcile here what a resumed tool depends on.
|
|
303
|
+
// previous is run.json as the run was left (null when there was none): `sleeping` with its wakeAt after a park,
|
|
304
|
+
// `paused` with its detail after a planned stop or a fork, `running` with the dead holder and no new seal after a crash.
|
|
287
305
|
},
|
|
288
306
|
async onOpen(run) {
|
|
289
307
|
// Called once per incarnation, after the run is open and resumed. run.generation is 1 on a first start and
|
|
@@ -302,7 +320,8 @@ rerun. Any other tool is an effect: its intent is committed before it starts, an
|
|
|
302
320
|
was `interrupted`, never run again. `run.setStatus("done" | "failed" | "paused" | "sleeping", detail)` records the run's
|
|
303
321
|
status in `run.json`; `done` and `failed` run the durability barrier first. `run.release()` closes the Harness, kills the
|
|
304
322
|
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
|
|
323
|
+
or throws, or an `onOpen` that rejects, is `AppError` (exit 1, which the unit retries; a module that fails lets the claim
|
|
324
|
+
go first). A `beforeResume` that rejects
|
|
306
325
|
fails the open (its step is `before-resume`) and releases the claim. Besides the Harness, the hook gets `storage`, the
|
|
307
326
|
store the Harness was opened over as four reads (`scanConversations`, `scanTasks`, `scanDocuments`, `document`) with no
|
|
308
327
|
way to a write, and `head`, the store's last committed sequence: a recorder snapshots every document at `head` there.
|
|
@@ -314,8 +333,10 @@ Until the hook commits, the current state is the state at `head`, which is how a
|
|
|
314
333
|
|
|
315
334
|
| Command | What it does |
|
|
316
335
|
|---|---|
|
|
336
|
+
| `run --dir PATH --app MODULE [--id ID]` | The instance on a local directory (`localRun`): no disk, no mount token, no key. The same app contract and exit codes as on a disk (76 when another process holds the run, or when a process its last incarnation left will not die; 75 when fenced), and the same run flags but the Archil ones (`--disk`, `--region`, `--mount-root`, `--archil`, `--token-stdin` are refused beside it, exit 2). `--id` defaults to the directory's name |
|
|
317
337
|
| `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
338
|
| `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 |
|
|
339
|
+
| `status --dir PATH` | `run.json` read from the directory, and whether its holder is still the process that claimed it: `running` (this boot, its pid alive with the start ticks `run --dir` recorded), `stopped`, `gone` (another boot), or `unknown` when this machine's boot or processes cannot be read |
|
|
319
340
|
| `status --disk D --region R --id ID` | `run.json` over the S3 API, the run's delegations and the holder's state |
|
|
320
341
|
| `release --id ID` | Unmount the run's mount on this host (a dead mount is cleaned) |
|
|
321
342
|
| `fork --id A --new-id B` | Copy a released, sealed run into a new run (see Serve, parking and fork) |
|
|
@@ -333,7 +354,7 @@ An instance's exit code tells the host's process supervisor what to do:
|
|
|
333
354
|
| 2 | Usage error | no |
|
|
334
355
|
| 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
356
|
| 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 |
|
|
357
|
+
| 75 | Fenced: the claim was revoked, its mount failed or left the mount table (`CLAIM_UNMOUNTED`), or the lease lapsed; or an incarnation given no mount token found no live mount to reuse (`CLAIM_GONE_NO_TOKEN`: a restart whose mount went with the failed incarnation) | never in place; the supervisor decides |
|
|
337
358
|
| 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
359
|
|
|
339
360
|
## Host drivers and the supervisor
|
|
@@ -372,18 +393,29 @@ Revoking a holder that is alive is safe: its next fsync fails and it exits 75, a
|
|
|
372
393
|
reaches the new owner. No wait follows a revoke, because the fence is the server's, not a clock. Two supervisors racing
|
|
373
394
|
on one run are safe: the mount admits one claimant and the other instance exits 76.
|
|
374
395
|
|
|
375
|
-
A host driver is three calls, and nothing else in the package knows which compute runs an instance:
|
|
396
|
+
A host driver is three calls, plus an optional fourth, and nothing else in the package knows which compute runs an instance:
|
|
376
397
|
|
|
377
398
|
```ts
|
|
378
399
|
export interface HostDriver {
|
|
379
400
|
// 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
|
-
|
|
401
|
+
// driver key a start, so a retry of the same attempt finds the instance it already started. `env` is what this start
|
|
402
|
+
// adds to the instance's environment, over the driver's own `env`; PATH and PDA_HOLDER are the driver's (refused).
|
|
403
|
+
start(ref: RunRef, mountToken: string, attempt?: { attempt: number; env?: Record<string, string> }): Promise<HostHandle>;
|
|
382
404
|
status(handle: HostHandle): Promise<"running" | "stopped" | "failed" | "gone" | "unknown">;
|
|
383
405
|
stop(handle: HostHandle): Promise<void>;
|
|
406
|
+
// Optional: take the instance away as a crash would. No drain, no release, nothing cleaned.
|
|
407
|
+
kill?(handle: HostHandle): Promise<void>;
|
|
408
|
+
readonly kills?: "process" | "box";
|
|
384
409
|
}
|
|
385
410
|
```
|
|
386
411
|
|
|
412
|
+
`kill` leaves the run's claim the way a crash leaves it, and the supervisor's next pass decides what follows. `kills` says
|
|
413
|
+
what a kill takes. `localHost` kills `process`: `systemctl kill --signal=SIGKILL --kill-whom=all` on the unit, or SIGKILL
|
|
414
|
+
to the child's process group. Its machine stays, and with `restart` on, systemd starts the unit again. `dockerHost` kills
|
|
415
|
+
`box` (`docker kill`; the container stays exited until the next start removes it), and so does `daytonaHost` (the
|
|
416
|
+
sandbox's delete). A handle from another fleet or boot is a no-op, and a kill that fails while the instance still runs
|
|
417
|
+
throws `KILL_FAILED`.
|
|
418
|
+
|
|
387
419
|
`localHost()` runs instances on this machine as systemd transient units with `KillMode=control-group`, so stopping or
|
|
388
420
|
restarting the unit kills every command the instance started, pi's detached ones included. The unit restarts on failure,
|
|
389
421
|
except after exit 65, 70, 75 or 76. `localHost({ mode: "child" })` runs a detached child process instead, for development,
|
|
@@ -393,12 +425,16 @@ without that guarantee.
|
|
|
393
425
|
starts local units, so pass the driver to `ensureRunning`). The box comes from a snapshot that holds Node, the archil client
|
|
394
426
|
(`libfuse2`), this package, the run user and the sudoers drop-in, or from a default image that the caller's `prepare` installs
|
|
395
427
|
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.
|
|
428
|
+
alive in place and never restarts it after exit 0, 65, 70, 75 or 76. Every incarnation of an attempt gets the attempt's
|
|
429
|
+
token on stdin. A restart whose mount is still live reuses it, and one whose mount went with the failed incarnation mounts
|
|
430
|
+
again: after a takeover, that mount is `HeldError` (76, terminal). The token stays in the launcher's memory for the attempt. The mount token is uploaded to a root-only file and
|
|
397
431
|
handed to the instance on stdin, never put in the sandbox's environment, which Daytona stores and returns on every read.
|
|
398
432
|
Status comes from the sandbox state plus the launcher's own word, and `stop` drains the instance, then deletes the box. The
|
|
399
433
|
archil client needs the sandbox's egress to Archil's mount servers (Daytona's Tier 3 or 4, full egress; a domain allowlist
|
|
400
434
|
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.
|
|
435
|
+
mount aside (`archil-scoped retire`) and kills its daemon. Before the move, the client must list no Active delegation
|
|
436
|
+
under the mount. Its own bookkeeping under `.archil/` doesn't count: the client makes `.archil/client-<n>` and its
|
|
437
|
+
`unlinked` whenever a file under the mount is deleted, and no checkin clears them once the mount is read-only. Measured live on Daytona's hosted `us` region, about 6 ms from
|
|
402
438
|
`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
439
|
power-off, a freeze of the instance and its FUSE daemon, and a network partition were each taken over on another box (the
|
|
404
440
|
power-off through the orphaned delegation, the freeze and the partition through the lease, the partitioned box stopped by
|
|
@@ -486,6 +522,12 @@ only the claim probe `.claim` is rewritten by the mount); `run.json`, `owner.loc
|
|
|
486
522
|
that fails after that empties the directory through its own mount and removes it, and one that lost the directory to another
|
|
487
523
|
fork or start leaves it alone.
|
|
488
524
|
|
|
525
|
+
On a local directory, `forkLocal(from, [{ id, root }, ...])` makes the same copy without a disk: one new run directory per
|
|
526
|
+
entry, from a directory of a run released and sealed (`SOURCE_NOT_RELEASED` for anything else, `TARGET_EXISTS` for a target
|
|
527
|
+
that is there). The source is only read; every copy is made beside its target under a temporary name and none is published until all are complete, so a failed
|
|
528
|
+
batch leaves no half copy. A target inside the source (also through a symlink) is `INVALID_ARGUMENT`. A local directory has no mount to take it exclusively, so nothing stops a process that opens the source while it
|
|
529
|
+
is being copied: fork a run after it is released, and open the forks with `localRun` like any run.
|
|
530
|
+
|
|
489
531
|
### Setting up a host for production
|
|
490
532
|
|
|
491
533
|
The Quickstart runs the supervisor as your own user. On a production host the instance and the agent's tools run as an
|
|
@@ -528,10 +570,14 @@ mount token reaches the journal, and that nothing is left behind. It needs syste
|
|
|
528
570
|
|---|---|---|
|
|
529
571
|
| 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
572
|
| 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 |
|
|
573
|
+
| A Docker host is lost before its instance reaches running (the container killed or removed, or its daemon gone, between the start and the instance's first `run.json` write) | If the driver's start is still running, it fails typed (`START_FAILED`) and tries to remove the container it made (a removal that fails, as it does while the daemon is gone, is not retried); the supervisor's tick fails `HOST_START_FAILED`, counts no failed start, and the next tick starts again. If the start had returned, the supervisor's start mark (`runs/<id>/start.json`) keeps the run `starting` until the start grace passes (default the lease expiry, 90 s, doubled per consecutive failed start up to 10 min), so nothing is revoked or started twice; then it starts again, revoking first if the container had mounted (its FUSE daemon died with it, so its delegation is orphaned), and that start tries to remove the run's earlier dead containers. Removal is attempted, never guaranteed: once the daemon is back, look for leftovers with `docker ps -a --filter label=pda.run=<id>` | nothing is lost: an instance writes `run.json` at its generation before it opens the store, so it had committed nothing. The run has no instance for up to the grace; the time from the loss to the next instance's first commit is not measured on Docker |
|
|
574
|
+
| A Daytona host is lost before its instance reaches running (the box errors, is deleted or never starts while the driver starts it, or is lost after the launcher returned and before the instance's first `run.json` write) | While the driver starts it, a box that goes to an error state or away, does not start within the start timeout, or refuses an upload gets a delete request (attempted: a failed delete is not retried) and the start fails typed (`START_FAILED`); the supervisor counts no failed start and starts again on the next tick. Lost after the start returned: as on Docker, `starting` until the grace passes, then revoke if its client had mounted, and start a new box. A box deletes itself once stopped and Daytona destroys it at its TTL (default 25 h), so a box whose delete failed leaks nothing past that | as on Docker: nothing committed, no instance for up to the grace; not measured on Daytona |
|
|
531
575
|
| 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
576
|
| 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
577
|
| 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
578
|
| 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 |
|
|
579
|
+
| The mountpoint holds entries written since the starting incarnation began, before its claim mounted (an app's factory writing under `ctx.root`, which the CLI runs before the mount) | acquire refuses the start: `MOUNTPOINT_WRITTEN`, exit 65 (terminal, since a restart would write them again), naming the first few as `<mp>/...` in at most 240 characters; nothing moves aside and nothing mounts | the writes stay where the app left them, never silently lost; write in `onOpen`. The two rules side by side: this incarnation's own fresh writes are named (they are what the app must fix); an earlier incarnation's leftovers are counted, never named (they may be another incarnation's files) |
|
|
580
|
+
| The mountpoint holds more entries than the stray check looks at (100,000), or one this process cannot read | acquire refuses the start: `MOUNTPOINT_UNCHECKED`, exit 65, naming none of the entries; nothing moves aside and nothing mounts. A directory keeps its time when a file in it is rewritten in place, so only a check that looked at every entry can rule out this incarnation's own writes | someone looks at the directory and moves it aside by hand |
|
|
535
581
|
| 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
582
|
| 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
583
|
| 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 |
|
|
@@ -549,19 +595,28 @@ about revocation, takeover and the lease's replacement do not apply. These do:
|
|
|
549
595
|
|
|
550
596
|
| Failure | What happens | Effects |
|
|
551
597
|
|---|---|---|
|
|
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
|
|
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,
|
|
598
|
+
| 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 any code of the new incarnation runs), 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 |
|
|
599
|
+
| 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, with the message below; the records stay | none: the run does not resume beside it. End those processes and open the run again |
|
|
554
600
|
| 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
601
|
| A second process opens the run on this machine | The owner lock is refused (`HeldError`, exit 76) | none |
|
|
556
602
|
| 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
603
|
|
|
604
|
+
The refusal names each process, as the open writes it (here one, in a run at `<root>`):
|
|
605
|
+
|
|
606
|
+
```
|
|
607
|
+
1 process(es) a killed incarnation's commands started are still alive after SIGKILL: pid 4242 (tar -xf /mnt/slow/archive.tar). The run does not resume beside them. End them (kill -9 4242, as their owner, or wait for them) and open the run again; they stay recorded in <root>/commands until then.
|
|
608
|
+
```
|
|
609
|
+
|
|
558
610
|
What a revoke cannot do: no fence reaches a third party. A paid API that already has a request has it. The package
|
|
559
611
|
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
612
|
paid API an idempotency key from your own business key.
|
|
561
613
|
|
|
562
614
|
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
615
|
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).
|
|
616
|
+
to 6.3 s with a 6 s test lease (90 to 120 s with the defaults). A start that never reaches running costs its start grace: live on Archil with a 6 s grace and a 3 s tick,
|
|
617
|
+
starts that all failed were retried six times in five minutes, each grace doubled (`test/live/supervise-slow.live.test.ts`),
|
|
618
|
+
and a takeover whose start was slowed 5 s before its first `run.json` write started once and was never revoked, 5 of 5
|
|
619
|
+
(`test/live/supervise.live.test.ts`). On Docker and Daytona hosts, a start lost before its instance runs is not measured.
|
|
565
620
|
|
|
566
621
|
## Security
|
|
567
622
|
|
|
@@ -577,20 +632,22 @@ to 6.3 s with a 6 s test lease (90 to 120 s with the defaults).
|
|
|
577
632
|
- **The API key stays with the supervisor.** Hosts and instances never hold it, and the local driver never copies its own
|
|
578
633
|
environment into a unit.
|
|
579
634
|
- **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
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
635
|
+
command starts under `setpriv --no-new-privs` (on Linux; macOS has no equivalent, see [On macOS](#on-macos)), so a
|
|
636
|
+
command cannot use the run user's sudo rule (sudo refuses: "The no new privileges flag is set"). An app that turns
|
|
637
|
+
this off (`archilEnv(claim, { noNewPrivs: false })`) or runs commands another way must confine them itself. Commands
|
|
638
|
+
run next to the FUSE client on the host that holds the claim: if the agent is not trusted with the host, sandbox its
|
|
639
|
+
commands (for example with bubblewrap) and keep the FUSE client outside the sandbox.
|
|
584
640
|
- **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 (`..`,
|
|
586
|
-
paths, symlinks, dangling links, a missing tail) and refuses it unless it lies in `work/` (reads may also use
|
|
587
|
-
the bash tool tells the model its long output went, and `readRoots` the app names; writes may also go
|
|
588
|
-
the environment's `createTempDir` returned, for the tool it was handed to). An agent cannot `write
|
|
641
|
+
sandbox, so every file operation of the environment resolves its path to the file the kernel would reach (`..`,
|
|
642
|
+
absolute paths, symlinks, dangling links, a missing tail) and refuses it unless it lies in `work/` (reads may also use
|
|
643
|
+
`tmp/`, where the bash tool tells the model its long output went, and `readRoots` the app names; writes may also go
|
|
644
|
+
inside a directory the environment's `createTempDir` returned, for the tool it was handed to). An agent cannot `write
|
|
589
645
|
../store/run.sqlite`, `../run.json` or `../owner.lock`: the call fails with a `PathOutsideWorkError` the model sees as
|
|
590
646
|
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
|
|
592
|
-
|
|
593
|
-
confineFiles: false })` turns it off
|
|
647
|
+
so a link a command swaps in after the check is refused too (on Linux; on macOS the walk is by path, see [On
|
|
648
|
+
macOS](#on-macos)). This covers the file tools only; a command still runs as the run user and can reach anything that
|
|
649
|
+
user can, which is what an app's own command sandbox is for. `archilEnv(claim, { confineFiles: false })` turns it off
|
|
650
|
+
where `/proc/self/fd` is unavailable.
|
|
594
651
|
- **`--serve` authenticates only with a bearer token.** On 127.0.0.1 (the default) the token is optional. A non-loopback
|
|
595
652
|
`--serve-host` requires `--serve-token-file`, a file of mode 0600 holding the token (never argv or the environment); every
|
|
596
653
|
request must send `authorization: Bearer <token>`, and it is compared in constant time. The file is readable by the run
|
|
@@ -636,21 +693,76 @@ then list the prefix and check it is empty.
|
|
|
636
693
|
|
|
637
694
|
## Library
|
|
638
695
|
|
|
639
|
-
`openDurableRun(ref, options)` is what `run` calls: mount, verify the claim, take the owner lock,
|
|
640
|
-
the store, check the seal, open and resume the
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
`
|
|
644
|
-
|
|
696
|
+
`openDurableRun(ref, options)` is what `run` calls: mount, verify the claim, take the owner lock, make `store/`, `work/`
|
|
697
|
+
and `tmp/`, end a killed incarnation's commands, write `run.json`, open the store, check the seal, open and resume the
|
|
698
|
+
Harness, keep the lease. Its `afterClaim(claim)` is awaited once the claim is held and before `run.json`, with the
|
|
699
|
+
claim's `ref`, `root`, `work` and `store`. What it returns (`harness`, `beforeResume`, `seed`) goes into the open, so
|
|
700
|
+
`harness` may come from there instead of the options, and a factory can give a new run its seed (over the options'
|
|
701
|
+
`seed`); the seed runs right after `afterClaim` and before `run.json`, on a new run only. A rejection fails the open with `run.json` as the open found it and lets the
|
|
702
|
+
claim go. `acquire`, `ensureRunning`, `localHost`,
|
|
703
|
+
`archilEnv` and the typed errors (`FencedError`, `HeldError`, `StoreBehindSealError`) are exported for apps that compose
|
|
704
|
+
their own lifecycle, and so are serve (`serveRun`, `requestRun`), parking (`watchParking`, `drain`, `busyState`, and
|
|
705
|
+
`leaseParkTarget` for a Harness a host opened over `openRunLease`) and `fork`. `src/index.ts` lists every export; the
|
|
706
|
+
`.d.ts` files in `dist/` are the reference.
|
|
707
|
+
|
|
708
|
+
A supervisor of your own (a driver `supervise` does not start, such as `daytonaHost`) gets its `control` from
|
|
709
|
+
`archilControl({ apiKey, disk, region })`: the disk's S3 and control API, the same object `supervise` builds from its flags.
|
|
710
|
+
|
|
711
|
+
```ts
|
|
712
|
+
const control = await archilControl({ apiKey: process.env.ARCHIL_API_KEY!, disk: "dsk-...", region: "aws-us-east-1" });
|
|
713
|
+
await ensureRunning({ disk: "dsk-...", region: "aws-us-east-1", id: "job-1" }, daytonaHost({ client, snapshot }), { control });
|
|
714
|
+
```
|
|
645
715
|
|
|
646
716
|
A host that owns its own pi-durable Harness and store connections imports the narrow entry instead:
|
|
647
717
|
`@parcha/pi-durable-disk/lease` holds `openRunLease` (the claim, the owner lock, `run.json` with its heartbeat, the lease
|
|
648
718
|
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`),
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
--format=esm`, which inlines
|
|
719
|
+
(`Fence`, `FencedDatabase`, `assertPragmas`, `PROFILE_PRAGMAS`, `openArchilStore`), parking (`leaseParkTarget`,
|
|
720
|
+
`watchParking`, `drain`, `busyState`), and the typed errors and exit codes. It leaves out the supervisor, the host drivers,
|
|
721
|
+
the CLI and the app contract, and the package declares `"sideEffects": false`, so a bundler keeps only what you use: a
|
|
722
|
+
bundle of the app with `await import("@parcha/pi-durable-disk/lease")` is about 63 KB minified against about 620 KB
|
|
723
|
+
for `await import("@parcha/pi-durable-disk")` (esbuild `--bundle --minify --platform=node --format=esm`, which inlines
|
|
724
|
+
pi-durable, chord and their dependencies).
|
|
725
|
+
|
|
726
|
+
`@parcha/pi-durable-disk/internals` holds the claim, status and Daytona plumbing this repository's examples and demos use:
|
|
727
|
+
`runPath`, `unmountClaim`, `createRunDir`, the mount-token and delegation helpers, `isLeaseFresh`, and the Daytona labels,
|
|
728
|
+
names and `sweepSandboxes`. It carries no compatibility promise: a name there can change or go in any release.
|
|
729
|
+
|
|
730
|
+
### Seeding a new run
|
|
731
|
+
|
|
732
|
+
`seed` (in `openDurableRun`'s options, and `openRunLease`'s) writes what a new run starts with before anything sees the
|
|
733
|
+
run: its store file, a SQLite database the Harness then opens (saved sign-in profiles, say), and files in its
|
|
734
|
+
workspace (`work/.agents/skills/`). `beforeResume` runs after the store is open, too late for that.
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
await openDurableRun(ref, {
|
|
738
|
+
...localRun("/var/runs/run-1"), // or a disk's mountToken
|
|
739
|
+
harness: { models, registry },
|
|
740
|
+
async seed({ storePath, workDir }) { // also root: the claim's root
|
|
741
|
+
const db = new DatabaseSync(storePath);
|
|
742
|
+
db.exec("CREATE TABLE IF NOT EXISTS x_agent_profiles (name TEXT PRIMARY KEY, profile BLOB)");
|
|
743
|
+
db.close(); // closed before it returns: the store opens it next
|
|
744
|
+
await cp(skillsDir, join(workDir, ".agents/skills"), { recursive: true });
|
|
745
|
+
},
|
|
746
|
+
});
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
- **When it runs.** Once, on the open that finds no `run.json`: inside the run's first claim, with the owner lock held,
|
|
750
|
+
after `store/`, `work/` and `tmp/` exist, a killed incarnation's commands are ended and `afterClaim` has run, before
|
|
751
|
+
`run.json` is written and before the store opens. Its step is `seed`. What it wrote is made durable (the claim's
|
|
752
|
+
barrier) before `run.json` publishes the run.
|
|
753
|
+
- **From the app.** `afterClaim` may return `seed` (as `AfterClaim`), over the options' one, so a factory that runs on the
|
|
754
|
+
claimed directory can give the run its starting state.
|
|
755
|
+
- **When it doesn't.** Never for a run that has `run.json`, whatever its generation, and never on a fork's first open
|
|
756
|
+
(`fork` writes `run.json`).
|
|
757
|
+
- **When it fails.** A seed that throws fails the open with its own error. A process killed while it seeds leaves the run
|
|
758
|
+
unpublished in the same way: no `run.json`, the claim let go. The next open seeds again, so a seed overwrites what an
|
|
759
|
+
earlier attempt wrote.
|
|
760
|
+
- **How long it may take.** Under a supervisor, a seed must end within the start grace (`--start-grace`,
|
|
761
|
+
`startGraceMs`; default the lease expiry, 90 s). Past it, `ensureRunning` takes the start as failed and revokes the
|
|
762
|
+
claim. The seed's next write or its barrier is then refused, the instance is fenced (exit 75) with nothing published,
|
|
763
|
+
and the next start seeds again. A deployment whose seeds can take longer raises the start grace.
|
|
764
|
+
- **On a local directory** (`localRun`) it is the same. As with `work/`, the files it writes are the operating system's
|
|
765
|
+
to flush; a SQLite database it commits with `synchronous = FULL` is durable when the commit returns.
|
|
654
766
|
|
|
655
767
|
### A run on a local directory
|
|
656
768
|
|
|
@@ -666,6 +778,14 @@ const run = await openDurableRun({ disk: "local", region: "local", id: "run-1" }
|
|
|
666
778
|
});
|
|
667
779
|
```
|
|
668
780
|
|
|
781
|
+
The run's status is its `run.json`, and `readLocalRunStatus(root)` (the directory `localRun` takes) reads it, read-only and safe beside a live instance, the way `readRunStatus` reads a disk's: the
|
|
782
|
+
same `RunRecord` (`status`, `generation`, `wakeAt`, `holder`, `sealedSeq`, `detail`), `null` when the run has no `run.json`
|
|
783
|
+
yet, and `RunRecordError` (`RUN_JSON_INVALID`) when it is malformed.
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
const status = await readLocalRunStatus("/var/runs/run-1"); // { status: "sleeping", wakeAt: "...", ... } or null
|
|
787
|
+
```
|
|
788
|
+
|
|
669
789
|
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
790
|
machine's owner lock keeps a second process off the run (a second open while one holds it is refused), and nothing at
|
|
671
791
|
all keeps out a process on another machine that can reach the directory. Its barrier refuses once the run is fenced,
|
|
@@ -673,10 +793,124 @@ like a mount's, but syncs nothing: the store's commits are made durable by SQLit
|
|
|
673
793
|
`work/` are the operating system's to flush, so a power loss can lose what it had not written yet. `mountToken` is
|
|
674
794
|
required only with the default `acquire`; without either, the open is refused (`INVALID_ARGUMENT`) before anything mounts.
|
|
675
795
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
796
|
+
#### On macOS
|
|
797
|
+
|
|
798
|
+
A local run works on macOS for development, and is weaker there than on Linux in four ways. Use Linux, or the
|
|
799
|
+
[Docker quickstart](#quickstart-with-docker), when the agent is not trusted.
|
|
800
|
+
|
|
801
|
+
- **Commands keep your rights: `no_new_privs` has no macOS equivalent.** On Linux every command starts under
|
|
802
|
+
`setpriv --no-new-privs`, so it cannot use `sudo` or gain privilege from a setuid program. macOS has no such flag and nothing
|
|
803
|
+
that does its job (`sandbox-exec` is deprecated), so `noNewPrivs` defaults to false there and no `setpriv` runs. That
|
|
804
|
+
leaves open: a command can use `sudo` wherever sudo asks it for no password (a `NOPASSWD` rule, for one), and setuid
|
|
805
|
+
programs keep their privilege. `runAs` is not available either (it needs `setpriv`). As on Linux, a command runs as
|
|
806
|
+
you and can reach anything you can.
|
|
807
|
+
- **The file tools' confinement is path-based.** On Linux it walks a path through `/proc/self/fd`, one held directory at
|
|
808
|
+
a time, so no component can be swapped once it was checked; macOS has no `/proc/self/fd`. There it resolves the path
|
|
809
|
+
(`realpath`) and refuses it unless it lies in `work/` or another root the file tools may use, as on Linux, opens each
|
|
810
|
+
component by its full path with `O_NOFOLLOW`, then checks that the path still resolves to itself and names the
|
|
811
|
+
directory it opened (same device and inode). It refuses `../store/run.sqlite`, absolute paths and links out of
|
|
812
|
+
`work/`, a link already in place included. That leaves open: a command that swaps a directory above the target for a
|
|
813
|
+
link between that check and the operation can win, and the file tool then acts where the link points.
|
|
814
|
+
- **The run's root is addressed by its path.** On Linux, `run.json` and the run's directories are written through the
|
|
815
|
+
directory opened at acquire. On macOS each write is checked first against that directory, and a root renamed and
|
|
816
|
+
replaced between the check and the write gets the write.
|
|
817
|
+
- **Commands are recorded from `ps`.** With no `/proc`, the instance reads one `ps` listing every 0.5 s while commands run,
|
|
818
|
+
`ps -E` for a command's mark and `sysctl kern.bootsessionuuid` for the boot. A command started within about 0.5 s
|
|
819
|
+
before a kill can be missed, and a process is told from a later one with the same pid by the second it started. The
|
|
820
|
+
lease watchdog reads the same listing.
|
|
821
|
+
|
|
822
|
+
`fsync` on macOS does not flush the drive's cache, so the store also sets SQLite's `fullfsync` and
|
|
823
|
+
`checkpoint_fullfsync` there (SQLite then syncs with `F_FULLFSYNC`), and refuses to open unless they read back. CI runs
|
|
824
|
+
the no-key quickstart, the local run's tests (its command records, `run --dir` and `status --dir`, the lease watchdog,
|
|
825
|
+
the file tools' confinement) and the keyless examples on macOS.
|
|
826
|
+
|
|
827
|
+
### A short claim on any path, and a read of what it released
|
|
828
|
+
|
|
829
|
+
`withClaim` holds one path of a disk exclusively for a moment, outside any run: pi-durable-agent carries an agent's home
|
|
830
|
+
(`agent/agent.sqlite`) back at a run's end this way. `readReleased` reads a file such a claim released over the S3 API,
|
|
831
|
+
with no mount, so a reader never blocks a writer. Both are supervisor side: they take the `control` of
|
|
832
|
+
`archilControl({ apiKey, disk, region })`.
|
|
833
|
+
|
|
834
|
+
```ts
|
|
835
|
+
await withClaim({ control, disk: "dsk-...", region: "aws-us-east-1", path: "agent" }, async (held) => {
|
|
836
|
+
const db = new DatabaseSync(join(held.root, "agent.sqlite"));
|
|
837
|
+
db.exec("INSERT INTO runs (id) VALUES ('job-1'); PRAGMA wal_checkpoint(TRUNCATE)");
|
|
838
|
+
db.close();
|
|
839
|
+
});
|
|
840
|
+
const store = await readReleased({ control, path: "agent" }, "agent.sqlite", { sqlite: true }); // null: none yet
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
A claim is one exclusive mount, about 0.4 to 1 s with its release, so it is for a run's end, never a turn.
|
|
844
|
+
- **Exclusive by the mount.** Archil refuses a second client's mount of the path, an ancestor or a descendant. The call
|
|
845
|
+
mounts at a fresh `<mountRoot>/runs/<pid>-<uuid>` (default `/mnt/archil/.claims`; the shape `archil-scoped`'s cleanup
|
|
846
|
+
takes on a host that cannot unmount) and never shares a mount. Archil mounts no path whose directory is missing, so it
|
|
847
|
+
first makes the path's directories the disk lacks, over S3, as the calling process's own (0700; `owner` sets them). It writes
|
|
848
|
+
`.claim` with its deadline (`holdMs`, default 60 s), runs `fn`, then barriers, unmounts and gives its mount token back,
|
|
849
|
+
whether `fn` resolved or threw.
|
|
850
|
+
- **Lost holders are revoked first.** A holder whose client is gone (orphaned) is revoked before the mount, and so is a
|
|
851
|
+
holder of exactly the path whose `.claim` names a deadline that has passed and stays the same for 3 s (a holder that
|
|
852
|
+
just mounted rewrites it at once; a holder of a path above or below is never revoked by this path's `.claim`). A live holder within its deadline is waited for (`waitMs`, default 30 s), never
|
|
853
|
+
revoked. A holder revoked while still alive fails its next fsync, so its commit rolls back and its own call is FENCED:
|
|
854
|
+
the deadline never decides what is durable, the fence does.
|
|
855
|
+
- **What it refuses.** A path that is not relative and of safe segments, or that is under `runs/` (a run's own) or
|
|
856
|
+
`.archil/`, is `ClaimError` `INVALID_ARGUMENT`, before any call. A live holder after `waitMs` is `HeldError`
|
|
857
|
+
`CLAIM_HELD` (exit 76). The control API failing, or a delegation with no path it cannot attribute, is
|
|
858
|
+
`CONTROL_API_FAILED`. A claim revoked from under `fn` is `FencedError` (exit 75), and `fn`'s writes are then not
|
|
859
|
+
durable.
|
|
860
|
+
- **`CLAIM_HELD` is retryable.** The path is not released yet: retry within `waitMs`, and never fall back to an older
|
|
861
|
+
release.
|
|
862
|
+
|
|
863
|
+
`readReleased` returns the bytes only when the read is of a released file. Nothing holds the path (or an ancestor or a
|
|
864
|
+
descendant) before or after the read, and the object is unchanged across it (its etag, else its size and time). For
|
|
865
|
+
SQLite (`sqlite: true`), its `-wal` must also be absent or empty, so its writer checkpointed with `TRUNCATE` and closed. A
|
|
866
|
+
held path is `CLAIM_HELD`, retryable as above, and a WAL with content is `NOT_RELEASED`. A file that changed during every
|
|
867
|
+
one of `attempts` reads (default 3) is `CLAIM_HELD`. Open the bytes as a local file, read-only (`?immutable=1`).
|
|
868
|
+
|
|
869
|
+
### Places: a run a supervisor can kill and bring back
|
|
870
|
+
|
|
871
|
+
A place is where one run lives and how its instance starts, is killed and comes back, for a supervisor that decides when
|
|
872
|
+
to kill (agentrun's reference demo). Each factory takes its options and returns `(ctx) => Place`; `ctx.paths` is the run's
|
|
873
|
+
id and directory as the supervisor computed them.
|
|
874
|
+
|
|
875
|
+
- `localPlace({ app, root? })` runs `pi-durable-disk run --dir <root> --id <id> --app <app>` as a child in its own
|
|
876
|
+
process group, with `{ PATH, ...order.env }` and nothing else of the caller's environment. It needs no disk, no key and
|
|
877
|
+
no token. A process kill is SIGKILL to the group, and the next runner resumes the run from the directory. A box kill
|
|
878
|
+
also calls `ctx.destroyBoxes` (a deletion that failed is tried again by the next kill or recover). `release()` kills
|
|
879
|
+
what still runs, then, under the run's owner lock, ends the commands the run recorded (pi starts each in a process
|
|
880
|
+
group of its own, which the instance's kill does not reach). While another process holds the run, they are its own,
|
|
881
|
+
and release leaves them. With `ctx.requestFile`, `signals(attempt)` yields the kill request the runner writes
|
|
882
|
+
there when it parks at its planned crossing, and `requestFile` is that same file. `return()` ends a pending `next()`.
|
|
883
|
+
- `archilPlace({ disk, region, control, host })` runs the instance on the disk through a host driver set up with the app.
|
|
884
|
+
- `token(attempt)` mints that attempt's mount token through `control`, and the runner handed that token mounts with it.
|
|
885
|
+
- A start is `ensureRunning` with the token (`mountToken`) and the order's environment (`StartAttempt.env`).
|
|
886
|
+
- A kill is the driver's `kill`, and `kills` is the driver's.
|
|
887
|
+
- A recovery is the takeover: the killed holder's delegations are revoked whatever its lease says, and the next
|
|
888
|
+
instance starts. It reports `revoked` and `ms`. A newer incarnation than the one the place killed (another
|
|
889
|
+
supervisor's) gets the usual lease and start-grace checks instead, so a live one is `HELD`, not revoked.
|
|
890
|
+
- `status()` reads `run.json` over S3 and lists the run's delegations.
|
|
891
|
+
- `requestFile(attempt)` is `<root>/signals/kill-<attempt>.json`, in the run's directory and outside `work/`, and
|
|
892
|
+
`signals(attempt)` reads `runs/<id>/signals/kill-<attempt>.json` over S3 every 500 ms (`signalPollMs`). A runner on
|
|
893
|
+
a box of its own can't reach the supervisor's files, so its request travels through the disk. The runner must write
|
|
894
|
+
it durably for S3 to see it: a temporary file, fsync, rename, fsync of the directory. A missing object is a request
|
|
895
|
+
not written yet; any other read failure is retried. Once reads have failed for `signalFailMs` (60 s) without a
|
|
896
|
+
break, the iterator yields one `{ kind: "signals-failed", code, message }` and ends, so a parked runner never waits
|
|
897
|
+
with no word of why. `code` is the failing read's own error code when it has one (`CONTROL_API_FAILED` for a read
|
|
898
|
+
that timed out), else `SIGNALS_FAILED`. The iterator never rejects.
|
|
899
|
+
- A request names `point` and `n`, and every other field it carries stays in the signal's `crossing`, except the
|
|
900
|
+
per-attempt nonce the supervisor put in the order's env. That one is lifted to the signal's own `nonce` (when it is a
|
|
901
|
+
string), where the supervisor reads it for its check, as from the local place. The place's token never goes into a
|
|
902
|
+
request: on a disk it is the mount token, and the app never holds it.
|
|
903
|
+
- `release()` stops every instance the place started and removes every token it minted. What it could not stop or
|
|
904
|
+
remove, it reports (`RELEASE_FAILED`) and tries again on the next `release()`. The run's directory stays
|
|
905
|
+
(`deleteRunTree`).
|
|
906
|
+
|
|
907
|
+
A runner's `exited` says how it ended. Locally that is the exit code or signal, and the outcome is the instance's
|
|
908
|
+
`{"outcome":{...}}` line on stdout. Without that line, or on a disk (a box's stdout is out of reach), the outcome is
|
|
909
|
+
`run.json`'s, with its generation, and `code`, `message`, `record` and `storeSha256` from the detail of the last status
|
|
910
|
+
the app set. Its `status` is the run's word, not the record's, so a supervisor needs no package vocabulary: `complete`
|
|
911
|
+
when the run is `done` and that detail carries a record, `failed` for anything else (done with no record, failed, or a
|
|
912
|
+
run its instance left `running`, `paused` or `sleeping`). So an app that runs on a box and wants its supervisor to have
|
|
913
|
+
its record puts the record there, in its final `setStatus("done", { record })`.
|
|
680
914
|
|
|
681
915
|
## Tests
|
|
682
916
|
|
package/dist/app.d.ts
CHANGED
|
@@ -26,7 +26,11 @@ export type AppOptions = OpenDurableRunOptions["harness"] & {
|
|
|
26
26
|
/** Record a parking run's wake (park.ts `ParkOptions.wake`); default run.json `sleeping` with `wakeAt`. */
|
|
27
27
|
wake?(run: DurableRun, at: number | null): Promise<void>;
|
|
28
28
|
};
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* What `--app` names: `export default async (ctx) => ({ registry, models, onOpen })`. The CLI calls it once the claim is
|
|
31
|
+
* held, so `ctx.root` is the live run directory and the factory may write under it; the Harness opens with what it
|
|
32
|
+
* returns.
|
|
33
|
+
*/
|
|
30
34
|
export type AppModule = (ctx: AppContext) => AppOptions | Promise<AppOptions>;
|
|
31
35
|
/**
|
|
32
36
|
* The app module is missing, does not load, has no default export function, throws, returns no options, or its
|