@parcha/pi-durable-disk 0.1.0-beta.13 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,102 @@
2
2
 
3
3
  ## Unreleased
4
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
+
5
101
  ## 0.1.0-beta.13 (2026-10-14)
6
102
 
7
103
  **pi-durable 1.1**
package/README.md CHANGED
@@ -19,6 +19,8 @@ machine. A Cloudflare Durable Object gives it both. This package gives it both o
19
19
  | You have | Start with | What you see |
20
20
  |---|---|---|
21
21
  | Linux and Node 22.19 or later, nothing else | [Quickstart on one machine](#quickstart-on-one-machine-no-disk-no-key) | A durable chat on a directory of this machine, killed with `SIGKILL` and finished by a second process. One machine and no fence: the directory is the claim |
22
+ | Linux and Node 22.19 or later, and an app module | `pi-durable-disk run --dir PATH --app MODULE` ([The CLI](#the-cli)) | Your app, or example 01's (`examples/01-durable-chat/app.ts`), as an instance on a directory of this machine: `kill -9` it and run the same command again, and it resumes. One machine and no fence |
23
+ | macOS and Node 22.19 or later, for development | The same [quickstart](#quickstart-on-one-machine-no-disk-no-key) and `run --dir` | The same runs, development grade: commands keep your rights, and the file tools' confinement holds against the agent's paths but not a command racing it ([On macOS](#on-macos)). Use Linux or Docker when the agent is not trusted |
22
24
  | Docker (macOS or Linux) and an Archil account | [Quickstart with Docker](#quickstart-with-docker) | A host killed in the middle of a paid charge, and the run resumed on another, every instance in a container |
23
25
  | A Linux host with FUSE and an Archil account | [Quickstart on a Linux host](#quickstart-on-a-linux-host-kill-a-host-watch-the-run-resume-on-another), then [Setting up a host for production](#setting-up-a-host-for-production) | The production shape: systemd units, the supervisor, and a lost host's run back in about a second |
24
26
  | An app or a host of your own | [The app](#the-app) and [Library](#library) | `openDurableRun`, `localRun`, the supervisor and host drivers, `serve`, parking and `fork` from your code |
@@ -60,7 +62,7 @@ and no key.
60
62
  - An Archil account, a disk, and an API key for the supervisor, all from the Archil console. Create the disk in the region
61
63
  nearest the host (any size; its id looks like `dsk-0123456789abcdef` and its region like `aws-us-east-1`). Hosts and
62
64
  instances never hold the API key.
63
- - `@earendil-works/pi-durable` and `@earendil-works/chord`, `>=1.0.4 <1.3.0`, as peer dependencies. The package is developed against 1.1.0, and CI runs its whole suite on 1.0.4 too, the lowest version the range accepts, and on 1.2.
65
+ - `@earendil-works/pi-durable` and `@earendil-works/chord`, `>=1.0.4 <1.4.0`, as peer dependencies. The package is developed against 1.1.0, and CI runs its whole suite on 1.0.4 too, the lowest version the range accepts, and on 1.2 and 1.3.
64
66
 
65
67
  Not covered yet: a Kubernetes host driver (the `HostDriver` interface is below) and running the loop across machines
66
68
  where FUSE is unavailable. Without Archil, a run can live on a plain directory of one machine
@@ -70,9 +72,11 @@ where FUSE is unavailable. Without Archil, a run can live on a plain directory o
70
72
  ## Install
71
73
 
72
74
  ```sh
73
- npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
75
+ npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable@~1.2.0 @earendil-works/pi-ai@~1.2.0 @earendil-works/chord@~1.2.0
74
76
  ```
75
77
 
78
+ The pi packages are pinned to 1.2: pi 1.3.0 pins its own packages to exact versions, and this release's ranges stop below 1.3 until the next one.
79
+
76
80
  From a checkout (this package is `packages/pi-durable-disk` of the repository; the root build compiles `dist/`):
77
81
 
78
82
  ```sh
@@ -82,17 +86,18 @@ cd packages/pi-durable-disk
82
86
 
83
87
  ## Quickstart on one machine: no disk, no key
84
88
 
85
- The fastest look, on Linux with Node 22.19 or later: a durable chat on a directory of this machine, with
86
- [`localRun`](#a-run-on-a-local-directory) instead of a disk and pi-ai's faux model instead of a key. One process answers the
87
- first messages and is killed (`SIGKILL`) after its third answer; a second opens the same run and answers the rest, and the
88
- transcript, read back from the store, has every message answered once. The directory is the claim, so this guards one
89
- machine only; the quickstarts below put the run on a disk that outlives the machine.
89
+ The fastest look, on Linux with Node 22.19 or later (or on macOS, [for development](#on-macos)): a durable chat on a
90
+ directory of this machine, with [`localRun`](#a-run-on-a-local-directory) instead of a disk and pi-ai's faux model
91
+ instead of a key. One process answers the first messages and is killed (`SIGKILL`) after its third answer; a second
92
+ opens the same run and answers the rest, and the transcript, read back from the store, has every message answered once.
93
+ The directory is the claim, so this guards one machine only; the quickstarts below put the run on a disk that outlives
94
+ the machine.
90
95
 
91
96
  In an empty directory, install the package and its peers, save this as `chat.mjs`
92
97
  (it is [examples/01-durable-chat/on-a-directory.mjs](examples/01-durable-chat/on-a-directory.mjs)):
93
98
 
94
99
  ```sh
95
- npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
100
+ npm install @parcha/pi-durable-disk@beta @earendil-works/pi-durable@~1.2.0 @earendil-works/pi-ai@~1.2.0 @earendil-works/chord@~1.2.0
96
101
  ```
97
102
 
98
103
  ```js
@@ -281,9 +286,11 @@ export default async function app(where: AppContext): Promise<AppOptions> {
281
286
  return {
282
287
  models,
283
288
  registry,
284
- async beforeResume({ harness, context, generation }) {
289
+ async beforeResume({ harness, context, generation, previous }) {
285
290
  // Awaited once per incarnation, after the Harness is open and before it resumes: no resumed task takes a step
286
291
  // until this settles, and what it admits commits before their steps do. Reconcile here what a resumed tool depends on.
292
+ // previous is run.json as the run was left (null when there was none): `sleeping` with its wakeAt after a park,
293
+ // `paused` with its detail after a planned stop or a fork, `running` with the dead holder and no new seal after a crash.
287
294
  },
288
295
  async onOpen(run) {
289
296
  // Called once per incarnation, after the run is open and resumed. run.generation is 1 on a first start and
@@ -314,8 +321,10 @@ Until the hook commits, the current state is the state at `head`, which is how a
314
321
 
315
322
  | Command | What it does |
316
323
  |---|---|
324
+ | `run --dir PATH --app MODULE [--id ID]` | The instance on a local directory (`localRun`): no disk, no mount token, no key. The same app contract and exit codes as on a disk (76 when another process holds the run, or when a process its last incarnation left will not die; 75 when fenced), and the same run flags but the Archil ones (`--disk`, `--region`, `--mount-root`, `--archil`, `--token-stdin` are refused beside it, exit 2). `--id` defaults to the directory's name |
317
325
  | `run --disk D --region R --id ID --app MODULE` | The instance a host driver starts: claim the run, open it, resume it. `--token-stdin` reads the mount token from stdin. Lease flags: `--heartbeat-ms`, `--lease-expiry-ms`, `--lease-margin-ms`. `--on-sigterm resume` (default) or `pause`. `--serve PORT`, `--park-threshold`, `--park-idle`, `--drain-timeout` (below). `--run-as USER` runs the agent's commands as USER when the instance runs as root (in a container) |
318
326
  | `supervise --disk D --region R (--id ID ... \| --all) [--every 30s] --app MODULE` | One decision per run, once or in a loop: nothing to do, start, or revoke and start; `--app` is the module each started instance runs. `--create` makes the run directory. `--check` proves this host's fence first. `--sweep-tokens` removes expired token users. `--host docker --image IMAGE` starts each instance in a container (`--app-root`, `--fleet`, `--name-prefix`, `--run-as`, `--docker-arg`, `--apparmor`). Prints one JSON line per decision |
327
+ | `status --dir PATH` | `run.json` read from the directory, and whether its holder is still the process that claimed it: `running` (this boot, its pid alive with the start ticks `run --dir` recorded), `stopped`, `gone` (another boot), or `unknown` when this machine's boot or processes cannot be read |
319
328
  | `status --disk D --region R --id ID` | `run.json` over the S3 API, the run's delegations and the holder's state |
320
329
  | `release --id ID` | Unmount the run's mount on this host (a dead mount is cleaned) |
321
330
  | `fork --id A --new-id B` | Copy a released, sealed run into a new run (see Serve, parking and fork) |
@@ -486,6 +495,12 @@ only the claim probe `.claim` is rewritten by the mount); `run.json`, `owner.loc
486
495
  that fails after that empties the directory through its own mount and removes it, and one that lost the directory to another
487
496
  fork or start leaves it alone.
488
497
 
498
+ On a local directory, `forkLocal(from, [{ id, root }, ...])` makes the same copy without a disk: one new run directory per
499
+ entry, from a directory of a run released and sealed (`SOURCE_NOT_RELEASED` for anything else, `TARGET_EXISTS` for a target
500
+ that is there). The source is only read; every copy is made beside its target under a temporary name and none is published until all are complete, so a failed
501
+ batch leaves no half copy. A target inside the source (also through a symlink) is `INVALID_ARGUMENT`. A local directory has no mount to take it exclusively, so nothing stops a process that opens the source while it
502
+ is being copied: fork a run after it is released, and open the forks with `localRun` like any run.
503
+
489
504
  ### Setting up a host for production
490
505
 
491
506
  The Quickstart runs the supervisor as your own user. On a production host the instance and the agent's tools run as an
@@ -528,6 +543,8 @@ mount token reaches the journal, and that nothing is left behind. It needs syste
528
543
  |---|---|---|
529
544
  | The instance crashes or is OOM-killed, host alive | The unit restarts it in place on the same claim; the unit's cgroup kills the previous incarnation's commands; reopen, `resume()` | running tasks go back to pending; effects in flight are `interrupted`, never rerun; at most one cut model request is sent again |
530
545
  | The host is lost (power-off, VM loss, pre-emption) | The client's sockets close and Archil flags the delegation orphaned in 0.2 s; the supervisor revokes (tens of ms) and starts an instance elsewhere | every acknowledged commit is there; unflushed workspace writes are lost; no effect whose intent was committed runs again |
546
+ | A Docker host is lost before its instance reaches running (the container killed or removed, or its daemon gone, between the start and the instance's first `run.json` write) | If the driver's start is still running, it fails typed (`START_FAILED`) and tries to remove the container it made (a removal that fails, as it does while the daemon is gone, is not retried); the supervisor's tick fails `HOST_START_FAILED`, counts no failed start, and the next tick starts again. If the start had returned, the supervisor's start mark (`runs/<id>/start.json`) keeps the run `starting` until the start grace passes (default the lease expiry, 90 s, doubled per consecutive failed start up to 10 min), so nothing is revoked or started twice; then it starts again, revoking first if the container had mounted (its FUSE daemon died with it, so its delegation is orphaned), and that start tries to remove the run's earlier dead containers. Removal is attempted, never guaranteed: once the daemon is back, look for leftovers with `docker ps -a --filter label=pda.run=<id>` | nothing is lost: an instance writes `run.json` at its generation before it opens the store, so it had committed nothing. The run has no instance for up to the grace; the time from the loss to the next instance's first commit is not measured on Docker |
547
+ | A Daytona host is lost before its instance reaches running (the box errors, is deleted or never starts while the driver starts it, or is lost after the launcher returned and before the instance's first `run.json` write) | While the driver starts it, a box that goes to an error state or away, does not start within the start timeout, or refuses an upload gets a delete request (attempted: a failed delete is not retried) and the start fails typed (`START_FAILED`); the supervisor counts no failed start and starts again on the next tick. Lost after the start returned: as on Docker, `starting` until the grace passes, then revoke if its client had mounted, and start a new box. A box deletes itself once stopped and Daytona destroys it at its TTL (default 25 h), so a box whose delete failed leaks nothing past that | as on Docker: nothing committed, no instance for up to the grace; not measured on Daytona |
531
548
  | The host hangs or is partitioned (a zombie) | It is not orphaned, so the lease expires at 90 s; the supervisor stops the host if its driver can, revokes, and starts elsewhere | the zombie's next commit fails (`SQLITE_IOERR_FSYNC`) and it exits 75; a call it already has in flight may complete, but it cannot start a new one, because a new call needs an intent commit |
532
549
  | A zombie that never commits again | Its lease watchdog (a worker thread, so a main thread stuck in a FUSE request does not matter) kills its commands at 75 s without touching the mount | an in-flight command runs at most until the self-fence |
533
550
  | The mount is taken away under a live instance (an operator's `umount -l`, a cleanup's `fusermount -u`) | The instance writes `run.json` and creates its directories through the directory it opened at acquire, and checks before each write that the run's path is still that mount; at the next heartbeat the path names the local directory under the mountpoint, so it exits 75 (`CLAIM_UNMOUNTED`) | nothing of its own is written to the local disk; as host lost |
@@ -550,18 +567,27 @@ about revocation, takeover and the lease's replacement do not apply. These do:
550
567
  | Failure | What happens | Effects |
551
568
  |---|---|---|
552
569
  | 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 |
570
+ | A process the killed incarnation's commands started does not die at the next open (an uninterruptible wait, or one the run's user may not signal) | The open is refused: `HeldError` `COMMANDS_ALIVE`, exit 76, with the message below; the records stay | none: the run does not resume beside it. End those processes and open the run again |
554
571
  | 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
572
  | A second process opens the run on this machine | The owner lock is refused (`HeldError`, exit 76) | none |
556
573
  | 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
574
 
575
+ The refusal names each process, as the open writes it (here one, in a run at `<root>`):
576
+
577
+ ```
578
+ 1 process(es) a killed incarnation's commands started are still alive after SIGKILL: pid 4242 (tar -xf /mnt/slow/archive.tar). The run does not resume beside them. End them (kill -9 4242, as their owner, or wait for them) and open the run again; they stay recorded in <root>/commands until then.
579
+ ```
580
+
558
581
  What a revoke cannot do: no fence reaches a third party. A paid API that already has a request has it. The package
559
582
  guarantees that this runtime does not start an effect twice; an effect your model decides to retry is a new call, so give a
560
583
  paid API an idempotency key from your own business key.
561
584
 
562
585
  Measured on a VM about 3 ms from `aws-us-east-1`, archil 0.8.42: acquire 222 ms p50, release 139 ms, takeover by revoke
563
586
  252 ms, commit 3.75 to 5.63 ms p50, a killed host's run back to its first commit in 1 to 1.5 s, a hung host replaced in 6
564
- to 6.3 s with a 6 s test lease (90 to 120 s with the defaults).
587
+ to 6.3 s with a 6 s test lease (90 to 120 s with the defaults). A start that never reaches running costs its start grace: live on Archil with a 6 s grace and a 3 s tick,
588
+ starts that all failed were retried six times in five minutes, each grace doubled (`test/live/supervise-slow.live.test.ts`),
589
+ and a takeover whose start was slowed 5 s before its first `run.json` write started once and was never revoked, 5 of 5
590
+ (`test/live/supervise.live.test.ts`). On Docker and Daytona hosts, a start lost before its instance runs is not measured.
565
591
 
566
592
  ## Security
567
593
 
@@ -577,20 +603,22 @@ to 6.3 s with a 6 s test lease (90 to 120 s with the defaults).
577
603
  - **The API key stays with the supervisor.** Hosts and instances never hold it, and the local driver never copies its own
578
604
  environment into a unit.
579
605
  - **No privilege for the agent.** The instance, pi's tools and the agent's commands run as an unprivileged user. Every
580
- command starts under `setpriv --no-new-privs`, so a command cannot use the run user's sudo rule (sudo refuses: "The no new
581
- privileges flag is set"). An app that turns this off (`archilEnv(claim, { noNewPrivs: false })`) or runs commands another
582
- way must confine them itself. Commands run next to the FUSE client on the host that holds the claim: if the agent is not
583
- trusted with the host, sandbox its commands (for example with bubblewrap) and keep the FUSE client outside the sandbox.
606
+ command starts under `setpriv --no-new-privs` (on Linux; macOS has no equivalent, see [On macOS](#on-macos)), so a
607
+ command cannot use the run user's sudo rule (sudo refuses: "The no new privileges flag is set"). An app that turns
608
+ this off (`archilEnv(claim, { noNewPrivs: false })`) or runs commands another way must confine them itself. Commands
609
+ run next to the FUSE client on the host that holds the claim: if the agent is not trusted with the host, sandbox its
610
+ commands (for example with bubblewrap) and keep the FUSE client outside the sandbox.
584
611
  - **The file tools stay inside `work/`.** pi's `read`, `write` and `edit` run in the loop's process, not in a command
585
- sandbox, so every file operation of the environment resolves its path to the file the kernel would reach (`..`, absolute
586
- paths, symlinks, dangling links, a missing tail) and refuses it unless it lies in `work/` (reads may also use `tmp/`, where
587
- the bash tool tells the model its long output went, and `readRoots` the app names; writes may also go inside a directory
588
- the environment's `createTempDir` returned, for the tool it was handed to). An agent cannot `write
612
+ sandbox, so every file operation of the environment resolves its path to the file the kernel would reach (`..`,
613
+ absolute paths, symlinks, dangling links, a missing tail) and refuses it unless it lies in `work/` (reads may also use
614
+ `tmp/`, where the bash tool tells the model its long output went, and `readRoots` the app names; writes may also go
615
+ inside a directory the environment's `createTempDir` returned, for the tool it was handed to). An agent cannot `write
589
616
  ../store/run.sqlite`, `../run.json` or `../owner.lock`: the call fails with a `PathOutsideWorkError` the model sees as
590
617
  a failed tool result. A path is opened one component at a time from a held handle of `work/`, without following links,
591
- so a link a command swaps in after the check is refused too. This covers the file tools only; a command still runs as
592
- the run user and can reach anything that user can, which is what an app's own command sandbox is for. `archilEnv(claim, {
593
- confineFiles: false })` turns it off where `/proc/self/fd` is unavailable.
618
+ so a link a command swaps in after the check is refused too (on Linux; on macOS the walk is by path, see [On
619
+ macOS](#on-macos)). This covers the file tools only; a command still runs as the run user and can reach anything that
620
+ user can, which is what an app's own command sandbox is for. `archilEnv(claim, { confineFiles: false })` turns it off
621
+ where `/proc/self/fd` is unavailable.
594
622
  - **`--serve` authenticates only with a bearer token.** On 127.0.0.1 (the default) the token is optional. A non-loopback
595
623
  `--serve-host` requires `--serve-token-file`, a file of mode 0600 holding the token (never argv or the environment); every
596
624
  request must send `authorization: Bearer <token>`, and it is compared in constant time. The file is readable by the run
@@ -638,19 +666,32 @@ then list the prefix and check it is empty.
638
666
 
639
667
  `openDurableRun(ref, options)` is what `run` calls: mount, verify the claim, take the owner lock, write `run.json`, open
640
668
  the store, check the seal, open and resume the Harness, keep the lease. `acquire`, `ensureRunning`, `localHost`,
641
- `archilEnv`, `openArchilStore` and the typed errors (`FencedError`, `HeldError`, `StoreBehindSealError`) are exported
642
- for apps that compose their own lifecycle, and so are serve (`serveRun`, `requestRun`), parking (`watchParking`, `drain`,
643
- `busyState`, and `leaseParkTarget` for a Harness a host opened over `openRunLease`) and `fork`. The `.d.ts` files in `dist/`
644
- are the reference.
669
+ `archilEnv` and the typed errors (`FencedError`, `HeldError`, `StoreBehindSealError`) are exported for apps that compose
670
+ their own lifecycle, and so are serve (`serveRun`, `requestRun`), parking (`watchParking`, `drain`, `busyState`, and
671
+ `leaseParkTarget` for a Harness a host opened over `openRunLease`) and `fork`. `src/index.ts` lists every export; the
672
+ `.d.ts` files in `dist/` are the reference.
673
+
674
+ A supervisor of your own (a driver `supervise` does not start, such as `daytonaHost`) gets its `control` from
675
+ `archilControl({ apiKey, disk, region })`: the disk's S3 and control API, the same object `supervise` builds from its flags.
676
+
677
+ ```ts
678
+ const control = await archilControl({ apiKey: process.env.ARCHIL_API_KEY!, disk: "dsk-...", region: "aws-us-east-1" });
679
+ await ensureRunning({ disk: "dsk-...", region: "aws-us-east-1", id: "job-1" }, daytonaHost({ client, snapshot }), { control });
680
+ ```
645
681
 
646
682
  A host that owns its own pi-durable Harness and store connections imports the narrow entry instead:
647
683
  `@parcha/pi-durable-disk/lease` holds `openRunLease` (the claim, the owner lock, `run.json` with its heartbeat, the lease
648
684
  self-fence, the seal and the release, without a store or a Harness), `storeHead`, the store's fence and pragma helpers
649
- (`Fence`, `FencedDatabase`, `assertPragmas`, `PROFILE_PRAGMAS`, `openArchilStore`), and the typed errors and exit codes. It
650
- leaves out the supervisor, the host drivers, the CLI and the app contract, and the package declares `"sideEffects": false`, so
651
- a bundler keeps only what you use: a bundle of the app with `await import("@parcha/pi-durable-disk/lease")` is about 60 KB
652
- minified against about 620 KB for `await import("@parcha/pi-durable-disk")` (esbuild `--bundle --minify --platform=node
653
- --format=esm`, which inlines pi-durable, chord and their dependencies).
685
+ (`Fence`, `FencedDatabase`, `assertPragmas`, `PROFILE_PRAGMAS`, `openArchilStore`), parking (`leaseParkTarget`,
686
+ `watchParking`, `drain`, `busyState`), and the typed errors and exit codes. It leaves out the supervisor, the host drivers,
687
+ the CLI and the app contract, and the package declares `"sideEffects": false`, so a bundler keeps only what you use: a
688
+ bundle of the app with `await import("@parcha/pi-durable-disk/lease")` is about 63 KB minified against about 620 KB
689
+ for `await import("@parcha/pi-durable-disk")` (esbuild `--bundle --minify --platform=node --format=esm`, which inlines
690
+ pi-durable, chord and their dependencies).
691
+
692
+ `@parcha/pi-durable-disk/internals` holds the claim, status and Daytona plumbing this repository's examples and demos use:
693
+ `runPath`, `unmountClaim`, `createRunDir`, the mount-token and delegation helpers, `isLeaseFresh`, and the Daytona labels,
694
+ names and `sweepSandboxes`. It carries no compatibility promise: a name there can change or go in any release.
654
695
 
655
696
  ### A run on a local directory
656
697
 
@@ -673,10 +714,36 @@ like a mount's, but syncs nothing: the store's commits are made durable by SQLit
673
714
  `work/` are the operating system's to flush, so a power loss can lose what it had not written yet. `mountToken` is
674
715
  required only with the default `acquire`; without either, the open is refused (`INVALID_ARGUMENT`) before anything mounts.
675
716
 
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.
717
+ #### On macOS
718
+
719
+ A local run works on macOS for development, and is weaker there than on Linux in four ways. Use Linux, or the
720
+ [Docker quickstart](#quickstart-with-docker), when the agent is not trusted.
721
+
722
+ - **Commands keep your rights: `no_new_privs` has no macOS equivalent.** On Linux every command starts under
723
+ `setpriv --no-new-privs`, so it cannot use `sudo` or gain privilege from a setuid program. macOS has no such flag and nothing
724
+ that does its job (`sandbox-exec` is deprecated), so `noNewPrivs` defaults to false there and no `setpriv` runs. That
725
+ leaves open: a command can use `sudo` wherever sudo asks it for no password (a `NOPASSWD` rule, for one), and setuid
726
+ programs keep their privilege. `runAs` is not available either (it needs `setpriv`). As on Linux, a command runs as
727
+ you and can reach anything you can.
728
+ - **The file tools' confinement is path-based.** On Linux it walks a path through `/proc/self/fd`, one held directory at
729
+ a time, so no component can be swapped once it was checked; macOS has no `/proc/self/fd`. There it resolves the path
730
+ (`realpath`) and refuses it unless it lies in `work/` or another root the file tools may use, as on Linux, opens each
731
+ component by its full path with `O_NOFOLLOW`, then checks that the path still resolves to itself and names the
732
+ directory it opened (same device and inode). It refuses `../store/run.sqlite`, absolute paths and links out of
733
+ `work/`, a link already in place included. That leaves open: a command that swaps a directory above the target for a
734
+ link between that check and the operation can win, and the file tool then acts where the link points.
735
+ - **The run's root is addressed by its path.** On Linux, `run.json` and the run's directories are written through the
736
+ directory opened at acquire. On macOS each write is checked first against that directory, and a root renamed and
737
+ replaced between the check and the write gets the write.
738
+ - **Commands are recorded from `ps`.** With no `/proc`, the instance reads one `ps` listing every 0.5 s while commands run,
739
+ `ps -E` for a command's mark and `sysctl kern.bootsessionuuid` for the boot. A command started within about 0.5 s
740
+ before a kill can be missed, and a process is told from a later one with the same pid by the second it started. The
741
+ lease watchdog reads the same listing.
742
+
743
+ `fsync` on macOS does not flush the drive's cache, so the store also sets SQLite's `fullfsync` and
744
+ `checkpoint_fullfsync` there (SQLite then syncs with `F_FULLFSYNC`), and refuses to open unless they read back. CI runs
745
+ the no-key quickstart and the local run's tests (its command records, `run --dir` and `status --dir`, the lease watchdog,
746
+ the file tools' confinement) on macOS.
680
747
 
681
748
  ## Tests
682
749
 
package/dist/claim.d.ts CHANGED
@@ -55,7 +55,7 @@ export interface ArchilHost {
55
55
  cli?: number;
56
56
  staleGrace?: number;
57
57
  };
58
- /** File access on the mount; replaceable so tests can inject a dead mount (ENOTCONN) or a fence (EIO). */
58
+ /** File access on the mount; replaceable so tests can inject a dead mount (ENOTCONN, ECONNABORTED) or a fence (EIO). */
59
59
  fs?: {
60
60
  stat?(path: string): Promise<unknown>;
61
61
  persist?(path: string, data: string): Promise<void>;
@@ -205,6 +205,11 @@ export declare function revoke(control: ControlApi, id: string): Promise<Delegat
205
205
  export declare function revokeBestEffort(control: Pick<ControlApi, "listDelegations" | "revokeDelegation" | "exec">, id: string): Promise<Delegation[]>;
206
206
  /** Revoke through the control API, then mount plainly; if the API path fails, mount with `--force`. */
207
207
  export declare function takeOver(control: ControlApi, opts: AcquireOptions): Promise<Claim>;
208
+ /**
209
+ * The errno codes of a stat on a FUSE mount whose daemon is gone: ENOTCONN once its connection is torn down, ECONNABORTED
210
+ * for a stat already in flight while it is torn down. Codes, never messages: a message is translated by the locale.
211
+ */
212
+ export declare const DEAD_CONNECTION: ReadonlySet<string>;
208
213
  /**
209
214
  * Unmount whatever the run has at `mountpoint`: `archil unmount` flushes and checks the delegation in; a dead mount
210
215
  * (which `archil unmount` refuses) is cleaned with `fusermount -u`; a live mount refused with ENOENT (Sysbox) is checked
package/dist/claim.js CHANGED
@@ -368,14 +368,24 @@ function isOurs(entry, ref) {
368
368
  const base = `${ref.disk}:/${runPath(ref.id)}`;
369
369
  return entry.fstype === "fuse.archil" && (entry.source === base || entry.source === `${base}[${ref.region}]`);
370
370
  }
371
- /** A FUSE mount whose daemon is gone answers stat with ENOTCONN; `archil` refuses it as "not running". */
371
+ /**
372
+ * The errno codes of a stat on a FUSE mount whose daemon is gone: ENOTCONN once its connection is torn down, ECONNABORTED
373
+ * for a stat already in flight while it is torn down. Codes, never messages: a message is translated by the locale.
374
+ */
375
+ export const DEAD_CONNECTION = new Set(["ENOTCONN", "ECONNABORTED"]);
376
+ /**
377
+ * Whether the FUSE daemon behind a mount is gone, which `archil` reports as "not running". Its connection is torn down,
378
+ * so a stat fails with ENOTCONN; a stat already in flight while the dying daemon's connection is torn down (right after a
379
+ * kill -9, while its threads exit) fails with ECONNABORTED instead. Both are the connection's own end: a live daemon,
380
+ * however slow, answers, and a stopped one keeps the stat waiting.
381
+ */
372
382
  async function isDead(host, mountpoint) {
373
383
  try {
374
384
  await host.stat(mountpoint);
375
385
  return false;
376
386
  }
377
387
  catch (err) {
378
- return err.code === "ENOTCONN";
388
+ return DEAD_CONNECTION.has(String(err.code));
379
389
  }
380
390
  }
381
391
  // Exit codes of the mount tools are not evidence (a busy mount can report success and stay); the mount table decides.
package/dist/cli.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { type Parking } from "./park.js";
3
3
  import { type RunServer } from "./serve.js";
4
+ import { type ArchilControl, type DiskClient } from "./supervise.js";
4
5
  import { type DurableRun } from "./run.js";
5
6
  /** "30s", "500ms", "2m", "1h", or plain milliseconds. */
6
7
  /**
@@ -28,6 +29,11 @@ export declare function runUser(spec: string, passwd?: string): {
28
29
  home: string;
29
30
  name: string;
30
31
  };
32
+ /**
33
+ * The disk's control API for the supervisor (`archilControl`), from the API key in `--api-key-env` (default
34
+ * ARCHIL_API_KEY) and `--disk` and `--region`. `client` replaces the `disk` client (tests).
35
+ */
36
+ export declare function cliControl(values: Record<string, unknown>, client?: DiskClient): Promise<ArchilControl>;
31
37
  /** What the instance does besides the app between its open and its exit. */
32
38
  export interface InstanceLife {
33
39
  /** How long a drain waits for running work before the release; 0 (default) releases at once. */