@yemi33/minions 0.1.2013 → 0.1.2014

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.
@@ -32,8 +32,10 @@ function prRow(pr) {
32
32
  const reviewLabel = sq.status === 'waiting' ? 'reviewing (minions)' : sq.status ? sq.status + ' (minions)' : (effectiveReviewStatus || 'pending');
33
33
  const reviewTitle = '';
34
34
  const buildClass = pr._buildStatusStale ? 'build-stale' : pr.buildStatus === 'passing' ? 'build-pass' : pr.buildStatus === 'failing' ? 'build-fail' : pr.buildStatus === 'running' ? 'building' : 'no-build';
35
- const buildLabel = (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '');
36
- const buildTitle = pr._buildStatusDetail || '';
35
+ const buildLabel = (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '') + (pr.buildStaleMergeCommit ? ' (stale merge commit)' : '');
36
+ const buildTitle = pr.buildStaleMergeCommit
37
+ ? 'ADO has builds on this PR\'s merge ref, but none target the current merge commit (typically after a rebase or target-branch advance). Engine classified from the pre-rebase build set and queued a fresh build (rate limited 30m). See Issue #2747.'
38
+ : (pr._buildStatusDetail || '');
37
39
  const statusClass = pr.status === 'merged' ? 'merged' : pr.status === 'abandoned' ? 'rejected' : pr.status === 'active' ? 'active' : 'draft';
38
40
  const statusLabel = pr.status || 'active';
39
41
  const branchError = pr._branchResolutionError?.reason || '';
package/engine/ado.js CHANGED
@@ -407,6 +407,70 @@ function classifyBuildStatus(prBuilds) {
407
407
  return 'none';
408
408
  }
409
409
 
410
+ /**
411
+ * Stale merge-commit fallback classifier (Issue #2747). When ADO returns
412
+ * builds on a PR's merge ref but none target the current merge commit
413
+ * (ADO recomputed merge-commit after a target-branch advance / rebase
414
+ * without re-queueing), classify the stale set:
415
+ * - Stale-failing → 'failing' + buildStaleMergeCommit:true. A stale
416
+ * failing signal is strictly more actionable than 'none'; a fix WI
417
+ * either does real work (failure still in the branch) or verifies
418
+ * green via push (rebase resolved it).
419
+ * - Stale-succeeded/other → preserve cached pr.buildStatus (or 'none')
420
+ * and do NOT set the stale-merge flag. Succeeded-on-pre-rebase is
421
+ * ambiguous and could be false confidence.
422
+ * Returns { buildStatus, buildStaleMergeCommit }.
423
+ */
424
+ function classifyStaleBuilds(allBuilds, cachedBuildStatus) {
425
+ const staleStatus = classifyBuildStatus(allBuilds);
426
+ if (staleStatus === 'failing') {
427
+ return { buildStatus: 'failing', buildStaleMergeCommit: true };
428
+ }
429
+ return { buildStatus: cachedBuildStatus || 'none', buildStaleMergeCommit: false };
430
+ }
431
+
432
+ /**
433
+ * Filter for PR-validation builds — only these should be re-queued on
434
+ * stale-merge-commit detection. ADO records `triggerInfo.pullRequestId`
435
+ * when a build was triggered by a PR validation policy; some pipelines
436
+ * are conventionally named with "PR" in the title (e.g. "Office - PR"),
437
+ * which we also accept as a fallback signal.
438
+ */
439
+ function isPrValidationBuild(build) {
440
+ if (!build) return false;
441
+ if (build.triggerInfo && build.triggerInfo.pullRequestId) return true;
442
+ const name = String(build?.definition?.name || '');
443
+ return /\bPR\b/i.test(name);
444
+ }
445
+
446
+ // Rate-limit window for stale-merge-commit fresh-build requeue: at most one
447
+ // queue per PR per 30 min, tracked via pr._lastBuildRequeueAt timestamp.
448
+ const PR_BUILD_REQUEUE_INTERVAL_MS = 30 * 60 * 1000;
449
+
450
+ function shouldRequeueStaleBuild(pr, nowMs) {
451
+ if (!pr) return false;
452
+ const last = Number(pr._lastBuildRequeueAt) || 0;
453
+ const now = Number.isFinite(nowMs) ? nowMs : Date.now();
454
+ return (now - last) >= PR_BUILD_REQUEUE_INTERVAL_MS;
455
+ }
456
+
457
+ /**
458
+ * Queue a fresh PR-validation build via the ADO Builds REST API
459
+ * (equivalent of `az pipelines build queue --definition-id <id>
460
+ * --branch refs/pull/<n>/merge` but uses the engine's existing PAT and
461
+ * adoFetch wrapper — no external `az` CLI dependency). Forces ADO to
462
+ * recompute the merge commit so the next poll has real data. Failures
463
+ * are non-fatal — callers should try/catch and log warn.
464
+ */
465
+ async function queueFreshAdoBuild({ orgBase, project, prNumber, definitionId, token }) {
466
+ const url = `${orgBase}/${project.adoProject}/_apis/build/builds?api-version=7.1`;
467
+ const body = JSON.stringify({
468
+ definition: { id: definitionId },
469
+ sourceBranch: `refs/pull/${prNumber}/merge`,
470
+ });
471
+ return adoFetch(url, token, { method: 'POST', body });
472
+ }
473
+
410
474
  /** Map ADO reviewer vote array to a review status string. */
411
475
  function votesToReviewStatus(votes) {
412
476
  if (votes.some(v => v === -10)) return 'changes-requested';
@@ -1167,6 +1231,8 @@ async function pollPrStatus(config) {
1167
1231
  let buildFailureSignature = pr.buildFailureSignature || '';
1168
1232
  let buildStatusResolved = true;
1169
1233
  let buildStatusStaleDetail = '';
1234
+ let buildStaleMergeCommit = false;
1235
+ let staleFailingDefinitions = []; // PR-validation defs to re-queue when stale-failing
1170
1236
 
1171
1237
  if (prNumber && mergeCommitId) {
1172
1238
  const buildRepositoryGuid = await resolveAdoBuildRepositoryGuid(project, token, orgBase, 'ADO build polling');
@@ -1194,17 +1260,43 @@ async function pollPrStatus(config) {
1194
1260
  failed?.status,
1195
1261
  ].filter(Boolean).join('\n') || buildFailReason, 80);
1196
1262
  }
1197
- } else if (allBuilds.length > 0 && pr.buildStatus) {
1198
- // Stale merge-commit fallback ADO returned builds for this PR's merge ref
1199
- // but none target the current `mergeCommitId`. Most likely the target branch
1200
- // moved, ADO recomputed the merge commit, but no new source-side changes
1201
- // triggered a rebuild. Preserve the previous `pr.buildStatus` so the tracker
1202
- // reflects the last known truth instead of flipping to a spurious 'none'.
1203
- // Also log a warn so stale states are detectable in engine logs. Issue #1233.
1263
+ } else if (allBuilds.length > 0) {
1264
+ // Stale merge-commit classification (Issue #2747). ADO returned
1265
+ // builds for this PR's merge ref but none target the current
1266
+ // `mergeCommitId` most likely the target branch moved (rebase
1267
+ // or push), ADO recomputed the merge commit, but no new
1268
+ // source-side changes triggered a rebuild. Classify the stale
1269
+ // set: stale-failing flips to 'failing' + buildStaleMergeCommit
1270
+ // (strictly more actionable than 'none'); stale-succeeded/other
1271
+ // preserves cached state (no false confidence).
1272
+ const stale = classifyStaleBuilds(allBuilds, pr.buildStatus);
1273
+ buildStatus = stale.buildStatus;
1274
+ buildStaleMergeCommit = stale.buildStaleMergeCommit;
1275
+ if (buildStaleMergeCommit) {
1276
+ const failed = allBuilds.find(b => b.result === 'failed') || allBuilds.find(b => b.result === 'canceled');
1277
+ buildFailReason = failed?.definition?.name || 'Build failed (stale merge commit)';
1278
+ buildFailureSignature = shared.safeSlugComponent([
1279
+ failed?.definition?.name,
1280
+ failed?.result,
1281
+ failed?.status,
1282
+ 'stale-merge-commit',
1283
+ ].filter(Boolean).join('\n') || buildFailReason, 80);
1284
+ // Collect PR-validation definitions with failures for fresh-build queue.
1285
+ const seenDefIds = new Set();
1286
+ for (const b of allBuilds) {
1287
+ if ((b.result === 'failed' || b.result === 'canceled') && isPrValidationBuild(b)) {
1288
+ const defId = b?.definition?.id;
1289
+ if (defId != null && !seenDefIds.has(defId)) {
1290
+ seenDefIds.add(defId);
1291
+ staleFailingDefinitions.push({ id: defId, name: b?.definition?.name || `id=${defId}` });
1292
+ }
1293
+ }
1294
+ }
1295
+ } else if (pr.buildFailReason && buildStatus === pr.buildStatus) {
1296
+ buildFailReason = pr.buildFailReason;
1297
+ }
1204
1298
  const sampleSv = (allBuilds[0]?.sourceVersion || '').slice(0, 8);
1205
- 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}'`);
1206
- buildStatus = pr.buildStatus;
1207
- if (pr.buildFailReason) buildFailReason = pr.buildFailReason;
1299
+ 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}); classified '${buildStatus}' from stale set${buildStaleMergeCommit ? ' (stale failing — will queue fresh build if rate limit allows)' : ''}`);
1208
1300
  }
1209
1301
  } catch (e) {
1210
1302
  buildStatusResolved = false;
@@ -1272,6 +1364,47 @@ async function pollPrStatus(config) {
1272
1364
  updated = true;
1273
1365
  }
1274
1366
  }
1367
+ // Persist buildStaleMergeCommit flag (Issue #2747) so the dashboard
1368
+ // can render the distinction and the auto-queue decision below has
1369
+ // a stable cross-tick signal. Clear when no longer stale.
1370
+ if (buildStaleMergeCommit) {
1371
+ if (!pr.buildStaleMergeCommit) {
1372
+ pr.buildStaleMergeCommit = true;
1373
+ updated = true;
1374
+ }
1375
+ } else if (pr.buildStaleMergeCommit) {
1376
+ delete pr.buildStaleMergeCommit;
1377
+ updated = true;
1378
+ }
1379
+ }
1380
+
1381
+ // Auto-queue fresh build on stale-failing detection (Issue #2747).
1382
+ // Forces ADO to recompute the merge commit so the next poll has real
1383
+ // data instead of stuck-stale failing builds. Rate-limited to at most
1384
+ // one queue per PR per 30 min via pr._lastBuildRequeueAt. Only PR-
1385
+ // validation definitions are queued; non-PR builds are filtered out
1386
+ // upstream. Failures are non-fatal — logged warn and skipped.
1387
+ if (buildStatusResolved && buildStatus === 'failing' && buildStaleMergeCommit && staleFailingDefinitions.length > 0) {
1388
+ if (shouldRequeueStaleBuild(pr)) {
1389
+ let queuedCount = 0;
1390
+ for (const def of staleFailingDefinitions) {
1391
+ try {
1392
+ await queueFreshAdoBuild({ orgBase, project, prNumber, definitionId: def.id, token });
1393
+ queuedCount++;
1394
+ log('info', `PR ${pr.id} build: queued fresh build for definition '${def.name}' (id=${def.id}) on refs/pull/${prNumber}/merge to refresh stale merge-commit classification`);
1395
+ } catch (e) {
1396
+ log('warn', `PR ${pr.id} build: failed to queue fresh build for definition '${def.name}' (id=${def.id}): ${e.message}`);
1397
+ }
1398
+ }
1399
+ if (queuedCount > 0) {
1400
+ pr._lastBuildRequeueAt = Date.now();
1401
+ updated = true;
1402
+ }
1403
+ } else {
1404
+ const lastAt = Number(pr._lastBuildRequeueAt) || 0;
1405
+ const ageMin = Math.floor((Date.now() - lastAt) / 60000);
1406
+ log('info', `PR ${pr.id} build: stale-failing detected but skipping fresh-queue — last requeue ${ageMin}m ago (rate limit: 30 min)`);
1407
+ }
1275
1408
  }
1276
1409
 
1277
1410
  // Auto-complete: set auto-complete on PR when builds green + review approved
@@ -1766,12 +1899,16 @@ async function resetReviewerNegativeVote(pr, project) {
1766
1899
  * mergeConflict: boolean,
1767
1900
  * buildStatusStale?: boolean,
1768
1901
  * buildStatusDetail?: string,
1902
+ * buildStaleMergeCommit?: boolean,
1769
1903
  * }
1770
1904
  *
1771
1905
  * `buildStatus` is null when ADO has builds on the merge ref but none target the
1772
- * current merge commit (target-branch advance with no source-side rebuild yet
1773
- * matches pollPrStatus's "preserve previous buildStatus" semantics from issue
1774
- * #1233; the caller must trust the cached value).
1906
+ * current merge commit AND the stale set is not failing (target-branch advance
1907
+ * with no source-side rebuild yet — matches pollPrStatus's "preserve previous
1908
+ * buildStatus" semantics from issue #1233; the caller must trust the cached
1909
+ * value). When the stale set IS failing, buildStatus flips to 'failing' and
1910
+ * `buildStaleMergeCommit: true` is returned (Issue #2747) — a stale failing
1911
+ * signal is strictly more actionable than cache fallback.
1775
1912
  */
1776
1913
  async function checkLiveBuildAndConflict(pr, project) {
1777
1914
  try {
@@ -1806,6 +1943,7 @@ async function checkLiveBuildAndConflict(pr, project) {
1806
1943
  let buildStatus = null;
1807
1944
  let buildStatusStale = false;
1808
1945
  let buildStatusDetail = '';
1946
+ let buildStaleMergeCommit = false;
1809
1947
  if (prData.status === 'active') {
1810
1948
  const mergeCommitId = prData.lastMergeCommit?.commitId;
1811
1949
  if (mergeCommitId) {
@@ -1824,9 +1962,19 @@ async function checkLiveBuildAndConflict(pr, project) {
1824
1962
  buildStatus = classifyBuildStatus(prBuilds);
1825
1963
  } else if (allBuilds.length === 0) {
1826
1964
  buildStatus = 'none';
1965
+ } else {
1966
+ // Stale merge-commit (Issue #2747) — mirror pollPrStatus.
1967
+ // Stale-failing flips to 'failing' + buildStaleMergeCommit so
1968
+ // the caller skips the cache fallback and dispatches a fix.
1969
+ // Stale-succeeded leaves buildStatus null so caller falls
1970
+ // back to cached state (no false confidence).
1971
+ const stale = classifyStaleBuilds(allBuilds, pr?.buildStatus || null);
1972
+ if (stale.buildStaleMergeCommit) {
1973
+ buildStatus = stale.buildStatus;
1974
+ buildStaleMergeCommit = true;
1975
+ }
1976
+ // else: leave buildStatus null — caller falls back to cached state (issue #1233).
1827
1977
  }
1828
- // else: merge-commit mismatch — leave buildStatus null so caller
1829
- // falls back to cached state (issue #1233).
1830
1978
  } catch (e) {
1831
1979
  buildStatusStale = true;
1832
1980
  buildStatusDetail = `ADO live build query failed: ${e.message}`;
@@ -1844,6 +1992,7 @@ async function checkLiveBuildAndConflict(pr, project) {
1844
1992
  buildStatus,
1845
1993
  mergeConflict,
1846
1994
  ...(buildStatusStale ? { buildStatusStale, buildStatusDetail } : {}),
1995
+ ...(buildStaleMergeCommit ? { buildStaleMergeCommit: true } : {}),
1847
1996
  };
1848
1997
  } catch (e) {
1849
1998
  log('warn', `Live build/conflict check for ${pr.id}: ${e.message}`);
@@ -2222,4 +2371,10 @@ module.exports = {
2222
2371
  VOTE_SNAPSHOT_MAX_AGE_MS,
2223
2372
  STUCK_VOTE_AGE_THRESHOLD_MS,
2224
2373
  STUCK_VOTE_WARN_INTERVAL_MS,
2374
+ // Issue #2747 — stale merge-commit classifier + auto-queue helpers, exported for unit tests.
2375
+ classifyBuildStatus,
2376
+ classifyStaleBuilds,
2377
+ isPrValidationBuild,
2378
+ shouldRequeueStaleBuild,
2379
+ PR_BUILD_REQUEUE_INTERVAL_MS,
2225
2380
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2013",
3
+ "version": "0.1.2014",
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"