@parcha/pi-durable-disk 0.1.0-beta.15 → 0.1.0-beta.17

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,64 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.0-beta.17 (2026-10-18)
6
+
7
+ - **A claim directory's descriptor is closed by `close()` only, never by garbage collection (GRE-3853)**
8
+ - While a new run's seed waited on a promise nothing else held, the open's pending await chain was all that reached the
9
+ claim directory, so a collection could close its FileHandle. The start mark's keeper, which writes through
10
+ `/proc/self/fd/<n>` on a thread of its own, then failed silently, and the supervisor's grace ran out under a live seed.
11
+ A later open in the process could also be handed that number, and the keeper would have written the mark there.
12
+ - Every handle the package hands out as `/proc/self/fd/<n>` (the claim directory's `at`, a confined pin's paths, the walk
13
+ that makes them, the text line reader and the temp directory) is now held until it is closed.
14
+ - The keeper writes only while its directory is still the one it started on (same device and inode). If the directory
15
+ can't be reached or is another one, it stops. Its first failure is a process warning, code `PDA_START_MARK`, not
16
+ silence.
17
+
18
+ ## 0.1.0-beta.16 (2026-10-18)
19
+
20
+ - **A place's outcome carries the models the run's live parts chose (`PlaceOutcome.models`)**
21
+ - `PlaceOutcome.models?: Record<string, string>`: the role-to-model ids (`lead`, `eyes`, ...) the app reported, on any
22
+ outcome status, so a supervisor's manifest names the models of a remote run as of a local one.
23
+ - `archilPlace` reads it from `run.json`'s final status detail (`detail.models`); `localPlace` from the instance's
24
+ `{"outcome":...}` line, or from `run.json` without one.
25
+ - Both keep it only when it is a plain object whose values are all strings. A missing or malformed one is left out, never
26
+ replaced by `{}`. Before, `localPlace` passed the outcome line's `models` through unchecked, a malformed one included,
27
+ and `archilPlace` never carried it.
28
+ - **`archilPlace.recordHome`: a runner on a box records into the run's disk, and the place brings the recording home**
29
+ - `Place.recordHome` (`PlaceRecordHome`): `dir(attempt)` is where that attempt's runner records, and
30
+ `bringHome(into, { final })` brings what it recorded into the supervisor's recording directory. `archilPlace` has
31
+ it when its control can list (`RecordControl`, as `archilControl` does); `localPlace` has none, since its runner
32
+ writes the supervisor's directory itself.
33
+ - `dir(attempt)` is `<root>/record/<attempt>`, inside the run's directory. `bringHome` reads it over S3 (list and get,
34
+ never the run's claim). Blobs come first, hash-checked and written whole; then the new whole lines of each
35
+ `.spool/*.jsonl` segment, with an offset per file name kept as what `into` already holds, so it is idempotent across
36
+ calls and processes. Carriers that overlap on one destination append each line once: one call at a time per
37
+ destination in a place, and a lock file per spool file across places and processes (a gone holder's is taken over;
38
+ one held past 5 s is `CARRY_FAILED`). Every write is carried to its last byte.
39
+ - With `ensure.create`, each start first makes the EMPTY `signals/`, `record/<attempt>/.spool/` and
40
+ `record/<attempt>/blobs/` over the control, owned by the run user (mode 0755), while no delegation holds the run. A
41
+ directory made by path on an Archil mount was seen to refuse file creates. Every file in them stays the runner's; a
42
+ refused put fails the start with `DIRS_REFUSED`.
43
+ - Box receipts: `HostDriver.box(handle)` (`HostBox`) and `PlaceStatus.boxes`. `archilPlace.status()` lists a receipt
44
+ for each box it started, released ones included: provider, kind, `from`, `to` and, when it has a price, the published
45
+ list price per second (`usdPerSecond`), the page it was read from (`source`) and the date read (`priced`). An unpriced
46
+ box has none of the three, never 0, and no field holds the provider's box id. `daytonaHost` reports its sandboxes, priced at `DAYTONA_LIST_PRICES`
47
+ (https://www.daytona.io/pricing, read 2026-10-16), with the "first 5 GiB free" applied to disk (the footnote's
48
+ placement is ambiguous). A box of unknown size has no price, nor does a box with a GPU, which the page prices per
49
+ model (`daytonaUsdPerSecond`). `localPlace` reports none.
50
+ - Failures carry codes (`PlaceError`). `BLOB_MISMATCH` is thrown after everything else came home. `CARRY_FAILED` is
51
+ thrown for a list or get that fails, and on `final` for a line whose blob never landed. `BOX_GONE` is never thrown
52
+ by this place: the recording lives on the disk, which outlives the box.
53
+ - **A seed of any length stays in the supervisor's start grace**
54
+ - While a new run seeds it has no `run.json` heartbeat, and `ensureRunning` keeps a start in its grace only while the
55
+ start mark (`runs/<id>/start.json`) is younger than the grace (90 s by default). A longer seed was taken as a failed
56
+ start: revoked, then seeded again.
57
+ - Now, while the seed runs, the instance rewrites the mark's `at` every heartbeat through its claim (fsync and rename).
58
+ It does so only when a supervisor's mark for generation 1 is there, keeping its generation, `by`, `failures` and
59
+ `lastExit`. The rewrite runs on a thread of its own, one at a time, so a seed that blocks its thread (a synchronous
60
+ import) stays covered. It stops when the seed ends, before the open goes on, and `run.json`'s heartbeat takes over.
61
+ - A seeder that dies stops rewriting, and the grace runs out as for any start. The mark's format does not change.
62
+
5
63
  ## 0.1.0-beta.15 (2026-10-16)
