@yemi33/minions 0.1.2233 → 0.1.2234

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/minions.js CHANGED
@@ -373,6 +373,24 @@ function spawnSupervisor() {
373
373
  // at function-definition time.
374
374
  const _supervisorPidPath = () => path.join(MINIONS_HOME, 'engine', 'supervisor.pid');
375
375
 
376
+ /**
377
+ * Resolve the cold-start restart-health budget (ms) from config, falling back
378
+ * to ENGINE_DEFAULTS.restartHealthTimeoutMs. Mirrors the Settings clamp range
379
+ * [15000, 300000] so a config typo can't wedge `minions restart` for an hour
380
+ * or shrink the budget back below a real cold boot. W-mqpx0tpi.
381
+ */
382
+ function _resolveRestartHealthTimeoutMs() {
383
+ const home = process.env.MINIONS_HOME || (typeof MINIONS_HOME !== 'undefined' ? MINIONS_HOME : null);
384
+ let cfg = null;
385
+ try {
386
+ if (home) cfg = JSON.parse(fs.readFileSync(path.join(home, 'config.json'), 'utf8'));
387
+ } catch { /* config may not exist yet */ }
388
+ const D = shared.ENGINE_DEFAULTS;
389
+ const raw = cfg && cfg.engine && Number(cfg.engine.restartHealthTimeoutMs);
390
+ const resolved = Number.isFinite(raw) && raw > 0 ? raw : D.restartHealthTimeoutMs;
391
+ return Math.min(300000, Math.max(15000, resolved));
392
+ }
393
+
376
394
  /**
377
395
  * Spawn engine + dashboard + supervisor, verify health, optionally open browser.
378
396
  * Shared by `minions start` and `minions restart` — restart layers a kill phase
@@ -412,6 +430,7 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
412
430
  minionsHome: MINIONS_HOME,
413
431
  dashboardPid: dashProc.pid,
414
432
  dashboardPort: actualPort,
433
+ timeoutMs: _resolveRestartHealthTimeoutMs(),
415
434
  });
416
435
  if (!result.ok) {
417
436
  console.error(formatRestartHealthError(result));
@@ -720,7 +739,13 @@ const DASHBOARD_PORT = resolveDashboardPort(rest).port;
720
739
  // spawning dashboard.js.
721
740
  process.env.MINIONS_PORT = String(DASHBOARD_PORT);
722
741
  const POST_UPDATE_INIT_TIMEOUT_MS = 120000;
723
- const POST_UPDATE_RESTART_TIMEOUT_MS = 60000;
742
+ // W-mqpx0tpi the post-update `minions restart` runs as a child of `minions
743
+ // update` under this wall-clock cap. It must outlive the restart-health budget
744
+ // (now up to ~60s for a cold post-`npm install` dashboard boot) plus spawn +
745
+ // port-file-wait overhead, or the parent would SIGKILL a perfectly healthy
746
+ // slow boot and print a scary failure. Give comfortable headroom above the
747
+ // resolved health timeout.
748
+ const POST_UPDATE_RESTART_TIMEOUT_MS = Math.max(120000, _resolveRestartHealthTimeoutMs() + 60000);
724
749
 
725
750
  function isSubpath(parent, child) {
726
751
  const rel = path.relative(path.resolve(parent), path.resolve(child));
@@ -1042,7 +1067,9 @@ function runPostUpdateRestart() {
1042
1067
  console.error(`\n ERROR: Post-update restart failed (${detail}).`);
1043
1068
  }
1044
1069
  console.error(' Runtime files were synchronized, but the engine/dashboard were not restarted.');
1045
- console.error(' Run this command to finish the update without using the Windows command shim:');
1070
+ console.error(` If this was a timeout, the dashboard may still be finishing a cold start —`);
1071
+ console.error(` re-check http://localhost:${DASHBOARD_PORT} or run \`minions status\` first.`);
1072
+ console.error(' If it is genuinely down, run this command to finish the update:');
1046
1073
  console.error(` ${formatPackageCliCommand(args)}\n`);
1047
1074
  process.exit(1);
1048
1075
  }
@@ -513,14 +513,37 @@ function _scanSelectNone() {
513
513
  document.querySelectorAll('[data-scan-idx]').forEach(function(cb) { if (!cb.disabled) cb.checked = false; });
514
514
  }
515
515
 
516
+ // Stamp a per-repo Added/Failed status onto a scan-result row. Uses DOM
517
+ // construction with textContent (no innerHTML) so server-provided error text
518
+ // is rendered safely without tripping the no-unsanitized lint gate.
519
+ function _markScanRow(label, ok, text) {
520
+ if (!label) return;
521
+ var prev = label.querySelector('.scan-row-status');
522
+ if (prev) prev.remove();
523
+ label.style.borderColor = ok ? 'var(--green)' : 'var(--red)';
524
+ var badge = document.createElement('span');
525
+ badge.className = 'scan-row-status';
526
+ badge.style.flex = '0 0 auto';
527
+ badge.style.marginLeft = '8px';
528
+ badge.style.maxWidth = '45%';
529
+ badge.style.fontSize = 'var(--text-sm)';
530
+ badge.style.fontWeight = '600';
531
+ badge.style.textAlign = 'right';
532
+ badge.style.color = ok ? 'var(--green)' : 'var(--red)';
533
+ badge.textContent = (ok ? '✓ ' : '✗ ') + text;
534
+ label.appendChild(badge);
535
+ }
536
+
516
537
  async function _addSelectedProjects() {
517
- var checkboxes = document.querySelectorAll('[data-scan-idx]:checked:not(:disabled)');
538
+ var checkboxes = Array.prototype.slice.call(document.querySelectorAll('[data-scan-idx]:checked:not(:disabled)'));
518
539
  if (checkboxes.length === 0) { alert('Select at least one repo'); return; }
519
540
  var repos = window._scanRepos || [];
520
541
  var added = 0;
542
+ var failed = 0;
521
543
  for (var cb of checkboxes) {
522
544
  var repo = repos[parseInt(cb.dataset.scanIdx)];
523
545
  if (!repo) continue;
546
+ var label = cb.closest('label');
524
547
  try {
525
548
  var res = await fetch('/api/projects/add', {
526
549
  method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -537,11 +560,21 @@ async function _addSelectedProjects() {
537
560
  localPath: data.path || repo.path,
538
561
  });
539
562
  cb.disabled = true;
540
- cb.closest('label').style.opacity = '0.5';
541
- showToast('scan-toast', added + ' project(s) added', true);
563
+ cb.checked = true;
564
+ if (label) { label.style.opacity = '0.5'; _markScanRow(label, true, 'Added'); }
565
+ } else {
566
+ failed++;
567
+ if (label) _markScanRow(label, false, 'Failed: ' + (data.error || ('HTTP ' + res.status)));
542
568
  }
543
- } catch { /* continue with next */ }
569
+ } catch (e) {
570
+ failed++;
571
+ if (label) _markScanRow(label, false, 'Failed: ' + ((e && e.message) ? e.message : 'network error'));
572
+ }
544
573
  }
574
+ // Summary toast: success styling only when everything added cleanly; any
575
+ // failure flips it to the error color so the count is unmistakable.
576
+ var summary = added + ' added' + (failed ? ', ' + failed + ' failed' : '');
577
+ showToast('scan-toast', summary, added > 0 && failed === 0);
545
578
  if (added > 0) {
546
579
  refresh();
547
580
  }
@@ -894,13 +894,11 @@ function _wiRenderDetail(item) {
894
894
  }
895
895
  if (artPills) html += field('Artifacts', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + artPills + '</div>');
896
896
 
897
- // P-d5a6f7c4 (Harness Transparency, Stage 3 — surface). Render the grounded
898
- // harness usage the agent self-reported and the engine cross-checked. Shape:
899
- // { skills:[{name,source,path,grounded}], mcpServers:[{name,scope,grounded}],
900
- // commands:[{name,scope,grounded}], docs:[{path,why,grounded}] }. Entries
901
- // with grounded:false are KEPT and visually distinguished they flag a claim
902
- // the engine has no record of exposing (stale path, renamed skill, out-of-band
903
- // tool, or hallucination) for a human to adjudicate.
897
+ // P-d5a6f7c4 (Harness Transparency, Stage 3 — surface). Render the harness
898
+ // usage the agent self-reported. Shape:
899
+ // { skills:[{name,source,path}], mcpServers:[{name,scope}],
900
+ // commands:[{name,scope}], docs:[{path,why}] }. Every entry renders as a
901
+ // plain solid pill; the engine's internal grounding flag is not surfaced.
904
902
  (function() {
905
903
  var hu = item._harnessUsed;
906
904
  if (!hu || typeof hu !== 'object') return;
@@ -911,35 +909,23 @@ function _wiRenderDetail(item) {
911
909
  { key: 'docs', icon: '📄', label: 'Doc', primary: 'path', secondary: 'why' },
912
910
  ];
913
911
  var pills = '';
914
- var anyUngrounded = false;
912
+ var pStyle = 'display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;font-size:var(--text-sm);background:var(--surface2);border:1px solid var(--border);color:var(--text)';
915
913
  kinds.forEach(function(k) {
916
914
  var entries = Array.isArray(hu[k.key]) ? hu[k.key] : [];
917
915
  entries.forEach(function(e) {
918
916
  if (!e || typeof e !== 'object') return;
919
917
  var primary = e[k.primary];
920
918
  if (typeof primary !== 'string' || !primary) return;
921
- var grounded = e.grounded === true;
922
- if (!grounded) anyUngrounded = true;
923
919
  var secondary = (typeof e[k.secondary] === 'string') ? e[k.secondary] : '';
924
- // grounded solid surface; ungrounded dashed yellow border + marker
925
- var pStyle = grounded
926
- ? 'display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;font-size:var(--text-sm);background:var(--surface2);border:1px solid var(--border);color:var(--text)'
927
- : 'display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;font-size:var(--text-sm);background:rgba(210,153,34,0.08);border:1px dashed var(--yellow);color:var(--text)';
928
- var title = grounded
929
- ? k.label + ' — engine confirmed this affordance was exposed' + (secondary ? ' (' + secondary + ')' : '')
930
- : k.label + ' — NOT grounded: the agent reports using this but the engine has no record of exposing it' + (secondary ? ' (' + secondary + ')' : '');
920
+ var title = k.label + (secondary ? ' (' + secondary + ')' : '');
931
921
  pills += '<span style="' + pStyle + '" title="' + escapeHtml(title) + '">'
932
922
  + k.icon + ' ' + escapeHtml(primary)
933
923
  + (secondary ? ' <span style="color:var(--muted);font-size:var(--text-xs)">' + escapeHtml(secondary) + '</span>' : '')
934
- + (grounded ? '' : ' <span style="color:var(--yellow);font-weight:700" title="not grounded">&#x26A0;</span>')
935
924
  + '</span> ';
936
925
  });
937
926
  });
938
927
  if (!pills) return;
939
- var legend = anyUngrounded
940
- ? '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:4px">&#x26A0; dashed = agent-reported, not verified by the engine</div>'
941
- : '';
942
- html += field('Repo harnesses used', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + pills + '</div>' + legend);
928
+ html += field('Repo harnesses used', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + pills + '</div>');
943
929
  })();
