@yemi33/minions 0.1.2028 → 0.1.2030

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.
@@ -4,14 +4,21 @@
4
4
  // Registry: add one line per page. Counter returns a value; badge shows when value increases.
5
5
  const _pageCounters = {
6
6
  home: function(d) { return (d.dispatch?.completed || []).length; },
7
- work: function(d) { return (d.workItems || []).length + '|' + (d.workItems || []).filter(function(w) { return w.status === 'done' || w.status === 'failed'; }).length; },
7
+ // work signature: total + in-flight count. State transitions (pending /
8
+ // dispatched → done) flip the dot; per-item title/updatedAt churn doesn't (F9/S4).
9
+ work: function(d) { return (d.workItems || []).length + '|' + (d.workItems || []).filter(function(w) { return w.status === 'pending' || w.status === 'dispatched'; }).length; },
8
10
  plans: function(d) { return (d.prdProgress?.complete || 0) + '|' + (d.plans || []).length + '|' + (d.plans || []).map(function(p) { return p.status || ''; }).join(','); },
9
11
  prs: function(d) { return (d.pullRequests || []).length + '|' + (d.pullRequests || []).filter(function(p) { return p.status === 'merged'; }).length; },
10
12
  inbox: function(d) { return (d.inbox || []).length + '|' + (d.notes?.content || '').length; },
11
- watches: function(d) { return (d.watches || []).length + '|' + (d.watches || []).map(function(w) { return [w.id || '', w.status || '', w.triggerCount || 0, w.last_triggered || ''].join(':'); }).join(','); },
13
+ // watches signature: count + max(last_triggered). Dot fires when any watch
14
+ // triggers or the count changes; triggerCount removed because it advances
15
+ // on the same event as last_triggered (F9/S4).
16
+ watches: function(d) { return (d.watches || []).length + '|' + (d.watches || []).reduce(function(m, w) { return Math.max(m, new Date(w.last_triggered || 0).getTime() || 0); }, 0); },
12
17
  meetings: function(d) { return (d.meetings || []).length + '|' + (d.meetings || []).reduce(function(s, m) { return s + (m.round || 0); }, 0); },
13
18
  pipelines: function(d) { return (d.pipelines || []).length + '|' + (d.pipelines || []).reduce(function(s, p) { return s + (p.runs || []).length; }, 0); },
14
19
  schedule: function(d) { return (d.schedules || []).length; },
20
+ // tools signature: skills count + mcp servers count.
21
+ tools: function(d) { return (d.skills || []).length + '|' + (d.mcpServers || []).length; },
15
22
  engine: function(d) { return (d.dispatch?.completed || []).filter(function(c) { return c.result === 'error'; }).length; },
16
23
  qa: function(d) { return (d.qaRuns?.total || 0) + '|' + (d.qaRuns?.sig || ''); },
17
24
  };
@@ -6,6 +6,9 @@ let _pipelinePollInterval = null;
6
6
  const PIPELINE_PER_PAGE = 25;
7
7
  let _pipelinePage = 0;
8
8
  let _pipelineTotalPages = 1;
9
+ // F7: pipeline ids whose enable/disable POST is in flight — used to render
10
+ // the toggle button as `disabled` so a second click can't race the first.
11
+ const _pipelineToggleInFlight = new Set();
9
12
  function _stopPipelinePoll() { if (_pipelinePollInterval) { clearInterval(_pipelinePollInterval); _pipelinePollInterval = null; } _pipelinePollId = null; }
10
13
 
