@yemi33/minions 0.1.2095 → 0.1.2096

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/bin/minions.js CHANGED
@@ -11,8 +11,8 @@
11
11
  * minions update [--no-wait] Update to latest version (--no-wait backgrounds the post-update restart)
12
12
  * minions version Show installed and package versions
13
13
  * minions doctor Check prerequisites and runtime health
14
- * minions restart [--open] Start engine + dashboard (--open forces a new browser tab)
15
- * minions start Start the engine
14
+ * minions restart [--open] Stop + start engine + dashboard (--open forces a new browser tab)
15
+ * minions start [--open] Start engine + dashboard if not already running (no-op when up)
16
16
  * minions stop Stop the engine
17
17
  * minions status Show engine status
18
18
  * minions pause / resume Pause/resume dispatching
@@ -267,6 +267,53 @@ function spawnSupervisor() {
267
267
  // at function-definition time.
268
268
  const _supervisorPidPath = () => path.join(MINIONS_HOME, 'engine', 'supervisor.pid');
269
269
 
270
+ /**
271
+ * Spawn engine + dashboard + supervisor, verify health, optionally open browser.
272
+ * Shared by `minions start` and `minions restart` — restart layers a kill phase
273
+ * on top of this, start runs it directly. The IIFE at the end intentionally
274
+ * doesn't await so the parent can return; process.exit(1) fires from inside
275
+ * the IIFE if health verification fails.
276
+ */
277
+ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs }) {
278
+ const engineOut = _openStdioLog('engine-stdio.log');
279
+ const engineErr = _openStdioLog('engine-stdio.log');
280
+ const engineProc = spawn(process.execPath, [path.join(MINIONS_HOME, 'engine.js'), 'start', ...rest], {
281
+ cwd: MINIONS_HOME, stdio: ['ignore', engineOut, engineErr], detached: true, windowsHide: true
282
+ });
283
+ engineProc.unref();
284
+ console.log(`\n Engine started (PID: ${engineProc.pid})`);
285
+ const dashProc = spawnDashboard();
286
+ console.log(` Dashboard started (PID: ${dashProc.pid})`);
287
+ console.log(` Dashboard: http://localhost:${DASH_PORT}`);
288
+ const supProc = spawnSupervisor();
289
+ console.log(` Supervisor started (PID: ${supProc.pid})`);
290
+ console.log(' Verifying restart health...');
291
+ void (async () => {
292
+ const result = await waitForRestartHealth({
293
+ minionsHome: MINIONS_HOME,
294
+ dashboardPid: dashProc.pid,
295
+ dashboardPort: DASH_PORT,
296
+ });
297
+ if (!result.ok) {
298
+ console.error(formatRestartHealthError(result));
299
+ process.exit(1);
300
+ }
301
+ console.log(` Restart verified: engine PID ${result.engine.pid}; dashboard healthy.`);
302
+
303
+ const shouldOpen = forceOpen || !dashWasUp ||
304
+ !(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }));
305
+ if (shouldOpen) {
306
+ console.log(` Opening dashboard in browser...`);
307
+ _openInBrowser(`http://localhost:${DASH_PORT}`);
308
+ }
309
+ console.log('');
310
+ })().catch(err => {
311
+ // Health check failures already process.exit(1) above; reaching here means
312
+ // the browser-open path threw, which is non-fatal — restart already verified.
313
+ console.log(` Could not open dashboard: ${err.message}`);
314
+ });
315
+ }
316
+
270
317
  /** Clear the stop-intent flag so the supervisor resumes guarding the engine
271
318
  * and dashboard. Called at the top of every start/restart path.
272
319
  * Delegates to engine/shared.js when available so engine.js, dashboard.js,
@@ -803,8 +850,14 @@ function delegate(script, args) {
803
850
 
804
851
  // ─── Command routing ────────────────────────────────────────────────────────
805
852
 
853
+ // `start` is intentionally NOT in this set — it has its own handler that
854
+ // spawns the full stack (engine + dashboard + supervisor + health verify +
855
+ // browser). The internal `engine.js start` subcommand is still reachable for
856
+ // callers that want the daemon alone (supervisor.spawnEngine, dashboard's
857
+ // in-process engine watchdog) — they invoke engine.js directly, not through
858
+ // the CLI.
806
859
  const engineCmds = new Set([
807
- 'start', 'stop', 'status', 'pause', 'resume',
860
+ 'stop', 'status', 'pause', 'resume',
808
861
  'queue', 'sources', 'discover', 'dispatch',
809
862
  'spawn', 'work', 'cleanup', 'mcp-sync', 'plan',
810
863
  'kill', 'complete', 'config', 'pr', 'bridge',
@@ -824,8 +877,8 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
824
877
  minions list List linked projects
825
878
 
826
879
  Engine:
827
- minions restart [--open] Start engine + dashboard (use after reboot; --open forces a new browser tab)
828
- minions start Start engine daemon only
880
+ minions restart [--open] Stop + start engine + dashboard (--open forces a new browser tab)
881
+ minions start [--open] Start engine + dashboard if not already running (idempotent; --open forces a new tab)
829
882
  minions stop Stop the engine
830
883
  minions status Show agents, projects, queue
831
884
  minions pause / resume Pause/resume dispatching
@@ -898,6 +951,42 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
898
951
  showVersion();
899
952
  } else if (cmd === 'add' || cmd === 'remove' || cmd === 'list' || cmd === 'scan') {
900
953
  delegate('minions.js', [cmd, ...rest]);
954
+ } else if (cmd === 'start') {
955
+ // `minions start` brings up the full Minions stack: engine + dashboard +
956
+ // supervisor + health verification + browser. The internal `engine.js start`
957
+ // subcommand (engine alone, used by supervisor.spawnEngine and dashboard's
958
+ // engine watchdog) is still reachable directly via `node engine.js start`,
959
+ // but the CLI verb no longer falls through to it.
960
+ //
961
+ // Idempotent: if Minions is already up (engine alive + port listening),
962
+ // print status and optionally open the browser. Partial state (one side
963
+ // up, the other down) is a recovery scenario — refuse and direct the
964
+ // user to `minions restart`, which has the kill phase needed to reconcile.
965
+ ensureInstalled();
966
+ const enginePid = readEnginePid(MINIONS_HOME);
967
+ let engineAlive = false;
968
+ if (enginePid) {
969
+ try { process.kill(enginePid, 0); engineAlive = true; } catch { /* dead */ }
970
+ }
971
+ const dashUp = isPortListening(DASH_PORT);
972
+ if (engineAlive && dashUp) {
973
+ console.log(`\n Minions is already running (engine PID ${enginePid}; dashboard http://localhost:${DASH_PORT}).`);
974
+ if (forceOpen) {
975
+ console.log(` Opening dashboard in browser...`);
976
+ _openInBrowser(`http://localhost:${DASH_PORT}`);
977
+ } else {
978
+ console.log(` Run \`minions dash\` to open the dashboard, or \`minions start --open\` to force a new browser tab.\n`);
979
+ }
980
+ process.exit(0);
981
+ }
982
+ if (engineAlive || dashUp) {
983
+ const which = engineAlive ? `engine (PID ${enginePid}) is up but dashboard is down` : `dashboard is up but engine is down`;
984
+ console.error(`\n ERROR: Partial state detected — ${which}.`);
985
+ console.error(` Run \`minions restart\` to reconcile (it kills survivors before respawning).\n`);
986
+ process.exit(1);
987
+ }
988
+ console.log('\n Starting Minions (engine + dashboard + supervisor)...');
989
+ spawnFullStackAndVerify({ rest, forceOpen, dashWasUp: false, restartStartMs: Date.now() });
901
990
  } else if (cmd === 'restart') {
902
991
  // `--cli` / `--model` flags forward to `engine.js start` so the runtime
903
992
  // fleet flips before the daemon spawns (P-6b3f9c2e AC: works on restart).
@@ -946,43 +1035,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
946
1035
  _clearDashboardBrowserState(MINIONS_HOME);
947
1036
  // Clear stop-intent so the freshly-spawned supervisor resumes guarding.
948
1037
  clearStopIntent();
949
- const engineOut = _openStdioLog('engine-stdio.log');
950
- const engineErr = _openStdioLog('engine-stdio.log');
951
- const engineProc = spawn(process.execPath, [path.join(MINIONS_HOME, 'engine.js'), 'start', ...rest], {
952
- cwd: MINIONS_HOME, stdio: ['ignore', engineOut, engineErr], detached: true, windowsHide: true
953
- });
954
- engineProc.unref();
955
- console.log(`\n Engine started (PID: ${engineProc.pid})`);
956
- const dashProc = spawnDashboard();
957
- console.log(` Dashboard started (PID: ${dashProc.pid})`);
958
- console.log(` Dashboard: http://localhost:${DASH_PORT}`);
959
- const supProc = spawnSupervisor();
960
- console.log(` Supervisor started (PID: ${supProc.pid})`);
961
- console.log(' Verifying restart health...');
962
- void (async () => {
963
- const result = await waitForRestartHealth({
964
- minionsHome: MINIONS_HOME,
965
- dashboardPid: dashProc.pid,
966
- dashboardPort: DASH_PORT,
967
- });
968
- if (!result.ok) {
969
- console.error(formatRestartHealthError(result));
970
- process.exit(1);
971
- }
972
- console.log(` Restart verified: engine PID ${result.engine.pid}; dashboard healthy.`);
973
-
974
- const shouldOpen = forceOpen || !dashWasUp ||
975
- !(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }));
976
- if (shouldOpen) {
977
- console.log(` Opening dashboard in browser...`);
978
- _openInBrowser(`http://localhost:${DASH_PORT}`);
979
- }
980
- console.log('');
981
- })().catch(err => {
982
- // Health check failures already process.exit(1) above; reaching here means
983
- // the browser-open path threw, which is non-fatal — restart already verified.
984
- console.log(` Could not open dashboard: ${err.message}`);
985
- });
1038
+ spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs });
986
1039
  } else if (cmd === 'nuke') {
987
1040
  ensureInstalled();
988
1041
  if (!rest.includes('--confirm')) {
@@ -22,15 +22,15 @@
22
22
  const _pageCounters = {
23
23
  home: function(d) {
24
24
  // dispatch is on /api/dispatch — wait for the cache.
25
- const dispatch = window._lastDispatch || d.dispatch;
25
+ const dispatch = window._lastDispatch;
26
26
  if (!dispatch) return null;
27
27
  const completed = dispatch.completed || [];
28
28
  const last = completed[completed.length - 1];
29
29
  return completed.length + '|' + (last?.id || '') + '|' + (last?.completed_at || '');
30
30
  },
31
31
  work: function(d) {
32
- if (!Array.isArray(window._lastWorkItems) && !Array.isArray(d.workItems)) return null;
33
- const wis = window._lastWorkItems || d.workItems || [];
32
+ if (!Array.isArray(window._lastWorkItems)) return null;
33
+ const wis = window._lastWorkItems;
34
34
  return wis.length + '|' + wis.filter(function(w) { return w.status === 'pending' || w.status === 'dispatched'; }).length;
35
35
  },
36
36
  plans: function(d) {
@@ -42,8 +42,8 @@ const _pageCounters = {
42
42
  return (prog.complete || 0) + '|' + plans.length + '|' + plans.map(function(p) { return (p.file || '') + ':' + (p.status || ''); }).join(',');
43
43
  },
44
44
  prs: function(d) {
45
- if (!Array.isArray(window._lastPullRequests) && !Array.isArray(d.pullRequests)) return null;
46
- const prs = window._lastPullRequests || d.pullRequests || [];
45
+ if (!Array.isArray(window._lastPullRequests)) return null;
46
+ const prs = window._lastPullRequests;
47
47
  const counts = {};
48
48
  for (const p of prs) counts[p.status || ''] = (counts[p.status || ''] || 0) + 1;
49
49
  return prs.length + '|' + Object.keys(counts).sort().map(function(k) { return k + ':' + counts[k]; }).join(',');
@@ -57,25 +57,25 @@ const _pageCounters = {
57
57
  return ib.length + '|' + (notes.content || '').length;
58
58
  },
59
59
  watches: function(d) {
60
- if (!Array.isArray(window._lastWatches) && !Array.isArray(d.watches)) return null;
61
- const ws = window._lastWatches || d.watches || [];
60
+ if (!Array.isArray(window._lastWatches)) return null;
61
+ const ws = window._lastWatches;
62
62
  return ws.length + '|' + ws.reduce(function(m, w) { return Math.max(m, new Date(w.last_triggered || 0).getTime() || 0); }, 0);
63
63
  },
64
64
  meetings: function(d) {
65
65
  // Meetings total comes from /api/meetings-total; list from /state/meetings.
66
- if (window._lastMeetingsTotal === undefined && d.meetingsTotal === undefined) return null;
67
- const total = window._lastMeetingsTotal ?? d.meetingsTotal ?? 0;
68
- const list = window._lastMeetings || d.meetings || [];
66
+ if (window._lastMeetingsTotal === undefined) return null;
67
+ const total = window._lastMeetingsTotal ?? 0;
68
+ const list = window._lastMeetings || [];
69
69
  return total + '|' + list.reduce(function(s, m) { return s + (m.round || 0); }, 0);
70
70
  },
71
71
  pipelines: function(d) {
72
- if (!Array.isArray(window._lastPipelines) && !Array.isArray(d.pipelines)) return null;
73
- const pls = window._lastPipelines || d.pipelines || [];
72
+ if (!Array.isArray(window._lastPipelines)) return null;
73
+ const pls = window._lastPipelines;
74
74
  return pls.length + '|' + pls.reduce(function(s, p) { return s + (p.runs || []).length; }, 0);
75
75
  },
76
76
  schedule: function(d) {
77
- if (!Array.isArray(window._lastSchedules) && !Array.isArray(d.schedules)) return null;
78
- const sch = window._lastSchedules || d.schedules || [];
77
+ if (!Array.isArray(window._lastSchedules)) return null;
78
+ const sch = window._lastSchedules;
79
79
  return sch.length + '|' + sch.map(function(s) { return (s.id || '') + ':' + (s.enabled === false ? '0' : '1') + ':' + (s.cron || ''); }).sort().join(',');
80
80
  },
81
81
  tools: function(d) {
@@ -83,15 +83,15 @@ const _pageCounters = {
83
83
  return (d.skills || []).length + '|' + (d.mcpServers || []).length;
84
84
  },
85
85
  engine: function(d) {
86
- const dispatch = window._lastDispatch || d.dispatch;
86
+ const dispatch = window._lastDispatch;
87
87
  if (!dispatch) return null;
88
88
  const errors = (dispatch.completed || []).filter(function(c) { return c.result === 'error'; });
89
89
  const lastErr = errors[errors.length - 1];
90
90
  return errors.length + '|' + (lastErr?.id || '') + '|' + (lastErr?.completed_at || '');
91
91
  },
92
92
  qa: function(d) {
93
- if (!window._lastQaRunsSummary && !d.qaRuns) return null;
94
- const q = window._lastQaRunsSummary || d.qaRuns || {};
93
+ if (!window._lastQaRunsSummary) return null;
94
+ const q = window._lastQaRunsSummary;
95
95
  return (q.total || 0) + '|' + (q.sig || '');
96
96
  },
97
97
  };
@@ -116,6 +116,33 @@ function prRow(pr) {
116
116
  var followupChip = followupCount > 0
117
117
  ? ' <span class="pr-badge draft" style="font-size:8px" title="' + followupCount + ' follow-up work item(s) dispatched from comments on this PR">+' + followupCount + ' follow-up' + (followupCount === 1 ? '' : 's') + '</span>'
118
118
  : '';
119
+ // Issue #2969 — paused-cause chip(s). Prefer the API-enriched _pausedCauses
120
+ // field; fall back to deriving from _noOpFixes for the /state/ disk-fetch
121
+ // path (which bypasses queries.getPullRequests enrichment).
122
+ var pausedCauses = Array.isArray(pr._pausedCauses) ? pr._pausedCauses : [];
123
+ if (!pausedCauses.length && pr._noOpFixes && typeof pr._noOpFixes === 'object') {
124
+ pausedCauses = Object.keys(pr._noOpFixes).filter(function(c) {
125
+ var rec = pr._noOpFixes[c];
126
+ return rec && typeof rec === 'object' && rec.paused === true;
127
+ });
128
+ }
129
+ var pausedChips = '';
130
+ if (pausedCauses.length) {
131
+ pausedChips = pausedCauses.map(function(cause) {
132
+ var rec = (pr._noOpFixes && pr._noOpFixes[cause]) || {};
133
+ var titleAttr = 'PR-fix automation paused for "' + cause + '" after '
134
+ + (rec.count || '?') + ' no-op attempt(s). Reason: '
135
+ + (rec.reason || 'unknown') + '. Click to resume (clears _noOpFixes['
136
+ + cause + '] so the next discovery pass can re-dispatch).';
137
+ return ' <button class="pr-badge rejected pr-paused-chip" '
138
+ + 'style="font-size:8px;cursor:pointer;border:none" '
139
+ + 'title="' + escapeHtml(titleAttr) + '" '
140
+ + 'data-pr-id="' + escapeHtml(String(pr.id || '')) + '" '
141
+ + 'data-pr-cause="' + escapeHtml(cause) + '" '
142
+ + 'onclick="event.stopPropagation();resumePausedCause(this)">'
143
+ + '⏸ paused: ' + escapeHtml(cause) + ' ▶</button>';
144
+ }).join('');
145
+ }
119
146
  const titleText = pr.title || 'Untitled';
120
147
  const agentText = pr.agent || '—';
121
148
  const reviewerCell = sq.reviewer && sq.status !== 'waiting'
@@ -167,7 +194,7 @@ function prRow(pr) {
167
194
  // balance.
168
195
  return '<tr>' +
169
196
  '<td><span class="pr-id" title="' + escapeHtml(String(prId)) + '">' + escapeHtml(String(prId)) + '</span></td>' +
170
- '<td><a class="pr-title" title="' + escapeHtml(titleText) + '" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(titleText) + '</a>' + followupChip + (pr.description ? '<div class="pr-desc" title="' + escapeHtml(pr.description) + '">' + escapeHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
197
+ '<td><a class="pr-title" title="' + escapeHtml(titleText) + '" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(titleText) + '</a>' + followupChip + pausedChips + (pr.description ? '<div class="pr-desc" title="' + escapeHtml(pr.description) + '">' + escapeHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
171
198
  '<td><span class="pr-agent" title="' + escapeHtml(agentText) + '">' + escapeHtml(agentText) + '</span></td>' +
172
199
  '<td><span class="' + branchClass + '" title="' + escapeHtml(branchError || branchLabel) + '">' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
173
200
  '<td><span class="pr-badge ' + reviewClass + '" title="' + escapeHtml(reviewTitle || reviewLabel) + '">' + escapeHtml(reviewLabel) + '</span></td>' +
@@ -428,4 +455,36 @@ async function togglePrObserve(btn) {
428
455
  }
429
456
  }
430
457
 
431
- window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, unlinkPr, togglePrObserve };
458
+ // Issue #2969 resume a paused PR-fix cause. Optimistic UI: hide the chip
459
+ // immediately, show success toast, then POST. On API error, restore the chip
460
+ // via refresh().
461
+ async function resumePausedCause(btn) {
462
+ if (!btn || btn.disabled) return;
463
+ const prId = btn.dataset.prId;
464
+ const cause = btn.dataset.prCause;
465
+ if (!prId || !cause) return;
466
+ if (!confirm('Resume paused cause "' + cause + '" on ' + prId + '?\n\nThis clears the pause flag so the engine can re-dispatch on the next discovery pass.')) return;
467
+ btn.disabled = true;
468
+ const prevDisplay = btn.style.display;
469
+ btn.style.display = 'none';
470
+ showToast('pr-toast', 'Resumed ' + cause + ' on ' + prId, true);
471
+ try {
472
+ const res = await fetch('/api/pull-requests/clear-paused-cause', {
473
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
474
+ body: JSON.stringify({ prId, cause })
475
+ });
476
+ if (!res.ok) {
477
+ const d = await res.json().catch(() => ({}));
478
+ btn.style.display = prevDisplay;
479
+ btn.disabled = false;
480
+ showToast('pr-toast', 'Failed: ' + (d.error || 'unknown'), false);
481
+ return;
482
+ }
483
+ } catch (e) {
484
+ btn.style.display = prevDisplay;
485
+ btn.disabled = false;
486
+ showToast('pr-toast', 'Error: ' + e.message, false);
487
+ }
488
+ }
489
+
490
+ window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, unlinkPr, togglePrObserve, resumePausedCause };
package/dashboard.js CHANGED
@@ -11157,6 +11157,45 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11157
11157
  return jsonReply(res, 200, { ok: true });
11158
11158
  }},
11159
11159
 
11160
+ { method: 'POST', path: '/api/pull-requests/clear-paused-cause', desc: 'Resume a paused PR-fix cause (issue #2969) — clears _noOpFixes[cause] so the next discoverFromPrs pass can re-dispatch', params: 'prId (canonical host:slug#number), cause (build-failure|review-feedback|human-feedback|merge-conflict|pr-fix)', handler: async (req, res) => {
11161
+ const body = await readBody(req);
11162
+ const prId = typeof body?.prId === 'string' ? body.prId.trim() : '';
11163
+ const cause = typeof body?.cause === 'string' ? body.cause.trim() : '';
11164
+ if (!prId) return jsonReply(res, 400, { error: 'prId required' });
11165
+ if (!cause) return jsonReply(res, 400, { error: 'cause required' });
11166
+ // Validate cause is one of the known PR_FIX_CAUSE values to reject
11167
+ // typos before scanning state files.
11168
+ const knownCauses = new Set(Object.values(shared.PR_FIX_CAUSE));
11169
+ if (!knownCauses.has(cause)) {
11170
+ return jsonReply(res, 400, { error: `unknown cause: ${cause}` });
11171
+ }
11172
+ reloadConfig();
11173
+ const lifecycle = require('./engine/lifecycle');
11174
+ const prPaths = [
11175
+ ...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
11176
+ shared.centralPullRequestsPath(MINIONS_DIR),
11177
+ ];
11178
+ let prFound = false;
11179
+ let causeFound = false;
11180
+ for (const prPath of prPaths) {
11181
+ if (causeFound) break;
11182
+ shared.mutatePullRequests(prPath, (prs) => {
11183
+ if (!Array.isArray(prs)) return prs;
11184
+ const target = prs.find(p => p && p.id === prId);
11185
+ if (!target) return prs;
11186
+ prFound = true;
11187
+ if (!target._noOpFixes || !target._noOpFixes[cause]) return prs;
11188
+ lifecycle.clearPrNoOpFixAttempt(target, cause);
11189
+ causeFound = true;
11190
+ return prs;
11191
+ });
11192
+ }
11193
+ if (!prFound) return jsonReply(res, 404, { error: `PR ${prId} not found` });
11194
+ if (!causeFound) return jsonReply(res, 400, { error: `cause ${cause} is not tracked on PR ${prId}` });
11195
+ invalidateStatusCache();
11196
+ return jsonReply(res, 200, { ok: true, cleared: cause, prId });
11197
+ }},
11198
+
11160
11199
  { method: 'POST', path: '/api/plans/create', desc: 'Create a plan from user-provided content', params: 'title, content, project?', handler: async (req, res) => {
11161
11200
  const body = await readBody(req);
11162
11201
  const { title, content, project: projectName, meetingId } = body;
package/engine/cleanup.js CHANGED
@@ -196,6 +196,119 @@ function cleanupMergedPrLocalBranch(root, project, pr) {
196
196
  *
197
197
  * Returns the number of files unlinked.
198
198
  */
199
+ // ─── Stale .backup sidecar prune (P-c91d4f76) ──────────────────────────────
200
+ //
201
+ // safeWrite() writes a `.backup` next to every JSON state file it touches,
202
+ // and safeJsonNoRestore (W-bfa1d) stopped the read path from resurrecting
203
+ // them after the SQL Phase 0–7 migration shipped. Nothing else prunes
204
+ // them, so they accumulate. This step runs in the existing 10-tick
205
+ // cleanup phase and deletes only `.backup` sidecars — never the live
206
+ // `.json` — and only when SQLite is healthy so backups remain the legacy
207
+ // fallback for users on Node < 22.5.
208
+ //
209
+ // SQL_BACKED maps every engine-dir state file with a known SQL backing
210
+ // table to that table; safe-by-age files have no SQL backing but their
211
+ // .backup sidecars are equally useless once SQLite is the authority.
212
+
213
+ const ENGINE_SQL_BACKED_BACKUPS = [
214
+ { file: 'dispatch.json', table: 'dispatches' },
215
+ { file: 'metrics.json', table: 'metrics' },
216
+ { file: 'watches.json', table: 'watches' },
217
+ { file: 'schedule-runs.json', table: 'schedule_runs' },
218
+ { file: 'pipeline-runs.json', table: 'pipeline_runs' },
219
+ { file: 'managed-processes.json', table: 'managed_processes' },
220
+ { file: 'log.json', table: 'logs' },
221
+ ];
222
+
223
+ const ENGINE_SAFE_BY_AGE_BACKUPS = [
224
+ 'cc-sessions.json',
225
+ 'control.json',
226
+ 'cooldowns.json',
227
+ 'dashboard-browser.json',
228
+ 'doc-sessions.json',
229
+ 'kb-pins.json',
230
+ 'pending-rebases.json',
231
+ 'pr-links.json',
232
+ 'qa-sessions.json',
233
+ ];
234
+
235
+ const MEETINGS_BACKUP_FRESH_WINDOW_MS = 24 * 60 * 60 * 1000;
236
+
237
+ function _tableHasRow(db, table) {
238
+ try {
239
+ const row = db.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get();
240
+ return !!row;
241
+ } catch { return false; }
242
+ }
243
+
244
+ function _tryPruneBackup(liveFile, pruned) {
245
+ const backupPath = liveFile + '.backup';
246
+ if (!fs.existsSync(backupPath)) return;
247
+ if (!fs.existsSync(liveFile)) return; // never prune .backup when live is missing
248
+ try {
249
+ fs.unlinkSync(backupPath);
250
+ pruned.count += 1;
251
+ } catch { /* per-file failures must not abort the sweep */ }
252
+ }
253
+
254
+ function pruneStaleBackupSidecars(config) {
255
+ const pruned = { count: 0 };
256
+ let db;
257
+ try { db = require('./db').getDb(); }
258
+ catch { return pruned; } // SQLite unavailable — keep backups as the legacy fallback
259
+
260
+ // (1a) SQL-backed engine state files — require the table to be populated.
261
+ for (const { file, table } of ENGINE_SQL_BACKED_BACKUPS) {
262
+ if (!_tableHasRow(db, table)) continue;
263
+ _tryPruneBackup(path.join(ENGINE_DIR, file), pruned);
264
+ }
265
+
266
+ // (1b) Per-project work-items.json + pull-requests.json — both share
267
+ // global SQL tables, so populate-check those tables once.
268
+ const wiHasRow = _tableHasRow(db, 'work_items');
269
+ const prHasRow = _tableHasRow(db, 'pull_requests');
270
+ if (wiHasRow || prHasRow) {
271
+ for (const project of getProjects(config)) {
272
+ try {
273
+ if (wiHasRow) _tryPruneBackup(projectWorkItemsPath(project), pruned);
274
+ if (prHasRow) _tryPruneBackup(projectPrPath(project), pruned);
275
+ } catch { /* malformed project entry — skip */ }
276
+ }
277
+ }
278
+
279
+ // (1c) Safe-by-age set — no SQL table backing, but the .backup sidecars
280
+ // are equally useless once SQLite is healthy.
281
+ for (const file of ENGINE_SAFE_BY_AGE_BACKUPS) {
282
+ _tryPruneBackup(path.join(ENGINE_DIR, file), pruned);
283
+ }
284
+
285
+ // (2) Meetings: prune MTG-*.json.backup only if the live sibling exists
286
+ // AND was modified within the last 24h. Skip otherwise — possible mid-
287
+ // rename / archival state where the sidecar is still load-bearing.
288
+ const meetingsDir = path.join(MINIONS_DIR, 'meetings');
289
+ if (fs.existsSync(meetingsDir)) {
290
+ const freshCutoff = Date.now() - MEETINGS_BACKUP_FRESH_WINDOW_MS;
291
+ let entries = [];
292
+ try { entries = fs.readdirSync(meetingsDir); } catch { entries = []; }
293
+ for (const name of entries) {
294
+ if (!name.startsWith('MTG-') || !name.endsWith('.json.backup')) continue;
295
+ const backupPath = path.join(meetingsDir, name);
296
+ const livePath = backupPath.slice(0, -'.backup'.length);
297
+ let liveStat;
298
+ try { liveStat = fs.statSync(livePath); }
299
+ catch { continue; } // no live sibling — leave .backup alone
300
+ if (!liveStat.isFile()) continue;
301
+ if (liveStat.mtimeMs < freshCutoff) continue;
302
+ try {
303
+ fs.unlinkSync(backupPath);
304
+ pruned.count += 1;
305
+ } catch { /* per-file failures must not abort the sweep */ }
306
+ }
307
+ }
308
+
309
+ return pruned.count;
310
+ }
311
+
199
312
  function sweepLeakedTestMeetings(meetingsDir) {
200
313
  let cleaned = 0;
201
314
  try {
@@ -1368,6 +1481,17 @@ async function runCleanup(config, verbose = false) {
1368
1481
  log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pendingContextsTrimmed} pendingCtx trimmed, ${cleaned.notesArchive} archived notes, ${cleaned.pidFiles} PID files, ${cleaned.completionReports} completion reports`);
1369
1482
  }
1370
1483
 
1484
+ // 16. Prune stale safeWrite .backup sidecars (P-c91d4f76) — SQL is the
1485
+ // authority post-Phase-0–7, so the .backup sidecars never get read again
1486
+ // and just accumulate. One summary line per sweep, no per-file spam.
1487
+ cleaned.backupSidecars = 0;
1488
+ try {
1489
+ cleaned.backupSidecars = pruneStaleBackupSidecars(config);
1490
+ if (cleaned.backupSidecars > 0) {
1491
+ log('info', `cleanup: pruned ${cleaned.backupSidecars} stale .backup sidecars (engine/, meetings/)`);
1492
+ }
1493
+ } catch (e) { log('warn', `pruneStaleBackupSidecars: ${e.message}`); }
1494
+
1371
1495
  return cleaned;
1372
1496
  }
1373
1497
 
@@ -1394,6 +1518,7 @@ module.exports = {
1394
1518
  runCleanup,
1395
1519
  scrubStaleMetrics,
1396
1520
  sweepLeakedTestMeetings, // exported for testing
1521
+ pruneStaleBackupSidecars, // P-c91d4f76 — exported for testing
1397
1522
  worktreeDirMatchesBranch, // exported for testing
1398
1523
  worktreeMatchesBranch, // exported for testing
1399
1524
  getWorktreeBranch, // exported for lifecycle cleanup
@@ -1932,6 +1932,11 @@ function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChang
1932
1932
  const count = sameEvidence ? (Number(prior.count) || 0) + 1 : 1;
1933
1933
  const pauseAfter = Number(config?.engine?.prNoOpFixPauseAttempts) || ENGINE_DEFAULTS.prNoOpFixPauseAttempts;
1934
1934
  const paused = count >= pauseAfter;
1935
+ // Detect the false→true edge so we alert once per pause cycle rather than
1936
+ // every subsequent attempt against the same (already-paused) record. When
1937
+ // evidence drifts and the count resets, prior.paused implicitly becomes
1938
+ // irrelevant — the new record is treated as a fresh pause cycle.
1939
+ const flippedToPaused = paused && !(sameEvidence && prior?.paused === true);
1935
1940
  const now = ts();
1936
1941
  target._noOpFixes = target._noOpFixes && typeof target._noOpFixes === 'object' ? target._noOpFixes : {};
1937
1942
  target._noOpFixes[cause] = {
@@ -2015,6 +2020,48 @@ function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChang
2015
2020
  }
2016
2021
  if (cause === shared.PR_FIX_CAUSE.BUILD_FAILURE) delete target._buildFixPushedAt;
2017
2022
  if (cause === shared.PR_FIX_CAUSE.MERGE_CONFLICT) delete target._conflictFixedAt;
2023
+
2024
+ // Issue #2969 — surface pause flips to humans. The existing narrower alert
2025
+ // at the build-fix-push-unverified path only fires for BUILD_FAILURE causes
2026
+ // with explicitly unverified evidence (`local-head` / `worktree-diff`); the
2027
+ // ado:office/office/office#5216166 repro paused two causes silently because
2028
+ // its `branchChange` carried no `evidence` field at all and never reached
2029
+ // that branch. This catch-all alert covers every cause and every evidence
2030
+ // path. writeToInbox already dedupes by `${agentId}-${slug}-${dateStamp}`
2031
+ // filename, AND we guard on `flippedToPaused` so subsequent attempts on the
2032
+ // same already-paused record don't re-alert.
2033
+ if (flippedToPaused) {
2034
+ try {
2035
+ const prNumber = target.prNumber || shared.getPrNumber(target) || target.id || 'unknown';
2036
+ const record = target._noOpFixes[cause];
2037
+ const beforeHeadStr = String(record.beforeHead || '').slice(0, 40);
2038
+ const afterHeadStr = String(record.afterHead || '').slice(0, 40);
2039
+ const noteBody = `# PR automation paused: ${cause} on ${target.id || prNumber}\n\n`
2040
+ + `**PR:** ${target.url || target.id || '(unknown)'}\n`
2041
+ + `**Branch:** ${target.branch || '(unknown)'}\n`
2042
+ + `**Cause:** ${cause}\n`
2043
+ + `**Attempt:** ${count}/${pauseAfter} (paused)\n`
2044
+ + `**Pre-dispatch head:** ${beforeHeadStr || '(unknown)'}\n`
2045
+ + `**Post-completion head:** ${afterHeadStr || '(unknown)'}\n`
2046
+ + `**Reason:** ${record.reason}\n\n`
2047
+ + `The engine reached \`engine.prNoOpFixPauseAttempts\` (${pauseAfter}) no-op fix attempts against the same evidence fingerprint and has stopped re-dispatching the \`${cause}\` cause for this PR.\n\n`
2048
+ + `**Recovery options:**\n`
2049
+ + `1. A new head SHA on this PR auto-clears the pause (evidence fingerprint changes).\n`
2050
+ + `2. Call the resume endpoint to clear this single cause manually:\n`
2051
+ + ` \`POST /api/pull-requests/clear-paused-cause\` with body \`{ "prId": "${target.id || ''}", "cause": "${cause}" }\`\n`
2052
+ + `3. Use the dashboard PR card's "resume" chip (UI shortcut for the same endpoint).\n`;
2053
+ shared.writeToInbox(
2054
+ 'engine',
2055
+ `pr-cause-paused-${prNumber}-${cause}`,
2056
+ noteBody,
2057
+ null,
2058
+ { wi: dispatchItem?.meta?.item?.id || null, pr: target.id || null, cause }
2059
+ );
2060
+ } catch (err) {
2061
+ log('warn', `pr-cause-paused inbox alert for ${target.id || '(unknown)'} (${cause}): ${err.message}`);
2062
+ }
2063
+ }
2064
+
2018
2065
  return target._noOpFixes[cause];
2019
2066
  }
2020
2067
 
@@ -5087,4 +5134,10 @@ module.exports = {
5087
5134
  pickReReviewAgentHints,
5088
5135
  extractReviewThreadIds,
5089
5136
  REVIEW_SKILL_TAGS,
5137
+ // Issue #2969 — exported so dashboard.js can clear a paused cause from the
5138
+ // resume endpoint.
5139
+ clearPrNoOpFixAttempt,
5140
+ // Issue #2969 — exported for direct unit testing of the pause-flip alert
5141
+ // path without going through updatePrAfterFix's many guards.
5142
+ recordPrNoOpFixAttempt,
5090
5143
  };
package/engine/queries.js CHANGED
@@ -732,6 +732,10 @@ function getPullRequests(config) {
732
732
  shared.normalizePrRecords([pr], project);
733
733
  pr._project = project.name || 'Project';
734
734
  }
735
+ // Issue #2969 — surface paused PR-fix causes on the enriched record so
736
+ // dashboards / API consumers can render a chip without re-parsing
737
+ // _noOpFixes themselves.
738
+ pr._pausedCauses = shared.getPrPausedCauses(pr);
735
739
  allPrs.push(pr);
736
740
  seenIds.add(pr.id);
737
741
  }
@@ -758,6 +762,8 @@ function getPullRequests(config) {
758
762
  if (prNumber != null) pr.url = base + prNumber;
759
763
  }
760
764
  pr._project = project.name || 'Project';
765
+ // Issue #2969 — surface paused PR-fix causes (see SQL path above).
766
+ pr._pausedCauses = shared.getPrPausedCauses(pr);
761
767
  allPrs.push(pr);
762
768
  seenIds.add(pr.id);
763
769
  }
@@ -768,6 +774,8 @@ function getPullRequests(config) {
768
774
  for (const pr of centralPrs) {
769
775
  if (!pr?.id || seenIds.has(pr.id)) continue;
770
776
  pr._project = 'central';
777
+ // Issue #2969 — surface paused PR-fix causes (see SQL path above).
778
+ pr._pausedCauses = shared.getPrPausedCauses(pr);
771
779
  allPrs.push(pr);
772
780
  seenIds.add(pr.id);
773
781
  }
package/engine/shared.js CHANGED
@@ -5239,6 +5239,24 @@ function isPrNoOpFixCausePaused(pr, cause) {
5239
5239
  return record.evidenceFingerprint === prFixEvidenceFingerprint(pr, cause);
5240
5240
  }
5241
5241
 
5242
+ // Issue #2969 — Derive the list of currently-paused causes for surfacing on
5243
+ // `/api/pull-requests` + the dashboard PR card. Returns deduped cause strings
5244
+ // from `_noOpFixes[c].paused === true`. Does NOT re-validate evidence
5245
+ // fingerprints (the dashboard surface shows the persisted pause flag — when
5246
+ // evidence drifts and the discovery filter ignores the stale record, the
5247
+ // next fix dispatch overwrites `_noOpFixes[cause]` and naturally re-derives
5248
+ // this field on the following enrichment pass).
5249
+ function getPrPausedCauses(pr) {
5250
+ if (!pr || !pr._noOpFixes || typeof pr._noOpFixes !== 'object') return [];
5251
+ const causes = [];
5252
+ for (const [cause, record] of Object.entries(pr._noOpFixes)) {
5253
+ if (record && typeof record === 'object' && record.paused === true) {
5254
+ causes.push(cause);
5255
+ }
5256
+ }
5257
+ return causes;
5258
+ }
5259
+
5242
5260
  /**
5243
5261
  * Recursively purge Windows reserved-name pseudo-files (NUL, CON, PRN, AUX, etc.)
5244
5262
  * using the \\?\ extended path prefix that bypasses reserved-name interpretation.
@@ -5647,6 +5665,7 @@ module.exports = {
5647
5665
  prFixEvidenceFingerprint,
5648
5666
  getPrNoOpFixRecord,
5649
5667
  isPrNoOpFixCausePaused,
5668
+ getPrPausedCauses,
5650
5669
  parseSkillFrontmatter,
5651
5670
  sleepMs,
5652
5671
  killGracefully,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2095",
3
+ "version": "0.1.2096",
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"