@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/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
- // Default false so a fresh install behaves identically to before this knob.
3221
- liveCheckoutAutoStash: false,
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 ON discarding uncommitted dirt via reset-to-remote keeps
3596
- // live-checkout branches from diverging (the old `false` default accumulated
3597
- // `minions: auto-save agent WIP` commits on every dirty exit). Resolution
3598
- // precedence lives in `resolveLiveCheckoutAutoReset`. Fires ONLY on confirmed
3599
- // dirty-tree detection, never on mid-operation / blob-fetch / tooling failures.
3600
- liveCheckoutAutoReset: true,
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
- // ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
7904
-
7905
- function normalizeKillPid(proc) {
7906
- const pid = Number(proc?.pid);
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-check a single PID's command line for a Minions agent invocation
8447
- // (`claude` or `copilot`, including the `node spawn-agent.js --runtime <name>`
8448
- // wrapper and `gh copilot` fallback). Used by orphan/recycled-PID safety:
8449
- // - engine/cleanup.js: gate before killing a PID found in engine/tmp/pid-*.pid
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 };