11
14
  /**
@@ -389,7 +392,7 @@ function openPipelineDetail(id) {
389
392
  '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--yellow);border-color:var(--yellow)" onclick="_retriggerPipeline(\'' + escHtml(id) + '\',this)">Retrigger</button>'
390
393
  : '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--green);border-color:var(--green)" onclick="_triggerPipeline(\'' + escHtml(id) + '\',this)">Run Now</button>') +
391
394
  '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--blue);border-color:var(--blue)" onclick="openEditPipelineModal(\'' + escHtml(id) + '\')">Edit</button>' +
392
- '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" onclick="_togglePipelineEnabled(\'' + escHtml(id) + '\',' + !p.enabled + ',this)">' + (p.enabled !== false ? 'Disable' : 'Enable') + '</button>' +
395
+ '<button class="pr-pager-btn' + (_pipelineToggleInFlight.has(id) ? ' disabled' : '') + '" style="font-size:9px;padding:2px 8px" onclick="_togglePipelineEnabled(\'' + escHtml(id) + '\',' + !p.enabled + ',this)">' + (p.enabled !== false ? 'Disable' : 'Enable') + '</button>' +
393
396
  '<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red);border-color:var(--red)" onclick="_deletePipelineConfirm(\'' + escHtml(id) + '\')">Delete</button>' +
394
397
  '</div>' +
395
398
  '</div>';
@@ -566,14 +569,46 @@ async function _retriggerPipeline(id, btn) {
566
569
  }
567
570
 
568
571
  async function _togglePipelineEnabled(id, enabled, btn) {
572
+ if (_pipelineToggleInFlight.has(id)) return; // F7: prevent double-fires
573
+ // F7: optimistic — flip cached `enabled` and re-render both the pipeline
574
+ // card list (so the DISABLED badge appears/clears) and the detail modal
575
+ // (so the toggle button label switches) immediately. Rollback on failure.
576
+ var p = _pipelinesData.find(function(x) { return x.id === id; });
577
+ var prevEnabled = p ? p.enabled : undefined;
578
+ if (p) p.enabled = enabled;
579
+ _pipelineToggleInFlight.add(id);
569
580
  if (btn) { btn.textContent = enabled ? 'Enabling...' : 'Disabling...'; btn.style.pointerEvents = 'none'; }
581
+ renderPipelines(_pipelinesData);
582
+ if (document.getElementById('modal')?.classList?.contains('open') && _pipelinePollId === id) {
583
+ openPipelineDetail(id);
584
+ }
570
585
  showToast('cmd-toast', enabled ? 'Pipeline enabled' : 'Pipeline disabled', true);
571
586
  try {
572
587
  var res = await fetch('/api/pipelines/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id, enabled: enabled }) });
573
- if (res.ok) { refresh(); await _refreshPipelineDetail(id); }
574
- else { showToast('cmd-toast', 'Failed to ' + (enabled ? 'enable' : 'disable') + ' pipeline', false); }
575
- } catch (e) { showToast('cmd-toast', 'Error: ' + e.message, false); }
576
- if (btn) { btn.textContent = enabled ? 'Disable' : 'Enable'; btn.style.pointerEvents = ''; }
588
+ if (res.ok) {
589
+ _pipelineToggleInFlight.delete(id);
590
+ renderPipelines(_pipelinesData);
591
+ refresh();
592
+ await _refreshPipelineDetail(id);
593
+ } else {
594
+ if (p) p.enabled = prevEnabled;
595
+ _pipelineToggleInFlight.delete(id);
596
+ renderPipelines(_pipelinesData);
597
+ if (document.getElementById('modal')?.classList?.contains('open') && _pipelinePollId === id) {
598
+ openPipelineDetail(id);
599
+ }
600
+ showToast('cmd-toast', 'Failed to ' + (enabled ? 'enable' : 'disable') + ' pipeline', false);
601
+ }
602
+ } catch (e) {
603
+ if (p) p.enabled = prevEnabled;
604
+ _pipelineToggleInFlight.delete(id);
605
+ renderPipelines(_pipelinesData);
606
+ if (document.getElementById('modal')?.classList?.contains('open') && _pipelinePollId === id) {
607
+ openPipelineDetail(id);
608
+ }
609
+ showToast('cmd-toast', 'Error: ' + e.message, false);
610
+ }
611
+ if (btn) { btn.style.pointerEvents = ''; }
577
612
  }
578
613
 
579
614
  async function _continuePipeline(id, stageId, btn) {
@@ -5,6 +5,10 @@
5
5
  const _DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
6
6
  const _DAY_NAMES = ['Sundays', 'Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays'];
7
7
 
8
+ // F7: schedule ids whose enable/disable POST is in flight — used to render
9
+ // the toggle button as `disabled` so a second click can't race the first.
10
+ const _schedToggleInFlight = new Set();
11
+
8
12
  function _formatCronDowList(days, timeStr, fallback) {
9
13
  const normalized = [...new Set(days)].sort((a, b) => a - b);
10
14
  const key = normalized.join(',');
@@ -346,7 +350,7 @@ function renderSchedules(schedules) {
346
350
  '<td><span class="pr-date">' + escHtml(lastRun) + '</span></td>' +
347
351
  '<td style="white-space:nowrap">' +
348
352
  '<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();runScheduleNow(\'' + escHtml(s.id) + '\',this)" title="Run now">Run now</button>' +
349
- '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + ';border-color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + ';margin-right:4px" onclick="event.stopPropagation();toggleScheduleEnabled(\'' + escHtml(s.id) + '\',' + !s.enabled + ')" title="' + (s.enabled ? 'Disable' : 'Enable') + '">' + (s.enabled ? '&#x23F8;' : '&#x25B6;') + '</button>' +
353
+ '<button class="pr-pager-btn' + (_schedToggleInFlight.has(s.id) ? ' disabled' : '') + '" style="font-size:9px;padding:1px 6px;color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + ';border-color:' + (s.enabled ? 'var(--yellow)' : 'var(--green)') + ';margin-right:4px" onclick="event.stopPropagation();toggleScheduleEnabled(\'' + escHtml(s.id) + '\',' + !s.enabled + ')" title="' + (s.enabled ? 'Disable' : 'Enable') + '">' + (s.enabled ? '&#x23F8;' : '&#x25B6;') + '</button>' +
350
354
  '<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();openEditScheduleModal(\'' + escHtml(s.id) + '\')" title="Edit">&#x270E;</button>' +
351
355
  '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="event.stopPropagation();deleteSchedule(\'' + escHtml(s.id) + '\')" title="Delete">&#x2715;</button>' +
352
356
  '</td>' +
@@ -574,19 +578,37 @@ function _findSchedRow(id) {
574
578
  }
575
579
 
576
580
  async function toggleScheduleEnabled(id, enabled) {
577
- // Optimistic toggle swap badge text immediately
578
- var row = _findSchedRow(id);
579
- if (row) { var badge = row.querySelector('.status-badge'); if (badge) badge.textContent = enabled ? 'ENABLED' : 'DISABLED'; }
581
+ if (_schedToggleInFlight.has(id)) return; // F7: prevent double-fires
582
+ // F7: optimistic — flip cached `enabled` on the schedule + re-render so the
583
+ // badge AND the toggle button switch synchronously. Rollback on failure.
584
+ const list = window._lastSchedules || [];
585
+ const s = list.find(x => x.id === id);
586
+ const prevEnabled = s ? s.enabled : undefined;
587
+ if (s) s.enabled = enabled;
588
+ _schedToggleInFlight.add(id);
589
+ renderSchedules(list);
580
590
  try {
581
591
  const res = await fetch('/api/schedules/update', {
582
592
  method: 'POST', headers: { 'Content-Type': 'application/json' },
583
593
  body: JSON.stringify({ id, enabled })
584
594
  });
585
- if (res.ok) { refresh(); } else {
595
+ if (res.ok) {
596
+ _schedToggleInFlight.delete(id);
597
+ renderSchedules(list);
598
+ refresh();
599
+ } else {
586
600
  const d = await res.json().catch(() => ({}));
587
- _showScheduleError('Toggle failed: ' + (d.error || 'unknown')); refresh();
601
+ if (s) s.enabled = prevEnabled;
602
+ _schedToggleInFlight.delete(id);
603
+ renderSchedules(list);
604
+ showToast('cmd-toast', 'Toggle failed: ' + (d.error || 'unknown'), false);
588
605
  }
589
- } catch (e) { _showScheduleError('Toggle error: ' + e.message); refresh(); }
606
+ } catch (e) {
607
+ if (s) s.enabled = prevEnabled;
608
+ _schedToggleInFlight.delete(id);
609
+ renderSchedules(list);
610
+ showToast('cmd-toast', 'Toggle error: ' + e.message, false);
611
+ }
590
612
  }
591
613
 
592
614
  async function runScheduleNow(id, btn) {
@@ -9,6 +9,10 @@ const _WATCH_STATUS_BADGES = {
9
9
  expired: '<span class="pr-badge" style="background:rgba(139,148,158,0.15);color:var(--muted);border-color:var(--muted)">expired</span>',
10
10
  };
11
11
 
12
+ // F7: ids whose pause/resume POST is in flight — used to render the toggle
13
+ // button as `disabled` so a second click can't race the first request.
14
+ const _watchToggleInFlight = new Set();
15
+
12
16
  // Cache of target types fetched from /api/watches/target-types. Populated on
13
17
  // first modal open and refreshed per modal show. Each entry:
14
18
  // { value, label, conditions: [...], description }
@@ -158,11 +162,12 @@ function renderWatches(watchesData) {
158
162
  '<td><span class="pr-date">' + escHtml(lastChecked) + '</span></td>' +
159
163
  '<td style="white-space:nowrap">';
160
164
 
161
- // Pause/Resume button
165
+ // Pause/Resume button (F7: marks as `disabled` while POST in flight)
166
+ var pauseBusyCls = _watchToggleInFlight.has(w.id) ? ' disabled' : '';
162
167
  if (w.status === 'active') {
163
- html += '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-right:4px" onclick="event.stopPropagation();toggleWatchPause(\'' + escHtml(w.id) + '\',true)" title="Pause">&#x23F8;</button>';
168
+ html += '<button class="pr-pager-btn' + pauseBusyCls + '" style="font-size:9px;padding:1px 6px;color:var(--yellow);border-color:var(--yellow);margin-right:4px" onclick="event.stopPropagation();toggleWatchPause(\'' + escHtml(w.id) + '\',true)" title="Pause">&#x23F8;</button>';
164
169
  } else if (w.status === 'paused') {
165
- html += '<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();toggleWatchPause(\'' + escHtml(w.id) + '\',false)" title="Resume">&#x25B6;</button>';
170
+ html += '<button class="pr-pager-btn' + pauseBusyCls + '" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();toggleWatchPause(\'' + escHtml(w.id) + '\',false)" title="Resume">&#x25B6;</button>';
166
171
  }
167
172
 
168
173
  // Delete button
@@ -343,7 +348,16 @@ function openWatchDetail(id) {
343
348
  // ─── CRUD Actions ───────────────────────────────────────────────────────────
344
349
 
345
350
  function toggleWatchPause(id, pause) {
351
+ if (_watchToggleInFlight.has(id)) return; // F7: prevent double-fires
346
352
  var newStatus = pause ? 'paused' : 'active';
353
+ var list = window._lastWatches || [];
354
+ var w = list.find(function(x) { return x.id === id; });
355
+ var prevStatus = w ? w.status : null;
356
+ // F7: optimistic — flip cached status + re-render so the badge AND the
357
+ // pause/resume action button switch synchronously. Rollback on failure.
358
+ if (w) { w.status = newStatus; }
359
+ _watchToggleInFlight.add(id);
360
+ renderWatches(list);
347
361
  showToast('cmd-toast', (pause ? 'Pausing' : 'Resuming') + ' watch...', true);
348
362
  fetch('/api/watches/update', {
349
363
  method: 'POST',
@@ -351,9 +365,20 @@ function toggleWatchPause(id, pause) {
351
365
  body: JSON.stringify({ id: id, status: newStatus })
352
366
  }).then(async function(res) {
353
367
  var data = await res.json().catch(function() { return {}; });
354
- if (!res.ok || data.error) showToast('cmd-toast', 'Error: ' + (data.error || ('HTTP ' + res.status)), false);
355
- else if (typeof refresh === 'function') refresh();
368
+ if (!res.ok || data.error) {
369
+ if (w) { w.status = prevStatus; }
370
+ _watchToggleInFlight.delete(id);
371
+ renderWatches(list);
372
+ showToast('cmd-toast', 'Error: ' + (data.error || ('HTTP ' + res.status)), false);
373
+ return;
374
+ }
375
+ _watchToggleInFlight.delete(id);
376
+ renderWatches(list);
377
+ if (typeof refresh === 'function') refresh();
356
378
  }).catch(function(err) {
379
+ if (w) { w.status = prevStatus; }
380
+ _watchToggleInFlight.delete(id);
381
+ renderWatches(list);
357
382
  showToast('cmd-toast', 'Error: ' + err.message, false);
358
383
  });
359
384
  }
package/dashboard.js CHANGED
@@ -25,6 +25,8 @@ const ado = require('./engine/ado');
25
25
  const gh = require('./engine/github');
26
26
  const issues = require('./engine/issues');
27
27
  const watchesMod = require('./engine/watches');
28
+ const meetingMod = require('./engine/meeting');
29
+ const qaRunsMod = require('./engine/qa-runs');
28
30
  const routing = require('./engine/routing');
29
31
  const playbook = require('./engine/playbook');
30
32
  const dispatchMod = require('./engine/dispatch');
@@ -1593,10 +1595,27 @@ const _slowMtimeTrackedFiles = () => queries.getStatusSlowStateMtimePaths(CONFIG
1593
1595
  let _lastMtimes = {}; // { filePath: mtimeMs } — fast-state baseline
1594
1596
  let _lastSlowMtimes = {}; // { filePath: mtimeMs } — slow-state baseline
1595
1597
 
1598
+ // Stat a tracked path with transient-error tolerance. ENOENT (file/dir doesn't
1599
+ // exist) is normal — fresh installs, deleted projects, empty PRD dirs all hit
1600
+ // this — and maps to 0 so the entry just doesn't bust the cache. EBUSY /
1601
+ // EACCES / EPERM are transient on Windows (OneDrive sync, antivirus scan,
1602
+ // file replication service); falling through to 0 would produce oscillating
1603
+ // mtimes (real, then 0, then real again) that look like a real change on
1604
+ // every other poll. Preserving the previous successful mtime on those errors
1605
+ // keeps the cache stable across short lock windows.
1606
+ function _statMtimeMs(fp, prevSnapshot) {
1607
+ try {
1608
+ return fs.statSync(fp).mtimeMs;
1609
+ } catch (e) {
1610
+ if (e && e.code === 'ENOENT') return 0;
1611
+ return prevSnapshot && (fp in prevSnapshot) ? prevSnapshot[fp] : 0;
1612
+ }
1613
+ }
1614
+
1596
1615
  function _getMtimes() {
1597
1616
  const result = {};
1598
1617
  for (const fp of _mtimeTrackedFiles()) {
1599
- try { result[fp] = fs.statSync(fp).mtimeMs; } catch { result[fp] = 0; }
1618
+ result[fp] = _statMtimeMs(fp, _lastMtimes);
1600
1619
  }
1601
1620
  return result;
1602
1621
  }
@@ -1604,7 +1623,7 @@ function _getMtimes() {
1604
1623
  function _getSlowMtimes() {
1605
1624
  const result = {};
1606
1625
  for (const fp of _slowMtimeTrackedFiles()) {
1607
- try { result[fp] = fs.statSync(fp).mtimeMs; } catch { result[fp] = 0; }
1626
+ result[fp] = _statMtimeMs(fp, _lastSlowMtimes);
1608
1627
  }
1609
1628
  return result;
1610
1629
  }
@@ -1677,25 +1696,41 @@ function _buildStatusFastState() {
1677
1696
  metrics: getMetrics(),
1678
1697
  workItems: getWorkItems(),
1679
1698
  watches: watchesMod.getWatches(),
1680
- meetings: (() => { try { return require('./engine/meeting').getMeetings(); } catch { return []; } })(),
1699
+ meetings: _safeStatusSlice('meetings', () => meetingMod.getMeetings(), []),
1681
1700
  // QA runs — surfaced for the sidebar activity-dot counter and any future
1682
1701
  // CC/aggregate view. Tab-level rendering keeps its own /api/qa/runs poll
1683
1702
  // (5 s while the QA page is mounted). qa-runs.json is in the mtime tracker
1684
1703
  // so a new run lights the dot within one /api/status poll cycle (~4 s).
1685
- qaRuns: (() => {
1686
- try {
1687
- const runs = require('./engine/qa-runs').listRuns({ limit: 50 }) || [];
1688
- return {
1689
- total: runs.length,
1690
- // Signature of (id, status) for the most recent 20 runs so the
1691
- // sidebar counter advances on status flips AND on new entries.
1692
- sig: runs.slice(0, 20).map(r => (r && r.id || '') + ':' + (r && r.status || '')).join(','),
1693
- };
1694
- } catch { return { total: 0, sig: '' }; }
1695
- })(),
1704
+ // Uses the unsorted summary helper rather than listRuns({limit:50}) the
1705
+ // latter reads + sorts the FULL qa-runs.json on every fast-state rebuild
1706
+ // and would charge O(N log N) to the /api/status hot path. The summary
1707
+ // helper returns { total, sig } without sorting; that's all the sidebar
1708
+ // counter needs to detect new runs and status flips.
1709
+ qaRuns: _safeStatusSlice('qaRuns', () => qaRunsMod.summarizeRunsForStatus(), { total: 0, sig: '' }),
1696
1710
  };
1697
1711
  }
1698
1712
 
1713
+ // Run a status-slice producer with rate-limited error logging. The lazy-
1714
+ // require IIFEs this replaces silently degraded to fallback values if the
1715
+ // underlying module had a syntax error or got renamed — sidebar dots went
1716
+ // dark with no diagnostic. We keep returning the fallback so the rest of
1717
+ // the status payload survives, but we log once per minute per slice so a
1718
+ // persistent error doesn't spam the engine log AND isn't invisible.
1719
+ const _statusSliceErrorLastLogged = new Map();
1720
+ function _safeStatusSlice(name, fn, fallback) {
1721
+ try {
1722
+ return fn();
1723
+ } catch (e) {
1724
+ const now = Date.now();
1725
+ const last = _statusSliceErrorLastLogged.get(name) || 0;
1726
+ if (now - last > 60000) {
1727
+ _statusSliceErrorLastLogged.set(name, now);
1728
+ console.error('[status] slice ' + name + ' failed: ' + (e && e.message || e));
1729
+ }
1730
+ return fallback;
1731
+ }
1732
+ }
1733
+
1699
1734
  // Build the slow-state slice (rarely-changing data: ~60s TTL).
1700
1735
  function _buildStatusSlowState() {
1701
1736
  const prdInfo = getPrdInfo();
@@ -7,7 +7,7 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const shared = require('./shared');
9
9
  const queries = require('./queries');
10
- const { setCooldownFailure } = require('./cooldown');
10
+ const { setCooldown, setCooldownFailure } = require('./cooldown');
11
11
 
12
12
  const { safeJson, mutateJsonFileLocked, mutateWorkItems,
13
13
  mutatePullRequests, getProjects, projectWorkItemsPath, projectPrPath, log, ts, dateStamp,
@@ -170,7 +170,27 @@ function addToDispatch(item) {
170
170
  added = true;
171
171
  return dispatch;
172
172
  });
173
- if (added) log('info', `Queued dispatch: ${item.id} (${item.type} → ${item.agent})`);
173
+ if (added) {
174
+ log('info', `Queued dispatch: ${item.id} (${item.type} → ${item.agent})`);
175
+ // W-mph8xt88000ke0fc — Stamp the dispatch cooldown ONLY when the item
176
+ // actually survives the queue-time dedup gauntlet. Discover-side blocks
177
+ // (discoverFromPrs build-failure / review-feedback / merge-conflict,
178
+ // discoverFromWorkItems, discoverCentralWorkItems) used to call
179
+ // setCooldown(key) inline the instant a candidate item was built,
180
+ // BEFORE addToDispatch ran the prDedupeKey / workItem-id / dispatchKey
181
+ // dedup checks. The losing candidate left an orphan cooldown that
182
+ // blocked re-dispatch of that cause for `cooldownMinutes || 30` minutes
183
+ // even though no agent ever ran (live repro:
184
+ // ado:office/iss/constellation#5227109 on 2026-05-22 — build-fix and
185
+ // human-fix landed within 38 ms; only the human-fix ran but the orphan
186
+ // build-fix cooldown blocked the next 24 min of retries).
187
+ //
188
+ // Callers opt in by setting `item.meta.cooldownKey`. When absent (e.g.
189
+ // lifecycle.js auto-redispatches that intentionally bypass the cooldown
190
+ // ladder), no stamp fires — same as the pre-fix behavior for those
191
+ // paths.
192
+ if (item.meta?.cooldownKey) setCooldown(item.meta.cooldownKey);
193
+ }
174
194
  return item.id;
175
195
  }
176
196
 
@@ -2128,6 +2128,75 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
2128
2128
  return result;
2129
2129
  }
2130
2130
 
2131
+ // W-mph6br6a0006a2b9 (F9): record an agent-error fix completion onto the PR
2132
+ // so the engine.js same-head guard relaxes for the next tick. A fix dispatch
2133
+ // that crashed (non-zero exit, hard contract failure, nonce mismatch) never
2134
+ // reached updatePrAfterFix and therefore left _lastDispatchByCause[cause]
2135
+ // either absent (no entry) or — worse — frozen at a prior noop record. The
2136
+ // guard at engine.js:~4254/4445 reads `outcome === 'noop' && !indeterminate`
2137
+ // to suppress re-dispatch on the same head; once a noop record exists it
2138
+ // stays in place permanently because the SHA can't advance until the fix
2139
+ // runs. Reuses the W-mpg6wptq0011cc68 sibling-flag escape-hatch: setting
2140
+ // `indeterminate: true` is sufficient to release the guard. Preserves any
2141
+ // prior `outcome` value verbatim (success/noop/etc) so downstream debugging
2142
+ // can still see what the last known classification was.
2143
+ function updatePrAfterFixError(pr, project, source, options = {}) {
2144
+ if (!pr?.id) return null;
2145
+ if (!options || typeof options !== 'object' || Array.isArray(options)) return null;
2146
+ const prPath = project ? shared.projectPrPath(project) : centralPrPath();
2147
+ const dispatchItem = options.dispatchItem || null;
2148
+ const errorMessage = String(options.errorMessage || '').slice(0, 200);
2149
+ const errorClass = String(options.errorClass || 'agent-error');
2150
+ const cause = shared.getPrFixAutomationCause({
2151
+ dispatchKey: dispatchItem?.meta?.dispatchKey,
2152
+ source,
2153
+ task: dispatchItem?.task,
2154
+ });
2155
+ let result = null;
2156
+ shared.mutateJsonFileLocked(prPath, (prs) => {
2157
+ if (!Array.isArray(prs)) return prs;
2158
+ const target = shared.findPrRecord(prs, pr, project);
2159
+ if (!target) return prs;
2160
+ target._lastDispatchByCause = target._lastDispatchByCause
2161
+ && typeof target._lastDispatchByCause === 'object' ? target._lastDispatchByCause : {};
2162
+ const prior = target._lastDispatchByCause[cause] || null;
2163
+ const now = ts();
2164
+ const baselineHead = getPrFixBaselineHead(target);
2165
+ // Preserve the prior `outcome` verbatim (could be 'noop' from a previous
2166
+ // indeterminate noop, or undefined if no prior record). The same-head
2167
+ // guard only suppresses on `outcome === 'noop' && !indeterminate`; setting
2168
+ // `indeterminate: true` releases it regardless of outcome value.
2169
+ const next = {
2170
+ ...(prior || {}),
2171
+ indeterminate: true,
2172
+ errorClass,
2173
+ error: errorMessage,
2174
+ errorAt: now,
2175
+ dispatchedAt: now,
2176
+ dispatchId: dispatchItem?.id || prior?.dispatchId || null,
2177
+ };
2178
+ // Refresh headSha when we know the baseline; never clobber a prior value
2179
+ // with empty string (loses guard fidelity for operator-readable history).
2180
+ if (baselineHead) next.headSha = baselineHead;
2181
+ else if (prior?.headSha) next.headSha = prior.headSha;
2182
+ // Preserve the human-feedback comment id from the prior record OR refresh
2183
+ // from the live PR record. Symmetric with recordPrNoOpFixAttempt so the
2184
+ // engine.js human-feedback guard's commentId match still works once
2185
+ // indeterminate flips back off (which it doesn't here, but state shape
2186
+ // stays consistent across paths).
2187
+ if (cause === shared.PR_FIX_CAUSE.HUMAN_FEEDBACK) {
2188
+ const commentId = prior?.lastProcessedCommentId
2189
+ || String(target.humanFeedback?.lastProcessedCommentId || '');
2190
+ if (commentId) next.lastProcessedCommentId = commentId;
2191
+ }
2192
+ target._lastDispatchByCause[cause] = next;
2193
+ result = { cause, indeterminate: true, errorClass };
2194
+ log('warn', `Updated ${pr.id} → recorded ${cause} agent-error fix attempt (indeterminate=true) — same-head guard relaxed for next tick${errorMessage ? ` (${errorMessage.slice(0, 80)})` : ''}`);
2195
+ return prs;
2196
+ }, { defaultValue: [] });
2197
+ return result;
2198
+ }
2199
+
2131
2200
  // ─── Post-Merge Rebase ──────────────────────────────────────────────────────
2132
2201
 
2133
2202
  /**
@@ -4365,6 +4434,40 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
4365
4434
  }
4366
4435
  } catch (err) { log('warn', `PRD sync after fix: ${err.message}`); }
4367
4436
  }
4437
+ } else if (type === WORK_TYPE.FIX && meta?.pr?.id) {
4438
+ // W-mph6br6a0006a2b9 (F9): the fix dispatch did not complete cleanly
4439
+ // (agent crash, non-zero exit, hard contract failure, or nonce mismatch),
4440
+ // so updatePrAfterFix above was skipped. Without a write here the PR's
4441
+ // `_lastDispatchByCause[cause]` either stays empty OR — if a prior
4442
+ // indeterminate noop existed — keeps a stale record around. Stamp an
4443
+ // agent-error indeterminate record so:
4444
+ // 1. The same-head guard at engine.js:~4254/4445 relaxes for the next
4445
+ // tick (`indeterminate: true` is the W-mpg6wptq0011cc68 escape).
4446
+ // 2. Operators reading dashboard PR detail can distinguish "agent
4447
+ // crashed" (errorClass='agent-error', errorAt set) from the
4448
+ // "indeterminate noop" case (no errorClass, branchChange === null).
4449
+ // 3. Prior `outcome` is preserved verbatim — no state-shape churn.
4450
+ try {
4451
+ const failureClass = nonceMismatch?.failureClass
4452
+ || completionContractFailure?.failureClass
4453
+ || (structuredCompletion && typeof structuredCompletion.failure_class === 'string'
4454
+ ? structuredCompletion.failure_class
4455
+ : '');
4456
+ const summary = (structuredCompletion && typeof structuredCompletion.summary === 'string'
4457
+ ? structuredCompletion.summary
4458
+ : '') || resultSummary || '';
4459
+ const errorMessage = [
4460
+ failureClass ? `failure_class=${failureClass}` : '',
4461
+ summary || `agent exited with code ${code}`,
4462
+ ].filter(Boolean).join(' — ');
4463
+ updatePrAfterFixError(meta.pr, meta?.project, meta?.source, {
4464
+ dispatchItem,
4465
+ errorMessage,
4466
+ errorClass: 'agent-error',
4467
+ });
4468
+ } catch (err) {
4469
+ log('warn', `PR agent-error fix record for ${meta?.pr?.id || 'unknown PR'}: ${err.message}`);
4470
+ }
4368
4471
  }
4369
4472
  checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
4370
4473
  if (finalResult === DISPATCH_RESULT.SUCCESS) {
@@ -4558,6 +4661,7 @@ module.exports = {
4558
4661
  syncPrsFromOutput,
4559
4662
  updatePrAfterReview,
4560
4663
  updatePrAfterFix,
4664
+ updatePrAfterFixError,
4561
4665
  fixCompletionChangedBranch,
4562
4666
  handlePostMerge,
4563
4667
  checkForLearnings,
package/engine/qa-runs.js CHANGED
@@ -28,6 +28,16 @@ const path = require('path');
28
28
  const shared = require('./shared');
29
29
  const { mutateJsonFileLocked, uid, ts, log } = shared;
30
30
 
31
+ // Cap qa-runs.json so the file doesn't grow unboundedly over months of nightly
32
+ // QA dispatch. Without a cap, listRuns + summarizeRunsForStatus pay O(N) on
33
+ // every read, and /api/status's fast-state slice runs the summary on every
34
+ // rebuild — at 10k+ historical runs the JSON parse alone starts eating into
35
+ // the W-mpehsyhv event-loop budget that CC SSE isolation depends on. Mirrors
36
+ // the 2500-entry cap on engine/log.json. createRun trims oldest-by-createdAt
37
+ // when crossing the threshold; terminal-status runs that have already shipped
38
+ // completion notifications are safe to drop.
39
+ const QA_RUNS_MAX_RECORDS = 2000;
40
+
31
41
  const QA_RUN_STATUS = Object.freeze({
32
42
  PENDING: 'pending',
33
43
  RUNNING: 'running',
@@ -141,6 +151,12 @@ function createRun({ runbookId, targetName, project, workItemId } = {}) {
141
151
  mutateJsonFileLocked(qaRunsPath(), (runs) => {
142
152
  if (!Array.isArray(runs)) runs = [];
143
153
  runs.push(run);
154
+ // Rotation: drop oldest-by-createdAt when over the cap. Cheap because
155
+ // this runs only on createRun, not on every read.
156
+ if (runs.length > QA_RUNS_MAX_RECORDS) {
157
+ runs.sort((a, b) => ((a && a.createdAt) || '').localeCompare((b && b.createdAt) || ''));
158
+ runs = runs.slice(runs.length - QA_RUNS_MAX_RECORDS);
159
+ }
144
160
  return runs;
145
161
  }, { defaultValue: [] });
146
162
 
@@ -299,6 +315,30 @@ function setRunWorkItemId(id, workItemId) {
299
315
  return captured;
300
316
  }
301
317
 
318
+ /**
319
+ * Cheap summary helper for the dashboard /api/status fast-state slice. Returns
320
+ * `{ total, sig }` without sorting the run list — the sidebar activity-dot
321
+ * counter only needs to detect (a) when total advances (new run created) and
322
+ * (b) when any status flips. `sig` joins id:status across all current runs;
323
+ * any change to either advances the string, which is enough signal for the
324
+ * counter. We deliberately skip the sort that listRuns() does because this
325
+ * runs on every fast-state rebuild (~every 10 s + every mtime-tracked write),
326
+ * and an O(N log N) sort on a 2 k-entry file would eat into the event-loop
327
+ * budget that CC SSE isolation (W-mpehsyhv) depends on.
328
+ *
329
+ * @returns {{ total: number, sig: string }}
330
+ */
331
+ function summarizeRunsForStatus() {
332
+ const runs = shared.safeJsonArr(qaRunsPath());
333
+ if (!Array.isArray(runs) || runs.length === 0) return { total: 0, sig: '' };
334
+ let sig = '';
335
+ for (const r of runs) {
336
+ if (!r) continue;
337
+ sig += (r.id || '') + ':' + (r.status || '') + ',';
338
+ }
339
+ return { total: runs.length, sig };
340
+ }
341
+
302
342
  module.exports = {
303
343
  QA_RUN_STATUS,
304
344
  TERMINAL_STATUSES,
@@ -313,7 +353,9 @@ module.exports = {
313
353
  getRun,
314
354
  listRuns,
315
355
  getRunsForWorkItem,
356
+ summarizeRunsForStatus,
316
357
  // Exposed for tests:
317
358
  validateTransition,
318
359
  isValidStatus,
360
+ QA_RUNS_MAX_RECORDS,
319
361
  };
package/engine/queries.js CHANGED
@@ -1885,10 +1885,14 @@ function resetProjectGitStatusCache() {
1885
1885
  * rebases.json`, `agents/<id>/managed-spawn.json` — not in the
1886
1886
  * `/api/status` payload.
1887
1887
  * - `pinned.md`, `schedules`, `pipeline-runs.json`, `schedule-runs.json`,
1888
- * PRD JSON — slow-state only.
1889
- * - `meetings/` directory — dir mtime semantics are flaky on Windows
1890
- * for file-content changes inside the dir; meeting transitions also
1891
- * mutate work items, which already invalidate fast state.
1888
+ * PRD JSON — slow-state only (see `getStatusSlowStateMtimePaths`).
1889
+ *
1890
+ * Files tracked PER-FILE rather than via dir mtime:
1891
+ * - `meetings/<id>.json` round transitions edit each file in-place via
1892
+ * `engine/meeting.js#mutateMeeting`. The parent dir's mtime does not
1893
+ * advance on Windows NTFS for in-place edits inside an existing file,
1894
+ * so tracking each meeting JSON individually catches round/status flips
1895
+ * that dir mtime would miss. Bounded by active meeting count.
1892
1896
  *
1893
1897
  * Performance: `_getMtimes()` in dashboard.js does `fs.statSync` per path
1894
1898
  * per `getStatus()` call. Roughly N=4 engine paths + 2 per project today,
@@ -1928,17 +1932,13 @@ function getStatusFastStateMtimePaths(config) {
1928
1932
  // meetings/<id>.json (surfaced by meeting.getMeetings) — round transitions
1929
1933
  // edit each file in-place via mutateMeeting, so the parent dir's mtime
1930
1934
  // does NOT advance on Windows. Tracking each file individually catches
1931
- // in-file edits. Bounded by meeting count (typically <50 active); a 50-
1932
- // meeting fleet adds ~50 statSync calls per cache miss still cheap.
1933
- // Filter `.backup` sidecars (from safe-write tempfile pattern) and
1934
- // non-*.json entries so corrupted state can't pollute the registry.
1935
+ // in-file edits. Bounded by active meeting count; safeWrite's `.json.backup`
1936
+ // tempfile sidecars are excluded by the `.json` suffix check (a path
1937
+ // ending in `.json.backup` does not end in `.json`).
1935
1938
  try {
1936
1939
  const meetingsDir = path.join(MINIONS_DIR, 'meetings');
1937
- const entries = fs.readdirSync(meetingsDir);
1938
- for (const f of entries) {
1939
- if (f.endsWith('.json') && !f.endsWith('.backup.json')) {
1940
- files.push(path.join(meetingsDir, f));
1941
- }
1940
+ for (const f of fs.readdirSync(meetingsDir)) {
1941
+ if (f.endsWith('.json')) files.push(path.join(meetingsDir, f));
1942
1942
  }
1943
1943
  } catch { /* meetings dir absent → no meetings to track */ }
1944
1944
  // Per-project work-items (surfaced by getWorkItems) and pull-requests
@@ -1989,24 +1989,25 @@ function getStatusFastStateMtimePaths(config) {
1989
1989
  * - `mcpServers`, version, autoMode, installId — change only on human/
1990
1990
  * CLI edits, which already pop the slow-state via reloadConfig + the
1991
1991
  * 60 s TTL.
1992
- * - `~/.claude/skills/`, `~/.copilot/skills/` — user-home dirs that
1993
- * `extractSkillsFromOutput` writes to from the agent-close path,
1994
- * which already calls `invalidateStatusCache()` directly.
1992
+ * - `~/.claude/skills/`, `~/.copilot/skills/`, `<project>/.claude/skills/`,
1993
+ * `<project>/.github/skills/` — `extractSkillsFromOutput` writes here
1994
+ * from the agent-close path, which already calls
1995
+ * `invalidateStatusCache({includeSlow: true})` directly. Tracking the
1996
+ * user-home dir is additionally harmful because it's shared with every
1997
+ * Claude Code session on the machine; non-Minions activity would
1998
+ * otherwise bust this fleet's dashboard cache.
1995
1999
  * - project git state — already invalidated via the
1996
2000
  * `_setOnProjectGitStatusChanged` callback into `invalidateStatusCache`
1997
2001
  * (W-mpgrk5cy fix); also tracked in fast-state via `.git/logs/HEAD`.
1998
2002
  */
1999
- function getStatusSlowStateMtimePaths(config) {
2000
- const projects = getProjects(config || getConfig());
2001
- const files = [
2003
+ function getStatusSlowStateMtimePaths(_config) {
2004
+ // _config accepted for symmetry with getStatusFastStateMtimePaths but
2005
+ // unused every entry below is a fleet-global path.
2006
+ return [
2002
2007
  // prd/*.json (surfaced by getPrdInfo) — engine writes via syncPrdFromPrs,
2003
- // the materializer, and plan-to-prd outputs. Dir mtime advances on entry
2004
- // add/remove; in-file edits use getPrdInfo's own per-file mtime cache so
2005
- // small-content flips still surface via the 10 s TTL backstop, but the
2006
- // big "new PRD created" event surfaces immediately.
2008
+ // the materializer, and plan-to-prd outputs.
2007
2009
  PRD_DIR,
2008
- // prd/archive/*.json — manual archive moves PRDs here; dir mtime catches
2009
- // the move.
2010
+ // prd/archive/*.json — manual archive moves PRDs here.
2010
2011
  path.join(PRD_DIR, 'archive'),
2011
2012
  // prd/guides/*.md — verify agent writes new files here on E2E completion.
2012
2013
  path.join(MINIONS_DIR, 'prd', 'guides'),
@@ -2016,26 +2017,12 @@ function getStatusSlowStateMtimePaths(config) {
2016
2017
  // stage transition (the most user-visible slow-state lag pre-fix).
2017
2018
  path.join(ENGINE_DIR, 'pipeline-runs.json'),
2018
2019
  // pipelines/*.json — pipeline definitions, edited by humans + plan agents.
2019
- // Dir mtime is fine because pipeline edits are wholesale file replacements
2020
- // (no in-place tweaks once a pipeline is authored).
2021
2020
  path.join(MINIONS_DIR, 'pipelines'),
2022
2021
  // pinned.md — single file, dashboard-side writes already call
2023
2022
  // invalidateStatusCache({includeSlow:true}); tracker entry catches any
2024
2023
  // CLI/editor edit that bypasses the API.
2025
2024
  path.join(MINIONS_DIR, 'pinned.md'),
2026
- // engine/skill-states/ — engine writes when agents extract new skills.
2027
- // Dir mtime catches new skill files. Skips per-skill in-place edits which
2028
- // the agent-close invalidate already covers.
2029
- SKILLS_DIR,
2030
2025
  ];
2031
- // Per-project local skill dirs — agents extract project-scoped skills here.
2032
- for (const p of projects) {
2033
- if (p && p.localPath) {
2034
- files.push(path.join(p.localPath, '.claude', 'skills'));
2035
- files.push(path.join(p.localPath, '.github', 'skills'));
2036
- }
2037
- }
2038
- return files;
2039
2026
  }
2040
2027
 
2041
2028
  // ── Exports ─────────────────────────────────────────────────────────────────
package/engine.js CHANGED
@@ -4315,7 +4315,7 @@ async function discoverFromPrs(config, project) {
4315
4315
  const item = buildPrDispatch(agentId, config, project, pr, 'review', {
4316
4316
  pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
4317
4317
  pr_author: pr.agent || '', pr_url: pr.url || '',
4318
- }, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
4318
+ }, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, cooldownKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
4319
4319
  if (item) { newWork.push(item); fixDispatched = true; }
4320
4320
  }
4321
4321
 
@@ -4424,7 +4424,7 @@ async function discoverFromPrs(config, project) {
4424
4424
  pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
4425
4425
  reviewer: 'Human Reviewer',
4426
4426
  review_note: reviewNote,
4427
- }, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, automationCauseKey: humanCauseKey, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
4427
+ }, `Fix ${pr.id}: ${pr.title || ''} — human feedback`, { dispatchKey: key, cooldownKey: key, automationCauseKey: humanCauseKey, source: 'pr-human-feedback', pr, branch: prBranch, project: projMeta });
4428
4428
  if (item) { newWork.push(item); fixDispatched = true; }
4429
4429
  } // end if (!skipHumanFeedback) — cause-local guard for #2632
4430
4430
  }
@@ -4478,7 +4478,7 @@ async function discoverFromPrs(config, project) {
4478
4478
  pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
4479
4479
  pr_author: pr.agent || '', pr_url: pr.url || '',
4480
4480
  }, `Review ${pr.id}: ${pr.title}`, {
4481
- dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta,
4481
+ dispatchKey: key, cooldownKey: key, source: 'pr', pr, branch: prBranch, project: projMeta,
4482
4482
  deferReviewerResolution: true,
4483
4483
  });
4484
4484
  if (deferred) {
@@ -4492,7 +4492,7 @@ async function discoverFromPrs(config, project) {
4492
4492
  const item = buildPrDispatch(agentId, config, project, pr, 'review', {
4493
4493
  pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: prBranch,
4494
4494
  pr_author: pr.agent || '', pr_url: pr.url || '',
4495
- }, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
4495
+ }, `Review ${pr.id}: ${pr.title}`, { dispatchKey: key, cooldownKey: key, source: 'pr', pr, branch: prBranch, project: projMeta });
4496
4496
  if (item) { newWork.push(item); }
4497
4497
  }
4498
4498
 
@@ -4513,7 +4513,7 @@ async function discoverFromPrs(config, project) {
4513
4513
  pr_id: pr.id, pr_branch: prBranch,
4514
4514
  review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
4515
4515
  }, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, {
4516
- dispatchKey: key, automationCauseKey: reviewCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta,
4516
+ dispatchKey: key, cooldownKey: key, automationCauseKey: reviewCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta,
4517
4517
  // W-mpg58wv3 — closure-loop binding. Carries the originating minion review
4518
4518
  // WI id (and any ADO thread ids it cited) onto the fix WI so the
4519
4519
  // post-completion path in lifecycle.js can auto-dispatch a re-review
@@ -4523,7 +4523,7 @@ async function discoverFromPrs(config, project) {
4523
4523
  addresses_threads: Array.isArray(pr.minionsReview?.threads) ? pr.minionsReview.threads.slice() : [],
4524
4524
  });
4525
4525
  if (item) {
4526
- newWork.push(item); setCooldown(key); fixDispatched = true;
4526
+ newWork.push(item); fixDispatched = true;
4527
4527
  }
4528
4528
  }
4529
4529
 
@@ -4635,9 +4635,9 @@ async function discoverFromPrs(config, project) {
4635
4635
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
4636
4636
  pr_id: pr.id, pr_branch: prBranch,
4637
4637
  review_note: reviewNote,
4638
- }, `Fix build failure on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, automationCauseKey: buildCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta });
4638
+ }, `Fix build failure on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, cooldownKey: key, automationCauseKey: buildCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta });
4639
4639
  if (item) {
4640
- newWork.push(item); setCooldown(key); fixDispatched = true;
4640
+ newWork.push(item); fixDispatched = true;
4641
4641
  try {
4642
4642
  const prPath = projectPrPath(project);
4643
4643
  mutatePullRequests(prPath, prs => {
@@ -4707,10 +4707,9 @@ async function discoverFromPrs(config, project) {
4707
4707
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
4708
4708
  pr_id: pr.id, pr_branch: prBranch,
4709
4709
  review_note: `This PR has merge conflicts with the target branch. Inspect the live PR and repository history, choose the safest merge/rebase/update strategy, resolve all conflicts, validate the result, and push the branch.`,
4710
- }, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, automationCauseKey: conflictCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta });
4710
+ }, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, cooldownKey: key, automationCauseKey: conflictCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta });
4711
4711
  if (item) {
4712
4712
  newWork.push(item);
4713
- setCooldown(key);
4714
4713
  // Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
4715
4714
  try {
4716
4715
  mutatePullRequests(projectPrPath(project), prs => {
@@ -5123,10 +5122,9 @@ function discoverFromWorkItems(config, project) {
5123
5122
  agentRole: config.agents[agentId]?.role || tempAgents.get(agentId)?.role || 'Agent',
5124
5123
  task: `[${project?.name || 'project'}] ${item.title || item.description?.slice(0, 80) || item.id}`,
5125
5124
  prompt,
5126
- meta: { dispatchKey: key, source: 'work-item', branch: branchName, branchStrategy: item.branchStrategy || 'parallel', useExistingBranch: !!(isPrTargeted || (item.branchStrategy === 'shared-branch' && item.featureBranch)), item: promptItem, project: { name: project?.name, localPath: project?.localPath }, deferAgentResolution: deferredAgentResolution, ...(linkedPr ? { pr: linkedPr } : {}) }
5125
+ meta: { dispatchKey: key, cooldownKey: key, source: 'work-item', branch: branchName, branchStrategy: item.branchStrategy || 'parallel', useExistingBranch: !!(isPrTargeted || (item.branchStrategy === 'shared-branch' && item.featureBranch)), item: promptItem, project: { name: project?.name, localPath: project?.localPath }, deferAgentResolution: deferredAgentResolution, ...(linkedPr ? { pr: linkedPr } : {}) }
5127
5126
  });
5128
5127
 
5129
- setCooldown(key);
5130
5128
  } catch (err) { log('warn', `discoverFromWorkItems: skipping ${item.id}: ${err.message}`); }
5131
5129
  }
5132
5130
 
@@ -5567,7 +5565,7 @@ function discoverCentralWorkItems(config) {
5567
5565
  task: `[fan-out] ${item.title} → ${agent.name}${assignedProject ? ' → ' + assignedProject.name : ''}`,
5568
5566
  prompt,
5569
5567
  meta: {
5570
- dispatchKey: fanKey, source: 'central-work-item-fanout', item, parentKey: key,
5568
+ dispatchKey: fanKey, cooldownKey: key, source: 'central-work-item-fanout', item, parentKey: key,
5571
5569
  branch: fanBranch,
5572
5570
  deadline: item.timeout ? Date.now() + item.timeout : Date.now() + (config.engine?.fanOutTimeout || config.engine?.agentTimeout || ENGINE_DEFAULTS.agentTimeout)
5573
5571
  }
@@ -5579,7 +5577,6 @@ function discoverCentralWorkItems(config) {
5579
5577
  scope: 'fan-out',
5580
5578
  fanOutAgents: idleAgents.map(a => a.id),
5581
5579
  });
5582
- setCooldown(key);
5583
5580
  log('info', `Fan-out: ${item.id} queued for ${idleAgents.length} agents: ${idleAgents.map(a => a.name).join(', ')}`);
5584
5581
 
5585
5582
  } else {
@@ -5793,10 +5790,8 @@ function discoverCentralWorkItems(config) {
5793
5790
  agentRole,
5794
5791
  task: item.title || item.description?.slice(0, 80) || item.id,
5795
5792
  prompt,
5796
- meta: { dispatchKey: key, source: 'central-work-item', item: { ...item, ...mutations.get(item.id) }, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: centralBranch, ...(targetProject ? { project: { name: targetProject.name, localPath: targetProject.localPath } } : {}) }
5793
+ meta: { dispatchKey: key, cooldownKey: key, source: 'central-work-item', item: { ...item, ...mutations.get(item.id) }, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: centralBranch, ...(targetProject ? { project: { name: targetProject.name, localPath: targetProject.localPath } } : {}) }
5797
5794
  });
5798
-
5799
- setCooldown(key);
5800
5795
  }
5801
5796
  } catch (err) { log('warn', `discoverCentralWorkItems: skipping ${item.id}: ${err.message}`); }
5802
5797
  }
@@ -6004,7 +5999,10 @@ async function discoverWork(config) {
6004
5999
 
6005
6000
  for (const item of allWork) {
6006
6001
  await addToDispatchWithValidation(item, { config });
6007
- if (item.meta?.dispatchKey) setCooldown(item.meta.dispatchKey);
6002
+ // W-mph8xt88000ke0fc — Cooldowns are stamped by addToDispatch ONLY on
6003
+ // successful append (post-dedup). Stamping unconditionally here used to
6004
+ // leave orphan cooldowns for items collapsed by prDedupeKey / workItem-id
6005
+ // / dispatchKey dedup or routed to the pre-dispatch review queue.
6008
6006
  if (item.meta?.source === 'pr-human-feedback') {
6009
6007
  clearPendingHumanFeedbackFlag(item.meta.project, item.meta.pr?.id);
6010
6008
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2028",
3
+ "version": "0.1.2030",
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"