@yemi33/minions 0.1.2352 → 0.1.2354
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/docs/shared-lifecycle-module-map.md +675 -0
- package/engine/cli.js +4 -0
- package/engine/lifecycle.js +55 -22
- package/engine/live-checkout.js +64 -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
package/engine/shared.js
CHANGED
|
@@ -7,6 +7,11 @@ const fs = require('fs');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const crypto = require('crypto');
|
|
10
|
+
// Process-management helpers (kill trees, PID liveness, process-table
|
|
11
|
+
// enumeration, cwd-holder detection) live in process-utils.js
|
|
12
|
+
// (P-mrb8yg9x001k1401-b). Re-exported below via `...processUtils` so no
|
|
13
|
+
// caller of require('./shared') needs to change.
|
|
14
|
+
const processUtils = require('./process-utils');
|
|
10
15
|
|
|
11
16
|
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
12
17
|
const DEFAULT_MINIONS_HOME = path.join(os.homedir(), '.minions');
|
|
@@ -1570,17 +1575,6 @@ function sleepMs(ms) {
|
|
|
1570
1575
|
// rewrites) while the PID guard keeps crashed-holder recovery fast.
|
|
1571
1576
|
const LOCK_STALE_MS = 300000; // 5 minutes — force-remove locks older than this
|
|
1572
1577
|
|
|
1573
|
-
// Shared.js-local PID liveness check. Avoids a circular require on engine/cli.js
|
|
1574
|
-
// (which has its own isPidAlive) and engine/timeout.js (which has
|
|
1575
|
-
// isOsPidAliveForDispatch — but that one looks up pid from a side-channel
|
|
1576
|
-
// pid-file, whereas the lock reaper already has the holder pid in-hand from the
|
|
1577
|
-
// lock contents).
|
|
1578
|
-
function isPidAlive(pid) {
|
|
1579
|
-
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
1580
|
-
try { process.kill(pid, 0); return true; }
|
|
1581
|
-
catch { return false; }
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
1578
|
// P-8a4d6f29 — single helper for detached-process stdio capture with
|
|
1585
1579
|
// rotate-on-open. Used by bin/minions.js (engine + dashboard stdio logs) and
|
|
1586
1580
|
// engine/managed-spawn.js openManagedLog. Centralising replaces the previous
|
|
@@ -1767,7 +1761,7 @@ function withFileLock(lockPath, fn, {
|
|
|
1767
1761
|
|
|
1768
1762
|
let shouldReap;
|
|
1769
1763
|
if (holderPid !== null) {
|
|
1770
|
-
shouldReap = !isPidAlive(holderPid) || mtimeAge > LOCK_STALE_MS * 5;
|
|
1764
|
+
shouldReap = !processUtils.isPidAlive(holderPid) || mtimeAge > LOCK_STALE_MS * 5;
|
|
1771
1765
|
} else {
|
|
1772
1766
|
// Legacy empty/non-JSON lockfile: trust mtime alone
|
|
1773
1767
|
shouldReap = true;
|
|
@@ -3217,8 +3211,11 @@ const ENGINE_DEFAULTS = {
|
|
|
3217
3211
|
// so dispatch proceeds without manual operator intervention. Per-project
|
|
3218
3212
|
// `project.liveCheckoutAutoStash` overrides this (resolveLiveCheckoutAutoStash).
|
|
3219
3213
|
// The engine never auto-pops the stash — the operator runs `git stash pop`.
|
|
3220
|
-
//
|
|
3221
|
-
|
|
3214
|
+
// W-mrawiym8000c42c9 — default true fleet-wide: the stash is non-destructive
|
|
3215
|
+
// (recoverable via `git stash pop`, never auto-popped by the engine) and is
|
|
3216
|
+
// now the FIRST line of defense in the dirty-tree recovery flow, now that
|
|
3217
|
+
// the destructive `liveCheckoutAutoReset` defaults OFF (W-mrawgw4q000a6bff).
|
|
3218
|
+
liveCheckoutAutoStash: true,
|
|
3222
3219
|
prNoOpFixPauseAttempts: 2, // pause one PR automation cause after repeated no-op fixes for unchanged evidence
|
|
3223
3220
|
quarantineAutoRecoveryMax: 2, // #2996 follow-up: cap on auto-flipping WORKTREE_DIRTY/WORKTREE_DIVERGENT failures back to pending (the quarantine is self-healing so the next dispatch starts clean; the cap prevents infinite loops if quarantine itself keeps failing).
|
|
3224
3221
|
// W-mq5n1zx5 — Layer 1a/2b: harden the quarantine rename path against
|
|
@@ -3592,12 +3589,21 @@ const ENGINE_DEFAULTS = {
|
|
|
3592
3589
|
// When ON, a dirty/broken live-checkout tree is `git fetch origin` +
|
|
3593
3590
|
// `git reset --hard origin/<branch>`'d before dispatch instead of failing
|
|
3594
3591
|
// LIVE_CHECKOUT_DIRTY. Per-project `project.liveCheckoutAutoReset` overrides
|
|
3595
|
-
// this. Default
|
|
3596
|
-
//
|
|
3597
|
-
// `minions: auto-save agent WIP` commits
|
|
3598
|
-
//
|
|
3599
|
-
//
|
|
3600
|
-
|
|
3592
|
+
// this. Default FLIPPED BACK to false under W-mrawgw4q000a6bff: the original
|
|
3593
|
+
// divergence problem this was ON to solve (dirty live-checkout trees
|
|
3594
|
+
// accumulating `minions: auto-save agent WIP` commits) is now primarily
|
|
3595
|
+
// addressed by `liveCheckoutAutoStash` (non-destructive — `git stash push`,
|
|
3596
|
+
// recoverable via `git stash pop` — see `resolveLiveCheckoutAutoStash` /
|
|
3597
|
+
// `applyLiveCheckoutAutoStash` in engine/live-checkout.js), which the
|
|
3598
|
+
// stash-before-reset ordering (W-mrawgw4q000a6bff) now always attempts FIRST
|
|
3599
|
+
// when both flags are enabled for a dispatch. Auto-reset remains available as
|
|
3600
|
+
// an explicit opt-in (per-project `project.liveCheckoutAutoReset: true` or
|
|
3601
|
+
// fleet `engine.liveCheckoutAutoReset: true`) for cases that specifically
|
|
3602
|
+
// need the branch fast-forwarded/discarded to `origin/<mainRef>`, not just
|
|
3603
|
+
// cleaned. Resolution precedence lives in `resolveLiveCheckoutAutoReset`
|
|
3604
|
+
// (UNCHANGED by this flip). Fires ONLY on confirmed dirty-tree detection,
|
|
3605
|
+
// never on mid-operation / blob-fetch / tooling failures.
|
|
3606
|
+
liveCheckoutAutoReset: false,
|
|
3601
3607
|
orphanHolderScanTimeoutMs: 5000, // 5s ceiling for the cross-platform holder scan (PowerShell / /proc walk / lsof)
|
|
3602
3608
|
ccMaxTurns: 50, // max tool-use turns per CC/doc-chat call before CLI stops (per response, not per session)
|
|
3603
3609
|
ccWorkerIdleTimeoutMs: 30 * 60 * 1000, // W-mr0qs0vw: idle-reaper window for the persistent `copilot --acp` worker pool (engine/cc-worker-pool.js). After this much inactivity with no in-flight turn the warm ACP process is killed; the next message cold-spawns a fresh session with NO memory of prior turns (CC shows a "context cleared after inactivity" notice). Tradeoff: shorter = less idle memory/process footprint, longer = more context durability across gaps between messages. Wired into the pool via ccWorkerPool.setIdleTimeoutMs() on every reloadConfig(); clamped to [60000, 28800000] (1min–8h) in the settings POST handler.
|
|
@@ -7900,536 +7906,10 @@ function autoEnrollPrFromWorkItem(item, project, minionsDir) {
|
|
|
7900
7906
|
// Backward-compat alias — external callers that reference autoEnrollPrFromFixWorkItem keep working.
|
|
7901
7907
|
const autoEnrollPrFromFixWorkItem = autoEnrollPrFromWorkItem;
|
|
7902
7908
|
|
|
7903
|
-
//
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
7908
|
-
}
|
|
7909
|
-
|
|
7910
|
-
function unixChildPids(pid) {
|
|
7911
|
-
if (!Number.isInteger(pid) || pid <= 0) return [];
|
|
7912
|
-
try {
|
|
7913
|
-
return _execSync(`pgrep -P ${pid}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 })
|
|
7914
|
-
.split(/\r?\n/)
|
|
7915
|
-
.map(line => Number(line.trim()))
|
|
7916
|
-
.filter(childPid => Number.isInteger(childPid) && childPid > 0 && childPid !== pid);
|
|
7917
|
-
} catch {
|
|
7918
|
-
return [];
|
|
7919
|
-
}
|
|
7920
|
-
}
|
|
7921
|
-
|
|
7922
|
-
function killUnixProcessTree(pid, signal, seen = new Set()) {
|
|
7923
|
-
if (!Number.isInteger(pid) || pid <= 0 || seen.has(pid)) return;
|
|
7924
|
-
seen.add(pid);
|
|
7925
|
-
for (const childPid of unixChildPids(pid)) {
|
|
7926
|
-
killUnixProcessTree(childPid, signal, seen);
|
|
7927
|
-
}
|
|
7928
|
-
try { process.kill(pid, signal); } catch { /* process may be dead */ }
|
|
7929
|
-
}
|
|
7930
|
-
|
|
7931
|
-
function unrefTimer(timer) {
|
|
7932
|
-
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
7933
|
-
return timer;
|
|
7934
|
-
}
|
|
7935
|
-
|
|
7936
|
-
function killGracefully(proc, graceMs = 5000) {
|
|
7937
|
-
const pid = normalizeKillPid(proc);
|
|
7938
|
-
if (!pid) return;
|
|
7939
|
-
if (process.platform === 'win32') {
|
|
7940
|
-
try { _execSync(`taskkill /PID ${pid} /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
|
|
7941
|
-
unrefTimer(setTimeout(() => {
|
|
7942
|
-
try { _execSync(`taskkill /PID ${pid} /F /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
|
|
7943
|
-
}, graceMs));
|
|
7944
|
-
} else {
|
|
7945
|
-
killUnixProcessTree(pid, 'SIGTERM');
|
|
7946
|
-
unrefTimer(setTimeout(() => {
|
|
7947
|
-
killUnixProcessTree(pid, 'SIGKILL');
|
|
7948
|
-
}, graceMs));
|
|
7949
|
-
}
|
|
7950
|
-
}
|
|
7951
|
-
|
|
7952
|
-
function killImmediate(proc) {
|
|
7953
|
-
const pid = normalizeKillPid(proc);
|
|
7954
|
-
if (!pid) return;
|
|
7955
|
-
if (process.platform === 'win32') {
|
|
7956
|
-
try { _execSync(`taskkill /PID ${pid} /F /T`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); } catch { /* process may be dead */ }
|
|
7957
|
-
} else {
|
|
7958
|
-
killUnixProcessTree(pid, 'SIGKILL');
|
|
7959
|
-
}
|
|
7960
|
-
}
|
|
7961
|
-
|
|
7962
|
-
// Decide whether a Windows `tasklist /FI "PID eq <pid>" /NH` output proves the
|
|
7963
|
-
// pid is alive. A bare `out.includes(String(pid))` can read a DEAD pid as alive
|
|
7964
|
-
// on a digit-substring collision — the pid appears inside another column (a
|
|
7965
|
-
// larger PID, a memory-KB figure, a session id). Require a word-boundary match
|
|
7966
|
-
// (`\b<pid>\b`) and, when an `imageName` is supplied, that the expected process
|
|
7967
|
-
// image is present too. Mirrors the hardened check in engine/restart-health.js.
|
|
7968
|
-
// Pure (no shell-out) so callers can pass captured output and unit tests can
|
|
7969
|
-
// feed crafted samples.
|
|
7970
|
-
function tasklistOutputShowsPid(out, pid, { imageName } = {}) {
|
|
7971
|
-
if (!out) return false;
|
|
7972
|
-
const n = Number(pid);
|
|
7973
|
-
if (!Number.isInteger(n) || n <= 0) return false;
|
|
7974
|
-
if (!new RegExp(`\\b${n}\\b`).test(out)) return false;
|
|
7975
|
-
if (imageName && !out.toLowerCase().includes(String(imageName).toLowerCase())) return false;
|
|
7976
|
-
return true;
|
|
7977
|
-
}
|
|
7978
|
-
|
|
7979
|
-
// W-mq0e2dae000a003d — cross-platform CPU-seconds sampler used by the
|
|
7980
|
-
// spawn-phase watchdog to decide whether a process is genuinely wedged
|
|
7981
|
-
// vs busy. Returns the cumulative user+system CPU time in seconds, or
|
|
7982
|
-
// null when we can't determine it (process dead, sampler unavailable,
|
|
7983
|
-
// shell errored). Fail-open: a null result MUST NOT cause the watchdog
|
|
7984
|
-
// to fire — silence-from-the-OS is not the same as silence-from-the-process.
|
|
7985
|
-
function getProcessCpuSeconds(pid) {
|
|
7986
|
-
const n = Number(pid);
|
|
7987
|
-
if (!Number.isInteger(n) || n <= 0) return null;
|
|
7988
|
-
try {
|
|
7989
|
-
if (process.platform === 'win32') {
|
|
7990
|
-
// PowerShell. CPU = total processor time in seconds (sum of user + kernel).
|
|
7991
|
-
// 2.5s timeout keeps the tick budget bounded if PS is sluggish.
|
|
7992
|
-
const out = _execSync(
|
|
7993
|
-
`powershell -NoProfile -NonInteractive -Command "(Get-Process -Id ${n} -ErrorAction Stop).CPU"`,
|
|
7994
|
-
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 2500, windowsHide: true, encoding: 'utf8' }
|
|
7995
|
-
);
|
|
7996
|
-
const v = parseFloat(String(out).trim());
|
|
7997
|
-
return Number.isFinite(v) ? v : null;
|
|
7998
|
-
}
|
|
7999
|
-
if (process.platform === 'linux') {
|
|
8000
|
-
// /proc/<pid>/stat fields 14 (utime) + 15 (stime) in clock ticks.
|
|
8001
|
-
// _SC_CLK_TCK is 100 on virtually every Linux distro; sysconf isn't
|
|
8002
|
-
// exposed from Node so we use the conventional value. If a host
|
|
8003
|
-
// ever ships USER_HZ != 100 this would under/over-report by a
|
|
8004
|
-
// constant factor; safe within an order of magnitude for our gate.
|
|
8005
|
-
const raw = fs.readFileSync(`/proc/${n}/stat`, 'utf8');
|
|
8006
|
-
// The comm field can contain spaces and parens — slice past the last ')'
|
|
8007
|
-
const close = raw.lastIndexOf(')');
|
|
8008
|
-
if (close < 0) return null;
|
|
8009
|
-
const fields = raw.slice(close + 2).split(/\s+/);
|
|
8010
|
-
// After comm: state(0) ppid(1) pgrp(2) ... utime is index 11, stime 12
|
|
8011
|
-
const utime = Number(fields[11]);
|
|
8012
|
-
const stime = Number(fields[12]);
|
|
8013
|
-
if (!Number.isFinite(utime) || !Number.isFinite(stime)) return null;
|
|
8014
|
-
return (utime + stime) / 100;
|
|
8015
|
-
}
|
|
8016
|
-
if (process.platform === 'darwin') {
|
|
8017
|
-
// `ps -p N -o cputime=` → "MM:SS.ss" or "HH:MM:SS"
|
|
8018
|
-
const out = _execSync(`ps -p ${n} -o cputime=`, { stdio: ['ignore', 'pipe', 'pipe'], timeout: 2500, windowsHide: true, encoding: 'utf8' });
|
|
8019
|
-
const t = String(out).trim();
|
|
8020
|
-
if (!t) return null;
|
|
8021
|
-
const parts = t.split(':').map(s => parseFloat(s));
|
|
8022
|
-
if (!parts.every(p => Number.isFinite(p))) return null;
|
|
8023
|
-
let secs = 0;
|
|
8024
|
-
while (parts.length) secs = secs * 60 + parts.shift();
|
|
8025
|
-
return secs;
|
|
8026
|
-
}
|
|
8027
|
-
} catch { /* fail-open */ }
|
|
8028
|
-
return null;
|
|
8029
|
-
}
|
|
8030
|
-
|
|
8031
|
-
// Single-PID kill (no /T tree walk) — used by the orphan-MCP sweep where we
|
|
8032
|
-
// already enumerated descendants ourselves and the parent is dead, so /T would
|
|
8033
|
-
// be a no-op anyway.
|
|
8034
|
-
function killByPidImmediate(pid) {
|
|
8035
|
-
const n = Number(pid);
|
|
8036
|
-
if (!Number.isInteger(n) || n <= 0 || n === process.pid) return false;
|
|
8037
|
-
if (process.platform === 'win32') {
|
|
8038
|
-
try { _execSync(`taskkill /PID ${n} /F`, { stdio: 'pipe', timeout: 3000, windowsHide: true }); return true; }
|
|
8039
|
-
catch { return false; }
|
|
8040
|
-
}
|
|
8041
|
-
try { process.kill(n, 'SIGKILL'); return true; } catch { return false; }
|
|
8042
|
-
}
|
|
8043
|
-
|
|
8044
|
-
// Batched kill — one OS process for N PIDs. `taskkill` accepts repeated /PID
|
|
8045
|
-
// flags natively; on Unix we still loop process.kill, which is in-process and
|
|
8046
|
-
// cheap. Returns the count of successful kills.
|
|
8047
|
-
function killByPidsImmediate(pids) {
|
|
8048
|
-
const valid = (Array.isArray(pids) ? pids : [])
|
|
8049
|
-
.map(Number)
|
|
8050
|
-
.filter(n => Number.isInteger(n) && n > 0 && n !== process.pid);
|
|
8051
|
-
if (!valid.length) return 0;
|
|
8052
|
-
if (process.platform === 'win32') {
|
|
8053
|
-
const flags = valid.map(p => `/PID ${p}`).join(' ');
|
|
8054
|
-
try { _execSync(`taskkill /F ${flags}`, { stdio: 'pipe', timeout: 5000, windowsHide: true }); return valid.length; }
|
|
8055
|
-
catch {
|
|
8056
|
-
let killed = 0;
|
|
8057
|
-
for (const p of valid) { if (killByPidImmediate(p)) killed++; }
|
|
8058
|
-
return killed;
|
|
8059
|
-
}
|
|
8060
|
-
}
|
|
8061
|
-
let killed = 0;
|
|
8062
|
-
for (const p of valid) {
|
|
8063
|
-
try { process.kill(p, 'SIGKILL'); killed++; } catch { /* dead */ }
|
|
8064
|
-
}
|
|
8065
|
-
return killed;
|
|
8066
|
-
}
|
|
8067
|
-
|
|
8068
|
-
// ─── Process Table Enumeration ───────────────────────────────────────────────
|
|
8069
|
-
// Cross-platform listing of every live process as { pid, ppid, name, cmd? }.
|
|
8070
|
-
// `cmd` is best-effort — included on Windows (via wmic) and Linux (/proc); may
|
|
8071
|
-
// be empty on macOS without ps -ww.
|
|
8072
|
-
|
|
8073
|
-
function _parseWmicCsv(text) {
|
|
8074
|
-
const lines = String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
8075
|
-
if (lines.length < 2) return [];
|
|
8076
|
-
const header = lines[0].split(',');
|
|
8077
|
-
const idx = (col) => header.findIndex(h => h.toLowerCase() === col.toLowerCase());
|
|
8078
|
-
const nameIdx = idx('Name');
|
|
8079
|
-
const ppidIdx = idx('ParentProcessId');
|
|
8080
|
-
const pidIdx = idx('ProcessId');
|
|
8081
|
-
const cmdIdx = idx('CommandLine');
|
|
8082
|
-
if (nameIdx < 0 || ppidIdx < 0 || pidIdx < 0) return [];
|
|
8083
|
-
const out = [];
|
|
8084
|
-
for (let i = 1; i < lines.length; i++) {
|
|
8085
|
-
// CommandLine can contain commas — naive split is fine because Name + PIDs
|
|
8086
|
-
// sit at fixed positions and CommandLine, when present, is the LAST column.
|
|
8087
|
-
const cols = lines[i].split(',');
|
|
8088
|
-
if (cols.length < 4) continue;
|
|
8089
|
-
const pid = parseInt(cols[pidIdx], 10);
|
|
8090
|
-
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
8091
|
-
const ppid = parseInt(cols[ppidIdx], 10);
|
|
8092
|
-
const name = cols[nameIdx] || '';
|
|
8093
|
-
const cmd = cmdIdx >= 0 ? cols.slice(cmdIdx).join(',') : '';
|
|
8094
|
-
out.push({ pid, ppid: Number.isInteger(ppid) ? ppid : 0, name, cmd });
|
|
8095
|
-
}
|
|
8096
|
-
return out;
|
|
8097
|
-
}
|
|
8098
|
-
|
|
8099
|
-
// PowerShell Get-CimInstance is the modern path (wmic is removed on Win11
|
|
8100
|
-
// 24H2+). We try it first and fall back to wmic for older Windows hosts.
|
|
8101
|
-
function _psListProcesses() {
|
|
8102
|
-
const script = "Get-CimInstance Win32_Process | Select-Object Name,ParentProcessId,ProcessId,CommandLine | ConvertTo-Json -Compress -Depth 2";
|
|
8103
|
-
try {
|
|
8104
|
-
const out = _execSync(
|
|
8105
|
-
`powershell -NoProfile -NonInteractive -Command "${script}"`,
|
|
8106
|
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
|
|
8107
|
-
);
|
|
8108
|
-
const parsed = JSON.parse(out);
|
|
8109
|
-
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
8110
|
-
return arr.map(p => ({
|
|
8111
|
-
pid: Number(p.ProcessId),
|
|
8112
|
-
ppid: Number(p.ParentProcessId) || 0,
|
|
8113
|
-
name: p.Name || '',
|
|
8114
|
-
cmd: p.CommandLine || '',
|
|
8115
|
-
})).filter(p => Number.isInteger(p.pid) && p.pid > 0);
|
|
8116
|
-
} catch { return null; }
|
|
8117
|
-
}
|
|
8118
|
-
|
|
8119
|
-
function _winListProcesses() {
|
|
8120
|
-
const ps = _psListProcesses();
|
|
8121
|
-
if (ps && ps.length) return ps;
|
|
8122
|
-
try {
|
|
8123
|
-
const out = _execSync(
|
|
8124
|
-
'wmic process get Name,ParentProcessId,ProcessId,CommandLine /format:csv',
|
|
8125
|
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
|
|
8126
|
-
);
|
|
8127
|
-
return _parseWmicCsv(out);
|
|
8128
|
-
} catch { return []; }
|
|
8129
|
-
}
|
|
8130
|
-
|
|
8131
|
-
function _unixListProcesses() {
|
|
8132
|
-
try {
|
|
8133
|
-
const out = _execSync(
|
|
8134
|
-
'ps -A -o pid=,ppid=,comm=',
|
|
8135
|
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }
|
|
8136
|
-
);
|
|
8137
|
-
return out.split(/\r?\n/).map(line => {
|
|
8138
|
-
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/);
|
|
8139
|
-
if (!m) return null;
|
|
8140
|
-
return { pid: parseInt(m[1], 10), ppid: parseInt(m[2], 10), name: m[3], cmd: '' };
|
|
8141
|
-
}).filter(Boolean);
|
|
8142
|
-
} catch { return []; }
|
|
8143
|
-
}
|
|
8144
|
-
|
|
8145
|
-
function listAllProcesses() {
|
|
8146
|
-
return process.platform === 'win32' ? _winListProcesses() : _unixListProcesses();
|
|
8147
|
-
}
|
|
8148
|
-
|
|
8149
|
-
// W-mq6f2fe0000557fa — orphan-worktree GC: identify OS processes whose cwd
|
|
8150
|
-
// is at or inside `dir`. Cross-platform with a fail-open contract: on any
|
|
8151
|
-
// error or timeout we return [] (the caller continues with its existing
|
|
8152
|
-
// escalation path and an unenriched note).
|
|
8153
|
-
//
|
|
8154
|
-
// Return shape: [{ pid, cmdline, startedAt, ageMs }, ...]
|
|
8155
|
-
// - pid: numeric OS pid
|
|
8156
|
-
// - cmdline: full command line as a single string (best-effort; possibly truncated)
|
|
8157
|
-
// - startedAt: ms-since-epoch process start time (0 when unknown)
|
|
8158
|
-
// - ageMs: Date.now() - startedAt (0 when startedAt is 0)
|
|
8159
|
-
//
|
|
8160
|
-
// Platform notes:
|
|
8161
|
-
// - Windows: PowerShell `Get-CimInstance Win32_Process` filtered by the
|
|
8162
|
-
// worktree basename appearing in CommandLine. (Win32_Process does not
|
|
8163
|
-
// expose the working directory; the basename match is the most-reliable
|
|
8164
|
-
// heuristic — every spawn-agent invocation embeds the dispatch id, which
|
|
8165
|
-
// is the worktree basename, in its argv.)
|
|
8166
|
-
// - Linux: walk `/proc/*/cwd` and keep PIDs whose readlinkSync resolves
|
|
8167
|
-
// under `dir`. Cmdline read from `/proc/<pid>/cmdline` (NUL-separated).
|
|
8168
|
-
// Start time derived from stat() mtime of the cwd entry as a proxy.
|
|
8169
|
-
// - macOS: `lsof -F p -d cwd` filtered by directory, then `ps -p <pid>` for
|
|
8170
|
-
// cmdline + start time.
|
|
8171
|
-
function findProcessesWithCwdInside(dir, opts = {}) {
|
|
8172
|
-
if (!dir || typeof dir !== 'string') return [];
|
|
8173
|
-
let resolved;
|
|
8174
|
-
try { resolved = path.resolve(dir); } catch { return []; }
|
|
8175
|
-
if (!resolved) return [];
|
|
8176
|
-
const timeoutMs = Number(opts.timeoutMs) > 0
|
|
8177
|
-
? Number(opts.timeoutMs)
|
|
8178
|
-
: (ENGINE_DEFAULTS.orphanHolderScanTimeoutMs || 5000);
|
|
8179
|
-
const now = Date.now();
|
|
8180
|
-
|
|
8181
|
-
try {
|
|
8182
|
-
if (process.platform === 'win32') {
|
|
8183
|
-
return _findWindowsProcessesWithCwdInside(resolved, timeoutMs, now);
|
|
8184
|
-
}
|
|
8185
|
-
if (process.platform === 'linux') {
|
|
8186
|
-
return _findLinuxProcessesWithCwdInside(resolved, timeoutMs, now);
|
|
8187
|
-
}
|
|
8188
|
-
return _findMacProcessesWithCwdInside(resolved, timeoutMs, now);
|
|
8189
|
-
} catch { return []; }
|
|
8190
|
-
}
|
|
8191
|
-
|
|
8192
|
-
function _findWindowsProcessesWithCwdInside(resolved, timeoutMs, now) {
|
|
8193
|
-
// Win32_Process exposes CommandLine but not the cwd. Match by basename of
|
|
8194
|
-
// the worktree dir (the dispatch id like W-mq1k8z6o003acd89) appearing in
|
|
8195
|
-
// the cmdline — every spawn-agent prompt-file path embeds it.
|
|
8196
|
-
const basename = path.basename(resolved);
|
|
8197
|
-
if (!basename || basename.length < 3) return [];
|
|
8198
|
-
// PowerShell-quote: outer escape ' as ''
|
|
8199
|
-
const psBasename = basename.replace(/'/g, "''");
|
|
8200
|
-
const psResolved = resolved.replace(/'/g, "''");
|
|
8201
|
-
// Use -like with wildcards on BOTH basename and full path so a process
|
|
8202
|
-
// whose cmdline carries the worktree dir (but a different basename
|
|
8203
|
-
// anywhere in argv) also matches.
|
|
8204
|
-
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`;
|
|
8205
|
-
let raw;
|
|
8206
|
-
try {
|
|
8207
|
-
raw = _execSync(
|
|
8208
|
-
`powershell -NoProfile -NonInteractive -Command "${script}"`,
|
|
8209
|
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
|
|
8210
|
-
);
|
|
8211
|
-
} catch { return []; }
|
|
8212
|
-
if (!raw || !String(raw).trim()) return [];
|
|
8213
|
-
let parsed;
|
|
8214
|
-
try { parsed = JSON.parse(raw); }
|
|
8215
|
-
catch { return []; }
|
|
8216
|
-
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
8217
|
-
const out = [];
|
|
8218
|
-
for (const r of arr) {
|
|
8219
|
-
if (!r) continue;
|
|
8220
|
-
const pid = Number(r.pid);
|
|
8221
|
-
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
8222
|
-
const startedAt = Number(r.startedAt) || 0;
|
|
8223
|
-
out.push({
|
|
8224
|
-
pid,
|
|
8225
|
-
cmdline: r.cmdline ? String(r.cmdline) : '',
|
|
8226
|
-
startedAt,
|
|
8227
|
-
ageMs: startedAt > 0 ? Math.max(0, now - startedAt) : 0,
|
|
8228
|
-
});
|
|
8229
|
-
}
|
|
8230
|
-
return out;
|
|
8231
|
-
}
|
|
8232
|
-
|
|
8233
|
-
function _findLinuxProcessesWithCwdInside(resolved, _timeoutMs, now) {
|
|
8234
|
-
// /proc walk: scan numeric entries, readlink cwd, keep matches.
|
|
8235
|
-
let entries;
|
|
8236
|
-
try { entries = fs.readdirSync('/proc'); }
|
|
8237
|
-
catch { return []; }
|
|
8238
|
-
const prefix = resolved + path.sep;
|
|
8239
|
-
const out = [];
|
|
8240
|
-
for (const e of entries) {
|
|
8241
|
-
if (!/^\d+$/.test(e)) continue;
|
|
8242
|
-
const pid = Number(e);
|
|
8243
|
-
let cwd;
|
|
8244
|
-
try { cwd = fs.readlinkSync(`/proc/${pid}/cwd`); }
|
|
8245
|
-
catch { continue; }
|
|
8246
|
-
if (!cwd) continue;
|
|
8247
|
-
if (cwd !== resolved && !cwd.startsWith(prefix)) continue;
|
|
8248
|
-
let cmdline = '';
|
|
8249
|
-
try {
|
|
8250
|
-
const buf = fs.readFileSync(`/proc/${pid}/cmdline`);
|
|
8251
|
-
cmdline = buf.toString('utf8').replace(/\0/g, ' ').trim();
|
|
8252
|
-
} catch { /* cmdline read can fail on race */ }
|
|
8253
|
-
let startedAt = 0;
|
|
8254
|
-
try {
|
|
8255
|
-
const st = fs.statSync(`/proc/${pid}`);
|
|
8256
|
-
startedAt = st && st.ctimeMs ? Math.floor(st.ctimeMs) : 0;
|
|
8257
|
-
} catch { /* stat can fail on race */ }
|
|
8258
|
-
out.push({
|
|
8259
|
-
pid,
|
|
8260
|
-
cmdline,
|
|
8261
|
-
startedAt,
|
|
8262
|
-
ageMs: startedAt > 0 ? Math.max(0, now - startedAt) : 0,
|
|
8263
|
-
});
|
|
8264
|
-
}
|
|
8265
|
-
return out;
|
|
8266
|
-
}
|
|
8267
|
-
|
|
8268
|
-
function _findMacProcessesWithCwdInside(resolved, timeoutMs, now) {
|
|
8269
|
-
// `lsof -a -d cwd -F pn` emits PID then cwd path. Filter for dir matches,
|
|
8270
|
-
// then run a single `ps` pass to enrich cmdline + start time.
|
|
8271
|
-
let raw;
|
|
8272
|
-
try {
|
|
8273
|
-
raw = _execSync('lsof -a -d cwd -F pn 2>/dev/null', {
|
|
8274
|
-
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024,
|
|
8275
|
-
});
|
|
8276
|
-
} catch { return []; }
|
|
8277
|
-
if (!raw) return [];
|
|
8278
|
-
const prefix = resolved + path.sep;
|
|
8279
|
-
const pids = new Set();
|
|
8280
|
-
let currentPid = null;
|
|
8281
|
-
for (const line of String(raw).split(/\r?\n/)) {
|
|
8282
|
-
if (!line) continue;
|
|
8283
|
-
const tag = line[0];
|
|
8284
|
-
const val = line.slice(1);
|
|
8285
|
-
if (tag === 'p') {
|
|
8286
|
-
const n = Number(val);
|
|
8287
|
-
currentPid = Number.isInteger(n) && n > 0 ? n : null;
|
|
8288
|
-
} else if (tag === 'n' && currentPid != null) {
|
|
8289
|
-
if (val === resolved || val.startsWith(prefix)) pids.add(currentPid);
|
|
8290
|
-
}
|
|
8291
|
-
}
|
|
8292
|
-
if (pids.size === 0) return [];
|
|
8293
|
-
const out = [];
|
|
8294
|
-
for (const pid of pids) {
|
|
8295
|
-
let line;
|
|
8296
|
-
try {
|
|
8297
|
-
line = String(_execSync(`ps -p ${pid} -o lstart=,command=`, {
|
|
8298
|
-
stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, encoding: 'utf8',
|
|
8299
|
-
}) || '').trim();
|
|
8300
|
-
} catch { line = ''; }
|
|
8301
|
-
let startedAt = 0;
|
|
8302
|
-
let cmdline = '';
|
|
8303
|
-
if (line) {
|
|
8304
|
-
// ps lstart= emits "Mon Jun 9 07:04:33 2026" (24 chars), then cmdline.
|
|
8305
|
-
const lstart = line.slice(0, 24).trim();
|
|
8306
|
-
cmdline = line.slice(24).trim();
|
|
8307
|
-
const parsed = Date.parse(lstart);
|
|
8308
|
-
if (!Number.isNaN(parsed)) startedAt = parsed;
|
|
8309
|
-
}
|
|
8310
|
-
out.push({
|
|
8311
|
-
pid,
|
|
8312
|
-
cmdline,
|
|
8313
|
-
startedAt,
|
|
8314
|
-
ageMs: startedAt > 0 ? Math.max(0, now - startedAt) : 0,
|
|
8315
|
-
});
|
|
8316
|
-
}
|
|
8317
|
-
return out;
|
|
8318
|
-
}
|
|
8319
|
-
|
|
8320
|
-
// W-mqila0t5 — Windows-only PEB-based CWD probe used by the worktree-holder
|
|
8321
|
-
// reaper. Unlike findProcessesWithCwdInside (whose Windows path is a
|
|
8322
|
-
// CommandLine-basename heuristic — Win32_Process does NOT expose the working
|
|
8323
|
-
// directory), this reads each process's REAL current directory out of its PEB
|
|
8324
|
-
// (NtQueryInformationProcess → PebBaseAddress, then ReadProcessMemory of
|
|
8325
|
-
// RTL_USER_PROCESS_PARAMETERS.CurrentDirectory.DosPath). That's the only way
|
|
8326
|
-
// to find a process that pins a directory via its CWD — e.g. an orphaned
|
|
8327
|
-
// copilot.exe / cmd.exe / node.exe agent child whose cwd is the worktree root
|
|
8328
|
-
// but whose argv never mentions the path (so the cmdline heuristic
|
|
8329
|
-
// false-negatives, and the Restart Manager / file-handle detectors report ZERO
|
|
8330
|
-
// lockers because the lock is a DIRECTORY handle, not a file handle).
|
|
8331
|
-
//
|
|
8332
|
-
// Returns [{ pid, name, cwd }] for processes whose CWD is at-or-under
|
|
8333
|
-
// `worktreePath`. POSIX returns [] (the EBUSY race is Windows-specific; POSIX
|
|
8334
|
-
// reaping stays a no-op). Fail-open: any error / timeout / spawn failure →
|
|
8335
|
-
// [] (reap nothing). x64 offsets only (PebBaseAddress@8, PEB+0x20 →
|
|
8336
|
-
// ProcessParameters, RTL_USER_PROCESS_PARAMETERS+0x38 → CurrentDirectory).
|
|
8337
|
-
function _windowsCwdProbeScript(psTarget) {
|
|
8338
|
-
return [
|
|
8339
|
-
"$ErrorActionPreference='SilentlyContinue'",
|
|
8340
|
-
`$target = '${psTarget}'.TrimEnd('\\').ToLower()`,
|
|
8341
|
-
'Add-Type -Namespace MinionsPeb -Name Native -MemberDefinition @"',
|
|
8342
|
-
'[DllImport("ntdll.dll")] public static extern int NtQueryInformationProcess(IntPtr h, int cls, byte[] info, int len, ref int ret);',
|
|
8343
|
-
'[DllImport("kernel32.dll")] public static extern IntPtr OpenProcess(int access, bool inherit, int pid);',
|
|
8344
|
-
'[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);',
|
|
8345
|
-
'[DllImport("kernel32.dll")] public static extern bool ReadProcessMemory(IntPtr h, IntPtr baseAddr, byte[] buf, int size, ref int read);',
|
|
8346
|
-
'"@',
|
|
8347
|
-
'function Get-ProcCwd($procId) {',
|
|
8348
|
-
' $h = [MinionsPeb.Native]::OpenProcess(0x410, $false, $procId)',
|
|
8349
|
-
' if ($h -eq [IntPtr]::Zero) { return $null }',
|
|
8350
|
-
' try {',
|
|
8351
|
-
' $pbi = New-Object byte[] 48; $ret = 0',
|
|
8352
|
-
' if ([MinionsPeb.Native]::NtQueryInformationProcess($h, 0, $pbi, 48, [ref]$ret) -ne 0) { return $null }',
|
|
8353
|
-
' $pebBase = [System.BitConverter]::ToInt64($pbi, 8)',
|
|
8354
|
-
' if ($pebBase -eq 0) { return $null }',
|
|
8355
|
-
' $p8 = New-Object byte[] 8; $r = 0',
|
|
8356
|
-
' if (-not [MinionsPeb.Native]::ReadProcessMemory($h, [IntPtr]($pebBase + 0x20), $p8, 8, [ref]$r)) { return $null }',
|
|
8357
|
-
' $params = [System.BitConverter]::ToInt64($p8, 0)',
|
|
8358
|
-
' if ($params -eq 0) { return $null }',
|
|
8359
|
-
' $us = New-Object byte[] 16',
|
|
8360
|
-
' if (-not [MinionsPeb.Native]::ReadProcessMemory($h, [IntPtr]($params + 0x38), $us, 16, [ref]$r)) { return $null }',
|
|
8361
|
-
' $len = [System.BitConverter]::ToUInt16($us, 0)',
|
|
8362
|
-
' $bufPtr = [System.BitConverter]::ToInt64($us, 8)',
|
|
8363
|
-
' if ($len -le 0 -or $bufPtr -eq 0) { return $null }',
|
|
8364
|
-
' $sb = New-Object byte[] $len',
|
|
8365
|
-
' if (-not [MinionsPeb.Native]::ReadProcessMemory($h, [IntPtr]$bufPtr, $sb, $len, [ref]$r)) { return $null }',
|
|
8366
|
-
' return [System.Text.Encoding]::Unicode.GetString($sb, 0, $len)',
|
|
8367
|
-
' } finally { [void][MinionsPeb.Native]::CloseHandle($h) }',
|
|
8368
|
-
'}',
|
|
8369
|
-
'foreach ($p in Get-Process) {',
|
|
8370
|
-
' $cwd = Get-ProcCwd $p.Id',
|
|
8371
|
-
' if (-not $cwd) { continue }',
|
|
8372
|
-
" $c = $cwd.TrimEnd('\\').ToLower()",
|
|
8373
|
-
" if ($c -eq $target -or $c.StartsWith($target + '\\')) {",
|
|
8374
|
-
' Write-Output ("{0}|{1}|{2}" -f $p.Id, $p.ProcessName, $cwd)',
|
|
8375
|
-
' }',
|
|
8376
|
-
'}',
|
|
8377
|
-
].join('\n');
|
|
8378
|
-
}
|
|
8379
|
-
|
|
8380
|
-
function _parseCwdHolderLines(raw, resolved) {
|
|
8381
|
-
if (!raw || !String(raw).trim()) return [];
|
|
8382
|
-
const prefix = (resolved.replace(/[\\/]+$/g, '') + path.sep).toLowerCase();
|
|
8383
|
-
const target = resolved.replace(/[\\/]+$/g, '').toLowerCase();
|
|
8384
|
-
const out = [];
|
|
8385
|
-
for (const line of String(raw).split(/\r?\n/)) {
|
|
8386
|
-
const t = line.trim();
|
|
8387
|
-
if (!t) continue;
|
|
8388
|
-
const parts = t.split('|');
|
|
8389
|
-
if (parts.length < 3) continue;
|
|
8390
|
-
const pid = Number(parts[0]);
|
|
8391
|
-
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
8392
|
-
const name = parts[1] || '';
|
|
8393
|
-
const cwd = parts.slice(2).join('|');
|
|
8394
|
-
// Defense-in-depth: re-confirm the cwd is at/under THIS worktree so a
|
|
8395
|
-
// sibling worktree's live agent is never reaped (the script filters too).
|
|
8396
|
-
const cwdLower = cwd.replace(/[\\/]+$/g, '').toLowerCase();
|
|
8397
|
-
if (cwdLower !== target && !cwdLower.startsWith(prefix)) continue;
|
|
8398
|
-
out.push({ pid, name, cwd });
|
|
8399
|
-
}
|
|
8400
|
-
return out;
|
|
8401
|
-
}
|
|
8402
|
-
|
|
8403
|
-
function findProcessCwdHolders(worktreePath, opts = {}) {
|
|
8404
|
-
if (process.platform !== 'win32') return [];
|
|
8405
|
-
if (!worktreePath || typeof worktreePath !== 'string') return [];
|
|
8406
|
-
let resolved;
|
|
8407
|
-
try { resolved = path.resolve(worktreePath); } catch { return []; }
|
|
8408
|
-
if (!resolved) return [];
|
|
8409
|
-
const timeoutMs = Number(opts.timeoutMs) > 0
|
|
8410
|
-
? Number(opts.timeoutMs)
|
|
8411
|
-
: (ENGINE_DEFAULTS.statusProbeKillTimeoutMs || 12000);
|
|
8412
|
-
const psTarget = resolved.replace(/'/g, "''");
|
|
8413
|
-
const script = _windowsCwdProbeScript(psTarget);
|
|
8414
|
-
// Run from a temp .ps1 file to avoid fragile inline-quote escaping of the
|
|
8415
|
-
// embedded C#. Fail-open at every step.
|
|
8416
|
-
let scriptPath = null;
|
|
8417
|
-
let raw;
|
|
8418
|
-
try {
|
|
8419
|
-
const dir = _dispatchTmpRoot();
|
|
8420
|
-
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* exists */ }
|
|
8421
|
-
scriptPath = path.join(dir, `cwd-probe-${process.pid}-${Date.now()}.ps1`);
|
|
8422
|
-
fs.writeFileSync(scriptPath, script, 'utf8');
|
|
8423
|
-
raw = _execSync(
|
|
8424
|
-
`powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}"`,
|
|
8425
|
-
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
|
|
8426
|
-
);
|
|
8427
|
-
} catch { raw = ''; /* ETIMEDOUT / spawn fail / write fail → reap nothing */ }
|
|
8428
|
-
finally {
|
|
8429
|
-
if (scriptPath) { try { fs.unlinkSync(scriptPath); } catch { /* best-effort */ } }
|
|
8430
|
-
}
|
|
8431
|
-
return _parseCwdHolderLines(raw, resolved);
|
|
8432
|
-
}
|
|
7909
|
+
// Cross-platform process-management helpers (kill trees, PID liveness,
|
|
7910
|
+
// process-table enumeration, cwd-holder detection, descendant/reachability
|
|
7911
|
+
// BFS) were extracted to engine/process-utils.js (P-mrb8yg9x001k1401-b) and
|
|
7912
|
+
// are re-exported below via `...processUtils`.
|
|
8433
7913
|
|
|
8434
7914
|
// W-mq6f2fe0000557fa — clear the per-path failure cooldown entry so the
|
|
8435
7915
|
// post-holder-reap retry can attempt removeWorktree even though the path
|
|
@@ -8443,109 +7923,10 @@ function clearWorktreeFailureCache(wtPath) {
|
|
|
8443
7923
|
} catch { return false; }
|
|
8444
7924
|
}
|
|
8445
7925
|
|
|
8446
|
-
// Cross-
|
|
8447
|
-
//
|
|
8448
|
-
//
|
|
8449
|
-
//
|
|
8450
|
-
// - engine/timeout.js: gate before parking a dispatch as still-alive when
|
|
8451
|
-
// the OS PID is alive but may belong to an unrelated recycled-PID process
|
|
8452
|
-
//
|
|
8453
|
-
// Windows: PowerShell Get-CimInstance for the full CommandLine of one PID.
|
|
8454
|
-
// Linux: /proc/<pid>/cmdline (NUL-separated).
|
|
8455
|
-
// macOS / when /proc isn't available: fallback `ps -p <pid> -o command=`.
|
|
8456
|
-
//
|
|
8457
|
-
// Returns false when the PID is invalid, the process doesn't exist, the
|
|
8458
|
-
// command line can't be read, or the cmdline contains neither `claude` nor
|
|
8459
|
-
// `copilot`. False is the safe default for both call sites: cleanup falls
|
|
8460
|
-
// through to "skip kill" and timeout falls through to "treat PID as dead".
|
|
8461
|
-
function isProcessCommandLineMatchingAgent(pid) {
|
|
8462
|
-
const n = Number(pid);
|
|
8463
|
-
if (!Number.isInteger(n) || n <= 0) return false;
|
|
8464
|
-
let cmdline = '';
|
|
8465
|
-
try {
|
|
8466
|
-
if (process.platform === 'win32') {
|
|
8467
|
-
const out = _execSync(
|
|
8468
|
-
`powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${n}').CommandLine"`,
|
|
8469
|
-
{ stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true, encoding: 'utf8' }
|
|
8470
|
-
);
|
|
8471
|
-
cmdline = String(out || '').trim();
|
|
8472
|
-
} else {
|
|
8473
|
-
try {
|
|
8474
|
-
const buf = fs.readFileSync(`/proc/${n}/cmdline`);
|
|
8475
|
-
cmdline = buf.toString('utf8').replace(/\0/g, ' ').trim();
|
|
8476
|
-
} catch {
|
|
8477
|
-
try {
|
|
8478
|
-
cmdline = String(_execSync(`ps -p ${n} -o command=`,
|
|
8479
|
-
{ stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000, encoding: 'utf8' }) || '').trim();
|
|
8480
|
-
} catch { return false; }
|
|
8481
|
-
}
|
|
8482
|
-
}
|
|
8483
|
-
} catch { return false; }
|
|
8484
|
-
if (!cmdline) return false;
|
|
8485
|
-
const lower = cmdline.toLowerCase();
|
|
8486
|
-
return lower.includes('claude') || lower.includes('copilot');
|
|
8487
|
-
}
|
|
8488
|
-
|
|
8489
|
-
function _buildChildMap(processes) {
|
|
8490
|
-
const childMap = new Map();
|
|
8491
|
-
for (const p of processes) {
|
|
8492
|
-
if (!childMap.has(p.ppid)) childMap.set(p.ppid, []);
|
|
8493
|
-
childMap.get(p.ppid).push(p.pid);
|
|
8494
|
-
}
|
|
8495
|
-
return childMap;
|
|
8496
|
-
}
|
|
8497
|
-
|
|
8498
|
-
// BFS descendants of rootPid given a process snapshot. `allProcesses` is
|
|
8499
|
-
// injectable for tests and for amortizing one snapshot across multiple
|
|
8500
|
-
// `listProcessDescendants` calls in the same tick.
|
|
8501
|
-
function listProcessDescendants(rootPid, allProcesses = null) {
|
|
8502
|
-
const root = Number(rootPid);
|
|
8503
|
-
if (!Number.isInteger(root) || root <= 0) return [];
|
|
8504
|
-
const procs = Array.isArray(allProcesses) ? allProcesses : listAllProcesses();
|
|
8505
|
-
const childMap = _buildChildMap(procs);
|
|
8506
|
-
const result = [];
|
|
8507
|
-
const seen = new Set([root]);
|
|
8508
|
-
const queue = [root];
|
|
8509
|
-
while (queue.length) {
|
|
8510
|
-
const cur = queue.shift();
|
|
8511
|
-
const children = childMap.get(cur) || [];
|
|
8512
|
-
for (const c of children) {
|
|
8513
|
-
if (seen.has(c)) continue;
|
|
8514
|
-
seen.add(c);
|
|
8515
|
-
result.push(c);
|
|
8516
|
-
queue.push(c);
|
|
8517
|
-
}
|
|
8518
|
-
}
|
|
8519
|
-
return result;
|
|
8520
|
-
}
|
|
8521
|
-
|
|
8522
|
-
// Same BFS, but starts from any number of root PIDs and returns the full reach
|
|
8523
|
-
// set (including the roots themselves). Used by the orphan-MCP sweep to compute
|
|
8524
|
-
// "everything anchored to Minions" so non-anchored MCP node processes can be
|
|
8525
|
-
// identified.
|
|
8526
|
-
function listProcessReachable(rootPids, allProcesses = null) {
|
|
8527
|
-
const roots = (Array.isArray(rootPids) ? rootPids : [rootPids])
|
|
8528
|
-
.map(Number)
|
|
8529
|
-
.filter(n => Number.isInteger(n) && n > 0);
|
|
8530
|
-
if (!roots.length) return new Set();
|
|
8531
|
-
const procs = Array.isArray(allProcesses) ? allProcesses : listAllProcesses();
|
|
8532
|
-
const childMap = _buildChildMap(procs);
|
|
8533
|
-
const seen = new Set();
|
|
8534
|
-
const queue = [];
|
|
8535
|
-
for (const r of roots) {
|
|
8536
|
-
if (!seen.has(r)) { seen.add(r); queue.push(r); }
|
|
8537
|
-
}
|
|
8538
|
-
while (queue.length) {
|
|
8539
|
-
const cur = queue.shift();
|
|
8540
|
-
const children = childMap.get(cur) || [];
|
|
8541
|
-
for (const c of children) {
|
|
8542
|
-
if (seen.has(c)) continue;
|
|
8543
|
-
seen.add(c);
|
|
8544
|
-
queue.push(c);
|
|
8545
|
-
}
|
|
8546
|
-
}
|
|
8547
|
-
return seen;
|
|
8548
|
-
}
|
|
7926
|
+
// Cross-platform process-management helpers (kill trees, PID liveness,
|
|
7927
|
+
// process-table enumeration, cwd-holder detection, descendant/reachability
|
|
7928
|
+
// BFS) were extracted to engine/process-utils.js (P-mrb8yg9x001k1401-b) and
|
|
7929
|
+
// are re-exported below via `...processUtils`.
|
|
8549
7930
|
|
|
8550
7931
|
// ─── Work Items & Pull Requests Mutation Helpers ────────────────────────────
|
|
8551
7932
|
|
|
@@ -9805,19 +9186,7 @@ module.exports = {
|
|
|
9805
9186
|
isBuildFixIneffectivePaused,
|
|
9806
9187
|
parseSkillFrontmatter,
|
|
9807
9188
|
sleepMs,
|
|
9808
|
-
killGracefully,
|
|
9809
|
-
killImmediate,
|
|
9810
|
-
tasklistOutputShowsPid,
|
|
9811
|
-
getProcessCpuSeconds,
|
|
9812
|
-
killByPidImmediate,
|
|
9813
|
-
killByPidsImmediate,
|
|
9814
|
-
isProcessCommandLineMatchingAgent,
|
|
9815
|
-
listAllProcesses,
|
|
9816
|
-
findProcessesWithCwdInside,
|
|
9817
|
-
findProcessCwdHolders,
|
|
9818
9189
|
clearWorktreeFailureCache,
|
|
9819
|
-
listProcessDescendants,
|
|
9820
|
-
listProcessReachable,
|
|
9821
9190
|
removeWorktree,
|
|
9822
9191
|
isWorktreePathLive,
|
|
9823
9192
|
getWorktreeBlockingDispatchInfo,
|
|
@@ -9833,7 +9202,6 @@ module.exports = {
|
|
|
9833
9202
|
_purgeReservedFiles, // exported for testing
|
|
9834
9203
|
_WIN_RESERVED_NAMES, // exported for testing
|
|
9835
9204
|
LOCK_STALE_MS,
|
|
9836
|
-
isPidAlive,
|
|
9837
9205
|
openAppendLogFd,
|
|
9838
9206
|
flushLogs,
|
|
9839
9207
|
redactSecrets,
|
|
@@ -9849,3 +9217,8 @@ module.exports = {
|
|
|
9849
9217
|
createBackoffTracker,
|
|
9850
9218
|
backfillPrPrdItems,
|
|
9851
9219
|
};
|
|
9220
|
+
|
|
9221
|
+
// Re-export process-management helpers extracted to engine/process-utils.js
|
|
9222
|
+
// (P-mrb8yg9x001k1401-b) so every existing require('./shared') call keeps
|
|
9223
|
+
// working unchanged.
|
|
9224
|
+
module.exports = { ...module.exports, ...processUtils };
|