@yemi33/minions 0.1.2094 → 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/cli-api-client.js +718 -0
- package/bin/minions.js +112 -42
- package/dashboard/js/refresh.js +17 -17
- package/dashboard/js/render-prs.js +61 -2
- package/dashboard.js +39 -0
- package/engine/cleanup.js +125 -0
- package/engine/lifecycle.js +53 -0
- package/engine/queries.js +8 -0
- package/engine/shared.js +19 -0
- package/package.json +1 -1
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]
|
|
15
|
-
* minions start
|
|
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
|
-
'
|
|
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]
|
|
828
|
-
minions start
|
|
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
|
|
@@ -848,6 +901,15 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
848
901
|
minions nuke --confirm Factory reset (delete state, reset config to defaults)
|
|
849
902
|
minions uninstall --confirm Remove everything + uninstall npm package
|
|
850
903
|
|
|
904
|
+
Dashboard API (HTTP passthrough to localhost:7331):
|
|
905
|
+
minions api <METHOD> <PATH> Generic API call (any endpoint, see /api/routes)
|
|
906
|
+
minions wi <subcmd> Work-items: list/show/cancel/retry/delete/archive/reopen/feedback
|
|
907
|
+
minions plans <subcmd> Plans: list/show/approve/pause/reject/archive/unarchive/regenerate
|
|
908
|
+
minions schedule <subcmd> Schedules: list/show/add/update/delete/run-now/parse
|
|
909
|
+
minions watch <subcmd> Watches: list/show/add/update/delete/target-types/action-types
|
|
910
|
+
minions feature <subcmd> Feature flags: list/toggle
|
|
911
|
+
minions settings <subcmd> Settings: get [path]/set <path> <value>/reset
|
|
912
|
+
|
|
851
913
|
Dashboard:
|
|
852
914
|
minions dash Start web dashboard (default :7331)
|
|
853
915
|
|
|
@@ -889,6 +951,42 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
889
951
|
showVersion();
|
|
890
952
|
} else if (cmd === 'add' || cmd === 'remove' || cmd === 'list' || cmd === 'scan') {
|
|
891
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() });
|
|
892
990
|
} else if (cmd === 'restart') {
|
|
893
991
|
// `--cli` / `--model` flags forward to `engine.js start` so the runtime
|
|
894
992
|
// fleet flips before the daemon spawns (P-6b3f9c2e AC: works on restart).
|
|
@@ -937,43 +1035,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
937
1035
|
_clearDashboardBrowserState(MINIONS_HOME);
|
|
938
1036
|
// Clear stop-intent so the freshly-spawned supervisor resumes guarding.
|
|
939
1037
|
clearStopIntent();
|
|
940
|
-
|
|
941
|
-
const engineErr = _openStdioLog('engine-stdio.log');
|
|
942
|
-
const engineProc = spawn(process.execPath, [path.join(MINIONS_HOME, 'engine.js'), 'start', ...rest], {
|
|
943
|
-
cwd: MINIONS_HOME, stdio: ['ignore', engineOut, engineErr], detached: true, windowsHide: true
|
|
944
|
-
});
|
|
945
|
-
engineProc.unref();
|
|
946
|
-
console.log(`\n Engine started (PID: ${engineProc.pid})`);
|
|
947
|
-
const dashProc = spawnDashboard();
|
|
948
|
-
console.log(` Dashboard started (PID: ${dashProc.pid})`);
|
|
949
|
-
console.log(` Dashboard: http://localhost:${DASH_PORT}`);
|
|
950
|
-
const supProc = spawnSupervisor();
|
|
951
|
-
console.log(` Supervisor started (PID: ${supProc.pid})`);
|
|
952
|
-
console.log(' Verifying restart health...');
|
|
953
|
-
void (async () => {
|
|
954
|
-
const result = await waitForRestartHealth({
|
|
955
|
-
minionsHome: MINIONS_HOME,
|
|
956
|
-
dashboardPid: dashProc.pid,
|
|
957
|
-
dashboardPort: DASH_PORT,
|
|
958
|
-
});
|
|
959
|
-
if (!result.ok) {
|
|
960
|
-
console.error(formatRestartHealthError(result));
|
|
961
|
-
process.exit(1);
|
|
962
|
-
}
|
|
963
|
-
console.log(` Restart verified: engine PID ${result.engine.pid}; dashboard healthy.`);
|
|
964
|
-
|
|
965
|
-
const shouldOpen = forceOpen || !dashWasUp ||
|
|
966
|
-
!(await _waitForBrowserReconnect(MINIONS_HOME, { afterMs: restartStartMs, timeoutMs: 5000 }));
|
|
967
|
-
if (shouldOpen) {
|
|
968
|
-
console.log(` Opening dashboard in browser...`);
|
|
969
|
-
_openInBrowser(`http://localhost:${DASH_PORT}`);
|
|
970
|
-
}
|
|
971
|
-
console.log('');
|
|
972
|
-
})().catch(err => {
|
|
973
|
-
// Health check failures already process.exit(1) above; reaching here means
|
|
974
|
-
// the browser-open path threw, which is non-fatal — restart already verified.
|
|
975
|
-
console.log(` Could not open dashboard: ${err.message}`);
|
|
976
|
-
});
|
|
1038
|
+
spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs });
|
|
977
1039
|
} else if (cmd === 'nuke') {
|
|
978
1040
|
ensureInstalled();
|
|
979
1041
|
if (!rest.includes('--confirm')) {
|
|
@@ -1163,6 +1225,14 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
1163
1225
|
sock.connect(DASH_PORT, '127.0.0.1');
|
|
1164
1226
|
} else if (engineCmds.has(cmd)) {
|
|
1165
1227
|
delegate('engine.js', [cmd, ...rest]);
|
|
1228
|
+
} else if (require('./cli-api-client').isHandled(cmd)) {
|
|
1229
|
+
// Generic `minions api` passthrough + ergonomic per-resource subcommands
|
|
1230
|
+
// (wi/plans/schedule/watch/feature/settings). All route through the
|
|
1231
|
+
// dashboard's HTTP API at localhost:7331 so the auth/CSRF/origin contract
|
|
1232
|
+
// is identical to dashboard UI calls — no parallel state-mutation paths.
|
|
1233
|
+
ensureInstalled();
|
|
1234
|
+
require('./cli-api-client').dispatch(cmd, rest).then(code => process.exit(code || 0))
|
|
1235
|
+
.catch(err => { console.error(err && err.message || err); process.exit(2); });
|
|
1166
1236
|
} else {
|
|
1167
1237
|
console.log(` Unknown command: ${cmd}`);
|
|
1168
1238
|
console.log(' Run "minions help" for usage.\n');
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -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
|
|
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)
|
|
33
|
-
const wis = window._lastWorkItems
|
|
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)
|
|
46
|
-
const prs = window._lastPullRequests
|
|
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)
|
|
61
|
-
const ws = window._lastWatches
|
|
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
|
|
67
|
-
const total = window._lastMeetingsTotal ??
|
|
68
|
-
const list = window._lastMeetings ||
|
|
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)
|
|
73
|
-
const pls = window._lastPipelines
|
|
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)
|
|
78
|
-
const sch = window._lastSchedules
|
|
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
|
|
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
|
|
94
|
-
const q = window._lastQaRunsSummary
|
|
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
|
-
|
|
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
|
package/engine/lifecycle.js
CHANGED
|
@@ -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
|
};
|