@yemi33/minions 0.1.600 → 0.1.602
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -1
- package/dashboard/js/render-pipelines.js +23 -6
- package/dashboard.js +64 -3
- package/engine/cleanup.js +12 -1
- package/engine/pipeline.js +137 -4
- package/engine/shared.js +2 -0
- package/engine.js +14 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.602 (2026-04-08)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- add conditional stage and stop-when support to pipelines (#553)
|
|
4
7
|
|
|
5
8
|
### Fixes
|
|
9
|
+
- rotate live-output.log on spawn to preserve previous session (closes #543) (#551)
|
|
6
10
|
- restore pendingFix on failed human-feedback dispatch (closes #540) (#550)
|
|
7
11
|
|
|
8
12
|
## 0.1.599 (2026-04-08)
|
|
@@ -191,13 +191,13 @@ function renderPipelines(pipelines) {
|
|
|
191
191
|
el.innerHTML = pipelines.map(function(p) {
|
|
192
192
|
const activeRun = (p.runs || []).find(function(r) { return r.status === 'running'; });
|
|
193
193
|
const lastRun = (p.runs || []).slice(-1)[0];
|
|
194
|
-
const statusColor = activeRun ? 'var(--blue)' : lastRun?.status === 'completed' ? 'var(--green)' : lastRun?.status === 'failed' ? 'var(--red)' : 'var(--muted)';
|
|
195
|
-
const statusLabel = activeRun ? 'Running' : lastRun ? (lastRun.status === 'completed' ? 'Completed' : lastRun.status === 'failed' ? 'Failed' : lastRun.status) : 'Never run';
|
|
194
|
+
const statusColor = activeRun ? 'var(--blue)' : lastRun?.status === 'completed' ? 'var(--green)' : lastRun?.status === 'failed' ? 'var(--red)' : lastRun?.status === 'stopped' ? 'var(--yellow)' : 'var(--muted)';
|
|
195
|
+
const statusLabel = activeRun ? 'Running' : lastRun ? (lastRun.status === 'completed' ? 'Completed' : lastRun.status === 'failed' ? 'Failed' : lastRun.status === 'stopped' ? 'Stopped' : lastRun.status) : 'Never run';
|
|
196
196
|
const trigger = p.trigger?.cron ? _cronToHuman(p.trigger.cron) : 'Manual';
|
|
197
197
|
|
|
198
198
|
// Stage flow visualization
|
|
199
199
|
var stageFlow = (p.stages || []).map(function(s) {
|
|
200
|
-
var icon = { task: '\u2699', meeting: '\uD83D\uDCAC', plan: '\uD83D\uDCCB', 'merge-prs': '\uD83D\uDD00', api: '\uD83C\uDF10', wait: '\u23F8', parallel: '\u2693', schedule: '\u23F0' }[s.type] || '\u2022';
|
|
200
|
+
var icon = { task: '\u2699', meeting: '\uD83D\uDCAC', plan: '\uD83D\uDCCB', 'merge-prs': '\uD83D\uDD00', api: '\uD83C\uDF10', wait: '\u23F8', parallel: '\u2693', schedule: '\u23F0', condition: '\u2753' }[s.type] || '\u2022';
|
|
201
201
|
var stageStatus = activeRun?.stages?.[s.id]?.status || 'pending';
|
|
202
202
|
var color = stageStatus === 'completed' ? 'var(--green)' : stageStatus === 'running' ? 'var(--blue)' : stageStatus === 'failed' ? 'var(--red)' : stageStatus === 'waiting-human' ? 'var(--yellow)' : 'var(--muted)';
|
|
203
203
|
return '<span style="color:' + color + ';font-size:11px" title="' + escHtml(s.id) + ': ' + escHtml(s.title || s.type) + ' (' + stageStatus + ')">' + icon + ' ' + escHtml(s.id) + '</span>';
|
|
@@ -220,7 +220,8 @@ function renderPipelines(pipelines) {
|
|
|
220
220
|
'<div style="display:flex;align-items:center;gap:8px">' +
|
|
221
221
|
'<span style="color:' + statusColor + ';font-size:11px;font-weight:600">' + statusLabel + '</span>' +
|
|
222
222
|
'<span style="font-size:10px;color:var(--muted)">' + escHtml(trigger) + '</span>' +
|
|
223
|
-
(p.
|
|
223
|
+
(p.stopWhen ? '<span style="font-size:9px;color:var(--yellow)" title="Auto-stops when condition met: ' + escHtml(typeof p.stopWhen === 'string' ? p.stopWhen : (p.stopWhen.check || 'condition')) + '">STOP-WHEN</span>' : '') +
|
|
224
|
+
(p.enabled === false ? '<span style="font-size:9px;color:var(--red)"' + (p._stopReason ? ' title="' + escHtml(p._stopReason) + '"' : '') + '>' + (p._stoppedBy ? 'AUTO-STOPPED' : 'DISABLED') + '</span>' : '') +
|
|
224
225
|
'</div>' +
|
|
225
226
|
'</div>' +
|
|
226
227
|
'<div style="margin-top:6px;display:flex;gap:4px;align-items:center;flex-wrap:wrap">' + stageFlow + '</div>' +
|
|
@@ -266,6 +267,20 @@ function openPipelineDetail(id) {
|
|
|
266
267
|
'</div>';
|
|
267
268
|
}
|
|
268
269
|
|
|
270
|
+
// stopWhen info
|
|
271
|
+
if (p.stopWhen) {
|
|
272
|
+
var swLabel = typeof p.stopWhen === 'string' ? p.stopWhen : (p.stopWhen.check || JSON.stringify(p.stopWhen));
|
|
273
|
+
html += '<div style="border:1px solid color-mix(in srgb, var(--yellow) 30%, transparent);border-radius:6px;padding:4px 10px;background:color-mix(in srgb, var(--yellow) 6%, transparent);font-size:11px">' +
|
|
274
|
+
'<span style="color:var(--yellow);font-weight:600">Stop When:</span> <span style="color:var(--text)">' + escHtml(swLabel) + '</span>' +
|
|
275
|
+
(p._stoppedBy ? ' <span style="color:var(--green);font-size:10px">\u2714 triggered' + (p._stoppedAt ? ' at ' + escHtml(p._stoppedAt.slice(0, 16).replace('T', ' ')) : '') + '</span>' : '') +
|
|
276
|
+
'</div>';
|
|
277
|
+
}
|
|
278
|
+
if (p._stopReason && p.enabled === false) {
|
|
279
|
+
html += '<div style="border:1px solid color-mix(in srgb, var(--red) 30%, transparent);border-radius:6px;padding:4px 10px;background:color-mix(in srgb, var(--red) 6%, transparent);font-size:11px">' +
|
|
280
|
+
'<span style="color:var(--red);font-weight:600">Auto-stopped:</span> <span style="color:var(--text)">' + escHtml(p._stopReason) + '</span>' +
|
|
281
|
+
'</div>';
|
|
282
|
+
}
|
|
283
|
+
|
|
269
284
|
html += '<h4 style="font-size:12px;color:var(--blue);margin:0">Stages</h4>';
|
|
270
285
|
(p.stages || []).forEach(function(s, i) {
|
|
271
286
|
var stageRun = activeRun?.stages?.[s.id] || {};
|
|
@@ -278,7 +293,9 @@ function openPipelineDetail(id) {
|
|
|
278
293
|
'<span style="font-weight:600;font-size:12px">' + (i + 1) + '. ' + escHtml(s.title || s.id) + '</span>' +
|
|
279
294
|
'<span style="color:' + statusColor + ';font-size:10px;font-weight:600">' + stageStatus.toUpperCase() + '</span>' +
|
|
280
295
|
'</div>' +
|
|
281
|
-
'<div style="font-size:10px;color:var(--muted);margin-top:4px">Type: ' + escHtml(s.type) + ' | Depends on: ' + escHtml(deps) + (s.agent ? ' | Agent: ' + escHtml(s.agent) : '') +
|
|
296
|
+
'<div style="font-size:10px;color:var(--muted);margin-top:4px">Type: ' + escHtml(s.type) + ' | Depends on: ' + escHtml(deps) + (s.agent ? ' | Agent: ' + escHtml(s.agent) : '') +
|
|
297
|
+
(s.type === 'condition' ? ' | Check: ' + escHtml(typeof (s.check || s.condition) === 'string' ? (s.check || s.condition) : ((s.check || s.condition || {}).check || '?')) + (s.onMet ? ' | onMet: ' + escHtml(s.onMet) : '') + (s.onUnmet ? ' | onUnmet: ' + escHtml(s.onUnmet) : '') : '') +
|
|
298
|
+
'</div>' +
|
|
282
299
|
_renderMonitoredResources(s.monitoredResources || []) +
|
|
283
300
|
_renderArtifactLinks(stageRun.artifacts) +
|
|
284
301
|
(stageRun.output ? '<div style="margin-top:6px;font-size:11px;max-height:150px;overflow-y:auto">' + renderMd(stageRun.output.slice(0, 500)) + '</div>' : '') +
|
|
@@ -291,7 +308,7 @@ function openPipelineDetail(id) {
|
|
|
291
308
|
if (runs.length > 0) {
|
|
292
309
|
html += '<h4 style="font-size:12px;color:var(--blue);margin:0">Recent Runs</h4>';
|
|
293
310
|
runs.forEach(function(r, ri) {
|
|
294
|
-
var color = r.status === 'completed' ? 'var(--green)' : r.status === 'failed' ? 'var(--red)' : r.status === 'running' ? 'var(--blue)' : 'var(--muted)';
|
|
311
|
+
var color = r.status === 'completed' ? 'var(--green)' : r.status === 'failed' ? 'var(--red)' : r.status === 'running' ? 'var(--blue)' : r.status === 'stopped' ? 'var(--yellow)' : 'var(--muted)';
|
|
295
312
|
// Collect all artifacts across stages for this run
|
|
296
313
|
var runArtifacts = _collectRunArtifacts(r);
|
|
297
314
|
var artifactCount = runArtifacts.total;
|
package/dashboard.js
CHANGED
|
@@ -1499,6 +1499,26 @@ const server = http.createServer(async (req, res) => {
|
|
|
1499
1499
|
try {
|
|
1500
1500
|
const stat = fs.statSync(liveLogPath);
|
|
1501
1501
|
const fileSize = stat.size;
|
|
1502
|
+
|
|
1503
|
+
// Fall back to previous session log when current is sparse (fixes #543)
|
|
1504
|
+
const SPARSE_THRESHOLD = 500;
|
|
1505
|
+
if (fileSize < SPARSE_THRESHOLD) {
|
|
1506
|
+
const prevPath = path.join(agentDir, 'live-output-prev.log');
|
|
1507
|
+
try {
|
|
1508
|
+
const prevStat = fs.statSync(prevPath);
|
|
1509
|
+
if (prevStat.size > SPARSE_THRESHOLD) {
|
|
1510
|
+
const prevTailBytes = Math.min(tailBytes, prevStat.size);
|
|
1511
|
+
const prevStart = Math.max(0, prevStat.size - prevTailBytes);
|
|
1512
|
+
const prevFd = fs.openSync(prevPath, 'r');
|
|
1513
|
+
const prevBuf = Buffer.alloc(prevStat.size - prevStart);
|
|
1514
|
+
fs.readSync(prevFd, prevBuf, 0, prevBuf.length, prevStart);
|
|
1515
|
+
fs.closeSync(prevFd);
|
|
1516
|
+
const prevContent = prevBuf.toString('utf8');
|
|
1517
|
+
if (prevContent) safeWrite(`data: ${JSON.stringify(prevContent + '\n\n--- previous session (new session starting) ---\n\n')}\n\n`);
|
|
1518
|
+
}
|
|
1519
|
+
} catch { /* prev file may not exist — that's fine */ }
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1502
1522
|
if (fileSize > 0) {
|
|
1503
1523
|
const readStart = Math.max(0, fileSize - tailBytes);
|
|
1504
1524
|
const readLen = fileSize - readStart;
|
|
@@ -1565,7 +1585,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1565
1585
|
|
|
1566
1586
|
async function handleAgentLive(req, res, match) {
|
|
1567
1587
|
const agentId = match[1];
|
|
1568
|
-
const
|
|
1588
|
+
const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
|
|
1589
|
+
const livePath = path.join(agentDir, 'live-output.log');
|
|
1590
|
+
const prevPath = path.join(agentDir, 'live-output-prev.log');
|
|
1569
1591
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
1570
1592
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1571
1593
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
@@ -1576,6 +1598,28 @@ const server = http.createServer(async (req, res) => {
|
|
|
1576
1598
|
try {
|
|
1577
1599
|
const stat = fs.statSync(livePath);
|
|
1578
1600
|
if (stat.size === 0) { res.end('No live output. Agent may not be running.'); return; }
|
|
1601
|
+
|
|
1602
|
+
// Fall back to previous session log when current is sparse (fixes #543)
|
|
1603
|
+
// Sparse = only header + init JSON, typically < 500 bytes
|
|
1604
|
+
const SPARSE_THRESHOLD = 500;
|
|
1605
|
+
if (stat.size < SPARSE_THRESHOLD && fs.existsSync(prevPath)) {
|
|
1606
|
+
try {
|
|
1607
|
+
const prevStat = fs.statSync(prevPath);
|
|
1608
|
+
if (prevStat.size > SPARSE_THRESHOLD) {
|
|
1609
|
+
// Prepend separator + previous session tail, then append current sparse content
|
|
1610
|
+
const prevTailBytes = Math.max(1, Math.min(tailBytes - stat.size - 100, prevStat.size));
|
|
1611
|
+
const prevStart = Math.max(0, prevStat.size - prevTailBytes);
|
|
1612
|
+
const prevBuf = Buffer.alloc(Math.min(prevTailBytes, prevStat.size));
|
|
1613
|
+
const prevFd = fs.openSync(prevPath, 'r');
|
|
1614
|
+
fs.readSync(prevFd, prevBuf, 0, prevBuf.length, prevStart);
|
|
1615
|
+
fs.closeSync(prevFd);
|
|
1616
|
+
const currentBuf = fs.readFileSync(livePath, 'utf8');
|
|
1617
|
+
res.end(prevBuf.toString('utf8') + '\n\n--- previous session (new session starting) ---\n\n' + currentBuf);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
} catch { /* fall through to normal read */ }
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1579
1623
|
const start = Math.max(0, stat.size - tailBytes);
|
|
1580
1624
|
const buf = Buffer.alloc(Math.min(tailBytes, stat.size));
|
|
1581
1625
|
const fd = fs.openSync(livePath, 'r');
|
|
@@ -1583,6 +1627,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
1583
1627
|
fs.closeSync(fd);
|
|
1584
1628
|
res.end(buf.toString('utf8'));
|
|
1585
1629
|
} catch {
|
|
1630
|
+
// If live-output.log doesn't exist but prev does, serve that
|
|
1631
|
+
try {
|
|
1632
|
+
if (fs.existsSync(prevPath)) {
|
|
1633
|
+
const prevStat = fs.statSync(prevPath);
|
|
1634
|
+
if (prevStat.size > 0) {
|
|
1635
|
+
const start = Math.max(0, prevStat.size - tailBytes);
|
|
1636
|
+
const buf = Buffer.alloc(Math.min(tailBytes, prevStat.size));
|
|
1637
|
+
const fd = fs.openSync(prevPath, 'r');
|
|
1638
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
1639
|
+
fs.closeSync(fd);
|
|
1640
|
+
res.end(buf.toString('utf8') + '\n\n--- previous session (current session output unavailable) ---\n');
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
} catch { /* fall through */ }
|
|
1586
1645
|
res.end('No live output. Agent may not be running.');
|
|
1587
1646
|
}
|
|
1588
1647
|
return;
|
|
@@ -3961,18 +4020,19 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3961
4020
|
const result = pipelines.map(p => ({ ...p, runs: (runs[p.id] || []).slice(-5) }));
|
|
3962
4021
|
return jsonReply(res, 200, result);
|
|
3963
4022
|
}},
|
|
3964
|
-
{ method: 'POST', path: '/api/pipelines', desc: 'Create a pipeline', params: 'id, title, stages[], trigger?, monitoredResources?', handler: async (req, res) => {
|
|
4023
|
+
{ method: 'POST', path: '/api/pipelines', desc: 'Create a pipeline', params: 'id, title, stages[], trigger?, stopWhen?, monitoredResources?', handler: async (req, res) => {
|
|
3965
4024
|
const body = await readBody(req);
|
|
3966
4025
|
if (!body.id || !body.title || !body.stages) return jsonReply(res, 400, { error: 'id, title, and stages required' });
|
|
3967
4026
|
const { savePipeline, getPipeline } = require('./engine/pipeline');
|
|
3968
4027
|
if (getPipeline(body.id)) return jsonReply(res, 409, { error: 'Pipeline already exists' });
|
|
3969
4028
|
const pipeline = { id: body.id, title: body.title, stages: body.stages, trigger: body.trigger || {}, enabled: body.enabled !== false };
|
|
4029
|
+
if (body.stopWhen) pipeline.stopWhen = body.stopWhen;
|
|
3970
4030
|
if (Array.isArray(body.monitoredResources) && body.monitoredResources.length > 0) pipeline.monitoredResources = body.monitoredResources;
|
|
3971
4031
|
savePipeline(pipeline);
|
|
3972
4032
|
invalidateStatusCache();
|
|
3973
4033
|
return jsonReply(res, 200, { ok: true, id: pipeline.id });
|
|
3974
4034
|
}},
|
|
3975
|
-
{ method: 'POST', path: '/api/pipelines/update', desc: 'Update a pipeline', params: 'id, title?, stages?, trigger?, enabled?, monitoredResources?', handler: async (req, res) => {
|
|
4035
|
+
{ method: 'POST', path: '/api/pipelines/update', desc: 'Update a pipeline', params: 'id, title?, stages?, trigger?, enabled?, stopWhen?, monitoredResources?', handler: async (req, res) => {
|
|
3976
4036
|
const body = await readBody(req);
|
|
3977
4037
|
if (!body.id) return jsonReply(res, 400, { error: 'id required' });
|
|
3978
4038
|
const { getPipeline, savePipeline } = require('./engine/pipeline');
|
|
@@ -3983,6 +4043,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3983
4043
|
if (body.trigger !== undefined) pipeline.trigger = body.trigger;
|
|
3984
4044
|
if (body.enabled !== undefined) pipeline.enabled = body.enabled;
|
|
3985
4045
|
if (body.monitoredResources !== undefined) pipeline.monitoredResources = body.monitoredResources;
|
|
4046
|
+
if (body.stopWhen !== undefined) pipeline.stopWhen = body.stopWhen;
|
|
3986
4047
|
savePipeline(pipeline);
|
|
3987
4048
|
invalidateStatusCache();
|
|
3988
4049
|
return jsonReply(res, 200, { ok: true });
|
package/engine/cleanup.js
CHANGED
|
@@ -76,7 +76,7 @@ function runCleanup(config, verbose = false) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
// 2. Clean live-output.log for idle agents (not currently working)
|
|
79
|
+
// 2. Clean live-output.log and live-output-prev.log for idle agents (not currently working)
|
|
80
80
|
for (const [agentId] of Object.entries(config.agents || {})) {
|
|
81
81
|
const status = getAgentStatus(agentId);
|
|
82
82
|
if (status.status !== 'working') {
|
|
@@ -90,6 +90,17 @@ function runCleanup(config, verbose = false) {
|
|
|
90
90
|
}
|
|
91
91
|
} catch { /* cleanup */ }
|
|
92
92
|
}
|
|
93
|
+
// Also clean rotated previous session log
|
|
94
|
+
const prevPath = path.join(AGENTS_DIR, agentId, 'live-output-prev.log');
|
|
95
|
+
if (fs.existsSync(prevPath)) {
|
|
96
|
+
try {
|
|
97
|
+
const stat = fs.statSync(prevPath);
|
|
98
|
+
if (stat.mtimeMs < oneHourAgo) {
|
|
99
|
+
fs.unlinkSync(prevPath);
|
|
100
|
+
cleaned.liveOutputs++;
|
|
101
|
+
}
|
|
102
|
+
} catch { /* cleanup */ }
|
|
103
|
+
}
|
|
93
104
|
}
|
|
94
105
|
}
|
|
95
106
|
|
package/engine/pipeline.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* engine/pipeline.js — Multi-stage pipeline orchestration.
|
|
3
|
-
* Pipelines chain stages (task, meeting, plan, merge-prs, api, wait, parallel)
|
|
4
|
-
* with dependency tracking and
|
|
3
|
+
* Pipelines chain stages (task, meeting, plan, merge-prs, api, wait, parallel, condition)
|
|
4
|
+
* with dependency tracking, artifact discovery, and conditional stop/termination.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const fs = require('fs');
|
|
@@ -124,6 +124,79 @@ function resolveStageConfig(stage, run) {
|
|
|
124
124
|
return resolved;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// ── Condition Evaluation ─────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Evaluate a pipeline condition. Used by both `stopWhen` and `condition` stages.
|
|
131
|
+
* @param {string|object} condition — condition name (string shorthand) or { check, ... } object
|
|
132
|
+
* @param {{ run, pipeline, config }} ctx — evaluation context
|
|
133
|
+
* @returns {boolean} whether the condition is met
|
|
134
|
+
*/
|
|
135
|
+
function evaluateCondition(condition, ctx) {
|
|
136
|
+
const { run, pipeline, config } = ctx;
|
|
137
|
+
// Normalize: string shorthand → { check: string }
|
|
138
|
+
const cond = typeof condition === 'string' ? { check: condition } : (condition || {});
|
|
139
|
+
|
|
140
|
+
switch (cond.check) {
|
|
141
|
+
case 'runSucceeded': {
|
|
142
|
+
// True when the current/last run completed with all stages succeeded (none failed)
|
|
143
|
+
if (!run) return false;
|
|
144
|
+
return Object.values(run.stages || {}).every(
|
|
145
|
+
s => s.status === PIPELINE_STATUS.COMPLETED || s.status === PIPELINE_STATUS.PENDING
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
case 'noFailedItems': {
|
|
149
|
+
// True when all work items created by the pipeline are done (not failed)
|
|
150
|
+
if (!run) return false;
|
|
151
|
+
const wiPath = path.join(__dirname, '..', 'work-items.json');
|
|
152
|
+
const workItems = safeJson(wiPath) || [];
|
|
153
|
+
const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
|
|
154
|
+
return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
|
|
155
|
+
}, []);
|
|
156
|
+
const all = [...workItems, ...allProjectWi];
|
|
157
|
+
const pipelineWis = all.filter(w => w._pipelineRun === run.runId);
|
|
158
|
+
if (pipelineWis.length === 0) return true; // no items = nothing failed
|
|
159
|
+
return pipelineWis.every(w => w.status !== WI_STATUS.FAILED);
|
|
160
|
+
}
|
|
161
|
+
case 'maxRuns': {
|
|
162
|
+
// True when total run count for this pipeline >= threshold
|
|
163
|
+
const threshold = cond.value || 1;
|
|
164
|
+
const runs = getPipelineRuns();
|
|
165
|
+
const pipelineRuns = runs[pipeline?.id] || [];
|
|
166
|
+
return pipelineRuns.length >= threshold;
|
|
167
|
+
}
|
|
168
|
+
case 'allBuildsGreen': {
|
|
169
|
+
// True when all PRs in monitoredResources (or run artifacts) have buildStatus 'passing'
|
|
170
|
+
const projects = shared.getProjects(config);
|
|
171
|
+
const allPrs = projects.reduce((acc, p) => {
|
|
172
|
+
return acc.concat(safeJson(shared.projectPrPath(p)) || []);
|
|
173
|
+
}, []);
|
|
174
|
+
|
|
175
|
+
// Collect PR IDs to check: from monitoredResources (type: 'pr') or run artifact PRs
|
|
176
|
+
const prIds = new Set();
|
|
177
|
+
for (const res of (pipeline?.monitoredResources || [])) {
|
|
178
|
+
const r = typeof res === 'string' ? { label: res } : res;
|
|
179
|
+
if (r.type === 'pr' && r.label) prIds.add(r.label);
|
|
180
|
+
}
|
|
181
|
+
// Also check PRs from run artifacts
|
|
182
|
+
if (run) {
|
|
183
|
+
for (const [, s] of Object.entries(run.stages || {})) {
|
|
184
|
+
for (const prId of (s.artifacts?.prs || [])) prIds.add(prId);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (prIds.size === 0) return false; // no PRs to check = can't confirm green
|
|
188
|
+
for (const prId of prIds) {
|
|
189
|
+
const pr = allPrs.find(p => p.id === prId);
|
|
190
|
+
if (!pr || pr.buildStatus !== 'passing') return false;
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
default:
|
|
195
|
+
log('warn', `Pipeline condition: unknown check '${cond.check}'`);
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
127
200
|
// ── Stage Execution ──────────────────────────────────────────────────────────
|
|
128
201
|
|
|
129
202
|
async function executeStage(stage, run, pipeline, config) {
|
|
@@ -143,6 +216,8 @@ async function executeStage(stage, run, pipeline, config) {
|
|
|
143
216
|
return executeMergePrsStage(resolved, stageState, run, config);
|
|
144
217
|
case STAGE_TYPE.SCHEDULE:
|
|
145
218
|
return executeScheduleStage(resolved, stageState, config);
|
|
219
|
+
case STAGE_TYPE.CONDITION:
|
|
220
|
+
return executeConditionStage(resolved, stageState, run, pipeline, config);
|
|
146
221
|
case STAGE_TYPE.WAIT:
|
|
147
222
|
// wait stages just sit in waiting-human status until continued via API
|
|
148
223
|
return { status: PIPELINE_STATUS.WAITING_HUMAN };
|
|
@@ -375,6 +450,38 @@ function executeScheduleStage(stage, stageState, config) {
|
|
|
375
450
|
return { status: PIPELINE_STATUS.COMPLETED, completedAt: ts() };
|
|
376
451
|
}
|
|
377
452
|
|
|
453
|
+
function executeConditionStage(stage, stageState, run, pipeline, config) {
|
|
454
|
+
const check = stage.check || stage.condition;
|
|
455
|
+
if (!check) {
|
|
456
|
+
log('warn', `Pipeline ${pipeline.id}: condition stage ${stage.id} has no check defined`);
|
|
457
|
+
return { status: PIPELINE_STATUS.FAILED, error: 'no check defined' };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const met = evaluateCondition(check, { run, pipeline, config });
|
|
461
|
+
const action = met ? (stage.onMet || 'complete') : (stage.onUnmet || 'fail');
|
|
462
|
+
log('info', `Pipeline ${pipeline.id}: condition '${typeof check === 'string' ? check : check.check}' → ${met ? 'met' : 'unmet'}, action: ${action}`);
|
|
463
|
+
|
|
464
|
+
switch (action) {
|
|
465
|
+
case 'complete':
|
|
466
|
+
case 'continue':
|
|
467
|
+
return { status: PIPELINE_STATUS.COMPLETED, completedAt: ts(), output: `Condition ${met ? 'met' : 'unmet'}` };
|
|
468
|
+
case 'fail':
|
|
469
|
+
return { status: PIPELINE_STATUS.FAILED, error: `Condition ${met ? 'met' : 'unmet'}` };
|
|
470
|
+
case 'stop-pipeline':
|
|
471
|
+
// Disable the pipeline and complete the run
|
|
472
|
+
pipeline.enabled = false;
|
|
473
|
+
pipeline._stoppedBy = stage.id;
|
|
474
|
+
pipeline._stoppedAt = ts();
|
|
475
|
+
pipeline._stopReason = `Condition '${typeof check === 'string' ? check : check.check}' ${met ? 'met' : 'unmet'}`;
|
|
476
|
+
savePipeline(pipeline);
|
|
477
|
+
log('info', `Pipeline ${pipeline.id}: auto-disabled by condition stage ${stage.id}`);
|
|
478
|
+
return { status: PIPELINE_STATUS.COMPLETED, completedAt: ts(), output: `Pipeline stopped: condition ${met ? 'met' : 'unmet'}`, _stopPipeline: true };
|
|
479
|
+
default:
|
|
480
|
+
log('warn', `Pipeline ${pipeline.id}: unknown condition action '${action}'`);
|
|
481
|
+
return { status: PIPELINE_STATUS.FAILED, error: `unknown action '${action}'` };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
378
485
|
async function executeParallelStage(stage, stageState, run, pipeline, config) {
|
|
379
486
|
const subStages = stage.stages || [];
|
|
380
487
|
const subResults = {};
|
|
@@ -496,7 +603,8 @@ function isStageComplete(stage, stageState, run, config) {
|
|
|
496
603
|
}
|
|
497
604
|
case STAGE_TYPE.API:
|
|
498
605
|
case STAGE_TYPE.SCHEDULE:
|
|
499
|
-
|
|
606
|
+
case STAGE_TYPE.CONDITION:
|
|
607
|
+
return true; // immediate — resolved synchronously on execute
|
|
500
608
|
case STAGE_TYPE.WAIT:
|
|
501
609
|
return stageState.status === PIPELINE_STATUS.COMPLETED;
|
|
502
610
|
case STAGE_TYPE.PARALLEL: {
|
|
@@ -626,6 +734,12 @@ async function discoverPipelineWork(config) {
|
|
|
626
734
|
updateRunStage(pipeline.id, activeRun.runId, stage.id, { ...result, startedAt: ts() });
|
|
627
735
|
Object.assign(stageState, result, { startedAt: ts() });
|
|
628
736
|
if (result.status === PIPELINE_STATUS.RUNNING) anyRunning = true;
|
|
737
|
+
// Condition stage signaled pipeline stop — complete the run immediately
|
|
738
|
+
if (result._stopPipeline) {
|
|
739
|
+
completeRun(pipeline.id, activeRun.runId, PIPELINE_STATUS.STOPPED);
|
|
740
|
+
allComplete = true;
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
629
743
|
log('info', `Pipeline ${pipeline.id}: started stage ${stage.id} (${stage.type})`);
|
|
630
744
|
}
|
|
631
745
|
}
|
|
@@ -636,7 +750,25 @@ async function discoverPipelineWork(config) {
|
|
|
636
750
|
|
|
637
751
|
// Check if run is done
|
|
638
752
|
if (allComplete) {
|
|
639
|
-
|
|
753
|
+
// Only complete if not already stopped by a condition stage
|
|
754
|
+
if (activeRun.status !== PIPELINE_STATUS.STOPPED) {
|
|
755
|
+
completeRun(pipeline.id, activeRun.runId, PIPELINE_STATUS.COMPLETED);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Evaluate top-level stopWhen after successful run completion
|
|
759
|
+
if (pipeline.stopWhen && pipeline.enabled !== false) {
|
|
760
|
+
try {
|
|
761
|
+
const stopMet = evaluateCondition(pipeline.stopWhen, { run: activeRun, pipeline, config });
|
|
762
|
+
if (stopMet) {
|
|
763
|
+
pipeline.enabled = false;
|
|
764
|
+
pipeline._stoppedBy = 'stopWhen';
|
|
765
|
+
pipeline._stoppedAt = ts();
|
|
766
|
+
pipeline._stopReason = `stopWhen condition '${typeof pipeline.stopWhen === 'string' ? pipeline.stopWhen : (pipeline.stopWhen.check || 'unknown')}' met`;
|
|
767
|
+
savePipeline(pipeline);
|
|
768
|
+
log('info', `Pipeline ${pipeline.id}: auto-disabled by stopWhen condition`);
|
|
769
|
+
}
|
|
770
|
+
} catch (e) { log('warn', `Pipeline ${pipeline.id}: stopWhen evaluation failed: ${e.message}`); }
|
|
771
|
+
}
|
|
640
772
|
} else if (anyFailed && !anyRunning) {
|
|
641
773
|
completeRun(pipeline.id, activeRun.runId, PIPELINE_STATUS.FAILED);
|
|
642
774
|
}
|
|
@@ -648,4 +780,5 @@ module.exports = {
|
|
|
648
780
|
getPipelines, getPipeline, savePipeline, deletePipeline,
|
|
649
781
|
getPipelineRuns, getActiveRun, startRun, updateRunStage, completeRun,
|
|
650
782
|
discoverPipelineWork,
|
|
783
|
+
evaluateCondition, // exported for testing
|
|
651
784
|
};
|
package/engine/shared.js
CHANGED
|
@@ -558,10 +558,12 @@ const DISPATCH_RESULT = { SUCCESS: 'success', ERROR: 'error', TIMEOUT: 'timeout'
|
|
|
558
558
|
const PIPELINE_STATUS = {
|
|
559
559
|
PENDING: 'pending', RUNNING: 'running', COMPLETED: 'completed',
|
|
560
560
|
FAILED: 'failed', PAUSED: 'paused', WAITING_HUMAN: 'waiting-human',
|
|
561
|
+
STOPPED: 'stopped', // pipeline auto-disabled by stopWhen or condition stage
|
|
561
562
|
};
|
|
562
563
|
const STAGE_TYPE = {
|
|
563
564
|
TASK: 'task', MEETING: 'meeting', PLAN: 'plan', API: 'api',
|
|
564
565
|
MERGE_PRS: 'merge-prs', SCHEDULE: 'schedule', WAIT: 'wait', PARALLEL: 'parallel',
|
|
566
|
+
CONDITION: 'condition',
|
|
565
567
|
};
|
|
566
568
|
const MEETING_STATUS = {
|
|
567
569
|
INVESTIGATING: 'investigating', DEBATING: 'debating', CONCLUDING: 'concluding',
|
package/engine.js
CHANGED
|
@@ -560,6 +560,20 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
560
560
|
|
|
561
561
|
// Live output file — written as data arrives so dashboard can tail it
|
|
562
562
|
const liveOutputPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
|
|
563
|
+
|
|
564
|
+
// Rotate previous live output to preserve session history (fixes #543: orphan recovery overwrites)
|
|
565
|
+
// Only rotate if the existing file has meaningful content (beyond just the header)
|
|
566
|
+
const LIVE_OUTPUT_SPARSE_THRESHOLD = 500; // bytes — header + init JSON is typically < 500
|
|
567
|
+
try {
|
|
568
|
+
if (fs.existsSync(liveOutputPath)) {
|
|
569
|
+
const prevStat = fs.statSync(liveOutputPath);
|
|
570
|
+
if (prevStat.size > LIVE_OUTPUT_SPARSE_THRESHOLD) {
|
|
571
|
+
const prevPath = path.join(AGENTS_DIR, agentId, 'live-output-prev.log');
|
|
572
|
+
fs.renameSync(liveOutputPath, prevPath);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} catch { /* rotation is best-effort — overwrite still happens below */ }
|
|
576
|
+
|
|
563
577
|
safeWrite(liveOutputPath, `# Live output for ${agentId} — ${id}\n# Started: ${startedAt}\n# Task: ${dispatchItem.task}\n\n`);
|
|
564
578
|
|
|
565
579
|
// Keep live log active even when the agent produces no stdout/stderr for long stretches.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.602",
|
|
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"
|