@yemi33/minions 0.1.2312 → 0.1.2313

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.
@@ -187,6 +187,30 @@ function prRow(pr) {
187
187
  + '⏸ paused: ' + escapeHtml(cause) + ' ▶</button>';
188
188
  }).join('');
189
189
  }
190
+ // #639 follow-up — build-fix-ineffective chip. Distinct from the generic
191
+ // no-op pausedCauses above: pr._buildFixIneffective is set when a
192
+ // BUILD_FAILURE fix reported "no branch change" AND a live CI re-poll
193
+ // confirmed the build is STILL failing on that same head (see
194
+ // engine/lifecycle.js recordBuildFixIneffective). This is a separate,
195
+ // headSha-keyed pause distinct from _noOpFixes, so it needs its own,
196
+ // clearly-labeled indicator — a still-red build after an unchanged fix
197
+ // attempt usually means the failure isn't fixable by another agent pass
198
+ // (flaky infra, external outage, etc.), hence "infra issue suspected".
199
+ // No resume action is wired here (clearing this record is an engine-side
200
+ // change out of scope for this UI-only follow-up) — informational only.
201
+ var buildFixIneffective = pr._buildFixIneffective;
202
+ if (buildFixIneffective && typeof buildFixIneffective === 'object' && buildFixIneffective.paused === true) {
203
+ var bfiTitle = 'Build-fix reported no branch change, and a live CI re-poll confirmed the build is still "'
204
+ + (buildFixIneffective.liveBuildStatus || 'failing') + '" on the same commit ('
205
+ + (buildFixIneffective.headSha || 'unknown head') + ') after '
206
+ + (buildFixIneffective.count || '?') + ' ineffective attempt(s). Reason: '
207
+ + (buildFixIneffective.reason || 'unknown')
208
+ + '. A new push to this branch will reset this pause.';
209
+ pausedChips += ' <span class="pr-badge rejected pr-build-fix-ineffective-chip" '
210
+ + 'style="font-size:var(--text-xs)" '
211
+ + 'title="' + escapeHtml(bfiTitle) + '">'
212
+ + '⏸ infra issue suspected ▶</span>';
213
+ }
190
214
  const titleText = pr.title || 'Untitled';
191
215
  // Polish (W-mqv5ccn7): flatten Markdown in the body so the preview shows
192
216
  // plain text instead of raw `##` / `**…**` / backtick source.
@@ -2647,6 +2647,104 @@ async function detectPrFixBranchChange(meta, config) {
2647
2647
  return { changed: null, beforeHead, afterHead: remoteHead || '', reason: 'unable to prove branch head after fix' };
2648
2648
  }
2649
2649
 
2650
+ // #639 — lazy requires for the host-specific live build/conflict checkers.
2651
+ // engine/github.js and engine/ado.js both lazy-require lifecycle.js (see
2652
+ // their `lifecycleModule()` helpers), so a top-level require here would form
2653
+ // an import cycle. Deferring to first call avoids that.
2654
+ let _githubPrModule = null;
2655
+ function githubPrModule() { if (!_githubPrModule) _githubPrModule = require('./github'); return _githubPrModule; }
2656
+ let _adoPrModule = null;
2657
+ function adoPrModule() { if (!_adoPrModule) _adoPrModule = require('./ado'); return _adoPrModule; }
2658
+
2659
+ // #639 — No-op fix detection previously only proved whether the agent pushed
2660
+ // a NEW commit; it never checked whether the build the fix was dispatched
2661
+ // against had actually turned green. That let a stuck failure get marked
2662
+ // "handled"/no-op forever: a fix commit lands and gets recorded as handled,
2663
+ // CI re-runs and fails again with the SAME signature against that exact
2664
+ // commit, and every subsequent build-fix dispatch finds "the fix is already
2665
+ // on the branch" (branch unchanged) and concludes no-op — without ever
2666
+ // re-checking that the build is still red. Call this right before accepting
2667
+ // a BUILD_FAILURE "no branch change" outcome as a clean no-op so the caller
2668
+ // can distinguish "genuinely nothing to do" from "fix was ineffective".
2669
+ async function checkBuildStillFailingLive(pr, project) {
2670
+ if (!pr || !project) return null;
2671
+ try {
2672
+ const checkFn = project.repoHost === 'github'
2673
+ ? githubPrModule().checkLiveBuildAndConflict
2674
+ : adoPrModule().checkLiveBuildAndConflict;
2675
+ if (typeof checkFn !== 'function') return null;
2676
+ const live = await checkFn(pr, project);
2677
+ // A stale/errored live read carries no fresh evidence — don't let it
2678
+ // masquerade as a confirmed still-failing result.
2679
+ if (!live || live.buildStatusStale) return null;
2680
+ return live.buildStatus || null;
2681
+ } catch (err) {
2682
+ log('warn', `Live build re-check for ${pr.id || pr.url || '(unknown PR)'}: ${err.message}`);
2683
+ return null;
2684
+ }
2685
+ }
2686
+
2687
+ // #639 — Records a build-fix dispatch that reported "no branch change" AND
2688
+ // was verified (via live re-poll) to still be failing on the same head.
2689
+ // Tracked deliberately separately from `_noOpFixes[BUILD_FAILURE]` — see
2690
+ // `shared.isBuildFixIneffectivePaused` for why sharing that counter would
2691
+ // let a genuinely-broken build get stuck paused forever with no distinct
2692
+ // signal. The dispatch-eligibility gate for BUILD_FAILURE reads BOTH
2693
+ // `shared.isPrNoOpFixCausePaused` and `shared.isBuildFixIneffectivePaused`
2694
+ // (engine.js), and neither this record's `outcome` nor its pause state
2695
+ // suppresses the same-head skip guard tied to `outcome === 'noop'` — so the
2696
+ // engine keeps re-dispatching (subject to the normal cooldown) with a fresh
2697
+ // instruction to inspect the CURRENT failure rather than assume the prior
2698
+ // commit already fixed it, until either a real fix lands (head moves) or
2699
+ // the attempt cap is reached and a human is alerted.
2700
+ function recordBuildFixIneffective(target, dispatchItem, config, liveBuildStatus) {
2701
+ const headSha = getPrFixBaselineHead(target);
2702
+ const prior = target._buildFixIneffective && typeof target._buildFixIneffective === 'object'
2703
+ ? target._buildFixIneffective : null;
2704
+ const sameHead = !!prior && !!headSha && prior.headSha === headSha;
2705
+ const count = sameHead ? (Number(prior.count) || 0) + 1 : 1;
2706
+ const pauseAfter = Number(config?.engine?.prNoOpFixPauseAttempts) || ENGINE_DEFAULTS.prNoOpFixPauseAttempts;
2707
+ const paused = count >= pauseAfter;
2708
+ const flippedToPaused = paused && !(sameHead && prior?.paused === true);
2709
+ const now = ts();
2710
+ const reason = `build-fix reported no branch change but live CI is still ${liveBuildStatus || 'failing'} on head ${String(headSha || '').slice(0, 12)}`;
2711
+ target._buildFixIneffective = {
2712
+ count,
2713
+ paused,
2714
+ headSha,
2715
+ liveBuildStatus: liveBuildStatus || 'failing',
2716
+ firstAt: sameHead ? (prior.firstAt || now) : now,
2717
+ lastAt: now,
2718
+ dispatchId: dispatchItem?.id || null,
2719
+ reason,
2720
+ };
2721
+ if (flippedToPaused) {
2722
+ try {
2723
+ const prNumber = target.prNumber || shared.getPrNumber(target) || target.id || 'unknown';
2724
+ const noteBody = `# Build fix ineffective: ${target.id || prNumber}\n\n`
2725
+ + `**PR:** ${target.url || target.id || '(unknown)'}\n`
2726
+ + `**Branch:** ${target.branch || '(unknown)'}\n`
2727
+ + `**Head:** ${String(headSha || '').slice(0, 40) || '(unknown)'}\n`
2728
+ + `**Attempt:** ${count}/${pauseAfter} (paused)\n\n`
2729
+ + `A build-failure fix dispatch reported no branch change, but re-polling live CI showed the build is STILL \`${liveBuildStatus || 'failing'}\` against that exact commit. `
2730
+ + `The engine reached \`engine.prNoOpFixPauseAttempts\` (${pauseAfter}) consecutive ineffective-fix attempts against the same head and has paused build-failure automation for this PR.\n\n`
2731
+ + `**Recovery options:**\n`
2732
+ + `1. A new head SHA on this PR auto-clears the pause (a real fix attempt landed).\n`
2733
+ + `2. Manually inspect the live CI logs — the automated fixes may be addressing the wrong failure, or the failure may not be fixable by re-pushing (flaky infra, protected branch, etc).\n`;
2734
+ shared.writeToInbox(
2735
+ 'engine',
2736
+ `build-fix-ineffective-${prNumber}`,
2737
+ noteBody,
2738
+ null,
2739
+ { wi: dispatchItem?.meta?.item?.id || null, pr: target.id || null, cause: shared.PR_FIX_CAUSE.BUILD_FAILURE }
2740
+ );
2741
+ } catch (err) {
2742
+ log('warn', `build-fix-ineffective inbox alert for ${target.id || '(unknown)'}: ${err.message}`);
2743
+ }
2744
+ }
2745
+ return target._buildFixIneffective;
2746
+ }
2747
+
2650
2748
  function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChange, config, noopReason) {
2651
2749
  const evidenceFingerprint = shared.prFixEvidenceFingerprint(target, cause);
2652
2750
  const prior = shared.getPrNoOpFixRecord(target, cause);
@@ -2992,6 +3090,22 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
2992
3090
  return prs;
2993
3091
  }
