@yemi33/minions 0.1.2028 → 0.1.2029

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();
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2028",
3
+ "version": "0.1.2029",
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"