944
930
 
945
931
  // P-34fa5d79 — Mentions: notes whose YAML frontmatter cites this WI
@@ -381,6 +381,7 @@ async function openSettings() {
381
381
  settingsField('Worktree Create Retries', 'set-worktreeCreateRetries', e.worktreeCreateRetries || 1, '', 'Retry count for transient worktree add failures (0-3)') +
382
382
  settingsField('Worktree Root', 'set-worktreeRoot', e.worktreeRoot || '../worktrees', '', 'Relative or absolute path for git worktrees; on Windows prefer a short path like C:\\wt') +
383
383
  settingsField('Assert-Clean Status Probe Timeout', 'set-assertCleanStatusTimeoutMs', e.assertCleanStatusTimeoutMs || 10000, 'ms', 'Timeout for the `git status --porcelain` preflight probe in assertCleanSharedWorktree. Raise this (e.g. 60000) on GVFS/Plastic-backed monorepos where sparse hydration runs longer than the 10s default. On timeout the engine quarantines the bad worktree and emits a RETRYABLE failure so the next dispatch starts fresh. Clamped 1000–120000ms.') +
384
+ settingsField('Restart-health Timeout', 'set-restartHealthTimeoutMs', e.restartHealthTimeoutMs || 60000, 'ms', 'Cold-start budget for `minions restart` / post-update restart verification. The dashboard runs node:sqlite migrations + SPA HTML assembly + initial state reads synchronously BEFORE it binds its port, so right after `npm install` on a slow/loaded box the PID is alive but the port is unbound for tens of seconds. Below this budget that no longer prints a false "Restart verification failed" + exit(1) — the verifier keeps polling until the port listens. A genuinely crashed spawn (PID dies) still fails fast, well under the full budget. Clamped 15000–300000ms.') +
384
385
  settingsField('Orphan-holder scan timeout', 'set-orphanHolderScanTimeoutMs', e.orphanHolderScanTimeoutMs || 5000, 'ms', 'Cap on the cross-platform scan (PowerShell / /proc walk / lsof) that identifies the OS process holding a stuck worktree\'s cwd. Bumps up only matter on heavily-loaded hosts where the holder scan races the sweep tick. Clamped 1000–30000ms.') +
385
386
  settingsField('Worktree-holder reap probe timeout', 'set-statusProbeKillTimeoutMs', e.statusProbeKillTimeoutMs || 12000, 'ms', 'Windows-only. Timeout for the pre-quarantine worktree-holder reap probe (legacy git.exe-cmdline sweep + PEB-CWD scan that kills an orphaned agent process tree whose CWD pins the worktree dir, causing EBUSY on rename AND `git worktree remove --force`). The old hardcoded 2000ms was the proximate cause of the reaper being a no-op under concurrent-agent load (PowerShell cold-start + process enumeration exceeded 2s → ETIMEDOUT → reaped nothing). ETIMEDOUT is always swallowed (reap nothing; quarantine still proceeds), so raising this only widens the success window. Clamped 2000–60000ms.') +
386
387
  '</div>';
@@ -983,6 +984,7 @@ async function saveSettings() {
983
984
  worktreeCreateRetries: document.getElementById('set-worktreeCreateRetries').value,
984
985
  worktreeRoot: document.getElementById('set-worktreeRoot').value,
985
986
  assertCleanStatusTimeoutMs: document.getElementById('set-assertCleanStatusTimeoutMs').value,
987
+ restartHealthTimeoutMs: document.getElementById('set-restartHealthTimeoutMs')?.value,
986
988
  idleAlertMinutes: document.getElementById('set-idleAlertMinutes').value,
987
989
  shutdownTimeout: document.getElementById('set-shutdownTimeout').value,
988
990
  restartGracePeriod: document.getElementById('set-restartGracePeriod').value,
package/dashboard.js CHANGED
@@ -10525,6 +10525,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10525
10525
  // 2min ceiling (covers even a GVFS-backed sparse hydration without
10526
10526
  // letting a config typo wedge dispatch for an hour).
10527
10527
  assertCleanStatusTimeoutMs: [1000, 120000],
10528
+ // W-mqpx0tpi — cold-start restart-health budget. 15s floor (anything
10529
+ // shorter re-introduces the false-failure bug on a post-`npm install`
10530
+ // dashboard boot); 5min ceiling (a config typo shouldn't let a wedged
10531
+ // boot hang `minions restart` indefinitely — a dead PID still fails
10532
+ // fast well before the budget).
10533
+ restartHealthTimeoutMs: [15000, 300000],
10528
10534
  // W-mq6f2fe0000557fa — cap on the cross-platform holder scan for the
10529
10535
  // orphan-worktree GC. 1s floor (PowerShell startup alone is 0.5–1s);
10530
10536
  // 30s ceiling (the scan runs once per orphan-sweep tick and a slow
@@ -3,9 +3,22 @@ const path = require('path');
3
3
  const http = require('http');
4
4
  const https = require('https');
5
5
  const { execSync } = require('child_process');
6
+ const shared = require('./shared');
6
7
 
7
- const DEFAULT_RESTART_HEALTH_TIMEOUT_MS = 15000;
8
+ // W-mqpx0tpi single source of truth for the cold-start health budget. The
9
+ // old fixed 15s was too short for a post-`npm install` dashboard boot on a
10
+ // slow/loaded machine: the dashboard runs node:sqlite migrations + SPA HTML
11
+ // assembly + initial state reads SYNCHRONOUSLY before it ever reaches
12
+ // server.listen(), so the PID is alive but the port is unbound for the whole
13
+ // window, tripping a false "Restart verification failed" + exit(1). The knob
14
+ // lives in ENGINE_DEFAULTS.restartHealthTimeoutMs (Settings-configurable);
15
+ // the literal is only a fallback if shared somehow failed to load.
16
+ const DEFAULT_RESTART_HEALTH_TIMEOUT_MS =
17
+ (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.restartHealthTimeoutMs) || 60000;
8
18
  const DEFAULT_RESTART_HEALTH_INTERVAL_MS = 250;
19
+ // Consecutive dead-PID reads tolerated before fail-fast. A small guard avoids
20
+ // a single flaky tasklist / kill(0) false-negative aborting a healthy boot.
21
+ const DEFAULT_DEAD_POLLS_BEFORE_FAST_FAIL = 2;
9
22
 
10
23
  function sleep(ms) {
11
24
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -182,9 +195,14 @@ async function waitForRestartHealth(options = {}) {
182
195
  const timeoutMs = options.timeoutMs ?? DEFAULT_RESTART_HEALTH_TIMEOUT_MS;
183
196
  const intervalMs = options.intervalMs ?? DEFAULT_RESTART_HEALTH_INTERVAL_MS;
184
197
  const maxAttempts = normalizePid(options.maxAttempts);
198
+ const deadPollsBeforeFastFail =
199
+ Number.isInteger(options.deadPollsBeforeFastFail) && options.deadPollsBeforeFastFail > 0
200
+ ? options.deadPollsBeforeFastFail
201
+ : DEFAULT_DEAD_POLLS_BEFORE_FAST_FAIL;
185
202
  const started = Date.now();
186
203
  let attempts = 0;
187
204
  let last = null;
205
+ let consecutiveDead = 0;
188
206
 
189
207
  while (true) {
190
208
  attempts++;
@@ -192,6 +210,25 @@ async function waitForRestartHealth(options = {}) {
192
210
  last.attempts = attempts;
193
211
  last.elapsedMs = Date.now() - started;
194
212
  if (last.ok) return last;
213
+
214
+ // W-mqpx0tpi — distinguish "still starting" from "dead". When the dashboard
215
+ // PID we just spawned is ALIVE but the port isn't listening yet, keep
216
+ // waiting out the (now much larger) timeout: that's the legitimate cold
217
+ // start. But if the PID has DIED, fail FAST — a genuinely crashed spawn
218
+ // should surface in seconds instead of holding the slot for the full
219
+ // budget. Only the process-kind check exposes `alive`; the HTTP-kind path
220
+ // has no PID-liveness signal, so it falls through to the timeout as before.
221
+ const dash = last.dashboard;
222
+ if (dash && dash.kind === 'process' && dash.alive === false) {
223
+ consecutiveDead++;
224
+ if (consecutiveDead >= deadPollsBeforeFastFail) {
225
+ last.failedFast = true;
226
+ return last;
227
+ }
228
+ } else {
229
+ consecutiveDead = 0;
230
+ }
231
+
195
232
  if (maxAttempts && attempts >= maxAttempts) break;
196
233
  const remainingMs = timeoutMs - (Date.now() - started);
197
234
  if (!maxAttempts && remainingMs <= 0) break;
@@ -210,7 +247,22 @@ function formatRestartHealthError(result) {
210
247
  const elapsed = typeof result.elapsedMs === 'number' ? `${result.elapsedMs}ms` : 'unknown time';
211
248
  const attempts = result.attempts || 0;
212
249
  const details = (result.errors || ['Unknown restart verification failure']).map(err => ` - ${err}`).join('\n');
213
- return `\n ERROR: Restart verification failed after ${elapsed} (${attempts} attempt${attempts === 1 ? '' : 's'}).\n${details}\n`;
250
+ // W-mqpx0tpi when the timeout was hit with the dashboard PID alive but the
251
+ // port not yet listening, the dashboard is almost certainly still finishing a
252
+ // cold start rather than broken. Point the operator at a re-check instead of
253
+ // implying breakage.
254
+ let guidance = '';
255
+ const dash = result && result.dashboard;
256
+ if (dash && dash.kind === 'process' && dash.alive && !dash.listening) {
257
+ const port = dash.port || 7331;
258
+ guidance =
259
+ `\n The dashboard process is alive but has not yet bound port ${port}.\n` +
260
+ ` It may still be finishing a cold start — node:sqlite migrations, SPA\n` +
261
+ ` HTML assembly, and the first state reads all run synchronously before\n` +
262
+ ` the dashboard reaches server.listen(). Re-check http://localhost:${port}\n` +
263
+ ` or run \`minions status\` before assuming anything is broken.\n`;
264
+ }
265
+ return `\n ERROR: Restart verification failed after ${elapsed} (${attempts} attempt${attempts === 1 ? '' : 's'}).\n${details}\n${guidance}`;
214
266
  }
215
267
 
216
268
  module.exports = {
package/engine/shared.js CHANGED
@@ -2635,6 +2635,17 @@ const ENGINE_DEFAULTS = {
2635
2635
  // raise this knob (e.g. 60000) before tuning anything else. Clamped to
2636
2636
  // [1000, 120000] in dashboard.js POST /api/settings.
2637
2637
  assertCleanStatusTimeoutMs: 10000,
2638
+ // W-mqpx0tpi — cold-start health budget for `minions restart` / post-update
2639
+ // restart verification (engine/restart-health.js#waitForRestartHealth). The
2640
+ // dashboard runs node:sqlite migrations + SPA HTML assembly + initial state
2641
+ // reads SYNCHRONOUSLY before server.listen(), so right after `npm install`
2642
+ // on a slow/loaded box the PID is alive but port 7331 is unbound well past
2643
+ // the old fixed 15s, producing a false "Restart verification failed" +
2644
+ // exit(1) even though the dashboard comes up healthy moments later. Default
2645
+ // 60s comfortably covers a cold boot; a dead PID still fails fast (the
2646
+ // verifier doesn't wait out the full budget for a crashed spawn). Clamped to
2647
+ // [15000, 300000] in dashboard.js POST /api/settings.
2648
+ restartHealthTimeoutMs: 60000,
2638
2649
  workItemCreateDedupWindowMs: 15 * 60 * 1000, // 15min — collapse duplicate CC/API create races
2639
2650
  idleAlertMinutes: 15,
2640
2651
  restartGracePeriod: 1200000, // 20min
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2233",
3
+ "version": "0.1.2234",
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"