@dynamicagents/plugins 0.12.0 → 0.14.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.
Files changed (132) hide show
  1. package/README.md +58 -77
  2. package/dist/browser/index.d.ts +0 -2
  3. package/dist/browser/index.js +21 -27
  4. package/dist/claude-code/cards.d.ts +55 -0
  5. package/dist/claude-code/cards.js +283 -0
  6. package/dist/claude-code/config.d.ts +28 -63
  7. package/dist/claude-code/config.js +0 -11
  8. package/dist/claude-code/egress.d.ts +8 -1
  9. package/dist/claude-code/egress.js +42 -4
  10. package/dist/claude-code/events.d.ts +106 -9
  11. package/dist/claude-code/events.js +201 -56
  12. package/dist/claude-code/github.d.ts +39 -0
  13. package/dist/claude-code/github.js +82 -0
  14. package/dist/claude-code/index.d.ts +16 -90
  15. package/dist/claude-code/index.js +14 -276
  16. package/dist/claude-code/model.d.ts +152 -0
  17. package/dist/claude-code/model.js +366 -0
  18. package/dist/claude-code/run.d.ts +230 -116
  19. package/dist/claude-code/run.js +380 -167
  20. package/dist/claude-code/session.d.ts +131 -0
  21. package/dist/claude-code/session.js +288 -0
  22. package/dist/claude-code/spec.d.ts +46 -0
  23. package/dist/claude-code/spec.js +83 -0
  24. package/dist/claude-code/verified.d.ts +15 -0
  25. package/dist/claude-code/verified.js +15 -0
  26. package/dist/computer/context.d.ts +1 -5
  27. package/dist/computer/context.js +7 -10
  28. package/dist/computer/file-lock.js +5 -4
  29. package/dist/computer/gate.d.ts +4 -4
  30. package/dist/computer/gate.js +5 -5
  31. package/dist/computer/index.d.ts +40 -258
  32. package/dist/computer/index.js +71 -324
  33. package/dist/computer/paths.d.ts +36 -16
  34. package/dist/computer/paths.js +42 -29
  35. package/dist/computer/proxy.d.ts +45 -0
  36. package/dist/computer/proxy.js +234 -0
  37. package/dist/computer/render.d.ts +1 -40
  38. package/dist/computer/render.js +2 -68
  39. package/dist/computer/tools-exec.js +12 -13
  40. package/dist/computer/tools-file.d.ts +9 -1
  41. package/dist/computer/tools-file.js +22 -82
  42. package/dist/computer/{tools-find.d.ts → tools-grep.d.ts} +1 -1
  43. package/dist/computer/tools-grep.js +129 -0
  44. package/dist/repo/checkout.d.ts +4 -5
  45. package/dist/repo/checkout.js +31 -8
  46. package/dist/repo/context.d.ts +35 -9
  47. package/dist/repo/context.js +101 -49
  48. package/dist/repo/index.d.ts +73 -28
  49. package/dist/repo/index.js +112 -96
  50. package/dist/repo/tools-checks.d.ts +3 -0
  51. package/dist/repo/tools-checks.js +112 -0
  52. package/dist/repo/tools-clone.js +1 -1
  53. package/dist/repo/tools-pr.d.ts +14 -0
  54. package/dist/repo/{tools-forge.js → tools-pr.js} +53 -36
  55. package/dist/repo/tools-review.d.ts +9 -0
  56. package/dist/repo/tools-review.js +67 -47
  57. package/dist/repo/tools-worktree.js +144 -60
  58. package/dist/repo/tools-worktrees.d.ts +12 -0
  59. package/dist/repo/tools-worktrees.js +48 -0
  60. package/dist/repo/url.d.ts +2 -2
  61. package/dist/repo/url.js +7 -7
  62. package/dist/scratch/index.d.ts +4 -4
  63. package/dist/scratch/index.js +12 -11
  64. package/dist/{computer → workspace}/advisory.d.ts +4 -4
  65. package/dist/{computer → workspace}/advisory.js +2 -2
  66. package/dist/{computer/host → workspace}/ca-trust.d.ts +8 -1
  67. package/dist/{computer/host → workspace}/ca-trust.js +12 -3
  68. package/dist/{computer/host → workspace}/container-deps.d.ts +4 -0
  69. package/dist/{computer/host → workspace}/container-deps.js +26 -1
  70. package/dist/{computer/host → workspace}/container-fault.d.ts +3 -3
  71. package/dist/{computer/host → workspace}/container-fault.js +23 -8
  72. package/dist/workspace/deps-snapshot.d.ts +110 -0
  73. package/dist/workspace/deps-snapshot.js +424 -0
  74. package/dist/workspace/exec.d.ts +94 -0
  75. package/dist/workspace/exec.js +153 -0
  76. package/dist/workspace/format.d.ts +36 -0
  77. package/dist/workspace/format.js +57 -0
  78. package/dist/{computer/host → workspace}/git-host.d.ts +21 -4
  79. package/dist/{computer/host → workspace}/git-host.js +72 -2
  80. package/dist/{computer/host → workspace}/git-identity.d.ts +1 -1
  81. package/dist/{computer/host → workspace}/git-identity.js +1 -1
  82. package/dist/workspace/index.d.ts +44 -47
  83. package/dist/workspace/index.js +52 -112
  84. package/dist/{computer/host → workspace}/install-job.d.ts +53 -4
  85. package/dist/{computer/host → workspace}/install-job.js +346 -178
  86. package/dist/{computer → workspace}/install.d.ts +17 -8
  87. package/dist/{computer → workspace}/install.js +24 -6
  88. package/dist/{computer/host/workspace.d.ts → workspace/object.d.ts} +125 -27
  89. package/dist/{computer/host/workspace.js → workspace/object.js} +341 -58
  90. package/dist/workspace/open.d.ts +95 -0
  91. package/dist/workspace/open.js +63 -0
  92. package/dist/workspace/read.d.ts +16 -0
  93. package/dist/workspace/read.js +22 -0
  94. package/dist/{computer → workspace}/shell.d.ts +5 -5
  95. package/dist/{computer → workspace}/shell.js +6 -6
  96. package/dist/{computer/host → workspace}/wake.d.ts +3 -1
  97. package/package.json +17 -37
  98. package/dist/arc-agi/analysis.d.ts +0 -322
  99. package/dist/arc-agi/analysis.js +0 -802
  100. package/dist/arc-agi/client.d.ts +0 -82
  101. package/dist/arc-agi/client.js +0 -113
  102. package/dist/arc-agi/game-tools.d.ts +0 -24
  103. package/dist/arc-agi/game-tools.js +0 -26
  104. package/dist/arc-agi/index.d.ts +0 -40
  105. package/dist/arc-agi/index.js +0 -175
  106. package/dist/arc-agi/main-agent.d.ts +0 -24
  107. package/dist/arc-agi/main-agent.js +0 -62
  108. package/dist/arc-agi/recipe.d.ts +0 -48
  109. package/dist/arc-agi/recipe.js +0 -97
  110. package/dist/arc-agi/scorecard.d.ts +0 -118
  111. package/dist/arc-agi/scorecard.js +0 -163
  112. package/dist/arc-agi/soul.d.ts +0 -29
  113. package/dist/arc-agi/soul.js +0 -79
  114. package/dist/arc-agi/store.d.ts +0 -153
  115. package/dist/arc-agi/store.js +0 -215
  116. package/dist/arc-agi/tools.d.ts +0 -36
  117. package/dist/arc-agi/tools.js +0 -574
  118. package/dist/arc-agi/types.d.ts +0 -211
  119. package/dist/arc-agi/types.js +0 -19
  120. package/dist/claude-code/recipe.d.ts +0 -53
  121. package/dist/claude-code/recipe.js +0 -100
  122. package/dist/computer/read.d.ts +0 -133
  123. package/dist/computer/read.js +0 -200
  124. package/dist/computer/tools-find.js +0 -216
  125. package/dist/recall/index.d.ts +0 -105
  126. package/dist/recall/index.js +0 -181
  127. package/dist/repo/tools-forge.d.ts +0 -4
  128. package/dist/triage/index.d.ts +0 -360
  129. package/dist/triage/index.js +0 -311
  130. /package/dist/{computer/host → workspace}/sync.d.ts +0 -0
  131. /package/dist/{computer/host → workspace}/sync.js +0 -0
  132. /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 `../index.js`, and that is structural: the barrel
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 "../install.js";
8
- import { pathExists } from "../read.js";
7
+ import { installFingerprint, resolveInstallCommand, rootFingerprint } 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 "../render.js";
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 a subagent
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`
@@ -38,8 +38,9 @@ function execWasLost(err) {
38
38
  * ## What decides that an install is needed
39
39
  *
40
40
  * The tree lives on the container's disk (see `./container-deps.ts`), so it goes
41
- * with the container. {@link CONTAINER_TREE_KEY} records a successful install and
42
- * is cleared whenever that container is seen gone.
41
+ * with the container. {@link CONTAINER_TREE_KEY} records a successful install — or
42
+ * a tree restored with a snapshot of the container that ran one, through
43
+ * {@link InstallJob.adopt} — and is cleared whenever that container is seen gone.
43
44
  */
44
45
  /**
45
46
  * The install's job id, which is also the key its state record lives under.
@@ -78,6 +79,11 @@ const INSTALL_STALE_MS = 5 * 60_000;
78
79
  const CONTAINER_TREE_KEY = "install:tree";
79
80
  /** Records a deployed object may still hold, deleted on the next install. */
80
81
  const RETIRED_KEYS = ["install:syncing", "install:completed"];
82
+ /**
83
+ * The command a reservation's placeholder names, until the install's own is
84
+ * resolved. The gate renders it while it waits.
85
+ */
86
+ const RESOLVING = "(being resolved)";
81
87
  /**
82
88
  * The exec id an install runs under.
83
89
  *
@@ -108,8 +114,12 @@ export class InstallJob {
108
114
  *
109
115
  * **Arming writes `running` before anything is running**, which holds the gate
110
116
  * shut in the moments before the alarm fires. The alarm then presents the stamp
111
- * arming wrote to `claim`, which recognises its own placeholder and nothing
117
+ * arming wrote to `reserve`, which recognises its own placeholder and nothing
112
118
  * else — taking over any `running` record instead would be displacement again.
119
+ *
120
+ * Every write a run makes goes through the lifecycle's `generation`, under
121
+ * the `startedAt` its reservation returned, so a run that has been displaced
122
+ * writes nothing.
113
123
  */
114
124
  #job;
115
125
  // Built here rather than as a field initializer, which would run before the
@@ -181,7 +191,7 @@ export class InstallJob {
181
191
  dir: context.dir
182
192
  });
183
193
  // Everything the arming handshake needs — the placeholder write, the stamp
184
- // the alarm presents to `claim`, the cooldown floor and the run intent — in
194
+ // the alarm presents to `reserve`, the cooldown floor and the run intent — in
185
195
  // one call, and unwound as a unit if the intent cannot be scheduled.
186
196
  await this.#job.arm({ command: state.command });
187
197
  }
@@ -203,6 +213,57 @@ export class InstallJob {
203
213
  async dir() {
204
214
  return (await this.#job.context())?.dir;
205
215
  }
216
+ /** Where the last install ran, and for which repository's override. */
217
+ async target() {
218
+ const context = await this.#job.context();
219
+ if (!context?.dir)
220
+ return undefined;
221
+ return {
222
+ dir: context.dir,
223
+ ...(context.repo ? { repo: context.repo } : {})
224
+ };
225
+ }
226
+ /**
227
+ * The fingerprint an install into `target` would write as its marker, from the
228
+ * checkout as it stands — `null` when there is nothing to install there.
229
+ */
230
+ async fingerprintFor(target) {
231
+ const probe = this.#probe();
232
+ const resolution = await resolveInstallCommand(probe, target.dir, this.deps.plan(), target.repo);
233
+ return await installFingerprint(probe, target.dir, resolution);
234
+ }
235
+ /** What `dir`'s install inputs hash to — see `rootFingerprint`. */
236
+ async rootFingerprint(dir) {
237
+ return await rootFingerprint(this.#probe(), dir, this.deps.plan());
238
+ }
239
+ /** The tree this container holds, if a finished install says it does. */
240
+ async tree() {
241
+ const tree = await this.#tree();
242
+ return (tree && { dir: tree.dir, fingerprint: tree.fingerprint, at: tree.at });
243
+ }
244
+ /**
245
+ * Record a tree this container came up holding — restored with a snapshot of
246
+ * the container that installed it — as if this container had installed it.
247
+ *
248
+ * Only for a tree whose marker the setup read and matched against the checkout
249
+ * as it stands; see `./deps-snapshot.ts`. So the last install's context moves
250
+ * to it too: a checkout that moved since that install would otherwise read as
251
+ * missing its tree, and arm an install that finds the tree already there.
252
+ */
253
+ async adopt(tree) {
254
+ await this.deps.storage.put(CONTAINER_TREE_KEY, {
255
+ dir: tree.dir,
256
+ fingerprint: tree.fingerprint,
257
+ at: Date.now()
258
+ });
259
+ const context = await this.#job.context();
260
+ if (context?.dir === tree.dir && context.fingerprint !== tree.fingerprint)
261
+ await this.#job.putContext({ ...context, fingerprint: tree.fingerprint });
262
+ console.info(`[${this.deps.tag()}] adopted a restored dependency tree`, {
263
+ id: this.deps.id(),
264
+ dir: tree.dir
265
+ });
266
+ }
206
267
  /** The container went away, and its tree with it. */
207
268
  async containerGone() {
208
269
  await this.deps.storage.delete(CONTAINER_TREE_KEY);
@@ -239,7 +300,7 @@ export class InstallJob {
239
300
  *
240
301
  * Called from `repo_clone` through the repo plugin's `afterCheckout` hook, so
241
302
  * it runs inside a model turn and must not block on the install — 225 seconds
242
- * for slack-gatekeeper, against a chunk step that dies at ten minutes.
303
+ * for slack-gatekeeper, against a turn that is cut at fifteen minutes.
243
304
  *
244
305
  * That caller is a model turn, which lives long enough to hold the drain handed
245
306
  * to `ctx.waitUntil` below. **A short-lived caller cannot**, which is why an
@@ -273,35 +334,10 @@ export class InstallJob {
273
334
  async #beginInstall(req, own, opts) {
274
335
  await this.deps.touch();
275
336
  await this.deps.ready();
276
- /**
277
- * One install at a time — the hazard is displacement.
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
- */
337
+ // {@link state} rather than the lifecycle's raw `read()`: a `running` record
338
+ // left by a dead isolate is repaired on the way past, and one nobody is
339
+ // draining is re-attached.
295
340
  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
341
  /**
306
342
  * A full workspace refuses the install and **writes nothing**.
307
343
  *
@@ -325,11 +361,126 @@ export class InstallJob {
325
361
  });
326
362
  return current;
327
363
  }
364
+ /**
365
+ * One install at a time — the hazard is displacement.
366
+ *
367
+ * `repo_clone` calls this and a recovered turn calls it again. Every call
368
+ * spawns with the same {@link INSTALL_EXEC_ID}, so without this each would
369
+ * displace the last while the displaced command's drain stayed attached
370
+ * through `ctx.waitUntil` — then wrote *its* outcome over a record
371
+ * describing an install still running perfectly well.
372
+ *
373
+ * Reserved rather than checked: everything between here and the spawn —
374
+ * probing the checkout, hashing the lockfile — awaits the container, and a
375
+ * check that wrote nothing would let a second caller through in that time.
376
+ *
377
+ * `takeOverArmedAt` is the one exemption, narrow on purpose: the alarm's
378
+ * placeholder is a `running` record for an install that has not started, so
379
+ * the alarm must pass its own guard and only its own. Matching the exact
380
+ * `startedAt` it wrote is what stops that becoming "take over any running
381
+ * install", which is displacement again in a new hat.
382
+ */
383
+ const reserved = await this.#job.reserve({ command: RESOLVING }, this.deps.timeoutMs(), opts?.takeOverArmedAt);
384
+ if (!reserved.ok) {
385
+ console.info(`[${this.deps.tag()}] an install is already in flight`, {
386
+ id: this.deps.id(),
387
+ command: reserved.current.command,
388
+ seconds: Math.round((Date.now() - reserved.current.startedAt) / 1000)
389
+ });
390
+ return reserved.current;
391
+ }
392
+ const { startedAt } = reserved;
393
+ const run = this.#job.generation(startedAt);
394
+ this.#owned.add(startedAt);
395
+ let handed = false;
396
+ try {
397
+ return await this.#spawnReserved(req, run, reserved, own, () => {
398
+ handed = true;
399
+ });
400
+ }
401
+ finally {
402
+ if (!handed)
403
+ this.#owned.delete(startedAt);
404
+ }
405
+ }
406
+ /**
407
+ * Everything after the reservation: prepare, spawn, and hand the command to
408
+ * `own`. Split out so {@link #beginInstall} can hold the generation for exactly
409
+ * this long — `handing` is called as the drain takes it over.
410
+ */
411
+ async #spawnReserved(req, run, reserved, own, handing) {
412
+ const { startedAt } = reserved;
328
413
  // Nothing trusts the interception CA here, deliberately: the workspace's own
329
414
  // `ready` does it, above every early return in this method. An install
330
415
  // already in flight and a full workspace both return before this line, and
331
416
  // neither of them means the container cannot speak TLS — the full workspace
332
417
  // least of all, since egress is what the agent needs to dig itself out.
418
+ let prepared;
419
+ try {
420
+ prepared = await this.#prepare(req, run, startedAt);
421
+ }
422
+ catch (err) {
423
+ await this.#release(run, reserved.previous, err);
424
+ throw err;
425
+ }
426
+ if (prepared.kind === "settled")
427
+ return prepared.state;
428
+ const { resolution, fingerprint, state } = prepared;
429
+ let handle;
430
+ try {
431
+ // The marker lets a new isolate check {@link CONTAINER_TREE_KEY} against
432
+ // the container; see `reconcile`.
433
+ const marked = `${resolution.command} && printf %s ` +
434
+ `${shellQuote(fingerprint ?? "none")} > node_modules/${INSTALLED_MARKER}`;
435
+ handle = await this.deps.workspace().runtime.exec(marked, {
436
+ id: INSTALL_EXEC_ID,
437
+ cwd: req.dir,
438
+ encoding: "utf8",
439
+ timeoutMs: this.deps.timeoutMs()
440
+ });
441
+ }
442
+ catch (err) {
443
+ // The command never started, so nothing will ever drain it and no
444
+ // re-attach can find it. Close the record here: a `failed` install is
445
+ // recoverable — the agent is told what happened and can run the command
446
+ // itself — where a `running` one that nobody owns is not.
447
+ //
448
+ // Which sentence, though, depends on why. The default advice — run it
449
+ // yourself with `bash` — is good for a container that is merely
450
+ // unreachable, and useless for a deployment fault: the same container is
451
+ // what `bash` would have to reach. Under one of those, say what an
452
+ // operator has to do and do not send the agent after a command that
453
+ // cannot run either.
454
+ const fault = deploymentFault(err);
455
+ console.error(`[${this.deps.tag()}] ${fault?.summary ?? "the install could not be started"}`, {
456
+ id: this.deps.id(),
457
+ command: resolution.command,
458
+ ...(fault ? { remedy: fault.remedy } : {}),
459
+ err: String(err)
460
+ });
461
+ const failed = {
462
+ state: "failed",
463
+ command: resolution.command,
464
+ finishedAt: Date.now(),
465
+ error: fault
466
+ ? `the install could not be started: ${fault.remedy}`
467
+ : `the install could not be started (${String(err)}). The container ` +
468
+ "was most likely unreachable. Run the command yourself with bash, " +
469
+ "or clone again to retry it."
470
+ };
471
+ return await this.#settle(run, failed);
472
+ }
473
+ handing();
474
+ await own(handle, startedAt);
475
+ return state;
476
+ }
477
+ /**
478
+ * Everything between a reservation and the spawn: resolve the command, and
479
+ * settle the run here when there is nothing to install or the tree is already
480
+ * this lockfile's. Otherwise write this install's own `running` record and
481
+ * context, and arm the watchdog.
482
+ */
483
+ async #prepare(req, run, startedAt) {
333
484
  const probe = this.#probe();
334
485
  const resolution = await resolveInstallCommand(probe, req.dir, this.deps.plan(), req.repo);
335
486
  if (resolution.kind === "skip") {
@@ -337,7 +488,6 @@ export class InstallJob {
337
488
  state: "skipped",
338
489
  reason: resolution.reason
339
490
  };
340
- await this.#job.write(state);
341
491
  /**
342
492
  * The common branch, and the one whose absence is indistinguishable from
343
493
  * never having been called.
@@ -355,7 +505,7 @@ export class InstallJob {
355
505
  ...(req.repo ? { repo: req.repo } : {}),
356
506
  reason: resolution.reason
357
507
  });
358
- return state;
508
+ return { kind: "settled", state: await this.#settle(run, state) };
359
509
  }
360
510
  const fingerprint = await installFingerprint(probe, req.dir, resolution);
361
511
  // Skipped only for this container's own tree, for this lockfile.
@@ -371,18 +521,18 @@ export class InstallJob {
371
521
  ms: 0,
372
522
  tail: "dependencies already installed in this container for this lockfile"
373
523
  };
374
- await this.#job.write(state);
375
- return state;
524
+ return { kind: "settled", state: await this.#settle(run, state) };
376
525
  }
377
526
  // The tree is rebuilt from here, so nothing vouches for it until exit 0.
378
527
  await this.deps.storage.delete([CONTAINER_TREE_KEY, ...RETIRED_KEYS]);
379
- const startedAt = Date.now();
380
528
  const state = {
381
529
  state: "running",
382
530
  command: resolution.command,
383
531
  startedAt
384
532
  };
385
- await this.#job.write(state);
533
+ if (!(await run.write(state))) {
534
+ return { kind: "settled", state: await this.#job.read() };
535
+ }
386
536
  await this.#job.putContext({
387
537
  dir: req.dir,
388
538
  // Kept so a reinstall the alarm drives — which has no caller to ask —
@@ -391,8 +541,7 @@ export class InstallJob {
391
541
  // on every later run, installing a different tree than the first time.
392
542
  ...(req.repo ? { repo: req.repo } : {}),
393
543
  fingerprint,
394
- command: resolution.command,
395
- startedAt
544
+ command: resolution.command
396
545
  });
397
546
  /**
398
547
  * Armed **before** the spawn — the hazard is an isolate that dies between
@@ -408,54 +557,33 @@ export class InstallJob {
408
557
  * `running`, so finishing first costs one wake-up.
409
558
  */
410
559
  await this.#job.armWatch();
411
- let handle;
412
- try {
413
- // The marker lets a new isolate check {@link CONTAINER_TREE_KEY} against
414
- // the container; see `reconcile`.
415
- const marked = `${resolution.command} && printf %s ` +
416
- `${shellQuote(fingerprint ?? "none")} > node_modules/${INSTALLED_MARKER}`;
417
- handle = await this.deps.workspace().runtime.exec(marked, {
418
- id: INSTALL_EXEC_ID,
419
- cwd: req.dir,
420
- encoding: "utf8",
421
- timeoutMs: this.deps.timeoutMs()
422
- });
423
- }
424
- catch (err) {
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 = {
560
+ return { kind: "spawn", resolution, fingerprint, state };
561
+ }
562
+ /** Settle `run` with `state`, or say what stands if it was displaced. */
563
+ async #settle(run, state) {
564
+ return (await run.write(state)) ? state : await this.#job.read();
565
+ }
566
+ /**
567
+ * Hand a reservation back when nothing was spawned under it: the record as it
568
+ * was, unless that was a run of its own — the alarm's placeholder — which
569
+ * nothing would now finish, so `failed`.
570
+ */
571
+ async #release(run, previous, err) {
572
+ const back = previous.state === "running"
573
+ ? {
444
574
  state: "failed",
445
- command: resolution.command,
575
+ command: previous.command,
446
576
  finishedAt: Date.now(),
447
- error: fault
448
- ? `the install could not be started: ${fault.remedy}`
449
- : `the install could not be started (${String(err)}). The container ` +
450
- "was most likely unreachable. Run the command yourself with sb_exec, " +
451
- "or clone again to retry it."
452
- };
453
- await this.#job.write(failed);
454
- await this.#job.clearWatch();
455
- return failed;
577
+ error: `the install could not be prepared (${String(err)}). Run the ` +
578
+ "command yourself with bash, or clone again to retry it."
579
+ }
580
+ : previous;
581
+ try {
582
+ await run.write(back);
583
+ }
584
+ catch (rollback) {
585
+ throw new AggregateError([err, rollback], "the install could not be prepared, and its reservation could not be released");
456
586
  }
457
- await own(handle, startedAt);
458
- return state;
459
587
  }
460
588
  /**
461
589
  * Whether this container holds a finished tree, asked only when it qualifies a
@@ -504,83 +632,102 @@ export class InstallJob {
504
632
  finishedAt: Date.now(),
505
633
  error: `the install has been running for ${minutes} minutes without ` +
506
634
  "reporting, which is past its timeout — it is not going to finish. " +
507
- "Run the command yourself with sb_exec if you still need it."
635
+ "Run the command yourself with bash if you still need it."
508
636
  };
509
- await this.#job.write(failed);
510
- await this.#job.clearWatch();
511
- return failed;
637
+ // Under the record's own generation, so a reservation that took the record
638
+ // since it was read is not overwritten.
639
+ const repaired = await this.#job
640
+ .generation(state.startedAt)
641
+ .write(failed);
642
+ return repaired ? failed : await this.#job.read();
512
643
  }
513
- if (!this.#draining) {
514
- await this.#reattachInstall();
515
- return await this.#job.read();
516
- }
517
- return state;
644
+ if (await this.#drivenHere(state.startedAt))
645
+ return state;
646
+ await this.#reattachInstall(state.startedAt);
647
+ return await this.#job.read();
518
648
  }
519
649
  /**
520
650
  * When a reinstall was queued, if one is waiting to run.
521
651
  *
522
- * Read by {@link file://../advisory.ts deriveAdvisories}, which is the only
652
+ * Read by {@link file://./advisory.ts deriveAdvisories}, which is the only
523
653
  * caller that needs it: a `failed` record with a repair already queued is a
524
654
  * transient condition wearing a permanent record's clothes.
525
655
  */
526
656
  async reinstallArmedAt() {
527
657
  return await this.#job.armedAt();
528
658
  }
529
- /** True while this isolate holds the drain, so the watchdog leaves it alone. */
530
- #draining = false;
531
- async #drainInstall(handle,
532
659
  /**
533
- * The stamp the install this drain was handed belongs to.
660
+ * The generations this isolate is driving: reserved, taken over from the
661
+ * alarm's placeholder, or draining — until the drain ends.
534
662
  *
535
- * Passed in rather than read below, and that is the whole of the fix: this
536
- * method's first `await` is reached long after `#beginInstall` returned, so a
537
- * second install that claimed in between has already rewritten the context.
538
- * A drain reading it then adopts the *other* install's generation, passes
539
- * every `stillMine()` check, and writes its own verdict over a command that
540
- * is still running.
663
+ * The re-attach is for a `running` record nobody here is driving, which is one
664
+ * a dead isolate left. Asked of a generation that is reserved but not yet
665
+ * spawned, it finds no exec, or the last container's, and writes `failed` over
666
+ * an install about to start, whose own verdict is then discarded as
667
+ * superseded. Owning from the reservation rather than from the drain is what
668
+ * closes that window: resolving and spawning await the container for seconds.
669
+ */
670
+ #owned = new Set();
671
+ /**
672
+ * Whether a `running` record's install is this isolate's to finish: one it
673
+ * owns, or the alarm's placeholder still waiting for the alarm, whose
674
+ * `startedAt` is the arming stamp.
675
+ */
676
+ async #drivenHere(startedAt) {
677
+ return (this.#owned.has(startedAt) || (await this.#job.armedAt()) === startedAt);
678
+ }
679
+ async #drainInstall(handle,
680
+ /**
681
+ * The generation this drain writes under: the `startedAt` its reservation
682
+ * returned, or — re-attached — the running record's.
541
683
  *
542
- * Absent only for {@link InstallJob.#reattachInstall}, which by definition
543
- * did not start what it is picking up and has nothing but the record to go
544
- * on.
684
+ * Passed in rather than read below: this method's first `await` is reached
685
+ * long after `#beginInstall` returned, and a record read then may already be
686
+ * a later install's, whose generation this drain would adopt.
545
687
  */
