@yemi33/minions 0.1.1616 → 0.1.1617

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1617 (2026-04-29)
4
+
5
+ ### Fixes
6
+ - guard stale build & conflict auto-fixes with live pre-dispatch check (#1851)
7
+
3
8
  ## 0.1.1616 (2026-04-29)
4
9
 
5
10
  ### Features
package/engine/ado.js CHANGED
@@ -844,6 +844,77 @@ async function checkLiveReviewStatus(pr, project) {
844
844
  }
845
845
  }
846
846
 
847
+ /**
848
+ * Cheap pre-dispatch freshness check for build status and merge-conflict state.
849
+ * Mirrors checkLiveReviewStatus — fetches PR data once, classifies builds for the
850
+ * current merge commit, and reports whether ADO still considers the PR conflicted.
851
+ *
852
+ * Returns null if the check can't run (no token, no PR number, network error) so
853
+ * callers can fall back to cached state. Otherwise returns:
854
+ * {
855
+ * buildStatus: 'failing' | 'passing' | 'running' | 'none' | null,
856
+ * mergeConflict: boolean,
857
+ * }
858
+ *
859
+ * `buildStatus` is null when ADO has builds on the merge ref but none target the
860
+ * current merge commit (target-branch advance with no source-side rebuild yet —
861
+ * matches pollPrStatus's "preserve previous buildStatus" semantics from issue
862
+ * #1233; the caller must trust the cached value).
863
+ */
864
+ async function checkLiveBuildAndConflict(pr, project) {
865
+ try {
866
+ const token = await getAdoToken();
867
+ if (!token) return null;
868
+ const orgBase = shared.getAdoOrgBase(project);
869
+ const prNum = shared.getPrNumber(pr);
870
+ if (!prNum) return null;
871
+ const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}`;
872
+ const prUrl = `${repoBase}/pullrequests/${prNum}?api-version=7.1`;
873
+ // 4s timeout — same budget as checkLiveReviewStatus. This is a pre-dispatch
874
+ // gate; we'd rather miss a freshness signal and fall back to cache than
875
+ // block dispatch on a slow ADO call.
876
+ const prData = await adoFetch(prUrl, token, { timeout: 4000 });
877
+ if (!prData) return null;
878
+
879
+ // Conflict signal — ADO reports `mergeStatus: 'conflicts'` when the merge
880
+ // would conflict; anything else means clean (or recomputing).
881
+ const mergeConflict = prData.mergeStatus === 'conflicts';
882
+
883
+ // Build signal — only meaningful when the PR is still open. We replicate
884
+ // pollPrStatus's narrowing logic so the live check and the cached poll
885
+ // agree on what 'failing' / 'passing' / 'running' / 'none' mean.
886
+ let buildStatus = null;
887
+ if (prData.status === 'active') {
888
+ const mergeCommitId = prData.lastMergeCommit?.commitId;
889
+ if (mergeCommitId) {
890
+ try {
891
+ const mergeRef = encodeURIComponent(`refs/pull/${prNum}/merge`);
892
+ const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${project.repositoryId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
893
+ const buildsData = await adoFetch(buildsUrl, token, { timeout: 4000 });
894
+ const allBuilds = buildsData?.value || [];
895
+ const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
896
+ if (prBuilds.length > 0) {
897
+ buildStatus = classifyBuildStatus(prBuilds);
898
+ } else if (allBuilds.length === 0) {
899
+ buildStatus = 'none';
900
+ }
901
+ // else: merge-commit mismatch — leave buildStatus null so caller
902
+ // falls back to cached state (issue #1233).
903
+ } catch (e) { log('warn', `Live build check builds query for ${pr.id}: ${e.message}`); }
904
+ } else {
905
+ // No merge commit yet — likely conflict or fresh PR. Treat as 'none'
906
+ // so a stale 'failing' cache can be cleared by the caller.
907
+ buildStatus = 'none';
908
+ }
909
+ }
910
+
911
+ return { buildStatus, mergeConflict };
912
+ } catch (e) {
913
+ log('warn', `Live build/conflict check for ${pr.id}: ${e.message}`);
914
+ return null;
915
+ }
916
+ }
917
+
847
918
  async function fetchAdoPrMetadata(prNum, adoOrg, adoProj, adoRepo) {
848
919
  const token = await getAdoToken();
849
920
  if (!token) return null;
@@ -968,6 +1039,22 @@ function _setAdoThrottleForTest(state) {
968
1039
  _adoThrottle._setForTest(state);
969
1040
  }
970
1041
 
1042
+ /** Inject a token into the cache — exported for testing only.
1043
+ * Lets tests exercise functions that call getAdoToken() without invoking azureauth.
1044
+ * Pass null to force getAdoToken() to return null synchronously (no exec). */
1045
+ function _setAdoTokenForTest(token) {
1046
+ if (token == null) {
1047
+ // Clear cache AND set a future failure backoff so getAdoToken short-circuits
1048
+ // to null without spawning azureauth — otherwise tests would hang on the
1049
+ // 15s execAsync timeout or open a real auth popup.
1050
+ _adoTokenCache = { token: null, expiresAt: 0 };
1051
+ _adoTokenFailedUntil = Date.now() + 60 * 60 * 1000;
1052
+ } else {
1053
+ _adoTokenCache = { token, expiresAt: Date.now() + 30 * 60 * 1000 };
1054
+ _adoTokenFailedUntil = 0;
1055
+ }
1056
+ }
1057
+
971
1058
  module.exports = {
972
1059
  getAdoToken,
973
1060
  adoFetch,
@@ -975,6 +1062,7 @@ module.exports = {
975
1062
  pollPrHumanComments,
976
1063
  reconcilePrs,
977
1064
  checkLiveReviewStatus,
1065
+ checkLiveBuildAndConflict,
978
1066
  needsAdoPollRetry,
979
1067
  isAdoAuthError, // exported for testing
980
1068
  isAdoThrottled,
@@ -984,4 +1072,5 @@ module.exports = {
984
1072
  findOpenPrOnBranch,
985
1073
  _resetAdoThrottle, // exported for testing
986
1074
  _setAdoThrottleForTest, // exported for testing
1075
+ _setAdoTokenForTest, // exported for testing
987
1076
  };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-04-29T00:05:48.131Z"
4
+ "cachedAt": "2026-04-29T01:10:14.567Z"
5
5
  }
package/engine/github.js CHANGED
@@ -789,11 +789,79 @@ async function checkLiveReviewStatus(pr, project) {
789
789
  }
790
790
  }
791
791
 
792
+ /**
793
+ * Cheap pre-dispatch freshness check for build status and merge-conflict state.
794
+ * Mirrors checkLiveReviewStatus — fetches PR data once, classifies check-runs
795
+ * for the current head SHA, and reports whether GitHub still considers the PR
796
+ * unmergeable.
797
+ *
798
+ * Returns null if the check can't run (no slug/PR/network) so callers can fall
799
+ * back to cached state. Otherwise returns:
800
+ * {
801
+ * buildStatus: 'failing' | 'passing' | 'running' | 'none' | null,
802
+ * mergeConflict: boolean,
803
+ * }
804
+ *
805
+ * `mergeConflict` is true only when GitHub explicitly reports `mergeable: false`
806
+ * — `mergeable: null` means GitHub is still computing the merge state, so the
807
+ * caller should treat that as "no fresh signal" and trust the cache.
808
+ *
809
+ * `buildStatus` is null when we couldn't query check-runs or the PR isn't open;
810
+ * caller falls back to cached value.
811
+ */
812
+ async function checkLiveBuildAndConflict(pr, project) {
813
+ try {
814
+ const slug = getRepoSlug(project);
815
+ if (!slug) return null;
816
+ const prNum = shared.getPrNumber(pr);
817
+ if (!prNum) return null;
818
+ const prData = await ghApi(`/pulls/${prNum}`, slug);
819
+ if (!prData || prData === GH_NOT_FOUND) return null;
820
+
821
+ // Conflict signal — only treat `mergeable === false` as a positive
822
+ // conflict. `null` means "GitHub still computing" → we have no fresh
823
+ // info, so fall back to whatever the cache says.
824
+ let mergeConflict;
825
+ if (prData.mergeable === false) mergeConflict = true;
826
+ else if (prData.mergeable === true) mergeConflict = false;
827
+ else mergeConflict = !!pr._mergeConflict; // computing — preserve cached view
828
+
829
+ // Build signal — only meaningful for open PRs. Mirrors pollPrStatus's
830
+ // check-runs classification so the live read and cached poll agree on
831
+ // 'failing' / 'passing' / 'running' / 'none'.
832
+ let buildStatus = null;
833
+ if (prData.state === 'open' && prData.head?.sha) {
834
+ try {
835
+ const checksData = await ghApi(`/commits/${prData.head.sha}/check-runs`, slug);
836
+ if (checksData && Array.isArray(checksData.check_runs)) {
837
+ const runs = checksData.check_runs;
838
+ if (runs.length === 0) {
839
+ buildStatus = 'none';
840
+ } else {
841
+ const hasFailed = runs.some(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
842
+ const allDone = runs.every(r => r.status === 'completed');
843
+ const allPassed = runs.every(r => r.conclusion === 'success' || r.conclusion === 'skipped' || r.conclusion === 'neutral');
844
+ if (hasFailed) buildStatus = 'failing';
845
+ else if (allDone && allPassed) buildStatus = 'passing';
846
+ else buildStatus = 'running';
847
+ }
848
+ }
849
+ } catch (e) { log('warn', `Live build check checks query for ${pr.id}: ${e.message}`); }
850
+ }
851
+
852
+ return { buildStatus, mergeConflict };
853
+ } catch (e) {
854
+ log('warn', `Live build/conflict check for ${pr.id}: ${e.message}`);
855
+ return null;
856
+ }
857
+ }
858
+
792
859
  module.exports = {
793
860
  pollPrStatus,
794
861
  pollPrHumanComments,
795
862
  reconcilePrs,
796
863
  checkLiveReviewStatus,
864
+ checkLiveBuildAndConflict,
797
865
  isGhThrottled,
798
866
  getGhThrottleState,
799
867
  // Exported for testing
package/engine.js CHANGED
@@ -1581,8 +1581,8 @@ function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
1581
1581
  // ─── Inbox Consolidation (extracted to engine/consolidation.js) ──────────────
1582
1582
 
1583
1583
  const { consolidateInbox } = require('./engine/consolidation');
1584
- const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview, needsAdoPollRetry, getAdoToken, isAdoThrottled } = require('./engine/ado');
1585
- const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs, checkLiveReviewStatus: ghCheckLiveReview, isGhThrottled } = require('./engine/github');
1584
+ const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview, checkLiveBuildAndConflict: adoCheckLiveBuildAndConflict, needsAdoPollRetry, getAdoToken, isAdoThrottled } = require('./engine/ado');
1585
+ const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs, checkLiveReviewStatus: ghCheckLiveReview, checkLiveBuildAndConflict: ghCheckLiveBuildAndConflict, isGhThrottled } = require('./engine/github');
1586
1586
 
1587
1587
  // ─── State Snapshot ─────────────────────────────────────────────────────────
1588
1588
 
@@ -2255,6 +2255,39 @@ async function discoverFromPrs(config, project) {
2255
2255
 
2256
2256
  const key = `build-fix-${project?.name || 'default'}-${prDisplayId}`;
2257
2257
  if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
2258
+
2259
+ // Pre-dispatch live build check — cached buildStatus may be stale: ADO can
2260
+ // recompute the merge commit when master moves and pollPrStatus deliberately
2261
+ // preserves the previous 'failing' value (issue #1233); GitHub check-runs
2262
+ // may have flipped to 'passing' minutes before the next 12-tick poll. Mirror
2263
+ // the review/re-review live-vote guard so we don't dispatch a fix for a
2264
+ // build that has already recovered.
2265
+ try {
2266
+ const checkBcFn = project.repoHost === 'github' ? ghCheckLiveBuildAndConflict : adoCheckLiveBuildAndConflict;
2267
+ const live = await checkBcFn(pr, project);
2268
+ if (live && live.buildStatus && live.buildStatus !== 'failing') {
2269
+ log('info', `Pre-dispatch build check: ${pr.id} build is ${live.buildStatus} (cached was failing) — skipping build-fix`);
2270
+ // Persist the fresh status so subsequent ticks don't re-check on every pass
2271
+ try {
2272
+ mutatePullRequests(projectPrPath(project), prs => {
2273
+ const target = shared.findPrRecord(prs, pr, project);
2274
+ if (!target) return;
2275
+ target.buildStatus = live.buildStatus;
2276
+ if (live.buildStatus === 'passing') {
2277
+ delete target.buildErrorLog;
2278
+ delete target.buildFailReason;
2279
+ delete target._buildFailNotified;
2280
+ if (target.buildFixAttempts) {
2281
+ delete target.buildFixAttempts;
2282
+ delete target.buildFixEscalated;
2283
+ }
2284
+ }
2285
+ });
2286
+ } catch {}
2287
+ continue;
2288
+ }
2289
+ } catch (e) { log('warn', `Pre-dispatch build check for ${pr.id}: ${e.message} — skipping dispatch`); continue; }
2290
+
2258
2291
  const agentId = resolveAgent('fix', config, pr.agent);
2259
2292
  if (!agentId) continue;
2260
2293
 
@@ -2306,22 +2339,47 @@ async function discoverFromPrs(config, project) {
2306
2339
  const conflictFixedAt = pr._conflictFixedAt;
2307
2340
  const withinLag = conflictFixedAt && Date.now() - new Date(conflictFixedAt).getTime() < 10 * 60 * 1000;
2308
2341
  if (!withinLag && !fixThrottled && !isAlreadyDispatched(key) && !isOnCooldown(key, cooldownMs)) {
2309
- const agentId = resolveAgent('fix', config, pr.agent);
2310
- if (agentId) {
2311
- const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2312
- pr_id: pr.id, pr_branch: pr.branch || '',
2313
- review_note: `This PR has merge conflicts with the target branch. Resolve the conflicts:\n\n1. Pull latest from main/master\n2. Resolve all conflicts (prefer PR branch changes unless main has critical fixes)\n3. Build and test after resolving\n4. Push the resolved branch`,
2314
- }, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2315
- if (item) {
2316
- newWork.push(item);
2317
- setCooldown(key);
2318
- // Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
2342
+ // Pre-dispatch live conflict check — cached `_mergeConflict` may be
2343
+ // stale: ADO/GitHub recompute mergeStatus asynchronously (1–5 min lag),
2344
+ // so a successful upstream merge can leave the flag set even after the
2345
+ // conflict is gone. Mirror the review/re-review live-vote guard so we
2346
+ // don't dispatch a conflict-fix for a PR that's already clean.
2347
+ let liveSkip = false;
2348
+ try {
2349
+ const checkBcFn = project.repoHost === 'github' ? ghCheckLiveBuildAndConflict : adoCheckLiveBuildAndConflict;
2350
+ const live = await checkBcFn(pr, project);
2351
+ if (live && live.mergeConflict === false) {
2352
+ log('info', `Pre-dispatch conflict check: ${pr.id} reports clean merge (cached was conflict) — skipping conflict-fix`);
2319
2353
  try {
2320
2354
  mutatePullRequests(projectPrPath(project), prs => {
2321
2355
  const target = shared.findPrRecord(prs, pr, project);
2322
- if (target) target._conflictFixedAt = new Date().toISOString();
2356
+ if (!target) return;
2357
+ delete target._mergeConflict;
2358
+ delete target._conflictFixedAt;
2323
2359
  });
2324
- } catch (e) { log('warn', `conflict-fix timestamp: ${e.message}`); }
2360
+ } catch {}
2361
+ liveSkip = true;
2362
+ }
2363
+ } catch (e) { log('warn', `Pre-dispatch conflict check for ${pr.id}: ${e.message} — skipping dispatch`); liveSkip = true; }
2364
+
2365
+ if (!liveSkip) {
2366
+ const agentId = resolveAgent('fix', config, pr.agent);
2367
+ if (agentId) {
2368
+ const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
2369
+ pr_id: pr.id, pr_branch: pr.branch || '',
2370
+ review_note: `This PR has merge conflicts with the target branch. Resolve the conflicts:\n\n1. Pull latest from main/master\n2. Resolve all conflicts (prefer PR branch changes unless main has critical fixes)\n3. Build and test after resolving\n4. Push the resolved branch`,
2371
+ }, `Fix merge conflicts on ${pr.id}: ${pr.title || ''}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
2372
+ if (item) {
2373
+ newWork.push(item);
2374
+ setCooldown(key);
2375
+ // Record dispatch timestamp so re-dispatch is suppressed during ADO lag window
2376
+ try {
2377
+ mutatePullRequests(projectPrPath(project), prs => {
2378
+ const target = shared.findPrRecord(prs, pr, project);
2379
+ if (target) target._conflictFixedAt = new Date().toISOString();
2380
+ });
2381
+ } catch (e) { log('warn', `conflict-fix timestamp: ${e.message}`); }
2382
+ }
2325
2383
  }
2326
2384
  }
2327
2385
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1616",
3
+ "version": "0.1.1617",
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"