@dynamicagents/plugins 0.11.0 → 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.
- package/README.md +12 -13
- package/dist/arc-agi/analysis.d.ts +16 -30
- package/dist/arc-agi/analysis.js +20 -47
- package/dist/arc-agi/client.d.ts +3 -2
- package/dist/arc-agi/game-tools.d.ts +5 -6
- package/dist/arc-agi/index.d.ts +6 -8
- package/dist/arc-agi/index.js +3 -3
- package/dist/arc-agi/main-agent.d.ts +5 -8
- package/dist/arc-agi/main-agent.js +5 -8
- package/dist/arc-agi/recipe.d.ts +17 -20
- package/dist/arc-agi/recipe.js +17 -20
- package/dist/arc-agi/soul.d.ts +16 -27
- package/dist/arc-agi/soul.js +16 -27
- package/dist/arc-agi/store.d.ts +4 -5
- package/dist/arc-agi/store.js +4 -5
- package/dist/arc-agi/tools.d.ts +1 -1
- package/dist/arc-agi/tools.js +26 -19
- package/dist/arc-agi/types.d.ts +6 -7
- package/dist/claude-code/config.d.ts +88 -11
- package/dist/claude-code/config.js +11 -2
- package/dist/claude-code/credentials.d.ts +21 -9
- package/dist/claude-code/credentials.js +43 -9
- package/dist/claude-code/egress.d.ts +7 -13
- package/dist/claude-code/egress.js +9 -10
- package/dist/claude-code/events.d.ts +47 -0
- package/dist/claude-code/events.js +96 -10
- package/dist/claude-code/index.d.ts +31 -13
- package/dist/claude-code/index.js +62 -15
- package/dist/claude-code/run.d.ts +135 -41
- package/dist/claude-code/run.js +366 -61
- package/dist/computer/advisory.d.ts +31 -18
- package/dist/computer/advisory.js +30 -5
- package/dist/computer/context.d.ts +23 -0
- package/dist/computer/context.js +225 -0
- package/dist/computer/file-lock.d.ts +5 -0
- package/dist/computer/file-lock.js +39 -0
- package/dist/computer/gate.d.ts +8 -8
- package/dist/computer/gate.js +7 -7
- package/dist/computer/host/ca-trust.d.ts +88 -0
- package/dist/computer/host/ca-trust.js +303 -0
- package/dist/computer/host/container-deps.d.ts +54 -0
- package/dist/computer/host/container-deps.js +224 -0
- package/dist/computer/host/container-fault.d.ts +38 -0
- package/dist/computer/host/container-fault.js +58 -0
- package/dist/computer/host/git-host.d.ts +62 -0
- package/dist/computer/host/git-host.js +156 -0
- package/dist/computer/host/git-identity.d.ts +57 -0
- package/dist/computer/host/git-identity.js +147 -0
- package/dist/computer/host/install-job.d.ts +121 -0
- package/dist/computer/host/install-job.js +766 -0
- package/dist/computer/host/sync.d.ts +111 -0
- package/dist/computer/host/sync.js +124 -0
- package/dist/computer/host/wake.d.ts +19 -0
- package/dist/computer/host/wake.js +10 -0
- package/dist/computer/host/workspace.d.ts +424 -0
- package/dist/computer/host/workspace.js +1258 -0
- package/dist/computer/index.d.ts +70 -50
- package/dist/computer/index.js +79 -767
- package/dist/computer/install.d.ts +8 -12
- package/dist/computer/install.js +6 -9
- package/dist/computer/paths.d.ts +21 -16
- package/dist/computer/paths.js +40 -38
- package/dist/computer/read.d.ts +18 -22
- package/dist/computer/read.js +24 -27
- package/dist/computer/render.d.ts +28 -0
- package/dist/computer/render.js +34 -1
- package/dist/computer/shell.d.ts +45 -0
- package/dist/computer/shell.js +80 -0
- package/dist/computer/tools-exec.d.ts +3 -0
- package/dist/computer/tools-exec.js +269 -0
- package/dist/computer/tools-file.d.ts +4 -0
- package/dist/computer/tools-file.js +124 -0
- package/dist/computer/tools-find.d.ts +3 -0
- package/dist/computer/tools-find.js +216 -0
- package/dist/recall/index.d.ts +9 -3
- package/dist/recall/index.js +22 -12
- package/dist/repo/checkout.d.ts +34 -4
- package/dist/repo/checkout.js +36 -1
- package/dist/repo/context.d.ts +109 -0
- package/dist/repo/context.js +530 -0
- package/dist/repo/index.d.ts +6 -29
- package/dist/repo/index.js +17 -1027
- package/dist/repo/tools-clone.d.ts +4 -0
- package/dist/repo/tools-clone.js +192 -0
- package/dist/repo/tools-forge.d.ts +4 -0
- package/dist/repo/tools-forge.js +179 -0
- package/dist/repo/tools-review.d.ts +3 -0
- package/dist/repo/tools-review.js +373 -0
- package/dist/repo/tools-worktree.d.ts +4 -0
- package/dist/repo/tools-worktree.js +221 -0
- package/dist/triage/index.d.ts +6 -1
- package/dist/triage/index.js +32 -23
- package/dist/workspace/index.d.ts +3 -3
- package/dist/workspace/index.js +3 -3
- 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
|
+
}
|