@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +262 -0
  2. package/README.md +281 -47
  3. package/dist/app.d.ts +5 -1
  4. package/dist/claim.d.ts +51 -3
  5. package/dist/claim.js +157 -17
  6. package/dist/cli.d.ts +6 -0
  7. package/dist/cli.js +112 -47
  8. package/dist/commands.d.ts +4 -1
  9. package/dist/commands.js +48 -90
  10. package/dist/confine.d.ts +36 -7
  11. package/dist/confine.js +125 -18
  12. package/dist/env.d.ts +7 -22
  13. package/dist/env.js +25 -60
  14. package/dist/errors.d.ts +2 -1
  15. package/dist/fork.d.ts +23 -0
  16. package/dist/fork.js +103 -3
  17. package/dist/hosts/attempt-env.d.ts +3 -0
  18. package/dist/hosts/attempt-env.js +15 -0
  19. package/dist/hosts/daytona-launch.d.ts +3 -1
  20. package/dist/hosts/daytona-launch.js +35 -12
  21. package/dist/hosts/daytona.d.ts +2 -2
  22. package/dist/hosts/daytona.js +48 -5
  23. package/dist/hosts/docker.d.ts +2 -2
  24. package/dist/hosts/docker.js +22 -5
  25. package/dist/hosts/local-host.d.ts +9 -3
  26. package/dist/hosts/local-host.js +59 -22
  27. package/dist/index.d.ts +35 -16
  28. package/dist/index.js +27 -16
  29. package/dist/internals.d.ts +3 -0
  30. package/dist/internals.js +6 -0
  31. package/dist/lease.d.ts +4 -1
  32. package/dist/lease.js +2 -0
  33. package/dist/local-run.js +3 -2
  34. package/dist/place.d.ts +190 -0
  35. package/dist/place.js +526 -0
  36. package/dist/process-table.d.ts +38 -0
  37. package/dist/process-table.js +158 -0
  38. package/dist/run.d.ts +61 -10
  39. package/dist/run.js +69 -33
  40. package/dist/status.d.ts +19 -3
  41. package/dist/status.js +25 -13
  42. package/dist/store.d.ts +14 -2
  43. package/dist/store.js +16 -4
  44. package/dist/supervise.d.ts +43 -1
  45. package/dist/supervise.js +26 -1
  46. package/dist/watchdog.d.ts +3 -1
  47. package/dist/watchdog.js +43 -12
  48. package/dist/with-claim.d.ts +63 -0
  49. package/dist/with-claim.js +325 -0
  50. 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.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.
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 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.
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 }: known before anything mounts.
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). A `beforeResume` that rejects
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
- start(ref: RunRef, mountToken: string, attempt?: { attempt: number }): Promise<HostHandle>;
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. The mount token is uploaded to a root-only file and
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. Measured live on Daytona's hosted `us` region, about 6 ms from
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 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 |
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`, 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.
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 (`..`, 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
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. 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.
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, 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.
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`), 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).
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
- 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.
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
- /** What `--app` names: `export default async (ctx) => ({ registry, models, onOpen })`. */
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