@yemi33/minions 0.1.2080 → 0.1.2082

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.
@@ -104,6 +104,22 @@ const _sectionCacheVersions = {};
104
104
  // into the ring-buffer entry then resets _lastChangedFlags to null so steady-
105
105
  // state has no side effect. See "Refresh diagnostics" block below.
106
106
  let _lastChangedFlags = null;
107
+
108
+ // Per-renderer isolation: a bare `renderX(...)` call that throws used to
109
+ // abort the rest of _processStatusUpdate, leaving the work-items and
110
+ // dispatch tables frozen until a hard refresh. _safeRender wraps each
111
+ // call so one throw can't take out the chain — every downstream renderer
112
+ // still runs and paints fresh DOM. Throws are logged to Console for
113
+ // triage but don't surface a UI banner (intentional — silent recovery is
114
+ // less disruptive than a red banner for what's typically a transient
115
+ // data-shape blip).
116
+ function _safeRender(name, fn) {
117
+ try { fn(); }
118
+ catch (e) {
119
+ // eslint-disable-next-line no-console
120
+ console.error('[render] ' + name + ' threw:', e);
121
+ }
122
+ }
107
123
  function _changed(key, value, version) {
108
124
  var v = version == null ? (RENDER_VERSIONS[key] || 0) : version;
109
125
  // Drop the stale-version entry so the cache doesn't grow unbounded across bumps.
@@ -294,12 +310,13 @@ function _processStatusUpdate(data) {
294
310
  window._lastStatus = data;
295
311
 
296
312
 
297
- // Render every section every tick (W-mpn7keq9000302c9 + this commit).
298
- // Every `_changed(...)` GATE was producing the same class of bug
299
- // ref-equality or JSON.stringify short-circuit falsely matching across
300
- // ticks, leaving the DOM frozen until the user did a hard refresh. The
301
- // gates were a small CPU win at the cost of real-time correctness on
302
- // every screen.
313
+ // Render every section every tick, each call ISOLATED via _safeRender so a
314
+ // single bad-data renderer can't abort the rest of the chain. Prior bug:
315
+ // an upstream throw (e.g. renderEngineStatus on a partial engine slice)
316
+ // propagated to refresh()'s outer catch, skipping every downstream
317
+ // renderer including renderWorkItems below until the offending record
318
+ // cycled out of the slim /api/status payload. Per-call try/catch keeps
319
+ // each renderer independent; throws log to Console (no UI banner).
303
320
  //
304
321
  // We KEEP the `_changed(...)` CALLS (their side effect of populating
305
322
  // `_lastChangedFlags` is still load-bearing for the diag ring-buffer
@@ -308,80 +325,78 @@ function _processStatusUpdate(data) {
308
325
  // value to gate the render. Each renderer is a contained DOM rewrite
309
326
  // (~ a few KB of HTML); ~10–20 of them per 4s tick is well under one
310
327
  // frame's budget.
311
- //
312
- // Gates intentionally kept: none on the render path. The render-versions
313
- // bump path (RENDER_VERSIONS map + _changed's stringify cache) is still
314
- // useful for the diag flag, just not as a render skip.
315
328
  _changed('agents', data.agents);
316
- renderAgents(data.agents);
317
- cmdUpdateAgentList(data.agents);
329
+ _safeRender('agents', function() { renderAgents(data.agents); });
330
+ _safeRender('cmdUpdateAgentList', function() { cmdUpdateAgentList(data.agents); });
318
331
  // prdProgress + prdPrs are captured together so both flags publish to
319
332
  // the diag buffer; the renderer + cachePrdItems run unconditionally.
320
333
  _changed('prdProgress', data.prdProgress);
321
334
  _changed('prdPrs', data.pullRequests?.length);
322
- renderPrdProgress(data.prdProgress);
323
- _cachePrdItems(data.prdProgress);
335
+ _safeRender('prdProgress', function() { renderPrdProgress(data.prdProgress); });
336
+ _safeRender('cachePrdItems', function() { _cachePrdItems(data.prdProgress); });
324
337
  _changed('inbox', data.inbox);
325
- renderInbox(data.inbox || []);
338
+ _safeRender('inbox', function() { renderInbox(data.inbox || []); });
326
339
  _changed('projects', data.projects);
327
- cmdUpdateProjectList(data.projects || []);
328
- renderProjects(data.projects || []);
340
+ _safeRender('cmdUpdateProjectList', function() { cmdUpdateProjectList(data.projects || []); });
341
+ _safeRender('projects', function() { renderProjects(data.projects || []); });
329
342
  // FRE banner — safe to call every tick (idempotent + cheap). Pass the full
330
343
  // status payload so the runtime-CLI explainer reads autoMode.defaultCli from
331
344
  // THIS tick (window._lastStatus is hoisted above, but renderFre takes the
332
345
  // payload directly to avoid the window-global indirection).
333
346
  if (typeof renderFre === 'function') {
334
- try { renderFre(data); } catch { /* expected on first load */ }
347
+ _safeRender('fre', function() { renderFre(data); });
335
348
  }
336
349
  _changed('notes', data.notes);
337
- renderNotes(data.notes);
350
+ _safeRender('notes', function() { renderNotes(data.notes); });
338
351
  _changed('prd', [data.prd, data.prdProgress]);
339
- renderPrd(data.prd, data.prdProgress);
352
+ _safeRender('prd', function() { renderPrd(data.prd, data.prdProgress); });
340
353
  // Capture prs + workItems change signals once — also reused by the cross-slice
341
354
  // render triggers at the bottom of this function (F1/F3, W-mpgb0xbh000e3b86).
342
355
  // _changed mutates _sectionCache so it must be called exactly once per key.
343
356
  var _prsChanged = _changed('prs', data.pullRequests);
344
357
  var _workItemsChanged = _changed('workItems', data.workItems);
345
- renderPrs(data.pullRequests || []);
358
+ _safeRender('prs', function() { renderPrs(data.pullRequests || []); });
346
359
  _changed('archivedPrds', data.archivedPrds);
347
- renderArchiveButtons(data.archivedPrds || []);
360
+ _safeRender('archiveButtons', function() { renderArchiveButtons(data.archivedPrds || []); });
348
361
  _changed('engine', data.engine);
349
- if (data.engine) renderEngineStatus(data.engine);
350
- var qs = document.getElementById('engine-quick-stats');
351
- if (qs && data.engine) {
352
- var wt = data.engine.worktreeCount != null ? data.engine.worktreeCount : '-';
353
- var pid = data.engine.pid || '-';
354
- // W-mpnc4u8c001d9d6c replace the dead "Tick: -" chip (control.json
355
- // never carried a `tick` field) with a live "Next tick in Xs" countdown
356
- // driven by engine.lastTickAt (stamped at the start of every tickInner)
357
- // and engine.tickInterval (config, surfaced in the status payload).
358
- // _updateNextTickChip below ticks the inner span every 1s without
359
- // re-rendering this whole row.
360
- _engineCountdown.lastTickAt = Number(data.engine.lastTickAt) || 0;
361
- _engineCountdown.tickInterval = Number(data.engine.tickInterval) || 0;
362
- _engineCountdown.engineState = data.engine.state || 'stopped';
363
- // Feed the cadence ring buffer so the overshoot label can surface the
364
- // observed tick-to-tick gap (W-mpodheao0006a37a).
365
- _recordEngineTickObservation(_engineCountdown.lastTickAt);
366
- // eslint-disable-next-line no-unsanitized/property -- reason: composed from internal engine metrics (pid, lastTickAt/tickInterval, worktreeCount) and a literal id; no user data flows in
367
- qs.innerHTML = '<span>PID: <b>' + pid + '</b></span>' +
368
- '<span>Next tick in: <b id="engine-next-tick">' + _formatNextTickText() + '</b></span>' +
369
- '<span>Worktrees: <b>' + wt + '</b></span>';
370
- _startNextTickTicker();
371
- }
362
+ if (data.engine) _safeRender('engineStatus', function() { renderEngineStatus(data.engine); });
363
+ _safeRender('engineQuickStats', function() {
364
+ var qs = document.getElementById('engine-quick-stats');
365
+ if (qs && data.engine) {
366
+ var wt = data.engine.worktreeCount != null ? data.engine.worktreeCount : '-';
367
+ var pid = data.engine.pid || '-';
368
+ // W-mpnc4u8c001d9d6c replace the dead "Tick: -" chip (control.json
369
+ // never carried a `tick` field) with a live "Next tick in Xs" countdown
370
+ // driven by engine.lastTickAt (stamped at the start of every tickInner)
371
+ // and engine.tickInterval (config, surfaced in the status payload).
372
+ // _updateNextTickChip below ticks the inner span every 1s without
373
+ // re-rendering this whole row.
374
+ _engineCountdown.lastTickAt = Number(data.engine.lastTickAt) || 0;
375
+ _engineCountdown.tickInterval = Number(data.engine.tickInterval) || 0;
376
+ _engineCountdown.engineState = data.engine.state || 'stopped';
377
+ // Feed the cadence ring buffer so the overshoot label can surface the
378
+ // observed tick-to-tick gap (W-mpodheao0006a37a).
379
+ _recordEngineTickObservation(_engineCountdown.lastTickAt);
380
+ // eslint-disable-next-line no-unsanitized/property -- reason: composed from internal engine metrics (pid, lastTickAt/tickInterval, worktreeCount) and a literal id; no user data flows in
381
+ qs.innerHTML = '<span>PID: <b>' + pid + '</b></span>' +
382
+ '<span>Next tick in: <b id="engine-next-tick">' + _formatNextTickText() + '</b></span>' +
383
+ '<span>Worktrees: <b>' + wt + '</b></span>';
384
+ _startNextTickTicker();
385
+ }
386
+ });
372
387
  _changed('version', data.version);
373
- renderVersionBanner(data.version);
388
+ _safeRender('versionBanner', function() { renderVersionBanner(data.version); });
374
389
  _changed('adoThrottle', data.adoThrottle);
375
- renderAdoThrottleAlert(data.adoThrottle);
390
+ _safeRender('adoThrottle', function() { renderAdoThrottleAlert(data.adoThrottle); });
376
391
  _changed('ghThrottle', data.ghThrottle);
377
- renderGhThrottleAlert(data.ghThrottle);
392
+ _safeRender('ghThrottle', function() { renderGhThrottleAlert(data.ghThrottle); });
378
393
  _changed('dispatch', data.dispatch);
379
- renderDispatch(data.dispatch);
380
- prunePrdRequeueState(window._lastWorkItems);
394
+ _safeRender('dispatch', function() { renderDispatch(data.dispatch); });
395
+ _safeRender('prunePrdRequeueState', function() { prunePrdRequeueState(window._lastWorkItems); });
381
396
  _changed('engineLog', data.engineLog);
382
- renderEngineLog(data.engineLog || []);
397
+ _safeRender('engineLog', function() { renderEngineLog(data.engineLog || []); });
383
398
  _changed('metrics', data.metrics);
384
- renderMetrics(data.metrics || {});
399
+ _safeRender('metrics', function() { renderMetrics(data.metrics || {}); });
385
400
  // managed-processes panel — ETag-gated so unchanged ticks return 304 with
386
401
  // no body (P-6e2a8b13). Sequenced BEFORE the keep-processes call below via
387
402
  // .then() so the keep renderer reads a populated managed-PID cache for
@@ -400,21 +415,23 @@ function _processStatusUpdate(data) {
400
415
  .catch(function () { /* keep render even if managed fetch failed — getLastItems() returns the last good cache (or []) */ })
401
416
  .then(function () { try { renderKeepProcesses(); } catch {} });
402
417
  }
403
- renderWorkItems(data.workItems || []);
418
+ _safeRender('workItems', function() { renderWorkItems(data.workItems || []); });
404
419
  _changed('skills', data.skills);
405
- renderSkills(data.skills || []);
420
+ _safeRender('skills', function() { renderSkills(data.skills || []); });
406
421
  _changed('mcpServers', data.mcpServers);
407
- renderMcpServers(data.mcpServers || []);
422
+ _safeRender('mcpServers', function() { renderMcpServers(data.mcpServers || []); });
408
423
  _changed('schedules', data.schedules);
409
- renderSchedules(data.schedules || []);
424
+ _safeRender('schedules', function() { renderSchedules(data.schedules || []); });
410
425
  _changed('watches', data.watches);
411
- renderWatches(data.watches || []);
426
+ _safeRender('watches', function() { renderWatches(data.watches || []); });
412
427
  _changed('meetings', data.meetings);
413
- renderMeetings(data.meetings || []);
428
+ _safeRender('meetings', function() { renderMeetings(data.meetings || []); });
414
429
  _changed('pipelines', data.pipelines);
415
- if (typeof renderPipelines === 'function') renderPipelines(data.pipelines || []);
430
+ if (typeof renderPipelines === 'function') {
431
+ _safeRender('pipelines', function() { renderPipelines(data.pipelines || []); });
432
+ }
416
433
  _changed('pinned', data.pinned);
417
- renderPinned(data.pinned || []);
434
+ _safeRender('pinned', function() { renderPinned(data.pinned || []); });
418
435
  // Sidebar counts (cheap)
419
436
  const swi = document.getElementById('sidebar-wi');
420
437
  if (swi) swi.textContent = (data.workItems || []).length || '';
@@ -430,8 +447,8 @@ function _processStatusUpdate(data) {
430
447
  // and after every kb-sweep (engine/queries.js _kbCache / kb-sweep.js).
431
448
  // Previously throttled to every 3rd cycle (~12s) — see W-mphfb6ss000a3b9e
432
449
  // for the cadence audit + Playwright coverage.
433
- refreshKnowledgeBase();
434
- refreshPlans();
450
+ _safeRender('refreshKnowledgeBase', function() { refreshKnowledgeBase(); });
451
+ _safeRender('refreshPlans', function() { refreshPlans(); });
435
452
 
436
453
  // Cross-slice render triggers (F1/F3, W-mpgb0xbh000e3b86): renderPrs reads
437
454
  // window._lastWorkItems for the +N follow-up chip count and derivePlanStatus
@@ -444,14 +461,14 @@ function _processStatusUpdate(data) {
444
461
  // F1: only the work-item slice moved this tick — renderPrs wasn't called
445
462
  // above, so the +N follow-up chip would otherwise stay stale until the
446
463
  // next PR mutation.
447
- renderPrs(data.pullRequests || []);
464
+ _safeRender('prs:cross-slice', function() { renderPrs(data.pullRequests || []); });
448
465
  }
449
466
  if ((_workItemsChanged || _prsChanged) && Array.isArray(window._lastPlans) && typeof renderPlans === 'function') {
450
467
  // F3: derivePlanStatus + _renderVerifyBadge derive from pullRequests +
451
468
  // workItems. Re-render against cached plans so plan status flips within
452
469
  // one /api/status tick (~4s) instead of one refreshPlans poll. No-op
453
470
  // until _lastPlans is populated.
454
- renderPlans(window._lastPlans);
471
+ _safeRender('plans:cross-slice', function() { renderPlans(window._lastPlans); });
455
472
  }
456
473
 
457
474
  // Sidebar activity indicators — show red dot on pages with new activity
package/dashboard.js CHANGED
@@ -5593,6 +5593,9 @@ const server = http.createServer(async (req, res) => {
5593
5593
  try {
5594
5594
  const body = await readBody(req);
5595
5595
  if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
5596
+ // Reject markdown filenames — mutateJsonFileLocked below silently overwrites
5597
+ // the file with JSON, destroying source plans (see /api/plans/approve trap).
5598
+ if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename in `source` (got `' + body.source + '`). Pass prd/<plan>.json, not plans/<plan>.md.' });
5596
5599
  const planPath = resolvePlanPath(body.source);
5597
5600
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
5598
5601
  // Pre-check: verify item exists before taking the lock
@@ -5649,6 +5652,7 @@ const server = http.createServer(async (req, res) => {
5649
5652
  try {
5650
5653
  const body = await readBody(req);
5651
5654
  if (!body.source || !body.itemId) return jsonReply(res, 400, { error: 'source and itemId required' });
5655
+ if (!body.source.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename in `source` (got `' + body.source + '`). Pass prd/<plan>.json, not plans/<plan>.md.' });
5652
5656
  const planPath = resolvePlanPath(body.source);
5653
5657
  if (!fs.existsSync(planPath)) return jsonReply(res, 404, { error: 'plan file not found' });
5654
5658
  let removed = false;
@@ -6381,6 +6385,7 @@ const server = http.createServer(async (req, res) => {
6381
6385
  try {
6382
6386
  const body = await readBody(req);
6383
6387
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
6388
+ if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). To approve a plan, pass prd/<plan>.json, not the source plans/<plan>.md.' });
6384
6389
  const planPath = resolvePlanPath(body.file);
6385
6390
  let wasStale = false;
6386
6391
  const plan = mutateJsonFileLocked(planPath, (data) => {
@@ -6474,6 +6479,7 @@ const server = http.createServer(async (req, res) => {
6474
6479
  try {
6475
6480
  const body = await readBody(req);
6476
6481
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
6482
+ if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). Pass prd/<plan>.json, not the source plans/<plan>.md.' });
6477
6483
  const planPath = resolvePlanPath(body.file);
6478
6484
  mutateJsonFileLocked(planPath, (plan) => {
6479
6485
  if (!plan || Array.isArray(plan) || typeof plan !== 'object') plan = {};
@@ -6624,6 +6630,7 @@ const server = http.createServer(async (req, res) => {
6624
6630
  try {
6625
6631
  const body = await readBody(req);
6626
6632
  if (!body.file) return jsonReply(res, 400, { error: 'file required' });
6633
+ if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). Pass prd/<plan>.json, not the source plans/<plan>.md.' });
6627
6634
  const planPath = resolvePlanPath(body.file);
6628
6635
  const plan = mutateJsonFileLocked(planPath, (data) => {
6629
6636
  if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
@@ -6882,6 +6889,7 @@ const server = http.createServer(async (req, res) => {
6882
6889
  try {
6883
6890
  const body = await readBody(req);
6884
6891
  if (!body.file || !body.feedback) return jsonReply(res, 400, { error: 'file and feedback required' });
6892
+ if (!body.file.endsWith('.json')) return jsonReply(res, 400, { error: 'expected a PRD JSON filename (got `' + body.file + '`). Pass prd/<plan>.json, not the source plans/<plan>.md.' });
6885
6893
  const planPath = resolvePlanPath(body.file);
6886
6894
  const plan = mutateJsonFileLocked(planPath, (data) => {
6887
6895
  if (!data || Array.isArray(data) || typeof data !== 'object') data = {};
@@ -299,11 +299,19 @@ function evaluateCondition(condition, ctx) {
299
299
 
300
300
  switch (cond.check) {
301
301
  case 'runSucceeded': {
302
- // True when the current/last run completed with all stages succeeded (none failed)
302
+ // P-bfa1e finding #21: tightened predicate.
303
+ // Previously this accepted PENDING as "succeeded" via `s.status === COMPLETED || s.status === PENDING`,
304
+ // which let `stopWhen`/`stop-pipeline` short-circuit fire while stages were still queued for the next
305
+ // dispatch cycle. A "succeeded" run is one where:
306
+ // 1. The run object exists (no run → nothing has succeeded).
307
+ // 2. At least one stage exists (vacuous-truth empty `.every()` would otherwise lie about success).
308
+ // 3. EVERY stage is COMPLETED — RUNNING/PENDING/WAITING_HUMAN/PAUSED/FAILED/STOPPED all fail the check.
309
+ // Any `stopWhen` or condition-stage `stop-pipeline` consumer wired to `runSucceeded` now sees `false`
310
+ // until the run is fully done, so it cannot prematurely terminate the pipeline.
303
311
  if (!run) return false;
304
- return Object.values(run.stages || {}).every(
305
- s => s.status === PIPELINE_STATUS.COMPLETED || s.status === PIPELINE_STATUS.PENDING
306
- );
312
+ const stages = Object.values(run.stages || {});
313
+ if (stages.length === 0) return false;
314
+ return stages.every(s => s && s.status === PIPELINE_STATUS.COMPLETED);
307
315
  }
308
316
  case 'noFailedItems': {
309
317
  // True when all work items created by the pipeline are done (not failed)
@@ -1156,6 +1164,23 @@ async function discoverPipelineWork(config) {
1156
1164
  // Condition stage signaled pipeline stop — complete the run immediately
1157
1165
  if (result._stopPipeline) {
1158
1166
  completeRun(pipeline.id, activeRun.runId, PIPELINE_STATUS.STOPPED);
1167
+ // P-bfa1e finding #24: the local `activeRun` reference still has
1168
+ // `status === RUNNING` because we captured it at L980 before any
1169
+ // completion writes. The post-loop `allComplete` block at L1138
1170
+ // guards with `activeRun.status !== PIPELINE_STATUS.STOPPED`
1171
+ // before re-calling `completeRun(..., COMPLETED)` — if we leave
1172
+ // the stale RUNNING status in place that guard fails and we
1173
+ // clobber the STOPPED status with COMPLETED. Refresh the local
1174
+ // reference from disk so downstream reads in this same tick see
1175
+ // STOPPED. `getActiveRun` excludes terminal runs, so fall back
1176
+ // to a defensive in-place status patch when the refresh comes
1177
+ // back empty.
1178
+ const refreshed = getActiveRun(pipeline.id);
1179
+ if (refreshed) {
1180
+ activeRun = refreshed;
1181
+ } else {
1182
+ activeRun.status = PIPELINE_STATUS.STOPPED;
1183
+ }
1159
1184
  allComplete = true;
1160
1185
  break;
1161
1186
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2080",
3
+ "version": "0.1.2082",
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"