@yemi33/minions 0.1.1871 → 0.1.1872

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,8 +1,10 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1871 (2026-05-11)
3
+ ## 0.1.1872 (2026-05-11)
4
4
 
5
5
  ### Features
6
+ - Stale-HEAD guard on fix-task pushes (P-c8f2d5e3) (#2360)
7
+ - Cached buildStatus invalidation on no-op completion (#2355)
6
8
  - per-agent memory file architecture (P-f1c5a8b6) (#2354)
7
9
  - Implement pre-dispatch acceptance criteria validation gate (P-a2d6b9c7) (#2352)
8
10
 
@@ -1592,7 +1592,7 @@ async function detectPrFixBranchChange(meta, config) {
1592
1592
  return { changed: null, beforeHead, afterHead: remoteHead || '', reason: 'unable to prove branch head after fix' };
1593
1593
  }
1594
1594
 
1595
- function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChange, config) {
1595
+ function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChange, config, noopReason) {
1596
1596
  const evidenceFingerprint = shared.prFixEvidenceFingerprint(target, cause);
1597
1597
  const prior = shared.getPrNoOpFixRecord(target, cause);
1598
1598
  const sameEvidence = prior?.evidenceFingerprint === evidenceFingerprint;
@@ -1623,6 +1623,20 @@ function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChang
1623
1623
  afterHead: branchChange?.afterHead || '',
1624
1624
  };
1625
1625
 
1626
+ // Record a same-SHA dispatch outcome on the PR record so the eligibility
1627
+ // filter can short-circuit duplicate build-fix dispatches against an
1628
+ // unchanged commit. Reset happens implicitly when headSha advances and the
1629
+ // discovery filter compares lastDispatchHeadSha to the current head.
1630
+ const headSha = getPrFixBaselineHead(target);
1631
+ target.lastDispatchedAt = now;
1632
+ target.lastDispatchOutcome = 'noop';
1633
+ target.lastDispatchHeadSha = headSha;
1634
+ target.lastDispatchReason = String(
1635
+ noopReason
1636
+ || branchChange?.reason
1637
+ || 'fix completed without changing the PR branch'
1638
+ ).slice(0, 500);
1639
+
1626
1640
  if (cause === shared.PR_FIX_CAUSE.HUMAN_FEEDBACK && target.humanFeedback) {
1627
1641
  target.humanFeedback.pendingFix = !paused;
1628
1642
  if (paused) target.humanFeedback.noOpPaused = true;
@@ -1639,6 +1653,14 @@ function clearPrNoOpFixAttempt(target, cause) {
1639
1653
  if (Object.keys(target._noOpFixes).length === 0) delete target._noOpFixes;
1640
1654
  if (target._lastNoOpFix?.cause === cause) delete target._lastNoOpFix;
1641
1655
  if (target.humanFeedback) delete target.humanFeedback.noOpPaused;
1656
+ // The lastDispatch* trackers exist to prevent duplicate noop dispatches at
1657
+ // the same head; once the agent actually pushed a fix we no longer want them
1658
+ // to suppress a fresh dispatch (the SHA may have moved or the next failure
1659
+ // is genuinely new).
1660
+ delete target.lastDispatchedAt;
1661
+ delete target.lastDispatchOutcome;
1662
+ delete target.lastDispatchHeadSha;
1663
+ delete target.lastDispatchReason;
1642
1664
  }
1643
1665
 
1644
1666
  function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId = '') {
@@ -1666,7 +1688,7 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
1666
1688
  target.minionsReview = next;
1667
1689
  };
1668
1690
  if (explicitlyChangedBranch && options.branchChange?.changed === false) {
1669
- const record = recordPrNoOpFixAttempt(target, cause, source, options.dispatchItem, options.branchChange, options.config);
1691
+ const record = recordPrNoOpFixAttempt(target, cause, source, options.dispatchItem, options.branchChange, options.config, options.noopReason);
1670
1692
  result = { noOp: true, cause, paused: !!record.paused, count: record.count };
1671
1693
  log('warn', `Updated ${pr.id} → recorded no-op ${cause} fix attempt ${record.count}${record.paused ? ' (paused)' : ''}; PR branch was unchanged`);
1672
1694
  return prs;
@@ -1678,7 +1700,7 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
1678
1700
  // automation cause handled — a future tick with working detection must
1679
1701
  // be free to re-dispatch.
1680
1702
  if (explicitlyChangedBranch && options.branchChange?.changed === null) {
1681
- const record = recordPrNoOpFixAttempt(target, cause, source, options.dispatchItem, options.branchChange, options.config);
1703
+ const record = recordPrNoOpFixAttempt(target, cause, source, options.dispatchItem, options.branchChange, options.config, options.noopReason);
1682
1704
  result = { noOp: true, cause, paused: !!record.paused, count: record.count, indeterminate: true };
1683
1705
  log('warn', `Updated ${pr.id} → recorded indeterminate ${cause} fix attempt ${record.count}${record.paused ? ' (paused)' : ''}; PR branch advance could not be verified${options.branchChange?.reason ? ` (${options.branchChange.reason})` : ''}`);
1684
1706
  return prs;
@@ -3208,6 +3230,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
3208
3230
  dispatchItem,
3209
3231
  branchChange: prFixBranchChange,
3210
3232
  config,
3233
+ noopReason: noopRationale || meta?._noopReason || '',
3211
3234
  });
3212
3235
  // (#984) Sync PRD status for PR-linked features: fix work items have a different ID
3213
3236
  // than the original PRD feature, so syncPrdItemStatus(fixWiId, ...) finds nothing.
@@ -162,6 +162,81 @@ function formatProcessExitSentinel(exitCode, signal) {
162
162
  return `\n[process-exit] code=${exitCode}${signal ? ` signal=${signal}` : ''}\n`;
163
163
  }
164
164
 
165
+ /**
166
+ * Pre-push stale-HEAD guard for fix-task dispatches (P-c8f2d5e3).
167
+ *
168
+ * When the engine reuses an existing worktree on a PR branch that was rebased
169
+ * upstream (force-push), the local HEAD can sit behind origin/<branch>. The
170
+ * first push from that worktree silently overwrites the rebased history — a
171
+ * confirmed silent-overwrite footgun captured in team memory.
172
+ *
173
+ * This helper runs:
174
+ * git fetch origin <branch>
175
+ * git rev-list --count HEAD..origin/<branch>
176
+ * inside the worktree. When the count is > 0 it throws a clear, actionable
177
+ * error so engine.spawnAgent can abort the dispatch before invoking the
178
+ * runtime CLI — i.e. before the agent has a chance to push.
179
+ *
180
+ * The fetch is best-effort: if origin doesn't have the ref yet (first push on
181
+ * a fresh branch, common for shared-branch plan items), the helper returns
182
+ * `{ ok: true, skipped: 'no-upstream' }` instead of failing — there's no
183
+ * rebased tip to overwrite. Any other fetch failure is also treated as a
184
+ * skip with `skipped: 'fetch-failed'` so transient network issues don't
185
+ * brick an otherwise-healthy dispatch.
186
+ *
187
+ * @param {object} args
188
+ * @param {string} args.branch - PR branch name (already sanitized)
189
+ * @param {string} args.cwd - Worktree path
190
+ * @param {function} [args.exec] - Async exec(cmd, opts) — injectable for tests
191
+ * @param {object} [args.gitOpts] - Options passed through to exec
192
+ * @returns {Promise<{ok: true, behindCount: number, skipped?: string}>}
193
+ * @throws {Error & {code: 'STALE_HEAD'}} when local HEAD is behind origin
194
+ */
195
+ async function assertStaleHeadOk({ branch, cwd, exec, gitOpts } = {}) {
196
+ if (!branch) throw new Error('assertStaleHeadOk: branch is required');
197
+ if (!cwd) throw new Error('assertStaleHeadOk: cwd is required');
198
+ const execFn = typeof exec === 'function'
199
+ ? exec
200
+ : require('./shared').execAsync;
201
+ const opts = { ...(gitOpts || {}), cwd };
202
+
203
+ // Best-effort fetch. Branch-missing-on-origin is a legitimate state (first
204
+ // push on a freshly-cut feature branch) and must NOT block dispatch.
205
+ try {
206
+ await execFn(`git fetch origin "${branch}"`, opts);
207
+ } catch (err) {
208
+ const msg = (err && (err.stderr?.toString?.() || err.message || '')) + '';
209
+ if (/couldn'?t find remote ref|not found in upstream|unknown revision/i.test(msg)) {
210
+ return { ok: true, behindCount: 0, skipped: 'no-upstream' };
211
+ }
212
+ // Other failures (network/auth/timeout) — skip rather than block.
213
+ return { ok: true, behindCount: 0, skipped: 'fetch-failed' };
214
+ }
215
+
216
+ let countOut;
217
+ try {
218
+ countOut = await execFn(`git rev-list --count HEAD..origin/${branch}`, opts);
219
+ } catch (err) {
220
+ // origin/<branch> resolution failed AFTER fetch — treat as no-upstream.
221
+ return { ok: true, behindCount: 0, skipped: 'rev-list-failed' };
222
+ }
223
+ const raw = typeof countOut === 'string'
224
+ ? countOut
225
+ : (countOut?.stdout?.toString?.() ?? String(countOut ?? ''));
226
+ const behindCount = parseInt(String(raw).trim(), 10);
227
+ if (!Number.isFinite(behindCount) || behindCount <= 0) {
228
+ return { ok: true, behindCount: Number.isFinite(behindCount) ? behindCount : 0 };
229
+ }
230
+ const err = new Error(
231
+ `PR branch was rebased; local HEAD is stale (${behindCount} commits behind origin). ` +
232
+ `Run \`git pull --rebase origin ${branch}\` first.`
233
+ );
234
+ err.code = 'STALE_HEAD';
235
+ err.behindCount = behindCount;
236
+ err.branch = branch;
237
+ throw err;
238
+ }
239
+
165
240
  // The orphan reaper recovers an agent's exit code by scanning live-output.log for
166
241
  // `[process-exit] code=N`. The previous design wrote the sentinel to stdout, hoping
167
242
  // the engine's stdout consumer (engine.js) would copy it into the file — but when
@@ -456,6 +531,6 @@ function main() {
456
531
  });
457
532
  }
458
533
 
459
- module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, computeAddDirs, createParentPipeForwarder };
534
+ module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, computeAddDirs, createParentPipeForwarder, assertStaleHeadOk };
460
535
 
