@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/CHANGELOG.md CHANGED
@@ -2,6 +2,268 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.0-beta.15 (2026-10-16)
6
+
7
+ - **`HostDriver.kill`: take an instance away as a crash would, and `kills` says what that takes**
8
+ - `kill(handle)` is optional on the interface and implemented by the three drivers. It drains nothing, releases
9
+ nothing and cleans nothing: the run's claim is left as a crash leaves it, and the supervisor's next pass decides what
10
+ follows. A handle the driver cannot reach, or one from another fleet or boot, is a no-op.
11
+ - `kills` is `process` or `box`. `localHost` kills `process`: `systemctl kill --signal=SIGKILL --kill-whom=all` on the
12
+ unit, or SIGKILL to the child's process group (checked against its start ticks). `dockerHost` and `daytonaHost` kill
13
+ `box`: `docker kill` (the container is left exited and the next start removes it), and the Daytona sandbox's delete
14
+ (a refused delete stops it and deletes again). The launcher is not asked to stop.
15
+ - A kill that fails while the instance still runs throws `KILL_FAILED` (`LocalHostError`, `DockerHostError`,
16
+ `DaytonaHostError`).
17
+ - **`archilPlace.signals`: a runner's kill request travels through the run's disk, so a box can be killed at a named crossing**
18
+ - `Place.requestFile(attempt)` says where an attempt's runner writes its kill request; a place with `signals` has it,
19
+ and `signals(attempt)` reads what is written there. `localPlace`'s is the supervisor's own file (`ctx.requestFile`).
20
+ - `archilPlace`'s is `<root>/signals/kill-<attempt>.json`, in the run's directory and outside `work/`, read over S3
21
+ every 500 ms (`signalPollMs`). The runner writes it durably (a temporary file, fsync, rename, fsync of the directory)
22
+ or S3 may not see it. A missing object is a request not written yet; once reads have failed otherwise for
23
+ `signalFailMs` (60 s) without a break, the iterator yields one `signals-failed` signal (`code`, `message`) and ends.
24
+ It never rejects.
25
+ - A request is yielded once it names `point` and `n`, with every other field it carries in `crossing`, except the
26
+ per-attempt nonce from the supervisor's order, which is the signal's own `nonce` (`PlaceSignal.nonce`), where the
27
+ supervisor reads it for its check. The place's token (on a disk, the mount token) never goes into a request. The
28
+ iterator's `return()` ends a pending `next()` even while its read is in flight.
29
+ - **Places: `localPlace` and `archilPlace`, a run that a supervisor can kill and bring back**
30
+ - Each factory returns `(ctx) => Place`, matching agentrun's reference place seam structurally: `token`, `kills`,
31
+ `start`, `signals`, `kill`, `status`, `recover`, `release`. The package exports the types as `Place`, `PlaceRunner`, `PlaceExit`,
32
+ `RunnerOrder`, `PlaceSignal` and `PlaceContext`, with `PlaceError` for its failures.
33
+ - `localPlace({ app, root? })` runs `run --dir` as a child in its own process group, with no disk and no key. A process
34
+ kill is SIGKILL to the group, and the next runner resumes the run from the directory. `release()` also ends the
35
+ commands the run recorded, which run in process groups of their own, under the run's owner lock (while another
36
+ process holds the run, they are its own). Given `ctx.requestFile`,
37
+ `signals(attempt)` yields the runner's kill request (`PlaceSignal`), and the iterator's `return()` ends a pending
38
+ `next()`.
39
+ - `archilPlace({ disk, region, control, host })` mints each attempt's mount token itself (`token(attempt)`) and starts
40
+ through `ensureRunning`. Its kill is the driver's `kill`. Its recovery takes the run over: the killed holder's delegations
41
+ are revoked whatever its lease says, and the result lists them; a newer incarnation another supervisor started gets
42
+ the usual checks instead. A runner's outcome is `run.json`, read over S3.
43
+ `release()` stops what the place started and removes the tokens it minted; what it could not, it reports
44
+ (`RELEASE_FAILED`) and tries again on the next `release()`.
45
+ - `EnsureOptions.mountToken`: a start uses a token the caller minted instead of minting one, and removes it as it would
46
+ its own when the driver starts nothing or adopts.
47
+ - `StartAttempt.env`: what one start adds to its instance's environment. `localHost`, `dockerHost` and `daytonaHost` merge
48
+ it over their own `env`. An order that sets PATH or PDA_HOLDER (the driver's), or a name that is not a variable name,
49
+ is refused (`INVALID_ARGUMENT`).
50
+ - **`readLocalRunStatus(root)`: the status of a run on a local directory, from the root entry**
51
+ - New root export `readLocalRunStatus(root)` returns the `RunRecord` of a run on a local directory (`localRun`): the same strict
52
+ reader and the same type as `readRunStatus`, which reads a disk's `run.json` through the control API and so cannot serve a
53
+ directory. It answers `null` for a run with no `run.json` yet (or no directory) and throws `RunRecordError`
54
+ (`RUN_JSON_INVALID`) for a malformed record. Before, a caller had `parseRunRecord` and `RUN_JSON` from `./internals`, which
55
+ carries no compatibility promise. The `serve-and-park` example reads its run this way, so its Since is 0.1.0-beta.15.
56
+ Additive.
57
+ - **`seed`: what a new run starts with, written before anything sees the run**
58
+ - `openDurableRun` and `openRunLease` take `seed: (dir: { root, storePath, workDir }) => void | Promise<void>`. It runs
59
+ once, on the open that finds no `run.json`: inside the run's first claim, with the owner lock held, after `store/`,
60
+ `work/` and `tmp/` exist, and before `run.json` is written and the store opens (step `seed`). What it wrote is made
61
+ durable (the claim's barrier) before `run.json` publishes the run.
62
+ - It never runs for a run that has `run.json`. A seed that throws fails the open with its own error, and a process killed
63
+ while it seeds leaves the run unpublished; either way the next open seeds again. It works the same with `localRun`.
64
+ - New types `RunSeed` and `RunSeedDir`, exported from the package and from `./lease`.
65
+ - **`withClaim` and `readReleased`: a short exclusive claim on any path of a disk, and a consistent S3 read of what it
66
+ released**
67
+ - `withClaim({ control, disk, region, path }, fn, options)` holds `path` exclusively while `fn` runs (pi-durable-agent's
68
+ carry back of `agent/agent.sqlite` at a run's end). It mounts at a fresh `<mountRoot>/runs/<pid>-<uuid>`, writes `.claim`
69
+ with its deadline (`holdMs`, 60 s), runs `fn`, then barriers, unmounts and gives its mount token back, also when `fn`
70
+ throws.
71
+ - A lost holder is revoked before the mount: orphaned, or holding exactly the path with an expired `.claim` that stays
72
+ the same for 3 s. A live one within its deadline is waited for (`waitMs`, 30 s), never revoked. An abort of
73
+ `signal` before the mount runs nothing.
74
+ - It makes the path's directories the disk lacks (owned by the calling process, 0700, or `owner`): archil mounts no
75
+ path whose directory is missing.
76
+ - It refuses `INVALID_ARGUMENT` for an unsafe path or one under `runs/` or `.archil/`, `CLAIM_HELD` (76) for a live
77
+ holder after `waitMs`, and `CONTROL_API_FAILED`. It is `FENCED` (75) when revoked from under `fn`, whose writes are
78
+ then not durable.
79
+ - `readReleased({ control, path }, file, { sqlite })` reads a released file over S3, with no mount: no delegation before
80
+ or after, the object unchanged across the read, and for SQLite no `-wal` with content (`NOT_RELEASED`). It returns
81
+ `null` when the file does not exist.
82
+ - `CLAIM_HELD` from either is retryable: the path is not released yet. Retry within `waitMs`, and never fall back to an
83
+ older release.
84
+ - **On a host that refuses every unmount (Sysbox, as Daytona runs sandboxes), the archil client's own bookkeeping no longer fails the release**
85
+ - Deleting any file under an archil mount makes the client list two Active delegations of its own, `<mp>/.archil/client-<n>`
86
+ and `<mp>/.archil/client-<n>/unlinked`. They are outside the run's tree, a checkin of the run leaves them, and a
87
+ checkin of them fails `EROFS` once the mount is read-only.
88
+ - The release counted them as the run's. So any run whose app deleted a file failed every release with
89
+ `UNMOUNT_FAILED` ("still lists a delegation after archil checkin"), even after a completed run. Its instance then
90
+ exited failed, and the restarts failed `CLAIM_NOT_VERIFIED` until the box was deleted.
91
+ - They no longer count. Every other Active delegation under the mount still holds the run, and the mount is left where
92
+ it is, as before.
93
+ - **`run --app`: the app's factory runs once the claim is held, so `ctx.root` is the live run directory**
94
+ - The CLI called the app module's factory before the claim mounted, and a factory that wrote under `ctx.root` (a
95
+ recorder's spool, a `signals/` directory) wrote into the bare mountpoint. The mount hid those writes, and the run later
96
+ failed `ENOENT` on its own files. The claim is now taken first: the mount, the owner lock, the run's directories and
97
+ the end of a killed incarnation's commands. Then the factory runs, then this incarnation's `run.json`, and the Harness
98
+ opens with what the factory returned.
99
+ - `openDurableRun` gains `afterClaim(claim)`, awaited once the claim is held and before `run.json`, with the claim's
100
+ `ref`, `root`, `work` and `store`. Its result (`harness`, `beforeResume`, as `AfterClaim`) goes into the open, so
101
+ `harness` is now optional in the options when `afterClaim` gives it. With neither, the open is refused
102
+ (`INVALID_ARGUMENT`) before any claim; an `afterClaim` that returns no `harness` is refused the same way, after the
103
+ claim. A rejection fails the open and lets the claim go. The open step is `after-claim` (`OpenStep`).
104
+ - The open order changed, for `openRunLease` too: acquire, owner lock, layout (`store/`, `work/`, `tmp/`), the
105
+ `commands` reap, `after-claim`, `seed` (a new run's), `run.json`, heartbeat, then store, seal and Harness. `afterClaim`
106
+ may return a new run's `seed` (`AfterClaim.seed`), which runs over the options' one, right after it. The layout and the reap used to
107
+ come after `run.json` and the heartbeat, and the reap ran only in `openDurableRun`. Code that reads `onStep` sees the
108
+ new order.
109
+ - An open that fails before `run.json` publishes nothing of its incarnation: a new run keeps no `run.json`, and a
110
+ resumed run keeps the previous one as it was. So a module that does not load (`APP_FAILED`, exit 1, as before), or a
111
+ reap refused by a surviving command (exit 76), reads to `ensureRunning` as a failed start: it backs off (the grace
112
+ doubled per failure), instead of finding a fresh `running` record and starting the run again on every tick. The steps
113
+ before `run.json` run inside the supervisor's start grace, not under the heartbeat. Claim options, if one ever needs
114
+ them, are static (CLI flags or the environment), never returned by the factory.
115
+ - **The keyless examples run on macOS, from 0.1.0-beta.14, and say so**
116
+ - kill-9's own check that the cut command still runs read `/proc`, so on macOS the example crashed after the kill. It
117
+ reads `ps` where there is no `/proc`.
118
+ - The examples index says macOS needs 0.1.0-beta.14 or later (the first release that runs there), whatever an
119
+ example's Since says; it no longer says "Linux only".
120
+ - The stranger's walk of the examples installs, per platform, the later of an example's Since and the spec's
121
+ `examples.platformFloors` (macOS: 0.1.0-beta.14). Its Since alone installed 0.1.0-beta.13 on a Mac, which has no
122
+ macOS support, and failed kill-9, audit-across-crashes and no-double-charge there.
123
+ - The macOS CI job runs the keyless example tests (kill-9, audit-across-crashes, no-double-charge, fork-into-many,
124
+ serve-and-park) on a Mac, and its stranger step is blocking again.
125
+ - **A start whose own writes are in the bare mountpoint is refused, `MOUNTPOINT_WRITTEN`, instead of losing them**
126
+ - The CLI calls an app's factory before its claim mounts. A factory that wrote under `ctx.root` (a recorder, a `signals/`
127
+ directory) wrote into the bare mountpoint. Acquire then moved those entries aside as an earlier incarnation's
128
+ leftovers, the mount hid the rest, and the run later failed `ENOENT` on files it had written itself. Every pre-mount
129
+ write was lost without a word.
130
+ - Acquire now tells the two apart by time. Entries written since this process started are its own, so the start is
131
+ refused: `ClaimError` `MOUNTPOINT_WRITTEN`, exit 65 (terminal, since a restart would write them again). The message
132
+ names the first few as `<mp>/...` in at most 240 characters. Nothing moves aside and nothing mounts. Older leftovers
133
+ are moved aside as before, logged by count only.
134
+ - The check looks at every entry, since a directory keeps its time when a file in it is rewritten in place. A mountpoint
135
+ it cannot look at whole (more than 100,000 entries, or an entry this process cannot read) may hide such a write, so it
136
+ is refused too: `ClaimError` `MOUNTPOINT_UNCHECKED`, exit 65, naming none of the entries. Nothing moves aside and
137
+ nothing mounts; someone looks at the directory and moves it aside by hand.
138
+ - `ClaimError` takes an `exitCode`.
139
+ - **A place's outcome from `run.json` is in the run's word: `complete` or `failed`, not the record's status**
140
+ - `localPlace` (without the instance's outcome line) and `archilPlace` read a runner's outcome from `run.json`. Its
141
+ `status` was the record's (`done`, `failed`, `running`, ...), which a supervisor would have to know the package to
142
+ read. It is now `complete` when the run is `done` and the detail of the app's last status carries a `record`, and
143
+ `failed` for anything else: done with no record, failed, or a run its instance left `running`, `paused` or `sleeping`.
144
+ The generation, `code`, `message`, `record` and `storeSha256` are unchanged. An outcome line the instance prints is
145
+ passed through as it is.
146
+ - **A restart whose mount went with the failed incarnation mounts again on Daytona, and fails at once and clearly everywhere else**
147
+ - `daytonaHost`'s launcher handed the attempt's mount token to the first incarnation only, so a restart could only reuse a
148
+ live mount. When the failed incarnation's release had already let the mount go (always on Sysbox, where it is moved
149
+ aside), every restart had no token and no mount. Each one then failed with archil's misleading "Authentication failed",
150
+ until the launcher's restart limit, and the run never recovered. Every incarnation of an attempt now gets the token:
151
+ such a restart mounts again, and after a takeover that mount is `HeldError` (76, terminal), as before.
152
+ - Everywhere, an incarnation given no mount token (`--token-stdin` read nothing) that finds no live mount to reuse is now
153
+ fenced at once, before any mount: `FencedError` `CLAIM_GONE_NO_TOKEN`, exit 75, terminal. It no longer loops on a mount
154
+ that cannot succeed, and the supervisor's next start brings a new token. `localHost` keeps handing its token to the
155
+ first incarnation only.
156
+ - **The Daytona live helpers build a runtime snapshot, so a box is ready in about 2 s instead of 16 s**
157
+ - `test/live/daytona-snapshot.ts build <commit>` builds `pda-p9-runtime-<commit>-<stamp>` from `snapshotDockerfile`:
158
+ `daytona-medium`'s image with the pinned Node and archil client, the run user, the wrapper, the sudoers drop-in and the
159
+ package's dependencies. `prepareOnSnapshot` is the matching `prepare` for a `daytonaHost` start with `snapshot: <name>`:
160
+ it puts the checkout's package source over the snapshot's `node_modules` and installs dependencies only when they
161
+ changed. `check`, `baseline` and `delete` boot, compare and remove.
162
+ - `build <commit> --demo` adds the disk demo's dependencies: the third-party closure of the five workspace packages its box
163
+ installs (dsl, pi, pi-browser, pi-vision, pi-transport) and pi-codemode, pinned to the workspace lockfile at `commit`.
164
+ `installDemoPacks` then installs the checkout's packs offline, in about 1.5 s instead of 6 to 7 s over the network;
165
+ `check-demo` measures it and imports every entry the demo's child uses.
166
+ - The ledger (`P9-STATE.json`) records snapshots too. A snapshot is built once under a new name, never over an existing
167
+ one, and only one this lane built can be deleted. Every command needs the same `PDA_STATE_DIR`. A box whose delete
168
+ failed stays open in the ledger and fails the command, and a build still running when the wait ends stays open.
169
+ - No package code changes: `daytonaHost` already takes `snapshot` and `prepare`.
170
+
171
+ ## 0.1.0-beta.14 (2026-10-16)
172
+
173
+ - **The root entry lists its exports, 134 instead of 0.1.0-beta.13's 228; the rest move to `./lease`, to a new `./internals`, or out of the package's entries.** Root values go from 136 to 46: 92 leave root (19 to `./lease`, 15 to `./internals`, 58 to no entry) and 2 are new. Who breaks: an import of one of the names below from `@parcha/pi-durable-disk`. What to do: import it from the entry named here; a name that no entry exports had no caller outside the package; open an issue if you need one.
174
+ - To `@parcha/pi-durable-disk/lease` only (the store and lease internals): `EXIT_DATAERR`, `EXIT_FENCED`, `EXIT_HELD`, `EXIT_SOFTWARE`, `LeaseLapsedError`, `OWNER_LOCK`, `PROFILE_PRAGMAS`, `STORE_FILE`, `StoreBusyError`, `StorePragmaError`, `assertPragmas`, `exitCodeFor`, `openArchilStore`, `openRunLease`, `ownWriteFenced`, `storeHead`, and the types `OpenOptions`, `PragmaMismatch`. The classes `Fence`, `FencedDatabase`, `StoreFencedError` also come from `./lease`; the root still exports them as types, so a signature that names them type-checks.
175
+ - To `@parcha/pi-durable-disk/internals`, a new entry with no compatibility promise (a name there can change or go in any release): `LABEL_FLEET`, `LABEL_RUN`, `RUN_JSON`, `createRunDir`, `findDelegations`, `isLeaseFresh`, `mintMountToken`, `parseRunRecord`, `pathlessResolver`, `removeMountToken`, `runPath`, `sandboxName`, `sandboxStatus`, `sweepSandboxes`, `unmountClaim`. `parseRunRecord` and `RUN_JSON` read a run's `run.json` directly, as the serve-and-park example does; `readRunStatus` (root) reads it through the control API.
176
+ - `workspaceBarrier` and `WorkspaceBarrierOptions` are deleted: nothing installed the barrier extension, and its tests go with it.
177
+ - `superviseRuns`, `checkHost` and `sweepTokens` are no longer exported: the `pi-durable-disk` CLI is the supported way to run the supervisor.
178
+ - No longer exported by any entry, each still used inside the package: `ARCHIL_FSTYPE`, `CLAIM_PROBE`, `CONTROL_TIMEOUT_MS`, `DAYTONA_API_URL`, `DEFAULT_HEARTBEAT_MS`, `DEFAULT_LEASE_EXPIRY_MS`, `DEFAULT_LEASE_MARGIN_MS`, `DEFAULT_MOUNT_ROOT`, `DOCKER_PARK_THRESHOLD_MS`, `LEASE_EXPIRY_MS`, `LOCAL_PARK_THRESHOLD_MS`, `MOUNT_TOKEN_TTL`, `READ_ONLY_TOOLS`, `RETRY_CODES`, `RUN_JSON_TEMP`, `RUN_STATUSES`, `START_BACKOFF_MAX_MS`, `START_MARK`, `STONITH_TIMEOUT_MS`, `TERMINAL_EXITS`, `TOKEN_GRACE_MS`, `TOKEN_PREFIX`, `TOKEN_TTL`, `WRITING_TOOLS`, `companionsOf`, `containerStatus`, `currentBootId`, `currentHolder`, `deviceName`, `diskKey`, `isLoopback`, `loadApp`, `matchDelegations`, `parseShow`, `parseTokenNickname`, `persistRecord`, `procStartTicks`, `readRunRecord`, `readServeToken`, `readStartMark`, `recordWake`, `replaySafe`, `revokeBestEffort`, `revokeCompanions`, `runCommand`, `runDocker`, `startGrace`, `takeOwnerLock`, `tarOneFile`, `tokenNickname`, `unitStatus`, `waitDeadline`, `withTimeouts`, `writeRunRecord`, and the types `CheckReport`, `CheckStep`, `OwnerLock`, `ServeCode`, `StartMark`, `TokenSweep`, `TokenSweepOptions`.
179
+ - New in the root entry: `archilControl`, `forkLocal`, and the types `ArchilControl`, `DiskClient`, `ForkLocalResult` (#479, #500). `TokenUser`, which `ArchilControl.listUsers()` returns, stays in the root entry.
180
+ - `./lease` gains parking (`leaseParkTarget`, `watchParking`, `drain`, `busyState` and their types) and the types `DurableRun` and `RunEnv` that `ParkTarget` names, so a host that opened its Harness over `openRunLease` parks it without importing the root entry.
181
+ - `test/entries.test.ts` pins every entry's values and types (`test/fixtures/entries.json`), and fails when an entry's declarations name a type of the package that the entry's caller cannot import.
182
+ - Accept pi 1.3: the `@earendil-works/pi-durable` and `@earendil-works/chord` peer ranges now end below 1.4, with the floors unchanged (1.0.4, and 1.1.0 for `@parcha/pi-browser`). pi 1.3.0 pins its own packages to exact versions, so an install is all 1.2 or all 1.3; CI runs every package's whole suite on a 1.3.0 family as well as on 1.2.0.
183
+ - On pi-ai 1.3, a tool call whose arguments are not a JSON object reaches no tool: pi-durable refuses it and shows the model the text it sent. A durable node's `submit` then spends none of its submissions. Before 1.3 the tool ran with `{}`, so `submit` answered with the schema's violations and counted one submission.
184
+ - New export `archilControl({ apiKey, disk, region })`: one Archil disk's control API (S3 reads and writes, delegations, token users, `listUsers`), the object `ensureRunning`, `superviseRuns`, `checkHost`, `fork` and the token janitor take as `control`. A library caller composing its own supervisor, such as one driving `daytonaHost`, which `supervise` does not start, builds it without depending on the `disk` client. It is the CLI's own builder, moved and exported; `supervise` now calls it, so both get the same object. Additive.
185
+ - `beforeResume` receives `previous`: `run.json` as the run was left, before this open wrote over it, or null when there was none (a new run's first open). A host can tell how the run was left: `sleeping` with its `wakeAt` after a park, `paused` or `done` with the `detail` it set, `running` with the dead holder and no new seal after a crash, and on a fork's first open the record `fork` wrote (`paused`, generation 0, `detail.forkedFrom`). Before, the hook saw only this incarnation's `record`, whose `detail` the open has already reset to null. `RunLease` (`./lease`) has `previous` too. Additive.
186
+ - **The CLI runs on a local directory: `pi-durable-disk run --dir PATH --app MODULE`, no disk and no key**
187
+ - `run --dir PATH` is the instance on a directory of this machine (`localRun`): the same app contract and exit codes as
188
+ on a disk (76 when another process holds the run, or when a process its last incarnation left will not die; 75 when
189
+ fenced) and the same run flags but the Archil ones. `--disk`, `--region`, `--mount-root`, `--archil` and
190
+ `--token-stdin` beside `--dir` are a usage error (exit 2). `--id` defaults to the directory's name. Example 01's app
191
+ runs as it is: `kill -9` the instance and run the same command again, and it resumes.
192
+ - `status --dir PATH` reads `run.json` from the directory and says whether its holder is still the process that claimed
193
+ it: `run --dir` records the instance's start ticks in the holder, so a later process that reuses the pid reads as
194
+ `stopped`, whatever the lease says (`running`, `stopped`, or `gone` from another boot).
195
+ - **`forkLocal`: fork a run on a local directory into many**
196
+ - `forkLocal(from, [{ id, root }, ...])` copies a released, sealed run directory of this machine (`localRun`) into one new
197
+ run directory per target, with no disk and no mount: the copy `fork` makes, for one machine. Each fork starts `paused`
198
+ at generation 0 with the source's `sealedSeq`, so its first open is generation 1, and goes on on its own.
199
+ - The source is only read and stays byte for byte; every copy is made beside its target under a temporary name and none is published until all are
200
+ complete, so a failed batch leaves nothing behind. Targets keep the source directory's owner and mode. A source that is not released and sealed is `ForkError` `SOURCE_NOT_RELEASED`, a target that
201
+ exists is `TARGET_EXISTS`, and a target inside the source, a repeated directory or no target is `INVALID_ARGUMENT`.
202
+ - New exports: `forkLocal` and the `ForkLocalResult` type. A run's `commands/` records are not copied.
203
+ - **A run on a local directory works on macOS, for development, and the README says what it costs**
204
+ - `localRun`, the no-key quickstart and `run --dir` / `status --dir` run on macOS. There is no `/proc` there, so the
205
+ process table comes from `ps` and `sysctl`: the commands a killed instance left running are recorded and ended at the
206
+ next open as on Linux (a walk every 0.5 s, `ps -E` for a command's mark, a process told from a later one by the second
207
+ it started), the lease watchdog kills the commands at a lapse, and `status --dir` knows its holder by pid and start.
208
+ Before, the open threw (`/proc/self/fd`), and with no boot id the commands went unrecorded.
209
+ - On macOS the run's root is addressed by its path (`openClaimDir`'s new `byPath`, its default there), each write
210
+ checked first against the directory opened; `noNewPrivs` defaults to false (no `no_new_privs` there, so commands keep
211
+ the user's rights); and the file tools are confined by path: each component opened by its full path with
212
+ `O_NOFOLLOW`, then the path checked to still name the directory held. It refuses an agent's paths out of `work/` and
213
+ the links in place, not a command that swaps a directory between that check and the operation. An app that passes
214
+ `noNewPrivs` keeps its choice.
215
+ - The store also sets SQLite's `fullfsync` and `checkpoint_fullfsync` on macOS, where `fsync` does not flush the drive's
216
+ cache, and refuses to open (`StorePragmaError`) unless they read back.
217
+ - `currentBootId()` and `procStartTicks()` read the same table: on macOS the boot session's uuid, and the second a
218
+ process started.
219
+ - A `macos-latest` CI job runs the stranger's no-key quickstart and the local run's tests on a Mac.
220
+ - `status --dir` says `unknown` when this machine's boot or process listing cannot be read, where it said `gone` or
221
+ `stopped`.
222
+ - `launchStatus` (and so the Daytona host's `status`) reports a launch that ended as `failed` or `stopped` as soon as the launcher has saved its final `exited` record and no instance lives, even while the launcher's own process is still exiting. It used to say `running` until that process was gone, so a status read in that moment (a supervisor poll, or the terminal-exit test under load) saw a finished launch as live. `stop` returns at once with the recorded outcome and still sends SIGTERM to a launcher process that outstays its record.
223
+ - `daytonaHost`'s in-box launcher runs when its path goes through a link. It ran its main only when `process.argv[1]` resolved to its own module path, which Node gives with links followed, so under a symlinked package directory (pnpm-style linked `node_modules`, or a link to an installed package) it exited 0 without doing anything and every start failed with "the instance ... did not start: " and no reason. It now compares the real path. And when the launcher gives no reason, the driver's error carries what it printed (its last 4 KB) or says it printed nothing; it prints only its state, never the token.
224
+ - `daytonaHost` accepts a package installed with npm: a box path may contain `@`, so `packageDir: "/opt/app/node_modules/@scope/pkg"` (and the launcher inside it) is a plain absolute path. It was refused as `INVALID_ARGUMENT`. A path with a `..` segment is now refused too; the old pattern let `/opt/../etc` through.
225
+ - A dead FUSE mount caught while its daemon's connection is being torn down now reads as dead. Right after a `kill -9` of the daemon, the stat that decides "dead" can be in flight, and the kernel ends it with `ECONNABORTED`; only a stat issued after the teardown gets `ENOTCONN`. `unmountClaim` (and so `release` and the takeover paths) and the local host driver's `stop` counted only `ENOTCONN`, so such a mount read as live: `UNMOUNT_FAILED`, or a dead mount left behind. Both now read either code as dead and clean the mount with `fusermount -u`; any other stat error still reads as live, and a live mount is still never forced.
226
+ - The local host driver decided "dead" by matching the English text "Transport endpoint is not connected" in `stat`'s stderr, which coreutils translates, so under any other locale a dead mount read as live. It now runs the stat in a child that prints the errno code, and the check reads that code (`DEAD_CONNECTION`, exported from the claim module); messages are never read.
227
+ - The acceptance rig builds the supervisor's control API with the package's own `archilControl` (the one the CLI uses) instead of a hand-kept copy. The copy had no `exec`, and without `exec` a delegation the control API lists with no path (a dead client's private directory among them) cannot be attributed, so every supervisor pass on a disk that lists one refused with `CONTROL_API_FAILED`. The rig now starts runs on such a disk.
228
+ - The live suites' runner (`test/live/ci.ts all`) no longer exits 13 instead of 1 when another live run holds the lock: its
229
+ lock holder stays referenced until the lock is decided, so a refused holder whose pipes close before its exit event no
230
+ longer leaves the process with nothing to wait on and its top-level await unsettled.
231
+ - A local live runner, `test/live/ci.ts all`, for the ship skill to run before a change to this package ships: `npm run build && node packages/pi-durable-disk/test/live/ci.ts all` under a credential wrapper that exports `ARCHIL_API_KEY`, `PDA_LIVE_DISK` and `PDA_LIVE_REGION` for a scratch organisation and a scratch disk. It refuses to start without the key (the suites would otherwise skip and pass), runs the host suites and then the Docker suites, each under a deadline, always sweeps what a cut suite left (its systemd units, containers and mounts on the host, then its run directories and token users on the disk), prints the suites' timings, and ends on `ok: live disk check passed (...)` or `not ok: <step>: <reason>`. `all --slow` adds `supervise-slow`, the three supervise tests that wait out fixed windows (about 17 minutes), now a file of their own. The live suites now give their run directories the running user's uid rather than 1000.
232
+ - **An example: fork one run into many**
233
+ - `examples/fork-into-many` runs on directories of this machine with pi-ai's faux model (no disk, no key, Linux). An agent
234
+ drafts a note and the run is released at that checkpoint; `forkLocal` copies the sealed run three times, and each fork
235
+ opens as generation 1 with the checkpoint's conversation and files, takes its own instruction and finishes. The source stays
236
+ byte for byte. It ends with one `ok:` line, and `test/examples/fork-into-many.test.ts` runs it on every change.
237
+ - **An example: a coding agent that survives `kill -9`**
238
+ - `examples/kill-9` runs on a directory of this machine with pi-ai's faux model (no disk, no key, Linux). An agent writes
239
+ files in `work/`, is refused a write outside it, and starts a long `bash` command; the process is `SIGKILL`ed in the
240
+ middle of it. A second process opens the run, which ends the command the first left running; the files are intact, the cut
241
+ command comes back `interrupted` and is not run again, and the agent finishes. It ends with one `ok:` line, and `test/examples/kill-9.test.ts` runs it on every change.
242
+ - New example, `examples/no-double-charge/` (`node examples/no-double-charge/main.ts`): a paid call that never double-charges across a `kill -9`, with no disk and no key. A fake payment server in the example counts charges by idempotency key and holds invoice 2's charge in flight while the agent is killed. The run reopens in a second process, the cut `charge` call comes back `interrupted` and isn't sent again, and the server ends with one charge per invoice. `test/examples/no-double-charge.test.ts` runs it in CI.
243
+ - New example, `examples/planned-move/`: a run moves from this machine (a systemd unit) to a Daytona sandbox at a step boundary, by hand-over and not take-over. The first incarnation sets `paused` with where it is going and releases (sealing the store); the supervisor starts it in a box with `ensureRunning(..., { demand: true })`, which revokes nothing; the second incarnation's `beforeResume` reads `previous` and tells the agent it moved before any step resumes. It needs an Archil scratch disk, a Daytona key and a host set up for production, and the same run user uid on both hosts. Its test skips without the keys.
244
+ - **An example, `serve-and-park`: an idle agent holds no process**
245
+ - An instance serves `POST /submit` on a directory of this machine. Its model is overloaded and pi retries after a long
246
+ wait, so the run parks: `run.json` says `sleeping` with `wakeAt`, the claim is released and the process exits 0. A
247
+ small restart loop opens the run again at `wakeAt`, the retry finishes, and a repeated `requestId` is answered with the
248
+ first submission instead of running again. No disk, no key: `node examples/serve-and-park/main.ts` after the build,
249
+ with pi-ai's faux model. A test runs it in `npm test`.
250
+ - **Examples say which published version they need, and how to run one from an install**
251
+ - `examples/README.md` gains a "Since" column (the first published version that has what each example imports) and a
252
+ "Run one from an install" section: an empty directory, `npm init -y && npm pkg set type=module`, the README's install
253
+ line, the example's files, `node main.ts`. `fork-into-many` and `planned-move` need 0.1.0-beta.14 or later (`forkLocal`,
254
+ `archilControl` and `beforeResume`'s `previous` are not in 0.1.0-beta.13, where they stop at their import); each example's
255
+ README states its version. Checked by running each keyless example against the published 0.1.0-beta.13.
256
+ - The failure table gains a Docker host and a Daytona host lost before the instance reaches running: a start still in the
257
+ driver fails typed and tries to remove what it made (attempted, not guaranteed: on Docker a removal that fails is left
258
+ for the operator to find by its `pda.run` label; on Daytona the box's TTL bounds it), no failed start is counted, and
259
+ the next tick starts again; a start that had
260
+ returned is held `starting` by the start mark until the start grace passes, then started again (revoking an orphaned
261
+ delegation first). Nothing is lost, since an instance writes `run.json` at its generation before it opens the store.
262
+ Timings are cited from the live suites where measured, and marked not measured on Docker and Daytona.
263
+ - The README quotes the refusal an open writes when a process a killed incarnation's commands started will not die
264
+ (`COMMANDS_ALIVE`), for one survivor, word for word; a test builds the message from the code and fails if the README's
265
+ copy differs.
266
+
5
267
  ## 0.1.0-beta.13 (2026-10-14)
6
268
 
7
269
  **pi-durable 1.1**