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

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 (46) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +268 -28
  3. package/dist/app.d.ts +5 -0
  4. package/dist/app.js +3 -2
  5. package/dist/claim.d.ts +34 -2
  6. package/dist/claim.js +86 -5
  7. package/dist/cli.d.ts +6 -0
  8. package/dist/cli.js +98 -33
  9. package/dist/commands.d.ts +70 -0
  10. package/dist/commands.js +257 -0
  11. package/dist/confine.d.ts +47 -7
  12. package/dist/confine.js +148 -19
  13. package/dist/env.d.ts +17 -26
  14. package/dist/env.js +73 -68
  15. package/dist/errors.d.ts +2 -1
  16. package/dist/errors.js +1 -1
  17. package/dist/fork.d.ts +23 -0
  18. package/dist/fork.js +103 -3
  19. package/dist/hosts/daytona-launch.d.ts +3 -1
  20. package/dist/hosts/daytona-launch.js +30 -6
  21. package/dist/hosts/daytona.js +15 -3
  22. package/dist/hosts/local-host.d.ts +7 -1
  23. package/dist/hosts/local-host.js +30 -16
  24. package/dist/index.d.ts +31 -15
  25. package/dist/index.js +23 -15
  26. package/dist/internals.d.ts +3 -0
  27. package/dist/internals.js +6 -0
  28. package/dist/lease.d.ts +5 -2
  29. package/dist/lease.js +3 -1
  30. package/dist/local-run.d.ts +10 -0
  31. package/dist/local-run.js +64 -0
  32. package/dist/park.d.ts +4 -3
  33. package/dist/park.js +4 -3
  34. package/dist/process-table.d.ts +38 -0
  35. package/dist/process-table.js +158 -0
  36. package/dist/run.d.ts +52 -10
  37. package/dist/run.js +42 -7
  38. package/dist/status.d.ts +11 -3
  39. package/dist/status.js +15 -13
  40. package/dist/store.d.ts +14 -2
  41. package/dist/store.js +16 -4
  42. package/dist/supervise.d.ts +22 -3
  43. package/dist/supervise.js +40 -13
  44. package/dist/watchdog.d.ts +3 -1
  45. package/dist/watchdog.js +46 -13
  46. package/package.json +12 -7