546
- ownedAt) {
547
- this.#draining = true;
688
+ startedAt) {
689
+ this.#owned.add(startedAt);
548
690
  const context = await this.#job.context();
549
691
  const command = context?.command ?? "(unknown)";
550
- const startedAt = ownedAt ?? context?.startedAt ?? Date.now();
551
692
  /**
552
693
  * Whether this drain still owns the record.
553
694
  *
554
- * The guard in `startInstall` stops two installs overlapping in the first
555
- * place; this makes it harmless if one ever does. A drain can outlive the
556
- * command it was watching — `ctx.waitUntil` keeps running after the RPC
695
+ * The reservation in `#beginInstall` stops two installs overlapping in the
696
+ * first place; this makes it harmless if one ever does. A drain can outlive
697
+ * the command it was watching — `ctx.waitUntil` keeps running after the RPC
557
698
  * returns — and the damage a late one does is silent: it writes a verdict
558
699
  * about a finished command over a record describing a live one, and every
559
- * `sb_exec` then reads a result that belongs to nothing.
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.
700
+ * `bash` then reads a result that belongs to nothing.
566
701
  *
567
- * Wrapped rather than used bare only to log the transition, and only once —
568
- * a superseded drain asks this on both the success and the error path.
702
+ * Every write goes through `run.write`, which checks and writes in one step
703
+ * and clears the watchdog with a verdict. `stillMine` is asked only before
704
+ * this drain's own side effects. The superseded line is logged once — a
705
+ * superseded drain can be refused on both the success and the error path.
569
706
  */
570
- const generation = this.#job.generation(startedAt);
707
+ const run = this.#job.generation(startedAt);
571
708
  let logged = false;
709
+ const superseded = async () => {
710
+ if (logged)
711
+ return;
712
+ logged = true;
713
+ const current = await this.#job.read();
714
+ console.warn(`[${this.deps.tag()}] discarding a superseded install drain`, {
715
+ id: this.deps.id(),
716
+ command,
717
+ startedAt,
718
+ current: current.state === "running" ? current.startedAt : current.state
719
+ });
720
+ };
572
721
  const stillMine = async () => {
573
- if (await generation.stillMine())
722
+ if (await run.stillMine())
574
723
  return true;
575
- if (!logged) {
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
- }
724
+ await superseded();
725
+ return false;
726
+ };
727
+ const settle = async (verdict, also) => {
728
+ if (await run.write(verdict, also))
729
+ return true;
730
+ await superseded();
584
731
  return false;
585
732
  };
586
733
  try {
@@ -593,33 +740,48 @@ export class InstallJob {
593
740
  if (!(await stillMine()))
594
741
  return;
595
742
  if (result.exitCode === 0) {
596
- if (context?.dir) {
597
- const tree = {
743
+ // With the verdict, in the same write: the tree is this install's only if
744
+ // the verdict is.
745
+ const tree = context?.dir
746
+ ? {
598
747
  dir: context.dir,
599
748
  fingerprint: context.fingerprint,
600
749
  at: Date.now()
601
- };
602
- await this.deps.storage.put(CONTAINER_TREE_KEY, tree);
603
- }
750
+ }
751
+ : undefined;
604
752
  console.info(`[${this.deps.tag()}] install finished`, {
605
753
  id: this.deps.id(),
606
754
  command,
607
755
  seconds: Math.round((Date.now() - startedAt) / 1000)
608
756
  });
609
- await this.#job.write({
757
+ const installed = tree?.fingerprint
758
+ ? { dir: tree.dir, fingerprint: tree.fingerprint, at: tree.at }
759
+ : undefined;
760
+ const kept = await settle({
610
761
  state: "done",
611
762
  command,
612
763
  exitCode: 0,
613
764
  finishedAt: Date.now(),
614
765
  ms: Date.now() - startedAt,
615
766
  tail
616
- });
767
+ }, tree
768
+ ? {
769
+ ...(installed ? this.deps.installedRecords(installed) : {}),
770
+ [CONTAINER_TREE_KEY]: tree
771
+ }
772
+ : undefined);
773
+ // Outside the verdict: a host that cannot act on the news must not turn
774
+ // a finished install into a failed one, which the catch below would.
775
+ if (kept && installed)
776
+ await this.deps.installed(installed).catch((err) => {
777
+ console.warn(`[${this.deps.tag()}] could not act on a finished install`, { id: this.deps.id(), err: String(err) });
778
+ });
617
779
  }
618
780
  else {
619
781
  // Logged, and this line is not optional. This is the *ordinary* way an
620
782
  // install fails — the other paths are all exceptional — and it used to
621
783
  // write the record and say nothing, so an operator looking at why the
622
- // subagent was complaining found the complaint and no cause. The tail
784
+ // agent was complaining found the complaint and no cause. The tail
623
785
  // is the install's own last words; without it the only copy is inside a
624
786
  // Durable Object nobody can query.
625
787
  console.error(`[${this.deps.tag()}] install failed`, {
@@ -629,7 +791,7 @@ export class InstallJob {
629
791
  seconds: Math.round((Date.now() - startedAt) / 1000),
630
792
  tail: truncateOutput(tail, 1000)
631
793
  });
632
- await this.#job.write({
794
+ await settle({
633
795
  state: "failed",
634
796
  command,
635
797
  finishedAt: Date.now(),
@@ -658,7 +820,7 @@ export class InstallJob {
658
820
  seconds: Math.round((Date.now() - startedAt) / 1000),
659
821
  err: String(err)
660
822
  });
661
- await this.#job.write({
823
+ await settle({
662
824
  state: "failed",
663
825
  command,
664
826
  finishedAt: Date.now(),
@@ -666,13 +828,8 @@ export class InstallJob {
666
828
  });
667
829
  }
668
830
  finally {
669
- this.#draining = false;
831
+ this.#owned.delete(startedAt);
670
832
  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
833
  }
677
834
  }
678
835
  /**
@@ -681,10 +838,12 @@ export class InstallJob {
681
838
  * `getExec` with `resume: "tail"` re-opens the stream of a command that is
682
839
  * still running in the container — or replays the end of one that finished
683
840
  * while nobody was listening, which is the case that would otherwise leave the
684
- * record stuck at `running` and every `sb_exec` blocked behind it.
841
+ * record stuck at `running` and every `bash` blocked behind it.
685
842
  */
686
- async #reattachInstall() {
687
- if (this.#draining)
843
+ async #reattachInstall(
844
+ /** The running record's `startedAt`: the generation it picks up. */
845
+ startedAt) {
846
+ if (await this.#drivenHere(startedAt))
688
847
  return;
689
848
  try {
690
849
  const handle = await this.deps
@@ -693,12 +852,12 @@ export class InstallJob {
693
852
  encoding: "utf8",
694
853
  resume: "tail"
695
854
  });
696
- this.deps.waitUntil(this.#drainInstall(handle));
855
+ this.deps.waitUntil(this.#drainInstall(handle, startedAt));
697
856
  }
698
857
  catch (err) {
699
858
  // The exec is gone entirely — the container was replaced under it. Say so
700
859
  // rather than leaving the gate closed forever; the next checkout starts a
701
- // new install, and `sb_exec` can run in the meantime.
860
+ // new install, and `bash` can run in the meantime.
702
861
  if (execWasLost(err))
703
862
  await this.deps.containerGone();
704
863
  // Same split as the spawn path: a replaced container is worth re-running
@@ -717,16 +876,15 @@ export class InstallJob {
717
876
  err: String(err)
718
877
  });
719
878
  const context = await this.#job.context();
720
- await this.#job.write({
879
+ await this.#job.generation(startedAt).write({
721
880
  state: "failed",
722
881
  command: context?.command ?? "(unknown)",
723
882
  finishedAt: Date.now(),
724
883
  error: fault
725
884
  ? `the install stopped without reporting: ${fault.remedy}`
726
885
  : "the install stopped without reporting — its container was most " +
727
- "likely replaced. Re-run it with sb_exec, or clone again to restart it."
886
+ "likely replaced. Re-run it with bash, or clone again to restart it."
728
887
  });
729
- await this.#job.clearWatch();
730
888
  }
731
889
  }
732
890
  /**
@@ -734,24 +892,34 @@ export class InstallJob {
734
892
  *
735
893
  * `clearArmed` first and unconditionally: this handler runs for minutes, and
736
894
  * the stamp left in place is what a second arming would recognise as its own.
737
- * `startInstall`'s in-flight guard would catch a double, but the cheaper
895
+ * The reservation in `#beginInstall` would catch a double, but the cheaper
738
896
  * answer is not to schedule one.
739
897
  */
740
898
  async onRun() {
741
899
  const context = await this.#job.context();
742
900
  const armedAt = await this.#job.armedAt();
743
- await this.#job.clearArmed();
744
- if (!context?.dir || armedAt === undefined)
745
- return;
746
- const state = await this.#awaited({
747
- dir: context.dir,
748
- ...(context.repo ? { repo: context.repo } : {})
749
- }, armedAt);
750
- console.info(`[${this.deps.tag()}] armed reinstall finished`, {
751
- id: this.deps.id(),
752
- dir: context.dir,
753
- state: state.state
754
- });
901
+ // Owned before the arm is cleared: from then until the reservation takes it
902
+ // over, the placeholder is a `running` record that only this call will finish.
903
+ if (armedAt !== undefined)
904
+ this.#owned.add(armedAt);
905
+ try {
906
+ await this.#job.clearArmed();
907
+ if (!context?.dir || armedAt === undefined)
908
+ return;
909
+ const state = await this.#awaited({
910
+ dir: context.dir,
911
+ ...(context.repo ? { repo: context.repo } : {})
912
+ }, armedAt);
913
+ console.info(`[${this.deps.tag()}] armed reinstall finished`, {
914
+ id: this.deps.id(),
915
+ dir: context.dir,
916
+ state: state.state
917
+ });
918
+ }
919
+ finally {
920
+ if (armedAt !== undefined)
921
+ this.#owned.delete(armedAt);
922
+ }
755
923
  }
756
924
  /** The watchdog: an install still running that nobody is draining. */
757
925
  async onWatch() {
@@ -760,7 +928,7 @@ export class InstallJob {
760
928
  return;
761
929
  // Still running and nobody draining it: this isolate is new since the
762
930
  // command started. Re-attach, and come back if it is still going.
763
- await this.#reattachInstall();
931
+ await this.#reattachInstall(state.startedAt);
764
932
  await this.#job.armWatch();
765
933
  }
766
934
  }