6
64
 
7
65
  - **`HostDriver.kill`: take an instance away as a crash would, and `kills` says what that takes**
package/README.md CHANGED
@@ -422,7 +422,13 @@ except after exit 65, 70, 75 or 76. `localHost({ mode: "child" })` runs a detach
422
422
  without that guarantee.
423
423
 
424
424
  `daytonaHost({ client, snapshot, ... })` runs each instance in a Daytona sandbox of its own (library only: `supervise`
425
- starts local units, so pass the driver to `ensureRunning`). The box comes from a snapshot that holds Node, the archil client
425
+ starts local units, so pass the driver to `ensureRunning`). Its `box(handle)` is that sandbox's receipt: its size
426
+ (vCPUs, memory, disk), when it was made and when the driver deleted it, and its list price per second at
427
+ `DAYTONA_LIST_PRICES`. Those are Daytona's published rates, with the page and the date they were read
428
+ (https://www.daytona.io/pricing, 2026-10-16): $0.0504 per vCPU-hour, $0.0162 per GiB-hour of memory and $0.000108 per
429
+ GiB-hour of disk past the first 5 GiB, billed per second. The page's "first 5 free" footnote is applied to disk only (its
430
+ placement is ambiguous). A box of unknown size gets no price, and neither does a box with a GPU: the page prices GPUs per
431
+ model, which the driver doesn't map (`daytonaUsdPerSecond`). The box comes from a snapshot that holds Node, the archil client
426
432
  (`libfuse2`), this package, the run user and the sudoers drop-in, or from a default image that the caller's `prepare` installs
427
433
  them into. A container sandbox has no systemd (PID 1 is the Daytona daemon), so an in-box launcher keeps the instance
428
434
  alive in place and never restarts it after exit 0, 65, 70, 75 or 76. Every incarnation of an attempt gets the attempt's
