@yemi33/minions 0.1.2178 → 0.1.2180
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/README.md +7 -5
- package/bin/minions.js +39 -17
- package/dashboard/js/command-parser.js +1 -1
- package/dashboard/js/memory-panel.js +324 -0
- package/dashboard/js/qa.js +2 -2
- package/dashboard/js/refresh.js +19 -1
- package/dashboard/js/render-other.js +143 -2
- package/dashboard/js/render-prs.js +2 -1
- package/dashboard/js/render-schedules.js +1 -1
- package/dashboard/js/render-watches.js +1 -1
- package/dashboard/js/render-work-items.js +18 -1
- package/dashboard/js/settings.js +23 -0
- package/dashboard/pages/engine-memory-panel.html +56 -0
- package/dashboard/pages/engine.html +1 -0
- package/dashboard/pages/tools.html +8 -0
- package/dashboard/slim/js/link-pr.js +5 -5
- package/dashboard/slim/js/modals-tiles.js +44 -3
- package/dashboard/slim/js/projects.js +8 -6
- package/dashboard/slim/styles.css +20 -0
- package/dashboard-build.js +17 -2
- package/dashboard.js +693 -19
- package/docs/branch-derivation.md +13 -1
- package/docs/diagnostics-memory.md +446 -0
- package/docs/harness-propagation.md +273 -0
- package/docs/human-vs-automated.md +1 -1
- package/docs/runtime-adapters.md +5 -0
- package/engine/cli.js +24 -5
- package/engine/diagnostics-memory.js +190 -0
- package/engine/lifecycle.js +111 -1
- package/engine/preflight.js +265 -0
- package/engine/queries.js +331 -19
- package/engine/runtimes/claude.js +36 -0
- package/engine/runtimes/codex.js +19 -0
- package/engine/runtimes/copilot.js +27 -36
- package/engine/shared.js +390 -15
- package/engine/spawn-agent.js +178 -12
- package/engine/watchdog.js +6 -0
- package/engine.js +277 -4
- package/package.json +2 -2
package/dashboard.js
CHANGED
|
@@ -14,6 +14,7 @@ const http = require('http');
|
|
|
14
14
|
const zlib = require('zlib');
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
|
+
const v8 = require('v8');
|
|
17
18
|
const llm = require('./engine/llm');
|
|
18
19
|
const { resolveRuntime } = require('./engine/runtimes');
|
|
19
20
|
|
|
@@ -43,11 +44,12 @@ const steeringStore = require('./engine/steering-store');
|
|
|
43
44
|
const projectDiscovery = require('./engine/project-discovery');
|
|
44
45
|
const features = require('./engine/features');
|
|
45
46
|
const ccWorkerPool = require('./engine/cc-worker-pool');
|
|
47
|
+
const diagnosticsMemory = require('./engine/diagnostics-memory');
|
|
46
48
|
const os = require('os');
|
|
47
49
|
|
|
48
50
|
const { safeRead, safeReadOrNull, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeUnlink, mutateJsonFileLocked, mutateTextFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, WORKTREE_REQUIRING_TYPES, reopenWorkItem } = shared;
|
|
49
51
|
const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
|
|
50
|
-
getSkills, getInbox, getNotesWithMeta, getPullRequests,
|
|
52
|
+
getSkills, getCommands, getInbox, getNotesWithMeta, getPullRequests,
|
|
51
53
|
getEngineLog, getMetrics, getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getProjectGitStatus, timeSince,
|
|
52
54
|
MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, DISPATCH_PATH, PRD_DIR } = queries;
|
|
53
55
|
|
|
@@ -104,6 +106,122 @@ const KB_PINS_PATH = shared.PINNED_ITEMS_PATH;
|
|
|
104
106
|
const DASHBOARD_BROWSER_PRESENCE_PATH = path.join(ENGINE_DIR, 'dashboard-browser.json');
|
|
105
107
|
const DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS = 45000;
|
|
106
108
|
|
|
109
|
+
// P-c3d4e5f6 — diagnostics-memory dashboard surface.
|
|
110
|
+
// The engine writes its latest sample to engine/diagnostics-memory.json on
|
|
111
|
+
// every memoryBaselineEveryTicks ticks; the dashboard reads it for
|
|
112
|
+
// /api/diagnostics/memory and accumulates polled engine samples into its
|
|
113
|
+
// own ring buffer (separate from diagnosticsMemory's own dashboard-self
|
|
114
|
+
// buffer) for /api/diagnostics/memory/history?process=engine. Both rings
|
|
115
|
+
// are in-process — restarting the dashboard zeroes them.
|
|
116
|
+
const DIAGNOSTICS_MEMORY_SIDECAR_PATH = path.join(ENGINE_DIR, 'diagnostics-memory.json');
|
|
117
|
+
const DIAGNOSTICS_MEMORY_STALE_MS = 5 * 60 * 1000;
|
|
118
|
+
const DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS = 60000;
|
|
119
|
+
const DIAGNOSTICS_MEMORY_ENGINE_RING_CAP = 1440;
|
|
120
|
+
const _engineMemoryRing = [];
|
|
121
|
+
let _lastEngineSampleCapturedAt = 0;
|
|
122
|
+
let _memorySamplerStop = null;
|
|
123
|
+
|
|
124
|
+
// Pure: assemble the /api/diagnostics/memory payload from already-read
|
|
125
|
+
// inputs. Exported for direct unit testing — handler callers feed it the
|
|
126
|
+
// live dashboard sample + the sidecar contents + Date.now().
|
|
127
|
+
function _buildMemoryDiagnostics({ dashboardSample, engineSample, now } = {}) {
|
|
128
|
+
const _now = Number.isFinite(now) ? now : Date.now();
|
|
129
|
+
const dashboard = dashboardSample && typeof dashboardSample === 'object' ? dashboardSample : null;
|
|
130
|
+
// engineSample may be the literal object from the sidecar OR null/{} when
|
|
131
|
+
// the sidecar is missing/unparseable. Treat anything without a finite
|
|
132
|
+
// capturedAt as stale (covers both "file missing" and "ancient sample").
|
|
133
|
+
let engine = null;
|
|
134
|
+
let engineStale = true;
|
|
135
|
+
if (engineSample && typeof engineSample === 'object' && Number.isFinite(engineSample.capturedAt)) {
|
|
136
|
+
engine = engineSample;
|
|
137
|
+
engineStale = (_now - engineSample.capturedAt) > DIAGNOSTICS_MEMORY_STALE_MS;
|
|
138
|
+
}
|
|
139
|
+
return { dashboard, engine, engineStale };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Pure: slice the requested ring buffer. Returns up to `limit` newest
|
|
143
|
+
// samples (oldest first). `limit` defaults to the full buffer when missing
|
|
144
|
+
// or non-positive (matches diagnosticsMemory.getHistory semantics).
|
|
145
|
+
function _buildMemoryHistory({ ring, limit } = {}) {
|
|
146
|
+
if (!Array.isArray(ring)) return [];
|
|
147
|
+
const n = ring.length;
|
|
148
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit >= n) return ring.slice();
|
|
149
|
+
return ring.slice(n - limit);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Read the engine sidecar and, when it carries a never-seen-before
|
|
153
|
+
// capturedAt, push it onto the engine-side ring buffer. Dedup is by
|
|
154
|
+
// capturedAt so re-reads between engine baseline ticks don't bloat the
|
|
155
|
+
// ring. Best-effort: any failure leaves the ring untouched.
|
|
156
|
+
function _pollAndAccumulateEngineSample() {
|
|
157
|
+
let sample;
|
|
158
|
+
try { sample = safeJsonObj(DIAGNOSTICS_MEMORY_SIDECAR_PATH); }
|
|
159
|
+
catch { return; }
|
|
160
|
+
if (!sample || typeof sample !== 'object') return;
|
|
161
|
+
if (!Number.isFinite(sample.capturedAt)) return;
|
|
162
|
+
if (sample.capturedAt === _lastEngineSampleCapturedAt) return;
|
|
163
|
+
_lastEngineSampleCapturedAt = sample.capturedAt;
|
|
164
|
+
_engineMemoryRing.push(sample);
|
|
165
|
+
while (_engineMemoryRing.length > DIAGNOSTICS_MEMORY_ENGINE_RING_CAP) _engineMemoryRing.shift();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _resetDiagnosticsMemoryForTesting() {
|
|
169
|
+
_engineMemoryRing.length = 0;
|
|
170
|
+
_lastEngineSampleCapturedAt = 0;
|
|
171
|
+
if (_memorySamplerStop) {
|
|
172
|
+
try { _memorySamplerStop(); } catch { /* ignore */ }
|
|
173
|
+
_memorySamplerStop = null;
|
|
174
|
+
}
|
|
175
|
+
try { diagnosticsMemory._resetForTest(); } catch { /* ignore */ }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// P-e5f6a7b8 — operator-driven heap snapshot capture for both dashboard
|
|
179
|
+
// and engine processes. v8.writeHeapSnapshot stalls the calling process
|
|
180
|
+
// for several seconds and emits a 50–200 MB file; the endpoint is
|
|
181
|
+
// guarded by a literal confirm token + per-process rate limit + retention
|
|
182
|
+
// cap so a runaway operator can't fill the disk or pin the engine.
|
|
183
|
+
const DIAGNOSTICS_DIR = path.join(ENGINE_DIR, 'diagnostics');
|
|
184
|
+
const HEAP_SNAPSHOT_REQUEST_PATH = path.join(DIAGNOSTICS_DIR, 'heap-snapshot-request.json');
|
|
185
|
+
const HEAP_SNAPSHOT_CONFIRM_TOKEN = 'YES_I_UNDERSTAND_THIS_STALLS_THE_ENGINE';
|
|
186
|
+
const HEAP_SNAPSHOT_RATE_LIMIT_MS = 60_000;
|
|
187
|
+
const HEAP_SNAPSHOT_ENGINE_TIMEOUT_MS = 30_000;
|
|
188
|
+
const HEAP_SNAPSHOT_ENGINE_POLL_MS = 250;
|
|
189
|
+
const HEAP_SNAPSHOT_RETAIN_COUNT = 5;
|
|
190
|
+
let _heapSnapshotLastCapturedAt = 0;
|
|
191
|
+
|
|
192
|
+
// Pure: compose the on-disk path for a heap snapshot. ISO timestamps
|
|
193
|
+
// contain colons (and a dot before the millis), which are illegal in
|
|
194
|
+
// NTFS filenames — replace both with `-` so Windows isn't surprised.
|
|
195
|
+
function _heapSnapshotPathFor(processLabel, iso) {
|
|
196
|
+
const safeIso = String(iso).replace(/[:.]/g, '-');
|
|
197
|
+
return path.join(DIAGNOSTICS_DIR, `heap-${processLabel}-${safeIso}.heapsnapshot`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Prune `heap-<processLabel>-*.heapsnapshot` files in `dir` down to the
|
|
201
|
+
// most-recent `retain` by mtime. Best-effort: missing dir / unreadable
|
|
202
|
+
// stat / failed unlink are swallowed (the next successful capture
|
|
203
|
+
// retries the prune).
|
|
204
|
+
function _heapSnapshotPrune(dir, processLabel, retain) {
|
|
205
|
+
let names;
|
|
206
|
+
try { names = fs.readdirSync(dir); }
|
|
207
|
+
catch { return; }
|
|
208
|
+
const prefix = `heap-${processLabel}-`;
|
|
209
|
+
const candidates = names.filter(n => n.startsWith(prefix) && n.endsWith('.heapsnapshot'));
|
|
210
|
+
if (candidates.length <= retain) return;
|
|
211
|
+
const stamped = candidates.map(n => {
|
|
212
|
+
try { return { n, mtime: fs.statSync(path.join(dir, n)).mtimeMs }; }
|
|
213
|
+
catch { return { n, mtime: 0 }; }
|
|
214
|
+
});
|
|
215
|
+
stamped.sort((a, b) => b.mtime - a.mtime); // newest first
|
|
216
|
+
for (const s of stamped.slice(retain)) {
|
|
217
|
+
try { fs.unlinkSync(path.join(dir, s.n)); } catch { /* best effort */ }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function _resetHeapSnapshotRateLimitForTesting() {
|
|
222
|
+
_heapSnapshotLastCapturedAt = 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
107
225
|
function ensureConfiguredProjectStateFiles() {
|
|
108
226
|
for (const p of PROJECTS) {
|
|
109
227
|
const root = p.localPath ? path.resolve(p.localPath) : null;
|
|
@@ -387,6 +505,25 @@ function inferActionPrRecord(action, prs, project = null) {
|
|
|
387
505
|
}
|
|
388
506
|
|
|
389
507
|
function copyWorkItemPrFields(item, input, pr = null) {
|
|
508
|
+
// W-mqbaby2a000pa8ee: Gate the LOOSE description/title scan in
|
|
509
|
+
// `shared.extractWorkItemPrRef` on `type: "fix"`. Without this gate,
|
|
510
|
+
// an implement/explore/test WI that merely mentions an existing PR
|
|
511
|
+
// in prose ("Class bug surfaced today on pull request 130") gets
|
|
512
|
+
// `targetPr` / `pr_id` / `prNumber` stamped, the engine then treats
|
|
513
|
+
// the WI as a fix against that PR, the PR-branch lookup fails, and
|
|
514
|
+
// the dispatch silently skips with `_pendingReason: null` forever.
|
|
515
|
+
//
|
|
516
|
+
// Structured PR pointers (`targetPr` / `pr_id` / `prUrl` / `prNumber` /
|
|
517
|
+
// `references[].url` / `meta.pr_followup.parent_pr_url`) are explicit
|
|
518
|
+
// operator intent — they stamp on EVERY type. An explicit `pr` record
|
|
519
|
+
// (caller already resolved the PR) also bypasses the gate. Only the
|
|
520
|
+
// last-resort description/title regex scan is type-gated.
|
|
521
|
+
if (!pr) {
|
|
522
|
+
const structuredRef = shared.extractStructuredWorkItemPrRef(input);
|
|
523
|
+
if (!structuredRef && String(item?.type || '').toLowerCase() !== WORK_TYPE.FIX) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
390
527
|
const prRef = getWorkItemPrRef(input);
|
|
391
528
|
if (!prRef && !pr) return;
|
|
392
529
|
const prNumber = pr ? shared.getPrNumber(pr) : shared.getPrNumber(prRef);
|
|
@@ -1044,6 +1181,251 @@ function _resolveSkillReadPath({ file, dir, source, config, skillFiles } = {}) {
|
|
|
1044
1181
|
return null;
|
|
1045
1182
|
}
|
|
1046
1183
|
|
|
1184
|
+
// ── Harness propagation diagnostic (P-c601f9a2) ──────────────────────────────
|
|
1185
|
+
// Per-runtime view of where the CLI looks for skills/commands/MCPs, what the
|
|
1186
|
+
// engine actually attaches via --add-dir, which assets are explicitly
|
|
1187
|
+
// suppressed (e.g. AGENTS.md auto-load, Copilot's bundled github-mcp-server),
|
|
1188
|
+
// and the canonical footgun: per-project assets that live as uncommitted files
|
|
1189
|
+
// in the operator's main checkout but won't propagate to fresh worktrees.
|
|
1190
|
+
//
|
|
1191
|
+
// Surfaced via GET /api/harness/diagnostics and rendered on the Tools page so
|
|
1192
|
+
// operators can spot a missing or unpropagated harness asset without leaving
|
|
1193
|
+
// the dashboard. See docs/harness-propagation.md for the full contract.
|
|
1194
|
+
const HARNESS_FOOTGUN_DIRS = [
|
|
1195
|
+
'.claude/skills',
|
|
1196
|
+
'.claude/commands',
|
|
1197
|
+
'.copilot/skills',
|
|
1198
|
+
'.copilot/commands',
|
|
1199
|
+
'.agents/skills',
|
|
1200
|
+
'.agents/commands',
|
|
1201
|
+
];
|
|
1202
|
+
const HARNESS_FOOTGUN_FILES = [
|
|
1203
|
+
'.claude/.mcp.json',
|
|
1204
|
+
'.copilot/.mcp.json',
|
|
1205
|
+
'.mcp.json',
|
|
1206
|
+
];
|
|
1207
|
+
|
|
1208
|
+
const _PROJECT_LOCAL_FOOTGUN_WARNING =
|
|
1209
|
+
'These files exist in your main checkout but are uncommitted or untracked in ' +
|
|
1210
|
+
'git. A fresh `git worktree add` for a mutating dispatch will NOT see them, so ' +
|
|
1211
|
+
'the dispatched agent silently underperforms. Fix: commit them, move them to ' +
|
|
1212
|
+
'user scope (~/.claude/skills/..., ~/.copilot/skills/...), or flip the project ' +
|
|
1213
|
+
'to live-checkout mode (worktreeMode: live).';
|
|
1214
|
+
|
|
1215
|
+
function _walkUncommittedHarnessAssets(absStart, rootDir, projectName) {
|
|
1216
|
+
const results = [];
|
|
1217
|
+
const stack = [absStart];
|
|
1218
|
+
let count = 0;
|
|
1219
|
+
while (stack.length > 0 && count < 500) {
|
|
1220
|
+
const cur = stack.pop();
|
|
1221
|
+
let entries;
|
|
1222
|
+
try { entries = fs.readdirSync(cur, { withFileTypes: true }); }
|
|
1223
|
+
catch { continue; }
|
|
1224
|
+
for (const ent of entries) {
|
|
1225
|
+
if (ent.name === '.git' || ent.name === 'node_modules') continue;
|
|
1226
|
+
const full = path.join(cur, ent.name);
|
|
1227
|
+
if (ent.isDirectory()) { stack.push(full); continue; }
|
|
1228
|
+
if (!ent.isFile()) continue;
|
|
1229
|
+
const rel = path.relative(rootDir, full);
|
|
1230
|
+
const normalized = rel.replace(/\\/g, '/');
|
|
1231
|
+
let kind = null;
|
|
1232
|
+
if (
|
|
1233
|
+
normalized.startsWith('.claude/skills/') ||
|
|
1234
|
+
normalized.startsWith('.copilot/skills/') ||
|
|
1235
|
+
normalized.startsWith('.agents/skills/')
|
|
1236
|
+
) kind = 'skill';
|
|
1237
|
+
else if (
|
|
1238
|
+
normalized.startsWith('.claude/commands/') ||
|
|
1239
|
+
normalized.startsWith('.copilot/commands/') ||
|
|
1240
|
+
normalized.startsWith('.agents/commands/')
|
|
1241
|
+
) kind = 'command';
|
|
1242
|
+
else if (
|
|
1243
|
+
normalized === '.mcp.json' ||
|
|
1244
|
+
normalized === '.claude/.mcp.json' ||
|
|
1245
|
+
normalized === '.copilot/.mcp.json'
|
|
1246
|
+
) kind = 'mcp';
|
|
1247
|
+
if (!kind) continue;
|
|
1248
|
+
results.push({ file: rel, kind, scope: `project:${projectName}` });
|
|
1249
|
+
count++;
|
|
1250
|
+
if (count >= 500) break;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return results;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function _scanProjectLocalHarnessFootgun(project) {
|
|
1257
|
+
const out = {
|
|
1258
|
+
project: project && project.name ? project.name : '',
|
|
1259
|
+
localPath: project && project.localPath ? project.localPath : '',
|
|
1260
|
+
uncommittedAssets: [],
|
|
1261
|
+
footgunWarning: _PROJECT_LOCAL_FOOTGUN_WARNING,
|
|
1262
|
+
};
|
|
1263
|
+
if (!project || !project.localPath) return out;
|
|
1264
|
+
let raw = '';
|
|
1265
|
+
try {
|
|
1266
|
+
const { execFileSync } = require('child_process');
|
|
1267
|
+
raw = execFileSync('git', ['status', '--porcelain', '-z'], {
|
|
1268
|
+
cwd: project.localPath,
|
|
1269
|
+
encoding: 'utf8',
|
|
1270
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1271
|
+
timeout: 10000,
|
|
1272
|
+
});
|
|
1273
|
+
} catch { return out; }
|
|
1274
|
+
if (!raw) return out;
|
|
1275
|
+
const records = raw.split('\0').filter(Boolean);
|
|
1276
|
+
const seen = new Set();
|
|
1277
|
+
for (const rec of records) {
|
|
1278
|
+
if (rec.length < 4) continue;
|
|
1279
|
+
const filePath = rec.slice(3);
|
|
1280
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
1281
|
+
const isDirRecord = normalized.endsWith('/');
|
|
1282
|
+
const trimmed = isDirRecord ? normalized.slice(0, -1) : normalized;
|
|
1283
|
+
let overlaps = false;
|
|
1284
|
+
for (const dir of HARNESS_FOOTGUN_DIRS) {
|
|
1285
|
+
if (
|
|
1286
|
+
trimmed === dir ||
|
|
1287
|
+
trimmed.startsWith(dir + '/') ||
|
|
1288
|
+
dir.startsWith(trimmed + '/')
|
|
1289
|
+
) { overlaps = true; break; }
|
|
1290
|
+
}
|
|
1291
|
+
if (!overlaps) {
|
|
1292
|
+
for (const file of HARNESS_FOOTGUN_FILES) {
|
|
1293
|
+
if (trimmed === file || file.startsWith(trimmed + '/')) { overlaps = true; break; }
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (!overlaps) continue;
|
|
1297
|
+
|
|
1298
|
+
const absStart = path.join(project.localPath, filePath);
|
|
1299
|
+
let stat;
|
|
1300
|
+
try { stat = fs.statSync(absStart); } catch { continue; }
|
|
1301
|
+
if (stat.isDirectory()) {
|
|
1302
|
+
for (const asset of _walkUncommittedHarnessAssets(absStart, project.localPath, out.project)) {
|
|
1303
|
+
if (seen.has(asset.file)) continue;
|
|
1304
|
+
seen.add(asset.file);
|
|
1305
|
+
out.uncommittedAssets.push(asset);
|
|
1306
|
+
}
|
|
1307
|
+
} else {
|
|
1308
|
+
let kind = 'other';
|
|
1309
|
+
if (normalized.includes('/skills/')) kind = 'skill';
|
|
1310
|
+
else if (normalized.includes('/commands/')) kind = 'command';
|
|
1311
|
+
else if (normalized.endsWith('.mcp.json')) kind = 'mcp';
|
|
1312
|
+
if (seen.has(filePath)) continue;
|
|
1313
|
+
seen.add(filePath);
|
|
1314
|
+
out.uncommittedAssets.push({ file: filePath, kind, scope: `project:${out.project}` });
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return out;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function _buildHarnessDiagnostics(opts = {}) {
|
|
1321
|
+
const homeDir = opts.homeDir || os.homedir();
|
|
1322
|
+
const engineConfig = opts.engineConfig || (CONFIG && CONFIG.engine) || {};
|
|
1323
|
+
const projects = (opts.projects || PROJECTS || []).filter(
|
|
1324
|
+
p => p && p.name && p.localPath && !String(p.name).startsWith('YOUR_'),
|
|
1325
|
+
);
|
|
1326
|
+
const existsFn = fs.existsSync;
|
|
1327
|
+
|
|
1328
|
+
let preflight, registry;
|
|
1329
|
+
try { preflight = require('./engine/preflight'); }
|
|
1330
|
+
catch { preflight = null; }
|
|
1331
|
+
try { registry = require('./engine/runtimes'); }
|
|
1332
|
+
catch { registry = null; }
|
|
1333
|
+
|
|
1334
|
+
const fleetDefaultCli = shared.resolveAgentCli(null, engineConfig);
|
|
1335
|
+
const runtimeNames = registry ? registry.listRuntimes() : [];
|
|
1336
|
+
const runtimes = [];
|
|
1337
|
+
const missingDirs = [];
|
|
1338
|
+
|
|
1339
|
+
const slimRow = r => ({ path: r.path, scope: r.scope, exists: !!r.exists });
|
|
1340
|
+
const collectMissing = (rows, runtimeName, kind) => {
|
|
1341
|
+
for (const r of rows) {
|
|
1342
|
+
if (r && !r.exists) {
|
|
1343
|
+
missingDirs.push({ runtime: runtimeName, path: r.path, scope: r.scope, kind });
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
for (const runtimeName of runtimeNames) {
|
|
1349
|
+
if (!preflight) break;
|
|
1350
|
+
let runtime;
|
|
1351
|
+
try { runtime = registry.resolveRuntime(runtimeName); }
|
|
1352
|
+
catch { continue; }
|
|
1353
|
+
if (!runtime) continue;
|
|
1354
|
+
let rows;
|
|
1355
|
+
try { rows = preflight._runtimeHarnessRows(runtime, { homeDir, projects, existsFn }); }
|
|
1356
|
+
catch { continue; }
|
|
1357
|
+
const userAssetDirs = rows.userAssetDirs.map(slimRow);
|
|
1358
|
+
const skillRoots = [...rows.skillRootsUser, ...rows.skillRootsProject].map(slimRow);
|
|
1359
|
+
const skillWriteTargets = rows.skillWriteTargets.map(slimRow);
|
|
1360
|
+
const commandRoots = [...rows.commandRootsUser, ...rows.commandRootsProject].map(slimRow);
|
|
1361
|
+
const mcpConfigPaths = [...rows.mcpConfigUser, ...rows.mcpConfigProject].map(slimRow);
|
|
1362
|
+
|
|
1363
|
+
runtimes.push({
|
|
1364
|
+
name: runtimeName,
|
|
1365
|
+
userAssetDirs,
|
|
1366
|
+
skillRoots,
|
|
1367
|
+
skillWriteTargets,
|
|
1368
|
+
commandRoots,
|
|
1369
|
+
mcpConfigPaths,
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
collectMissing(userAssetDirs, runtimeName, 'asset');
|
|
1373
|
+
collectMissing(skillRoots, runtimeName, 'skill');
|
|
1374
|
+
collectMissing(skillWriteTargets, runtimeName, 'skill-write');
|
|
1375
|
+
collectMissing(commandRoots, runtimeName, 'command');
|
|
1376
|
+
collectMissing(mcpConfigPaths, runtimeName, 'mcp');
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
let addDirSnapshot = [];
|
|
1380
|
+
if (preflight && registry) {
|
|
1381
|
+
let fleetRuntime = null;
|
|
1382
|
+
try { fleetRuntime = registry.resolveRuntime(fleetDefaultCli); }
|
|
1383
|
+
catch { fleetRuntime = null; }
|
|
1384
|
+
if (fleetRuntime) {
|
|
1385
|
+
const snapshot = preflight._computeAddDirSnapshot(fleetRuntime, {
|
|
1386
|
+
minionsHome: MINIONS_DIR,
|
|
1387
|
+
homeDir,
|
|
1388
|
+
existsFn,
|
|
1389
|
+
});
|
|
1390
|
+
if (Array.isArray(snapshot)) addDirSnapshot = snapshot.map(slimRow);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
const suppressed = [];
|
|
1395
|
+
if (engineConfig.copilotSuppressAgentsMd !== false) {
|
|
1396
|
+
suppressed.push({
|
|
1397
|
+
flag: 'copilotSuppressAgentsMd',
|
|
1398
|
+
value: true,
|
|
1399
|
+
effect: 'Copilot --no-custom-instructions: in-tree AGENTS.md is NOT auto-loaded, so Minions playbook prompts are not overridden by repo-local instructions.',
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
if (engineConfig.copilotDisableBuiltinMcps !== false) {
|
|
1403
|
+
suppressed.push({
|
|
1404
|
+
flag: 'copilotDisableBuiltinMcps',
|
|
1405
|
+
value: true,
|
|
1406
|
+
effect: 'Copilot --disable-builtin-mcps: bundled github-mcp-server is stripped so dispatched agents do not open a parallel PR alongside the Minions PR.',
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
if (engineConfig.hermeticHarness === true) {
|
|
1410
|
+
suppressed.push({
|
|
1411
|
+
flag: 'hermeticHarness',
|
|
1412
|
+
value: true,
|
|
1413
|
+
effect: 'Hermetic harness mode: only the engine playbook + system prompt are surfaced; user-scope skills, commands, and MCP configs are NOT propagated to this dispatch.',
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const projectLocalOnMain = projects.map(_scanProjectLocalHarnessFootgun);
|
|
1418
|
+
|
|
1419
|
+
return {
|
|
1420
|
+
fleetDefaultCli,
|
|
1421
|
+
runtimes,
|
|
1422
|
+
addDirSnapshot,
|
|
1423
|
+
suppressed,
|
|
1424
|
+
projectLocalOnMain,
|
|
1425
|
+
missingDirs,
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1047
1429
|
function _agentSessionIsDraining(agentId) {
|
|
1048
1430
|
const activeForAgent = (getDispatchQueue().active || []).some(d => d.agent === agentId);
|
|
1049
1431
|
if (!activeForAgent) return false;
|
|
@@ -1951,6 +2333,7 @@ function _buildStatusSlowState() {
|
|
|
1951
2333
|
// initialized + installId, and the version banner.
|
|
1952
2334
|
return {
|
|
1953
2335
|
skills: getSkills(),
|
|
2336
|
+
commands: getCommands(CONFIG),
|
|
1954
2337
|
mcpServers: getMcpServers(),
|
|
1955
2338
|
projects: PROJECTS.map(p => {
|
|
1956
2339
|
const status = getProjectGitStatus(p.localPath, p.mainBranch);
|
|
@@ -1958,6 +2341,7 @@ function _buildStatusSlowState() {
|
|
|
1958
2341
|
const branchMismatch = !!(mainBranch && status.remoteDefaultBranch && mainBranch !== status.remoteDefaultBranch);
|
|
1959
2342
|
return {
|
|
1960
2343
|
name: p.name,
|
|
2344
|
+
displayName: shared.projectDisplayName(p),
|
|
1961
2345
|
path: p.localPath,
|
|
1962
2346
|
description: p.description || '',
|
|
1963
2347
|
...status,
|
|
@@ -3497,13 +3881,25 @@ function getWorkItemPrRef(input) {
|
|
|
3497
3881
|
// the dispatch to the existing PR branch instead of a fresh `work/<wi-id>`
|
|
3498
3882
|
// parallel branch (issue #2999 / W-mpx6i5kh000ac040).
|
|
3499
3883
|
//
|
|
3500
|
-
//
|
|
3501
|
-
//
|
|
3502
|
-
//
|
|
3503
|
-
//
|
|
3504
|
-
//
|
|
3505
|
-
//
|
|
3506
|
-
//
|
|
3884
|
+
// STRUCTURED-vs-LOOSE SPLIT (W-mq18ec6h000p7b87): two extractors exist —
|
|
3885
|
+
// - `shared.extractStructuredWorkItemPrRef`: structured fields +
|
|
3886
|
+
// `references[].url` + `meta.pr_followup.parent_pr_url`. NO scan.
|
|
3887
|
+
// - `shared.extractWorkItemPrRef` (this helper): structured walk + a
|
|
3888
|
+
// last-resort first-paragraph/title scan.
|
|
3889
|
+
// The engine's `pr_not_found` dispatch gate uses the structured-only
|
|
3890
|
+
// variant. The original design intent was that the stamp path could
|
|
3891
|
+
// afford to be loose because gating would still require explicit
|
|
3892
|
+
// operator intent.
|
|
3893
|
+
//
|
|
3894
|
+
// FIX-TYPE GATE on the stamp path (W-mqbaby2a000pa8ee): in practice
|
|
3895
|
+
// the asymmetry leaked — the loose stamp writes to `item.pr_id` (a
|
|
3896
|
+
// canonical structured field), and the strict gate later reads
|
|
3897
|
+
// `item.pr_id` and sees the loose stamp AS IF it were structured
|
|
3898
|
+
// intent. To stop that leak without changing the loose detector
|
|
3899
|
+
// (which has other legitimate callers), `copyWorkItemPrFields` now
|
|
3900
|
+
// gates the loose result on `item.type === "fix"`. Non-fix WIs only
|
|
3901
|
+
// get stamped from structured fields (incl. references/follow-up).
|
|
3902
|
+
// See the comment block on `copyWorkItemPrFields` above for details.
|
|
3507
3903
|
return shared.extractWorkItemPrRef(input);
|
|
3508
3904
|
}
|
|
3509
3905
|
|
|
@@ -5775,11 +6171,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
5775
6171
|
if (!followupCheck.valid) {
|
|
5776
6172
|
return jsonReply(res, 400, { error: followupCheck.error });
|
|
5777
6173
|
}
|
|
6174
|
+
// P-714ef144 — per-WI meta.workdir override validation. Same shape
|
|
6175
|
+
// contract the engine enforces at dispatch time (shared.validateWorkItemWorkdir);
|
|
6176
|
+
// running it here means the operator gets an immediate 400 instead
|
|
6177
|
+
// of a deferred non-retryable INVALID_WORKDIR failure at dispatch.
|
|
6178
|
+
const workdirCheck = shared.validateWorkItemWorkdir(body.meta.workdir);
|
|
6179
|
+
if (!workdirCheck.valid) {
|
|
6180
|
+
return jsonReply(res, 400, { error: `meta.workdir: ${workdirCheck.error}` });
|
|
6181
|
+
}
|
|
5778
6182
|
item.meta = { ...body.meta };
|
|
5779
6183
|
if (followupCheck.value) {
|
|
5780
6184
|
item.meta.pr_followup = followupCheck.value;
|
|
5781
6185
|
validatedFollowup = followupCheck.value;
|
|
5782
6186
|
}
|
|
6187
|
+
if (workdirCheck.value === null) {
|
|
6188
|
+
delete item.meta.workdir;
|
|
6189
|
+
} else {
|
|
6190
|
+
item.meta.workdir = workdirCheck.value;
|
|
6191
|
+
}
|
|
5783
6192
|
}
|
|
5784
6193
|
// PR follow-up traceability headers (W-mpej3cox00099466).
|
|
5785
6194
|
const originAgent = extractMinionsAgentHeader(req);
|
|
@@ -5860,6 +6269,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
5860
6269
|
if (!Array.isArray(body.depends_on)) return jsonReply(res, 400, { error: 'depends_on must be an array of strings' });
|
|
5861
6270
|
if (!body.depends_on.every(s => typeof s === 'string')) return jsonReply(res, 400, { error: 'depends_on entries must be strings' });
|
|
5862
6271
|
}
|
|
6272
|
+
// P-714ef144 — pre-validate meta.workdir on the request body (same
|
|
6273
|
+
// contract the engine enforces). Operators get an immediate 400
|
|
6274
|
+
// instead of a deferred INVALID_WORKDIR at dispatch time. We tolerate
|
|
6275
|
+
// three shapes here:
|
|
6276
|
+
// - body.workdir: "packages/foo" (top-level convenience)
|
|
6277
|
+
// - body.meta: { workdir: "..." } (full meta replace — currently
|
|
6278
|
+
// never sent by the UI, but kept for symmetry with create)
|
|
6279
|
+
// - neither: no change to existing item.meta.workdir
|
|
6280
|
+
let workdirUpdate; // undefined = no change, null = unset, string = set
|
|
6281
|
+
if (body.workdir !== undefined) {
|
|
6282
|
+
const wdCheck = shared.validateWorkItemWorkdir(body.workdir);
|
|
6283
|
+
if (!wdCheck.valid) return jsonReply(res, 400, { error: `workdir: ${wdCheck.error}` });
|
|
6284
|
+
workdirUpdate = wdCheck.value; // null or normalized string
|
|
6285
|
+
} else if (body.meta && typeof body.meta === 'object' && 'workdir' in body.meta) {
|
|
6286
|
+
const wdCheck = shared.validateWorkItemWorkdir(body.meta.workdir);
|
|
6287
|
+
if (!wdCheck.valid) return jsonReply(res, 400, { error: `meta.workdir: ${wdCheck.error}` });
|
|
6288
|
+
workdirUpdate = wdCheck.value;
|
|
6289
|
+
}
|
|
5863
6290
|
|
|
5864
6291
|
const target = resolveProjectSourceTarget(source, PROJECTS);
|
|
5865
6292
|
if (target.error) return jsonReply(res, 404, { error: target.error });
|
|
@@ -5886,6 +6313,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
5886
6313
|
if (Array.isArray(body.depends_on)) {
|
|
5887
6314
|
item.depends_on = body.depends_on.map(s => s.trim()).filter(Boolean);
|
|
5888
6315
|
}
|
|
6316
|
+
if (workdirUpdate !== undefined) {
|
|
6317
|
+
// P-714ef144 — apply the workdir update. null clears the field
|
|
6318
|
+
// (and the meta object if it becomes empty after the delete);
|
|
6319
|
+
// a string value writes onto item.meta.workdir creating meta if
|
|
6320
|
+
// it doesn't already exist.
|
|
6321
|
+
if (workdirUpdate === null) {
|
|
6322
|
+
if (item.meta && typeof item.meta === 'object') {
|
|
6323
|
+
delete item.meta.workdir;
|
|
6324
|
+
if (Object.keys(item.meta).length === 0) delete item.meta;
|
|
6325
|
+
}
|
|
6326
|
+
} else {
|
|
6327
|
+
if (!item.meta || typeof item.meta !== 'object') item.meta = {};
|
|
6328
|
+
item.meta.workdir = workdirUpdate;
|
|
6329
|
+
}
|
|
6330
|
+
}
|
|
5889
6331
|
item.updatedAt = new Date().toISOString();
|
|
5890
6332
|
result = { code: 200, body: { ok: true, item } };
|
|
5891
6333
|
return items;
|
|
@@ -7954,6 +8396,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7954
8396
|
return;
|
|
7955
8397
|
}
|
|
7956
8398
|
|
|
8399
|
+
async function handleHarnessDiagnostics(req, res) {
|
|
8400
|
+
try {
|
|
8401
|
+
const out = _buildHarnessDiagnostics();
|
|
8402
|
+
return jsonReply(res, 200, out, req);
|
|
8403
|
+
} catch (e) {
|
|
8404
|
+
return jsonReply(res, 500, { error: e && e.message ? e.message : String(e) }, req);
|
|
8405
|
+
}
|
|
8406
|
+
}
|
|
8407
|
+
|
|
7957
8408
|
async function handleProjectsBrowse(req, res) {
|
|
7958
8409
|
try {
|
|
7959
8410
|
// Async child_process helpers — the picker dialog can sit on screen
|
|
@@ -9548,6 +9999,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9548
9999
|
for (const key of Object.keys(childEnv)) {
|
|
9549
10000
|
if (key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE') || key.startsWith('CLAUDECODE_')) delete childEnv[key];
|
|
9550
10001
|
}
|
|
10002
|
+
// W-mqb9y83o — suppress auto-open in the respawned dashboard. The
|
|
10003
|
+
// restart endpoint is fired by CC or the dashboard UI; the operator
|
|
10004
|
+
// already has a tab open (that's how they hit the button), so the
|
|
10005
|
+
// post-restart hook must not pop a second one. Hard kill-switch read
|
|
10006
|
+
// by bin/minions.js#spawnFullStackAndVerify.
|
|
10007
|
+
childEnv.MINIONS_NO_AUTO_OPEN = '1';
|
|
9551
10008
|
const proc = cpSpawn(process.execPath, [minionsBin, 'restart'], {
|
|
9552
10009
|
cwd: MINIONS_DIR, stdio: 'ignore', detached: true, env: childEnv, windowsHide: true,
|
|
9553
10010
|
});
|
|
@@ -10121,6 +10578,157 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10121
10578
|
} catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
10122
10579
|
}
|
|
10123
10580
|
|
|
10581
|
+
// P-c3d4e5f6 — /api/diagnostics/memory.
|
|
10582
|
+
// Returns the latest in-process dashboard sample, the latest engine
|
|
10583
|
+
// sample (read fresh from engine/diagnostics-memory.json via safeJsonObj),
|
|
10584
|
+
// and an engineStale boolean. Engine staleness is true when the sidecar
|
|
10585
|
+
// is missing/unparseable OR its capturedAt is > 5 min old. The handler
|
|
10586
|
+
// does one small JSON read and one in-process sample — < 10ms warm.
|
|
10587
|
+
function handleDiagnosticsMemory(req, res) {
|
|
10588
|
+
try {
|
|
10589
|
+
let dashboardSample = null;
|
|
10590
|
+
try { dashboardSample = diagnosticsMemory.sampleSelf({ label: 'dashboard' }); }
|
|
10591
|
+
catch { /* leave dashboardSample null on sampler failure */ }
|
|
10592
|
+
const engineSample = safeJsonObj(DIAGNOSTICS_MEMORY_SIDECAR_PATH);
|
|
10593
|
+
const payload = _buildMemoryDiagnostics({
|
|
10594
|
+
dashboardSample,
|
|
10595
|
+
engineSample,
|
|
10596
|
+
now: Date.now(),
|
|
10597
|
+
});
|
|
10598
|
+
return jsonReply(res, 200, payload, req);
|
|
10599
|
+
} catch (e) {
|
|
10600
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
10601
|
+
}
|
|
10602
|
+
}
|
|
10603
|
+
|
|
10604
|
+
// P-c3d4e5f6 — /api/diagnostics/memory/history?process=engine|dashboard&limit=N.
|
|
10605
|
+
// process=dashboard returns the diagnosticsMemory module's own ring
|
|
10606
|
+
// buffer (populated by startPeriodicSampling on dashboard boot).
|
|
10607
|
+
// process=engine returns the dashboard's accumulated polled snapshots
|
|
10608
|
+
// of engine/diagnostics-memory.json — engine.js only persists the
|
|
10609
|
+
// latest sample to the sidecar, so engine-side history is rebuilt by
|
|
10610
|
+
// the dashboard's poller (dedup by capturedAt). Limit defaults to the
|
|
10611
|
+
// full buffer when missing / non-positive.
|
|
10612
|
+
function handleDiagnosticsMemoryHistory(req, res) {
|
|
10613
|
+
try {
|
|
10614
|
+
const u = new URL(req.url, 'http://x');
|
|
10615
|
+
const proc = (u.searchParams.get('process') || '').trim().toLowerCase();
|
|
10616
|
+
const limitRaw = u.searchParams.get('limit');
|
|
10617
|
+
const limitParsed = limitRaw == null || limitRaw === '' ? null : parseInt(limitRaw, 10);
|
|
10618
|
+
const limit = Number.isInteger(limitParsed) && limitParsed > 0 ? limitParsed : null;
|
|
10619
|
+
let samples;
|
|
10620
|
+
if (proc === 'dashboard') {
|
|
10621
|
+
samples = diagnosticsMemory.getHistory(limit ? { limit } : {});
|
|
10622
|
+
} else if (proc === 'engine') {
|
|
10623
|
+
samples = _buildMemoryHistory({ ring: _engineMemoryRing, limit });
|
|
10624
|
+
} else {
|
|
10625
|
+
return jsonReply(res, 400, { error: 'process must be one of: engine, dashboard' }, req);
|
|
10626
|
+
}
|
|
10627
|
+
return jsonReply(res, 200, { process: proc, count: samples.length, samples }, req);
|
|
10628
|
+
} catch (e) {
|
|
10629
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
10630
|
+
}
|
|
10631
|
+
}
|
|
10632
|
+
|
|
10633
|
+
// P-e5f6a7b8 — POST /api/diagnostics/heap-snapshot.
|
|
10634
|
+
// Guarded by a literal confirm token + a 60s rate limit. On a valid
|
|
10635
|
+
// request the handler synchronously calls v8.writeHeapSnapshot for the
|
|
10636
|
+
// dashboard process (stalls THIS process for seconds, writes 50–200 MB),
|
|
10637
|
+
// then drops a sentinel JSON in engine/diagnostics/ that engine.js
|
|
10638
|
+
// consumes on its next tick to capture the engine-side snapshot. The
|
|
10639
|
+
// handler polls for the engine snapshot for up to 30 s (sentinel
|
|
10640
|
+
// removal + new file appearance) before returning. Retains the 5
|
|
10641
|
+
// most-recent snapshots per process; oldest are auto-pruned on every
|
|
10642
|
+
// successful capture.
|
|
10643
|
+
async function handleDiagnosticsHeapSnapshot(req, res) {
|
|
10644
|
+
try {
|
|
10645
|
+
const u = new URL(req.url, 'http://x');
|
|
10646
|
+
const confirm = (u.searchParams.get('confirm') || '').trim();
|
|
10647
|
+
if (confirm !== HEAP_SNAPSHOT_CONFIRM_TOKEN) {
|
|
10648
|
+
return jsonReply(res, 400, {
|
|
10649
|
+
error: 'confirm token required',
|
|
10650
|
+
hint:
|
|
10651
|
+
`POST again with ?confirm=${HEAP_SNAPSHOT_CONFIRM_TOKEN}. ` +
|
|
10652
|
+
`WARNING: capturing a heap snapshot stalls each process for several ` +
|
|
10653
|
+
`seconds and writes a 50–200 MB file per process under engine/diagnostics/.`,
|
|
10654
|
+
}, req);
|
|
10655
|
+
}
|
|
10656
|
+
const now = Date.now();
|
|
10657
|
+
if (_heapSnapshotLastCapturedAt && (now - _heapSnapshotLastCapturedAt) < HEAP_SNAPSHOT_RATE_LIMIT_MS) {
|
|
10658
|
+
const retryAfter = Math.ceil((HEAP_SNAPSHOT_RATE_LIMIT_MS - (now - _heapSnapshotLastCapturedAt)) / 1000);
|
|
10659
|
+
res.setHeader('Retry-After', String(Math.max(1, retryAfter)));
|
|
10660
|
+
return jsonReply(res, 429, {
|
|
10661
|
+
error: `rate limit: at most 1 successful heap snapshot per ${HEAP_SNAPSHOT_RATE_LIMIT_MS / 1000}s`,
|
|
10662
|
+
retryAfterSeconds: Math.max(1, retryAfter),
|
|
10663
|
+
}, req);
|
|
10664
|
+
}
|
|
10665
|
+
|
|
10666
|
+
try { fs.mkdirSync(DIAGNOSTICS_DIR, { recursive: true }); }
|
|
10667
|
+
catch (e) { return jsonReply(res, 500, { error: `mkdir diagnostics dir failed: ${e.message}` }, req); }
|
|
10668
|
+
|
|
10669
|
+
const iso = new Date().toISOString();
|
|
10670
|
+
const dashboardSnapshotPath = _heapSnapshotPathFor('dashboard', iso);
|
|
10671
|
+
const expectedEnginePath = _heapSnapshotPathFor('engine', iso);
|
|
10672
|
+
|
|
10673
|
+
// Dashboard side — synchronous; this stalls the event loop for seconds.
|
|
10674
|
+
try { v8.writeHeapSnapshot(dashboardSnapshotPath); }
|
|
10675
|
+
catch (e) { return jsonReply(res, 500, { error: `dashboard writeHeapSnapshot failed: ${e.message}` }, req); }
|
|
10676
|
+
|
|
10677
|
+
// Sentinel for engine.js. Engine reads its own ISO from the sentinel
|
|
10678
|
+
// so the engine snapshot lands at the predictable predicted path.
|
|
10679
|
+
try {
|
|
10680
|
+
fs.writeFileSync(HEAP_SNAPSHOT_REQUEST_PATH, JSON.stringify({ requestedAt: iso }));
|
|
10681
|
+
} catch (e) {
|
|
10682
|
+
return jsonReply(res, 500, { error: `sentinel write failed: ${e.message}` }, req);
|
|
10683
|
+
}
|
|
10684
|
+
|
|
10685
|
+
// Poll for engine snapshot — sentinel removed AND the predicted file
|
|
10686
|
+
// exists. Fall back to the newest engine snapshot if the predicted
|
|
10687
|
+
// path isn't there (engine wrote a different ISO somehow).
|
|
10688
|
+
const deadline = Date.now() + HEAP_SNAPSHOT_ENGINE_TIMEOUT_MS;
|
|
10689
|
+
let engineSnapshotPath = null;
|
|
10690
|
+
while (Date.now() < deadline) {
|
|
10691
|
+
let sentinelGone = false;
|
|
10692
|
+
try { sentinelGone = !fs.existsSync(HEAP_SNAPSHOT_REQUEST_PATH); } catch { sentinelGone = false; }
|
|
10693
|
+
if (sentinelGone) {
|
|
10694
|
+
if (fs.existsSync(expectedEnginePath)) {
|
|
10695
|
+
engineSnapshotPath = expectedEnginePath;
|
|
10696
|
+
break;
|
|
10697
|
+
}
|
|
10698
|
+
try {
|
|
10699
|
+
const newest = fs.readdirSync(DIAGNOSTICS_DIR)
|
|
10700
|
+
.filter(n => n.startsWith('heap-engine-') && n.endsWith('.heapsnapshot'))
|
|
10701
|
+
.map(n => ({ n, m: (() => { try { return fs.statSync(path.join(DIAGNOSTICS_DIR, n)).mtimeMs; } catch { return 0; } })() }))
|
|
10702
|
+
.sort((a, b) => b.m - a.m)[0];
|
|
10703
|
+
if (newest) {
|
|
10704
|
+
engineSnapshotPath = path.join(DIAGNOSTICS_DIR, newest.n);
|
|
10705
|
+
break;
|
|
10706
|
+
}
|
|
10707
|
+
} catch { /* directory race */ }
|
|
10708
|
+
}
|
|
10709
|
+
await new Promise(r => setTimeout(r, HEAP_SNAPSHOT_ENGINE_POLL_MS));
|
|
10710
|
+
}
|
|
10711
|
+
const engineTimedOut = !engineSnapshotPath;
|
|
10712
|
+
|
|
10713
|
+
// Stamp the rate-limit window on completion, regardless of engine
|
|
10714
|
+
// timeout — the operator already paid the dashboard-stall cost.
|
|
10715
|
+
_heapSnapshotLastCapturedAt = Date.now();
|
|
10716
|
+
|
|
10717
|
+
// Prune oldest snapshots per process down to the retention cap.
|
|
10718
|
+
try { _heapSnapshotPrune(DIAGNOSTICS_DIR, 'dashboard', HEAP_SNAPSHOT_RETAIN_COUNT); } catch { /* best effort */ }
|
|
10719
|
+
try { _heapSnapshotPrune(DIAGNOSTICS_DIR, 'engine', HEAP_SNAPSHOT_RETAIN_COUNT); } catch { /* best effort */ }
|
|
10720
|
+
|
|
10721
|
+
return jsonReply(res, 200, {
|
|
10722
|
+
dashboardSnapshot: path.resolve(dashboardSnapshotPath),
|
|
10723
|
+
engineSnapshot: engineSnapshotPath ? path.resolve(engineSnapshotPath) : null,
|
|
10724
|
+
engineTimedOut,
|
|
10725
|
+
timeoutMs: HEAP_SNAPSHOT_ENGINE_TIMEOUT_MS,
|
|
10726
|
+
}, req);
|
|
10727
|
+
} catch (e) {
|
|
10728
|
+
return jsonReply(res, e.statusCode || 500, { error: e.message }, req);
|
|
10729
|
+
}
|
|
10730
|
+
}
|
|
10731
|
+
|
|
10124
10732
|
// Slim UX surface for the experimental redesigned dashboard.
|
|
10125
10733
|
// The markup/CSS/JS live as fragments under dashboard/slim/ (layout.html +
|
|
10126
10734
|
// styles.css + body.html + js/*.js) and are assembled by buildSlimHtml() —
|
|
@@ -11623,8 +12231,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11623
12231
|
{ method: 'POST', path: '/api/qa/runners/reload', desc: 'Clear the in-process runner registry, re-register built-ins, and re-scan qa-runners.d/ for plugin edits.', handler: handleQaRunnersReload },
|
|
11624
12232
|
|
|
11625
12233
|
// Work items
|
|
11626
|
-
{ method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?, oneShot?, meta?, meta.pr_followup?, X-Minions-Agent?, X-Minions-Origin-Wi?', handler: handleWorkItemsCreate },
|
|
11627
|
-
{ method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?', handler: handleWorkItemsUpdate },
|
|
12234
|
+
{ method: 'POST', path: '/api/work-items', desc: 'Create a new work item', params: 'title, type?, description?, priority?, project?, agent?, agents?, scope?, references?, acceptanceCriteria?, skipPr?, oneShot?, meta?, meta.pr_followup?, meta.workdir?, X-Minions-Agent?, X-Minions-Origin-Wi?', handler: handleWorkItemsCreate },
|
|
12235
|
+
{ method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?, depends_on?, workdir?, meta?', handler: handleWorkItemsUpdate },
|
|
11628
12236
|
{ method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
|
|
11629
12237
|
{ method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
|
|
11630
12238
|
{ method: 'POST', path: '/api/work-items/cancel', desc: 'Cancel a work item, kill agent, clear dispatch', params: 'id, source?, reason?', handler: handleWorkItemsCancel },
|
|
@@ -12198,6 +12806,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12198
12806
|
// Skills
|
|
12199
12807
|
{ method: 'GET', path: '/api/skill', desc: 'Read a skill file', params: 'file, source?, dir?', handler: handleSkillRead },
|
|
12200
12808
|
|
|
12809
|
+
// Harness propagation diagnostic (P-c601f9a2)
|
|
12810
|
+
{ method: 'GET', path: '/api/harness/diagnostics', desc: 'Per-runtime harness diagnostic (add-dirs, suppressed flags, project-local-on-main footgun, missing dirs)', handler: handleHarnessDiagnostics },
|
|
12811
|
+
|
|
12201
12812
|
// Projects
|
|
12202
12813
|
{ method: 'POST', path: '/api/projects/browse', desc: 'Open folder picker dialog, return selected path', handler: handleProjectsBrowse },
|
|
12203
12814
|
{ method: 'POST', path: '/api/projects/scan', desc: 'Scan a directory for git repos', params: 'path?, depth?', handler: handleProjectsScan },
|
|
@@ -12497,6 +13108,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12497
13108
|
{ method: 'POST', path: '/api/diagnostics/refresh', desc: 'Append a dashboard refresh-diagnostic ring buffer batch to engine/dashboard-diagnostics.log (rotated at 1 MB)', params: 'entries[]', handler: handleDiagnosticsRefresh },
|
|
12498
13109
|
// Diagnostics — per-org ADO throttle state (W-mq03l6zh0006f0a1-d).
|
|
12499
13110
|
{ method: 'GET', path: '/api/diagnostics/ado-throttle', desc: 'Snapshot of per-org ADO throttle tracker state — { orgs: { [orgBase]: { throttled, retryAfter, consecutiveHits } } }. Falls back to a single `global` key when running against pre-per-org engines.', handler: handleDiagnosticsAdoThrottle },
|
|
13111
|
+
// Diagnostics — engine + dashboard memory baseline (P-c3d4e5f6).
|
|
13112
|
+
{ method: 'GET', path: '/api/diagnostics/memory', desc: 'Latest in-process dashboard memory sample plus the most-recent engine sample read from engine/diagnostics-memory.json. engineStale=true when the sidecar is missing or its capturedAt is > 5 min old.', handler: handleDiagnosticsMemory },
|
|
13113
|
+
{ method: 'GET', path: '/api/diagnostics/memory/history', desc: 'In-memory ring buffer of memory samples. process=dashboard returns the dashboard\'s own collector (populated by startPeriodicSampling on boot). process=engine returns the dashboard\'s polled accumulation of engine/diagnostics-memory.json — engine.js only persists the latest sample to the sidecar, so engine-side history is rebuilt by the dashboard poller (dedup by capturedAt). Optional limit caps returned newest-N samples.', params: 'process (engine|dashboard), limit?', handler: handleDiagnosticsMemoryHistory },
|
|
13114
|
+
// Diagnostics — operator-driven heap snapshot capture (P-e5f6a7b8).
|
|
13115
|
+
{ method: 'POST', path: '/api/diagnostics/heap-snapshot', desc: 'Capture v8 heap snapshots for both the dashboard and engine processes. Requires ?confirm=YES_I_UNDERSTAND_THIS_STALLS_THE_ENGINE because v8.writeHeapSnapshot stalls each process for several seconds and writes a 50–200 MB file. Rate-limited to 1 successful call per 60s; retains the 5 most-recent snapshots per process under engine/diagnostics/.', params: 'confirm=YES_I_UNDERSTAND_THIS_STALLS_THE_ENGINE', handler: handleDiagnosticsHeapSnapshot },
|
|
12500
13116
|
];
|
|
12501
13117
|
|
|
12502
13118
|
// ── Route Dispatcher ────────────────────────────────────────────────────────
|
|
@@ -12654,6 +13270,7 @@ module.exports = {
|
|
|
12654
13270
|
_linkPullRequestForTracking: linkPullRequestForTracking,
|
|
12655
13271
|
_updatePullRequestObserveFlag: updatePullRequestObserveFlag,
|
|
12656
13272
|
_resolveSkillReadPath,
|
|
13273
|
+
_buildHarnessDiagnostics,
|
|
12657
13274
|
// exported for testing — see test/unit/plans-archive-warnings.test.js
|
|
12658
13275
|
_archivePrdPostProcess,
|
|
12659
13276
|
// Per-CC-turn correlation surface
|
|
@@ -12695,6 +13312,11 @@ module.exports = {
|
|
|
12695
13312
|
refreshStatusAsync,
|
|
12696
13313
|
handleStatus: _handleStatusRequest,
|
|
12697
13314
|
invalidateStatusCache,
|
|
13315
|
+
// exported for testing — see test/unit/status-snapshot-budget.test.js (P-f2a3b4c5).
|
|
13316
|
+
// The slim snapshot builder is the synchronous assembler used by getStatusJson()
|
|
13317
|
+
// and refreshStatusAsync(); the budget test calls it directly to measure byte
|
|
13318
|
+
// size and rebuild-time without standing up an HTTP server.
|
|
13319
|
+
getStatus,
|
|
12698
13320
|
// Raw state-file passthrough — exported for direct unit testing.
|
|
12699
13321
|
handleStateRead,
|
|
12700
13322
|
STATE_READ_ALLOWED_DIRS,
|
|
@@ -12717,6 +13339,27 @@ module.exports = {
|
|
|
12717
13339
|
// route's `builder` closure (getWorkItems().map(slimWorkItemForList)).
|
|
12718
13340
|
_slimWorkItemForList: slimWorkItemForList,
|
|
12719
13341
|
_WORK_ITEMS_SLIM_DESCRIPTION_CAP: WORK_ITEMS_SLIM_DESCRIPTION_CAP,
|
|
13342
|
+
// P-c3d4e5f6 — diagnostics-memory dashboard surface (handlers live in
|
|
13343
|
+
// the request-dispatch closure; expose the pure builders + constants so
|
|
13344
|
+
// unit tests can exercise the staleness gate and history slicing
|
|
13345
|
+
// without binding a server.)
|
|
13346
|
+
_buildMemoryDiagnostics,
|
|
13347
|
+
_buildMemoryHistory,
|
|
13348
|
+
_pollAndAccumulateEngineSample,
|
|
13349
|
+
_resetDiagnosticsMemoryForTesting,
|
|
13350
|
+
DIAGNOSTICS_MEMORY_SIDECAR_PATH,
|
|
13351
|
+
DIAGNOSTICS_MEMORY_STALE_MS,
|
|
13352
|
+
DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS,
|
|
13353
|
+
DIAGNOSTICS_MEMORY_ENGINE_RING_CAP,
|
|
13354
|
+
// P-e5f6a7b8 — heap snapshot helpers exposed for direct unit testing.
|
|
13355
|
+
_heapSnapshotPathFor,
|
|
13356
|
+
_heapSnapshotPrune,
|
|
13357
|
+
_resetHeapSnapshotRateLimitForTesting,
|
|
13358
|
+
HEAP_SNAPSHOT_REQUEST_PATH,
|
|
13359
|
+
HEAP_SNAPSHOT_CONFIRM_TOKEN,
|
|
13360
|
+
HEAP_SNAPSHOT_RATE_LIMIT_MS,
|
|
13361
|
+
HEAP_SNAPSHOT_ENGINE_TIMEOUT_MS,
|
|
13362
|
+
HEAP_SNAPSHOT_RETAIN_COUNT,
|
|
12720
13363
|
};
|
|
12721
13364
|
|
|
12722
13365
|
// Start the HTTP server only when run directly (node dashboard.js).
|
|
@@ -12807,15 +13450,19 @@ if (require.main === module) {
|
|
|
12807
13450
|
Promise.resolve(queries.getKnowledgeBaseEntries())
|
|
12808
13451
|
.catch(err => console.warn(`[dashboard] KB cache warm failed: ${err && err.message}`));
|
|
12809
13452
|
|
|
12810
|
-
// Auto-open the browser
|
|
12811
|
-
//
|
|
12812
|
-
//
|
|
12813
|
-
|
|
12814
|
-
|
|
12815
|
-
|
|
12816
|
-
|
|
12817
|
-
|
|
12818
|
-
|
|
13453
|
+
// Auto-open the browser. `minions restart` and the upgrade path set
|
|
13454
|
+
// MINIONS_NO_AUTO_OPEN=1 because the CLI orchestrates the open itself
|
|
13455
|
+
// after observing whether an existing tab reconnected; the primitive
|
|
13456
|
+
// (engine/shared.js#openUrlInBrowser) now owns the env-var check and
|
|
13457
|
+
// emits a debug-level SUPPRESSED log entry so we can prove the kill-
|
|
13458
|
+
// switch is firing.
|
|
13459
|
+
const result = shared.openUrlInBrowser(`http://localhost:${PORT}`, {
|
|
13460
|
+
reason: 'dashboard-self-open',
|
|
13461
|
+
callerHint: 'dashboard.js:13124',
|
|
13462
|
+
});
|
|
13463
|
+
if (!result.ok && !result.suppressed) {
|
|
13464
|
+
console.log(` Could not auto-open browser: ${result.error}`);
|
|
13465
|
+
console.log(` Please open http://localhost:${PORT} manually.`);
|
|
12819
13466
|
}
|
|
12820
13467
|
|
|
12821
13468
|
// Warm the CC runtime binary cache off the request path so the first CC /
|
|
@@ -12871,12 +13518,39 @@ if (require.main === module) {
|
|
|
12871
13518
|
}
|
|
12872
13519
|
}, 30000).unref();
|
|
12873
13520
|
console.log(` Engine watchdog: active (checks every 30s)`);
|
|
13521
|
+
|
|
13522
|
+
// ─── Diagnostics: dashboard memory sampler (P-c3d4e5f6) ─────────────────
|
|
13523
|
+
// Drive the diagnosticsMemory ring buffer for /api/diagnostics/memory and
|
|
13524
|
+
// /api/diagnostics/memory/history?process=dashboard. The internal
|
|
13525
|
+
// setInterval already calls recordSample for us; the onSample callback
|
|
13526
|
+
// doubles as the engine-sidecar poller so /…/history?process=engine
|
|
13527
|
+
// accumulates one entry per fresh engine MEMORY_BASELINE write
|
|
13528
|
+
// (deduplicated by capturedAt).
|
|
13529
|
+
try {
|
|
13530
|
+
_memorySamplerStop = diagnosticsMemory.startPeriodicSampling({
|
|
13531
|
+
intervalMs: DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS,
|
|
13532
|
+
onSample: () => { try { _pollAndAccumulateEngineSample(); } catch { /* swallow */ } },
|
|
13533
|
+
});
|
|
13534
|
+
// Seed the engine ring immediately so the first call to
|
|
13535
|
+
// /api/diagnostics/memory/history?process=engine right after boot
|
|
13536
|
+
// already returns the latest sidecar sample without waiting a full
|
|
13537
|
+
// sampling interval.
|
|
13538
|
+
try { _pollAndAccumulateEngineSample(); } catch { /* swallow */ }
|
|
13539
|
+
} catch (e) {
|
|
13540
|
+
console.warn(`[dashboard] memory sampler failed to start: ${e && e.message}`);
|
|
13541
|
+
}
|
|
12874
13542
|
})();
|
|
12875
13543
|
|
|
12876
13544
|
// ── Graceful shutdown: flush debounced writes + clear runtime port file ──
|
|
12877
13545
|
function _gracefulShutdown() {
|
|
12878
13546
|
try { flushPendingDocSessions(); } catch {}
|
|
12879
13547
|
try { shared.clearDashboardPortFile(MINIONS_DIR); } catch {}
|
|
13548
|
+
// P-c3d4e5f6 — stop the diagnostics-memory sampler cleanly so the
|
|
13549
|
+
// setInterval handle doesn't keep the event loop alive on SIGTERM.
|
|
13550
|
+
if (_memorySamplerStop) {
|
|
13551
|
+
try { _memorySamplerStop(); } catch { /* swallow */ }
|
|
13552
|
+
_memorySamplerStop = null;
|
|
13553
|
+
}
|
|
12880
13554
|
}
|
|
12881
13555
|
server.on('close', () => _gracefulShutdown());
|
|
12882
13556
|
process.on('SIGTERM', () => { _gracefulShutdown(); process.exit(0); });
|