2994
3092
  if (explicitlyChangedBranch && options.branchChange?.changed === false) {
3093
+ // #639 — a BUILD_FAILURE fix that reported "no branch change" is only a
3094
+ // genuine no-op if the build the fix was dispatched against actually
3095
+ // recovered. `options.buildStillFailing` (set by the caller from a live
3096
+ // re-poll of the current head commit) proves it did NOT — the same
3097
+ // failure persists against the exact commit the agent left in place.
3098
+ // Route through the distinct fixIneffective counter instead of the
3099
+ // shared no-op/pause counter so a stuck-red build gets its own signal
3100
+ // and its own (non-sticky-forever) pause, rather than silently
3101
+ // consuming the generic no-op pause budget with no way to tell "there
3102
+ // was truly nothing to do" apart from "the fix didn't work".
3103
+ if (cause === shared.PR_FIX_CAUSE.BUILD_FAILURE && options.buildStillFailing) {
3104
+ const record = recordBuildFixIneffective(target, options.dispatchItem, options.config, options.buildStillFailing);
3105
+ result = { noOp: true, cause, fixIneffective: true, paused: !!record.paused, count: record.count };
3106
+ log('warn', `Updated ${pr.id} → build-fix reported no branch change, but live CI is still ${options.buildStillFailing} on the same head; recorded fixIneffective attempt ${record.count}${record.paused ? ' (paused)' : ''}`);
3107
+ return prs;
3108
+ }
2995
3109
  const record = recordPrNoOpFixAttempt(target, cause, source, options.dispatchItem, options.branchChange, options.config, options.noopReason);
2996
3110
  result = { noOp: true, cause, paused: !!record.paused, count: record.count };
2997
3111
  log('warn', `Updated ${pr.id} → recorded no-op ${cause} fix attempt ${record.count}${record.paused ? ' (paused)' : ''}; PR branch was unchanged`);
@@ -3022,6 +3136,10 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
3022
3136
  target._buildFixPushedAt = ts();
3023
3137
  delete target._buildFixPushFailedCount;
3024
3138
  delete target._buildFixNeedsHumanRebase;
3139
+ // #639 — a genuine branch-changing fix landed; drop any stale
3140
+ // fixIneffective pause from a prior head so the next ineffective
3141
+ // detection (if any) starts a fresh count against the new commit.
3142
+ delete target._buildFixIneffective;
3025
3143
  }
