@yemi33/minions 0.1.2225 → 0.1.2226
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/engine/supervisor.js +144 -1
- package/package.json +1 -1
package/engine/supervisor.js
CHANGED
|
@@ -27,7 +27,7 @@ require('./stdio-timestamps').installIfNotInstalled();
|
|
|
27
27
|
const fs = require('fs');
|
|
28
28
|
const path = require('path');
|
|
29
29
|
const os = require('os');
|
|
30
|
-
const { spawn, execSync } = require('child_process');
|
|
30
|
+
const { spawn, spawnSync, execSync } = require('child_process');
|
|
31
31
|
|
|
32
32
|
// Lazy path getters route through engine/shared.js when available so the
|
|
33
33
|
// supervisor's notion of "the engine dir" honors MINIONS_TEST_DIR (used by
|
|
@@ -128,6 +128,139 @@ function listeningPidsForPort(port) {
|
|
|
128
128
|
} catch { return []; }
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// Reap-before-respawn (supervisor respawn-storm fix).
|
|
132
|
+
//
|
|
133
|
+
// checkEngine/checkDashboard only spawn a replacement — they never killed the
|
|
134
|
+
// orphan. When the engine's liveness signal (control.json `pid`) goes stale
|
|
135
|
+
// because a transient EPERM blocked the heartbeat tmp->rename on Windows, the
|
|
136
|
+
// supervisor kept respawning engines WITHOUT reaping the dead-but-still-running
|
|
137
|
+
// ones. Each extra engine contends harder on control.json -> more EPERM -> the
|
|
138
|
+
// PID stays stale -> unbounded accumulation (observed: 36 engines + 41
|
|
139
|
+
// dashboards, dozens of console-window popups per minute). Reaping every stray
|
|
140
|
+
// process that runs THIS install's engine.js/dashboard.js before spawning the
|
|
141
|
+
// replacement bounds the live count to ~1: a persistent EPERM degrades into a
|
|
142
|
+
// slow 1-in-1-out restart cycle instead of a runaway storm.
|
|
143
|
+
//
|
|
144
|
+
// Only ever called from the respawn branch, i.e. AFTER we've already concluded
|
|
145
|
+
// the watched process is dead — so every match is by definition an orphan that
|
|
146
|
+
// is safe to kill. Scoped to MINIONS_DIR's script path so a second Minions
|
|
147
|
+
// checkout on the same machine is never touched. Opt out via
|
|
148
|
+
// MINIONS_SUPERVISOR_REAP=0.
|
|
149
|
+
const REAP_ENABLED = process.env.MINIONS_SUPERVISOR_REAP !== '0';
|
|
150
|
+
|
|
151
|
+
function _normPath(p) { return String(p).replace(/\\/g, '/').toLowerCase(); }
|
|
152
|
+
|
|
153
|
+
// Split a command line into argv tokens, honoring double-quote grouping
|
|
154
|
+
// (Windows CommandLine quotes any path containing spaces, e.g.
|
|
155
|
+
// `"C:\Program Files\nodejs\node.exe"`). We only need this accurate enough to
|
|
156
|
+
// tell which token is the *script* being executed.
|
|
157
|
+
function _tokenizeCmdline(cmdline) {
|
|
158
|
+
const toks = [];
|
|
159
|
+
let cur = '';
|
|
160
|
+
let inQuote = false;
|
|
161
|
+
for (const ch of String(cmdline)) {
|
|
162
|
+
if (ch === '"') { inQuote = !inQuote; continue; }
|
|
163
|
+
if (!inQuote && (ch === ' ' || ch === '\t')) { if (cur) { toks.push(cur); cur = ''; } continue; }
|
|
164
|
+
cur += ch;
|
|
165
|
+
}
|
|
166
|
+
if (cur) toks.push(cur);
|
|
167
|
+
return toks;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// True iff `cmdline` RUNS `target` as its script — not merely mentions it as an
|
|
171
|
+
// argument. The script is the first `.js` token after the node executable, so
|
|
172
|
+
// `node .../engine.js start` matches but `node eslint.js .../engine.js` (where
|
|
173
|
+
// engine.js is an argument to a different script) does NOT. Exact, normalized
|
|
174
|
+
// equality also rejects longer-name false positives (`.../engine.js.bak`,
|
|
175
|
+
// `.../engine.json`) that a plain substring `includes` would catch. `target` is
|
|
176
|
+
// already normalized by the caller.
|
|
177
|
+
//
|
|
178
|
+
// This is intentionally stricter than substring matching: every orphan the
|
|
179
|
+
// supervisor needs to reap was spawned by spawnEngine()/spawnDashboard() with a
|
|
180
|
+
// byte-identical `path.join(MINIONS_DIR, '<script>.js')`, so exact script-token
|
|
181
|
+
// match catches all of them while a foreign process that only *names* the path
|
|
182
|
+
// is left alone (fail-safe: never kill on a loose match).
|
|
183
|
+
function _cmdRunsScript(cmdline, target) {
|
|
184
|
+
const toks = _tokenizeCmdline(cmdline);
|
|
185
|
+
// toks[0] is the node executable; the script is the first later .js token.
|
|
186
|
+
for (let i = 1; i < toks.length; i++) {
|
|
187
|
+
const norm = _normPath(toks[i]);
|
|
188
|
+
if (norm.endsWith('.js')) return norm === target;
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// PIDs of `node` processes that RUN `needle` (a script path) as their script.
|
|
194
|
+
// Cross-platform: PowerShell CIM on Windows, `ps` elsewhere. Best effort —
|
|
195
|
+
// returns [] on any enumeration failure (fail-open: leak rather than risk
|
|
196
|
+
// killing the wrong process).
|
|
197
|
+
//
|
|
198
|
+
// The OS does the coarse filtering (WQL `CommandLine LIKE` / a `grep` on the
|
|
199
|
+
// script basename) so only candidate rows — not the machine's entire node/
|
|
200
|
+
// process table — cross into the JS heap; `_cmdRunsScript` then applies the
|
|
201
|
+
// exact script-position match. On a busy host (CI runners, many node procs)
|
|
202
|
+
// this keeps the buffered stdout to a handful of rows instead of megabytes.
|
|
203
|
+
// `maxBuffer` caps the read defensively in case the coarse filter still matches
|
|
204
|
+
// a lot. The basename is an internal constant (`engine.js`/`dashboard.js`), so
|
|
205
|
+
// it carries no shell/WQL-injection risk.
|
|
206
|
+
const _ENUM_MAX_BUFFER = 8 * 1024 * 1024;
|
|
207
|
+
function listNodePidsMatching(needle) {
|
|
208
|
+
const target = _normPath(needle);
|
|
209
|
+
const base = path.basename(needle); // coarse prefilter, e.g. 'engine.js'
|
|
210
|
+
const pids = [];
|
|
211
|
+
try {
|
|
212
|
+
let out = '';
|
|
213
|
+
if (isWin) {
|
|
214
|
+
// spawnSync (no shell) + -EncodedCommand sidesteps cmd.exe quoting — an
|
|
215
|
+
// inline `powershell -Command "... $(...) | ..."` via execSync gets its
|
|
216
|
+
// `$(...)`/`|` mangled by cmd.exe before PowerShell ever sees them.
|
|
217
|
+
const ps = `Get-CimInstance Win32_Process -Filter "name='node.exe' AND CommandLine LIKE '%${base}%'" | `
|
|
218
|
+
+ 'ForEach-Object { "$($_.ProcessId)|$($_.CommandLine)" }';
|
|
219
|
+
const res = spawnSync('powershell',
|
|
220
|
+
['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(ps, 'utf16le').toString('base64')],
|
|
221
|
+
{ encoding: 'utf8', timeout: 8000, windowsHide: true, maxBuffer: _ENUM_MAX_BUFFER });
|
|
222
|
+
out = (res && res.stdout) || '';
|
|
223
|
+
} else {
|
|
224
|
+
// grep prefilters to candidate rows; no match exits 1 -> execSync throws
|
|
225
|
+
// -> caught below -> [] (correct: nothing to reap).
|
|
226
|
+
out = execSync(`ps -eo pid=,args= | grep -iF -- '${base}'`,
|
|
227
|
+
{ encoding: 'utf8', timeout: 8000, maxBuffer: _ENUM_MAX_BUFFER });
|
|
228
|
+
}
|
|
229
|
+
for (const line of out.split('\n')) {
|
|
230
|
+
const trimmed = line.trim();
|
|
231
|
+
if (!trimmed) continue;
|
|
232
|
+
// Windows rows are "PID|<full command line>"; ps rows are "PID <args>".
|
|
233
|
+
const m = isWin ? trimmed.match(/^(\d+)\|(.*)$/) : trimmed.match(/^(\d+)\s+(.*)$/);
|
|
234
|
+
if (!m) continue;
|
|
235
|
+
const pid = Number(m[1]);
|
|
236
|
+
if (pid && _cmdRunsScript(m[2], target)) pids.push(pid);
|
|
237
|
+
}
|
|
238
|
+
} catch { /* enumeration failed — fail-open with whatever we collected */ }
|
|
239
|
+
return pids;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _killHard(pid) {
|
|
243
|
+
const shared = _sharedOrNull();
|
|
244
|
+
try {
|
|
245
|
+
if (shared && typeof shared.killImmediate === 'function') { shared.killImmediate({ pid }); return; }
|
|
246
|
+
} catch { /* fall through to platform kill */ }
|
|
247
|
+
try {
|
|
248
|
+
if (isWin) execSync(`taskkill /F /T /PID ${pid}`, { timeout: 5000, windowsHide: true, stdio: 'ignore' });
|
|
249
|
+
else process.kill(pid, 'SIGKILL');
|
|
250
|
+
} catch { /* already gone */ }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Kill every stray process running `scriptPath`, except this supervisor.
|
|
254
|
+
// Returns the number reaped. No-op when MINIONS_SUPERVISOR_REAP=0.
|
|
255
|
+
function reapStrayProcesses(scriptPath, label) {
|
|
256
|
+
if (!REAP_ENABLED) return 0;
|
|
257
|
+
const pids = listNodePidsMatching(scriptPath).filter(p => p !== process.pid);
|
|
258
|
+
if (!pids.length) return 0;
|
|
259
|
+
console.log(`[supervisor] Reaping ${pids.length} stray ${label} process(es) before respawn: ${pids.join(', ')}`);
|
|
260
|
+
for (const pid of pids) _killHard(pid);
|
|
261
|
+
return pids.length;
|
|
262
|
+
}
|
|
263
|
+
|
|
131
264
|
function openAppendFd(name) {
|
|
132
265
|
// Try to use the same append-fd helper the rest of the codebase uses, fall
|
|
133
266
|
// back to a plain fs.openSync(..., 'a') when it isn't available. We re-route
|
|
@@ -226,6 +359,9 @@ function checkEngine(now) {
|
|
|
226
359
|
}
|
|
227
360
|
|
|
228
361
|
console.log(`[supervisor] Engine PID ${control.pid || '(none)'} is dead — respawning...`);
|
|
362
|
+
// Reap any orphan engines first so a stale control.json PID can't snowball
|
|
363
|
+
// into an unbounded pile of contending engine processes.
|
|
364
|
+
reapStrayProcesses(path.join(MINIONS_DIR, 'engine.js'), 'engine');
|
|
229
365
|
const newPid = spawnEngine();
|
|
230
366
|
_lastEngineRespawnAt = now;
|
|
231
367
|
console.log(`[supervisor] Engine respawned (new PID: ${newPid})`);
|
|
@@ -238,6 +374,10 @@ function checkDashboard(now) {
|
|
|
238
374
|
if (pids.length > 0) return;
|
|
239
375
|
|
|
240
376
|
console.log(`[supervisor] Dashboard not listening on port ${dashPort} — respawning...`);
|
|
377
|
+
// Reap stray dashboards first. Besides bounding the count, this collapses the
|
|
378
|
+
// port-desync case: orphan dashboards holding other ports are killed, freeing
|
|
379
|
+
// the canonical port for the fresh one whose beacon _resolveDashPort() reads.
|
|
380
|
+
reapStrayProcesses(path.join(MINIONS_DIR, 'dashboard.js'), 'dashboard');
|
|
241
381
|
const newPid = spawnDashboard();
|
|
242
382
|
_lastDashboardRespawnAt = now;
|
|
243
383
|
console.log(`[supervisor] Dashboard respawned (new PID: ${newPid})`);
|
|
@@ -313,6 +453,9 @@ module.exports = {
|
|
|
313
453
|
isStopIntentSet,
|
|
314
454
|
isPidAlive,
|
|
315
455
|
listeningPidsForPort,
|
|
456
|
+
listNodePidsMatching,
|
|
457
|
+
reapStrayProcesses,
|
|
458
|
+
_cmdRunsScript,
|
|
316
459
|
openAppendFd,
|
|
317
460
|
checkEngine,
|
|
318
461
|
checkDashboard,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2226",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|