@yemi33/minions 0.1.1914 → 0.1.1916
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/render-prs.js +5 -6
- package/dashboard/js/settings.js +2 -6
- package/dashboard.js +27 -15
- package/docs/deprecated.json +19 -1
- package/engine/ado.js +1 -4
- package/engine/cleanup.js +46 -0
- package/engine/copilot-models.json +5 -0
- package/engine/github.js +1 -5
- package/engine/shared.js +184 -1
- package/engine/spawn-agent.js +39 -1
- package/engine.js +0 -1
- package/package.json +1 -1
|
@@ -10,12 +10,11 @@ function prRow(pr) {
|
|
|
10
10
|
// If PR is merged/abandoned, treat 'waiting' review as resolved
|
|
11
11
|
const effectiveReviewStatus = (pr.status === 'merged' || pr.status === 'abandoned') && pr.reviewStatus === 'waiting' ? (pr.status === 'merged' ? 'approved' : 'pending') : pr.reviewStatus;
|
|
12
12
|
const reviewSource = sq.status || effectiveReviewStatus || 'pending';
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const buildLabel = pr.buildFixEscalated ? 'escalated (' + (pr.buildFixAttempts || '?') + ' fixes)' : (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '');
|
|
13
|
+
const reviewClass = reviewSource === 'approved' ? 'approved' : (reviewSource === 'changes-requested' || reviewSource === 'rejected') ? 'rejected' : reviewSource === 'waiting' ? 'building' : 'draft';
|
|
14
|
+
const reviewLabel = sq.status === 'waiting' ? 'reviewing (minions)' : sq.status ? sq.status + ' (minions)' : (effectiveReviewStatus || 'pending');
|
|
15
|
+
const reviewTitle = '';
|
|
16
|
+
const buildClass = pr._buildStatusStale ? 'build-stale' : pr.buildStatus === 'passing' ? 'build-pass' : pr.buildStatus === 'failing' ? 'build-fail' : pr.buildStatus === 'running' ? 'building' : 'no-build';
|
|
17
|
+
const buildLabel = (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '');
|
|
19
18
|
const buildTitle = pr._buildStatusDetail || '';
|
|
20
19
|
const statusClass = pr.status === 'merged' ? 'merged' : pr.status === 'abandoned' ? 'rejected' : pr.status === 'active' ? 'active' : 'draft';
|
|
21
20
|
const statusLabel = pr.status || 'active';
|
package/dashboard/js/settings.js
CHANGED
|
@@ -97,6 +97,7 @@ async function openSettings() {
|
|
|
97
97
|
settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
|
|
98
98
|
settingsToggle('Auto-archive Plans', 'set-autoArchive', !!e.autoArchive, 'Automatically archive plans after verify completes (off = manual archive via dashboard)') +
|
|
99
99
|
settingsToggle('Auto-complete PRs', 'set-autoCompletePrs', !!e.autoCompletePrs, 'Auto-merge PRs when builds pass and review is approved (opt-in)') +
|
|
100
|
+
settingsToggle('CC Worker Pool', 'set-ccUseWorkerPool', !!e.ccUseWorkerPool, 'Route Command Center / doc-chat through a persistent copilot --acp worker per tab instead of spawning a fresh CLI per turn (opt-in)') +
|
|
100
101
|
'</div>' +
|
|
101
102
|
|
|
102
103
|
'<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">PR Polling & Dispatch Gates</h3>' +
|
|
@@ -121,9 +122,7 @@ async function openSettings() {
|
|
|
121
122
|
|
|
122
123
|
'<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Limits & Thresholds</h3>' +
|
|
123
124
|
'<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px">' +
|
|
124
|
-
settingsField('Eval Max Iterations', 'set-evalMaxIterations', e.evalMaxIterations || 3, '', 'Max review→fix cycles before escalating (1-10)') +
|
|
125
125
|
settingsField('Eval Max Cost', 'set-evalMaxCost', e.evalMaxCost === null || e.evalMaxCost === undefined ? '' : e.evalMaxCost, '$', 'USD ceiling per work item across all eval iterations (blank = no limit)') +
|
|
126
|
-
settingsField('Max Build Fix Attempts', 'set-maxBuildFixAttempts', e.maxBuildFixAttempts || 3, '', 'Max auto-fix dispatches per PR before escalating to human (1-10)') +
|
|
127
126
|
settingsField('Agent Busy Reassign', 'set-agentBusyReassignMs', e.agentBusyReassignMs || 600000, 'ms', 'Reassign work to another agent after it waits this long on a busy agent') +
|
|
128
127
|
settingsField('Version Check Interval', 'set-versionCheckInterval', e.versionCheckInterval || 3600000, 'ms', 'How often to check npm for updates (default: 1 hour)') +
|
|
129
128
|
settingsField('Ignored Comment Authors', 'set-ignoredCommentAuthors', (e.ignoredCommentAuthors || []).join(', '), '', 'Comma-separated usernames — comments auto-closed, never trigger fixes') +
|
|
@@ -230,7 +229,6 @@ async function openSettings() {
|
|
|
230
229
|
settingsToggle('Copilot: suppress AGENTS.md', 'set-copilotSuppressAgentsMd', e.copilotSuppressAgentsMd !== false, '--no-custom-instructions: stops AGENTS.md auto-load from fighting Minions playbook prompts') +
|
|
231
230
|
settingsToggle('Copilot: reasoning summaries', 'set-copilotReasoningSummaries', !!e.copilotReasoningSummaries, '--enable-reasoning-summaries (Anthropic-family models only)') +
|
|
232
231
|
settingsToggle('Disable model discovery', 'set-disableModelDiscovery', !!e.disableModelDiscovery, 'Skip /api/runtimes/<name>/models REST calls fleet-wide. Settings UI falls back to free-text.') +
|
|
233
|
-
settingsToggle('Use persistent Copilot worker pool (faster CC responses)', 'set-ccUseWorkerPool', !!e.ccUseWorkerPool, 'Experimental — sub-task C of W-mp2w003600196c51 (CC perf). When ON, Command Center routes through engine/cc-worker-pool.js (one persistent `copilot --acp` process per CC tab) instead of spawning a fresh CLI per turn. Saves ~14s of cold-start cost on warm follow-up turns. Engine/agent dispatch path is unchanged. Off by default.') +
|
|
234
232
|
'</div>' +
|
|
235
233
|
'<div style="display:grid;grid-template-columns:1fr 3fr;gap:8px;margin-top:8px">' +
|
|
236
234
|
'<div>' +
|
|
@@ -601,13 +599,12 @@ async function saveSettings() {
|
|
|
601
599
|
autoFixReviewFeedback: document.getElementById('set-autoFixReviewFeedback').checked,
|
|
602
600
|
autoFixHumanComments: document.getElementById('set-autoFixHumanComments').checked,
|
|
603
601
|
autoCompletePrs: document.getElementById('set-autoCompletePrs').checked,
|
|
602
|
+
ccUseWorkerPool: !!document.getElementById('set-ccUseWorkerPool')?.checked,
|
|
604
603
|
adoPollEnabled: document.getElementById('set-adoPollEnabled').checked,
|
|
605
604
|
ghPollEnabled: document.getElementById('set-ghPollEnabled').checked,
|
|
606
605
|
prPollStatusEvery: document.getElementById('set-prPollStatusEvery').value,
|
|
607
606
|
prPollCommentsEvery: document.getElementById('set-prPollCommentsEvery').value,
|
|
608
|
-
evalMaxIterations: document.getElementById('set-evalMaxIterations').value,
|
|
609
607
|
evalMaxCost: document.getElementById('set-evalMaxCost').value || null,
|
|
610
|
-
maxBuildFixAttempts: document.getElementById('set-maxBuildFixAttempts').value,
|
|
611
608
|
agentBusyReassignMs: document.getElementById('set-agentBusyReassignMs').value,
|
|
612
609
|
ignoredCommentAuthors: document.getElementById('set-ignoredCommentAuthors').value,
|
|
613
610
|
versionCheckInterval: document.getElementById('set-versionCheckInterval').value,
|
|
@@ -626,7 +623,6 @@ async function saveSettings() {
|
|
|
626
623
|
copilotReasoningSummaries: !!document.getElementById('set-copilotReasoningSummaries')?.checked,
|
|
627
624
|
maxBudgetUsd: (document.getElementById('set-maxBudgetUsd')?.value ?? '').trim(),
|
|
628
625
|
disableModelDiscovery: !!document.getElementById('set-disableModelDiscovery')?.checked,
|
|
629
|
-
ccUseWorkerPool: !!document.getElementById('set-ccUseWorkerPool')?.checked,
|
|
630
626
|
maxTurnsByType: (function() {
|
|
631
627
|
var mbt = {};
|
|
632
628
|
var types = ['explore', 'ask', 'review', 'implement', 'fix', 'test', 'verify', 'plan', 'decompose'];
|
package/dashboard.js
CHANGED
|
@@ -7941,32 +7941,44 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7941
7941
|
|
|
7942
7942
|
const pathname = req.url.split('?')[0];
|
|
7943
7943
|
const _reqStart = Date.now();
|
|
7944
|
+
// Wrap handler invocations so a thrown rejection never leaves the socket
|
|
7945
|
+
// half-open. Pre-fix, an async handler that threw after writeHead (or before
|
|
7946
|
+
// it could finalize) would leave the response without an end(), parking the
|
|
7947
|
+
// socket in CLOSE_WAIT on the server side indefinitely. Observed in the wild:
|
|
7948
|
+
// 150+ CLOSE_WAIT sockets pinned to port 7331 on a live dashboard.
|
|
7949
|
+
const _logIfApi = () => {
|
|
7950
|
+
if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream') && !pathname.includes('/browser-presence')) {
|
|
7951
|
+
console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
|
|
7952
|
+
}
|
|
7953
|
+
};
|
|
7954
|
+
const _runHandler = async (handler, matchArg) => {
|
|
7955
|
+
try {
|
|
7956
|
+
const _result = await handler(req, res, matchArg);
|
|
7957
|
+
_logIfApi();
|
|
7958
|
+
return _result;
|
|
7959
|
+
} catch (err) {
|
|
7960
|
+
console.error(` ${req.method} ${pathname} handler error:`, err && err.stack || err);
|
|
7961
|
+
_logIfApi();
|
|
7962
|
+
try {
|
|
7963
|
+
if (!res.headersSent) jsonReply(res, 500, { error: 'Internal server error' }, req);
|
|
7964
|
+
else if (!res.writableEnded) res.end();
|
|
7965
|
+
} catch { /* socket may already be torn down */ }
|
|
7966
|
+
}
|
|
7967
|
+
};
|
|
7944
7968
|
for (const route of ROUTES) {
|
|
7945
7969
|
if (route.method !== req.method) continue;
|
|
7946
7970
|
if (typeof route.path === 'string') {
|
|
7947
7971
|
// For /api/skill, match with query string prefix since it has no fixed path variant
|
|
7948
7972
|
if (route.path === '/api/skill') {
|
|
7949
7973
|
if (!req.url.startsWith('/api/skill?') && req.url !== '/api/skill') continue;
|
|
7950
|
-
|
|
7951
|
-
if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream') && !pathname.includes('/browser-presence')) {
|
|
7952
|
-
console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
|
|
7953
|
-
}
|
|
7954
|
-
return _result;
|
|
7974
|
+
return _runHandler(route.handler, {});
|
|
7955
7975
|
}
|
|
7956
7976
|
if (pathname !== route.path) continue;
|
|
7957
|
-
|
|
7958
|
-
if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream') && !pathname.includes('/browser-presence')) {
|
|
7959
|
-
console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
|
|
7960
|
-
}
|
|
7961
|
-
return _result;
|
|
7977
|
+
return _runHandler(route.handler, {});
|
|
7962
7978
|
}
|
|
7963
7979
|
const m = pathname.match(route.path);
|
|
7964
7980
|
if (m) {
|
|
7965
|
-
|
|
7966
|
-
if (pathname.startsWith('/api/') && !pathname.includes('/status') && !pathname.includes('/hot-reload') && !pathname.includes('/status-stream') && !pathname.includes('/browser-presence')) {
|
|
7967
|
-
console.log(` ${req.method} ${pathname} ${Date.now() - _reqStart}ms`);
|
|
7968
|
-
}
|
|
7969
|
-
return _result;
|
|
7981
|
+
return _runHandler(route.handler, m);
|
|
7970
7982
|
}
|
|
7971
7983
|
}
|
|
7972
7984
|
|
package/docs/deprecated.json
CHANGED
|
@@ -1 +1,19 @@
|
|
|
1
|
-
[
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "config-poll-key-migration",
|
|
4
|
+
"location": "engine/queries.js:123-162",
|
|
5
|
+
"function": "migrateDeprecatedConfigPollKeysOnce",
|
|
6
|
+
"reason": "One-time boot migration that renames legacy engine config keys (adoPollStatusEvery → prPollStatusEvery, adoPollCommentsEvery → prPollCommentsEvery). Keys were renamed on 2026-04-16; this shim ensures any persisted config.json on a long-lived host gets transparently rewritten on next boot.",
|
|
7
|
+
"targetRemovalDate": "2026-08-09",
|
|
8
|
+
"notes": "Safe to delete on or after 2026-08-09 (3 months after the rename) once we are confident every engine has rebooted at least once and the closure-flagged migration (_configPollKeyMigrationChecked at line 123) is no longer reachable on any persisted config. Removal is one function + the call site in getConfig() at engine/queries.js:165."
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": "legacy-done-aliases",
|
|
12
|
+
"location": "engine/cleanup.js:799-894",
|
|
13
|
+
"constants": ["LEGACY_DONE_ALIASES", "LEGACY_NEEDS_REVIEW_STATUS"],
|
|
14
|
+
"reason": "Read-side tolerance: cleanup sweep auto-migrates four obsolete work-item / PRD status strings ('in-pr', 'implemented', 'complete', 'needs-human-review') to the canonical 'done' / 'failed' values. The aliases are no longer written anywhere in the engine; the constants exist only to repair stale on-disk values from old engine versions.",
|
|
15
|
+
"targetRemovalDate": null,
|
|
16
|
+
"notes": "Keep indefinitely until telemetry / a sweep log shows zero migrations performed for 30 consecutive days across all known projects (work-items.json + prd/*.json). At that point the constants and both _migrateLegacyItem branches in engine/cleanup.js (definitions at :799-800; usage at :803-815 for work items and :880-887 for PRD missing_features) can be deleted. Total cost on disk today: 4 strings."
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
|
package/engine/ado.js
CHANGED
|
@@ -714,7 +714,6 @@ async function pollPrStatus(config) {
|
|
|
714
714
|
delete pr._buildStatusStale;
|
|
715
715
|
delete pr._buildStatusDetail;
|
|
716
716
|
delete pr.buildFixAttempts;
|
|
717
|
-
delete pr.buildFixEscalated;
|
|
718
717
|
}
|
|
719
718
|
// Cancel any pending review/fix dispatches — they're stale now that the PR is closed
|
|
720
719
|
try {
|
|
@@ -862,8 +861,6 @@ async function pollPrStatus(config) {
|
|
|
862
861
|
updated = true;
|
|
863
862
|
shared.trackReviewMetric(pr, newReviewStatus, config);
|
|
864
863
|
if (newReviewStatus === 'approved') {
|
|
865
|
-
delete pr._reviewFixCycles;
|
|
866
|
-
delete pr._evalEscalated;
|
|
867
864
|
// Teams notification for PR approval — non-blocking, edge-triggered (only on transition)
|
|
868
865
|
try {
|
|
869
866
|
const teams = require('./teams');
|
|
@@ -975,7 +972,7 @@ async function pollPrStatus(config) {
|
|
|
975
972
|
delete pr.buildErrorLog;
|
|
976
973
|
delete pr.buildFailureSignature;
|
|
977
974
|
// Reset build fix retry counter on recovery — allows fresh auto-fix cycles if build breaks again
|
|
978
|
-
if (pr.buildFixAttempts) { delete pr.buildFixAttempts;
|
|
975
|
+
if (pr.buildFixAttempts) { delete pr.buildFixAttempts; }
|
|
979
976
|
}
|
|
980
977
|
}
|
|
981
978
|
updated = true;
|
package/engine/cleanup.js
CHANGED
|
@@ -659,6 +659,52 @@ async function runCleanup(config, verbose = false) {
|
|
|
659
659
|
}
|
|
660
660
|
} catch (e) { log('warn', 'cleanup zombie processes: ' + e.message); }
|
|
661
661
|
|
|
662
|
+
// 4b. Sweep stray MCP descendants. Spawn-agent.js reaps its descendants on
|
|
663
|
+
// close, but a hard kill (engine restart, OS crash) can skip that path and
|
|
664
|
+
// leave orphan node.exe processes running MCP servers (Azure, Playwright,
|
|
665
|
+
// Loop, etc.) reparented away from the Minions tree. This sweep walks
|
|
666
|
+
// descendants from every anchor we know (engine, dashboard, active spawn-
|
|
667
|
+
// agents) and kills any node MCP processes outside that set.
|
|
668
|
+
cleaned.strayMcps = 0;
|
|
669
|
+
try {
|
|
670
|
+
const allProcs = shared.listAllProcesses();
|
|
671
|
+
if (allProcs.length) {
|
|
672
|
+
const anchorPids = [process.pid];
|
|
673
|
+
for (const info of activeProcesses.values()) {
|
|
674
|
+
if (info?.proc?.pid) anchorPids.push(info.proc.pid);
|
|
675
|
+
}
|
|
676
|
+
// Dashboard runs as a sibling process (not parented to engine on Windows)
|
|
677
|
+
// and hosts Command Center + doc-chat sessions whose Claude CLI children
|
|
678
|
+
// spawn their own MCP servers. Without anchoring it, the sweep would
|
|
679
|
+
// classify those MCP grandchildren as stray and kill live CC state on
|
|
680
|
+
// every cleanup tick. control.json doesn't carry a dashboardPid field,
|
|
681
|
+
// so we anchor by scanning the snapshot we already have for any node
|
|
682
|
+
// running dashboard.js.
|
|
683
|
+
for (const p of allProcs) {
|
|
684
|
+
if (p.cmd && /[\\/]dashboard\.js(?![\w.-])/i.test(p.cmd)) anchorPids.push(p.pid);
|
|
685
|
+
}
|
|
686
|
+
const reach = shared.listProcessReachable(anchorPids, allProcs);
|
|
687
|
+
// MCP server commandlines. Matches scoped (@modelcontextprotocol/*,
|
|
688
|
+
// @<scope>/mcp[-*]) and flat (mcp-server-*, *-mcp-server,
|
|
689
|
+
// loop-mcp-server) package paths. Specifically does NOT match
|
|
690
|
+
// @github/copilot (the Copilot CLI itself) — that would false-positive
|
|
691
|
+
// and kill a user's interactive `copilot` in another terminal, since
|
|
692
|
+
// its parent chain has no Minions ancestor and so isn't in `reach`.
|
|
693
|
+
const mcpRe = /[\\/](?:@modelcontextprotocol[\\/]|@[\w.-]+[\\/]mcp(?:[\\/]|-[\w-]*[\\/])|mcp-server-[\w-]+|[\w-]+-mcp-server|(?:ms-)?loop-mcp-server)[\\/]?/i;
|
|
694
|
+
const strayPids = [];
|
|
695
|
+
for (const p of allProcs) {
|
|
696
|
+
if (!p.cmd || !/node(?:\.exe)?\b/i.test(p.name)) continue;
|
|
697
|
+
if (!mcpRe.test(p.cmd)) continue;
|
|
698
|
+
if (reach.has(p.pid)) continue;
|
|
699
|
+
strayPids.push(p.pid);
|
|
700
|
+
}
|
|
701
|
+
if (strayPids.length) {
|
|
702
|
+
cleaned.strayMcps = shared.killByPidsImmediate(strayPids);
|
|
703
|
+
log('info', `Cleanup: ${cleaned.strayMcps}/${strayPids.length} stray MCP descendants reaped`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
} catch (e) { log('warn', 'cleanup stray MCPs: ' + e.message); }
|
|
707
|
+
|
|
662
708
|
// 5. Clean spawn-debug.log
|
|
663
709
|
try { fs.unlinkSync(path.join(ENGINE_DIR, 'spawn-debug.log')); } catch { /* cleanup */ }
|
|
664
710
|
|
package/engine/github.js
CHANGED
|
@@ -592,7 +592,6 @@ async function pollPrStatus(config) {
|
|
|
592
592
|
delete pr.buildFailureSignature;
|
|
593
593
|
delete pr._buildFailNotified;
|
|
594
594
|
delete pr.buildFixAttempts;
|
|
595
|
-
delete pr.buildFixEscalated;
|
|
596
595
|
}
|
|
597
596
|
// Cancel any pending review/fix dispatches — they're stale now that the PR is closed
|
|
598
597
|
try {
|
|
@@ -647,10 +646,7 @@ async function pollPrStatus(config) {
|
|
|
647
646
|
pr.reviewStatus = newReviewStatus;
|
|
648
647
|
updated = true;
|
|
649
648
|
shared.trackReviewMetric(pr, newReviewStatus, config);
|
|
650
|
-
// Reset review→fix cycle counter on approval (loop succeeded)
|
|
651
649
|
if (newReviewStatus === 'approved') {
|
|
652
|
-
delete pr._reviewFixCycles;
|
|
653
|
-
delete pr._evalEscalated;
|
|
654
650
|
// Teams notification for PR approval — non-blocking, edge-triggered (only on transition)
|
|
655
651
|
try {
|
|
656
652
|
const teams = require('./teams');
|
|
@@ -717,7 +713,7 @@ async function pollPrStatus(config) {
|
|
|
717
713
|
delete pr.buildErrorLog;
|
|
718
714
|
delete pr.buildFailureSignature;
|
|
719
715
|
// Reset build fix retry counter on recovery — allows fresh auto-fix cycles if build breaks again
|
|
720
|
-
if (pr.buildFixAttempts) { delete pr.buildFixAttempts;
|
|
716
|
+
if (pr.buildFixAttempts) { delete pr.buildFixAttempts; }
|
|
721
717
|
}
|
|
722
718
|
}
|
|
723
719
|
updated = true;
|
package/engine/shared.js
CHANGED
|
@@ -3146,6 +3146,185 @@ function killImmediate(proc) {
|
|
|
3146
3146
|
}
|
|
3147
3147
|
}
|
|
3148
3148
|
|
|
3149
|
+
// Single-PID kill (no /T tree walk) — used by the orphan-MCP sweep where we
|
|
3150
|
+
// already enumerated descendants ourselves and the parent is dead, so /T would
|
|
3151
|
+
// be a no-op anyway.
|
|
3152
|
+
function killByPidImmediate(pid) {
|
|
3153
|
+
const n = Number(pid);
|
|
3154
|
+
if (!Number.isInteger(n) || n <= 0 || n === process.pid) return false;
|
|
3155
|
+
if (process.platform === 'win32') {
|
|
3156
|
+
try { _execSync(`taskkill /PID ${n} /F`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); return true; }
|
|
3157
|
+
catch { return false; }
|
|
3158
|
+
}
|
|
3159
|
+
try { process.kill(n, 'SIGKILL'); return true; } catch { return false; }
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
// Batched kill — one OS process for N PIDs. `taskkill` accepts repeated /PID
|
|
3163
|
+
// flags natively; on Unix we still loop process.kill, which is in-process and
|
|
3164
|
+
// cheap. Returns the count of successful kills.
|
|
3165
|
+
function killByPidsImmediate(pids) {
|
|
3166
|
+
const valid = (Array.isArray(pids) ? pids : [])
|
|
3167
|
+
.map(Number)
|
|
3168
|
+
.filter(n => Number.isInteger(n) && n > 0 && n !== process.pid);
|
|
3169
|
+
if (!valid.length) return 0;
|
|
3170
|
+
if (process.platform === 'win32') {
|
|
3171
|
+
const flags = valid.map(p => `/PID ${p}`).join(' ');
|
|
3172
|
+
try { _execSync(`taskkill /F ${flags}`, { stdio: 'pipe', timeout: 5000, windowsHide: true }); return valid.length; }
|
|
3173
|
+
catch {
|
|
3174
|
+
let killed = 0;
|
|
3175
|
+
for (const p of valid) { if (killByPidImmediate(p)) killed++; }
|
|
3176
|
+
return killed;
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
let killed = 0;
|
|
3180
|
+
for (const p of valid) {
|
|
3181
|
+
try { process.kill(p, 'SIGKILL'); killed++; } catch { /* dead */ }
|
|
3182
|
+
}
|
|
3183
|
+
return killed;
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
// ─── Process Table Enumeration ───────────────────────────────────────────────
|
|
3187
|
+
// Cross-platform listing of every live process as { pid, ppid, name, cmd? }.
|
|
3188
|
+
// `cmd` is best-effort — included on Windows (via wmic) and Linux (/proc); may
|
|
3189
|
+
// be empty on macOS without ps -ww.
|
|
3190
|
+
|
|
3191
|
+
function _parseWmicCsv(text) {
|
|
3192
|
+
const lines = String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
3193
|
+
if (lines.length < 2) return [];
|
|
3194
|
+
const header = lines[0].split(',');
|
|
3195
|
+
const idx = (col) => header.findIndex(h => h.toLowerCase() === col.toLowerCase());
|
|
3196
|
+
const nameIdx = idx('Name');
|
|
3197
|
+
const ppidIdx = idx('ParentProcessId');
|
|
3198
|
+
const pidIdx = idx('ProcessId');
|
|
3199
|
+
const cmdIdx = idx('CommandLine');
|
|
3200
|
+
if (nameIdx < 0 || ppidIdx < 0 || pidIdx < 0) return [];
|
|
3201
|
+
const out = [];
|
|
3202
|
+
for (let i = 1; i < lines.length; i++) {
|
|
3203
|
+
// CommandLine can contain commas — naive split is fine because Name + PIDs
|
|
3204
|
+
// sit at fixed positions and CommandLine, when present, is the LAST column.
|
|
3205
|
+
const cols = lines[i].split(',');
|
|
3206
|
+
if (cols.length < 4) continue;
|
|
3207
|
+
const pid = parseInt(cols[pidIdx], 10);
|
|
3208
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
3209
|
+
const ppid = parseInt(cols[ppidIdx], 10);
|
|
3210
|
+
const name = cols[nameIdx] || '';
|
|
3211
|
+
const cmd = cmdIdx >= 0 ? cols.slice(cmdIdx).join(',') : '';
|
|
3212
|
+
out.push({ pid, ppid: Number.isInteger(ppid) ? ppid : 0, name, cmd });
|
|
3213
|
+
}
|
|
3214
|
+
return out;
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
// PowerShell Get-CimInstance is the modern path (wmic is removed on Win11
|
|
3218
|
+
// 24H2+). We try it first and fall back to wmic for older Windows hosts.
|
|
3219
|
+
function _psListProcesses() {
|
|
3220
|
+
const script = "Get-CimInstance Win32_Process | Select-Object Name,ParentProcessId,ProcessId,CommandLine | ConvertTo-Json -Compress -Depth 2";
|
|
3221
|
+
try {
|
|
3222
|
+
const out = _execSync(
|
|
3223
|
+
`powershell -NoProfile -NonInteractive -Command "${script}"`,
|
|
3224
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
|
|
3225
|
+
);
|
|
3226
|
+
const parsed = JSON.parse(out);
|
|
3227
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
3228
|
+
return arr.map(p => ({
|
|
3229
|
+
pid: Number(p.ProcessId),
|
|
3230
|
+
ppid: Number(p.ParentProcessId) || 0,
|
|
3231
|
+
name: p.Name || '',
|
|
3232
|
+
cmd: p.CommandLine || '',
|
|
3233
|
+
})).filter(p => Number.isInteger(p.pid) && p.pid > 0);
|
|
3234
|
+
} catch { return null; }
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
function _winListProcesses() {
|
|
3238
|
+
const ps = _psListProcesses();
|
|
3239
|
+
if (ps && ps.length) return ps;
|
|
3240
|
+
try {
|
|
3241
|
+
const out = _execSync(
|
|
3242
|
+
'wmic process get Name,ParentProcessId,ProcessId,CommandLine /format:csv',
|
|
3243
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
|
|
3244
|
+
);
|
|
3245
|
+
return _parseWmicCsv(out);
|
|
3246
|
+
} catch { return []; }
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
function _unixListProcesses() {
|
|
3250
|
+
try {
|
|
3251
|
+
const out = _execSync(
|
|
3252
|
+
'ps -A -o pid=,ppid=,comm=',
|
|
3253
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }
|
|
3254
|
+
);
|
|
3255
|
+
return out.split(/\r?\n/).map(line => {
|
|
3256
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
3257
|
+
if (!m) return null;
|
|
3258
|
+
return { pid: parseInt(m[1], 10), ppid: parseInt(m[2], 10), name: m[3], cmd: '' };
|
|
3259
|
+
}).filter(Boolean);
|
|
3260
|
+
} catch { return []; }
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
function listAllProcesses() {
|
|
3264
|
+
return process.platform === 'win32' ? _winListProcesses() : _unixListProcesses();
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
function _buildChildMap(processes) {
|
|
3268
|
+
const childMap = new Map();
|
|
3269
|
+
for (const p of processes) {
|
|
3270
|
+
if (!childMap.has(p.ppid)) childMap.set(p.ppid, []);
|
|
3271
|
+
childMap.get(p.ppid).push(p.pid);
|
|
3272
|
+
}
|
|
3273
|
+
return childMap;
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
// BFS descendants of rootPid given a process snapshot. `allProcesses` is
|
|
3277
|
+
// injectable for tests and for amortizing one snapshot across multiple
|
|
3278
|
+
// `listProcessDescendants` calls in the same tick.
|
|
3279
|
+
function listProcessDescendants(rootPid, allProcesses = null) {
|
|
3280
|
+
const root = Number(rootPid);
|
|
3281
|
+
if (!Number.isInteger(root) || root <= 0) return [];
|
|
3282
|
+
const procs = Array.isArray(allProcesses) ? allProcesses : listAllProcesses();
|
|
3283
|
+
const childMap = _buildChildMap(procs);
|
|
3284
|
+
const result = [];
|
|
3285
|
+
const seen = new Set([root]);
|
|
3286
|
+
const queue = [root];
|
|
3287
|
+
while (queue.length) {
|
|
3288
|
+
const cur = queue.shift();
|
|
3289
|
+
const children = childMap.get(cur) || [];
|
|
3290
|
+
for (const c of children) {
|
|
3291
|
+
if (seen.has(c)) continue;
|
|
3292
|
+
seen.add(c);
|
|
3293
|
+
result.push(c);
|
|
3294
|
+
queue.push(c);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
return result;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
// Same BFS, but starts from any number of root PIDs and returns the full reach
|
|
3301
|
+
// set (including the roots themselves). Used by the orphan-MCP sweep to compute
|
|
3302
|
+
// "everything anchored to Minions" so non-anchored MCP node processes can be
|
|
3303
|
+
// identified.
|
|
3304
|
+
function listProcessReachable(rootPids, allProcesses = null) {
|
|
3305
|
+
const roots = (Array.isArray(rootPids) ? rootPids : [rootPids])
|
|
3306
|
+
.map(Number)
|
|
3307
|
+
.filter(n => Number.isInteger(n) && n > 0);
|
|
3308
|
+
if (!roots.length) return new Set();
|
|
3309
|
+
const procs = Array.isArray(allProcesses) ? allProcesses : listAllProcesses();
|
|
3310
|
+
const childMap = _buildChildMap(procs);
|
|
3311
|
+
const seen = new Set();
|
|
3312
|
+
const queue = [];
|
|
3313
|
+
for (const r of roots) {
|
|
3314
|
+
if (!seen.has(r)) { seen.add(r); queue.push(r); }
|
|
3315
|
+
}
|
|
3316
|
+
while (queue.length) {
|
|
3317
|
+
const cur = queue.shift();
|
|
3318
|
+
const children = childMap.get(cur) || [];
|
|
3319
|
+
for (const c of children) {
|
|
3320
|
+
if (seen.has(c)) continue;
|
|
3321
|
+
seen.add(c);
|
|
3322
|
+
queue.push(c);
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
return seen;
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3149
3328
|
// ─── Work Items & Pull Requests Mutation Helpers ────────────────────────────
|
|
3150
3329
|
|
|
3151
3330
|
/**
|
|
@@ -3604,6 +3783,11 @@ module.exports = {
|
|
|
3604
3783
|
sleepMs,
|
|
3605
3784
|
killGracefully,
|
|
3606
3785
|
killImmediate,
|
|
3786
|
+
killByPidImmediate,
|
|
3787
|
+
killByPidsImmediate,
|
|
3788
|
+
listAllProcesses,
|
|
3789
|
+
listProcessDescendants,
|
|
3790
|
+
listProcessReachable,
|
|
3607
3791
|
removeWorktree,
|
|
3608
3792
|
_purgeReservedFiles, // exported for testing
|
|
3609
3793
|
_WIN_RESERVED_NAMES, // exported for testing
|
|
@@ -3611,7 +3795,6 @@ module.exports = {
|
|
|
3611
3795
|
flushLogs,
|
|
3612
3796
|
redactSecrets,
|
|
3613
3797
|
slugify,
|
|
3614
|
-
safeSlugComponent,
|
|
3615
3798
|
getPrAutomationCauses,
|
|
3616
3799
|
hasPrAutomationCause,
|
|
3617
3800
|
markPrAutomationCause,
|
package/engine/spawn-agent.js
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
const fs = require('fs');
|
|
36
36
|
const os = require('os');
|
|
37
37
|
const path = require('path');
|
|
38
|
-
const { runFile, cleanChildEnv, killGracefully, killImmediate, ts, resolveEngineCacheDir } = require('./shared');
|
|
38
|
+
const { runFile, cleanChildEnv, killGracefully, killImmediate, killByPidsImmediate, listProcessDescendants, ts, resolveEngineCacheDir } = require('./shared');
|
|
39
39
|
const { resolveRuntime } = require('./runtimes');
|
|
40
40
|
const { acquireAdoTokenSync, isLikelyAdoToken } = require('./ado-token');
|
|
41
41
|
|
|
@@ -484,6 +484,32 @@ function main() {
|
|
|
484
484
|
}, MCP_STARTUP_TIMEOUT);
|
|
485
485
|
proc.stdout.once('data', () => { gotFirstOutput = true; clearTimeout(startupTimer); });
|
|
486
486
|
|
|
487
|
+
// Descendant snapshot loop (orphan-MCP fix). The runtime CLI (claude/copilot)
|
|
488
|
+
// spawns one node child per configured MCP server (Azure, Playwright, Loop,
|
|
489
|
+
// …). When the runtime exits normally on Windows, those grandchildren are
|
|
490
|
+
// reparented and survive indefinitely — they don't watch stdin for EOF, and
|
|
491
|
+
// taskkill /T against the dead parent PID no longer finds them. We snapshot
|
|
492
|
+
// the descendant tree periodically while the runtime is alive so the close
|
|
493
|
+
// handler can reap any survivors by PID after the parent has gone.
|
|
494
|
+
const trackedDescendants = new Set();
|
|
495
|
+
let snapshotInFlight = false;
|
|
496
|
+
function snapshotDescendants() {
|
|
497
|
+
if (!proc.pid || snapshotInFlight) return;
|
|
498
|
+
snapshotInFlight = true;
|
|
499
|
+
try {
|
|
500
|
+
for (const pid of listProcessDescendants(proc.pid)) trackedDescendants.add(pid);
|
|
501
|
+
} catch { /* best-effort */ }
|
|
502
|
+
finally { snapshotInFlight = false; }
|
|
503
|
+
}
|
|
504
|
+
// Initial snapshot is deferred: MCP children spawn ~1-3s after runtime boot,
|
|
505
|
+
// and a 0ms snapshot would always be empty. Recurring snapshots run every
|
|
506
|
+
// 30s — Get-CimInstance enumerates every process on the box, so anything
|
|
507
|
+
// hotter starves the cleanup tick with N concurrent agents.
|
|
508
|
+
const initialSnapshotTimer = setTimeout(snapshotDescendants, 3000);
|
|
509
|
+
if (initialSnapshotTimer.unref) initialSnapshotTimer.unref();
|
|
510
|
+
const descTimer = setInterval(snapshotDescendants, 30000);
|
|
511
|
+
if (descTimer.unref) descTimer.unref();
|
|
512
|
+
|
|
487
513
|
// Track the real OS exit code via the 'exit' event. Node's 'close' event
|
|
488
514
|
// can report code=0 on Windows when the OS-level exit was non-zero
|
|
489
515
|
// (observed empirically with both Claude Code CLI and Copilot CLI exiting
|
|
@@ -500,6 +526,18 @@ function main() {
|
|
|
500
526
|
});
|
|
501
527
|
proc.on('close', (code, signal) => {
|
|
502
528
|
clearTimeout(startupTimer);
|
|
529
|
+
clearTimeout(initialSnapshotTimer);
|
|
530
|
+
clearInterval(descTimer);
|
|
531
|
+
// Final snapshot + reap, but only when the runtime actually spawned
|
|
532
|
+
// children. Read-only / very short agents (exit before the 3s initial
|
|
533
|
+
// snapshot fires) skip the wmic shell-out entirely.
|
|
534
|
+
if (trackedDescendants.size || gotFirstOutput) {
|
|
535
|
+
snapshotDescendants();
|
|
536
|
+
if (trackedDescendants.size) {
|
|
537
|
+
const reaped = killByPidsImmediate([...trackedDescendants]);
|
|
538
|
+
try { fs.appendFileSync(debugPath, `DESCENDANTS reaped=${reaped}/${trackedDescendants.size}\n`); } catch {}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
503
541
|
// Prefer the 'exit' event's code/signal when present — see note above.
|
|
504
542
|
const effectiveCode = (realExitFromEvent != null) ? realExitFromEvent : code;
|
|
505
543
|
const effectiveSignal = realSignalFromEvent || signal;
|
package/engine.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1916",
|
|
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"
|