agent-dag 1.35.20 → 1.35.22

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/deck.js CHANGED
@@ -9,8 +9,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { existsSync, readFileSync } from "node:fs";
10
10
  import { dieOfSignal } from "../src/server/supervisor.mjs";
11
11
  import {
12
- CURSOR_HIDE, CURSOR_SHOW, colorProfile, fit, glyphs, labelColumn, link, motionOK, palette,
13
- pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns, unicodeOK,
12
+ CURSOR_HIDE, CURSOR_SHOW, colorProfile, fit, glyphs, labelColumn, link, motionOK, oneLine,
13
+ palette, pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns, unicodeOK,
14
14
  unregisteredDetail, wordmark,
15
15
  } from "../src/server/term.mjs";
16
16
  import { PRODUCT } from "../src/server/brand.mjs";
@@ -101,7 +101,7 @@ const { installHooks, keepDiscovery, removeDiscovery, hasCodexInstalled } =
101
101
  // the watcher tails, and the watcher lives in that module. Recomputing the path
102
102
  // here is how the banner came to print ~/.codex/sessions on machines whose
103
103
  // sessions are somewhere else entirely — see the row further down.
104
- const { startServer, hookToken, releaseRestart, CODEX_SESSIONS_DIR, canonicalWorkspace } =
104
+ const { startServer, hookToken, releaseRestart, markDeckReady, CODEX_SESSIONS_DIR, canonicalWorkspace } =
105
105
  await import(pathToFileURL(join(PKG_ROOT, "src/server/index.mjs")).href);
106
106
 
107
107
  // Resolved here rather than left as typed, for the reason the events log above
@@ -445,32 +445,116 @@ let restarting = false;
445
445
  const UPGRADE_ANSWER_MS = 150_000;
446
446
  let upgradeTimer = null;
447
447
 