package/CHANGELOG.md CHANGED
@@ -1,5 +1,246 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.1.0-beta.14 (2026-10-16)
6
+
7
+ - **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.
8
+ - 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.
9
+ - 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.
10
+ - `workspaceBarrier` and `WorkspaceBarrierOptions` are deleted: nothing installed the barrier extension, and its tests go with it.
11
+ - `superviseRuns`, `checkHost` and `sweepTokens` are no longer exported: the `pi-durable-disk` CLI is the supported way to run the supervisor.
12
+ - 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`.
13
+ - 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.
14
+ - `./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.
15
+ - `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.
16
+ - 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.
17
+ - 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.
18
+ - 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.
19
+ - `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.
20
+ - **The CLI runs on a local directory: `pi-durable-disk run --dir PATH --app MODULE`, no disk and no key**
21
+ - `run --dir PATH` is the instance on a directory of this machine (`localRun`): the same app contract and exit codes as
22
+ on a disk (76 when another process holds the run, or when a process its last incarnation left will not die; 75 when
23
+ fenced) and the same run flags but the Archil ones. `--disk`, `--region`, `--mount-root`, `--archil` and
24
+ `--token-stdin` beside `--dir` are a usage error (exit 2). `--id` defaults to the directory's name. Example 01's app
25
+ runs as it is: `kill -9` the instance and run the same command again, and it resumes.
26
+ - `status --dir PATH` reads `run.json` from the directory and says whether its holder is still the process that claimed
27
+ it: `run --dir` records the instance's start ticks in the holder, so a later process that reuses the pid reads as
28
+ `stopped`, whatever the lease says (`running`, `stopped`, or `gone` from another boot).
29
+ - **`forkLocal`: fork a run on a local directory into many**
30
+ - `forkLocal(from, [{ id, root }, ...])` copies a released, sealed run directory of this machine (`localRun`) into one new
31
+ run directory per target, with no disk and no mount: the copy `fork` makes, for one machine. Each fork starts `paused`
32
+ at generation 0 with the source's `sealedSeq`, so its first open is generation 1, and goes on on its own.
33
+ - 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
34
+ 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
35
+ exists is `TARGET_EXISTS`, and a target inside the source, a repeated directory or no target is `INVALID_ARGUMENT`.
36
+ - New exports: `forkLocal` and the `ForkLocalResult` type. A run's `commands/` records are not copied.
37
+ - **A run on a local directory works on macOS, for development, and the README says what it costs**
38
+ - `localRun`, the no-key quickstart and `run --dir` / `status --dir` run on macOS. There is no `/proc` there, so the
39
+ process table comes from `ps` and `sysctl`: the commands a killed instance left running are recorded and ended at the
40
+ 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
41
+ it started), the lease watchdog kills the commands at a lapse, and `status --dir` knows its holder by pid and start.
42
+ Before, the open threw (`/proc/self/fd`), and with no boot id the commands went unrecorded.
43
+ - On macOS the run's root is addressed by its path (`openClaimDir`'s new `byPath`, its default there), each write
44
+ checked first against the directory opened; `noNewPrivs` defaults to false (no `no_new_privs` there, so commands keep
45
+ the user's rights); and the file tools are confined by path: each component opened by its full path with
46
+ `O_NOFOLLOW`, then the path checked to still name the directory held. It refuses an agent's paths out of `work/` and
47
+ the links in place, not a command that swaps a directory between that check and the operation. An app that passes
48
+ `noNewPrivs` keeps its choice.
49
+ - The store also sets SQLite's `fullfsync` and `checkpoint_fullfsync` on macOS, where `fsync` does not flush the drive's
50
+ cache, and refuses to open (`StorePragmaError`) unless they read back.
51
+ - `currentBootId()` and `procStartTicks()` read the same table: on macOS the boot session's uuid, and the second a
52
+ process started.
53
+ - A `macos-latest` CI job runs the stranger's no-key quickstart and the local run's tests on a Mac.
54
+ - `status --dir` says `unknown` when this machine's boot or process listing cannot be read, where it said `gone` or
55
+ `stopped`.
56
+ - `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.
57
+ - `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.
58
+ - `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.
59
+ - 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.
60
+ - 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.
61
+ - 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.
62
+ - The live suites' runner (`test/live/ci.ts all`) no longer exits 13 instead of 1 when another live run holds the lock: its
63
+ lock holder stays referenced until the lock is decided, so a refused holder whose pipes close before its exit event no
64
+ longer leaves the process with nothing to wait on and its top-level await unsettled.
65
+ - 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.
66
+ - **An example: fork one run into many**
67
+ - `examples/fork-into-many` runs on directories of this machine with pi-ai's faux model (no disk, no key, Linux). An agent
68
+ drafts a note and the run is released at that checkpoint; `forkLocal` copies the sealed run three times, and each fork
69
+ opens as generation 1 with the checkpoint's conversation and files, takes its own instruction and finishes. The source stays
70
+ byte for byte. It ends with one `ok:` line, and `test/examples/fork-into-many.test.ts` runs it on every change.
71
+ - **An example: a coding agent that survives `kill -9`**
72
+ - `examples/kill-9` runs on a directory of this machine with pi-ai's faux model (no disk, no key, Linux). An agent writes
73
+ files in `work/`, is refused a write outside it, and starts a long `bash` command; the process is `SIGKILL`ed in the
74
+ middle of it. A second process opens the run, which ends the command the first left running; the files are intact, the cut
75
+ 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.
76
+ - 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.
77
+ - 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.
78
+ - **An example, `serve-and-park`: an idle agent holds no process**
79
+ - An instance serves `POST /submit` on a directory of this machine. Its model is overloaded and pi retries after a long
80
+ wait, so the run parks: `run.json` says `sleeping` with `wakeAt`, the claim is released and the process exits 0. A
81
+ small restart loop opens the run again at `wakeAt`, the retry finishes, and a repeated `requestId` is answered with the
82
+ first submission instead of running again. No disk, no key: `node examples/serve-and-park/main.ts` after the build,
83
+ with pi-ai's faux model. A test runs it in `npm test`.
84
+ - **Examples say which published version they need, and how to run one from an install**
85
+ - `examples/README.md` gains a "Since" column (the first published version that has what each example imports) and a
86
+ "Run one from an install" section: an empty directory, `npm init -y && npm pkg set type=module`, the README's install
87
+ line, the example's files, `node main.ts`. `fork-into-many` and `planned-move` need 0.1.0-beta.14 or later (`forkLocal`,
88
+ `archilControl` and `beforeResume`'s `previous` are not in 0.1.0-beta.13, where they stop at their import); each example's
89
+ README states its version. Checked by running each keyless example against the published 0.1.0-beta.13.
90
+ - The failure table gains a Docker host and a Daytona host lost before the instance reaches running: a start still in the
91
+ driver fails typed and tries to remove what it made (attempted, not guaranteed: on Docker a removal that fails is left
92
+ for the operator to find by its `pda.run` label; on Daytona the box's TTL bounds it), no failed start is counted, and
93
+ the next tick starts again; a start that had
94
+ returned is held `starting` by the start mark until the start grace passes, then started again (revoking an orphaned
95
+ delegation first). Nothing is lost, since an instance writes `run.json` at its generation before it opens the store.
96
+ Timings are cited from the live suites where measured, and marked not measured on Docker and Daytona.
97
+ - The README quotes the refusal an open writes when a process a killed incarnation's commands started will not die
98
+ (`COMMANDS_ALIVE`), for one survivor, word for word; a test builds the message from the code and fails if the README's
99
+ copy differs.
100
+
101
+ ## 0.1.0-beta.13 (2026-10-14)
102
+
103
+ **pi-durable 1.1**
104
+ - The peer ranges of `@earendil-works/pi-durable` and `@earendil-works/chord` widen from `~1.0.4` to `>=1.0.4 <1.2.0`:
105
+ the package installs beside pi-durable 1.1 and still beside 1.0.4. It is developed against 1.1.0, and a CI job
106
+ (`pi-durable-disk-pi-floor`) runs its whole suite again on pi-durable, chord and pi-ai 1.0.4, the lowest the range
107
+ accepts. The conformance minimum follows the installed version (23 cases on 1.0.4, 24 on 1.1), and the scan-order
108
+ control is skipped, with its reason, where the installed `Storage` contract has no order.
109
+ - pi-durable 1.1.0 requires a `Storage` to honour `order` on its conversation, entry, task and submission scans and to
110
+ continue a cursor in the order it was returned with. The store's scans are pi's `SqliteStorage` behind the fenced
111
+ database, so they do: pi-durable 1.1.0's storage conformance suite (24 cases, one of them the scans in either order)
112
+ passes on both profiles, and a control shows the runner fails a storage that drops `order`.
113
+ - What the package reads of pi's internals is unchanged in 1.1.0: `durable_metadata.next_seq` for the seal, and the
114
+ `retry` and `poll` checkpoints of generation and compaction for parking. Each is read off what the installed pi wrote:
115
+ the seal test commits through pi's storage, a park test waits on a real retry, and a new test waits on a real
116
+ deferred answer (pi's poll phase), so a version that moved either shape fails a test rather than this sentence.
117
+ - The Docker runtime image installs pi-durable, pi-ai and chord 1.1.0.
118
+
119
+ **A dead client's private directories**
120
+ - Each client holds delegations on its own private directories too, `.archil/client-<clientId>` and its `unlinked/`, which the
121
+ control API lists with no path. A client that dies (its FUSE daemon killed under a running instance, a lost host) leaves them
122
+ orphaned after the run's own delegation is revoked and its directory deleted. They block no run (they are on the client's
123
+ own directories, never a run's), but nothing revoked them, so they piled up and resurfaced in listings: a scratch disk held
124
+ 96, from 86 clients, all from the live suites' daemon kills. Revoking them is hygiene.
125
+ - `revoke` (and so `takeOver`), the supervisor's takeover in `ensureRunning` (an orphaned holder or an expired lease), and
126
+ `deleteRunTree` now also revoke those private-directory delegations, from the same listing, and only for the clients they
127
+ are already revoking on the run: never as a sweep. One `Disk.exec` (`stat`) confirms each inode is the client's own private
128
+ directory, and a client that holds anything else (it may hold another run) keeps everything but the run's delegation.
129
+ Best effort: without `exec`, or when it or a revoke fails, they stay, and the run's revoke never fails for them. The run's
130
+ own delegations, which every count and decision uses, are unchanged, and `revoke` still returns only those.
131
+ - `revokeBestEffort` revokes what `revoke` revokes and skips every revoke that fails (`revoke` stops at the first); only the
132
+ listing can fail it. `deleteRunTree` uses it, and the live suites' cleanups revoke through it (by path or by inode, and the
133
+ holders' private directories) before they delete a run's tree; the Docker, Daytona and lifecycle suites matched by path
134
+ only. The run suite's daemon-kill and host-loss tests check that no delegation of a killed client is left.
135
+ - New exports: `revokeBestEffort`, `revokeCompanions`, `companionsOf`.
136
+ - **`beforeResume`: an awaited step before the run resumes**
137
+ - `openDurableRun` and an app (`AppOptions`) take an optional `beforeResume(run)`, awaited once per incarnation after the
138
+ Harness is open (its registry installed) and the run is live, and before the Harness resumes. No resumed task takes a
139
+ step until it settles, and what it admits (a submission, a write) is committed before anything a resumed task's steps
140
+ commit. It sees the open Harness, the open's context, the generation, the run record and the claim.
141
+ - It also gets the store the Harness was opened over, read-only, and its head: `storage` has `scanConversations`,
142
+ `scanTasks`, `scanDocuments` and `document` on a frozen object of its own, with no `commit` or `mintId` even through a
143
+ cast, and `head` is the store's last committed sequence once the Harness is open. A recorder snapshots every document
144
+ at `head` in the hook; until the hook commits, the current state is the state at `head`, which is how a document that
145
+ keeps no history (`latest`, session and task documents) reads.
146
+ - Use: a host reconciling what a resumed tool depends on (a browser, a mount) before the task that uses it continues;
147
+ `onStep` cannot, since it is synchronous and gets no Harness.
148
+ - A rejection fails the open like any open step (`before-resume`), and the claim is released.
149
+ - New exports: the `BeforeResume` and `StoreReads` types.
150
+ - **`localRun(root)`: a run on a plain local directory, one machine**
151
+ - New export `localRun(root)` returns `openDurableRun`'s `acquire` and `claimDir` for a run in a directory of this
152
+ machine: the claim is the directory (made when missing), with no mount; `claimDir` is `openClaimDir(root, { fstype:
153
+ null })`. Its barrier refuses once fenced and syncs nothing (SQLite makes the store's commits durable; `work/` is the
154
+ OS's to flush). It is for one machine and says so: only the owner lock keeps a second process off the run.
155
+ - `mountToken` is optional: required only with the default `acquire` (refused with `INVALID_ARGUMENT` before anything
156
+ mounts when missing); a replaced `acquire` is handed an empty token.
157
+ - A local run needs Linux, for now, and the README says why: the run's root is held open through `/proc/self/fd`
158
+ (`openClaimDir`), and the default environment needs `setpriv` (`noNewPrivs`) and `/proc/self/fd` (`confineFiles`).
159
+ macOS has neither.
160
+ - Installs beside pi-durable 1.2: the peer ranges of `@earendil-works/pi-durable` and `@earendil-works/chord` widen to `>=1.0.4 <1.3.0`, so the package installs beside 1.0.4, 1.1 and 1.2. It is still developed against 1.1.0; CI runs its whole suite at 1.2.0 (`pi-1-2`) and at 1.0.4 (`pi-durable-disk-pi-floor`).
161
+ - pi-durable 1.2 reads a tool result's `output` and sends the model an empty result for one that says `content`, with no error (1.0.4 and 1.1 read `content`). The package's own source builds no tool result. The test apps' tools (the lifecycle app, the acceptance app, the live instance) and the paid-effect example give their text through `api.output()` and name neither key, which every version reads; tests assert the model gets a tool's text, not an empty result.
162
+ - pi-durable 1.2 fails a Session on any error a `Storage` throws, except its own `StorageRequestError` (an invalid request) and a read its caller cancelled. The store is pi's `SqliteStorage` behind the fenced database and the lease's commit observer, neither of which rewraps an error, so an invalid read (a cursor that is not one, an unknown conversation) fails only that call. A fenced commit still fails the Session: on 1.2 later operations refuse with `SessionFailed`, its `cause` the fenced error, where 1.0.4 and 1.1 said the Session was poisoned.
163
+ - pi-durable 1.2's `bash` answers a nonzero exit with an error result where 1.0.4 and 1.1 threw. The workspace barrier runs after it either way, with the command's writes on disk before the error result commits.
164
+ - The floor job measured a mixed family: its install left pi-durable 1.0.4 at the root loading chord and pi-ai 1.1.0, and the version guard checked only what the package resolved. The guard now also checks chord and pi-ai as the installed pi-durable loads them, and reads `PI_EXPECT_VERSION` beside `PDA_EXPECT_PI_DURABLE`; the job installs one copy of each family package through `scripts/pi-leg.mjs`.
165
+ - The test helpers that signal a process (`killQuietly`, `alive`) refuse a pid of 0 or below: a test that never learned its child's pid passed 0 from its `finally`, and `kill(0)` signalled the test runner's whole process group. A test removes a directory a run still writes into without racing its heartbeat (from #251).
166
+ - `@parcha/pi-durable-disk/lease` exports the four types its own signatures name: `AcquireOptions`, `PersistRecord` and `ClaimDir` (in `OpenRunLeaseOptions`) and `Unmounted` (in `Claim`). A host that imports only this entry can now write the type of every option it passes to `openRunLease`. They are type-only exports, so the entry's bundle still leaves out the supervisor, the host drivers, the CLI and the app contract. A test checks that every type of the package the entry's declarations name is exported by the entry.
167
+ - **A run on a local directory ends the commands a killed instance left running, before it resumes**
168
+ - pi makes each command the leader of its own process group, so a SIGKILL of the instance does not reach its commands,
169
+ and a local run has no unit cgroup to end them: a command kept running, and writing in `work/`, beside the next
170
+ incarnation. Now a local run's environment marks each command it starts (`PI_DURABLE_DISK_COMMANDS=<its token>` in the
171
+ command's environment) and records each of its commands' groups (the leader and every descendant it has seen, each by
172
+ pid, start ticks and boot) under `<root>/commands/`: another environment's commands in the same process, or the app's
173
+ own detached helpers, are never its to record. Each walk follows every recorded process still alive, so a background
174
+ worker whose leader exited, and the children it starts later, are recorded too, and the next open, holding the owner lock, kills what is
175
+ still the recorded process, waits until it is gone and clears the records, before the Harness opens (a new open step,
176
+ `commands`, after `layout`).
177
+ - A kill reaches a process only when its pid, start ticks and boot all match a record, and never pid 1 or below
178
+ (`kill(-1)` would reach every process the user may signal), the opening process or its group. Only the instance writes records; a command that forged one could already kill what it names (it runs as
179
+ the same user, or with `runAs` cannot write the instance's 0700 directory), so a record grants nothing.
180
+ - What is still alive at the reap's deadline (a process in an uninterruptible wait, or one it may not signal) refuses the
181
+ open: `HeldError` with holder `commands` (`COMMANDS_ALIVE`, exit 76), naming each pid and its command line and saying
182
+ what to do (end them, then open the run again). The run never resumes beside it, its record stays, and the next open
183
+ tries again. A record that cannot be written (a full disk) is said once on
184
+ stderr and the recorder keeps trying. A command started within the last walk's interval (about 0.1 s) before the kill
185
+ can be missed.
186
+ - A claim opts in with `Claim.commands` (the directory); `localRun`'s claim sets it. A mount's run is unchanged: its host
187
+ ends commands with the unit's cgroup or the container.
188
+ - **Stopping an instance never signals pid 1 or below, whatever its handle or state file says, nor does the lease watchdog**
189
+ - `localHost`'s `stop` of a development-mode (`child`) handle signals the handle's pid as a group. A handle comes back
190
+ from `run.json` with whatever was written there: pid 1 with pid 1's real start ticks made it `kill(-1)`, every process
191
+ the supervisor's user may signal, and pid 0 (or a pid that is not an integer) with no start ticks made it `kill(-0)`,
192
+ the supervisor's own group. It now signals only an integer pid above 1 whose handle carries start ticks.
193
+ - The Daytona launcher's `stop` had the first of those: a state file naming pid 1 with its real start ticks made it
194
+ SIGTERM pid 1 and `kill(-1, SIGKILL)`. Its liveness check now takes only an integer pid above 1 with start ticks.
195
+ - The lease watchdog's sweep killed a direct child's process group unless it was the instance's own or 0 and below; a
196
+ child in group 1 (possible in a container whose init's group is in the instance's session) made it `kill(-1)`. A
197
+ child in group 1 is now killed alone.
198
+ - **A tool may write in the temp directory it was given**
199
+ - `archilEnv`'s confinement made all of `<root>/tmp/` read-only to file operations, including the directories its own
200
+ `createTempDir` (and `createTempFile`) hands out, so a tool that writes into the temp dir it asked for was refused
201
+ `outside_work`: pi-vision's `read` of any media file failed on a disk run. A directory `createTempDir` returns is now
202
+ writable, for the run's environments; the rest of `tmp/` (another run's directories, `tmp/` itself) stays read-only.
203
+ - The tool may remove that directory (`env.remove`, as vision does when it is done), and its grant goes with it.
204
+ - It is made through a held handle of `tmp/` that does not follow a link, and granted by name under the canonical `tmp/`:
205
+ a `tmp/` a command replaced with a link is refused, and a directory swapped for a link grants nothing beyond itself.
206
+ - **The self-fence watchdog starts in a host launched with `--input-type=module`**
207
+ - The watchdog's worker is eval'd CommonJS and inherited the host's Node flags. Under `--input-type=module` (a host that
208
+ runs inline ESM through `-e` or stdin) Node read it as ESM, it threw `require is not defined`, and the run died at
209
+ open. The worker now starts with no inherited flags (`execArgv: []`); it needs none of the host's.
210
+ - An example's test can no longer hang past its timeout: `runExample` waits for the example's output only until the timeout
211
+ and a 2 s grace, then drops the pipes a process outside the example's group still holds and reports the run as timed out.
212
+ The package's tests run with `--test-timeout=240000`, so a test that hangs fails with its name.
213
+ - **An example, `audit-across-crashes`: the store is the record**
214
+ - A run on a local directory is killed twice in the middle of a tool call. A recorder in `beforeResume` snapshots every
215
+ task, document and transcript entry at each incarnation's `head`, and the example prints a per-generation account (who
216
+ asked what, who answered, which calls were cut and which generation settled them as `interrupted`) read from those
217
+ snapshots alone. No disk, no key: `node examples/audit-across-crashes/main.ts` after the build, with pi-ai's faux model.
218
+ A test runs it in `npm test`.
219
+ - **An examples index and the conventions every new example follows**
220
+ - `examples/README.md` lists the examples with what each shows, what it needs, and whether CI runs it, and states how a
221
+ new one is written: one directory and one property per example, self-contained imports, `node examples/<name>/main.ts`
222
+ on a local directory with pi-ai's faux model (no key, no network), one closing `ok: ...` line, and a test under
223
+ `test/examples/` that runs it on every change.
224
+ - `test/examples/conventions.test.ts` checks what code can: each example's files, its imports (read from the parse tree),
225
+ its test and its row in the index. `test/examples/_run-example.ts` runs an example from a scratch directory as its own
226
+ process group and reaps the group whatever ends it, and `assertExampleOk` fails a run that exited 0 while a
227
+ process it started still held its output; the quickstart's test uses both.
228
+ - **A quickstart on one machine: no disk, no key**
229
+ - The README opens with a durable chat on a directory of this machine (`localRun`) and pi-ai's faux model: one process
230
+ is killed (`SIGKILL`) after its third answer, a second opens the same run and answers the rest, and the transcript read
231
+ back from the store has every message answered once. It is `examples/01-durable-chat/on-a-directory.mjs`.
232
+ - The stranger test installs the packed package and its peers in an empty directory, runs the README's script and
233
+ expects that line, so the quickstart a reader copies is the one that is checked.
234
+ - The README's bundle sizes for the two entries are re-measured: about 60 KB for `@parcha/pi-durable-disk/lease` against about 620 KB for the root entry (it said 52 KB and 493 KB), with the esbuild command that produces them.
235
+ - **The README starts with a path chooser and states how a run on a local directory fails**
236
+ - "Choose your path" maps what a reader has (Linux and Node only, Docker and an Archil account, a Linux host with FUSE, an
237
+ app of their own) to the quickstart or section to start with, and points to the examples index.
238
+ - The failure table gains the rows for a run on a local directory (`localRun`): a crash or SIGKILL (the next open ends
239
+ the commands the killed instance left running, then resumes), a power loss (the store holds, `work/` may be older), a
240
+ second process on the machine (refused, exit 76), a process of the killed incarnation that will not die (the open is
241
+ refused, exit 76, naming it), and another machine reaching the directory (unsupported: no fence).
242
+ The layout names a local run's `commands/`.
243
+
3
244
  ## 0.1.0-beta.12, 2026-10-09
4
245
 
5
246
  Renamed from `@parcha/pi-durable-archil`. The package is now `@parcha/pi-durable-disk` and its command is `pi-durable-disk`, for branding: