@yemi33/minions 0.1.2147 → 0.1.2149

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
@@ -2352,6 +2352,30 @@ const ENGINE_DEFAULTS = {
2352
2352
  autoConsolidateMemory: false, // opt-in: periodically spawn engine/kb-sweep-runner.js from the tick loop (4h cadence). Inbox→notes consolidation already runs every tick via consolidateInbox; this flag only controls the KB sweep.
2353
2353
  prNoOpFixPauseAttempts: 2, // pause one PR automation cause after repeated no-op fixes for unchanged evidence
2354
2354
  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).
2355
+ // W-mq5n1zx5 — Layer 1a/2b: harden the quarantine rename path against
2356
+ // Windows EBUSY/EPERM races where a lingering `git.exe` descendant (leaked
2357
+ // by a status-probe timeout) still holds packfile handles when we try to
2358
+ // rename the worktree dir out of the way. With these on, the engine
2359
+ // retries the rename with jittered backoff (worst case ≈ baseMs * 2^N + N
2360
+ // jitter), then — if everything still fails — asks git to tear down its
2361
+ // own worktree record via `git worktree remove --force` so the next
2362
+ // dispatch isn't blocked by a half-renamed tree. Each knob is overridable
2363
+ // via `config.engine.<name>`.
2364
+ quarantineRenameRetryAttempts: 6,
2365
+ quarantineRenameRetryBaseMs: 250,
2366
+ quarantineForceRemoveFallback: true,
2367
+ // W-mq5n1zx5 — Layer 1b/2a: pre-empt the EBUSY race itself. (1b)
2368
+ // `--no-optional-locks` tells git to skip the .git/index.lock acquire
2369
+ // around the untracked-cache refresh in status; typical probe duration
2370
+ // under AV drops from 6–12s to <500ms, removing the timeout that
2371
+ // creates the leaked child in the first place. (2a) On Windows only,
2372
+ // before attempting the rename we walk live git.exe processes whose
2373
+ // command line points at the worktree path and Stop-Process them, so
2374
+ // a still-alive descendant from a previous status probe doesn't pin
2375
+ // packfile handles. Both gates are belt-and-suspenders — most
2376
+ // incidents are closed by (1b) alone — but both are cheap and idempotent.
2377
+ statusProbeUseNoOptionalLocks: true,
2378
+ statusProbeKillDescendantsWin32: true,
2355
2379
  completionReportRetentionDays: 90, // retain completion report sidecars beyond capped dispatch history
2356
2380
  completionReportMaxFiles: 5000, // hard cap for completion report sidecars during cleanup
2357
2381
  // P-bfa2c-cors-wildcard: extra Origins permitted to receive an
