@dynamicagents/plugins 0.12.0 → 0.13.0
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/README.md +58 -77
- package/dist/browser/index.d.ts +0 -2
- package/dist/browser/index.js +21 -27
- package/dist/claude-code/cards.d.ts +53 -0
- package/dist/claude-code/cards.js +272 -0
- package/dist/claude-code/config.d.ts +28 -63
- package/dist/claude-code/config.js +0 -11
- package/dist/claude-code/egress.d.ts +8 -1
- package/dist/claude-code/egress.js +42 -4
- package/dist/claude-code/events.d.ts +106 -9
- package/dist/claude-code/events.js +201 -56
- package/dist/claude-code/github.d.ts +39 -0
- package/dist/claude-code/github.js +82 -0
- package/dist/claude-code/index.d.ts +16 -90
- package/dist/claude-code/index.js +14 -276
- package/dist/claude-code/model.d.ts +152 -0
- package/dist/claude-code/model.js +342 -0
- package/dist/claude-code/run.d.ts +209 -112
- package/dist/claude-code/run.js +339 -138
- package/dist/claude-code/session.d.ts +131 -0
- package/dist/claude-code/session.js +285 -0
- package/dist/claude-code/spec.d.ts +46 -0
- package/dist/claude-code/spec.js +83 -0
- package/dist/claude-code/verified.d.ts +15 -0
- package/dist/claude-code/verified.js +15 -0
- package/dist/computer/context.d.ts +1 -5
- package/dist/computer/context.js +7 -10
- package/dist/computer/file-lock.js +5 -4
- package/dist/computer/gate.d.ts +4 -4
- package/dist/computer/gate.js +5 -5
- package/dist/computer/index.d.ts +40 -258
- package/dist/computer/index.js +71 -324
- package/dist/computer/paths.d.ts +36 -16
- package/dist/computer/paths.js +42 -29
- package/dist/computer/proxy.d.ts +45 -0
- package/dist/computer/proxy.js +234 -0
- package/dist/computer/render.d.ts +1 -40
- package/dist/computer/render.js +2 -68
- package/dist/computer/tools-exec.js +12 -13
- package/dist/computer/tools-file.d.ts +9 -1
- package/dist/computer/tools-file.js +22 -82
- package/dist/computer/{tools-find.d.ts → tools-grep.d.ts} +1 -1
- package/dist/computer/tools-grep.js +129 -0
- package/dist/repo/checkout.d.ts +4 -5
- package/dist/repo/checkout.js +31 -8
- package/dist/repo/context.d.ts +35 -9
- package/dist/repo/context.js +101 -49
- package/dist/repo/index.d.ts +73 -28
- package/dist/repo/index.js +112 -96
- package/dist/repo/tools-checks.d.ts +3 -0
- package/dist/repo/tools-checks.js +112 -0
- package/dist/repo/tools-clone.js +1 -1
- package/dist/repo/tools-pr.d.ts +14 -0
- package/dist/repo/{tools-forge.js → tools-pr.js} +53 -36
- package/dist/repo/tools-review.d.ts +9 -0
- package/dist/repo/tools-review.js +67 -47
- package/dist/repo/tools-worktree.js +144 -60
- package/dist/repo/tools-worktrees.d.ts +12 -0
- package/dist/repo/tools-worktrees.js +48 -0
- package/dist/repo/url.d.ts +2 -2
- package/dist/repo/url.js +7 -7
- package/dist/scratch/index.d.ts +4 -4
- package/dist/scratch/index.js +12 -11
- package/dist/{computer → workspace}/advisory.d.ts +4 -4
- package/dist/{computer → workspace}/advisory.js +2 -2
- package/dist/{computer/host → workspace}/ca-trust.d.ts +8 -1
- package/dist/{computer/host → workspace}/ca-trust.js +12 -3
- package/dist/{computer/host → workspace}/container-fault.d.ts +3 -3
- package/dist/{computer/host → workspace}/container-fault.js +23 -8
- package/dist/workspace/exec.d.ts +94 -0
- package/dist/workspace/exec.js +153 -0
- package/dist/workspace/format.d.ts +36 -0
- package/dist/workspace/format.js +57 -0
- package/dist/{computer/host → workspace}/git-host.d.ts +21 -4
- package/dist/{computer/host → workspace}/git-host.js +72 -2
- package/dist/{computer/host → workspace}/git-identity.d.ts +1 -1
- package/dist/{computer/host → workspace}/git-identity.js +1 -1
- package/dist/workspace/index.d.ts +43 -47
- package/dist/workspace/index.js +52 -112
- package/dist/{computer/host → workspace}/install-job.d.ts +4 -4
- package/dist/{computer/host → workspace}/install-job.js +276 -176
- package/dist/{computer → workspace}/install.d.ts +8 -8
- package/dist/{computer → workspace}/install.js +3 -3
- package/dist/{computer/host/workspace.d.ts → workspace/object.d.ts} +105 -27
- package/dist/{computer/host/workspace.js → workspace/object.js} +217 -48
- package/dist/workspace/open.d.ts +95 -0
- package/dist/workspace/open.js +63 -0
- package/dist/workspace/read.d.ts +16 -0
- package/dist/workspace/read.js +22 -0
- package/dist/{computer → workspace}/shell.d.ts +5 -5
- package/dist/{computer → workspace}/shell.js +6 -6
- package/dist/{computer/host → workspace}/wake.d.ts +1 -1
- package/package.json +16 -36
- package/dist/arc-agi/analysis.d.ts +0 -322
- package/dist/arc-agi/analysis.js +0 -802
- package/dist/arc-agi/client.d.ts +0 -82
- package/dist/arc-agi/client.js +0 -113
- package/dist/arc-agi/game-tools.d.ts +0 -24
- package/dist/arc-agi/game-tools.js +0 -26
- package/dist/arc-agi/index.d.ts +0 -40
- package/dist/arc-agi/index.js +0 -175
- package/dist/arc-agi/main-agent.d.ts +0 -24
- package/dist/arc-agi/main-agent.js +0 -62
- package/dist/arc-agi/recipe.d.ts +0 -48
- package/dist/arc-agi/recipe.js +0 -97
- package/dist/arc-agi/scorecard.d.ts +0 -118
- package/dist/arc-agi/scorecard.js +0 -163
- package/dist/arc-agi/soul.d.ts +0 -29
- package/dist/arc-agi/soul.js +0 -79
- package/dist/arc-agi/store.d.ts +0 -153
- package/dist/arc-agi/store.js +0 -215
- package/dist/arc-agi/tools.d.ts +0 -36
- package/dist/arc-agi/tools.js +0 -574
- package/dist/arc-agi/types.d.ts +0 -211
- package/dist/arc-agi/types.js +0 -19
- package/dist/claude-code/recipe.d.ts +0 -53
- package/dist/claude-code/recipe.js +0 -100
- package/dist/computer/read.d.ts +0 -133
- package/dist/computer/read.js +0 -200
- package/dist/computer/tools-find.js +0 -216
- package/dist/recall/index.d.ts +0 -105
- package/dist/recall/index.js +0 -181
- package/dist/repo/tools-forge.d.ts +0 -4
- package/dist/triage/index.d.ts +0 -360
- package/dist/triage/index.js +0 -311
- /package/dist/{computer/host → workspace}/container-deps.d.ts +0 -0
- /package/dist/{computer/host → workspace}/container-deps.js +0 -0
- /package/dist/{computer/host → workspace}/sync.d.ts +0 -0
- /package/dist/{computer/host → workspace}/sync.js +0 -0
- /package/dist/{computer/host → workspace}/wake.js +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { shellQuote } from "@cloudflare/computer";
|
|
2
2
|
import { JobLifecycle } from "@dynamicagents/core/job";
|
|
3
|
-
// Leaf modules rather than
|
|
3
|
+
// Leaf modules rather than `./index.ts`, and that is structural: the barrel
|
|
4
4
|
// re-exports this directory, so reaching it from here would be a cycle — through
|
|
5
5
|
// a module that builds a class at import time, where initialisation order
|
|
6
6
|
// decides whether the base is `undefined`.
|
|
7
|
-
import { installFingerprint, resolveInstallCommand } from "
|
|
8
|
-
import { pathExists } from "
|
|
7
|
+
import { installFingerprint, resolveInstallCommand } from "./install.js";
|
|
8
|
+
import { pathExists } from "./read.js";
|
|
9
9
|
import { deploymentFault } from "./container-fault.js";
|
|
10
10
|
import { INSTALLED_MARKER } from "./container-deps.js";
|
|
11
|
-
import { truncateOutput } from "
|
|
11
|
+
import { truncateOutput } from "./format.js";
|
|
12
12
|
/**
|
|
13
13
|
* Whether a thrown value is the runtime reporting a replaced container.
|
|
14
14
|
*
|
|
@@ -27,7 +27,7 @@ function execWasLost(err) {
|
|
|
27
27
|
* ## The rule every guard here serves
|
|
28
28
|
*
|
|
29
29
|
* `running` is the one install state that **blocks work**: the plugin's gate
|
|
30
|
-
* waits on it and then refuses to run. Every other state is a fact
|
|
30
|
+
* waits on it and then refuses to run. Every other state is a fact an agent
|
|
31
31
|
* can act on. So a `running` record must never outlive the command it describes,
|
|
32
32
|
* and the ways it can are not all reachable from one place — the spawn can fail
|
|
33
33
|
* before a drain is attached, a drain can be cut short by an eviction, `getExec`
|
|
@@ -78,6 +78,11 @@ const INSTALL_STALE_MS = 5 * 60_000;
|
|
|
78
78
|
const CONTAINER_TREE_KEY = "install:tree";
|
|
79
79
|
/** Records a deployed object may still hold, deleted on the next install. */
|
|
80
80
|
const RETIRED_KEYS = ["install:syncing", "install:completed"];
|
|
81
|
+
/**
|
|
82
|
+
* The command a reservation's placeholder names, until the install's own is
|
|
83
|
+
* resolved. The gate renders it while it waits.
|
|
84
|
+
*/
|
|
85
|
+
const RESOLVING = "(being resolved)";
|
|
81
86
|
/**
|
|
82
87
|
* The exec id an install runs under.
|
|
83
88
|
*
|
|
@@ -108,8 +113,12 @@ export class InstallJob {
|
|
|
108
113
|
*
|
|
109
114
|
* **Arming writes `running` before anything is running**, which holds the gate
|
|
110
115
|
* shut in the moments before the alarm fires. The alarm then presents the stamp
|
|
111
|
-
* arming wrote to `
|
|
116
|
+
* arming wrote to `reserve`, which recognises its own placeholder and nothing
|
|
112
117
|
* else — taking over any `running` record instead would be displacement again.
|
|
118
|
+
*
|
|
119
|
+
* Every write a run makes goes through the lifecycle's `generation`, under
|
|
120
|
+
* the `startedAt` its reservation returned, so a run that has been displaced
|
|
121
|
+
* writes nothing.
|
|
113
122
|
*/
|
|
114
123
|
#job;
|
|
115
124
|
// Built here rather than as a field initializer, which would run before the
|
|
@@ -181,7 +190,7 @@ export class InstallJob {
|
|
|
181
190
|
dir: context.dir
|
|
182
191
|
});
|
|
183
192
|
// Everything the arming handshake needs — the placeholder write, the stamp
|
|
184
|
-
// the alarm presents to `
|
|
193
|
+
// the alarm presents to `reserve`, the cooldown floor and the run intent — in
|
|
185
194
|
// one call, and unwound as a unit if the intent cannot be scheduled.
|
|
186
195
|
await this.#job.arm({ command: state.command });
|
|
187
196
|
}
|
|
@@ -239,7 +248,7 @@ export class InstallJob {
|
|
|
239
248
|
*
|
|
240
249
|
* Called from `repo_clone` through the repo plugin's `afterCheckout` hook, so
|
|
241
250
|
* it runs inside a model turn and must not block on the install — 225 seconds
|
|
242
|
-
* for slack-gatekeeper, against a
|
|
251
|
+
* for slack-gatekeeper, against a turn that is cut at fifteen minutes.
|
|
243
252
|
*
|
|
244
253
|
* That caller is a model turn, which lives long enough to hold the drain handed
|
|
245
254
|
* to `ctx.waitUntil` below. **A short-lived caller cannot**, which is why an
|
|
@@ -273,35 +282,10 @@ export class InstallJob {
|
|
|
273
282
|
async #beginInstall(req, own, opts) {
|
|
274
283
|
await this.deps.touch();
|
|
275
284
|
await this.deps.ready();
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
* `repo_clone` calls this and a retried chunk calls it again. Every call
|
|
280
|
-
* spawns with the same {@link INSTALL_EXEC_ID}, so without this each would
|
|
281
|
-
* displace the last while the displaced command's drain stayed attached
|
|
282
|
-
* through `ctx.waitUntil` — then wrote *its* outcome over a record
|
|
283
|
-
* describing an install still running perfectly well.
|
|
284
|
-
*
|
|
285
|
-
* {@link state} rather than the lifecycle's raw `read()`, so a `running`
|
|
286
|
-
* record left by a dead isolate is resolved here rather than blocking a
|
|
287
|
-
* legitimate retry forever.
|
|
288
|
-
*
|
|
289
|
-
* `takeOverArmedAt` is the one exemption, narrow on purpose: the alarm's
|
|
290
|
-
* placeholder is a `running` record for an install that has not started, so
|
|
291
|
-
* the alarm must pass its own guard and only its own. Matching the exact
|
|
292
|
-
* `startedAt` it wrote is what stops that becoming "take over any running
|
|
293
|
-
* install", which is displacement again in a new hat.
|
|
294
|
-
*/
|
|
285
|
+
// {@link state} rather than the lifecycle's raw `read()`: a `running` record
|
|
286
|
+
// left by a dead isolate is repaired on the way past, and one nobody is
|
|
287
|
+
// draining is re-attached.
|
|
295
288
|
const current = await this.state();
|
|
296
|
-
const claim = this.#job.claim(current, this.deps.timeoutMs(), opts?.takeOverArmedAt);
|
|
297
|
-
if (!claim.ok) {
|
|
298
|
-
console.info(`[${this.deps.tag()}] an install is already in flight`, {
|
|
299
|
-
id: this.deps.id(),
|
|
300
|
-
command: claim.current.command,
|
|
301
|
-
seconds: Math.round((Date.now() - claim.current.startedAt) / 1000)
|
|
302
|
-
});
|
|
303
|
-
return claim.current;
|
|
304
|
-
}
|
|
305
289
|
/**
|
|
306
290
|
* A full workspace refuses the install and **writes nothing**.
|
|
307
291
|
*
|
|
@@ -325,11 +309,126 @@ export class InstallJob {
|
|
|
325
309
|
});
|
|
326
310
|
return current;
|
|
327
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* One install at a time — the hazard is displacement.
|
|
314
|
+
*
|
|
315
|
+
* `repo_clone` calls this and a recovered turn calls it again. Every call
|
|
316
|
+
* spawns with the same {@link INSTALL_EXEC_ID}, so without this each would
|
|
317
|
+
* displace the last while the displaced command's drain stayed attached
|
|
318
|
+
* through `ctx.waitUntil` — then wrote *its* outcome over a record
|
|
319
|
+
* describing an install still running perfectly well.
|
|
320
|
+
*
|
|
321
|
+
* Reserved rather than checked: everything between here and the spawn —
|
|
322
|
+
* probing the checkout, hashing the lockfile — awaits the container, and a
|
|
323
|
+
* check that wrote nothing would let a second caller through in that time.
|
|
324
|
+
*
|
|
325
|
+
* `takeOverArmedAt` is the one exemption, narrow on purpose: the alarm's
|
|
326
|
+
* placeholder is a `running` record for an install that has not started, so
|
|
327
|
+
* the alarm must pass its own guard and only its own. Matching the exact
|
|
328
|
+
* `startedAt` it wrote is what stops that becoming "take over any running
|
|
329
|
+
* install", which is displacement again in a new hat.
|
|
330
|
+
*/
|
|
331
|
+
const reserved = await this.#job.reserve({ command: RESOLVING }, this.deps.timeoutMs(), opts?.takeOverArmedAt);
|
|
332
|
+
if (!reserved.ok) {
|
|
333
|
+
console.info(`[${this.deps.tag()}] an install is already in flight`, {
|
|
334
|
+
id: this.deps.id(),
|
|
335
|
+
command: reserved.current.command,
|
|
336
|
+
seconds: Math.round((Date.now() - reserved.current.startedAt) / 1000)
|
|
337
|
+
});
|
|
338
|
+
return reserved.current;
|
|
339
|
+
}
|
|
340
|
+
const { startedAt } = reserved;
|
|
341
|
+
const run = this.#job.generation(startedAt);
|
|
342
|
+
this.#owned.add(startedAt);
|
|
343
|
+
let handed = false;
|
|
344
|
+
try {
|
|
345
|
+
return await this.#spawnReserved(req, run, reserved, own, () => {
|
|
346
|
+
handed = true;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
if (!handed)
|
|
351
|
+
this.#owned.delete(startedAt);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Everything after the reservation: prepare, spawn, and hand the command to
|
|
356
|
+
* `own`. Split out so {@link #beginInstall} can hold the generation for exactly
|
|
357
|
+
* this long — `handing` is called as the drain takes it over.
|
|
358
|
+
*/
|
|
359
|
+
async #spawnReserved(req, run, reserved, own, handing) {
|
|
360
|
+
const { startedAt } = reserved;
|
|
328
361
|
// Nothing trusts the interception CA here, deliberately: the workspace's own
|
|
329
362
|
// `ready` does it, above every early return in this method. An install
|
|
330
363
|
// already in flight and a full workspace both return before this line, and
|
|
331
364
|
// neither of them means the container cannot speak TLS — the full workspace
|
|
332
365
|
// least of all, since egress is what the agent needs to dig itself out.
|
|
366
|
+
let prepared;
|
|
367
|
+
try {
|
|
368
|
+
prepared = await this.#prepare(req, run, startedAt);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
await this.#release(run, reserved.previous, err);
|
|
372
|
+
throw err;
|
|
373
|
+
}
|
|
374
|
+
if (prepared.kind === "settled")
|
|
375
|
+
return prepared.state;
|
|
376
|
+
const { resolution, fingerprint, state } = prepared;
|
|
377
|
+
let handle;
|
|
378
|
+
try {
|
|
379
|
+
// The marker lets a new isolate check {@link CONTAINER_TREE_KEY} against
|
|
380
|
+
// the container; see `reconcile`.
|
|
381
|
+
const marked = `${resolution.command} && printf %s ` +
|
|
382
|
+
`${shellQuote(fingerprint ?? "none")} > node_modules/${INSTALLED_MARKER}`;
|
|
383
|
+
handle = await this.deps.workspace().runtime.exec(marked, {
|
|
384
|
+
id: INSTALL_EXEC_ID,
|
|
385
|
+
cwd: req.dir,
|
|
386
|
+
encoding: "utf8",
|
|
387
|
+
timeoutMs: this.deps.timeoutMs()
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
// The command never started, so nothing will ever drain it and no
|
|
392
|
+
// re-attach can find it. Close the record here: a `failed` install is
|
|
393
|
+
// recoverable — the agent is told what happened and can run the command
|
|
394
|
+
// itself — where a `running` one that nobody owns is not.
|
|
395
|
+
//
|
|
396
|
+
// Which sentence, though, depends on why. The default advice — run it
|
|
397
|
+
// yourself with `bash` — is good for a container that is merely
|
|
398
|
+
// unreachable, and useless for a deployment fault: the same container is
|
|
399
|
+
// what `bash` would have to reach. Under one of those, say what an
|
|
400
|
+
// operator has to do and do not send the agent after a command that
|
|
401
|
+
// cannot run either.
|
|
402
|
+
const fault = deploymentFault(err);
|
|
403
|
+
console.error(`[${this.deps.tag()}] ${fault?.summary ?? "the install could not be started"}`, {
|
|
404
|
+
id: this.deps.id(),
|
|
405
|
+
command: resolution.command,
|
|
406
|
+
...(fault ? { remedy: fault.remedy } : {}),
|
|
407
|
+
err: String(err)
|
|
408
|
+
});
|
|
409
|
+
const failed = {
|
|
410
|
+
state: "failed",
|
|
411
|
+
command: resolution.command,
|
|
412
|
+
finishedAt: Date.now(),
|
|
413
|
+
error: fault
|
|
414
|
+
? `the install could not be started: ${fault.remedy}`
|
|
415
|
+
: `the install could not be started (${String(err)}). The container ` +
|
|
416
|
+
"was most likely unreachable. Run the command yourself with bash, " +
|
|
417
|
+
"or clone again to retry it."
|
|
418
|
+
};
|
|
419
|
+
return await this.#settle(run, failed);
|
|
420
|
+
}
|
|
421
|
+
handing();
|
|
422
|
+
await own(handle, startedAt);
|
|
423
|
+
return state;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Everything between a reservation and the spawn: resolve the command, and
|
|
427
|
+
* settle the run here when there is nothing to install or the tree is already
|
|
428
|
+
* this lockfile's. Otherwise write this install's own `running` record and
|
|
429
|
+
* context, and arm the watchdog.
|
|
430
|
+
*/
|
|
431
|
+
async #prepare(req, run, startedAt) {
|
|
333
432
|
const probe = this.#probe();
|
|
334
433
|
const resolution = await resolveInstallCommand(probe, req.dir, this.deps.plan(), req.repo);
|
|
335
434
|
if (resolution.kind === "skip") {
|
|
@@ -337,7 +436,6 @@ export class InstallJob {
|
|
|
337
436
|
state: "skipped",
|
|
338
437
|
reason: resolution.reason
|
|
339
438
|
};
|
|
340
|
-
await this.#job.write(state);
|
|
341
439
|
/**
|
|
342
440
|
* The common branch, and the one whose absence is indistinguishable from
|
|
343
441
|
* never having been called.
|
|
@@ -355,7 +453,7 @@ export class InstallJob {
|
|
|
355
453
|
...(req.repo ? { repo: req.repo } : {}),
|
|
356
454
|
reason: resolution.reason
|
|
357
455
|
});
|
|
358
|
-
return state;
|
|
456
|
+
return { kind: "settled", state: await this.#settle(run, state) };
|
|
359
457
|
}
|
|
360
458
|
const fingerprint = await installFingerprint(probe, req.dir, resolution);
|
|
361
459
|
// Skipped only for this container's own tree, for this lockfile.
|
|
@@ -371,18 +469,18 @@ export class InstallJob {
|
|
|
371
469
|
ms: 0,
|
|
372
470
|
tail: "dependencies already installed in this container for this lockfile"
|
|
373
471
|
};
|
|
374
|
-
await this.#
|
|
375
|
-
return state;
|
|
472
|
+
return { kind: "settled", state: await this.#settle(run, state) };
|
|
376
473
|
}
|
|
377
474
|
// The tree is rebuilt from here, so nothing vouches for it until exit 0.
|
|
378
475
|
await this.deps.storage.delete([CONTAINER_TREE_KEY, ...RETIRED_KEYS]);
|
|
379
|
-
const startedAt = Date.now();
|
|
380
476
|
const state = {
|
|
381
477
|
state: "running",
|
|
382
478
|
command: resolution.command,
|
|
383
479
|
startedAt
|
|
384
480
|
};
|
|
385
|
-
await
|
|
481
|
+
if (!(await run.write(state))) {
|
|
482
|
+
return { kind: "settled", state: await this.#job.read() };
|
|
483
|
+
}
|
|
386
484
|
await this.#job.putContext({
|
|
387
485
|
dir: req.dir,
|
|
388
486
|
// Kept so a reinstall the alarm drives — which has no caller to ask —
|
|
@@ -391,8 +489,7 @@ export class InstallJob {
|
|
|
391
489
|
// on every later run, installing a different tree than the first time.
|
|
392
490
|
...(req.repo ? { repo: req.repo } : {}),
|
|
393
491
|
fingerprint,
|
|
394
|
-
command: resolution.command
|
|
395
|
-
startedAt
|
|
492
|
+
command: resolution.command
|
|
396
493
|
});
|
|
397
494
|
/**
|
|
398
495
|
* Armed **before** the spawn — the hazard is an isolate that dies between
|
|
@@ -408,54 +505,33 @@ export class InstallJob {
|
|
|
408
505
|
* `running`, so finishing first costs one wake-up.
|
|
409
506
|
*/
|
|
410
507
|
await this.#job.armWatch();
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
// The command never started, so nothing will ever drain it and no
|
|
426
|
-
// re-attach can find it. Close the record here: a `failed` install is
|
|
427
|
-
// recoverable — the subagent is told what happened and can run the command
|
|
428
|
-
// itself — where a `running` one that nobody owns is not.
|
|
429
|
-
//
|
|
430
|
-
// Which sentence, though, depends on why. The default advice — run it
|
|
431
|
-
// yourself with `sb_exec` — is good for a container that is merely
|
|
432
|
-
// unreachable, and useless for a deployment fault: the same container is
|
|
433
|
-
// what `sb_exec` would have to reach. Under one of those, say what an
|
|
434
|
-
// operator has to do and do not send the subagent after a command that
|
|
435
|
-
// cannot run either.
|
|
436
|
-
const fault = deploymentFault(err);
|
|
437
|
-
console.error(`[${this.deps.tag()}] ${fault?.summary ?? "the install could not be started"}`, {
|
|
438
|
-
id: this.deps.id(),
|
|
439
|
-
command: resolution.command,
|
|
440
|
-
...(fault ? { remedy: fault.remedy } : {}),
|
|
441
|
-
err: String(err)
|
|
442
|
-
});
|
|
443
|
-
const failed = {
|
|
508
|
+
return { kind: "spawn", resolution, fingerprint, state };
|
|
509
|
+
}
|
|
510
|
+
/** Settle `run` with `state`, or say what stands if it was displaced. */
|
|
511
|
+
async #settle(run, state) {
|
|
512
|
+
return (await run.write(state)) ? state : await this.#job.read();
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Hand a reservation back when nothing was spawned under it: the record as it
|
|
516
|
+
* was, unless that was a run of its own — the alarm's placeholder — which
|
|
517
|
+
* nothing would now finish, so `failed`.
|
|
518
|
+
*/
|
|
519
|
+
async #release(run, previous, err) {
|
|
520
|
+
const back = previous.state === "running"
|
|
521
|
+
? {
|
|
444
522
|
state: "failed",
|
|
445
|
-
command:
|
|
523
|
+
command: previous.command,
|
|
446
524
|
finishedAt: Date.now(),
|
|
447
|
-
error:
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
525
|
+
error: `the install could not be prepared (${String(err)}). Run the ` +
|
|
526
|
+
"command yourself with bash, or clone again to retry it."
|
|
527
|
+
}
|
|
528
|
+
: previous;
|
|
529
|
+
try {
|
|
530
|
+
await run.write(back);
|
|
531
|
+
}
|
|
532
|
+
catch (rollback) {
|
|
533
|
+
throw new AggregateError([err, rollback], "the install could not be prepared, and its reservation could not be released");
|
|
456
534
|
}
|
|
457
|
-
await own(handle, startedAt);
|
|
458
|
-
return state;
|
|
459
535
|
}
|
|
460
536
|
/**
|
|
461
537
|
* Whether this container holds a finished tree, asked only when it qualifies a
|
|
@@ -504,85 +580,102 @@ export class InstallJob {
|
|
|
504
580
|
finishedAt: Date.now(),
|
|
505
581
|
error: `the install has been running for ${minutes} minutes without ` +
|
|
506
582
|
"reporting, which is past its timeout — it is not going to finish. " +
|
|
507
|
-
"Run the command yourself with
|
|
583
|
+
"Run the command yourself with bash if you still need it."
|
|
508
584
|
};
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
await this.#
|
|
515
|
-
return await this.#job.read();
|
|
585
|
+
// Under the record's own generation, so a reservation that took the record
|
|
586
|
+
// since it was read is not overwritten.
|
|
587
|
+
const repaired = await this.#job
|
|
588
|
+
.generation(state.startedAt)
|
|
589
|
+
.write(failed);
|
|
590
|
+
return repaired ? failed : await this.#job.read();
|
|
516
591
|
}
|
|
517
|
-
|
|
592
|
+
if (await this.#drivenHere(state.startedAt))
|
|
593
|
+
return state;
|
|
594
|
+
await this.#reattachInstall(state.startedAt);
|
|
595
|
+
return await this.#job.read();
|
|
518
596
|
}
|
|
519
597
|
/**
|
|
520
598
|
* When a reinstall was queued, if one is waiting to run.
|
|
521
599
|
*
|
|
522
|
-
* Read by {@link file
|
|
600
|
+
* Read by {@link file://./advisory.ts deriveAdvisories}, which is the only
|
|
523
601
|
* caller that needs it: a `failed` record with a repair already queued is a
|
|
524
602
|
* transient condition wearing a permanent record's clothes.
|
|
525
603
|
*/
|
|
526
604
|
async reinstallArmedAt() {
|
|
527
605
|
return await this.#job.armedAt();
|
|
528
606
|
}
|
|
529
|
-
/** True while this isolate holds the drain, so the watchdog leaves it alone. */
|
|
530
|
-
#draining = false;
|
|
531
|
-
async #drainInstall(handle,
|
|
532
607
|
/**
|
|
533
|
-
* The
|
|
608
|
+
* The generations this isolate is driving: reserved, taken over from the
|
|
609
|
+
* alarm's placeholder, or draining — until the drain ends.
|
|
534
610
|
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
611
|
+
* The re-attach is for a `running` record nobody here is driving, which is one
|
|
612
|
+
* a dead isolate left. Asked of a generation that is reserved but not yet
|
|
613
|
+
* spawned, it finds no exec, or the last container's, and writes `failed` over
|
|
614
|
+
* an install about to start, whose own verdict is then discarded as
|
|
615
|
+
* superseded. Owning from the reservation rather than from the drain is what
|
|
616
|
+
* closes that window: resolving and spawning await the container for seconds.
|
|
617
|
+
*/
|
|
618
|
+
#owned = new Set();
|
|
619
|
+
/**
|
|
620
|
+
* Whether a `running` record's install is this isolate's to finish: one it
|
|
621
|
+
* owns, or the alarm's placeholder still waiting for the alarm, whose
|
|
622
|
+
* `startedAt` is the arming stamp.
|
|
623
|
+
*/
|
|
624
|
+
async #drivenHere(startedAt) {
|
|
625
|
+
return (this.#owned.has(startedAt) || (await this.#job.armedAt()) === startedAt);
|
|
626
|
+
}
|
|
627
|
+
async #drainInstall(handle,
|
|
628
|
+
/**
|
|
629
|
+
* The generation this drain writes under: the `startedAt` its reservation
|
|
630
|
+
* returned, or — re-attached — the running record's.
|
|
541
631
|
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
632
|
+
* Passed in rather than read below: this method's first `await` is reached
|
|
633
|
+
* long after `#beginInstall` returned, and a record read then may already be
|
|
634
|
+
* a later install's, whose generation this drain would adopt.
|
|
545
635
|
*/
|
|
546
|
-
|
|
547
|
-
this.#
|
|
636
|
+
startedAt) {
|
|
637
|
+
this.#owned.add(startedAt);
|
|
548
638
|
const context = await this.#job.context();
|
|
549
639
|
const command = context?.command ?? "(unknown)";
|
|
550
|
-
const startedAt = ownedAt ?? context?.startedAt ?? Date.now();
|
|
551
640
|
/**
|
|
552
641
|
* Whether this drain still owns the record.
|
|
553
642
|
*
|
|
554
|
-
* The
|
|
555
|
-
* place; this makes it harmless if one ever does. A drain can outlive
|
|
556
|
-
* command it was watching — `ctx.waitUntil` keeps running after the RPC
|
|
643
|
+
* The reservation in `#beginInstall` stops two installs overlapping in the
|
|
644
|
+
* first place; this makes it harmless if one ever does. A drain can outlive
|
|
645
|
+
* the command it was watching — `ctx.waitUntil` keeps running after the RPC
|
|
557
646
|
* returns — and the damage a late one does is silent: it writes a verdict
|
|
558
647
|
* about a finished command over a record describing a live one, and every
|
|
559
|
-
* `
|
|
560
|
-
*
|
|
561
|
-
* `startedAt` is the generation marker. `#beginInstall` rewrites the context
|
|
562
|
-
* before it spawns, so a drain whose stamp no longer matches has been
|
|
563
|
-
* superseded and has nothing useful left to say. The marker also **latches**:
|
|
564
|
-
* ownership is not recoverable, so a stamp that happens to match again does
|
|
565
|
-
* not hand the record back.
|
|
648
|
+
* `bash` then reads a result that belongs to nothing.
|
|
566
649
|
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
650
|
+
* Every write goes through `run.write`, which checks and writes in one step
|
|
651
|
+
* and clears the watchdog with a verdict. `stillMine` is asked only before
|
|
652
|
+
* this drain's own side effects. The superseded line is logged once — a
|
|
653
|
+
* superseded drain can be refused on both the success and the error path.
|
|
569
654
|
*/
|
|
570
|
-
const
|
|
655
|
+
const run = this.#job.generation(startedAt);
|
|
571
656
|
let logged = false;
|
|
657
|
+
const superseded = async () => {
|
|
658
|
+
if (logged)
|
|
659
|
+
return;
|
|
660
|
+
logged = true;
|
|
661
|
+
const current = await this.#job.read();
|
|
662
|
+
console.warn(`[${this.deps.tag()}] discarding a superseded install drain`, {
|
|
663
|
+
id: this.deps.id(),
|
|
664
|
+
command,
|
|
665
|
+
startedAt,
|
|
666
|
+
current: current.state === "running" ? current.startedAt : current.state
|
|
667
|
+
});
|
|
668
|
+
};
|
|
572
669
|
const stillMine = async () => {
|
|
573
|
-
if (await
|
|
670
|
+
if (await run.stillMine())
|
|
574
671
|
return true;
|
|
575
|
-
|
|
576
|
-
logged = true;
|
|
577
|
-
console.warn(`[${this.deps.tag()}] discarding a superseded install drain`, {
|
|
578
|
-
id: this.deps.id(),
|
|
579
|
-
command,
|
|
580
|
-
startedAt,
|
|
581
|
-
current: (await this.#job.context())?.startedAt
|
|
582
|
-
});
|
|
583
|
-
}
|
|
672
|
+
await superseded();
|
|
584
673
|
return false;
|
|
585
674
|
};
|
|
675
|
+
const settle = async (verdict, also) => {
|
|
676
|
+
if (!(await run.write(verdict, also)))
|
|
677
|
+
await superseded();
|
|
678
|
+
};
|
|
586
679
|
try {
|
|
587
680
|
const result = await handle.result();
|
|
588
681
|
// Middle-out rather than a tail cut, and it marks what it dropped: an
|
|
@@ -593,33 +686,34 @@ export class InstallJob {
|
|
|
593
686
|
if (!(await stillMine()))
|
|
594
687
|
return;
|
|
595
688
|
if (result.exitCode === 0) {
|
|
596
|
-
|
|
597
|
-
|
|
689
|
+
// With the verdict, in the same write: the tree is this install's only if
|
|
690
|
+
// the verdict is.
|
|
691
|
+
const tree = context?.dir
|
|
692
|
+
? {
|
|
598
693
|
dir: context.dir,
|
|
599
694
|
fingerprint: context.fingerprint,
|
|
600
695
|
at: Date.now()
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
}
|
|
696
|
+
}
|
|
697
|
+
: undefined;
|
|
604
698
|
console.info(`[${this.deps.tag()}] install finished`, {
|
|
605
699
|
id: this.deps.id(),
|
|
606
700
|
command,
|
|
607
701
|
seconds: Math.round((Date.now() - startedAt) / 1000)
|
|
608
702
|
});
|
|
609
|
-
await
|
|
703
|
+
await settle({
|
|
610
704
|
state: "done",
|
|
611
705
|
command,
|
|
612
706
|
exitCode: 0,
|
|
613
707
|
finishedAt: Date.now(),
|
|
614
708
|
ms: Date.now() - startedAt,
|
|
615
709
|
tail
|
|
616
|
-
});
|
|
710
|
+
}, tree ? { [CONTAINER_TREE_KEY]: tree } : undefined);
|
|
617
711
|
}
|
|
618
712
|
else {
|
|
619
713
|
// Logged, and this line is not optional. This is the *ordinary* way an
|
|
620
714
|
// install fails — the other paths are all exceptional — and it used to
|
|
621
715
|
// write the record and say nothing, so an operator looking at why the
|
|
622
|
-
//
|
|
716
|
+
// agent was complaining found the complaint and no cause. The tail
|
|
623
717
|
// is the install's own last words; without it the only copy is inside a
|
|
624
718
|
// Durable Object nobody can query.
|
|
625
719
|
console.error(`[${this.deps.tag()}] install failed`, {
|
|
@@ -629,7 +723,7 @@ export class InstallJob {
|
|
|
629
723
|
seconds: Math.round((Date.now() - startedAt) / 1000),
|
|
630
724
|
tail: truncateOutput(tail, 1000)
|
|
631
725
|
});
|
|
632
|
-
await
|
|
726
|
+
await settle({
|
|
633
727
|
state: "failed",
|
|
634
728
|
command,
|
|
635
729
|
finishedAt: Date.now(),
|
|
@@ -658,7 +752,7 @@ export class InstallJob {
|
|
|
658
752
|
seconds: Math.round((Date.now() - startedAt) / 1000),
|
|
659
753
|
err: String(err)
|
|
660
754
|
});
|
|
661
|
-
await
|
|
755
|
+
await settle({
|
|
662
756
|
state: "failed",
|
|
663
757
|
command,
|
|
664
758
|
finishedAt: Date.now(),
|
|
@@ -666,13 +760,8 @@ export class InstallJob {
|
|
|
666
760
|
});
|
|
667
761
|
}
|
|
668
762
|
finally {
|
|
669
|
-
this.#
|
|
763
|
+
this.#owned.delete(startedAt);
|
|
670
764
|
handle[Symbol.dispose]();
|
|
671
|
-
// Not if this drain was superseded: the watchdog belongs to whichever
|
|
672
|
-
// install owns the record now, and clearing it here would disarm the one
|
|
673
|
-
// recovery path the *live* install has.
|
|
674
|
-
if (!generation.superseded())
|
|
675
|
-
await this.#job.clearWatch();
|
|
676
765
|
}
|
|
677
766
|
}
|
|
678
767
|
/**
|
|
@@ -681,10 +770,12 @@ export class InstallJob {
|
|
|
681
770
|
* `getExec` with `resume: "tail"` re-opens the stream of a command that is
|
|
682
771
|
* still running in the container — or replays the end of one that finished
|
|
683
772
|
* while nobody was listening, which is the case that would otherwise leave the
|
|
684
|
-
* record stuck at `running` and every `
|
|
773
|
+
* record stuck at `running` and every `bash` blocked behind it.
|
|
685
774
|
*/
|
|
686
|
-
async #reattachInstall(
|
|
687
|
-
|
|
775
|
+
async #reattachInstall(
|
|
776
|
+
/** The running record's `startedAt`: the generation it picks up. */
|
|
777
|
+
startedAt) {
|
|
778
|
+
if (await this.#drivenHere(startedAt))
|
|
688
779
|
return;
|
|
689
780
|
try {
|
|
690
781
|
const handle = await this.deps
|
|
@@ -693,12 +784,12 @@ export class InstallJob {
|
|
|
693
784
|
encoding: "utf8",
|
|
694
785
|
resume: "tail"
|
|
695
786
|
});
|
|
696
|
-
this.deps.waitUntil(this.#drainInstall(handle));
|
|
787
|
+
this.deps.waitUntil(this.#drainInstall(handle, startedAt));
|
|
697
788
|
}
|
|
698
789
|
catch (err) {
|
|
699
790
|
// The exec is gone entirely — the container was replaced under it. Say so
|
|
700
791
|
// rather than leaving the gate closed forever; the next checkout starts a
|
|
701
|
-
// new install, and `
|
|
792
|
+
// new install, and `bash` can run in the meantime.
|
|
702
793
|
if (execWasLost(err))
|
|
703
794
|
await this.deps.containerGone();
|
|
704
795
|
// Same split as the spawn path: a replaced container is worth re-running
|
|
@@ -717,16 +808,15 @@ export class InstallJob {
|
|
|
717
808
|
err: String(err)
|
|
718
809
|
});
|
|
719
810
|
const context = await this.#job.context();
|
|
720
|
-
await this.#job.write({
|
|
811
|
+
await this.#job.generation(startedAt).write({
|
|
721
812
|
state: "failed",
|
|
722
813
|
command: context?.command ?? "(unknown)",
|
|
723
814
|
finishedAt: Date.now(),
|
|
724
815
|
error: fault
|
|
725
816
|
? `the install stopped without reporting: ${fault.remedy}`
|
|
726
817
|
: "the install stopped without reporting — its container was most " +
|
|
727
|
-
"likely replaced. Re-run it with
|
|
818
|
+
"likely replaced. Re-run it with bash, or clone again to restart it."
|
|
728
819
|
});
|
|
729
|
-
await this.#job.clearWatch();
|
|
730
820
|
}
|
|
731
821
|
}
|
|
732
822
|
/**
|
|
@@ -734,24 +824,34 @@ export class InstallJob {
|
|
|
734
824
|
*
|
|
735
825
|
* `clearArmed` first and unconditionally: this handler runs for minutes, and
|
|
736
826
|
* the stamp left in place is what a second arming would recognise as its own.
|
|
737
|
-
*
|
|
827
|
+
* The reservation in `#beginInstall` would catch a double, but the cheaper
|
|
738
828
|
* answer is not to schedule one.
|
|
739
829
|
*/
|
|
740
830
|
async onRun() {
|
|
741
831
|
const context = await this.#job.context();
|
|
742
832
|
const armedAt = await this.#job.armedAt();
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
833
|
+
// Owned before the arm is cleared: from then until the reservation takes it
|
|
834
|
+
// over, the placeholder is a `running` record that only this call will finish.
|
|
835
|
+
if (armedAt !== undefined)
|
|
836
|
+
this.#owned.add(armedAt);
|
|
837
|
+
try {
|
|
838
|
+
await this.#job.clearArmed();
|
|
839
|
+
if (!context?.dir || armedAt === undefined)
|
|
840
|
+
return;
|
|
841
|
+
const state = await this.#awaited({
|
|
842
|
+
dir: context.dir,
|
|
843
|
+
...(context.repo ? { repo: context.repo } : {})
|
|
844
|
+
}, armedAt);
|
|
845
|
+
console.info(`[${this.deps.tag()}] armed reinstall finished`, {
|
|
846
|
+
id: this.deps.id(),
|
|
847
|
+
dir: context.dir,
|
|
848
|
+
state: state.state
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
finally {
|
|
852
|
+
if (armedAt !== undefined)
|
|
853
|
+
this.#owned.delete(armedAt);
|
|
854
|
+
}
|
|
755
855
|
}
|
|
756
856
|
/** The watchdog: an install still running that nobody is draining. */
|
|
757
857
|
async onWatch() {
|
|
@@ -760,7 +860,7 @@ export class InstallJob {
|
|
|
760
860
|
return;
|
|
761
861
|
// Still running and nobody draining it: this isolate is new since the
|
|
762
862
|
// command started. Re-attach, and come back if it is still going.
|
|
763
|
-
await this.#reattachInstall();
|
|
863
|
+
await this.#reattachInstall(state.startedAt);
|
|
764
864
|
await this.#job.armWatch();
|
|
765
865
|
}
|
|
766
866
|
}
|