@yemi33/minions 0.1.2337 → 0.1.2338
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/bin/minions.js +48 -1
- package/engine/ado.js +35 -0
- package/engine/dispatch.js +135 -0
- package/engine/github.js +17 -0
- package/engine/shared.js +104 -3
- package/engine.js +4 -3
- package/package.json +1 -1
package/bin/minions.js
CHANGED
|
@@ -794,6 +794,13 @@ function getPkgVersion() {
|
|
|
794
794
|
try { return require(path.join(PKG_ROOT, 'package.json')).version; } catch { return '0.0.0'; }
|
|
795
795
|
}
|
|
796
796
|
|
|
797
|
+
// Same as getPkgVersion() but bypasses require()'s module cache — needed to
|
|
798
|
+
// see what `npm install -g` actually wrote to disk *within the same process*
|
|
799
|
+
// (require() would keep returning the version captured at process start).
|
|
800
|
+
function getPkgVersionFresh() {
|
|
801
|
+
try { return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version; } catch { return null; }
|
|
802
|
+
}
|
|
803
|
+
|
|
797
804
|
function getInstalledVersion() {
|
|
798
805
|
try {
|
|
799
806
|
const vFile = path.join(MINIONS_HOME, '.minions-version');
|
|
@@ -1279,8 +1286,30 @@ ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
|
|
|
1279
1286
|
saveInstalledVersion(getPkgVersion());
|
|
1280
1287
|
console.log(` Version synced to ${getPkgVersion()} (dev/symlink install — pull from git to update code)`);
|
|
1281
1288
|
} else {
|
|
1289
|
+
const preVersion = getPkgVersionFresh();
|
|
1290
|
+
// Resolve the target version explicitly BEFORE installing, and pin the
|
|
1291
|
+
// install to that exact version instead of a floating `@latest` tag. This
|
|
1292
|
+
// avoids a race where `npm view` (used here and by `minions version`) and
|
|
1293
|
+
// a separate `npm install -g pkg@latest` resolve `latest` against npm's
|
|
1294
|
+
// registry-metadata cache at different times and land on different
|
|
1295
|
+
// versions — the metadata cache has its own TTL, independent of tarball
|
|
1296
|
+
// integrity checks, so a stale local packument can make `npm install -g
|
|
1297
|
+
// pkg@latest` silently reinstall an already-cached older version even
|
|
1298
|
+
// though `npm view pkg version` reports a newer one (W-mr9k4a5x00090184).
|
|
1299
|
+
let targetVersion;
|
|
1300
|
+
try {
|
|
1301
|
+
targetVersion = execSync('npm view @yemi33/minions version', { encoding: 'utf8', timeout: 15000, windowsHide: true }).trim();
|
|
1302
|
+
} catch (e) {
|
|
1303
|
+
console.error(' Failed to resolve the latest version from the npm registry:', e.message);
|
|
1304
|
+
process.exit(1);
|
|
1305
|
+
}
|
|
1306
|
+
if (!targetVersion) {
|
|
1307
|
+
console.error(' Failed to resolve the latest version from the npm registry (empty response).');
|
|
1308
|
+
process.exit(1);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1282
1311
|
try {
|
|
1283
|
-
execSync(
|
|
1312
|
+
execSync(`npm install -g @yemi33/minions@${targetVersion}`, { stdio: 'inherit', timeout: 120000 });
|
|
1284
1313
|
} catch (e) {
|
|
1285
1314
|
console.error(' npm update failed:', e.message);
|
|
1286
1315
|
process.exit(1);
|
|
@@ -1288,6 +1317,24 @@ ${fs.existsSync(path.join(PKG_ROOT, '.git')) ? `
|
|
|
1288
1317
|
// Equivalent to `minions init --force --skip-start`, but avoids recursing through
|
|
1289
1318
|
// the global shim while npm is still settling the updated install.
|
|
1290
1319
|
runPostUpdateInit();
|
|
1320
|
+
|
|
1321
|
+
// Verify the install actually advanced to the resolved target version. A
|
|
1322
|
+
// zero exit from `npm install` only means npm performed SOME install/relink
|
|
1323
|
+
// action — it does NOT guarantee the resolved version moved forward. Re-read
|
|
1324
|
+
// package.json fresh from disk (bypassing require()'s cache, same reasoning
|
|
1325
|
+
// as getPkgVersionFresh()) so we see exactly what npm wrote, and fail loudly
|
|
1326
|
+
// instead of restarting the engine/dashboard on stale code.
|
|
1327
|
+
const installedVersion = getPkgVersionFresh();
|
|
1328
|
+
if (installedVersion !== targetVersion) {
|
|
1329
|
+
console.error(`\n ERROR: Update did not advance as expected.`);
|
|
1330
|
+
console.error(` Before update: ${preVersion || 'unknown'}`);
|
|
1331
|
+
console.error(` Resolved target: ${targetVersion} (from npm registry, resolved before install)`);
|
|
1332
|
+
console.error(` On disk after npm: ${installedVersion || 'unknown'} (${PKG_ROOT})`);
|
|
1333
|
+
console.error(` This usually means npm resolved a stale local registry-metadata cache.`);
|
|
1334
|
+
console.error(` Try: npm cache clean --force`);
|
|
1335
|
+
console.error(` Then: minions update\n`);
|
|
1336
|
+
process.exit(1);
|
|
1337
|
+
}
|
|
1291
1338
|
}
|
|
1292
1339
|
// Restart engine + dashboard so they pick up the new code. With --no-wait the
|
|
1293
1340
|
// restart is spawned detached so the caller (e.g. a DevBox maintenance task)
|
package/engine/ado.js
CHANGED
|
@@ -2623,6 +2623,40 @@ async function prExists(adoOrg, adoProject, adoRepo, prNumber) {
|
|
|
2623
2623
|
}
|
|
2624
2624
|
}
|
|
2625
2625
|
|
|
2626
|
+
/**
|
|
2627
|
+
* W-mr9jo2n2000667ad (bug B) — fetch the list of file paths an ADO PR
|
|
2628
|
+
* touched, so the stale-PR-dispatch pruner can verify a fix WI's scope
|
|
2629
|
+
* actually overlaps the merged/abandoned PR before cancelling it on status
|
|
2630
|
+
* alone. Reads the latest iteration's changes. Returns null on any failure
|
|
2631
|
+
* (no token / network / missing iterations) — callers must treat null as
|
|
2632
|
+
* "unknown", not "no files changed".
|
|
2633
|
+
*/
|
|
2634
|
+
async function getPrChangedFiles(project, prNumber) {
|
|
2635
|
+
const n = parseInt(prNumber, 10);
|
|
2636
|
+
if (!project || !Number.isInteger(n) || n <= 0) return null;
|
|
2637
|
+
const token = await getAdoToken();
|
|
2638
|
+
if (!token) return null;
|
|
2639
|
+
const orgBase = getAdoOrgBase(project);
|
|
2640
|
+
const adoProj = project.adoProject;
|
|
2641
|
+
const adoRepo = getAdoRepositoryLookupKey(project);
|
|
2642
|
+
if (!orgBase || !adoProj || !adoRepo) return null;
|
|
2643
|
+
try {
|
|
2644
|
+
const itersUrl = `${orgBase}/${encodeURIComponent(adoProj)}/_apis/git/repositories/${encodeURIComponent(adoRepo)}/pullrequests/${encodeURIComponent(String(n))}/iterations?api-version=7.1`;
|
|
2645
|
+
const iters = await adoFetch(itersUrl, token);
|
|
2646
|
+
const iterList = iters?.value;
|
|
2647
|
+
if (!Array.isArray(iterList) || !iterList.length) return null;
|
|
2648
|
+
const latestId = iterList[iterList.length - 1]?.id;
|
|
2649
|
+
if (!latestId) return null;
|
|
2650
|
+
const changesUrl = `${orgBase}/${encodeURIComponent(adoProj)}/_apis/git/repositories/${encodeURIComponent(adoRepo)}/pullrequests/${encodeURIComponent(String(n))}/iterations/${latestId}/changes?api-version=7.1`;
|
|
2651
|
+
const changes = await adoFetch(changesUrl, token);
|
|
2652
|
+
const entries = changes?.changeEntries;
|
|
2653
|
+
if (!Array.isArray(entries)) return null;
|
|
2654
|
+
return entries.map(e => e?.item?.path).filter(Boolean);
|
|
2655
|
+
} catch {
|
|
2656
|
+
return null;
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2626
2660
|
/**
|
|
2627
2661
|
* Fetch live PR and build status for a single PR number.
|
|
2628
2662
|
* Used by engine/ado-status.js so agents can check CI without raw curl calls.
|
|
@@ -3017,6 +3051,7 @@ module.exports = {
|
|
|
3017
3051
|
getAdoThrottleState,
|
|
3018
3052
|
fetchAdoPrMetadata,
|
|
3019
3053
|
prExists, // issue #246 — confirm a loose ref points at a PR (not a work item) before stamping
|
|
3054
|
+
getPrChangedFiles, // W-mr9jo2n2000667ad — stale-PR-pruner scope-overlap verification
|
|
3020
3055
|
fetchSinglePrBuildStatus,
|
|
3021
3056
|
findOpenPrOnBranch,
|
|
3022
3057
|
applyAdoPrMetadata, // #3079 — exported for unit tests of targetRefName + retarget reset
|
package/engine/dispatch.js
CHANGED
|
@@ -573,6 +573,50 @@ function cancelSourceWorkItemForPrunedDispatch(entry, reason) {
|
|
|
573
573
|
return cancelled;
|
|
574
574
|
}
|
|
575
575
|
|
|
576
|
+
/**
|
|
577
|
+
* W-mr9jo2n2000667ad (bug B) — best-effort resolution of the work item behind
|
|
578
|
+
* a pending dispatch entry, used only by the scope-overlap gate below.
|
|
579
|
+
*/
|
|
580
|
+
function _resolveWorkItemForPrunedEntry(entry) {
|
|
581
|
+
const itemId = entry?.meta?.item?.id;
|
|
582
|
+
if (!itemId) return null;
|
|
583
|
+
try {
|
|
584
|
+
const wiPath = lifecycle().resolveWorkItemPath(entry.meta);
|
|
585
|
+
if (!wiPath) return null;
|
|
586
|
+
const items = safeJsonArr(wiPath);
|
|
587
|
+
return items.find(w => w && w.id === itemId) || null;
|
|
588
|
+
} catch {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* W-mr9jo2n2000667ad (bug B) — fetch the changed-file list for the dispatch's
|
|
595
|
+
* targeted PR from the live host API. Returns null (unknown) on any failure
|
|
596
|
+
* or unsupported host so callers fail open to the pre-existing cancel
|
|
597
|
+
* behavior rather than getting stuck.
|
|
598
|
+
*/
|
|
599
|
+
async function _fetchChangedFilesForPrunedEntry(entry, project) {
|
|
600
|
+
const prNumber = shared.getPrNumber(entry?.meta?.pr);
|
|
601
|
+
if (prNumber == null || !project) return null;
|
|
602
|
+
const host = String(project.repoHost || '').toLowerCase();
|
|
603
|
+
try {
|
|
604
|
+
if (host === 'github') {
|
|
605
|
+
const github = require('./github');
|
|
606
|
+
const slug = github.getRepoSlug(project);
|
|
607
|
+
if (!slug) return null;
|
|
608
|
+
return await github.getPrChangedFiles(slug, prNumber);
|
|
609
|
+
}
|
|
610
|
+
if (host === 'ado' || !host) {
|
|
611
|
+
const ado = require('./ado');
|
|
612
|
+
return await ado.getPrChangedFiles(project, prNumber);
|
|
613
|
+
}
|
|
614
|
+
} catch (e) {
|
|
615
|
+
log('warn', `pruneStalePrDispatches: failed fetching changed files for ${entry.id}: ${e.message}`);
|
|
616
|
+
}
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
|
|
576
620
|
function pruneStalePrDispatches(config = queries.getConfig()) {
|
|
577
621
|
const removed = [];
|
|
578
622
|
mutateDispatch((dispatch) => {
|
|
@@ -593,6 +637,96 @@ function pruneStalePrDispatches(config = queries.getConfig()) {
|
|
|
593
637
|
return removed.length;
|
|
594
638
|
}
|
|
595
639
|
|
|
640
|
+
// Reasons produced by getStalePrDispatchReason's `tracked.status !== ACTIVE`
|
|
641
|
+
// branch (`PR <id> is <merged|abandoned|closed>`) are the ONLY ones gated by
|
|
642
|
+
// the scope-overlap check below — context-only and branch-mismatch reasons
|
|
643
|
+
// are independent of file scope and prune exactly as before.
|
|
644
|
+
const _MERGED_OR_ABANDONED_REASON_RE = /\bis (merged|abandoned|closed)$/i;
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Async variant of `pruneStalePrDispatches` (bug B, W-mr9jo2n2000667ad):
|
|
648
|
+
* before hard-cancelling a `fix`-type work item purely because its targeted
|
|
649
|
+
* PR is now merged/abandoned/closed, verify the WI's own scope (file paths
|
|
650
|
+
* mentioned in its title/description, or an explicit `files`/`scope.files`
|
|
651
|
+
* field) actually appears in that PR's changed-file diff. If the WI has
|
|
652
|
+
* discoverable scope hints AND the PR's changed files can be fetched AND
|
|
653
|
+
* the two sets are disjoint, the WI is almost certainly unrelated to the PR
|
|
654
|
+
* (e.g. a false-positive `targetPr` stamp from a scratch-filename substring
|
|
655
|
+
* — see extractPrRefFromText hardening) — leave the dispatch pending and
|
|
656
|
+
* write an inbox alert for human review instead of silently cancelling it.
|
|
657
|
+
*
|
|
658
|
+
* Falls back to the original unconditional-cancel behavior when scope hints
|
|
659
|
+
* are absent, the PR's changed files can't be fetched, or the overlap check
|
|
660
|
+
* finds a genuine match — this only tightens the false-positive path, it
|
|
661
|
+
* never blocks a legitimate "PR merged, WI resolved" cancellation.
|
|
662
|
+
*
|
|
663
|
+
* Engine callers should await this; the sync `pruneStalePrDispatches` above
|
|
664
|
+
* is kept for callers/tests that don't need the network-backed scope check.
|
|
665
|
+
*/
|
|
666
|
+
async function pruneStalePrDispatchesAsync(config = queries.getConfig()) {
|
|
667
|
+
const snapshot = queries.getDispatch();
|
|
668
|
+
const pending = Array.isArray(snapshot?.pending) ? snapshot.pending : [];
|
|
669
|
+
|
|
670
|
+
const reasonsById = new Map();
|
|
671
|
+
for (const entry of pending) {
|
|
672
|
+
const reason = getStalePrDispatchReason(entry, config);
|
|
673
|
+
if (reason) reasonsById.set(entry.id, reason);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const flaggedForReview = [];
|
|
677
|
+
for (const entry of pending) {
|
|
678
|
+
const reason = reasonsById.get(entry.id);
|
|
679
|
+
if (!reason) continue;
|
|
680
|
+
if (entry.type !== WORK_TYPE.FIX || !_MERGED_OR_ABANDONED_REASON_RE.test(reason)) continue;
|
|
681
|
+
try {
|
|
682
|
+
const wi = _resolveWorkItemForPrunedEntry(entry);
|
|
683
|
+
const scopePaths = shared.extractScopeFilePathsFromWorkItem(wi);
|
|
684
|
+
if (!scopePaths.length) continue; // unknown scope — keep legacy cancel behavior
|
|
685
|
+
|
|
686
|
+
const project = _resolveDispatchProject(entry.meta.project, config);
|
|
687
|
+
const changedFiles = await _fetchChangedFilesForPrunedEntry(entry, project);
|
|
688
|
+
const overlap = shared.scopeOverlapsPrChangedFiles(scopePaths, changedFiles);
|
|
689
|
+
if (overlap === false) {
|
|
690
|
+
reasonsById.delete(entry.id);
|
|
691
|
+
flaggedForReview.push({ entry, reason, scopePaths, changedFiles });
|
|
692
|
+
}
|
|
693
|
+
// overlap === true → genuine match, fall through to cancel as before.
|
|
694
|
+
// overlap === null → changed files unavailable, fail open to legacy cancel.
|
|
695
|
+
} catch (e) {
|
|
696
|
+
log('warn', `pruneStalePrDispatches: scope-overlap check failed for ${entry.id}: ${e.message}`);
|
|
697
|
+
// best-effort — fall through to legacy cancel behavior
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const removed = [];
|
|
702
|
+
mutateDispatch((dispatch) => {
|
|
703
|
+
dispatch.pending = (dispatch.pending || []).filter(entry => {
|
|
704
|
+
const reason = reasonsById.get(entry.id);
|
|
705
|
+
if (!reason) return true;
|
|
706
|
+
removed.push({ entry, reason });
|
|
707
|
+
return false;
|
|
708
|
+
});
|
|
709
|
+
return dispatch;
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
for (const { entry, reason } of removed) {
|
|
713
|
+
try { deleteDispatchPromptSidecar(entry); } catch { /* cleanup best-effort */ }
|
|
714
|
+
const cancelled = cancelSourceWorkItemForPrunedDispatch(entry, reason);
|
|
715
|
+
log('info', `Dropped stale PR dispatch ${entry.id}: ${reason}${cancelled ? ' (source work item cancelled)' : ''}`);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
for (const { entry, reason, scopePaths } of flaggedForReview) {
|
|
719
|
+
const itemId = entry?.meta?.item?.id || entry.id;
|
|
720
|
+
const msg = `Stale-PR-dispatch pruner kept ${itemId} pending: targetPr ${reason}, but the WI's ` +
|
|
721
|
+
`scope (${scopePaths.join(', ')}) does not appear in that PR's diff. Not auto-cancelling — ` +
|
|
722
|
+
`flagging for human review.`;
|
|
723
|
+
log('warn', `pruneStalePrDispatches: ${msg}`);
|
|
724
|
+
try { writeInboxAlert(`stale-pr-scope-mismatch-${itemId}`, msg); } catch { /* best-effort */ }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return removed.length;
|
|
728
|
+
}
|
|
729
|
+
|
|
596
730
|
// ─── Retryable Failure Classification ────────────────────────────────────────
|
|
597
731
|
|
|
598
732
|
function isRetryableFailureReason(reason = '', failureClass = '') {
|
|
@@ -1315,6 +1449,7 @@ module.exports = {
|
|
|
1315
1449
|
updateAgentStatus,
|
|
1316
1450
|
getStalePrDispatchReason,
|
|
1317
1451
|
pruneStalePrDispatches,
|
|
1452
|
+
pruneStalePrDispatchesAsync, // W-mr9jo2n2000667ad — scope-overlap-verified variant; engine.js tick should await this
|
|
1318
1453
|
cancelPendingDispatchesForPr,
|
|
1319
1454
|
cleanDispatchEntries,
|
|
1320
1455
|
cancelPendingWorkItems,
|
package/engine/github.js
CHANGED
|
@@ -420,6 +420,22 @@ async function prExists(slug, prNumber, opts = {}) {
|
|
|
420
420
|
return null;
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
+
/**
|
|
424
|
+
* W-mr9jo2n2000667ad (bug B) — fetch the list of file paths a GitHub PR
|
|
425
|
+
* touched, so the stale-PR-dispatch pruner can verify a fix WI's scope
|
|
426
|
+
* actually overlaps the merged/abandoned PR before cancelling it on status
|
|
427
|
+
* alone. Paginated (a PR can touch more than the default 30-file page).
|
|
428
|
+
* Returns null on any failure (network / auth / 404) — callers must treat
|
|
429
|
+
* null as "unknown", not "no files changed".
|
|
430
|
+
*/
|
|
431
|
+
async function getPrChangedFiles(slug, prNumber) {
|
|
432
|
+
const n = parseInt(prNumber, 10);
|
|
433
|
+
if (!slug || !Number.isInteger(n) || n <= 0) return null;
|
|
434
|
+
const result = await ghApi(`/pulls/${n}/files`, slug, { paginate: true });
|
|
435
|
+
if (!Array.isArray(result)) return null;
|
|
436
|
+
return result.map(f => f && f.filename).filter(Boolean);
|
|
437
|
+
}
|
|
438
|
+
|
|
423
439
|
const BUILD_ERROR_LOG_MAX_LINES = 150;
|
|
424
440
|
|
|
425
441
|
/**
|
|
@@ -1961,6 +1977,7 @@ module.exports = {
|
|
|
1961
1977
|
getGhThrottleState,
|
|
1962
1978
|
ghApi, // P-8c1b6e45 — used by engine/shared-branch-pr-reconcile.js to list open PRs on a feature branch
|
|
1963
1979
|
prExists, // issue #246 — confirm a loose ref points at a PR (not an issue) before stamping
|
|
1980
|
+
getPrChangedFiles, // W-mr9jo2n2000667ad — stale-PR-pruner scope-overlap verification
|
|
1964
1981
|
// Exported for testing
|
|
1965
1982
|
isGitHub,
|
|
1966
1983
|
getRepoSlug,
|
package/engine/shared.js
CHANGED
|
@@ -6777,6 +6777,12 @@ function parsePrUrl(url) {
|
|
|
6777
6777
|
// `isPrTargeted` paths intact and prevents PR-feedback fix WIs from being
|
|
6778
6778
|
// dispatched on a fresh `work/<wi-id>` branch when only the description /
|
|
6779
6779
|
// references[] carried the PR pointer (issue #2999).
|
|
6780
|
+
// Reject a digit-string match when it's immediately followed by a
|
|
6781
|
+
// file-extension-like suffix (".diff", ".json", ".patch", ...) — a strong
|
|
6782
|
+
// signal the match is actually a scratch/diff filename fragment (e.g.
|
|
6783
|
+
// "lambert-pr685.diff") rather than a genuine PR reference.
|
|
6784
|
+
const _FOLLOWED_BY_FILE_EXT_RE = /^\.[A-Za-z0-9]{1,8}\b/;
|
|
6785
|
+
|
|
6780
6786
|
function extractPrRefFromText(value) {
|
|
6781
6787
|
const text = String(value || '');
|
|
6782
6788
|
if (!text.trim()) return null;
|
|
@@ -6784,10 +6790,23 @@ function extractPrRefFromText(value) {
|
|
|
6784
6790
|
if (urlMatch) return String(urlMatch[0] || '').replace(/[),.;:]+$/g, '');
|
|
6785
6791
|
const canonicalMatch = text.match(/\b(?:github|ado):[^\s#]+#\d+\b/i);
|
|
6786
6792
|
if (canonicalMatch) return canonicalMatch[0];
|
|
6793
|
+
// W-mr9jo2n2000667ad (bug A): require a clear delimiter — "#", "-", or
|
|
6794
|
+
// whitespace — between "PR"/"pull request"/"pullrequest" and the digits.
|
|
6795
|
+
// A bare digit-adjacent "pr" substring (e.g. "pr685" inside
|
|
6796
|
+
// "lambert-pr685.diff") is very likely part of a scratch/diff filename, not
|
|
6797
|
+
// a genuine "see PR #685" reference, and must NOT match. Also reject when
|
|
6798
|
+
// the digits are immediately followed by a file extension, even when a
|
|
6799
|
+
// delimiter IS present (guards "PR-685.diff"-style scratch filenames too).
|
|
6787
6800
|
const legacyMatch = text.match(/\bPR-(\d+)\b/i);
|
|
6788
|
-
if (legacyMatch
|
|
6789
|
-
|
|
6790
|
-
|
|
6801
|
+
if (legacyMatch && !_FOLLOWED_BY_FILE_EXT_RE.test(text.slice(legacyMatch.index + legacyMatch[0].length))) {
|
|
6802
|
+
return legacyMatch[1];
|
|
6803
|
+
}
|
|
6804
|
+
const numberMatch = text.match(/\b(?:pr|pull\s+request|pullrequest)\s*[#-]\s*(\d+)\b/i)
|
|
6805
|
+
|| text.match(/\b(?:pr|pull\s+request|pullrequest)\s+(\d+)\b/i);
|
|
6806
|
+
if (numberMatch && !_FOLLOWED_BY_FILE_EXT_RE.test(text.slice(numberMatch.index + numberMatch[0].length))) {
|
|
6807
|
+
return numberMatch[1];
|
|
6808
|
+
}
|
|
6809
|
+
return null;
|
|
6791
6810
|
}
|
|
6792
6811
|
|
|
6793
6812
|
// Cap for the description-text fallback scan in `extractWorkItemPrRef`.
|
|
@@ -7002,6 +7021,86 @@ function extractWorkItemPrRef(item) {
|
|
|
7002
7021
|
return extractPrRefFromText(item.title) || null;
|
|
7003
7022
|
}
|
|
7004
7023
|
|
|
7024
|
+
// ─── Work-item scope vs PR-diff overlap (bug B, W-mr9jo2n2000667ad) ─────────
|
|
7025
|
+
//
|
|
7026
|
+
// `pruneStalePrDispatches` used to cancel a fix-type WI the moment its
|
|
7027
|
+
// `targetPr` pointed at a merged/abandoned PR, with no check that the PR
|
|
7028
|
+
// actually touched the files the WI was about. A cleanup WI whose targetPr
|
|
7029
|
+
// was itself a false-positive stamp (see extractPrRefFromText hardening
|
|
7030
|
+
// above) got silently cancelled even though the merged PR never touched the
|
|
7031
|
+
// WI's actual scope. `extractScopeFilePathsFromWorkItem` + `scopeOverlapsPrChangedFiles`
|
|
7032
|
+
// let the pruner verify overlap before treating "PR merged" as "WI resolved".
|
|
7033
|
+
|
|
7034
|
+
/**
|
|
7035
|
+
* Best-effort extraction of file-path-like scope hints from a work item.
|
|
7036
|
+
* Prefers an explicit structured scope (`item.files` or `item.scope.files`
|
|
7037
|
+
* array — either may be populated by a caller/agent that already knows the
|
|
7038
|
+
* exact touched paths). Falls back to scanning title + description for
|
|
7039
|
+
* backtick-quoted code spans (the common "delete `foo/bar.js`" convention)
|
|
7040
|
+
* and bare path-like / dotted-filename tokens. Returns [] when nothing
|
|
7041
|
+
* scope-like is found — callers must treat [] as "unknown scope", not "no
|
|
7042
|
+
* overlap", and fall back to their pre-existing behavior.
|
|
7043
|
+
*/
|
|
7044
|
+
function extractScopeFilePathsFromWorkItem(item) {
|
|
7045
|
+
if (!item || typeof item !== 'object') return [];
|
|
7046
|
+
const explicit = Array.isArray(item.files) ? item.files
|
|
7047
|
+
: Array.isArray(item?.scope?.files) ? item.scope.files
|
|
7048
|
+
: null;
|
|
7049
|
+
if (explicit && explicit.length) {
|
|
7050
|
+
return explicit.map(f => String(f || '').trim()).filter(Boolean);
|
|
7051
|
+
}
|
|
7052
|
+
const text = `${item.title || ''}\n${item.description || ''}`;
|
|
7053
|
+
if (!text.trim()) return [];
|
|
7054
|
+
const paths = new Set();
|
|
7055
|
+
// Backtick-quoted code spans: `path/to/file.ext` or `bare-filename`.
|
|
7056
|
+
const codeSpanRe = /`([^`\n]{1,200})`/g;
|
|
7057
|
+
let m;
|
|
7058
|
+
while ((m = codeSpanRe.exec(text))) {
|
|
7059
|
+
const candidate = m[1].trim();
|
|
7060
|
+
if (candidate && !/\s/.test(candidate)) paths.add(candidate);
|
|
7061
|
+
}
|
|
7062
|
+
// Bare path-like tokens: contain a '/' (directory-qualified) or end in a
|
|
7063
|
+
// dotted extension (bare filename mentioned in prose).
|
|
7064
|
+
const pathTokenRe = /\b[\w.-]+\/[\w./-]+\b|\b[\w-]+\.[A-Za-z0-9]{1,12}\b/g;
|
|
7065
|
+
while ((m = pathTokenRe.exec(text))) {
|
|
7066
|
+
paths.add(m[0]);
|
|
7067
|
+
}
|
|
7068
|
+
return Array.from(paths);
|
|
7069
|
+
}
|
|
7070
|
+
|
|
7071
|
+
/**
|
|
7072
|
+
* Does `scopePaths` (from extractScopeFilePathsFromWorkItem) intersect
|
|
7073
|
+
* `changedFiles` (a PR's changed-file paths)? Matches on exact path,
|
|
7074
|
+
* suffix (directory-qualified scope hint vs a deeper repo path), or bare
|
|
7075
|
+
* filename (basename) equality so "lambert-pr685.diff" mentioned in prose
|
|
7076
|
+
* matches a changed path like "scratch/lambert-pr685.diff".
|
|
7077
|
+
*
|
|
7078
|
+
* Returns:
|
|
7079
|
+
* true — at least one scope hint matches a changed file
|
|
7080
|
+
* false — scope hints and changed files are both known and disjoint
|
|
7081
|
+
* null — unknown (no scope hints extracted, or changedFiles unavailable)
|
|
7082
|
+
* — callers must treat null as "can't verify", not "no overlap".
|
|
7083
|
+
*/
|
|
7084
|
+
function scopeOverlapsPrChangedFiles(scopePaths, changedFiles) {
|
|
7085
|
+
if (!Array.isArray(scopePaths) || !scopePaths.length) return null;
|
|
7086
|
+
if (!Array.isArray(changedFiles) || !changedFiles.length) return null;
|
|
7087
|
+
const normalize = p => String(p || '').replace(/^\.\//, '').replace(/\\/g, '/').toLowerCase();
|
|
7088
|
+
const changedNorm = changedFiles.map(normalize).filter(Boolean);
|
|
7089
|
+
const changedBase = new Set(changedNorm.map(p => p.split('/').pop()));
|
|
7090
|
+
const changedSet = new Set(changedNorm);
|
|
7091
|
+
for (const raw of scopePaths) {
|
|
7092
|
+
const p = normalize(raw);
|
|
7093
|
+
if (!p) continue;
|
|
7094
|
+
if (changedSet.has(p)) return true;
|
|
7095
|
+
const base = p.split('/').pop();
|
|
7096
|
+
if (base && changedBase.has(base)) return true;
|
|
7097
|
+
for (const cf of changedNorm) {
|
|
7098
|
+
if (cf.endsWith('/' + p) || p.endsWith('/' + cf)) return true;
|
|
7099
|
+
}
|
|
7100
|
+
}
|
|
7101
|
+
return false;
|
|
7102
|
+
}
|
|
7103
|
+
|
|
7005
7104
|
function getProjectPrScope(project) {
|
|
7006
7105
|
if (!project) return '';
|
|
7007
7106
|
const host = String(project.repoHost || '').toLowerCase();
|
|
@@ -9531,6 +9630,8 @@ module.exports = {
|
|
|
9531
9630
|
extractPrRefFromText,
|
|
9532
9631
|
extractWorkItemPrRef,
|
|
9533
9632
|
extractStructuredWorkItemPrRef,
|
|
9633
|
+
extractScopeFilePathsFromWorkItem,
|
|
9634
|
+
scopeOverlapsPrChangedFiles,
|
|
9534
9635
|
rewriteInboxRefsAcrossProjects,
|
|
9535
9636
|
_artifactNoteFileToken, // exported so the one-time note-link backfill shares the token grammar
|
|
9536
9637
|
getProjectPrScope,
|
package/engine.js
CHANGED
|
@@ -120,7 +120,7 @@ const READ_ONLY_ROOT_TASK_TYPES = shared.READ_ONLY_ROOT_TASK_TYPES;
|
|
|
120
120
|
// ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
|
|
121
121
|
|
|
122
122
|
const { mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch,
|
|
123
|
-
writeInboxAlert, updateAgentStatus, pruneStalePrDispatches } = require('./engine/dispatch');
|
|
123
|
+
writeInboxAlert, updateAgentStatus, pruneStalePrDispatches, pruneStalePrDispatchesAsync } = require('./engine/dispatch');
|
|
124
124
|
|
|
125
125
|
// ─── Timeout / Steering / Idle (extracted to engine/timeout.js) ──────────────
|
|
126
126
|
|
|
@@ -10391,7 +10391,8 @@ async function tickInner() {
|
|
|
10391
10391
|
} catch (err) { log('warn', `Stall detection error: ${err?.message || err}`); }
|
|
10392
10392
|
}
|
|
10393
10393
|
|
|
10394
|
-
try {
|
|
10394
|
+
try { await pruneStalePrDispatchesAsync(config); } catch (e) { log('warn', 'prune stale PR dispatches: ' + e.message); }
|
|
10395
|
+
if (_isTickStale(myGeneration)) return;
|
|
10395
10396
|
|
|
10396
10397
|
// Process pending dispatches before discovery. discoverWork() runs
|
|
10397
10398
|
// pre-dispatch LLM validation for newly found work; a slow validator must not
|
|
@@ -10914,7 +10915,7 @@ module.exports = {
|
|
|
10914
10915
|
validateConfig,
|
|
10915
10916
|
|
|
10916
10917
|
// Dispatch management (re-exported from engine/dispatch.js)
|
|
10917
|
-
mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch, writeInboxAlert, updateAgentStatus, pruneStalePrDispatches,
|
|
10918
|
+
mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch, writeInboxAlert, updateAgentStatus, pruneStalePrDispatches, pruneStalePrDispatchesAsync,
|
|
10918
10919
|
activeProcesses, realActivityMap, engineRestartGraceExempt,
|
|
10919
10920
|
get engineRestartGraceUntil() { return engineRestartGraceUntil; },
|
|
10920
10921
|
set engineRestartGraceUntil(v) { engineRestartGraceUntil = v; },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2338",
|
|
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"
|