@yemi33/minions 0.1.2142 → 0.1.2144

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/engine/shared.js CHANGED
@@ -85,6 +85,105 @@ const LOG_PATH = path.join(MINIONS_DIR, 'engine', 'log.json');
85
85
  const CONSTELLATION_BRIDGE_MARKER_PATH = path.join(MINIONS_DIR, 'engine', 'constellation-bridge.json');
86
86
  const CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION = 1;
87
87
 
88
+ // ── Dashboard port runtime file (W-mq5nwl9l) ────────────────────────────────
89
+ // dashboard.js writes this file as soon as `server.listen` resolves so every
90
+ // downstream consumer (CLI status/dash/restart, cli-api-client, supervisor)
91
+ // can discover the ACTUALLY-bound port rather than guessing 7331. The
92
+ // resolver chain (explicit > env MINIONS_DASHBOARD_PORT > config.engine.
93
+ // dashboardPort > default 7331) is shared between dashboard.js (deciding what
94
+ // to try first) and bin/minions.js (deciding what to display/probe when the
95
+ // runtime file is missing or stale). The auto-fallback scan picks the first
96
+ // free port in [requestedPort, requestedPort + DASHBOARD_PORT_SCAN_MAX) on
97
+ // EADDRINUSE so a busy 7331 doesn't break startup.
98
+ const DEFAULT_DASHBOARD_PORT = 7331;
99
+ const DASHBOARD_PORT_SCAN_MAX = 100;
100
+ const DASHBOARD_PORT_PATH = path.join(MINIONS_DIR, 'engine', 'dashboard-port.json');
101
+
102
+ function _dashboardPortPath(minionsHome) {
103
+ return minionsHome
104
+ ? path.join(minionsHome, 'engine', 'dashboard-port.json')
105
+ : DASHBOARD_PORT_PATH;
106
+ }
107
+
108
+ function _validPort(n) {
109
+ const v = Number.isInteger(n) ? n : parseInt(n, 10);
110
+ return Number.isInteger(v) && v > 0 && v <= 65535 ? v : null;
111
+ }
112
+
113
+ /**
114
+ * Read the runtime dashboard-port.json beacon. Returns `{port, pid, boundAt,
115
+ * path}` on success, or `null` when the file is missing, malformed, or
116
+ * contains an out-of-range port. No throw.
117
+ */
118
+ function readDashboardPortFile(minionsHome) {
119
+ const fp = _dashboardPortPath(minionsHome);
120
+ try {
121
+ const raw = fs.readFileSync(fp, 'utf8');
122
+ const data = JSON.parse(raw);
123
+ const port = _validPort(data && data.port);
124
+ if (port === null) return null;
125
+ return {
126
+ port,
127
+ pid: Number.isInteger(data.pid) ? data.pid : null,
128
+ boundAt: typeof data.boundAt === 'string' ? data.boundAt : null,
129
+ path: fp,
130
+ };
131
+ } catch { return null; }
132
+ }
133
+
134
+ /**
135
+ * Atomically write the runtime dashboard-port.json beacon. The dashboard
136
+ * calls this once `server.listen` resolves so every CLI/client consumer
137
+ * agrees on the bound port (which may differ from the requested port after
138
+ * an EADDRINUSE fallback). Best-effort: file IO errors do not throw.
139
+ */
140
+ function writeDashboardPortFile({ port, pid, minionsHome } = {}) {
141
+ const validPort = _validPort(port);
142
+ if (validPort === null) return { ok: false, error: new Error(`invalid port: ${port}`) };
143
+ const fp = _dashboardPortPath(minionsHome);
144
+ const payload = {
145
+ port: validPort,
146
+ pid: Number.isInteger(pid) ? pid : process.pid,
147
+ boundAt: new Date().toISOString(),
148
+ };
149
+ try {
150
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
151
+ const tmp = fp + '.tmp';
152
+ fs.writeFileSync(tmp, JSON.stringify(payload));
153
+ fs.renameSync(tmp, fp);
154
+ return { ok: true, path: fp };
155
+ } catch (err) {
156
+ return { ok: false, error: err };
157
+ }
158
+ }
159
+
160
+ /** Delete the runtime dashboard-port.json beacon. Called on graceful
161
+ * dashboard shutdown so the next CLI status check doesn't probe a stale
162
+ * port. Best-effort. */
163
+ function clearDashboardPortFile(minionsHome) {
164
+ const fp = _dashboardPortPath(minionsHome);
165
+ try { fs.unlinkSync(fp); return { ok: true }; } catch { return { ok: false }; }
166
+ }
167
+
168
+ /**
169
+ * Resolve the *requested* dashboard port via the precedence chain:
170
+ * explicit > env MINIONS_DASHBOARD_PORT > config.engine.dashboardPort > 7331
171
+ *
172
+ * Pure — performs no IO. Callers that want the actually-bound port should
173
+ * prefer `readDashboardPortFile()` first and fall back to this. Returns
174
+ * `{port, source}` so log lines can stay accurate ("from env", "from
175
+ * config", etc.).
176
+ */
177
+ function resolveDashboardPort(opts = {}) {
178
+ const { explicit, env, config, defaultPort = DEFAULT_DASHBOARD_PORT } = opts;
179
+ let p;
180
+ if ((p = _validPort(explicit)) !== null) return { port: p, source: 'explicit' };
181
+ if ((p = _validPort(env)) !== null) return { port: p, source: 'env' };
182
+ if ((p = _validPort(config)) !== null) return { port: p, source: 'config' };
183
+ const def = _validPort(defaultPort) || DEFAULT_DASHBOARD_PORT;
184
+ return { port: def, source: 'default' };
185
+ }
186
+
88
187
  // ── Timestamps & Logging ────────────────────────────────────────────────────
