agent-dag 3.22.1 → 3.22.3
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 +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
package/src/server/stop-deck.mjs
DELETED
|
@@ -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
|
-
}
|