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

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/dist/place.js ADDED
@@ -0,0 +1,859 @@
1
+ // Places: where one run lives, and how its instance starts, is killed and comes back. A supervisor that decides when to
2
+ // kill (agentrun's reference demo) is the same for every place; the place decides how. `localPlace` runs the instance on
3
+ // a directory of this machine (`run --dir`, no disk and no key); `archilPlace` runs it on an Archil disk through a host
4
+ // driver, mints each attempt's mount token itself and takes the run over after a kill. The types match the reference's
5
+ // place seam (examples/reference/place.ts) structurally. The supervisor reads nothing under the run's directory: the
6
+ // outcome, the run's record and its delegations come through the place.
7
+ import { spawn } from "node:child_process";
8
+ import { createHash, randomUUID } from "node:crypto";
9
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeSync } from "node:fs";
10
+ import { dirname, extname, join, posix, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { DEFAULT_MOUNT_ROOT, findDelegations, mintMountToken, pathlessResolver, removeMountToken, runPath } from "./claim.js";
13
+ import { reapCommands } from "./commands.js";
14
+ import { HeldError, PdaError } from "./errors.js";
15
+ import { takeOwnerLock } from "./run.js";
16
+ import { readRunRecord } from "./status.js";
17
+ import { CONTROL_TIMEOUT_MS, ensureRunning, readRunStatus, readStartMark, TOKEN_TTL, withTimeouts, } from "./supervise.js";
18
+ export class PlaceError extends PdaError {
19
+ constructor(code, message, options = {}) {
20
+ super(code, message, options);
21
+ }
22
+ }
23
+ // The package's own CLI, with the extension this module has: `.ts` from a checkout, `.js` once built.
24
+ const CLI = fileURLToPath(new URL(`./cli${extname(import.meta.url)}`, import.meta.url));
25
+ const SAFE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
26
+ /** After the instance exits, how long its output may take to close (a command it left running can hold it open). */
27
+ const OUTPUT_GRACE_MS = 2_000;
28
+ /** How often a local place looks for a kill request (a file of this machine). */
29
+ const LOCAL_SIGNAL_POLL_MS = 20;
30
+ /** How long reads of a kill request may fail without a break before `signals` says so. */
31
+ const SIGNAL_FAIL_MS = 60_000;
32
+ /** An S3 read of a key that is not there (yet): the only failure a request poll expects. */
33
+ const notThere = (error) => {
34
+ const e = error;
35
+ return e.status === 404 || e.statusCode === 404 || e.code === "NoSuchKey";
36
+ };
37
+ const STDERR_KEEP = 600;
38
+ /** The longest message a place reports in a signal, as a kill line's. */
39
+ const MESSAGE_KEEP = 240;
40
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
41
+ /**
42
+ * The outcome `run.json` gives, in the run's vocabulary rather than the record's: `complete` when the run is `done` and
43
+ * delivered a record (the detail of the app's last status carries one), `failed` for anything else (done with no result,
44
+ * failed, or a run its instance left `running`, `paused` or `sleeping`). Its generation, and what the app put in that
45
+ * detail.
46
+ */
47
+ function outcomeOf(record) {
48
+ if (!record)
49
+ return undefined;
50
+ const detail = record.detail !== null && typeof record.detail === "object" && !Array.isArray(record.detail) ? record.detail : {};
51
+ const delivered = record.status === "done" && "record" in detail && detail.record !== null && detail.record !== undefined;
52
+ const models = modelsOf(detail.models);
53
+ return {
54
+ status: delivered ? "complete" : "failed",
55
+ generation: record.generation,
56
+ code: typeof detail.code === "string" ? detail.code : null,
57
+ ...(typeof detail.message === "string" ? { message: detail.message } : {}),
58
+ ...("record" in detail ? { record: detail.record } : {}),
59
+ ...(typeof detail.storeSha256 === "string" ? { storeSha256: detail.storeSha256 } : {}),
60
+ ...(models ? { models } : {}),
61
+ };
62
+ }
63
+ /**
64
+ * The role-to-model ids an outcome carries: a copy of `value` when it is a plain object whose values are all strings,
65
+ * else undefined, so a malformed report is left out and never stands in as `{}`.
66
+ */
67
+ function modelsOf(value) {
68
+ if (value === null || typeof value !== "object" || Array.isArray(value))
69
+ return undefined;
70
+ const proto = Object.getPrototypeOf(value);
71
+ if (proto !== Object.prototype && proto !== null)
72
+ return undefined;
73
+ const entries = Object.entries(value);
74
+ return entries.every(([, id]) => typeof id === "string") ? Object.fromEntries(entries) : undefined;
75
+ }
76
+ const pathsUnder = (id, root) => ({ id, root, work: join(root, "work"), store: join(root, "store") });
77
+ /**
78
+ * The run on a directory of this machine: each runner is `pi-durable-disk run --dir <root> --id <id> --app <app>`, a
79
+ * child in its own process group, with `{ PATH, ...order.env }` and nothing else of this process's environment. No disk,
80
+ * no key and no token. A process kill is SIGKILL to the group, and the next runner resumes the run from the directory; a
81
+ * box kill also takes the boxes the run made (`destroyBoxes`), so `kills` has `box` only when the context gives that.
82
+ * A runner's outcome is the instance's `{"outcome":...}` line on stdout, or, without one, `run.json` as it left it.
83
+ */
84
+ export function localPlace(options) {
85
+ if (!options.app)
86
+ throw new PlaceError("INVALID_ARGUMENT", "localPlace needs the app module the instance runs");
87
+ return (ctx) => {
88
+ runPath(ctx.paths.id);
89
+ const root = resolve(options.root ?? ctx.paths.root);
90
+ const paths = pathsUnder(ctx.paths.id, root);
91
+ const command = options.command ?? [process.execPath, CLI];
92
+ const alive = new Map();
93
+ /** A runner's boxes being taken, or taken: one deletion per runner, shared while in flight, retried after a failure. */
94
+ const boxesTaken = new Map();
95
+ const start = async (order) => {
96
+ const argv = [...command.slice(1), "run", "--dir", root, "--id", paths.id, "--app", options.app, ...(options.runArgs ?? [])];
97
+ const child = spawn(command[0], argv, {
98
+ detached: true,
99
+ env: { PATH: process.env.PATH || SAFE_PATH, ...order.env },
100
+ stdio: [order.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
101
+ });
102
+ await new Promise((ok, fail) => {
103
+ child.once("spawn", ok);
104
+ child.once("error", (err) => fail(new PlaceError("INVALID_ARGUMENT", `cannot start ${command[0]}`, { cause: err })));
105
+ });
106
+ if (order.stdin !== undefined) {
107
+ child.stdin.on("error", () => { });
108
+ child.stdin.end(order.stdin);
109
+ }
110
+ let outcome;
111
+ let partial = "";
112
+ let stderr = "";
113
+ child.stdout.setEncoding("utf8").on("data", (chunk) => {
114
+ const lines = (partial + chunk).split("\n");
115
+ partial = lines.pop() ?? "";
116
+ for (const line of lines)
117
+ outcome = outcomeLine(line) ?? outcome;
118
+ });
119
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
120
+ if (stderr.length < 64 * 1024)
121
+ stderr += chunk;
122
+ });
123
+ const exited = new Promise((settle) => {
124
+ let ended;
125
+ let done = false;
126
+ const finish = async () => {
127
+ if (done || !ended)
128
+ return;
129
+ done = true;
130
+ outcome = outcomeLine(partial) ?? outcome;
131
+ const fallback = outcome ? undefined : outcomeOf(await readRunRecord(root).catch(() => undefined));
132
+ const report = outcome ?? fallback;
133
+ settle({ exit: ended.signal ?? ended.code, signal: ended.signal, ...(report ? { outcome: report } : {}), stderr: stderr.trim().slice(0, STDERR_KEEP) });
134
+ };
135
+ child.once("exit", (code, signal) => {
136
+ // Its pid is free from here: no kill may reach the group by it again.
137
+ alive.delete(runner);
138
+ ended = { code, signal };
139
+ setTimeout(() => void finish(), OUTPUT_GRACE_MS).unref();
140
+ });
141
+ child.once("close", () => void finish());
142
+ });
143
+ const runner = { epoch: 1, pid: child.pid, exited };
144
+ alive.set(runner, child.pid);
145
+ return runner;
146
+ };
147
+ const kill = async (runner, kind) => {
148
+ // Only a runner still alive: its pid (the group's id) is not yet free for another process to take.
149
+ const pid = alive.get(runner);
150
+ if (pid !== undefined) {
151
+ try {
152
+ process.kill(-pid, "SIGKILL");
153
+ }
154
+ catch {
155
+ /* already gone */
156
+ }
157
+ }
158
+ await runner.exited;
159
+ // Once per runner: a supervisor that kills and then recovers the same runner takes its boxes once. A deletion that
160
+ // failed is forgotten, so the next kill or recover tries again before any runner starts.
161
+ if (kind === "box" && ctx.destroyBoxes) {
162
+ let taking = boxesTaken.get(runner);
163
+ if (!taking) {
164
+ taking = (async () => ctx.destroyBoxes())().then(() => undefined, (error) => {
165
+ boxesTaken.delete(runner);
166
+ throw error;
167
+ });
168
+ boxesTaken.set(runner, taking);
169
+ }
170
+ await taking;
171
+ }
172
+ };
173
+ return {
174
+ name: "local",
175
+ paths,
176
+ kills: ctx.destroyBoxes ? ["process", "box"] : ["process"],
177
+ start,
178
+ // The runner is a child of this machine, so its kill request is the supervisor's own file.
179
+ ...(ctx.requestFile
180
+ ? {
181
+ requestFile: ctx.requestFile,
182
+ signals: (attempt) => {
183
+ const file = ctx.requestFile(attempt);
184
+ return requestSignals(async () => (existsSync(file) ? readFileSync(file, "utf8") : undefined), LOCAL_SIGNAL_POLL_MS, SIGNAL_FAIL_MS);
185
+ },
186
+ }
187
+ : {}),
188
+ kill,
189
+ status: async () => ({ record: (await readRunRecord(root)) ?? null }),
190
+ recover: async (last, kind, order) => {
191
+ await kill(last, kind);
192
+ return { runner: await start(order) };
193
+ },
194
+ release: async () => {
195
+ await Promise.all([...alive.keys()].map((runner) => kill(runner, "process")));
196
+ // pi runs each command in a process group of its own, which the instance's kill does not reach: end what the
197
+ // run recorded, as its next open would, since there may be none. Only under the run's owner lock, as an open
198
+ // does: while another process holds the run, the commands are its own, not this place's.
199
+ const commands = join(root, "commands");
200
+ if (!existsSync(commands))
201
+ return;
202
+ let lock;
203
+ try {
204
+ lock = takeOwnerLock(root);
205
+ }
206
+ catch (error) {
207
+ if (error instanceof HeldError)
208
+ return;
209
+ throw error;
210
+ }
211
+ let reaped;
212
+ try {
213
+ reaped = await reapCommands(commands);
214
+ }
215
+ finally {
216
+ lock.release();
217
+ }
218
+ if (reaped.survivors.length > 0) {
219
+ throw new PlaceError("RELEASE_FAILED", `releasing ${root} left commands alive after SIGKILL: pids ${reaped.survivors.map((m) => m.pid).join(", ")}`);
220
+ }
221
+ },
222
+ };
223
+ };
224
+ }
225
+ /**
226
+ * The kill request a runner writes whole when it parks at its planned crossing, as one signal, read with `read` every
227
+ * `pollMs` until it names a crossing. `read` answers undefined while there is no request yet; a read that fails is tried
228
+ * again, and once reads have failed for `failMs` without a break, the iterator yields one `signals-failed` and ends (a
229
+ * runner parked at its crossing would otherwise wait with no word of why). It never rejects. A custom iterator, not a
230
+ * generator: `return()` must end a
231
+ * pending `next()`, which a generator would queue behind the poll, and it does even while a read is in flight (a read
232
+ * over the network can hang). Each wait has its own wake, dropped after it, so a long poll attaches nothing to anything
233
+ * that lives as long as the iterator.
234
+ */
235
+ function requestSignals(read, pollMs, failMs) {
236
+ return {
237
+ [Symbol.asyncIterator]() {
238
+ let closed = false;
239
+ let wake;
240
+ /** `work`, or undefined as soon as `return()` runs. */
241
+ const unlessReturned = (work) => new Promise((resolve, reject) => {
242
+ wake = () => resolve(undefined);
243
+ work.then(resolve, reject);
244
+ }).finally(() => {
245
+ wake = undefined;
246
+ });
247
+ let failingSince;
248
+ return {
249
+ async next() {
250
+ while (!closed) {
251
+ let text;
252
+ try {
253
+ text = await unlessReturned(read());
254
+ failingSince = undefined;
255
+ }
256
+ catch (error) {
257
+ failingSince ??= Date.now();
258
+ if (!closed && Date.now() - failingSince >= failMs) {
259
+ closed = true;
260
+ const code = error?.code;
261
+ const message = `reading the kill request failed for ${failMs} ms: ${String(error?.message ?? error)}`.slice(0, MESSAGE_KEEP);
262
+ return { done: false, value: { kind: "signals-failed", code: typeof code === "string" && code ? code : "SIGNALS_FAILED", message } };
263
+ }
264
+ }
265
+ const request = closed || text === undefined ? undefined : requestIn(text);
266
+ if (request) {
267
+ closed = true;
268
+ // The nonce is the signal's, not the crossing's: a supervisor reads it at the top, as from any place.
269
+ const { nonce, ...crossing } = request;
270
+ return { done: false, value: { kind: "kill-request", crossing, ...(typeof nonce === "string" ? { nonce } : {}) } };
271
+ }
272
+ if (!closed)
273
+ await unlessReturned(sleep(pollMs));
274
+ }
275
+ return { done: true, value: undefined };
276
+ },
277
+ async return() {
278
+ closed = true;
279
+ wake?.();
280
+ return { done: true, value: undefined };
281
+ },
282
+ };
283
+ },
284
+ };
285
+ }
286
+ /**
287
+ * What a request names: `point` and `n` at least, with every other field it carries; the per-attempt nonce the
288
+ * supervisor put in the order (never the place's token) is lifted out of the crossing into the signal. Anything else (a
289
+ * half write, another file) is not one yet.
290
+ */
291
+ function requestIn(text) {
292
+ try {
293
+ const v = JSON.parse(text);
294
+ return v && typeof v.point === "string" && typeof v.n === "number" && Number.isFinite(v.n) ? v : undefined;
295
+ }
296
+ catch {
297
+ return undefined;
298
+ }
299
+ }
300
+ /**
301
+ * The `outcome` of a `{"outcome":{...}}` line with at least a status; undefined for any other line. Its `models` stays
302
+ * only when it is a plain object of strings (`modelsOf`), as `run.json`'s does.
303
+ */
304
+ function outcomeLine(line) {
305
+ if (!line.startsWith("{"))
306
+ return undefined;
307
+ try {
308
+ const parsed = JSON.parse(line);
309
+ if (!parsed.outcome || typeof parsed.outcome !== "object" || typeof parsed.outcome.status !== "string")
310
+ return undefined;
311
+ const { models: reported, ...outcome } = parsed.outcome;
312
+ const models = modelsOf(reported);
313
+ return models ? { ...outcome, models } : outcome;
314
+ }
315
+ catch {
316
+ return undefined;
317
+ }
318
+ }
319
+ /** Where in the run's directory an Archil place's runners write their kill requests. */
320
+ const REQUESTS = "signals";
321
+ /** Where in the run's directory an Archil place's runners record, one directory per attempt. */
322
+ const RECORDS = "record";
323
+ const BLOB_NAME = /^([0-9a-f]{64})\.[A-Za-z0-9]{1,16}$/;
324
+ const SPOOL_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,200}\.jsonl$/;
325
+ const NEWLINE = 0x0a;
326
+ /** A message for a recording's line: at most 240 characters, so it never carries more than the line can hold. */
327
+ const said = (text) => text.slice(0, MESSAGE_KEEP);
328
+ /**
329
+ * 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
330
+ * stopped. A write that takes none fails, as one that throws (a full disk) does.
331
+ */
332
+ export function writeAll(fd, bytes, write = writeSync) {
333
+ for (let at = 0; at < bytes.length;) {
334
+ const n = write(fd, bytes, at, bytes.length - at);
335
+ if (!(n > 0))
336
+ throw new PlaceError("CARRY_FAILED", `a write took no bytes, ${bytes.length - at} of ${bytes.length} left`);
337
+ at += n;
338
+ }
339
+ }
340
+ /** `bytes` into `file` whole: a temporary file, fsync, rename, then the directory's fsync. */
341
+ function writeWhole(file, bytes) {
342
+ const dir = dirname(file);
343
+ mkdirSync(dir, { recursive: true });
344
+ const tmp = join(dir, `.${randomUUID()}.tmp`);
345
+ const fd = openSync(tmp, "wx", 0o644);
346
+ try {
347
+ writeAll(fd, bytes);
348
+ fsyncSync(fd);
349
+ }
350
+ catch (error) {
351
+ closeSync(fd);
352
+ rmSync(tmp, { force: true });
353
+ throw error;
354
+ }
355
+ closeSync(fd);
356
+ renameSync(tmp, file);
357
+ syncDir(dir);
358
+ }
359
+ /** `bytes` at the end of `file`, fsynced; a file it creates is made durable in its directory too. */
360
+ function appendDurably(file, bytes) {
361
+ const dir = dirname(file);
362
+ const created = !existsSync(file);
363
+ mkdirSync(dir, { recursive: true });
364
+ const fd = openSync(file, "a", 0o644);
365
+ try {
366
+ writeAll(fd, bytes);
367
+ fsyncSync(fd);
368
+ }
369
+ finally {
370
+ closeSync(fd);
371
+ }
372
+ if (created)
373
+ syncDir(dir);
374
+ }
375
+ /** How long a carrier waits for another one appending to the same destination file. */
376
+ const CARRY_LOCK_WAIT_MS = 5_000;
377
+ /** A lock file with no holder written yet (its taker is between the create and the write) counts as held this long. */
378
+ const CARRY_LOCK_UNNAMED_MS = 10_000;
379
+ const alive = (pid) => {
380
+ try {
381
+ process.kill(pid, 0);
382
+ return true;
383
+ }
384
+ catch (error) {
385
+ return error.code === "EPERM";
386
+ }
387
+ };
388
+ /**
389
+ * `fn` while holding `lock`, a file created exclusively that names its holder's pid, so two carriers on one destination
390
+ * (two places, two processes) never append at once. A lock whose holder is gone (its process ended) is taken over; one a
391
+ * live holder keeps past CARRY_LOCK_WAIT_MS is CARRY_FAILED. `fn` is synchronous, so the lock is held only for it.
392
+ */
393
+ async function withCarryLock(lock, fn) {
394
+ for (const t0 = Date.now();;) {
395
+ let fd;
396
+ try {
397
+ fd = openSync(lock, "wx", 0o644);
398
+ }
399
+ catch (error) {
400
+ if (error.code !== "EEXIST")
401
+ throw error;
402
+ }
403
+ if (fd !== undefined) {
404
+ // Once created, the lock goes on every way out: a failed write of the pid or close of the file included.
405
+ try {
406
+ try {
407
+ writeAll(fd, new TextEncoder().encode(String(process.pid)));
408
+ }
409
+ finally {
410
+ closeSync(fd);
411
+ }
412
+ fn();
413
+ }
414
+ finally {
415
+ rmSync(lock, { force: true });
416
+ }
417
+ return;
418
+ }
419
+ let holder = 0;
420
+ let ageMs = 0;
421
+ try {
422
+ holder = Number(readFileSync(lock, "utf8").trim());
423
+ ageMs = Date.now() - statSync(lock).mtimeMs;
424
+ }
425
+ catch (error) {
426
+ // Released meanwhile: take it on the next turn. Any other failure to read it (its mode, the disk) is the carry's.
427
+ if (error.code === "ENOENT" && Date.now() - t0 <= CARRY_LOCK_WAIT_MS)
428
+ continue;
429
+ throw new PlaceError("CARRY_FAILED", said(`reading the carry lock ${lock} failed: ${error?.message ?? error}`), { cause: error });
430
+ }
431
+ const gone = Number.isSafeInteger(holder) && holder > 0 ? !alive(holder) : ageMs > CARRY_LOCK_UNNAMED_MS;
432
+ if (gone) {
433
+ rmSync(lock, { force: true });
434
+ continue;
435
+ }
436
+ if (Date.now() - t0 > CARRY_LOCK_WAIT_MS)
437
+ throw new PlaceError("CARRY_FAILED", said(`another carrier (pid ${holder || "unnamed"}) holds ${lock} past ${CARRY_LOCK_WAIT_MS} ms`));
438
+ await new Promise((resolve) => setTimeout(resolve, 20));
439
+ }
440
+ }
441
+ function syncDir(dir) {
442
+ const fd = openSync(dir, "r");
443
+ try {
444
+ fsyncSync(fd);
445
+ }
446
+ finally {
447
+ closeSync(fd);
448
+ }
449
+ }
450
+ /** The blob hashes (SHA-256, hex) a recording's line names in its `blobs`; none for a line that is not JSON or names none. */
451
+ function blobsOf(line) {
452
+ try {
453
+ const v = JSON.parse(new TextDecoder().decode(line));
454
+ return Array.isArray(v?.blobs) ? v.blobs.filter((b) => typeof b === "string") : [];
455
+ }
456
+ catch {
457
+ return [];
458
+ }
459
+ }
460
+ /**
461
+ * Brings home into `into` what the runners recorded under `prefix` (`runs/<id>/record/`), read through the control (S3
462
+ * ListObjects and GetObject: nothing takes the run's claim). It keeps no state: a blob is home when `into/blobs/<name>`
463
+ * exists, and a spool file's carried bytes are what `into/.spool/<name>` holds, so a second call, or a call from another
464
+ * process, carries nothing twice. Carriers that overlap on one destination never append at once: the place runs one call
465
+ * at a time per destination, and every append happens under a lock file beside its spool file (`.<name>.lock`, naming
466
+ * its holder's pid), where the destination's size is read again so only the lines past it are appended. A lock whose
467
+ * holder is gone is taken over; one held past 5 s is CARRY_FAILED. Every write is carried to its last byte.
468
+ * - Blobs first: each one not yet home is fetched, checked against the hash its name carries, and written whole into
469
+ * `into/blobs`. One whose bytes do not match is skipped; everything else is brought home, then the call throws
470
+ * BLOB_MISMATCH, and the next call tries that blob again.
471
+ * - Then lines: the new whole lines of each `.spool/<name>.jsonl`, the recorder's segments (`<stream>.<n>.jsonl`), each
472
+ * with its own offset: what `into/.spool/<name>` already holds. A file is fetched only when it grew since this place
473
+ * last fetched it (the SDK has no ranged read). A line whose blobs are not home yet waits for the next call, so no line arrives before its blob; on
474
+ * `final` it is carried anyway, and its missing blobs are CARRY_FAILED once everything else is home.
475
+ * - A listing or fetch that fails is CARRY_FAILED. The recording is on the run's disk, not in the box, so a lost box
476
+ * costs only what it had not fsynced: this place never throws BOX_GONE.
477
+ */
478
+ async function carryHome(control, prefix, into, final, fetched) {
479
+ const fetch = async (key) => {
480
+ try {
481
+ return await control.getObject(key);
482
+ }
483
+ catch (error) {
484
+ throw new PlaceError("CARRY_FAILED", said(`reading ${key.slice(prefix.length)} of the run's recording failed: ${error?.message ?? error}`), { cause: error });
485
+ }
486
+ };
487
+ let objects;
488
+ try {
489
+ objects = (await control.listObjects(prefix, { recursive: true })).objects;
490
+ }
491
+ catch (error) {
492
+ throw new PlaceError("CARRY_FAILED", said(`listing the run's recording failed: ${error?.message ?? error}`), { cause: error });
493
+ }
494
+ // `<attempt>/blobs/<name>` and `<attempt>/.spool/<name>.jsonl`; anything else (`started`, a box's temporary file) is not carried.
495
+ const parts = (key) => key.slice(prefix.length).split("/");
496
+ const blobs = objects.filter((o) => {
497
+ const p = parts(o.key);
498
+ return p.length === 3 && p[1] === "blobs" && BLOB_NAME.test(p[2]);
499
+ });
500
+ const spools = objects.filter((o) => {
501
+ const p = parts(o.key);
502
+ return p.length === 3 && p[1] === ".spool" && SPOOL_NAME.test(p[2]);
503
+ });
504
+ const home = (name) => existsSync(join(into, "blobs", name));
505
+ const mismatched = [];
506
+ for (const blob of blobs) {
507
+ const name = parts(blob.key)[2];
508
+ if (home(name))
509
+ continue;
510
+ const bytes = await fetch(blob.key);
511
+ if (createHash("sha256").update(bytes).digest("hex") !== BLOB_NAME.exec(name)[1]) {
512
+ mismatched.push(name);
513
+ continue;
514
+ }
515
+ writeWhole(join(into, "blobs", name), bytes);
516
+ }
517
+ // A line names its blobs by hash; the file is `<hash>.<ext>`.
518
+ const homeHashes = new Set(existsSync(join(into, "blobs")) ? readdirSync(join(into, "blobs")).map((n) => BLOB_NAME.exec(n)?.[1]).filter((h) => h !== undefined) : []);
519
+ const waiting = new Set();
520
+ for (const spool of spools) {
521
+ const name = parts(spool.key)[2];
522
+ const dest = join(into, ".spool", name);
523
+ const carried = existsSync(dest) ? statSync(dest).size : 0;
524
+ // Not fetched again while its size is what this place last fetched and nothing in it waited for a blob (a partial
525
+ // last line keeps it bigger than what is home). Never skipped on `final`. Only a saving: what is home is still what
526
+ // the destination holds.
527
+ // Per destination: what this place fetched for one `into` says nothing of another.
528
+ const cacheKey = `${resolve(into)}\0${spool.key}`;
529
+ if (spool.size !== undefined && (spool.size <= carried || (!final && fetched.get(cacheKey) === spool.size)))
530
+ continue;
531
+ const bytes = await fetch(spool.key);
532
+ if (bytes.length <= carried)
533
+ continue;
534
+ let heldForBlob = false;
535
+ let end = carried;
536
+ for (let at = carried; at < bytes.length;) {
537
+ const nl = bytes.indexOf(NEWLINE, at);
538
+ if (nl < 0)
539
+ break; // a line still being written
540
+ const missing = blobsOf(bytes.subarray(at, nl)).filter((hash) => !homeHashes.has(hash));
541
+ if (missing.length > 0 && !final) {
542
+ heldForBlob = true;
543
+ break;
544
+ }
545
+ for (const b of missing)
546
+ waiting.add(b);
547
+ end = nl + 1;
548
+ at = nl + 1;
549
+ }
550
+ if (end > carried) {
551
+ // Another carrier on this destination (another place, another process) may have appended since `carried` was read.
552
+ // Under the file's lock, what the destination holds now decides: only the lines past it are appended.
553
+ mkdirSync(dirname(dest), { recursive: true });
554
+ await withCarryLock(join(dirname(dest), `.${name}.lock`), () => {
555
+ const now = existsSync(dest) ? statSync(dest).size : 0;
556
+ if (end > now)
557
+ appendDurably(dest, bytes.subarray(now, end));
558
+ });
559
+ }
560
+ if (heldForBlob)
561
+ fetched.delete(cacheKey);
562
+ else
563
+ fetched.set(cacheKey, bytes.length);
564
+ }
565
+ if (mismatched.length > 0) {
566
+ throw new PlaceError("BLOB_MISMATCH", said(`${mismatched.length} blob(s) of the run's recording do not hash to their names: ${mismatched.join(", ")}`));
567
+ }
568
+ if (final && waiting.size > 0) {
569
+ throw new PlaceError("CARRY_FAILED", said(`${waiting.size} blob(s) the run's recording names never reached its disk: ${[...waiting].join(", ")}`));
570
+ }
571
+ }
572
+ const countFrom1 = (attempt) => {
573
+ if (!Number.isSafeInteger(attempt) || attempt < 1)
574
+ throw new PlaceError("INVALID_ARGUMENT", `attempt ${attempt} is not a count from 1`);
575
+ return attempt;
576
+ };
577
+ const requestName = (attempt) => `kill-${countFrom1(attempt)}.json`;
578
+ /**
579
+ * The run on an Archil disk, its instance run by a host driver. `token(attempt)` mints the attempt's mount token through
580
+ * the place's control, and the runner it is handed to mounts with it. A start is `ensureRunning` (it creates the run's
581
+ * directory with `ensure.create` and writes the start mark) with that token and the order's environment. A kill is the
582
+ * driver's `kill`: `process` or `box`, whichever the driver takes. A recovery is the takeover: the dead holder's
583
+ * delegations revoked (its lease counts as expired, since the place killed it) and the next instance started, which
584
+ * `revoked` and `ms` report. A runner writes its kill request in the run's directory (`requestFile`), and `signals` reads
585
+ * it over S3. A runner ends when the driver no longer reports it running, or when the place killed it; its
586
+ * outcome is `run.json` read over S3, with `code`, `message`, `record` and `storeSha256` from the detail of the last
587
+ * status the app set. `release` stops every instance it started and removes every token it minted; the run's directory
588
+ * stays (`deleteRunTree`).
589
+ */
590
+ export function archilPlace(options) {
591
+ const { host } = options;
592
+ // Every call the place makes on the disk is bounded as ensureRunning bounds its own: one that does not answer fails as
593
+ // CONTROL_API_FAILED, so a stalled request never leaves a recovery, a release or a runner's end waiting forever.
594
+ const control = withTimeouts(options.control, options.ensure?.controlTimeoutMs ?? CONTROL_TIMEOUT_MS);
595
+ const pollMs = options.pollMs ?? 1_000;
596
+ const startTimeoutMs = options.startTimeoutMs ?? 60_000;
597
+ const signalPollMs = options.signalPollMs ?? 500;
598
+ const signalFailMs = options.signalFailMs ?? SIGNAL_FAIL_MS;
599
+ return (ctx) => {
600
+ const ref = { disk: options.disk, region: options.region, id: options.id ?? ctx.paths.id };
601
+ runPath(ref.id);
602
+ const hostRoot = host.mountRoot;
603
+ const mountRoot = options.mountRoot ?? (typeof hostRoot === "string" ? hostRoot : DEFAULT_MOUNT_ROOT);
604
+ const root = posix.join(mountRoot, runPath(ref.id));
605
+ const paths = { id: ref.id, root, work: posix.join(root, "work"), store: posix.join(root, "store") };
606
+ const minted = new Map();
607
+ const handles = [];
608
+ /** Every handle the place started, released or not: the boxes it reports. */
609
+ const started = [];
610
+ const killed = new Map();
611
+ let resolver = pathlessResolver(control);
612
+ const records = `${runPath(ref.id)}/${RECORDS}/`;
613
+ const recording = typeof options.control.listObjects === "function";
614
+ const owner = options.ensure?.create;
615
+ /**
616
+ * Before attempt `gen`'s runner starts: the EMPTY directories it writes into, `signals/` and, with `recordHome`,
617
+ * `record/<gen>/.spool/` and `record/<gen>/blobs/`, made over the control (PutObject with the owner, as createRunDir
618
+ * does: an exec mkdir would make them root's) and owned by the run user (`ensure.create`), in ensureRunning's window
619
+ * where no delegation holds the run (a held run refuses them: DIRS_REFUSED). A directory a process
620
+ * made by path on an Archil mount was seen to refuse file creates afterwards (ENOENT), and one made over the control
621
+ * does not. Every file in them stays the runner's (its kill request, its started, its spool), and a runner that
622
+ * makes them itself finds them made. Without an owner nothing is made: a directory the run user does not own would
623
+ * refuse the runner's files.
624
+ */
625
+ const makeDirs = async (gen) => {
626
+ if (!owner)
627
+ return;
628
+ const run = `${runPath(ref.id)}/`;
629
+ const dirs = [`${run}${REQUESTS}/`, ...(recording ? [`${run}${RECORDS}/`, `${run}${RECORDS}/${gen}/`, `${run}${RECORDS}/${gen}/.spool/`, `${run}${RECORDS}/${gen}/blobs/`] : [])];
630
+ for (const dir of dirs) {
631
+ try {
632
+ if (!(await control.headObject(dir)))
633
+ await control.putObject(dir, "", { uid: owner.uid, gid: owner.gid, mode: owner.mode ?? 0o755 });
634
+ }
635
+ catch (error) {
636
+ throw new PlaceError("DIRS_REFUSED", said(`making ${dir.slice(run.length)} in the run over the control was refused: ${error?.message ?? error}`), { cause: error });
637
+ }
638
+ }
639
+ };
640
+ const fetchedSizes = new Map();
641
+ const carrying = new Map();
642
+ const handleOf = new Map();
643
+ const mint = async (attempt) => {
644
+ const t = await mintMountToken(control, { run: { id: ref.id, attempt }, prefix: options.ensure?.tokenPrefix, ttl: options.ensure?.tokenTtl ?? TOKEN_TTL });
645
+ const m = { ...t, removed: false };
646
+ minted.set(t.token, m);
647
+ return m;
648
+ };
649
+ const drop = async (m) => {
650
+ if (m.removed)
651
+ return;
652
+ await removeMountToken(control, m.identifier);
653
+ m.removed = true;
654
+ };
655
+ const outcome = async () => outcomeOf(await readRunStatus(control, ref.id).catch(() => null));
656
+ const watch = (handle) => {
657
+ let onKill = () => { };
658
+ const killedNow = new Promise((r) => (onKill = () => r("killed")));
659
+ let wasKilled = false;
660
+ const exited = (async () => {
661
+ let failures = 0;
662
+ let status = "running";
663
+ while (!wasKilled) {
664
+ try {
665
+ status = await host.status(handle);
666
+ failures = 0;
667
+ }
668
+ catch (err) {
669
+ if (++failures >= 10)
670
+ throw new PlaceError("STATUS_FAILED", `the status of ${String(handle.name ?? handle.driver)} failed ${failures} times`, { cause: err });
671
+ }
672
+ if (failures === 0 && status !== "running")
673
+ break;
674
+ await Promise.race([sleep(pollMs), killedNow]);
675
+ }
676
+ const report = await outcome();
677
+ return wasKilled
678
+ ? { exit: "SIGKILL", signal: "SIGKILL", ...(report ? { outcome: report } : {}), stderr: "" }
679
+ : { exit: status, signal: null, ...(report ? { outcome: report } : {}), stderr: "" };
680
+ })();
681
+ return {
682
+ exited,
683
+ kill: () => {
684
+ wasKilled = true;
685
+ onKill();
686
+ },
687
+ };
688
+ };
689
+ /** A start or a takeover: `ensureRunning` with the order's token and environment, until it starts or decides there is nothing to start. */
690
+ const ensure = async (order, takeover) => {
691
+ const given = order.stdin !== undefined ? minted.get(order.stdin) : undefined;
692
+ const m = given && !given.removed ? given : await mint(order.gen);
693
+ // The driver gets the order's environment with each start; everything else is the host's own.
694
+ const driver = {
695
+ // Inside ensureRunning, after its revoke and start mark: no delegation holds the run, so S3 takes the writes.
696
+ start: async (r, token, attempt) => {
697
+ await makeDirs(order.gen);
698
+ return host.start(r, token, { attempt: attempt?.attempt ?? order.gen, env: order.env });
699
+ },
700
+ status: (h) => host.status(h),
701
+ stop: (h) => host.stop(h),
702
+ };
703
+ const t0 = performance.now();
704
+ const deadline = Date.now() + startTimeoutMs;
705
+ for (;;) {
706
+ let result;
707
+ try {
708
+ result = await ensureRunning(ref, driver, {
709
+ ...options.ensure,
710
+ control,
711
+ demand: true,
712
+ mountToken: m,
713
+ // The place killed the holder: whatever its lease says, it is expired, and no start of it is in flight.
714
+ ...(takeover ? { leaseExpiryMs: Number.NEGATIVE_INFINITY, startGraceMs: 0 } : {}),
715
+ });
716
+ }
717
+ catch (err) {
718
+ // Nothing mounted with the token. ensureRunning's own removal ignores a failure, so remove it again (Archil
719
+ // answers a removal of a gone user with success): a failure here leaves it for release.
720
+ await drop(m).catch(() => { });
721
+ throw err;
722
+ }
723
+ if (result.action === "started") {
724
+ // An adopted instance mounts with an earlier start's token: this one is unused, removed as above.
725
+ if (result.adopted)
726
+ await drop(m).catch(() => { });
727
+ handles.push(result.handle);
728
+ started.push(result.handle);
729
+ const w = watch(result.handle);
730
+ const mark = result.startMark ? await readStartMark(control, ref.id).catch(() => null) : null;
731
+ const runner = { epoch: mark?.generation ?? order.gen, exited: w.exited };
732
+ killed.set(runner, w.kill);
733
+ handleOf.set(runner, result.handle);
734
+ // A runner the driver reports ended is gone: a later kill of it is a no-op. One whose status could not be read
735
+ // may still run, so it stays killable.
736
+ w.exited.then(() => void killed.delete(runner), () => undefined);
737
+ return { result, runner, ms: Math.round(performance.now() - t0) };
738
+ }
739
+ if ((result.action === "pending" || result.action === "starting") && Date.now() < deadline) {
740
+ await sleep(pollMs);
741
+ continue;
742
+ }
743
+ await drop(m).catch(() => { });
744
+ if (result.action === "terminal") {
745
+ // Nothing to start: the run is done or failed, and the runner ends as it begins.
746
+ const report = await outcome();
747
+ return { result, runner: { epoch: result.generation, exited: Promise.resolve({ exit: null, signal: null, ...(report ? { outcome: report } : {}), stderr: "" }) }, ms: Math.round(performance.now() - t0) };
748
+ }
749
+ if (result.action === "healthy")
750
+ throw new PlaceError("HELD", `${runPath(ref.id)} is held by a live instance this place did not start`);
751
+ throw new PlaceError("START_TIMED_OUT", `${runPath(ref.id)} stayed ${result.action} for ${startTimeoutMs} ms`);
752
+ }
753
+ };
754
+ // The driver's kill, whatever the kind asked: it takes what `kills` says. Once per runner.
755
+ const kill = async (runner, _kind) => {
756
+ if (!host.kill)
757
+ throw new PlaceError("KILL_UNSUPPORTED", "the place's host driver has no kill");
758
+ const handle = handleOf.get(runner);
759
+ const end = killed.get(runner);
760
+ if (handle && end) {
761
+ await host.kill(handle);
762
+ killed.delete(runner);
763
+ end();
764
+ // The kill is made; a runner whose status reads had failed still reports that failure through `exited`.
765
+ await runner.exited.catch(() => undefined);
766
+ return;
767
+ }
768
+ await runner.exited;
769
+ };
770
+ return {
771
+ name: "archil",
772
+ paths,
773
+ kills: host.kill && host.kills ? [host.kills] : [],
774
+ token: async (attempt) => (await mint(attempt)).token,
775
+ ...(recording
776
+ ? {
777
+ recordHome: {
778
+ // In the run's directory, so the runner's delegation covers it and deleteRunTree removes it.
779
+ dir: (attempt) => posix.join(root, RECORDS, String(countFrom1(attempt))),
780
+ // One carry at a time per destination in this place; another place or process is held off by the file lock.
781
+ bringHome: (into, carry = {}) => {
782
+ const key = resolve(into);
783
+ const run = (carrying.get(key) ?? Promise.resolve()).catch(() => { }).then(() => carryHome(control, records, into, carry.final === true, fetchedSizes));
784
+ const settled = run.then(() => { }, () => { });
785
+ carrying.set(key, settled);
786
+ void settled.then(() => {
787
+ if (carrying.get(key) === settled)
788
+ carrying.delete(key);
789
+ });
790
+ return run;
791
+ },
792
+ },
793
+ }
794
+ : {}),
795
+ // The request goes in the run's directory, outside work/ where the agent's tools are confined, and the place reads
796
+ // it over S3: a runner on a box of its own cannot reach the supervisor's files.
797
+ requestFile: (attempt) => posix.join(root, REQUESTS, requestName(attempt)),
798
+ signals: (attempt) => {
799
+ const key = `${runPath(ref.id)}/${REQUESTS}/${requestName(attempt)}`;
800
+ return requestSignals(async () => {
801
+ try {
802
+ return new TextDecoder().decode(await control.getObject(key));
803
+ }
804
+ catch (error) {
805
+ if (notThere(error))
806
+ return undefined;
807
+ throw error;
808
+ }
809
+ }, signalPollMs, signalFailMs);
810
+ },
811
+ start: async (order) => (await ensure(order, false)).runner,
812
+ kill,
813
+ status: async () => {
814
+ // One resolver for the place's life: Archil lists a fresh run's delegation with no path, and its inode cache spares
815
+ // every later call the `find` on the disk. It keeps its first failure for good, so a failed call gets the next
816
+ // call a new one: one transient error must not fail the rest of a long run.
817
+ const resolve = resolver;
818
+ const [record, delegations] = await Promise.all([
819
+ readRunStatus(control, ref.id),
820
+ findDelegations(control, ref.id, resolve).catch((error) => {
821
+ if (resolver === resolve)
822
+ resolver = pathlessResolver(control);
823
+ throw error;
824
+ }),
825
+ ]);
826
+ const boxes = host.box ? started.map((h) => host.box(h)).filter((b) => b !== null) : undefined;
827
+ return { record, delegations: delegations.map(({ clientId, inodeId, path, isOrphaned, isPending }) => ({ clientId, inodeId, path, isOrphaned, isPending })), ...(boxes ? { boxes } : {}) };
828
+ },
829
+ recover: async (last, kind, order) => {
830
+ if (killed.has(last))
831
+ await kill(last, kind);
832
+ // Forced only while the newest incarnation the disk knows of (run.json or a start mark) is the one this place
833
+ // killed. A newer one, another supervisor's, gets the usual lease and start-grace checks.
834
+ const [record, mark] = await Promise.all([readRunStatus(control, ref.id), readStartMark(control, ref.id)]);
835
+ const newest = Math.max(record?.generation ?? 0, mark?.generation ?? 0);
836
+ const { result, runner, ms } = await ensure(order, handleOf.has(last) && newest <= last.epoch);
837
+ return { runner, revoked: result.action === "started" ? result.revoked : [], ms };
838
+ },
839
+ release: async () => {
840
+ const failed = [];
841
+ // A handle leaves the list only once its stop succeeded, so a later release tries a failed one again.
842
+ for (const handle of [...handles]) {
843
+ try {
844
+ await host.stop(handle);
845
+ handles.splice(handles.indexOf(handle), 1);
846
+ }
847
+ catch (err) {
848
+ failed.push(`stop ${String(handle.name ?? handle.driver)}: ${err.message}`);
849
+ }
850
+ }
851
+ for (const m of minted.values()) {
852
+ await drop(m).catch((err) => failed.push(`token ${m.nickname}: ${err.message}`));
853
+ }
854
+ if (failed.length)
855
+ throw new PlaceError("RELEASE_FAILED", `releasing ${runPath(ref.id)} left: ${failed.join("; ")}`);
856
+ },
857
+ };
858
+ };
859
+ }