@yemi33/minions 0.1.2177 → 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 +9 -1
- package/dashboard/js/render-dispatch.js +92 -0
- package/dashboard/js/render-other.js +1 -1
- package/dashboard/js/render-plans.js +82 -13
- package/dashboard/js/render-prs.js +2 -1
- package/dashboard/js/render-schedules.js +1 -1
- package/dashboard/js/render-watches.js +1 -1
- package/dashboard/js/settings.js +100 -11
- package/dashboard/layout.html +6 -0
- 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/styles.css +39 -0
- package/dashboard-build.js +17 -2
- package/dashboard.js +469 -21
- package/docs/README.md +8 -1
- package/docs/auto-discovery.md +40 -0
- package/docs/branch-derivation.md +13 -1
- package/docs/cross-repo-plans.md +292 -0
- package/docs/deprecated.json +4 -4
- package/docs/pr-auto-fix-dispatch.md +64 -0
- package/docs/pr-review-fix-loop.md +1 -1
- package/docs/watches.md +1 -0
- package/engine/ado.js +1 -10
- package/engine/diagnostics-memory.js +190 -0
- package/engine/dispatch.js +53 -0
- package/engine/lifecycle.js +155 -191
- package/engine/meeting.js +30 -0
- package/engine/playbook.js +15 -0
- package/engine/queries.js +165 -5
- package/engine/runtimes/copilot.js +19 -0
- package/engine/shared.js +303 -3
- package/engine/watchdog.js +6 -0
- package/engine.js +576 -113
- package/package.json +2 -2
- package/playbooks/plan-to-prd.md +25 -2
- package/playbooks/plan.md +4 -2
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;
|
|
@@ -182,6 +252,27 @@ function mutateDashboardConfig(mutator) {
|
|
|
182
252
|
}, { defaultValue: { projects: [], agents: {}, engine: {} }, skipWriteIfUnchanged: true });
|
|
183
253
|
}
|
|
184
254
|
|
|
255
|
+
// P-f3c9d0e7 — shared body for the four convenience pause/resume endpoints
|
|
256
|
+
// (`/api/engine/polling/{pause,resume}` and `/api/engine/auto-fix/{pause,resume}`).
|
|
257
|
+
// Persists `config.engine[key] = paused`, busts the in-memory CONFIG so the
|
|
258
|
+
// dashboard sees the new value immediately, invalidates the status cache so
|
|
259
|
+
// the SPA's next poll picks it up, and replies `{ paused, at: <iso> }` per
|
|
260
|
+
// the plan's wire contract. Idempotent — repeating the same call writes the
|
|
261
|
+
// same value (mutateDashboardConfig short-circuits when unchanged) and
|
|
262
|
+
// returns the same shape.
|
|
263
|
+
function _setEnginePauseFlag(res, key, paused) {
|
|
264
|
+
mutateDashboardConfig(config => {
|
|
265
|
+
if (!config.engine || typeof config.engine !== 'object' || Array.isArray(config.engine)) {
|
|
266
|
+
config.engine = {};
|
|
267
|
+
}
|
|
268
|
+
config.engine[key] = paused;
|
|
269
|
+
return config;
|
|
270
|
+
});
|
|
271
|
+
reloadConfig();
|
|
272
|
+
invalidateStatusCache();
|
|
273
|
+
return jsonReply(res, 200, { paused, at: new Date().toISOString() });
|
|
274
|
+
}
|
|
275
|
+
|
|
185
276
|
function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
|
|
186
277
|
if (!current || typeof current !== 'object' || Array.isArray(current)) current = {};
|
|
187
278
|
if (body.engine) {
|
|
@@ -366,6 +457,25 @@ function inferActionPrRecord(action, prs, project = null) {
|
|
|
366
457
|
}
|
|
367
458
|
|
|
368
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
|
+
}
|
|
369
479
|
const prRef = getWorkItemPrRef(input);
|
|
370
480
|
if (!prRef && !pr) return;
|
|
371
481
|
const prNumber = pr ? shared.getPrNumber(pr) : shared.getPrNumber(prRef);
|
|
@@ -1907,6 +2017,11 @@ function _buildStatusFastState() {
|
|
|
1907
2017
|
&& hbAge > ENGINE_HEARTBEAT_STALE_MS
|
|
1908
2018
|
&& tickAge > tickStaleThresholdMs),
|
|
1909
2019
|
tickInterval: tickInterval,
|
|
2020
|
+
// P-g7a2b4c5 — surface the two operator kill-switches on every status
|
|
2021
|
+
// payload so the cross-page sticky banner can render without a second
|
|
2022
|
+
// round-trip. Both default to false when unset in config.engine.
|
|
2023
|
+
pollingPaused: !!CONFIG?.engine?.pollingPaused,
|
|
2024
|
+
autoFixPaused: !!CONFIG?.engine?.autoFixPaused,
|
|
1910
2025
|
},
|
|
1911
2026
|
adoThrottle: ado.getAdoThrottleState(),
|
|
1912
2027
|
ghThrottle: gh.getGhThrottleState(),
|
|
@@ -1932,6 +2047,7 @@ function _buildStatusSlowState() {
|
|
|
1932
2047
|
const branchMismatch = !!(mainBranch && status.remoteDefaultBranch && mainBranch !== status.remoteDefaultBranch);
|
|
1933
2048
|
return {
|
|
1934
2049
|
name: p.name,
|
|
2050
|
+
displayName: shared.projectDisplayName(p),
|
|
1935
2051
|
path: p.localPath,
|
|
1936
2052
|
description: p.description || '',
|
|
1937
2053
|
...status,
|
|
@@ -3471,13 +3587,25 @@ function getWorkItemPrRef(input) {
|
|
|
3471
3587
|
// the dispatch to the existing PR branch instead of a fresh `work/<wi-id>`
|
|
3472
3588
|
// parallel branch (issue #2999 / W-mpx6i5kh000ac040).
|
|
3473
3589
|
//
|
|
3474
|
-
//
|
|
3475
|
-
//
|
|
3476
|
-
//
|
|
3477
|
-
//
|
|
3478
|
-
//
|
|
3479
|
-
//
|
|
3480
|
-
//
|
|
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.
|
|
3481
3609
|
return shared.extractWorkItemPrRef(input);
|
|
3482
3610
|
}
|
|
3483
3611
|
|
|
@@ -6700,9 +6828,50 @@ const server = http.createServer(async (req, res) => {
|
|
|
6700
6828
|
try {
|
|
6701
6829
|
const plan = JSON.parse(content);
|
|
6702
6830
|
const status = plan.status || 'active';
|
|
6831
|
+
// W-mqacrzis0003df4a Bug 2 — fresh staleness from plans/*.md mtime.
|
|
6832
|
+
// plan.planStale lags by an engine tick; stat the source plan here
|
|
6833
|
+
// so the Plans tab does not silently mirror a stale-but-cached false.
|
|
6834
|
+
let freshStale = false;
|
|
6835
|
+
if (!archived && plan.source_plan) {
|
|
6836
|
+
try {
|
|
6837
|
+
const sourceMtime = Math.floor(fs.statSync(path.join(PLANS_DIR, plan.source_plan)).mtimeMs);
|
|
6838
|
+
const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
|
|
6839
|
+
if (recorded && sourceMtime > recorded) freshStale = true;
|
|
6840
|
+
} catch { /* source plan may have been deleted/renamed */ }
|
|
6841
|
+
}
|
|
6842
|
+
// P-e8d49105 — _projects rollup feeds the plan-card multi-badge
|
|
6843
|
+
// path in dashboard/js/render-plans.js. For PRD JSONs, walk
|
|
6844
|
+
// missing_features[].project (order-preserving dedup) and fall
|
|
6845
|
+
// back to plan.project for items that omit it. Single-project
|
|
6846
|
+
// plans collapse to a single-element array, identical to today's
|
|
6847
|
+
// single `p.project` badge.
|
|
6848
|
+
const _projects = [];
|
|
6849
|
+
const _seen = new Set();
|
|
6850
|
+
for (const it of (plan.missing_features || [])) {
|
|
6851
|
+
const proj = (it && it.project) || plan.project || '';
|
|
6852
|
+
if (proj && !_seen.has(proj)) { _seen.add(proj); _projects.push(proj); }
|
|
6853
|
+
}
|
|
6854
|
+
if (_projects.length === 0 && plan.project) _projects.push(plan.project);
|
|
6855
|
+
// P-66b1faec — _perProjectProgress feeds the per-project status
|
|
6856
|
+
// pills in the plan card meta line (dashboard/js/render-plans.js
|
|
6857
|
+
// renderPlanCard). Group missing_features by item.project (with
|
|
6858
|
+
// fallback to plan.project) and count `status === 'done'` per
|
|
6859
|
+
// bucket. Keys are first-seen order so the rendered pill row is
|
|
6860
|
+
// stable. Always emitted on PRD JSON records (possibly empty);
|
|
6861
|
+
// MD drafts omit it since they carry no item-level statuses.
|
|
6862
|
+
const _perProjectProgress = {};
|
|
6863
|
+
for (const it of (plan.missing_features || [])) {
|
|
6864
|
+
const proj = (it && it.project) || plan.project || '';
|
|
6865
|
+
if (!proj) continue;
|
|
6866
|
+
if (!_perProjectProgress[proj]) _perProjectProgress[proj] = { complete: 0, total: 0 };
|
|
6867
|
+
_perProjectProgress[proj].total += 1;
|
|
6868
|
+
if (it && it.status === 'done') _perProjectProgress[proj].complete += 1;
|
|
6869
|
+
}
|
|
6703
6870
|
return {
|
|
6704
6871
|
file: f, format: 'prd', archived,
|
|
6705
6872
|
project: plan.project || '',
|
|
6873
|
+
_projects,
|
|
6874
|
+
_perProjectProgress,
|
|
6706
6875
|
summary: plan.plan_summary || '',
|
|
6707
6876
|
status,
|
|
6708
6877
|
branchStrategy: plan.branch_strategy || 'parallel',
|
|
@@ -6717,7 +6886,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6717
6886
|
sourcePlan: plan.source_plan || null,
|
|
6718
6887
|
archiveReady: plan._archiveReady || false,
|
|
6719
6888
|
archiveReadyAt: plan._archiveReadyAt || null,
|
|
6720
|
-
planStale: plan.planStale || false,
|
|
6889
|
+
planStale: freshStale || plan.planStale || false,
|
|
6721
6890
|
};
|
|
6722
6891
|
} catch { return null; /* JSON parse fallback */ }
|
|
6723
6892
|
} else {
|
|
@@ -6726,9 +6895,19 @@ const server = http.createServer(async (req, res) => {
|
|
|
6726
6895
|
const authorMatch = content.match(/\*\*Author:\*\*\s*(.+)/m);
|
|
6727
6896
|
const dateMatch = content.match(/\*\*Date:\*\*\s*(.+)/m);
|
|
6728
6897
|
const versionMatch = f.match(/-v(\d+)/);
|
|
6898
|
+
// P-e8d49105 — for MD drafts, the cross-repo marker / **Projects:**
|
|
6899
|
+
// line is the source of truth (parsed by shared.extractPlanTargetProjects).
|
|
6900
|
+
// If neither is present, fall back to the singular **Project:** header
|
|
6901
|
+
// so single-project drafts render the same badge as today.
|
|
6902
|
+
const declaredProject = projectMatch ? projectMatch[1].trim() : '';
|
|
6903
|
+
const targetProjects = shared.extractPlanTargetProjects(content);
|
|
6904
|
+
const _projects = targetProjects.length > 0
|
|
6905
|
+
? targetProjects
|
|
6906
|
+
: (declaredProject ? [declaredProject] : []);
|
|
6729
6907
|
return {
|
|
6730
6908
|
file: f, format: 'draft', archived,
|
|
6731
|
-
project:
|
|
6909
|
+
project: declaredProject,
|
|
6910
|
+
_projects,
|
|
6732
6911
|
summary: titleMatch ? titleMatch[1].trim() : f.replace('.md', ''),
|
|
6733
6912
|
status: archived ? 'completed' : completedPrdFiles.has(f) ? 'converted' : 'draft',
|
|
6734
6913
|
branchStrategy: '',
|
|
@@ -6842,6 +7021,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
6842
7021
|
return data;
|
|
6843
7022
|
}, { defaultValue: {} });
|
|
6844
7023
|
|
|
7024
|
+
// W-mqacrzis0003df4a — Fresh source-plan staleness check. Approve is the
|
|
7025
|
+
// last gate before materialization, and the diff-aware regen block below
|
|
7026
|
+
// gates on `wasStale`. The persisted `data.planStale` flag lags by an
|
|
7027
|
+
// engine tick (~10s); without this fresh stat a fast user can Approve
|
|
7028
|
+
// within the tick window, `wasStale` stays false, the diff-aware regen
|
|
7029
|
+
// is silently skipped, and items materialize from the OLD PRD. Mirrors
|
|
7030
|
+
// the staleness logic in engine/queries.js#getPrdInfo + the /api/plans
|
|
7031
|
+
// handler above so all three readers agree.
|
|
7032
|
+
if (!wasStale && plan && plan.source_plan && plan.sourcePlanModifiedAt) {
|
|
7033
|
+
try {
|
|
7034
|
+
const sourceMtime = Math.floor(fs.statSync(path.join(PLANS_DIR, plan.source_plan)).mtimeMs);
|
|
7035
|
+
const recorded = new Date(plan.sourcePlanModifiedAt).getTime();
|
|
7036
|
+
if (recorded && sourceMtime > recorded) wasStale = true;
|
|
7037
|
+
} catch { /* source plan may have been deleted/renamed — fall through with wasStale=false */ }
|
|
7038
|
+
}
|
|
7039
|
+
|
|
6845
7040
|
// Resume paused work items across all projects
|
|
6846
7041
|
let resumed = 0;
|
|
6847
7042
|
const resumedItemIds = [];
|
|
@@ -7193,7 +7388,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
7193
7388
|
|
|
7194
7389
|
let archivedSource = null;
|
|
7195
7390
|
let plan = {};
|
|
7196
|
-
|
|
7391
|
+
const archiveWarnings = [];
|
|
7392
|
+
let archivedPrd = null;
|
|
7393
|
+
const archivedPrds = [];
|
|
7197
7394
|
if (isPrd) {
|
|
7198
7395
|
const result = _archivePrdPostProcess({
|
|
7199
7396
|
planFile: body.file,
|
|
@@ -7202,8 +7399,75 @@ const server = http.createServer(async (req, res) => {
|
|
|
7202
7399
|
plansDir: PLANS_DIR,
|
|
7203
7400
|
});
|
|
7204
7401
|
archivedSource = result.archivedSource;
|
|
7205
|
-
archiveWarnings
|
|
7402
|
+
archiveWarnings.push(...result.archiveWarnings);
|
|
7206
7403
|
plan = result.plan;
|
|
7404
|
+
} else {
|
|
7405
|
+
// W-mqa27r9b0004c055 — symmetric cascade: archiving a .md must also
|
|
7406
|
+
// archive any PRD whose `source_plan` points back at it. Without this,
|
|
7407
|
+
// the dashboard still renders the PRD as a status-completed plan card
|
|
7408
|
+
// forever (real incident: killswitches-and-granular-controls.md →
|
|
7409
|
+
// minions-opg-2026-06-10-2.json). The per-PRD status-flip / sidecar /
|
|
7410
|
+
// source-plan-move steps are delegated to _archivePrdPostProcess so
|
|
7411
|
+
// both branches share the per-concern try/catch granularity Dallas
|
|
7412
|
+
// shipped for the PRD branch in W-mqa13ulk0002def5 (PR #3222) — the
|
|
7413
|
+
// helper's source-plan move is a safe no-op here because the outer
|
|
7414
|
+
// handler already renamed body.file into plans/archive/.
|
|
7415
|
+
let prdFiles = [];
|
|
7416
|
+
try {
|
|
7417
|
+
prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json'));
|
|
7418
|
+
} catch (e) {
|
|
7419
|
+
// ENOENT on prd/ is expected for projects without a PRD dir yet —
|
|
7420
|
+
// don't surface as a user-visible warning. Other errors (EACCES,
|
|
7421
|
+
// EIO) get logged + warned.
|
|
7422
|
+
if (e.code !== 'ENOENT') {
|
|
7423
|
+
const warning = `Archive could not enumerate ${PRD_DIR}: ${e.message}`;
|
|
7424
|
+
archiveWarnings.push(warning);
|
|
7425
|
+
console.warn(warning);
|
|
7426
|
+
}
|
|
7427
|
+
}
|
|
7428
|
+
for (const prdFile of prdFiles) {
|
|
7429
|
+
const prdLivePath = path.join(PRD_DIR, prdFile);
|
|
7430
|
+
let prd = null;
|
|
7431
|
+
try {
|
|
7432
|
+
prd = safeJsonObj(prdLivePath);
|
|
7433
|
+
} catch (e) {
|
|
7434
|
+
const warning = `Archive could not read PRD ${prdFile}: ${e.message}`;
|
|
7435
|
+
archiveWarnings.push(warning);
|
|
7436
|
+
console.warn(warning);
|
|
7437
|
+
continue;
|
|
7438
|
+
}
|
|
7439
|
+
if (!prd || prd.source_plan !== body.file) continue;
|
|
7440
|
+
|
|
7441
|
+
const prdArchiveDir = path.join(PRD_DIR, 'archive');
|
|
7442
|
+
try {
|
|
7443
|
+
if (!fs.existsSync(prdArchiveDir)) fs.mkdirSync(prdArchiveDir, { recursive: true });
|
|
7444
|
+
} catch (e) {
|
|
7445
|
+
const warning = `Archive could not create PRD archive dir for ${prdFile}: ${e.message}`;
|
|
7446
|
+
archiveWarnings.push(warning);
|
|
7447
|
+
console.warn(warning);
|
|
7448
|
+
continue;
|
|
7449
|
+
}
|
|
7450
|
+
const prdArchivePath = path.join(prdArchiveDir, prdFile);
|
|
7451
|
+
try {
|
|
7452
|
+
fs.renameSync(prdLivePath, prdArchivePath);
|
|
7453
|
+
} catch (e) {
|
|
7454
|
+
const warning = `Archive could not move PRD ${prdFile}: ${e.message}`;
|
|
7455
|
+
archiveWarnings.push(warning);
|
|
7456
|
+
console.warn(warning);
|
|
7457
|
+
continue;
|
|
7458
|
+
}
|
|
7459
|
+
// Delegate status-flip + sidecar cleanup + (no-op) source-plan move
|
|
7460
|
+
// to the shared helper so both branches stay in lockstep.
|
|
7461
|
+
const cascadeResult = _archivePrdPostProcess({
|
|
7462
|
+
planFile: prdFile,
|
|
7463
|
+
archivePath: prdArchivePath,
|
|
7464
|
+
planPath: prdLivePath,
|
|
7465
|
+
plansDir: PLANS_DIR,
|
|
7466
|
+
});
|
|
7467
|
+
archiveWarnings.push(...cascadeResult.archiveWarnings);
|
|
7468
|
+
archivedPrds.push(prdFile);
|
|
7469
|
+
}
|
|
7470
|
+
if (archivedPrds.length > 0) archivedPrd = archivedPrds[0];
|
|
7207
7471
|
}
|
|
7208
7472
|
|
|
7209
7473
|
// Cancel pending work items linked to this plan so the engine stops
|
|
@@ -7233,6 +7497,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
7233
7497
|
invalidateStatusCache();
|
|
7234
7498
|
invalidatePlansCache();
|
|
7235
7499
|
const payload = { ok: true, archived: body.file, archivedSource, cancelledItems };
|
|
7500
|
+
if (archivedPrd) payload.archivedPrd = archivedPrd;
|
|
7501
|
+
if (archivedPrds.length > 1) payload.archivedPrds = archivedPrds;
|
|
7236
7502
|
if (archiveWarnings.length > 0) payload.warnings = archiveWarnings;
|
|
7237
7503
|
return jsonReply(res, 200, payload);
|
|
7238
7504
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
@@ -9384,6 +9650,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9384
9650
|
for (const key of Object.keys(childEnv)) {
|
|
9385
9651
|
if (key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE') || key.startsWith('CLAUDECODE_')) delete childEnv[key];
|
|
9386
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';
|
|
9387
9659
|
const proc = cpSpawn(process.execPath, [minionsBin, 'restart'], {
|
|
9388
9660
|
cwd: MINIONS_DIR, stdio: 'ignore', detached: true, env: childEnv, windowsHide: true,
|
|
9389
9661
|
});
|
|
@@ -9501,6 +9773,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9501
9773
|
// larger models); max 1h (matches CC_CALL_TIMEOUT_MS so the watchdog
|
|
9502
9774
|
// never outlives the outer abort).
|
|
9503
9775
|
ccTurnTimeoutMs: [10000, 3600000],
|
|
9776
|
+
// W-mq9acoo800177bcb — bounded-concurrency for pre-dispatch validator.
|
|
9777
|
+
// 1 floor (sequential fallback) and 20 ceiling (above this the LLM
|
|
9778
|
+
// provider's per-second rate limits dominate; throughput gains taper).
|
|
9779
|
+
preDispatchEvalConcurrency: [1, 20],
|
|
9504
9780
|
};
|
|
9505
9781
|
for (const [key, [min, max]] of Object.entries(numericFields)) {
|
|
9506
9782
|
if (e[key] !== undefined) {
|
|
@@ -9624,6 +9900,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9624
9900
|
else if (valid.includes(e.copilotStreamMode)) _setEngineConfig('copilotStreamMode', e.copilotStreamMode);
|
|
9625
9901
|
else _clamped.push(`copilotStreamMode: "${e.copilotStreamMode}" not in [on, off] (kept previous value)`);
|
|
9626
9902
|
}
|
|
9903
|
+
// P-mcp-storm — MCP server names to disable for autonomous Copilot agents
|
|
9904
|
+
// (--disable-mcp-server). Accept an array OR a comma/whitespace string;
|
|
9905
|
+
// normalize to a deduped array of trimmed names. Empty clears (inherit all).
|
|
9906
|
+
if (e.copilotAgentDisabledMcpServers !== undefined) {
|
|
9907
|
+
const src = Array.isArray(e.copilotAgentDisabledMcpServers)
|
|
9908
|
+
? e.copilotAgentDisabledMcpServers
|
|
9909
|
+
: String(e.copilotAgentDisabledMcpServers || '').split(/[\s,]+/);
|
|
9910
|
+
const names = [...new Set(src.map(s => String(s == null ? '' : s).trim()).filter(Boolean))];
|
|
9911
|
+
if (names.length) _setEngineConfig('copilotAgentDisabledMcpServers', names);
|
|
9912
|
+
else _deleteEngineConfig('copilotAgentDisabledMcpServers');
|
|
9913
|
+
}
|
|
9627
9914
|
// W-mpmwxkrw000872ec — fontSize allowlist. Clamps invalid values
|
|
9628
9915
|
// (rather than silently failing) so the dashboard bootstrap never
|
|
9629
9916
|
// ends up with an unknown data-font-size attribute.
|
|
@@ -9942,6 +10229,58 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9942
10229
|
} catch (e) { return jsonReply(res, e.statusCode || 500, { error: e.message }); }
|
|
9943
10230
|
}
|
|
9944
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
|
+
|
|
9945
10284
|
// Slim UX surface for the experimental redesigned dashboard.
|
|
9946
10285
|
// The markup/CSS/JS live as fragments under dashboard/slim/ (layout.html +
|
|
9947
10286
|
// styles.css + body.html + js/*.js) and are assembled by buildSlimHtml() —
|
|
@@ -11746,6 +12085,37 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11746
12085
|
return jsonReply(res, 200, { ok: true, cleared: cause, prId });
|
|
11747
12086
|
}},
|
|
11748
12087
|
|
|
12088
|
+
// ─── P-f3c9d0e7: convenience pause/resume endpoints ─────────────────────
|
|
12089
|
+
// Single-call wrappers over `POST /api/settings { engine: { <flag>: bool } }`
|
|
12090
|
+
// for the two operator kill-switches:
|
|
12091
|
+
// - engine.pollingPaused (P-a1f3c2d4) — pauses all PR polling
|
|
12092
|
+
// - engine.autoFixPaused (P-b2e5d8c7) — pauses auto-fix dispatch
|
|
12093
|
+
//
|
|
12094
|
+
// These exist so the dashboard "Emergency Stop" buttons and any future
|
|
12095
|
+
// `minions pause` / `minions autopause` CLI commands are one-line
|
|
12096
|
+
// implementations (no JSON body construction needed).
|
|
12097
|
+
//
|
|
12098
|
+
// Engine pickup contract: engine.js calls reloadConfig() each tick and
|
|
12099
|
+
// reads `config.engine.<flag> === true` at every gate site, so the next
|
|
12100
|
+
// tick observes the change. Setting the flag to `false` on resume is
|
|
12101
|
+
// enough — the engine has its own log-throttle reset
|
|
12102
|
+
// (`_resetPollingPausedLogState`, `_resetAutoFixPausedLogState`) that
|
|
12103
|
+
// fires automatically when the gate reads `!paused`. The engine and
|
|
12104
|
+
// dashboard run in separate processes, so the dashboard can't (and
|
|
12105
|
+
// doesn't need to) scrub engine-process module state directly.
|
|
12106
|
+
{ method: 'POST', path: '/api/engine/polling/pause', desc: 'Pause all PR polling — sets engine.pollingPaused=true (P-f3c9d0e7 convenience over POST /api/settings)', handler: async (req, res) => {
|
|
12107
|
+
return _setEnginePauseFlag(res, 'pollingPaused', true);
|
|
12108
|
+
}},
|
|
12109
|
+
{ method: 'POST', path: '/api/engine/polling/resume', desc: 'Resume PR polling — sets engine.pollingPaused=false', handler: async (req, res) => {
|
|
12110
|
+
return _setEnginePauseFlag(res, 'pollingPaused', false);
|
|
12111
|
+
}},
|
|
12112
|
+
{ method: 'POST', path: '/api/engine/auto-fix/pause', desc: 'Pause auto-fix dispatch — sets engine.autoFixPaused=true', handler: async (req, res) => {
|
|
12113
|
+
return _setEnginePauseFlag(res, 'autoFixPaused', true);
|
|
12114
|
+
}},
|
|
12115
|
+
{ method: 'POST', path: '/api/engine/auto-fix/resume', desc: 'Resume auto-fix dispatch — sets engine.autoFixPaused=false', handler: async (req, res) => {
|
|
12116
|
+
return _setEnginePauseFlag(res, 'autoFixPaused', false);
|
|
12117
|
+
}},
|
|
12118
|
+
|
|
11749
12119
|
{ method: 'POST', path: '/api/plans/create', desc: 'Create a plan from user-provided content', params: 'title, content, project?', handler: async (req, res) => {
|
|
11750
12120
|
const body = await readBody(req);
|
|
11751
12121
|
const { title, content, project: projectName, meetingId } = body;
|
|
@@ -11758,6 +12128,29 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11758
12128
|
return jsonReply(res, 400, { error: 'Plan content must start with a markdown heading (#), bold text (**), or a list item' });
|
|
11759
12129
|
}
|
|
11760
12130
|
|
|
12131
|
+
// P-2e9b54d1: `project` may be a string (today's behavior) OR an array
|
|
12132
|
+
// of project names (cross-repo plan). Normalize to a deduped, trimmed
|
|
12133
|
+
// string[]. Empty values are dropped; ≥2 entries triggers the cross-
|
|
12134
|
+
// repo plan shape (no singular `**Project:**` header, plural
|
|
12135
|
+
// `**Projects:**` line + `<!-- minions:targetProjects=... -->` marker).
|
|
12136
|
+
const rawProjects = Array.isArray(body.project)
|
|
12137
|
+
? body.project
|
|
12138
|
+
: (body.project ? [body.project] : []);
|
|
12139
|
+
const projectNames = [];
|
|
12140
|
+
for (const raw of rawProjects) {
|
|
12141
|
+
const name = String(raw || '').trim();
|
|
12142
|
+
if (name && !projectNames.includes(name)) projectNames.push(name);
|
|
12143
|
+
}
|
|
12144
|
+
if (projectNames.length > 0) {
|
|
12145
|
+
reloadConfig();
|
|
12146
|
+
const projects = shared.getProjects(CONFIG);
|
|
12147
|
+
for (const name of projectNames) {
|
|
12148
|
+
if (!findProjectByName(projects, name)) {
|
|
12149
|
+
return jsonReply(res, 400, { error: formatUnknownProjectError(name, projects) });
|
|
12150
|
+
}
|
|
12151
|
+
}
|
|
12152
|
+
}
|
|
12153
|
+
|
|
11761
12154
|
const plansDir = path.join(MINIONS_DIR, 'plans');
|
|
11762
12155
|
if (!fs.existsSync(plansDir)) fs.mkdirSync(plansDir, { recursive: true });
|
|
11763
12156
|
const slug = shared.slugify(title);
|
|
@@ -11765,8 +12158,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11765
12158
|
const filename = `${slug}-${date}.md`;
|
|
11766
12159
|
const filePath = shared.uniquePath(path.join(plansDir, filename));
|
|
11767
12160
|
|
|
12161
|
+
const projectLines = projectNames.length >= 2
|
|
12162
|
+
? `**Projects:** ${projectNames.join(', ')}\n` +
|
|
12163
|
+
`<!-- minions:targetProjects=${projectNames.join(',')} -->\n`
|
|
12164
|
+
: (projectNames.length === 1 ? `**Project:** ${projectNames[0]}\n` : '');
|
|
11768
12165
|
const header = `# ${title}\n\n` +
|
|
11769
|
-
|
|
12166
|
+
projectLines +
|
|
11770
12167
|
(meetingId ? `**Source Meeting:** ${meetingId}\n` : '') +
|
|
11771
12168
|
`**Created:** ${date}\n**By:** human teammate\n\n---\n\n`;
|
|
11772
12169
|
safeWrite(filePath, header + content);
|
|
@@ -12260,6 +12657,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12260
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 },
|
|
12261
12658
|
// Diagnostics — per-org ADO throttle state (W-mq03l6zh0006f0a1-d).
|
|
12262
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 },
|
|
12263
12663
|
];
|
|
12264
12664
|
|
|
12265
12665
|
// ── Route Dispatcher ────────────────────────────────────────────────────────
|
|
@@ -12458,6 +12858,11 @@ module.exports = {
|
|
|
12458
12858
|
refreshStatusAsync,
|
|
12459
12859
|
handleStatus: _handleStatusRequest,
|
|
12460
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,
|
|
12461
12866
|
// Raw state-file passthrough — exported for direct unit testing.
|
|
12462
12867
|
handleStateRead,
|
|
12463
12868
|
STATE_READ_ALLOWED_DIRS,
|
|
@@ -12480,6 +12885,18 @@ module.exports = {
|
|
|
12480
12885
|
// route's `builder` closure (getWorkItems().map(slimWorkItemForList)).
|
|
12481
12886
|
_slimWorkItemForList: slimWorkItemForList,
|
|
12482
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,
|
|
12483
12900
|
};
|
|
12484
12901
|
|
|
12485
12902
|
// Start the HTTP server only when run directly (node dashboard.js).
|
|
@@ -12570,15 +12987,19 @@ if (require.main === module) {
|
|
|
12570
12987
|
Promise.resolve(queries.getKnowledgeBaseEntries())
|
|
12571
12988
|
.catch(err => console.warn(`[dashboard] KB cache warm failed: ${err && err.message}`));
|
|
12572
12989
|
|
|
12573
|
-
// Auto-open the browser
|
|
12574
|
-
//
|
|
12575
|
-
//
|
|
12576
|
-
|
|
12577
|
-
|
|
12578
|
-
|
|
12579
|
-
|
|
12580
|
-
|
|
12581
|
-
|
|
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.`);
|
|
12582
13003
|
}
|
|
12583
13004
|
|
|
12584
13005
|
// Warm the CC runtime binary cache off the request path so the first CC /
|
|
@@ -12634,12 +13055,39 @@ if (require.main === module) {
|
|
|
12634
13055
|
}
|
|
12635
13056
|
}, 30000).unref();
|
|
12636
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
|
+
}
|
|
12637
13079
|
})();
|
|
12638
13080
|
|
|
12639
13081
|
// ── Graceful shutdown: flush debounced writes + clear runtime port file ──
|
|
12640
13082
|
function _gracefulShutdown() {
|
|
12641
13083
|
try { flushPendingDocSessions(); } catch {}
|
|
12642
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
|
+
}
|
|
12643
13091
|
}
|
|
12644
13092
|
server.on('close', () => _gracefulShutdown());
|
|
12645
13093
|
process.on('SIGTERM', () => { _gracefulShutdown(); process.exit(0); });
|