@yemi33/minions 0.1.1648 → 0.1.1649

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1649 (2026-05-01)
4
+
5
+ ### Features
6
+ - ADO build poll repositoryId GUID handling
7
+
8
+ ### Fixes
9
+ - yemi33/minions#1925
10
+
3
11
  ## 0.1.1648 (2026-05-01)
4
12
 
5
13
  ### Other
@@ -16,6 +16,7 @@ function prRow(pr) {
16
16
  const reviewTitle = reviewEscalated ? 'Review/re-review and review-fix automation stopped after evalMaxIterations; build-fix and conflict-fix automation may still run.' : '';
17
17
  const buildClass = pr.buildFixEscalated ? 'build-escalated' : pr._buildStatusStale ? 'build-stale' : pr.buildStatus === 'passing' ? 'build-pass' : pr.buildStatus === 'failing' ? 'build-fail' : pr.buildStatus === 'running' ? 'building' : 'no-build';
18
18
  const buildLabel = pr.buildFixEscalated ? 'escalated (' + (pr.buildFixAttempts || '?') + ' fixes)' : (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '');
19
+ const buildTitle = pr._buildStatusDetail || '';
19
20
  const statusClass = pr.status === 'merged' ? 'merged' : pr.status === 'abandoned' ? 'rejected' : pr.status === 'active' ? 'active' : 'draft';
20
21
  const statusLabel = pr.status || 'active';
21
22
  const branchError = pr._branchResolutionError?.reason || '';
@@ -34,7 +35,7 @@ function prRow(pr) {
34
35
  '<td><span class="' + branchClass + '"' + (branchError ? ' title="' + escapeHtml(branchError) + '"' : '') + '>' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
35
36
  '<td><span class="pr-badge ' + reviewClass + '"' + (reviewTitle ? ' title="' + escapeHtml(reviewTitle) + '"' : '') + '>' + escapeHtml(reviewLabel) + '</span></td>' +
36
37
  '<td>' + (sq.reviewer && sq.status !== 'waiting' ? '<span class="pr-agent" title="' + escapeHtml(sq.note || '') + '">' + escapeHtml(sq.reviewer) + '</span>' : sq.reviewer && sq.status === 'waiting' ? '<span class="pr-agent" style="color:var(--muted)" title="Vote pending confirmation">' + escapeHtml(sq.reviewer) + '…</span>' : pr.reviewedBy && pr.reviewedBy.length ? '<span class="pr-agent">' + escapeHtml(pr.reviewedBy.join(', ')) + '</span>' : '<span style="color:var(--muted);font-size:11px">—</span>') + '</td>' +
37
- '<td><span class="pr-badge ' + buildClass + '">' + escapeHtml(buildLabel) + '</span></td>' +
38
+ '<td><span class="pr-badge ' + buildClass + '"' + (buildTitle ? ' title="' + escapeHtml(buildTitle) + '"' : '') + '>' + escapeHtml(buildLabel) + '</span></td>' +
38
39
  '<td><span class="pr-badge ' + statusClass + '">' + escapeHtml(statusLabel) + '</span></td>' +
39
40
  '<td><span class="pr-date">' + escapeHtml((pr.created || '—').slice(0, 16).replace('T', ' ')) + '</span></td>' +
40
41
  '<td><button class="pr-pager-btn" style="font-size:9px;padding:1px 5px;color:var(--red);border-color:var(--red)" data-pr-id="' + escapeHtml(String(prId)) + '" onclick="event.stopPropagation();unlinkPr(this.dataset.prId)" title="Remove from tracking">x</button></td>' +
@@ -42,7 +42,7 @@ if (!prNumberArg) {
42
42
  ' --project <name> Scope search to one project (optional)',
43
43
  '',
44
44
  'Output: JSON with fields: prNumber, title, branch, status, reviewStatus,',
45
- ' buildStatus, buildErrorLog (if failing), mergeConflict, url, project, source',
45
+ ' buildStatus, buildStatusStale/detail (if stale), buildErrorLog (if failing), mergeConflict, url, project, source',
46
46
  '',
47
47
  'buildStatus values: passing | failing | running | none',
48
48
  'reviewStatus values: approved | changes-requested | waiting | pending',
@@ -73,6 +73,10 @@ function findInCache(projects) {
73
73
  source: 'cached',
74
74
  };
75
75
  if (pr.buildErrorLog) out.buildErrorLog = pr.buildErrorLog;
76
+ if (pr._buildStatusStale) {
77
+ out.buildStatusStale = true;
78
+ if (pr._buildStatusDetail) out.buildStatusDetail = pr._buildStatusDetail;
79
+ }
76
80
  if (pr._mergeConflict) out.mergeConflict = true;
77
81
  return out;
78
82
  }
package/engine/ado.js CHANGED
@@ -26,17 +26,31 @@ const getAdoPrUrl = (project, prNumber) => {
26
26
  const repoPath = encodeURIComponent(project.repoName || project.repositoryId || '');
27
27
  return `https://dev.azure.com/${project.adoOrg}/${project.adoProject}/_git/${repoPath}/pullrequest/${prNumber}`;
28
28
  };
29
+ const ADO_GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
29
30
 
30
31
  function isGitHubProject(project) {
31
32
  return String(project?.repoHost || '').toLowerCase() === 'github';
32
33
  }
33
34
 
34
- function getAdoRepositoryId(project) {
35
+ function isAdoGuid(value) {
36
+ return ADO_GUID_RE.test(String(value || '').trim());
37
+ }
38
+
39
+ function getAdoRepositoryLookupKey(project) {
35
40
  const repositoryId = String(project?.repositoryId || '').trim();
36
41
  if (repositoryId) return repositoryId;
37
42
  return String(project?.repoName || '').trim();
38
43
  }
39
44
 
45
+ function getAdoRepositoryId(project) {
46
+ return getAdoRepositoryLookupKey(project);
47
+ }
48
+
49
+ function getConfiguredAdoRepositoryGuid(project) {
50
+ const repositoryId = String(project?.repositoryId || '').trim();
51
+ return isAdoGuid(repositoryId) ? repositoryId : '';
52
+ }
53
+
40
54
  function getAdoProjectLabel(project) {
41
55
  return project?.name || project?.repoName || `${project?.adoOrg || 'unknown-org'}/${project?.adoProject || 'unknown-project'}`;
42
56
  }
@@ -45,6 +59,85 @@ function logMissingAdoRepository(project, purpose) {
45
59
  log('error', `${purpose} disabled for project ${getAdoProjectLabel(project)}: missing project.repositoryId and project.repoName; configure one so Azure DevOps repository API calls can target the repo`);
46
60
  }
47
61
 
62
+ function adoConfigPath() {
63
+ return path.join(shared.MINIONS_DIR, 'config.json');
64
+ }
65
+
66
+ function sameAdoProject(a, b) {
67
+ if (!a || !b) return false;
68
+ if (a.name && b.name && a.name === b.name) return true;
69
+ if (a.localPath && b.localPath && path.resolve(a.localPath) === path.resolve(b.localPath)) return true;
70
+ return String(a.adoOrg || '') === String(b.adoOrg || '')
71
+ && String(a.adoProject || '') === String(b.adoProject || '')
72
+ && String(a.repoName || '') === String(b.repoName || '')
73
+ && String(a.repoHost || 'ado').toLowerCase() === String(b.repoHost || 'ado').toLowerCase();
74
+ }
75
+
76
+ function persistAdoRepositoryGuid(project, guid, repoName) {
77
+ if (!isAdoGuid(guid)) return;
78
+ const previous = String(project?.repositoryId || '').trim();
79
+ project.repositoryId = guid;
80
+ if (!project.repoName && repoName) project.repoName = repoName;
81
+ if (previous === guid) return;
82
+
83
+ try {
84
+ let persisted = false;
85
+ mutateJsonFileLocked(adoConfigPath(), (config) => {
86
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return config;
87
+ if (!Array.isArray(config.projects)) return config;
88
+ const target = config.projects.find(p => sameAdoProject(p, project));
89
+ if (!target) return config;
90
+ target.repositoryId = guid;
91
+ if (!target.repoName && repoName) target.repoName = repoName;
92
+ persisted = true;
93
+ return config;
94
+ }, { defaultValue: { projects: [] }, skipWriteIfUnchanged: true });
95
+ if (persisted) {
96
+ log('info', `Resolved ADO repository GUID for ${getAdoProjectLabel(project)}: ${previous || project.repoName || 'unknown'} → ${guid}`);
97
+ } else {
98
+ log('warn', `Resolved ADO repository GUID for ${getAdoProjectLabel(project)} but could not find the project in config.json to persist it`);
99
+ }
100
+ } catch (e) {
101
+ log('warn', `Resolved ADO repository GUID for ${getAdoProjectLabel(project)} but failed to persist it: ${e.message}`);
102
+ }
103
+ }
104
+
105
+ async function resolveAdoBuildRepositoryGuid(project, token, orgBase, purpose, opts = {}) {
106
+ const configured = getConfiguredAdoRepositoryGuid(project);
107
+ if (configured) return configured;
108
+
109
+ const lookupKey = getAdoRepositoryLookupKey(project);
110
+ if (!lookupKey) {
111
+ logMissingAdoRepository(project, purpose);
112
+ return null;
113
+ }
114
+
115
+ try {
116
+ const repoUrl = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodeURIComponent(lookupKey)}?api-version=7.1`;
117
+ const repoData = await adoFetch(repoUrl, token, opts);
118
+ const guid = String(repoData?.id || '').trim();
119
+ if (!isAdoGuid(guid)) {
120
+ log('error', `${purpose} disabled for project ${getAdoProjectLabel(project)}: ADO repository lookup for "${lookupKey}" did not return a repository GUID`);
121
+ return null;
122
+ }
123
+ persistAdoRepositoryGuid(project, guid, repoData?.name || project.repoName || '');
124
+ return guid;
125
+ } catch (e) {
126
+ log('warn', `${purpose} could not resolve repository GUID for project ${getAdoProjectLabel(project)} from "${lookupKey}": ${e.message}`);
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function markBuildStatusStale(pr, detail) {
132
+ pr._buildStatusStale = true;
133
+ if (detail) pr._buildStatusDetail = detail;
134
+ }
135
+
136
+ function clearBuildStatusStale(pr) {
137
+ if (pr._buildStatusStale) delete pr._buildStatusStale;
138
+ if (pr._buildStatusDetail) delete pr._buildStatusDetail;
139
+ }
140
+
48
141
  // ── Build/Review Status Helpers ───────────────────────────────────────────────
49
142
 
50
143
  /** Classify an array of ADO build records into a single status string. */
@@ -353,9 +446,6 @@ async function pollPrStatus(config) {
353
446
  const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}/pullrequests/${prNum}`;
354
447
  let updated = false;
355
448
 
356
- // Clear stale flag — we're attempting a fresh poll
357
- if (pr._buildStatusStale) { delete pr._buildStatusStale; updated = true; }
358
-
359
449
  const prData = await adoFetch(`${repoBase}?api-version=7.1`, token);
360
450
 
361
451
  const sourceBranch = stripRefsHeads(prData.sourceRefName);
@@ -387,6 +477,8 @@ async function pollPrStatus(config) {
387
477
  delete pr.buildFailReason;
388
478
  delete pr.buildErrorLog;
389
479
  delete pr._buildFailNotified;
480
+ delete pr._buildStatusStale;
481
+ delete pr._buildStatusDetail;
390
482
  delete pr.buildFixAttempts;
391
483
  delete pr.buildFixEscalated;
392
484
  }
@@ -485,42 +577,59 @@ async function pollPrStatus(config) {
485
577
  // merge commit (same ref accumulates builds across all prior pushes to the PR).
486
578
  const prNumber = pr.prNumber;
487
579
  const mergeCommitId = prData.lastMergeCommit?.commitId;
488
- let buildStatus = 'none';
489
- let buildFailReason = '';
580
+ let buildStatus = pr.buildStatus || 'none';
581
+ let buildFailReason = pr.buildFailReason || '';
490
582
  let buildStatuses = []; // for error log fetching
583
+ let buildStatusResolved = true;
584
+ let buildStatusStaleDetail = '';
491
585
 
492
586
  if (prNumber && mergeCommitId) {
493
- try {
494
- const mergeRef = encodeURIComponent(`refs/pull/${prNumber}/merge`);
495
- const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodedRepoId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
496
- const buildsData = await adoFetch(buildsUrl, token);
497
- const allBuilds = buildsData?.value || [];
498
- const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
499
-
500
- if (prBuilds.length > 0) {
501
- buildStatus = classifyBuildStatus(prBuilds);
502
- if (buildStatus === 'failing') {
503
- const failed = prBuilds.find(b => b.result === 'failed');
504
- buildFailReason = failed?.definition?.name || 'Build failed';
505
- // Build fake status objects for error log fetching
506
- buildStatuses = prBuilds.filter(b => b.result === 'failed').map(b => ({
507
- state: 'failed', targetUrl: `${orgBase}/${project.adoProject}/_build/results?buildId=${b.id}`,
508
- _buildId: String(b.id),
509
- }));
587
+ const buildRepositoryGuid = await resolveAdoBuildRepositoryGuid(project, token, orgBase, 'ADO build polling');
588
+ if (!buildRepositoryGuid) {
589
+ buildStatusResolved = false;
590
+ buildStatusStaleDetail = 'ADO Builds API requires a repository GUID; repository GUID could not be resolved from project.repositoryId/project.repoName';
591
+ } else {
592
+ try {
593
+ const mergeRef = encodeURIComponent(`refs/pull/${prNumber}/merge`);
594
+ const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodeURIComponent(buildRepositoryGuid)}&repositoryType=TfsGit&$top=25&api-version=7.1`;
595
+ const buildsData = await adoFetch(buildsUrl, token);
596
+ const allBuilds = buildsData?.value || [];
597
+ const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
598
+ buildStatus = 'none';
599
+ buildFailReason = '';
600
+
601
+ if (prBuilds.length > 0) {
602
+ buildStatus = classifyBuildStatus(prBuilds);
603
+ if (buildStatus === 'failing') {
604
+ const failed = prBuilds.find(b => b.result === 'failed');
605
+ buildFailReason = failed?.definition?.name || 'Build failed';
606
+ // Build fake status objects for error log fetching
607
+ buildStatuses = prBuilds.filter(b => b.result === 'failed').map(b => ({
608
+ state: 'failed', targetUrl: `${orgBase}/${project.adoProject}/_build/results?buildId=${b.id}`,
609
+ _buildId: String(b.id),
610
+ }));
611
+ }
612
+ } else if (allBuilds.length > 0 && pr.buildStatus) {
613
+ // Stale merge-commit fallback — ADO returned builds for this PR's merge ref
614
+ // but none target the current `mergeCommitId`. Most likely the target branch
615
+ // moved, ADO recomputed the merge commit, but no new source-side changes
616
+ // triggered a rebuild. Preserve the previous `pr.buildStatus` so the tracker
617
+ // reflects the last known truth instead of flipping to a spurious 'none'.
618
+ // Also log a warn so stale states are detectable in engine logs. Issue #1233.
619
+ const sampleSv = (allBuilds[0]?.sourceVersion || '').slice(0, 8);
620
+ log('warn', `PR ${pr.id} build: merge-commit mismatch — ${allBuilds.length} build(s) on merge ref, none target current merge commit ${String(mergeCommitId).slice(0, 8)} (sample sourceVersion ${sampleSv}); preserving previous buildStatus '${pr.buildStatus}'`);
621
+ buildStatus = pr.buildStatus;
622
+ if (pr.buildFailReason) buildFailReason = pr.buildFailReason;
510
623
  }
511
- } else if (allBuilds.length > 0 && pr.buildStatus) {
512
- // Stale merge-commit fallback — ADO returned builds for this PR's merge ref
513
- // but none target the current `mergeCommitId`. Most likely the target branch
514
- // moved, ADO recomputed the merge commit, but no new source-side changes
515
- // triggered a rebuild. Preserve the previous `pr.buildStatus` so the tracker
516
- // reflects the last known truth instead of flipping to a spurious 'none'.
517
- // Also log a warn so stale states are detectable in engine logs. Issue #1233.
518
- const sampleSv = (allBuilds[0]?.sourceVersion || '').slice(0, 8);
519
- log('warn', `PR ${pr.id} build: merge-commit mismatch — ${allBuilds.length} build(s) on merge ref, none target current merge commit ${String(mergeCommitId).slice(0, 8)} (sample sourceVersion ${sampleSv}); preserving previous buildStatus '${pr.buildStatus}'`);
520
- buildStatus = pr.buildStatus;
521
- if (pr.buildFailReason) buildFailReason = pr.buildFailReason;
624
+ } catch (e) {
625
+ buildStatusResolved = false;
626
+ buildStatusStaleDetail = `ADO build query failed: ${e.message}`;
627
+ log('warn', `ADO build query for ${pr.id}: ${e.message}; preserving previous buildStatus '${pr.buildStatus || 'none'}'`);
522
628
  }
523
- } catch (e) { log('warn', `ADO build query for ${pr.id}: ${e.message}`); }
629
+ }
630
+ } else {
631
+ buildStatus = 'none';
632
+ buildFailReason = '';
524
633
  }
525
634
 
526
635
  // Record actual poll time — makes lastBuildCheck reflect when the engine last
@@ -528,50 +637,64 @@ async function pollPrStatus(config) {
528
637
  pr.lastBuildCheck = ts();
529
638
  updated = true;
530
639
 
531
- if (pr.buildStatus !== buildStatus) {
532
- log('info', `PR ${pr.id} build: ${pr.buildStatus || 'none'} → ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
533
- pr.buildStatus = buildStatus;
534
- if (buildFailReason) pr.buildFailReason = buildFailReason;
535
- else delete pr.buildFailReason;
536
- // Build transitioned — clear grace period and auto-complete flag
537
- delete pr._buildFixPushedAt;
538
- if (buildStatus === 'failing') delete pr._autoCompleted;
539
- if (buildStatus !== 'failing') {
540
- delete pr._buildFailNotified;
541
- // Preserve buildErrorLog + buildFixAttempts through transient 'none'/'running'
542
- // transitions — only clear on confirmed 'passing' recovery. Issue #1232: 'none'
543
- // can also occur when ADO recomputes the merge commit after a target-branch
544
- // update but no new builds have been triggered yet (filter by sourceVersion
545
- // returns []), which previously wiped the last known error log and caused
546
- // fix agents to be dispatched blind.
547
- if (buildStatus === 'passing') {
548
- delete pr.buildErrorLog;
549
- // Reset build fix retry counter on recovery — allows fresh auto-fix cycles if build breaks again
550
- if (pr.buildFixAttempts) { delete pr.buildFixAttempts; delete pr.buildFixEscalated; }
551
- }
640
+ if (buildStatusResolved) {
641
+ if (pr._buildStatusStale || pr._buildStatusDetail) {
642
+ clearBuildStatusStale(pr);
643
+ updated = true;
552
644
  }
645
+ } else {
646
+ markBuildStatusStale(pr, buildStatusStaleDetail);
553
647
  updated = true;
648
+ }
554
649
 
555
- // Fetch actual compiler/build error logs when transitioning to failing
556
- if (buildStatus === 'failing') {
557
- const failedStatusObjs = buildStatuses.filter(s => s.state === 'failed' || s.state === 'error').slice(0, 10);
558
- const logParts = [];
559
- const seenBuildIds = new Set();
560
- for (const failedStatusObj of failedStatusObjs) {
561
- const errorLog = await fetchAdoBuildErrorLog(orgBase, project, failedStatusObj, token, pr, seenBuildIds);
562
- if (errorLog) logParts.push(errorLog);
563
- }
564
- if (logParts.length > 0) {
565
- pr.buildErrorLog = logParts.join('\n\n');
566
- log('info', `PR ${pr.id}: fetched error logs from ${logParts.length} failing pipeline(s)`);
650
+ if (buildStatusResolved) {
651
+ if (pr.buildStatus !== buildStatus) {
652
+ log('info', `PR ${pr.id} build: ${pr.buildStatus || 'none'} ${buildStatus}${buildFailReason ? ' (' + buildFailReason + ')' : ''}`);
653
+ pr.buildStatus = buildStatus;
654
+ if (buildFailReason) pr.buildFailReason = buildFailReason;
655
+ else delete pr.buildFailReason;
656
+ // Build transitioned clear grace period and auto-complete flag
657
+ delete pr._buildFixPushedAt;
658
+ if (buildStatus === 'failing') delete pr._autoCompleted;
659
+ if (buildStatus !== 'failing') {
660
+ delete pr._buildFailNotified;
661
+ delete pr._buildStatusStale;
662
+ delete pr._buildStatusDetail;
663
+ // Preserve buildErrorLog + buildFixAttempts through transient 'none'/'running'
664
+ // transitions — only clear on confirmed 'passing' recovery. Issue #1232: 'none'
665
+ // can also occur when ADO recomputes the merge commit after a target-branch
666
+ // update but no new builds have been triggered yet (filter by sourceVersion
667
+ // returns []), which previously wiped the last known error log and caused
668
+ // fix agents to be dispatched blind.
669
+ if (buildStatus === 'passing') {
670
+ delete pr.buildErrorLog;
671
+ // Reset build fix retry counter on recovery — allows fresh auto-fix cycles if build breaks again
672
+ if (pr.buildFixAttempts) { delete pr.buildFixAttempts; delete pr.buildFixEscalated; }
673
+ }
567
674
  }
675
+ updated = true;
568
676
 
569
- // Teams notification for build failure non-blocking
570
- try {
571
- const teams = require('./teams');
572
- const prFilePath = shared.projectPrPath(project);
573
- teams.teamsNotifyPrEvent(pr, 'build-failed', project, prFilePath).catch(() => {});
574
- } catch {}
677
+ // Fetch actual compiler/build error logs when transitioning to failing
678
+ if (buildStatus === 'failing') {
679
+ const failedStatusObjs = buildStatuses.filter(s => s.state === 'failed' || s.state === 'error').slice(0, 10);
680
+ const logParts = [];
681
+ const seenBuildIds = new Set();
682
+ for (const failedStatusObj of failedStatusObjs) {
683
+ const errorLog = await fetchAdoBuildErrorLog(orgBase, project, failedStatusObj, token, pr, seenBuildIds);
684
+ if (errorLog) logParts.push(errorLog);
685
+ }
686
+ if (logParts.length > 0) {
687
+ pr.buildErrorLog = logParts.join('\n\n');
688
+ log('info', `PR ${pr.id}: fetched error logs from ${logParts.length} failing pipeline(s)`);
689
+ }
690
+
691
+ // Teams notification for build failure — non-blocking
692
+ try {
693
+ const teams = require('./teams');
694
+ const prFilePath = shared.projectPrPath(project);
695
+ teams.teamsNotifyPrEvent(pr, 'build-failed', project, prFilePath).catch(() => {});
696
+ } catch {}
697
+ }
575
698
  }
576
699
  }
577
700
 
@@ -618,7 +741,7 @@ async function pollPrStatus(config) {
618
741
  // Auth errors → mark build status stale so dashboard shows uncertainty
619
742
  // and engine re-polls on next tick instead of waiting 6 ticks
620
743
  if (isAdoAuthError(err)) {
621
- pr._buildStatusStale = true;
744
+ markBuildStatusStale(pr, `ADO auth error: ${err.message}`);
622
745
  _adoPollHadAuthFailure = true;
623
746
  log('warn', `PR ${pr.id}: build status marked stale (auth error: ${err.message})`);
624
747
  return true; // count as updated to persist the stale flag
@@ -923,6 +1046,8 @@ async function checkLiveReviewStatus(pr, project) {
923
1046
  * {
924
1047
  * buildStatus: 'failing' | 'passing' | 'running' | 'none' | null,
925
1048
  * mergeConflict: boolean,
1049
+ * buildStatusStale?: boolean,
1050
+ * buildStatusDetail?: string,
926
1051
  * }
927
1052
  *
928
1053
  * `buildStatus` is null when ADO has builds on the merge ref but none target the
@@ -937,12 +1062,12 @@ async function checkLiveBuildAndConflict(pr, project) {
937
1062
  const orgBase = shared.getAdoOrgBase(project);
938
1063
  const prNum = shared.getPrNumber(pr);
939
1064
  if (!prNum) return null;
940
- const adoRepositoryId = getAdoRepositoryId(project);
941
- if (!adoRepositoryId) {
1065
+ const adoRepositoryLookupKey = getAdoRepositoryLookupKey(project);
1066
+ if (!adoRepositoryLookupKey) {
942
1067
  logMissingAdoRepository(project, 'ADO live build/conflict check');
943
1068
  return null;
944
1069
  }
945
- const encodedRepoId = encodeURIComponent(adoRepositoryId);
1070
+ const encodedRepoId = encodeURIComponent(adoRepositoryLookupKey);
946
1071
  const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}`;
947
1072
  const prUrl = `${repoBase}/pullrequests/${prNum}?api-version=7.1`;
948
1073
  // 4s timeout — same budget as checkLiveReviewStatus. This is a pre-dispatch
@@ -959,23 +1084,35 @@ async function checkLiveBuildAndConflict(pr, project) {
959
1084
  // pollPrStatus's narrowing logic so the live check and the cached poll
960
1085
  // agree on what 'failing' / 'passing' / 'running' / 'none' mean.
961
1086
  let buildStatus = null;
1087
+ let buildStatusStale = false;
1088
+ let buildStatusDetail = '';
962
1089
  if (prData.status === 'active') {
963
1090
  const mergeCommitId = prData.lastMergeCommit?.commitId;
964
1091
  if (mergeCommitId) {
965
- try {
966
- const mergeRef = encodeURIComponent(`refs/pull/${prNum}/merge`);
967
- const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodedRepoId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
968
- const buildsData = await adoFetch(buildsUrl, token, { timeout: 4000 });
969
- const allBuilds = buildsData?.value || [];
970
- const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
971
- if (prBuilds.length > 0) {
972
- buildStatus = classifyBuildStatus(prBuilds);
973
- } else if (allBuilds.length === 0) {
974
- buildStatus = 'none';
1092
+ const buildRepositoryGuid = await resolveAdoBuildRepositoryGuid(project, token, orgBase, 'ADO live build check', { timeout: 4000 });
1093
+ if (!buildRepositoryGuid) {
1094
+ buildStatusStale = true;
1095
+ buildStatusDetail = 'ADO Builds API requires a repository GUID; repository GUID could not be resolved from project.repositoryId/project.repoName';
1096
+ } else {
1097
+ try {
1098
+ const mergeRef = encodeURIComponent(`refs/pull/${prNum}/merge`);
1099
+ const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodeURIComponent(buildRepositoryGuid)}&repositoryType=TfsGit&$top=25&api-version=7.1`;
1100
+ const buildsData = await adoFetch(buildsUrl, token, { timeout: 4000 });
1101
+ const allBuilds = buildsData?.value || [];
1102
+ const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
1103
+ if (prBuilds.length > 0) {
1104
+ buildStatus = classifyBuildStatus(prBuilds);
1105
+ } else if (allBuilds.length === 0) {
1106
+ buildStatus = 'none';
1107
+ }
1108
+ // else: merge-commit mismatch — leave buildStatus null so caller
1109
+ // falls back to cached state (issue #1233).
1110
+ } catch (e) {
1111
+ buildStatusStale = true;
1112
+ buildStatusDetail = `ADO live build query failed: ${e.message}`;
1113
+ log('warn', `Live build check builds query for ${pr.id}: ${e.message}`);
975
1114
  }
976
- // else: merge-commit mismatch — leave buildStatus null so caller
977
- // falls back to cached state (issue #1233).
978
- } catch (e) { log('warn', `Live build check builds query for ${pr.id}: ${e.message}`); }
1115
+ }
979
1116
  } else {
980
1117
  // No merge commit yet — likely conflict or fresh PR. Treat as 'none'
981
1118
  // so a stale 'failing' cache can be cleared by the caller.
@@ -983,7 +1120,11 @@ async function checkLiveBuildAndConflict(pr, project) {
983
1120
  }
984
1121
  }
985
1122
 
986
- return { buildStatus, mergeConflict };
1123
+ return {
1124
+ buildStatus,
1125
+ mergeConflict,
1126
+ ...(buildStatusStale ? { buildStatusStale, buildStatusDetail } : {}),
1127
+ };
987
1128
  } catch (e) {
988
1129
  log('warn', `Live build/conflict check for ${pr.id}: ${e.message}`);
989
1130
  return null;
@@ -1007,7 +1148,8 @@ async function fetchAdoPrMetadata(prNum, adoOrg, adoProj, adoRepo) {
1007
1148
  /**
1008
1149
  * Fetch live PR and build status for a single PR number.
1009
1150
  * Used by engine/ado-status.js so agents can check CI without raw curl calls.
1010
- * Returns { prNumber, title, branch, status, reviewStatus, buildStatus, buildErrorLog?,
1151
+ * Returns { prNumber, title, branch, status, reviewStatus, buildStatus,
1152
+ * buildStatusStale?, buildStatusDetail?, buildErrorLog?,
1011
1153
  * mergeConflict, url, project } or null on auth failure.
1012
1154
  */
1013
1155
  async function fetchSinglePrBuildStatus(project, prNumber) {
@@ -1015,29 +1157,45 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
1015
1157
  if (!token) return null;
1016
1158
 
1017
1159
  const orgBase = getAdoOrgBase(project);
1018
- const adoRepositoryId = getAdoRepositoryId(project);
1019
- if (!adoRepositoryId) {
1160
+ const adoRepositoryLookupKey = getAdoRepositoryLookupKey(project);
1161
+ if (!adoRepositoryLookupKey) {
1020
1162
  logMissingAdoRepository(project, 'ADO single PR status fetch');
1021
1163
  return null;
1022
1164
  }
1023
- const encodedRepoId = encodeURIComponent(adoRepositoryId);
1165
+ const encodedRepoId = encodeURIComponent(adoRepositoryLookupKey);
1024
1166
  const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}`;
1025
- const mergeRef = encodeURIComponent(`refs/pull/${prNumber}/merge`);
1026
- const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodedRepoId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
1027
1167
 
1028
- // Fetch PR metadata and builds in parallel
1029
- const [prData, buildsData] = await Promise.all([
1168
+ // Fetch PR metadata and resolve the Builds API repository GUID in parallel.
1169
+ const [prData, buildRepositoryGuid] = await Promise.all([
1030
1170
  adoFetch(`${repoBase}/pullrequests/${prNumber}?api-version=7.1`, token),
1031
- adoFetch(buildsUrl, token).catch(() => null),
1171
+ resolveAdoBuildRepositoryGuid(project, token, orgBase, 'ADO single PR status fetch'),
1032
1172
  ]);
1033
1173
  if (!prData) return null;
1034
1174
 
1175
+ let buildsData = null;
1176
+ let buildStatusStale = false;
1177
+ let buildStatusDetail = '';
1178
+ if (buildRepositoryGuid) {
1179
+ try {
1180
+ const mergeRef = encodeURIComponent(`refs/pull/${prNumber}/merge`);
1181
+ const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodeURIComponent(buildRepositoryGuid)}&repositoryType=TfsGit&$top=25&api-version=7.1`;
1182
+ buildsData = await adoFetch(buildsUrl, token);
1183
+ } catch (e) {
1184
+ buildStatusStale = true;
1185
+ buildStatusDetail = `ADO build query failed: ${e.message}`;
1186
+ log('warn', `fetchSinglePrBuildStatus builds query for PR #${prNumber}: ${e.message}`);
1187
+ }
1188
+ } else {
1189
+ buildStatusStale = true;
1190
+ buildStatusDetail = 'ADO Builds API requires a repository GUID; repository GUID could not be resolved from project.repositoryId/project.repoName';
1191
+ }
1192
+
1035
1193
  const mergeCommitId = prData.lastMergeCommit?.commitId;
1036
1194
  const prBuilds = mergeCommitId
1037
1195
  ? (buildsData?.value || []).filter(b => b.sourceVersion === mergeCommitId)
1038
1196
  : [];
1039
1197
 
1040
- let buildStatus = classifyBuildStatus(prBuilds);
1198
+ let buildStatus = buildStatusStale ? null : classifyBuildStatus(prBuilds);
1041
1199
  let buildErrorLog = null;
1042
1200
 
1043
1201
  if (buildStatus === 'failing') {
@@ -1067,6 +1225,7 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
1067
1225
  status: prData.status || 'unknown',
1068
1226
  reviewStatus: votesToReviewStatus(votes),
1069
1227
  buildStatus,
1228
+ ...(buildStatusStale ? { buildStatusStale, buildStatusDetail } : {}),
1070
1229
  ...(buildErrorLog ? { buildErrorLog } : {}),
1071
1230
  mergeConflict: prData.mergeStatus === 'conflicts',
1072
1231
  url: prUrl,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-01T01:02:02.441Z"
4
+ "cachedAt": "2026-05-01T01:27:16.930Z"
5
5
  }
@@ -787,9 +787,15 @@ function syncPrsFromOutput(output, agentId, meta, config) {
787
787
  if (!output || !String(output).trim()) {
788
788
  const today = dateStamp();
789
789
  const inboxFiles = getInboxFiles().filter(f => f.includes(agentId) && f.includes(today));
790
+ const currentItemId = meta?.item?.id ? String(meta.item.id) : '';
791
+ function isCurrentItemInboxNote(fileName, content) {
792
+ if (!currentItemId) return true;
793
+ return String(fileName || '').includes(currentItemId) || String(content || '').includes(currentItemId);
794
+ }
790
795
  for (const f of inboxFiles) {
791
796
  const content = safeRead(path.join(INBOX_DIR, f));
792
797
  if (!content) continue;
798
+ if (!isCurrentItemInboxNote(f, content)) continue;
793
799
  const prHeaderPattern = /(?:^|\n)\s*\*{0,2}(?:PR|Pull\s+Request|E2E\s+PR)\s+(?:created|opened|submitted)\*{0,2}\s*[:\-]\s*([^\n]+)/gi;
794
800
  while ((match = prHeaderPattern.exec(content)) !== null) {
795
801
  addPrUrlEvidence(match[1]);
package/engine.js CHANGED
@@ -2383,6 +2383,16 @@ async function discoverFromPrs(config, project) {
2383
2383
  try {
2384
2384
  const checkBcFn = project.repoHost === 'github' ? ghCheckLiveBuildAndConflict : adoCheckLiveBuildAndConflict;
2385
2385
  const live = await checkBcFn(pr, project);
2386
+ if (live?.buildStatusStale) {
2387
+ try {
2388
+ mutatePullRequests(projectPrPath(project), prs => {
2389
+ const target = shared.findPrRecord(prs, pr, project);
2390
+ if (!target) return;
2391
+ target._buildStatusStale = true;
2392
+ if (live.buildStatusDetail) target._buildStatusDetail = live.buildStatusDetail;
2393
+ });
2394
+ } catch {}
2395
+ }
2386
2396
  if (live && live.buildStatus && live.buildStatus !== 'failing') {
2387
2397
  log('info', `Pre-dispatch build check: ${pr.id} build is ${live.buildStatus} (cached was failing) — skipping build-fix`);
2388
2398
  // Persist the fresh status so subsequent ticks don't re-check on every pass
@@ -2391,6 +2401,8 @@ async function discoverFromPrs(config, project) {
2391
2401
  const target = shared.findPrRecord(prs, pr, project);
2392
2402
  if (!target) return;
2393
2403
  target.buildStatus = live.buildStatus;
2404
+ delete target._buildStatusStale;
2405
+ delete target._buildStatusDetail;
2394
2406
  if (live.buildStatus === 'passing') {
2395
2407
  delete target.buildErrorLog;
2396
2408
  delete target.buildFailReason;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1648",
3
+ "version": "0.1.1649",
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"