@yemi33/minions 0.1.2142 → 0.1.2143

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.
@@ -41,6 +41,128 @@ function _managedSpawn() {
41
41
 
42
42
  const _noopLog = () => {};
43
43
 
44
+ // ── Stuck-dir escalation (Layer 4 of W-mq5o6bvy000x7191) ─────────────────────
45
+ // In-memory ring of paths that have failed removal N consecutive times. After
46
+ // escalation we (a) write a dedup'd inbox note, (b) suppress repeat per-tick
47
+ // warns for `worktreeStuckSuppressMs`, and (c) reduce retry cadence to
48
+ // `worktreeStuckSlowRetryMs`. The ring also remembers the last attempt time so
49
+ // the slow-cadence gate is precise. Memory only — dies with the engine; a
50
+ // fresh boot starts fresh and is re-escalated on the same path after N more
51
+ // failures, which is the desired behavior.
52
+ const _stuckPaths = new Map(); // resolved path -> { failures, escalatedAt, suppressedUntil, lastAttempt, lastError, lastNoteDate }
53
+
54
+ function _stuckRecord(resolvedPath) {
55
+ let rec = _stuckPaths.get(resolvedPath);
56
+ if (!rec) { rec = { failures: 0, escalatedAt: 0, suppressedUntil: 0, lastAttempt: 0, lastError: '', lastNoteDate: '' }; _stuckPaths.set(resolvedPath, rec); }
57
+ return rec;
58
+ }
59
+
60
+ function _isStuckPathSuppressed(resolvedPath, now = Date.now()) {
61
+ const rec = _stuckPaths.get(resolvedPath);
62
+ if (!rec || !rec.suppressedUntil) return false;
63
+ return now < rec.suppressedUntil;
64
+ }
65
+
66
+ function _shouldSlowRetry(resolvedPath, slowRetryMs, now = Date.now()) {
67
+ const rec = _stuckPaths.get(resolvedPath);
68
+ if (!rec || !rec.escalatedAt) return true; // not escalated → eligible
69
+ return (now - rec.lastAttempt) >= slowRetryMs;
70
+ }
71
+
72
+ function _markStuckSuccess(resolvedPath, opts = {}) {
73
+ const rec = _stuckPaths.get(resolvedPath);
74
+ if (!rec) return false;
75
+ const wasEscalated = rec.escalatedAt > 0;
76
+ _stuckPaths.delete(resolvedPath);
77
+ if (wasEscalated) {
78
+ try { shared.bumpWorktreeGcMetric('recoveredAfterEscalation'); } catch { /* optional */ }
79
+ try {
80
+ const writer = typeof opts.writeToInbox === 'function' ? opts.writeToInbox : shared.writeToInbox;
81
+ const basename = path.basename(resolvedPath);
82
+ const body = [
83
+ `# Worktree recovered: ${basename}`,
84
+ '',
85
+ `Previously stuck worktree dir **${resolvedPath}** has been successfully removed`,
86
+ `after escalation. Suppression cleared.`,
87
+ ].join('\n');
88
+ writer('engine', `worktree-recovered-${basename}`, body);
89
+ } catch { /* note best-effort */ }
90
+ }
91
+ return wasEscalated;
92
+ }
93
+
94
+ function _maybeEscalateStuck(resolvedPath, errorMsg, opts = {}) {
95
+ const cfgEng = (opts.config && opts.config.engine) || {};
96
+ const threshold = cfgEng.worktreeStuckThreshold || shared.ENGINE_DEFAULTS.worktreeStuckThreshold || 10;
97
+ const suppressMs = cfgEng.worktreeStuckSuppressMs || shared.ENGINE_DEFAULTS.worktreeStuckSuppressMs || (60 * 60 * 1000);
98
+ const now = Date.now();
99
+ const rec = _stuckRecord(resolvedPath);
100
+ rec.failures++;
101
+ rec.lastAttempt = now;
102
+ rec.lastError = errorMsg || rec.lastError || 'unknown';
103
+ if (rec.failures < threshold) return { escalated: false, alreadyEscalated: false };
104
+
105
+ const alreadyEscalated = rec.escalatedAt > 0;
106
+ if (!alreadyEscalated) {
107
+ rec.escalatedAt = now;
108
+ rec.suppressedUntil = now + suppressMs;
109
+ try { shared.bumpWorktreeGcMetric('stuckEscalated'); } catch { /* optional */ }
110
+ } else if (now >= rec.suppressedUntil) {
111
+ // Re-arm the suppression window on each post-escalation failure tick.
112
+ rec.suppressedUntil = now + suppressMs;
113
+ }
114
+
115
+ // Dedup inbox note per UTC day.
116
+ const today = new Date(now).toISOString().slice(0, 10);
117
+ if (rec.lastNoteDate === today) return { escalated: !alreadyEscalated, alreadyEscalated };
118
+ rec.lastNoteDate = today;
119
+
120
+ try {
121
+ const writer = typeof opts.writeToInbox === 'function' ? opts.writeToInbox : shared.writeToInbox;
122
+ const basename = path.basename(resolvedPath);
123
+ const body = [
124
+ `# Worktree stuck: ${basename}`,
125
+ '',
126
+ `Worktree dir **${resolvedPath}** has failed removal ${rec.failures} consecutive times.`,
127
+ `Last error: \`${rec.lastError}\``,
128
+ '',
129
+ '## Suggested manual recovery (Windows)',
130
+ '',
131
+ 'Identify the lock holder:',
132
+ '',
133
+ '```powershell',
134
+ `Get-Process | Where-Object { $_.Path -like '${resolvedPath.replace(/'/g, "''")}*' }`,
135
+ '```',
136
+ '',
137
+ 'If `handle.exe` from Sysinternals is installed:',
138
+ '',
139
+ '```',
140
+ `handle.exe -p <pid> ${resolvedPath}`,
141
+ '```',
142
+ '',
143
+ 'Once the holder is gone, force-remove:',
144
+ '',
145
+ '```powershell',
146
+ `Remove-Item -Recurse -Force "${resolvedPath}"`,
147
+ '```',
148
+ '',
149
+ `The engine will continue to retry this path at the slow cadence`,
150
+ `(\`ENGINE_DEFAULTS.worktreeStuckSlowRetryMs\`, default 30min). When the`,
151
+ `holder finally releases, a \`worktree-recovered-${basename}\` note is`,
152
+ `written here to clear this alert.`,
153
+ '',
154
+ `Per-tick warn spam is suppressed for the next`,
155
+ `\`ENGINE_DEFAULTS.worktreeStuckSuppressMs\` (default 60min).`,
156
+ ].join('\n');
157
+ writer('engine', `worktree-stuck-${basename}`, body);
158
+ } catch { /* note best-effort */ }
159
+
160
+ return { escalated: !alreadyEscalated, alreadyEscalated };
161
+ }
162
+
163
+ // Test-only reset hook. Production code never calls this.
164
+ function _resetStuckPathsForTesting() { _stuckPaths.clear(); }
165
+
44
166
  /**
45
167
  * Decide whether a dispatch-end worktree should be GC'd.
46
168
  *
@@ -162,6 +284,8 @@ function gcDispatchWorktreeIfOrphan(opts) {
162
284
  worktreeRoot,
163
285
  log = _noopLog,
164
286
  removeWorktree = null,
287
+ config = null,
288
+ writeToInbox = null,
165
289
  } = opts || {};
166
290
  const decision = shouldGcDispatchWorktree(opts);
167
291
  if (!decision.gc) {
@@ -171,17 +295,26 @@ function gcDispatchWorktreeIfOrphan(opts) {
171
295
  return { outcome: 'skip', reason: 'no-git-root', removed: false };
172
296
  }
173
297
  const _removeFn = typeof removeWorktree === 'function' ? removeWorktree : shared.removeWorktree;
298
+ const resolved = (() => { try { return path.resolve(worktreePath); } catch { return worktreePath; } })();
174
299
  try {
175
300
  const removed = _removeFn(worktreePath, gitRoot, worktreeRoot);
176
301
  if (removed) {
302
+ _markStuckSuccess(resolved, { writeToInbox });
177
303
  log('info', `worktree-gc: dispatch-end removed ${path.basename(worktreePath)}`);
178
304
  return { outcome: 'gc', reason: decision.reason, removed: true };
179
305
  }
180
- log('warn', `worktree-gc: dispatch-end remove returned false for ${worktreePath}`);
181
- return { outcome: 'gc-failed', reason: 'remove-failed', removed: false };
306
+ // W-mq5o6bvy000x7191 (Layer 4): track stuck dirs even on soft-false returns.
307
+ const { escalated, alreadyEscalated } = _maybeEscalateStuck(resolved, 'remove returned false', { config, writeToInbox });
308
+ if (!alreadyEscalated && !_isStuckPathSuppressed(resolved)) {
309
+ log('warn', `worktree-gc: dispatch-end remove returned false for ${worktreePath}`);
310
+ }
311
+ return { outcome: 'gc-failed', reason: 'remove-failed', removed: false, stuckEscalated: escalated };
182
312
  } catch (gcErr) {
183
- log('warn', `worktree-gc: dispatch-end remove threw for ${worktreePath}: ${gcErr.message}`);
184
- return { outcome: 'gc-failed', reason: 'remove-threw', removed: false };
313
+ const { escalated, alreadyEscalated } = _maybeEscalateStuck(resolved, gcErr && gcErr.message, { config, writeToInbox });
314
+ if (!alreadyEscalated && !_isStuckPathSuppressed(resolved)) {
315
+ log('warn', `worktree-gc: dispatch-end remove threw for ${worktreePath}: ${gcErr.message}`);
316
+ }
317
+ return { outcome: 'gc-failed', reason: 'remove-threw', removed: false, stuckEscalated: escalated };
185
318
  }
186
319
  }
187
320
 
@@ -335,21 +468,255 @@ function pruneOrphanWorktrees(opts) {
335
468
  }
336
469
 
337
470
  try {
471
+ // W-mq5o6bvy000x7191 (Layer 4): respect slow-cadence retry for paths
472
+ // that have escalated past `worktreeStuckThreshold`.
473
+ const wtResolved = path.resolve(wtPath);
474
+ const slowRetryMs = (opts.config?.engine?.worktreeStuckSlowRetryMs)
475
+ ?? shared.ENGINE_DEFAULTS.worktreeStuckSlowRetryMs
476
+ ?? (30 * 60 * 1000);
477
+ if (!_shouldSlowRetry(wtResolved, slowRetryMs)) {
478
+ projStats.kept++; result.kept++;
479
+ continue;
480
+ }
481
+ const suppressed = _isStuckPathSuppressed(wtResolved);
338
482
  const removed = _removeWorktree(wtPath, rootDir, wtParent);
339
483
  if (removed) {
340
484
  projStats.evicted++; result.evicted++;
485
+ _markStuckSuccess(wtResolved, { writeToInbox: opts.writeToInbox });
341
486
  log('info', `worktree-gc: boot-evicted orphan ${name} for project ${project.name || 'default'}`);
342
487
  } else {
343
488
  projStats.failed++; result.failed++;
344
- log('warn', `worktree-gc: boot-evict returned false for ${wtPath}`);
489
+ const { alreadyEscalated } = _maybeEscalateStuck(wtResolved, 'remove returned false', { config: opts.config, writeToInbox: opts.writeToInbox });
490
+ if (!alreadyEscalated && !suppressed) {
491
+ log('warn', `worktree-gc: boot-evict returned false for ${wtPath}`);
492
+ }
493
+ }
494
+ } catch (rmErr) {
495
+ projStats.failed++; result.failed++;
496
+ const wtResolved = path.resolve(wtPath);
497
+ const suppressed = _isStuckPathSuppressed(wtResolved);
498
+ const { alreadyEscalated } = _maybeEscalateStuck(wtResolved, rmErr && rmErr.message, { config: opts.config, writeToInbox: opts.writeToInbox });
499
+ if (!alreadyEscalated && !suppressed) {
500
+ log('warn', `worktree-gc: boot-evict threw for ${wtPath}: ${rmErr.message}`);
501
+ }
502
+ }
503
+ }
504
+ result.perProject[project.name || rootDir] = projStats;
505
+ }
506
+ return result;
507
+ }
508
+
509
+ /**
510
+ * Out-of-root scan: walk `git worktree list --porcelain` for every project,
511
+ * find registered worktrees outside the configured `worktreeRoot`, and reap
512
+ * the unprotected ones. Companion to `pruneOrphanWorktrees`, which only
513
+ * walks `<localPath>/<worktreeRoot>` and is permanently blind to worktrees
514
+ * created under `D:/tmp-*`, `D:/squad-worktrees/*`, etc.
515
+ *
516
+ * Also calls `git worktree prune --expire=now` per project so registry
517
+ * entries whose dirs were manually deleted (`Remove-Item`d) drop out of the
518
+ * git index immediately.
519
+ *
520
+ * - projects — array of `{ name, localPath }`
521
+ * - dispatchSnap — `{ active: [...], pending: [...] }`
522
+ * - worktreeRootRel — configured `engine.worktreeRoot` (default '../worktrees')
523
+ * used to compute the inside-root set we DO NOT touch
524
+ * here (the existing scanner handles it).
525
+ * - log / fs / removeWorktree / listManagedSpecs / execSilent — injection
526
+ *
527
+ * Protection rules mirror `pruneOrphanWorktrees`:
528
+ * 1. The main checkout (path === projectLocalPath) is always kept.
529
+ * 2. Any path inside the configured `worktreeRoot` is skipped (delegated).
530
+ * 3. Branch matches an active/pending dispatch → keep.
531
+ * 4. Path is in the worktree-pool → keep.
532
+ * 5. managed_spawn cwd anchored → keep.
533
+ *
534
+ * Returns `{ scanned, kept, evicted, failed, prunedRegistry, perProject }`.
535
+ *
536
+ * W-mq5o6bvy000x7191 Layer 3.
537
+ */
538
+ function pruneOrphanWorktreesFromGitRegistry(opts) {
539
+ opts = opts || {};
540
+ const projects = Array.isArray(opts.projects) ? opts.projects : [];
541
+ const dispatchSnap = opts.dispatchSnap || { active: [], pending: [] };
542
+ const worktreeRootRel = typeof opts.worktreeRootRel === 'string' && opts.worktreeRootRel.length > 0
543
+ ? opts.worktreeRootRel
544
+ : (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.worktreeRoot) || '../worktrees';
545
+ const log = typeof opts.log === 'function' ? opts.log : _noopLog;
546
+ const _fs = opts.fs || fs;
547
+ const _removeWorktree = typeof opts.removeWorktree === 'function'
548
+ ? opts.removeWorktree
549
+ : shared.removeWorktree;
550
+ const _listManagedSpecs = typeof opts.listManagedSpecs === 'function'
551
+ ? opts.listManagedSpecs
552
+ : (() => {
553
+ try { return _managedSpawn().listManagedSpecs(); }
554
+ catch (_e) { return []; }
555
+ });
556
+ const _execSilent = typeof opts.execSilent === 'function'
557
+ ? opts.execSilent
558
+ : shared.execSilent;
559
+ const _parseWorktreePorcelain = typeof opts.parseWorktreePorcelain === 'function'
560
+ ? opts.parseWorktreePorcelain
561
+ : shared.parseWorktreePorcelain;
562
+
563
+ // Branches of active/pending dispatches (with and without `refs/heads/`
564
+ // normalization). Matched case-insensitively to mirror git's behavior on
565
+ // Windows (case-insensitive filesystems).
566
+ const protectedBranches = new Set();
567
+ for (const d of [...(dispatchSnap.active || []), ...(dispatchSnap.pending || [])]) {
568
+ const br = d && d.meta && d.meta.branch;
569
+ if (br && typeof br === 'string') {
570
+ protectedBranches.add(br.toLowerCase());
571
+ protectedBranches.add(br.toLowerCase().replace(/^refs\/heads\//, ''));
572
+ }
573
+ }
574
+
575
+ // Pool-known paths (idle + borrowed + stale-but-still-on-disk).
576
+ const poolPaths = new Set();
577
+ try {
578
+ const entries = (worktreePool.readPool() || { entries: [] }).entries || [];
579
+ for (const e of entries) {
580
+ if (e && e.path) poolPaths.add(worktreePool._normalizePath(e.path));
581
+ }
582
+ } catch (_e) { /* pool readable optional */ }
583
+ if (Array.isArray(opts.extraPoolPaths)) {
584
+ for (const p of opts.extraPoolPaths) poolPaths.add(worktreePool._normalizePath(p));
585
+ }
586
+
587
+ // managed_spawn cwds.
588
+ const managedSpawnCwds = [];
589
+ try {
590
+ for (const rec of (_listManagedSpecs() || [])) {
591
+ if (!rec || typeof rec.cwd !== 'string' || rec.cwd.length === 0) continue;
592
+ try { managedSpawnCwds.push(path.resolve(rec.cwd)); }
593
+ catch (_e) { /* malformed cwd — skip */ }
594
+ }
595
+ } catch (_e) { /* optional */ }
596
+
597
+ const result = { scanned: 0, kept: 0, evicted: 0, failed: 0, prunedRegistry: 0, perProject: {} };
598
+ const _seenAbs = new Set(); // dedup across projects sharing a parent
599
+
600
+ for (const project of projects) {
601
+ if (!project || !project.localPath) continue;
602
+ let rootDir;
603
+ try { rootDir = path.resolve(String(project.localPath)); } catch { continue; }
604
+ let rootExists = false;
605
+ try { rootExists = _fs.existsSync(rootDir); } catch { rootExists = false; }
606
+ if (!rootExists) continue;
607
+
608
+ // Compute the inside-`worktreeRoot` boundary so we DON'T double-sweep the
609
+ // dirs the existing scanner already handles.
610
+ const wtParentAbs = path.resolve(rootDir, worktreeRootRel);
611
+ const wtParentPrefix = wtParentAbs + path.sep;
612
+
613
+ const projStats = { scanned: 0, kept: 0, evicted: 0, failed: 0, prunedRegistry: 0 };
614
+ let raw = '';
615
+ try {
616
+ raw = String(_execSilent('git --no-optional-locks worktree list --porcelain', {
617
+ cwd: rootDir, timeout: 15000, windowsHide: true,
618
+ }) || '');
619
+ } catch (e) {
620
+ log('warn', `worktree-gc: git worktree list failed for ${project.name || rootDir}: ${e.message}`);
621
+ result.perProject[project.name || rootDir] = projStats;
622
+ continue;
623
+ }
624
+
625
+ let trees;
626
+ try { trees = _parseWorktreePorcelain(raw); }
627
+ catch (e) {
628
+ log('warn', `worktree-gc: parse worktree list failed for ${project.name || rootDir}: ${e.message}`);
629
+ result.perProject[project.name || rootDir] = projStats;
630
+ continue;
631
+ }
632
+
633
+ for (const wt of trees) {
634
+ if (!wt || !wt.path) continue;
635
+ let wtAbs;
636
+ try { wtAbs = path.resolve(wt.path); } catch { continue; }
637
+ // Skip main checkout
638
+ if (wtAbs === rootDir) continue;
639
+ // Skip dirs inside the configured worktreeRoot (handled by sibling scanner)
640
+ if (wtAbs === wtParentAbs || wtAbs.startsWith(wtParentPrefix)) continue;
641
+ if (_seenAbs.has(wtAbs)) continue;
642
+ _seenAbs.add(wtAbs);
643
+
644
+ projStats.scanned++;
645
+ result.scanned++;
646
+
647
+ // Branch protection
648
+ if (wt.branch && protectedBranches.has(String(wt.branch).toLowerCase())) {
649
+ projStats.kept++; result.kept++; continue;
650
+ }
651
+ // Pool protection
652
+ const normPath = worktreePool._normalizePath(wtAbs);
653
+ if (poolPaths.has(normPath)) {
654
+ projStats.kept++; result.kept++; continue;
655
+ }
656
+ // managed_spawn anchor protection
657
+ if (managedSpawnCwds.length > 0) {
658
+ const wtPrefix = wtAbs + path.sep;
659
+ let anchored = false;
660
+ for (const cwd of managedSpawnCwds) {
661
+ if (cwd === wtAbs || cwd.startsWith(wtPrefix)) { anchored = true; break; }
662
+ }
663
+ if (anchored) { projStats.kept++; result.kept++; continue; }
664
+ }
665
+
666
+ // Slow-cadence gate for already-escalated stuck paths
667
+ const slowRetryMs = (opts.config?.engine?.worktreeStuckSlowRetryMs)
668
+ ?? shared.ENGINE_DEFAULTS.worktreeStuckSlowRetryMs
669
+ ?? (30 * 60 * 1000);
670
+ if (!_shouldSlowRetry(wtAbs, slowRetryMs)) {
671
+ projStats.kept++; result.kept++; continue;
672
+ }
673
+
674
+ // parentDir for the safety boundary check inside removeWorktree must be
675
+ // the dir *containing* this worktree (not the project's worktreeRoot,
676
+ // since that's not where this dir lives).
677
+ const parentDir = path.dirname(wtAbs);
678
+ const suppressed = _isStuckPathSuppressed(wtAbs);
679
+ try {
680
+ const removed = _removeWorktree(wtAbs, rootDir, parentDir);
681
+ if (removed) {
682
+ projStats.evicted++; result.evicted++;
683
+ _markStuckSuccess(wtAbs, { writeToInbox: opts.writeToInbox });
684
+ try { shared.bumpWorktreeGcMetric('outOfRootEvicted'); } catch { /* optional */ }
685
+ log('info', `worktree-gc: out-of-root evicted ${wtAbs} for project ${project.name || 'default'}`);
686
+ } else {
687
+ projStats.failed++; result.failed++;
688
+ const { alreadyEscalated } = _maybeEscalateStuck(wtAbs, 'remove returned false', { config: opts.config, writeToInbox: opts.writeToInbox });
689
+ if (!alreadyEscalated && !suppressed) {
690
+ log('warn', `worktree-gc: out-of-root remove returned false for ${wtAbs}`);
691
+ }
345
692
  }
346
693
  } catch (rmErr) {
347
694
  projStats.failed++; result.failed++;
348
- log('warn', `worktree-gc: boot-evict threw for ${wtPath}: ${rmErr.message}`);
695
+ const { alreadyEscalated } = _maybeEscalateStuck(wtAbs, rmErr && rmErr.message, { config: opts.config, writeToInbox: opts.writeToInbox });
696
+ if (!alreadyEscalated && !suppressed) {
697
+ log('warn', `worktree-gc: out-of-root remove threw for ${wtAbs}: ${rmErr.message}`);
698
+ }
349
699
  }
350
700
  }
701
+
702
+ // Always finish with `git worktree prune --expire=now` to drop registry
703
+ // entries whose dirs were manually removed (e.g. operator `Remove-Item`).
704
+ try {
705
+ _execSilent('git worktree prune --expire=now', {
706
+ cwd: rootDir, timeout: 10000, windowsHide: true,
707
+ });
708
+ // We can't tell how many entries pruned without a second `worktree list`
709
+ // diff; count the call itself for cadence visibility.
710
+ projStats.prunedRegistry = 1;
711
+ result.prunedRegistry++;
712
+ try { shared.bumpWorktreeGcMetric('registryPruned'); } catch { /* optional */ }
713
+ } catch (e) {
714
+ log('warn', `worktree-gc: git worktree prune failed for ${project.name || rootDir}: ${e.message}`);
715
+ }
716
+
351
717
  result.perProject[project.name || rootDir] = projStats;
352
718
  }
719
+
353
720
  return result;
354
721
  }
355
722
 
@@ -357,4 +724,8 @@ module.exports = {
357
724
  shouldGcDispatchWorktree,
358
725
  gcDispatchWorktreeIfOrphan,
359
726
  pruneOrphanWorktrees,
727
+ pruneOrphanWorktreesFromGitRegistry,
728
+ // exported for testing (W-mq5o6bvy000x7191)
729
+ _resetStuckPathsForTesting,
730
+ _stuckPaths,
360
731
  };
package/engine.js CHANGED
@@ -5018,6 +5018,23 @@ function _warnSilentDiscoveryOnce(kind, project, dataPath, config) {
5018
5018
  log('warn', `Silent-discovery footgun: project "${projName}" has ${count} record(s) in ${path.basename(dataPath)} but workSources.${kind}.enabled is not true — engine will not pick them up. ${hint}`);
5019
5019
  }
5020
5020
 
5021
+ /**
5022
+ * W-mq5rs2eq000da8a9 — in-process dedupe set for PR dispatch-skip log lines.
5023
+ * Keyed by `${prId}:${reason}` so each (PR, reason) combination logs at most
5024
+ * once per engine process. Cleared only on engine restart — no persistence
5025
+ * needed; the goal is operator visibility into WHY a tracked PR is being
5026
+ * skipped (replaces the silent `continue` that hid the `_autoObserve` bug).
5027
+ */
5028
+ const _prDispatchSkipLogged = new Set();
5029
+
5030
+ function _logPrDispatchSkipOnce(pr, reason) {
5031
+ const prId = pr?.id || pr?.url || '<unknown-pr>';
5032
+ const key = `${prId}:${reason}`;
5033
+ if (_prDispatchSkipLogged.has(key)) return;
5034
+ _prDispatchSkipLogged.add(key);
5035
+ log('info', `Discovery: skipping PR ${prId} (${reason}) — not eligible for auto-managed review/fix dispatch`);
5036
+ }
5037
+
5021
5038
  /**
5022
5039
  * Scan pull-requests.json for PRs needing review or fixes
5023
5040
  */
@@ -5059,9 +5076,12 @@ async function discoverFromPrs(config, project) {
5059
5076
  .filter(Boolean)
5060
5077
  );
5061
5078
 
5062
- const knownAgents = new Set(Object.keys(config.agents || {}));
5063
5079
  for (const pr of prs) {
5064
- if (pr.status !== PR_STATUS.ACTIVE || pr._contextOnly) continue;
5080
+ if (pr.status !== PR_STATUS.ACTIVE) continue;
5081
+ if (pr._contextOnly) {
5082
+ _logPrDispatchSkipOnce(pr, 'context-only');
5083
+ continue;
5084
+ }
5065
5085
  if (!shared.isPrCompatibleWithProject(project, pr, pr.url || '')) continue;
5066
5086
  const prDisplayId = shared.getPrDisplayId(pr);
5067
5087
  const prCanonicalId = shared.getCanonicalPrId(project, pr, pr.url || '');
@@ -5072,10 +5092,15 @@ async function discoverFromPrs(config, project) {
5072
5092
  log('info', `Branch mutex: skipping PR ${pr.id} dispatch — branch ${prBranchForMutex} locked by another agent`);
5073
5093
  continue;
5074
5094
  }
5075
- // Skip human-authored PRs not linked to any work item — only auto-manage agent PRs
5076
- // Manually-linked PRs with autoObserve=true (_manual && !_contextOnly) are allowed through
5077
- const isAgentPr = knownAgents.has((pr.agent || '').toLowerCase()) || (pr.prdItems && pr.prdItems.length > 0) || (pr._manual && !pr._contextOnly);
5078
- if (!isAgentPr) continue;
5095
+ // Auto-managed gate: single source of truth in engine/shared.js.
5096
+ // Honors prdItems, _autoObserve, sourcePlan/itemType, and any non-empty
5097
+ // non-'human' agent author. See W-mq5rs2eq000da8a9 for the bug fix —
5098
+ // the prior inline `knownAgents.has(...) || prdItems || (_manual && !_contextOnly)`
5099
+ // silently dropped PRs with `_autoObserve: true` + human author.
5100
+ if (!shared.isAutoManagedPrRecord(pr)) {
5101
+ _logPrDispatchSkipOnce(pr, 'not-auto-managed');
5102
+ continue;
5103
+ }
5079
5104
 
5080
5105
  const prNumber = shared.getPrNumber(pr);
5081
5106
  // Use reviewStatus as single source of truth (synced from ADO/GitHub votes)
@@ -7530,6 +7555,28 @@ async function tickInner() {
7530
7555
  if (_isTickStale(myGeneration)) return;
7531
7556
  }
7532
7557
 
7558
+ // 2.54. Periodic worktree GC sweep (W-mq5o6bvy000x7191). Catches dirs the
7559
+ // dispatch-end gcDispatchWorktreeIfOrphan couldn't remove (typically Windows
7560
+ // file locks: EPERM/EBUSY on lingering spawn-agent.exe handles, AV scans,
7561
+ // node_modules\.bin shims). Delegates to `cleanup.runPeriodicWorktreeSweep`,
7562
+ // which runs the two pruners in `engine/worktree-gc.js` against a single
7563
+ // shared `dispatchSnap`: (1) in-root `pruneOrphanWorktrees` and (2)
7564
+ // out-of-root `pruneOrphanWorktreesFromGitRegistry` (catches `git worktree
7565
+ // list` entries outside `worktreeRoot`). Runs at worktreePruneIntervalTicks
7566
+ // cadence (default 30 ticks ~= 5 min).
7567
+ const wtPruneEvery = Math.max(1, ENGINE_DEFAULTS.worktreePruneIntervalTicks || 30);
7568
+ if (tickCount % wtPruneEvery === 0) {
7569
+ safe('pruneWorktreesPeriodic', () => {
7570
+ const { runPeriodicWorktreeSweep } = require('./engine/cleanup');
7571
+ const stats = runPeriodicWorktreeSweep(config);
7572
+ const totalEvicted = (stats.evicted || 0) + (stats.outOfRootEvicted || 0);
7573
+ if (totalEvicted > 0 || stats.failed > 0) {
7574
+ log('info', `worktree-prune sweep: scanned=${stats.scanned} evicted=${stats.evicted} outOfRootEvicted=${stats.outOfRootEvicted} kept=${stats.kept} failed=${stats.failed} prunedRegistry=${stats.prunedRegistry}`);
7575
+ }
7576
+ });
7577
+ if (_isTickStale(myGeneration)) return;
7578
+ }
7579
+
7533
7580
  // 2.55. Check persistent watches (~3 min — cadence in ENGINE_DEFAULTS.watchPollEvery)
7534
7581
  const watchPollIntervalMs = _pollIntervalMsFromTicks(ENGINE_DEFAULTS.watchPollEvery || 18, tickIntervalMs);
7535
7582
  if (_shouldRunPeriodicPhase(now, lastWatchCheckAt, watchPollIntervalMs)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2142",
3
+ "version": "0.1.2143",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"