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

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +262 -0
  2. package/README.md +281 -47
  3. package/dist/app.d.ts +5 -1
  4. package/dist/claim.d.ts +51 -3
  5. package/dist/claim.js +157 -17
  6. package/dist/cli.d.ts +6 -0
  7. package/dist/cli.js +112 -47
  8. package/dist/commands.d.ts +4 -1
  9. package/dist/commands.js +48 -90
  10. package/dist/confine.d.ts +36 -7
  11. package/dist/confine.js +125 -18
  12. package/dist/env.d.ts +7 -22
  13. package/dist/env.js +25 -60
  14. package/dist/errors.d.ts +2 -1
  15. package/dist/fork.d.ts +23 -0
  16. package/dist/fork.js +103 -3
  17. package/dist/hosts/attempt-env.d.ts +3 -0
  18. package/dist/hosts/attempt-env.js +15 -0
  19. package/dist/hosts/daytona-launch.d.ts +3 -1
  20. package/dist/hosts/daytona-launch.js +35 -12
  21. package/dist/hosts/daytona.d.ts +2 -2
  22. package/dist/hosts/daytona.js +48 -5
  23. package/dist/hosts/docker.d.ts +2 -2
  24. package/dist/hosts/docker.js +22 -5
  25. package/dist/hosts/local-host.d.ts +9 -3
  26. package/dist/hosts/local-host.js +59 -22
  27. package/dist/index.d.ts +35 -16
  28. package/dist/index.js +27 -16
  29. package/dist/internals.d.ts +3 -0
  30. package/dist/internals.js +6 -0
  31. package/dist/lease.d.ts +4 -1
  32. package/dist/lease.js +2 -0
  33. package/dist/local-run.js +3 -2
  34. package/dist/place.d.ts +190 -0
  35. package/dist/place.js +526 -0
  36. package/dist/process-table.d.ts +38 -0
  37. package/dist/process-table.js +158 -0
  38. package/dist/run.d.ts +61 -10
  39. package/dist/run.js +69 -33
  40. package/dist/status.d.ts +19 -3
  41. package/dist/status.js +25 -13
  42. package/dist/store.d.ts +14 -2
  43. package/dist/store.js +16 -4
  44. package/dist/supervise.d.ts +43 -1
  45. package/dist/supervise.js +26 -1
  46. package/dist/watchdog.d.ts +3 -1
  47. package/dist/watchdog.js +43 -12
  48. package/dist/with-claim.d.ts +63 -0
  49. package/dist/with-claim.js +325 -0
  50. package/package.json +9 -3
