@yemi33/minions 0.1.1947 → 0.1.1948

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/engine/ado.js CHANGED
@@ -690,6 +690,14 @@ async function pollPrStatus(config) {
690
690
  if (applyAdoPrMetadata(pr, prData)) updated = true;
691
691
 
692
692
  let newStatus = pr.status;
693
+ // W-mp5trwh60008386d: ADO does NOT need the `prAbandonConfirmCount` confirmation logic
694
+ // that engine/github.js uses. ADO only flips a PR to `abandoned` when a *successful*
695
+ // adoFetch returns `prData.status === 'abandoned'` (below). On 404/auth failure adoFetch
696
+ // throws, the throw propagates to forEachActivePr's `Promise.allSettled`, and the PR
697
+ // record is left untouched — equivalent to "infinite confirmations required". The shared
698
+ // ENGINE_DEFAULTS.prAbandonConfirmCount constant lives in engine/shared.js so a future
699
+ // ADO change can adopt the same semantics if the failure model ever maps 404 to
700
+ // abandonment directly. See engine/github.js pollPrStatus for the GitHub implementation.
693
701
  if (prData.status === 'completed') newStatus = PR_STATUS.MERGED;
694
702
  else if (prData.status === 'abandoned') newStatus = PR_STATUS.ABANDONED;
695
703
  else if (prData.status === 'active') newStatus = PR_STATUS.ACTIVE;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-14T22:37:10.295Z"
4
+ "cachedAt": "2026-05-14T22:39:03.456Z"
5
5
  }
package/engine/github.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  const shared = require('./shared');
8
- const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeJsonArr, safeWrite, mutateJsonFileLocked, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, createThrottleTracker, getProjectOrg } = shared;
8
+ const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeJsonArr, safeWrite, mutateJsonFileLocked, MINIONS_DIR, getPrLinks, backfillPrPrdItems, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, ENGINE_DEFAULTS, createThrottleTracker, getProjectOrg } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
  const { MINIONS_COMMENT_MARKER_RE } = require('./gh-comment');
11
11
  const path = require('path');