461
536
  if (require.main === module) main();
package/engine.js CHANGED
@@ -28,6 +28,7 @@ const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
28
28
  WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, DISPATCH_RESULT, AGENT_STATUS,
29
29
  FAILURE_CLASS } = shared;
30
30
  const { resolveRuntime } = require('./engine/runtimes');
31
+ const { assertStaleHeadOk } = require('./engine/spawn-agent');
31
32
  const queries = require('./engine/queries');
32
33
 
33
34
  // ─── Paths ──────────────────────────────────────────────────────────────────
@@ -1114,6 +1115,41 @@ async function spawnAgent(dispatchItem, config) {
1114
1115
  log('warn', `Agent ${agentId} running ${type} task in main repo (no worktree) for ${id} — changes may land on master directly`);
1115
1116
  }
1116
1117
 
1118
+ // ── Stale-HEAD guard for fix-task pushes (P-c8f2d5e3) ────────────────────
1119
+ // When a PR branch is rebased upstream (force-push), a reused worktree can
1120
+ // sit on local HEAD that's behind origin/<branch>. The first push from that
1121
+ // worktree silently overwrites the rebased history. Fix-task dispatches are
1122
+ // the canonical case: they always target an existing PR branch the engine
1123
+ // already polled. Abort dispatch BEFORE invoking the runtime CLI so the
1124
+ // agent never gets a chance to push over the rebased tip.
1125
+ // Read-only and non-fix dispatches are out of scope — implement tasks cut
1126
+ // their own branch from main, and review/verify don't push.
1127
+ if (type === WORK_TYPE.FIX && branchName && worktreePath && cwd === worktreePath) {
1128
+ try {
1129
+ const guard = await assertStaleHeadOk({
1130
+ branch: branchName,
1131
+ cwd: worktreePath,
1132
+ exec: execAsync,
1133
+ gitOpts: { ..._gitOpts, timeout: 15000 },
1134
+ });
1135
+ if (guard.skipped) {
1136
+ log('info', `Stale-HEAD guard skipped for ${id} (${branchName}): ${guard.skipped}`);
1137
+ }
1138
+ } catch (err) {
1139
+ if (err && err.code === 'STALE_HEAD') {
1140
+ log('error', `Stale-HEAD guard rejected fix dispatch ${id} on ${branchName}: ${err.message}`);
1141
+ _cleanupPromptFiles();
1142
+ completeDispatch(id, DISPATCH_RESULT.ERROR, err.message.slice(0, 300));
1143
+ cleanupTempAgent(agentId);
1144
+ return null;
1145
+ }
1146
+ // Non-STALE_HEAD failures from the guard itself shouldn't block dispatch
1147
+ // (the guard is conservative by design — fetch/network issues fall through
1148
+ // to skipped:'fetch-failed'). Log and continue.
1149
+ log('warn', `Stale-HEAD guard error for ${id} (${branchName}): ${err.message}`);
1150
+ }
1151
+ }
1152
+
1117
1153
  // ── Runtime + opts resolution (P-2a6d9c4f) ────────────────────────────────
