agent-dag 1.33.115 → 1.33.117

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 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 { npxFailureHint, npxFailureSummary, npxLaunch } from "../src/server/npx.mjs";
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, npxRestartSpec,
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
- child = spawn(process.execPath, args, {
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.on("message", (m) => {
86
- if (m && m.type === "listening" && typeof m.port === "number") boundPort = m.port;
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
- child.on("exit", (code, signal) => {
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
- child.on("error", (err) => {
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
- process.stdout.write(`\n ↻ fetching ${spec}…\n`);
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
- recordRestartFailure({
226
- name: pkgName,
227
- command: `npx -y ${spec}`,
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
- version: VERSION === "?" ? null : VERSION,
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 the supervisor has
286
- // to fetch it so there is no target version to name yet.
287
- const viaNpx = mode === "npx";
288
- const to = viaNpx ? null : restartTarget();
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
- viaNpx
291
- ? `\n ${C.yellow}↻${C.reset} ${C.dim}updating via npx…${C.reset}\n`
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() {