@parcha/pi-durable-disk 0.1.0-beta.12 → 0.1.0-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +145 -0
- package/README.md +184 -11
- package/dist/app.d.ts +5 -0
- package/dist/app.js +3 -2
- package/dist/claim.d.ts +28 -1
- package/dist/claim.js +74 -3
- package/dist/cli.js +2 -1
- package/dist/commands.d.ts +67 -0
- package/dist/commands.js +299 -0
- package/dist/confine.d.ts +11 -0
- package/dist/confine.js +24 -2
- package/dist/env.d.ts +10 -4
- package/dist/env.js +50 -10
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +1 -1
- package/dist/hosts/daytona-launch.js +3 -1
- package/dist/hosts/local-host.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lease.d.ts +2 -2
- package/dist/lease.js +1 -1
- package/dist/local-run.d.ts +10 -0
- package/dist/local-run.js +63 -0
- package/dist/park.d.ts +4 -3
- package/dist/park.js +4 -3
- package/dist/run.d.ts +42 -10
- package/dist/run.js +39 -7
- package/dist/supervise.d.ts +3 -3
- package/dist/supervise.js +15 -13
- package/dist/watchdog.js +4 -2
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,150 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.0-beta.13 (2026-10-14)
|
|
6
|
+
|
|
7
|
+
**pi-durable 1.1**
|
|
8
|
+
- The peer ranges of `@earendil-works/pi-durable` and `@earendil-works/chord` widen from `~1.0.4` to `>=1.0.4 <1.2.0`:
|
|
9
|
+
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
|
|
10
|
+
(`pi-durable-disk-pi-floor`) runs its whole suite again on pi-durable, chord and pi-ai 1.0.4, the lowest the range
|
|
11
|
+
accepts. The conformance minimum follows the installed version (23 cases on 1.0.4, 24 on 1.1), and the scan-order
|
|
12
|
+
control is skipped, with its reason, where the installed `Storage` contract has no order.
|
|
13
|
+
- pi-durable 1.1.0 requires a `Storage` to honour `order` on its conversation, entry, task and submission scans and to
|
|
14
|
+
continue a cursor in the order it was returned with. The store's scans are pi's `SqliteStorage` behind the fenced
|
|
15
|
+
database, so they do: pi-durable 1.1.0's storage conformance suite (24 cases, one of them the scans in either order)
|
|
16
|
+
passes on both profiles, and a control shows the runner fails a storage that drops `order`.
|
|
17
|
+
- What the package reads of pi's internals is unchanged in 1.1.0: `durable_metadata.next_seq` for the seal, and the
|
|
18
|
+
`retry` and `poll` checkpoints of generation and compaction for parking. Each is read off what the installed pi wrote:
|
|
19
|
+
the seal test commits through pi's storage, a park test waits on a real retry, and a new test waits on a real
|
|
20
|
+
deferred answer (pi's poll phase), so a version that moved either shape fails a test rather than this sentence.
|
|
21
|
+
- The Docker runtime image installs pi-durable, pi-ai and chord 1.1.0.
|
|
22
|
+
|
|
23
|
+
**A dead client's private directories**
|
|
24
|
+
- Each client holds delegations on its own private directories too, `.archil/client-<clientId>` and its `unlinked/`, which the
|
|
25
|
+
control API lists with no path. A client that dies (its FUSE daemon killed under a running instance, a lost host) leaves them
|
|
26
|
+
orphaned after the run's own delegation is revoked and its directory deleted. They block no run (they are on the client's
|
|
27
|
+
own directories, never a run's), but nothing revoked them, so they piled up and resurfaced in listings: a scratch disk held
|
|
28
|
+
96, from 86 clients, all from the live suites' daemon kills. Revoking them is hygiene.
|
|
29
|
+
- `revoke` (and so `takeOver`), the supervisor's takeover in `ensureRunning` (an orphaned holder or an expired lease), and
|
|
30
|
+
`deleteRunTree` now also revoke those private-directory delegations, from the same listing, and only for the clients they
|
|
31
|
+
are already revoking on the run: never as a sweep. One `Disk.exec` (`stat`) confirms each inode is the client's own private
|
|
32
|
+
directory, and a client that holds anything else (it may hold another run) keeps everything but the run's delegation.
|
|
33
|
+
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
|
|
34
|
+
own delegations, which every count and decision uses, are unchanged, and `revoke` still returns only those.
|
|
35
|
+
- `revokeBestEffort` revokes what `revoke` revokes and skips every revoke that fails (`revoke` stops at the first); only the
|
|
36
|
+
listing can fail it. `deleteRunTree` uses it, and the live suites' cleanups revoke through it (by path or by inode, and the
|
|
37
|
+
holders' private directories) before they delete a run's tree; the Docker, Daytona and lifecycle suites matched by path
|
|
38
|
+
only. The run suite's daemon-kill and host-loss tests check that no delegation of a killed client is left.
|
|
39
|
+
- New exports: `revokeBestEffort`, `revokeCompanions`, `companionsOf`.
|
|
40
|
+
- **`beforeResume`: an awaited step before the run resumes**
|
|
41
|
+
- `openDurableRun` and an app (`AppOptions`) take an optional `beforeResume(run)`, awaited once per incarnation after the
|
|
42
|
+
Harness is open (its registry installed) and the run is live, and before the Harness resumes. No resumed task takes a
|
|
43
|
+
step until it settles, and what it admits (a submission, a write) is committed before anything a resumed task's steps
|
|
44
|
+
commit. It sees the open Harness, the open's context, the generation, the run record and the claim.
|
|
45
|
+
- It also gets the store the Harness was opened over, read-only, and its head: `storage` has `scanConversations`,
|
|
46
|
+
`scanTasks`, `scanDocuments` and `document` on a frozen object of its own, with no `commit` or `mintId` even through a
|
|
47
|
+
cast, and `head` is the store's last committed sequence once the Harness is open. A recorder snapshots every document
|
|
48
|
+
at `head` in the hook; until the hook commits, the current state is the state at `head`, which is how a document that
|
|
49
|
+
keeps no history (`latest`, session and task documents) reads.
|
|
50
|
+
- Use: a host reconciling what a resumed tool depends on (a browser, a mount) before the task that uses it continues;
|
|
51
|
+
`onStep` cannot, since it is synchronous and gets no Harness.
|
|
52
|
+
- A rejection fails the open like any open step (`before-resume`), and the claim is released.
|
|
53
|
+
- New exports: the `BeforeResume` and `StoreReads` types.
|
|
54
|
+
- **`localRun(root)`: a run on a plain local directory, one machine**
|
|
55
|
+
- New export `localRun(root)` returns `openDurableRun`'s `acquire` and `claimDir` for a run in a directory of this
|
|
56
|
+
machine: the claim is the directory (made when missing), with no mount; `claimDir` is `openClaimDir(root, { fstype:
|
|
57
|
+
null })`. Its barrier refuses once fenced and syncs nothing (SQLite makes the store's commits durable; `work/` is the
|
|
58
|
+
OS's to flush). It is for one machine and says so: only the owner lock keeps a second process off the run.
|
|
59
|
+
- `mountToken` is optional: required only with the default `acquire` (refused with `INVALID_ARGUMENT` before anything
|
|
60
|
+
mounts when missing); a replaced `acquire` is handed an empty token.
|
|
61
|
+
- A local run needs Linux, for now, and the README says why: the run's root is held open through `/proc/self/fd`
|
|
62
|
+
(`openClaimDir`), and the default environment needs `setpriv` (`noNewPrivs`) and `/proc/self/fd` (`confineFiles`).
|
|
63
|
+
macOS has neither.
|
|
64
|
+
- 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`).
|
|
65
|
+
- 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.
|
|
66
|
+
- 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.
|
|
67
|
+
- 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.
|
|
68
|
+
- 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`.
|
|
69
|
+
- 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).
|
|
70
|
+
- `@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.
|
|
71
|
+
- **A run on a local directory ends the commands a killed instance left running, before it resumes**
|
|
72
|
+
- pi makes each command the leader of its own process group, so a SIGKILL of the instance does not reach its commands,
|
|
73
|
+
and a local run has no unit cgroup to end them: a command kept running, and writing in `work/`, beside the next
|
|
74
|
+
incarnation. Now a local run's environment marks each command it starts (`PI_DURABLE_DISK_COMMANDS=<its token>` in the
|
|
75
|
+
command's environment) and records each of its commands' groups (the leader and every descendant it has seen, each by
|
|
76
|
+
pid, start ticks and boot) under `<root>/commands/`: another environment's commands in the same process, or the app's
|
|
77
|
+
own detached helpers, are never its to record. Each walk follows every recorded process still alive, so a background
|
|
78
|
+
worker whose leader exited, and the children it starts later, are recorded too, and the next open, holding the owner lock, kills what is
|
|
79
|
+
still the recorded process, waits until it is gone and clears the records, before the Harness opens (a new open step,
|
|
80
|
+
`commands`, after `layout`).
|
|
81
|
+
- A kill reaches a process only when its pid, start ticks and boot all match a record, and never pid 1 or below
|
|
82
|
+
(`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
|
|
83
|
+
the same user, or with `runAs` cannot write the instance's 0700 directory), so a record grants nothing.
|
|
84
|
+
- What is still alive at the reap's deadline (a process in an uninterruptible wait, or one it may not signal) refuses the
|
|
85
|
+
open: `HeldError` with holder `commands` (`COMMANDS_ALIVE`, exit 76), naming each pid and its command line and saying
|
|
86
|
+
what to do (end them, then open the run again). The run never resumes beside it, its record stays, and the next open
|
|
87
|
+
tries again. A record that cannot be written (a full disk) is said once on
|
|
88
|
+
stderr and the recorder keeps trying. A command started within the last walk's interval (about 0.1 s) before the kill
|
|
89
|
+
can be missed.
|
|
90
|
+
- A claim opts in with `Claim.commands` (the directory); `localRun`'s claim sets it. A mount's run is unchanged: its host
|
|
91
|
+
ends commands with the unit's cgroup or the container.
|
|
92
|
+
- **Stopping an instance never signals pid 1 or below, whatever its handle or state file says, nor does the lease watchdog**
|
|
93
|
+
- `localHost`'s `stop` of a development-mode (`child`) handle signals the handle's pid as a group. A handle comes back
|
|
94
|
+
from `run.json` with whatever was written there: pid 1 with pid 1's real start ticks made it `kill(-1)`, every process
|
|
95
|
+
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)`,
|
|
96
|
+
the supervisor's own group. It now signals only an integer pid above 1 whose handle carries start ticks.
|
|
97
|
+
- The Daytona launcher's `stop` had the first of those: a state file naming pid 1 with its real start ticks made it
|
|
98
|
+
SIGTERM pid 1 and `kill(-1, SIGKILL)`. Its liveness check now takes only an integer pid above 1 with start ticks.
|
|
99
|
+
- The lease watchdog's sweep killed a direct child's process group unless it was the instance's own or 0 and below; a
|
|
100
|
+
child in group 1 (possible in a container whose init's group is in the instance's session) made it `kill(-1)`. A
|
|
101
|
+
child in group 1 is now killed alone.
|
|
102
|
+
- **A tool may write in the temp directory it was given**
|
|
103
|
+
- `archilEnv`'s confinement made all of `<root>/tmp/` read-only to file operations, including the directories its own
|
|
104
|
+
`createTempDir` (and `createTempFile`) hands out, so a tool that writes into the temp dir it asked for was refused
|
|
105
|
+
`outside_work`: pi-vision's `read` of any media file failed on a disk run. A directory `createTempDir` returns is now
|
|
106
|
+
writable, for the run's environments; the rest of `tmp/` (another run's directories, `tmp/` itself) stays read-only.
|
|
107
|
+
- The tool may remove that directory (`env.remove`, as vision does when it is done), and its grant goes with it.
|
|
108
|
+
- It is made through a held handle of `tmp/` that does not follow a link, and granted by name under the canonical `tmp/`:
|
|
109
|
+
a `tmp/` a command replaced with a link is refused, and a directory swapped for a link grants nothing beyond itself.
|
|
110
|
+
- **The self-fence watchdog starts in a host launched with `--input-type=module`**
|
|
111
|
+
- The watchdog's worker is eval'd CommonJS and inherited the host's Node flags. Under `--input-type=module` (a host that
|
|
112
|
+
runs inline ESM through `-e` or stdin) Node read it as ESM, it threw `require is not defined`, and the run died at
|
|
113
|
+
open. The worker now starts with no inherited flags (`execArgv: []`); it needs none of the host's.
|
|
114
|
+
- An example's test can no longer hang past its timeout: `runExample` waits for the example's output only until the timeout
|
|
115
|
+
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.
|
|
116
|
+
The package's tests run with `--test-timeout=240000`, so a test that hangs fails with its name.
|
|
117
|
+
- **An example, `audit-across-crashes`: the store is the record**
|
|
118
|
+
- A run on a local directory is killed twice in the middle of a tool call. A recorder in `beforeResume` snapshots every
|
|
119
|
+
task, document and transcript entry at each incarnation's `head`, and the example prints a per-generation account (who
|
|
120
|
+
asked what, who answered, which calls were cut and which generation settled them as `interrupted`) read from those
|
|
121
|
+
snapshots alone. No disk, no key: `node examples/audit-across-crashes/main.ts` after the build, with pi-ai's faux model.
|
|
122
|
+
A test runs it in `npm test`.
|
|
123
|
+
- **An examples index and the conventions every new example follows**
|
|
124
|
+
- `examples/README.md` lists the examples with what each shows, what it needs, and whether CI runs it, and states how a
|
|
125
|
+
new one is written: one directory and one property per example, self-contained imports, `node examples/<name>/main.ts`
|
|
126
|
+
on a local directory with pi-ai's faux model (no key, no network), one closing `ok: ...` line, and a test under
|
|
127
|
+
`test/examples/` that runs it on every change.
|
|
128
|
+
- `test/examples/conventions.test.ts` checks what code can: each example's files, its imports (read from the parse tree),
|
|
129
|
+
its test and its row in the index. `test/examples/_run-example.ts` runs an example from a scratch directory as its own
|
|
130
|
+
process group and reaps the group whatever ends it, and `assertExampleOk` fails a run that exited 0 while a
|
|
131
|
+
process it started still held its output; the quickstart's test uses both.
|
|
132
|
+
- **A quickstart on one machine: no disk, no key**
|
|
133
|
+
- The README opens with a durable chat on a directory of this machine (`localRun`) and pi-ai's faux model: one process
|
|
134
|
+
is killed (`SIGKILL`) after its third answer, a second opens the same run and answers the rest, and the transcript read
|
|
135
|
+
back from the store has every message answered once. It is `examples/01-durable-chat/on-a-directory.mjs`.
|
|
136
|
+
- The stranger test installs the packed package and its peers in an empty directory, runs the README's script and
|
|
137
|
+
expects that line, so the quickstart a reader copies is the one that is checked.
|
|
138
|
+
- 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.
|
|
139
|
+
- **The README starts with a path chooser and states how a run on a local directory fails**
|
|
140
|
+
- "Choose your path" maps what a reader has (Linux and Node only, Docker and an Archil account, a Linux host with FUSE, an
|
|
141
|
+
app of their own) to the quickstart or section to start with, and points to the examples index.
|
|
142
|
+
- The failure table gains the rows for a run on a local directory (`localRun`): a crash or SIGKILL (the next open ends
|
|
143
|
+
the commands the killed instance left running, then resumes), a power loss (the store holds, `work/` may be older), a
|
|
144
|
+
second process on the machine (refused, exit 76), a process of the killed incarnation that will not die (the open is
|
|
145
|
+
refused, exit 76, naming it), and another machine reaching the directory (unsupported: no fence).
|
|
146
|
+
The layout names a local run's `commands/`.
|
|
147
|
+
|
|
3
148
|
## 0.1.0-beta.12, 2026-10-09
|
|
4
149
|
|
|
5
150
|
Renamed from `@parcha/pi-durable-archil`. The package is now `@parcha/pi-durable-disk` and its command is `pi-durable-disk`, for branding:
|
package/README.md
CHANGED
|
@@ -14,6 +14,18 @@ release names no other backend and promises none.
|
|
|
14
14
|
pi-durable assumes two things it does not provide: exactly one live process per store, and a store that outlives the
|
|
15
15
|
machine. A Cloudflare Durable Object gives it both. This package gives it both on an Archil disk you mount on any Linux host.
|
|
16
16
|
|
|
17
|
+
## Choose your path
|
|
18
|
+
|
|
19
|
+
| You have | Start with | What you see |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Linux and Node 22.19 or later, nothing else | [Quickstart on one machine](#quickstart-on-one-machine-no-disk-no-key) | A durable chat on a directory of this machine, killed with `SIGKILL` and finished by a second process. One machine and no fence: the directory is the claim |
|
|
22
|
+
| Docker (macOS or Linux) and an Archil account | [Quickstart with Docker](#quickstart-with-docker) | A host killed in the middle of a paid charge, and the run resumed on another, every instance in a container |
|
|
23
|
+
| A Linux host with FUSE and an Archil account | [Quickstart on a Linux host](#quickstart-on-a-linux-host-kill-a-host-watch-the-run-resume-on-another), then [Setting up a host for production](#setting-up-a-host-for-production) | The production shape: systemd units, the supervisor, and a lost host's run back in about a second |
|
|
24
|
+
| An app or a host of your own | [The app](#the-app) and [Library](#library) | `openDurableRun`, `localRun`, the supervisor and host drivers, `serve`, parking and `fork` from your code |
|
|
25
|
+
|
|
26
|
+
[examples/](examples/README.md) lists the examples, one property each, with what each needs: several run with no disk
|
|
27
|
+
and no key.
|
|
28
|
+
|
|
17
29
|
## How it maps to a Durable Object
|
|
18
30
|
|
|
19
31
|
| Durable Object | This package | Supplied by |
|
|
@@ -48,10 +60,12 @@ machine. A Cloudflare Durable Object gives it both. This package gives it both o
|
|
|
48
60
|
- An Archil account, a disk, and an API key for the supervisor, all from the Archil console. Create the disk in the region
|
|
49
61
|
nearest the host (any size; its id looks like `dsk-0123456789abcdef` and its region like `aws-us-east-1`). Hosts and
|
|
50
62
|
instances never hold the API key.
|
|
51
|
-
- `@earendil-works/pi-durable` and `@earendil-works/chord`,
|
|
63
|
+
- `@earendil-works/pi-durable` and `@earendil-works/chord`, `>=1.0.4 <1.3.0`, as peer dependencies. The package is developed against 1.1.0, and CI runs its whole suite on 1.0.4 too, the lowest version the range accepts, and on 1.2.
|
|
64
|
+
|
|
65
|
+
Not covered yet: a Kubernetes host driver (the `HostDriver` interface is below) and running the loop across machines
|
|
66
|
+
where FUSE is unavailable. Without Archil, a run can live on a plain directory of one machine
|
|
67
|
+
([A run on a local directory](#a-run-on-a-local-directory)).
|
|
52
68
|
|
|
53
|
-
Not covered yet: a Kubernetes host driver (the `HostDriver` interface is below) and running the loop where FUSE is
|
|
54
|
-
unavailable.
|
|
55
69
|
|
|
56
70
|
## Install
|
|
57
71
|
|
|
@@ -66,6 +80,114 @@ git clone https://github.com/Parcha-ai/agentrun.git && cd agentrun && npm ci --i
|
|
|
66
80
|
cd packages/pi-durable-disk
|
|
67
81
|
```
|
|
68
82
|
|
|
83
|
+
## Quickstart on one machine: no disk, no key
|
|
84
|
+
|
|
85
|
+
The fastest look, on Linux with Node 22.19 or later: a durable chat on a directory of this machine, with
|
|
86
|
+
[`localRun`](#a-run-on-a-local-directory) instead of a disk and pi-ai's faux model instead of a key. One process answers the
|
|
87
|
+
first messages and is killed (`SIGKILL`) after its third answer; a second opens the same run and answers the rest, and the
|
|
88
|
+
transcript, read back from the store, has every message answered once. The directory is the claim, so this guards one
|
|
89
|
+
machine only; the quickstarts below put the run on a disk that outlives the machine.
|
|
90
|
+
|
|
91
|
+
In an empty directory, install the package and its peers, save this as `chat.mjs`
|
|
92
|
+
(it is [examples/01-durable-chat/on-a-directory.mjs](examples/01-durable-chat/on-a-directory.mjs)):
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
// A durable chat on a directory of this machine: no disk, no key (pi-ai's faux model answers). One process answers the
|
|
100
|
+
// first messages and is killed (SIGKILL) after its third answer; a second opens the same run and answers the rest, and
|
|
101
|
+
// the transcript, read back from the store, has every message answered once. One machine: the directory is the claim.
|
|
102
|
+
import { spawn } from "node:child_process";
|
|
103
|
+
import { rmSync } from "node:fs";
|
|
104
|
+
import { join } from "node:path";
|
|
105
|
+
import { createInterface } from "node:readline";
|
|
106
|
+
import { fileURLToPath } from "node:url";
|
|
107
|
+
import { BACKGROUND_CONTEXT as ctx } from "@earendil-works/chord/context";
|
|
108
|
+
import { createModels, fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai";
|
|
109
|
+
import { createRegistry } from "@earendil-works/pi-durable";
|
|
110
|
+
import { localRun, openDurableRun } from "@parcha/pi-durable-disk";
|
|
111
|
+
|
|
112
|
+
const MESSAGES = ["Where does your transcript live?", "What happens if this process is killed now?", "Who answers after that?", "What was my first question?", "Are you the same process?", "Say goodbye."];
|
|
113
|
+
const root = join(process.cwd(), "chat-run");
|
|
114
|
+
const text = (message) => (typeof message.content === "string" ? message.content : message.content.map((part) => part.text ?? "").join(""));
|
|
115
|
+
|
|
116
|
+
if (process.argv[2] === "instance") {
|
|
117
|
+
let generation = 0;
|
|
118
|
+
const faux = fauxProvider();
|
|
119
|
+
faux.setResponses(Array.from({ length: 100 }, () => (context) => {
|
|
120
|
+
const asked = context.messages.filter((message) => message.role === "user");
|
|
121
|
+
return fauxAssistantMessage(`Answer ${asked.length}, by generation ${generation}: "${text(asked.at(-1))}"`);
|
|
122
|
+
}));
|
|
123
|
+
const models = createModels();
|
|
124
|
+
models.setProvider(faux.provider);
|
|
125
|
+
const run = await openDurableRun({ disk: "local", region: "local", id: "chat" }, { ...localRun(root), harness: { models, registry: createRegistry() } });
|
|
126
|
+
generation = run.generation;
|
|
127
|
+
const model = faux.getModel();
|
|
128
|
+
const chat = await run.harness.root(ctx, { agent: { model: { provider: model.provider, modelId: model.id } } });
|
|
129
|
+
const answers = async () => (await chat.entries({}, 100, undefined, ctx)).items.reverse().filter((entry) => entry.kind === "pi.assistant");
|
|
130
|
+
const before = (await answers()).length;
|
|
131
|
+
console.log(`generation ${generation} opened the run in ${root} (${before} answered before)`);
|
|
132
|
+
for (const [i, content] of MESSAGES.entries()) {
|
|
133
|
+
// The request id makes a message a no-op once it was admitted: a new generation walks the list from the top.
|
|
134
|
+
await (await chat.submit({ type: "input", content, requestId: `message-${i + 1}` }, ctx)).wait(ctx);
|
|
135
|
+
if (i >= before) console.log(`generation ${generation} answered message ${i + 1}`);
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
137
|
+
}
|
|
138
|
+
for (const answer of await answers()) console.log(` ${text(answer.model[0])}`);
|
|
139
|
+
await run.setStatus("done");
|
|
140
|
+
await run.release();
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The supervisor: an instance, killed after its third answer, then another on the same directory (a fresh one each time).
|
|
145
|
+
// It reads whole lines, and waits for `close` (the process gone and all its output read), not `exit`.
|
|
146
|
+
rmSync(root, { recursive: true, force: true });
|
|
147
|
+
const start = () => spawn(process.execPath, [fileURLToPath(import.meta.url), "instance"], { stdio: ["ignore", "pipe", "inherit"] });
|
|
148
|
+
const eachLine = (child, onLine) =>
|
|
149
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
150
|
+
console.log(line);
|
|
151
|
+
onLine(line);
|
|
152
|
+
});
|
|
153
|
+
const first = start();
|
|
154
|
+
eachLine(first, (line) => {
|
|
155
|
+
if (/answered message 3\b/.test(line)) first.kill("SIGKILL");
|
|
156
|
+
});
|
|
157
|
+
await new Promise((resolve) => first.on("close", resolve));
|
|
158
|
+
console.log("the first process was killed (SIGKILL); a second opens the same run");
|
|
159
|
+
const output = [];
|
|
160
|
+
const second = start();
|
|
161
|
+
eachLine(second, (line) => output.push(line));
|
|
162
|
+
const code = await new Promise((resolve) => second.on("close", resolve));
|
|
163
|
+
const answered = output.flatMap((line) => {
|
|
164
|
+
const match = /^ {2}Answer (\d+), by generation (\d+)/.exec(line);
|
|
165
|
+
return match ? [{ n: Number(match[1]), generation: Number(match[2]) }] : [];
|
|
166
|
+
});
|
|
167
|
+
const once = MESSAGES.every((_, i) => answered.filter((a) => a.n === i + 1).length === 1) && answered.length === MESSAGES.length;
|
|
168
|
+
const generations = [...new Set(answered.map((a) => a.generation))].sort();
|
|
169
|
+
if (code !== 0 || !once || generations.length < 2) {
|
|
170
|
+
console.error(`not ok: exit ${code}, answers ${JSON.stringify(answered)}`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
console.log(`ok: ${MESSAGES.length} messages answered once each, by generations ${generations.join(" and ")}, from the store in ${root}`);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Then run it (about five seconds; it writes the run under `./chat-run/`):
|
|
177
|
+
|
|
178
|
+
```sh
|
|
179
|
+
node chat.mjs
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
It ends with:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
Answer 1, by generation 1: "Where does your transcript live?"
|
|
186
|
+
...
|
|
187
|
+
Answer 6, by generation 2: "Say goodbye."
|
|
188
|
+
ok: 6 messages answered once each, by generations 1 and 2, from the store in <this directory>/chat-run
|
|
189
|
+
```
|
|
190
|
+
|
|
69
191
|
## Quickstart with Docker
|
|
70
192
|
|
|
71
193
|
You need Docker (Docker Desktop, OrbStack or Colima on macOS, Docker Engine on Linux), Node 22.19 or later, and an Archil
|
|
@@ -140,7 +262,7 @@ Each example's README lists the exact commands the demo runs, so you can run the
|
|
|
140
262
|
|
|
141
263
|
An instance is `pi-durable-disk run --app <module> ...`: `openDurableRun` with the app's options. The module is an ES
|
|
142
264
|
module whose default export is a function of where the run lives and returns pi's Harness options without `env` (the run
|
|
143
|
-
builds `env` on its claim: the workspace is `<root>/work`), plus an optional `onOpen`:
|
|
265
|
+
builds `env` on its claim: the workspace is `<root>/work`), plus an optional `beforeResume` and an optional `onOpen`:
|
|
144
266
|
|
|
145
267
|
```ts
|
|
146
268
|
import { BACKGROUND_CONTEXT as ctx } from "@earendil-works/chord/context";
|
|
@@ -159,6 +281,10 @@ export default async function app(where: AppContext): Promise<AppOptions> {
|
|
|
159
281
|
return {
|
|
160
282
|
models,
|
|
161
283
|
registry,
|
|
284
|
+
async beforeResume({ harness, context, generation }) {
|
|
285
|
+
// Awaited once per incarnation, after the Harness is open and before it resumes: no resumed task takes a step
|
|
286
|
+
// until this settles, and what it admits commits before their steps do. Reconcile here what a resumed tool depends on.
|
|
287
|
+
},
|
|
162
288
|
async onOpen(run) {
|
|
163
289
|
// Called once per incarnation, after the run is open and resumed. run.generation is 1 on a first start and
|
|
164
290
|
// higher on every resume. run.harness is pi-durable's Harness.
|
|
@@ -176,7 +302,11 @@ rerun. Any other tool is an effect: its intent is committed before it starts, an
|
|
|
176
302
|
was `interrupted`, never run again. `run.setStatus("done" | "failed" | "paused" | "sleeping", detail)` records the run's
|
|
177
303
|
status in `run.json`; `done` and `failed` run the durability barrier first. `run.release()` closes the Harness, kills the
|
|
178
304
|
run's commands, runs the barrier, seals `run.json` with the store's last sequence, and unmounts. A module that is missing
|
|
179
|
-
or throws, or an `onOpen` that rejects, is `AppError` (exit 1, which the unit retries).
|
|
305
|
+
or throws, or an `onOpen` that rejects, is `AppError` (exit 1, which the unit retries). A `beforeResume` that rejects
|
|
306
|
+
fails the open (its step is `before-resume`) and releases the claim. Besides the Harness, the hook gets `storage`, the
|
|
307
|
+
store the Harness was opened over as four reads (`scanConversations`, `scanTasks`, `scanDocuments`, `document`) with no
|
|
308
|
+
way to a write, and `head`, the store's last committed sequence: a recorder snapshots every document at `head` there.
|
|
309
|
+
Until the hook commits, the current state is the state at `head`, which is how a document that keeps no history reads.
|
|
180
310
|
|
|
181
311
|
## The CLI
|
|
182
312
|
|
|
@@ -409,10 +539,22 @@ mount token reaches the journal, and that nothing is left behind. It needs syste
|
|
|
409
539
|
| A second instance on the same host | The owner lock (an exclusive SQLite lock on `owner.lock`) is refused, exit 76 | none |
|
|
410
540
|
| Two supervisors race | The mount admits one claimant; the other instance exits 76 | none |
|
|
411
541
|
| The control API lists the run's holder without a path (it resolves paths best-effort) | The supervisor maps pathless delegations' inodes to run directories with one `Disk.exec` per pass (`find -inum`) and counts one on the run's directory as the run's: a live one keeps the run healthy, an orphaned one is revoked and the run restarted. Where `exec` is unavailable or fails, the pass is `CONTROL_API_FAILED` and starts nothing (the supervisor never forces); a dead holder then stays until Archil lists its path again or an operator revokes it by client and inode | no second instance; nothing started over a holder it cannot see |
|
|
542
|
+
| A client dies holding its own private directories (`.archil/client-<clientId>` and its `unlinked/`, which the control API lists with no path and on no run directory) | They block no run. Every revoke that fences or deletes a run (`revoke`, `takeOver`, the supervisor's takeover, `deleteRunTree`) also revokes, from the same listing, the private-directory delegations of the clients it revokes on the run, confirmed by one `Disk.exec` (`stat`), unless the client holds anything else; best effort, so one left stays for an operator to revoke by client and inode | none: they hold no run directory |
|
|
412
543
|
| The store is behind its `run.json` seal | `STORE_BEHIND_SEAL`: `run.json` is marked failed and the instance exits 65; the supervisor does not restart it | none started |
|
|
413
544
|
| SIGTERM (a deploy) | The instance stops taking submissions and drains for up to `--drain-timeout`, writes `sleeping` with its wake (now; the deadline when everything only waits; null when idle and woken by requests), then closes, kills its commands, barriers, seals and unmounts, exit 0; the supervisor starts it again at the wake | work cut at the drain's deadline resumes on the next open: safe tools rerun, unsafe ones report the interruption |
|
|
414
545
|
| Everything the run does is a retry or poll wait longer than the park threshold | The instance writes `sleeping` with `wakeAt` (the deadline), releases and exits 0; the supervisor starts it at `wakeAt` and pi's timer sleeps whatever is left; a `run.json` write that fails is a fence (exit 75) and the lease path resumes the run | nothing is lost: the wait is a checkpoint in the store |
|
|
415
546
|
|
|
547
|
+
**On a local directory** (`localRun`) nothing is mounted or delegated and no supervisor watches the run, so the rows above
|
|
548
|
+
about revocation, takeover and the lease's replacement do not apply. These do:
|
|
549
|
+
|
|
550
|
+
| Failure | What happens | Effects |
|
|
551
|
+
|---|---|---|
|
|
552
|
+
| The instance crashes or is SIGKILLed | Its owner lock goes with the process. The next open of the directory (by the app, or whatever restarts it) takes the run, ends the commands the killed incarnation left running (its `commands` step, before the Harness opens), and `resume()`s from the last commit | running tasks go back to pending; effects in flight are `interrupted`, never rerun; no command of the killed incarnation still runs once the run resumes. pi makes each command the leader of its own process group, so the SIGKILL does not reach them and a local run has no unit cgroup: the instance records each command's group under `commands/` (by pid, start ticks and boot) and the next open kills what still matches. A command started within about 0.1 s before the kill can be missed |
|
|
553
|
+
| A process the killed incarnation's commands started does not die at the next open (an uninterruptible wait, or one the run's user may not signal) | The open is refused: `HeldError` `COMMANDS_ALIVE`, exit 76, naming each pid and its command line; the records stay | none: the run does not resume beside it. End those processes (`kill -9` as their owner, or wait for them) and open the run again |
|
|
554
|
+
| Power loss or an OS crash | The store's commits are durable (SQLite at `synchronous = FULL`); `work/` is the operating system's to flush, since the claim's barrier syncs nothing | the store resumes from its last commit; files the agent wrote in `work/` can be older than what the store recorded |
|
|
555
|
+
| A second process opens the run on this machine | The owner lock is refused (`HeldError`, exit 76) | none |
|
|
556
|
+
| A process on another machine reaches the directory (a network share) | Nothing stops it: a local directory has no fence | unsupported, since two writers can corrupt the store. A directory another machine can reach needs a disk with a claim and a fence |
|
|
557
|
+
|
|
416
558
|
What a revoke cannot do: no fence reaches a third party. A paid API that already has a request has it. The package
|
|
417
559
|
guarantees that this runtime does not start an effect twice; an effect your model decides to retry is a new call, so give a
|
|
418
560
|
paid API an idempotency key from your own business key.
|
|
@@ -442,7 +584,8 @@ to 6.3 s with a 6 s test lease (90 to 120 s with the defaults).
|
|
|
442
584
|
- **The file tools stay inside `work/`.** pi's `read`, `write` and `edit` run in the loop's process, not in a command
|
|
443
585
|
sandbox, so every file operation of the environment resolves its path to the file the kernel would reach (`..`, absolute
|
|
444
586
|
paths, symlinks, dangling links, a missing tail) and refuses it unless it lies in `work/` (reads may also use `tmp/`, where
|
|
445
|
-
the bash tool tells the model its long output went, and `readRoots` the app names
|
|
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
|
|
446
589
|
../store/run.sqlite`, `../run.json` or `../owner.lock`: the call fails with a `PathOutsideWorkError` the model sees as
|
|
447
590
|
a failed tool result. A path is opened one component at a time from a held handle of `work/`, without following links,
|
|
448
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
|
|
@@ -473,6 +616,9 @@ to 6.3 s with a 6 s test lease (90 to 120 s with the defaults).
|
|
|
473
616
|
tmp/ pi's temp files and long-output spills, so a path a result names still exists after a takeover
|
|
474
617
|
```
|
|
475
618
|
|
|
619
|
+
A run on a local directory (`localRun`) has the same layout, plus `commands/`: the process groups of the commands its
|
|
620
|
+
instance started, which the next open ends before it resumes (a local run has no unit cgroup to end them).
|
|
621
|
+
|
|
476
622
|
The mountpoint is the same absolute path on every host of a deployment, so paths inside the run are identical wherever
|
|
477
623
|
it resumes.
|
|
478
624
|
|
|
@@ -484,9 +630,9 @@ its objects, and remove its token users (`supervise --id ID --sweep-tokens --tok
|
|
|
484
630
|
|
|
485
631
|
**Revoke before you delete.** Deleting the objects of a run through the S3 API while a delegation on the path is still
|
|
486
632
|
held, even an orphaned one left by a killed host, does not take effect: the same run id came back with its old store when a
|
|
487
|
-
supervisor next started it. `deleteRunTree(control, id)` does it in order: it revokes the run's delegations
|
|
488
|
-
requires the prefix to be empty. Doing it by hand, revoke
|
|
489
|
-
empty.
|
|
633
|
+
supervisor next started it. `deleteRunTree(control, id)` does it in order: it revokes the run's delegations and its holders' private directories,
|
|
634
|
+
deletes, and requires the prefix to be empty. Doing it by hand, revoke first (`revoke(control, id)` takes the same set), delete,
|
|
635
|
+
then list the prefix and check it is empty.
|
|
490
636
|
|
|
491
637
|
## Library
|
|
492
638
|
|
|
@@ -502,8 +648,35 @@ A host that owns its own pi-durable Harness and store connections imports the na
|
|
|
502
648
|
self-fence, the seal and the release, without a store or a Harness), `storeHead`, the store's fence and pragma helpers
|
|
503
649
|
(`Fence`, `FencedDatabase`, `assertPragmas`, `PROFILE_PRAGMAS`, `openArchilStore`), and the typed errors and exit codes. It
|
|
504
650
|
leaves out the supervisor, the host drivers, the CLI and the app contract, and the package declares `"sideEffects": false`, so
|
|
505
|
-
a bundler keeps only what you use: a bundle of the app with `await import("@parcha/pi-durable-disk/lease")` is about
|
|
506
|
-
minified against about
|
|
651
|
+
a bundler keeps only what you use: a bundle of the app with `await import("@parcha/pi-durable-disk/lease")` is about 60 KB
|
|
652
|
+
minified against about 620 KB for `await import("@parcha/pi-durable-disk")` (esbuild `--bundle --minify --platform=node
|
|
653
|
+
--format=esm`, which inlines pi-durable, chord and their dependencies).
|
|
654
|
+
|
|
655
|
+
### A run on a local directory
|
|
656
|
+
|
|
657
|
+
`localRun(root)` returns the `acquire` and `claimDir` that put a run on a plain directory of this machine, with no mount
|
|
658
|
+
and no mount token:
|
|
659
|
+
|
|
660
|
+
```ts
|
|
661
|
+
import { localRun, openDurableRun } from "@parcha/pi-durable-disk";
|
|
662
|
+
|
|
663
|
+
const run = await openDurableRun({ disk: "local", region: "local", id: "run-1" }, {
|
|
664
|
+
...localRun("/var/runs/run-1"), // made when it is missing; work/ and store/ inside it
|
|
665
|
+
harness: { models, registry },
|
|
666
|
+
});
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
It is for one machine (a laptop, a CI job, a test), and says so: the claim is the directory itself, so nothing but this
|
|
670
|
+
machine's owner lock keeps a second process off the run (a second open while one holds it is refused), and nothing at
|
|
671
|
+
all keeps out a process on another machine that can reach the directory. Its barrier refuses once the run is fenced,
|
|
672
|
+
like a mount's, but syncs nothing: the store's commits are made durable by SQLite, and the files the agent wrote in
|
|
673
|
+
`work/` are the operating system's to flush, so a power loss can lose what it had not written yet. `mountToken` is
|
|
674
|
+
required only with the default `acquire`; without either, the open is refused (`INVALID_ARGUMENT`) before anything mounts.
|
|
675
|
+
|
|
676
|
+
It needs Linux, for now. The run's root is held open through `/proc/self/fd` (`openClaimDir`), and the default
|
|
677
|
+
environment confines commands with `setpriv` (util-linux, `noNewPrivs`) and the file tools through `/proc/self/fd`
|
|
678
|
+
(`confineFiles`). macOS has neither, and turning the environment's two off does not help: the open still holds the root
|
|
679
|
+
through `/proc/self/fd`. A portable claim directory is what a local run on macOS would need.
|
|
507
680
|
|
|
508
681
|
## Tests
|
|
509
682
|
|
package/dist/app.d.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface AppContext {
|
|
|
11
11
|
}
|
|
12
12
|
/** pi's HarnessOptions without `env` (registry, models, settings, conversationCreated, now, onReport), plus the hooks below. */
|
|
13
13
|
export type AppOptions = OpenDurableRunOptions["harness"] & {
|
|
14
|
+
/**
|
|
15
|
+
* Awaited once per incarnation after the Harness is open and before it resumes (`OpenDurableRunOptions.beforeResume`):
|
|
16
|
+
* no resumed task takes a step until it settles. A rejection fails the open (exit 1, retried).
|
|
17
|
+
*/
|
|
18
|
+
beforeResume?: OpenDurableRunOptions["beforeResume"];
|
|
14
19
|
/**
|
|
15
20
|
* Called once per incarnation after the run is open and resumed; `run.generation` tells a first start from a resume.
|
|
16
21
|
* Work that must keep the instance up belongs in pi's tasks and tools: parking sees only those.
|
package/dist/app.js
CHANGED
|
@@ -8,8 +8,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
8
8
|
};
|
|
9
9
|
// The app an instance hosts (`pi-durable-disk run --app <module>`): an ES module whose default export is a function
|
|
10
10
|
// of where the run lives that returns pi's Harness options (without `env`, which the run builds on its claim), an
|
|
11
|
-
// optional `
|
|
12
|
-
// conversation, and how a parking run records
|
|
11
|
+
// optional `beforeResume` for what must settle before the run resumes, an optional `onOpen` for the app to submit or
|
|
12
|
+
// resume work once the run is open and resumed, how `serve` creates the root conversation, and how a parking run records
|
|
13
|
+
// its wake.
|
|
13
14
|
import { resolve } from "node:path";
|
|
14
15
|
import { pathToFileURL } from "node:url";
|
|
15
16
|
import { PdaError } from "./errors.js";
|
package/dist/claim.d.ts
CHANGED
|
@@ -92,6 +92,12 @@ export interface Claim {
|
|
|
92
92
|
readonly fenced: boolean;
|
|
93
93
|
/** Mark the claim lost (the store's facade calls this on an I/O-class error); later barriers throw at once. */
|
|
94
94
|
markFenced(cause?: unknown): void;
|
|
95
|
+
/**
|
|
96
|
+
* Where the run's environment records the commands it starts, so that the next open of this claim ends what a killed
|
|
97
|
+
* incarnation left running (`reapCommands`, before the Harness opens). Set by a claim that nothing else cleans up after,
|
|
98
|
+
* as a local run's; a mount's host ends them with its unit's cgroup or its container.
|
|
99
|
+
*/
|
|
100
|
+
readonly commands?: string;
|
|
95
101
|
/** `archil sync`: every pending write on the mount is durable when it resolves. A failure fences the claim. */
|
|
96
102
|
barrier(): Promise<{
|
|
97
103
|
ms: number;
|
|
@@ -174,8 +180,29 @@ export type PathlessResolver = (pathless: readonly Delegation[]) => Promise<Read
|
|
|
174
180
|
* removes them.
|
|
175
181
|
*/
|
|
176
182
|
export declare function pathlessResolver(control: Pick<ControlApi, "exec">): PathlessResolver;
|
|
177
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* The delegations `held`'s clients also hold on their own private directories (`privateDirs`: inode to the client whose
|
|
185
|
+
* private directory it is). A killed client leaves them orphaned, and nothing ties them to a run: they block no run, but
|
|
186
|
+
* they pile up and resurface in listings. A client that holds anything else beyond `held` may hold another run, so none
|
|
187
|
+
* of its delegations is a companion.
|
|
188
|
+
*/
|
|
189
|
+
export declare function companionsOf(all: readonly Delegation[], held: readonly Delegation[], privateDirs: ReadonlyMap<number, string>): Delegation[];
|
|
190
|
+
/**
|
|
191
|
+
* Revoke the companions of `held` (`companionsOf`): only for clients the caller is already revoking because it fences or
|
|
192
|
+
* deletes a run, never as a sweep. From `all` or a fresh listing. Best effort, and hygiene only: a companion blocks no
|
|
193
|
+
* run, so one left behind (or a failed listing or `exec`) costs a listing entry. Returns the ones revoked.
|
|
194
|
+
*/
|
|
195
|
+
export declare function revokeCompanions(control: Pick<ControlApi, "listDelegations" | "revokeDelegation" | "exec">, held: readonly Delegation[], all?: readonly Delegation[]): Promise<Delegation[]>;
|
|
196
|
+
/**
|
|
197
|
+
* Revoke every delegation on the run through the control API, then its holders' private directories
|
|
198
|
+
* (`revokeCompanions`). The old holder's next fsync returns EIO. Returns the run's own delegations.
|
|
199
|
+
*/
|
|
178
200
|
export declare function revoke(control: ControlApi, id: string): Promise<Delegation[]>;
|
|
201
|
+
/**
|
|
202
|
+
* Revoke what `revoke` revokes, skipping every revoke that fails: for cleanup, where deleting the tree next checks what
|
|
203
|
+
* is left. Only the listing (and a pathless resolution) can fail it. Returns the run's own delegations.
|
|
204
|
+
*/
|
|
205
|
+
export declare function revokeBestEffort(control: Pick<ControlApi, "listDelegations" | "revokeDelegation" | "exec">, id: string): Promise<Delegation[]>;
|
|
179
206
|
/** Revoke through the control API, then mount plainly; if the API path fails, mount with `--force`. */
|
|
180
207
|
export declare function takeOver(control: ControlApi, opts: AcquireOptions): Promise<Claim>;
|
|
181
208
|
/**
|
package/dist/claim.js
CHANGED
|
@@ -195,18 +195,89 @@ async function runDirsByInode(control, inodes) {
|
|
|
195
195
|
}
|
|
196
196
|
return names;
|
|
197
197
|
}
|
|
198
|
-
/**
|
|
198
|
+
/** A client's private directories on the disk: `.archil/client-<clientId>` and its `unlinked/`. */
|
|
199
|
+
const PRIVATE_DIR = /^\/*\.archil\/client-([A-Za-z0-9_-]+)(\/unlinked)?$/;
|
|
200
|
+
/**
|
|
201
|
+
* The delegations `held`'s clients also hold on their own private directories (`privateDirs`: inode to the client whose
|
|
202
|
+
* private directory it is). A killed client leaves them orphaned, and nothing ties them to a run: they block no run, but
|
|
203
|
+
* they pile up and resurface in listings. A client that holds anything else beyond `held` may hold another run, so none
|
|
204
|
+
* of its delegations is a companion.
|
|
205
|
+
*/
|
|
206
|
+
export function companionsOf(all, held, privateDirs) {
|
|
207
|
+
const clients = new Set(held.map((d) => d.clientId));
|
|
208
|
+
const listed = new Set(held.map((d) => `${d.clientId}/${d.inodeId}`));
|
|
209
|
+
const others = all.filter((d) => clients.has(d.clientId) && !listed.has(`${d.clientId}/${d.inodeId}`));
|
|
210
|
+
const own = (d) => (d.path ? PRIVATE_DIR.exec(d.path)?.[1] : privateDirs.get(d.inodeId)) === d.clientId;
|
|
211
|
+
const holdsMore = new Set(others.filter((d) => !own(d)).map((d) => d.clientId));
|
|
212
|
+
return others.filter((d) => !holdsMore.has(d.clientId));
|
|
213
|
+
}
|
|
214
|
+
/** The inodes of `clients`' private directories, by one `exec` (`stat`); none without `exec`. */
|
|
215
|
+
async function privateDirInodes(control, clients) {
|
|
216
|
+
const asked = clients.filter((c) => /^[A-Za-z0-9_-]+$/.test(c));
|
|
217
|
+
const dirs = new Map();
|
|
218
|
+
if (!control.exec || asked.length === 0)
|
|
219
|
+
return dirs;
|
|
220
|
+
const paths = asked.flatMap((c) => [`.archil/client-${c}`, `.archil/client-${c}/unlinked`]);
|
|
221
|
+
const r = await control.exec(`stat -c '%i %n' -- ${paths.join(" ")} 2>/dev/null; true`);
|
|
222
|
+
for (const line of String(r.stdout ?? "").split("\n")) {
|
|
223
|
+
const m = /^(\d+) (\S+)$/.exec(line);
|
|
224
|
+
const client = m && PRIVATE_DIR.exec(m[2])?.[1];
|
|
225
|
+
if (m && client && asked.includes(client))
|
|
226
|
+
dirs.set(Number(m[1]), client);
|
|
227
|
+
}
|
|
228
|
+
return dirs;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Revoke the companions of `held` (`companionsOf`): only for clients the caller is already revoking because it fences or
|
|
232
|
+
* deletes a run, never as a sweep. From `all` or a fresh listing. Best effort, and hygiene only: a companion blocks no
|
|
233
|
+
* run, so one left behind (or a failed listing or `exec`) costs a listing entry. Returns the ones revoked.
|
|
234
|
+
*/
|
|
235
|
+
export async function revokeCompanions(control, held, all) {
|
|
236
|
+
if (held.length === 0)
|
|
237
|
+
return [];
|
|
238
|
+
const listing = all ?? (await control.listDelegations().catch(() => []));
|
|
239
|
+
const clients = [...new Set(held.map((d) => d.clientId))];
|
|
240
|
+
const listed = new Set(held.map((d) => `${d.clientId}/${d.inodeId}`));
|
|
241
|
+
if (!listing.some((d) => clients.includes(d.clientId) && !listed.has(`${d.clientId}/${d.inodeId}`)))
|
|
242
|
+
return [];
|
|
243
|
+
const dirs = await privateDirInodes(control, clients).catch(() => new Map());
|
|
244
|
+
const revoked = [];
|
|
245
|
+
for (const d of companionsOf(listing, held, dirs)) {
|
|
246
|
+
await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).then(() => revoked.push(d), () => { });
|
|
247
|
+
}
|
|
248
|
+
return revoked;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Revoke every delegation on the run through the control API, then its holders' private directories
|
|
252
|
+
* (`revokeCompanions`). The old holder's next fsync returns EIO. Returns the run's own delegations.
|
|
253
|
+
*/
|
|
199
254
|
export async function revoke(control, id) {
|
|
200
255
|
const path = runPath(id);
|
|
256
|
+
let all;
|
|
257
|
+
let held;
|
|
201
258
|
try {
|
|
202
|
-
|
|
259
|
+
all = await control.listDelegations();
|
|
260
|
+
held = await matchDelegations(all, id, pathlessResolver(control));
|
|
203
261
|
for (const d of held)
|
|
204
262
|
await control.revokeDelegation(d);
|
|
205
|
-
return held;
|
|
206
263
|
}
|
|
207
264
|
catch (err) {
|
|
208
265
|
throw new ClaimError("CONTROL_API_FAILED", `revoking ${path} failed`, { cause: err });
|
|
209
266
|
}
|
|
267
|
+
await revokeCompanions(control, held, all);
|
|
268
|
+
return held;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Revoke what `revoke` revokes, skipping every revoke that fails: for cleanup, where deleting the tree next checks what
|
|
272
|
+
* is left. Only the listing (and a pathless resolution) can fail it. Returns the run's own delegations.
|
|
273
|
+
*/
|
|
274
|
+
export async function revokeBestEffort(control, id) {
|
|
275
|
+
const all = await control.listDelegations();
|
|
276
|
+
const held = await matchDelegations(all, id, pathlessResolver(control));
|
|
277
|
+
for (const d of held)
|
|
278
|
+
await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).catch(() => { });
|
|
279
|
+
await revokeCompanions(control, held, all);
|
|
280
|
+
return held;
|
|
210
281
|
}
|
|
211
282
|
/** Revoke through the control API, then mount plainly; if the API path fails, mount with `--force`. */
|
|
212
283
|
export async function takeOver(control, opts) {
|
package/dist/cli.js
CHANGED
|
@@ -232,7 +232,7 @@ async function runInstance(values) {
|
|
|
232
232
|
log("app failed", { code: err.code, message: err.message });
|
|
233
233
|
return exitCodeFor(err);
|
|
234
234
|
}
|
|
235
|
-
const { onOpen, root: rootOptions, wake: appWake, ...harness } = app;
|
|
235
|
+
const { onOpen, beforeResume, root: rootOptions, wake: appWake, ...harness } = app;
|
|
236
236
|
const holder = JSON.parse(process.env.PDA_HOLDER ?? "{}");
|
|
237
237
|
const cgroup = watchdogOwnsCgroup(holder);
|
|
238
238
|
if (cgroup.warning)
|
|
@@ -261,6 +261,7 @@ async function runInstance(values) {
|
|
|
261
261
|
ownCgroup: cgroup.owns,
|
|
262
262
|
lease: Object.fromEntries(Object.entries(lease).filter(([, v]) => v !== undefined)),
|
|
263
263
|
onStep: (step, t) => void (steps[step] = Math.round(t)),
|
|
264
|
+
...(beforeResume ? { beforeResume } : {}),
|
|
264
265
|
});
|
|
265
266
|
}
|
|
266
267
|
catch (err) {
|