@yemi33/minions 0.1.1005 → 0.1.1007

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,12 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1005 (2026-04-16)
3
+ ## 0.1.1007 (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)
9
+ - cap pendingContexts in cooldowns.json to prevent bloat (#1126)
6
10
  - fix stopAfter=0 watch expiry — run forever for all conditions (#1117)
7
11
  - fix doc-chat Clear chat not persisting session deletion to localStorage (#1102)
8
12
  - remove DEFAULTS alias from timeout.js (#1101)
@@ -19,10 +23,6 @@
19
23
  - add quality standard reminder to all agent and CC prompts
20
24
  - surface in-flight tool calls in lastAction (#1064)
21
25
  - implement dashboard loop/watch management panel
22
- - reassign tasks when preferred agent is busy too long
23
- - wire agentBusyReassignMs into settings UI and persist
24
- - ADO throttle detection, poll guards, and dashboard banner (#1051)
25
- - gate auto-fix conflict dispatch behind autoFixConflicts flag
26
26
 
27
27
  ### Fixes
28
28
  - harden repo-scoped PR identity handling
@@ -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.
@@ -1419,6 +1478,63 @@ const server = http.createServer(async (req, res) => {
1419
1478
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1420
1479
  }
1421
1480
 
1481
+ async function handleWorkItemsCancel(req, res) {
1482
+ try {
1483
+ const body = await readBody(req);
1484
+ const { id, source, reason } = body;
1485
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
1486
+
1487
+ // Find the right work-items file
1488
+ let wiPath;
1489
+ if (!source || source === 'central') {
1490
+ wiPath = path.join(MINIONS_DIR, 'work-items.json');
1491
+ } else {
1492
+ const proj = PROJECTS.find(p => p.name === source);
1493
+ if (proj) wiPath = shared.projectWorkItemsPath(proj);
1494
+ }
1495
+ if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
1496
+
1497
+ let result = null;
1498
+ mutateJsonFileLocked(wiPath, (items) => {
1499
+ if (!Array.isArray(items)) items = [];
1500
+ const item = items.find(i => i.id === id);
1501
+ if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
1502
+ // Reject already-done or already-cancelled items
1503
+ if (DONE_STATUSES.has(item.status) || item.status === WI_STATUS.CANCELLED) {
1504
+ result = { code: 400, body: { error: 'cannot cancel item with status: ' + item.status } };
1505
+ return items;
1506
+ }
1507
+ item.status = WI_STATUS.CANCELLED;
1508
+ item._cancelledBy = reason || 'user';
1509
+ item.cancelledAt = new Date().toISOString();
1510
+ result = { code: 200, body: { ok: true, item } };
1511
+ return items;
1512
+ });
1513
+ if (!result) return jsonReply(res, 500, { error: 'unexpected state' });
1514
+ if (result.code !== 200) return jsonReply(res, result.code, result.body);
1515
+
1516
+ // Clean dispatch entries + kill running agent (outside lock)
1517
+ const dispatchRemoved = cleanDispatchEntries(d =>
1518
+ d.meta?.item?.id === id ||
1519
+ d.meta?.dispatchKey?.endsWith(id)
1520
+ );
1521
+
1522
+ // Clean cooldown entries
1523
+ try {
1524
+ const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
1525
+ const cooldowns = safeJsonObj(cooldownPath);
1526
+ let cleaned = false;
1527
+ for (const key of Object.keys(cooldowns)) {
1528
+ if (key.includes(id)) { delete cooldowns[key]; cleaned = true; }
1529
+ }
1530
+ if (cleaned) safeWrite(cooldownPath, cooldowns);
1531
+ } catch (e) { console.error('cooldown cleanup on cancel:', e.message); }
1532
+
1533
+ invalidateStatusCache();
1534
+ return jsonReply(res, 200, { ok: true, id, dispatchRemoved });
1535
+ } catch (e) { return jsonReply(res, 400, { error: e.message }); }
1536
+ }
1537
+
1422
1538
  async function handleWorkItemsArchive(req, res) {
1423
1539
  try {
1424
1540
  const body = await readBody(req);
@@ -3532,6 +3648,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3532
3648
  }
3533
3649
 
3534
3650
  const parsed = parseCCActions(result.text);
3651
+ // Safety net: detect /loop invocation and convert to create-watch
3652
+ const _loopWatch = _detectLoopInvocation(parsed.text, parsed.actions);
3653
+ if (_loopWatch) {
3654
+ parsed.actions.push(_loopWatch);
3655
+ console.warn('[CC] /loop invocation detected — converted to create-watch');
3656
+ try { shared.log('warn', '/loop invocation detected in CC response — auto-converted to create-watch'); } catch {}
3657
+ }
3535
3658
  if (parsed.actions.length > 0) {
3536
3659
  parsed.actionResults = await executeCCActions(parsed.actions);
3537
3660
  }
@@ -3694,6 +3817,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3694
3817
 
3695
3818
  // Send final result with actions — execute server-side first
3696
3819
  const { text: displayText, actions } = parseCCActions(result.text);
3820
+ // Safety net: detect /loop invocation and convert to create-watch
3821
+ const _loopWatch = _detectLoopInvocation(displayText, actions);
3822
+ if (_loopWatch) {
3823
+ actions.push(_loopWatch);
3824
+ console.warn('[CC] /loop invocation detected — converted to create-watch');
3825
+ try { shared.log('warn', '/loop invocation detected in CC response — auto-converted to create-watch'); } catch {}
3826
+ }
3697
3827
  let actionResults;
3698
3828
  if (actions.length > 0) {
3699
3829
  actionResults = await executeCCActions(actions);
@@ -4244,6 +4374,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4244
4374
  { 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
4375
  { method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
4246
4376
  { method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
4377
+ { method: 'POST', path: '/api/work-items/cancel', desc: 'Cancel a work item, kill agent, clear dispatch', params: 'id, source?, reason?', handler: handleWorkItemsCancel },
4247
4378
  { method: 'POST', path: '/api/work-items/archive', desc: 'Move a completed/failed work item to archive', params: 'id, source?', handler: handleWorkItemsArchive },
4248
4379
  { method: 'GET', path: '/api/work-items/archive', desc: 'List archived work items', handler: handleWorkItemsArchiveList },
4249
4380
  { method: 'POST', path: '/api/work-items/reopen', desc: 'Reopen a done/failed work item back to pending', params: 'id, project?, description?', handler: handleWorkItemsReopen },
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
@@ -8,7 +8,7 @@ const path = require('path');
8
8
  const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
- const { exec, execSilent, log, ts } = shared;
11
+ const { exec, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
12
12
  const { safeJson, safeWrite, safeReadDir, mutateWorkItems, getProjects, projectWorkItemsPath, projectPrPath,
13
13
  sanitizeBranch, KB_CATEGORIES } = shared;
14
14
  const { getDispatch, getAgentStatus } = queries;
@@ -597,11 +597,23 @@ function runCleanup(config, verbose = false) {
597
597
  } catch (e) { log('warn', 'prune doc-sessions: ' + e.message); }
598
598
 
599
599
  // 11. Cap cooldowns.json — keep at most 500 entries (on top of 24h TTL in cooldown.js)
600
+ // Also trim pendingContexts arrays to ENGINE_DEFAULTS.maxPendingContexts to prevent bloat.
600
601
  cleaned.cooldowns = 0;
602
+ cleaned.pendingContextsTrimmed = 0;
601
603
  try {
602
604
  const cooldownPath = path.join(ENGINE_DIR, 'cooldowns.json');
603
605
  const cooldowns = safeJson(cooldownPath);
604
606
  if (cooldowns && typeof cooldowns === 'object') {
607
+ let dirty = false;
608
+ // Trim oversized pendingContexts arrays (one-time migration + ongoing cap)
609
+ const pendingCtxCap = ENGINE_DEFAULTS.maxPendingContexts;
610
+ for (const v of Object.values(cooldowns)) {
611
+ if (Array.isArray(v.pendingContexts) && v.pendingContexts.length > pendingCtxCap) {
612
+ v.pendingContexts = v.pendingContexts.slice(-pendingCtxCap);
613
+ cleaned.pendingContextsTrimmed++;
614
+ dirty = true;
615
+ }
616
+ }
605
617
  const entries = Object.entries(cooldowns);
606
618
  const COOLDOWN_CAP = 500;
607
619
  if (entries.length > COOLDOWN_CAP) {
@@ -610,6 +622,8 @@ function runCleanup(config, verbose = false) {
610
622
  const keep = Object.fromEntries(entries.slice(0, COOLDOWN_CAP));
611
623
  cleaned.cooldowns = entries.length - COOLDOWN_CAP;
612
624
  safeWrite(cooldownPath, keep);
625
+ } else if (dirty) {
626
+ safeWrite(cooldownPath, cooldowns);
613
627
  }
614
628
  }
615
629
  } catch (e) { log('warn', 'cap cooldowns: ' + e.message); }
@@ -653,8 +667,8 @@ function runCleanup(config, verbose = false) {
653
667
  }
654
668
  } catch { /* optional — file may not exist */ }
655
669
 
656
- if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles > 0) {
657
- log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pidFiles} PID files`);
670
+ if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles + cleaned.pendingContextsTrimmed > 0) {
671
+ log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pendingContextsTrimmed} pendingCtx trimmed, ${cleaned.pidFiles} PID files`);
658
672
  }
659
673
 
660
674
  return cleaned;
@@ -7,7 +7,7 @@ const path = require('path');
7
7
  const shared = require('./shared');
8
8
  const queries = require('./queries');
9
9
 
10
- const { safeJson, safeWrite, log } = shared;
10
+ const { safeJson, safeWrite, log, ENGINE_DEFAULTS } = shared;
11
11
  const { ENGINE_DIR } = queries;
12
12
 
13
13
  const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
@@ -37,6 +37,13 @@ function saveCooldowns() {
37
37
  for (const [k, v] of dispatchCooldowns) {
38
38
  if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
39
39
  }
40
+ // Trim pendingContexts arrays before writing to prevent bloat
41
+ const cap = ENGINE_DEFAULTS.maxPendingContexts;
42
+ for (const [, v] of dispatchCooldowns) {
43
+ if (Array.isArray(v.pendingContexts) && v.pendingContexts.length > cap) {
44
+ v.pendingContexts = v.pendingContexts.slice(-cap);
45
+ }
46
+ }
40
47
  const obj = Object.fromEntries(dispatchCooldowns);
41
48
  try {
42
49
  safeWrite(COOLDOWN_PATH, obj);
@@ -61,8 +68,11 @@ function setCooldown(key) {
61
68
 
62
69
  function setCooldownWithContext(key, context) {
63
70
  const existing = dispatchCooldowns.get(key);
64
- const pendingContexts = existing?.pendingContexts || [];
71
+ let pendingContexts = existing?.pendingContexts || [];
65
72
  if (context) pendingContexts.push(context);
73
+ // Cap to last N entries to prevent unbounded growth (cooldowns.json bloat)
74
+ const cap = ENGINE_DEFAULTS.maxPendingContexts;
75
+ if (pendingContexts.length > cap) pendingContexts = pendingContexts.slice(-cap);
66
76
  dispatchCooldowns.set(key, {
67
77
  timestamp: Date.now(),
68
78
  failures: existing?.failures || 0,
@@ -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
  }
@@ -814,6 +814,8 @@ function syncPrsFromOutput(output, agentId, meta, config) {
814
814
  });
815
815
  }
816
816
 
817
+ const entryBranch = meta?.branch || '';
818
+
817
819
  for (const [prPath, { name, project: targetProject, entries }] of newPrsByPath) {
818
820
  const linksToPersist = [];
819
821
  mutateJsonFileLocked(prPath, (data) => {
@@ -824,6 +826,28 @@ function syncPrsFromOutput(output, agentId, meta, config) {
824
826
  }
825
827
  for (const { prId, fullId, entry } of entries) {
826
828
  if (prs.some(p => p.id === fullId || (p.url && p.url === entry.url))) continue;
829
+
830
+ // Branch-level dedup: skip if an active PR already exists on the same branch.
831
+ // This prevents duplicate PRs when an agent retries and calls `gh pr create` again
832
+ // on the same branch (GitHub allows multiple PRs from one branch).
833
+ // Only block when the existing PR is active — abandoned/merged PRs don't conflict.
834
+ const branch = entry.branch || entryBranch;
835
+ if (branch) {
836
+ const existingOnBranch = prs.find(p => p.branch === branch && p.status === PR_STATUS.ACTIVE && p.id !== fullId);
837
+ if (existingOnBranch) {
838
+ log('warn', `Duplicate PR detected: ${fullId} on branch ${branch} — already tracked as ${existingOnBranch.id}. Skipping.`);
839
+ // Best-effort close the duplicate on GitHub (non-blocking, fire-and-forget)
840
+ try {
841
+ const ghSlug = output.match(/github\.com\/([^/]+\/[^/]+)/)?.[1];
842
+ if (ghSlug) {
843
+ execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment "Closing duplicate — ${existingOnBranch.id} already tracks this branch."`, { timeout: 15000 })
844
+ .catch(() => {});
845
+ }
846
+ } catch { /* best-effort */ }
847
+ continue;
848
+ }
849
+ }
850
+
827
851
  prs.push(entry);
828
852
  if (meta?.item?.id) {
829
853
  linksToPersist.push({ prId: fullId, itemId: meta.item.id, project: targetProject, prNumber: entry.prNumber, url: entry.url });
package/engine/shared.js CHANGED
@@ -569,6 +569,7 @@ const ENGINE_DEFAULTS = {
569
569
  ccModel: 'sonnet', // model for Command Center and doc-chat (sonnet, haiku, opus)
570
570
  ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
571
571
  heartbeatTimeouts: {}, // populated after WORK_TYPE is defined (below)
572
+ maxPendingContexts: 20, // cap pendingContexts arrays in cooldowns.json to prevent unbounded growth
572
573
  ccMaxTurns: 50, // max tool-use turns for CC/doc-chat before CLI stops
573
574
  // Teams integration — config.teams shape: { enabled, appId, appPassword, certPath, privateKeyPath, tenantId, notifyEvents, ccMirror, inboxPollInterval }
574
575
  // Auth modes: (1) appId + appPassword (client secret), or (2) appId + certPath + privateKeyPath + tenantId (certificate)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1005",
3
+ "version": "0.1.1007",
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)