@yemi33/minions 0.1.2225 → 0.1.2227
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/dashboard/js/refresh.js +11 -20
- package/dashboard/js/settings.js +9 -0
- package/engine/supervisor.js +144 -1
- package/package.json +1 -1
package/dashboard/js/refresh.js
CHANGED
|
@@ -162,7 +162,8 @@ const RENDER_VERSIONS = {
|
|
|
162
162
|
// cache-bust entry exists so future tick-driven code (or feature-flag-aware
|
|
163
163
|
// renderers) can call `_changed('settings', …)` and bump on visual revamps.
|
|
164
164
|
// Bumped to 2 by W-mpmwxkcn000646cc (left-rail tabbed Settings layout).
|
|
165
|
-
|
|
165
|
+
// Bumped to 3 by W-mqk2s8q1 (Advanced → Diagnostics: relocated the diag button).
|
|
166
|
+
settings: 3,
|
|
166
167
|
};
|
|
167
168
|
const _sectionCache = {};
|
|
168
169
|
const _lastValueByKey = {};
|
|
@@ -1383,25 +1384,15 @@ switchPage(currentPage);
|
|
|
1383
1384
|
|
|
1384
1385
|
window.MinionsRefresh = { refresh };
|
|
1385
1386
|
|
|
1386
|
-
// ── Refresh-diagnostic
|
|
1387
|
-
//
|
|
1388
|
-
//
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
chip.type = 'button';
|
|
1396
|
-
chip.textContent = 'diag';
|
|
1397
|
-
chip.title = 'Open dashboard refresh diagnostics';
|
|
1398
|
-
// Bottom-LEFT (not right) to avoid overlapping the Command Center Send
|
|
1399
|
-
// button when the CC drawer is open (the drawer covers the right edge).
|
|
1400
|
-
chip.style.cssText = 'position:fixed;left:12px;bottom:12px;z-index:9999;background:var(--surface,#222);color:var(--muted,#aaa);border:1px solid var(--border,#444);border-radius:10px;padding:3px 8px;font-size:var(--text-sm);font-family:inherit;cursor:pointer;opacity:0.75';
|
|
1401
|
-
chip.addEventListener('click', _openRefreshDiagModal);
|
|
1402
|
-
document.body.appendChild(chip);
|
|
1403
|
-
} catch { /* DOM may not be ready in unusual test embeds */ }
|
|
1404
|
-
})();
|
|
1387
|
+
// ── Refresh-diagnostic modal (W-mphejzx100081972) ─────────────────────────
|
|
1388
|
+
// The modal renders a table view of the ring buffer plus a "send to engine"
|
|
1389
|
+
// button that POSTs the snapshot to /api/diagnostics/refresh. Previously
|
|
1390
|
+
// opened from a floating bottom-left "diag" chip injected into document.body;
|
|
1391
|
+
// relocated into Settings → Advanced → Diagnostics (W-mqk2s8q1) so it lives
|
|
1392
|
+
// alongside the other diagnostic/admin controls instead of overlaying every
|
|
1393
|
+
// page. Exposed on window so the Settings pane's inline handler (which runs
|
|
1394
|
+
// in global scope) can invoke it.
|
|
1395
|
+
try { window._openRefreshDiagModal = _openRefreshDiagModal; } catch { /* non-browser */ }
|
|
1405
1396
|
|
|
1406
1397
|
function _openRefreshDiagModal() {
|
|
1407
1398
|
// Remove any prior instance so re-clicks re-render against the latest buffer.
|
package/dashboard/js/settings.js
CHANGED
|
@@ -521,6 +521,15 @@ async function openSettings() {
|
|
|
521
521
|
settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
|
|
522
522
|
settingsToggle('Disable model discovery', 'set-disableModelDiscovery', !!e.disableModelDiscovery, 'Skip /api/runtimes/<name>/models REST calls fleet-wide. Settings UI falls back to free-text.') +
|
|
523
523
|
settingsToggle('QA: dual-write JSON sidecars', 'set-qaDualWriteJson', e.qaDualWriteJson !== false, 'Keep mirroring qa-runs.json and qa-sessions.json on every mutation alongside the SQLite source of truth. Default ON. Disable only once external tooling has been migrated off the JSON files — disabling drops the rollback path back to the JSON store.') +
|
|
524
|
+
'</div>' +
|
|
525
|
+
// W-mqk2s8q1 — dashboard refresh diagnostics, relocated here from the
|
|
526
|
+
// floating bottom-left "diag" chip so it sits alongside the other
|
|
527
|
+
// diagnostic/admin controls. Opens the same ring-buffer modal defined in
|
|
528
|
+
// refresh.js (exposed as window._openRefreshDiagModal).
|
|
529
|
+
'<h4>Diagnostics</h4>' +
|
|
530
|
+
'<div data-search="diagnostics refresh diag ring buffer status poll etag">' +
|
|
531
|
+
'<button type="button" class="btn-add" onclick="window._openRefreshDiagModal && window._openRefreshDiagModal()">Open Refresh Diagnostics</button>' +
|
|
532
|
+
'<div style="font-size:var(--text-xs);color:var(--muted);margin-top:4px">Inspect the dashboard\'s /api/status refresh ring buffer (response status, ETag hits, render timings, per-section change flags) and optionally send a snapshot to the engine.</div>' +
|
|
524
533
|
'</div>';
|
|
525
534
|
|
|
526
535
|
// Section registry — order is intentional (Runtime + Auto-fix surface first
|
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.2227",
|
|
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"
|