1118
1154
  // Every CLI-specific knob flows through the runtime adapter resolved from
1119
1155
  // resolveAgentCli(agent, engine). Engine code MUST NOT branch on
@@ -2936,6 +2972,20 @@ async function discoverFromPrs(config, project) {
2936
2972
  const autoFixBuilds = config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds;
2937
2973
  if (pollEnabled && autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing'
2938
2974
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.BUILD_FAILURE)) {
2975
+ // P-b7e1c4d2: skip when the most recent dispatch already noop'd against
2976
+ // the same head SHA — chronic across PRs #2315–#2323 where every fix
2977
+ // agent rebutted "this is a pre-existing master baseline" but the
2978
+ // cached buildStatus:failing kept re-triggering the loop. The check
2979
+ // clears automatically once a new commit lands (lastDispatchHeadSha
2980
+ // stops matching the current head).
2981
+ const currentHeadSha = String(pr.headSha || pr._adoSourceCommit || pr._adoHeadCommit || '').trim();
2982
+ if (pr.lastDispatchOutcome === 'noop'
2983
+ && pr.lastDispatchHeadSha
2984
+ && currentHeadSha
2985
+ && pr.lastDispatchHeadSha === currentHeadSha) {
2986
+ log('info', `Skipping build-fix for ${pr.id}: last dispatch was noop on the same head ${currentHeadSha.slice(0, 8)} (${(pr.lastDispatchReason || '').slice(0, 120)})`);
2987
+ continue;
2988
+ }
2939
2989
  const buildCauseKey = getPrAutomationCauseKey('build', pr);
2940
2990
  const key = getPrAutomationDispatchKey(`build-fix-${project?.name || 'default'}-${prDisplayId}`, buildCauseKey);
2941
2991
  if (isPrAutomationCauseHandledOrPending(project, pr, buildCauseKey)) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1871",
3
+ "version": "0.1.1872",
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"