@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 CHANGED
@@ -1,8 +1,12 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.600 (2026-04-08)
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.enabled === false ? '<span style="font-size:9px;color:var(--red)">DISABLED</span>' : '') +
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) : '') + '</div>' +
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 livePath = path.join(MINIONS_DIR, 'agents', agentId, 'live-output.log');
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
 
@@ -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 artifact discovery.
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
- return true; // fire-and-forget
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
- completeRun(pipeline.id, activeRun.runId, PIPELINE_STATUS.COMPLETED);
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.600",
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"