@yemi33/minions 0.1.2353 → 0.1.2355
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 +71 -19
- package/docs/README.md +2 -0
- package/docs/kb-dedup-duplicate-pair-investigation.md +138 -0
- package/docs/kb-sweep.md +13 -1
- package/docs/live-checkout-mode.md +7 -4
- package/engine/cli.js +4 -0
- package/engine/kb-sweep.js +14 -4
- package/engine/lifecycle.js +55 -22
- package/engine/live-checkout.js +30 -5
- package/engine/playbook.js +1 -0
- package/engine/pr-action.js +12 -7
- package/engine/process-utils.js +700 -0
- package/engine/shared.js +39 -666
- package/engine.js +78 -0
- package/package.json +1 -1
- package/playbooks/plan-to-prd.md +2 -0
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/process-utils.js — Cross-platform process-management helpers
|
|
3
|
+
* (kill trees, PID liveness, CPU sampling, process-table enumeration, cwd
|
|
4
|
+
* holder detection, descendant/reachability BFS).
|
|
5
|
+
*
|
|
6
|
+
* Extracted from engine/shared.js (P-mrb8yg9x001k1401-b) — a self-contained
|
|
7
|
+
* slice with no dependency on the rest of shared.js's state/JSON machinery.
|
|
8
|
+
* shared.js re-exports these names so no caller needs to change its
|
|
9
|
+
* `require('./shared')` call. Where a function needs MINIONS_DIR /
|
|
10
|
+
* ENGINE_DEFAULTS from shared.js, it lazily `require('./shared')`s inside
|
|
11
|
+
* the function body (not at module top-level) to avoid a load-time
|
|
12
|
+
* circular-require deadlock with shared.js requiring this module back.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { execSync: _execSync } = require('child_process');
|
|
18
|
+
|
|
19
|
+
// Shared.js-local PID liveness check, moved here unchanged. Avoids a circular
|
|
20
|
+
// require on engine/cli.js (which has its own isPidAlive) and engine/timeout.js
|
|
21
|
+
// (which has isOsPidAliveForDispatch — but that one looks up pid from a
|
|
22
|
+
// side-channel pid-file, whereas callers that already have the pid in-hand
|
|
23
|
+
// use this simple check).
|
|
24
|
+
function isPidAlive(pid) {
|
|
25
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
26
|
+
try { process.kill(pid, 0); return true; }
|
|
27
|
+
catch { return false; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function normalizeKillPid(proc) {
|
|
33
|
+
const pid = Number(proc?.pid);
|
|
34
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function unixChildPids(pid) {
|
|
38
|
+
if (!Number.isInteger(pid) || pid <= 0) return [];
|
|
39
|
+
try {
|
|
40
|
+
return _execSync(`pgrep -P ${pid}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 })
|
|
41
|
+
.split(/\r?\n/)
|
|
42
|
+
.map(line => Number(line.trim()))
|
|
43
|
+
.filter(childPid => Number.isInteger(childPid) && childPid > 0 && childPid !== pid);
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function killUnixProcessTree(pid, signal, seen = new Set()) {
|
|
50
|
+
if (!Number.isInteger(pid) || pid <= 0 || seen.has(pid)) return;
|
|
51
|
+
seen.add(pid);
|
|
52
|
+
for (const childPid of unixChildPids(pid)) {
|
|
53
|
+
killUnixProcessTree(childPid, signal, seen);
|
|
54
|
+
}
|
|
55
|
+
try { process.kill(pid, signal); } catch { /* process may be dead */ }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function unrefTimer(timer) {
|
|
59
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
60
|
+
return timer;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function killGracefully(proc, graceMs = 5000) {
|
|
64
|
+
const pid = normalizeKillPid(proc);
|
|
65
|
+
if (!pid) return;
|
|
66
|
+
if (process.platform === 'win32') {
|
|
67
|
+
try { _execSync(`taskkill /PID ${pid} /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
|
|
68
|
+
unrefTimer(setTimeout(() => {
|
|
69
|
+
try { _execSync(`taskkill /PID ${pid} /F /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
|
|
70
|
+
}, graceMs));
|
|
71
|
+
} else {
|
|
72
|
+
killUnixProcessTree(pid, 'SIGTERM');
|
|
73
|
+
unrefTimer(setTimeout(() => {
|
|
74
|
+
killUnixProcessTree(pid, 'SIGKILL');
|
|
75
|
+
}, graceMs));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function killImmediate(proc) {
|
|
80
|
+
const pid = normalizeKillPid(proc);
|
|
81
|
+
if (!pid) return;
|
|
82
|
+
if (process.platform === 'win32') {
|
|
83
|
+
try { _execSync(`taskkill /PID ${pid} /F /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
|
|
84
|
+
} else {
|
|
85
|
+
killUnixProcessTree(pid, 'SIGKILL');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Decide whether a Windows `tasklist /FI "PID eq <pid>" /NH` output proves the
|
|
90
|
+
// pid is alive. A bare `out.includes(String(pid))` can read a DEAD pid as alive
|
|
91
|
+
// on a digit-substring collision — the pid appears inside another column (a
|
|
92
|
+
// larger PID, a memory-KB figure, a session id). Require a word-boundary match
|
|
93
|
+
// (`\b<pid>\b`) and, when an `imageName` is supplied, that the expected process
|
|
94
|
+
// image is present too. Mirrors the hardened check in engine/restart-health.js.
|
|
95
|
+
// Pure (no shell-out) so callers can pass captured output and unit tests can
|
|
96
|
+
// feed crafted samples.
|
|
97
|
+
function tasklistOutputShowsPid(out, pid, { imageName } = {}) {
|
|
98
|
+
if (!out) return false;
|
|
99
|
+
const n = Number(pid);
|
|
100
|
+
if (!Number.isInteger(n) || n <= 0) return false;
|
|
101
|
+
if (!new RegExp(`\\b${n}\\b`).test(out)) return false;
|
|
102
|
+
if (imageName && !out.toLowerCase().includes(String(imageName).toLowerCase())) return false;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// W-mq0e2dae000a003d — cross-platform CPU-seconds sampler used by the
|
|
107
|
+
// spawn-phase watchdog to decide whether a process is genuinely wedged
|
|
108
|
+
// vs busy. Returns the cumulative user+system CPU time in seconds, or
|
|
109
|
+
// null when we can't determine it (process dead, sampler unavailable,
|
|
110
|
+
// shell errored). Fail-open: a null result MUST NOT cause the watchdog
|
|
111
|
+
// to fire — silence-from-the-OS is not the same as silence-from-the-process.
|
|
112
|
+
function getProcessCpuSeconds(pid) {
|
|
113
|
+
const n = Number(pid);
|
|
114
|
+
if (!Number.isInteger(n) || n <= 0) return null;
|
|
115
|
+
try {
|
|
116
|
+
if (process.platform === 'win32') {
|
|
117
|
+
// PowerShell. CPU = total processor time in seconds (sum of user + kernel).
|
|
118
|
+
// 2.5s timeout keeps the tick budget bounded if PS is sluggish.
|
|
119
|
+
const out = _execSync(
|
|
120
|
+
`powershell -NoProfile -NonInteractive -Command "(Get-Process -Id ${n} -ErrorAction Stop).CPU"`,
|
|
121
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 2500, windowsHide: true, encoding: 'utf8' }
|
|
122
|
+
);
|
|
123
|
+
const v = parseFloat(String(out).trim());
|
|
124
|
+
return Number.isFinite(v) ? v : null;
|
|
125
|
+
}
|
|
126
|
+
if (process.platform === 'linux') {
|
|
127
|
+
// /proc/<pid>/stat fields 14 (utime) + 15 (stime) in clock ticks.
|
|
128
|
+
// _SC_CLK_TCK is 100 on virtually every Linux distro; sysconf isn't
|
|
129
|
+
// exposed from Node so we use the conventional value. If a host
|
|
130
|
+
// ever ships USER_HZ != 100 this would under/over-report by a
|
|
131
|
+
// constant factor; safe within an order of magnitude for our gate.
|
|
132
|
+
const raw = fs.readFileSync(`/proc/${n}/stat`, 'utf8');
|
|
133
|
+
// The comm field can contain spaces and parens — slice past the last ')'
|
|
134
|
+
const close = raw.lastIndexOf(')');
|
|
135
|
+
if (close < 0) return null;
|
|
136
|
+
const fields = raw.slice(close + 2).split(/\s+/);
|
|
137
|
+
// After comm: state(0) ppid(1) pgrp(2) ... utime is index 11, stime 12
|
|
138
|
+
const utime = Number(fields[11]);
|
|
139
|
+
const stime = Number(fields[12]);
|
|
140
|
+
if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
|
|
141
|
+
return (utime + stime) / 100;
|
|
142
|
+
}
|
|
143
|
+
if (process.platform === 'darwin') {
|
|
144
|
+
// `ps -p N -o cputime=` → "MM:SS.ss" or "HH:MM:SS"
|
|
145
|
+
const out = _execSync(`ps -p ${n} -o cputime=`, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 2500, windowsHide: true, encoding: 'utf8' });
|
|
146
|
+
const t = String(out).trim();
|
|
147
|
+
if (!t) return null;
|
|
148
|
+
const parts = t.split(':').map(s => parseFloat(s));
|
|
149
|
+
if (!parts.every(p => Number.isFinite(p))) return null;
|
|
150
|
+
let secs = 0;
|
|
151
|
+
while (parts.length) secs = secs * 60 + parts.shift();
|
|
152
|
+
return secs;
|
|
153
|
+
}
|
|
154
|
+
} catch { /* fail-open */ }
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Single-PID kill (no /T tree walk) — used by the orphan-MCP sweep where we
|
|
159
|
+
// already enumerated descendants ourselves and the parent is dead, so /T would
|
|
160
|
+
// be a no-op anyway.
|
|
161
|
+
function killByPidImmediate(pid) {
|
|
162
|
+
const n = Number(pid);
|
|
163
|
+
if (!Number.isInteger(n) || n <= 0 || n === process.pid) return false;
|
|
164
|
+
if (process.platform === 'win32') {
|
|
165
|
+
try { _execSync(`taskkill /PID ${n} /F`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); return true; }
|
|
166
|
+
catch { return false; }
|
|
167
|
+
}
|
|
168
|
+
try { process.kill(n, 'SIGKILL'); return true; } catch { return false; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Batched kill — one OS process for N PIDs. `taskkill` accepts repeated /PID
|
|
172
|
+
// flags natively; on Unix we still loop process.kill, which is in-process and
|
|
173
|
+
// cheap. Returns the count of successful kills.
|
|
174
|
+
function killByPidsImmediate(pids) {
|
|
175
|
+
const valid = (Array.isArray(pids) ? pids : [])
|
|
176
|
+
.map(Number)
|
|
177
|
+
.filter(n => Number.isInteger(n) && n > 0 && n !== process.pid);
|
|
178
|
+
if (!valid.length) return 0;
|
|
179
|
+
if (process.platform === 'win32') {
|
|
180
|
+
const flags = valid.map(p => `/PID ${p}`).join(' ');
|
|
181
|
+
try { _execSync(`taskkill /F ${flags}`, { stdio: 'pipe', timeout: 5000, windowsHide: true }); return valid.length; }
|
|
182
|
+
catch {
|
|
183
|
+
let killed = 0;
|
|
184
|
+
for (const p of valid) { if (killByPidImmediate(p)) killed++; }
|
|
185
|
+
return killed;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
let killed = 0;
|
|
189
|
+
for (const p of valid) {
|
|
190
|
+
try { process.kill(p, 'SIGKILL'); killed++; } catch { /* dead */ }
|
|
191
|
+
}
|
|
192
|
+
return killed;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Process Table Enumeration ───────────────────────────────────────────────
|
|
196
|
+
// Cross-platform listing of every live process as { pid, ppid, name, cmd? }.
|
|
197
|
+
// `cmd` is best-effort — included on Windows (via wmic) and Linux (/proc); may
|
|
198
|
+
// be empty on macOS without ps -ww.
|
|
199
|
+
|
|
200
|
+
function _parseWmicCsv(text) {
|
|
201
|
+
const lines = String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
202
|
+
if (lines.length < 2) return [];
|
|
203
|
+
const header = lines[0].split(',');
|
|
204
|
+
const idx = (col) => header.findIndex(h => h.toLowerCase() === col.toLowerCase());
|
|
205
|
+
const nameIdx = idx('Name');
|
|
206
|
+
const ppidIdx = idx('ParentProcessId');
|
|
207
|
+
const pidIdx = idx('ProcessId');
|
|
208
|
+
const cmdIdx = idx('CommandLine');
|
|
209
|
+
if (nameIdx < 0 || ppidIdx < 0 || pidIdx < 0) return [];
|
|
210
|
+
const out = [];
|
|
211
|
+
for (let i = 1; i < lines.length; i++) {
|
|
212
|
+
// CommandLine can contain commas — naive split is fine because Name + PIDs
|
|
213
|
+
// sit at fixed positions and CommandLine, when present, is the LAST column.
|
|
214
|
+
const cols = lines[i].split(',');
|
|
215
|
+
if (cols.length < 4) continue;
|
|
216
|
+
const pid = parseInt(cols[pidIdx], 10);
|
|
217
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
218
|
+
const ppid = parseInt(cols[ppidIdx], 10);
|
|
219
|
+
const name = cols[nameIdx] || '';
|
|
220
|
+
const cmd = cmdIdx >= 0 ? cols.slice(cmdIdx).join(',') : '';
|
|
221
|
+
out.push({ pid, ppid: Number.isInteger(ppid) ? ppid : 0, name, cmd });
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// PowerShell Get-CimInstance is the modern path (wmic is removed on Win11
|
|
227
|
+
// 24H2+). We try it first and fall back to wmic for older Windows hosts.
|
|
228
|
+
function _psListProcesses() {
|
|
229
|
+
const script = "Get-CimInstance Win32_Process | Select-Object Name,ParentProcessId,ProcessId,CommandLine | ConvertTo-Json -Compress -Depth 2";
|
|
230
|
+
try {
|
|
231
|
+
const out = _execSync(
|
|
232
|
+
`powershell -NoProfile -NonInteractive -Command "${script}"`,
|
|
233
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
|
|
234
|
+
);
|
|
235
|
+
const parsed = JSON.parse(out);
|
|
236
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
237
|
+
return arr.map(p => ({
|
|
238
|
+
pid: Number(p.ProcessId),
|
|
239
|
+
ppid: Number(p.ParentProcessId) || 0,
|
|
240
|
+
name: p.Name || '',
|
|
241
|
+
cmd: p.CommandLine || '',
|
|
242
|
+
})).filter(p => Number.isInteger(p.pid) && p.pid > 0);
|
|
243
|
+
} catch { return null; }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function _winListProcesses() {
|
|
247
|
+
const ps = _psListProcesses();
|
|
248
|
+
if (ps && ps.length) return ps;
|
|
249
|
+
try {
|
|
250
|
+
const out = _execSync(
|
|
251
|
+
'wmic process get Name,ParentProcessId,ProcessId,CommandLine /format:csv',
|
|
252
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
|
|
253
|
+
);
|
|
254
|
+
return _parseWmicCsv(out);
|
|
255
|
+
} catch { return []; }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function _unixListProcesses() {
|
|
259
|
+
try {
|
|
260
|
+
const out = _execSync(
|
|
261
|
+
'ps -A -o pid=,ppid=,comm=',
|
|
262
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }
|
|
263
|
+
);
|
|
264
|
+
return out.split(/\r?\n/).map(line => {
|
|
265
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
266
|
+
if (!m) return null;
|
|
267
|
+
return { pid: parseInt(m[1], 10), ppid: parseInt(m[2], 10), name: m[3], cmd: '' };
|
|
268
|
+
}).filter(Boolean);
|
|
269
|
+
} catch { return []; }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function listAllProcesses() {
|
|
273
|
+
return process.platform === 'win32' ? _winListProcesses() : _unixListProcesses();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// W-mq6f2fe0000557fa — orphan-worktree GC: identify OS processes whose cwd
|
|
277
|
+
// is at or inside `dir`. Cross-platform with a fail-open contract: on any
|
|
278
|
+
// error or timeout we return [] (the caller continues with its existing
|
|
279
|
+
// escalation path and an unenriched note).
|
|
280
|
+
//
|
|
281
|
+
// Return shape: [{ pid, cmdline, startedAt, ageMs }, ...]
|
|
282
|
+
// - pid: numeric OS pid
|
|
283
|
+
// - cmdline: full command line as a single string (best-effort; possibly truncated)
|
|
284
|
+
// - startedAt: ms-since-epoch process start time (0 when unknown)
|
|
285
|
+
// - ageMs: Date.now() - startedAt (0 when startedAt is 0)
|
|
286
|
+
//
|
|
287
|
+
// Platform notes:
|
|
288
|
+
// - Windows: PowerShell `Get-CimInstance Win32_Process` filtered by the
|
|
289
|
+
// worktree basename appearing in CommandLine. (Win32_Process does not
|
|
290
|
+
// expose the working directory; the basename match is the most-reliable
|
|
291
|
+
// heuristic — every spawn-agent invocation embeds the dispatch id, which
|
|
292
|
+
// is the worktree basename, in its argv.)
|
|
293
|
+
// - Linux: walk `/proc/*/cwd` and keep PIDs whose readlinkSync resolves
|
|
294
|
+
// under `dir`. Cmdline read from `/proc/<pid>/cmdline` (NUL-separated).
|
|
295
|
+
// Start time derived from stat() mtime of the cwd entry as a proxy.
|
|
296
|
+
// - macOS: `lsof -F p -d cwd` filtered by directory, then `ps -p <pid>` for
|
|
297
|
+
// cmdline + start time.
|
|
298
|
+
function findProcessesWithCwdInside(dir, opts = {}) {
|
|
299
|
+
if (!dir || typeof dir !== 'string') return [];
|
|
300
|
+
let resolved;
|
|
301
|
+
try { resolved = path.resolve(dir); } catch { return []; }
|
|
302
|
+
if (!resolved) return [];
|
|
303
|
+
// Lazy require avoids a load-time circular-require deadlock: shared.js
|
|
304
|
+
// requires this module at top-level, so shared's own module.exports isn't
|
|
305
|
+
// populated yet at that point — but by the time this function actually
|
|
306
|
+
// RUNS, shared.js has finished loading and require('./shared') resolves
|
|
307
|
+
// to its full, cached exports object.
|
|
308
|
+
const { ENGINE_DEFAULTS } = require('./shared');
|
|
309
|
+
const timeoutMs = Number(opts.timeoutMs) > 0
|
|
310
|
+
? Number(opts.timeoutMs)
|
|
311
|
+
: (ENGINE_DEFAULTS.orphanHolderScanTimeoutMs || 5000);
|
|
312
|
+
const now = Date.now();
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
if (process.platform === 'win32') {
|
|
316
|
+
return _findWindowsProcessesWithCwdInside(resolved, timeoutMs, now);
|
|
317
|
+
}
|
|
318
|
+
if (process.platform === 'linux') {
|
|
319
|
+
return _findLinuxProcessesWithCwdInside(resolved, timeoutMs, now);
|
|
320
|
+
}
|
|
321
|
+
return _findMacProcessesWithCwdInside(resolved, timeoutMs, now);
|
|
322
|
+
} catch { return []; }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function _findWindowsProcessesWithCwdInside(resolved, timeoutMs, now) {
|
|
326
|
+
// Win32_Process exposes CommandLine but not the cwd. Match by basename of
|
|
327
|
+
// the worktree dir (the dispatch id like W-mq1k8z6o003acd89) appearing in
|
|
328
|
+
// the cmdline — every spawn-agent prompt-file path embeds it.
|
|
329
|
+
const basename = path.basename(resolved);
|
|
330
|
+
if (!basename || basename.length < 3) return [];
|
|
331
|
+
// PowerShell-quote: outer escape ' as ''
|
|
332
|
+
const psBasename = basename.replace(/'/g, "''");
|
|
333
|
+
const psResolved = resolved.replace(/'/g, "''");
|
|
334
|
+
// Use -like with wildcards on BOTH basename and full path so a process
|
|
335
|
+
// whose cmdline carries the worktree dir (but a different basename
|
|
336
|
+
// anywhere in argv) also matches.
|
|
337
|
+
const script = `Get-CimInstance Win32_Process | Where-Object { ($_.CommandLine -like '*${psBasename}*') -or ($_.CommandLine -like '*${psResolved}*') } | ForEach-Object { [PSCustomObject]@{ pid = $_.ProcessId; cmdline = $_.CommandLine; startedAt = if ($_.CreationDate) { [int64](([datetimeoffset]$_.CreationDate).ToUnixTimeMilliseconds()) } else { 0 } } } | ConvertTo-Json -Compress -Depth 2`;
|
|
338
|
+
let raw;
|
|
339
|
+
try {
|
|
340
|
+
raw = _execSync(
|
|
341
|
+
`powershell -NoProfile -NonInteractive -Command "${script}"`,
|
|
342
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
|
|
343
|
+
);
|
|
344
|
+
} catch { return []; }
|
|
345
|
+
if (!raw || !String(raw).trim()) return [];
|
|
346
|
+
let parsed;
|
|
347
|
+
try { parsed = JSON.parse(raw); }
|
|
348
|
+
catch { return []; }
|
|
349
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
350
|
+
const out = [];
|
|
351
|
+
for (const r of arr) {
|
|
352
|
+
if (!r) continue;
|
|
353
|
+
const pid = Number(r.pid);
|
|
354
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
355
|
+
const startedAt = Number(r.startedAt) || 0;
|
|
356
|
+
out.push({
|
|
357
|
+
pid,
|
|
358
|
+
cmdline: r.cmdline ? String(r.cmdline) : '',
|
|
359
|
+
startedAt,
|
|
360
|
+
ageMs: startedAt > 0 ? Math.max(0, now - startedAt) : 0,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function _findLinuxProcessesWithCwdInside(resolved, _timeoutMs, now) {
|
|
367
|
+
// /proc walk: scan numeric entries, readlink cwd, keep matches.
|
|
368
|
+
let entries;
|
|
369
|
+
try { entries = fs.readdirSync('/proc'); }
|
|
370
|
+
catch { return []; }
|
|
371
|
+
const prefix = resolved + path.sep;
|
|
372
|
+
const out = [];
|
|
373
|
+
for (const e of entries) {
|
|
374
|
+
if (!/^\d+$/.test(e)) continue;
|
|
375
|
+
const pid = Number(e);
|
|
376
|
+
let cwd;
|
|
377
|
+
try { cwd = fs.readlinkSync(`/proc/${pid}/cwd`); }
|
|
378
|
+
catch { continue; }
|
|
379
|
+
if (!cwd) continue;
|
|
380
|
+
if (cwd !== resolved && !cwd.startsWith(prefix)) continue;
|
|
381
|
+
let cmdline = '';
|
|
382
|
+
try {
|
|
383
|
+
const buf = fs.readFileSync(`/proc/${pid}/cmdline`);
|
|
384
|
+
cmdline = buf.toString('utf8').replace(/\0/g, ' ').trim();
|
|
385
|
+
} catch { /* cmdline read can fail on race */ }
|
|
386
|
+
let startedAt = 0;
|
|
387
|
+
try {
|
|
388
|
+
const st = fs.statSync(`/proc/${pid}`);
|
|
389
|
+
startedAt = st && st.ctimeMs ? Math.floor(st.ctimeMs) : 0;
|
|
390
|
+
} catch { /* stat can fail on race */ }
|
|
391
|
+
out.push({
|
|
392
|
+
pid,
|
|
393
|
+
cmdline,
|
|
394
|
+
startedAt,
|
|
395
|
+
ageMs: startedAt > 0 ? Math.max(0, now - startedAt) : 0,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function _findMacProcessesWithCwdInside(resolved, timeoutMs, now) {
|
|
402
|
+
// `lsof -a -d cwd -F pn` emits PID then cwd path. Filter for dir matches,
|
|
403
|
+
// then run a single `ps` pass to enrich cmdline + start time.
|
|
404
|
+
let raw;
|
|
405
|
+
try {
|
|
406
|
+
raw = _execSync('lsof -a -d cwd -F pn 2>/dev/null', {
|
|
407
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024,
|
|
408
|
+
});
|
|
409
|
+
} catch { return []; }
|
|
410
|
+
if (!raw) return [];
|
|
411
|
+
const prefix = resolved + path.sep;
|
|
412
|
+
const pids = new Set();
|
|
413
|
+
let currentPid = null;
|
|
414
|
+
for (const line of String(raw).split(/\r?\n/)) {
|
|
415
|
+
if (!line) continue;
|
|
416
|
+
const tag = line[0];
|
|
417
|
+
const val = line.slice(1);
|
|
418
|
+
if (tag === 'p') {
|
|
419
|
+
const n = Number(val);
|
|
420
|
+
currentPid = Number.isInteger(n) && n > 0 ? n : null;
|
|
421
|
+
} else if (tag === 'n' && currentPid != null) {
|
|
422
|
+
if (val === resolved || val.startsWith(prefix)) pids.add(currentPid);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (pids.size === 0) return [];
|
|
426
|
+
const out = [];
|
|
427
|
+
for (const pid of pids) {
|
|
428
|
+
let line;
|
|
429
|
+
try {
|
|
430
|
+
line = String(_execSync(`ps -p ${pid} -o lstart=,command=`, {
|
|
431
|
+
stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, encoding: 'utf8',
|
|
432
|
+
}) || '').trim();
|
|
433
|
+
} catch { line = ''; }
|
|
434
|
+
let startedAt = 0;
|
|
435
|
+
let cmdline = '';
|
|
436
|
+
if (line) {
|
|
437
|
+
// ps lstart= emits "Mon Jun 9 07:04:33 2026" (24 chars), then cmdline.
|
|
438
|
+
const lstart = line.slice(0, 24).trim();
|
|
439
|
+
cmdline = line.slice(24).trim();
|
|
440
|
+
const parsed = Date.parse(lstart);
|
|
441
|
+
if (!Number.isNaN(parsed)) startedAt = parsed;
|
|
442
|
+
}
|
|
443
|
+
out.push({
|
|
444
|
+
pid,
|
|
445
|
+
cmdline,
|
|
446
|
+
startedAt,
|
|
447
|
+
ageMs: startedAt > 0 ? Math.max(0, now - startedAt) : 0,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// W-mqila0t5 — Windows-only PEB-based CWD probe used by the worktree-holder
|
|
454
|
+
// reaper. Unlike findProcessesWithCwdInside (whose Windows path is a
|
|
455
|
+
// CommandLine-basename heuristic — Win32_Process does NOT expose the working
|
|
456
|
+
// directory), this reads each process's REAL current directory out of its PEB
|
|
457
|
+
// (NtQueryInformationProcess → PebBaseAddress, then ReadProcessMemory of
|
|
458
|
+
// RTL_USER_PROCESS_PARAMETERS.CurrentDirectory.DosPath). That's the only way
|
|
459
|
+
// to find a process that pins a directory via its CWD — e.g. an orphaned
|
|
460
|
+
// copilot.exe / cmd.exe / node.exe agent child whose cwd is the worktree root
|
|
461
|
+
// but whose argv never mentions the path (so the cmdline heuristic
|
|
462
|
+
// false-negatives, and the Restart Manager / file-handle detectors report ZERO
|
|
463
|
+
// lockers because the lock is a DIRECTORY handle, not a file handle).
|
|
464
|
+
//
|
|
465
|
+
// Returns [{ pid, name, cwd }] for processes whose CWD is at-or-under
|
|
466
|
+
// `worktreePath`. POSIX returns [] (the EBUSY race is Windows-specific; POSIX
|
|
467
|
+
// reaping stays a no-op). Fail-open: any error / timeout / spawn failure →
|
|
468
|
+
// [] (reap nothing). x64 offsets only (PebBaseAddress@8, PEB+0x20 →
|
|
469
|
+
// ProcessParameters, RTL_USER_PROCESS_PARAMETERS+0x38 → CurrentDirectory).
|
|
470
|
+
function _windowsCwdProbeScript(psTarget) {
|
|
471
|
+
return [
|
|
472
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
473
|
+
`$target = '${psTarget}'.TrimEnd('\\').ToLower()`,
|
|
474
|
+
'Add-Type -Namespace MinionsPeb -Name Native -MemberDefinition @"',
|
|
475
|
+
'[DllImport("ntdll.dll")] public static extern int NtQueryInformationProcess(IntPtr h, int cls, byte[] info, int len, ref int ret);',
|
|
476
|
+
'[DllImport("kernel32.dll")] public static extern IntPtr OpenProcess(int access, bool inherit, int pid);',
|
|
477
|
+
'[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);',
|
|
478
|
+
'[DllImport("kernel32.dll")] public static extern bool ReadProcessMemory(IntPtr h, IntPtr baseAddr, byte[] buf, int size, ref int read);',
|
|
479
|
+
'"@',
|
|
480
|
+
'function Get-ProcCwd($procId) {',
|
|
481
|
+
' $h = [MinionsPeb.Native]::OpenProcess(0x410, $false, $procId)',
|
|
482
|
+
' if ($h -eq [IntPtr]::Zero) { return $null }',
|
|
483
|
+
' try {',
|
|
484
|
+
' $pbi = New-Object byte[] 48; $ret = 0',
|
|
485
|
+
' if ([MinionsPeb.Native]::NtQueryInformationProcess($h, 0, $pbi, 48, [ref]$ret) -ne 0) { return $null }',
|
|
486
|
+
' $pebBase = [System.BitConverter]::ToInt64($pbi, 8)',
|
|
487
|
+
' if ($pebBase -eq 0) { return $null }',
|
|
488
|
+
' $p8 = New-Object byte[] 8; $r = 0',
|
|
489
|
+
' if (-not [MinionsPeb.Native]::ReadProcessMemory($h, [IntPtr]($pebBase + 0x20), $p8, 8, [ref]$r)) { return $null }',
|
|
490
|
+
' $params = [System.BitConverter]::ToInt64($p8, 0)',
|
|
491
|
+
' if ($params -eq 0) { return $null }',
|
|
492
|
+
' $us = New-Object byte[] 16',
|
|
493
|
+
' if (-not [MinionsPeb.Native]::ReadProcessMemory($h, [IntPtr]($params + 0x38), $us, 16, [ref]$r)) { return $null }',
|
|
494
|
+
' $len = [System.BitConverter]::ToUInt16($us, 0)',
|
|
495
|
+
' $bufPtr = [System.BitConverter]::ToInt64($us, 8)',
|
|
496
|
+
' if ($len -le 0 -or $bufPtr -eq 0) { return $null }',
|
|
497
|
+
' $sb = New-Object byte[] $len',
|
|
498
|
+
' if (-not [MinionsPeb.Native]::ReadProcessMemory($h, [IntPtr]$bufPtr, $sb, $len, [ref]$r)) { return $null }',
|
|
499
|
+
' return [System.Text.Encoding]::Unicode.GetString($sb, 0, $len)',
|
|
500
|
+
' } finally { [void][MinionsPeb.Native]::CloseHandle($h) }',
|
|
501
|
+
'}',
|
|
502
|
+
'foreach ($p in Get-Process) {',
|
|
503
|
+
' $cwd = Get-ProcCwd $p.Id',
|
|
504
|
+
' if (-not $cwd) { continue }',
|
|
505
|
+
" $c = $cwd.TrimEnd('\\').ToLower()",
|
|
506
|
+
" if ($c -eq $target -or $c.StartsWith($target + '\\')) {",
|
|
507
|
+
' Write-Output ("{0}|{1}|{2}" -f $p.Id, $p.ProcessName, $cwd)',
|
|
508
|
+
' }',
|
|
509
|
+
'}',
|
|
510
|
+
].join('\n');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function _parseCwdHolderLines(raw, resolved) {
|
|
514
|
+
if (!raw || !String(raw).trim()) return [];
|
|
515
|
+
const prefix = (resolved.replace(/[\\/]+$/g, '') + path.sep).toLowerCase();
|
|
516
|
+
const target = resolved.replace(/[\\/]+$/g, '').toLowerCase();
|
|
517
|
+
const out = [];
|
|
518
|
+
for (const line of String(raw).split(/\r?\n/)) {
|
|
519
|
+
const t = line.trim();
|
|
520
|
+
if (!t) continue;
|
|
521
|
+
const parts = t.split('|');
|
|
522
|
+
if (parts.length < 3) continue;
|
|
523
|
+
const pid = Number(parts[0]);
|
|
524
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
525
|
+
const name = parts[1] || '';
|
|
526
|
+
const cwd = parts.slice(2).join('|');
|
|
527
|
+
// Defense-in-depth: re-confirm the cwd is at/under THIS worktree so a
|
|
528
|
+
// sibling worktree's live agent is never reaped (the script filters too).
|
|
529
|
+
const cwdLower = cwd.replace(/[\\/]+$/g, '').toLowerCase();
|
|
530
|
+
if (cwdLower !== target && !cwdLower.startsWith(prefix)) continue;
|
|
531
|
+
out.push({ pid, name, cwd });
|
|
532
|
+
}
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function findProcessCwdHolders(worktreePath, opts = {}) {
|
|
537
|
+
if (process.platform !== 'win32') return [];
|
|
538
|
+
if (!worktreePath || typeof worktreePath !== 'string') return [];
|
|
539
|
+
let resolved;
|
|
540
|
+
try { resolved = path.resolve(worktreePath); } catch { return []; }
|
|
541
|
+
if (!resolved) return [];
|
|
542
|
+
// Lazy require — see comment in findProcessesWithCwdInside above.
|
|
543
|
+
const { ENGINE_DEFAULTS, MINIONS_DIR } = require('./shared');
|
|
544
|
+
const timeoutMs = Number(opts.timeoutMs) > 0
|
|
545
|
+
? Number(opts.timeoutMs)
|
|
546
|
+
: (ENGINE_DEFAULTS.statusProbeKillTimeoutMs || 12000);
|
|
547
|
+
const psTarget = resolved.replace(/'/g, "''");
|
|
548
|
+
const script = _windowsCwdProbeScript(psTarget);
|
|
549
|
+
// Run from a temp .ps1 file to avoid fragile inline-quote escaping of the
|
|
550
|
+
// embedded C#. Fail-open at every step.
|
|
551
|
+
let scriptPath = null;
|
|
552
|
+
let raw;
|
|
553
|
+
try {
|
|
554
|
+
const dir = path.join(MINIONS_DIR, 'engine', 'tmp');
|
|
555
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* exists */ }
|
|
556
|
+
scriptPath = path.join(dir, `cwd-probe-${process.pid}-${Date.now()}.ps1`);
|
|
557
|
+
fs.writeFileSync(scriptPath, script, 'utf8');
|
|
558
|
+
raw = _execSync(
|
|
559
|
+
`powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}"`,
|
|
560
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
|
|
561
|
+
);
|
|
562
|
+
} catch { raw = ''; /* ETIMEDOUT / spawn fail / write fail → reap nothing */ }
|
|
563
|
+
finally {
|
|
564
|
+
if (scriptPath) { try { fs.unlinkSync(scriptPath); } catch { /* best-effort */ } }
|
|
565
|
+
}
|
|
566
|
+
return _parseCwdHolderLines(raw, resolved);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Cross-check a single PID's command line for a Minions agent invocation
|
|
570
|
+
// (`claude` or `copilot`, including the `node spawn-agent.js --runtime <name>`
|
|
571
|
+
// wrapper and `gh copilot` fallback). Used by orphan/recycled-PID safety:
|
|
572
|
+
// - engine/cleanup.js: gate before killing a PID found in engine/tmp/pid-*.pid
|
|
573
|
+
// - engine/timeout.js: gate before parking a dispatch as still-alive when
|
|
574
|
+
// the OS PID is alive but may belong to an unrelated recycled-PID process
|
|
575
|
+
//
|
|
576
|
+
// Windows: PowerShell Get-CimInstance for the full CommandLine of one PID.
|
|
577
|
+
// Linux: /proc/<pid>/cmdline (NUL-separated).
|
|
578
|
+
// macOS / when /proc isn't available: fallback `ps -p <pid> -o command=`.
|
|
579
|
+
//
|
|
580
|
+
// Returns false when the PID is invalid, the process doesn't exist, the
|
|
581
|
+
// command line can't be read, or the cmdline contains neither `claude` nor
|
|
582
|
+
// `copilot`. False is the safe default for both call sites: cleanup falls
|
|
583
|
+
// through to "skip kill" and timeout falls through to "treat PID as dead".
|
|
584
|
+
function isProcessCommandLineMatchingAgent(pid) {
|
|
585
|
+
const n = Number(pid);
|
|
586
|
+
if (!Number.isInteger(n) || n <= 0) return false;
|
|
587
|
+
let cmdline = '';
|
|
588
|
+
try {
|
|
589
|
+
if (process.platform === 'win32') {
|
|
590
|
+
const out = _execSync(
|
|
591
|
+
`powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${n}').CommandLine"`,
|
|
592
|
+
{ stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true, encoding: 'utf8' }
|
|
593
|
+
);
|
|
594
|
+
cmdline = String(out || '').trim();
|
|
595
|
+
} else {
|
|
596
|
+
try {
|
|
597
|
+
const buf = fs.readFileSync(`/proc/${n}/cmdline`);
|
|
598
|
+
cmdline = buf.toString('utf8').replace(/\0/g, ' ').trim();
|
|
599
|
+
} catch {
|
|
600
|
+
try {
|
|
601
|
+
cmdline = String(_execSync(`ps -p ${n} -o command=`,
|
|
602
|
+
{ stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, encoding: 'utf8' }) || '').trim();
|
|
603
|
+
} catch { return false; }
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
} catch { return false; }
|
|
607
|
+
if (!cmdline) return false;
|
|
608
|
+
const lower = cmdline.toLowerCase();
|
|
609
|
+
return lower.includes('claude') || lower.includes('copilot');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function _buildChildMap(processes) {
|
|
613
|
+
const childMap = new Map();
|
|
614
|
+
for (const p of processes) {
|
|
615
|
+
if (!childMap.has(p.ppid)) childMap.set(p.ppid, []);
|
|
616
|
+
childMap.get(p.ppid).push(p.pid);
|
|
617
|
+
}
|
|
618
|
+
return childMap;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// BFS descendants of rootPid given a process snapshot. `allProcesses` is
|
|
622
|
+
// injectable for tests and for amortizing one snapshot across multiple
|
|
623
|
+
// `listProcessDescendants` calls in the same tick.
|
|
624
|
+
function listProcessDescendants(rootPid, allProcesses = null) {
|
|
625
|
+
const root = Number(rootPid);
|
|
626
|
+
if (!Number.isInteger(root) || root <= 0) return [];
|
|
627
|
+
const procs = Array.isArray(allProcesses) ? allProcesses : listAllProcesses();
|
|
628
|
+
const childMap = _buildChildMap(procs);
|
|
629
|
+
const result = [];
|
|
630
|
+
const seen = new Set([root]);
|
|
631
|
+
const queue = [root];
|
|
632
|
+
while (queue.length) {
|
|
633
|
+
const cur = queue.shift();
|
|
634
|
+
const children = childMap.get(cur) || [];
|
|
635
|
+
for (const c of children) {
|
|
636
|
+
if (seen.has(c)) continue;
|
|
637
|
+
seen.add(c);
|
|
638
|
+
result.push(c);
|
|
639
|
+
queue.push(c);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return result;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Same BFS, but starts from any number of root PIDs and returns the full reach
|
|
646
|
+
// set (including the roots themselves). Used by the orphan-MCP sweep to compute
|
|
647
|
+
// "everything anchored to Minions" so non-anchored MCP node processes can be
|
|
648
|
+
// identified.
|
|
649
|
+
function listProcessReachable(rootPids, allProcesses = null) {
|
|
650
|
+
const roots = (Array.isArray(rootPids) ? rootPids : [rootPids])
|
|
651
|
+
.map(Number)
|
|
652
|
+
.filter(n => Number.isInteger(n) && n > 0);
|
|
653
|
+
if (!roots.length) return new Set();
|
|
654
|
+
const procs = Array.isArray(allProcesses) ? allProcesses : listAllProcesses();
|
|
655
|
+
const childMap = _buildChildMap(procs);
|
|
656
|
+
const seen = new Set();
|
|
657
|
+
const queue = [];
|
|
658
|
+
for (const r of roots) {
|
|
659
|
+
if (!seen.has(r)) { seen.add(r); queue.push(r); }
|
|
660
|
+
}
|
|
661
|
+
while (queue.length) {
|
|
662
|
+
const cur = queue.shift();
|
|
663
|
+
const children = childMap.get(cur) || [];
|
|
664
|
+
for (const c of children) {
|
|
665
|
+
if (seen.has(c)) continue;
|
|
666
|
+
seen.add(c);
|
|
667
|
+
queue.push(c);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return seen;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
module.exports = {
|
|
674
|
+
normalizeKillPid,
|
|
675
|
+
unixChildPids,
|
|
676
|
+
killUnixProcessTree,
|
|
677
|
+
killGracefully,
|
|
678
|
+
killImmediate,
|
|
679
|
+
killByPidImmediate,
|
|
680
|
+
killByPidsImmediate,
|
|
681
|
+
tasklistOutputShowsPid,
|
|
682
|
+
getProcessCpuSeconds,
|
|
683
|
+
_parseWmicCsv,
|
|
684
|
+
_psListProcesses,
|
|
685
|
+
_winListProcesses,
|
|
686
|
+
_unixListProcesses,
|
|
687
|
+
listAllProcesses,
|
|
688
|
+
findProcessesWithCwdInside,
|
|
689
|
+
_findWindowsProcessesWithCwdInside,
|
|
690
|
+
_findLinuxProcessesWithCwdInside,
|
|
691
|
+
_findMacProcessesWithCwdInside,
|
|
692
|
+
_windowsCwdProbeScript,
|
|
693
|
+
_parseCwdHolderLines,
|
|
694
|
+
findProcessCwdHolders,
|
|
695
|
+
isProcessCommandLineMatchingAgent,
|
|
696
|
+
_buildChildMap,
|
|
697
|
+
listProcessDescendants,
|
|
698
|
+
listProcessReachable,
|
|
699
|
+
isPidAlive,
|
|
700
|
+
};
|