@@ -2538,6 +2562,17 @@ const ENGINE_DEFAULTS = {
2538
2562
  worktreeStuckThreshold: 10, // consecutive removal failures before escalation
2539
2563
  worktreeStuckSuppressMs: 60 * 60 * 1000, // 60min — suppress per-tick warn after escalation
2540
2564
  worktreeStuckSlowRetryMs: 30 * 60 * 1000, // 30min — slow-cadence retry window after escalation
2565
+ // W-mq6f2fe0000557fa — orphan-worktree GC: identify the OS process holding
2566
+ // a stuck worktree's cwd and, behind this opt-in flag, kill it. Default OFF
2567
+ // because killing a foreign process is destructive; flipping on rescues the
2568
+ // engine from the W-mq1k8z6o003acd89 / PID 16828 lock-spin pattern automatically.
2569
+ // Auto-reap only fires for processes whose cmdline matches `spawn-agent.js`
2570
+ // AND whose age exceeds `agentTimeout * 2` AND that have no live dispatch row
2571
+ // — i.e. unambiguous orphan agents the engine forgot about. The holder scan
2572
+ // itself ALWAYS runs on every orphan-sweep escalation (the flag only gates
2573
+ // the kill); holder details are appended to the inbox note either way.
2574
+ autoReapOrphanWorktreeHolders: false,
2575
+ orphanHolderScanTimeoutMs: 5000, // 5s ceiling for the cross-platform holder scan (PowerShell / /proc walk / lsof)
2541
2576
  ccMaxTurns: 50, // max tool-use turns per CC/doc-chat call before CLI stops (per response, not per session)
2542
2577
  ccTurnTimeoutMs: 300000, // W-mpmwxni2000c25c7-b/-d: 5min per-turn no-progress watchdog. The window resets on every liveness signal — token chunk, tool-call notification, tool-update — so an actively-streaming CC/doc-chat turn (long shell command, deep search, sub-agent loop) survives indefinitely up to the outer CC_CALL_TIMEOUT_MS (~1h) ceiling. Only true silence past this window with no progress fires the cancel: the in-flight LLM call is aborted and the handler surfaces `{code:'cc-turn-timeout', retryable:true}` via the typed error envelope so the UI can stop the spinner and offer Retry. Clamped to [10000, 3600000] in the settings POST handler. Independent of CC_CALL_TIMEOUT_MS. Non-streaming doc-chat is the lone wall-clock exception (no progress hooks); see _raceCcDocChatTimeout in dashboard.js for the dual factory/promise shape.
2543
2578
  docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity (LRU; sessions are non-expiring otherwise)
@@ -3682,6 +3717,7 @@ const FAILURE_CLASS = {
3682
3717
  WORKTREE_PREFLIGHT: 'worktree-preflight', // Pre-spawn worktree validation rejected (nested-in-project, drive-root collapse) — never retryable
3683
3718
  WORKTREE_DIRTY: 'worktree-dirty', // #2996: reused worktree had uncommitted edits and the engine could not auto-heal (or already quarantined). Non-retryable for this dispatch — next discovery cycle creates a fresh worktree.
3684
3719
  WORKTREE_DIVERGENT: 'worktree-divergent', // #2996: reused worktree's local branch was N commits ahead of origin (unsafe to reset, may contain unpushed agent work). Engine quarantined the worktree + backed up the local ref; non-retryable for this dispatch.
3720
+ WORKTREE_QUARANTINE_ENV_BLOCKED: 'worktree-quarantine-env-blocked', // W-mq5n1zx5: quarantine rename was attempted but the OS refused to release the worktree dir (Windows EBUSY/EPERM/EACCES from a lingering git.exe descendant). Engine retried with jittered backoff and (when enabled) `git worktree remove --force`; if all paths failed we surface this dedicated class so the WI auto-recovery loop can re-queue WITHOUT bumping the per-agent retry counter (the failure is environmental, not the agent's fault).
3685
3721
  DEPENDENCY_MERGE_SETUP: 'dependency-merge-setup', // Dependency pre-merge plumbing (stash/status/reset) failed before a real file conflict was verified. Retryable so a fresh worktree can recover.
3686
3722
  INVALID_KEEP_PROCESSES_WORKDIR: 'invalid-keep-processes-workdir', // W-mp6k7ywi000fa33c: keep-pids.json declared a cwd that is not a real git worktree (likely a selective copy of the repo) — never retryable; agent must rerun in a real worktree
3687
3723
  INVALID_KEEP_PROCESSES_SCHEMA: 'invalid-keep-processes-schema', // W-mp7i902u000l991f: keep-pids.json failed validation for a reason other than workdir (pids-missing, ttl-too-long, expires_at-missing, pids-too-many, port-invalid, etc.) — agent wrote the wrong shape; never retryable until they fix the file
@@ -6239,6 +6275,189 @@ function listAllProcesses() {
6239
6275
  return process.platform === 'win32' ? _winListProcesses() : _unixListProcesses();
6240
6276
  }
6241
6277
 
6278
+ // W-mq6f2fe0000557fa — orphan-worktree GC: identify OS processes whose cwd
6279
+ // is at or inside `dir`. Cross-platform with a fail-open contract: on any
6280
+ // error or timeout we return [] (the caller continues with its existing
6281
+ // escalation path and an unenriched note).
6282
+ //
6283
+ // Return shape: [{ pid, cmdline, startedAt, ageMs }, ...]
6284
+ // - pid: numeric OS pid
6285
+ // - cmdline: full command line as a single string (best-effort; possibly truncated)
6286
+ // - startedAt: ms-since-epoch process start time (0 when unknown)
6287
+ // - ageMs: Date.now() - startedAt (0 when startedAt is 0)
6288
+ //
6289
+ // Platform notes:
6290
+ // - Windows: PowerShell `Get-CimInstance Win32_Process` filtered by the
6291
+ // worktree basename appearing in CommandLine. (Win32_Process does not
6292
+ // expose the working directory; the basename match is the most-reliable
6293
+ // heuristic — every spawn-agent invocation embeds the dispatch id, which
6294
+ // is the worktree basename, in its argv.)
6295
+ // - Linux: walk `/proc/*/cwd` and keep PIDs whose readlinkSync resolves
6296
+ // under `dir`. Cmdline read from `/proc/<pid>/cmdline` (NUL-separated).
6297
+ // Start time derived from stat() mtime of the cwd entry as a proxy.
6298
+ // - macOS: `lsof -F p -d cwd` filtered by directory, then `ps -p <pid>` for
6299
+ // cmdline + start time.
6300
+ function findProcessesWithCwdInside(dir, opts = {}) {
6301
+ if (!dir || typeof dir !== 'string') return [];
6302
+ let resolved;
6303
+ try { resolved = path.resolve(dir); } catch { return []; }
6304
+ if (!resolved) return [];
6305
+ const timeoutMs = Number(opts.timeoutMs) > 0
6306
+ ? Number(opts.timeoutMs)
6307
+ : (ENGINE_DEFAULTS.orphanHolderScanTimeoutMs || 5000);
6308
+ const now = Date.now();
6309
+
6310
+ try {
6311
+ if (process.platform === 'win32') {
6312
+ return _findWindowsProcessesWithCwdInside(resolved, timeoutMs, now);
6313
+ }
6314
+ if (process.platform === 'linux') {
6315
+ return _findLinuxProcessesWithCwdInside(resolved, timeoutMs, now);
6316
+ }
6317
+ return _findMacProcessesWithCwdInside(resolved, timeoutMs, now);
6318
+ } catch { return []; }
6319
+ }
6320
+
6321
+ function _findWindowsProcessesWithCwdInside(resolved, timeoutMs, now) {
6322
+ // Win32_Process exposes CommandLine but not the cwd. Match by basename of
6323
+ // the worktree dir (the dispatch id like W-mq1k8z6o003acd89) appearing in
6324
+ // the cmdline — every spawn-agent prompt-file path embeds it.
6325
+ const basename = path.basename(resolved);
6326
+ if (!basename || basename.length < 3) return [];
6327
+ // PowerShell-quote: outer escape ' as ''
6328
+ const psBasename = basename.replace(/'/g, "''");
6329
+ const psResolved = resolved.replace(/'/g, "''");
6330
+ // Use -like with wildcards on BOTH basename and full path so a process
6331
+ // whose cmdline carries the worktree dir (but a different basename
6332
+ // anywhere in argv) also matches.
6333
+ 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`;
6334
+ let raw;
6335
+ try {
6336
+ raw = _execSync(
6337
+ `powershell -NoProfile -NonInteractive -Command "${script}"`,
6338
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }
6339
+ );
6340
+ } catch { return []; }
6341
+ if (!raw || !String(raw).trim()) return [];
6342
+ let parsed;
6343
+ try { parsed = JSON.parse(raw); }
6344
+ catch { return []; }
6345
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
6346
+ const out = [];
6347
+ for (const r of arr) {
6348
+ if (!r) continue;
6349
+ const pid = Number(r.pid);
6350
+ if (!Number.isInteger(pid) || pid <= 0) continue;
6351
+ const startedAt = Number(r.startedAt) || 0;
6352
+ out.push({
6353
+ pid,
6354
+ cmdline: r.cmdline ? String(r.cmdline) : '',
6355
+ startedAt,
6356
+ ageMs: startedAt > 0 ? Math.max(0, now - startedAt) : 0,
6357
+ });
6358
+ }
6359
+ return out;
6360
+ }
6361
+
6362
+ function _findLinuxProcessesWithCwdInside(resolved, _timeoutMs, now) {
6363
+ // /proc walk: scan numeric entries, readlink cwd, keep matches.
6364
+ let entries;
6365
+ try { entries = fs.readdirSync('/proc'); }
6366
+ catch { return []; }
6367
+ const prefix = resolved + path.sep;
6368
+ const out = [];
6369
+ for (const e of entries) {
6370
+ if (!/^\d+$/.test(e)) continue;
6371
+ const pid = Number(e);
6372
+ let cwd;
6373
+ try { cwd = fs.readlinkSync(`/proc/${pid}/cwd`); }
6374
+ catch { continue; }
6375
+ if (!cwd) continue;
6376
+ if (cwd !== resolved && !cwd.startsWith(prefix)) continue;
6377
+ let cmdline = '';
6378
+ try {
6379
+ const buf = fs.readFileSync(`/proc/${pid}/cmdline`);
6380
+ cmdline = buf.toString('utf8').replace(/\0/g, ' ').trim();
6381
+ } catch { /* cmdline read can fail on race */ }
6382
+ let startedAt = 0;
6383
+ try {
6384
+ const st = fs.statSync(`/proc/${pid}`);
6385
+ startedAt = st && st.ctimeMs ? Math.floor(st.ctimeMs) : 0;
6386
+ } catch { /* stat can fail on race */ }
6387
+ out.push({
6388
+ pid,
6389
+ cmdline,
6390
+ startedAt,
6391
+ ageMs: startedAt > 0 ? Math.max(0, now - startedAt) : 0,
6392
+ });
6393
+ }
6394
+ return out;
6395
+ }
6396
+
6397
+ function _findMacProcessesWithCwdInside(resolved, timeoutMs, now) {
6398
+ // `lsof -a -d cwd -F pn` emits PID then cwd path. Filter for dir matches,
6399
+ // then run a single `ps` pass to enrich cmdline + start time.
6400
+ let raw;
6401
+ try {
6402
+ raw = _execSync('lsof -a -d cwd -F pn 2>/dev/null', {
6403
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024,
6404
+ });
6405
+ } catch { return []; }
6406
+ if (!raw) return [];
6407
+ const prefix = resolved + path.sep;
6408
+ const pids = new Set();
6409
+ let currentPid = null;
6410
+ for (const line of String(raw).split(/\r?\n/)) {
6411
+ if (!line) continue;
6412
+ const tag = line[0];
6413
+ const val = line.slice(1);
6414
+ if (tag === 'p') {
6415
+ const n = Number(val);
6416
+ currentPid = Number.isInteger(n) && n > 0 ? n : null;
6417
+ } else if (tag === 'n' && currentPid != null) {
6418
+ if (val === resolved || val.startsWith(prefix)) pids.add(currentPid);
6419
+ }
6420
+ }
6421
+ if (pids.size === 0) return [];
6422
+ const out = [];
6423
+ for (const pid of pids) {
6424
+ let line;
6425
+ try {
6426
+ line = String(_execSync(`ps -p ${pid} -o lstart=,command=`, {
6427
+ stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, encoding: 'utf8',
6428
+ }) || '').trim();
6429
+ } catch { line = ''; }
6430
+ let startedAt = 0;
6431
+ let cmdline = '';
6432
+ if (line) {
6433
+ // ps lstart= emits "Mon Jun 9 07:04:33 2026" (24 chars), then cmdline.
6434
+ const lstart = line.slice(0, 24).trim();
6435
+ cmdline = line.slice(24).trim();
6436
+ const parsed = Date.parse(lstart);
6437
+ if (!Number.isNaN(parsed)) startedAt = parsed;
6438
+ }
6439
+ out.push({
6440
+ pid,
6441
+ cmdline,
6442
+ startedAt,
6443
+ ageMs: startedAt > 0 ? Math.max(0, now - startedAt) : 0,
6444
+ });
6445
+ }
6446
+ return out;
6447
+ }
6448
+
6449
+ // W-mq6f2fe0000557fa — clear the per-path failure cooldown entry so the
6450
+ // post-holder-reap retry can attempt removeWorktree even though the path
6451
+ // has already failed >= 3 times. Without this, the cooldown silently
6452
+ // suppresses the retry and the orphan dir is left stuck.
6453
+ function clearWorktreeFailureCache(wtPath) {
6454
+ if (!wtPath) return false;
6455
+ try {
6456
+ const resolved = path.resolve(wtPath);
6457
+ return _removeWorktreeFailures.delete(resolved);
6458
+ } catch { return false; }
6459
+ }
6460
+
6242
6461
  // Cross-check a single PID's command line for a Minions agent invocation
6243
6462
  // (`claude` or `copilot`, including the `node spawn-agent.js --runtime <name>`
6244
6463
  // wrapper and `gh copilot` fallback). Used by orphan/recycled-PID safety:
@@ -6573,6 +6792,61 @@ function _prHeadSha(pr) {
6573
6792
  return String(pr?.headRefOid || pr?.headSha || pr?._adoSourceCommit || pr?._adoHeadCommit || '').trim();
6574
6793
  }
6575
6794
 
6795
+ // Target-branch base SHA, normalized across hosts. GitHub PRs carry `baseSha`
6796
+ // (engine/github.js:746-749); ADO PRs carry `_adoTargetCommit`
6797
+ // (engine/ado.js:1168-1172, inline in pollPrStatus).
6798
+ function _prBaseSha(pr) {
6799
+ return String(pr?.baseSha || pr?._adoTargetCommit || '').trim();
6800
+ }
6801
+
6802
+ // Target ref name, normalized across hosts. GitHub PRs carry `baseRefName`
6803
+ // (e.g. "master"); ADO PRs carry `targetRefName` (e.g. "refs/heads/main").
6804
+ function _prTargetRefName(pr) {
6805
+ return String(pr?.baseRefName || pr?.targetRefName || '').trim();
6806
+ }
6807
+
6808
+ // #3079 — Composite key the engine uses to gate merge-conflict re-dispatch.
6809
+ // The same-head guard at engine.js:5676 previously compared only source head
6810
+ // SHA, missing the case where a PR is retargeted (e.g. parent branch → main)
6811
+ // without the source moving. Including the base SHA and target ref name makes
6812
+ // the guard target-aware so retargets naturally release the pause.
6813
+ function prMergeConflictGuardKey(pr) {
6814
+ return `${_prHeadSha(pr)}|${_prBaseSha(pr)}|${_prTargetRefName(pr)}`;
6815
+ }
6816
+
6817
+ // #3079 — Helper invoked by the ADO + GitHub poller metadata-apply paths.
6818
+ // When the PR's target ref changes (user retargeted via gh pr edit --base or
6819
+ // ADO PR edit), clear the stale MERGE_CONFLICT records so the next tick can
6820
+ // re-evaluate the conflict against the new target. Other causes
6821
+ // (BUILD_FAILURE, REVIEW_FEEDBACK, HUMAN_FEEDBACK) are untouched — they are
6822
+ // not target-sensitive. First-poll seeding (no prior target ref recorded)
6823
+ // is a no-op so we don't strip state immediately after an upsert.
6824
+ //
6825
+ // Returns true when state was actually mutated (callers may use this to set
6826
+ // the surrounding "updated" flag and trigger a persist).
6827
+ function resetMergeConflictStateOnRetarget(pr, newTargetRef) {
6828
+ if (!pr) return false;
6829
+ const next = String(newTargetRef || '').trim();
6830
+ if (!next) return false;
6831
+ const prior = _prTargetRefName(pr);
6832
+ // First-poll seeding: no prior value → record new value via the caller,
6833
+ // do NOT clear merge-conflict state.
6834
+ if (!prior) return false;
6835
+ if (prior === next) return false;
6836
+ let changed = false;
6837
+ if (pr._lastDispatchByCause && pr._lastDispatchByCause[PR_FIX_CAUSE.MERGE_CONFLICT]) {
6838
+ delete pr._lastDispatchByCause[PR_FIX_CAUSE.MERGE_CONFLICT];
6839
+ if (Object.keys(pr._lastDispatchByCause).length === 0) delete pr._lastDispatchByCause;
6840
+ changed = true;
6841
+ }
6842
+ if (pr._noOpFixes && pr._noOpFixes[PR_FIX_CAUSE.MERGE_CONFLICT]) {
6843
+ delete pr._noOpFixes[PR_FIX_CAUSE.MERGE_CONFLICT];
6844
+ if (Object.keys(pr._noOpFixes).length === 0) delete pr._noOpFixes;
6845
+ changed = true;
6846
+ }
6847
+ return changed;
6848
+ }
6849
+
6576
6850
  function prFixEvidenceFingerprint(pr, cause = PR_FIX_CAUSE.UNKNOWN) {
6577
6851
  const review = pr?.minionsReview || {};
6578
6852
  const feedback = pr?.humanFeedback || {};
@@ -6596,6 +6870,16 @@ function prFixEvidenceFingerprint(pr, cause = PR_FIX_CAUSE.UNKNOWN) {
6596
6870
  evidence.mergeConflict = !!pr?._mergeConflict;
6597
6871
  evidence.mergeStatus = pr?.mergeStatus || '';
6598
6872
  evidence.mergeConflictDetail = pr?._mergeConflictDetail || '';
6873
+ // #3079 — same rationale as BUILD_FAILURE / REVIEW_FEEDBACK in #2979:
6874
+ // without head/base/target fields the fingerprint was sticky across a
6875
+ // rebase + force-push AND across a PR retarget, so existing
6876
+ // _noOpFixes[MERGE_CONFLICT] pauses never released. Adding head + base
6877
+ // SHA + target ref name gives MERGE_CONFLICT the same natural-unsticking
6878
+ // property the other auto-fix causes already have.
6879
+ evidence.headRefOid = _prHeadSha(pr);
6880
+ evidence.baseSha = _prBaseSha(pr);
6881
+ evidence.targetRefName = _prTargetRefName(pr);
6882
+ evidence.lastPushedAt = pr?.lastPushedAt || '';
6599
6883
  } else {
6600
6884
  evidence.reviewStatus = pr?.reviewStatus || '';
6601
6885
  evidence.lastReviewedAt = pr?.lastReviewedAt || '';
@@ -7221,6 +7505,8 @@ module.exports = {
7221
7505
  PR_FIX_CAUSE,
7222
7506
  getPrFixAutomationCause,
7223
7507
  prFixEvidenceFingerprint,
7508
+ prMergeConflictGuardKey,
7509
+ resetMergeConflictStateOnRetarget,
7224
7510
  getPrNoOpFixRecord,
7225
7511
  isPrNoOpFixCausePaused,
7226
7512
  getPrPausedCauses,
@@ -7233,6 +7519,8 @@ module.exports = {
7233
7519
  killByPidsImmediate,
7234
7520
  isProcessCommandLineMatchingAgent,
7235
7521
  listAllProcesses,
7522
+ findProcessesWithCwdInside,
7523
+ clearWorktreeFailureCache,
7236
7524
  listProcessDescendants,
7237
7525
  listProcessReachable,
7238
7526
  removeWorktree,