@dynamicagents/plugins 0.11.1 → 0.12.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 (94) hide show
  1. package/README.md +12 -13
  2. package/dist/arc-agi/analysis.d.ts +16 -30
  3. package/dist/arc-agi/analysis.js +20 -47
  4. package/dist/arc-agi/game-tools.d.ts +5 -6
  5. package/dist/arc-agi/index.d.ts +6 -8
  6. package/dist/arc-agi/index.js +3 -3
  7. package/dist/arc-agi/main-agent.d.ts +5 -8
  8. package/dist/arc-agi/main-agent.js +5 -8
  9. package/dist/arc-agi/recipe.d.ts +17 -20
  10. package/dist/arc-agi/recipe.js +17 -20
  11. package/dist/arc-agi/soul.d.ts +16 -27
  12. package/dist/arc-agi/soul.js +16 -27
  13. package/dist/arc-agi/store.d.ts +4 -5
  14. package/dist/arc-agi/store.js +4 -5
  15. package/dist/arc-agi/tools.d.ts +1 -1
  16. package/dist/arc-agi/tools.js +10 -12
  17. package/dist/arc-agi/types.d.ts +6 -7
  18. package/dist/claude-code/config.d.ts +88 -11
  19. package/dist/claude-code/config.js +11 -2
  20. package/dist/claude-code/credentials.d.ts +21 -9
  21. package/dist/claude-code/credentials.js +43 -9
  22. package/dist/claude-code/egress.d.ts +7 -13
  23. package/dist/claude-code/egress.js +9 -10
  24. package/dist/claude-code/events.d.ts +47 -0
  25. package/dist/claude-code/events.js +96 -10
  26. package/dist/claude-code/index.d.ts +31 -13
  27. package/dist/claude-code/index.js +62 -15
  28. package/dist/claude-code/run.d.ts +135 -41
  29. package/dist/claude-code/run.js +366 -61
  30. package/dist/computer/advisory.d.ts +31 -18
  31. package/dist/computer/advisory.js +30 -5
  32. package/dist/computer/context.d.ts +23 -0
  33. package/dist/computer/context.js +225 -0
  34. package/dist/computer/file-lock.d.ts +5 -0
  35. package/dist/computer/file-lock.js +39 -0
  36. package/dist/computer/gate.d.ts +8 -8
  37. package/dist/computer/gate.js +7 -7
  38. package/dist/computer/host/ca-trust.d.ts +88 -0
  39. package/dist/computer/host/ca-trust.js +303 -0
  40. package/dist/computer/host/container-deps.d.ts +54 -0
  41. package/dist/computer/host/container-deps.js +224 -0
  42. package/dist/computer/host/container-fault.d.ts +38 -0
  43. package/dist/computer/host/container-fault.js +58 -0
  44. package/dist/computer/host/git-host.d.ts +62 -0
  45. package/dist/computer/host/git-host.js +156 -0
  46. package/dist/computer/host/git-identity.d.ts +57 -0
  47. package/dist/computer/host/git-identity.js +147 -0
  48. package/dist/computer/host/install-job.d.ts +121 -0
  49. package/dist/computer/host/install-job.js +766 -0
  50. package/dist/computer/host/sync.d.ts +111 -0
  51. package/dist/computer/host/sync.js +124 -0
  52. package/dist/computer/host/wake.d.ts +19 -0
  53. package/dist/computer/host/wake.js +10 -0
  54. package/dist/computer/host/workspace.d.ts +424 -0
  55. package/dist/computer/host/workspace.js +1258 -0
  56. package/dist/computer/index.d.ts +70 -50
  57. package/dist/computer/index.js +79 -767
  58. package/dist/computer/install.d.ts +8 -12
  59. package/dist/computer/install.js +6 -9
  60. package/dist/computer/paths.d.ts +21 -16
  61. package/dist/computer/paths.js +40 -38
  62. package/dist/computer/read.d.ts +18 -22
  63. package/dist/computer/read.js +24 -27
  64. package/dist/computer/render.d.ts +28 -0
  65. package/dist/computer/render.js +34 -1
  66. package/dist/computer/shell.d.ts +45 -0
  67. package/dist/computer/shell.js +80 -0
  68. package/dist/computer/tools-exec.d.ts +3 -0
  69. package/dist/computer/tools-exec.js +269 -0
  70. package/dist/computer/tools-file.d.ts +4 -0
  71. package/dist/computer/tools-file.js +124 -0
  72. package/dist/computer/tools-find.d.ts +3 -0
  73. package/dist/computer/tools-find.js +216 -0
  74. package/dist/recall/index.d.ts +9 -3
  75. package/dist/recall/index.js +22 -12
  76. package/dist/repo/checkout.d.ts +34 -4
  77. package/dist/repo/checkout.js +36 -1
  78. package/dist/repo/context.d.ts +109 -0
  79. package/dist/repo/context.js +530 -0
  80. package/dist/repo/index.d.ts +6 -29
  81. package/dist/repo/index.js +17 -1027
  82. package/dist/repo/tools-clone.d.ts +4 -0
  83. package/dist/repo/tools-clone.js +192 -0
  84. package/dist/repo/tools-forge.d.ts +4 -0
  85. package/dist/repo/tools-forge.js +179 -0
  86. package/dist/repo/tools-review.d.ts +3 -0
  87. package/dist/repo/tools-review.js +373 -0
  88. package/dist/repo/tools-worktree.d.ts +4 -0
  89. package/dist/repo/tools-worktree.js +221 -0
  90. package/dist/triage/index.d.ts +6 -1
  91. package/dist/triage/index.js +32 -23
  92. package/dist/workspace/index.d.ts +3 -3
  93. package/dist/workspace/index.js +3 -3
  94. package/package.json +21 -15
