@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.
@@ -299,6 +299,11 @@ export function localHost(opts = {}) {
299
299
  return;
300
300
  if (h.mode === "child") {
301
301
  const pid = Number(h.pid);
302
+ // A handle comes back from run.json with whatever was written there, and stop signals its pid as a group: only a
303
+ // real process, then. A pid of 1 or below names a group (`kill(-0)` is this supervisor's own, `kill(-1)` every
304
+ // process it may signal), and without start ticks nothing tells a reused pid from the instance.
305
+ if (!Number.isInteger(pid) || pid <= 1 || typeof h.startTicks !== "number")
306
+ return;
302
307
  const alive = () => procStartTicks(pid) === h.startTicks;
303
308
  if (!alive())
304
309
  return;
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./fork.js";
7
7
  export * from "./hosts/daytona.js";
8
8
  export * from "./hosts/docker.js";
9
9
  export * from "./hosts/local-host.js";
10
+ export * from "./local-run.js";
10
11
  export * from "./park.js";
11
12
  export * from "./run.js";
12
13
  export * from "./serve.js";
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export * from "./fork.js";
8
8
  export * from "./hosts/daytona.js";
9
9
  export * from "./hosts/docker.js";
10
10
  export * from "./hosts/local-host.js";
11
+ export * from "./local-run.js";
11
12
  export * from "./park.js";
12
13
  export * from "./run.js";
13
14
  export * from "./serve.js";
package/dist/lease.d.ts CHANGED
@@ -3,5 +3,5 @@ export type { LeaseOptions, OpenRunLeaseOptions, OpenStep, ReleaseStep, RunClaim
3
3
  export * from "./errors.js";
4
4
  export { assertPragmas, Fence, FencedDatabase, openArchilStore, PROFILE_PRAGMAS, StoreBusyError, StoreFencedError, StorePragmaError } from "./store.js";
5
5
  export type { ArchilStore, OpenOptions, PragmaMismatch, Profile } from "./store.js";
6
- export type { ArchilHost, Claim, RunRef } from "./claim.js";
7
- export type { RunHolder, RunRecord, RunStatus } from "./status.js";
6
+ export type { AcquireOptions, ArchilHost, Claim, RunRef, Unmounted } from "./claim.js";
7
+ export type { ClaimDir, PersistRecord, RunHolder, RunRecord, RunStatus } from "./status.js";
package/dist/lease.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The narrow entry (`@parcha/pi-durable-disk/lease`) for a host that owns its own pi-durable Harness and store connections: the
2
2
  // claim's lifecycle without the supervisor, the host drivers, the CLI or the app contract. Importing it must not pull those
3
- // in (test/lease.test.ts bundles it and checks); a name added here is part of that promise.
3
+ // in (test/lease-entry.test.ts bundles it and checks); a name added here is part of that promise.
4
4
  export { EXIT_DATAERR, EXIT_SOFTWARE, LeaseLapsedError, openRunLease, OWNER_LOCK, RunError, StoreBehindSealError, storeHead, STORE_FILE } from "./run.js";
5
5
  export * from "./errors.js";
6
6
  export { assertPragmas, Fence, FencedDatabase, openArchilStore, PROFILE_PRAGMAS, StoreBusyError, StoreFencedError, StorePragmaError } from "./store.js";
@@ -0,0 +1,10 @@
1
+ import type { AcquireOptions, Claim } from "./claim.js";
2
+ import { type ClaimDir } from "./status.js";
3
+ export interface LocalRun {
4
+ /** Takes the claim: the directory, made when it is missing. */
5
+ readonly acquire: (options: AcquireOptions) => Promise<Claim>;
6
+ /** Holds the directory open as the run's root; a local directory is not an Archil mount, so its type is not checked. */
7
+ readonly claimDir: (root: string) => Promise<ClaimDir>;
8
+ }
9
+ /** `openDurableRun`'s `acquire` and `claimDir` for a run in `root`, a directory on this machine. */
10
+ export declare function localRun(root: string): LocalRun;
@@ -0,0 +1,63 @@
1
+ // A run on a plain local directory, for one machine: the claim is the directory itself. Nothing mounts and nothing is
2
+ // delegated, so nothing but this machine's owner lock stands between two processes and the run: it is for a laptop, a CI
3
+ // job or a test, never for a directory another machine can reach.
4
+ //
5
+ // const run = await openDurableRun(ref, { ...localRun("/var/runs/run-1"), harness: { models, registry } });
6
+ //
7
+ // No mount token is needed (`acquire` is replaced). The claim's barrier does not sync the files the agent wrote: the
8
+ // store's own commits are made durable by SQLite, and work/ is the operating system's to flush; a power loss can lose what
9
+ // it had not written yet. It refuses once the claim is fenced, as a mount's does.
10
+ //
11
+ // Linux only, for now: the run's root is held open through /proc/self/fd (openClaimDir), and the default environment
12
+ // (`archilEnv`) confines commands with `setpriv` (util-linux) and the file tools through /proc/self/fd. macOS has neither.
13
+ import { mkdir } from "node:fs/promises";
14
+ import { join, resolve } from "node:path";
15
+ import { FencedError } from "./errors.js";
16
+ import { openClaimDir } from "./status.js";
17
+ /** `openDurableRun`'s `acquire` and `claimDir` for a run in `root`, a directory on this machine. */
18
+ export function localRun(root) {
19
+ const dir = resolve(root);
20
+ return {
21
+ acquire: async ({ ref }) => {
22
+ await mkdir(dir, { recursive: true });
23
+ return new LocalClaim(ref, dir);
24
+ },
25
+ claimDir: (at) => openClaimDir(at, { fstype: null }),
26
+ };
27
+ }
28
+ class LocalClaim {
29
+ ref;
30
+ disk;
31
+ root;
32
+ work;
33
+ store;
34
+ /** `<root>/commands`: a local run has no unit cgroup, so the next open ends the commands a killed one left running. */
35
+ commands;
36
+ reused = false;
37
+ stray = null;
38
+ forced = false;
39
+ timings = { mountMs: 0, verifyMs: 0 };
40
+ #fence;
41
+ constructor(ref, root) {
42
+ this.ref = ref;
43
+ this.disk = ref.disk;
44
+ this.root = root;
45
+ this.work = join(root, "work");
46
+ this.store = join(root, "store");
47
+ this.commands = join(root, "commands");
48
+ }
49
+ get fenced() {
50
+ return this.#fence !== undefined;
51
+ }
52
+ markFenced(cause) {
53
+ this.#fence ??= { cause };
54
+ }
55
+ async barrier() {
56
+ if (this.#fence)
57
+ throw new FencedError(`${this.root} is fenced; nothing is retried on it`, { cause: this.#fence.cause });
58
+ return { ms: 0 };
59
+ }
60
+ async release() {
61
+ return { via: "none" };
62
+ }
63
+ }
package/dist/park.d.ts CHANGED
@@ -26,9 +26,10 @@ export type BusyState = {
26
26
  };
27
27
  /**
28
28
  * The deadline of a task that is only sleeping: `until` in pi's retry phase (generation and compaction), `pollAt` in
29
- * its deferred poll phase. This is pi-durable 1.0.4's internal checkpoint shape (harness/generation.js, compaction.js),
30
- * read here and nowhere else. Any other shape is no deadline, which counts as busy, so a pi that changes the shape keeps
31
- * instances up rather than parking them mid-work. pi#10325 asks for this as a contract.
29
+ * its deferred poll phase. This is pi-durable's internal checkpoint shape, the same in 1.0.4 and 1.1.0
30
+ * (harness/generation.js, compaction.js), read here and nowhere else. Any other shape is no deadline, which counts as
31
+ * busy, so a pi that changes the shape keeps instances up rather than parking them mid-work. pi#10325 asks for this as
32
+ * a contract.
32
33
  */
33
34
  export declare function waitDeadline(checkpoint: JsonValue | undefined): number | undefined;
34
35
  /**
package/dist/park.js CHANGED
@@ -27,9 +27,10 @@ export function leaseParkTarget(lease, harness, context = BACKGROUND_CONTEXT) {
27
27
  export const LOCAL_PARK_THRESHOLD_MS = 60_000;
28
28
  /**
29
29
  * The deadline of a task that is only sleeping: `until` in pi's retry phase (generation and compaction), `pollAt` in
30
- * its deferred poll phase. This is pi-durable 1.0.4's internal checkpoint shape (harness/generation.js, compaction.js),
31
- * read here and nowhere else. Any other shape is no deadline, which counts as busy, so a pi that changes the shape keeps
32
- * instances up rather than parking them mid-work. pi#10325 asks for this as a contract.
30
+ * its deferred poll phase. This is pi-durable's internal checkpoint shape, the same in 1.0.4 and 1.1.0
31
+ * (harness/generation.js, compaction.js), read here and nowhere else. Any other shape is no deadline, which counts as
32
+ * busy, so a pi that changes the shape keeps instances up rather than parking them mid-work. pi#10325 asks for this as
33
+ * a contract.
33
34
  */
34
35
  export function waitDeadline(checkpoint) {
35
36
  if (typeof checkpoint !== "object" || checkpoint === null || Array.isArray(checkpoint))
package/dist/run.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Context, JsonValue } from "@earendil-works/chord";
2
2
  import { Harness } from "@earendil-works/pi-durable";
3
- import type { HarnessOptions, Storage, ToolRegistration } from "@earendil-works/pi-durable";
3
+ import type { HarnessOptions, Seq, Storage, ToolRegistration } from "@earendil-works/pi-durable";
4
4
  import type { SqliteDatabase, SqliteExecutor } from "@earendil-works/pi-durable/storage/sqlite";
5
5
  import { type AcquireOptions, type ArchilHost, type Claim, type RunRef } from "./claim.js";
6
6
  import { FencedError, PdaError } from "./errors.js";
@@ -57,11 +57,15 @@ export interface LeaseOptions {
57
57
  /** How often the self-fence timer looks; default 1 s. */
58
58
  readonly checkMs?: number;
59
59
  }
60
- export type OpenStep = "acquire" | "owner-lock" | "run.json" | "heartbeat" | "layout" | "store" | "seal" | "harness" | "live" | "resume";
60
+ export type OpenStep = "acquire" | "owner-lock" | "run.json" | "heartbeat" | "layout" | "commands" | "store" | "seal" | "harness" | "live" | "before-resume" | "resume";
61
61
  export type ReleaseStep = "close" | "cleanup" | "barrier" | "seal" | "unlock" | "unmount";
62
62
  export interface OpenRunLeaseOptions {
63
- /** The mount token from the host driver (reusable, 24 h); travels only on the archil wrapper's stdin, never in an environment. */
64
- readonly mountToken: string;
63
+ /**
64
+ * The mount token from the host driver (reusable, 24 h); travels only on the archil wrapper's stdin, never in an
65
+ * environment. Required to mount an Archil disk (the default `acquire`); a run whose `acquire` is replaced (a local
66
+ * directory: `localRun`) needs none, and its `acquire` is handed an empty token.
67
+ */
68
+ readonly mountToken?: string;
65
69
  /** Default /mnt/archil. */
66
70
  readonly mountRoot?: string;
67
71
  /** Mount with `--force`: the takeover fallback when the supervisor could not revoke. */
@@ -110,7 +114,35 @@ export interface OpenDurableRunOptions<Tool extends ToolRegistration = ToolRegis
110
114
  readonly env?: (claim: Claim) => RunEnv;
111
115
  /** Wraps the raw database inside the store's fence (instrumentation, fault injection). */
112
116
  readonly decorateStore?: (database: SqliteDatabase) => SqliteDatabase;
117
+ /**
118
+ * Awaited once the Harness is open (its registry installed) and the run is live, before the Harness resumes: no
119
+ * resumed task takes a step until it settles, and what it admits (a submission, a write) is committed before anything
120
+ * a resumed task's steps commit. A host reconciles here what a resumed tool depends on (a browser, a mount). A rejection
121
+ * fails the open as any step does (`before-resume`), and the claim is released.
122
+ */
123
+ readonly beforeResume?: (run: BeforeResume) => void | Promise<void>;
124
+ }
125
+ /**
126
+ * What `beforeResume` sees: the open Harness, not yet resumed, the open's context, the incarnation it belongs to, and the
127
+ * store the Harness was opened over with its head (a recorder snapshots every document at `head` here).
128
+ */
129
+ export interface BeforeResume {
130
+ readonly harness: Harness;
131
+ readonly context: Context;
132
+ readonly generation: number;
133
+ readonly record: RunRecord;
134
+ readonly claim: RunClaim;
135
+ /** The store the Harness was opened over, read-only: four reads on an object of their own, and no way to a write. */
136
+ readonly storage: StoreReads;
137
+ /**
138
+ * The store's last committed sequence once the Harness is open, read just before the hook runs. Until the hook commits
139
+ * something, the store's current state is its state at `head`: a document that keeps no history (`latest`, and session
140
+ * and task documents) reads only at "current", a `rewindable` one at `head` too.
141
+ */
142
+ readonly head: Seq;
113
143
  }
144
+ /** The reads of a store that `beforeResume` gets. */
145
+ export type StoreReads = Pick<Storage, "scanConversations" | "scanTasks" | "scanDocuments" | "document">;
114
146
  /** The claim as the app sees it: paths, the fenced flag, and a barrier whose failure fences the run. */
115
147
  export interface RunClaim {
116
148
  readonly ref: RunRef;
@@ -228,12 +260,12 @@ export interface StoreHeadSource {
228
260
  readonly file: string;
229
261
  }
230
262
  /**
231
- * The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable 1.0.4's
232
- * public `Storage` has no head query, and the seal check must run before `Harness.open` can commit (its recovery commit
233
- * would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq` and stores the
234
- * successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store without that table,
235
- * row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the check silently;
236
- * a unit test pins the read to the sequence pi's commits return.
263
+ * The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable's public
264
+ * `Storage` has no head query (1.0.4 and 1.1.0), and the seal check must run before `Harness.open` can commit (its
265
+ * recovery commit would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq`
266
+ * and stores the successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store
267
+ * without that table, row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the
268
+ * check silently; a unit test pins the read to the sequence pi's commits return.
237
269
  *
238
270
  * `database` is the store's fence-aware facade (`FencedDatabase`), which turns an I/O-class error into a fence; through
239
271
  * a raw connection such an error would read as STORE_HEAD_UNREADABLE.
package/dist/run.js CHANGED
@@ -10,6 +10,7 @@ import { DatabaseSync } from "node:sqlite";
10
10
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
11
11
  import { Harness } from "@earendil-works/pi-durable";
12
12
  import { acquire } from "./claim.js";
13
+ import { reapOrRefuse } from "./commands.js";
13
14
  import { archilEnv } from "./env.js";
14
15
  import { EXIT_FENCED, FencedError, HeldError, ownWriteFenced, PdaError } from "./errors.js";
15
16
  import { openArchilStore } from "./store.js";
@@ -59,6 +60,18 @@ export class LeaseLapsedError extends FencedError {
59
60
  this.by = by;
60
61
  }
61
62
  }
63
+ /**
64
+ * `storage`'s four reads on a frozen object that holds nothing else: no `commit` or `mintId` on it, and no reference to
65
+ * `storage` a cast could reach.
66
+ */
67
+ function storeReads(storage) {
68
+ return Object.freeze({
69
+ scanConversations: (...args) => storage.scanConversations(...args),
70
+ scanTasks: (...args) => storage.scanTasks(...args),
71
+ scanDocuments: (...args) => storage.scanDocuments(...args),
72
+ document: (...args) => storage.document(...args),
73
+ });
74
+ }
62
75
  /** Locks this process holds. A collected DatabaseSync closes its file, which would drop the lock without a word. */
63
76
  const heldLocks = new Set();
64
77
  const SQLITE_BUSY = 5;
@@ -119,12 +132,12 @@ export function takeOwnerLock(root) {
119
132
  };
120
133
  }
121
134
  /**
122
- * The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable 1.0.4's
123
- * public `Storage` has no head query, and the seal check must run before `Harness.open` can commit (its recovery commit
124
- * would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq` and stores the
125
- * successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store without that table,
126
- * row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the check silently;
127
- * a unit test pins the read to the sequence pi's commits return.
135
+ * The last sequence the store committed: the one read of pi's internal schema in this package. pi-durable's public
136
+ * `Storage` has no head query (1.0.4 and 1.1.0), and the seal check must run before `Harness.open` can commit (its
137
+ * recovery commit would reuse a lost sequence number). pi's SqliteStorage gives each commit `durable_metadata.next_seq`
138
+ * and stores the successor in the same transaction, so the head is `next_seq - 1`. The query only reads. A store
139
+ * without that table, row or column fails closed (STORE_HEAD_UNREADABLE), so a pi whose schema moved never skips the
140
+ * check silently; a unit test pins the read to the sequence pi's commits return.
128
141
  *
129
142
  * `database` is the store's fence-aware facade (`FencedDatabase`), which turns an I/O-class error into a fence; through
130
143
  * a raw connection such an error would read as STORE_HEAD_UNREADABLE.
@@ -605,9 +618,12 @@ async function openLease(ref, options, step) {
605
618
  catch (error) {
606
619
  throw new RunError("INVALID_ARGUMENT", `holder ${JSON.stringify(options.holder)}: ${error.message}`, { cause: error });
607
620
  }
621
+ if (options.acquire === undefined && !options.mountToken) {
622
+ throw new RunError("INVALID_ARGUMENT", "mountToken is required to mount an Archil disk; a run on a local directory passes localRun(root) instead");
623
+ }
608
624
  const claim = await step("acquire", () => (options.acquire ?? acquire)({
609
625
  ref,
610
- token: options.mountToken,
626
+ token: options.mountToken ?? "",
611
627
  ...(options.mountRoot === undefined ? {} : { mountRoot: options.mountRoot }),
612
628
  ...(options.force === undefined ? {} : { force: options.force }),
613
629
  ...(options.host === undefined ? {} : { host: options.host }),
@@ -680,6 +696,15 @@ class Run {
680
696
  const storage = lease.observe(this.#store.storage);
681
697
  this.#harness = await step("harness", () => Harness.open(storage, { ...this.#options.harness, env: this.env }, this.#context));
682
698
  await step("live", () => lease.live());
699
+ const before = this.#options.beforeResume;
700
+ if (before) {
701
+ // The storage the Harness was opened over (the lease observes its commits), and its head now: nothing commits
702
+ // between the open and the hook, since the Harness has not resumed.
703
+ await step("before-resume", async () => {
704
+ const head = (await storeHead(this.#store));
705
+ await before({ harness: this.#harness, context: this.#context, generation: lease.generation, record: lease.record, claim: lease.claim, storage: storeReads(storage), head });
706
+ });
707
+ }
683
708
  await step("resume", () => this.#harness.resume());
684
709
  }
685
710
  /** Undo a failed open: close what this layer opened, then the lease's own undo. */
@@ -735,6 +760,13 @@ export async function openDurableRun(ref, options) {
735
760
  let env;
736
761
  const lease = await openLease(ref, { ...options, context, cleanup: (c) => env?.cleanup(c) ?? Promise.resolve() }, step);
737
762
  try {
763
+ // A claim that records its commands (a local run's): end what a killed incarnation left running, now that the owner
764
+ // lock is held and before the Harness opens, so no command of it still writes in work/ once the run resumes.
765
+ const commands = lease.rawClaim.commands;
766
+ // A process of a killed incarnation that outlives its SIGKILL refuses the open (HeldError, 76): the run never resumes
767
+ // beside it, and its record stays for the next open.
768
+ if (commands !== undefined)
769
+ await step("commands", () => reapOrRefuse(commands));
738
770
  env = (options.env ?? ((c) => archilEnv(c)))(lease.rawClaim);
739
771
  }
740
772
  catch (error) {
@@ -330,9 +330,9 @@ export declare function checkHost(options: CheckOptions): Promise<CheckReport>;
330
330
  /**
331
331
  * Delete a run's directory tree over S3. A tree with a delegation on it, even an orphaned one, is not deleted: S3
332
332
  * DeleteObjects leaves its objects in place and reports no error, and the run id would later reopen with its old
333
- * store. So: revoke every delegation on the subtree, delete the files, then the directory markers deepest first (a
334
- * directory with children refuses), then list the prefix. Anything left is RUN_TREE_NOT_DELETED; a non-empty prefix is
335
- * never reported as deleted.
333
+ * store. So: revoke every delegation on the subtree and its holders' private directories, delete the files, then the
334
+ * directory markers deepest first (a directory with children refuses), then list the prefix. Anything left is
335
+ * RUN_TREE_NOT_DELETED; a non-empty prefix is never reported as deleted.
336
336
  */
337
337
  export declare function deleteRunTree(control: CheckControl, id: string): Promise<{
338
338
  objects: number;
package/dist/supervise.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import { open } from "node:fs/promises";
7
7
  import { hostname } from "node:os";
8
8
  import { join } from "node:path";
9
- import { acquire, createRunDir, findDelegations, matchDelegations, pathlessResolver, mintMountToken, MOUNT_TOKEN_TTL, parseTokenNickname, removeMountToken, runPath, unmountClaim, } from "./claim.js";
9
+ import { acquire, createRunDir, findDelegations, matchDelegations, pathlessResolver, revokeBestEffort, revokeCompanions, mintMountToken, MOUNT_TOKEN_TTL, parseTokenNickname, removeMountToken, runPath, unmountClaim, } from "./claim.js";
10
10
  import { FencedError, HeldError, PdaError } from "./errors.js";
11
11
  import { parseRunRecord, RUN_JSON } from "./status.js";
12
12
  export const CONTROL_TIMEOUT_MS = 10_000;
@@ -106,7 +106,7 @@ export async function ensureRunning(ref, host, options) {
106
106
  }
107
107
  // A due wake still goes through the delegation check: an instance that wrote `sleeping` and died before releasing
108
108
  // left a held or orphaned delegation, which a plain start would hit (76) on every tick.
109
- const held = await listHeld(opts.control, ref.id, opts.pathless);
109
+ const { held, all } = await listHeld(opts.control, ref.id, opts.pathless);
110
110
  // A start in flight: the instance has not written run.json at its generation yet (it may not even have mounted).
111
111
  // Revoking it or starting a second one would fence what was just started.
112
112
  const baseGraceMs = opts.startGraceMs ?? leaseExpiryMs;
@@ -144,7 +144,7 @@ export async function ensureRunning(ref, host, options) {
144
144
  const inFlight = await starting();
145
145
  if (inFlight)
146
146
  return inFlight;
147
- await revokeListed(opts.control, ref.id, held);
147
+ await revokeListed(opts.control, ref.id, held, all);
148
148
  return start("orphaned", held);
149
149
  }
150
150
  if (held.some((d) => d.isPending && !d.isOrphaned))
@@ -157,7 +157,7 @@ export async function ensureRunning(ref, host, options) {
157
157
  if (inFlight)
158
158
  return inFlight;
159
159
  const stonith = status?.holder ? await stopHolder(host, status.holder, opts.stonithTimeoutMs ?? STONITH_TIMEOUT_MS) : { outcome: "no-holder" };
160
- await revokeListed(opts.control, ref.id, held);
160
+ await revokeListed(opts.control, ref.id, held, all);
161
161
  return start("lease-expired", held, stonith);
162
162
  }
163
163
  /**
@@ -219,9 +219,11 @@ async function ensureRunDir(control, id, owner) {
219
219
  await createRunDir(control, id, owner);
220
220
  return true;
221
221
  }
222
+ /** The run's delegations (`matchDelegations`), and the listing they came from. */
222
223
  async function listHeld(control, id, resolve) {
223
224
  try {
224
- return await findDelegations(control, id, resolve);
225
+ const all = await control.listDelegations();
226
+ return { held: await matchDelegations(all, id, resolve ?? pathlessResolver(control)), all };
225
227
  }
226
228
  catch (err) {
227
229
  throw new SuperviseError("CONTROL_API_FAILED", `listing delegations on ${runPath(id)} failed`, { cause: err });
@@ -231,18 +233,20 @@ const sameDelegation = (a, b) => a.clientId === b.clientId && a.inodeId === b.in
231
233
  /**
232
234
  * Revoke exactly the delegations the decision was made on, never a fresh listing: a racing supervisor may have started
233
235
  * a new holder since, and that one is not ours to judge. A revoke that fails is fine only if the delegation is gone.
236
+ * Then the same clients' private directories (`revokeCompanions`): those clients are gone or fenced.
234
237
  */
235
- async function revokeListed(control, id, held) {
238
+ async function revokeListed(control, id, held, all) {
236
239
  for (const d of held) {
237
240
  try {
238
241
  await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId });
239
242
  }
240
243
  catch (err) {
241
- const still = await listHeld(control, id).then((now) => now.some((n) => sameDelegation(n, d)));
244
+ const still = await listHeld(control, id).then((now) => now.held.some((n) => sameDelegation(n, d)));
242
245
  if (still)
243
246
  throw new SuperviseError("CONTROL_API_FAILED", `revoking ${runPath(id)} (client ${d.clientId}) failed`, { cause: err });
244
247
  }
245
248
  }
249
+ await revokeCompanions(control, held, all);
246
250
  }
247
251
  function settleWithin(promise, ms) {
248
252
  return new Promise((resolve) => {
@@ -501,15 +505,13 @@ export async function checkHost(options) {
501
505
  /**
502
506
  * Delete a run's directory tree over S3. A tree with a delegation on it, even an orphaned one, is not deleted: S3
503
507
  * DeleteObjects leaves its objects in place and reports no error, and the run id would later reopen with its old
504
- * store. So: revoke every delegation on the subtree, delete the files, then the directory markers deepest first (a
505
- * directory with children refuses), then list the prefix. Anything left is RUN_TREE_NOT_DELETED; a non-empty prefix is
506
- * never reported as deleted.
508
+ * store. So: revoke every delegation on the subtree and its holders' private directories, delete the files, then the
509
+ * directory markers deepest first (a directory with children refuses), then list the prefix. Anything left is
510
+ * RUN_TREE_NOT_DELETED; a non-empty prefix is never reported as deleted.
507
511
  */
508
512
  export async function deleteRunTree(control, id) {
509
513
  const prefix = `${runPath(id)}/`;
510
- const held = await findDelegations(control, id);
511
- for (const d of held)
512
- await control.revokeDelegation({ clientId: d.clientId, inodeId: d.inodeId }).catch(() => { });
514
+ const held = await revokeBestEffort(control, id);
513
515
  const keys = (await control.listObjects(prefix, { recursive: true })).objects.map((o) => o.key);
514
516
  const dirs = [...new Set([...keys.filter((k) => k.endsWith("/")), prefix])];
515
517
  const depth = (k) => k.split("/").length;
package/dist/watchdog.js CHANGED
@@ -60,7 +60,7 @@ function sweep(instance, options = {}) {
60
60
  if (!Number.isInteger(pid) || pid <= 0 || pid === instance) continue;
61
61
  const child = stat(proc, pid);
62
62
  if (child === undefined || child.ppid !== instance) continue;
63
- targets.add(child.pgrp === own.pgrp || child.pgrp <= 0 ? pid : -child.pgrp);
63
+ targets.add(child.pgrp === own.pgrp || child.pgrp <= 1 ? pid : -child.pgrp);
64
64
  }
65
65
  if (options.ownCgroup) {
66
66
  for (const pid of cgroupPids(instance, proc, options.sys ?? "/sys/fs/cgroup")) if (pid !== instance && pid > 0) targets.add(pid);
@@ -110,7 +110,9 @@ export class LeaseWatchdog {
110
110
  Atomics.store(this.#shared, SLOT_BEAT, options.beatNs);
111
111
  Atomics.store(this.#shared, SLOT_LIMIT, BigInt(Math.round(options.limitMs * 1e6)));
112
112
  const data = { buffer, checkMs: options.checkMs, instance: process.pid, ownCgroup: options.ownCgroup === true };
113
- this.#worker = new Worker(WORKER_SOURCE, { eval: true, workerData: data });
113
+ // No inherited Node flags: the source is self-contained CommonJS and needs none of the host's, and some would make Node
114
+ // read it as ESM (`--input-type=module`, a host running inline ESM through -e or stdin), where `require` is undefined.
115
+ this.#worker = new Worker(WORKER_SOURCE, { eval: true, workerData: data, execArgv: [] });
114
116
  this.#worker.unref();
115
117
  }
116
118
  /** A heartbeat write that started at `startNs` succeeded. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parcha/pi-durable-disk",
3
- "version": "0.1.0-beta.12",
3
+ "version": "0.1.0-beta.13",
4
4
  "description": "A durable pi host on a network disk (Archil): one owner per run, a fence, durable commits, and a supervisor.",
5
5
  "keywords": [
6
6
  "pi",
@@ -49,7 +49,7 @@
49
49
  "build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/rewrite-dts.mjs dist",
50
50
  "prepack": "npm run build",
51
51
  "typecheck": "tsc --noEmit && tsc --noEmit -p examples",
52
- "test": "node --test 'test/*.test.ts'",
52
+ "test": "node --test --test-timeout=240000 'test/*.test.ts' 'test/examples/*.test.ts'",
53
53
  "test:acceptance": "node --test --test-concurrency=1 'test/acceptance/*.test.ts'",
54
54
  "test:live": "node --test --test-concurrency=1 'test/live/*.test.ts'",
55
55
  "test:live:claim": "node --test --test-concurrency=1 test/live/claim.live.test.ts",
@@ -62,16 +62,16 @@
62
62
  "verify:tarball": "node scripts/verify-tarball.mjs"
63
63
  },
64
64
  "peerDependencies": {
65
- "@earendil-works/chord": "~1.0.4",
66
- "@earendil-works/pi-durable": "~1.0.4"
65
+ "@earendil-works/chord": ">=1.0.4 <1.3.0",
66
+ "@earendil-works/pi-durable": ">=1.0.4 <1.3.0"
67
67
  },
68
68
  "dependencies": {
69
69
  "disk": "1.7.0"
70
70
  },
71
71
  "devDependencies": {
72
- "@earendil-works/chord": "1.0.4",
73
- "@earendil-works/pi-ai": "1.0.4",
74
- "@earendil-works/pi-durable": "1.0.4"
72
+ "@earendil-works/chord": "1.1.0",
73
+ "@earendil-works/pi-ai": "1.1.0",
74
+ "@earendil-works/pi-durable": "1.1.0"
75
75
  },
76
76
  "repository": {
77
77
  "type": "git",