@yemi33/minions 0.1.1006 → 0.1.1008

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/CHANGELOG.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1006 (2026-04-16)
3
+ ## 0.1.1008 (2026-04-16)
4
4
 
5
5
  ### Features
6
+ - fix duplicate PR creation + cancel stale review dispatches on PR close (#1137)
7
+ - intercept /loop skill invocations in CC and convert to watches (#1140)
8
+ - add cancel work item endpoint, UI button, and CC action (#1136)
6
9
  - cap pendingContexts in cooldowns.json to prevent bloat (#1126)
7
10
  - fix stopAfter=0 watch expiry — run forever for all conditions (#1117)
8
11
  - fix doc-chat Clear chat not persisting session deletion to localStorage (#1102)
@@ -20,11 +23,10 @@
20
23
  - add quality standard reminder to all agent and CC prompts
21
24
  - surface in-flight tool calls in lastAction (#1064)
22
25
  - implement dashboard loop/watch management panel
23
- - reassign tasks when preferred agent is busy too long
24
- - wire agentBusyReassignMs into settings UI and persist
25
- - ADO throttle detection, poll guards, and dashboard banner (#1051)
26
26
 
27
27
  ### Fixes
28
+ - harden audited state transitions
29
+ - scrub stale temp agent IDs from metrics.json (#1129)
28
30
  - harden repo-scoped PR identity handling
29
31
  - only gate reviews/fixes when no free agent slots remain
30
32
  - extend auto-link fallback to ADO projects
@@ -43,8 +45,6 @@
43
45
  - build fix sets fixDispatched to block same-tick conflict fix
44
46
  - ENGINE_DEFAULTS undefined in tick + playbook empty-var false positives
45
47
  - defer review setCooldown to post-gating in discoverWork
46
- - move review verdict check before updateWorkItemStatus(DONE)
47
- - address review feedback — move writeToInbox outside lock, add absolute condition auto-expire
48
48
 
49
49
  ### Other
50
50
  - refactor: migrate PR links to array format and consolidate shared helpers
@@ -860,6 +860,13 @@ async function ccExecuteAction(action, targetTabId) {
860
860
  status.style.color = 'var(--orange)';
861
861
  break;
862
862
  }
863
+ case 'cancel-work-item': {
864
+ await _ccFetch('/api/work-items/cancel', { id: action.id, source: action.source || '', reason: action.reason || 'cc' });
865
+ status.innerHTML = '&#10003; Cancelled work item: <strong>' + escHtml(action.id) + '</strong>';
866
+ status.style.color = 'var(--orange)';
867
+ wakeEngine();
868
+ break;
869
+ }
863
870
  case 'plan-edit': {
864
871
  // Read the plan, send instruction to doc-chat, show version actions
865
872
  var normalizedFile = normalizePlanFile(action.file);
@@ -70,6 +70,7 @@ function wiRow(item) {
70
70
  ((item.status === 'pending' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();editWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Edit work item">&#x270E;</button>' : '') +
71
71
  ((item.status === 'done' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--muted);border-color:var(--border);margin-right:4px" onclick="event.stopPropagation();archiveWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Archive work item">&#x1F4E6;</button>' : '') +
72
72
  ((item.status === 'done' || item.status === 'failed' || item.status === 'needs-human-review') && !item._humanFeedback ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();feedbackWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Give feedback">&#x1F44D;&#x1F44E;</button>' : (item._humanFeedback ? '<span style="font-size:9px" title="Feedback given">' + (item._humanFeedback.rating === 'up' ? '&#x1F44D;' : '&#x1F44E;') + '</span> ' : '')) +
73
+ ((item.status === 'pending' || item.status === 'dispatched' || item.status === 'queued' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--orange);border-color:var(--orange);margin-right:4px" onclick="event.stopPropagation();cancelWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Cancel work item and kill agent">&#x1F6AB;</button>' : '') +
73
74
  '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="event.stopPropagation();deleteWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Delete work item and kill agent">&#x2715;</button>' +
74
75
  '</td>' +
75
76
  '</tr>';
@@ -209,6 +210,21 @@ async function deleteWorkItem(id, source) {
209
210
  } catch (e) { showToast('cmd-toast', 'Delete error: ' + e.message, false); refresh(); }
210
211
  }
211
212
 
213
+ async function cancelWorkItem(id, source) {
214
+ if (!confirm('Cancel work item ' + id + '? This will kill any running agent and mark it cancelled.')) return;
215
+ markDeleted('wi:' + id);
216
+ var wiTable = document.getElementById('work-items-content');
217
+ (wiTable || document).querySelectorAll('tr').forEach(function(r) { if (r.textContent.includes(id)) r.remove(); });
218
+ showToast('cmd-toast', 'Work item cancelled', true);
219
+ try {
220
+ const res = await fetch('/api/work-items/cancel', {
221
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
222
+ body: JSON.stringify({ id, source: source || undefined })
223
+ });
224
+ if (!res.ok) { const d = await res.json().catch(() => ({})); showToast('cmd-toast', 'Cancel failed: ' + (d.error || 'unknown'), false); refresh(); }
225
+ } catch (e) { showToast('cmd-toast', 'Cancel error: ' + e.message, false); refresh(); }
226
+ }
227
+
212
228
  async function archiveWorkItem(id, source) {
213
229
  markDeleted('wi:' + id);
214
230
  var wiTable = document.getElementById('work-items-content');
package/dashboard.js CHANGED
@@ -607,6 +607,65 @@ function parseCCActions(text) {
607
607
  return { text: displayText, actions };
608
608
  }
609
609
 
610
+ // ── /loop → create-watch safety net ──────────────────────────────────────────
611
+ // CC sometimes invokes the /loop skill instead of emitting a create-watch action.
612
+ // This pure function detects /loop invocation in CC response text and synthesizes
613
+ // a create-watch action as a fallback. Returns null if no conversion needed.
614
+
615
+ function _detectLoopInvocation(text, actions) {
616
+ if (!text) return null;
617
+ // If a create-watch action was already emitted, no fallback needed
618
+ if (actions && actions.some(a => a.type === 'create-watch')) return null;
619
+
620
+ // Check for /loop invocation patterns in CC response
621
+ const loopPatterns = [
622
+ /\/loop\b/i,
623
+ /\bloop skill\b/i,
624
+ /\bSkill.*\bloop\b/i,
625
+ /\bstarted.*\bloop\b/i,
626
+ /\bmonitoring.*\bloop\b/i,
627
+ /\binvok(?:e|ed|ing).*\bloop\b/i,
628
+ ];
629
+ if (!loopPatterns.some(p => p.test(text))) return null;
630
+
631
+ // Extract target — PR number or work item ID
632
+ const prMatch = text.match(/\bPR[- #]?(\d+)\b/i) || text.match(/\bpull[- ]request[- #]?(\d+)/i);
633
+ const wiMatch = text.match(/\bW-([a-z0-9]+)\b/);
634
+
635
+ let target = null, targetType = 'pr';
636
+ if (prMatch) {
637
+ target = prMatch[1];
638
+ targetType = 'pr';
639
+ } else if (wiMatch) {
640
+ target = 'W-' + wiMatch[1];
641
+ targetType = 'work-item';
642
+ }
643
+ if (!target) return null; // Can't synthesize without a target
644
+
645
+ // Extract interval (e.g. "every 15 minutes", "every 5m")
646
+ const intervalMatch = text.match(/every\s+(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?)\b/i);
647
+ let interval = '5m';
648
+ if (intervalMatch) interval = intervalMatch[1] + intervalMatch[2][0];
649
+
650
+ // Infer condition from keywords
651
+ let condition = 'any';
652
+ if (/\bbuild\b/i.test(text) && /\b(?:pass(?:es|ing|ed)?|green|succeed(?:s|ed)?|success)\b/i.test(text)) condition = 'build-pass';
653
+ else if (/\bbuild\b/i.test(text) && /\b(?:fail(?:s|ing|ed)?|red|broken|broke)\b/i.test(text)) condition = 'build-fail';
654
+ else if (/\bmerge[d]?\b/i.test(text)) condition = 'merged';
655
+ else if (/\bcomplete[d]?\b/i.test(text)) condition = 'completed';
656
+
657
+ return {
658
+ type: 'create-watch',
659
+ target,
660
+ targetType,
661
+ condition,
662
+ interval,
663
+ owner: 'human',
664
+ description: 'Auto-converted from /loop invocation',
665
+ stopAfter: condition === 'any' ? 0 : 1,
666
+ };
667
+ }
668
+
610
669
  // ── Server-side CC action execution ──────────────────────────────────────────
611
670
  // Actions are executed server-side so all clients (frontend, curl, Teams) get the same behavior.
612
671
  // The frontend still shows status toasts but no longer needs to fire the API calls.
@@ -661,6 +720,7 @@ async function executeCCActions(actions) {
661
720
  const kbDir = path.join(MINIONS_DIR, 'knowledge', category);
662
721
  if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
663
722
  shared.safeWrite(path.join(kbDir, slug + '.md'), `# ${action.title}\n\n${action.content || action.description || ''}`);
723
+ queries.invalidateKnowledgeBaseCache();
664
724
  results.push({ type: 'knowledge', ok: true });
665
725
  break;
666
726
  }
@@ -1419,6 +1479,63 @@ const server = http.createServer(async (req, res) => {
1419
1479
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1420
1480
  }
1421
1481
 
1482
+ async function handleWorkItemsCancel(req, res) {
1483
+ try {
1484
+ const body = await readBody(req);
1485
+ const { id, source, reason } = body;
1486
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
1487
+
1488
+ // Find the right work-items file
1489
+ let wiPath;
1490
+ if (!source || source === 'central') {
1491
+ wiPath = path.join(MINIONS_DIR, 'work-items.json');
1492
+ } else {
1493
+ const proj = PROJECTS.find(p => p.name === source);
1494
+ if (proj) wiPath = shared.projectWorkItemsPath(proj);
1495
+ }
1496
+ if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
1497
+
1498
+ let result = null;
1499
+ mutateJsonFileLocked(wiPath, (items) => {
1500
+ if (!Array.isArray(items)) items = [];
1501
+ const item = items.find(i => i.id === id);
1502
+ if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
1503
+ // Reject already-done or already-cancelled items
1504
+ if (DONE_STATUSES.has(item.status) || item.status === WI_STATUS.CANCELLED) {
1505
+ result = { code: 400, body: { error: 'cannot cancel item with status: ' + item.status } };
1506
+ return items;
1507
+ }
1508
+ item.status = WI_STATUS.CANCELLED;
1509
+ item._cancelledBy = reason || 'user';
1510
+ item.cancelledAt = new Date().toISOString();
1511
+ result = { code: 200, body: { ok: true, item } };
1512
+ return items;
1513
+ });
1514
+ if (!result) return jsonReply(res, 500, { error: 'unexpected state' });
1515
+ if (result.code !== 200) return jsonReply(res, result.code, result.body);
1516
+
1517
+ // Clean dispatch entries + kill running agent (outside lock)
1518
+ const dispatchRemoved = cleanDispatchEntries(d =>
1519
+ d.meta?.item?.id === id ||
1520
+ d.meta?.dispatchKey?.endsWith(id)
1521
+ );
1522
+
1523
+ // Clean cooldown entries
1524
+ try {
1525
+ const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
1526
+ const cooldowns = safeJsonObj(cooldownPath);
1527
+ let cleaned = false;
1528
+ for (const key of Object.keys(cooldowns)) {
1529
+ if (key.includes(id)) { delete cooldowns[key]; cleaned = true; }
1530
+ }
1531
+ if (cleaned) safeWrite(cooldownPath, cooldowns);
1532
+ } catch (e) { console.error('cooldown cleanup on cancel:', e.message); }
1533
+
1534
+ invalidateStatusCache();
1535
+ return jsonReply(res, 200, { ok: true, id, dispatchRemoved });
1536
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1537
+ }
1538
+
1422
1539
  async function handleWorkItemsArchive(req, res) {
1423
1540
  try {
1424
1541
  const body = await readBody(req);
@@ -1758,13 +1875,18 @@ const server = http.createServer(async (req, res) => {
1758
1875
  if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
1759
1876
  const planPath = resolvePlanPath(body.source);
1760
1877
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
1761
- const plan = safeJsonObj(planPath);
1762
- if (!plan) return jsonReply(res, 500, { error: 'failed to read plan file' });
1763
- const idx = (plan.missing_features || []).findIndex(f => f.id === body.itemId);
1764
- if (idx < 0) return jsonReply(res, 404, { error: 'item not found in plan' });
1765
-
1766
- plan.missing_features.splice(idx, 1);
1767
- safeWrite(planPath, plan);
1878
+ let removed = false;
1879
+ mutateJsonFileLocked(planPath, (plan) => {
1880
+ if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = { missing_features: [] };
1881
+ const features = Array.isArray(plan.missing_features) ? plan.missing_features : [];
1882
+ const idx = features.findIndex(f => f.id === body.itemId);
1883
+ if (idx < 0) return plan;
1884
+ features.splice(idx, 1);
1885
+ plan.missing_features = features;
1886
+ removed = true;
1887
+ return plan;
1888
+ }, { defaultValue: { missing_features: [] } });
1889
+ if (!removed) return jsonReply(res, 404, { error: 'item not found in plan' });
1768
1890
 
1769
1891
  // Also remove any materialized work item for this plan item
1770
1892
  let cancelled = false;
@@ -2121,7 +2243,7 @@ const server = http.createServer(async (req, res) => {
2121
2243
  for (const cat of shared.KB_CATEGORIES) result[cat] = [];
2122
2244
  for (const e of entries) {
2123
2245
  if (!result[e.cat]) result[e.cat] = [];
2124
- result[e.cat].push({ file: e.file, category: e.cat, title: e.title, agent: e.agent, date: e.date, size: e.size, preview: e.preview });
2246
+ result[e.cat].push({ file: e.file, category: e.cat, title: e.title, agent: e.agent, date: e.date, sortTs: e.sortTs, size: e.size, preview: e.preview });
2125
2247
  }
2126
2248
  const swept = safeJson(path.join(ENGINE_DIR, 'kb-swept.json'));
2127
2249
  if (swept) result.lastSwept = swept.timestamp;
@@ -2315,6 +2437,7 @@ If nothing to do: { "duplicates": [], "reclassify": [], "remove": [] }`;
2315
2437
 
2316
2438
  const summary = `${merged} duplicates merged, ${removed} stale removed, ${reclassified} reclassified${pruned ? ', ' + pruned + ' old swept files pruned' : ''}`;
2317
2439
  safeWrite(path.join(ENGINE_DIR, 'kb-swept.json'), JSON.stringify({ timestamp: new Date().toISOString(), summary }));
2440
+ queries.invalidateKnowledgeBaseCache();
2318
2441
  global._kbSweepLastResult = { ok: true, summary, plan };
2319
2442
  global._kbSweepLastCompletedAt = Date.now();
2320
2443
  } catch (e) {
@@ -3160,6 +3283,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3160
3283
  if (!fs.existsSync(kbDir)) fs.mkdirSync(kbDir, { recursive: true });
3161
3284
  const kbFile = path.join(kbDir, name);
3162
3285
  safeWrite(kbFile, kbContent);
3286
+ queries.invalidateKnowledgeBaseCache();
3163
3287
 
3164
3288
  // Move inbox item to archive
3165
3289
  const archiveDir = path.join(MINIONS_DIR, 'notes', 'archive');
@@ -3532,6 +3656,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3532
3656
  }
3533
3657
 
3534
3658
  const parsed = parseCCActions(result.text);
3659
+ // Safety net: detect /loop invocation and convert to create-watch
3660
+ const _loopWatch = _detectLoopInvocation(parsed.text, parsed.actions);
3661
+ if (_loopWatch) {
3662
+ parsed.actions.push(_loopWatch);
3663
+ console.warn('[CC] /loop invocation detected — converted to create-watch');
3664
+ try { shared.log('warn', '/loop invocation detected in CC response — auto-converted to create-watch'); } catch {}
3665
+ }
3535
3666
  if (parsed.actions.length > 0) {
3536
3667
  parsed.actionResults = await executeCCActions(parsed.actions);
3537
3668
  }
@@ -3694,6 +3825,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3694
3825
 
3695
3826
  // Send final result with actions — execute server-side first
3696
3827
  const { text: displayText, actions } = parseCCActions(result.text);
3828
+ // Safety net: detect /loop invocation and convert to create-watch
3829
+ const _loopWatch = _detectLoopInvocation(displayText, actions);
3830
+ if (_loopWatch) {
3831
+ actions.push(_loopWatch);
3832
+ console.warn('[CC] /loop invocation detected — converted to create-watch');
3833
+ try { shared.log('warn', '/loop invocation detected in CC response — auto-converted to create-watch'); } catch {}
3834
+ }
3697
3835
  let actionResults;
3698
3836
  if (actions.length > 0) {
3699
3837
  actionResults = await executeCCActions(actions);
@@ -4244,6 +4382,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4244
4382
  { method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?', handler: handleWorkItemsUpdate },
4245
4383
  { method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
4246
4384
  { method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
4385
+ { method: 'POST', path: '/api/work-items/cancel', desc: 'Cancel a work item, kill agent, clear dispatch', params: 'id, source?, reason?', handler: handleWorkItemsCancel },
4247
4386
  { method: 'POST', path: '/api/work-items/archive', desc: 'Move a completed/failed work item to archive', params: 'id, source?', handler: handleWorkItemsArchive },
4248
4387
  { method: 'GET', path: '/api/work-items/archive', desc: 'List archived work items', handler: handleWorkItemsArchiveList },
4249
4388
  { method: 'POST', path: '/api/work-items/reopen', desc: 'Reopen a done/failed work item back to pending', params: 'id, project?, description?', handler: handleWorkItemsReopen },
@@ -4548,6 +4687,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4548
4687
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
4549
4688
  const header = '# ' + title + '\n\n*Created by human teammate on ' + new Date().toISOString().slice(0, 10) + '*\n\n';
4550
4689
  safeWrite(filePath, header + content);
4690
+ queries.invalidateKnowledgeBaseCache();
4551
4691
  invalidateStatusCache();
4552
4692
  return jsonReply(res, 200, { ok: true, path: filePath });
4553
4693
  }},
package/engine/ado.js CHANGED
@@ -16,6 +16,10 @@ function engine() {
16
16
  return _engine;
17
17
  }
18
18
 
19
+ // Lazy require for dispatch module (avoids circular dependency via engine)
20
+ let _dispatch = null;
21
+ function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
22
+
19
23
  const stripRefsHeads = s => (s || '').replace('refs/heads/', '');
20
24
  const getAdoPrUrl = (project, prNumber) => {
21
25
  if (project.prUrlBase) return `${project.prUrlBase}${prNumber}`;
@@ -346,6 +350,10 @@ async function pollPrStatus(config) {
346
350
  delete pr.buildFixAttempts;
347
351
  delete pr.buildFixEscalated;
348
352
  }
353
+ // Cancel any pending review/fix dispatches — they're stale now that the PR is closed
354
+ try {
355
+ dispatchModule().cancelPendingDispatchesForPr(pr.id);
356
+ } catch (e) { log('warn', `Cancel dispatches for ${pr.id}: ${e.message}`); }
349
357
  await engine().handlePostMerge(pr, project, config, newStatus);
350
358
  }
351
359
  }
package/engine/cleanup.js CHANGED
@@ -9,7 +9,7 @@ const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
11
  const { exec, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
12
- const { safeJson, safeWrite, safeReadDir, mutateWorkItems, getProjects, projectWorkItemsPath, projectPrPath,
12
+ const { safeJson, safeWrite, safeReadDir, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
13
13
  sanitizeBranch, KB_CATEGORIES } = shared;
14
14
  const { getDispatch, getAgentStatus } = queries;
15
15
 
@@ -667,6 +667,9 @@ function runCleanup(config, verbose = false) {
667
667
  }
668
668
  } catch { /* optional — file may not exist */ }
669
669
 
670
+ // 14. Scrub stale temp agent keys from metrics.json
671
+ try { scrubStaleMetrics(); } catch { /* best-effort cleanup */ }
672
+
670
673
  if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles + cleaned.pendingContextsTrimmed > 0) {
671
674
  log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pendingContextsTrimmed} pendingCtx trimmed, ${cleaned.pidFiles} PID files`);
672
675
  }
@@ -674,9 +677,27 @@ function runCleanup(config, verbose = false) {
674
677
  return cleaned;
675
678
  }
676
679
 
680
+ // ─── Metrics Scrub ──────────────────────────────────────────────────────────
681
+
682
+ /** Remove stale temp-*, agent1, and _test-* keys from metrics.json.
683
+ * Mirrors the guard in lifecycle.js updateMetrics() — cleans up keys
684
+ * that were written before that guard existed. */
685
+ function scrubStaleMetrics() {
686
+ const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
687
+ if (!fs.existsSync(metricsPath)) return;
688
+ mutateJsonFileLocked(metricsPath, metrics => {
689
+ for (const key of Object.keys(metrics)) {
690
+ if (key.startsWith('temp-') || key === 'agent1' || key.startsWith('_test')) {
691
+ delete metrics[key];
692
+ }
693
+ }
694
+ });
695
+ }
696
+
677
697
  // ─── Exports ─────────────────────────────────────────────────────────────────
678
698
 
679
699
  module.exports = {
680
700
  runCleanup,
701
+ scrubStaleMetrics,
681
702
  worktreeDirMatchesBranch, // exported for testing
682
703
  };
@@ -278,6 +278,31 @@ function updateAgentStatus(dispatchId, status, detail) {
278
278
  });
279
279
  }
280
280
 
281
+ // ─── Cancel Pending Dispatches for Closed PR ───────────────────────────────
282
+
283
+ /**
284
+ * Cancel all pending dispatch entries that reference a specific PR.
285
+ * Called when a PR transitions to merged/abandoned/closed — any pending
286
+ * review, fix, or re-review dispatches for that PR are stale and should
287
+ * not be spawned.
288
+ * @param {string} prId — PR identifier (e.g. 'PR-100')
289
+ * @returns {number} count of cancelled entries
290
+ */
291
+ function cancelPendingDispatchesForPr(prId) {
292
+ if (!prId) return 0;
293
+ let cancelled = 0;
294
+ mutateDispatch((dispatch) => {
295
+ const before = dispatch.pending.length;
296
+ dispatch.pending = dispatch.pending.filter(d => d.meta?.pr?.id !== prId);
297
+ cancelled = before - dispatch.pending.length;
298
+ return dispatch;
299
+ });
300
+ if (cancelled > 0) {
301
+ log('info', `Cancelled ${cancelled} pending dispatch(es) for closed PR ${prId}`);
302
+ }
303
+ return cancelled;
304
+ }
305
+
281
306
  // ─── Exports ─────────────────────────────────────────────────────────────────
282
307
 
283
308
  module.exports = {
@@ -287,4 +312,5 @@ module.exports = {
287
312
  completeDispatch,
288
313
  writeInboxAlert,
289
314
  updateAgentStatus,
315
+ cancelPendingDispatchesForPr,
290
316
  };
package/engine/github.js CHANGED
@@ -16,6 +16,10 @@ function engine() {
16
16
  return _engine;
17
17
  }
18
18
 
19
+ // Lazy require for dispatch module (avoids circular dependency via engine)
20
+ let _dispatch = null;
21
+ function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
22
+
19
23
  // ─── Helpers ────────────────────────────────────────────────────────────────
20
24
 
21
25
  function isGitHub(project) {
@@ -327,6 +331,10 @@ async function pollPrStatus(config) {
327
331
  delete pr.buildFixAttempts;
328
332
  delete pr.buildFixEscalated;
329
333
  }
334
+ // Cancel any pending review/fix dispatches — they're stale now that the PR is closed
335
+ try {
336
+ dispatchModule().cancelPendingDispatchesForPr(pr.id);
337
+ } catch (e) { log('warn', `Cancel dispatches for ${pr.id}: ${e.message}`); }
330
338
  await engine().handlePostMerge(pr, project, config, newStatus);
331
339
  }
332
340
  }
@@ -13,6 +13,7 @@ const { safeRead, safeJson, safeWrite, mutateJsonFileLocked, mutateWorkItems, ex
13
13
  const { trackEngineUsage } = require('./llm');
14
14
  const queries = require('./queries');
15
15
  const { isBranchActive } = require('./cooldown');
16
+ const { worktreeDirMatchesBranch } = require('./cleanup');
16
17
  const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
17
18
  MINIONS_DIR, ENGINE_DIR, PLANS_DIR, PRD_DIR, INBOX_DIR, AGENTS_DIR } = queries;
18
19
 
@@ -814,6 +815,8 @@ function syncPrsFromOutput(output, agentId, meta, config) {
814
815
  });
815
816
  }
816
817
 
818
+ const entryBranch = meta?.branch || '';
819
+
817
820
  for (const [prPath, { name, project: targetProject, entries }] of newPrsByPath) {
818
821
  const linksToPersist = [];
819
822
  mutateJsonFileLocked(prPath, (data) => {
@@ -824,6 +827,28 @@ function syncPrsFromOutput(output, agentId, meta, config) {
824
827
  }
825
828
  for (const { prId, fullId, entry } of entries) {
826
829
  if (prs.some(p => p.id === fullId || (p.url && p.url === entry.url))) continue;
830
+
831
+ // Branch-level dedup: skip if an active PR already exists on the same branch.
832
+ // This prevents duplicate PRs when an agent retries and calls `gh pr create` again
833
+ // on the same branch (GitHub allows multiple PRs from one branch).
834
+ // Only block when the existing PR is active — abandoned/merged PRs don't conflict.
835
+ const branch = entry.branch || entryBranch;
836
+ if (branch) {
837
+ const existingOnBranch = prs.find(p => p.branch === branch && p.status === PR_STATUS.ACTIVE && p.id !== fullId);
838
+ if (existingOnBranch) {
839
+ log('warn', `Duplicate PR detected: ${fullId} on branch ${branch} — already tracked as ${existingOnBranch.id}. Skipping.`);
840
+ // Best-effort close the duplicate on GitHub (non-blocking, fire-and-forget)
841
+ try {
842
+ const ghSlug = output.match(/github\.com\/([^/]+\/[^/]+)/)?.[1];
843
+ if (ghSlug) {
844
+ execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment "Closing duplicate — ${existingOnBranch.id} already tracks this branch."`, { timeout: 15000 })
845
+ .catch(() => {});
846
+ }
847
+ } catch { /* best-effort */ }
848
+ continue;
849
+ }
850
+ }
851
+
827
852
  prs.push(entry);
828
853
  if (meta?.item?.id) {
829
854
  linksToPersist.push({ prId: fullId, itemId: meta.item.id, project: targetProject, prNumber: entry.prNumber, url: entry.url });
@@ -1041,8 +1066,10 @@ const PENDING_REBASES_PATH = path.join(ENGINE_DIR, 'pending-rebases.json');
1041
1066
 
1042
1067
  function queuePendingRebase(pr, project, mergedItemId) {
1043
1068
  mutateJsonFileLocked(PENDING_REBASES_PATH, (pending) => {
1044
- if (pending.some(e => e.prId === pr.id)) return; // already queued
1069
+ const prDisplayId = shared.getPrDisplayId(pr);
1070
+ if (pending.some(e => e.projectName === project.name && shared.getPrDisplayId(e.prId) === prDisplayId)) return pending; // already queued
1045
1071
  pending.push({ prId: pr.id, branch: pr.branch, projectName: project.name, mergedItemId, queuedAt: ts(), attempts: 0 });
1072
+ return pending;
1046
1073
  }, { defaultValue: [] });
1047
1074
  }
1048
1075
 
@@ -1063,9 +1090,11 @@ async function processPendingRebases(config) {
1063
1090
  const project = shared.getProjects(config).find(p => p.name === entry.projectName);
1064
1091
  if (!project) continue;
1065
1092
 
1066
- const prs = safeJson(projectPrPath(project)) || [];
1067
- const pr = prs.find(p => p.id === entry.prId && p.status === PR_STATUS.ACTIVE);
1093
+ const prs = getPrs(project);
1094
+ const pr = shared.findPrRecord(prs, entry.prId, project);
1095
+ if (pr && pr.id !== entry.prId) entry.prId = pr.id;
1068
1096
  if (!pr) continue; // PR closed/merged since queuing
1097
+ if (pr.status !== PR_STATUS.ACTIVE) continue; // PR closed/merged since queuing
1069
1098
 
1070
1099
  const result = await rebaseBranchOntoMain(pr, project, config);
1071
1100
  if (!result.success) {
@@ -1096,12 +1125,11 @@ async function handlePostMerge(pr, project, config, newStatus) {
1096
1125
  const root = path.resolve(project.localPath);
1097
1126
  const wtRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
1098
1127
  // Find worktrees matching this branch — dir format is {slug}-{branch}-{suffix}
1099
- const branchSlug = shared.sanitizeBranch(pr.branch).toLowerCase();
1100
1128
  try {
1101
1129
  const dirs = require('fs').readdirSync(wtRoot);
1102
1130
  for (const dir of dirs) {
1103
1131
  const dirLower = dir.toLowerCase();
1104
- if (dirLower.includes(branchSlug) || dir === pr.branch || dir === `bt-${prNum}`) {
1132
+ if (worktreeDirMatchesBranch(dirLower, pr.branch) || dir === pr.branch || dir === `bt-${prNum}`) {
1105
1133
  const wtPath = path.join(wtRoot, dir);
1106
1134
  try {
1107
1135
  if (!require('fs').statSync(wtPath).isDirectory()) continue;
@@ -1778,7 +1806,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1778
1806
  // Find the worktree directory for this dispatch's branch
1779
1807
  const branchSlug = shared.sanitizeBranch ? shared.sanitizeBranch(meta.branch) : meta.branch.replace(/[^a-zA-Z0-9._\-\/]/g, '-');
1780
1808
  const dirs = fs.readdirSync(worktreeRoot).filter(d => {
1781
- return d.includes(branchSlug) && fs.statSync(path.join(worktreeRoot, d)).isDirectory();
1809
+ return worktreeDirMatchesBranch(d.toLowerCase(), meta.branch) && fs.statSync(path.join(worktreeRoot, d)).isDirectory();
1782
1810
  });
1783
1811
  // Only remove if no other active dispatch uses this branch
1784
1812
  const dispatch = getDispatch();
@@ -7,6 +7,7 @@
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
+ const queries = require('./queries');
10
11
  const { safeJson, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS } = shared;
11
12
  const http = require('http');
12
13
  const { parseCronExpr, shouldRunNow } = require('./scheduler');
@@ -144,6 +145,46 @@ function resolveStageConfig(stage, run) {
144
145
  return resolved;
145
146
  }
146
147
 
148
+ function collectPipelinePrRefs(pipeline, run) {
149
+ const refs = [];
150
+ const seen = new Set();
151
+ function addPrRef(resource) {
152
+ if (!resource) return;
153
+ let type = '';
154
+ let id = '';
155
+ let url = '';
156
+ if (typeof resource === 'string') {
157
+ id = resource.trim();
158
+ url = /^https?:\/\//i.test(id) ? id : '';
159
+ } else if (typeof resource === 'object') {
160
+ type = String(resource.type || '').trim().toLowerCase();
161
+ id = String(resource.label || resource.id || '').trim();
162
+ url = String(resource.url || '').trim();
163
+ } else {
164
+ return;
165
+ }
166
+ if (type && type !== 'pr') return;
167
+ const refId = id || url;
168
+ if (!refId && !url) return;
169
+ const prNumber = shared.getPrNumber({ id: refId, url });
170
+ if (!type && prNumber == null) return;
171
+ const key = url || refId;
172
+ if (!key || seen.has(key)) return;
173
+ seen.add(key);
174
+ refs.push({ id: refId, url });
175
+ }
176
+ for (const res of (pipeline?.monitoredResources || [])) addPrRef(res);
177
+ for (const stage of (pipeline?.stages || [])) {
178
+ for (const res of (stage?.monitoredResources || [])) addPrRef(res);
179
+ }
180
+ if (run) {
181
+ for (const [, stageState] of Object.entries(run.stages || {})) {
182
+ for (const prRef of (stageState.artifacts?.prs || [])) addPrRef(prRef);
183
+ }
184
+ }
185
+ return refs;
186
+ }
187
+
147
188
  // ── Condition Evaluation ─────────────────────────────────────────────────────
148
189
 
149
190
  /**
@@ -186,27 +227,12 @@ function evaluateCondition(condition, ctx) {
186
227
  return pipelineRuns.length >= threshold;
187
228
  }
188
229
  case 'allBuildsGreen': {
189
- // True when all PRs in monitoredResources (or run artifacts) have buildStatus 'passing'
190
- const projects = shared.getProjects(config);
191
- const allPrs = projects.reduce((acc, p) => {
192
- return acc.concat(safeJson(shared.projectPrPath(p)) || []);
193
- }, []);
194
-
195
- // Collect PR IDs to check: from monitoredResources (type: 'pr') or run artifact PRs
196
- const prIds = new Set();
197
- for (const res of (pipeline?.monitoredResources || [])) {
198
- const r = typeof res === 'string' ? { label: res } : res;
199
- if (r.type === 'pr' && r.label) prIds.add(r.label);
200
- }
201
- // Also check PRs from run artifacts
202
- if (run) {
203
- for (const [, s] of Object.entries(run.stages || {})) {
204
- for (const prId of (s.artifacts?.prs || [])) prIds.add(prId);
205
- }
206
- }
207
- if (prIds.size === 0) return false; // no PRs to check = can't confirm green
208
- for (const prId of prIds) {
209
- const pr = allPrs.find(p => p.id === prId);
230
+ // True when all PRs in monitoredResources (pipeline-level or stage-level) or run artifacts have buildStatus 'passing'
231
+ const allPrs = queries.getPullRequests(config);
232
+ const prRefs = collectPipelinePrRefs(pipeline, run);
233
+ if (prRefs.length === 0) return false; // no PRs to check = can't confirm green
234
+ for (const prRef of prRefs) {
235
+ const pr = shared.findPrRecord(allPrs, prRef);
210
236
  if (!pr || pr.buildStatus !== 'passing') return false;
211
237
  }
212
238
  return true;
package/engine/teams.js CHANGED
@@ -526,7 +526,7 @@ async function teamsNotifyPrEvent(pr, event, project, prFilePath) {
526
526
  if (!cfg.notifyEvents || !cfg.notifyEvents.includes(event)) return;
527
527
 
528
528
  // Dedup check — don't re-notify the same event
529
- if (pr._teamsNotifiedEvents && pr._teamsNotifiedEvents.includes(event)) return;
529
+ if (!prFilePath && pr._teamsNotifiedEvents && pr._teamsNotifiedEvents.includes(event)) return;
530
530
 
531
531
  const card = cards.buildPrCard(pr, event, project);
532
532
 
@@ -535,24 +535,45 @@ async function teamsNotifyPrEvent(pr, event, project, prFilePath) {
535
535
  const convKeys = Object.keys(state.conversations || {});
536
536
  if (convKeys.length === 0) return;
537
537
 
538
+ let claimedEvent = false;
539
+ let shouldPost = true;
538
540
  try {
539
- await teamsPost(convKeys[0], card);
540
- log('info', `Teams PR notification sent: ${event} for ${pr.id}`);
541
-
542
- // Record dedup — update _teamsNotifiedEvents on PR via lock
543
541
  if (prFilePath) {
544
542
  mutateJsonFileLocked(prFilePath, (prs) => {
545
543
  if (!Array.isArray(prs)) return prs;
546
544
  const target = shared.findPrRecord(prs, pr);
547
- if (target) {
548
- if (!target._teamsNotifiedEvents) target._teamsNotifiedEvents = [];
549
- if (!target._teamsNotifiedEvents.includes(event)) {
550
- target._teamsNotifiedEvents.push(event);
551
- }
545
+ if (!target) return prs;
546
+ if (!target._teamsNotifiedEvents) target._teamsNotifiedEvents = [];
547
+ if (target._teamsNotifiedEvents.includes(event)) {
548
+ shouldPost = false;
549
+ return prs;
552
550
  }
551
+ target._teamsNotifiedEvents.push(event);
552
+ claimedEvent = true;
553
+ return prs;
553
554
  }, { defaultValue: [] });
554
555
  }
556
+ if (!shouldPost) return;
557
+ await teamsPost(convKeys[0], card);
558
+ if (pr && typeof pr === 'object') {
559
+ if (!Array.isArray(pr._teamsNotifiedEvents)) pr._teamsNotifiedEvents = [];
560
+ if (!pr._teamsNotifiedEvents.includes(event)) pr._teamsNotifiedEvents.push(event);
561
+ }
562
+ log('info', `Teams PR notification sent: ${event} for ${pr.id}`);
555
563
  } catch (err) {
564
+ if (claimedEvent && prFilePath) {
565
+ try {
566
+ mutateJsonFileLocked(prFilePath, (prs) => {
567
+ if (!Array.isArray(prs)) return prs;
568
+ const target = shared.findPrRecord(prs, pr);
569
+ if (!target || !Array.isArray(target._teamsNotifiedEvents)) return prs;
570
+ target._teamsNotifiedEvents = target._teamsNotifiedEvents.filter(e => e !== event);
571
+ return prs;
572
+ }, { defaultValue: [] });
573
+ } catch (revertErr) {
574
+ log('warn', `Teams PR dedup revert failed for ${pr.id}: ${revertErr.message}`);
575
+ }
576
+ }
556
577
  log('warn', `Teams PR notification failed for ${pr.id}: ${err.message}`);
557
578
  }
558
579
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1006",
3
+ "version": "0.1.1008",
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"
@@ -91,11 +91,14 @@ Additional actions (all take `id` or `file` as primary key):
91
91
  - Schedules: schedule (id, title, cron, workType, project, agent, description, priority, enabled), delete-schedule (id)
92
92
  - Pipelines: create-pipeline (id, title, stages[], trigger, stopWhen, monitoredResources), edit-pipeline (id, title, stages, trigger), delete-pipeline (id), trigger-pipeline (id), abort-pipeline (id), retrigger-pipeline (id)
93
93
  - Meetings: add-meeting-note (id, note), advance-meeting (id), end-meeting (id), archive-meeting (id), unarchive-meeting (id), delete-meeting (id)
94
- - Work item ops: delete-work-item (id, source), archive-work-item (id), work-item-feedback (id, rating: up/down, comment), reopen-work-item (id, project[, description] — reopen a done/failed item back to pending)
94
+ - Work item ops: delete-work-item (id, source), cancel-work-item (id, source?, reason? — cancel a pending/dispatched/failed item, kills running agent), archive-work-item (id), work-item-feedback (id, rating: up/down, comment), reopen-work-item (id, project[, description] — reopen a done/failed item back to pending)
95
95
  - PRD ops: edit-prd-item, remove-prd-item, reopen-prd-item (id, file — re-dispatches on existing branch)
96
96
  - **create-watch**: target, targetType (pr/work-item), condition (merged/build-fail/build-pass/completed/failed/status-change/any/new-comments/vote-change), interval ("15m", "1h", "30s" — default "5m"), owner (agent id or "human"), description, project, stopAfter (0=run forever, 1=expire on first match, N=expire after N matches), onNotMet (null=do nothing, "notify"=write to inbox each poll while condition not met)
97
+ **NEVER use the /loop skill for monitoring tasks.** Always use the `create-watch` action — it persists across engine restarts and appears in the Watches page. /loop runs ephemerally in the session and leaves no trace.
98
+ Trigger phrases: user says "keep an eye on X", "watch X every N min", "monitor X", "check X periodically", "ping me when X" → always emit `create-watch`.
97
99
  Example: user says "check PR 1065 build every 15 min until green" → `{"type":"create-watch","target":"1065","targetType":"pr","condition":"build-pass","interval":"15m","stopAfter":1,"description":"Watch PR 1065 build until green"}`
98
100
  Example: user says "ping me every 15 min while build is still failing" → `{"type":"create-watch","target":"1065","targetType":"pr","condition":"build-pass","interval":"15m","stopAfter":1,"onNotMet":"notify","description":"Watch PR 1065 build — notify each poll"}`
101
+ Example: user says "keep an eye on PR 200 every 5 min" → `{"type":"create-watch","target":"200","targetType":"pr","condition":"any","interval":"5m","stopAfter":0,"description":"Monitor PR 200"}`
99
102
  - **delete-watch**: id — remove a watch permanently
100
103
  Example: user says "stop watching PR 1065" → `{"type":"delete-watch","id":"watch-abc123"}`
101
104
  - **pause-watch**: id — pause a watch without deleting it (can be resumed later)