agent-dag 3.22.1 → 3.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-CJYsv0lr.css +0 -1
  10. package/dist/web/assets/index-Ifm23DDC.js +0 -270
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -398
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,171 +0,0 @@
1
- // Ending a deck that is not our child.
2
- //
3
- // `ccdeck --stop` holds a pid and a port out of a discovery file and nothing
4
- // else — no ChildProcess, no IPC channel, no shell job. That rules out
5
- // exec.mjs's killTree, which takes a ChildProcess and calls `child.kill`, and it
6
- // is why the Windows half of that function is spelled a second time here rather
7
- // than shared: the two have the same shape and different inputs.
8
- //
9
- // THREE RUNGS, and each one exists for a failure the one above it cannot cover:
10
- //
11
- // THE POST. /api/shutdown, with the token out of the record, ending in the
12
- // deck's own shutdown(): the listener closes, the registration is unlinked,
13
- // the LAN beacon says goodbye so paired colleagues see it LEAVE rather than
14
- // time out. This is the rung that behaves identically on all three platforms,
15
- // which is the whole reason the route exists — Windows has no signals.
16
- //
17
- // SIGTERM, POSIX only. Not a fallback for a wedged deck — a wedged deck is
18
- // wedged for this too — but the only way to end a deck OLDER than the route,
19
- // which answers the POST with a 404 and would otherwise be unstoppable by its
20
- // own command. bin/deck.js has handled SIGTERM with shutdown(0) for years, so
21
- // this is still a clean exit on every version that has ever shipped.
22
- //
23
- // THE HARD KILL. SIGKILL, or `taskkill /T /F` on Windows. Leaves the discovery
24
- // file behind for the next boot to sweep and gives the LAN no goodbye, and is
25
- // still better than a process the user cannot end with the tool that started
26
- // it — which is the outcome this whole file is written against.
27
- //
28
- // THE PARENT GOES FIRST on both kill rungs. A worker killed under a live
29
- // supervisor is a worker the supervisor puts back: that is what it is for. So
30
- // the supervisor is ended first and the worker second, which is also what the
31
- // record's `parent` field is written for.
32
- import { request as httpRequest } from "node:http";
33
- import { spawn } from "node:child_process";
34
- import { isProcessAlive } from "./deck-probe.mjs";
35
-
36
- /** Long enough for a loopback POST and the deck's own teardown to begin; short
37
- * enough that a wedged deck does not hold the terminal. */
38
- export const STOP_ASK_MS = 2000;
39
- /** How long a deck gets to actually disappear after each rung. A clean shutdown
40
- * drains SSE connections and closes the listener; 1500ms of that is the
41
- * fallback timer in bin/deck.js's shutdown(), so this has to outlast it. */
42
- export const STOP_GONE_MS = 3000;
43
-
44
- /**
45
- * Ask one deck to end itself, politely.
46
- *
47
- * Never rejects: every failure is a verdict the caller has a next rung for. A
48
- * 404 is a deck too old to know the route, a refused connection is a record
49
- * whose port is already gone, a timeout is a deck that is not answering — and
50
- * all three lead to the same place.
51
- */
52
- export function askDeckToStop(rec, { timeoutMs = STOP_ASK_MS, request = httpRequest } = {}) {
53
- return new Promise(resolve => {
54
- let settled = false;
55
- const finish = (v) => { if (settled) return; settled = true; resolve(v); };
56
- let req;
57
- try {
58
- req = request({
59
- hostname: "127.0.0.1",
60
- port: rec.port,
61
- path: "/api/shutdown",
62
- method: "POST",
63
- timeout: timeoutMs,
64
- headers: {
65
- // The one spelling the server reads. The token came out of a file
66
- // only this user can open, which is the whole of the access control.
67
- "x-ccdeck-token": String(rec.token ?? ""),
68
- "content-length": "0",
69
- },
70
- }, res => {
71
- // The body is drained rather than read: the verdict is the status, and
72
- // a response left unconsumed keeps the socket open past our exit.
73
- res.resume();
74
- res.on("end", () => finish({
75
- ok: res.statusCode === 200,
76
- status: res.statusCode ?? 0,
77
- // A route that is not there is a deck older than this feature, and
78
- // the caller says so rather than reporting a mysterious refusal.
79
- old: res.statusCode === 404,
80
- }));
81
- });
82
- } catch (err) {
83
- return finish({ ok: false, status: 0, old: false, reason: err?.code ?? "request_failed" });
84
- }
85
- req.on("error", (err) => finish({ ok: false, status: 0, old: false, reason: err?.code ?? "unreachable" }));
86
- req.on("timeout", () => { req.destroy(); finish({ ok: false, status: 0, old: false, reason: "timeout" }); });
87
- req.end();
88
- });
89
- }
90
-
91
- /**
92
- * End a pid and everything under it.
93
- *
94
- * exec.mjs's killTree with a pid where its ChildProcess goes — see the note at
95
- * the top of this file. `/T` is the half that matters: the supervisor's worker
96
- * is a child of the pid being ended, and on Windows there is no process group
97
- * to signal as one.
98
- *
99
- * On Windows a "polite" rung does not exist. `taskkill` without `/F` posts
100
- * WM_CLOSE to a window, and the deck has none, so the only thing to do there is
101
- * the forceful one — which is why the caller skips the SIGTERM rung on win32
102
- * rather than running it twice under a different name.
103
- */
104
- export function killPidTree(pid, signal = "SIGTERM", {
105
- platform = process.platform, spawnFn = spawn, kill = (p, s) => process.kill(p, s),
106
- } = {}) {
107
- if (!Number.isInteger(pid) || pid <= 0) return;
108
- const plain = () => { try { kill(pid, signal); } catch { /* already gone */ } };
109
- if (platform !== "win32") return plain();
110
- try {
111
- const root = process.env.SystemRoot || process.env.systemroot;
112
- const exe = root ? `${root}\\System32\\taskkill.exe` : "taskkill";
113
- const killer = spawnFn(exe, ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
114
- killer.on("error", plain);
115
- killer.on("exit", (code) => { if (code !== 0) plain(); });
116
- killer.unref?.();
117
- } catch {
118
- plain();
119
- }
120
- }
121
-
122
- /** Has this pid actually gone, within the deadline? Polled rather than waited
123
- * on: this process is not the parent, so there is no exit to listen for. */
124
- async function gone(pid, { alive, deadlineMs, sleep, now }) {
125
- const until = now() + deadlineMs;
126
- for (;;) {
127
- if (!alive(pid)) return true;
128
- if (now() >= until) return false;
129
- await sleep(50);
130
- }
131
- }
132
-
133
- /**
134
- * Stop one deck, and say how it went out.
135
- *
136
- * `how` is not decoration. "asked" means the deck closed its listener, unlinked
137
- * its registration and left the LAN cleanly; "killed" means none of that
138
- * happened and the next boot has litter to sweep. A command that reported both
139
- * as "stopped" would hide the one case worth knowing about.
140
- */
141
- export async function stopDeck(rec, {
142
- ask = askDeckToStop,
143
- alive = isProcessAlive,
144
- kill = killPidTree,
145
- platform = process.platform,
146
- now = Date.now,
147
- sleep = (ms) => new Promise(r => setTimeout(r, ms)),
148
- askMs = STOP_ASK_MS,
149
- goneMs = STOP_GONE_MS,
150
- } = {}) {
151
- const wait = () => gone(rec.pid, { alive, deadlineMs: goneMs, sleep, now });
152
- const parent = Number.isInteger(rec.parent) ? rec.parent : null;
153
-
154
- const answer = await ask(rec, { timeoutMs: askMs });
155
- if (answer.ok && await wait()) return { ok: true, how: "asked" };
156
-
157
- if (platform !== "win32") {
158
- // Parent first: see the note at the top. A supervisor left alive over a
159
- // killed worker is a supervisor doing its job, which here means undoing
160
- // ours.
161
- if (parent !== null) kill(parent, "SIGTERM", { platform });
162
- kill(rec.pid, "SIGTERM", { platform });
163
- if (await wait()) return { ok: true, how: "signalled", old: answer.old === true };
164
- }
165
-
166
- if (parent !== null) kill(parent, "SIGKILL", { platform });
167
- kill(rec.pid, "SIGKILL", { platform });
168
- if (await wait()) return { ok: true, how: "killed", old: answer.old === true };
169
-
170
- return { ok: false, how: "stuck", reason: answer.reason ?? `http ${answer.status}` };
171
- }
@@ -1,392 +0,0 @@
1
- // What bin/agent-dag.js does when the worker dies: bring it back, fetch a
2
- // newer one, or let the whole thing stop — and, for a worker that was killed
3
- // rather than exited, how to hand that on to whoever started the deck. Plus the
4
- // question that now comes BEFORE any of that, since the fetch happens while the
5
- // worker is still serving: whether this upgrade is worth attempting at all.
6
- //
7
- // It lives here rather than inline in the supervisor because the supervisor
8
- // launches a real child process the moment it is imported, so the rule could
9
- // not be checked any other way — and it is a rule with a race in it.
10
- //
11
- // The race: the worker exits 75 because the user clicked Restart, and Ctrl+C
12
- // lands in the same instant. The supervisor's signal handler runs first and
13
- // sets `stopping`, then the exit event arrives carrying 75 — and a supervisor
14
- // that reads the code without asking whether it is still supposed to be
15
- // running spawns a fresh deck AFTER the user stopped it. That deck prints
16
- // `restarted → vX` over the shutdown and keeps serving until the handler's
17
- // 2.5s retry timer happens to kill it; the 76 variant starts a whole npx
18
- // registry fetch first. So `stopping` outranks the code, always.
19
- //
20
- // Ctrl+C reaches this process on every platform we support — POSIX delivers it
21
- // to the foreground process group, and on Windows the console raises
22
- // CTRL_C_EVENT for every process attached to it, which Node surfaces as
23
- // 'SIGINT'. What differs is only how the child dies, which is not this
24
- // function's business.
25
-
26
- import { constants } from "node:os";
27
-
28
- // Chosen because they mean nothing else here: the worker exits 0 normally and
29
- // non-zero on failure, both of which must pass straight through.
30
- export const RESTART_CODE = 75; // come back running the files on disk
31
- export const UPGRADE_CODE = 76; // come back through npx, which fetches newer files
32
-
33
- /**
34
- * What a dead worker means.
35
- *
36
- * { relaunch: "disk" } — spawn it again from the files on disk
37
- * { relaunch: "npx" } — spawn it again through npx, which fetches newer ones
38
- * { relaunch: null, code } — stop, exiting with `code`
39
- *
40
- * `stopping` is a parameter rather than the supervisor's module-level flag for
41
- * the same reason `spawnSpec` takes a platform: it is the input that decides
42
- * the answer, and both answers have to be testable.
43
- *
44
- * A stop exits 0 for 75 and 76. Those two are a private protocol between the
45
- * worker and its supervisor — nobody outside knows they mean "come back", and
46
- * 75 is EX_TEMPFAIL to anything that reads sysexits, so handing either to the
47
- * shell would report a failure the user did not have. Every other code is the
48
- * worker's own verdict and passes through untouched.
49
- */
50
- export function workerExitAction(code, stopping) {
51
- const ours = code === RESTART_CODE || code === UPGRADE_CODE;
52
- if (ours && !stopping) return { relaunch: code === UPGRADE_CODE ? "npx" : "disk" };
53
- return { relaunch: null, code: ours ? 0 : code ?? 0 };
54
- }
55
-
56
- // ── putting a crashed deck back ──────────────────────────────────────────────
57
- //
58
- // Before the deck ran in the background, a crash was self-reporting: the
59
- // terminal came back, the stack was on screen, and you knew within a second.
60
- // Detached, nothing says anything. The deck stops receiving hook events, stops
61
- // answering the LAN beacon, stops watching the quota — and the first sign is
62
- // noticing, hours later, that a day of work was never recorded.
63
- //
64
- // So the supervisor puts it back. WITH A CEILING, because the other failure is
65
- // worse than the one being fixed: a deck that dies on its own boot, respawned
66
- // forever, is a process spinning on a machine nobody is watching, writing the
67
- // same stack into the same log a thousand times an hour.
68
- //
69
- // The window is what makes the ceiling mean "is this broken" rather than "how
70
- // long has this machine been up". Five crashes in ten minutes is a deck that
71
- // cannot run; five crashes over three weeks is a machine that went to sleep
72
- // oddly three times, and putting it back each time is exactly right.
73
-
74
- /** Five, then stop and say why. */
75
- export const CRASH_CEILING = 5;
76
- /** The window those five are counted in. */
77
- export const CRASH_WINDOW_MS = 10 * 60 * 1000;
78
- /** The first wait, doubling per crash inside the window. */
79
- export const CRASH_BACKOFF_MS = 1000;
80
- /** …and where the doubling stops. Past this the delay is no longer protecting
81
- * anything: five attempts at 30s already span most of the window. */
82
- export const CRASH_BACKOFF_MAX_MS = 30_000;
83
-
84
- /**
85
- * Should this crash be answered with another deck, and after how long?
86
- *
87
- * `history` is the timestamps of the crashes already answered; the caller adds
88
- * this one only if the answer is yes, so a refusal does not push the count
89
- * further out of reach.
90
- *
91
- * The backoff doubles rather than waiting a fixed beat, because the two shapes
92
- * of crash want opposite things. A deck that falls over once an hour should
93
- * come back immediately — a second of downtime is a second of unrecorded work.
94
- * A deck that dies in its own first instruction should be tried slowly enough
95
- * that the log is readable and the CPU is idle between attempts.
96
- */
97
- export function crashPolicy(history = [], {
98
- now = Date.now(),
99
- ceiling = CRASH_CEILING,
100
- windowMs = CRASH_WINDOW_MS,
101
- backoffMs = CRASH_BACKOFF_MS,
102
- maxMs = CRASH_BACKOFF_MAX_MS,
103
- } = {}) {
104
- const recent = history.filter(t => now - t < windowMs);
105
- if (recent.length >= ceiling) {
106
- return { restart: false, delayMs: 0, recent: recent.length, history: recent };
107
- }
108
- return {
109
- restart: true,
110
- delayMs: Math.min(backoffMs * 2 ** recent.length, maxMs),
111
- recent: recent.length + 1,
112
- // The pruned list, so a caller that keeps it does not carry crashes from
113
- // last Tuesday into this decision forever.
114
- history: [...recent, now],
115
- };
116
- }
117
-
118
- /**
119
- * Was this a deck falling over, as opposed to one leaving?
120
- *
121
- * THREE THINGS HAVE TO BE TRUE, and each one removes a way this could respawn
122
- * something nobody asked for:
123
- *
124
- * It was UP. `served` is the supervisor having seen the worker bind a port. A
125
- * worker that never got that far did not crash, it failed to start — a port
126
- * the OS will not give us, a `dist/` that was never built — and retrying that
127
- * five times prints the same refusal six times and fixes nothing.
128
- *
129
- * We are not STOPPING. Ctrl+C, a SIGTERM, and `--stop`'s fallback ladder all
130
- * set it, and every one of them is somebody asking for this deck to be gone.
131
- *
132
- * It did not exit ZERO. That is the deck ending itself — the shutdown that
133
- * /api/shutdown runs, which is what `ccdeck --stop` asks for. Answering a
134
- * clean stop with a restart would make the off switch a no-op.
135
- *
136
- * A signal counts as a crash. `kill -9` on the worker alone is an OOM killer or
137
- * a stray hand, not a request to end the deck: `--stop` ends the supervisor
138
- * FIRST precisely so that this rule and that ladder do not fight.
139
- */
140
- export function isCrash({ code, signal, served, stopping } = {}) {
141
- if (!served || stopping) return false;
142
- if (signal) return true;
143
- return code !== 0 && code !== null && code !== undefined;
144
- }
145
-
146
- /**
147
- * How to report a worker that did not exit at all but was killed by a signal.
148
- *
149
- * { reraise: "SIGHUP", code: 129 } — die of the same signal; `code` is only
150
- * the fallback if that somehow returns
151
- * { reraise: null, code: 129 } — no signal to die of, exit with the number
152
- *
153
- * A supervisor that just exits 0 here tells the shell the deck stopped cleanly
154
- * after somebody killed it. The status a caller expects is the shell's own
155
- * convention, 128 + the signal number: 129 for SIGHUP, 143 for SIGTERM. On
156
- * POSIX the honest way to produce it is to die of the signal rather than to
157
- * exit with the arithmetic — only that also sets the "killed by a signal" bit
158
- * every wait(2)-based caller reads, and `$?` comes out the same either way.
159
- *
160
- * Windows has no signals: a process there cannot die of one, and `process.kill`
161
- * against ourselves would be a TerminateProcess with an unrelated status. So
162
- * the number is all we can offer, and it is still better than 0 — the deck was
163
- * killed, and whatever started it should be able to see that.
164
- *
165
- * The signal numbers come from `os.constants.signals`, which is also the table
166
- * `process.kill` accepts, so a name missing from it is a name we could not
167
- * re-raise anyway; it becomes a plain failure exit.
168
- */
169
- export function signalExitAction(signal, platform = process.platform, numbers = constants.signals) {
170
- const n = numbers?.[signal];
171
- if (typeof n !== "number") return { reraise: null, code: 1 };
172
- return { reraise: platform === "win32" ? null : signal, code: 128 + n };
173
- }
174
-
175
- /**
176
- * End this process the way the worker ended: killed by `signal`.
177
- *
178
- * The re-raise is the obvious half and on its own it does not work, which is
179
- * the reason this is a function rather than one line at the call site. The
180
- * supervisor traps SIGINT, SIGTERM and SIGHUP — and those are exactly the
181
- * signals a worker is most likely to die of, since deck.js does not handle
182
- * SIGHUP at all and registers the other two only after a boot that takes
183
- * seconds. For all three the re-raise lands back in our own handler, which
184
- * finds no child left and exits 0: `kill -HUP` on the deck was reported to the
185
- * shell as a clean, successful stop. Dropping the handler first restores the
186
- * default action, which is to die of the signal.
187
- *
188
- * `proc` is a parameter so the whole sequence can be driven in a test child.
189
- */
190
- /**
191
- * What to say when the upgrade that just succeeded took this install with it,
192
- * or null when that is not what happened.
193
- *
194
- * `npm i -g ccdeck` performed before #340 nests the deck inside a launcher
195
- * package. Upgrading such an install now writes the deck itself over that
196
- * launcher, and npm's reify removes everything that was underneath — including
197
- * the directory this supervisor and its worker are running from. The process
198
- * survives, because everything it needs is already loaded. The next spawn does
199
- * not: bin/deck.js is a path, and npm has just deleted it.
200
- *
201
- * That reached the user as `could not start …/bin/deck.js: ENOENT` and an exit
202
- * 1 — a dead deck and an errno, one click after an upgrade that reported
203
- * success. Nothing was broken. The new version is on the machine, and the
204
- * command they typed already points at it, because npm rewrote the bin shim in
205
- * the same install. The only thing missing was a sentence saying so.
206
- *
207
- * Deliberately not a hand-off. This process could spawn the successor's worker
208
- * instead, and that would be a new worker under an old supervisor — a pair
209
- * nothing has ever tested together, decided at the worst possible moment. One
210
- * command the user runs themselves is the smaller promise and the one that
211
- * cannot go wrong.
212
- *
213
- * Pure, and given its two filesystem answers rather than asking for them,
214
- * because the state it describes only ever exists in a process whose own files
215
- * were deleted after it started — which no test can produce by spawning one.
216
- */
217
- export function replacedNote({ workerExists, moved, product = "ccdeck", command = "ccdeck" }) {
218
- if (workerExists || !moved) return null;
219
- return [
220
- `${product}: the upgrade replaced this install, so it cannot restart in place.`,
221
- ` the new version is in ${moved}`,
222
- ` run \`${command}\` again to start it`,
223
- ].join("\n");
224
- }
225
-
226
- export function dieOfSignal(signal, proc = process) {
227
- const { reraise, code } = signalExitAction(signal, proc.platform);
228
- if (reraise) {
229
- proc.removeAllListeners(reraise);
230
- try { proc.kill(proc.pid, reraise); } catch { /* fall through to the number */ }
231
- }
232
- // Reached on Windows, and on POSIX only when the signal is ignored or
233
- // blocked — inherited dispositions survive spawn — so the kill above returns
234
- // instead of ending us.
235
- proc.exit(code);
236
- }
237
-
238
- // ── how often an upgrade that failed may be attempted again ──────────────────
239
- //
240
- // Reported from a terminal that had been left alone: the same version, the same
241
- // npm error, the same teardown, four times over. Nothing anywhere remembered
242
- // that this exact target had already failed, so every path back into the deck
243
- // offered the identical attempt — and each one cost a real interruption.
244
- //
245
- // Five minutes is chosen against what actually fails. A registry blip or a
246
- // version that has not finished propagating is gone well inside it; being
247
- // offline, behind a proxy that blocks the registry, or on the broken npx shim
248
- // of #184 is not, and no amount of retrying will change that. So the second
249
- // attempt waits, and there is no third: the copyable command is already on
250
- // screen beside the failure and is the honest escape hatch. A newer target
251
- // resets both — that is a different question, and it deserves its own answer.
252
- export const UPGRADE_COOLDOWN_MS = 300_000;
253
- export const UPGRADE_MAX_ATTEMPTS = 2;
254
-
255
- /** Whether a note on disk is a failure at all, and one about `target`.
256
- *
257
- * Both sides of the comparison can be null — the version check is cached for
258
- * an hour and can be off entirely — and two unknowns count as the same target
259
- * rather than as two different ones. Guessing "different" would hand back the
260
- * unbounded retry this exists to end. A note from a deck older than this rule
261
- * carries no target at all, which is genuinely unknown against a target we do
262
- * know, so it is not held against a fresh version. */
263
- function noteAbout(note, target) {
264
- if (!note || typeof note.error !== "string" || !note.error) return false;
265
- return (note.target ?? null) === (target ?? null);
266
- }
267
-
268
- /** How many attempts the note already stands for. A note written before this
269
- * rule existed has no count and is exactly one failure. */
270
- function attemptsOf(note) {
271
- return Number.isInteger(note?.attempts) && note.attempts > 0 ? note.attempts : 1;
272
- }
273
-
274
- /** When the attempt behind the note actually failed, which is not when the note
275
- * was last written: a refusal re-stamps `at` so the browser can tell it from
276
- * the failure before it, and the cooldown must not be pushed out by being
277
- * asked. */
278
- function failedAtOf(note) {
279
- if (typeof note?.failedAt === "number") return note.failedAt;
280
- return typeof note?.at === "number" ? note.at : null;
281
- }
282
-
283
- /**
284
- * Whether an upgrade to `target` may be attempted, given what the last one left
285
- * behind.
286
- *
287
- * { allow: true, attempt: n } — go ahead; this is the nth
288
- * { allow: false, reason: "cooling", waitMs, attempt }
289
- * { allow: false, reason: "exhausted", attempt }
290
- *
291
- * Pure, and the note is a parameter rather than a read, because this is the
292
- * rule that has to terminate an unattended loop and a rule worth a bug is worth
293
- * a test.
294
- */
295
- export function upgradeAttempt({
296
- note, target = null, now = Date.now(),
297
- cooldownMs = UPGRADE_COOLDOWN_MS, maxAttempts = UPGRADE_MAX_ATTEMPTS,
298
- } = {}) {
299
- if (!noteAbout(note, target)) return { allow: true, attempt: 1 };
300
- const attempt = attemptsOf(note);
301
- if (attempt >= maxAttempts) return { allow: false, reason: "exhausted", attempt, waitMs: 0 };
302
- const failedAt = failedAtOf(note);
303
- // A clock that moved backwards — a laptop waking, an NTP correction — must
304
- // not leave the deck cooling forever, so an unmeasurable wait counts as
305
- // elapsed. The attempt cap is what makes that safe: it holds however the
306
- // clock behaves, and it is the half that ends the loop.
307
- const waited = typeof failedAt === "number" && now >= failedAt ? now - failedAt : cooldownMs;
308
- if (waited < cooldownMs) {
309
- return { allow: false, reason: "cooling", attempt, waitMs: cooldownMs - waited };
310
- }
311
- return { allow: true, attempt: attempt + 1 };
312
- }
313
-
314
- /** The refusal as the deck should state it — this is the whole of what the
315
- * banner and the terminal say, so it has to name the version, say what already
316
- * happened to it, and leave the user somewhere to go. The copyable command is
317
- * already on screen next to it. */
318
- export function upgradeRefusalText({ reason, waitMs = 0, attempt = 0, dash = "—" } = {}, target = null) {
319
- const what = target ? `v${target}` : "this update";
320
- if (reason === "exhausted") {
321
- return `${what} failed to fetch ${attempt} times ${dash} not trying again from here; run the command yourself`;
322
- }
323
- const left = waitMs >= 60_000 ? `${Math.ceil(waitMs / 60_000)}m` : `${Math.max(1, Math.ceil(waitMs / 1000))}s`;
324
- return `${what} failed to fetch a moment ago ${dash} waiting ${left} before trying again`;
325
- }
326
-
327
- /**
328
- * Die when the process that started this one does — on all three operating
329
- * systems (#702).
330
- *
331
- * The deck is two processes: bin/agent-dag.js supervises, bin/deck.js serves.
332
- * The supervisor kills the worker on every path it knows about — a restart, an
333
- * upgrade, a Ctrl+C, its own exit — and cannot kill it on the one path it never
334
- * gets to run: being killed itself. SIGKILL cannot be handled, a crash runs no
335
- * handler, and a `taskkill` without `/T` reaches only the process it names. On
336
- * POSIX the worker is then re-parented to init and keeps its port, its temp
337
- * directory and its 40-60 MB of RSS forever. 310 of those were alive on one
338
- * development machine, the oldest a day and four hours old, every one a worker
339
- * whose supervisor a test's teardown had SIGKILLed.
340
- *
341
- * WHY THE IPC CHANNEL, AND NOT ANY OF THE OBVIOUS ALTERNATIVES. The requirement
342
- * is one signal meaning "whoever started me is gone", and the usual answers are
343
- * each missing a platform:
344
- *
345
- * • `process.ppid === 1` — POSIX re-parents an orphan to init, so polling ppid
346
- * works there and answers nothing on Windows, which does not re-parent at
347
- * all: the ppid goes on naming a pid that no longer exists.
348
- * • process groups and `kill(-pgid)` — a POSIX concept. Windows job objects
349
- * are the nearest equivalent and Node exposes none of it.
350
- * • a SIGTERM the parent sends on its way out — Windows delivers no signals to
351
- * a Node process, so `process.on("SIGTERM")` there never fires; and a parent
352
- * that was killed sends nothing anywhere.
353
- *
354
- * The channel is the one mechanism that behaves the same everywhere, because
355
- * Node normalises it: a Unix socketpair on POSIX, a named pipe on Windows, both
356
- * closed by the kernel when the process holding the other end stops existing,
357
- * however it stopped. Node turns that close into a single `disconnect` event on
358
- * the child's own `process`, and it arrives for a SIGKILL, a crash, a
359
- * `taskkill /F`, a closed console window and an ordinary exit alike. That is the
360
- * same reasoning the header above gives for Ctrl+C: what differs between
361
- * platforms is how a process dies, not that this notices.
362
- *
363
- * The channel is not created for this. It already carries `{type:"listening"}`
364
- * and the upgrade handshake; this only says what its closure means.
365
- *
366
- * ARMED ONLY WHERE THERE IS A PARENT TO DIE WITH — `process.send` is a function
367
- * exactly when a channel was given. `node bin/deck.js` run by hand has none, and
368
- * that is the same question `SUPERVISED` in bin/deck.js already asks before it
369
- * offers /api/restart.
370
- *
371
- * WHAT IT DELIBERATELY DOES NOT COVER: a parent that calls `child.disconnect()`
372
- * and stays alive would look identical from here. Nothing in this repo does
373
- * that, and the alternative — an "are you still there" ping — is a second
374
- * protocol to keep correct in exchange for a case that does not exist.
375
- *
376
- * Returns whether a leash was armed, so a caller can state it rather than infer
377
- * it. `proc` is a parameter for the reason `workerExitAction` takes `stopping`:
378
- * the behaviour has to be checkable without a second process.
379
- */
380
- export function dieWithParent(stop, proc = process) {
381
- if (!proc || typeof proc.once !== "function" || typeof proc.send !== "function") return false;
382
- let done = false;
383
- proc.once("disconnect", () => {
384
- if (done) return;
385
- done = true;
386
- // A stop that throws must still end the process: the whole point is that
387
- // nothing is left behind, and there is no parent left to notice a child
388
- // that failed to leave.
389
- try { stop(); } catch { proc.exit?.(0); }
390
- });
391
- return true;
392
- }