@@ -21,6 +21,19 @@ function engine() {
21
21
  let _dispatch = null;
22
22
  function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
23
23
 
24
+ // W-mp5trwh60008386d: test seam so unit tests can mock `gh api` shell-outs
25
+ // without spawning the real CLI. Production code goes through the real
26
+ // `execAsync` from shared; tests call `_setExecAsyncForTest(fn)` to swap in
27
+ // a stub. Pass `null` to restore. Mirrors the `_setAdoTokenForTest` pattern
28
+ // in engine/ado.js.
29
+ let _execAsyncOverride = null;
30
+ function _runExec(cmd, opts) {
31
+ return (_execAsyncOverride || execAsync)(cmd, opts);
32
+ }
33
+ function _setExecAsyncForTest(fn) {
34
+ _execAsyncOverride = (typeof fn === 'function') ? fn : null;
35
+ }
36
+
24
37
  // ─── Constants ──────────────────────────────────────────────────────────────
25
38
 
26
39
  // 10 MB — prevents maxBuffer exceeded errors on repos with many open PRs.
@@ -144,7 +157,7 @@ let _cachedViewerLogin = null;
144
157
  async function _resolveViewerLogin() {
145
158
  if (_cachedViewerLogin) return _cachedViewerLogin;
146
159
  try {
147
- const result = await execAsync('gh api user', { timeout: 10000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
160
+ const result = await _runExec('gh api user', { timeout: 10000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
148
161
  const parsed = JSON.parse(String(result || ''));
149
162
  const login = parsed?.login ? String(parsed.login).toLowerCase() : null;
150
163
  if (login) _cachedViewerLogin = login;
@@ -241,7 +254,7 @@ async function ghApi(endpoint, slug, opts = {}) {
241
254
  try {
242
255
  const paginateFlag = opts.paginate ? ' --paginate' : '';
243
256
  const cmd = `gh api${paginateFlag} "repos/${slug}${endpoint}"`;
244
- const result = await execAsync(cmd, { timeout: opts.timeout || 30000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
257
+ const result = await _runExec(cmd, { timeout: opts.timeout || 30000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
245
258
  const parsed = JSON.parse(result);
246
259
  _ghThrottle.recordSuccess();
247
260
  return parsed;
@@ -310,7 +323,7 @@ async function fetchGhBuildErrorLog(slug, failedRuns) {
310
323
  // Always fetch job log — annotations alone often lack test failure details
311
324
  try {
312
325
  const cmd = `gh api "repos/${slug}/actions/jobs/${run.id}/logs" 2>&1`;
313
- const result = await execAsync(cmd, { timeout: 15000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
326
+ const result = await _runExec(cmd, { timeout: 15000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
314
327
  if (result && !result.includes('Not Found')) {
315
328
  logParts.push(`--- ${run.name || 'Check'} (log) ---\n${result}`);
316
329
  }
@@ -351,9 +364,18 @@ async function forEachActiveGhPr(config, callback) {
351
364
  && shared.isPrCompatibleWithProject(project, pr, pr.url || ''));
352
365
  if (activePrs.length === 0) continue;
353
366
 
354
- // Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo
367
+ // Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo.
368
+ // W-mp5trwh60008386d: ghApi returns the GH_NOT_FOUND sentinel on 404 (a frozen object,
369
+ // *not* null). The pre-fix gate only matched `null`, so a 404 on the base repo (caused by
370
+ // a multi-account `gh auth` switch, network blip, or token rotation) fell through and every
371
+ // per-PR call below 404'd, permanently flipping all active PRs to `abandoned`. We now treat
372
+ // both null and GH_NOT_FOUND as "skip the project for this tick" and explicitly do NOT
373
+ // increment per-PR `_consecutive404s` counters since no per-PR call was made.
355
374
  const probe = await ghApi('', slug);
356
- if (probe === null) {
375
+ if (probe === null || probe === GH_NOT_FOUND) {
376
+ if (probe === GH_NOT_FOUND) {
377
+ log('warn', `GitHub repo probe for ${slug} returned 404 — skipping all per-PR polls for this project this tick (avoids spurious abandonments). Per-PR 404 counters NOT incremented.`);
378
+ }
357
379
  recordSlugFailure(slug);
358
380
  continue;
359
381
  }
@@ -388,6 +410,13 @@ async function forEachActiveGhPr(config, callback) {
388
410
  if (currentPrs[idx].reviewStatus === 'approved' && after.reviewStatus !== 'approved') {
389
411
  after.reviewStatus = 'approved';
390
412
  }
413
+ // W-mp5trwh60008386d: never downgrade `status: merged` — terminal state. A stale
414
+ // 404 reaching `prAbandonConfirmCount` could otherwise overwrite a concurrent
415
+ // success/merge poll. Keeps the central pull-requests.json status invariants
416
+ // intact even under multi-writer races.
417
+ if (currentPrs[idx].status === PR_STATUS.MERGED && after.status !== PR_STATUS.MERGED) {
418
+ after.status = PR_STATUS.MERGED;
419
+ }
391
420
  shared.applyPrFieldDelta(currentPrs[idx], before, after);
392
421
  }
393
422
  }
@@ -410,13 +439,40 @@ async function forEachActiveGhPr(config, callback) {
410
439
  const centralPath = path.join(MINIONS_DIR, 'pull-requests.json');
411
440
  const centralPrs = safeJsonArr(centralPath);
412
441
  const activeCentral = centralPrs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status) && pr.url);
442
+
443
+ // W-mp5trwh60008386d: probe each unique slug in the central list ONCE before iterating PRs.
444
+ // Without this gate, central PRs would inherit the same per-PR 404 trapdoor that project-local
445
+ // PRs had pre-fix — a multi-account `gh auth` switch or token rotation would let every
446
+ // central PR for an inaccessible slug accumulate `_consecutive404s` even though the failure
447
+ // is at the auth/repo layer, not the PR. Probe results live for this tick only (Map cleared
448
+ // each invocation).
449
+ const centralSlugProbes = new Map(); // slug → 'ok' | 'fail'
450
+ for (const pr of activeCentral) {
451
+ const ghMatch = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
452
+ if (!ghMatch) continue;
453
+ const slug = ghMatch[1];
454
+ if (centralSlugProbes.has(slug)) continue;
455
+ if (isSlugInBackoff(slug)) { centralSlugProbes.set(slug, 'fail'); continue; }
456
+ const probe = await ghApi('', slug);
457
+ if (probe === null || probe === GH_NOT_FOUND) {
458
+ if (probe === GH_NOT_FOUND) {
459
+ log('warn', `GitHub repo probe for ${slug} returned 404 (central PR poll) — skipping all central PRs for this slug this tick. Per-PR 404 counters NOT incremented.`);
460
+ }
461
+ recordSlugFailure(slug);
462
+ centralSlugProbes.set(slug, 'fail');
463
+ } else {
464
+ resetSlugBackoff(slug);
465
+ centralSlugProbes.set(slug, 'ok');
466
+ }
467
+ }
468
+
413
469
  let centralUpdated = 0;
414
470
  const updatedCentralRecords = [];
415
471
  for (const pr of activeCentral) {
416
472
  const ghMatch = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
417
473
  if (!ghMatch) continue;
418
474
  const slug = ghMatch[1];
419
- if (isSlugInBackoff(slug)) continue;
475
+ if (centralSlugProbes.get(slug) !== 'ok') continue; // probe failed → skip per-PR call
420
476
  const prNum = ghMatch[2];
421
477
  try {
422
478
  const before = shared.snapshotPrRecord(pr);
@@ -452,7 +508,13 @@ async function forEachActiveGhPr(config, callback) {
452
508
  // Only merge back central PRs that the callback actually modified
453
509
  for (const { before, after } of updatedCentralRecords) {
454
510
  const idx = currentPrs.findIndex(p => p.id === after.id);
455
- if (idx >= 0) shared.applyPrFieldDelta(currentPrs[idx], before, after);
511
+ if (idx >= 0) {
512
+ // W-mp5trwh60008386d: same merged-status guard as project-local PRs.
513
+ if (currentPrs[idx].status === PR_STATUS.MERGED && after.status !== PR_STATUS.MERGED) {
514
+ after.status = PR_STATUS.MERGED;
515
+ }
516
+ shared.applyPrFieldDelta(currentPrs[idx], before, after);
517
+ }
456
518
  }
457
519
  return currentPrs;
458
520
  }, { defaultValue: [] });
@@ -465,21 +527,49 @@ async function forEachActiveGhPr(config, callback) {
465
527
  // ─── PR Status Polling ──────────────────────────────────────────────────────
466
528
 
467
529
  async function pollPrStatus(config) {
530
+ // W-mp5trwh60008386d: per-PR 404 must be confirmed N times before flipping to abandoned.
531
+ // Single 404s are routinely transient (multi-account `gh auth` race, network blip, token
532
+ // rotation). Counter is per-PR (`pr._consecutive404s`) and reset on any successful response.
533
+ // Default N from ENGINE_DEFAULTS; opt-in override via config.engine.prAbandonConfirmCount.
534
+ const confirmCount = Math.max(1, Number(config?.engine?.prAbandonConfirmCount) || ENGINE_DEFAULTS.prAbandonConfirmCount);
535
+
468
536
  const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
469
537
  const prData = await ghApi(`/pulls/${prNum}`, slug);
470
538
  if (!prData) return false;
471
539
  if (prData === GH_NOT_FOUND) {
472
- // PR no longer exists mark abandoned so it stops being polled
473
- if (pr.status !== PR_STATUS.ABANDONED) {
474
- pr.status = PR_STATUS.ABANDONED;
475
- log('info', `PR ${pr.id} returned 404 marking abandoned`);
476
- return true;
540
+ // Per-PR 404. Base-repo probe in forEachActiveGhPr already passed for this tick, so the
541
+ // 404 is specific to this PR — but we still require N consecutive confirmations across
542
+ // separate poll ticks before flipping. Worst-case delay for a genuinely deleted PR is
543
+ // N × prPollStatusEvery × tickInterval (~36 min at defaults), which is acceptable for an
544
+ // irreversible status change.
545
+ const next = (Number(pr._consecutive404s) || 0) + 1;
546
+ if (next >= confirmCount) {
547
+ if (pr.status !== PR_STATUS.ABANDONED) {
548
+ pr.status = PR_STATUS.ABANDONED;
549
+ delete pr._consecutive404s;
550
+ log('info', `PR ${pr.id} returned 404 ${next} consecutive polls — marking abandoned`);
551
+ return true;
552
+ }
553
+ // Already abandoned; clear the counter if it lingered.
554
+ if (pr._consecutive404s) {
555
+ delete pr._consecutive404s;
556
+ return true;
557
+ }
558
+ return false;
477
559
  }
478
- return false;
560
+ pr._consecutive404s = next;
561
+ log('warn', `PR ${pr.id} returned 404 (${next}/${confirmCount}) — deferring abandonment until threshold`);
562
+ return true; // counter increment is a state change worth persisting
479
563
  }
480
564
 
481
565
  let updated = false;
482
566
 
567
+ // Successful response — clear any pending 404 confirmation streak.
568
+ if (pr._consecutive404s) {
569
+ delete pr._consecutive404s;
570
+ updated = true;
571
+ }
572
+
483
573
  const headBranch = prData.head?.ref ? String(prData.head.ref).trim() : '';
484
574
  if (headBranch && pr.branch !== headBranch) {
485
575
  pr.branch = headBranch;
@@ -742,7 +832,7 @@ async function pollPrStatus(config) {
742
832
  if (autoComplete) {
743
833
  try {
744
834
  const mergeMethod = ['squash', 'merge', 'rebase'].includes(config.engine?.prMergeMethod) ? config.engine.prMergeMethod : 'squash';
745
- await execAsync(`gh pr merge ${prNum} --${mergeMethod} --repo ${slug} --delete-branch`, { timeout: 30000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
835
+ await _runExec(`gh pr merge ${prNum} --${mergeMethod} --repo ${slug} --delete-branch`, { timeout: 30000, encoding: 'utf-8', maxBuffer: GH_MAX_BUFFER });
746
836
  pr._autoCompleted = true;
747
837
  log('info', `Auto-completed PR ${pr.id}: builds green + review approved → merged (${mergeMethod})`);
748
838
  updated = true;
@@ -1138,4 +1228,6 @@ module.exports = {
1138
1228
  _resolveViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
1139
1229
  _setCachedViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
1140
1230
  _backfillViewerDidAuthor, // exported for testing (W-mp3bp0ha000997ab-b backfill)
1231
+ _setExecAsyncForTest, // W-mp5trwh60008386d: test seam to mock `gh api` shell-outs
1232
+ GH_NOT_FOUND, // W-mp5trwh60008386d: exported so tests can assert sentinel propagation
1141
1233
  };
package/engine/shared.js CHANGED
@@ -1093,6 +1093,14 @@ const ENGINE_DEFAULTS = {
1093
1093
  ghPollEnabled: true, // poll GitHub PR status, comments, and reconciliation on each tick cycle
1094
1094
  prPollStatusEvery: 12, // poll PR build/review/merge status every N ticks for both ADO and GitHub (~12 min at default interval)
1095
1095
  prPollCommentsEvery: 12, // poll PR human comments every N ticks for both ADO and GitHub (~12 min at default interval)
1096
+ // W-mp5trwh60008386d: per-PR 404 must repeat across N consecutive successful base-repo probes
1097
+ // before flipping a PR to `abandoned`. A single 404 on `repos/{slug}/pulls/{n}` can be a transient
1098
+ // multi-account `gh auth` race, network blip, or token rotation — those used to permanently
1099
+ // corrupt active PRs. Counter resets to 0 on any successful per-PR response. ADO doesn't have
1100
+ // an analogous abandon-on-404 trapdoor (`adoFetch` throws on 404 → caught by Promise.allSettled
1101
+ // → no flip), so the constant is GitHub-only today but lives in shared defaults so a future
1102
+ // ADO change can adopt the same semantics.
1103
+ prAbandonConfirmCount: 3,
1096
1104
  watchesIncludeBehindBy: false, // opt-in: when true, GitHub PR poll calls /compare/{base}...{head} once per pr per pollPrStatusEvery cadence to populate pr.behindBy (powers the `behind-master` watch predicate). Off by default to avoid the extra API call. ADO PRs always get null (no commit-graph walk yet).
1097
1105
  autoCompletePrs: false, // auto-merge PRs when builds green + review approved (opt-in)
1098
1106
  prMergeMethod: 'squash', // merge method: squash, merge, rebase
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1947",
3
+ "version": "0.1.1948",
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"