@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.
@@ -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 by finding which repo URL appears near the PR number in output
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
- for (const pr of sqlPrs) {
751
- if (!pr?.id || seenIds.has(pr.id)) continue;
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
- function openUrlInBrowser(url) {
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
- return { ok: false, error: e && e.message ? e.message : String(e) };
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,
@@ -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.2178",
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/managed-spawn-load.test.js",
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",