@volter/supercode-health 0.1.19 → 0.1.21
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/lib/drift.mjs +152 -19
- package/lib/promises.mjs +187 -31
- package/package.json +1 -1
- package/promises.json +6 -2
package/lib/drift.mjs
CHANGED
|
@@ -43,34 +43,48 @@ async function runningInodes(pids) {
|
|
|
43
43
|
/** `ps`'s start time (`lstart`, as `Tue Oct 6 12:09:42 2026`) in ms, or NaN. */
|
|
44
44
|
const startedMs = (lstart) => Date.parse(String(lstart).trim().replace(/\s+/g, ' ').replace(/^\w{3} /, ''));
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
*/
|
|
51
|
-
export async function staleSupercode({ rows = null } = {}) {
|
|
52
|
-
const lines = rows ?? String((await run('ps', ['-A', '-ww', '-o', 'pid=,lstart=,command='], { timeoutMs: 10_000 })).stdout ?? '').split('\n');
|
|
53
|
-
const found = [];
|
|
46
|
+
/** The supercode install processes among `lines` (`pid [ppid tpgid] lstart(5 words) command`), each with the file it runs. */
|
|
47
|
+
function installProcesses(lines, withParent) {
|
|
48
|
+
const shape = withParent ? /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\w{3}\s+\w{3}\s+\d+\s+[\d:]+\s+\d{4})\s+(.*)$/u : /^\s*(\d+)()()\s+(\w{3}\s+\w{3}\s+\d+\s+[\d:]+\s+\d{4})\s+(.*)$/u;
|
|
49
|
+
const table = new Map(), found = [];
|
|
54
50
|
for (const line of lines) {
|
|
55
|
-
const match =
|
|
51
|
+
const match = shape.exec(line);
|
|
56
52
|
if (!match) continue;
|
|
57
|
-
const [, pid, lstart, command] = match;
|
|
53
|
+
const [, pid, ppid, tty, lstart, command] = match;
|
|
54
|
+
// on a terminal: its terminal has a foreground group (none reads 0 or -1); asking ps the terminal's name costs 0.3 s
|
|
55
|
+
const row = { pid: Number(pid), ppid: withParent ? Number(ppid) : null, tty: withParent && Number(tty) > 0, lstart, command };
|
|
56
|
+
table.set(row.pid, row);
|
|
58
57
|
const program = ran(command);
|
|
59
|
-
if (program) found.push({
|
|
58
|
+
if (program) found.push({ ...row, ...program });
|
|
60
59
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
return { table, found };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Each of `found` with whether the file it runs was replaced after it started, and when that file was last written.
|
|
64
|
+
* A native is read by the inode it runs, where lsof says it: the strongest reading (a ctime also moves on a chmod). */
|
|
65
|
+
function judged(found, inodes) {
|
|
66
|
+
const rows = [];
|
|
67
|
+
for (const { pid, ppid, tty, lstart, command, file } of found) {
|
|
65
68
|
const started = startedMs(lstart);
|
|
66
69
|
let stat;
|
|
67
70
|
try { stat = statSync(file); } catch { continue; } // a file gone is a removed install, said elsewhere
|
|
68
71
|
const installed = stat.ctimeMs;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
} else if (Number.isNaN(started) || installed <= started + 1000) continue; // ps says a start to the second
|
|
72
|
-
stale.push({ pid: Number(pid), command: command.slice(0, 200), file, startedAt: new Date(started).toISOString(), installedAt: new Date(installed).toISOString() });
|
|
72
|
+
const stale = inodes.has(pid) ? inodes.get(pid) !== stat.ino : !(Number.isNaN(started) || installed <= started + 1000); // ps says a start to the second
|
|
73
|
+
rows.push({ pid, ppid, tty, command: command.slice(0, 200), file, startedAt: Number.isNaN(started) ? null : new Date(started).toISOString(), installedAt: new Date(installed).toISOString(), stale });
|
|
73
74
|
}
|
|
75
|
+
return rows;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The running supercode processes (on macOS and Linux) whose install file was written after they started:
|
|
80
|
+
* `[{ pid, command, file, startedAt, installedAt }]`, newest install first. `rows` (ps lines, for a reading without
|
|
81
|
+
* ps) is `pid lstart(5 words) command`.
|
|
82
|
+
*/
|
|
83
|
+
export async function staleSupercode({ rows = null } = {}) {
|
|
84
|
+
const lines = rows ?? String((await run('ps', ['-A', '-ww', '-o', 'pid=,lstart=,command='], { timeoutMs: 10_000 })).stdout ?? '').split('\n');
|
|
85
|
+
const { found } = installProcesses(lines, false);
|
|
86
|
+
const inodes = rows ? new Map() : await runningInodes(found.filter((row) => row.native).map((row) => row.pid));
|
|
87
|
+
const stale = judged(found, inodes).filter((row) => row.stale).map(({ pid, command, file, startedAt, installedAt }) => ({ pid, command, file, startedAt, installedAt }));
|
|
74
88
|
return moved(stale).sort((a, b) => b.installedAt.localeCompare(a.installedAt));
|
|
75
89
|
}
|
|
76
90
|
|
|
@@ -114,3 +128,122 @@ export function pinnedHomes({ home = null } = {}) {
|
|
|
114
128
|
}
|
|
115
129
|
return pinned;
|
|
116
130
|
}
|
|
131
|
+
|
|
132
|
+
/** The service each process is one of, by pid, where the service manager says it: launchd's label for the processes it
|
|
133
|
+
* started (this user's domain: one `launchctl list`), a systemd unit from the process's own cgroup. Empty where
|
|
134
|
+
* neither answers. */
|
|
135
|
+
async function serviceLabels(pids) {
|
|
136
|
+
const labels = new Map();
|
|
137
|
+
if (process.platform === 'darwin') {
|
|
138
|
+
const listed = await run('/bin/launchctl', ['list'], { timeoutMs: 3000 }).catch(() => null);
|
|
139
|
+
for (const line of String(listed?.stdout ?? '').split('\n')) {
|
|
140
|
+
const row = /^(\d+)\s+\S+\s+(\S+)$/u.exec(line);
|
|
141
|
+
if (row) labels.set(Number(row[1]), `launchd ${row[2]}`);
|
|
142
|
+
}
|
|
143
|
+
} else if (process.platform === 'linux') {
|
|
144
|
+
for (const pid of pids) {
|
|
145
|
+
try {
|
|
146
|
+
const unit = readFileSync(`/proc/${pid}/cgroup`, 'utf8').trim().split('\n').pop().split('/').pop();
|
|
147
|
+
if (unit.endsWith('.service') && !/^user@\d+\.service$/u.test(unit)) labels.set(pid, `systemd ${unit}`);
|
|
148
|
+
} catch { /* gone, or not ours to read */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return labels;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every process and every supercode install process of this machine, for the readings that need who started what:
|
|
156
|
+
* `{ table: Map(pid → { pid, ppid, tty, lstart, command }), running: [{ pid, ppid, tty, command, file, startedAt,
|
|
157
|
+
* installedAt, stale }], labels: Map(pid → 'launchd <label>' | 'systemd <unit>') }`. `stale` is staleSupercode's reading: the file
|
|
158
|
+
* the process runs, in the install it runs from, was replaced after it started. `lines` and `labels` stand in for ps
|
|
159
|
+
* and the service manager (a recorded listing: `pid ppid tpgid lstart command`).
|
|
160
|
+
*/
|
|
161
|
+
export async function supercodeProcesses({ lines = null, labels = null } = {}) {
|
|
162
|
+
const listed = lines ? null : await run('ps', ['-A', '-ww', '-o', 'pid=,ppid=,tpgid=,lstart=,command='], { timeoutMs: 10_000 });
|
|
163
|
+
const { table, found } = installProcesses(lines ?? String(listed.stdout ?? '').split('\n'), true);
|
|
164
|
+
const [inodes, services] = await Promise.all([
|
|
165
|
+
lines ? new Map() : runningInodes(found.filter((row) => row.native).map((row) => row.pid)),
|
|
166
|
+
labels ?? serviceLabels(found.map((row) => row.pid).concat(found.map((row) => row.ppid))),
|
|
167
|
+
]);
|
|
168
|
+
const rows = judged(found, inodes);
|
|
169
|
+
const still = new Set(moved(rows.filter((row) => row.stale)).map((row) => row.pid));
|
|
170
|
+
return { table, running: rows.map((row) => ({ ...row, stale: row.stale && still.has(row.pid) })), labels: services };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const wordsOf = (command) => String(command ?? '').split(/\s+/u).filter(Boolean);
|
|
174
|
+
const base = (word) => String(word ?? '').split('/').pop();
|
|
175
|
+
/** The script node runs, or the program itself: what a process is, whatever started it. */
|
|
176
|
+
const scriptOf = (command) => { const words = wordsOf(command); return /(^|\/)node$/u.test(words[0] ?? '') ? words.slice(1).find((word) => !word.startsWith('-')) ?? words[0] : words[0]; };
|
|
177
|
+
/** A process as a reading names it: its program and the script it runs, and a `--root` where it has one; never its
|
|
178
|
+
* other arguments (a harness's carry a prompt, a command's a path of someone's). */
|
|
179
|
+
function named(command) {
|
|
180
|
+
const words = wordsOf(command), script = scriptOf(command);
|
|
181
|
+
const root = words.indexOf('--root');
|
|
182
|
+
const shown = [base(words[0]), ...(script !== words[0] ? [script] : words[1]?.startsWith('/') ? [words[1]] : [])].join(' ');
|
|
183
|
+
return `${shown}${root > 0 && words[root + 1] ? ` --root ${words[root + 1]}` : ''}`.slice(0, 240);
|
|
184
|
+
}
|
|
185
|
+
/** What a supercode process does: its script's last two path parts and its verb (the first plain words after it). */
|
|
186
|
+
function doing(command) {
|
|
187
|
+
const words = wordsOf(command), script = scriptOf(command);
|
|
188
|
+
const verb = words.slice(words.indexOf(script) + 1).filter((word) => /^[a-z][a-z-]*$/u.test(word)).slice(0, 2).join(' ');
|
|
189
|
+
return `${script.split('/').slice(-2).join('/')}${verb ? ` ${verb}` : ''}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The supercode install processes of a `supercodeProcesses` reading, by what replacing a release asks of each:
|
|
194
|
+
*
|
|
195
|
+
* - `helpers`: a process that lives exactly as long as one session and cannot take new code without that session
|
|
196
|
+
* starting again (the Codex pane's wrapper, and anything a harness started: its mail MCP server, that server's
|
|
197
|
+
* child). Told by where it runs: it is a pane wrapper, or a harness (`sessionPrograms`) or a pane wrapper is above
|
|
198
|
+
* it, or it is on a terminal (a pane's or a person's: a harness this list does not name, a command someone runs).
|
|
199
|
+
* Running a replaced release is what these do until their session ends; they are counted, never a broken promise.
|
|
200
|
+
* - `orphans`: a supercode command with no parent left (its parent is the system's first process) that no service
|
|
201
|
+
* manager started and that is not the machine's daemon, running longer than `orphanMin`. A stuck command, whatever
|
|
202
|
+
* release it runs.
|
|
203
|
+
* - `services`: every other process running a replaced release: what an install should have restarted (the daemon and
|
|
204
|
+
* its children, a board keeper's, another product's service). A process whose parent is one of these too is the same
|
|
205
|
+
* service (node and the native it starts). Each names who owns it (the service manager's label of it or of the
|
|
206
|
+
* nearest process above it that has one, and its parent), the install its file is in, and since when that file is
|
|
207
|
+
* known replaced: `remembered` (`pid:startedAt` → ISO time, a reading before this one) when it is earlier than the
|
|
208
|
+
* file's last write, which a later install moves.
|
|
209
|
+
*/
|
|
210
|
+
export function classifyReleaseProcesses({ table, running, labels = new Map() }, { now = Date.now(), sessionPrograms = ['claude', 'codex', 'gemini', 'grok'], daemonPid = null, orphanMin = 30, remembered = {} } = {}) {
|
|
211
|
+
const isPane = (command) => base(scriptOf(command)) === 'native-codex-pane.mjs';
|
|
212
|
+
const isHarness = (command) => { const words = wordsOf(command); return sessionPrograms.includes(base(words[0])) || sessionPrograms.includes(base(scriptOf(command))); };
|
|
213
|
+
const parentless = (row) => { const parent = table.get(row.ppid); return row.ppid === 1 || !parent || base(wordsOf(parent.command)[0]) === 'systemd'; };
|
|
214
|
+
const above = (row) => { const chain = []; for (let at = table.get(row.ppid), n = 0; at && n < 64; at = table.get(at.ppid), n++) chain.push(at); return chain; };
|
|
215
|
+
const byPid = new Map(running.map((row) => [row.pid, row]));
|
|
216
|
+
const kind = new Map();
|
|
217
|
+
for (const row of running) {
|
|
218
|
+
if (isPane(row.command) || row.tty || above(row).some((p) => isHarness(p.command) || isPane(p.command))) kind.set(row.pid, 'helper');
|
|
219
|
+
else if (parentless(row) && !labels.has(row.pid) && row.pid !== daemonPid && row.startedAt && now - Date.parse(row.startedAt) > orphanMin * 60_000) kind.set(row.pid, 'orphan');
|
|
220
|
+
else if (row.stale) kind.set(row.pid, 'service');
|
|
221
|
+
}
|
|
222
|
+
const of = (name) => running.filter((row) => kind.get(row.pid) === name);
|
|
223
|
+
const helpers = of('helper').filter((row) => row.stale);
|
|
224
|
+
const orphans = of('orphan').map((row) => ({ pid: row.pid, what: doing(row.command), install: installRoot(row.file), startedAt: row.startedAt, runningMs: now - Date.parse(row.startedAt), stale: row.stale }));
|
|
225
|
+
const services = new Map();
|
|
226
|
+
for (const row of of('service')) {
|
|
227
|
+
let top = row;
|
|
228
|
+
while (kind.get(top.ppid) === 'service') top = byPid.get(top.ppid);
|
|
229
|
+
const parent = table.get(top.ppid);
|
|
230
|
+
const label = labels.get(top.pid) ?? above(top).map((p) => labels.get(p.pid)).find(Boolean) ?? null;
|
|
231
|
+
const service = services.get(top.pid) ?? { pids: [], what: doing(top.command), owner: [labels.has(top.pid) || !parent || top.ppid === 1 ? null : named(parent.command), label].filter(Boolean).join(', ') || 'no parent and no service manager', install: installRoot(top.file), startedAt: top.startedAt, replacedAt: null };
|
|
232
|
+
service.pids.push(row.pid);
|
|
233
|
+
const known = [row.installedAt, remembered[`${row.pid}:${row.startedAt}`]].filter(Boolean).sort()[0];
|
|
234
|
+
if (!service.replacedAt || known < service.replacedAt) service.replacedAt = known;
|
|
235
|
+
services.set(top.pid, service);
|
|
236
|
+
}
|
|
237
|
+
const listed = [...services.values()].map((service) => ({ ...service, outlivedMs: now - Date.parse(service.replacedAt) })).sort((a, b) => a.replacedAt.localeCompare(b.replacedAt));
|
|
238
|
+
// what the next reading remembers: every service process's earliest known replacement
|
|
239
|
+
const remember = {};
|
|
240
|
+
for (const row of of('service')) { let top = row; while (kind.get(top.ppid) === 'service') top = byPid.get(top.ppid); remember[`${row.pid}:${row.startedAt}`] = services.get(top.pid).replacedAt; }
|
|
241
|
+
return {
|
|
242
|
+
helpers: { count: helpers.length, oldest_start: helpers.map((row) => row.startedAt).sort()[0] ?? null, by: tally(helpers.map((row) => doing(row.command))) },
|
|
243
|
+
orphans, services: listed, remember,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const tally = (names) => { const counts = {}; for (const name of names) counts[name] = (counts[name] ?? 0) + 1; return counts; };
|
|
248
|
+
/** The install a file is in: the folder that holds the `node_modules` its package was put in. */
|
|
249
|
+
const installRoot = (file) => { const at = file.indexOf('/node_modules/@volter/'); return at > 0 ? file.slice(0, at) : dirname(file); };
|
package/lib/promises.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The core promises, read live (promises.json is the list). One pass reads every promise at once, each inside its own
|
|
2
2
|
// hard bound, and says of each `true`, `false` or `unknown` with the reading behind it and the milliseconds it took.
|
|
3
3
|
// A pass changes nothing: it reads files, this machine's daemon socket, two process listings, the server's machine
|
|
4
|
-
// list
|
|
4
|
+
// list, the installed release's own restart check and, on the install's host, two public documents of the npm registry. Where another program has to be started the reason is beside
|
|
5
5
|
// it; nothing here starts a shell to read what a file or a socket answers. No reading carries an environment's values,
|
|
6
6
|
// a credential or a message's text.
|
|
7
7
|
//
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
// zero) and readable there the whole time. The probe (probe.mjs) runs the pass every slow interval and mails a false
|
|
11
11
|
// promise to the `core` addresses on the pass that first reads it false; a release's activation watch runs the same
|
|
12
12
|
// pass once the new daemon serves and compares it with the last pass before it.
|
|
13
|
+
//
|
|
14
|
+
// A pass taken for a release (`since`: when that release's daemon began to serve) counts only what happened under it.
|
|
15
|
+
// A promise that reads an event (a board's last round, a mail's expiry) reads there only events that began at or after
|
|
16
|
+
// `since`: a round that met no daemon while an install restarted it says nothing of the release that came up. Until
|
|
17
|
+
// such an event exists the promise is unknown for that release, never true and never false.
|
|
13
18
|
import { readFileSync, realpathSync, statfsSync, statSync, accessSync, constants } from 'node:fs';
|
|
14
19
|
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
15
20
|
import { delimiter, isAbsolute, join } from 'node:path';
|
|
@@ -17,7 +22,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
17
22
|
import { run } from './run.mjs';
|
|
18
23
|
import { daemonCall, daemonHello } from './mail.mjs';
|
|
19
24
|
import { healthDir, machineName } from './paths.mjs';
|
|
20
|
-
import {
|
|
25
|
+
import { supercodeProcesses, classifyReleaseProcesses } from './drift.mjs';
|
|
21
26
|
import { harnessMarks } from './markers.mjs';
|
|
22
27
|
|
|
23
28
|
const json = (file) => { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } };
|
|
@@ -37,10 +42,17 @@ export function promiseList() {
|
|
|
37
42
|
export const PROMISE_DEFAULTS = Object.freeze({
|
|
38
43
|
mailExpiryMin: 15, // mail that expired undelivered this recently says delivery is failing now
|
|
39
44
|
staleReleaseHours: 2, // extrapolated: a service restarts with its install; two hours is past any restart in hand
|
|
45
|
+
stuckCommandMin: 30, // extrapolated: no supercode command is known to take this long once its caller is gone (the doors a command waits on are bounded in seconds)
|
|
40
46
|
boardRoundMin: 10, // extrapolated from a round's own length (about 30 s here) and the maintainer's 5 min line
|
|
41
47
|
diskFreeGB: 5, // extrapolated: admission's copy of an 800 MB mailbox and an install both need room
|
|
42
48
|
fleetSeenHours: 24, // a machine silent longer than this is taken to be off, not broken
|
|
49
|
+
releaseWaitMin: 30, // measured 2026-10-09/10 over 17 releases: published 2 to 16.5 min after the binary was built, a whole run 16 to 39 min; twice the longest wait
|
|
50
|
+
releaseReadMin: 5, // the registry is asked at most this often by one probe
|
|
51
|
+
releaseWatch: 'board', // where it is read: 'board' (a machine whose board dispatcher ran in the last 24 h), true, false
|
|
52
|
+
releasePackage: '@volter/supercode',
|
|
53
|
+
releaseRegistry: 'https://registry.npmjs.org',
|
|
43
54
|
harnesses: ['codex', 'claude'],
|
|
55
|
+
sessionPrograms: ['claude', 'codex', 'gemini', 'grok', 'goose', 'opencode', 'openclaw'], // a process under one of these lives as long as its session
|
|
44
56
|
daemonAnswerMs: 6000, // extrapolated: above the longest step the daemon is known to wait on in one call (a 5 s child)
|
|
45
57
|
});
|
|
46
58
|
|
|
@@ -72,6 +84,94 @@ function installs(name, search) {
|
|
|
72
84
|
return found;
|
|
73
85
|
}
|
|
74
86
|
|
|
87
|
+
/** Each service process's earliest known replacement (`pid:startedAt` → ISO time), as this process has read it. */
|
|
88
|
+
const replacedSeen = new Map();
|
|
89
|
+
|
|
90
|
+
/** The supercode processes by what replacing a release asks of them, read once a pass and shared by the two promises
|
|
91
|
+
* that read it. What an earlier pass knew of a service's replacement comes from this process and from the probe's
|
|
92
|
+
* snapshot, so it holds across a probe's restart and for a pass taken by hand. */
|
|
93
|
+
function releaseProcesses(context) {
|
|
94
|
+
context.processes ??= supercodeProcesses().then((reading) => {
|
|
95
|
+
const kept = json(join(healthDir(context.env), 'latest.json'))?.readings?.promises?.items?.find((item) => item.name === 'no-stale-release')?.detail?.remember ?? {};
|
|
96
|
+
const remembered = { ...kept };
|
|
97
|
+
for (const [key, at] of replacedSeen) if (!(remembered[key] <= at)) remembered[key] = at;
|
|
98
|
+
const daemonPid = json(join(teamsHome(context.env), 'machine.json'))?.pid ?? null;
|
|
99
|
+
const sorted = classifyReleaseProcesses(reading, { now: context.now, sessionPrograms: context.lines.sessionPrograms, daemonPid, orphanMin: context.lines.stuckCommandMin, remembered });
|
|
100
|
+
replacedSeen.clear();
|
|
101
|
+
for (const [key, at] of Object.entries(sorted.remember)) replacedSeen.set(key, at);
|
|
102
|
+
return sorted;
|
|
103
|
+
});
|
|
104
|
+
return context.processes;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `a` is a later release than `b` (plain `x.y.z`; a prerelease is read by its numbers). */
|
|
108
|
+
const laterVersion = (a, b) => { const [x, y] = [a, b].map((v) => String(v).split('-')[0].split('.').map(Number)); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; return false; };
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What the registry shows of a release, judged: `latest` is the version `npm install` of the release's package takes
|
|
112
|
+
* (the meta package's `latest`; the release workflow publishes it last, only when its checks passed); `built` is the
|
|
113
|
+
* publish time of each version of the release's binary package (the workflow publishes it under `candidate` once the
|
|
114
|
+
* build is done, before the checks). A built version later than `latest` is a release that has not reached anyone:
|
|
115
|
+
* its run is at its checks, or failed there, or stopped. False once the oldest such has waited `waitMin`; measured
|
|
116
|
+
* from the oldest, so a second failed release behind it does not start the wait again.
|
|
117
|
+
*/
|
|
118
|
+
export function releaseReading({ name, latest, built, now, waitMin }) {
|
|
119
|
+
const waiting = Object.entries(built ?? {}).filter(([version, at]) => /^\d+\.\d+\.\d+/u.test(version) && typeof at === 'string' && laterVersion(version, latest)).map(([version, at]) => ({ version, built_at: at })).sort((a, b) => a.built_at.localeCompare(b.built_at));
|
|
120
|
+
const detail = { package: name, latest, waiting, wait_min: waitMin };
|
|
121
|
+
if (!waiting.length) return yes(`${name}@${latest} is what installs, and no later release is built and waiting`, detail);
|
|
122
|
+
const since = now - Date.parse(waiting[0].built_at), first = waiting[0], behind = waiting.slice(1).map((w) => w.version);
|
|
123
|
+
return since > waitMin * 60_000
|
|
124
|
+
? no(`${waiting.length} release(s) built and not published: ${waiting.map((w) => w.version).join(', ')}. ${first.version} was built at ${first.built_at}, ${ago(since)} ago (the line is ${waitMin} min: a release that passes its checks is published 2 to 17 min after its build); ${name}@${latest} is still what installs. Its release run failed after the build or is stopped: read the run (gh run list --workflow release.yml)`, detail)
|
|
125
|
+
: yes(`${name}@${latest} is what installs; ${first.version} was built ${ago(since)} ago and is not published yet${behind.length ? `, with ${behind.join(', ')} behind it` : ''} (the line is ${waitMin} min)`, detail);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A registry document, read at most once in `everyMs` by this process and asked again with the tag it came with (an
|
|
129
|
+
* unchanged document answers with no body). No credential is sent: a registry that wants one is not read. */
|
|
130
|
+
const registryRead = new Map();
|
|
131
|
+
async function registryJson(url, { everyMs, timeoutMs, now = Date.now() }) {
|
|
132
|
+
const had = registryRead.get(url);
|
|
133
|
+
if (had && now - had.at < everyMs) return had.body;
|
|
134
|
+
const answer = await fetch(url, { headers: { accept: 'application/json', ...(had?.etag ? { 'if-none-match': had.etag } : {}) }, signal: AbortSignal.timeout(timeoutMs) })
|
|
135
|
+
.catch((error) => { throw new Error(`${new URL(url).host} did not answer within ${timeoutMs / 1000} s (${error.cause?.code ?? error.name})`); });
|
|
136
|
+
if (answer.status === 304 && had) { had.at = now; return had.body; }
|
|
137
|
+
if (!answer.ok) throw new Error(`${new URL(url).host} answered ${answer.status} for ${decodeURIComponent(new URL(url).pathname.slice(1))}`);
|
|
138
|
+
const body = await answer.json();
|
|
139
|
+
registryRead.set(url, { at: now, etag: answer.headers.get('etag'), body });
|
|
140
|
+
return body;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The board homes of this machine where a dispatcher ended a round in the last `hours`: the install's host is the
|
|
144
|
+
* machine that runs its board. */
|
|
145
|
+
function dispatcherHomes(context, hours = 24) {
|
|
146
|
+
const held = json(join(supercodeHome(context.env), 'board-homes.json')) ?? {};
|
|
147
|
+
return [...new Set(Object.values(held).filter((value) => typeof value === 'string'))].filter((root) => {
|
|
148
|
+
const at = Date.parse(json(join(root, 'dispatcher-heartbeat.json'))?.at ?? '');
|
|
149
|
+
return context.now - at <= hours * 3_600_000;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A kept release's promises that have read true under it (activation time → names), as this process has read them. */
|
|
154
|
+
const releaseHeldSeen = new Map();
|
|
155
|
+
|
|
156
|
+
/** The promises that have read true under the release activated at `at`: from this process and from the probe's
|
|
157
|
+
* snapshot, so it holds across a probe's restart and for a pass taken by hand. */
|
|
158
|
+
function releaseHeld(context, at) {
|
|
159
|
+
const kept = json(join(healthDir(context.env), 'latest.json'))?.readings?.promises?.items?.find((item) => item.name === 'release-kept')?.detail;
|
|
160
|
+
return new Set(at == null ? [] : [...(kept?.at_ms === at ? kept.held ?? [] : []), ...(releaseHeldSeen.get(at) ?? [])]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** One promise of the list read inside its own bound: past it, or when its probe throws, it is unknown and says so. */
|
|
164
|
+
async function readOne(entry, context) {
|
|
165
|
+
let timer;
|
|
166
|
+
const bound = new Promise((resolve) => { timer = setTimeout(() => resolve(unknown(`not read within its ${entry.bound_ms} ms bound`)), entry.bound_ms); });
|
|
167
|
+
const probe = PROBES[entry.probe];
|
|
168
|
+
const read = probe ? Promise.resolve().then(() => probe(context)).catch((error) => unknown(`could not be read: ${String(error.message ?? error).split('\n')[0].slice(0, 240)}`))
|
|
169
|
+
: Promise.resolve(unknown(`this release has no probe named ${entry.probe}`));
|
|
170
|
+
const result = await Promise.race([read, bound]);
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
75
175
|
const PROBES = {
|
|
76
176
|
async server(context) {
|
|
77
177
|
const items = await machineList(context);
|
|
@@ -110,7 +210,7 @@ const PROBES = {
|
|
|
110
210
|
const answer = await daemonCall('harness.v1.mail.expired', {}, { env: context.env, timeoutMs: 1300 });
|
|
111
211
|
if (!Array.isArray(answer?.rows)) return no('the daemon answered its mail door with no list of expiries');
|
|
112
212
|
if (record.mail_ready !== true) return no(`the daemon (pid ${record.pid}) answers and does not serve mail`);
|
|
113
|
-
const since = context.now - context.lines.mailExpiryMin * 60_000;
|
|
213
|
+
const since = Math.max(context.now - context.lines.mailExpiryMin * 60_000, context.since ?? 0);
|
|
114
214
|
const recent = answer.rows.filter((row) => Number(row?.expired_at_ms) >= since);
|
|
115
215
|
const reasons = [...new Set(recent.map((row) => String(row.reason ?? 'no reason given').slice(0, 80)))];
|
|
116
216
|
const detail = { pid: record.pid, expired_recently: recent.length, reasons };
|
|
@@ -177,37 +277,71 @@ const PROBES = {
|
|
|
177
277
|
: yes('no terminal window or tmux server carries a child-session mark');
|
|
178
278
|
},
|
|
179
279
|
|
|
280
|
+
// What replacing a release asks of a process depends on who started it (drift.mjs classifyReleaseProcesses). A
|
|
281
|
+
// session's own helpers (its mail MCP server, its Codex pane's wrapper) live as long as the session and are counted;
|
|
282
|
+
// a service is what an install should have restarted, and is the promise. How long a service has outlived its
|
|
283
|
+
// release is measured from the earliest reading that found its file replaced, kept from pass to pass (this process's
|
|
284
|
+
// memory and the last pass in the probe's snapshot): the file's own time moves with every later install, and a
|
|
285
|
+
// promise measured from it read true again each time another release was installed.
|
|
180
286
|
async stale(context) {
|
|
181
287
|
const line = context.lines.staleReleaseHours * 3_600_000;
|
|
182
|
-
const
|
|
183
|
-
const over =
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
288
|
+
const { helpers, services, orphans, remember } = await releaseProcesses(context);
|
|
289
|
+
const over = services.filter((service) => service.outlivedMs > line);
|
|
290
|
+
const say = (service) => `${service.what} (pid ${service.pids.join(', ')}) of ${service.owner}, from the install at ${service.install}, started ${service.startedAt}, its release replaced at least ${ago(service.outlivedMs)} ago`;
|
|
291
|
+
const beside = [
|
|
292
|
+
services.length - over.length ? `${services.length - over.length} service(s) run a release replaced in the last ${context.lines.staleReleaseHours} h` : null,
|
|
293
|
+
helpers.count ? `${helpers.count} session helper(s) run a replaced release and will until their sessions end (${Object.entries(helpers.by).sort((a, b) => b[1] - a[1]).map(([what, count]) => `${count} ${what}`).join(', ')}; the oldest started ${helpers.oldest_start}): not this promise's` : null,
|
|
294
|
+
].filter(Boolean).join('; ');
|
|
295
|
+
const detail = { services, helpers, stuck: orphans.length, remember };
|
|
296
|
+
return over.length ? no(`${over.length} service(s) run a release replaced more than ${context.lines.staleReleaseHours} h ago: ${over.map(say).join('; ')}${beside ? `. Beside them: ${beside}` : ''}`, detail)
|
|
297
|
+
: yes(`no service has outlived its release by ${context.lines.staleReleaseHours} h${beside ? `; ${beside}` : ''}`, detail);
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
// A command whose caller is gone and that still runs: nothing reads its answer and nothing ends it (2026-10-10: a
|
|
301
|
+
// `teams files put` ran three hours under the system's first process, waiting on a remote daemon that had stopped
|
|
302
|
+
// answering). It is not a stale release, whichever release it runs, and is said here by itself.
|
|
303
|
+
async stuck(context) {
|
|
304
|
+
const { orphans } = await releaseProcesses(context);
|
|
305
|
+
return orphans.length ? no(`${orphans.length} supercode command(s) have run more than ${context.lines.stuckCommandMin} min with no parent and no service manager: ${orphans.map((o) => `${o.what} (pid ${o.pid}, from ${o.install}, started ${o.startedAt}, ${ago(o.runningMs)} ago)`).join('; ')}`, orphans)
|
|
306
|
+
: yes(`no supercode command has run more than ${context.lines.stuckCommandMin} min without a parent`);
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
// Two public documents of the npm registry, no credential: the meta package's dist-tags (what installs) and the
|
|
310
|
+
// binary package's document for this platform (when each version was built). Read where the install's board runs,
|
|
311
|
+
// unless the config says (`releaseWatch`: true, false, or 'board'): one failed release is one alarm, not one a
|
|
312
|
+
// machine. Unknown when the registry does not answer; never false for that.
|
|
313
|
+
async release(context) {
|
|
314
|
+
const lines = context.lines;
|
|
315
|
+
if (lines.releaseWatch === false || (lines.releaseWatch !== true && !dispatcherHomes(context).length)) {
|
|
316
|
+
return yes(`not read on this machine: ${lines.releaseWatch === false ? 'its config turns it off' : 'no board dispatcher ran a round here in the last 24 h, and the install\'s host reads it'}`);
|
|
192
317
|
}
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
318
|
+
const registry = String(lines.releaseRegistry).replace(/\/+$/u, ''), name = lines.releasePackage;
|
|
319
|
+
const binary = `${name}-cli-${process.platform}-${process.arch}`;
|
|
320
|
+
const every = { everyMs: lines.releaseReadMin * 60_000, timeoutMs: 4000, now: context.now };
|
|
321
|
+
const [tags, built] = await Promise.all([
|
|
322
|
+
registryJson(`${registry}/-/package/${name.replace('/', '%2f')}/dist-tags`, every),
|
|
323
|
+
registryJson(`${registry}/${binary.replace('/', '%2f')}`, every),
|
|
324
|
+
]);
|
|
325
|
+
if (typeof tags?.latest !== 'string' || !built?.time) return unknown(`${registry} answered no latest for ${name} or no publish times for ${binary}`);
|
|
326
|
+
return releaseReading({ name, latest: tags.latest, built: built.time, now: context.now, waitMin: lines.releaseWaitMin });
|
|
196
327
|
},
|
|
197
328
|
|
|
198
329
|
async board(context) {
|
|
199
330
|
const held = json(join(supercodeHome(context.env), 'board-homes.json')) ?? {};
|
|
200
|
-
const line = context.lines.boardRoundMin * 60_000, rounds = [], broken = [];
|
|
331
|
+
const line = context.lines.boardRoundMin * 60_000, rounds = [], broken = [], before = [];
|
|
201
332
|
for (const root of new Set(Object.values(held).filter((value) => typeof value === 'string'))) {
|
|
202
333
|
const beat = json(join(root, 'dispatcher-heartbeat.json'));
|
|
203
334
|
if (!beat?.at) continue; // no dispatcher has run a round in this home
|
|
204
335
|
const age = context.now - Date.parse(beat.at);
|
|
205
336
|
const failed = (beat.boards ?? []).reduce((sum, board) => sum + (Number(board.failed) || 0), 0), errors = (beat.errors ?? []).length;
|
|
206
|
-
rounds.push({ home: root, round: beat.round ?? null, at: beat.at, failed, errors });
|
|
337
|
+
rounds.push({ home: root, round: beat.round ?? null, began: beat.began ?? null, at: beat.at, failed, errors });
|
|
338
|
+
// rounds that stopped are false whenever they are read; what a round met is read only of a round begun under `since`
|
|
207
339
|
if (!(age <= line)) broken.push(`${root}: its last round (${beat.round ?? '?'}) ended ${ago(age)} ago`);
|
|
340
|
+
else if (context.since != null && !(Date.parse(beat.began ?? '') >= context.since)) before.push(`${root}: round ${beat.round ?? '?'} began before the daemon that serves now did`);
|
|
208
341
|
else if (failed || errors) broken.push(`${root}: round ${beat.round ?? '?'} failed ${failed} and raised ${errors} error(s)`);
|
|
209
342
|
}
|
|
210
343
|
if (!rounds.length) return yes('no board dispatcher has run a round on this machine');
|
|
344
|
+
if (!broken.length && before.length) return unknown(`${before.join('; ')}; no round has begun and ended under it yet`);
|
|
211
345
|
return broken.length ? no(broken.join('; '), rounds) : yes(rounds.map((r) => `${r.home}: round ${r.round ?? '?'} ${ago(context.now - Date.parse(r.at))} ago, failed 0`).join('; '), rounds);
|
|
212
346
|
},
|
|
213
347
|
|
|
@@ -225,6 +359,11 @@ const PROBES = {
|
|
|
225
359
|
: yes(rows.map((row) => `${row.folder}: ${row.freeGB} GB free`).join('; '), rows);
|
|
226
360
|
},
|
|
227
361
|
|
|
362
|
+
// The watch's verdict (activation-watch.mjs) is one pass taken seconds after the daemon served, and is not the last
|
|
363
|
+
// word on a kept release. A promise it read false there, or could not read yet (a board whose first round under
|
|
364
|
+
// the release had not ended), is read again here as the watch reads it: under the release's daemon only. False is
|
|
365
|
+
// the charge; true ends it for this activation, kept from pass to pass (this process's memory and the last pass in
|
|
366
|
+
// the probe's snapshot) so nothing that fails later under the same release is laid at its activation.
|
|
228
367
|
async activation(context) {
|
|
229
368
|
const verdict = json(join(teamsHome(context.env), 'service', 'last-activation.json'));
|
|
230
369
|
if (!verdict?.state) return yes('no activation is recorded on this machine');
|
|
@@ -234,30 +373,47 @@ const PROBES = {
|
|
|
234
373
|
return no(`${release} was ${verdict.state === 'rolled_back' ? `put back at ${at}; ${verdict.restored?.release ?? 'the release before'} serves` : verdict.state === 'restarted' ? `stopped and started again at ${at}` : `stopped at ${at} and nothing was restored`}: ${verdict.reason ?? verdict.detail ?? 'no reason recorded'}`,
|
|
235
374
|
{ state: verdict.state, at_ms: verdict.at_ms ?? null, broken: verdict.promises?.broken ?? [] });
|
|
236
375
|
}
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
376
|
+
const list = promiseList().promises;
|
|
377
|
+
const own = (row) => list.find((entry) => entry.name === row.name)?.probe === 'activation';
|
|
378
|
+
const charged = (verdict.promises?.broken ?? []).filter((row) => !own(row));
|
|
379
|
+
const awaited = (verdict.promises?.unread ?? []).filter((row) => row.before === 'true' && !own(row));
|
|
380
|
+
const held = releaseHeld(context, verdict.at_ms ?? null);
|
|
381
|
+
const under = { ...context, since: verdict.serving_since_ms ?? null };
|
|
382
|
+
const open = [...charged, ...awaited].filter((row) => !held.has(row.name));
|
|
383
|
+
const read = await Promise.all(open.map((row) => {
|
|
384
|
+
const entry = list.find((item) => item.name === row.name);
|
|
385
|
+
return entry ? readOne(entry, under) : unknown('this release has no promise of that name');
|
|
386
|
+
}));
|
|
387
|
+
const broken = [], unread = [];
|
|
388
|
+
open.forEach((row, i) => {
|
|
389
|
+
if (read[i].state === 'true') held.add(row.name);
|
|
390
|
+
else if (read[i].state === 'false') broken.push({ ...row, reading: read[i].reading });
|
|
391
|
+
else if (charged.includes(row)) broken.push(row); // unread now: what the watch read stands
|
|
392
|
+
else unread.push(row.name);
|
|
393
|
+
});
|
|
394
|
+
releaseHeldSeen.clear();
|
|
395
|
+
if (verdict.at_ms != null) releaseHeldSeen.set(verdict.at_ms, [...held]);
|
|
396
|
+
const detail = { state: verdict.state, at_ms: verdict.at_ms ?? null, broken, held: [...held], unread };
|
|
397
|
+
if (broken.length) return no(`${release} was kept at ${at} and broke ${broken.map((b) => b.name).join(', ')}: ${verdict.promises.kept_because ?? 'it was not put back'}`, detail);
|
|
398
|
+
const cleared = charged.filter((row) => held.has(row.name)).map((row) => row.name);
|
|
399
|
+
return yes(`${release} ${verdict.state} at ${at}${cleared.length ? `; ${cleared.join(', ')} read false at its activation and ${cleared.length === 1 ? 'holds' : 'hold'} under it` : ''}${unread.length ? `; ${unread.join(', ')} not read under it yet` : ''}`,
|
|
400
|
+
charged.length || awaited.length ? detail : undefined);
|
|
240
401
|
},
|
|
241
402
|
};
|
|
242
403
|
|
|
243
404
|
/**
|
|
244
405
|
* One pass: `{ version, at, machine, ms, items: [{ name, promise, state, reading, detail?, ms, bound_ms, activation }] }`.
|
|
245
406
|
* Every probe runs at once; one that has not answered inside its bound is `unknown` and says so, and the pass ends
|
|
246
|
-
* with the slowest probe. `only` reads some of the list by name.
|
|
407
|
+
* with the slowest probe. `only` reads some of the list by name. `since` (ms) takes the pass for the release whose
|
|
408
|
+
* daemon began to serve then: an event that began before it is not read.
|
|
247
409
|
*/
|
|
248
|
-
export async function readPromises({ env = process.env, config = {}, machine = null, only = null, now = Date.now() } = {}) {
|
|
410
|
+
export async function readPromises({ env = process.env, config = {}, machine = null, only = null, now = Date.now(), since = null } = {}) {
|
|
249
411
|
const started = performance.now();
|
|
250
|
-
const context = { env, now, bin: env.SUPERCODE_BIN || 'supercode', machine: machineName(machine || env.SUPERCODE_HEALTH_MACHINE || undefined), lines: { ...PROMISE_DEFAULTS, ...(config.promises ?? {}) }, machines: null };
|
|
412
|
+
const context = { env, now, since, bin: env.SUPERCODE_BIN || 'supercode', machine: machineName(machine || env.SUPERCODE_HEALTH_MACHINE || undefined), lines: { ...PROMISE_DEFAULTS, ...(config.promises ?? {}) }, machines: null, processes: null };
|
|
251
413
|
const list = promiseList().promises.filter((entry) => !only || only.includes(entry.name));
|
|
252
414
|
const items = await Promise.all(list.map(async (entry) => {
|
|
253
415
|
const t0 = performance.now();
|
|
254
|
-
|
|
255
|
-
const bound = new Promise((resolve) => { timer = setTimeout(() => resolve(unknown(`not read within its ${entry.bound_ms} ms bound`)), entry.bound_ms); });
|
|
256
|
-
const probe = PROBES[entry.probe];
|
|
257
|
-
const read = probe ? Promise.resolve().then(() => probe(context)).catch((error) => unknown(`could not be read: ${String(error.message ?? error).split('\n')[0].slice(0, 240)}`))
|
|
258
|
-
: Promise.resolve(unknown(`this release has no probe named ${entry.probe}`));
|
|
259
|
-
const result = await Promise.race([read, bound]);
|
|
260
|
-
clearTimeout(timer);
|
|
416
|
+
const result = await readOne(entry, context);
|
|
261
417
|
return { name: entry.name, promise: entry.promise, ...result, ms: Math.round(performance.now() - t0), bound_ms: entry.bound_ms, activation: entry.activation };
|
|
262
418
|
}));
|
|
263
419
|
return { version: 1, at: new Date(now).toISOString(), machine: context.machine, ms: Math.round(performance.now() - started), items };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-health",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Optional machine pack: a cheap, deterministic probe of a machine's vitals that mails the machine's maintainer when a threshold is crossed. Each feature sits behind its own permission barrier.",
|
|
6
6
|
"license": "MIT",
|
package/promises.json
CHANGED
|
@@ -15,13 +15,17 @@
|
|
|
15
15
|
{ "name": "no-harness-marks", "probe": "marks", "bound_ms": 2000, "activation": "report",
|
|
16
16
|
"promise": "No terminal window or tmux server carries a harness's child-session marks, so a session a person starts in one is its own session." },
|
|
17
17
|
{ "name": "no-stale-release", "probe": "stale", "bound_ms": 3000, "activation": "report",
|
|
18
|
-
"promise": "No
|
|
18
|
+
"promise": "No service has run a supercode release for more than two hours after its own install replaced it: the daemon and its children, a board keeper's, another product's. A session's own helpers (its mail MCP server, its Codex pane's wrapper) live as long as the session and are counted, not held to this." },
|
|
19
|
+
{ "name": "no-stuck-command", "probe": "stuck", "bound_ms": 3000, "activation": "report",
|
|
20
|
+
"promise": "No supercode command has run more than 30 minutes with no parent and no service manager: one that has is stuck, with nothing reading its answer." },
|
|
19
21
|
{ "name": "board-round", "probe": "board", "bound_ms": 500, "activation": "report",
|
|
20
22
|
"promise": "Where a board's dispatcher runs, its last round ended in the last ten minutes with nothing failed." },
|
|
21
23
|
{ "name": "disk-free", "probe": "disk", "bound_ms": 500, "activation": "report",
|
|
22
24
|
"promise": "The volumes supercode's home, the person's home and the temporary folder are on each have more than 5 GB free." },
|
|
23
|
-
{ "name": "release-kept", "probe": "activation", "bound_ms":
|
|
25
|
+
{ "name": "release-kept", "probe": "activation", "bound_ms": 7000, "activation": "report",
|
|
24
26
|
"promise": "The last release activated on this machine was kept: it was not put back, and it broke no promise that held before it." },
|
|
27
|
+
{ "name": "release-published", "probe": "release", "bound_ms": 5000, "activation": "report",
|
|
28
|
+
"promise": "What main last tagged for release is published: no release has been built and waiting more than 30 minutes for the version npm installs to reach it. Read on the install's host, from the public registry." },
|
|
25
29
|
{ "name": "fleet-online", "probe": "fleet", "bound_ms": 3000, "activation": "report",
|
|
26
30
|
"promise": "Every other machine enrolled with this server and heard from in the last 24 hours is online now." }
|
|
27
31
|
]
|