89
188
  // Extracted from engine.js so engine/* modules can import directly without
90
189
  // circular-requiring the orchestrator.
@@ -2420,6 +2519,25 @@ const ENGINE_DEFAULTS = {
2420
2519
  mainBranchCacheMaxEntries: 100, // bound repo/branch detection cache in long-lived dashboard/engine processes
2421
2520
  removeWorktreeFailureTtlMs: 24 * 60 * 60 * 1000, // stale failed paths are forgotten after a day
2422
2521
  removeWorktreeFailureMaxEntries: 1000, // bound failed-worktree retry suppression cache
2522
+ // ── Worktree GC hardening (W-mq5o6bvy000x7191) ──────────────────────────
2523
+ // Windows file-lock (EPERM/EBUSY/EACCES/ENOTEMPTY) class on worktree
2524
+ // removal: AV scans, vscode reopen, Explorer thumbnailers, msbuild watchers
2525
+ // hold transient locks on files inside a worktree we're trying to reap.
2526
+ // Without retry, a single EPERM at the fs.rmSync step in removeWorktree
2527
+ // permanently drops the worktree into the 3-attempt cooldown cache.
2528
+ worktreeRemoveRetryAttempts: 6, // exponential-backoff attempts on EPERM/EBUSY/EACCES/ENOTEMPTY
2529
+ worktreeRemoveRetryBaseMs: 250, // base delay; effective delay = baseMs * 2^i + jitter
2530
+ // Periodic in-tick orphan-worktree sweep (Layer 2 of W-mq5o6bvy000x7191).
2531
+ // Boot-only reconcile in cli.js doesn't catch orphans accumulated by
2532
+ // long-lived engines; this re-runs the same pruner every N ticks.
2533
+ worktreePruneIntervalTicks: 30, // ~5 min at 10s tick (matches keep-processes/managed-spawn cadence factor)
2534
+ // Stuck-dir escalation (Layer 4 of W-mq5o6bvy000x7191). After the same
2535
+ // worktree path has failed removal N consecutive times, the engine
2536
+ // suppresses repeat log spam, writes a dedup'd inbox note, and continues
2537
+ // retrying at a slower cadence in the background.
2538
+ worktreeStuckThreshold: 10, // consecutive removal failures before escalation
2539
+ worktreeStuckSuppressMs: 60 * 60 * 1000, // 60min — suppress per-tick warn after escalation
2540
+ worktreeStuckSlowRetryMs: 30 * 60 * 1000, // 30min — slow-cadence retry window after escalation
2423
2541
  ccMaxTurns: 50, // max tool-use turns per CC/doc-chat call before CLI stops (per response, not per session)
2424
2542
  ccTurnTimeoutMs: 300000, // W-mpmwxni2000c25c7-b/-d: 5min per-turn no-progress watchdog. The window resets on every liveness signal — token chunk, tool-call notification, tool-update — so an actively-streaming CC/doc-chat turn (long shell command, deep search, sub-agent loop) survives indefinitely up to the outer CC_CALL_TIMEOUT_MS (~1h) ceiling. Only true silence past this window with no progress fires the cancel: the in-flight LLM call is aborted and the handler surfaces `{code:'cc-turn-timeout', retryable:true}` via the typed error envelope so the UI can stop the spinner and offer Retry. Clamped to [10000, 3600000] in the settings POST handler. Independent of CC_CALL_TIMEOUT_MS. Non-streaming doc-chat is the lone wall-clock exception (no progress hooks); see _raceCcDocChatTimeout in dashboard.js for the dual factory/promise shape.
2425
2543
  docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity (LRU; sessions are non-expiring otherwise)
@@ -5429,6 +5547,32 @@ function normalizePrLinkItems(value) {
5429
5547
  return [...new Set(items.filter(item => typeof item === 'string' && item))];
5430
5548
  }
5431
5549
 
5550
+ /**
5551
+ * Single source of truth for "should the engine dispatch review/fix for this PR?"
5552
+ *
5553
+ * Used by `discoverFromPrs` in engine.js to gate review + fix dispatch, and by
5554
+ * `upsertPullRequestRecord` to decide whether to preserve auto-managed metadata
5555
+ * on merge. Dispatch-loop callers MUST go through this helper — do NOT inline
5556
+ * variants like `knownAgents.has(pr.agent) || pr.prdItems?.length || pr._manual`
5557
+ * because they silently drop the `_autoObserve` case (PR linked with
5558
+ * autoObserve=true but no prdItems and no configured-agent author) and the
5559
+ * sourcePlan/itemType cases. See W-mq5rs2eq000da8a9 for the bug repro.
5560
+ *
5561
+ * A PR is auto-managed when ANY of the following hold (and `_contextOnly` is
5562
+ * not explicitly true — context-only always wins):
5563
+ * - It carries one or more `prdItems` (linked to work items)
5564
+ * - It was explicitly opted-in via `_autoObserve: true` (manual link with
5565
+ * autoObserve, or per-row toggle via POST /api/pull-requests/observe)
5566
+ * - It was created from a plan or has an itemType (`sourcePlan`/`itemType`)
5567
+ * - It has a non-empty agent author other than the literal string `'human'`
5568
+ * (the helper deliberately does NOT require the agent be in
5569
+ * `config.agents` — once `_autoObserve` is the explicit opt-in for
5570
+ * non-agent authors, the loosened agent-string check has no operational
5571
+ * downside for managed flows and avoids the silent-skip bug.)
5572
+ *
5573
+ * @param {object} pr Pull request record
5574
+ * @returns {boolean} true when the engine should drive review/fix for this PR
5575
+ */
5432
5576
  function isAutoManagedPrRecord(pr) {
5433
5577
  if (!pr || typeof pr !== 'object' || pr._contextOnly === true) return false;
5434
5578
  if (normalizePrLinkItems(pr.prdItems).length > 0) return true;
@@ -6101,6 +6245,82 @@ const _WIN_RESERVED_NAMES = new Set([
6101
6245
  'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
6102
6246
  ]);
6103
6247
 
6248
+ // ── Worktree GC hardening (W-mq5o6bvy000x7191) ──────────────────────────────
6249
+ // Exit codes that mean "transient lock — try again later" on Windows when a
6250
+ // file inside the worktree is held by AV scan, vscode reopen, msbuild watcher,
6251
+ // or Explorer thumbnailer. Anything else (ENOENT, missing dir, real fs error)
6252
+ // is rethrown immediately.
6253
+ const _WORKTREE_RETRYABLE_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY']);
6254
+
6255
+ /**
6256
+ * Retry helper for fs ops that can hit Windows file-lock errors.
6257
+ * Used by `removeWorktree`'s `fs.rmSync` call so a single transient EPERM
6258
+ * doesn't drop the worktree into the 3-attempt cooldown.
6259
+ *
6260
+ * - op: thunk that performs (and returns) the fs op
6261
+ * - label: short string used in the final thrown-error message
6262
+ * - opts.attempts: total attempts; default ENGINE_DEFAULTS.worktreeRemoveRetryAttempts
6263
+ * - opts.baseMs: base delay; effective delay per attempt = baseMs * 2^i + jitter
6264
+ * - opts.onAttempt: optional (attempt, err) => void for tests/metrics
6265
+ *
6266
+ * Returns the op's result on first success. On the final attempt that fails,
6267
+ * the underlying error is re-thrown (wrapped with attempt count in `.message`)
6268
+ * so callers can branch on `err.code` per usual.
6269
+ *
6270
+ * Non-retryable errors (code not in `_WORKTREE_RETRYABLE_CODES`) are thrown
6271
+ * immediately without sleeping or counting against the attempts budget.
6272
+ */
6273
+ function _retryFsOp(op, label, opts = {}) {
6274
+ const attempts = Number(opts.attempts) > 0
6275
+ ? Math.floor(opts.attempts)
6276
+ : (ENGINE_DEFAULTS.worktreeRemoveRetryAttempts || 6);
6277
+ const baseMs = Number(opts.baseMs) > 0
6278
+ ? Number(opts.baseMs)
6279
+ : (ENGINE_DEFAULTS.worktreeRemoveRetryBaseMs || 250);
6280
+ const onAttempt = typeof opts.onAttempt === 'function' ? opts.onAttempt : null;
6281
+ let lastErr;
6282
+ for (let i = 0; i < attempts; i++) {
6283
+ try {
6284
+ const result = op();
6285
+ if (onAttempt) { try { onAttempt(i + 1, null); } catch { /* metric optional */ } }
6286
+ return { result, attempt: i + 1 };
6287
+ } catch (e) {
6288
+ lastErr = e;
6289
+ if (onAttempt) { try { onAttempt(i + 1, e); } catch { /* metric optional */ } }
6290
+ if (!e || !_WORKTREE_RETRYABLE_CODES.has(e.code)) throw e;
6291
+ if (i === attempts - 1) break;
6292
+ const delay = baseMs * Math.pow(2, i) + Math.floor(Math.random() * 200);
6293
+ sleepMs(delay);
6294
+ }
6295
+ }
6296
+ const err = new Error(`${label} failed after ${attempts} retries: ${(lastErr && lastErr.message) || 'unknown'}`);
6297
+ err.code = (lastErr && lastErr.code) || 'EUNKNOWN';
6298
+ err.cause = lastErr;
6299
+ err.attempts = attempts;
6300
+ throw err;
6301
+ }
6302
+
6303
+ /**
6304
+ * Bump per-call counters on the `_worktreeGcOutcomes` metric. Best-effort;
6305
+ * silent on lock contention or missing metrics file. The schema lives under
6306
+ * a single key so all worktree-GC behavior is observable from one dashboard
6307
+ * tile.
6308
+ */
6309
+ function bumpWorktreeGcMetric(field, delta = 1) {
6310
+ if (!field || typeof field !== 'string') return;
6311
+ const n = Number(delta);
6312
+ if (!Number.isFinite(n) || n === 0) return;
6313
+ try {
6314
+ mutateMetrics((metrics) => {
6315
+ if (!metrics._worktreeGcOutcomes || typeof metrics._worktreeGcOutcomes !== 'object') {
6316
+ metrics._worktreeGcOutcomes = {};
6317
+ }
6318
+ metrics._worktreeGcOutcomes[field] = (metrics._worktreeGcOutcomes[field] || 0) + n;
6319
+ return metrics;
6320
+ });
6321
+ } catch { /* metric best-effort */ }
6322
+ }
6323
+
6104
6324
  const PR_FIX_CAUSE = {
6105
6325
  HUMAN_FEEDBACK: 'human-feedback',
6106
6326
  REVIEW_FEEDBACK: 'review-feedback',
@@ -6239,15 +6459,26 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot) {
6239
6459
  _purgeReservedFiles(resolved);
6240
6460
  }
6241
6461
 
6462
+ bumpWorktreeGcMetric('attempts');
6242
6463
  try {
6243
6464
  exec(`git worktree remove "${wtPath}" --force`, { cwd: gitRoot, stdio: 'pipe', timeout: 15000, windowsHide: true });
6244
6465
  _removeWorktreeFailures.delete(resolved);
6466
+ bumpWorktreeGcMetric('success');
6245
6467
  return true;
6246
6468
  } catch (gitErr) {
6247
6469
  try {
6248
- fs.rmSync(resolved, { recursive: true, force: true });
6470
+ // W-mq5o6bvy000x7191 (Layer 1): retry fs.rmSync with exponential backoff
6471
+ // for transient Windows file-locks (EPERM/EBUSY/EACCES/ENOTEMPTY) before
6472
+ // falling through to rd /s /q. A single AV/Explorer/vscode lock during
6473
+ // GC must not bury the whole worktree under the 3-attempt cooldown.
6474
+ const { attempt } = _retryFsOp(
6475
+ () => fs.rmSync(resolved, { recursive: true, force: true }),
6476
+ `fs.rmSync(${resolved})`
6477
+ );
6249
6478
  try { exec('git worktree prune', { cwd: gitRoot, stdio: 'pipe', timeout: 10000, windowsHide: true }); } catch {}
6250
6479
  _removeWorktreeFailures.delete(resolved);
6480
+ bumpWorktreeGcMetric('success');
6481
+ if (attempt > 1) bumpWorktreeGcMetric('successAfterRetry');
6251
6482
  return true;
6252
6483
  } catch (rmErr) {
6253
6484
  // Windows: try cmd /c rd /s /q for any error — handles reserved device names,
@@ -6257,6 +6488,7 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot) {
6257
6488
  exec(`cmd /c rd /s /q "${resolved}"`, { stdio: 'pipe', timeout: 15000, windowsHide: true });
6258
6489
  try { exec('git worktree prune', { cwd: gitRoot, stdio: 'pipe', timeout: 10000, windowsHide: true }); } catch {}
6259
6490
  _removeWorktreeFailures.delete(resolved);
6491
+ bumpWorktreeGcMetric('success');
6260
6492
  return true;
6261
6493
  } catch (rdErr) {
6262
6494
  log('warn', `removeWorktree: rd /s /q fallback failed for ${wtPath}: ${rdErr.message}`);
@@ -6265,8 +6497,10 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot) {
6265
6497
  const fail = _removeWorktreeFailures.get(resolved) || { count: 0, lastAttempt: 0 };
6266
6498
  fail.count++;
6267
6499
  fail.lastAttempt = Date.now();
6500
+ fail.lastError = rmErr && rmErr.message ? rmErr.message : 'unknown';
6268
6501
  _removeWorktreeFailures.set(resolved, fail);
6269
6502
  _pruneRemoveWorktreeFailures();
6503
+ bumpWorktreeGcMetric('totalFailure');
6270
6504
  if (fail.count <= 3) log('warn', `removeWorktree: failed for ${wtPath} (attempt ${fail.count}/3): ${rmErr.message}`);
6271
6505
  return false;
6272
6506
  }
@@ -6451,6 +6685,13 @@ module.exports = {
6451
6685
  LOG_PATH,
6452
6686
  CONSTELLATION_BRIDGE_MARKER_PATH,
6453
6687
  CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
6688
+ DEFAULT_DASHBOARD_PORT,
6689
+ DASHBOARD_PORT_SCAN_MAX,
6690
+ DASHBOARD_PORT_PATH,
6691
+ readDashboardPortFile,
6692
+ writeDashboardPortFile,
6693
+ clearDashboardPortFile,
6694
+ resolveDashboardPort,
6454
6695
  currentLogPath: _currentLogPath,
6455
6696
  ts,
6456
6697
  normalizeIsoTimestamp, // F6 (P-f6commentedit)
@@ -6598,6 +6839,7 @@ module.exports = {
6598
6839
  normalizePrRecords,
6599
6840
  normalizePrLinkItems, // exported for testing
6600
6841
  mergePrLinkItems, // exported for testing
6842
+ isAutoManagedPrRecord,
6601
6843
  upsertPullRequestRecord,
6602
6844
  nextWorkItemId,
6603
6845
  getProjectOrg,
@@ -6651,6 +6893,9 @@ module.exports = {
6651
6893
  listProcessDescendants,
6652
6894
  listProcessReachable,
6653
6895
  removeWorktree,
6896
+ _retryFsOp, // exported for testing (W-mq5o6bvy000x7191)
6897
+ bumpWorktreeGcMetric, // exported for testing (W-mq5o6bvy000x7191)
6898
+ _WORKTREE_RETRYABLE_CODES, // exported for testing (W-mq5o6bvy000x7191)
6654
6899
  _purgeReservedFiles, // exported for testing
6655
6900
  _WIN_RESERVED_NAMES, // exported for testing
6656
6901
  LOCK_STALE_MS,
@@ -52,7 +52,24 @@ const STOP_INTENT_PATH = path.join(STATIC_ENGINE_DIR, 'stop-intent.json');
52
52
  const SUPERVISOR_PID_PATH = path.join(STATIC_ENGINE_DIR, 'supervisor.pid');
53
53
 
54
54
  const SUPERVISOR_INTERVAL_MS = Number(process.env.MINIONS_SUPERVISOR_INTERVAL_MS) || 30000;
55
- const DASH_PORT = Number(process.env.MINIONS_DASHBOARD_PORT) || 7331;
55
+ // Dashboard port resolves dynamically each tick (W-mq5nwl9l). The dashboard
56
+ // auto-falls-back to the next free port when 7331 is in use and persists the
57
+ // actually-bound port to ~/.minions/engine/dashboard-port.json. Hard-coding
58
+ // 7331 here would make the supervisor probe the wrong port and respawn-loop
59
+ // the dashboard. _resolveDashPort() consults the runtime file first, falls
60
+ // back to the env var, then to the static default.
61
+ const DASH_PORT_DEFAULT = Number(process.env.MINIONS_DASHBOARD_PORT) || 7331;
62
+ function _resolveDashPort() {
63
+ const shared = _sharedOrNull();
64
+ if (shared && typeof shared.readDashboardPortFile === 'function') {
65
+ try {
66
+ const home = process.env.MINIONS_HOME || path.join(os.homedir(), '.minions');
67
+ const entry = shared.readDashboardPortFile(home);
68
+ if (entry && Number.isInteger(entry.port) && entry.port > 0) return entry.port;
69
+ } catch { /* fall through */ }
70
+ }
71
+ return DASH_PORT_DEFAULT;
72
+ }
56
73
  // Grace window after we (re)spawn a process — gives it time to bind its port
57
74
  // or write its PID before we re-probe. Without this we'd race the freshly
58
75
  // spawned process and double-spawn.
@@ -216,10 +233,11 @@ function checkEngine(now) {
216
233
 
217
234
  function checkDashboard(now) {
218
235
  if (now - _lastDashboardRespawnAt < POST_SPAWN_GRACE_MS) return;
219
- const pids = listeningPidsForPort(DASH_PORT);
236
+ const dashPort = _resolveDashPort();
237
+ const pids = listeningPidsForPort(dashPort);
220
238
  if (pids.length > 0) return;
221
239
 
222
- console.log(`[supervisor] Dashboard not listening on port ${DASH_PORT} — respawning...`);
240
+ console.log(`[supervisor] Dashboard not listening on port ${dashPort} — respawning...`);
223
241
  const newPid = spawnDashboard();
224
242
  _lastDashboardRespawnAt = now;
225
243
  console.log(`[supervisor] Dashboard respawned (new PID: ${newPid})`);
@@ -267,7 +285,7 @@ function main() {
267
285
  // its PID to control.json on cold Windows boots.
268
286
  _lastEngineRespawnAt = Date.now();
269
287
  _lastDashboardRespawnAt = Date.now();
270
- console.log(`[supervisor] Started (PID ${process.pid}); interval=${SUPERVISOR_INTERVAL_MS}ms, dashboardPort=${DASH_PORT}`);
288
+ console.log(`[supervisor] Started (PID ${process.pid}); interval=${SUPERVISOR_INTERVAL_MS}ms, dashboardPort=${_resolveDashPort()}(dynamic)`);
271
289
 
272
290
  const interval = setInterval(tick, SUPERVISOR_INTERVAL_MS);
273
291
  // Run one tick immediately so a freshly-restarted Minions covers any early