@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/bin/cli-api-client.js +41 -4
- package/bin/minions.js +130 -25
- package/dashboard/js/refresh.js +1 -1
- package/dashboard/js/render-prs.js +9 -7
- package/dashboard.js +101 -33
- package/docs/deprecated.json +55 -0
- package/engine/cleanup.js +54 -0
- package/engine/cli.js +35 -6
- package/engine/dispatch.js +1 -1
- package/engine/lifecycle.js +266 -0
- package/engine/queries.js +75 -1
- package/engine/shared.js +246 -1
- package/engine/supervisor.js +22 -4
- package/engine/worktree-gc.js +377 -6
- package/engine.js +53 -6
- package/package.json +1 -1
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
|
-
|
|
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,
|
package/engine/supervisor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 ${
|
|
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=${
|
|
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
|