@yemi33/minions 0.1.2307 → 0.1.2309

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})`);
@@ -1114,8 +1114,11 @@ async function saveSettings() {
1114
1114
  // Settings UI presents the idle timeout in minutes for readability; the
1115
1115
  // engine stores ms (ENGINE_DEFAULTS.ccWorkerIdleTimeoutMs). Convert here;
1116
1116
  // the server clamps to [60000, 28800000] ms. undefined when blank so the
1117
- // server keeps the prior value rather than zeroing it.
1117
+ // server keeps the prior value rather than zeroing it. W-mr1qbj90000d30bd:
1118
+ // the "Never idle-reap CC workers" toggle wins and submits the explicit
1119
+ // `0` sentinel (bypasses the clamp server-side), ignoring the minutes field.
1118
1120
  ccWorkerIdleTimeoutMs: (function () {
1121
+ if (document.getElementById('set-ccNeverIdleReap')?.checked) return 0;
1119
1122
  var m = Number(document.getElementById('set-ccWorkerIdleTimeoutMs')?.value);
1120
1123
  return (Number.isFinite(m) && m > 0) ? Math.round(m * 60000) : undefined;
1121
1124
  })(),
package/dashboard.js CHANGED
@@ -258,8 +258,12 @@ function reloadConfig() {
258
258
  // Settings change takes effect without a dashboard restart (W-mr0qs0vw). The
259
259
  // pool stays dependency-free at load and relies on this push.
260
260
  try {
261
- const idleMs = Number(CONFIG.engine?.ccWorkerIdleTimeoutMs)
262
- || shared.ENGINE_DEFAULTS.ccWorkerIdleTimeoutMs;
261
+ // W-mr1qbj90000d30bd 0 is the explicit "never idle-reap" sentinel and
262
+ // must NOT fall through to the default via `||` (0 is falsy in JS).
263
+ const cfgIdle = CONFIG.engine?.ccWorkerIdleTimeoutMs;
264
+ const idleMs = Number(cfgIdle) === 0
265
+ ? 0
266
+ : (Number(cfgIdle) || shared.ENGINE_DEFAULTS.ccWorkerIdleTimeoutMs);
263
267
  ccWorkerPool.setIdleTimeoutMs(idleMs);
264
268
  } catch { /* invalid value or pool unavailable — keep prior window */ }
265
269
  }
@@ -11063,12 +11067,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11063
11067
  // larger models); max 1h (matches CC_CALL_TIMEOUT_MS so the watchdog
11064
11068
  // never outlives the outer abort).
11065
11069
  ccTurnTimeoutMs: [10000, 3600000],
11066
- // W-mr0qs0vw — ACP worker idle-reaper window. 1min floor (anything
11067
- // shorter would reap a warm worker between a user's own messages and
11068
- // thrash cold-spawns); 8h ceiling (a config typo shouldn't pin a warm
11069
- // copilot process in memory indefinitely). Stored in ms; the Settings
11070
- // UI presents minutes and converts.
11071
- ccWorkerIdleTimeoutMs: [60000, 28800000],
11072
11070
  // W-mq9acoo800177bcb — bounded-concurrency for pre-dispatch validator.
11073
11071
  // 1 floor (sequential fallback) and 20 ceiling (above this the LLM
11074
11072
  // provider's per-second rate limits dominate; throughput gains taper).
@@ -11084,6 +11082,29 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11084
11082
  _setEngineConfig(key, val);
11085
11083
  }
11086
11084
  }
11085
+ // W-mr0qs0vw / W-mr1qbj90000d30bd — ACP worker idle-reaper window.
11086
+ // 1min floor (anything shorter would reap a warm worker between a
11087
+ // user's own messages and thrash cold-spawns); 8h ceiling (a config
11088
+ // typo shouldn't pin a warm copilot process in memory indefinitely).
11089
+ // Handled OUTSIDE the generic numericFields loop because `0` is a
11090
+ // valid, deliberate sentinel meaning "never idle-reap" (only closeTab
11091
+ // kills the warm worker) — it must bypass the [60000, 28800000]
11092
+ // clamp for that one value instead of being clamped up to 60000 or
11093
+ // falling back to the default via `Number(e[key]) || D[key]`
11094
+ // (0 is falsy in JS). Stored in ms; the Settings UI presents minutes.
11095
+ if (e.ccWorkerIdleTimeoutMs !== undefined) {
11096
+ const rawIdle = Number(e.ccWorkerIdleTimeoutMs);
11097
+ if (rawIdle === 0) {
11098
+ _setEngineConfig('ccWorkerIdleTimeoutMs', 0);
11099
+ } else {
11100
+ let val = rawIdle || D.ccWorkerIdleTimeoutMs;
11101
+ const raw = val;
11102
+ val = Math.max(60000, val);
11103
+ val = Math.min(28800000, val);
11104
+ if (val !== raw) _clamped.push(`ccWorkerIdleTimeoutMs: ${raw} → ${val} (range: 60000–28800000)`);
11105
+ _setEngineConfig('ccWorkerIdleTimeoutMs', val);
11106
+ }
11107
+ }
11087
11108
  // String fields
11088
11109
  if (e.worktreeRoot !== undefined) _setEngineConfig('worktreeRoot', String(e.worktreeRoot || D.worktreeRoot));
11089
11110
  // W-mpejf0fq000e84d6: operator login override. Empty string clears
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).
@@ -38,7 +38,10 @@
38
38
  * - closeTab → `session/cancel` if inflight, then kill proc
39
39
  * - Idle > timeout → kill proc (reaper sweep every 60 s; window is
40
40
  * ENGINE_DEFAULTS.ccWorkerIdleTimeoutMs, default
41
- * 30 min, pushed in via setIdleTimeoutMs())
41
+ * 30 min, pushed in via setIdleTimeoutMs()). A
42
+ * timeout of 0 is the "never idle-reap" sentinel
43
+ * (W-mr1qbj90000d30bd) — the warm worker is only
44
+ * ever killed by an explicit closeTab.
42
45
  *
43
46
  * Auth precheck: when `copilot --acp` exits before the initialize handshake
44
47
  * completes, surface
@@ -171,11 +174,18 @@ let _reaperTimer = null;
171
174
  const _reapedTabs = new Map();
172
175
 
173
176
  // Update the idle-reaper window at runtime. Called by dashboard.js on every
174
- // reloadConfig() with `engine.ccWorkerIdleTimeoutMs`. Ignores non-finite /
175
- // non-positive values so a config typo can't disable reaping or set a negative
176
- // window; the floor of 1000ms keeps a misconfigured tiny value from thrashing.
177
+ // reloadConfig() with `engine.ccWorkerIdleTimeoutMs`. `0` is the explicit
178
+ // "never idle-reap" sentinel (W-mr1qbj90000d30bd) it sets the window to
179
+ // Infinity so `_reapIdleTabs()` never kills a warm worker for inactivity;
180
+ // only an explicit `closeTab` does. Ignores non-finite / negative values so a
181
+ // config typo can't disable reaping or set a negative window; the floor of
182
+ // 1000ms keeps a misconfigured tiny positive value from thrashing.
177
183
  function setIdleTimeoutMs(ms) {
178
184
  const n = Number(ms);
185
+ if (n === 0) {
186
+ _idleReaperMs = Infinity;
187
+ return _idleReaperMs;
188
+ }
179
189
  if (!Number.isFinite(n) || n <= 0) return _idleReaperMs;
180
190
  _idleReaperMs = Math.max(1000, n);
181
191
  return _idleReaperMs;
@@ -876,6 +886,8 @@ function _ensureReaper() {
876
886
  }
877
887
 
878
888
  function _reapIdleTabs() {
889
+ // W-mr1qbj90000d30bd — idle-reap disabled entirely (sentinel: 0 → Infinity).
890
+ if (_idleReaperMs === Infinity) return;
879
891
  const now = _internals.now();
880
892
  for (const [tabId, worker] of [..._tabs]) {
881
893
  if (worker.inflight) continue;
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
 
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,
@@ -3156,6 +3226,19 @@ const ENGINE_DEFAULTS = {
3156
3226
  // at the default 10s tickInterval. Set to 0 (or any non-positive integer)
3157
3227
  // to disable both the log emission and sidecar write cleanly (operator opt-out).
3158
3228
  memoryBaselineEveryTicks: 6,
3229
+ // W-mr2c4i8m0004da94: proactive crash diagnostics for the engine.js
3230
+ // node.exe process. When enabled (default), every spawn of engine.js
3231
+ // (bin/minions.js `start`/`restart` AND engine/supervisor.js respawn-on-
3232
+ // death) gets NODE_OPTIONS += `--report-on-fatalerror --report-on-signal
3233
+ // --diagnostic-dir=<CRASH_REPORTS_DIR>` so a fatal V8 error (OOM, stack
3234
+ // overflow, native-addon crash) or an operator-sent signal
3235
+ // (SIGUSR2 POSIX / SIGBREAK Windows) leaves a JSON diagnostic report
3236
+ // instead of a silent death. See getEngineCrashDiagnosticsEnv,
3237
+ // pruneCrashDiagnosticsReports, and docs/diagnostics-crash-reports.md.
3238
+ crashDiagnostics: {
3239
+ enabled: true,
3240
+ retainCount: 20, // report.*.json files kept in CRASH_REPORTS_DIR; oldest pruned during runCleanup (cleanupEvery ticks)
3241
+ },
3159
3242
  stalledDispatchSweepEvery: 120, // stalled-dispatch retry sweep (~20 min at default 10s tick) — only fires when all agents idle
3160
3243
  // W-mp5trwh60008386d: per-PR 404 must repeat across N consecutive successful base-repo probes
3161
3244
  // before flipping a PR to `abandoned`. A single 404 on `repos/{slug}/pulls/{n}` can be a transient
@@ -9091,6 +9174,9 @@ function createBackoffTracker({ baseMs = 30000, maxMs = 10 * 60 * 1000 } = {}) {
9091
9174
  module.exports = {
9092
9175
  MINIONS_DIR,
9093
9176
  ENGINE_DIR,
9177
+ CRASH_REPORTS_DIR,
9178
+ getEngineCrashDiagnosticsEnv,
9179
+ pruneCrashDiagnosticsReports,
9094
9180
  resolveEngineCacheDir,
9095
9181
  openUrlInBrowser,
9096
9182
  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.2307",
3
+ "version": "0.1.2309",
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"