@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.
Files changed (130) 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 +53 -0
  5. package/dist/claude-code/cards.js +272 -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 +342 -0
  18. package/dist/claude-code/run.d.ts +209 -112
  19. package/dist/claude-code/run.js +339 -138
  20. package/dist/claude-code/session.d.ts +131 -0
  21. package/dist/claude-code/session.js +285 -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-fault.d.ts +3 -3
  69. package/dist/{computer/host → workspace}/container-fault.js +23 -8
  70. package/dist/workspace/exec.d.ts +94 -0
  71. package/dist/workspace/exec.js +153 -0
  72. package/dist/workspace/format.d.ts +36 -0
  73. package/dist/workspace/format.js +57 -0
  74. package/dist/{computer/host → workspace}/git-host.d.ts +21 -4
  75. package/dist/{computer/host → workspace}/git-host.js +72 -2
  76. package/dist/{computer/host → workspace}/git-identity.d.ts +1 -1
  77. package/dist/{computer/host → workspace}/git-identity.js +1 -1
  78. package/dist/workspace/index.d.ts +43 -47
  79. package/dist/workspace/index.js +52 -112
  80. package/dist/{computer/host → workspace}/install-job.d.ts +4 -4
  81. package/dist/{computer/host → workspace}/install-job.js +276 -176
  82. package/dist/{computer → workspace}/install.d.ts +8 -8
  83. package/dist/{computer → workspace}/install.js +3 -3
  84. package/dist/{computer/host/workspace.d.ts → workspace/object.d.ts} +105 -27
  85. package/dist/{computer/host/workspace.js → workspace/object.js} +217 -48
  86. package/dist/workspace/open.d.ts +95 -0
  87. package/dist/workspace/open.js +63 -0
  88. package/dist/workspace/read.d.ts +16 -0
  89. package/dist/workspace/read.js +22 -0
  90. package/dist/{computer → workspace}/shell.d.ts +5 -5
  91. package/dist/{computer → workspace}/shell.js +6 -6
  92. package/dist/{computer/host → workspace}/wake.d.ts +1 -1
  93. package/package.json +16 -36
  94. package/dist/arc-agi/analysis.d.ts +0 -322
  95. package/dist/arc-agi/analysis.js +0 -802
  96. package/dist/arc-agi/client.d.ts +0 -82
  97. package/dist/arc-agi/client.js +0 -113
  98. package/dist/arc-agi/game-tools.d.ts +0 -24
  99. package/dist/arc-agi/game-tools.js +0 -26
  100. package/dist/arc-agi/index.d.ts +0 -40
  101. package/dist/arc-agi/index.js +0 -175
  102. package/dist/arc-agi/main-agent.d.ts +0 -24
  103. package/dist/arc-agi/main-agent.js +0 -62
  104. package/dist/arc-agi/recipe.d.ts +0 -48
  105. package/dist/arc-agi/recipe.js +0 -97
  106. package/dist/arc-agi/scorecard.d.ts +0 -118
  107. package/dist/arc-agi/scorecard.js +0 -163
  108. package/dist/arc-agi/soul.d.ts +0 -29
  109. package/dist/arc-agi/soul.js +0 -79
  110. package/dist/arc-agi/store.d.ts +0 -153
  111. package/dist/arc-agi/store.js +0 -215
  112. package/dist/arc-agi/tools.d.ts +0 -36
  113. package/dist/arc-agi/tools.js +0 -574
  114. package/dist/arc-agi/types.d.ts +0 -211
  115. package/dist/arc-agi/types.js +0 -19
  116. package/dist/claude-code/recipe.d.ts +0 -53
  117. package/dist/claude-code/recipe.js +0 -100
  118. package/dist/computer/read.d.ts +0 -133
  119. package/dist/computer/read.js +0 -200
  120. package/dist/computer/tools-find.js +0 -216
  121. package/dist/recall/index.d.ts +0 -105
  122. package/dist/recall/index.js +0 -181
  123. package/dist/repo/tools-forge.d.ts +0 -4
  124. package/dist/triage/index.d.ts +0 -360
  125. package/dist/triage/index.js +0 -311
  126. /package/dist/{computer/host → workspace}/container-deps.d.ts +0 -0
  127. /package/dist/{computer/host → workspace}/container-deps.js +0 -0
  128. /package/dist/{computer/host → workspace}/sync.d.ts +0 -0
  129. /package/dist/{computer/host → workspace}/sync.js +0 -0
  130. /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 } 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`
@@ -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 `claim`, which recognises its own placeholder and nothing
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 `claim`, the cooldown floor and the run intent — in
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 chunk step that dies at ten minutes.
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
- * 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
- */
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.#job.write(state);
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 this.#job.write(state);
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
- 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 = {
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: resolution.command,
523
+ command: previous.command,
446
524
  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;
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 sb_exec if you still need it."
583
+ "Run the command yourself with bash if you still need it."
508
584
  };
509
- await this.#job.write(failed);
510
- await this.#job.clearWatch();
511
- return failed;
512
- }
513
- if (!this.#draining) {
514
- await this.#reattachInstall();
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
- return state;
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://../advisory.ts deriveAdvisories}, which is the only
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 stamp the install this drain was handed belongs to.
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
- * 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.
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
- * 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.
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
- ownedAt) {
547
- this.#draining = true;
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 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
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
- * `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.
648
+ * `bash` then reads a result that belongs to nothing.
566
649
  *
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.
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 generation = this.#job.generation(startedAt);
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 generation.stillMine())
670
+ if (await run.stillMine())
574
671
  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
- }
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
- if (context?.dir) {
597
- const tree = {
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
- await this.deps.storage.put(CONTAINER_TREE_KEY, tree);
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 this.#job.write({
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
- // subagent was complaining found the complaint and no cause. The tail
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 this.#job.write({
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 this.#job.write({
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.#draining = false;
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 `sb_exec` blocked behind it.
773
+ * record stuck at `running` and every `bash` blocked behind it.
685
774
  */
686
- async #reattachInstall() {
687
- if (this.#draining)
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 `sb_exec` can run in the meantime.
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 sb_exec, or clone again to restart it."
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
- * `startInstall`'s in-flight guard would catch a double, but the cheaper
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
- 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
- });
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
  }