@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 +58 -0
- package/README.md +50 -6
- package/dist/confine.js +4 -3
- package/dist/env.js +3 -2
- package/dist/held-fd.d.ts +5 -0
- package/dist/held-fd.js +14 -0
- package/dist/hosts/daytona.d.ts +25 -0
- package/dist/hosts/daytona.js +59 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/place.d.ts +51 -5
- package/dist/place.js +342 -9
- package/dist/run.js +11 -2
- package/dist/status.d.ts +23 -0
- package/dist/status.js +120 -1
- package/dist/supervise.d.ts +20 -13
- package/dist/supervise.js +4 -17
- package/package.json +2 -2
package/dist/place.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// kill (agentrun's reference demo) is the same for every place; the place decides how. `localPlace` runs the instance on
|
|
3
3
|
// a directory of this machine (`run --dir`, no disk and no key); `archilPlace` runs it on an Archil disk through a host
|
|
4
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 (
|
|
5
|
+
// place seam (@parcha/agentrun-reference/place) structurally. The supervisor reads nothing under the run's directory: the
|
|
6
6
|
// outcome, the run's record and its delegations come through the place.
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
12
|
import { DEFAULT_MOUNT_ROOT, findDelegations, mintMountToken, pathlessResolver, removeMountToken, runPath } from "./claim.js";
|
|
12
13
|
import { reapCommands } from "./commands.js";
|
|
@@ -48,6 +49,7 @@ function outcomeOf(record) {
|
|
|
48
49
|
return undefined;
|
|
49
50
|
const detail = record.detail !== null && typeof record.detail === "object" && !Array.isArray(record.detail) ? record.detail : {};
|
|
50
51
|
const delivered = record.status === "done" && "record" in detail && detail.record !== null && detail.record !== undefined;
|
|
52
|
+
const models = modelsOf(detail.models);
|
|
51
53
|
return {
|
|
52
54
|
status: delivered ? "complete" : "failed",
|
|
53
55
|
generation: record.generation,
|
|
@@ -55,8 +57,22 @@ function outcomeOf(record) {
|
|
|
55
57
|
...(typeof detail.message === "string" ? { message: detail.message } : {}),
|
|
56
58
|
...("record" in detail ? { record: detail.record } : {}),
|
|
57
59
|
...(typeof detail.storeSha256 === "string" ? { storeSha256: detail.storeSha256 } : {}),
|
|
60
|
+
...(models ? { models } : {}),
|
|
58
61
|
};
|
|
59
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
|
+
}
|
|
60
76
|
const pathsUnder = (id, root) => ({ id, root, work: join(root, "work"), store: join(root, "store") });
|
|
61
77
|
/**
|
|
62
78
|
* The run on a directory of this machine: each runner is `pi-durable-disk run --dir <root> --id <id> --app <app>`, a
|
|
@@ -281,13 +297,20 @@ function requestIn(text) {
|
|
|
281
297
|
return undefined;
|
|
282
298
|
}
|
|
283
299
|
}
|
|
284
|
-
/**
|
|
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
|
+
*/
|
|
285
304
|
function outcomeLine(line) {
|
|
286
305
|
if (!line.startsWith("{"))
|
|
287
306
|
return undefined;
|
|
288
307
|
try {
|
|
289
308
|
const parsed = JSON.parse(line);
|
|
290
|
-
|
|
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;
|
|
291
314
|
}
|
|
292
315
|
catch {
|
|
293
316
|
return undefined;
|
|
@@ -295,11 +318,263 @@ function outcomeLine(line) {
|
|
|
295
318
|
}
|
|
296
319
|
/** Where in the run's directory an Archil place's runners write their kill requests. */
|
|
297
320
|
const REQUESTS = "signals";
|
|
298
|
-
|
|
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) => {
|
|
299
573
|
if (!Number.isSafeInteger(attempt) || attempt < 1)
|
|
300
574
|
throw new PlaceError("INVALID_ARGUMENT", `attempt ${attempt} is not a count from 1`);
|
|
301
|
-
return
|
|
575
|
+
return attempt;
|
|
302
576
|
};
|
|
577
|
+
const requestName = (attempt) => `kill-${countFrom1(attempt)}.json`;
|
|
303
578
|
/**
|
|
304
579
|
* The run on an Archil disk, its instance run by a host driver. `token(attempt)` mints the attempt's mount token through
|
|
305
580
|
* the place's control, and the runner it is handed to mounts with it. A start is `ensureRunning` (it creates the run's
|
|
@@ -330,8 +605,40 @@ export function archilPlace(options) {
|
|
|
330
605
|
const paths = { id: ref.id, root, work: posix.join(root, "work"), store: posix.join(root, "store") };
|
|
331
606
|
const minted = new Map();
|
|
332
607
|
const handles = [];
|
|
608
|
+
/** Every handle the place started, released or not: the boxes it reports. */
|
|
609
|
+
const started = [];
|
|
333
610
|
const killed = new Map();
|
|
334
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();
|
|
335
642
|
const handleOf = new Map();
|
|
336
643
|
const mint = async (attempt) => {
|
|
337
644
|
const t = await mintMountToken(control, { run: { id: ref.id, attempt }, prefix: options.ensure?.tokenPrefix, ttl: options.ensure?.tokenTtl ?? TOKEN_TTL });
|
|
@@ -385,7 +692,11 @@ export function archilPlace(options) {
|
|
|
385
692
|
const m = given && !given.removed ? given : await mint(order.gen);
|
|
386
693
|
// The driver gets the order's environment with each start; everything else is the host's own.
|
|
387
694
|
const driver = {
|
|
388
|
-
|
|
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
|
+
},
|
|
389
700
|
status: (h) => host.status(h),
|
|
390
701
|
stop: (h) => host.stop(h),
|
|
391
702
|
};
|
|
@@ -414,6 +725,7 @@ export function archilPlace(options) {
|
|
|
414
725
|
if (result.adopted)
|
|
415
726
|
await drop(m).catch(() => { });
|
|
416
727
|
handles.push(result.handle);
|
|
728
|
+
started.push(result.handle);
|
|
417
729
|
const w = watch(result.handle);
|
|
418
730
|
const mark = result.startMark ? await readStartMark(control, ref.id).catch(() => null) : null;
|
|
419
731
|
const runner = { epoch: mark?.generation ?? order.gen, exited: w.exited };
|
|
@@ -460,6 +772,26 @@ export function archilPlace(options) {
|
|
|
460
772
|
paths,
|
|
461
773
|
kills: host.kill && host.kills ? [host.kills] : [],
|
|
462
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
|
+
: {}),
|
|
463
795
|
// The request goes in the run's directory, outside work/ where the agent's tools are confined, and the place reads
|
|
464
796
|
// it over S3: a runner on a box of its own cannot reach the supervisor's files.
|
|
465
797
|
requestFile: (attempt) => posix.join(root, REQUESTS, requestName(attempt)),
|
|
@@ -491,7 +823,8 @@ export function archilPlace(options) {
|
|
|
491
823
|
throw error;
|
|
492
824
|
}),
|
|
493
825
|
]);
|
|
494
|
-
|
|
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 } : {}) };
|
|
495
828
|
},
|
|
496
829
|
recover: async (last, kind, order) => {
|
|
497
830
|
if (killed.has(last))
|
package/dist/run.js
CHANGED
|
@@ -15,7 +15,7 @@ import { archilEnv } from "./env.js";
|
|
|
15
15
|
import { EXIT_FENCED, FencedError, HeldError, ownWriteFenced, PdaError } from "./errors.js";
|
|
16
16
|
import { openArchilStore } from "./store.js";
|
|
17
17
|
import { LeaseWatchdog } from "./watchdog.js";
|
|
18
|
-
import { currentHolder, DEFAULT_HEARTBEAT_MS, DEFAULT_LEASE_EXPIRY_MS, DEFAULT_LEASE_MARGIN_MS, openClaimDir, persistRecord, readRunRecord, RUN_JSON, RunRecordError, writeRunRecord, } from "./status.js";
|
|
18
|
+
import { currentHolder, DEFAULT_HEARTBEAT_MS, DEFAULT_LEASE_EXPIRY_MS, DEFAULT_LEASE_MARGIN_MS, keepStartMark, openClaimDir, persistRecord, readRunRecord, RUN_JSON, RunRecordError, writeRunRecord, } from "./status.js";
|
|
19
19
|
/** The same-host owner lock under the run's root. */
|
|
20
20
|
export const OWNER_LOCK = "owner.lock";
|
|
21
21
|
/** pi's store under the claim's `store/` directory. */
|
|
@@ -358,7 +358,16 @@ class Lease {
|
|
|
358
358
|
if (seed !== undefined && previous === undefined) {
|
|
359
359
|
await step("seed", async () => {
|
|
360
360
|
await this.#dir.assertMounted();
|
|
361
|
-
|
|
361
|
+
// While a new run seeds it has no `run.json` heartbeat, and a supervisor keeps a start in its grace only while its
|
|
362
|
+
// start mark is younger than the grace: the mark is kept fresh until the seed ends, through the held claim directory.
|
|
363
|
+
// A seeder that dies stops, and the grace runs out as for any start.
|
|
364
|
+
const stop = keepStartMark(this.#dir.at, this.#lease.heartbeatMs, claim.root);
|
|
365
|
+
try {
|
|
366
|
+
await seed({ root: claim.root, storePath: join(claim.store, STORE_FILE), workDir: claim.work });
|
|
367
|
+
}
|
|
368
|
+
finally {
|
|
369
|
+
await stop();
|
|
370
|
+
}
|
|
362
371
|
await this.#dir.assertMounted();
|
|
363
372
|
await claim.barrier();
|
|
364
373
|
});
|
package/dist/status.d.ts
CHANGED
|
@@ -81,6 +81,29 @@ export declare const persistRecord: PersistRecord;
|
|
|
81
81
|
* `PersistRecord`.
|
|
82
82
|
*/
|
|
83
83
|
export declare function writeRunRecord(root: string | ClaimDir, record: RunRecord, persist?: PersistRecord, signal?: AbortSignal): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* The supervisor's record of its last start of a run, in the run's directory: the generation the instance will write,
|
|
86
|
+
* when, by whom, and the failed starts right before it (`failures`; `lastExit`, what the decision saw of the last one).
|
|
87
|
+
*/
|
|
88
|
+
export declare const START_MARK = "start.json";
|
|
89
|
+
export type StartMark = {
|
|
90
|
+
generation: number;
|
|
91
|
+
at: string;
|
|
92
|
+
by: string | null;
|
|
93
|
+
failures: number;
|
|
94
|
+
lastExit: string | null;
|
|
95
|
+
};
|
|
96
|
+
/** A start mark's text as a mark, or null when it is not one (an unreadable mark gives no grace). */
|
|
97
|
+
export declare function parseStartMark(text: string): StartMark | null;
|
|
98
|
+
/**
|
|
99
|
+
* Keeps a supervisor's start mark for generation 1 in `dir` fresh: its `at` rewritten every `everyMs` (temp file, fsync,
|
|
100
|
+
* rename, fsync the directory), the rest of it as the supervisor wrote it; nothing is made when there is none. On a thread of
|
|
101
|
+
* its own, so a caller whose thread is blocked (a synchronous import) is kept fresh too. Only while `dir` is the directory
|
|
102
|
+
* the keeper first saw there. Best effort: a failure leaves the mark as it was, and the first one is a process warning
|
|
103
|
+
* (`PDA_START_MARK`) naming `label`; the grace then runs out as for any start. Returns the stop, which resolves once the
|
|
104
|
+
* thread has exited, so no write outlives it.
|
|
105
|
+
*/
|
|
106
|
+
export declare function keepStartMark(dir: string, everyMs: number, label?: string): () => Promise<void>;
|
|
84
107
|
/** The FencedError code of a claim root that is no longer the mount the instance opened (unmounted, lazily or not). */
|
|
85
108
|
export declare const CLAIM_UNMOUNTED = "CLAIM_UNMOUNTED";
|
|
86
109
|
/** The file system type of an Archil mount in the mount table. */
|
package/dist/status.js
CHANGED
|
@@ -6,7 +6,9 @@ import { constants } from "node:fs";
|
|
|
6
6
|
import { open, readFile, rename, rm, stat } from "node:fs/promises";
|
|
7
7
|
import { hostname } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
+
import { Worker } from "node:worker_threads";
|
|
9
10
|
import { FencedError, ownWriteFenced, PdaError } from "./errors.js";
|
|
11
|
+
import { fdPath, hold } from "./held-fd.js";
|
|
10
12
|
import { thisBoot } from "./process-table.js";
|
|
11
13
|
export const RUN_JSON = "run.json";
|
|
12
14
|
/** The temporary file every write goes through; renamed over `run.json` once it is fsynced. */
|
|
@@ -158,6 +160,122 @@ export async function writeRunRecord(root, record, persist = persistRecord, sign
|
|
|
158
160
|
throw ownWriteFenced(join(typeof root === "string" ? root : root.root, RUN_JSON), error);
|
|
159
161
|
}
|
|
160
162
|
}
|
|
163
|
+
// ---- the supervisor's start mark --------------------------------------------------------------------------------------
|
|
164
|
+
/**
|
|
165
|
+
* The supervisor's record of its last start of a run, in the run's directory: the generation the instance will write,
|
|
166
|
+
* when, by whom, and the failed starts right before it (`failures`; `lastExit`, what the decision saw of the last one).
|
|
167
|
+
*/
|
|
168
|
+
export const START_MARK = "start.json";
|
|
169
|
+
/** A start mark's text as a mark, or null when it is not one (an unreadable mark gives no grace). */
|
|
170
|
+
export function parseStartMark(text) {
|
|
171
|
+
try {
|
|
172
|
+
const v = JSON.parse(text);
|
|
173
|
+
if (!Number.isSafeInteger(v.generation) || v.generation < 1 || typeof v.at !== "string" || !Number.isFinite(Date.parse(v.at)))
|
|
174
|
+
return null;
|
|
175
|
+
const failures = Number.isSafeInteger(v.failures) && v.failures >= 0 ? v.failures : 0;
|
|
176
|
+
const lastExit = typeof v.lastExit === "string" ? v.lastExit : null;
|
|
177
|
+
return { generation: v.generation, at: v.at, by: typeof v.by === "string" ? v.by : null, failures, lastExit };
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// The keeper's code is plain CommonJS in a string, started with `eval: true` and no inherited Node flags, as the lease
|
|
184
|
+
// watchdog's is (src/watchdog.ts says why). One refresh at a time, all synchronous: a stop (the message) is handled only
|
|
185
|
+
// between two refreshes, so none is still writing once the thread has exited. It writes only while `dir` is still the
|
|
186
|
+
// directory it first saw (its device and inode, read on this thread, so a stalled mount never holds the caller's): a path
|
|
187
|
+
// that names another (a descriptor number reused) or none is never written, and the keeper stops. Its first failure goes
|
|
188
|
+
// to the main thread, once.
|
|
189
|
+
const KEEPER_SOURCE = `"use strict";
|
|
190
|
+
const fs = require("node:fs");
|
|
191
|
+
const path = require("node:path");
|
|
192
|
+
const { parentPort, workerData } = require("node:worker_threads");
|
|
193
|
+
const { dir, name, everyMs } = workerData;
|
|
194
|
+
const file = path.join(dir, name);
|
|
195
|
+
let first;
|
|
196
|
+
let stopped = false;
|
|
197
|
+
let reported = false;
|
|
198
|
+
const report = (message) => {
|
|
199
|
+
if (reported) return;
|
|
200
|
+
reported = true;
|
|
201
|
+
parentPort.postMessage(message);
|
|
202
|
+
};
|
|
203
|
+
function refresh() {
|
|
204
|
+
if (stopped) return;
|
|
205
|
+
let now;
|
|
206
|
+
try {
|
|
207
|
+
now = fs.statSync(dir, { bigint: true });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
stopped = true;
|
|
210
|
+
return report("its directory cannot be reached (" + (error.code || error.message) + "), so it is no longer the directory the keeper started on");
|
|
211
|
+
}
|
|
212
|
+
first ??= { dev: now.dev, ino: now.ino };
|
|
213
|
+
if (now.dev !== first.dev || now.ino !== first.ino) {
|
|
214
|
+
stopped = true;
|
|
215
|
+
return report("its directory is no longer the directory the keeper started on");
|
|
216
|
+
}
|
|
217
|
+
let text;
|
|
218
|
+
try {
|
|
219
|
+
text = fs.readFileSync(file, "utf8");
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error.code === "ENOENT") return; // no mark: nothing to keep
|
|
222
|
+
return report("reading it failed (" + (error.code || error.message) + ")");
|
|
223
|
+
}
|
|
224
|
+
let mark;
|
|
225
|
+
try {
|
|
226
|
+
mark = JSON.parse(text);
|
|
227
|
+
} catch {
|
|
228
|
+
return; // not a mark: nothing to keep
|
|
229
|
+
}
|
|
230
|
+
if (mark === null || typeof mark !== "object" || mark.generation !== 1 || typeof mark.at !== "string" || !Number.isFinite(Date.parse(mark.at))) return;
|
|
231
|
+
try {
|
|
232
|
+
const temp = file + ".tmp";
|
|
233
|
+
const fd = fs.openSync(temp, "w", 0o644);
|
|
234
|
+
try {
|
|
235
|
+
fs.writeSync(fd, JSON.stringify({ ...mark, at: new Date().toISOString() }));
|
|
236
|
+
fs.fsyncSync(fd);
|
|
237
|
+
} finally {
|
|
238
|
+
fs.closeSync(fd);
|
|
239
|
+
}
|
|
240
|
+
fs.renameSync(temp, file);
|
|
241
|
+
const handle = fs.openSync(dir, "r");
|
|
242
|
+
try {
|
|
243
|
+
fs.fsyncSync(handle);
|
|
244
|
+
} finally {
|
|
245
|
+
fs.closeSync(handle);
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
report("rewriting it failed (" + (error.code || error.message) + ")");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const timer = setInterval(refresh, everyMs);
|
|
252
|
+
refresh();
|
|
253
|
+
parentPort.once("message", () => {
|
|
254
|
+
clearInterval(timer);
|
|
255
|
+
parentPort.close();
|
|
256
|
+
});
|
|
257
|
+
`;
|
|
258
|
+
/**
|
|
259
|
+
* Keeps a supervisor's start mark for generation 1 in `dir` fresh: its `at` rewritten every `everyMs` (temp file, fsync,
|
|
260
|
+
* rename, fsync the directory), the rest of it as the supervisor wrote it; nothing is made when there is none. On a thread of
|
|
261
|
+
* its own, so a caller whose thread is blocked (a synchronous import) is kept fresh too. Only while `dir` is the directory
|
|
262
|
+
* the keeper first saw there. Best effort: a failure leaves the mark as it was, and the first one is a process warning
|
|
263
|
+
* (`PDA_START_MARK`) naming `label`; the grace then runs out as for any start. Returns the stop, which resolves once the
|
|
264
|
+
* thread has exited, so no write outlives it.
|
|
265
|
+
*/
|
|
266
|
+
export function keepStartMark(dir, everyMs, label = dir) {
|
|
267
|
+
const warn = (why) => process.emitWarning(`the start mark of ${label} is no longer kept fresh: ${why}; a supervisor's start grace runs out as for any start`, { code: "PDA_START_MARK" });
|
|
268
|
+
const worker = new Worker(KEEPER_SOURCE, { eval: true, workerData: { dir, name: START_MARK, everyMs }, execArgv: [] });
|
|
269
|
+
worker.on("message", (why) => warn(why));
|
|
270
|
+
worker.on("error", (error) => warn(`its thread failed (${error.message})`));
|
|
271
|
+
worker.unref();
|
|
272
|
+
const exited = new Promise((resolve) => worker.once("exit", () => resolve()));
|
|
273
|
+
return () => {
|
|
274
|
+
worker.ref();
|
|
275
|
+
worker.postMessage("stop");
|
|
276
|
+
return exited;
|
|
277
|
+
};
|
|
278
|
+
}
|
|
161
279
|
// ---- the claim directory ----------------------------------------------------------------------------------------------
|
|
162
280
|
/** The FencedError code of a claim root that is no longer the mount the instance opened (unmounted, lazily or not). */
|
|
163
281
|
export const CLAIM_UNMOUNTED = "CLAIM_UNMOUNTED";
|
|
@@ -216,7 +334,8 @@ export async function openClaimDir(root, options = {}) {
|
|
|
216
334
|
throw error instanceof FencedError ? error : unmounted(`checking the claim root ${root} failed: ${error.message}`, error);
|
|
217
335
|
}
|
|
218
336
|
const byPath = options.byPath ?? process.platform === "darwin";
|
|
219
|
-
|
|
337
|
+
// Held until close(): `at` names the directory by its number, and a collection must never free that number.
|
|
338
|
+
const at = byPath ? (hold(handle), root) : fdPath(handle);
|
|
220
339
|
let closed = false;
|
|
221
340
|
const same = (path, now, claims) => {
|
|
222
341
|
if (now.dev === claims.dev && now.ino === claims.ino)
|