@@ -0,0 +1,766 @@
1
+ import { shellQuote } from "@cloudflare/computer";
2
+ import { JobLifecycle } from "@dynamicagents/core/job";
3
+ // Leaf modules rather than `../index.js`, and that is structural: the barrel
4
+ // re-exports this directory, so reaching it from here would be a cycle — through
5
+ // a module that builds a class at import time, where initialisation order
6
+ // decides whether the base is `undefined`.
7
+ import { installFingerprint, resolveInstallCommand } from "../install.js";
8
+ import { pathExists } from "../read.js";
9
+ import { deploymentFault } from "./container-fault.js";
10
+ import { INSTALLED_MARKER } from "./container-deps.js";
11
+ import { truncateOutput } from "../render.js";
12
+ /**
13
+ * Whether a thrown value is the runtime reporting a replaced container.
14
+ *
15
+ * `code` rather than the message: the property the package sets deliberately,
16
+ * and the one that survives a reworded string. What it means here is narrower
17
+ * than "the command failed" — the container that command was running in is gone,
18
+ * so anything this object believed about *that* container is now about nothing.
19
+ */
20
+ function execWasLost(err) {
21
+ return err?.code === "EEXEC_LOST";
22
+ }
23
+ /**
24
+ * The dependency install: when it runs, what it runs, and what it owes on
25
+ * waking.
26
+ *
27
+ * ## The rule every guard here serves
28
+ *
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
31
+ * can act on. So a `running` record must never outlive the command it describes,
32
+ * and the ways it can are not all reachable from one place — the spawn can fail
33
+ * before a drain is attached, a drain can be cut short by an eviction, `getExec`
34
+ * can hand back a handle to a container that never answers, and installs can
35
+ * displace each other. Each guard below names the one it closes; the staleness
36
+ * bound in {@link InstallJob.state} is the proof that covers the rest.
37
+ *
38
+ * ## What decides that an install is needed
39
+ *
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.
43
+ */
44
+ /**
45
+ * The install's job id, which is also the key its state record lives under.
46
+ *
47
+ * Every other key is derived from it by `JobLifecycle`: `install:armed`,
48
+ * `install:last-armed`, `install:context`, and the wake intents `install-run`
49
+ * and `install-watch`. Changing this value renames all of them, which is a
50
+ * storage migration — the specs that read these keys directly would fail first.
51
+ */
52
+ const INSTALL_KEY = "install";
53
+ /**
54
+ * How long after arming an install before arming another.
55
+ *
56
+ * The bound on a *failing* install. A successful one is self-limiting — it
57
+ * leaves a tree in the workspace, so the arming probe short-circuits every later
58
+ * call — but a failure lands back on `failed` with the tree still absent, and
59
+ * `__getWorkspaceStub` is the busiest entry point in the object, so it would
60
+ * re-arm on essentially every tool call. Five minutes is far longer than an
61
+ * install (88s measured), so a broken install retries at roughly the rate a
62
+ * human would and a later task still gets a fresh attempt.
63
+ */
64
+ const INSTALL_ARM_COOLDOWN_MS = 5 * 60_000;
65
+ /** How often the watchdog checks on a running install. */
66
+ const INSTALL_WATCH_MS = 60_000;
67
+ /**
68
+ * How far past its own timeout a `running` install is still given the benefit of
69
+ * the doubt. Generous on purpose: declaring a live install dead costs a
70
+ * duplicate `npm ci`, and the margin only has to cover the slack between the
71
+ * runtime killing a command and this object hearing about it.
72
+ */
73
+ const INSTALL_STALE_MS = 5 * 60_000;
74
+ /**
75
+ * What the running container holds: the tree a host install finished, for which
76
+ * directory and lockfile. Written on exit 0, deleted when the container goes.
77
+ */
78
+ const CONTAINER_TREE_KEY = "install:tree";
79
+ /** Records a deployed object may still hold, deleted on the next install. */
80
+ const RETIRED_KEYS = ["install:syncing", "install:completed"];
81
+ /**
82
+ * The exec id an install runs under.
83
+ *
84
+ * Fixed rather than generated, because the point is to find it again: an
85
+ * isolate that dies mid-drain leaves the command running in the container, and
86
+ * `getExec(id, { resume: "tail" })` is how the next invocation re-attaches
87
+ * instead of starting a second `npm ci` alongside the first.
88
+ */
89
+ const INSTALL_EXEC_ID = "dependency-install";
90
+ export class InstallJob {
91
+ deps;
92
+ /**
93
+ * The dependency install, as a job this object owns through its alarm.
94
+ *
95
+ * `JobLifecycle` is core's, and what it owns is the choreography that is wrong
96
+ * the same ways every time: arming before anything runs, one job at a time
97
+ * under a staleness bound, a drain that can outlive its job, and a job nobody
98
+ * is draining. The timings below are this install's. The **drain
99
+ * loop stays here**, because an install runs to completion and writes a single
100
+ * verdict rather than reporting progress between bounded windows.
101
+ *
102
+ * **The alarm runs the install, and that is not a detail.** An install takes
103
+ * ~85 seconds and must not be owned by the request that noticed it was needed
104
+ * — a drain handed to `ctx.waitUntil` from a gate poll that returns in
105
+ * milliseconds is disposed mid-`npm ci`. An alarm invocation belongs to the
106
+ * object rather than to any caller, so nothing it awaits can be cut short by a
107
+ * response being sent.
108
+ *
109
+ * **Arming writes `running` before anything is running**, which holds the gate
110
+ * 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
112
+ * else — taking over any `running` record instead would be displacement again.
113
+ */
114
+ #job;
115
+ // Built here rather than as a field initializer, which would run before the
116
+ // constructor's own parameter property and read `deps` as undefined.
117
+ constructor(deps) {
118
+ this.deps = deps;
119
+ this.#job = new JobLifecycle({
120
+ id: INSTALL_KEY,
121
+ storage: deps.storage,
122
+ scheduler: deps.scheduler,
123
+ run: "installRun",
124
+ watch: "installWatch",
125
+ staleMs: INSTALL_STALE_MS,
126
+ watchMs: INSTALL_WATCH_MS,
127
+ armCooldownMs: INSTALL_ARM_COOLDOWN_MS
128
+ });
129
+ }
130
+ /**
131
+ * Start a dependency install the moment we can see one will be needed.
132
+ *
133
+ * The signal is {@link CONTAINER_TREE_KEY}: a local read, true only while the
134
+ * container that ran the install is the one running.
135
+ *
136
+ * Armed as soon as a container is up or a command is about to start one,
137
+ * because the model's first minute is README-reading and `git status` — an
138
+ * install armed there runs *through* that minute, where one armed on the first
139
+ * `npm` command charges its full time to that command.
140
+ *
141
+ * It writes `running` before anything is running: the alarm has not fired yet,
142
+ * and a `done` record would let an `npm` command through against a tree that
143
+ * is not there. That also makes this self-limiting — the next call sees
144
+ * `running` and stops.
145
+ */
146
+ async armIfTreeMissing() {
147
+ // Where the install went, and the only record of it: there is no caller here
148
+ // to ask, which is why `repo` is persisted alongside `dir`. Nothing to arm
149
+ // for a workspace that has never installed anything — that is `repo_clone`'s
150
+ // job, not this one's.
151
+ const context = await this.#job.context();
152
+ if (!context?.dir)
153
+ return;
154
+ // The branch that runs on almost every call, and it costs one local read.
155
+ const tree = await this.#tree();
156
+ if (tree?.dir === context.dir && tree.fingerprint === context.fingerprint)
157
+ return;
158
+ /**
159
+ * `done` **or** `failed`, matching core's `isRearmable`, and narrowed to the
160
+ * pair that carries a `state.command` — the placeholder needs one, since the
161
+ * gate renders it while the alarm is still pending.
162
+ *
163
+ * **`failed` has to be in there.** Leaving it out means a workspace whose
164
+ * install failed once declines to arm ever again, and one bad install
165
+ * poisons every task after it. Re-driving a failure cannot loop here: the
166
+ * arming cooldown is the floor under how often that can happen.
167
+ *
168
+ * `skipped` and `idle` stay out for reasons rather than caution: `skipped`
169
+ * means the resolver found nothing to install, so a missing tree is correct
170
+ * and permanent; `idle` means nothing has ever been installed, so there is
171
+ * no `install:context` naming where to do it.
172
+ *
173
+ * {@link state} rather than the lifecycle's raw `read()`, so a `running`
174
+ * record left by a dead isolate is repaired to `failed` here and can arm.
175
+ */
176
+ const state = await this.state();
177
+ if (state.state !== "done" && state.state !== "failed")
178
+ return;
179
+ console.info(`[${this.deps.tag()}] no dependency tree — arming an install`, {
180
+ id: this.deps.id(),
181
+ dir: context.dir
182
+ });
183
+ // Everything the arming handshake needs — the placeholder write, the stamp
184
+ // the alarm presents to `claim`, the cooldown floor and the run intent — in
185
+ // one call, and unwound as a unit if the intent cannot be scheduled.
186
+ await this.#job.arm({ command: state.command });
187
+ }
188
+ /** `resolveInstallCommand` reads the checkout through this. */
189
+ #probe() {
190
+ const fs = this.deps.workspace().fs;
191
+ return {
192
+ // The plugin's own, which asks for the stub's `exists` and only falls back
193
+ // to `stat` when there is none — the local `WorkspaceFilesystem` here being
194
+ // exactly that case.
195
+ exists: (path) => pathExists(fs, path),
196
+ readFile: (path) => fs.readFile(path, "utf8")
197
+ };
198
+ }
199
+ async #tree() {
200
+ return await this.deps.storage.get(CONTAINER_TREE_KEY);
201
+ }
202
+ /** Where the last install ran, for the container setup's marker read. */
203
+ async dir() {
204
+ return (await this.#job.context())?.dir;
205
+ }
206
+ /** The container went away, and its tree with it. */
207
+ async containerGone() {
208
+ await this.deps.storage.delete(CONTAINER_TREE_KEY);
209
+ }
210
+ /**
211
+ * Check the record against the marker a new isolate's setup read.
212
+ *
213
+ * The record can outlive its container in an isolate that did not watch it
214
+ * exit; the marker cannot.
215
+ */
216
+ async reconcile(marker) {
217
+ const tree = await this.#tree();
218
+ if (!tree || (tree.fingerprint ?? "none") === marker)
219
+ return;
220
+ console.warn(`[${this.deps.tag()}] the container does not hold the recorded tree`, {
221
+ id: this.deps.id(),
222
+ dir: tree.dir
223
+ });
224
+ await this.containerGone();
225
+ }
226
+ /**
227
+ * The record exactly as the job wrote it, with no staleness bound applied.
228
+ *
229
+ * {@link state} is what almost everything wants. This is for the one caller
230
+ * that must not repair anything on the way past: the container-idle handler,
231
+ * which only needs to know whether something is running before it destroys the
232
+ * container underneath it.
233
+ */
234
+ async read() {
235
+ return await this.#job.read();
236
+ }
237
+ /**
238
+ * Start installing this checkout's dependencies, and return without waiting.
239
+ *
240
+ * Called from `repo_clone` through the repo plugin's `afterCheckout` hook, so
241
+ * 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.
243
+ *
244
+ * That caller is a model turn, which lives long enough to hold the drain handed
245
+ * to `ctx.waitUntil` below. **A short-lived caller cannot**, which is why an
246
+ * install the alarm drives is awaited inside that alarm rather than calling
247
+ * this.
248
+ */
249
+ async start(req) {
250
+ return this.#beginInstall(req, (handle, startedAt) => {
251
+ // Drained here, in this object, on nobody's step budget. The watchdog picks
252
+ // it up if this isolate does not survive the command.
253
+ this.deps.waitUntil(this.#drainInstall(handle, startedAt));
254
+ });
255
+ }
256
+ /**
257
+ * The same install, drained **inside the caller** rather than after it.
258
+ *
259
+ * For the alarm, which owns no request: nothing it awaits can be cut short by a
260
+ * response being sent, so the drain cannot be disposed out from under an
261
+ * `npm ci` half-way through. Returns once the command has actually finished.
262
+ */
263
+ async #awaited(req, armedAt) {
264
+ await this.#beginInstall(req, (handle, startedAt) => this.#drainInstall(handle, startedAt), { takeOverArmedAt: armedAt });
265
+ return this.state();
266
+ }
267
+ /**
268
+ * Resolve, guard, spawn — and hand the running command to `own`, which decides
269
+ * whether the drain outlives this call or is awaited within it. That choice is
270
+ * the only difference between the two entry points above, and it is the
271
+ * difference that broke production, so it is the one thing this parameterises.
272
+ */
273
+ async #beginInstall(req, own, opts) {
274
+ await this.deps.touch();
275
+ 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
+ */
295
+ 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
+ /**
306
+ * A full workspace refuses the install and **writes nothing**.
307
+ *
308
+ * Capacity is not an install outcome, and recording it as one costs twice
309
+ * over. `skipped` is the variant meaning "this checkout has nothing to
310
+ * install", so a hard wall about the Durable Object would arrive wearing the
311
+ * label of a routine fact about the repository; and writing any record here
312
+ * erases what the record held, so a real install failure would disappear the
313
+ * moment the object filled up.
314
+ *
315
+ * It travels as its own advisory instead, from {@link advisories}, which
316
+ * reads it fresh on every call and therefore reaches commands that have
317
+ * nothing to do with dependencies.
318
+ */
319
+ const full = this.deps.headroom();
320
+ if (full) {
321
+ console.error(`[${this.deps.tag()}] refusing to install: the workspace is full`, {
322
+ id: this.deps.id(),
323
+ bytes: full.bytes,
324
+ capBytes: full.capBytes
325
+ });
326
+ return current;
327
+ }
328
+ // Nothing trusts the interception CA here, deliberately: the workspace's own
329
+ // `ready` does it, above every early return in this method. An install
330
+ // already in flight and a full workspace both return before this line, and
331
+ // neither of them means the container cannot speak TLS — the full workspace
332
+ // least of all, since egress is what the agent needs to dig itself out.
333
+ const probe = this.#probe();
334
+ const resolution = await resolveInstallCommand(probe, req.dir, this.deps.plan(), req.repo);
335
+ if (resolution.kind === "skip") {
336
+ const state = {
337
+ state: "skipped",
338
+ reason: resolution.reason
339
+ };
340
+ await this.#job.write(state);
341
+ /**
342
+ * The common branch, and the one whose absence is indistinguishable from
343
+ * never having been called.
344
+ *
345
+ * Every other outcome here leaves a line — an install in flight, a full
346
+ * workspace, a spawn that failed, each end of the drain — so a workspace
347
+ * that skipped and a workspace that never installed read identically in the
348
+ * logs, and any checkout without a `package.json` takes this path. A skip
349
+ * is routine; establishing that one happened should not require an argument
350
+ * from silence.
351
+ */
352
+ console.info(`[${this.deps.tag()}] install skipped`, {
353
+ id: this.deps.id(),
354
+ dir: req.dir,
355
+ ...(req.repo ? { repo: req.repo } : {}),
356
+ reason: resolution.reason
357
+ });
358
+ return state;
359
+ }
360
+ const fingerprint = await installFingerprint(probe, req.dir, resolution);
361
+ // Skipped only for this container's own tree, for this lockfile.
362
+ const tree = await this.#tree();
363
+ if (fingerprint &&
364
+ tree?.dir === req.dir &&
365
+ tree.fingerprint === fingerprint) {
366
+ const state = {
367
+ state: "done",
368
+ command: resolution.command,
369
+ exitCode: 0,
370
+ finishedAt: Date.now(),
371
+ ms: 0,
372
+ tail: "dependencies already installed in this container for this lockfile"
373
+ };
374
+ await this.#job.write(state);
375
+ return state;
376
+ }
377
+ // The tree is rebuilt from here, so nothing vouches for it until exit 0.
378
+ await this.deps.storage.delete([CONTAINER_TREE_KEY, ...RETIRED_KEYS]);
379
+ const startedAt = Date.now();
380
+ const state = {
381
+ state: "running",
382
+ command: resolution.command,
383
+ startedAt
384
+ };
385
+ await this.#job.write(state);
386
+ await this.#job.putContext({
387
+ dir: req.dir,
388
+ // Kept so a reinstall the alarm drives — which has no caller to ask —
389
+ // resolves the same command this one did. Without it a repository
390
+ // with an `INSTALL_PLAN` override would silently fall back to the default
391
+ // on every later run, installing a different tree than the first time.
392
+ ...(req.repo ? { repo: req.repo } : {}),
393
+ fingerprint,
394
+ command: resolution.command,
395
+ startedAt
396
+ });
397
+ /**
398
+ * Armed **before** the spawn — the hazard is an isolate that dies between
399
+ * the two.
400
+ *
401
+ * The record above already says `running`, so from here until something
402
+ * writes a terminal state the gate is shut and the alarm is the only thing
403
+ * that can open it. Arming afterwards leaves a window with nothing scheduled
404
+ * to recover: a `runtime.exec` that threw on the container's WebSocket left
405
+ * a workspace `running` for half an hour, refusing every command.
406
+ *
407
+ * Arming early is free — the handler clears the intent if the record is not
408
+ * `running`, so finishing first costs one wake-up.
409
+ */
410
+ 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 = {
444
+ state: "failed",
445
+ command: resolution.command,
446
+ 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;
456
+ }
457
+ await own(handle, startedAt);
458
+ return state;
459
+ }
460
+ /**
461
+ * Whether this container holds a finished tree, asked only when it qualifies a
462
+ * `deps-broken` advisory (see `deriveAdvisories`).
463
+ */
464
+ async treePresentIfItMatters(install) {
465
+ if (install.state !== "failed")
466
+ return false;
467
+ const dir = await this.dir();
468
+ return dir !== undefined && (await this.#tree())?.dir === dir;
469
+ }
470
+ /**
471
+ * The install record itself, with its staleness bound and re-attach applied.
472
+ *
473
+ * Split from {@link advisories} so `startInstall` can consult the record
474
+ * without going through the `node_modules` probe, which needs a container and
475
+ * has nothing to say about whether an install may start.
476
+ *
477
+ * Re-attaches on the way past. An isolate reset leaves the record saying
478
+ * `running` with nothing draining it, and without this the state would say
479
+ * `running` forever while the command had long since finished.
480
+ */
481
+ async state() {
482
+ const state = await this.#job.read();
483
+ if (state.state !== "running")
484
+ return state;
485
+ /**
486
+ * `running` has an expiry — the proof the file header refers to.
487
+ *
488
+ * The command carries a `timeoutMs` the runtime enforces, so past that plus
489
+ * a wide margin a live install is not a possibility: whatever the record
490
+ * says, nobody is coming back with an exit code. Writing `failed` here is
491
+ * not a guess about what happened, it is the only accurate thing left to
492
+ * say — which is what makes it a bound rather than another guard.
493
+ */
494
+ if (this.#job.isStale(state, this.deps.timeoutMs())) {
495
+ const minutes = Math.round((Date.now() - state.startedAt) / 60_000);
496
+ console.error(`[${this.deps.tag()}] abandoning a stale install`, {
497
+ id: this.deps.id(),
498
+ command: state.command,
499
+ minutes
500
+ });
501
+ const failed = {
502
+ state: "failed",
503
+ command: state.command,
504
+ finishedAt: Date.now(),
505
+ error: `the install has been running for ${minutes} minutes without ` +
506
+ "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."
508
+ };
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();
516
+ }
517
+ return state;
518
+ }
519
+ /**
520
+ * When a reinstall was queued, if one is waiting to run.
521
+ *
522
+ * Read by {@link file://../advisory.ts deriveAdvisories}, which is the only
523
+ * caller that needs it: a `failed` record with a repair already queued is a
524
+ * transient condition wearing a permanent record's clothes.
525
+ */
526
+ async reinstallArmedAt() {
527
+ return await this.#job.armedAt();
528
+ }
529
+ /** True while this isolate holds the drain, so the watchdog leaves it alone. */
530
+ #draining = false;
531
+ async #drainInstall(handle,
532
+ /**
533
+ * The stamp the install this drain was handed belongs to.
534
+ *
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.
541
+ *
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.
545
+ */
546
+ ownedAt) {
547
+ this.#draining = true;
548
+ const context = await this.#job.context();
549
+ const command = context?.command ?? "(unknown)";
550
+ const startedAt = ownedAt ?? context?.startedAt ?? Date.now();
551
+ /**
552
+ * Whether this drain still owns the record.
553
+ *
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
557
+ * returns — and the damage a late one does is silent: it writes a verdict
558
+ * 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.
566
+ *
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.
569
+ */
570
+ const generation = this.#job.generation(startedAt);
571
+ let logged = false;
572
+ const stillMine = async () => {
573
+ if (await generation.stillMine())
574
+ 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
+ }
584
+ return false;
585
+ };
586
+ try {
587
+ const result = await handle.result();
588
+ // Middle-out rather than a tail cut, and it marks what it dropped: an
589
+ // install's diagnosis is split between the two ends — the first error and
590
+ // the summary that follows it — and a plain `slice(-n)` silently keeps
591
+ // only the half that happens to be last.
592
+ const tail = truncateOutput(result.stdout + result.stderr, 2000);
593
+ if (!(await stillMine()))
594
+ return;
595
+ if (result.exitCode === 0) {
596
+ if (context?.dir) {
597
+ const tree = {
598
+ dir: context.dir,
599
+ fingerprint: context.fingerprint,
600
+ at: Date.now()
601
+ };
602
+ await this.deps.storage.put(CONTAINER_TREE_KEY, tree);
603
+ }
604
+ console.info(`[${this.deps.tag()}] install finished`, {
605
+ id: this.deps.id(),
606
+ command,
607
+ seconds: Math.round((Date.now() - startedAt) / 1000)
608
+ });
609
+ await this.#job.write({
610
+ state: "done",
611
+ command,
612
+ exitCode: 0,
613
+ finishedAt: Date.now(),
614
+ ms: Date.now() - startedAt,
615
+ tail
616
+ });
617
+ }
618
+ else {
619
+ // Logged, and this line is not optional. This is the *ordinary* way an
620
+ // install fails — the other paths are all exceptional — and it used to
621
+ // 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
623
+ // is the install's own last words; without it the only copy is inside a
624
+ // Durable Object nobody can query.
625
+ console.error(`[${this.deps.tag()}] install failed`, {
626
+ id: this.deps.id(),
627
+ command,
628
+ exitCode: result.exitCode,
629
+ seconds: Math.round((Date.now() - startedAt) / 1000),
630
+ tail: truncateOutput(tail, 1000)
631
+ });
632
+ await this.#job.write({
633
+ state: "failed",
634
+ command,
635
+ finishedAt: Date.now(),
636
+ exitCode: result.exitCode,
637
+ error: `the install command exited ${result.exitCode}`,
638
+ tail
639
+ });
640
+ }
641
+ }
642
+ catch (err) {
643
+ // Superseded drains fail here constantly — replacing an exec is what
644
+ // breaks the old handle — so this check matters more on the error path
645
+ // than on the success one.
646
+ if (!(await stillMine()))
647
+ return;
648
+ // The drain itself broke — the container went away mid-install, most
649
+ // likely. Distinct from a non-zero exit above, and worth telling apart in
650
+ // the logs, because this one says nothing about the repository.
651
+ //
652
+ // If it went away, nothing this isolate believes about it holds any more.
653
+ if (execWasLost(err))
654
+ await this.deps.containerGone();
655
+ console.error(`[${this.deps.tag()}] install drain failed`, {
656
+ id: this.deps.id(),
657
+ command,
658
+ seconds: Math.round((Date.now() - startedAt) / 1000),
659
+ err: String(err)
660
+ });
661
+ await this.#job.write({
662
+ state: "failed",
663
+ command,
664
+ finishedAt: Date.now(),
665
+ error: String(err)
666
+ });
667
+ }
668
+ finally {
669
+ this.#draining = false;
670
+ 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
+ }
677
+ }
678
+ /**
679
+ * Pick up an install this isolate did not start.
680
+ *
681
+ * `getExec` with `resume: "tail"` re-opens the stream of a command that is
682
+ * still running in the container — or replays the end of one that finished
683
+ * 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.
685
+ */
686
+ async #reattachInstall() {
687
+ if (this.#draining)
688
+ return;
689
+ try {
690
+ const handle = await this.deps
691
+ .workspace()
692
+ .runtime.getExec(INSTALL_EXEC_ID, {
693
+ encoding: "utf8",
694
+ resume: "tail"
695
+ });
696
+ this.deps.waitUntil(this.#drainInstall(handle));
697
+ }
698
+ catch (err) {
699
+ // The exec is gone entirely — the container was replaced under it. Say so
700
+ // rather than leaving the gate closed forever; the next checkout starts a
701
+ // new install, and `sb_exec` can run in the meantime.
702
+ if (execWasLost(err))
703
+ await this.deps.containerGone();
704
+ // Same split as the spawn path: a replaced container is worth re-running
705
+ // into, a deployment fault is not, and only one of them is the container's
706
+ // own doing.
707
+ const fault = deploymentFault(err);
708
+ if (fault)
709
+ console.error(`[${this.deps.tag()}] ${fault.summary}`, {
710
+ id: this.deps.id(),
711
+ remedy: fault.remedy,
712
+ err: String(err)
713
+ });
714
+ else
715
+ console.warn(`[${this.deps.tag()}] could not re-attach to the install`, {
716
+ id: this.deps.id(),
717
+ err: String(err)
718
+ });
719
+ const context = await this.#job.context();
720
+ await this.#job.write({
721
+ state: "failed",
722
+ command: context?.command ?? "(unknown)",
723
+ finishedAt: Date.now(),
724
+ error: fault
725
+ ? `the install stopped without reporting: ${fault.remedy}`
726
+ : "the install stopped without reporting — its container was most " +
727
+ "likely replaced. Re-run it with sb_exec, or clone again to restart it."
728
+ });
729
+ await this.#job.clearWatch();
730
+ }
731
+ }
732
+ /**
733
+ * The armed reinstall came due.
734
+ *
735
+ * `clearArmed` first and unconditionally: this handler runs for minutes, and
736
+ * 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
738
+ * answer is not to schedule one.
739
+ */
740
+ async onRun() {
741
+ const context = await this.#job.context();
742
+ 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
+ });
755
+ }
756
+ /** The watchdog: an install still running that nobody is draining. */
757
+ async onWatch() {
758
+ const state = await this.#job.read();
759
+ if (state.state !== "running")
760
+ return;
761
+ // Still running and nobody draining it: this isolate is new since the
762
+ // command started. Re-attach, and come back if it is still going.
763
+ await this.#reattachInstall();
764
+ await this.#job.armWatch();
765
+ }
766
+ }