448
+ // Whether the rest of this file has finished running.
449
+ //
450
+ // The server below starts accepting connections from inside startServer, before
451
+ // that call has returned — so /api/restart is reachable for the whole of the
452
+ // boot that follows it: the port report to the supervisor, the discovery file
453
+ // and its first fsynced write, and on a cold start the browser spawn. A restart
454
+ // landing in that window used to reach `shutdown` before the binding holding it
455
+ // was initialised and die of a ReferenceError, having already set the latch
456
+ // above, with nothing left to clear it — after which every restart from every
457
+ // tab was answered "ok" and did nothing, for the life of the process (#448).
458
+ //
459
+ // So an ask that arrives too early is held rather than run: the window is
460
+ // bounded and short, the user asked for something this deck can genuinely give
461
+ // a second later, and refusing outright would put back the same silence in a
462
+ // politer form. BOOT_RESTART_MS is the outer bound, for the reason
463
+ // UPGRADE_ANSWER_MS above is one: a boot that has not finished in ten seconds is
464
+ // itself the fault, and the restart is the answer to it rather than a casualty
465
+ // of it.
466
+ let booted = false;
467
+ let heldRestart = null;
468
+ let bootTimer = null;
469
+ const BOOT_RESTART_MS = 10_000;
470
+
448
471
  const requestRestart = (mode) => {
449
472
  if (restarting) return;
450
473
  restarting = true;
451
- // "npx" means the newer code is not on this disk at all, so it has to be
452
- // fetched and this process keeps serving while that happens. Exiting first
453
- // is what made every failed upgrade an outage: the SSE stream dropped, hook
454
- // events fired into the gap were lost outright (hook/hook.js is
455
- // fire-and-forget with a 1s timeout and no retry), and the canvas came back
456
- // with whatever was in flight stuck until the stale sweeper reaped it — all
457
- // of it paid before anyone knew whether npm could even resolve the version.
458
- // Nothing is torn down here now; the supervisor answers when it knows.
459
- if (mode === "npx") {
460
- upgradeTimer = setTimeout(() => abandonUpgrade("no answer from the supervisor"), UPGRADE_ANSWER_MS);
461
- upgradeTimer.unref?.();
462
- // Armed before the ask, not after: a send that throws is a supervisor that
463
- // can no longer answer, and the deck has to come back out of the latch on
464
- // its own rather than wait out an answer that cannot arrive.
465
- try { process.send({ type: "upgrade" }); }
466
- catch (err) { abandonUpgrade(err?.message ?? "the supervisor is no longer listening"); }
474
+ if (!booted) {
475
+ heldRestart = { mode };
476
+ bootTimer = setTimeout(() => { bootTimer = null; runHeldRestart(); }, BOOT_RESTART_MS);
477
+ bootTimer.unref?.();
478
+ // Said out loud for the same reason abandonUpgrade below is: the tab has
479
+ // already been told its restart was accepted, and a second of nothing
480
+ // happening on this terminal is otherwise indistinguishable from the bug
481
+ // this replaces.
482
+ write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restart queued ${G.dash} still starting up${P.reset}\n`);
467
483
  return;
468
484
  }
469
- const to = restartTarget();
470
- write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restarting${to ? ` ${G.arrow} v${to}` : ""}${G.ellipsis}${P.reset}\n`);
471
- shutdown(RESTART_CODE);
485
+ beginRestart(mode);
472
486
  };
473
487
 
488
+ // The restart itself, once there is a booted deck to end. Split out of
489
+ // requestRestart so the held ask above can re-enter it without tripping the
490
+ // latch it is already holding.
491
+ //
492
+ // Everything here runs inside one try: the whole point of #448 is that a throw
493
+ // on this path is not merely a failed restart but a permanent one, because the
494
+ // latch it leaves behind outlives it. There is no line in here worth dying for.
495
+ function beginRestart(mode) {
496
+ try {
497
+ // "npx" means the newer code is not on this disk at all, so it has to be
498
+ // fetched — and this process keeps serving while that happens. Exiting first
499
+ // is what made every failed upgrade an outage: the SSE stream dropped, hook
500
+ // events fired into the gap were lost outright (hook/hook.js is
501
+ // fire-and-forget with a 1s timeout and no retry), and the canvas came back
502
+ // with whatever was in flight stuck until the stale sweeper reaped it — all
503
+ // of it paid before anyone knew whether npm could even resolve the version.
504
+ // Nothing is torn down here now; the supervisor answers when it knows.
505
+ if (mode === "npx") {
506
+ upgradeTimer = setTimeout(() => abandonUpgrade("no answer from the supervisor"), UPGRADE_ANSWER_MS);
507
+ upgradeTimer.unref?.();
508
+ // Armed before the ask, not after: a send that throws is a supervisor that
509
+ // can no longer answer, and the deck has to come back out of the latch on
510
+ // its own rather than wait out an answer that cannot arrive.
511
+ try { process.send({ type: "upgrade" }); }
512
+ catch (err) { abandonUpgrade(err?.message ?? "the supervisor is no longer listening"); }
513
+ return;
514
+ }
515
+ const to = restartTarget();
516
+ write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restarting${to ? ` ${G.arrow} v${to}` : ""}${G.ellipsis}${P.reset}\n`);
517
+ shutdown(RESTART_CODE);
518
+ } catch (err) {
519
+ abandonRestart(err);
520
+ }
521
+ }
522
+
523
+ // The ask that was waiting for the boot to finish, now that it has. Safe to
524
+ // call when nothing is waiting, which is every ordinary boot.
525
+ function runHeldRestart() {
526
+ if (!heldRestart) return;
527
+ const { mode } = heldRestart;
528
+ heldRestart = null;
529
+ clearTimeout(bootTimer);
530
+ bootTimer = null;
531
+ beginRestart(mode);
532
+ }
533
+
534
+ // A restart that could not be started, said out loud and then let go of.
535
+ //
536
+ // Both halves of the latch have to come down — this file's and the server's —
537
+ // because a latch nothing clears is precisely how one failed request turned
538
+ // into a deck that refused every restart afterwards while answering "ok" to
539
+ // each one (#448). The reason is folded onto one line by oneLine: the terminal
540
+ // under this is repainted every 800ms by the pulse, and a stack written into
541
+ // that is a stack nobody can read (#432).
542
+ //
543
+ // A declaration rather than a const, like `shutdown` below and for the same
544
+ // reason: this is the handler for a binding that was not there yet, and it must
545
+ // not be capable of becoming the next one.
546
+ function abandonRestart(err) {
547
+ clearTimeout(bootTimer);
548
+ bootTimer = null;
549
+ heldRestart = null;
550
+ restarting = false;
551
+ releaseRestart();
552
+ write(
553
+ `\n ${P.err}${G.fail}${P.reset} ${P.muted}restart failed ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
554
+ ` ${P.muted}${oneLine(err?.stack ?? err, Math.max(20, cols() - 6), G.ellipsis)}${P.reset}\n`,
555
+ );
556
+ }
557
+
474
558
  // The upgrade did not happen and this deck is still the deck. Said out loud
475
559
  // because the terminal has just printed that a fetch was starting, and left
476
560
  // unsaid it reads as a restart that hung.
@@ -508,13 +592,24 @@ function restartTarget() {
508
592
  catch { return null; }
509
593
  }
510
594
 
595
+ // The three things `shutdown` has to tear down, named before the boot that
596
+ // fills them in rather than by it. From the line below onwards this process is
597
+ // answering HTTP, and /api/restart can therefore reach `shutdown` at any moment
598
+ // after it — including moments at which none of these exist yet. `let … = null`
599
+ // is what makes that a question shutdown can ask instead of a ReferenceError it
600
+ // dies of; the boot queue in requestRestart is what makes it a question it
601
+ // almost never has to ask. See #448.
602
+ let server = null;
603
+ let discovery = null;
604
+ let discoveryFile = null;
605
+
511
606
  const starting = startServer({
512
607
  port, persist, workspace, codex: wantCodex, claude: wantClaude,
513
608
  // Withheld when nothing is supervising us: without a parent, exiting is just
514
609
  // exiting, and /api/restart answers 501 so the UI hides the control.
515
610
  onRestart: SUPERVISED ? requestRestart : null,
516
611
  });
517
- const server = await (RESPAWN ? starting : step(`starting server${G.ellipsis}`, starting)).catch(err => {
612
+ server = await (RESPAWN ? starting : step(`starting server${G.ellipsis}`, starting)).catch(err => {
518
613
  // stderr, not a row: a deck that could not bind is not a status line, and
519
614
  // whatever launched it reads this stream.
520
615
  console.error(`${PRODUCT}: server failed: ${err.message}`);
@@ -564,7 +659,7 @@ if (RESPAWN) {
564
659
  // rollout files this deck tails itself, which a --no-codex deck must never be
565
660
  // elected to record. See writesCodexLog in src/server/log-writer.mjs.
566
661
  let registered = null;
567
- const discovery = keepDiscovery({
662
+ discovery = keepDiscovery({
568
663
  port: realPort,
569
664
  workspace,
570
665
  token: hookToken(),
@@ -577,7 +672,7 @@ const discovery = keepDiscovery({
577
672
  else if (!first) reportReregistered(state);
578
673
  },
579
674
  });
580
- const discoveryFile = discovery.file;
675
+ discoveryFile = discovery.file;
581
676
  // Now, not in five seconds: nothing should reach the pulse line below without
582
677
  // the deck knowing whether the hooks can see it.
583
678
  await discovery.check();
@@ -612,33 +707,78 @@ if (MOTION) {
612
707
  }, 800).unref();
613
708
  }
614
709
 
615
- const shutdown = async (code = 0) => {
710
+ // Boot is over. Everything `shutdown` tears down exists, so a restart can be
711
+ // run rather than held — and the server is told, so /api/restart stops
712
+ // describing a deck that is still assembling itself. This line is exactly where
713
+ // the window opened at the top of this file closes; see requestRestart.
714
+ booted = true;
715
+ markDeckReady();
716
+ runHeldRestart();
717
+
718
+ /**
719
+ * A declaration, not the `const` arrow this was for eight months.
720
+ *
721
+ * The difference is the whole of #448: a const is in its temporal dead zone
722
+ * until the line declaring it runs, and every line above — the port report, the
723
+ * discovery file, the browser spawn — executes with the server already
724
+ * accepting connections. A restart arriving in that window called this and got
725
+ * `ReferenceError: Cannot access 'shutdown' before initialization`, and the
726
+ * latch it had already set is what made that permanent. A declaration is
727
+ * hoisted, so from the first instruction of this module there is a function
728
+ * here to call.
729
+ *
730
+ * Hoisting alone would only have moved the fault one line down, onto `server`,
731
+ * `discovery` and `discoveryFile` — which is why those are `let … = null` above
732
+ * and asked about rather than assumed here. Between them, this is callable at
733
+ * any instant of this process's life and cannot end in a throw for the caller
734
+ * to lose.
735
+ */
736
+ async function shutdown(code = 0) {
616
737
  // Also set as exitCode, not only passed to exit(): if the event loop empties
617
738
  // on its own before either timer runs, Node would otherwise exit 0 and the
618
739
  // supervisor would take that as "done" instead of "bring me back".
619
740
  process.exitCode = code;
620
- // Before anything that can take time: a Ctrl+C the user has to watch for a
621
- // second and a half is a second and a half without a cursor.
622
- showCursor();
623
- if (tty && code !== RESTART_CODE && code !== UPGRADE_CODE) {
624
- write(`\n\n ${P.warn}${G.stop} shutting down${G.ellipsis}${P.reset}\n`);
741
+ // Nothing inside a shutdown is worth staying alive for, and this one is
742
+ // called from three places that cannot handle a rejection a signal handler,
743
+ // an IPC message handler, and a restart. An unhandled one there ends the
744
+ // process on Node's terms rather than ours, which is to say with the wrong
745
+ // exit code and therefore, half the time, without the supervisor bringing the
746
+ // deck back.
747
+ try {
748
+ // Before anything that can take time: a Ctrl+C the user has to watch for a
749
+ // second and a half is a second and a half without a cursor.
750
+ showCursor();
751
+ if (tty && code !== RESTART_CODE && code !== UPGRADE_CODE) {
752
+ write(`\n\n ${P.warn}${G.stop} shutting down${G.ellipsis}${P.reset}\n`);
753
+ }
754
+ // Stopped first, always: a tick landing after the unlink would re-register a
755
+ // deck that is on its way out, and leave the file behind for the hooks to
756
+ // find once nothing is listening.
757
+ //
758
+ // Guarded on its own, because a discovery file this process cannot remove is
759
+ // a nuisance the next boot's stale sweep clears up — worth carrying on to
760
+ // the orderly close below rather than skipping to the abrupt one.
761
+ try {
762
+ discovery?.stop();
763
+ if (discoveryFile) await removeDiscovery(discoveryFile);
764
+ } catch { /* the sweep at the next boot gets it */ }
765
+ // No server yet means nothing to drain and nothing to hand the port over to,
766
+ // so the exit is the whole of the shutdown.
767
+ if (!server) return process.exit(code);
768
+ server.close(() => process.exit(code));
769
+ // SSE connections never end by themselves, so close() alone would sit out the
770
+ // full 1500ms fallback on every restart. Hanging them up is safe — the stream
771
+ // sets retry: 1500 and replays from Last-Event-ID, so each tab reconnects and
772
+ // catches up without being told anything.
773
+ try { server.closeAllConnections?.(); } catch { /* Node < 18.2 */ }
774
+ setTimeout(() => process.exit(code), 1500).unref();
775
+ } catch {
776
+ process.exit(code);
625
777
  }
626
- // Stopped first, always: a tick landing after the unlink would re-register a
627
- // deck that is on its way out, and leave the file behind for the hooks to
628
- // find once nothing is listening.
629
- discovery.stop();
630
- await removeDiscovery(discoveryFile);
631
- server.close(() => process.exit(code));
632
- // SSE connections never end by themselves, so close() alone would sit out the
633
- // full 1500ms fallback on every restart. Hanging them up is safe — the stream
634
- // sets retry: 1500 and replays from Last-Event-ID, so each tab reconnects and
635
- // catches up without being told anything.
636
- try { server.closeAllConnections?.(); } catch { /* Node < 18.2 */ }
637
- setTimeout(() => process.exit(code), 1500).unref();
638
- };
778
+ }
639
779
  process.on("SIGINT", () => shutdown(0));
640
780
  process.on("SIGTERM", () => shutdown(0));
641
- process.on("beforeExit", () => { discovery.stop(); removeDiscovery(discoveryFile); });
781
+ process.on("beforeExit", () => { discovery?.stop(); if (discoveryFile) removeDiscovery(discoveryFile); });
642
782
 
643
783
  // ── helpers ───────────────────────────────────────────────────────────────────
644
784