3026
3144
  if (source === 'pr-human-feedback') {
3027
3145
  const clearPendingFix = shouldClearHumanFeedbackPendingFix(target, pr, automationCauseKey);
@@ -5729,6 +5847,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5729
5847
  // Archive is manual — user archives plans from the dashboard when ready
5730
5848
 
5731
5849
  let prFixBranchChange = null;
5850
+ let prFixBuildStillFailing = null;
5732
5851
  if (type === WORK_TYPE.FIX && effectiveSuccess && meta?.pr?.id) {
5733
5852
  try {
5734
5853
  prFixBranchChange = await detectPrFixBranchChange(meta, config);
@@ -5736,6 +5855,30 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5736
5855
  log('warn', `PR fix no-op detection for ${meta.pr.id}: ${err.message}`);
5737
5856
  prFixBranchChange = { changed: null, reason: err.message };
5738
5857
  }
5858
+ // #639 — a BUILD_FAILURE fix that didn't change the branch is not
5859
+ // necessarily a genuine no-op: the agent may have found its own (or a
5860
+ // prior dispatch's) fix commit already present and concluded "nothing to
5861
+ // change" without checking whether CI actually turned green on that
5862
+ // commit. Re-poll live CI for the head before letting updatePrAfterFix
5863
+ // accept the no-op — if it's still failing, flag it so the no-op path
5864
+ // records a distinct fixIneffective signal instead of a clean no-op.
5865
+ if (prFixBranchChange?.changed === false) {
5866
+ const fixCause = shared.getPrFixAutomationCause({
5867
+ dispatchKey: dispatchItem?.meta?.dispatchKey,
5868
+ source: meta?.source,
5869
+ task: dispatchItem?.task,
5870
+ });
5871
+ if (fixCause === shared.PR_FIX_CAUSE.BUILD_FAILURE) {
5872
+ try {
5873
+ const liveStatus = await checkBuildStillFailingLive(meta.pr, meta.project);
5874
+ if (liveStatus === shared.BUILD_STATUS.FAILING) {
5875
+ prFixBuildStillFailing = liveStatus;
5876
+ }
5877
+ } catch (err) {
5878
+ log('warn', `Live build re-check for ${meta.pr.id}: ${err.message}`);
5879
+ }
5880
+ }
5881
+ }
5739
5882
  }
5740
5883
 
5741
5884
  // Scheduled task back-reference: update schedule-runs.json and write linked inbox note
@@ -5854,6 +5997,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5854
5997
  automationCauseKey: meta?.automationCauseKey,
5855
5998
  dispatchItem,
5856
5999
  branchChange: prFixBranchChange,
6000
+ buildStillFailing: prFixBuildStillFailing,
5857
6001
  config,
5858
6002
  noopReason: noopRationale || meta?._noopReason || '',
5859
6003
  });
