@yemi33/minions 0.1.2308 → 0.1.2310

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
@@ -391,6 +391,19 @@ function _resolveRestartHealthTimeoutMs() {
391
391
  return Math.min(300000, Math.max(15000, resolved));
392
392
  }
393
393
 
394
+ /**
395
+ * Read config.json best-effort for callers that spawn engine.js before any
396
+ * config is loaded into memory. Mirrors _resolveRestartHealthTimeoutMs's
397
+ * inline read.
398
+ */
399
+ function _readConfigJsonSafe() {
400
+ const home = process.env.MINIONS_HOME || (typeof MINIONS_HOME !== 'undefined' ? MINIONS_HOME : null);
401
+ try {
402
+ if (home) return JSON.parse(fs.readFileSync(path.join(home, 'config.json'), 'utf8'));
403
+ } catch { /* config may not exist yet */ }
404
+ return null;
405
+ }
406
+
394
407
  /**
395
408
  * Spawn engine + dashboard + supervisor, verify health, optionally open browser.
396
409
  * Shared by `minions start` and `minions restart` — restart layers a kill phase
@@ -401,8 +414,14 @@ function _resolveRestartHealthTimeoutMs() {
401
414
  function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs }) {
402
415
  const engineOut = _openStdioLog('engine-stdio.log');
403
416
  const engineErr = _openStdioLog('engine-stdio.log');
417
+ // W-mr2c4i8m0004da94: enable Node's crash-diagnostics report for the
418
+ // engine.js process so a future silent death (the class investigated in
419
+ // W-mr2azk6i) leaves a JSON report under engine/diagnostics/crash-reports
420
+ // instead of vanishing with no trace.
421
+ const crashDiagEnv = shared.getEngineCrashDiagnosticsEnv(_readConfigJsonSafe());
404
422
  const engineProc = spawn(process.execPath, [..._sqliteSpawnFlags(), path.join(MINIONS_HOME, 'engine.js'), 'start', ...rest], {
405
- cwd: MINIONS_HOME, stdio: ['ignore', engineOut, engineErr], detached: true, windowsHide: true
423
+ cwd: MINIONS_HOME, stdio: ['ignore', engineOut, engineErr], detached: true, windowsHide: true,
424
+ env: { ...process.env, ...crashDiagEnv }
406
425
  });
407
426
  engineProc.unref();
408
427
  console.log(`\n Engine started (PID: ${engineProc.pid})`);
@@ -934,7 +953,8 @@ function init() {
934
953
  ? `\n Upgrade complete (${pkgVersion}). Restarting engine and dashboard...\n`
935
954
  : '\n Starting engine and dashboard...\n');
936
955
  const engineProc = spawn(process.execPath, [..._sqliteSpawnFlags(), path.join(MINIONS_HOME, 'engine.js'), 'start'], {
937
- cwd: MINIONS_HOME, stdio: 'ignore', detached: true, windowsHide: true
956
+ cwd: MINIONS_HOME, stdio: 'ignore', detached: true, windowsHide: true,
957
+ env: { ...process.env, ...shared.getEngineCrashDiagnosticsEnv(_readConfigJsonSafe()) }
938
958
  });
939
959
  engineProc.unref();
940
960
  console.log(` Engine started (PID: ${engineProc.pid})`);
@@ -135,7 +135,8 @@ const RENDER_VERSIONS = {
135
135
  prdPrs: 1,
136
136
  inbox: 2,
137
137
  // Bumped 4→5 for the clickable checkout-mode pill + picker (W-mr1b67zi0006b788).
138
- projects: 5,
138
+ // Bumped 5→6 for multi-select hybrid liveValidation.type support (W-mr2m1ute000a9c01).
139
+ projects: 6,
139
140
  notes: 1,
140
141
  prd: 3,
141
142
  prs: 3,
@@ -108,9 +108,11 @@ function _renderWorktreeModePill(p) {
108
108
  const name = escHtml(p.name);
109
109
  const common = ' data-checkout-pill="' + name + '" role="button" tabindex="0" aria-haspopup="true" aria-label="Change checkout mode"';
110
110
  if (p.checkoutMode === 'live') {
111
- if (p.liveValidationType) {
112
- const vt = escapeHtml(p.liveValidationType);
113
- return ' <span class="project-mode-pill project-mode-hybrid project-mode-pill-clickable"' + common + ' title="Hybrid live-validation — coding work items author in isolated worktrees; only the &quot;' + vt + '&quot; validation type runs in-place on the live checkout (capped to one mutating dispatch, refused on a dirty tree). Click to change.">⚡ Hybrid · ' + vt + '</span>';
111
+ const types = _liveValidationTypesArray(p);
112
+ if (types.length > 0) {
113
+ const typesText = types.join(', ');
114
+ const vt = escapeHtml(typesText);
115
+ return ' <span class="project-mode-pill project-mode-hybrid project-mode-pill-clickable"' + common + ' title="Hybrid live-validation — coding work items author in isolated worktrees; only the &quot;' + vt + '&quot; validation type(s) run in-place on the live checkout (capped to one mutating dispatch, refused on a dirty tree). Click to change.">⚡ Hybrid · ' + vt + '</span>';
114
116
  }
115
117
  return ' <span class="project-mode-pill project-mode-live project-mode-pill-clickable"' + common + ' title="Live-checkout dispatch mode — agents run in-place inside the project working tree (no isolated worktree); capped to one mutating dispatch and refused on a dirty tree. Click to change.">⚡ Live checkout</span>';
116
118
  }
@@ -130,6 +132,16 @@ function _findProjectInLastStatus(name) {
130
132
  return projects.find(function(p) { return p.name === name; }) || null;
131
133
  }
132
134
 
135
+ // Normalize p.liveValidationType (single string legacy shape, OR array of
136
+ // strings — W-mr2m1ute000a9c01 multi-select) into an array of non-empty
137
+ // strings for uniform handling by menu/pill rendering code.
138
+ function _liveValidationTypesArray(p) {
139
+ const raw = p && p.liveValidationType;
140
+ if (!raw) return [];
141
+ const arr = Array.isArray(raw) ? raw : [raw];
142
+ return arr.filter(function(t) { return typeof t === 'string' && t.length > 0; });
143
+ }
144
+
133
145
  function _closeCheckoutModeMenu() {
134
146
  const existing = document.getElementById('checkout-mode-menu');
135
147
  if (existing) existing.remove();
@@ -183,8 +195,8 @@ function _openCheckoutModeMenu(projectName, anchorEl) {
183
195
  item.appendChild(desc);
184
196
  }
185
197
  const isActive = (mode === 'worktree' && project.checkoutMode !== 'live')
186
- || (mode === 'live' && project.checkoutMode === 'live' && !project.liveValidationType)
187
- || (mode === 'hybrid' && project.checkoutMode === 'live' && !!project.liveValidationType);
198
+ || (mode === 'live' && project.checkoutMode === 'live' && _liveValidationTypesArray(project).length === 0)
199
+ || (mode === 'hybrid' && project.checkoutMode === 'live' && _liveValidationTypesArray(project).length > 0);
188
200
  if (isActive) {
189
201
  item.setAttribute('aria-checked', 'true');
190
202
  item.classList.add('checkout-mode-menu-item-active');
@@ -221,9 +233,12 @@ function _openCheckoutModeMenu(projectName, anchorEl) {
221
233
  if (first) first.focus();
222
234
  }
223
235
 
224
- // Second step of the picker for hybrid mode: pick which work-item type runs
236
+ // Second step of the picker for hybrid mode: pick which work-item type(s) run
225
237
  // in-place on the live checkout while everything else authors in isolated
226
- // worktrees. Renders a <select> + Back/Apply into the existing menu element.
238
+ // worktrees. Renders a checkbox list (multi-select, W-mr2m1ute000a9c01) +
239
+ // Back/Apply into the existing menu element. A checkbox list (rather than a
240
+ // native <select multiple>) keeps the existing "click one row" affordance
241
+ // discoverable while allowing more than one type to be selected at once.
227
242
  function _renderCheckoutModeHybridStep(menu, projectName) {
228
243
  const project = _findProjectInLastStatus(projectName);
229
244
  while (menu.firstChild) menu.removeChild(menu.firstChild);
@@ -235,21 +250,31 @@ function _renderCheckoutModeHybridStep(menu, projectName) {
235
250
 
236
251
  const label = document.createElement('div');
237
252
  label.className = 'checkout-mode-menu-item';
238
- label.textContent = 'Work-item type to validate live:';
253
+ label.textContent = 'Work-item type(s) to validate live:';
239
254
  menu.appendChild(label);
240
255
 
241
- const select = document.createElement('select');
242
- select.className = 'checkout-mode-menu-select';
256
+ const currentTypes = _liveValidationTypesArray(project);
257
+ const preselect = currentTypes.length > 0 ? currentTypes : [CHECKOUT_MODE_HYBRID_DEFAULT_TYPE];
258
+
259
+ const checkboxList = document.createElement('div');
260
+ checkboxList.className = 'checkout-mode-menu-checkboxes';
261
+ const checkboxes = [];
243
262
  CHECKOUT_MODE_HYBRID_TYPES.forEach(function(t) {
244
- const opt = document.createElement('option');
245
- opt.value = t;
246
- opt.textContent = t;
247
- select.appendChild(opt);
263
+ const row = document.createElement('label');
264
+ row.className = 'checkout-mode-menu-checkbox-row';
265
+ const cb = document.createElement('input');
266
+ cb.type = 'checkbox';
267
+ cb.value = t;
268
+ cb.className = 'checkout-mode-menu-checkbox';
269
+ cb.checked = preselect.includes(t);
270
+ checkboxes.push(cb);
271
+ const text = document.createElement('span');
272
+ text.textContent = t;
273
+ row.appendChild(cb);
274
+ row.appendChild(text);
275
+ checkboxList.appendChild(row);
248
276
  });
249
- select.value = (project && project.liveValidationType && CHECKOUT_MODE_HYBRID_TYPES.includes(project.liveValidationType))
250
- ? project.liveValidationType
251
- : CHECKOUT_MODE_HYBRID_DEFAULT_TYPE;
252
- menu.appendChild(select);
277
+ menu.appendChild(checkboxList);
253
278
 
254
279
  const actions = document.createElement('div');
255
280
  actions.className = 'checkout-mode-menu-actions';
@@ -270,9 +295,10 @@ function _renderCheckoutModeHybridStep(menu, projectName) {
270
295
  applyBtn.className = 'checkout-mode-menu-btn checkout-mode-menu-btn-primary';
271
296
  applyBtn.textContent = 'Apply';
272
297
  applyBtn.addEventListener('click', function() {
273
- const type = select.value;
298
+ const types = checkboxes.filter(function(cb) { return cb.checked; }).map(function(cb) { return cb.value; });
299
+ if (types.length === 0) return; // require at least one selected type
274
300
  _closeCheckoutModeMenu();
275
- _applyCheckoutModeChange(projectName, 'live', type);
301
+ _applyCheckoutModeChange(projectName, 'live', types);
276
302
  });
277
303
  actions.appendChild(applyBtn);
278
304
 
@@ -284,12 +310,21 @@ function _renderCheckoutModeHybridStep(menu, projectName) {
284
310
  // call (mirrors projectChipRemove / removePinnedNote), then POSTs to
285
311
  // /api/settings. On failure, reverts the optimistic flip, re-renders, and
286
312
  // shows an error toast.
313
+ //
314
+ // `liveValidationType` accepts a single type string (legacy call sites) OR an
315
+ // array of type strings (W-mr2m1ute000a9c01 — multi-select hybrid types);
316
+ // whichever shape is passed through is what gets POSTed as
317
+ // liveValidation.type, so shared.validateLiveValidation on the server decides
318
+ // the final persisted shape.
287
319
  async function _applyCheckoutModeChange(projectName, newMode, liveValidationType) {
288
320
  const project = _findProjectInLastStatus(projectName);
289
321
  if (!project) return;
322
+ const typesForLabel = liveValidationType
323
+ ? (Array.isArray(liveValidationType) ? liveValidationType : [liveValidationType])
324
+ : [];
290
325
  const label = newMode === 'worktree'
291
326
  ? 'Worktrees'
292
- : (liveValidationType ? ('Hybrid (' + liveValidationType + ')') : 'Live checkout');
327
+ : (typesForLabel.length > 0 ? ('Hybrid (' + typesForLabel.join(', ') + ')') : 'Live checkout');
293
328
  const ok = await confirmDialog({
294
329
  title: 'Change checkout mode?',
295
330
  message: 'Switch "' + projectName + '" to ' + label + '? This changes how every future dispatch on this project runs.',
@@ -1330,11 +1330,17 @@
1330
1330
  font-size: var(--text-xs); color: var(--muted); font-weight: 400;
1331
1331
  margin-top: 2px; line-height: 1.35;
1332
1332
  }
1333
- .checkout-mode-menu-select {
1334
- width: 100%; margin: 4px 0 8px; padding: 4px 6px;
1335
- background: var(--surface); color: var(--text); border: 1px solid var(--border);
1336
- border-radius: 4px;
1333
+ .checkout-mode-menu-checkboxes {
1334
+ display: flex; flex-direction: column; gap: 2px;
1335
+ margin: 4px 0 8px; max-height: 220px; overflow-y: auto;
1337
1336
  }
1337
+ .checkout-mode-menu-checkbox-row {
1338
+ display: flex; align-items: center; gap: 6px;
1339
+ padding: 4px 6px; border-radius: 4px; cursor: pointer;
1340
+ color: var(--text); font-size: var(--text-sm);
1341
+ }
1342
+ .checkout-mode-menu-checkbox-row:hover { background: var(--surface); }
1343
+ .checkout-mode-menu-checkbox { cursor: pointer; }
1338
1344
  .checkout-mode-menu-actions {
1339
1345
  display: flex; justify-content: flex-end; gap: 6px; padding-top: 4px;
1340
1346
  }
package/dashboard.js CHANGED
@@ -4300,7 +4300,10 @@ function _resetPreambleCache() {
4300
4300
  function _projectCheckoutModeLabel(p) {
4301
4301
  const mode = shared.resolveCheckoutMode(p);
4302
4302
  if (mode === 'live' && p && p.liveValidation && p.liveValidation.type) {
4303
- return `hybrid (live-validation: ${p.liveValidation.type})`;
4303
+ // liveValidation.type may be a single string (legacy) or an array of
4304
+ // strings (W-mr2m1ute000a9c01 — multi-select hybrid types); render all.
4305
+ const types = Array.isArray(p.liveValidation.type) ? p.liveValidation.type : [p.liveValidation.type];
4306
+ return `hybrid (live-validation: ${types.join(', ')})`;
4304
4307
  }
4305
4308
  return mode;
4306
4309
  }
package/docs/README.md CHANGED
@@ -61,6 +61,7 @@ Operational runbooks for engine operators and fleet maintainers.
61
61
  - [notes.md](notes.md) — Consolidated team knowledge: patterns, conventions, bugs, and build findings accumulated from agent inbox notes. Read by the engine and injected into agent prompts as shared context.
62
62
  - [auto-discovery.md](auto-discovery.md) — Auto-discovery and execution pipeline: the per-tick orchestration loop and the four work-discovery sources.
63
63
  - [diagnostics-memory.md](diagnostics-memory.md) — Operator runbook for the in-process memory + perf observability surface: `/api/diagnostics/memory[/history]`, `/api/diagnostics/heap-snapshot` guard-token capture, `MEMORY_BASELINE` log emissions, `--cpu-prof`/`--heap-prof` capture, and the `test/perf/soak.test.js` heap-growth regression gate.
64
+ - [diagnostics-crash-reports.md](diagnostics-crash-reports.md) — Proactive Node crash-diagnostics reports (`--report-on-fatalerror --report-on-signal --diagnostic-dir=...`) for the engine.js process on Windows: where reports land, config/opt-out, and retention.
64
65
  - [engine-restart.md](engine-restart.md) — How agents survive an engine restart: state persistence, the 20-minute startup grace period, and orphan reattachment via PID files and `live-output.log`.
65
66
  - [human-vs-automated.md](human-vs-automated.md) — Quick reference table of which features humans start, run, decide, and recover, and the two human approval gates.
66
67
  - [kb-sweep.md](kb-sweep.md) — Knowledge-base sweep runbook: how `engine/kb-sweep.js` consolidates `notes/inbox/` into `knowledge/` and survives `minions restart`.
@@ -0,0 +1,132 @@
1
+ # Diagnostics: crash reports for the engine.js process (Windows)
2
+
3
+ Operator runbook for the proactive crash-diagnostics feature shipped for
4
+ `W-mr2c4i8m0004da94`. Follow-up from `W-mr2azk6i` (2026-07-01 engine.js
5
+ crash-loop investigation): a run of 4 silent `engine.js` deaths left no
6
+ exception/stack trace in `engine-stdio.log`, no Windows Event Viewer
7
+ entries (Application/System logs were empty for the window), and no crash
8
+ dumps in `C:\Windows\Minidump` or `%LOCALAPPDATA%\CrashDumps`. The box had
9
+ no crash-event logging wired up at all, so a genuine native/access-violation
10
+ crash of `node.exe` would have vanished without a trace.
11
+
12
+ ## What this does
13
+
14
+ Every spawn of `engine.js` — via `minions start`/`restart` (`bin/minions.js`)
15
+ **and** the supervisor's respawn-on-death path (`engine/supervisor.js`) —
16
+ now gets Node's own diagnostic-report flags folded into `NODE_OPTIONS`:
17
+
18
+ ```
19
+ --report-on-fatalerror --report-on-signal --diagnostic-dir=<MINIONS_HOME>/engine/diagnostics/crash-reports
20
+ ```
21
+
22
+ This is a zero-dependency, built-in Node feature (no Sysinternals ProcDump
23
+ or other external tool needed):
24
+
25
+ - **`--report-on-fatalerror`** — writes a JSON diagnostic report the moment
26
+ the process hits an unrecoverable V8 error: out-of-memory, JS stack
27
+ overflow, or a native-addon crash. This is exactly the class of death a
28
+ silent `node.exe` disappearance on Windows would otherwise hide.
29
+ - **`--report-on-signal`** — lets an operator request a report on demand by
30
+ sending the configured signal to the running engine PID: `SIGUSR2` on
31
+ POSIX, `SIGBREAK` on Windows (Node's default `--report-signal`). Useful to
32
+ capture a report while a hang is in progress, before deciding to kill it.
33
+ - **`--diagnostic-dir=...`** — where both of the above land.
34
+
35
+ Reports are plain JSON (`report.<timestamp>.<pid>.<seq>.json`) containing
36
+ the JS/native stack, loaded modules, resource usage, and libuv handle
37
+ summary at the moment of the event — see [Node's diagnostic report
38
+ docs](https://nodejs.org/api/report.html) for the full schema.
39
+
40
+ ## Where reports land
41
+
42
+ `<MINIONS_HOME>/engine/diagnostics/crash-reports/` — a sibling of the
43
+ existing `engine/diagnostics/` directory used for heap snapshots and
44
+ CPU/heap profiles (see [docs/diagnostics-memory.md](diagnostics-memory.md)
45
+ §3–4), in its own subfolder so retention sweeps don't collide. The directory
46
+ is gitignored and created on first spawn if missing
47
+ (`getEngineCrashDiagnosticsEnv`, [`engine/shared.js`](../engine/shared.js)).
48
+
49
+ ## Config + opt-out
50
+
51
+ ```json
52
+ {
53
+ "engine": {
54
+ "crashDiagnostics": {
55
+ "enabled": true,
56
+ "retainCount": 20
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ - `enabled` (default `true`) — set `false` to disable the NODE_OPTIONS
63
+ injection entirely (falls back to the pre-existing no-diagnostics
64
+ behavior).
65
+ - `retainCount` (default `20`) — how many `report.*.json` files are kept
66
+ before the oldest are pruned. Restart the engine (`minions restart`)
67
+ after editing `enabled`, since it only takes effect at the next spawn.
68
+
69
+ Both live in `ENGINE_DEFAULTS.crashDiagnostics`
70
+ ([`engine/shared.js`](../engine/shared.js)).
71
+
72
+ ## Retention / cleanup
73
+
74
+ Reports are pruned by `pruneCrashDiagnosticsReports` (`engine/shared.js`),
75
+ called from `engine/cleanup.js#runCleanup` — the same periodic cleanup pass
76
+ that already runs every `ENGINE_DEFAULTS.cleanupEvery` ticks (default 60
77
+ ticks ≈ 10 min at the default 10 s tick interval). It keeps the newest
78
+ `retainCount` files by mtime and deletes the rest, mirroring the existing
79
+ heap-snapshot retention pattern in `dashboard.js#_heapSnapshotPrune`. Pruning
80
+ is best-effort: a missing directory or a transient Windows file lock is
81
+ swallowed and retried on the next cleanup pass.
82
+
83
+ ## Idempotency across the CLI → supervisor respawn chain
84
+
85
+ Both `bin/minions.js` (initial spawn) and `engine/supervisor.js` (respawn on
86
+ death) call the same `getEngineCrashDiagnosticsEnv(config)` helper. It checks
87
+ whether the caller's `NODE_OPTIONS` already contains `--diagnostic-dir=`
88
+ (which a spawned child inherits from its parent's env) and returns `{}`
89
+ unchanged if so — so a CLI-spawned engine that later gets respawned by the
90
+ supervisor never accumulates duplicate flags.
91
+
92
+ ## Inspecting a report after a crash
93
+
94
+ ```powershell
95
+ Get-ChildItem "$env:USERPROFILE\.minions\engine\diagnostics\crash-reports" |
96
+ Sort-Object LastWriteTime -Descending | Select-Object -First 5
97
+
98
+ Get-Content "$env:USERPROFILE\.minions\engine\diagnostics\crash-reports\report.<ts>.<pid>.001.json" |
99
+ ConvertFrom-Json | Select-Object javascriptStack, header
100
+ ```
101
+
102
+ Key fields to check first: `header.event` (what triggered the report,
103
+ e.g. `"FatalError"` or `"Signal"`), `javascriptStack`, `nativeStack`, and
104
+ `resourceUsage` (memory/CPU at the moment of the event).
105
+
106
+ ## Requesting a report on demand (hang investigation)
107
+
108
+ ```powershell
109
+ # Find the engine PID from engine/control.json, then:
110
+ # Windows: send SIGBREAK-equivalent via node's report trigger utility, or
111
+ # use Node's process.report.writeReport() interactively if you have a REPL
112
+ # attached. The simplest cross-platform trigger is `process.kill(pid, 'SIGBREAK')`
113
+ # from another Node process on Windows.
114
+ node -e "process.kill(<pid>, 'SIGBREAK')"
115
+ ```
116
+
117
+ A fresh `report.*.json` should appear in the crash-reports directory within
118
+ a second or two, without killing the process.
119
+
120
+ ## Related modules and tests
121
+
122
+ - Module: [`engine/shared.js`](../engine/shared.js) — `CRASH_REPORTS_DIR`,
123
+ `getEngineCrashDiagnosticsEnv`, `pruneCrashDiagnosticsReports`,
124
+ `ENGINE_DEFAULTS.crashDiagnostics`.
125
+ - Spawn wiring: [`bin/minions.js`](../bin/minions.js)
126
+ (`spawnFullStackAndVerify`, the post-install/upgrade auto-start path) and
127
+ [`engine/supervisor.js`](../engine/supervisor.js) (`spawnEngine`).
128
+ - Cleanup wiring: [`engine/cleanup.js`](../engine/cleanup.js) `runCleanup`.
129
+ - Tests: [`test/unit/crash-diagnostics.test.js`](../test/unit/crash-diagnostics.test.js).
130
+ - Related: [docs/diagnostics-memory.md](diagnostics-memory.md) (memory/GC/heap
131
+ observability — the sibling diagnostics surface this doc's directory lives
132
+ next to).
package/engine/cleanup.js CHANGED
@@ -1633,6 +1633,19 @@ async function runCleanup(config, verbose = false) {
1633
1633
  cleaned.knowledgeScratch = reapKnowledgeScratch();
1634
1634
  } catch (e) { log('warn', `reapKnowledgeScratch: ${e.message}`); }
1635
1635
 
1636
+ // 18. Prune old Node crash-diagnostics reports (W-mr2c4i8m0004da94). Node
1637
+ // writes `report.*.json` to CRASH_REPORTS_DIR on a fatal V8 error or
1638
+ // `--report-on-signal` request — see getEngineCrashDiagnosticsEnv. Keep
1639
+ // only the newest config.engine.crashDiagnostics.retainCount (default 20)
1640
+ // so a crash-loop or repeated on-demand reports don't accumulate unbounded.
1641
+ cleaned.crashReports = 0;
1642
+ try {
1643
+ cleaned.crashReports = shared.pruneCrashDiagnosticsReports(config);
1644
+ if (cleaned.crashReports > 0) {
1645
+ log('info', `cleanup: pruned ${cleaned.crashReports} old crash-diagnostics report(s)`);
1646
+ }
1647
+ } catch (e) { log('warn', `pruneCrashDiagnosticsReports: ${e.message}`); }
1648
+
1636
1649
  return cleaned;
1637
1650
  }
1638
1651
 
@@ -6503,9 +6503,20 @@ function collapseAllDuplicatePrRecords(config) {
6503
6503
 
6504
6504
  // M003 — After a coding WI completes successfully, auto-dispatch a live-validation
6505
6505
  // WI when project.liveValidation.autoDispatch === true and the completed item is
6506
- // a coding WI (not the validation type itself). Skips if the coding WI has no PR.
6507
- // Deduplicates: a non-terminal WI with meta.liveValidationFor === codingWiId
6508
- // blocks a second dispatch.
6506
+ // a coding WI (not one of the validation types itself). Skips if the coding WI
6507
+ // has no PR.
6508
+ //
6509
+ // liveValidation.type may be a single string (legacy / back-compat) or an
6510
+ // array of strings (W-mr2m1ute000a9c01 — multi-select hybrid types). When an
6511
+ // array, ONE validation WI is dispatched per configured type (each validation
6512
+ // WI still carries a single canonical `type` field so downstream routing —
6513
+ // playbooks, agent resolution, resolveCheckoutMode's membership check — keeps
6514
+ // working per-type).
6515
+ //
6516
+ // Deduplicates per (codingWiId, validationType) pair: a non-terminal WI with
6517
+ // meta.liveValidationFor === codingWiId AND meta.liveValidationType === type
6518
+ // blocks a second dispatch of that specific type, so multiple configured
6519
+ // types don't collide or skip each other.
6509
6520
  function autoDispatchLiveValidationWi(meta, config) {
6510
6521
  const item = meta?.item;
6511
6522
  if (!item?.id || !item?.type) return;
@@ -6521,8 +6532,12 @@ function autoDispatchLiveValidationWi(meta, config) {
6521
6532
  const lv = project.liveValidation;
6522
6533
  if (!lv || lv.autoDispatch !== true || !lv.type) return;
6523
6534
 
6524
- // Only auto-dispatch for coding WIs skip if this IS the validation type.
6525
- if (item.type === lv.type) return;
6535
+ const lvTypes = Array.isArray(lv.type) ? lv.type : [lv.type];
6536
+
6537
+ // Only auto-dispatch for coding WIs — skip types that ARE one of the
6538
+ // configured validation types (avoid an infinite validate-the-validation loop).
6539
+ const typesToDispatch = lvTypes.filter(t => t !== item.type);
6540
+ if (typesToDispatch.length === 0) return;
6526
6541
 
6527
6542
  // Resolve PR reference: prefer the canonical stamped _pr field (set by
6528
6543
  // stampWiPrRef which runs earlier in runPostCompletionHooks), then fall
@@ -6538,34 +6553,41 @@ function autoDispatchLiveValidationWi(meta, config) {
6538
6553
  mutateWorkItems(wiPath, items => {
6539
6554
  if (!Array.isArray(items)) return items;
6540
6555
 
6541
- // Dedup: skip if a non-terminal WI already tracks this coding WI.
6542
- const existing = items.find(i =>
6543
- i &&
6544
- i.meta &&
6545
- i.meta.liveValidationFor === codingWiId &&
6546
- !PLAN_TERMINAL_STATUSES.has(i.status)
6547
- );
6548
- if (existing) {
6549
- log('info', `liveValidation: dedup — non-terminal validation WI ${existing.id} already exists for ${codingWiId}`);
6550
- return items;
6551
- }
6556
+ for (const validationType of typesToDispatch) {
6557
+ // Dedup: skip if a non-terminal WI already tracks this (coding WI, type)
6558
+ // pair. Falls back to the WI's own `type` field when meta.liveValidationType
6559
+ // is absent (back-compat with validation WIs created before this field
6560
+ // existed, e.g. single-type configs from before W-mr2m1ute000a9c01).
6561
+ const existing = items.find(i =>
6562
+ i &&
6563
+ i.meta &&
6564
+ i.meta.liveValidationFor === codingWiId &&
6565
+ (i.meta.liveValidationType === validationType ||
6566
+ (!i.meta.liveValidationType && i.type === validationType)) &&
6567
+ !PLAN_TERMINAL_STATUSES.has(i.status)
6568
+ );
6569
+ if (existing) {
6570
+ log('info', `liveValidation: dedup — non-terminal validation WI ${existing.id} (type=${validationType}) already exists for ${codingWiId}`);
6571
+ continue;
6572
+ }
6552
6573
 
6553
- const validationWi = {
6554
- id: 'W-' + shared.uid(),
6555
- title: 'Validate: ' + (item.title || codingWiId),
6556
- type: lv.type,
6557
- status: WI_STATUS.PENDING,
6558
- depends_on: [codingWiId],
6559
- references: [prRef],
6560
- meta: { liveValidationFor: codingWiId },
6561
- project: projectName,
6562
- priority: item.priority || 'medium',
6563
- created: ts(),
6564
- createdBy: 'lifecycle:live-validation-auto-dispatch',
6565
- };
6574
+ const validationWi = {
6575
+ id: 'W-' + shared.uid(),
6576
+ title: 'Validate: ' + (item.title || codingWiId),
6577
+ type: validationType,
6578
+ status: WI_STATUS.PENDING,
6579
+ depends_on: [codingWiId],
6580
+ references: [prRef],
6581
+ meta: { liveValidationFor: codingWiId, liveValidationType: validationType },
6582
+ project: projectName,
6583
+ priority: item.priority || 'medium',
6584
+ created: ts(),
6585
+ createdBy: 'lifecycle:live-validation-auto-dispatch',
6586
+ };
6566
6587
 
6567
- items.push(validationWi);
6568
- log('info', `liveValidation: auto-dispatched validation WI ${validationWi.id} (type=${lv.type}) for coding WI ${codingWiId}`);
6588
+ items.push(validationWi);
6589
+ log('info', `liveValidation: auto-dispatched validation WI ${validationWi.id} (type=${validationType}) for coding WI ${codingWiId}`);
6590
+ }
6569
6591
  return items;
6570
6592
  });
6571
6593
  } catch (err) {
package/engine/shared.js CHANGED
@@ -58,6 +58,13 @@ const MINIONS_DIR = process.env.MINIONS_TEST_DIR || resolveMinionsHome(false, {
58
58
  preferSourceCheckout: true,
59
59
  });
60
60
  const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
61
+ // W-mr2c4i8m0004da94: Node's built-in `--diagnostic-dir` target for the
62
+ // engine.js process's crash-diagnostics report (see
63
+ // getEngineCrashDiagnosticsEnv / pruneCrashDiagnosticsReports below and
64
+ // docs/diagnostics-crash-reports.md). Sibling of the existing
65
+ // engine/diagnostics/ heap-snapshot dir, own subfolder so retention sweeps
66
+ // don't collide with heap-snapshot pruning.
67
+ const CRASH_REPORTS_DIR = path.join(ENGINE_DIR, 'diagnostics', 'crash-reports');
61
68
  const CONTROL_PATH = path.join(ENGINE_DIR, 'control.json');
62
69
  const COOLDOWNS_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
63
70
  // W-mp60tw0u000j3931: Persistent cross-restart engine state (migration markers,
@@ -1633,6 +1640,69 @@ function openAppendLogFd(name, dir, opts) {
1633
1640
  }
1634
1641
  }
1635
1642
 
1643
+ // W-mr2c4i8m0004da94: proactive crash diagnostics for the engine.js node
1644
+ // process on Windows. Follow-up from W-mr2azk6i (2026-07-01 crash-loop
1645
+ // investigation): 4 silent engine.js deaths with no exception in
1646
+ // engine-stdio.log, no Windows Event Viewer entries, and no crash dumps —
1647
+ // this box had no crash-event logging wired up for a genuine native/AV
1648
+ // crash. Node's own diagnostic-report feature is a zero-dependency fix:
1649
+ // `--report-on-fatalerror` writes a JSON report on unrecoverable V8 errors
1650
+ // (OOM, stack overflow, native-addon segfault) and `--report-on-signal`
1651
+ // lets an operator request one on demand (SIGUSR2 on POSIX, SIGBREAK on
1652
+ // Windows) without installing anything (e.g. Sysinternals ProcDump).
1653
+ //
1654
+ // Callers: bin/minions.js (CLI `start`/`restart` spawn) and
1655
+ // engine/supervisor.js (respawn-on-death). Both spawn engine.js as a fresh
1656
+ // process before any config is loaded into memory, so this reads config.json
1657
+ // directly rather than expecting an already-parsed config object — pass the
1658
+ // parsed object in if the caller already has it (e.g. from its own
1659
+ // best-effort config.json read), or `null`/`undefined` to use the defaults.
1660
+ //
1661
+ // Idempotent: if the caller's NODE_OPTIONS already carries
1662
+ // `--diagnostic-dir=`, returns `{}` unchanged so the CLI's spawn -> a later
1663
+ // supervisor respawn -> another respawn chain never piles up duplicate
1664
+ // flags (child processes inherit NODE_OPTIONS from their parent env).
1665
+ function getEngineCrashDiagnosticsEnv(config, baseEnv) {
1666
+ const env = baseEnv || process.env;
1667
+ const cfg = (config && config.engine && config.engine.crashDiagnostics) || {};
1668
+ const defaults = ENGINE_DEFAULTS.crashDiagnostics || {};
1669
+ const enabled = cfg.enabled !== undefined ? cfg.enabled !== false : defaults.enabled !== false;
1670
+ if (!enabled) return {};
1671
+ const existing = String(env.NODE_OPTIONS || '');
1672
+ if (existing.includes('--diagnostic-dir=')) return {};
1673
+ try { fs.mkdirSync(CRASH_REPORTS_DIR, { recursive: true }); } catch { /* best effort — Node still tries to write on crash */ }
1674
+ const flags = `--report-on-fatalerror --report-on-signal --diagnostic-dir=${CRASH_REPORTS_DIR}`;
1675
+ return { NODE_OPTIONS: existing ? `${existing} ${flags}` : flags };
1676
+ }
1677
+
1678
+ // Prune `report.*.json` files in CRASH_REPORTS_DIR down to the most-recent
1679
+ // `retainCount` (config.engine.crashDiagnostics.retainCount, default 20) by
1680
+ // mtime, mirroring dashboard.js's `_heapSnapshotPrune` retention pattern so
1681
+ // a leak-hunt session (or a genuine crash-loop) doesn't fill the disk with
1682
+ // reports unbounded. Best-effort: missing dir / unreadable stat / failed
1683
+ // unlink are swallowed. Called from engine/cleanup.js#runCleanup (fires
1684
+ // every ENGINE_DEFAULTS.cleanupEvery ticks, ~10 min at default tick
1685
+ // interval) so retention happens automatically without a dedicated sweep.
1686
+ function pruneCrashDiagnosticsReports(config) {
1687
+ const cfg = (config && config.engine && config.engine.crashDiagnostics) || {};
1688
+ const defaults = ENGINE_DEFAULTS.crashDiagnostics || {};
1689
+ const retain = Number.isFinite(cfg.retainCount) ? cfg.retainCount : (defaults.retainCount || 20);
1690
+ let names;
1691
+ try { names = fs.readdirSync(CRASH_REPORTS_DIR); } catch { return 0; }
1692
+ const candidates = names.filter(n => n.startsWith('report.') && n.endsWith('.json'));
1693
+ if (candidates.length <= retain) return 0;
1694
+ const stamped = candidates.map(n => {
1695
+ try { return { n, mtime: fs.statSync(path.join(CRASH_REPORTS_DIR, n)).mtimeMs }; }
1696
+ catch { return { n, mtime: 0 }; }
1697
+ });
1698
+ stamped.sort((a, b) => b.mtime - a.mtime); // newest first
1699
+ let removed = 0;
1700
+ for (const s of stamped.slice(retain)) {
1701
+ try { fs.unlinkSync(path.join(CRASH_REPORTS_DIR, s.n)); removed++; } catch { /* best effort */ }
1702
+ }
1703
+ return removed;
1704
+ }
1705
+
1636
1706
  function withFileLock(lockPath, fn, {
1637
1707
  timeoutMs = 5000,
1638
1708
  retryDelayMs = 25,
@@ -2783,8 +2853,14 @@ function resolveCheckoutMode(project, workItemType) {
2783
2853
  }
2784
2854
  if (canonical === CHECKOUT_MODES.LIVE) {
2785
2855
  // Apply liveValidation routing when the block is present and workItemType is provided.
2856
+ // liveValidation.type may be a single string (legacy / back-compat) or an
2857
+ // array of strings (W-mr2m1ute000a9c01, multi-select hybrid types) —
2858
+ // normalize to an array and do a membership check so either shape works.
2786
2859
  if (project.liveValidation && workItemType !== undefined) {
2787
- return workItemType === project.liveValidation.type
2860
+ const lvTypes = Array.isArray(project.liveValidation.type)
2861
+ ? project.liveValidation.type
2862
+ : [project.liveValidation.type];
2863
+ return lvTypes.includes(workItemType)
2788
2864
  ? CHECKOUT_MODES.LIVE
2789
2865
  : CHECKOUT_MODES.WORKTREE;
2790
2866
  }
@@ -2840,13 +2916,26 @@ function validateCheckoutMode(value) {
2840
2916
  // Validate + normalize a per-project `liveValidation` block (hybrid mode).
2841
2917
  // Hybrid = checkoutMode:'live' + liveValidation:{ type, autoDispatch }: coding
2842
2918
  // work items author in isolated worktrees (escaping the live cap) while only
2843
- // work items whose type === liveValidation.type run in-place on the live
2919
+ // work items whose type is IN liveValidation.type run in-place on the live
2844
2920
  // checkout. See resolveCheckoutMode (which routes per work-item type) and
2845
2921
  // docs/live-checkout-mode.md.
2846
2922
  //
2923
+ // `type` may be a single work-item-type string (legacy / back-compat) OR a
2924
+ // non-empty array of non-empty-string work-item types
2925
+ // (W-mr2m1ute000a9c01 — multi-select hybrid types, e.g. both "implement" and
2926
+ // "fix" validate live while other types stay in worktrees). Every reader of
2927
+ // liveValidation.type (resolveCheckoutMode, lifecycle.autoDispatchLiveValidationWi,
2928
+ // dashboard project mapper / pill / CC label) must handle BOTH shapes —
2929
+ // existing config.json entries with a plain string `type` continue to work
2930
+ // unchanged, no migration required.
2931
+ //
2847
2932
  // Returns:
2848
2933
  // - undefined → caller should clear the field (empty / null / '' input)
2849
- // - { type, autoDispatch? } normalized object on success
2934
+ // - { type, autoDispatch? } normalized object on success, where `type` is
2935
+ // whatever shape the caller passed in (string stays a string; array stays
2936
+ // a deduped array) — we intentionally do NOT force single-string input
2937
+ // into an array so already-persisted single-type configs round-trip
2938
+ // byte-identical through a Settings save that doesn't touch this field.
2850
2939
  // Throws HTTP 400 (via _httpError) on malformed input, or when the effective
2851
2940
  // checkout mode is not 'live' (liveValidation is meaningless without it — it is
2852
2941
  // silently ignored at resolve time, so we refuse to persist a misconfiguration).
@@ -2863,15 +2952,33 @@ function validateLiveValidation(value, opts) {
2863
2952
  if (checkoutMode !== CHECKOUT_MODES.LIVE) {
2864
2953
  throw _httpError(400, 'liveValidation requires checkoutMode "live" (hybrid mode). Set checkoutMode to "live" first, or clear liveValidation.');
2865
2954
  }
2866
- const type = typeof value.type === 'string' ? value.type.trim() : '';
2867
- if (!type) {
2868
- throw _httpError(400, 'Invalid liveValidation: "type" is required and must be a non-empty string — the work-item type that runs on the live checkout (e.g. "build-and-test").');
2955
+ const typeErrorMsg = 'Invalid liveValidation: "type" is required and must be a non-empty string, or a non-empty array of non-empty-string work-item types — the work-item type(s) that run on the live checkout (e.g. "build-and-test" or ["implement", "fix"]).';
2956
+ const rawType = value.type;
2957
+ let out;
2958
+ if (typeof rawType === 'string') {
2959
+ const trimmed = rawType.trim();
2960
+ if (!trimmed) throw _httpError(400, typeErrorMsg);
2961
+ out = { type: trimmed };
2962
+ } else if (Array.isArray(rawType)) {
2963
+ if (!rawType.every(t => typeof t === 'string' && t.trim().length > 0)) {
2964
+ throw _httpError(400, typeErrorMsg);
2965
+ }
2966
+ // Dedupe (preserving first-seen order) and cap at the number of distinct
2967
+ // work-item types the engine recognizes — a longer array can only be
2968
+ // full of duplicates or invented types.
2969
+ const deduped = [...new Set(rawType.map(t => t.trim()))];
2970
+ if (deduped.length === 0) throw _httpError(400, typeErrorMsg);
2971
+ if (deduped.length > VALID_WORK_TYPES.size) {
2972
+ throw _httpError(400, `Invalid liveValidation: "type" array has ${deduped.length} entries, more than the ${VALID_WORK_TYPES.size} distinct work-item types the engine recognizes — remove duplicates or invalid types.`);
2973
+ }
2974
+ out = { type: deduped };
2975
+ } else {
2976
+ throw _httpError(400, typeErrorMsg);
2869
2977
  }
2870
- const out = { type };
2871
2978
  // autoDispatch (optional): when true, lifecycle auto-creates a validation WI
2872
- // of `type` after each coding WI completes with a PR (engine/lifecycle.js
2873
- // autoDispatchLiveValidationWi). Coerce to a strict boolean; absent leaves it
2874
- // off so the operator opts in explicitly.
2979
+ // per configured `type` after each coding WI completes with a PR
2980
+ // (engine/lifecycle.js autoDispatchLiveValidationWi). Coerce to a strict
2981
+ // boolean; absent leaves it off so the operator opts in explicitly.
2875
2982
  if (Object.prototype.hasOwnProperty.call(value, 'autoDispatch')) {
2876
2983
  out.autoDispatch = !!value.autoDispatch;
2877
2984
  }
@@ -3156,6 +3263,19 @@ const ENGINE_DEFAULTS = {
3156
3263
  // at the default 10s tickInterval. Set to 0 (or any non-positive integer)
3157
3264
  // to disable both the log emission and sidecar write cleanly (operator opt-out).
3158
3265
  memoryBaselineEveryTicks: 6,
3266
+ // W-mr2c4i8m0004da94: proactive crash diagnostics for the engine.js
3267
+ // node.exe process. When enabled (default), every spawn of engine.js
3268
+ // (bin/minions.js `start`/`restart` AND engine/supervisor.js respawn-on-
3269
+ // death) gets NODE_OPTIONS += `--report-on-fatalerror --report-on-signal
3270
+ // --diagnostic-dir=<CRASH_REPORTS_DIR>` so a fatal V8 error (OOM, stack
3271
+ // overflow, native-addon crash) or an operator-sent signal
3272
+ // (SIGUSR2 POSIX / SIGBREAK Windows) leaves a JSON diagnostic report
3273
+ // instead of a silent death. See getEngineCrashDiagnosticsEnv,
3274
+ // pruneCrashDiagnosticsReports, and docs/diagnostics-crash-reports.md.
3275
+ crashDiagnostics: {
3276
+ enabled: true,
3277
+ retainCount: 20, // report.*.json files kept in CRASH_REPORTS_DIR; oldest pruned during runCleanup (cleanupEvery ticks)
3278
+ },
3159
3279
  stalledDispatchSweepEvery: 120, // stalled-dispatch retry sweep (~20 min at default 10s tick) — only fires when all agents idle
3160
3280
  // W-mp5trwh60008386d: per-PR 404 must repeat across N consecutive successful base-repo probes
3161
3281
  // before flipping a PR to `abandoned`. A single 404 on `repos/{slug}/pulls/{n}` can be a transient
@@ -9091,6 +9211,9 @@ function createBackoffTracker({ baseMs = 30000, maxMs = 10 * 60 * 1000 } = {}) {
9091
9211
  module.exports = {
9092
9212
  MINIONS_DIR,
9093
9213
  ENGINE_DIR,
9214
+ CRASH_REPORTS_DIR,
9215
+ getEngineCrashDiagnosticsEnv,
9216
+ pruneCrashDiagnosticsReports,
9094
9217
  resolveEngineCacheDir,
9095
9218
  openUrlInBrowser,
9096
9219
  CONTROL_PATH,
@@ -347,6 +347,18 @@ function _sqliteSpawnFlags() {
347
347
  return ['--experimental-sqlite'];
348
348
  }
349
349
 
350
+ // W-mr2c4i8m0004da94: crash-diagnostics env additions for a supervisor-
351
+ // initiated respawn. Best-effort config.json read — the supervisor spawns
352
+ // engine.js before any config is loaded into memory, mirroring the same
353
+ // read bin/minions.js does for its own engine spawns.
354
+ function _engineCrashDiagnosticsEnv() {
355
+ const shared = _sharedOrNull();
356
+ if (!shared || typeof shared.getEngineCrashDiagnosticsEnv !== 'function') return {};
357
+ let cfg = null;
358
+ try { cfg = JSON.parse(fs.readFileSync(path.join(MINIONS_DIR, 'config.json'), 'utf8')); } catch { /* config may not exist */ }
359
+ try { return shared.getEngineCrashDiagnosticsEnv(cfg); } catch { return {}; }
360
+ }
361
+
350
362
  function spawnEngine() {
351
363
  const out = openAppendFd('engine-stdio.log');
352
364
  const err = openAppendFd('engine-stdio.log');
@@ -355,6 +367,7 @@ function spawnEngine() {
355
367
  stdio: ['ignore', out, err],
356
368
  detached: true,
357
369
  windowsHide: true,
370
+ env: { ...process.env, ..._engineCrashDiagnosticsEnv() },
358
371
  });
359
372
  proc.unref();
360
373
  return proc.pid;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2308",
3
+ "version": "0.1.2310",
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"
@@ -325,7 +325,7 @@ Every configured project has an effective **checkout mode** — surfaced in your
325
325
 
326
326
  - **`worktree`** (default) — each dispatch gets its own isolated `git worktree`. Agents run fully in parallel; nothing touches the operator's working tree. Use for normal repos.
327
327
  - **`live`** — agents run **in-place** inside the project's `localPath` (no worktree). The engine caps this to **one mutating dispatch at a time** per project and **refuses on a dirty tree**. Use only when worktrees are unworkable (e.g. Android `repo`, submodules, deep Windows paths, emulators that bind the real checkout).
328
- - **`hybrid`** — `live` **plus** a `liveValidation: { type, autoDispatch }` block. Coding work items (`implement`/`fix`/`docs`/`decompose`) author in **isolated worktrees** (full parallelism), while **only** work items whose type matches `liveValidation.type` (e.g. `build-and-test`) run **in-place on the live checkout**. This is the best of both: parallel code authoring + a real on-disk build/validation that can't run in a worktree.
328
+ - **`hybrid`** — `live` **plus** a `liveValidation: { type, autoDispatch }` block. Coding work items (`implement`/`fix`/`docs`/`decompose`) author in **isolated worktrees** (full parallelism), while **only** work items whose type matches `liveValidation.type` (e.g. `build-and-test`) run **in-place on the live checkout**. This is the best of both: parallel code authoring + a real on-disk build/validation that can't run in a worktree. `type` accepts a single string (e.g. `"build-and-test"`) or an array of strings (e.g. `["implement","fix"]`) to route multiple work-item types to the live checkout at once.
329
329
 
330
330
  **When to recommend hybrid:** the project's build/test/validation genuinely cannot run in an isolated worktree (it needs the real checkout — submodules, a `repo`-managed tree, an emulator/dev-server bound to `localPath`, deep-path tooling), **but** you still want coding agents to work in parallel rather than serialize through the single live checkout. If the *whole* workflow must run on the real tree, use plain `live`. If nothing needs the real tree, stay on `worktree`.
331
331