@yemi33/minions 0.1.2179 → 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 +15 -6
- package/dashboard/js/memory-panel.js +62 -0
- package/dashboard/js/refresh.js +18 -0
- package/dashboard/js/render-other.js +142 -1
- package/dashboard/js/render-work-items.js +18 -1
- package/dashboard/js/settings.js +23 -0
- package/dashboard/pages/engine-memory-panel.html +7 -0
- package/dashboard/pages/tools.html +8 -0
- package/dashboard.js +466 -3
- 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/preflight.js +265 -0
- package/engine/queries.js +192 -15
- 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 +277 -13
- package/engine/spawn-agent.js +178 -12
- package/engine.js +232 -3
- package/package.json +1 -1
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
|
|
|
@@ -48,7 +49,7 @@ const os = require('os');
|
|
|
48
49
|
|
|
49
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;
|
|
50
51
|
const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
|
|
51
|
-
getSkills, getInbox, getNotesWithMeta, getPullRequests,
|
|
52
|
+
getSkills, getCommands, getInbox, getNotesWithMeta, getPullRequests,
|
|
52
53
|
getEngineLog, getMetrics, getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getProjectGitStatus, timeSince,
|
|
53
54
|
MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, DISPATCH_PATH, PRD_DIR } = queries;
|
|
54
55
|
|
|
@@ -174,6 +175,53 @@ function _resetDiagnosticsMemoryForTesting() {
|
|
|
174
175
|
try { diagnosticsMemory._resetForTest(); } catch { /* ignore */ }
|
|
175
176
|
}
|
|
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
|
+
|
|
177
225
|
function ensureConfiguredProjectStateFiles() {
|
|
178
226
|
for (const p of PROJECTS) {
|
|
179
227
|
const root = p.localPath ? path.resolve(p.localPath) : null;
|
|
@@ -1133,6 +1181,251 @@ function _resolveSkillReadPath({ file, dir, source, config, skillFiles } = {}) {
|
|
|
1133
1181
|
return null;
|
|
1134
1182
|
}
|
|
1135
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
|
+
|
|
1136
1429
|
function _agentSessionIsDraining(agentId) {
|
|
1137
1430
|
const activeForAgent = (getDispatchQueue().active || []).some(d => d.agent === agentId);
|
|
1138
1431
|
if (!activeForAgent) return false;
|
|
@@ -2040,6 +2333,7 @@ function _buildStatusSlowState() {
|
|
|
2040
2333
|
// initialized + installId, and the version banner.
|
|
2041
2334
|
return {
|
|
2042
2335
|
skills: getSkills(),
|
|
2336
|
+
commands: getCommands(CONFIG),
|
|
2043
2337
|
mcpServers: getMcpServers(),
|
|
2044
2338
|
projects: PROJECTS.map(p => {
|
|
2045
2339
|
const status = getProjectGitStatus(p.localPath, p.mainBranch);
|
|
@@ -5877,11 +6171,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
5877
6171
|
if (!followupCheck.valid) {
|
|
5878
6172
|
return jsonReply(res, 400, { error: followupCheck.error });
|
|
5879
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
|
+
}
|
|
5880
6182
|
item.meta = { ...body.meta };
|
|
5881
6183
|
if (followupCheck.value) {
|
|
5882
6184
|
item.meta.pr_followup = followupCheck.value;
|
|
5883
6185
|
validatedFollowup = followupCheck.value;
|
|
5884
6186
|
}
|
|
6187
|
+
if (workdirCheck.value === null) {
|
|
6188
|
+
delete item.meta.workdir;
|
|
6189
|
+
} else {
|
|
6190
|
+
item.meta.workdir = workdirCheck.value;
|
|
6191
|
+
}
|
|
5885
6192
|
}
|
|
5886
6193
|
// PR follow-up traceability headers (W-mpej3cox00099466).
|
|
5887
6194
|
const originAgent = extractMinionsAgentHeader(req);
|
|
@@ -5962,6 +6269,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
5962
6269
|
if (!Array.isArray(body.depends_on)) return jsonReply(res, 400, { error: 'depends_on must be an array of strings' });
|
|
5963
6270
|
if (!body.depends_on.every(s => typeof s === 'string')) return jsonReply(res, 400, { error: 'depends_on entries must be strings' });
|
|
5964
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
|
+
}
|
|
5965
6290
|
|
|
5966
6291
|
const target = resolveProjectSourceTarget(source, PROJECTS);
|
|
5967
6292
|
if (target.error) return jsonReply(res, 404, { error: target.error });
|
|
@@ -5988,6 +6313,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
5988
6313
|
if (Array.isArray(body.depends_on)) {
|
|
5989
6314
|
item.depends_on = body.depends_on.map(s => s.trim()).filter(Boolean);
|
|
5990
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
|
+
}
|
|
5991
6331
|
item.updatedAt = new Date().toISOString();
|
|
5992
6332
|
result = { code: 200, body: { ok: true, item } };
|
|
5993
6333
|
return items;
|
|
@@ -8056,6 +8396,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8056
8396
|
return;
|
|
8057
8397
|
}
|
|
8058
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
|
+
|
|
8059
8408
|
async function handleProjectsBrowse(req, res) {
|
|
8060
8409
|
try {
|
|
8061
8410
|
// Async child_process helpers — the picker dialog can sit on screen
|
|
@@ -10281,6 +10630,105 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10281
10630
|
}
|
|
10282
10631
|
}
|
|
10283
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
|
+
|
|
10284
10732
|
// Slim UX surface for the experimental redesigned dashboard.
|
|
10285
10733
|
// The markup/CSS/JS live as fragments under dashboard/slim/ (layout.html +
|
|
10286
10734
|
// styles.css + body.html + js/*.js) and are assembled by buildSlimHtml() —
|
|
@@ -11783,8 +12231,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11783
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 },
|
|
11784
12232
|
|
|
11785
12233
|
// Work items
|
|
11786
|
-
{ 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 },
|
|
11787
|
-
{ 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 },
|
|
11788
12236
|
{ method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
|
|
11789
12237
|
{ method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
|
|
11790
12238
|
{ method: 'POST', path: '/api/work-items/cancel', desc: 'Cancel a work item, kill agent, clear dispatch', params: 'id, source?, reason?', handler: handleWorkItemsCancel },
|
|
@@ -12358,6 +12806,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12358
12806
|
// Skills
|
|
12359
12807
|
{ method: 'GET', path: '/api/skill', desc: 'Read a skill file', params: 'file, source?, dir?', handler: handleSkillRead },
|
|
12360
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
|
+
|
|
12361
12812
|
// Projects
|
|
12362
12813
|
{ method: 'POST', path: '/api/projects/browse', desc: 'Open folder picker dialog, return selected path', handler: handleProjectsBrowse },
|
|
12363
12814
|
{ method: 'POST', path: '/api/projects/scan', desc: 'Scan a directory for git repos', params: 'path?, depth?', handler: handleProjectsScan },
|
|
@@ -12660,6 +13111,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12660
13111
|
// Diagnostics — engine + dashboard memory baseline (P-c3d4e5f6).
|
|
12661
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 },
|
|
12662
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 },
|
|
12663
13116
|
];
|
|
12664
13117
|
|
|
12665
13118
|
// ── Route Dispatcher ────────────────────────────────────────────────────────
|
|
@@ -12817,6 +13270,7 @@ module.exports = {
|
|
|
12817
13270
|
_linkPullRequestForTracking: linkPullRequestForTracking,
|
|
12818
13271
|
_updatePullRequestObserveFlag: updatePullRequestObserveFlag,
|
|
12819
13272
|
_resolveSkillReadPath,
|
|
13273
|
+
_buildHarnessDiagnostics,
|
|
12820
13274
|
// exported for testing — see test/unit/plans-archive-warnings.test.js
|
|
12821
13275
|
_archivePrdPostProcess,
|
|
12822
13276
|
// Per-CC-turn correlation surface
|
|
@@ -12897,6 +13351,15 @@ module.exports = {
|
|
|
12897
13351
|
DIAGNOSTICS_MEMORY_STALE_MS,
|
|
12898
13352
|
DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS,
|
|
12899
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,
|
|
12900
13363
|
};
|
|
12901
13364
|
|
|
12902
13365
|
// Start the HTTP server only when run directly (node dashboard.js).
|