agent-dag 1.33.115 → 1.33.116
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/bin/agent-dag.js +142 -14
- package/bin/deck.js +58 -9
- package/dist/web/assets/{index-bKb9ujtK.js → index-xn_csnqv.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/server/index.mjs +11 -0
- package/src/server/npx.mjs +108 -1
- package/src/server/self-update.mjs +32 -3
- package/src/server/supervisor.mjs +92 -1
package/bin/agent-dag.js
CHANGED
|
@@ -20,18 +20,27 @@
|
|
|
20
20
|
// inherited so the terminal is unchanged, and Ctrl+C keeps working because the
|
|
21
21
|
// process group never changes.
|
|
22
22
|
//
|
|
23
|
+
// The upgrade path has one more constraint, learned the expensive way: the
|
|
24
|
+
// worker must not be torn down to find out whether an upgrade is possible. It
|
|
25
|
+
// used to be — the worker exited 76, and only then did npx discover it was
|
|
26
|
+
// offline — so every failed update cost a full outage and could be retried
|
|
27
|
+
// identically forever. Now the worker ASKS (an `upgrade` message), the fetch
|
|
28
|
+
// happens beside it while it keeps serving, and only a fetch that worked is
|
|
29
|
+
// answered with the exit that gives up the port. See prefetchUpgrade.
|
|
30
|
+
//
|
|
23
31
|
// Everything else the deck does still lives in bin/deck.js. This file must stay
|
|
24
32
|
// boring: it is the one process that is never replaced.
|
|
25
33
|
import { spawn } from "node:child_process";
|
|
26
34
|
import { connect } from "node:net";
|
|
27
35
|
import { dirname, join } from "node:path";
|
|
28
36
|
import { fileURLToPath } from "node:url";
|
|
29
|
-
import {
|
|
37
|
+
import { killTree } from "../src/server/exec.mjs";
|
|
38
|
+
import { npxFailureHint, npxFailureSummary, npxLaunch, npxPrefetch } from "../src/server/npx.mjs";
|
|
30
39
|
import {
|
|
31
|
-
bareSpecName, claimRestartFailureKey, clearRestartFailure, installedVersion,
|
|
32
|
-
recordRestartFailure,
|
|
40
|
+
bareSpecName, claimRestartFailureKey, clearRestartFailure, installedVersion, lastKnownLatest,
|
|
41
|
+
npxRestartSpec, readRestartFailure, recordRestartFailure,
|
|
33
42
|
} from "../src/server/self-update.mjs";
|
|
34
|
-
import { dieOfSignal, workerExitAction } from "../src/server/supervisor.mjs";
|
|
43
|
+
import { dieOfSignal, upgradeAttempt, upgradeRefusalText, workerExitAction } from "../src/server/supervisor.mjs";
|
|
35
44
|
|
|
36
45
|
const BIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
37
46
|
const WORKER = join(BIN_DIR, "deck.js");
|
|
@@ -55,6 +64,15 @@ claimRestartFailureKey();
|
|
|
55
64
|
let boundPort = null;
|
|
56
65
|
let restarts = 0;
|
|
57
66
|
let child = null;
|
|
67
|
+
// The npx process fetching a replacement, while the worker above keeps serving.
|
|
68
|
+
// Held separately from `child` for exactly that reason: for the length of a
|
|
69
|
+
// fetch there are two of them, and only one is the deck.
|
|
70
|
+
let fetching = null;
|
|
71
|
+
// The upgrade this supervisor has committed to, from the moment the pre-flight
|
|
72
|
+
// allowed it until the replacement is serving or the attempt has been written
|
|
73
|
+
// off. giveUp runs long after the decision that permitted the attempt and has
|
|
74
|
+
// to record that decision's count, not a fresh one.
|
|
75
|
+
let attempting = null;
|
|
58
76
|
// Set by the signal handlers, and the first thing every exit path asks. Without
|
|
59
77
|
// it, Ctrl+C during an npx fetch looks exactly like a failed fetch, and a
|
|
60
78
|
// worker that was already exiting 75 or 76 when the signal landed reads as a
|
|
@@ -66,7 +84,7 @@ function launch(respawn) {
|
|
|
66
84
|
// Appended last so it wins: the worker's parser keeps the final --port.
|
|
67
85
|
if (respawn && boundPort != null) args.push("--port", String(boundPort));
|
|
68
86
|
|
|
69
|
-
|
|
87
|
+
const worker = spawn(process.execPath, args, {
|
|
70
88
|
// stdio inherited so the child owns the same terminal the user started:
|
|
71
89
|
// same banner, same colours, same Ctrl+C. The fourth slot adds an IPC
|
|
72
90
|
// channel — the only way the worker can tell us which port it got, since
|
|
@@ -82,12 +100,24 @@ function launch(respawn) {
|
|
|
82
100
|
},
|
|
83
101
|
});
|
|
84
102
|
|
|
85
|
-
child
|
|
86
|
-
|
|
103
|
+
child = worker;
|
|
104
|
+
|
|
105
|
+
worker.on("message", (m) => {
|
|
106
|
+
if (!m || typeof m !== "object") return;
|
|
107
|
+
if (m.type === "listening" && typeof m.port === "number") boundPort = m.port;
|
|
108
|
+
// The worker asking to be replaced, while it is still serving. Answered by
|
|
109
|
+
// prefetchUpgrade, which is the whole of this file's new shape: the fetch
|
|
110
|
+
// happens here, and only then does that worker exit — see UPGRADE_CODE.
|
|
111
|
+
else if (m.type === "upgrade") prefetchUpgrade(worker);
|
|
87
112
|
});
|
|
88
113
|
|
|
89
|
-
|
|
114
|
+
worker.on("exit", (code, signal) => {
|
|
90
115
|
child = null;
|
|
116
|
+
// A fetch is for the worker that asked for it. That worker is gone, so the
|
|
117
|
+
// download is spent effort and the process holding it has to be stopped:
|
|
118
|
+
// left running it would keep writing into the npx cache directory the next
|
|
119
|
+
// attempt reads, minutes after the deck stopped waiting for it.
|
|
120
|
+
if (fetching) { killTree(fetching); fetching = null; attempting = null; }
|
|
91
121
|
// `stopping` outranks the exit code — see supervisor.mjs. A restart and a
|
|
92
122
|
// Ctrl+C can land together, and honouring the code first is how the deck
|
|
93
123
|
// came back to life after the user stopped it.
|
|
@@ -111,7 +141,7 @@ function launch(respawn) {
|
|
|
111
141
|
else process.exit(next.code);
|
|
112
142
|
});
|
|
113
143
|
|
|
114
|
-
|
|
144
|
+
worker.on("error", (err) => {
|
|
115
145
|
console.error(`agents-deck: could not start ${WORKER}: ${err.message}`);
|
|
116
146
|
process.exit(1);
|
|
117
147
|
});
|
|
@@ -131,6 +161,88 @@ function withoutPortAndOpen(args) {
|
|
|
131
161
|
return out;
|
|
132
162
|
}
|
|
133
163
|
|
|
164
|
+
/** The note the browser reads, written by the only process that knows why an
|
|
165
|
+
* upgrade did not happen. `failedAt` is when the FETCH failed, which a refusal
|
|
166
|
+
* restating an earlier failure carries forward — see upgradeAttempt. */
|
|
167
|
+
function noteFailure({ pkgName, spec, error, target, attempts, failedAt = Date.now() }) {
|
|
168
|
+
recordRestartFailure({
|
|
169
|
+
name: pkgName,
|
|
170
|
+
command: `npx -y ${spec}`,
|
|
171
|
+
error,
|
|
172
|
+
version: VERSION === "?" ? null : VERSION,
|
|
173
|
+
target,
|
|
174
|
+
attempts,
|
|
175
|
+
failedAt,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Fetch the replacement while the deck this supervisor started keeps serving.
|
|
181
|
+
*
|
|
182
|
+
* The order used to be: kill the working deck, attempt, fail, rebuild the deck.
|
|
183
|
+
* Every failed upgrade was therefore a real interruption — the SSE stream
|
|
184
|
+
* dropped, hook events fired into the gap lost outright, the canvas back with
|
|
185
|
+
* tools stuck in flight — and it was paid in full even when the update never
|
|
186
|
+
* had a chance of working. Reported from a terminal that had been left alone:
|
|
187
|
+
* the same version, the same ETARGET, the same teardown, four times over.
|
|
188
|
+
*
|
|
189
|
+
* npm can resolve and download without the deck dying. So the fetch is done
|
|
190
|
+
* first, and the worker is only asked to exit once there is something to hand
|
|
191
|
+
* the port to. There is never a second server: npxPrefetch installs the package
|
|
192
|
+
* and runs nothing out of it, and the replacement is spawned only after this
|
|
193
|
+
* process has watched the old worker's exit.
|
|
194
|
+
*
|
|
195
|
+
* A refusal costs nothing but the click. The deck keeps its port, its stream
|
|
196
|
+
* and its hooks; the failure reaches the browser through the note, exactly as a
|
|
197
|
+
* failed fetch always has.
|
|
198
|
+
*/
|
|
199
|
+
async function prefetchUpgrade(worker) {
|
|
200
|
+
if (stopping || fetching || attempting) return;
|
|
201
|
+
const reply = (msg) => { try { worker.send?.(msg); } catch { /* the worker is gone */ } };
|
|
202
|
+
|
|
203
|
+
const spec = npxRestartSpec(PKG_ROOT);
|
|
204
|
+
// Not an npx run after all, so there is nothing to fetch and the files on
|
|
205
|
+
// disk are already the newest this deck can reach.
|
|
206
|
+
if (!spec) { reply({ type: "upgrade-refused", error: "this deck was not started by npx" }); return; }
|
|
207
|
+
const pkgName = bareSpecName(spec) ?? "agents-deck";
|
|
208
|
+
// What the banner offered, straight off the marker the version check writes.
|
|
209
|
+
// The note is keyed by it so that "this exact version already failed here" is
|
|
210
|
+
// a question anything can answer — and so a newer release clears the slate.
|
|
211
|
+
const target = lastKnownLatest(pkgName);
|
|
212
|
+
|
|
213
|
+
const note = readRestartFailure(pkgName);
|
|
214
|
+
const decision = upgradeAttempt({ note, target, now: Date.now() });
|
|
215
|
+
if (!decision.allow) {
|
|
216
|
+
const error = upgradeRefusalText(decision, target);
|
|
217
|
+
// Re-stamped even though nothing was attempted: the tab ends its attempt on
|
|
218
|
+
// a failure note it has not seen before, so a refusal that left the note
|
|
219
|
+
// untouched would leave the button reading "fetching…" for the full three
|
|
220
|
+
// minutes it allows, over a deck that never went anywhere.
|
|
221
|
+
noteFailure({
|
|
222
|
+
pkgName, spec, error, target,
|
|
223
|
+
attempts: decision.attempt,
|
|
224
|
+
failedAt: typeof note?.failedAt === "number" ? note.failedAt : Date.now(),
|
|
225
|
+
});
|
|
226
|
+
reply({ type: "upgrade-refused", error });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
attempting = { pkgName, spec, target, attempt: decision.attempt };
|
|
231
|
+
process.stdout.write(`\n ↻ fetching ${spec}…\n`);
|
|
232
|
+
const got = await npxPrefetch(spec, { onChild: (c) => { fetching = c; } });
|
|
233
|
+
fetching = null;
|
|
234
|
+
// Ctrl+C, or a worker that died on its own while npm was working: either way
|
|
235
|
+
// the deck this fetch was for is not there to be replaced, and the exit path
|
|
236
|
+
// that noticed has already had its say.
|
|
237
|
+
if (stopping || child !== worker) { attempting = null; return; }
|
|
238
|
+
if (got.ok) { reply({ type: "upgrade-ready" }); return; }
|
|
239
|
+
|
|
240
|
+
const error = [got.error, got.hint].filter(Boolean).join(" — ");
|
|
241
|
+
noteFailure({ pkgName, spec, error, target, attempts: decision.attempt });
|
|
242
|
+
attempting = null;
|
|
243
|
+
reply({ type: "upgrade-refused", error });
|
|
244
|
+
}
|
|
245
|
+
|
|
134
246
|
/**
|
|
135
247
|
* Come back on a newer version, for a deck that npx started.
|
|
136
248
|
*
|
|
@@ -162,7 +274,10 @@ function launchNpx() {
|
|
|
162
274
|
const pkgName = bareSpecName(spec) ?? "agents-deck";
|
|
163
275
|
clearRestartFailure(pkgName);
|
|
164
276
|
|
|
165
|
-
|
|
277
|
+
// No "fetching…" line here any more: prefetchUpgrade printed it and the
|
|
278
|
+
// tarball is already unpacked, so this resolves out of the npx cache and the
|
|
279
|
+
// next thing on screen is the new deck's own banner.
|
|
280
|
+
//
|
|
166
281
|
// npxLaunch prefers npm's own npx-cli.js next to this Node binary, which
|
|
167
282
|
// needs no PATH lookup and no batch shim; the PATH shim is the fallback and
|
|
168
283
|
// still goes through cmd.exe with each argument quoted.
|
|
@@ -222,12 +337,18 @@ function launchNpx() {
|
|
|
222
337
|
if (hint) console.error(` ${hint}`);
|
|
223
338
|
// Left for the worker about to be launched: it is the only way the browser
|
|
224
339
|
// learns this happened at all. See recordRestartFailure.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
340
|
+
//
|
|
341
|
+
// This is the outage the pre-flight cannot spare anyone: the fetch worked,
|
|
342
|
+
// the port was handed over, and the replacement still did not serve. It
|
|
343
|
+
// counts against the same target as any other failed attempt, so a copy
|
|
344
|
+
// that starts and dies is not offered forever either.
|
|
345
|
+
noteFailure({
|
|
346
|
+
pkgName, spec,
|
|
228
347
|
error: [summary ?? why, hint].filter(Boolean).join(" — "),
|
|
229
|
-
|
|
348
|
+
target: attempting?.target ?? null,
|
|
349
|
+
attempts: attempting?.attempt ?? 1,
|
|
230
350
|
});
|
|
351
|
+
attempting = null;
|
|
231
352
|
restarts++;
|
|
232
353
|
launch(true);
|
|
233
354
|
};
|
|
@@ -256,6 +377,13 @@ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
|
256
377
|
// on the next boot.
|
|
257
378
|
const second = stopping; // an impatient user pressing Ctrl+C again
|
|
258
379
|
stopping = true;
|
|
380
|
+
// A fetch in flight is not the deck and does not stop with it. On POSIX the
|
|
381
|
+
// Ctrl+C that reached us reached it too — it is in this process group — but
|
|
382
|
+
// a plain `kill` of the supervisor, or a systemd stop, is not, and on
|
|
383
|
+
// Windows the shim path leaves npm as a grandchild of cmd.exe that only
|
|
384
|
+
// killTree can reach. Stopped before the worker, since the worker's own
|
|
385
|
+
// exit path is what ends this process.
|
|
386
|
+
if (fetching) { killTree(fetching, second ? "SIGKILL" : sig); fetching = null; }
|
|
259
387
|
if (!child) { process.exit(0); return; }
|
|
260
388
|
try { child.kill(second ? "SIGKILL" : sig); } catch { /* already gone */ }
|
|
261
389
|
// The npx step is a shell that exec's the new deck, and a signal can land in
|
package/bin/deck.js
CHANGED
|
@@ -84,7 +84,7 @@ const persist = flags.noPersist
|
|
|
84
84
|
|
|
85
85
|
const { installHooks, keepDiscovery, removeDiscovery, hasCodexInstalled } =
|
|
86
86
|
await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
|
|
87
|
-
const { startServer, hookToken } =
|
|
87
|
+
const { startServer, hookToken, releaseRestart } =
|
|
88
88
|
await import(pathToFileURL(join(PKG_ROOT, "src/server/index.mjs")).href);
|
|
89
89
|
|
|
90
90
|
// Codex hooks install when ~/.codex/ exists, unless --no-codex was passed.
|
|
@@ -279,20 +279,69 @@ if (upgrade) {
|
|
|
279
279
|
// only after this process is gone — which is precisely what keeps the
|
|
280
280
|
// replacement from racing this listener onto a random fallback port.
|
|
281
281
|
let restarting = false;
|
|
282
|
+
// Outer bound on the supervisor's answer below. It cannot be reached today —
|
|
283
|
+
// the fetch has a deadline of its own and every path through it replies — but
|
|
284
|
+
// `restarting` is a latch, and a latch with no way out is how a deck ends up
|
|
285
|
+
// silently refusing every restart for the rest of its life.
|
|
286
|
+
const UPGRADE_ANSWER_MS = 150_000;
|
|
287
|
+
let upgradeTimer = null;
|
|
288
|
+
|
|
282
289
|
const requestRestart = (mode) => {
|
|
283
290
|
if (restarting) return;
|
|
284
291
|
restarting = true;
|
|
285
|
-
// "npx" means the newer code is not on this disk at all
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
292
|
+
// "npx" means the newer code is not on this disk at all, so it has to be
|
|
293
|
+
// fetched — and this process keeps serving while that happens. Exiting first
|
|
294
|
+
// is what made every failed upgrade an outage: the SSE stream dropped, hook
|
|
295
|
+
// events fired into the gap were lost outright (hook/hook.js is
|
|
296
|
+
// fire-and-forget with a 1s timeout and no retry), and the canvas came back
|
|
297
|
+
// with whatever was in flight stuck until the stale sweeper reaped it — all
|
|
298
|
+
// of it paid before anyone knew whether npm could even resolve the version.
|
|
299
|
+
// Nothing is torn down here now; the supervisor answers when it knows.
|
|
300
|
+
if (mode === "npx") {
|
|
301
|
+
upgradeTimer = setTimeout(() => abandonUpgrade("no answer from the supervisor"), UPGRADE_ANSWER_MS);
|
|
302
|
+
upgradeTimer.unref?.();
|
|
303
|
+
// Armed before the ask, not after: a send that throws is a supervisor that
|
|
304
|
+
// can no longer answer, and the deck has to come back out of the latch on
|
|
305
|
+
// its own rather than wait out an answer that cannot arrive.
|
|
306
|
+
try { process.send({ type: "upgrade" }); }
|
|
307
|
+
catch (err) { abandonUpgrade(err?.message ?? "the supervisor is no longer listening"); }
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const to = restartTarget();
|
|
311
|
+
process.stdout.write(`\n ${C.yellow}↻${C.reset} ${C.dim}restarting${to ? ` → v${to}` : ""}…${C.reset}\n`);
|
|
312
|
+
shutdown(RESTART_CODE);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// The upgrade did not happen and this deck is still the deck. Said out loud
|
|
316
|
+
// because the terminal has just printed that a fetch was starting, and left
|
|
317
|
+
// unsaid it reads as a restart that hung.
|
|
318
|
+
const abandonUpgrade = (why) => {
|
|
319
|
+
clearTimeout(upgradeTimer);
|
|
320
|
+
restarting = false;
|
|
321
|
+
// The server's own latch, which no longer has an exiting process to clear it.
|
|
322
|
+
releaseRestart();
|
|
289
323
|
process.stdout.write(
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
: `\n ${C.yellow}↻${C.reset} ${C.dim}restarting${to ? ` → v${to}` : ""}…${C.reset}\n`,
|
|
324
|
+
`\n ${C.yellow}✕${C.reset} ${C.dim}update not applied — still on ${C.reset}v${PKG_VERSION}\n` +
|
|
325
|
+
(why ? ` ${C.dim}${why}${C.reset}\n` : ""),
|
|
293
326
|
);
|
|
294
|
-
shutdown(viaNpx ? UPGRADE_CODE : RESTART_CODE);
|
|
295
327
|
};
|
|
328
|
+
|
|
329
|
+
// The supervisor's verdict on the fetch it was asked for. Only it can answer:
|
|
330
|
+
// the fetch is its child, and it is the process that will still be here when
|
|
331
|
+
// this one exits.
|
|
332
|
+
process.on("message", (m) => {
|
|
333
|
+
if (!restarting || !m || typeof m !== "object") return;
|
|
334
|
+
if (m.type === "upgrade-ready") {
|
|
335
|
+
clearTimeout(upgradeTimer);
|
|
336
|
+
// The replacement is on the machine now, so this is the last moment the
|
|
337
|
+
// port is worth holding: exiting hands it straight over.
|
|
338
|
+
process.stdout.write(`\n ${C.yellow}↻${C.reset} ${C.dim}updating via npx…${C.reset}\n`);
|
|
339
|
+
shutdown(UPGRADE_CODE);
|
|
340
|
+
} else if (m.type === "upgrade-refused") {
|
|
341
|
+
abandonUpgrade(m.error);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
296
345
|
// What a restart would land on. Read from disk now rather than remembered from
|
|
297
346
|
// boot, because the whole point is that the two differ.
|
|
298
347
|
function restartTarget() {
|