package/dist/place.js ADDED
@@ -0,0 +1,526 @@
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 { existsSync, readFileSync } from "node:fs";
9
+ import { extname, join, posix, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { DEFAULT_MOUNT_ROOT, findDelegations, mintMountToken, pathlessResolver, removeMountToken, runPath } from "./claim.js";
12
+ import { reapCommands } from "./commands.js";
13
+ import { HeldError, PdaError } from "./errors.js";
14
+ import { takeOwnerLock } from "./run.js";
15
+ import { readRunRecord } from "./status.js";
16
+ import { CONTROL_TIMEOUT_MS, ensureRunning, readRunStatus, readStartMark, TOKEN_TTL, withTimeouts, } from "./supervise.js";
17
+ export class PlaceError extends PdaError {
18
+ constructor(code, message, options = {}) {
19
+ super(code, message, options);
20
+ }
21
+ }
22
+ // The package's own CLI, with the extension this module has: `.ts` from a checkout, `.js` once built.
23
+ const CLI = fileURLToPath(new URL(`./cli${extname(import.meta.url)}`, import.meta.url));
24
+ const SAFE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
25
+ /** After the instance exits, how long its output may take to close (a command it left running can hold it open). */
26
+ const OUTPUT_GRACE_MS = 2_000;
27
+ /** How often a local place looks for a kill request (a file of this machine). */
28
+ const LOCAL_SIGNAL_POLL_MS = 20;
29
+ /** How long reads of a kill request may fail without a break before `signals` says so. */
30
+ const SIGNAL_FAIL_MS = 60_000;
31
+ /** An S3 read of a key that is not there (yet): the only failure a request poll expects. */
32
+ const notThere = (error) => {
33
+ const e = error;
34
+ return e.status === 404 || e.statusCode === 404 || e.code === "NoSuchKey";
35
+ };
36
+ const STDERR_KEEP = 600;
37
+ /** The longest message a place reports in a signal, as a kill line's. */
38
+ const MESSAGE_KEEP = 240;
39
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
40
+ /**
41
+ * The outcome `run.json` gives, in the run's vocabulary rather than the record's: `complete` when the run is `done` and
42
+ * delivered a record (the detail of the app's last status carries one), `failed` for anything else (done with no result,
43
+ * failed, or a run its instance left `running`, `paused` or `sleeping`). Its generation, and what the app put in that
44
+ * detail.
45
+ */
46
+ function outcomeOf(record) {
47
+ if (!record)
48
+ return undefined;
49
+ const detail = record.detail !== null && typeof record.detail === "object" && !Array.isArray(record.detail) ? record.detail : {};
50
+ const delivered = record.status === "done" && "record" in detail && detail.record !== null && detail.record !== undefined;
51
+ return {
52
+ status: delivered ? "complete" : "failed",
53
+ generation: record.generation,
54
+ code: typeof detail.code === "string" ? detail.code : null,
55
+ ...(typeof detail.message === "string" ? { message: detail.message } : {}),
56
+ ...("record" in detail ? { record: detail.record } : {}),
57
+ ...(typeof detail.storeSha256 === "string" ? { storeSha256: detail.storeSha256 } : {}),
58
+ };
59
+ }
60
+ const pathsUnder = (id, root) => ({ id, root, work: join(root, "work"), store: join(root, "store") });
61
+ /**
62
+ * The run on a directory of this machine: each runner is `pi-durable-disk run --dir <root> --id <id> --app <app>`, a
63
+ * child in its own process group, with `{ PATH, ...order.env }` and nothing else of this process's environment. No disk,
64
+ * no key and no token. A process kill is SIGKILL to the group, and the next runner resumes the run from the directory; a
65
+ * box kill also takes the boxes the run made (`destroyBoxes`), so `kills` has `box` only when the context gives that.
66
+ * A runner's outcome is the instance's `{"outcome":...}` line on stdout, or, without one, `run.json` as it left it.
67
+ */
68
+ export function localPlace(options) {
69
+ if (!options.app)
70
+ throw new PlaceError("INVALID_ARGUMENT", "localPlace needs the app module the instance runs");
71
+ return (ctx) => {
72
+ runPath(ctx.paths.id);
73
+ const root = resolve(options.root ?? ctx.paths.root);
74
+ const paths = pathsUnder(ctx.paths.id, root);
75
+ const command = options.command ?? [process.execPath, CLI];
76
+ const alive = new Map();
77
+ /** A runner's boxes being taken, or taken: one deletion per runner, shared while in flight, retried after a failure. */
78
+ const boxesTaken = new Map();
79
+ const start = async (order) => {
80
+ const argv = [...command.slice(1), "run", "--dir", root, "--id", paths.id, "--app", options.app, ...(options.runArgs ?? [])];
81
+ const child = spawn(command[0], argv, {
82
+ detached: true,
83
+ env: { PATH: process.env.PATH || SAFE_PATH, ...order.env },
84
+ stdio: [order.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
85
+ });
86
+ await new Promise((ok, fail) => {
87
+ child.once("spawn", ok);
88
+ child.once("error", (err) => fail(new PlaceError("INVALID_ARGUMENT", `cannot start ${command[0]}`, { cause: err })));
89
+ });
90
+ if (order.stdin !== undefined) {
91
+ child.stdin.on("error", () => { });
92
+ child.stdin.end(order.stdin);
93
+ }
94
+ let outcome;
95
+ let partial = "";
96
+ let stderr = "";
97
+ child.stdout.setEncoding("utf8").on("data", (chunk) => {
98
+ const lines = (partial + chunk).split("\n");
99
+ partial = lines.pop() ?? "";
100
+ for (const line of lines)
101
+ outcome = outcomeLine(line) ?? outcome;
102
+ });
103
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
104
+ if (stderr.length < 64 * 1024)
105
+ stderr += chunk;
106
+ });
107
+ const exited = new Promise((settle) => {
108
+ let ended;
109
+ let done = false;
110
+ const finish = async () => {
111
+ if (done || !ended)
112
+ return;
113
+ done = true;
114
+ outcome = outcomeLine(partial) ?? outcome;
115
+ const fallback = outcome ? undefined : outcomeOf(await readRunRecord(root).catch(() => undefined));
116
+ const report = outcome ?? fallback;
117
+ settle({ exit: ended.signal ?? ended.code, signal: ended.signal, ...(report ? { outcome: report } : {}), stderr: stderr.trim().slice(0, STDERR_KEEP) });
118
+ };
119
+ child.once("exit", (code, signal) => {
120
+ // Its pid is free from here: no kill may reach the group by it again.
121
+ alive.delete(runner);
122
+ ended = { code, signal };
123
+ setTimeout(() => void finish(), OUTPUT_GRACE_MS).unref();
124
+ });
125
+ child.once("close", () => void finish());
126
+ });
127
+ const runner = { epoch: 1, pid: child.pid, exited };
128
+ alive.set(runner, child.pid);
129
+ return runner;
130
+ };
131
+ const kill = async (runner, kind) => {
132
+ // Only a runner still alive: its pid (the group's id) is not yet free for another process to take.
133
+ const pid = alive.get(runner);
134
+ if (pid !== undefined) {
135
+ try {
136
+ process.kill(-pid, "SIGKILL");
137
+ }
138
+ catch {
139
+ /* already gone */
140
+ }
141
+ }
142
+ await runner.exited;
143
+ // Once per runner: a supervisor that kills and then recovers the same runner takes its boxes once. A deletion that
144
+ // failed is forgotten, so the next kill or recover tries again before any runner starts.
145
+ if (kind === "box" && ctx.destroyBoxes) {
146
+ let taking = boxesTaken.get(runner);
147
+ if (!taking) {
148
+ taking = (async () => ctx.destroyBoxes())().then(() => undefined, (error) => {
149
+ boxesTaken.delete(runner);
150
+ throw error;
151
+ });
152
+ boxesTaken.set(runner, taking);
153
+ }
154
+ await taking;
155
+ }
156
+ };
157
+ return {
158
+ name: "local",
159
+ paths,
160
+ kills: ctx.destroyBoxes ? ["process", "box"] : ["process"],
161
+ start,
162
+ // The runner is a child of this machine, so its kill request is the supervisor's own file.
163
+ ...(ctx.requestFile
164
+ ? {
165
+ requestFile: ctx.requestFile,
166
+ signals: (attempt) => {
167
+ const file = ctx.requestFile(attempt);
168
+ return requestSignals(async () => (existsSync(file) ? readFileSync(file, "utf8") : undefined), LOCAL_SIGNAL_POLL_MS, SIGNAL_FAIL_MS);
169
+ },
170
+ }
171
+ : {}),
172
+ kill,
173
+ status: async () => ({ record: (await readRunRecord(root)) ?? null }),
174
+ recover: async (last, kind, order) => {
175
+ await kill(last, kind);
176
+ return { runner: await start(order) };
177
+ },
178
+ release: async () => {
179
+ await Promise.all([...alive.keys()].map((runner) => kill(runner, "process")));
180
+ // pi runs each command in a process group of its own, which the instance's kill does not reach: end what the
181
+ // run recorded, as its next open would, since there may be none. Only under the run's owner lock, as an open
182
+ // does: while another process holds the run, the commands are its own, not this place's.
183
+ const commands = join(root, "commands");
184
+ if (!existsSync(commands))
185
+ return;
186
+ let lock;
187
+ try {
188
+ lock = takeOwnerLock(root);
189
+ }
190
+ catch (error) {
191
+ if (error instanceof HeldError)
192
+ return;
193
+ throw error;
194
+ }
195
+ let reaped;
196
+ try {
197
+ reaped = await reapCommands(commands);
198
+ }
199
+ finally {
200
+ lock.release();
201
+ }
202
+ if (reaped.survivors.length > 0) {
203
+ throw new PlaceError("RELEASE_FAILED", `releasing ${root} left commands alive after SIGKILL: pids ${reaped.survivors.map((m) => m.pid).join(", ")}`);
204
+ }
205
+ },
206
+ };
207
+ };
208
+ }
209
+ /**
210
+ * The kill request a runner writes whole when it parks at its planned crossing, as one signal, read with `read` every
211
+ * `pollMs` until it names a crossing. `read` answers undefined while there is no request yet; a read that fails is tried
212
+ * again, and once reads have failed for `failMs` without a break, the iterator yields one `signals-failed` and ends (a
213
+ * runner parked at its crossing would otherwise wait with no word of why). It never rejects. A custom iterator, not a
214
+ * generator: `return()` must end a
215
+ * pending `next()`, which a generator would queue behind the poll, and it does even while a read is in flight (a read
216
+ * over the network can hang). Each wait has its own wake, dropped after it, so a long poll attaches nothing to anything
217
+ * that lives as long as the iterator.
218
+ */
219
+ function requestSignals(read, pollMs, failMs) {
220
+ return {
221
+ [Symbol.asyncIterator]() {
222
+ let closed = false;
223
+ let wake;
224
+ /** `work`, or undefined as soon as `return()` runs. */
225
+ const unlessReturned = (work) => new Promise((resolve, reject) => {
226
+ wake = () => resolve(undefined);
227
+ work.then(resolve, reject);
228
+ }).finally(() => {
229
+ wake = undefined;
230
+ });
231
+ let failingSince;
232
+ return {
233
+ async next() {
234
+ while (!closed) {
235
+ let text;
236
+ try {
237
+ text = await unlessReturned(read());
238
+ failingSince = undefined;
239
+ }
240
+ catch (error) {
241
+ failingSince ??= Date.now();
242
+ if (!closed && Date.now() - failingSince >= failMs) {
243
+ closed = true;
244
+ const code = error?.code;
245
+ const message = `reading the kill request failed for ${failMs} ms: ${String(error?.message ?? error)}`.slice(0, MESSAGE_KEEP);
246
+ return { done: false, value: { kind: "signals-failed", code: typeof code === "string" && code ? code : "SIGNALS_FAILED", message } };
247
+ }
248
+ }
249
+ const request = closed || text === undefined ? undefined : requestIn(text);
250
+ if (request) {
251
+ closed = true;
252
+ // The nonce is the signal's, not the crossing's: a supervisor reads it at the top, as from any place.
253
+ const { nonce, ...crossing } = request;
254
+ return { done: false, value: { kind: "kill-request", crossing, ...(typeof nonce === "string" ? { nonce } : {}) } };
255
+ }
256
+ if (!closed)
257
+ await unlessReturned(sleep(pollMs));
258
+ }
259
+ return { done: true, value: undefined };
260
+ },
261
+ async return() {
262
+ closed = true;
263
+ wake?.();
264
+ return { done: true, value: undefined };
265
+ },
266
+ };
267
+ },
268
+ };
269
+ }
270
+ /**
271
+ * What a request names: `point` and `n` at least, with every other field it carries; the per-attempt nonce the
272
+ * supervisor put in the order (never the place's token) is lifted out of the crossing into the signal. Anything else (a
273
+ * half write, another file) is not one yet.
274
+ */
275
+ function requestIn(text) {
276
+ try {
277
+ const v = JSON.parse(text);
278
+ return v && typeof v.point === "string" && typeof v.n === "number" && Number.isFinite(v.n) ? v : undefined;
279
+ }
280
+ catch {
281
+ return undefined;
282
+ }
283
+ }
284
+ /** The `outcome` of a `{"outcome":{...}}` line with at least a status; undefined for any other line. */
285
+ function outcomeLine(line) {
286
+ if (!line.startsWith("{"))
287
+ return undefined;
288
+ try {
289
+ const parsed = JSON.parse(line);
290
+ return parsed.outcome && typeof parsed.outcome === "object" && typeof parsed.outcome.status === "string" ? parsed.outcome : undefined;
291
+ }
292
+ catch {
293
+ return undefined;
294
+ }
295
+ }
296
+ /** Where in the run's directory an Archil place's runners write their kill requests. */
297
+ const REQUESTS = "signals";
298
+ const requestName = (attempt) => {
299
+ if (!Number.isSafeInteger(attempt) || attempt < 1)
300
+ throw new PlaceError("INVALID_ARGUMENT", `attempt ${attempt} is not a count from 1`);
301
+ return `kill-${attempt}.json`;
302
+ };
303
+ /**
304
+ * The run on an Archil disk, its instance run by a host driver. `token(attempt)` mints the attempt's mount token through
305
+ * the place's control, and the runner it is handed to mounts with it. A start is `ensureRunning` (it creates the run's
306
+ * directory with `ensure.create` and writes the start mark) with that token and the order's environment. A kill is the
307
+ * driver's `kill`: `process` or `box`, whichever the driver takes. A recovery is the takeover: the dead holder's
308
+ * delegations revoked (its lease counts as expired, since the place killed it) and the next instance started, which
309
+ * `revoked` and `ms` report. A runner writes its kill request in the run's directory (`requestFile`), and `signals` reads
310
+ * it over S3. A runner ends when the driver no longer reports it running, or when the place killed it; its
311
+ * outcome is `run.json` read over S3, with `code`, `message`, `record` and `storeSha256` from the detail of the last
312
+ * status the app set. `release` stops every instance it started and removes every token it minted; the run's directory
313
+ * stays (`deleteRunTree`).
314
+ */
315
+ export function archilPlace(options) {
316
+ const { host } = options;
317
+ // Every call the place makes on the disk is bounded as ensureRunning bounds its own: one that does not answer fails as
318
+ // CONTROL_API_FAILED, so a stalled request never leaves a recovery, a release or a runner's end waiting forever.
319
+ const control = withTimeouts(options.control, options.ensure?.controlTimeoutMs ?? CONTROL_TIMEOUT_MS);
320
+ const pollMs = options.pollMs ?? 1_000;
321
+ const startTimeoutMs = options.startTimeoutMs ?? 60_000;
322
+ const signalPollMs = options.signalPollMs ?? 500;
323
+ const signalFailMs = options.signalFailMs ?? SIGNAL_FAIL_MS;
324
+ return (ctx) => {
325
+ const ref = { disk: options.disk, region: options.region, id: options.id ?? ctx.paths.id };
326
+ runPath(ref.id);
327
+ const hostRoot = host.mountRoot;
328
+ const mountRoot = options.mountRoot ?? (typeof hostRoot === "string" ? hostRoot : DEFAULT_MOUNT_ROOT);
329
+ const root = posix.join(mountRoot, runPath(ref.id));
330
+ const paths = { id: ref.id, root, work: posix.join(root, "work"), store: posix.join(root, "store") };
331
+ const minted = new Map();
332
+ const handles = [];
333
+ const killed = new Map();
334
+ let resolver = pathlessResolver(control);
335
+ const handleOf = new Map();
336
+ const mint = async (attempt) => {
337
+ const t = await mintMountToken(control, { run: { id: ref.id, attempt }, prefix: options.ensure?.tokenPrefix, ttl: options.ensure?.tokenTtl ?? TOKEN_TTL });
338
+ const m = { ...t, removed: false };
339
+ minted.set(t.token, m);
340
+ return m;
341
+ };
342
+ const drop = async (m) => {
343
+ if (m.removed)
344
+ return;
345
+ await removeMountToken(control, m.identifier);
346
+ m.removed = true;
347
+ };
348
+ const outcome = async () => outcomeOf(await readRunStatus(control, ref.id).catch(() => null));
349
+ const watch = (handle) => {
350
+ let onKill = () => { };
351
+ const killedNow = new Promise((r) => (onKill = () => r("killed")));
352
+ let wasKilled = false;
353
+ const exited = (async () => {
354
+ let failures = 0;
355
+ let status = "running";
356
+ while (!wasKilled) {
357
+ try {
358
+ status = await host.status(handle);
359
+ failures = 0;
360
+ }
361
+ catch (err) {
362
+ if (++failures >= 10)
363
+ throw new PlaceError("STATUS_FAILED", `the status of ${String(handle.name ?? handle.driver)} failed ${failures} times`, { cause: err });
364
+ }
365
+ if (failures === 0 && status !== "running")
366
+ break;
367
+ await Promise.race([sleep(pollMs), killedNow]);
368
+ }
369
+ const report = await outcome();
370
+ return wasKilled
371
+ ? { exit: "SIGKILL", signal: "SIGKILL", ...(report ? { outcome: report } : {}), stderr: "" }
372
+ : { exit: status, signal: null, ...(report ? { outcome: report } : {}), stderr: "" };
373
+ })();
374
+ return {
375
+ exited,
376
+ kill: () => {
377
+ wasKilled = true;
378
+ onKill();
379
+ },
380
+ };
381
+ };
382
+ /** A start or a takeover: `ensureRunning` with the order's token and environment, until it starts or decides there is nothing to start. */
383
+ const ensure = async (order, takeover) => {
384
+ const given = order.stdin !== undefined ? minted.get(order.stdin) : undefined;
385
+ const m = given && !given.removed ? given : await mint(order.gen);
386
+ // The driver gets the order's environment with each start; everything else is the host's own.
387
+ const driver = {
388
+ start: (r, token, attempt) => host.start(r, token, { attempt: attempt?.attempt ?? order.gen, env: order.env }),
389
+ status: (h) => host.status(h),
390
+ stop: (h) => host.stop(h),
391
+ };
392
+ const t0 = performance.now();
393
+ const deadline = Date.now() + startTimeoutMs;
394
+ for (;;) {
395
+ let result;
396
+ try {
397
+ result = await ensureRunning(ref, driver, {
398
+ ...options.ensure,
399
+ control,
400
+ demand: true,
401
+ mountToken: m,
402
+ // The place killed the holder: whatever its lease says, it is expired, and no start of it is in flight.
403
+ ...(takeover ? { leaseExpiryMs: Number.NEGATIVE_INFINITY, startGraceMs: 0 } : {}),
404
+ });
405
+ }
406
+ catch (err) {
407
+ // Nothing mounted with the token. ensureRunning's own removal ignores a failure, so remove it again (Archil
408
+ // answers a removal of a gone user with success): a failure here leaves it for release.
409
+ await drop(m).catch(() => { });
410
+ throw err;
411
+ }
412
+ if (result.action === "started") {
413
+ // An adopted instance mounts with an earlier start's token: this one is unused, removed as above.
414
+ if (result.adopted)
415
+ await drop(m).catch(() => { });
416
+ handles.push(result.handle);
417
+ const w = watch(result.handle);
418
+ const mark = result.startMark ? await readStartMark(control, ref.id).catch(() => null) : null;
419
+ const runner = { epoch: mark?.generation ?? order.gen, exited: w.exited };
420
+ killed.set(runner, w.kill);
421
+ handleOf.set(runner, result.handle);
422
+ // A runner the driver reports ended is gone: a later kill of it is a no-op. One whose status could not be read
423
+ // may still run, so it stays killable.
424
+ w.exited.then(() => void killed.delete(runner), () => undefined);
425
+ return { result, runner, ms: Math.round(performance.now() - t0) };
426
+ }
427
+ if ((result.action === "pending" || result.action === "starting") && Date.now() < deadline) {
428
+ await sleep(pollMs);
429
+ continue;
430
+ }
431
+ await drop(m).catch(() => { });
432
+ if (result.action === "terminal") {
433
+ // Nothing to start: the run is done or failed, and the runner ends as it begins.
434
+ const report = await outcome();
435
+ return { result, runner: { epoch: result.generation, exited: Promise.resolve({ exit: null, signal: null, ...(report ? { outcome: report } : {}), stderr: "" }) }, ms: Math.round(performance.now() - t0) };
436
+ }
437
+ if (result.action === "healthy")
438
+ throw new PlaceError("HELD", `${runPath(ref.id)} is held by a live instance this place did not start`);
439
+ throw new PlaceError("START_TIMED_OUT", `${runPath(ref.id)} stayed ${result.action} for ${startTimeoutMs} ms`);
440
+ }
441
+ };
442
+ // The driver's kill, whatever the kind asked: it takes what `kills` says. Once per runner.
443
+ const kill = async (runner, _kind) => {
444
+ if (!host.kill)
445
+ throw new PlaceError("KILL_UNSUPPORTED", "the place's host driver has no kill");
446
+ const handle = handleOf.get(runner);
447
+ const end = killed.get(runner);
448
+ if (handle && end) {
449
+ await host.kill(handle);
450
+ killed.delete(runner);
451
+ end();
452
+ // The kill is made; a runner whose status reads had failed still reports that failure through `exited`.
453
+ await runner.exited.catch(() => undefined);
454
+ return;
455
+ }
456
+ await runner.exited;
457
+ };
458
+ return {
459
+ name: "archil",
460
+ paths,
461
+ kills: host.kill && host.kills ? [host.kills] : [],
462
+ token: async (attempt) => (await mint(attempt)).token,
463
+ // The request goes in the run's directory, outside work/ where the agent's tools are confined, and the place reads
464
+ // it over S3: a runner on a box of its own cannot reach the supervisor's files.
465
+ requestFile: (attempt) => posix.join(root, REQUESTS, requestName(attempt)),
466
+ signals: (attempt) => {
467
+ const key = `${runPath(ref.id)}/${REQUESTS}/${requestName(attempt)}`;
468
+ return requestSignals(async () => {
469
+ try {
470
+ return new TextDecoder().decode(await control.getObject(key));
471
+ }
472
+ catch (error) {
473
+ if (notThere(error))
474
+ return undefined;
475
+ throw error;
476
+ }
477
+ }, signalPollMs, signalFailMs);
478
+ },
479
+ start: async (order) => (await ensure(order, false)).runner,
480
+ kill,
481
+ status: async () => {
482
+ // One resolver for the place's life: Archil lists a fresh run's delegation with no path, and its inode cache spares
483
+ // every later call the `find` on the disk. It keeps its first failure for good, so a failed call gets the next
484
+ // call a new one: one transient error must not fail the rest of a long run.
485
+ const resolve = resolver;
486
+ const [record, delegations] = await Promise.all([
487
+ readRunStatus(control, ref.id),
488
+ findDelegations(control, ref.id, resolve).catch((error) => {
489
+ if (resolver === resolve)
490
+ resolver = pathlessResolver(control);
491
+ throw error;
492
+ }),
493
+ ]);
494
+ return { record, delegations: delegations.map(({ clientId, inodeId, path, isOrphaned, isPending }) => ({ clientId, inodeId, path, isOrphaned, isPending })) };
495
+ },
496
+ recover: async (last, kind, order) => {
497
+ if (killed.has(last))
498
+ await kill(last, kind);
499
+ // Forced only while the newest incarnation the disk knows of (run.json or a start mark) is the one this place
500
+ // killed. A newer one, another supervisor's, gets the usual lease and start-grace checks.
501
+ const [record, mark] = await Promise.all([readRunStatus(control, ref.id), readStartMark(control, ref.id)]);
502
+ const newest = Math.max(record?.generation ?? 0, mark?.generation ?? 0);
503
+ const { result, runner, ms } = await ensure(order, handleOf.has(last) && newest <= last.epoch);
504
+ return { runner, revoked: result.action === "started" ? result.revoked : [], ms };
505
+ },
506
+ release: async () => {
507
+ const failed = [];
508
+ // A handle leaves the list only once its stop succeeded, so a later release tries a failed one again.
509
+ for (const handle of [...handles]) {
510
+ try {
511
+ await host.stop(handle);
512
+ handles.splice(handles.indexOf(handle), 1);
513
+ }
514
+ catch (err) {
515
+ failed.push(`stop ${String(handle.name ?? handle.driver)}: ${err.message}`);
516
+ }
517
+ }
518
+ for (const m of minted.values()) {
519
+ await drop(m).catch((err) => failed.push(`token ${m.nickname}: ${err.message}`));
520
+ }
521
+ if (failed.length)
522
+ throw new PlaceError("RELEASE_FAILED", `releasing ${runPath(ref.id)} left: ${failed.join("; ")}`);
523
+ },
524
+ };
525
+ };
526
+ }
@@ -0,0 +1,38 @@
1
+ /** A live process (a zombie is not one): its parent, its group, and its start. */
2
+ export interface ProcessInfo {
3
+ readonly ppid: number;
4
+ readonly pgrp: number;
5
+ readonly start: number;
6
+ }
7
+ /** One look at the processes. Through ps, every `info` and `children` of one snapshot sees the same listing; /proc is read live. */
8
+ export interface ProcessSnapshot {
9
+ info(pid: number): ProcessInfo | undefined;
10
+ /** The direct children of `pid`. */
11
+ children(pid: number): number[];
12
+ /** Whether `pid` was started with `name=value` in its environment (false when it cannot be read). */
13
+ marked(pid: number, name: string, value: string): boolean;
14
+ /** Its command line, for a message; `?` when it cannot be read. */
15
+ commandLine(pid: number): string;
16
+ }
17
+ export interface ProcessTable {
18
+ /** A new look. Through ps it throws when the listing cannot be had or read: no process is ever taken for gone on a failed read. */
19
+ snapshot(): ProcessSnapshot;
20
+ /** This boot's id; undefined when it cannot be read. */
21
+ bootId(): string | undefined;
22
+ /** How often a recorder's walk is worth repeating: a /proc read is cheap, a ps listing is not. */
23
+ readonly walkMs: number;
24
+ }
25
+ /** The table on Linux: `/proc` (or another directory laid out like it, for tests). */
26
+ export declare function procTable(proc?: string): ProcessTable;
27
+ /** Runs a program with arguments and returns what it printed; throws when it fails. */
28
+ export type CommandRunner = (file: string, args: readonly string[]) => string;
29
+ /** The table on macOS: `ps` and `sysctl`, through `run` (for tests). */
30
+ export declare function psTable(run?: CommandRunner): ProcessTable;
31
+ /** Test seam: a table that stands in for this machine's everywhere `processTable()` is read. */
32
+ export declare const processTableHooks: {
33
+ table?: ProcessTable;
34
+ };
35
+ /** This machine's table: ps on macOS, /proc elsewhere. */
36
+ export declare function processTable(): ProcessTable;
37
+ /** This boot's id on this machine, null when it cannot be read. */
38
+ export declare function thisBoot(): string | null;