@yemi33/minions 0.1.2178 → 0.1.2179
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/minions.js +24 -11
- package/dashboard/js/command-parser.js +1 -1
- package/dashboard/js/memory-panel.js +262 -0
- package/dashboard/js/qa.js +2 -2
- package/dashboard/js/refresh.js +1 -1
- package/dashboard/js/render-other.js +1 -1
- 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/pages/engine-memory-panel.html +49 -0
- package/dashboard/pages/engine.html +1 -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 +227 -16
- package/docs/branch-derivation.md +13 -1
- package/engine/diagnostics-memory.js +190 -0
- package/engine/lifecycle.js +111 -1
- package/engine/queries.js +139 -4
- package/engine/shared.js +113 -2
- package/engine/watchdog.js +6 -0
- package/engine.js +45 -1
- package/package.json +2 -2
package/dashboard-build.js
CHANGED
|
@@ -21,9 +21,24 @@ function buildDashboardHtml() {
|
|
|
21
21
|
const css = safeRead(path.join(dashDir, 'styles.css'));
|
|
22
22
|
|
|
23
23
|
const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'qa', 'engine'];
|
|
24
|
+
// Sub-fragments substituted into a parent page at assembly time via
|
|
25
|
+
// <!-- __MARKER__ --> tokens. Lets large panels live in their own
|
|
26
|
+
// fragment file without inflating the parent page. P-d4e5f6a7 introduced
|
|
27
|
+
// engine-memory-panel.html as the first such sub-fragment; add more here
|
|
28
|
+
// by mapping marker -> fragment basename.
|
|
29
|
+
const pageSubFragments = {
|
|
30
|
+
engine: { '<!-- __ENGINE_MEMORY_PANEL__ -->': 'engine-memory-panel' },
|
|
31
|
+
};
|
|
24
32
|
let pageHtml = '';
|
|
25
33
|
for (const p of pages) {
|
|
26
|
-
|
|
34
|
+
let content = safeRead(path.join(dashDir, 'pages', p + '.html'));
|
|
35
|
+
const subs = pageSubFragments[p];
|
|
36
|
+
if (subs) {
|
|
37
|
+
for (const [marker, basename] of Object.entries(subs)) {
|
|
38
|
+
const fragment = safeRead(path.join(dashDir, 'pages', basename + '.html'));
|
|
39
|
+
content = content.replace(marker, () => fragment);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
27
42
|
const activeClass = p === 'home' ? ' active' : '';
|
|
28
43
|
pageHtml += ` <div class="page${activeClass}" id="page-${p}">\n${content}\n </div>\n\n`;
|
|
29
44
|
}
|
|
@@ -32,7 +47,7 @@ function buildDashboardHtml() {
|
|
|
32
47
|
'utils', 'state', 'features-client', 'render-utils', 'detail-panel', 'live-stream',
|
|
33
48
|
'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
|
|
34
49
|
'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
|
|
35
|
-
'render-other', 'render-managed', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
|
|
50
|
+
'render-other', 'render-managed', 'memory-panel', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
|
|
36
51
|
'command-parser', 'command-input', 'command-center', 'command-history',
|
|
37
52
|
'confirm-dialog', 'modal', 'modal-qa', 'settings', 'qa', 'fre', 'refresh'
|
|
38
53
|
];
|
package/dashboard.js
CHANGED
|
@@ -43,6 +43,7 @@ const steeringStore = require('./engine/steering-store');
|
|
|
43
43
|
const projectDiscovery = require('./engine/project-discovery');
|
|
44
44
|
const features = require('./engine/features');
|
|
45
45
|
const ccWorkerPool = require('./engine/cc-worker-pool');
|
|
46
|
+
const diagnosticsMemory = require('./engine/diagnostics-memory');
|
|
46
47
|
const os = require('os');
|
|
47
48
|
|
|
48
49
|
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;
|
|
@@ -104,6 +105,75 @@ const KB_PINS_PATH = shared.PINNED_ITEMS_PATH;
|
|
|
104
105
|
const DASHBOARD_BROWSER_PRESENCE_PATH = path.join(ENGINE_DIR, 'dashboard-browser.json');
|
|
105
106
|
const DASHBOARD_BROWSER_PRESENCE_MAX_AGE_MS = 45000;
|
|
106
107
|
|
|
108
|
+
// P-c3d4e5f6 — diagnostics-memory dashboard surface.
|
|
109
|
+
// The engine writes its latest sample to engine/diagnostics-memory.json on
|
|
110
|
+
// every memoryBaselineEveryTicks ticks; the dashboard reads it for
|
|
111
|
+
// /api/diagnostics/memory and accumulates polled engine samples into its
|
|
112
|
+
// own ring buffer (separate from diagnosticsMemory's own dashboard-self
|
|
113
|
+
// buffer) for /api/diagnostics/memory/history?process=engine. Both rings
|
|
114
|
+
// are in-process — restarting the dashboard zeroes them.
|
|
115
|
+
const DIAGNOSTICS_MEMORY_SIDECAR_PATH = path.join(ENGINE_DIR, 'diagnostics-memory.json');
|
|
116
|
+
const DIAGNOSTICS_MEMORY_STALE_MS = 5 * 60 * 1000;
|
|
117
|
+
const DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS = 60000;
|
|
118
|
+
const DIAGNOSTICS_MEMORY_ENGINE_RING_CAP = 1440;
|
|
119
|
+
const _engineMemoryRing = [];
|
|
120
|
+
let _lastEngineSampleCapturedAt = 0;
|
|
121
|
+
let _memorySamplerStop = null;
|
|
122
|
+
|
|
123
|
+
// Pure: assemble the /api/diagnostics/memory payload from already-read
|
|
124
|
+
// inputs. Exported for direct unit testing — handler callers feed it the
|
|
125
|
+
// live dashboard sample + the sidecar contents + Date.now().
|
|
126
|
+
function _buildMemoryDiagnostics({ dashboardSample, engineSample, now } = {}) {
|
|
127
|
+
const _now = Number.isFinite(now) ? now : Date.now();
|
|
128
|
+
const dashboard = dashboardSample && typeof dashboardSample === 'object' ? dashboardSample : null;
|
|
129
|
+
// engineSample may be the literal object from the sidecar OR null/{} when
|
|
130
|
+
// the sidecar is missing/unparseable. Treat anything without a finite
|
|
131
|
+
// capturedAt as stale (covers both "file missing" and "ancient sample").
|
|
132
|
+
let engine = null;
|
|
133
|
+
let engineStale = true;
|
|
134
|
+
if (engineSample && typeof engineSample === 'object' && Number.isFinite(engineSample.capturedAt)) {
|
|
135
|
+
engine = engineSample;
|
|
136
|
+
engineStale = (_now - engineSample.capturedAt) > DIAGNOSTICS_MEMORY_STALE_MS;
|
|
137
|
+
}
|
|
138
|
+
return { dashboard, engine, engineStale };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Pure: slice the requested ring buffer. Returns up to `limit` newest
|
|
142
|
+
// samples (oldest first). `limit` defaults to the full buffer when missing
|
|
143
|
+
// or non-positive (matches diagnosticsMemory.getHistory semantics).
|
|
144
|
+
function _buildMemoryHistory({ ring, limit } = {}) {
|
|
145
|
+
if (!Array.isArray(ring)) return [];
|
|
146
|
+
const n = ring.length;
|
|
147
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit >= n) return ring.slice();
|
|
148
|
+
return ring.slice(n - limit);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Read the engine sidecar and, when it carries a never-seen-before
|
|
152
|
+
// capturedAt, push it onto the engine-side ring buffer. Dedup is by
|
|
153
|
+
// capturedAt so re-reads between engine baseline ticks don't bloat the
|
|
154
|
+
// ring. Best-effort: any failure leaves the ring untouched.
|
|
155
|
+
function _pollAndAccumulateEngineSample() {
|
|
156
|
+
let sample;
|
|
157
|
+
try { sample = safeJsonObj(DIAGNOSTICS_MEMORY_SIDECAR_PATH); }
|
|
158
|
+
catch { return; }
|
|
159
|
+
if (!sample || typeof sample !== 'object') return;
|
|
160
|
+
if (!Number.isFinite(sample.capturedAt)) return;
|
|
161
|
+
if (sample.capturedAt === _lastEngineSampleCapturedAt) return;
|
|
162
|
+
_lastEngineSampleCapturedAt = sample.capturedAt;
|
|
163
|
+
_engineMemoryRing.push(sample);
|
|
164
|
+
while (_engineMemoryRing.length > DIAGNOSTICS_MEMORY_ENGINE_RING_CAP) _engineMemoryRing.shift();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _resetDiagnosticsMemoryForTesting() {
|
|
168
|
+
_engineMemoryRing.length = 0;
|
|
169
|
+
_lastEngineSampleCapturedAt = 0;
|
|
170
|
+
if (_memorySamplerStop) {
|
|
171
|
+
try { _memorySamplerStop(); } catch { /* ignore */ }
|
|
172
|
+
_memorySamplerStop = null;
|
|
173
|
+
}
|
|
174
|
+
try { diagnosticsMemory._resetForTest(); } catch { /* ignore */ }
|
|
175
|
+
}
|
|
176
|
+
|
|
107
177
|
function ensureConfiguredProjectStateFiles() {
|
|
108
178
|
for (const p of PROJECTS) {
|
|
109
179
|
const root = p.localPath ? path.resolve(p.localPath) : null;
|
|
@@ -387,6 +457,25 @@ function inferActionPrRecord(action, prs, project = null) {
|
|
|
387
457
|
}
|
|
388
458
|
|
|
389
459
|
function copyWorkItemPrFields(item, input, pr = null) {
|
|
460
|
+
// W-mqbaby2a000pa8ee: Gate the LOOSE description/title scan in
|
|
461
|
+
// `shared.extractWorkItemPrRef` on `type: "fix"`. Without this gate,
|
|
462
|
+
// an implement/explore/test WI that merely mentions an existing PR
|
|
463
|
+
// in prose ("Class bug surfaced today on pull request 130") gets
|
|
464
|
+
// `targetPr` / `pr_id` / `prNumber` stamped, the engine then treats
|
|
465
|
+
// the WI as a fix against that PR, the PR-branch lookup fails, and
|
|
466
|
+
// the dispatch silently skips with `_pendingReason: null` forever.
|
|
467
|
+
//
|
|
468
|
+
// Structured PR pointers (`targetPr` / `pr_id` / `prUrl` / `prNumber` /
|
|
469
|
+
// `references[].url` / `meta.pr_followup.parent_pr_url`) are explicit
|
|
470
|
+
// operator intent — they stamp on EVERY type. An explicit `pr` record
|
|
471
|
+
// (caller already resolved the PR) also bypasses the gate. Only the
|
|
472
|
+
// last-resort description/title regex scan is type-gated.
|
|
473
|
+
if (!pr) {
|
|
474
|
+
const structuredRef = shared.extractStructuredWorkItemPrRef(input);
|
|
475
|
+
if (!structuredRef && String(item?.type || '').toLowerCase() !== WORK_TYPE.FIX) {
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
390
479
|
const prRef = getWorkItemPrRef(input);
|
|
391
480
|
if (!prRef && !pr) return;
|
|
392
481
|
const prNumber = pr ? shared.getPrNumber(pr) : shared.getPrNumber(prRef);
|
|
@@ -1958,6 +2047,7 @@ function _buildStatusSlowState() {
|
|
|
1958
2047
|
const branchMismatch = !!(mainBranch && status.remoteDefaultBranch && mainBranch !== status.remoteDefaultBranch);
|
|
1959
2048
|
return {
|
|
1960
2049
|
name: p.name,
|
|
2050
|
+
displayName: shared.projectDisplayName(p),
|
|
1961
2051
|
path: p.localPath,
|
|
1962
2052
|
description: p.description || '',
|
|
1963
2053
|
...status,
|
|
@@ -3497,13 +3587,25 @@ function getWorkItemPrRef(input) {
|
|
|
3497
3587
|
// the dispatch to the existing PR branch instead of a fresh `work/<wi-id>`
|
|
3498
3588
|
// parallel branch (issue #2999 / W-mpx6i5kh000ac040).
|
|
3499
3589
|
//
|
|
3500
|
-
//
|
|
3501
|
-
//
|
|
3502
|
-
//
|
|
3503
|
-
//
|
|
3504
|
-
//
|
|
3505
|
-
//
|
|
3506
|
-
//
|
|
3590
|
+
// STRUCTURED-vs-LOOSE SPLIT (W-mq18ec6h000p7b87): two extractors exist —
|
|
3591
|
+
// - `shared.extractStructuredWorkItemPrRef`: structured fields +
|
|
3592
|
+
// `references[].url` + `meta.pr_followup.parent_pr_url`. NO scan.
|
|
3593
|
+
// - `shared.extractWorkItemPrRef` (this helper): structured walk + a
|
|
3594
|
+
// last-resort first-paragraph/title scan.
|
|
3595
|
+
// The engine's `pr_not_found` dispatch gate uses the structured-only
|
|
3596
|
+
// variant. The original design intent was that the stamp path could
|
|
3597
|
+
// afford to be loose because gating would still require explicit
|
|
3598
|
+
// operator intent.
|
|
3599
|
+
//
|
|
3600
|
+
// FIX-TYPE GATE on the stamp path (W-mqbaby2a000pa8ee): in practice
|
|
3601
|
+
// the asymmetry leaked — the loose stamp writes to `item.pr_id` (a
|
|
3602
|
+
// canonical structured field), and the strict gate later reads
|
|
3603
|
+
// `item.pr_id` and sees the loose stamp AS IF it were structured
|
|
3604
|
+
// intent. To stop that leak without changing the loose detector
|
|
3605
|
+
// (which has other legitimate callers), `copyWorkItemPrFields` now
|
|
3606
|
+
// gates the loose result on `item.type === "fix"`. Non-fix WIs only
|
|
3607
|
+
// get stamped from structured fields (incl. references/follow-up).
|
|
3608
|
+
// See the comment block on `copyWorkItemPrFields` above for details.
|
|
3507
3609
|
return shared.extractWorkItemPrRef(input);
|
|
3508
3610
|
}
|
|
3509
3611
|
|
|
@@ -9548,6 +9650,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9548
9650
|
for (const key of Object.keys(childEnv)) {
|
|
9549
9651
|
if (key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE') || key.startsWith('CLAUDECODE_')) delete childEnv[key];
|
|
9550
9652
|
}
|
|
9653
|
+
// W-mqb9y83o — suppress auto-open in the respawned dashboard. The
|
|
9654
|
+
// restart endpoint is fired by CC or the dashboard UI; the operator
|
|
9655
|
+
// already has a tab open (that's how they hit the button), so the
|
|
9656
|
+
// post-restart hook must not pop a second one. Hard kill-switch read
|
|
9657
|
+
// by bin/minions.js#spawnFullStackAndVerify.
|
|
9658
|
+
childEnv.MINIONS_NO_AUTO_OPEN = '1';
|
|
9551
9659
|
const proc = cpSpawn(process.execPath, [minionsBin, 'restart'], {
|
|
9552
9660
|
cwd: MINIONS_DIR, stdio: 'ignore', detached: true, env: childEnv, windowsHide: true,
|
|
9553
9661
|
});
|
|
@@ -10121,6 +10229,58 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10121
10229
|
} catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
10122
10230
|
}
|
|
10123
10231
|
|
|
10232
|
+
// P-c3d4e5f6 — /api/diagnostics/memory.
|
|
10233
|
+
// Returns the latest in-process dashboard sample, the latest engine
|
|
10234
|
+
// sample (read fresh from engine/diagnostics-memory.json via safeJsonObj),
|
|
10235
|
+
// and an engineStale boolean. Engine staleness is true when the sidecar
|
|
10236
|
+
// is missing/unparseable OR its capturedAt is > 5 min old. The handler
|
|
10237
|
+
// does one small JSON read and one in-process sample — < 10ms warm.
|
|
10238
|
+
function handleDiagnosticsMemory(req, res) {
|
|
10239
|
+
try {
|
|
10240
|
+
let dashboardSample = null;
|
|
10241
|
+
try { dashboardSample = diagnosticsMemory.sampleSelf({ label: 'dashboard' }); }
|
|
10242
|
+
catch { /* leave dashboardSample null on sampler failure */ }
|
|
10243
|
+
const engineSample = safeJsonObj(DIAGNOSTICS_MEMORY_SIDECAR_PATH);
|
|
10244
|
+
const payload = _buildMemoryDiagnostics({
|
|
10245
|
+
dashboardSample,
|
|
10246
|
+
engineSample,
|
|
10247
|
+
now: Date.now(),
|
|
10248
|
+
});
|
|
10249
|
+
return jsonReply(res, 200, payload, req);
|
|
10250
|
+
} catch (e) {
|
|
10251
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
10252
|
+
}
|
|
10253
|
+
}
|
|
10254
|
+
|
|
10255
|
+
// P-c3d4e5f6 — /api/diagnostics/memory/history?process=engine|dashboard&limit=N.
|
|
10256
|
+
// process=dashboard returns the diagnosticsMemory module's own ring
|
|
10257
|
+
// buffer (populated by startPeriodicSampling on dashboard boot).
|
|
10258
|
+
// process=engine returns the dashboard's accumulated polled snapshots
|
|
10259
|
+
// of engine/diagnostics-memory.json — engine.js only persists the
|
|
10260
|
+
// latest sample to the sidecar, so engine-side history is rebuilt by
|
|
10261
|
+
// the dashboard's poller (dedup by capturedAt). Limit defaults to the
|
|
10262
|
+
// full buffer when missing / non-positive.
|
|
10263
|
+
function handleDiagnosticsMemoryHistory(req, res) {
|
|
10264
|
+
try {
|
|
10265
|
+
const u = new URL(req.url, 'http://x');
|
|
10266
|
+
const proc = (u.searchParams.get('process') || '').trim().toLowerCase();
|
|
10267
|
+
const limitRaw = u.searchParams.get('limit');
|
|
10268
|
+
const limitParsed = limitRaw == null || limitRaw === '' ? null : parseInt(limitRaw, 10);
|
|
10269
|
+
const limit = Number.isInteger(limitParsed) && limitParsed > 0 ? limitParsed : null;
|
|
10270
|
+
let samples;
|
|
10271
|
+
if (proc === 'dashboard') {
|
|
10272
|
+
samples = diagnosticsMemory.getHistory(limit ? { limit } : {});
|
|
10273
|
+
} else if (proc === 'engine') {
|
|
10274
|
+
samples = _buildMemoryHistory({ ring: _engineMemoryRing, limit });
|
|
10275
|
+
} else {
|
|
10276
|
+
return jsonReply(res, 400, { error: 'process must be one of: engine, dashboard' }, req);
|
|
10277
|
+
}
|
|
10278
|
+
return jsonReply(res, 200, { process: proc, count: samples.length, samples }, req);
|
|
10279
|
+
} catch (e) {
|
|
10280
|
+
return jsonReply(res, 500, { error: e.message }, req);
|
|
10281
|
+
}
|
|
10282
|
+
}
|
|
10283
|
+
|
|
10124
10284
|
// Slim UX surface for the experimental redesigned dashboard.
|
|
10125
10285
|
// The markup/CSS/JS live as fragments under dashboard/slim/ (layout.html +
|
|
10126
10286
|
// styles.css + body.html + js/*.js) and are assembled by buildSlimHtml() —
|
|
@@ -12497,6 +12657,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12497
12657
|
{ 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
12658
|
// Diagnostics — per-org ADO throttle state (W-mq03l6zh0006f0a1-d).
|
|
12499
12659
|
{ 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 },
|
|
12660
|
+
// Diagnostics — engine + dashboard memory baseline (P-c3d4e5f6).
|
|
12661
|
+
{ 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
|
+
{ 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 },
|
|
12500
12663
|
];
|
|
12501
12664
|
|
|
12502
12665
|
// ── Route Dispatcher ────────────────────────────────────────────────────────
|
|
@@ -12695,6 +12858,11 @@ module.exports = {
|
|
|
12695
12858
|
refreshStatusAsync,
|
|
12696
12859
|
handleStatus: _handleStatusRequest,
|
|
12697
12860
|
invalidateStatusCache,
|
|
12861
|
+
// exported for testing — see test/unit/status-snapshot-budget.test.js (P-f2a3b4c5).
|
|
12862
|
+
// The slim snapshot builder is the synchronous assembler used by getStatusJson()
|
|
12863
|
+
// and refreshStatusAsync(); the budget test calls it directly to measure byte
|
|
12864
|
+
// size and rebuild-time without standing up an HTTP server.
|
|
12865
|
+
getStatus,
|
|
12698
12866
|
// Raw state-file passthrough — exported for direct unit testing.
|
|
12699
12867
|
handleStateRead,
|
|
12700
12868
|
STATE_READ_ALLOWED_DIRS,
|
|
@@ -12717,6 +12885,18 @@ module.exports = {
|
|
|
12717
12885
|
// route's `builder` closure (getWorkItems().map(slimWorkItemForList)).
|
|
12718
12886
|
_slimWorkItemForList: slimWorkItemForList,
|
|
12719
12887
|
_WORK_ITEMS_SLIM_DESCRIPTION_CAP: WORK_ITEMS_SLIM_DESCRIPTION_CAP,
|
|
12888
|
+
// P-c3d4e5f6 — diagnostics-memory dashboard surface (handlers live in
|
|
12889
|
+
// the request-dispatch closure; expose the pure builders + constants so
|
|
12890
|
+
// unit tests can exercise the staleness gate and history slicing
|
|
12891
|
+
// without binding a server.)
|
|
12892
|
+
_buildMemoryDiagnostics,
|
|
12893
|
+
_buildMemoryHistory,
|
|
12894
|
+
_pollAndAccumulateEngineSample,
|
|
12895
|
+
_resetDiagnosticsMemoryForTesting,
|
|
12896
|
+
DIAGNOSTICS_MEMORY_SIDECAR_PATH,
|
|
12897
|
+
DIAGNOSTICS_MEMORY_STALE_MS,
|
|
12898
|
+
DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS,
|
|
12899
|
+
DIAGNOSTICS_MEMORY_ENGINE_RING_CAP,
|
|
12720
12900
|
};
|
|
12721
12901
|
|
|
12722
12902
|
// Start the HTTP server only when run directly (node dashboard.js).
|
|
@@ -12807,15 +12987,19 @@ if (require.main === module) {
|
|
|
12807
12987
|
Promise.resolve(queries.getKnowledgeBaseEntries())
|
|
12808
12988
|
.catch(err => console.warn(`[dashboard] KB cache warm failed: ${err && err.message}`));
|
|
12809
12989
|
|
|
12810
|
-
// Auto-open the browser
|
|
12811
|
-
//
|
|
12812
|
-
//
|
|
12813
|
-
|
|
12814
|
-
|
|
12815
|
-
|
|
12816
|
-
|
|
12817
|
-
|
|
12818
|
-
|
|
12990
|
+
// Auto-open the browser. `minions restart` and the upgrade path set
|
|
12991
|
+
// MINIONS_NO_AUTO_OPEN=1 because the CLI orchestrates the open itself
|
|
12992
|
+
// after observing whether an existing tab reconnected; the primitive
|
|
12993
|
+
// (engine/shared.js#openUrlInBrowser) now owns the env-var check and
|
|
12994
|
+
// emits a debug-level SUPPRESSED log entry so we can prove the kill-
|
|
12995
|
+
// switch is firing.
|
|
12996
|
+
const result = shared.openUrlInBrowser(`http://localhost:${PORT}`, {
|
|
12997
|
+
reason: 'dashboard-self-open',
|
|
12998
|
+
callerHint: 'dashboard.js:13124',
|
|
12999
|
+
});
|
|
13000
|
+
if (!result.ok && !result.suppressed) {
|
|
13001
|
+
console.log(` Could not auto-open browser: ${result.error}`);
|
|
13002
|
+
console.log(` Please open http://localhost:${PORT} manually.`);
|
|
12819
13003
|
}
|
|
12820
13004
|
|
|
12821
13005
|
// Warm the CC runtime binary cache off the request path so the first CC /
|
|
@@ -12871,12 +13055,39 @@ if (require.main === module) {
|
|
|
12871
13055
|
}
|
|
12872
13056
|
}, 30000).unref();
|
|
12873
13057
|
console.log(` Engine watchdog: active (checks every 30s)`);
|
|
13058
|
+
|
|
13059
|
+
// ─── Diagnostics: dashboard memory sampler (P-c3d4e5f6) ─────────────────
|
|
13060
|
+
// Drive the diagnosticsMemory ring buffer for /api/diagnostics/memory and
|
|
13061
|
+
// /api/diagnostics/memory/history?process=dashboard. The internal
|
|
13062
|
+
// setInterval already calls recordSample for us; the onSample callback
|
|
13063
|
+
// doubles as the engine-sidecar poller so /…/history?process=engine
|
|
13064
|
+
// accumulates one entry per fresh engine MEMORY_BASELINE write
|
|
13065
|
+
// (deduplicated by capturedAt).
|
|
13066
|
+
try {
|
|
13067
|
+
_memorySamplerStop = diagnosticsMemory.startPeriodicSampling({
|
|
13068
|
+
intervalMs: DIAGNOSTICS_MEMORY_SAMPLE_INTERVAL_MS,
|
|
13069
|
+
onSample: () => { try { _pollAndAccumulateEngineSample(); } catch { /* swallow */ } },
|
|
13070
|
+
});
|
|
13071
|
+
// Seed the engine ring immediately so the first call to
|
|
13072
|
+
// /api/diagnostics/memory/history?process=engine right after boot
|
|
13073
|
+
// already returns the latest sidecar sample without waiting a full
|
|
13074
|
+
// sampling interval.
|
|
13075
|
+
try { _pollAndAccumulateEngineSample(); } catch { /* swallow */ }
|
|
13076
|
+
} catch (e) {
|
|
13077
|
+
console.warn(`[dashboard] memory sampler failed to start: ${e && e.message}`);
|
|
13078
|
+
}
|
|
12874
13079
|
})();
|
|
12875
13080
|
|
|
12876
13081
|
// ── Graceful shutdown: flush debounced writes + clear runtime port file ──
|
|
12877
13082
|
function _gracefulShutdown() {
|
|
12878
13083
|
try { flushPendingDocSessions(); } catch {}
|
|
12879
13084
|
try { shared.clearDashboardPortFile(MINIONS_DIR); } catch {}
|
|
13085
|
+
// P-c3d4e5f6 — stop the diagnostics-memory sampler cleanly so the
|
|
13086
|
+
// setInterval handle doesn't keep the event loop alive on SIGTERM.
|
|
13087
|
+
if (_memorySamplerStop) {
|
|
13088
|
+
try { _memorySamplerStop(); } catch { /* swallow */ }
|
|
13089
|
+
_memorySamplerStop = null;
|
|
13090
|
+
}
|
|
12880
13091
|
}
|
|
12881
13092
|
server.on('close', () => _gracefulShutdown());
|
|
12882
13093
|
process.on('SIGTERM', () => { _gracefulShutdown(); process.exit(0); });
|
|
@@ -44,6 +44,18 @@ When a ref is detected, `copyWorkItemPrFields` stamps
|
|
|
44
44
|
`targetPr` / `pr_id` / `prNumber`, `item.branch` is unset, and
|
|
45
45
|
`discoverFromWorkItems` reuses the PR's source branch.
|
|
46
46
|
|
|
47
|
+
**Type gate (W-mqbaby2a000pa8ee).** The loose description/title scan
|
|
48
|
+
(step 4 above) only stamps on `type: "fix"` WIs. Non-fix WIs
|
|
49
|
+
(implement / explore / test / review / …) only get stamped from
|
|
50
|
+
structured fields (steps 1-3). Without this gate, an implement WI
|
|
51
|
+
whose description merely mentions an existing PR in prose
|
|
52
|
+
("Class bug surfaced today on pull request 130") would silently
|
|
53
|
+
get `targetPr` / `pr_id` / `prNumber` stamped, route through the
|
|
54
|
+
PR-fix dispatch path, fail the PR-branch lookup, and stick in
|
|
55
|
+
`_pendingReason: null` forever. Structured intent
|
|
56
|
+
(`targetPr` / `prUrl` / `references[].url` / etc.) still stamps on
|
|
57
|
+
every type — only the loose regex scan is gated.
|
|
58
|
+
|
|
47
59
|
## Structured-vs-loose split (W-mq18ec6h000p7b87)
|
|
48
60
|
|
|
49
61
|
The PR-ref extractor has **two** variants — pick the right one for the
|
|
@@ -52,7 +64,7 @@ call site:
|
|
|
52
64
|
| Helper | What it walks | Used by | Why |
|
|
53
65
|
|--------|---------------|---------|-----|
|
|
54
66
|
| `shared.extractStructuredWorkItemPrRef(item)` | Structured fields + `references[*].url` + `meta.pr_followup.parent_pr_url`. **No** description / title scan. | `engine.js#getStructuredWorkItemPrRef` → `pr_not_found` dispatch gate. | Gating blocks dispatch and MUST require explicit operator intent. A description like "see PR #3015 for context" must NOT trip the gate. |
|
|
55
|
-
| `shared.extractWorkItemPrRef(item)` | Structured walk + last-resort description / title scan. | `engine.js#getWorkItemPrRef` (branch derivation, prompt PR context, `resolveWorkItemPrRecord`); `dashboard.js#getWorkItemPrRef` (POST `/api/work-items` create-time `targetPr` stamping). | Callers downgrade gracefully when no PR record matches; stamp path preserves the operator UX of pasting a PR URL into description prose and getting `targetPr` auto-stamped. |
|
|
67
|
+
| `shared.extractWorkItemPrRef(item)` | Structured walk + last-resort description / title scan. | `engine.js#getWorkItemPrRef` (branch derivation, prompt PR context, `resolveWorkItemPrRecord`); `dashboard.js#getWorkItemPrRef` (POST `/api/work-items` create-time `targetPr` stamping, **type-gated on `fix` — W-mqbaby2a000pa8ee**). | Callers downgrade gracefully when no PR record matches; stamp path preserves the operator UX of pasting a PR URL into description prose and getting `targetPr` auto-stamped on `type: "fix"` WIs. |
|
|
56
68
|
|
|
57
69
|
**Rule of thumb: gate uses structured-only; stamp uses loose.**
|
|
58
70
|
Stamping is best-effort and reversible; gating blocks dispatch and should
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/diagnostics-memory.js — In-process memory + event-loop + GC sampler.
|
|
3
|
+
*
|
|
4
|
+
* P-a1b2c3d4 (memory + perf audit plan). Importable from both engine.js and
|
|
5
|
+
* dashboard.js — each Node process gets its own module-scope state (event-loop
|
|
6
|
+
* histogram, GC counters, ring buffer). No I/O; persistence is the caller's
|
|
7
|
+
* responsibility (see P-b2c3d4e5).
|
|
8
|
+
*
|
|
9
|
+
* Exports:
|
|
10
|
+
* - sampleSelf({ label }) → snapshot object with RSS / heap / event-loop /
|
|
11
|
+
* GC counters / pid / uptime / capturedAt.
|
|
12
|
+
* - getHistory({ limit }) → up-to-`limit` samples from the ring buffer
|
|
13
|
+
* (oldest first), defaults to all 1440.
|
|
14
|
+
* - recordSample(sample) → append to the ring buffer; rotates at the cap.
|
|
15
|
+
* - startPeriodicSampling({ intervalMs, onSample }) → setInterval-driven
|
|
16
|
+
* sampler; returns a stop() function. Dashboard.js uses this on boot;
|
|
17
|
+
* engine.js drives sampling from its own tick instead.
|
|
18
|
+
*
|
|
19
|
+
* Zero deps — all Node built-ins. The event-loop histogram is a
|
|
20
|
+
* `monitorEventLoopDelay({ resolution: 20 })` singleton lazily enabled on the
|
|
21
|
+
* first `sampleSelf()` call. The PerformanceObserver subscribes to
|
|
22
|
+
* `entryTypes: ['gc']` and accumulates pause times into module-scope counters.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const v8 = require('v8');
|
|
26
|
+
const { monitorEventLoopDelay, PerformanceObserver, constants } = require('perf_hooks');
|
|
27
|
+
|
|
28
|
+
const RING_BUFFER_CAP = 1440;
|
|
29
|
+
const HISTOGRAM_RESOLUTION_MS = 20;
|
|
30
|
+
const NS_PER_MS = 1e6;
|
|
31
|
+
|
|
32
|
+
const ringBuffer = [];
|
|
33
|
+
|
|
34
|
+
let eventLoopHistogram = null;
|
|
35
|
+
let gcObserver = null;
|
|
36
|
+
let gcPausesTotalMs = 0;
|
|
37
|
+
let gcCount = 0;
|
|
38
|
+
let lastGcPauseMs = 0;
|
|
39
|
+
let lastGcKind = null;
|
|
40
|
+
|
|
41
|
+
const GC_KIND_NAMES = {
|
|
42
|
+
[constants.NODE_PERFORMANCE_GC_MAJOR]: 'major',
|
|
43
|
+
[constants.NODE_PERFORMANCE_GC_MINOR]: 'minor',
|
|
44
|
+
[constants.NODE_PERFORMANCE_GC_INCREMENTAL]: 'incremental',
|
|
45
|
+
[constants.NODE_PERFORMANCE_GC_WEAKCB]: 'weakcb',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function _ensureHistogramStarted() {
|
|
49
|
+
if (eventLoopHistogram) return eventLoopHistogram;
|
|
50
|
+
eventLoopHistogram = monitorEventLoopDelay({ resolution: HISTOGRAM_RESOLUTION_MS });
|
|
51
|
+
eventLoopHistogram.enable();
|
|
52
|
+
return eventLoopHistogram;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _ensureGcObserverStarted() {
|
|
56
|
+
if (gcObserver) return gcObserver;
|
|
57
|
+
gcObserver = new PerformanceObserver((list) => {
|
|
58
|
+
for (const entry of list.getEntries()) {
|
|
59
|
+
// entry.duration is in ms (fractional). entry.kind / entry.detail.kind
|
|
60
|
+
// is the numeric NODE_PERFORMANCE_GC_* constant.
|
|
61
|
+
const pauseMs = Number(entry.duration) || 0;
|
|
62
|
+
gcPausesTotalMs += pauseMs;
|
|
63
|
+
gcCount += 1;
|
|
64
|
+
lastGcPauseMs = pauseMs;
|
|
65
|
+
const kindNum = (entry.detail && entry.detail.kind) || entry.kind || null;
|
|
66
|
+
lastGcKind = (kindNum != null && GC_KIND_NAMES[kindNum]) || (kindNum != null ? String(kindNum) : null);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
gcObserver.observe({ entryTypes: ['gc'], buffered: false });
|
|
70
|
+
// Don't keep the event loop alive just to observe GC.
|
|
71
|
+
if (typeof gcObserver.unref === 'function') gcObserver.unref();
|
|
72
|
+
return gcObserver;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _nsToMs(ns) {
|
|
76
|
+
if (!Number.isFinite(ns) || ns <= 0) return 0;
|
|
77
|
+
return ns / NS_PER_MS;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function _readEventLoopLag(hist) {
|
|
81
|
+
// hist.percentile(p) throws when the histogram is empty (no recorded
|
|
82
|
+
// samples yet). Guard so the first sampleSelf() call after process start
|
|
83
|
+
// returns zeros instead of throwing.
|
|
84
|
+
let p50 = 0, p99 = 0, max = 0;
|
|
85
|
+
try {
|
|
86
|
+
if (hist && typeof hist.percentile === 'function') {
|
|
87
|
+
const c = typeof hist.count === 'number' ? hist.count : (hist.totalCount || 0);
|
|
88
|
+
if (c > 0) {
|
|
89
|
+
p50 = _nsToMs(hist.percentile(50));
|
|
90
|
+
p99 = _nsToMs(hist.percentile(99));
|
|
91
|
+
max = _nsToMs(hist.max);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Empty / not-yet-populated histogram. Leave zeros.
|
|
96
|
+
}
|
|
97
|
+
return { p50, p99, max };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sampleSelf({ label = null } = {}) {
|
|
101
|
+
const hist = _ensureHistogramStarted();
|
|
102
|
+
_ensureGcObserverStarted();
|
|
103
|
+
|
|
104
|
+
const mem = process.memoryUsage();
|
|
105
|
+
const heap = v8.getHeapStatistics();
|
|
106
|
+
const lag = _readEventLoopLag(hist);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
rss: mem.rss,
|
|
110
|
+
heapUsed: mem.heapUsed,
|
|
111
|
+
heapTotal: mem.heapTotal,
|
|
112
|
+
external: mem.external,
|
|
113
|
+
arrayBuffers: mem.arrayBuffers || 0,
|
|
114
|
+
heapSizeLimit: heap.heap_size_limit,
|
|
115
|
+
eventLoopLagP50: lag.p50,
|
|
116
|
+
eventLoopLagP99: lag.p99,
|
|
117
|
+
eventLoopLagMax: lag.max,
|
|
118
|
+
lastGcPauseMs,
|
|
119
|
+
lastGcKind,
|
|
120
|
+
gcPausesTotalMs,
|
|
121
|
+
gcCount,
|
|
122
|
+
uptime: process.uptime(),
|
|
123
|
+
pid: process.pid,
|
|
124
|
+
label,
|
|
125
|
+
capturedAt: Date.now(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function recordSample(sample) {
|
|
130
|
+
if (!sample || typeof sample !== 'object') return;
|
|
131
|
+
ringBuffer.push(sample);
|
|
132
|
+
while (ringBuffer.length > RING_BUFFER_CAP) ringBuffer.shift();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getHistory({ limit } = {}) {
|
|
136
|
+
const n = ringBuffer.length;
|
|
137
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit >= n) {
|
|
138
|
+
return ringBuffer.slice();
|
|
139
|
+
}
|
|
140
|
+
return ringBuffer.slice(n - limit);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function startPeriodicSampling({ intervalMs = 60000, onSample = null } = {}) {
|
|
144
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
|
145
|
+
throw new Error('startPeriodicSampling: intervalMs must be a positive number');
|
|
146
|
+
}
|
|
147
|
+
const handle = setInterval(() => {
|
|
148
|
+
let sample;
|
|
149
|
+
try {
|
|
150
|
+
sample = sampleSelf({ label: 'periodic' });
|
|
151
|
+
} catch {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
recordSample(sample);
|
|
155
|
+
if (typeof onSample === 'function') {
|
|
156
|
+
try { onSample(sample); } catch { /* swallow — sampler must not crash on callback errors */ }
|
|
157
|
+
}
|
|
158
|
+
}, intervalMs);
|
|
159
|
+
if (typeof handle.unref === 'function') handle.unref();
|
|
160
|
+
return function stop() {
|
|
161
|
+
clearInterval(handle);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _resetForTest() {
|
|
166
|
+
ringBuffer.length = 0;
|
|
167
|
+
if (eventLoopHistogram && typeof eventLoopHistogram.disable === 'function') {
|
|
168
|
+
try { eventLoopHistogram.disable(); } catch {}
|
|
169
|
+
}
|
|
170
|
+
eventLoopHistogram = null;
|
|
171
|
+
if (gcObserver && typeof gcObserver.disconnect === 'function') {
|
|
172
|
+
try { gcObserver.disconnect(); } catch {}
|
|
173
|
+
}
|
|
174
|
+
gcObserver = null;
|
|
175
|
+
gcPausesTotalMs = 0;
|
|
176
|
+
gcCount = 0;
|
|
177
|
+
lastGcPauseMs = 0;
|
|
178
|
+
lastGcKind = null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
sampleSelf,
|
|
183
|
+
getHistory,
|
|
184
|
+
recordSample,
|
|
185
|
+
startPeriodicSampling,
|
|
186
|
+
// Constants for callers that need to know the cap up front.
|
|
187
|
+
RING_BUFFER_CAP,
|
|
188
|
+
// exported for testing
|
|
189
|
+
_resetForTest,
|
|
190
|
+
};
|