@@ -757,10 +763,15 @@ await openDurableRun(ref, {
757
763
  - **When it fails.** A seed that throws fails the open with its own error. A process killed while it seeds leaves the run
758
764
  unpublished in the same way: no `run.json`, the claim let go. The next open seeds again, so a seed overwrites what an
759
765
  earlier attempt wrote.
760
- - **How long it may take.** Under a supervisor, a seed must end within the start grace (`--start-grace`,
761
- `startGraceMs`; default the lease expiry, 90 s). Past it, `ensureRunning` takes the start as failed and revokes the
762
- claim. The seed's next write or its barrier is then refused, the instance is fenced (exit 75) with nothing published,
763
- and the next start seeds again. A deployment whose seeds can take longer raises the start grace.
766
+ - **How long it may take.** A new run has no `run.json` heartbeat while it seeds. A supervisor keeps a start in its grace
767
+ (`--start-grace`, `startGraceMs`; default the lease expiry, 90 s) only while its start mark (`start.json`) is younger
768
+ than the grace. So while the seed runs, the instance rewrites the mark's time every heartbeat, through its claim, when a
769
+ supervisor's mark for generation 1 is there. It does so on a thread of its own, so a seed that holds its thread (a
770
+ synchronous import through `DatabaseSync`) is covered too. A live seed of any length stays `starting`. If the mark
771
+ can't be rewritten, the instance says so once, as a process warning with code `PDA_START_MARK`. A seeder that dies stops
772
+ rewriting, the grace runs out as for any start, and the claim is revoked. If the claim is revoked while the seed still
773
+ runs, its next write or its barrier is refused, the instance is fenced (exit 75) with nothing published, and the next
774
+ start seeds again.
764
775
  - **On a local directory** (`localRun`) it is the same. As with `work/`, the files it writes are the operating system's
765
776
  to flush; a SQLite database it commits with `synchronous = FULL` is durable when the commit returns.
766
777
 
@@ -887,7 +898,35 @@ id and directory as the supervisor computed them.
887
898
  - A recovery is the takeover: the killed holder's delegations are revoked whatever its lease says, and the next
888
899
  instance starts. It reports `revoked` and `ms`. A newer incarnation than the one the place killed (another
889
900
  supervisor's) gets the usual lease and start-grace checks instead, so a live one is `HELD`, not revoked.
890
- - `status()` reads `run.json` over S3 and lists the run's delegations.
901
+ - `status()` reads `run.json` over S3 and lists the run's delegations, plus, when its host driver makes boxes
902
+ (`daytonaHost`), a receipt for each box the place started, released ones included (`boxes`, `HostBox`). A receipt
903
+ has the provider, the kind, `from` and `to` (null while it lives). It also has the published list price per second
904
+ (`usdPerSecond`), the page that price was read from (`source`) and the ISO date it was read (`priced`). A box with
905
+ no price has none of those three, never a price of 0. No field holds the provider's id of the box.
906
+ - `recordHome` (when the control can list, as `archilControl`'s can): an attempt's runner records into
907
+ `recordHome.dir(attempt)`, `<root>/record/<attempt>`, in the run's directory, so its delegation covers it and
908
+ `deleteRunTree` removes it. `bringHome(into, { final })` reads that directory through the control (S3 list and get:
909
+ nothing takes the run's claim) and brings the recording into the supervisor's `into`.
910
+ - Blobs come first, each checked against the hash its name carries and written whole (a temporary file, fsync,
911
+ rename). One that doesn't match is skipped; everything else still comes home, then the call throws
912
+ `BLOB_MISMATCH`.
913
+ - Then the new whole lines of each `.spool/*.jsonl` segment, with an offset per file name: what `into` already
914
+ holds of it. So a second call, or a call from another process, brings nothing twice. A line waits while a blob it
915
+ names isn't home yet. On `final` it comes home anyway, and the call throws `CARRY_FAILED` naming the blob.
916
+ - Carriers that overlap on one `into` never append at once. The place runs one call at a time per destination, and
917
+ each append happens under a lock file beside its spool file (`.<name>.lock`, naming its holder's pid), after the
918
+ destination's size is read again, so only the lines past it are appended. A lock whose holder is gone is taken
919
+ over; one held past 5 s is `CARRY_FAILED`. A second `into` gets every line: what the place fetched for one
920
+ destination says nothing of another.
921
+ - A list or get that fails is `CARRY_FAILED`.
922
+ - The recording is on the disk, not in the box, so a final call after a box kill still brings home everything the
923
+ box fsynced: this place never throws `BOX_GONE`.
924
+ - With `ensure.create` (the run user's uid and gid), the place makes the EMPTY directories the runner writes into
925
+ at each start: `signals/`, `record/<attempt>/.spool/` and `record/<attempt>/blobs/`. It makes them over the
926
+ control, owned by the run user, mode 0755, in ensureRunning's window where no delegation holds the run. A
927
+ directory a process made by path on an Archil mount was seen to refuse file creates afterwards (ENOENT), and one
928
+ made over the control didn't. Every file in them stays the runner's. A refused put fails the start
929
+ (`DIRS_REFUSED`, as the cause of `HOST_START_FAILED`), and without an owner nothing is made.
891
930
  - `requestFile(attempt)` is `<root>/signals/kill-<attempt>.json`, in the run's directory and outside `work/`, and
892
931
  `signals(attempt)` reads `runs/<id>/signals/kill-<attempt>.json` over S3 every 500 ms (`signalPollMs`). A runner on
893
932
  a box of its own can't reach the supervisor's files, so its request travels through the disk. The runner must write
@@ -912,6 +951,11 @@ when the run is `done` and that detail carries a record, `failed` for anything e
912
951
  run its instance left `running`, `paused` or `sleeping`). So an app that runs on a box and wants its supervisor to have
913
952
  its record puts the record there, in its final `setStatus("done", { record })`.
914
953
 
954
+ Either way the outcome may carry `models`, the role-to-model ids the run's live parts chose (`lead`, `eyes`, ...), on any
955
+ status, a failed run's too: from the outcome line's `models`, or from the final detail's (`setStatus("done", { record,
956
+ models })`). Both places keep it only when it is a plain object whose values are all strings; a missing or malformed one
957
+ is left out, never replaced by `{}`.
958
+
915
959
  ## Tests
916
960
 
917
961
  ```sh
package/dist/confine.js CHANGED
@@ -20,6 +20,7 @@ import { constants, existsSync, realpathSync } from "node:fs";
20
20
  import { lstat, mkdir, open, readlink, realpath, stat } from "node:fs/promises";
21
21
  import { basename, dirname, join } from "node:path";
22
22
  import { err, FileError, ok } from "@earendil-works/pi-durable/env";
23
+ import { fdPath, hold } from "./held-fd.js";
23
24
  /**
24
25
  * A file operation named a path that is not under the workspace (`outside_work`), or one whose components changed
25
26
  * while it was being resolved (`changed`). pi turns it into a failed tool result that names the path. Its `code` is pi's
@@ -153,7 +154,7 @@ export class Pin {
153
154
  this.name = name;
154
155
  this.#byPath = held.byPath;
155
156
  this.#names = held.names ?? [];
156
- this.dir = held.byPath ? held.path : `/proc/self/fd/${handle.fd}`;
157
+ this.dir = held.byPath ? (hold(handle), held.path) : fdPath(handle);
157
158
  this.entry = dirMode ? this.dir : name === "" ? `${this.dir}/.` : `${this.dir}/${name}`;
158
159
  this.#real = dirMode || name === "" ? canonical : dirname(canonical);
159
160
  }
@@ -354,14 +355,14 @@ async function walk(root, names, create, canonical, byPath = false) {
354
355
  }
355
356
  try {
356
357
  // The root was opened by name: it must be the directory that name denotes now, not one swapped in on the way.
357
- const actual = await readlink(`/proc/self/fd/${dir.fd}`);
358
+ const actual = await readlink(fdPath(dir));
358
359
  if (actual !== root)
359
360
  throw changed(canonical);
360
361
  let real = root;
361
362
  for (const name of names) {
362
363
  if (name === "" || name === "." || name === ".." || name.includes("\0"))
363
364
  throw changed(canonical);
364
- const step = `/proc/self/fd/${dir.fd}/${name}`;
365
+ const step = `${fdPath(dir)}/${name}`;
365
366
  real = join(real, name);
366
367
  let next;
367
368
  try {
package/dist/env.js CHANGED
@@ -14,6 +14,7 @@ import { CodingTools } from "@earendil-works/pi-durable/tools";
14
14
  import { COMMANDS_MARK, CommandRecorder } from "./commands.js";
15
15
  import { canonicalizeSync, confineHooks, Confinement, fileError, inside, namesHeld, PathOutsideWorkError } from "./confine.js";
16
16
  import { PdaError } from "./errors.js";
17
+ import { fdPath } from "./held-fd.js";
17
18
  /**
18
19
  * What `exec` returns once the factory's `cleanup()` ran: no command was started. Its `code` is pi's `spawn_error`
19
20
  * (the command could not start), `reason` tells it apart from a program that failed to spawn.
@@ -153,7 +154,7 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
153
154
  // pi opens the file again through the handle's own `/proc/self/fd` entry, which names that inode and no path; by path
154
155
  // (macOS), through the path just checked.
155
156
  const byPath = this.#scope.confinement.byPath;
156
- return this.#file(path, context, { access: "read", flags: constants.O_RDONLY }, (handle, pin) => super.openTextLineReader(byPath ? pin.entry : `/proc/self/fd/${handle.fd}`, context));
157
+ return this.#file(path, context, { access: "read", flags: constants.O_RDONLY }, (handle, pin) => super.openTextLineReader(byPath ? pin.entry : fdPath(handle), context));
157
158
  }
158
159
  async openBinaryReader(path, options, context) {
159
160
  if (this.#scope.confinement === undefined)
@@ -314,7 +315,7 @@ class ArchilExecutionEnv extends NodeExecutionEnv {
314
315
  // By path (macOS): made under tmp/'s path once that path is checked to still name the directory held.
315
316
  if (confinement.byPath && !(await namesHeld(tempRoot, held)))
316
317
  throw new PathOutsideWorkError(tempRoot, "changed", `${tempRoot}: changed while it was being resolved`);
317
- name = basename(await mkdtemp(`${confinement.byPath ? tempRoot : `/proc/self/fd/${held.fd}`}/${prefix ?? "tmp-"}`));
318
+ name = basename(await mkdtemp(`${confinement.byPath ? tempRoot : fdPath(held)}/${prefix ?? "tmp-"}`));
318
319
  }
319
320
  finally {
320
321
  await held.close();
@@ -0,0 +1,5 @@
1
+ import type { FileHandle } from "node:fs/promises";
2
+ /** Keeps `handle` reachable until it is closed. A handle held and never closed stays open until the process exits. */
3
+ export declare function hold(handle: FileHandle): FileHandle;
4
+ /** `/proc/self/fd/<n>` for `handle`, held until it is closed (`hold`). */
5
+ export declare function fdPath(handle: FileHandle): string;
@@ -0,0 +1,14 @@
1
+ const HELD = new Set();
2
+ /** Keeps `handle` reachable until it is closed. A handle held and never closed stays open until the process exits. */
3
+ export function hold(handle) {
4
+ if (!HELD.has(handle)) {
5
+ HELD.add(handle);
6
+ // A FileHandle is an EventEmitter that emits `close` once closed (Node's typings leave that out).
7
+ handle.once("close", () => HELD.delete(handle));
8
+ }
9
+ return handle;
10
+ }
11
+ /** `/proc/self/fd/<n>` for `handle`, held until it is closed (`hold`). */
12
+ export function fdPath(handle) {
13
+ return `/proc/self/fd/${hold(handle).fd}`;
14
+ }
@@ -7,12 +7,37 @@ export type SandboxInfo = {
7
7
  id: string;
8
8
  name: string;
9
9
  state?: string;
10
+ /** The box's size as Daytona allocates it: vCPUs, GPUs, memory and disk (the API's GB). */
11
+ cpu?: number;
12
+ gpu?: number;
13
+ memory?: number;
14
+ disk?: number;
15
+ createdAt?: string;
10
16
  snapshot?: string;
11
17
  labels?: Record<string, string>;
12
18
  target?: string;
13
19
  toolboxProxyUrl?: string;
14
20
  errorReason?: string;
15
21
  };
22
+ /**
23
+ * Daytona's published list prices, as its pricing page gave them on `readOn`: billed per second, quoted per hour, for
24
+ * each vCPU, each GiB of memory and each GiB of disk. The page's footnote "Price per GiB after first 5 free" is applied
25
+ * to storage (disk) only: its placement is ambiguous and could be read as covering memory too, so this is a choice.
26
+ * The page lists GPU rates per GPU model; this driver does not map a box's GPU to a model, so a GPU box is unpriced.
27
+ */
28
+ export declare const DAYTONA_LIST_PRICES: {
29
+ readonly page: "https://www.daytona.io/pricing";
30
+ readonly readOn: "2026-10-16";
31
+ readonly vcpuPerHour: 0.0504;
32
+ readonly memoryGiBPerHour: 0.0162;
33
+ readonly diskGiBPerHour: 0.000108;
34
+ readonly freeDiskGiB: 5;
35
+ };
36
+ /**
37
+ * A box's list price per second at DAYTONA_LIST_PRICES, from its size; null when its size is unknown or it has a GPU
38
+ * (priced per GPU model on the page, which this driver does not map).
39
+ */
40
+ export declare function daytonaUsdPerSecond(size: Pick<SandboxInfo, "cpu" | "gpu" | "memory" | "disk">): number | null;
16
41
  export type CreateSandboxBody = {
17
42
  name: string;
18
43
  snapshot: string;
@@ -21,6 +21,32 @@ import { TERMINAL_EXITS } from "./local-host.js";
21
21
  export const DAYTONA_API_URL = "https://app.daytona.io/api";
22
22
  export const LABEL_FLEET = "pda-fleet";
23
23
  export const LABEL_RUN = "pda-run";
24
+ /**
25
+ * Daytona's published list prices, as its pricing page gave them on `readOn`: billed per second, quoted per hour, for
26
+ * each vCPU, each GiB of memory and each GiB of disk. The page's footnote "Price per GiB after first 5 free" is applied
27
+ * to storage (disk) only: its placement is ambiguous and could be read as covering memory too, so this is a choice.
28
+ * The page lists GPU rates per GPU model; this driver does not map a box's GPU to a model, so a GPU box is unpriced.
29
+ */
30
+ export const DAYTONA_LIST_PRICES = {
31
+ page: "https://www.daytona.io/pricing",
32
+ readOn: "2026-10-16",
33
+ vcpuPerHour: 0.0504,
34
+ memoryGiBPerHour: 0.0162,
35
+ diskGiBPerHour: 0.000108,
36
+ freeDiskGiB: 5,
37
+ };
38
+ /**
39
+ * A box's list price per second at DAYTONA_LIST_PRICES, from its size; null when its size is unknown or it has a GPU
40
+ * (priced per GPU model on the page, which this driver does not map).
41
+ */
42
+ export function daytonaUsdPerSecond(size) {
43
+ const { cpu, memory, disk, gpu } = size;
44
+ const count = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
45
+ if (!count(cpu) || !count(memory) || !count(disk) || (gpu !== undefined && gpu !== 0))
46
+ return null;
47
+ const p = DAYTONA_LIST_PRICES;
48
+ return (cpu * p.vcpuPerHour + memory * p.memoryGiBPerHour + Math.max(0, disk - p.freeDiskGiB) * p.diskGiBPerHour) / 3600;
49
+ }
24
50
  export class DaytonaApiError extends PdaError {
25
51
  /** The HTTP status, null when no response arrived (network failure or timeout). */
26
52
  status;
@@ -256,6 +282,13 @@ export function daytonaHost(opts) {
256
282
  throw new DaytonaHostError("START_FAILED", `${what} in ${box.name} exited ${r.exitCode}: ${r.result.trim().split("\n").at(-1)?.slice(0, 300) ?? ""}`);
257
283
  return r;
258
284
  }
285
+ /** Each box this driver made, for its receipt: what Daytona said of it, when it was made, and when it ended. */
286
+ const made = new Map();
287
+ const ended = (id) => {
288
+ const m = made.get(id);
289
+ if (m && m.to === null)
290
+ m.to = new Date(now()).toISOString();
291
+ };
259
292
  return {
260
293
  fleet,
261
294
  mountRoot,
@@ -279,8 +312,10 @@ export function daytonaHost(opts) {
279
312
  ...(opts.network?.allowList ? { networkAllowList: opts.network.allowList } : {}),
280
313
  };
281
314
  let box = await createOrAdopt(body, ref.id);
315
+ made.set(box.id, { info: box, from: box.createdAt ?? new Date(t0).toISOString(), to: null });
282
316
  try {
283
317
  box = await waitStarted(box, t0 + startTimeoutMs);
318
+ made.get(box.id).info = { ...made.get(box.id).info, ...box };
284
319
  const prepared = now();
285
320
  if (opts.prepare)
286
321
  await opts.prepare(box, client);
@@ -308,7 +343,9 @@ export function daytonaHost(opts) {
308
343
  return handle;
309
344
  }
310
345
  catch (err) {
346
+ // The box was made and is deleted now: its receipt ends here, deleted or not (a delete that failed is the sweep's).
311
347
  await client.remove(box.id).catch(() => { });
348
+ ended(box.id);
312
349
  if (err instanceof DaytonaHostError)
313
350
  throw err;
314
351
  throw new DaytonaHostError("START_FAILED", `starting an instance in ${name} failed: ${err.message}`, { cause: err });
@@ -324,8 +361,10 @@ export function daytonaHost(opts) {
324
361
  catch (err) {
325
362
  throw new DaytonaHostError("DAYTONA_API_FAILED", `reading sandbox ${h.name}: ${err.message}`, { cause: err });
326
363
  }
327
- if (!box)
364
+ if (!box) {
365
+ ended(h.sandboxId);
328
366
  return "gone";
367
+ }
329
368
  // The label, not the id in a handle, decides whose box this is: a handle can never reach another fleet's box.
330
369
  if (box.labels?.[LABEL_FLEET] !== fleet)
331
370
  return "unknown";
@@ -366,6 +405,7 @@ export function daytonaHost(opts) {
366
405
  throw new DaytonaHostError("STOP_FAILED", `deleting sandbox ${h.name} failed: ${err.message}`, { cause: first });
367
406
  }
368
407
  }
408
+ ended(id);
369
409
  },
370
410
  async kill(h) {
371
411
  if (!mine(h))
@@ -394,6 +434,24 @@ export function daytonaHost(opts) {
394
434
  throw new DaytonaHostError("KILL_FAILED", `deleting sandbox ${h.name} failed: ${err.message}`, { cause: first });
395
435
  }
396
436
  }
437
+ ended(id);
438
+ },
439
+ box(h) {
440
+ if (!mine(h))
441
+ return null;
442
+ const m = made.get(h.sandboxId);
443
+ if (!m)
444
+ return null;
445
+ const { cpu, gpu, memory, disk } = m.info;
446
+ const usdPerSecond = daytonaUsdPerSecond(m.info);
447
+ const size = [cpu, memory, disk].every((n) => typeof n === "number") ? ` ${cpu} vCPU, ${memory} GiB memory, ${disk} GiB disk${gpu ? `, ${gpu} GPU` : ""}` : "";
448
+ return {
449
+ provider: "daytona",
450
+ kind: `sandbox${size}`,
451
+ from: m.from,
452
+ to: m.to,
453
+ ...(usdPerSecond === null ? {} : { usdPerSecond, source: DAYTONA_LIST_PRICES.page, priced: DAYTONA_LIST_PRICES.readOn }),
454
+ };
397
455
  },
398
456
  };
399
457
  }
package/dist/index.d.ts CHANGED
@@ -18,17 +18,17 @@ export type { ArchilStore, Fence, FencedDatabase, Profile, StoreFencedError } fr
18
18
  export { AppError } from "./app.js";
19
19
  export type { AppContext, AppModule, AppOptions } from "./app.js";
20
20
  export { archilControl, deleteRunTree, ensureRunning, readRunStatus, SuperviseError } from "./supervise.js";
21
- export type { ArchilControl, CheckControl, CheckOptions, DiskClient, EnsureOptions, EnsureResult, HostDriver, HostHandle, HostStatus, Json, StartAttempt, StartReason, StonithOutcome, SuperviseErrorCode, SupervisorControl, TokenUser } from "./supervise.js";
21
+ export type { ArchilControl, CheckControl, CheckOptions, DiskClient, EnsureOptions, EnsureResult, HostBox, HostDriver, HostHandle, HostStatus, Json, StartAttempt, StartReason, StonithOutcome, SuperviseErrorCode, SupervisorControl, TokenUser } from "./supervise.js";
22
22
  export { localHost, LocalHostError } from "./hosts/local-host.js";
23
23
  export type { LocalHostOptions, Ran, Runner } from "./hosts/local-host.js";
24
24
  export { dockerHost, DockerHostError } from "./hosts/docker.js";
25
25
  export type { ContainerInfo, DockerHostOptions, DockerRan, DockerRunner } from "./hosts/docker.js";
26
- export { DaytonaApiError, daytonaHost, DaytonaHostError, daytonaRest } from "./hosts/daytona.js";
26
+ export { DAYTONA_LIST_PRICES, DaytonaApiError, daytonaHost, DaytonaHostError, daytonaRest, daytonaUsdPerSecond } from "./hosts/daytona.js";
27
27
  export type { CreateSandboxBody, DaytonaClient, DaytonaHostErrorCode, DaytonaHostOptions, DaytonaRestOptions, ExecResult, SandboxInfo } from "./hosts/daytona.js";
28
28
  export { requestRun, ServeConfigError, ServeError, serveRun } from "./serve.js";
29
29
  export type { RequestOptions, RunResponse, RunServer, ServeConfigErrorCode, ServeErrorCode, ServeOptions } from "./serve.js";
30
30
  export { archilPlace, localPlace, PlaceError } from "./place.js";
31
- export type { ArchilPlaceOptions, LocalPlaceOptions, Place, PlaceContext, PlaceErrorCode, PlaceExit, PlaceKill, PlaceOutcome, PlacePaths, PlaceRecovery, PlaceRunner, PlaceSignal, PlaceStatus, RunnerOrder } from "./place.js";
31
+ export type { ArchilPlaceOptions, LocalPlaceOptions, Place, PlaceContext, PlaceErrorCode, PlaceExit, PlaceKill, PlaceOutcome, PlacePaths, PlaceRecordHome, PlaceRecovery, PlaceRunner, PlaceSignal, PlaceStatus, RecordControl, RunnerOrder } from "./place.js";
32
32
  export { busyState, drain, leaseParkTarget, watchParking } from "./park.js";
33
33
  export type { BusyState, DrainOptions, Parking, ParkOptions, ParkResult, ParkTarget } from "./park.js";
34
34
  export { fork, ForkError, forkLocal } from "./fork.js";
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ export { AppError } from "./app.js";
17
17
  export { archilControl, deleteRunTree, ensureRunning, readRunStatus, SuperviseError } from "./supervise.js";
18
18
  export { localHost, LocalHostError } from "./hosts/local-host.js";
19
19
  export { dockerHost, DockerHostError } from "./hosts/docker.js";
20
- export { DaytonaApiError, daytonaHost, DaytonaHostError, daytonaRest } from "./hosts/daytona.js";
20
+ export { DAYTONA_LIST_PRICES, DaytonaApiError, daytonaHost, DaytonaHostError, daytonaRest, daytonaUsdPerSecond } from "./hosts/daytona.js";
21
21
  // Serve: one HTTP request runs one turn.
22
22
  export { requestRun, ServeConfigError, ServeError, serveRun } from "./serve.js";
23
23
  // Places: where a run lives for a supervisor that kills it and brings it back (agentrun's reference place seam).
package/dist/place.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Delegation } from "disk";
2
2
  import { PdaError } from "./errors.js";
3
3
  import { type RunRecord } from "./status.js";
4
- import { type EnsureOptions, type HostDriver, type SupervisorControl } from "./supervise.js";
4
+ import { type EnsureOptions, type HostBox, type HostDriver, type SupervisorControl } from "./supervise.js";
5
5
  export type PlaceKill = "process" | "box";
6
6
  /** The run's paths in the place: its id, its directory, and the `work` and `store` under it. */
7
7
  export type PlacePaths = {
@@ -12,7 +12,8 @@ export type PlacePaths = {
12
12
  };
13
13
  /**
14
14
  * How the run stood when a runner ended: the instance's own report, or `run.json` as it left it. `status` is the run's
15
- * word: `complete` when it delivered, else `failed`.
15
+ * word: `complete` when it delivered, else `failed`. `models`, on any status: the role-to-model ids the run's live parts
16
+ * chose (`lead`, `eyes`, ...), when the app reported a plain object of strings; absent otherwise, never `{}` in its place.
16
17
  */
17
18
  export type PlaceOutcome = {
18
19
  status: string;
@@ -21,6 +22,7 @@ export type PlaceOutcome = {
21
22
  message?: string;
22
23
  record?: unknown;
23
24
  storeSha256?: string;
25
+ models?: Record<string, string>;
24
26
  };
25
27
  /** How a runner ended. `exit`: the exit code or signal (local), or the host's status (`stopped`, `failed`, `gone`) or `SIGKILL` when the place killed it (archil). */
26
28
  export type PlaceExit = {
@@ -42,9 +44,15 @@ export type RunnerOrder = {
42
44
  stdin?: string;
43
45
  };
44
46
  /** What the place reads of the run, the way it reads it: `run.json`, and on a disk the run's delegations. */
47
+ /**
48
+ * What the place reads of the run, the way it reads it: `run.json`; on a disk, the run's delegations; and the boxes the
49
+ * place made for its runners, each as a receipt (`HostBox`: provider, kind, from, to, list price per second and where
50
+ * that price was read), when its host driver makes boxes. A box the place released stays listed with its end.
51
+ */
45
52
  export type PlaceStatus = {
46
53
  record: RunRecord | null;
47
54
  delegations?: Pick<Delegation, "clientId" | "inodeId" | "path" | "isOrphaned" | "isPending">[];
55
+ boxes?: HostBox[];
48
56
  };
49
57
  /** What a recovery did. A takeover on a disk lists what it revoked, and `ms` is the whole of it (revoke and start). */
50
58
  export type PlaceRecovery = {
@@ -91,6 +99,23 @@ export type PlaceContext = {
91
99
  destroyBoxes?: () => void | Promise<void>;
92
100
  requestFile?: (attempt: number) => string;
93
101
  };
102
+ /**
103
+ * Where an attempt's runner records (its RECORD_DIR, in the place) and how the place brings that recording home. For a
104
+ * place whose runners cannot write the supervisor's recording directory.
105
+ */
106
+ export interface PlaceRecordHome {
107
+ /** The directory, in the place, attempt `attempt`'s runner records into. */
108
+ dir(attempt: number): string;
109
+ /**
110
+ * Brings home into `into` what the runners recorded: blobs first, then the new whole lines of each `.spool/*.jsonl`.
111
+ * Idempotent; `final` is the call after the runner ended, when nothing is still landing.
112
+ */
113
+ bringHome(into: string, options?: {
114
+ final?: boolean;
115
+ }): Promise<void>;
116
+ /** How far the place's clock is from the supervisor's, when it knows. */
117
+ readonly clockSkewMs?: number;
118
+ }
94
119
  export interface Place {
95
120
  /** `local` or `archil`, for the recording's lines. */
96
121
  readonly name: string;
@@ -122,8 +147,10 @@ export interface Place {
122
147
  move?(to: Place): Promise<Record<string, unknown>>;
123
148
  /** Lets the run go: stops what still runs (its commands too) and removes what the place made for it (tokens), never the run's directory. */
124
149
  release(): Promise<void>;
150
+ /** A place whose runners record where the supervisor cannot read says where, and brings the recording home. */
151
+ readonly recordHome?: PlaceRecordHome;
125
152
  }
126
- export type PlaceErrorCode = "INVALID_ARGUMENT" | "HELD" | "START_TIMED_OUT" | "STATUS_FAILED" | "KILL_UNSUPPORTED" | "RELEASE_FAILED";
153
+ export type PlaceErrorCode = "INVALID_ARGUMENT" | "HELD" | "START_TIMED_OUT" | "STATUS_FAILED" | "KILL_UNSUPPORTED" | "RELEASE_FAILED" | "BOX_GONE" | "CARRY_FAILED" | "BLOB_MISMATCH" | "DIRS_REFUSED";
127
154
  export declare class PlaceError extends PdaError {
128
155
  constructor(code: PlaceErrorCode, message: string, options?: {
129
156
  cause?: unknown;
@@ -150,8 +177,11 @@ export declare function localPlace(options: LocalPlaceOptions): (ctx: PlaceConte
150
177
  export interface ArchilPlaceOptions {
151
178
  disk: string;
152
179
  region: string;
153
- /** The disk's control API (`archilControl`). The place holds it; no instance ever does. */
154
- control: SupervisorControl;
180
+ /**
181
+ * The disk's control API (`archilControl`). The place holds it; no instance ever does. With `listObjects` (as
182
+ * `archilControl` has), the place has `recordHome`.
183
+ */
184
+ control: SupervisorControl & Partial<RecordControl>;
155
185
  /**
156
186
  * The driver that runs the instance (`localHost`, `dockerHost`, `daytonaHost`), set up with the app the instance runs.
157
187
  * Its `kill` and `kills` are the place's.
@@ -175,6 +205,22 @@ export interface ArchilPlaceOptions {
175
205
  */
176
206
  signalFailMs?: number;
177
207
  }
208
+ /** The listing `recordHome` reads the run's recordings with: S3 ListObjects, each object's size when the API gives it. */
209
+ export interface RecordControl {
210
+ listObjects(prefix: string, options?: {
211
+ recursive?: boolean;
212
+ }): Promise<{
213
+ objects: {
214
+ key: string;
215
+ size?: number;
216
+ }[];
217
+ }>;
218
+ }
219
+ /**
220
+ * Every byte of `bytes` to `fd`: a write may take fewer bytes than it was given, so it goes on from where the last one
221
+ * stopped. A write that takes none fails, as one that throws (a full disk) does.
222
+ */
223
+ export declare function writeAll(fd: number, bytes: Uint8Array, write?: (fd: number, bytes: Uint8Array, offset: number, length: number) => number): void;
178
224
  /**
179
225
  * The run on an Archil disk, its instance run by a host driver. `token(attempt)` mints the attempt's mount token through
180
226
  * the place's control, and the runner it is handed to mounts with it. A start is `ensureRunning` (it creates the run's