@@ -6666,6 +6810,11 @@ module.exports = {
6666
6810
  // Issue #2969 — exported for direct unit testing of the pause-flip alert
6667
6811
  // path without going through updatePrAfterFix's many guards.
6668
6812
  recordPrNoOpFixAttempt,
6813
+ // #639 — exported for direct unit testing of the build-fix-ineffective
6814
+ // detection path (no-op branch + still-failing live CI) without spinning
6815
+ // up the full ADO/GH poller stack.
6816
+ checkBuildStillFailingLive,
6817
+ recordBuildFixIneffective,
6669
6818
  // W-mq5uzmc6001d708f — post-hoc PR enrollment for orphan `_pr` pointers.
6670
6819
  enrollPrFromCanonicalId,
6671
6820
  _setEnrollmentGhRunnerForTest,
package/engine/shared.js CHANGED
@@ -8671,6 +8671,24 @@ function getPrPausedCauses(pr) {
8671
8671
  return causes;
8672
8672
  }
8673
8673
 
8674
+ // #639 — Distinct pause signal for "fix reported no branch change, but a live
8675
+ // re-poll proved the build is STILL failing on that exact (unchanged) head".
8676
+ // This is deliberately tracked separately from `_noOpFixes[BUILD_FAILURE]`:
8677
+ // that record's evidence fingerprint includes `headRefOid`, so when the head
8678
+ // never moves (the agent genuinely pushed nothing) the fingerprint never
8679
+ // changes either — a real "build never actually got fixed" case would get
8680
+ // stuck paused forever with no distinguishing signal from a benign "nothing
8681
+ // needed doing" no-op (see #639). `_buildFixIneffective` re-validates against
8682
+ // the live head SHA the same way `isPrNoOpFixCausePaused` re-validates the
8683
+ // fingerprint: a new head SHA (real fix attempt landed) always releases the
8684
+ // pause, even though the underlying counter never got a fresh evidence key.
8685
+ function isBuildFixIneffectivePaused(pr) {
8686
+ const record = pr?._buildFixIneffective;
8687
+ if (!record || typeof record !== 'object' || record.paused !== true) return false;
8688
+ const currentHead = _prHeadSha(pr);
8689
+ return !!currentHead && record.headSha === currentHead;
8690
+ }
8691
+
8674
8692
  /**
8675
8693
  * Recursively purge Windows reserved-name pseudo-files (NUL, CON, PRN, AUX, etc.)
8676
8694
  * using the \\?\ extended path prefix that bypasses reserved-name interpretation.
@@ -9460,6 +9478,7 @@ module.exports = {
9460
9478
  getPrNoOpFixRecord,
9461
9479
  isPrNoOpFixCausePaused,
9462
9480
  getPrPausedCauses,
9481
+ isBuildFixIneffectivePaused,
9463
9482
  parseSkillFrontmatter,
9464
9483
  sleepMs,
9465
9484
  killGracefully,
package/engine.js CHANGED
@@ -6619,6 +6619,18 @@ function isPrNoOpFixCauseSuppressed(pr, cause) {
6619
6619
  return true;
6620
6620
  }
6621
6621
 
6622
+ // #639 — distinct suppression check for the build-fix-ineffective pause
6623
+ // (see shared.isBuildFixIneffectivePaused / engine/lifecycle.js
6624
+ // recordBuildFixIneffective). Kept separate from isPrNoOpFixCauseSuppressed
6625
+ // so the log line is unambiguous about WHY build-fix automation stopped:
6626
+ // repeated attempts genuinely found nothing to change vs. repeated attempts
6627
+ // left the build still red on the same commit.
6628
+ function isBuildFixIneffectiveSuppressed(pr) {
6629
+ if (!shared.isBuildFixIneffectivePaused(pr)) return false;
6630
+ log('warn', `PR ${pr.id}: suppressing build-failure automation — live CI stayed failing across repeated fix attempts on the same head; waiting for a new commit or human resume`);
6631
+ return true;
6632
+ }
6633
+
6622
6634
  const PR_PENDING_MISSING_BRANCH = shared.PR_PENDING_REASON.MISSING_BRANCH;
6623
6635
 
6624
6636
  function normalizePrBranch(value) {
@@ -7330,7 +7342,8 @@ async function discoverFromPrs(config, project) {
7330
7342
  }
7331
7343
  if (pollEnabled && autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing'
7332
7344
  && !fixDispatched
7333
- && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.BUILD_FAILURE)) {
7345
+ && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.BUILD_FAILURE)
7346
+ && !isBuildFixIneffectiveSuppressed(pr)) {
7334
7347
  // W-mpritzcr0004afc5 (#2955): "don't fan out a parallel build-failure
7335
7348
  // fix while the review-feedback path owns the PR" — implemented via
7336
7349
  // the `!fixDispatched` gate above (mirrors the merge-conflict block
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2312",
3
+ "version": "0.1.2313",
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"