@volter/supercode-health 0.1.1 → 0.1.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 +9 -4
- package/bin/health.mjs +1 -1
- package/lib/darwin.mjs +13 -7
- package/lib/format.mjs +7 -3
- package/lib/linux.mjs +2 -2
- package/lib/mail.mjs +18 -40
- package/lib/probe.mjs +28 -7
- package/lib/sample.mjs +59 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,10 +32,15 @@ a minute, 24 h) and `probe.log` to `$SUPERCODE_HOME/health`, which defaults to
|
|
|
32
32
|
|
|
33
33
|
## Alarms
|
|
34
34
|
|
|
35
|
-
`supercode health subscribe sc:<machine
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
`supercode health subscribe sc:<machine>:agent:<name>` subscribes an agent (a maintainer:
|
|
36
|
+
the mailbox delivers to its main session as it is then, so a relaunched main still gets
|
|
37
|
+
it); a session or board address may be subscribed too, and its mail stops reaching it
|
|
38
|
+
when the session ends. Each raise, escalation and clear mails the subscribers. A
|
|
39
|
+
still-raised alarm mails again every `repeatMin` (60). Mail comes from
|
|
40
|
+
`sc:<machine>:operator:machine-health`, filed in each subscriber's mailbox
|
|
41
|
+
(`supercode message file`) after the cycle's reading is written; the mailbox delivers
|
|
42
|
+
it, within its bounds, and `supercode message waiting` / `supercode message expired` list
|
|
43
|
+
what it has not delivered.
|
|
39
44
|
|
|
40
45
|
## Config
|
|
41
46
|
|
package/bin/health.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// status [--json] [--all] this machine's latest reading (latest.json)
|
|
8
8
|
// features [--json] every feature, on or off, its permission barrier and why
|
|
9
9
|
// alarms [--json] the alarms raised now
|
|
10
|
-
// subscribe <address> | unsubscribe <address> alarm mail for
|
|
10
|
+
// subscribe <address> | unsubscribe <address> alarm mail for an agent (sc:<machine>:agent:<name>), session or board
|
|
11
11
|
// config where the config lives and what it currently resolves to
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
13
|
import { parseArgs } from 'node:util';
|
package/lib/darwin.mjs
CHANGED
|
@@ -156,14 +156,20 @@ export async function diskIO() {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
/** The user's launchd jobs: label → { pid, lastExit }. */
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
/** The user's launchd jobs: label → { pid, lastExit, runs }. `runs` counts every spawn since the job was loaded,
|
|
160
|
+
* so a KeepAlive job that crashes and respawns between two readings shows as runs rising with a failed last exit,
|
|
161
|
+
* where `launchctl list` alone shows a pid and looks running (12 ms per `launchctl print`). */
|
|
162
|
+
export async function services(labels = []) {
|
|
162
163
|
const map = new Map();
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
const uid = process.getuid?.();
|
|
165
|
+
await Promise.all(labels.map(async (label) => {
|
|
166
|
+
const r = await run('/bin/launchctl', ['print', `gui/${uid}/${label}`], { timeoutMs: 5000 });
|
|
167
|
+
if (!r.ok) return; // not loaded
|
|
168
|
+
const field = (name) => new RegExp(`^\\t${name} = (.+)$`, 'm').exec(r.stdout)?.[1];
|
|
169
|
+
const pid = Number(field('pid')) || null;
|
|
170
|
+
const lastExit = field('last exit code');
|
|
171
|
+
map.set(label, { pid, lastExit: lastExit == null || /never exited/.test(lastExit) ? 0 : Number.parseInt(lastExit, 10), runs: Number(field('runs')) || 0 });
|
|
172
|
+
}));
|
|
167
173
|
return map;
|
|
168
174
|
}
|
|
169
175
|
|
package/lib/format.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The snapshot as a person reads it: alarms first, then each reading, then which features are off and why.
|
|
2
2
|
const n = (v, unit = '') => (v == null ? '-' : `${v}${unit}`);
|
|
3
|
-
const who = (p) => (p?.session?.address ? ` ← ${p.session.address}` : p?.session?.harness ? ` ← ${p.session.harness} pid ${p.session.agentPid}` : '');
|
|
3
|
+
const who = (p) => (p?.session?.address ? ` ← ${p.session.address}` : p?.session?.harness ? ` ← ${p.session.harness} pid ${p.session.agentPid}` : p?.session?.note ? ` ← (${p.session.note})` : p?.session?.inherited ? ` ← (no agent above; inherited ${p.session.inherited})` : '');
|
|
4
4
|
|
|
5
5
|
export function formatStatus(s, { all = false } = {}) {
|
|
6
6
|
if (!s) return 'machine health: no reading yet (is the probe running? `supercode health run`)';
|
|
@@ -10,7 +10,7 @@ export function formatStatus(s, { all = false } = {}) {
|
|
|
10
10
|
if (age > 3 * (s.probe?.intervalSec ?? 15)) out.push(` STALE: the probe has not written for ${age}s; its readings below are old`);
|
|
11
11
|
out.push('', s.alarms.length ? `ALARMS (${s.alarms.length})` : 'ALARMS: none');
|
|
12
12
|
for (const a of s.alarms) out.push(` ${a.level.toUpperCase().padEnd(4)} ${a.summary}${a.detail?.pid ? ` [${a.detail.command?.slice(0, 80)}${who(a.detail)}]` : ''} (since ${a.since})`);
|
|
13
|
-
if (s.maintainers) out.push(` mailed to: ${s.maintainers.length ? s.maintainers.join(', ') : 'no maintainer subscribed (supercode health subscribe <address>)'}${s.mail?.
|
|
13
|
+
if (s.maintainers) out.push(` mailed to: ${s.maintainers.length ? s.maintainers.join(', ') : 'no maintainer subscribed (supercode health subscribe <address>)'}${s.mail?.error ? ` · last mail not filed: ${s.mail.error}` : s.mail?.filed ? ` · last mail filed ${s.mail.at} (supercode message waiting lists any not yet delivered)` : ''}`);
|
|
14
14
|
if (r.load) out.push('', `load ${r.load.one} / ${r.load.five} / ${r.load.fifteen} on ${r.load.cores} cores (${r.load.perCore}× per core)`);
|
|
15
15
|
if (r.memory) {
|
|
16
16
|
const m = r.memory;
|
|
@@ -25,6 +25,10 @@ export function formatStatus(s, { all = false } = {}) {
|
|
|
25
25
|
if (r.renderers) {
|
|
26
26
|
out.push(`renderers ${r.renderers.count} using ${r.renderers.totalMB} MB${r.renderers.orphanedHeadless.length ? `; ${r.renderers.orphanedHeadless.length} orphaned headless browser(s): ${r.renderers.orphanedHeadless.map((p) => p.pid).join(', ')}` : ''}`);
|
|
27
27
|
for (const p of r.renderers.top.slice(0, 3)) out.push(` ${String(p.cpuPct ?? '-').padStart(4)}% ${String(p.footprintMB).padStart(6)} MB ${p.pid}${who(p)}${p.tab ? ` · ${p.tab.url.slice(0, 60)} · ${p.tab.owner}${who(p.tab.server)}` : ' · tab not matched'}`);
|
|
28
|
+
// Chrome names no renderer per tab (CDP's TargetInfo has no pid): a renderer that holds no storage bucket
|
|
29
|
+
// cannot be matched, so the tabs no renderer was matched to are listed with their owners instead.
|
|
30
|
+
const unmatched = (r.browserTabs?.tabs ?? []).filter((t) => !t.renderers.length);
|
|
31
|
+
if (r.renderers.top.slice(0, 3).some((p) => !p.tab) && unmatched.length) out.push(` unmatched tabs (one may be served by an unmatched renderer above): ${unmatched.map((t) => `${t.url.slice(0, 50)} · ${t.owner}${who(t.server)}`).join(' | ')}`);
|
|
28
32
|
}
|
|
29
33
|
if (r.browserTabs) {
|
|
30
34
|
out.push(`tabs ${r.browserTabs.count} open${Object.keys(r.browserTabs.byHolder).length ? ` (${Object.entries(r.browserTabs.byHolder).map(([h, c]) => `${h} ${c}`).join(', ')})` : ''}`);
|
|
@@ -41,7 +45,7 @@ export function formatStatus(s, { all = false } = {}) {
|
|
|
41
45
|
for (const g of t.timeWaitByTarget.slice(0, 3)) out.push(` TIME_WAIT ${g.count} (${g.pct}%) against ${g.target}${g.listener ? ` · pid ${g.listener.pid} ${g.listener.command.slice(0, 60)}${who(g.listener)}` : ''}`);
|
|
42
46
|
}
|
|
43
47
|
if (r.files) out.push(`files ${r.files.open} of ${r.files.max} open (${r.files.pct}%)`);
|
|
44
|
-
if (r.services) for (const sv of r.services) out.push(`service ${sv.name ?? sv.label}: ${sv.running ? `running pid ${sv.pid}` : `NOT running (last exit ${sv.lastExit})`}${sv.logAgeMin != null ? ` · log written ${sv.logAgeMin} min ago` : ''}`);
|
|
48
|
+
if (r.services) for (const sv of r.services) out.push(`service ${sv.name ?? sv.label}: ${sv.crashing ? `CRASH-LOOPING: ${sv.restarts} restarts since the last reading, last exit ${sv.lastExit}, ${sv.runs} runs` : sv.running ? `running pid ${sv.pid}` : `NOT running (last exit ${sv.lastExit})`}${sv.logAgeMin != null ? ` · log written ${sv.logAgeMin} min ago` : ''}`);
|
|
45
49
|
if (r.supercode) {
|
|
46
50
|
const c = r.supercode;
|
|
47
51
|
out.push(`supercode connector ${c.connector ? `${c.connector.running ? 'running' : 'NOT running'}, server sees ${c.connector.connection ?? '-'}${c.connector.notSyncing ? `, ${c.connector.notSyncing} sessions not syncing (${c.connector.lastError})` : ''}` : (c.statusError ?? 'unknown')} · daemon ${c.daemon.answered ? `answered in ${c.daemon.ms} ms` : `NOT answering (${c.daemon.error})`} · launches ${c.launches.read ? `${c.launches.recent} in 24 h, ${c.launches.stuck.length} stuck, ${c.launches.failed.length} failed` : `unread (${c.launches.error})`}`);
|
package/lib/linux.mjs
CHANGED
|
@@ -135,11 +135,11 @@ export async function diskIO() {
|
|
|
135
135
|
export async function services(names = []) {
|
|
136
136
|
const map = new Map();
|
|
137
137
|
if (!names.length) return map;
|
|
138
|
-
const r = await run('systemctl', ['--user', 'show', '-p', 'Id,MainPID,ExecMainStatus,ActiveState', ...names], { timeoutMs: 5000 });
|
|
138
|
+
const r = await run('systemctl', ['--user', 'show', '-p', 'Id,MainPID,ExecMainStatus,ActiveState,NRestarts', ...names], { timeoutMs: 5000 });
|
|
139
139
|
if (!r.ok) throw new Error(r.error);
|
|
140
140
|
for (const block of r.stdout.split('\n\n')) {
|
|
141
141
|
const kv = Object.fromEntries(block.split('\n').filter(Boolean).map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]));
|
|
142
|
-
if (kv.Id) map.set(kv.Id.replace(/\.service$/, ''), { pid: Number(kv.MainPID) || null, lastExit: Number(kv.ExecMainStatus) || 0, active: kv.ActiveState });
|
|
142
|
+
if (kv.Id) map.set(kv.Id.replace(/\.service$/, ''), { pid: Number(kv.MainPID) || null, lastExit: Number(kv.ExecMainStatus) || 0, active: kv.ActiveState, runs: (Number(kv.NRestarts) || 0) + 1 });
|
|
143
143
|
}
|
|
144
144
|
return map;
|
|
145
145
|
}
|
package/lib/mail.mjs
CHANGED
|
@@ -1,50 +1,28 @@
|
|
|
1
|
-
// Alarm mail through supercode's
|
|
2
|
-
// `sc:<machine>:operator:machine-health
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// Alarm mail, filed in each subscriber's mailbox through supercode's filing door (`supercode message file`), from
|
|
2
|
+
// `sc:<machine>:operator:machine-health`, and delivered by the mailbox (docs/adr/0008-agent-mailbox.md, decision 17;
|
|
3
|
+
// D138 step 6). Filing is a local write: no door, no network, and nothing for the probe to retry. The mailbox resolves
|
|
4
|
+
// an agent address (`sc:<machine>:agent:<name>`) to the agent's main session when it delivers, retries within its
|
|
5
|
+
// bounds, and records what it could not deliver (`supercode message waiting`, `supercode message expired`).
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (Date.now() - item.at > 24 * 3_600_000) { this.items = this.items.filter((x) => x !== item); this.error = `dropped undelivered ${item.id} to ${item.to} after a day`; continue; }
|
|
18
|
-
try { await this.deliver(item); this.items = this.items.filter((x) => x !== item); this.error = null; }
|
|
19
|
-
catch (error) { item.tries += 1; this.error = `mail to ${item.to}: ${error.message}`; }
|
|
20
|
-
}
|
|
21
|
-
this.save();
|
|
22
|
-
return { pending: this.items.length, error: this.error };
|
|
23
|
-
}
|
|
24
|
-
deliver(item) {
|
|
25
|
-
return new Promise((resolve, reject) => {
|
|
26
|
-
const child = spawn(this.env.SUPERCODE_BIN || 'supercode', ['message', 'remote-deliver'], { env: this.env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: process.platform === 'win32' });
|
|
27
|
-
let detail = '';
|
|
28
|
-
const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('delivery timed out; kept for retry')); }, 30_000);
|
|
29
|
-
child.stdout.on('data', (c) => { detail = (detail + c).slice(-2000); });
|
|
30
|
-
child.stderr.on('data', (c) => { detail = (detail + c).slice(-2000); });
|
|
31
|
-
child.once('error', (error) => { clearTimeout(timer); reject(error); });
|
|
32
|
-
child.once('close', (code) => {
|
|
33
|
-
clearTimeout(timer);
|
|
34
|
-
let receipt; try { receipt = JSON.parse(detail.trim().split('\n').at(-1)); } catch { /* none */ }
|
|
35
|
-
if (code === 0 && receipt?.code === 0) resolve(receipt); else reject(new Error(receipt?.text || detail.trim().slice(-300) || `exit ${code}`));
|
|
36
|
-
});
|
|
37
|
-
child.stdin.on('error', () => {});
|
|
38
|
-
const board = item.to.split(':')[2] === 'board';
|
|
39
|
-
child.stdin.end(JSON.stringify({ op: 'send', from: `sc:${this.machine}:operator:machine-health`, from_name: 'Machine health', to: item.to, id: item.id, subject: item.subject, queue: board, body: item.body }));
|
|
40
|
-
});
|
|
8
|
+
/** File one alarm mail for each address. Answers what was filed and the first failure, if any. */
|
|
9
|
+
export function fileAlarm({ machine, env = process.env, to, key, subject, body }) {
|
|
10
|
+
const filed = [];
|
|
11
|
+
let error = null;
|
|
12
|
+
for (const address of to) {
|
|
13
|
+
const request = { from: `sc:${machine}:operator:machine-health`, from_name: 'Machine health', to: address, id: `${key}-${address}`, subject, body, wake: true };
|
|
14
|
+
const done = spawnSync(env.SUPERCODE_BIN || 'supercode', ['message', 'file'], { env, input: JSON.stringify(request), encoding: 'utf8', timeout: 10_000, windowsHide: true, shell: process.platform === 'win32' });
|
|
15
|
+
let answer = null; try { answer = JSON.parse(String(done.stdout ?? '').trim().split('\n').at(-1)); } catch { /* none */ }
|
|
16
|
+
if (answer?.code === 0) filed.push({ to: address, id: answer.message_id });
|
|
17
|
+
else error ??= `mail to ${address} was not filed: ${answer?.text || String(done.stderr ?? '').trim().slice(-300) || done.error?.message || `exit ${done.status}`}`;
|
|
41
18
|
}
|
|
19
|
+
return { filed, error };
|
|
42
20
|
}
|
|
43
21
|
|
|
44
22
|
const LEVEL = { warn: 'WARN', crit: 'CRIT', none: 'clear' };
|
|
45
23
|
function line(t) {
|
|
46
24
|
const d = t.detail;
|
|
47
|
-
const who = d?.session?.address ? `; session ${d.session.address}` : d?.session?.harness ? `; under ${d.session.harness} pid ${d.session.agentPid}` : d?.pid ? '; no agent session above it' : '';
|
|
25
|
+
const who = d?.session?.address ? `; session ${d.session.address}` : d?.session?.harness ? `; under ${d.session.harness} pid ${d.session.agentPid}` : d?.session?.note ? `; ${d.session.note}` : d?.session?.inherited ? `; no agent above it (environment inherited from ${d.session.inherited}, unverified)` : d?.pid ? '; no agent session above it' : '';
|
|
48
26
|
const proc = d?.pid ? ` [pid ${d.pid}: ${d.command}${who}]` : d?.listener ? ` [listener pid ${d.listener.pid}: ${d.listener.command}${d.listener.session?.address ? `; session ${d.listener.session.address}` : ''}]` : '';
|
|
49
27
|
return `${LEVEL[t.to] ?? t.to}${t.from !== 'none' && t.to !== 'none' ? ` (was ${t.from})` : ''} ${t.key}: ${t.summary}${proc}`;
|
|
50
28
|
}
|
package/lib/probe.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The probe: one per OS user, holding an exclusive pidfile. Every cycle it samples, judges each alarm,
|
|
2
|
-
// writes latest.json (what the doors read) and a minute history, and
|
|
2
|
+
// writes latest.json (what the doors read) and a minute history, and then files mail for the maintainers on a change
|
|
3
|
+
// (the mailbox delivers it; nothing the mail does can hold latest.json back).
|
|
3
4
|
// It decides nothing and changes nothing on the machine.
|
|
4
5
|
import { mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, appendFileSync, statSync, rmSync } from 'node:fs';
|
|
5
6
|
import { hostname } from 'node:os';
|
|
@@ -7,7 +8,7 @@ import { healthDir, files as healthFiles, machineName } from './paths.mjs';
|
|
|
7
8
|
import { DEFAULTS, merge, judge, raised } from './thresholds.mjs';
|
|
8
9
|
import { resolveFeatures } from './features.mjs';
|
|
9
10
|
import { Sampler } from './sample.mjs';
|
|
10
|
-
import {
|
|
11
|
+
import { fileAlarm, compose } from './mail.mjs';
|
|
11
12
|
import { run } from './run.mjs';
|
|
12
13
|
|
|
13
14
|
export async function platformReader(platform = process.platform) {
|
|
@@ -76,7 +77,10 @@ export class Probe {
|
|
|
76
77
|
this.config = loadConfig(this.f.config);
|
|
77
78
|
this.configMtime = this.#mtime(this.f.config);
|
|
78
79
|
this.sampler = new Sampler({ reader: this.reader, platform: this.platform, machine: this.machine, env: this.env, config: this.config });
|
|
79
|
-
|
|
80
|
+
// Alarm mail queued here by an earlier pack (outbox.json, retried per recipient until a day old) is not resent: a
|
|
81
|
+
// mail is a reading, superseded by the current one, and its recipients were often sessions that have ended. It is
|
|
82
|
+
// dropped, and every alarm raised now is mailed again at the first cycle, filed in the maintainers' mailboxes.
|
|
83
|
+
this.droppedOutbox = this.#dropOutbox();
|
|
80
84
|
this.extras = {
|
|
81
85
|
rusage: this.reader.rusageAvailable ? await this.reader.rusageAvailable() : false,
|
|
82
86
|
supercode: (await run(this.env.SUPERCODE_BIN || 'supercode', ['--version'], { timeoutMs: 10_000, env: this.env })).ok,
|
|
@@ -84,7 +88,18 @@ export class Probe {
|
|
|
84
88
|
const previous = readJson(this.f.latest, null);
|
|
85
89
|
// Alarms raised before a restart stay raised (and are not re-mailed); their windows restart honestly.
|
|
86
90
|
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
87
|
-
for (const a of (previous?.alarms ?? []).filter((x) => !x.detail?.pid || alive(x.detail.pid))) this.state[a.key] = { key: a.key, family: a.family, level: a.level, warnSince: null, critSince: null, raisedAt: a.since ? Date.parse(a.since) : Date.now(), lastMailAt: Date.now(), restoredUntil: Date.now() + 2 * 60_000, value: a.value, unit: a.unit, summary: a.summary, detail: a.detail };
|
|
91
|
+
for (const a of (previous?.alarms ?? []).filter((x) => !x.detail?.pid || alive(x.detail.pid))) this.state[a.key] = { key: a.key, family: a.family, level: a.level, warnSince: null, critSince: null, raisedAt: a.since ? Date.parse(a.since) : Date.now(), lastMailAt: this.droppedOutbox ? 0 : Date.now(), restoredUntil: Date.now() + 2 * 60_000, value: a.value, unit: a.unit, summary: a.summary, detail: a.detail };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Remove an earlier pack's outbox; answers how many mails it held, logged by recipient. */
|
|
95
|
+
#dropOutbox() {
|
|
96
|
+
const items = readJson(this.f.outbox, null);
|
|
97
|
+
if (!Array.isArray(items)) return 0;
|
|
98
|
+
const counts = {};
|
|
99
|
+
for (const item of items) counts[item?.to ?? '?'] = (counts[item?.to ?? '?'] ?? 0) + 1;
|
|
100
|
+
rmSync(this.f.outbox, { force: true });
|
|
101
|
+
this.log(`dropped ${items.length} undelivered alarm mail(s) an earlier pack queued (superseded by the current reading; the alarms raised now are mailed again): ${Object.entries(counts).map(([to, n]) => `${n} to ${to}`).join(', ') || 'none'}`);
|
|
102
|
+
return items.length;
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
#mtime(file) { try { return statSync(file).mtimeMs; } catch { return null; } }
|
|
@@ -109,10 +124,8 @@ export class Probe {
|
|
|
109
124
|
const mail = compose({ machine: this.machine, transitions, reminders, raised: alarms });
|
|
110
125
|
if (mail) {
|
|
111
126
|
for (const a of alarms) this.state[a.key].lastMailAt = now;
|
|
112
|
-
for (const address of to) this.outbox.enqueue(address, `health-${this.machine}-${now}-${address.split(':').at(-1).slice(0, 12)}`, mail.subject, mail.body);
|
|
113
127
|
this.log(`${mail.subject}${to.length ? '' : ' (no maintainer subscribed)'}\n${mail.body}`);
|
|
114
128
|
}
|
|
115
|
-
const delivery = this.outbox.items.length ? await this.outbox.flush() : { pending: 0, error: this.outbox.error };
|
|
116
129
|
const snapshot = {
|
|
117
130
|
version: 1, machine: this.machine, platform: this.platform, at: new Date(now).toISOString(),
|
|
118
131
|
probe: { pid: process.pid, intervalSec: this.config.intervalSec, slowIntervalSec: this.config.slowIntervalSec, sampleMs: this.sampleMs, user: this.env.USER ?? null, root: this.reader.isRoot?.() ?? false, config: this.config.source },
|
|
@@ -120,10 +133,18 @@ export class Probe {
|
|
|
120
133
|
alarms,
|
|
121
134
|
transitions: transitions.map((t) => ({ key: t.key, from: t.from, to: t.to, summary: t.summary })),
|
|
122
135
|
maintainers: to,
|
|
123
|
-
mail:
|
|
136
|
+
mail: this.lastMail ?? null,
|
|
124
137
|
readings,
|
|
125
138
|
};
|
|
139
|
+
// The reading is written first; the mail is filed after it, and what the filing did is written beside it.
|
|
126
140
|
writeAtomic(this.f.latest, snapshot);
|
|
141
|
+
if (mail && to.length) {
|
|
142
|
+
const result = fileAlarm({ machine: this.machine, env: this.env, to, key: `health-${this.machine}-${now}`, subject: mail.subject, body: mail.body });
|
|
143
|
+
this.lastMail = { at: new Date(now).toISOString(), filed: result.filed.length, to: result.filed.map((f) => f.to), error: result.error };
|
|
144
|
+
if (result.error) this.log(result.error);
|
|
145
|
+
snapshot.mail = this.lastMail;
|
|
146
|
+
writeAtomic(this.f.latest, snapshot);
|
|
147
|
+
}
|
|
127
148
|
if (now - this.lastHistory >= 60_000) { this.#history(snapshot, now); this.lastHistory = now; }
|
|
128
149
|
return snapshot;
|
|
129
150
|
}
|
package/lib/sample.mjs
CHANGED
|
@@ -18,6 +18,11 @@ const HARNESS = { claude: 'claude-code', codex: 'codex', gemini: 'gemini', goose
|
|
|
18
18
|
const RENDERER = /Helper \(Renderer\)|--type=renderer/;
|
|
19
19
|
const BROWSER_MAIN = /(Google Chrome|Chromium|chrome-headless-shell|chrome)(\s|$)/i;
|
|
20
20
|
const LOOPBACK = /^(127\.|::1$|localhost$)/;
|
|
21
|
+
/** The session an agent names on its own command line: `--resume <id>`, `--session-id <id>`, `-r <id>`, `codex resume <id>`. */
|
|
22
|
+
function argvSession(command) {
|
|
23
|
+
return /(?:--resume|--session-id|\s-r)[=\s]+([0-9a-f]{8}-[0-9a-f-]{27,})/.exec(command)?.[1]
|
|
24
|
+
?? /\bresume\s+([0-9a-zA-Z][0-9a-zA-Z-]{7,})/.exec(command)?.[1] ?? null;
|
|
25
|
+
}
|
|
21
26
|
// The slow set's readings and alarm families, by the feature that produces them.
|
|
22
27
|
const SLOW_FEATURE = { disk: 'disk', services: 'services', service: 'services', supercode: 'supercode', browserTabs: 'browser-tabs', tabs: 'browser-tabs', power: 'power' };
|
|
23
28
|
// Every alarm family, by the feature that observes it.
|
|
@@ -32,6 +37,8 @@ export class Sampler {
|
|
|
32
37
|
this.swapRing = []; // [[at, swapouts]] for the 30-minute sum
|
|
33
38
|
this.diskRing = new Map(); // mount → [[at, freeBytes]] for the fill rate
|
|
34
39
|
this.sessionCache = new Map(); // pid:command → session
|
|
40
|
+
this.serviceRuns = new Map(); // service label → spawn count at the last reading
|
|
41
|
+
this.recentListeners = new Map(); // port → when it was last seen listening (its TIME_WAITs outlive it)
|
|
35
42
|
this.topFoot = { at: 0, map: new Map() };
|
|
36
43
|
this.slow = { at: 0, readings: {}, observations: [], families: new Set(), errors: {} };
|
|
37
44
|
this.slowStarted = 0;
|
|
@@ -110,8 +117,11 @@ export class Sampler {
|
|
|
110
117
|
r.footprintSource = ru ? 'footprint' : top != null ? 'footprint (top)' : 'rss';
|
|
111
118
|
r.writeBps = same && dt && ru && prev.written != null && ru.diskWrittenBytes >= prev.written ? (ru.diskWrittenBytes - prev.written) / dt : null;
|
|
112
119
|
nextPrev.set(r.pid, { cpuSec: r.cpuSec, command: r.command, written: ru?.diskWrittenBytes ?? null });
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
// Growth compares like with like: a footprint read through a different instrument than the last reading
|
|
121
|
+
// (another user's process alternates between top's 5-minute map and RSS) starts a new history.
|
|
122
|
+
let hist = same ? (this.footHistory.get(r.pid) ?? []) : [];
|
|
123
|
+
if (hist.length && hist.at(-1)[2] !== r.footprintSource) hist = [];
|
|
124
|
+
hist.push([now, r.footprintBytes, r.footprintSource]);
|
|
115
125
|
while (hist.length && now - hist[0][0] > 130_000) hist.shift();
|
|
116
126
|
this.footHistory.set(r.pid, hist);
|
|
117
127
|
const ago = hist.findLast?.(([at]) => now - at >= 55_000) ?? [...hist].reverse().find(([at]) => now - at >= 55_000);
|
|
@@ -225,6 +235,10 @@ export class Sampler {
|
|
|
225
235
|
if (!['*', '0.0.0.0', '::', '[::]'].includes(s.local.addr)) local.add(s.local.addr);
|
|
226
236
|
}
|
|
227
237
|
const inRange = (port) => port >= range.first && port <= range.last;
|
|
238
|
+
for (const port of listening) this.recentListeners.set(port, now);
|
|
239
|
+
const hold = Math.max(4 * timeWaitSec * 1000, 120_000);
|
|
240
|
+
for (const [port, at] of this.recentListeners) if (now - at > hold) this.recentListeners.delete(port);
|
|
241
|
+
const wasListening = (port) => this.recentListeners.has(port);
|
|
228
242
|
const wildcard = new Set();
|
|
229
243
|
const used = new Map();
|
|
230
244
|
const add = (addr, port) => { if (!used.has(addr)) used.set(addr, new Set()); used.get(addr).add(port); };
|
|
@@ -236,9 +250,11 @@ export class Sampler {
|
|
|
236
250
|
if (local.has(s.foreign.addr) && inRange(s.foreign.port)) add(s.foreign.addr, s.foreign.port);
|
|
237
251
|
if (s.state !== 'TIME_WAIT') continue;
|
|
238
252
|
// Group each TIME_WAIT by the endpoint it churned against: a listener here (either side), else the remote.
|
|
253
|
+
// A listener that exited still owns its TIME_WAITs until they drain, so a port counts as a listener for
|
|
254
|
+
// a while after it was last seen listening; otherwise its alarm would clear while the sockets remain.
|
|
239
255
|
let target;
|
|
240
|
-
if (
|
|
241
|
-
else if ((local.has(s.foreign.addr) || LOOPBACK.test(s.foreign.addr)) &&
|
|
256
|
+
if (wasListening(s.local.port)) target = `:${s.local.port}`;
|
|
257
|
+
else if ((local.has(s.foreign.addr) || LOOPBACK.test(s.foreign.addr)) && wasListening(s.foreign.port)) target = `:${s.foreign.port}`;
|
|
242
258
|
else target = `${s.foreign.addr}:${s.foreign.port}`;
|
|
243
259
|
targets.set(target, (targets.get(target) ?? 0) + 1);
|
|
244
260
|
}
|
|
@@ -255,12 +271,13 @@ export class Sampler {
|
|
|
255
271
|
} else owners = this.owners ?? new Map();
|
|
256
272
|
const timeWaitByTarget = top.map(([target, count]) => {
|
|
257
273
|
const owner = target.startsWith(':') ? owners.get(Number(target.slice(1))) : null;
|
|
258
|
-
|
|
274
|
+
const exited = target.startsWith(':') && !listening.has(Number(target.slice(1)));
|
|
275
|
+
return { target, count, pct: round((count / size) * 100), ...(exited ? { exited: true } : {}), ...(owner ? { listener: { pid: owner.pid, command: short(owner.command) } } : {}) };
|
|
259
276
|
});
|
|
260
277
|
readings.tcp = { states, timeWait: states.TIME_WAIT ?? 0, range: { ...range, size }, timeWaitSec, ephemeral, timeWaitByTarget };
|
|
261
278
|
families.add('tcp');
|
|
262
279
|
for (const e of ephemeral) observations.push({ key: `tcp.ephemeral:${e.addr}`, family: 'tcp', def: alarm('tcp.ephemeral'), value: e.pct, unit: '% of ports', summary: `${e.used} of ${size} ephemeral ports in use on ${e.addr}` });
|
|
263
|
-
for (const t of timeWaitByTarget) observations.push({ key: `tcp.timeWaitTarget:${t.target}`, family: 'tcp', def, value: t.pct, unit: '% of ports', summary: `${t.count} TIME_WAIT against ${t.target.startsWith(':') ? `listener ${t.target}` : t.target}${t.listener ? ` (pid ${t.listener.pid})` : ''}; range ${size}, TIME_WAIT lasts ${timeWaitSec} s`, detail: t });
|
|
280
|
+
for (const t of timeWaitByTarget) observations.push({ key: `tcp.timeWaitTarget:${t.target}`, family: 'tcp', def, value: t.pct, unit: '% of ports', summary: `${t.count} TIME_WAIT against ${t.target.startsWith(':') ? `listener ${t.target}${t.exited ? ' (listener has exited; its TIME_WAITs are draining)' : ''}` : t.target}${t.listener ? ` (pid ${t.listener.pid})` : ''}; range ${size}, TIME_WAIT lasts ${timeWaitSec} s`, detail: t });
|
|
264
281
|
});
|
|
265
282
|
|
|
266
283
|
// ---- the slow set ---------------------------------------------------------------------------------
|
|
@@ -327,12 +344,18 @@ export class Sampler {
|
|
|
327
344
|
const log = s.log?.replace(/^~(?=\/)/, homedir());
|
|
328
345
|
if (log) try { logAgeMin = Math.max(0, round((now - statSync(log).mtimeMs) / 60_000)); } catch { logAgeMin = null; }
|
|
329
346
|
const running = Boolean(job?.pid);
|
|
330
|
-
|
|
331
|
-
|
|
347
|
+
// Down (ADR 0006): not loaded, not running, or failing. A KeepAlive job that crashes and is respawned
|
|
348
|
+
// always has a pid when read, so failing is runs rising since the last reading with a non-zero last exit.
|
|
349
|
+
const before = this.serviceRuns.get(s.label);
|
|
350
|
+
const restarts = job && before != null && job.runs >= before ? job.runs - before : 0;
|
|
351
|
+
if (job) this.serviceRuns.set(s.label, job.runs);
|
|
352
|
+
const crashing = Boolean(job && restarts > 0 && job.lastExit !== 0);
|
|
353
|
+
const down = !job || (!running && (job.lastExit !== 0 || s.keepAlive !== false)) || crashing;
|
|
354
|
+
const why = !job ? 'not loaded' : !running ? `not running (last exit ${job.lastExit})` : crashing ? `crash-looping: ${restarts} restart${restarts === 1 ? '' : 's'} since the last reading, last exit ${job.lastExit} (${job.runs} runs since loaded)` : null;
|
|
332
355
|
if (down) slow.observations.push({ key: `service.down:${s.label}`, family: 'service', def: alarm('service.down'), value: 1, met: { warn: true, crit: false, clears: false }, summary: `service ${s.name ?? s.label} is ${why}` });
|
|
333
356
|
else slow.observations.push({ key: `service.down:${s.label}`, family: 'service', def: alarm('service.down'), value: 0, met: { warn: false, crit: false, clears: true }, summary: `service ${s.name ?? s.label} running` });
|
|
334
357
|
if (s.log && s.maxSilentMin) slow.observations.push({ key: `service.deaf:${s.label}`, family: 'service', def: { warn: s.maxSilentMin, forSec: 0 }, value: logAgeMin ?? Infinity, unit: 'min silent', summary: logAgeMin == null ? `service ${s.name ?? s.label}: its log ${log} is missing` : `service ${s.name ?? s.label} has written nothing to its log for ${logAgeMin} min` });
|
|
335
|
-
return { label: s.label, name: s.name ?? null, running, pid: job?.pid ?? null, lastExit: job?.lastExit ?? null, ...(s.log ? { logAgeMin } : {}) };
|
|
358
|
+
return { label: s.label, name: s.name ?? null, running, pid: job?.pid ?? null, lastExit: job?.lastExit ?? null, runs: job?.runs ?? null, restarts, crashing, ...(s.log ? { logAgeMin } : {}) };
|
|
336
359
|
});
|
|
337
360
|
});
|
|
338
361
|
|
|
@@ -435,27 +458,44 @@ export class Sampler {
|
|
|
435
458
|
for (let i = 0; i < 40 && p > 1; i += 1) { const r = byPid.get(p); if (!r) break; out.push(r); p = r.ppid; }
|
|
436
459
|
return out;
|
|
437
460
|
};
|
|
461
|
+
// Who owns a process is the agent above it, named by the agent itself: the session id on its own command
|
|
462
|
+
// line (`--resume`/`--session-id`, `codex resume`), else the id it sets in its own children's environment.
|
|
463
|
+
// An environment is never trusted across a tmux server: every pane inherits the variables of whichever
|
|
464
|
+
// session started that server (measured 2026-10-02: one tmux server credited one Claude session with
|
|
465
|
+
// dozens of unrelated panes). Above no agent, an inherited id is reported as inherited, never as owner.
|
|
438
466
|
const need = new Set();
|
|
439
467
|
const plan = new Map();
|
|
468
|
+
const childOf = (pid) => this.procs.rows.find((x) => x.ppid === pid && !AGENT.test(base(x.command)));
|
|
440
469
|
for (const pid of wanted) {
|
|
441
470
|
const r = byPid.get(pid);
|
|
442
471
|
if (!r) continue;
|
|
443
472
|
const cacheKey = `${pid}:${r.command}`;
|
|
444
473
|
if (this.sessionCache.has(cacheKey)) { plan.set(pid, { cacheKey }); continue; }
|
|
445
474
|
const up = chain(pid);
|
|
446
|
-
const
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
475
|
+
const stop = up.findIndex((x) => AGENT.test(base(x.command)) || base(x.command) === 'tmux');
|
|
476
|
+
const top = stop >= 0 ? up[stop] : null;
|
|
477
|
+
const agent = top && AGENT.test(base(top.command)) ? top : null;
|
|
478
|
+
// The agent's direct child on this lineage carries the variables the agent set; for the agent itself, any child.
|
|
479
|
+
const witness = agent ? (stop > 0 ? up[stop - 1] : childOf(agent.pid)) : (top ? null : r);
|
|
480
|
+
if (witness) need.add(witness.pid);
|
|
481
|
+
plan.set(pid, { cacheKey, agent, witness, pane: Boolean(top && !agent) });
|
|
450
482
|
}
|
|
451
483
|
const env = need.size ? await this.#environments([...need]) : new Map();
|
|
452
484
|
for (const [pid, p] of plan) {
|
|
453
485
|
if (!this.sessionCache.has(p.cacheKey)) {
|
|
454
|
-
let
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
486
|
+
let session = null;
|
|
487
|
+
if (p.agent) {
|
|
488
|
+
const harness = HARNESS[base(p.agent.command)] ?? base(p.agent.command);
|
|
489
|
+
const seen = p.witness ? env.get(p.witness.pid) : null;
|
|
490
|
+
const id = argvSession(p.agent.command) ?? (harness === 'codex' ? seen?.codex : harness === 'claude-code' ? seen?.claude : null) ?? null;
|
|
491
|
+
session = { harness, id, address: id ? `sc:${this.machine}:${harness}:${id}` : null, agentPid: p.agent.pid };
|
|
492
|
+
} else if (p.pane) {
|
|
493
|
+
session = { harness: null, id: null, address: null, agentPid: null, note: 'in a tmux pane with no agent above it' };
|
|
494
|
+
} else {
|
|
495
|
+
const seen = p.witness ? env.get(p.witness.pid) : null;
|
|
496
|
+
const inherited = seen?.supercode ?? (seen?.claude ? `sc:${this.machine}:claude-code:${seen.claude}` : seen?.codex ? `sc:${this.machine}:codex:${seen.codex}` : null);
|
|
497
|
+
if (inherited) session = { harness: null, id: null, address: null, agentPid: null, inherited };
|
|
498
|
+
}
|
|
459
499
|
this.sessionCache.set(p.cacheKey, session);
|
|
460
500
|
if (this.sessionCache.size > 5000) this.sessionCache.delete(this.sessionCache.keys().next().value);
|
|
461
501
|
}
|
|
@@ -471,10 +511,7 @@ export class Sampler {
|
|
|
471
511
|
const supercode = /(?:^|\s|\0)SUPERCODE_CALLER=(sc:[^\s\0]+)/.exec(text)?.[1];
|
|
472
512
|
const claude = /(?:^|\s|\0)CLAUDE_CODE_SESSION_ID=([0-9a-f-]{36})/.exec(text)?.[1];
|
|
473
513
|
const codex = /(?:^|\s|\0)CODEX_THREAD_ID=([0-9a-zA-Z-]{8,})/.exec(text)?.[1];
|
|
474
|
-
|
|
475
|
-
if (codex) return { harness: 'codex', id: codex };
|
|
476
|
-
if (supercode) { const [, , harness, id] = supercode.split(':'); return { harness, id, address: supercode }; }
|
|
477
|
-
return null;
|
|
514
|
+
return supercode || claude || codex ? { supercode, claude, codex } : null;
|
|
478
515
|
};
|
|
479
516
|
if (this.platform === 'linux') {
|
|
480
517
|
for (const pid of pids) { try { const found = pick(readFileSync(`/proc/${pid}/environ`, 'utf8')); if (found) map.set(pid, found); } catch { /* another user's */ } }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-health",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|