@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.
@@ -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
- const content = safeRead(path.join(dashDir, 'pages', p + '.html'));
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
- // ASYMMETRY (W-mq18ec6h000p7b87): the engine's pr_not_found *gate* uses
3501
- // the strict `shared.extractStructuredWorkItemPrRef` gate uses
3502
- // structured-only; stamp uses loose. Rationale: operators creating fix
3503
- // WIs via API often paste the PR URL in description prose and expect
3504
- // `targetPr` to get auto-stamped here (best-effort, reversible). The gate
3505
- // blocks dispatch and must require explicit operator intent (a structured
3506
- // field) before doing so.
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 unless suppressed. `minions restart` and the
12811
- // upgrade path set MINIONS_NO_AUTO_OPEN=1 because the CLI orchestrates the
12812
- // open itself after observing whether an existing tab reconnected.
12813
- if (!process.env.MINIONS_NO_AUTO_OPEN) {
12814
- const result = shared.openUrlInBrowser(`http://localhost:${PORT}`);
12815
- if (!result.ok) {
12816
- console.log(` Could not auto-open browser: ${result.error}`);
12817
- console.log(` Please open http://localhost:${PORT} manually.`);
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
+ };