@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 +8 -0
- package/engine/copilot-models.json +1 -1
- package/engine/github.js +107 -15
- package/engine/shared.js +8 -0
- package/package.json +1 -1
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;
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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)
|
|
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
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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"
|