@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/engine/lifecycle.js
CHANGED
|
@@ -915,9 +915,30 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
|
|
|
915
915
|
(projects.length === 1 ? projects[0] : null);
|
|
916
916
|
const useCentral = !defaultProject;
|
|
917
917
|
|
|
918
|
-
// Match each PR to its correct project
|
|
918
|
+
// Match each PR to its correct project. W-mqba5ulq000nd255 — primary
|
|
919
|
+
// strategy is canonical scope: parse the evidence URL into a host scope
|
|
920
|
+
// (e.g. `github:opg-microsoft/minions`) and find the configured project
|
|
921
|
+
// whose `getProjectPrScope` matches. This routes the record into the
|
|
922
|
+
// correctly-scoped project file even when the dispatching agent ran
|
|
923
|
+
// against a different project (the cross-project PR case that previously
|
|
924
|
+
// produced stale `_invalidProjectScope` stubs in the wrong file). Falls
|
|
925
|
+
// back to the legacy substring match (handles legacy non-canonical
|
|
926
|
+
// configs), then to a repoName-by-_git fallback, then to the dispatching
|
|
927
|
+
// project (which will get the `_invalidProjectScope` stamp via
|
|
928
|
+
// normalizePrRecord — preserving today's tracking behavior for orphan /
|
|
929
|
+
// unknown-owner URLs).
|
|
919
930
|
function resolveProjectForPr(prId) {
|
|
920
931
|
const evidenceUrl = prEvidence.get(prId) || '';
|
|
932
|
+
// Scope match wins. Use the most-specific URL available: prefer the
|
|
933
|
+
// direct evidence URL captured alongside the PR id; fall back to the
|
|
934
|
+
// generic stdout scan if that's empty.
|
|
935
|
+
const urlScope = shared.getPrScopeInfo(null, evidenceUrl)?.scope || '';
|
|
936
|
+
if (urlScope) {
|
|
937
|
+
for (const p of projects) {
|
|
938
|
+
const projScope = shared.getProjectPrScope(p);
|
|
939
|
+
if (projScope && projScope === urlScope) return p;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
921
942
|
const evidenceText = `${outputText}\n${evidenceUrl}`;
|
|
922
943
|
for (const p of projects) {
|
|
923
944
|
if (!p.prUrlBase) continue;
|
|
@@ -5474,6 +5495,94 @@ function diagnoseEmptyOutput(failureClass, code, elapsedMs) {
|
|
|
5474
5495
|
return `[empty-output: process exited in ${elapsedMs}ms \u2014 possible causes: machine sleep, network unavailability, auth failure]`;
|
|
5475
5496
|
}
|
|
5476
5497
|
|
|
5498
|
+
// W-mqba5ulq000nd255 — Reconciliation sweep: delete PR records flagged with
|
|
5499
|
+
// `_invalidProjectScope: { reason: "pr_scope_mismatch" }` IFF a sibling
|
|
5500
|
+
// record for the same canonical pr.id exists in another project whose scope
|
|
5501
|
+
// matches the PR URL (i.e. the correctly-scoped project owns the canonical
|
|
5502
|
+
// record). Sibling-less mismatches are preserved as tracking. Runs once per
|
|
5503
|
+
// tick from engine.js after the ADO/GitHub reconcile polls finish.
|
|
5504
|
+
//
|
|
5505
|
+
// Returns { pruned, scanned } so the engine tick can log a summary line.
|
|
5506
|
+
function pruneScopeMismatchDuplicatePrs(config) {
|
|
5507
|
+
config = config || getConfig();
|
|
5508
|
+
const projects = shared.getProjects(config) || [];
|
|
5509
|
+
if (projects.length === 0) return { pruned: 0, scanned: 0 };
|
|
5510
|
+
|
|
5511
|
+
// Map project name -> canonical scope so we can find which project IS the
|
|
5512
|
+
// correctly-scoped owner for a given mismatch record.
|
|
5513
|
+
const scopeByProject = new Map();
|
|
5514
|
+
const projectByScope = new Map();
|
|
5515
|
+
for (const p of projects) {
|
|
5516
|
+
if (!p || !p.name) continue;
|
|
5517
|
+
const sc = shared.getProjectPrScope(p);
|
|
5518
|
+
if (!sc) continue;
|
|
5519
|
+
scopeByProject.set(p.name, sc);
|
|
5520
|
+
projectByScope.set(sc, p);
|
|
5521
|
+
}
|
|
5522
|
+
|
|
5523
|
+
// Index all PRs by canonical id across all projects (post-_scope decoration).
|
|
5524
|
+
const store = require('./pull-requests-store');
|
|
5525
|
+
const allPrs = store.readAllPullRequests() || [];
|
|
5526
|
+
const byId = new Map();
|
|
5527
|
+
for (const pr of allPrs) {
|
|
5528
|
+
if (!pr || !pr.id) continue;
|
|
5529
|
+
if (!byId.has(pr.id)) byId.set(pr.id, []);
|
|
5530
|
+
byId.get(pr.id).push(pr);
|
|
5531
|
+
}
|
|
5532
|
+
|
|
5533
|
+
// Build per-project delete sets keyed by id, so we batch one mutation per
|
|
5534
|
+
// affected project file.
|
|
5535
|
+
const deletesByProject = new Map(); // projectName -> Set(prId)
|
|
5536
|
+
let scanned = 0;
|
|
5537
|
+
let pruned = 0;
|
|
5538
|
+
|
|
5539
|
+
for (const records of byId.values()) {
|
|
5540
|
+
if (records.length < 2) continue;
|
|
5541
|
+
for (const rec of records) {
|
|
5542
|
+
scanned++;
|
|
5543
|
+
if (!rec._invalidProjectScope || rec._invalidProjectScope.reason !== 'pr_scope_mismatch') continue;
|
|
5544
|
+
const correctScope = rec._invalidProjectScope.prScope;
|
|
5545
|
+
if (!correctScope) continue;
|
|
5546
|
+
const correctProject = projectByScope.get(correctScope);
|
|
5547
|
+
if (!correctProject) continue; // no configured project owns the URL — keep as tracking
|
|
5548
|
+
// Sibling check: is there a record under the correct scope for the same id?
|
|
5549
|
+
const sibling = records.find(r => r !== rec && r._scope === correctProject.name);
|
|
5550
|
+
if (!sibling) continue;
|
|
5551
|
+
// Safe to prune.
|
|
5552
|
+
const owningScope = rec._scope;
|
|
5553
|
+
if (!owningScope || owningScope === 'central') continue;
|
|
5554
|
+
if (!deletesByProject.has(owningScope)) deletesByProject.set(owningScope, new Set());
|
|
5555
|
+
deletesByProject.get(owningScope).add(rec.id);
|
|
5556
|
+
}
|
|
5557
|
+
}
|
|
5558
|
+
|
|
5559
|
+
if (deletesByProject.size === 0) return { pruned: 0, scanned };
|
|
5560
|
+
|
|
5561
|
+
for (const [projectName, idsToDelete] of deletesByProject) {
|
|
5562
|
+
const project = projects.find(p => p.name === projectName);
|
|
5563
|
+
if (!project) continue;
|
|
5564
|
+
const prPath = projectPrPath(project);
|
|
5565
|
+
try {
|
|
5566
|
+
shared.mutatePullRequests(prPath, (prs) => {
|
|
5567
|
+
const before = prs.length;
|
|
5568
|
+
const next = prs.filter(p => !idsToDelete.has(p?.id));
|
|
5569
|
+
const deleted = before - next.length;
|
|
5570
|
+
if (deleted > 0) {
|
|
5571
|
+
pruned += deleted;
|
|
5572
|
+
for (const id of idsToDelete) {
|
|
5573
|
+
log('info', `[pull-requests] pruned scope-mismatch duplicate ${id} from project=${projectName} (sibling exists in correctly-scoped project)`);
|
|
5574
|
+
}
|
|
5575
|
+
}
|
|
5576
|
+
return next;
|
|
5577
|
+
});
|
|
5578
|
+
} catch (err) {
|
|
5579
|
+
log('warn', `pruneScopeMismatchDuplicatePrs: failed to mutate ${projectName}: ${err?.message || err}`);
|
|
5580
|
+
}
|
|
5581
|
+
}
|
|
5582
|
+
|
|
5583
|
+
return { pruned, scanned };
|
|
5584
|
+
}
|
|
5585
|
+
|
|
5477
5586
|
module.exports = {
|
|
5478
5587
|
checkPlanCompletion,
|
|
5479
5588
|
archivePlan,
|
|
@@ -5482,6 +5591,7 @@ module.exports = {
|
|
|
5482
5591
|
syncPrdItemStatus,
|
|
5483
5592
|
reconcilePrdStatuses,
|
|
5484
5593
|
syncPrsFromOutput,
|
|
5594
|
+
pruneScopeMismatchDuplicatePrs,
|
|
5485
5595
|
updatePrAfterReview,
|
|
5486
5596
|
updatePrAfterFix,
|
|
5487
5597
|
updatePrAfterFixError,
|
package/engine/queries.js
CHANGED
|
@@ -734,6 +734,118 @@ function getPrs(project) {
|
|
|
734
734
|
let _prsCache = null;
|
|
735
735
|
let _prsCacheAt = 0;
|
|
736
736
|
|
|
737
|
+
// ── W-mqba5ulq000nd255 — cross-project PR de-dupe helpers ────────────────────
|
|
738
|
+
//
|
|
739
|
+
// When a dispatching agent in project A reports a PR URL that actually lives
|
|
740
|
+
// on project B's repo, the engine historically wrote the PR into BOTH project
|
|
741
|
+
// files. The A copy got stamped with `_invalidProjectScope` (a tracking
|
|
742
|
+
// breadcrumb) but was never pruned. getPullRequests' legacy first-write-wins
|
|
743
|
+
// dedupe then surfaced the wrong scope first, masking the real record. These
|
|
744
|
+
// helpers collapse those duplicates at read time, with field-merge so we
|
|
745
|
+
// never drop prdItems, sourcePlan, itemType, minionsReview, or
|
|
746
|
+
// _automationFixCauses that lived on the loser.
|
|
747
|
+
|
|
748
|
+
// Timestamps that signal "this record was touched recently". Highest of the
|
|
749
|
+
// three breaks ties when neither record has a scope advantage.
|
|
750
|
+
function _prFreshnessMs(pr) {
|
|
751
|
+
if (!pr) return 0;
|
|
752
|
+
const cands = [pr.lastPushedAt, pr.mergedAt, pr.lastPolledAt, pr.lastPolled, pr._attachedAt, pr.created];
|
|
753
|
+
let best = 0;
|
|
754
|
+
for (const c of cands) {
|
|
755
|
+
if (!c) continue;
|
|
756
|
+
const t = typeof c === 'number' ? c : Date.parse(c);
|
|
757
|
+
if (Number.isFinite(t) && t > best) best = t;
|
|
758
|
+
}
|
|
759
|
+
return best;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function _groupPrRecordsById(prs) {
|
|
763
|
+
const groups = new Map();
|
|
764
|
+
const order = [];
|
|
765
|
+
for (const pr of prs) {
|
|
766
|
+
if (!pr || !pr.id) continue;
|
|
767
|
+
if (!groups.has(pr.id)) {
|
|
768
|
+
const g = [];
|
|
769
|
+
groups.set(pr.id, g);
|
|
770
|
+
order.push(g);
|
|
771
|
+
}
|
|
772
|
+
groups.get(pr.id).push(pr);
|
|
773
|
+
}
|
|
774
|
+
return order;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Choose the winning record from a group of cross-scope duplicates. Fields
|
|
778
|
+
// the winner left empty are filled from the loser(s). Returns the winner +
|
|
779
|
+
// the dropped sibling records so the caller can log the pruning decision.
|
|
780
|
+
function _pickPrDedupeWinner(group, projects) {
|
|
781
|
+
if (group.length === 1) return { winner: group[0], dropped: [] };
|
|
782
|
+
|
|
783
|
+
// Map each project name -> its canonical scope, so we can detect when a
|
|
784
|
+
// record's _scope matches the PR url-derived scope (preference #2 below).
|
|
785
|
+
const projectScopeByName = new Map();
|
|
786
|
+
for (const p of projects || []) {
|
|
787
|
+
if (!p || !p.name) continue;
|
|
788
|
+
const sc = shared.getProjectPrScope(p);
|
|
789
|
+
if (sc) projectScopeByName.set(p.name, sc);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Resolve the PR's "correct" scope from its URL (independent of _scope).
|
|
793
|
+
// Falls back to the canonical id when the URL is absent. Group members
|
|
794
|
+
// share the same id so any non-empty url is sufficient.
|
|
795
|
+
let urlScope = '';
|
|
796
|
+
for (const pr of group) {
|
|
797
|
+
const info = shared.getPrScopeInfo(pr, pr.url || '');
|
|
798
|
+
if (info && info.scope) { urlScope = info.scope; break; }
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Score each candidate. Higher is better. Composite key:
|
|
802
|
+
// bit 2: no _invalidProjectScope stamp (best signal)
|
|
803
|
+
// bit 1: _scope matches url-derived scope
|
|
804
|
+
// bit 0: freshness rank (broken into a separate tiebreak after)
|
|
805
|
+
function scoreOf(pr) {
|
|
806
|
+
const scopeOk = !pr._invalidProjectScope ? 1 : 0;
|
|
807
|
+
const projectScope = projectScopeByName.get(pr._scope) || '';
|
|
808
|
+
const matchesUrl = urlScope && projectScope && projectScope === urlScope ? 1 : 0;
|
|
809
|
+
return (scopeOk << 1) | matchesUrl;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
let winnerIdx = 0;
|
|
813
|
+
let winnerScore = scoreOf(group[0]);
|
|
814
|
+
let winnerFresh = _prFreshnessMs(group[0]);
|
|
815
|
+
for (let i = 1; i < group.length; i++) {
|
|
816
|
+
const s = scoreOf(group[i]);
|
|
817
|
+
const f = _prFreshnessMs(group[i]);
|
|
818
|
+
if (s > winnerScore || (s === winnerScore && f > winnerFresh)) {
|
|
819
|
+
winnerIdx = i;
|
|
820
|
+
winnerScore = s;
|
|
821
|
+
winnerFresh = f;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const winner = group[winnerIdx];
|
|
826
|
+
const dropped = group.filter((_, i) => i !== winnerIdx);
|
|
827
|
+
|
|
828
|
+
// Field-merge: fill empty winner fields from any loser. Winner wins on
|
|
829
|
+
// every field it has; losers only contribute where the winner is missing
|
|
830
|
+
// the field entirely (undefined, null, '', or empty array).
|
|
831
|
+
function isEmpty(v) {
|
|
832
|
+
if (v == null) return true;
|
|
833
|
+
if (typeof v === 'string') return v === '';
|
|
834
|
+
if (Array.isArray(v)) return v.length === 0;
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
const mergeKeys = ['prdItems', 'sourcePlan', 'itemType', 'minionsReview', '_automationFixCauses'];
|
|
838
|
+
for (const loser of dropped) {
|
|
839
|
+
for (const key of mergeKeys) {
|
|
840
|
+
if (isEmpty(winner[key]) && !isEmpty(loser[key])) {
|
|
841
|
+
winner[key] = loser[key];
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return { winner, dropped };
|
|
847
|
+
}
|
|
848
|
+
|
|
737
849
|
function getPullRequests(config) {
|
|
738
850
|
const now = Date.now();
|
|
739
851
|
if (_prsCache && (now - _prsCacheAt) < 1000) return _prsCache;
|
|
@@ -741,14 +853,34 @@ function getPullRequests(config) {
|
|
|
741
853
|
const projects = getProjects(config);
|
|
742
854
|
const projectByName = new Map(projects.map(p => [p.name, p]));
|
|
743
855
|
const allPrs = [];
|
|
744
|
-
const seenIds = new Set();
|
|
745
856
|
|
|
746
857
|
// SQL is the canonical (and only) PR store after Phase 9.
|
|
747
858
|
const store = require('./pull-requests-store');
|
|
748
859
|
const sqlPrs = store.readAllPullRequests() || [];
|
|
749
860
|
|
|
750
|
-
|
|
751
|
-
|
|
861
|
+
// W-mqba5ulq000nd255 — Read-side de-dupe across project scopes. When the
|
|
862
|
+
// same canonical pr.id appears under multiple scopes (e.g. an agent in
|
|
863
|
+
// project A reported a PR URL that actually lives on project B's repo,
|
|
864
|
+
// landing a stub stamped with _invalidProjectScope in A while the real
|
|
865
|
+
// record lives in B), collapse to one record using the preference order:
|
|
866
|
+
// 1. record where `_invalidProjectScope` is FALSY
|
|
867
|
+
// 2. record whose scope matches the PR url-derived scope
|
|
868
|
+
// 3. most-recently-updated (lastPushedAt / mergedAt / _attachedAt)
|
|
869
|
+
// 4. first-seen
|
|
870
|
+
// Then merge non-conflicting fields from the loser into the winner so we
|
|
871
|
+
// never lose prdItems, sourcePlan, itemType, minionsReview, or
|
|
872
|
+
// _automationFixCauses if only the loser carried them.
|
|
873
|
+
const groups = _groupPrRecordsById(sqlPrs);
|
|
874
|
+
for (const group of groups) {
|
|
875
|
+
const { winner, dropped } = _pickPrDedupeWinner(group, projects);
|
|
876
|
+
if (dropped.length > 0) {
|
|
877
|
+
try {
|
|
878
|
+
const droppedScopes = dropped.map(d => d._scope || 'unknown').join(', ');
|
|
879
|
+
shared.log('info', `[pull-requests] de-duped ${winner.id} across ${group.length} records, kept _project=${winner._scope || 'unknown'}, dropped [${droppedScopes}]`);
|
|
880
|
+
} catch { /* logging is best-effort */ }
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const pr = winner;
|
|
752
884
|
const scope = pr._scope;
|
|
753
885
|
delete pr._scope;
|
|
754
886
|
if (scope === 'central') {
|
|
@@ -769,7 +901,6 @@ function getPullRequests(config) {
|
|
|
769
901
|
// _noOpFixes themselves.
|
|
770
902
|
pr._pausedCauses = shared.getPrPausedCauses(pr);
|
|
771
903
|
allPrs.push(pr);
|
|
772
|
-
seenIds.add(pr.id);
|
|
773
904
|
}
|
|
774
905
|
allPrs.sort((a, b) => {
|
|
775
906
|
// W-mpej044m00076d63: sort by the full ISO `created` timestamp DESC so
|
|
@@ -2597,6 +2728,10 @@ module.exports = {
|
|
|
2597
2728
|
|
|
2598
2729
|
// Pull requests
|
|
2599
2730
|
getPrs, getPullRequests,
|
|
2731
|
+
// W-mqba5ulq000nd255 — exported for direct unit testing of the cross-project
|
|
2732
|
+
// PR dedupe helpers.
|
|
2733
|
+
_pickPrDedupeWinner,
|
|
2734
|
+
_groupPrRecordsById,
|
|
2600
2735
|
|
|
2601
2736
|
// Skills
|
|
2602
2737
|
collectSkillFiles, getSkills, getSkillIndex, invalidateSkillsCache,
|
package/engine/shared.js
CHANGED
|
@@ -484,18 +484,99 @@ function resolveEngineCacheDir(fallbackEngineDir) {
|
|
|
484
484
|
// Cross-platform URL opener. Uses execSync so failures fall through the
|
|
485
485
|
// try/catch and the caller sees them. Dashboard self-open and `minions dash`
|
|
486
486
|
// / `minions restart` post-health open all funnel through here.
|
|
487
|
-
|
|
487
|
+
//
|
|
488
|
+
// Every call writes a structured `event: 'browser-open'` entry to log.json
|
|
489
|
+
// (via shared.log) and bumps a per-reason counter in `_engine.browserOpens`
|
|
490
|
+
// in metrics.json. Callers MUST pass `opts.reason` so the log/metric is
|
|
491
|
+
// attributable; a missing reason is logged at `warn` level with
|
|
492
|
+
// `reason: 'unknown'` to make a regression loud rather than silent.
|
|
493
|
+
//
|
|
494
|
+
// `MINIONS_NO_AUTO_OPEN=1` short-circuits the open and writes a debug-level
|
|
495
|
+
// SUPPRESSED entry instead — this lets us prove the kill-switch is firing
|
|
496
|
+
// in production (absence of opens otherwise looks identical whether the
|
|
497
|
+
// guard worked, nobody tried to open, or the guard regressed).
|
|
498
|
+
//
|
|
499
|
+
// Note: browser windows opened by external MCP servers (e.g. @playwright/mcp)
|
|
500
|
+
// do NOT funnel through this primitive and are therefore NOT logged. See
|
|
501
|
+
// MTG-mqam2mjs000e39d7.
|
|
502
|
+
//
|
|
503
|
+
// @param {string} url - URL to open in the user's default browser.
|
|
504
|
+
// @param {Object} [opts]
|
|
505
|
+
// @param {string} [opts.reason] - Why we are opening (e.g. 'cli-dash-warm',
|
|
506
|
+
// 'cli-start-force-open', 'dashboard-self-open'). Required-in-practice;
|
|
507
|
+
// missing values are logged at warn level.
|
|
508
|
+
// @param {string} [opts.callerHint] - Optional `file:line` to disambiguate
|
|
509
|
+
// when one reason has multiple call sites.
|
|
510
|
+
// @returns {{ok: boolean, error?: string, suppressed?: boolean}}
|
|
511
|
+
function openUrlInBrowser(url, opts = {}) {
|
|
512
|
+
const reason = (opts && typeof opts.reason === 'string' && opts.reason) ? opts.reason : 'unknown';
|
|
513
|
+
const callerHint = (opts && typeof opts.callerHint === 'string') ? opts.callerHint : null;
|
|
514
|
+
// Heuristic-driven opens (no explicit user action this dispatch) log at warn
|
|
515
|
+
// so a leak shows up as a warn cluster in the dashboard log filter.
|
|
516
|
+
const HEURISTIC_REASONS = new Set(['cli-restart-no-beacon', 'dashboard-self-open']);
|
|
517
|
+
let level = HEURISTIC_REASONS.has(reason) ? 'warn' : 'info';
|
|
518
|
+
if (reason === 'unknown') level = 'warn';
|
|
519
|
+
const callerStack = (new Error()).stack.split('\n').slice(2, 5).map(s => s.trim()).join(' | ');
|
|
520
|
+
const baseMeta = {
|
|
521
|
+
event: 'browser-open',
|
|
522
|
+
url,
|
|
523
|
+
reason,
|
|
524
|
+
callerHint,
|
|
525
|
+
callerStack,
|
|
526
|
+
pid: process.pid,
|
|
527
|
+
platform: process.platform,
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// Kill-switch: log SUPPRESSED at debug level and skip execSync. Centralizing
|
|
531
|
+
// the check here means every caller (existing + future) is observable, and
|
|
532
|
+
// outer guards (e.g. ralph's W-mqb9y83o000le41e leak fixes) become belt-and-
|
|
533
|
+
// suspenders without breaking the log audit trail.
|
|
534
|
+
if (process.env.MINIONS_NO_AUTO_OPEN) {
|
|
535
|
+
log('debug', `[browser-open] SUPPRESSED reason=${reason} url=${url} (MINIONS_NO_AUTO_OPEN=1)`, {
|
|
536
|
+
...baseMeta,
|
|
537
|
+
suppressed: true,
|
|
538
|
+
});
|
|
539
|
+
_bumpBrowserOpenMetric(reason, { suppressed: true });
|
|
540
|
+
return { ok: false, suppressed: true };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
log(level, `[browser-open] reason=${reason} url=${url}`, baseMeta);
|
|
544
|
+
_bumpBrowserOpenMetric(reason);
|
|
545
|
+
|
|
488
546
|
const { execSync } = require('child_process');
|
|
489
547
|
try {
|
|
490
548
|
if (process.platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore', windowsHide: true });
|
|
491
549
|
else if (process.platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
492
550
|
else execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
|
551
|
+
log(level, `[browser-open] ok reason=${reason}`, { ...baseMeta, ok: true });
|
|
493
552
|
return { ok: true };
|
|
494
553
|
} catch (e) {
|
|
495
|
-
|
|
554
|
+
const errMsg = e && e.message ? e.message : String(e);
|
|
555
|
+
log(level, `[browser-open] failed reason=${reason} error=${errMsg}`, { ...baseMeta, ok: false, error: errMsg });
|
|
556
|
+
return { ok: false, error: errMsg };
|
|
496
557
|
}
|
|
497
558
|
}
|
|
498
559
|
|
|
560
|
+
// Bump per-reason counter under metrics._engine.browserOpens. Best-effort —
|
|
561
|
+
// metrics.json writes must never throw out of openUrlInBrowser. Schema:
|
|
562
|
+
// _engine.browserOpens.<reason> = { count, suppressedCount, lastAt }
|
|
563
|
+
function _bumpBrowserOpenMetric(reason, { suppressed = false } = {}) {
|
|
564
|
+
try {
|
|
565
|
+
mutateMetrics((metrics) => {
|
|
566
|
+
if (!metrics._engine || typeof metrics._engine !== 'object') metrics._engine = {};
|
|
567
|
+
if (!metrics._engine.browserOpens || typeof metrics._engine.browserOpens !== 'object') {
|
|
568
|
+
metrics._engine.browserOpens = {};
|
|
569
|
+
}
|
|
570
|
+
const slot = metrics._engine.browserOpens[reason] || { count: 0, suppressedCount: 0, lastAt: null };
|
|
571
|
+
if (suppressed) slot.suppressedCount = (slot.suppressedCount || 0) + 1;
|
|
572
|
+
else slot.count = (slot.count || 0) + 1;
|
|
573
|
+
slot.lastAt = new Date().toISOString();
|
|
574
|
+
metrics._engine.browserOpens[reason] = slot;
|
|
575
|
+
return metrics;
|
|
576
|
+
});
|
|
577
|
+
} catch { /* metric best-effort — never throw out of openUrlInBrowser */ }
|
|
578
|
+
}
|
|
579
|
+
|
|
499
580
|
function _flushLogBuffer() {
|
|
500
581
|
if (_logBuffer.length === 0) return;
|
|
501
582
|
const drained = _logBuffer.splice(0);
|
|
@@ -2533,6 +2614,11 @@ const ENGINE_DEFAULTS = {
|
|
|
2533
2614
|
cleanupEvery: 60, // runCleanup + MCP sync every N ticks (~10 min at default 10s tick)
|
|
2534
2615
|
planCompletionScanEvery: 60, // periodic PRD completion sweep (~10 min at default 10s tick) — catches plans completed while engine was down
|
|
2535
2616
|
watchPollEvery: 18, // checkWatches every N ticks (~3 min at default 10s tick)
|
|
2617
|
+
// P-b2c3d4e5: cadence for MEMORY_BASELINE log line + diagnostics-memory.json
|
|
2618
|
+
// sidecar write driven from engine.js tickInner. Defaults to 6 ticks ≈ 60s
|
|
2619
|
+
// at the default 10s tickInterval. Set to 0 (or any non-positive integer)
|
|
2620
|
+
// to disable both the log emission and sidecar write cleanly (operator opt-out).
|
|
2621
|
+
memoryBaselineEveryTicks: 6,
|
|
2536
2622
|
stalledDispatchSweepEvery: 120, // stalled-dispatch retry sweep (~20 min at default 10s tick) — only fires when all agents idle
|
|
2537
2623
|
// W-mp5trwh60008386d: per-PR 404 must repeat across N consecutive successful base-repo probes
|
|
2538
2624
|
// before flipping a PR to `abandoned`. A single 404 on `repos/{slug}/pulls/{n}` can be a transient
|
|
@@ -4284,6 +4370,30 @@ function getProjects(config) {
|
|
|
4284
4370
|
return [];
|
|
4285
4371
|
}
|
|
4286
4372
|
|
|
4373
|
+
// Generic, ambiguous project-name tokens that read poorly as a bare label in
|
|
4374
|
+
// the dashboard. For these, projectDisplayName derives a `<parentFolder>/<name>`
|
|
4375
|
+
// label from the project's localPath. `src` is the required case. DISPLAY ONLY —
|
|
4376
|
+
// the project `name` field is a load-bearing identifier and is never mutated.
|
|
4377
|
+
const GENERIC_PROJECT_NAME_TOKENS = new Set(['src', 'source', 'repo']);
|
|
4378
|
+
|
|
4379
|
+
// Human-facing label for a project. Returns `project.name` unchanged for normal
|
|
4380
|
+
// names. When the name is a generic token (e.g. `src`), derives the parent
|
|
4381
|
+
// folder one level up from localPath and returns `<parentBasename>/<name>` —
|
|
4382
|
+
// e.g. name `src` at localPath `C:/office/src` → `office/src`. Falls back to the
|
|
4383
|
+
// bare name when localPath is missing or the parent can't be derived. Never
|
|
4384
|
+
// mutates the project; preserves the original name casing.
|
|
4385
|
+
function projectDisplayName(project) {
|
|
4386
|
+
if (!project || typeof project !== 'object') return '';
|
|
4387
|
+
const name = project.name == null ? '' : String(project.name);
|
|
4388
|
+
if (!GENERIC_PROJECT_NAME_TOKENS.has(name.trim().toLowerCase())) return name;
|
|
4389
|
+
const localPath = project.localPath == null ? '' : String(project.localPath);
|
|
4390
|
+
const normalized = localPath.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
4391
|
+
if (!normalized) return name;
|
|
4392
|
+
const parent = path.basename(path.dirname(normalized));
|
|
4393
|
+
if (!parent || parent === '.') return name;
|
|
4394
|
+
return `${parent}/${name}`;
|
|
4395
|
+
}
|
|
4396
|
+
|
|
4287
4397
|
function formatUnknownProjectError(projectName, projects = []) {
|
|
4288
4398
|
const known = projects.map(p => p.name).filter(Boolean).join(', ') || '(none configured)';
|
|
4289
4399
|
return `Project "${projectName}" not found. Known projects: ${known}`;
|
|
@@ -7721,6 +7831,7 @@ module.exports = {
|
|
|
7721
7831
|
mergeManifestAllowedTools,
|
|
7722
7832
|
formatManifestRejection,
|
|
7723
7833
|
getProjects,
|
|
7834
|
+
projectDisplayName,
|
|
7724
7835
|
formatUnknownProjectError,
|
|
7725
7836
|
findProjectByName,
|
|
7726
7837
|
findProjectByNameOrPath,
|
package/engine/watchdog.js
CHANGED
|
@@ -146,6 +146,12 @@ async function tick(opts) {
|
|
|
146
146
|
detached: true,
|
|
147
147
|
stdio: 'ignore',
|
|
148
148
|
windowsHide: true,
|
|
149
|
+
// W-mqb9y83o — suppress auto-open in the respawned dashboard. The
|
|
150
|
+
// watchdog has no user-intent signal (it fires on health failure, not
|
|
151
|
+
// user action), so the post-restart hook MUST NOT pop a browser tab.
|
|
152
|
+
// The CLI's spawnFullStackAndVerify reads this env var as a hard
|
|
153
|
+
// kill-switch via MINIONS_NO_AUTO_OPEN=1.
|
|
154
|
+
env: { ...process.env, MINIONS_NO_AUTO_OPEN: '1' },
|
|
149
155
|
});
|
|
150
156
|
if (child && typeof child.unref === 'function') child.unref();
|
|
151
157
|
logLine(minionsHome, `spawned minions ${action} pid=${child && child.pid} (detached)`);
|
package/engine.js
CHANGED
|
@@ -168,7 +168,12 @@ const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, reconci
|
|
|
168
168
|
syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
|
|
169
169
|
updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs,
|
|
170
170
|
isItemCompleted, classifyFailure: classifyFailureFallback, diagnoseEmptyOutput, processPendingRebases, resolveWorkItemPath,
|
|
171
|
-
mergeArtifactNotes, promoteCompletionArtifacts } = require('./engine/lifecycle');
|
|
171
|
+
mergeArtifactNotes, promoteCompletionArtifacts, pruneScopeMismatchDuplicatePrs } = require('./engine/lifecycle');
|
|
172
|
+
|
|
173
|
+
// ─── Diagnostics: memory + event-loop + GC sampler (P-a1b2c3d4 / P-b2c3d4e5) ─
|
|
174
|
+
|
|
175
|
+
const diagnosticsMemory = require('./engine/diagnostics-memory');
|
|
176
|
+
const DIAGNOSTICS_MEMORY_PATH = path.join(ENGINE_DIR, 'diagnostics-memory.json');
|
|
172
177
|
|
|
173
178
|
// ─── Agent Spawner ──────────────────────────────────────────────────────────
|
|
174
179
|
|
|
@@ -8670,6 +8675,20 @@ async function tickInner() {
|
|
|
8670
8675
|
}
|
|
8671
8676
|
if (reconcilePolls.length) await Promise.allSettled(reconcilePolls);
|
|
8672
8677
|
if (_isTickStale(myGeneration)) return;
|
|
8678
|
+
|
|
8679
|
+
// W-mqba5ulq000nd255 — Cross-project PR scope-mismatch sweep. Cleans up
|
|
8680
|
+
// stale records that were stamped with `_invalidProjectScope` and have a
|
|
8681
|
+
// sibling in the correctly-scoped project. Cheap (a single read of all
|
|
8682
|
+
// PRs + a per-affected-project mutation). Runs once per reconcile tick;
|
|
8683
|
+
// sibling-less stamps are preserved as tracking signals.
|
|
8684
|
+
try {
|
|
8685
|
+
const result = pruneScopeMismatchDuplicatePrs(config);
|
|
8686
|
+
if (result?.pruned > 0) {
|
|
8687
|
+
log('info', `[pull-requests] scope-mismatch sweep pruned ${result.pruned} duplicate record(s) across ${result.scanned} scanned`);
|
|
8688
|
+
}
|
|
8689
|
+
} catch (err) {
|
|
8690
|
+
log('warn', `[pull-requests] scope-mismatch sweep error: ${err?.message || err}`);
|
|
8691
|
+
}
|
|
8673
8692
|
}
|
|
8674
8693
|
|
|
8675
8694
|
// 2.9. Stalled dispatch detection — auto-retry failed items blocking the graph (~20 min — cadence in ENGINE_DEFAULTS.stalledDispatchSweepEvery)
|
|
@@ -9209,6 +9228,29 @@ async function tickInner() {
|
|
|
9209
9228
|
if (!discoveryOk) {
|
|
9210
9229
|
log('warn', 'Discovery failed after pending dispatch pass — new work may be stale until next tick');
|
|
9211
9230
|
}
|
|
9231
|
+
|
|
9232
|
+
// 6. Periodic memory baseline (P-b2c3d4e5).
|
|
9233
|
+
// Sample once every ENGINE_DEFAULTS.memoryBaselineEveryTicks ticks (default
|
|
9234
|
+
// 6 ≈ 60s at the default 10s tickInterval). When the cadence is <= 0,
|
|
9235
|
+
// sampling is disabled cleanly — no log emission, no sidecar write
|
|
9236
|
+
// (operator opt-out). The diagnostics-memory.json sidecar is a passive
|
|
9237
|
+
// cache (single-object latest sample) — gitignored and exempt from the
|
|
9238
|
+
// SQL-first state rule, same as engine/dashboard-port.json.
|
|
9239
|
+
safe('memoryBaseline', () => emitMemoryBaseline(tickCount));
|
|
9240
|
+
}
|
|
9241
|
+
|
|
9242
|
+
function emitMemoryBaseline(tickN) {
|
|
9243
|
+
const every = Number(ENGINE_DEFAULTS.memoryBaselineEveryTicks);
|
|
9244
|
+
if (!Number.isFinite(every) || every <= 0) return;
|
|
9245
|
+
if ((tickN % every) !== 0) return;
|
|
9246
|
+
const sample = diagnosticsMemory.sampleSelf({ label: 'engine' });
|
|
9247
|
+
diagnosticsMemory.recordSample(sample);
|
|
9248
|
+
try { safeWrite(DIAGNOSTICS_MEMORY_PATH, sample); }
|
|
9249
|
+
catch (e) { log('warn', `memoryBaseline sidecar write: ${e.message}`); }
|
|
9250
|
+
log('info',
|
|
9251
|
+
`MEMORY_BASELINE engine rss=${sample.rss} heapUsed=${sample.heapUsed} ` +
|
|
9252
|
+
`eventLoopLagP99=${sample.eventLoopLagP99.toFixed(2)}ms ` +
|
|
9253
|
+
`gcPauses=${sample.gcCount}/${sample.gcPausesTotalMs.toFixed(2)}ms tickN=${tickN}`);
|
|
9212
9254
|
}
|
|
9213
9255
|
|
|
9214
9256
|
// ─── Exports (for engine/cli.js and other modules) ──────────────────────────
|
|
@@ -9287,6 +9329,8 @@ module.exports = {
|
|
|
9287
9329
|
resolvePreDispatchEvalConcurrency,
|
|
9288
9330
|
// P-c2e5a1d9-a — exported for testing the tick-generation force-release path
|
|
9289
9331
|
_isTickStale,
|
|
9332
|
+
// P-b2c3d4e5 — exported for testing the memory baseline emitter + sidecar path
|
|
9333
|
+
emitMemoryBaseline, DIAGNOSTICS_MEMORY_PATH,
|
|
9290
9334
|
get tickGeneration() { return tickGeneration; },
|
|
9291
9335
|
set tickGeneration(v) { tickGeneration = v; },
|
|
9292
9336
|
get tickRunning() { return tickRunning; },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2179",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"test:e2e:report": "npx playwright show-report test/playwright/report",
|
|
26
26
|
"test:e2e:video": "npx playwright test --video=on --headed",
|
|
27
27
|
"test:all": "node test/run-parallel.js && node test/minions-tests.js && node test/integration/run.js",
|
|
28
|
-
"test:perf": "node test/perf/
|
|
28
|
+
"test:perf": "node test/perf/run.js",
|
|
29
29
|
"test:e2e:accept": "node test/playwright/accept-baseline.js",
|
|
30
30
|
"test:e2e:accept-force": "node test/playwright/accept-baseline.js --force",
|
|
31
31
|
"test:setup": "npx playwright install chromium",
|