@yemi33/minions 0.1.2145 → 0.1.2147
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 +6 -0
- package/dashboard/js/render-pinned.js +14 -1
- package/dashboard.js +11 -0
- package/docs/architecture.excalidraw +3456 -0
- package/docs/completion-reports.md +33 -0
- package/engine/cleanup.js +9 -0
- package/engine/cli.js +23 -11
- package/engine/discover-review-skills.js +279 -0
- package/engine/playbook.js +35 -0
- package/engine/shared.js +210 -1
- package/engine/worktree-gc.js +9 -1
- package/engine.js +75 -8
- package/package.json +1 -1
- package/playbooks/review.md +4 -0
package/engine/shared.js
CHANGED
|
@@ -5919,6 +5919,97 @@ function migratePrGateFlags(projectsRoot) {
|
|
|
5919
5919
|
return summary;
|
|
5920
5920
|
}
|
|
5921
5921
|
|
|
5922
|
+
// ─── PR Reference → URL Derivation ───────────────────────────────────────────
|
|
5923
|
+
//
|
|
5924
|
+
// W-mq5wfh1v000e0da9 — Given a PR ref (URL, canonical `host:scope#N` id, or
|
|
5925
|
+
// bare number / legacy `PR-N`), derive a usable URL. Used by the fix-WI auto-
|
|
5926
|
+
// enrollment helper below so untracked PRs can be linked without forcing the
|
|
5927
|
+
// caller to construct a URL themselves.
|
|
5928
|
+
//
|
|
5929
|
+
// Returns null when no URL can be derived (e.g. bare number with no
|
|
5930
|
+
// project.prUrlBase to anchor against).
|
|
5931
|
+
function deriveUrlForPrRef(prRef, project) {
|
|
5932
|
+
const ref = String(prRef || '').trim();
|
|
5933
|
+
if (!ref) return null;
|
|
5934
|
+
if (/^https?:\/\//i.test(ref)) return ref;
|
|
5935
|
+
const canonical = parseCanonicalPrId(ref);
|
|
5936
|
+
if (canonical) {
|
|
5937
|
+
if (canonical.scope.startsWith('github:')) {
|
|
5938
|
+
const slug = canonical.scope.slice('github:'.length);
|
|
5939
|
+
return `https://github.com/${slug}/pull/${canonical.prNumber}`;
|
|
5940
|
+
}
|
|
5941
|
+
if (canonical.scope.startsWith('ado:')) {
|
|
5942
|
+
const parts = canonical.scope.slice('ado:'.length).split('/');
|
|
5943
|
+
if (parts.length >= 3) {
|
|
5944
|
+
return `https://dev.azure.com/${parts[0]}/${parts[1]}/_git/${parts[2]}/pullrequest/${canonical.prNumber}`;
|
|
5945
|
+
}
|
|
5946
|
+
}
|
|
5947
|
+
}
|
|
5948
|
+
// Bare number / PR-N — derive from project.prUrlBase if available.
|
|
5949
|
+
const numMatch = ref.match(/^(?:PR-)?(\d+)$/i);
|
|
5950
|
+
if (numMatch && project && project.prUrlBase) {
|
|
5951
|
+
return `${project.prUrlBase}${numMatch[1]}`;
|
|
5952
|
+
}
|
|
5953
|
+
return null;
|
|
5954
|
+
}
|
|
5955
|
+
|
|
5956
|
+
// W-mq5wfh1v000e0da9 — Auto-enroll a PR into pull-requests.json when a
|
|
5957
|
+
// `type: fix` work item is created with a structured PR pointer. Without
|
|
5958
|
+
// this, fix WIs against untracked PRs bypass the polling / review / build
|
|
5959
|
+
// pipelines (the engine `pr_not_found` gate blocks dispatch entirely, and
|
|
5960
|
+
// human PR comments / build failures never surface) until an operator hits
|
|
5961
|
+
// POST /api/pull-requests/link manually.
|
|
5962
|
+
//
|
|
5963
|
+
// Idempotent: no-ops if the PR is already enrolled. Concurrent calls are
|
|
5964
|
+
// safe because the underlying upsertPullRequestRecord serializes via
|
|
5965
|
+
// mutatePullRequests.
|
|
5966
|
+
//
|
|
5967
|
+
// Only inspects STRUCTURED refs (targetPr / pr_id / prId / sourcePr /
|
|
5968
|
+
// pullRequest / prUrl / prNumber / references[*].url / meta.pr_followup
|
|
5969
|
+
// .parent_pr_url). Description-only PR mentions are INTENTIONALLY skipped
|
|
5970
|
+
// per the "structured-vs-loose split" — enrollment must be intentional.
|
|
5971
|
+
//
|
|
5972
|
+
// Returns:
|
|
5973
|
+
// { skipped: true, reason } — not a fix / no ref / no URL / upsert error
|
|
5974
|
+
// { alreadyEnrolled: true, id } — PR already in pull-requests.json
|
|
5975
|
+
// { enrolled: true, id, prPath } — newly enrolled
|
|
5976
|
+
function autoEnrollPrFromFixWorkItem(item, project, minionsDir) {
|
|
5977
|
+
if (!item || item.type !== WORK_TYPE.FIX) return { skipped: true, reason: 'not-fix' };
|
|
5978
|
+
const prRef = extractStructuredWorkItemPrRef(item);
|
|
5979
|
+
if (!prRef) return { skipped: true, reason: 'no-structured-ref' };
|
|
5980
|
+
const url = deriveUrlForPrRef(prRef, project);
|
|
5981
|
+
if (!url) return { skipped: true, reason: 'no-url' };
|
|
5982
|
+
const prPath = project ? projectPrPath(project) : centralPullRequestsPath(minionsDir);
|
|
5983
|
+
const existing = safeJsonArr(prPath);
|
|
5984
|
+
if (findPrRecord(existing, prRef, project)) {
|
|
5985
|
+
return { alreadyEnrolled: true, id: getCanonicalPrId(project, prRef, url) };
|
|
5986
|
+
}
|
|
5987
|
+
const parsedUrl = parsePrUrl(url);
|
|
5988
|
+
const prNum = parsedUrl ? parsedUrl.prNumber : null;
|
|
5989
|
+
const prId = getCanonicalPrId(project, prRef, url);
|
|
5990
|
+
try {
|
|
5991
|
+
const result = upsertPullRequestRecord(prPath, {
|
|
5992
|
+
id: prId,
|
|
5993
|
+
prNumber: prNum,
|
|
5994
|
+
title: `PR #${prNum != null ? prNum : '?'} (polling...)`,
|
|
5995
|
+
description: '',
|
|
5996
|
+
agent: 'human',
|
|
5997
|
+
branch: '',
|
|
5998
|
+
reviewStatus: 'pending',
|
|
5999
|
+
status: 'active',
|
|
6000
|
+
created: new Date().toISOString(),
|
|
6001
|
+
url,
|
|
6002
|
+
contextOnly: false,
|
|
6003
|
+
}, { project, itemId: item.id });
|
|
6004
|
+
return result.created
|
|
6005
|
+
? { enrolled: true, id: result.id, prPath }
|
|
6006
|
+
: { alreadyEnrolled: true, id: result.id };
|
|
6007
|
+
} catch (e) {
|
|
6008
|
+
log('warn', `autoEnrollPrFromFixWorkItem ${item.id}: ${e.message}`);
|
|
6009
|
+
return { skipped: true, reason: 'upsert-error', error: e.message };
|
|
6010
|
+
}
|
|
6011
|
+
}
|
|
6012
|
+
|
|
5922
6013
|
// ─── Cross-Platform Process Kill Helpers ─────────────────────────────────────
|
|
5923
6014
|
|
|
5924
6015
|
function normalizeKillPid(proc) {
|
|
@@ -6576,13 +6667,126 @@ function _purgeReservedFiles(dirPath) {
|
|
|
6576
6667
|
}
|
|
6577
6668
|
}
|
|
6578
6669
|
|
|
6579
|
-
|
|
6670
|
+
// ── Live-worktree guard (W-mq5rwwss000f30a7) ─────────────────────────────────
|
|
6671
|
+
// Single source of truth for "is some non-terminal dispatch currently using
|
|
6672
|
+
// this worktree?" Every code path that wants to wipe / reset / recycle /
|
|
6673
|
+
// quarantine a worktree MUST call isWorktreePathLive() first and skip on
|
|
6674
|
+
// true. Without this guard the engine has wiped agents mid-task four times
|
|
6675
|
+
// in a row (W-mq5n1zx5000hcfb5 post-mortem) by reaping a worktree whose
|
|
6676
|
+
// dispatch was still active.
|
|
6677
|
+
//
|
|
6678
|
+
// Fail-open semantics: when SQLite is unreachable or the query throws, the
|
|
6679
|
+
// helper returns true (assume live). Better to leak a worktree than nuke
|
|
6680
|
+
// an agent's unpushed work.
|
|
6681
|
+
|
|
6682
|
+
function _normalizeWorktreePath(p) {
|
|
6683
|
+
if (!p || typeof p !== 'string') return '';
|
|
6684
|
+
let resolved;
|
|
6685
|
+
try { resolved = path.resolve(p); }
|
|
6686
|
+
catch { return ''; }
|
|
6687
|
+
resolved = resolved.replace(/\\/g, '/').replace(/\/+$/g, '');
|
|
6688
|
+
if (process.platform === 'win32') resolved = resolved.toLowerCase();
|
|
6689
|
+
return resolved;
|
|
6690
|
+
}
|
|
6691
|
+
|
|
6692
|
+
function isWorktreePathLive(worktreePath, opts = {}) {
|
|
6693
|
+
if (!worktreePath) return false;
|
|
6694
|
+
const target = _normalizeWorktreePath(worktreePath);
|
|
6695
|
+
if (!target) return false;
|
|
6696
|
+
const excludeDispatchId = opts.excludeDispatchId ? String(opts.excludeDispatchId) : null;
|
|
6697
|
+
let db = opts.db || null;
|
|
6698
|
+
if (!db) {
|
|
6699
|
+
try { db = require('./db').getDb(); }
|
|
6700
|
+
catch (e) {
|
|
6701
|
+
log('warn', `isWorktreePathLive: SQL unavailable for ${worktreePath} (${e.message}) — fail-open (assume live)`);
|
|
6702
|
+
return true;
|
|
6703
|
+
}
|
|
6704
|
+
}
|
|
6705
|
+
if (!db) {
|
|
6706
|
+
log('warn', `isWorktreePathLive: no db handle for ${worktreePath} — fail-open (assume live)`);
|
|
6707
|
+
return true;
|
|
6708
|
+
}
|
|
6709
|
+
let rows;
|
|
6710
|
+
try {
|
|
6711
|
+
rows = db.prepare(`
|
|
6712
|
+
SELECT id,
|
|
6713
|
+
json_extract(data, '$.worktreePath') AS top_wt,
|
|
6714
|
+
json_extract(data, '$.meta.worktreePath') AS meta_wt
|
|
6715
|
+
FROM dispatches
|
|
6716
|
+
WHERE status IN ('pending', 'active')
|
|
6717
|
+
`).all();
|
|
6718
|
+
} catch (e) {
|
|
6719
|
+
log('warn', `isWorktreePathLive: query threw for ${worktreePath} (${e.message}) — fail-open (assume live)`);
|
|
6720
|
+
return true;
|
|
6721
|
+
}
|
|
6722
|
+
for (const row of rows || []) {
|
|
6723
|
+
if (excludeDispatchId && String(row.id) === excludeDispatchId) continue;
|
|
6724
|
+
if (row.top_wt && _normalizeWorktreePath(row.top_wt) === target) return true;
|
|
6725
|
+
if (row.meta_wt && _normalizeWorktreePath(row.meta_wt) === target) return true;
|
|
6726
|
+
}
|
|
6727
|
+
return false;
|
|
6728
|
+
}
|
|
6729
|
+
|
|
6730
|
+
// Drop a deduped inbox note when a wipe site skips due to the live guard so
|
|
6731
|
+
// operators can see when the guard fires. Filename is keyed on basename +
|
|
6732
|
+
// UTC date — a single skip per worktree per day produces one note; further
|
|
6733
|
+
// skips that day silently no-op.
|
|
6734
|
+
function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag) {
|
|
6735
|
+
try {
|
|
6736
|
+
const base = path.basename(String(worktreePath || '').replace(/[\\/]+$/g, '')) || 'unknown';
|
|
6737
|
+
const safeBase = base.replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 80);
|
|
6738
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
6739
|
+
const fname = `engine-worktree-skip-live-${safeBase}-${date}.md`;
|
|
6740
|
+
const inboxDir = path.join(MINIONS_DIR, 'notes', 'inbox');
|
|
6741
|
+
try { fs.mkdirSync(inboxDir, { recursive: true }); } catch { /* exists */ }
|
|
6742
|
+
const fpath = path.join(inboxDir, fname);
|
|
6743
|
+
if (fs.existsSync(fpath)) return; // deduped
|
|
6744
|
+
const body = [
|
|
6745
|
+
'---',
|
|
6746
|
+
`id: NOTE-${crypto.randomBytes(8).toString('hex')}`,
|
|
6747
|
+
'agent: engine',
|
|
6748
|
+
`date: ${date}`,
|
|
6749
|
+
'---',
|
|
6750
|
+
'',
|
|
6751
|
+
`# Engine skipped worktree wipe — live dispatch guard fired (W-mq5rwwss000f30a7)`,
|
|
6752
|
+
'',
|
|
6753
|
+
`- caller: ${callerTag || 'unknown'}`,
|
|
6754
|
+
`- worktree: ${worktreePath}`,
|
|
6755
|
+
`- timestamp: ${new Date().toISOString()}`,
|
|
6756
|
+
'',
|
|
6757
|
+
'A non-terminal dispatch row still claims this worktree. The wipe was skipped to',
|
|
6758
|
+
'protect agent state. If this fires repeatedly, inspect engine/state.db dispatches',
|
|
6759
|
+
"table to find which dispatch is stuck claiming the path.",
|
|
6760
|
+
'',
|
|
6761
|
+
].join('\n');
|
|
6762
|
+
fs.writeFileSync(fpath, body);
|
|
6763
|
+
} catch { /* best-effort — never throw from the skip-note writer */ }
|
|
6764
|
+
}
|
|
6765
|
+
|
|
6766
|
+
function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
|
|
6580
6767
|
const resolved = path.resolve(wtPath);
|
|
6581
6768
|
const resolvedRoot = path.resolve(worktreeRoot) + path.sep;
|
|
6582
6769
|
if (!resolved.startsWith(resolvedRoot)) {
|
|
6583
6770
|
log('warn', `removeWorktree: refusing to remove ${wtPath} — not under ${worktreeRoot}`);
|
|
6584
6771
|
return false;
|
|
6585
6772
|
}
|
|
6773
|
+
// W-mq5rwwss000f30a7 — never wipe a worktree while an agent is actively
|
|
6774
|
+
// dispatched inside it. isWorktreePathLive fails OPEN (returns true) when
|
|
6775
|
+
// the dispatches table is unreachable, so we err on the side of leaking
|
|
6776
|
+
// the worktree rather than destroying agent work.
|
|
6777
|
+
//
|
|
6778
|
+
// PR #3133 review: callers that are themselves the owning dispatch (e.g.
|
|
6779
|
+
// the W-mpbqhstz001lf518 dispatch-end orphan GC, or the pool-return
|
|
6780
|
+
// chain) can pass `excludeDispatchId` so their OWN active row — which
|
|
6781
|
+
// legitimately claims the worktreePath via the pending→active persistence
|
|
6782
|
+
// at engine.js — is ignored by the guard. Any OTHER non-terminal row
|
|
6783
|
+
// still blocks the wipe.
|
|
6784
|
+
const excludeDispatchId = opts && opts.excludeDispatchId ? String(opts.excludeDispatchId) : null;
|
|
6785
|
+
if (isWorktreePathLive(resolved, excludeDispatchId ? { excludeDispatchId } : undefined)) {
|
|
6786
|
+
log('warn', `removeWorktree: skip — live dispatch in ${wtPath}`);
|
|
6787
|
+
_writeWorktreeSkipLiveInboxNote(wtPath, 'shared.removeWorktree');
|
|
6788
|
+
return false;
|
|
6789
|
+
}
|
|
6586
6790
|
_pruneRemoveWorktreeFailures();
|
|
6587
6791
|
// Skip paths that failed 3+ times — retry after 1 hour cooldown
|
|
6588
6792
|
const prior = _removeWorktreeFailures.get(resolved);
|
|
@@ -6978,6 +7182,8 @@ module.exports = {
|
|
|
6978
7182
|
upsertPullRequestRecord,
|
|
6979
7183
|
isAutoManagedPrRecord, // W-mq5s5ttx000j7ab8-a — exported for engine + watch-plugin gate consolidation
|
|
6980
7184
|
migratePrGateFlags, // W-mq5s5ttx000j7ab8-a — boot migration wired from engine/cli.js
|
|
7185
|
+
autoEnrollPrFromFixWorkItem,
|
|
7186
|
+
deriveUrlForPrRef, // exported for testing
|
|
6981
7187
|
nextWorkItemId,
|
|
6982
7188
|
getProjectOrg,
|
|
6983
7189
|
getAdoOrgBase,
|
|
@@ -7030,6 +7236,9 @@ module.exports = {
|
|
|
7030
7236
|
listProcessDescendants,
|
|
7031
7237
|
listProcessReachable,
|
|
7032
7238
|
removeWorktree,
|
|
7239
|
+
isWorktreePathLive,
|
|
7240
|
+
_normalizeWorktreePath, // exported for testing
|
|
7241
|
+
_writeWorktreeSkipLiveInboxNote, // exported for testing
|
|
7033
7242
|
_retryFsOp, // exported for testing (W-mq5o6bvy000x7191)
|
|
7034
7243
|
bumpWorktreeGcMetric, // exported for testing (W-mq5o6bvy000x7191)
|
|
7035
7244
|
_WORKTREE_RETRYABLE_CODES, // exported for testing (W-mq5o6bvy000x7191)
|
package/engine/worktree-gc.js
CHANGED
|
@@ -284,6 +284,13 @@ function gcDispatchWorktreeIfOrphan(opts) {
|
|
|
284
284
|
worktreeRoot,
|
|
285
285
|
log = _noopLog,
|
|
286
286
|
removeWorktree = null,
|
|
287
|
+
// PR #3133 review — when the dispatch-end GC is the caller, the
|
|
288
|
+
// current dispatch's own active row legitimately claims worktreePath
|
|
289
|
+
// (persisted by engine.js at pending→active). Pass `excludeDispatchId`
|
|
290
|
+
// so shared.removeWorktree's live-guard ignores that row and lets the
|
|
291
|
+
// dispatch GC its own orphan worktree. Other live claimants still
|
|
292
|
+
// block the wipe.
|
|
293
|
+
excludeDispatchId = null,
|
|
287
294
|
config = null,
|
|
288
295
|
writeToInbox = null,
|
|
289
296
|
} = opts || {};
|
|
@@ -295,9 +302,10 @@ function gcDispatchWorktreeIfOrphan(opts) {
|
|
|
295
302
|
return { outcome: 'skip', reason: 'no-git-root', removed: false };
|
|
296
303
|
}
|
|
297
304
|
const _removeFn = typeof removeWorktree === 'function' ? removeWorktree : shared.removeWorktree;
|
|
305
|
+
const _rmOpts = excludeDispatchId ? { excludeDispatchId } : undefined;
|
|
298
306
|
const resolved = (() => { try { return path.resolve(worktreePath); } catch { return worktreePath; } })();
|
|
299
307
|
try {
|
|
300
|
-
const removed = _removeFn(worktreePath, gitRoot, worktreeRoot);
|
|
308
|
+
const removed = _removeFn(worktreePath, gitRoot, worktreeRoot, _rmOpts);
|
|
301
309
|
if (removed) {
|
|
302
310
|
_markStuckSuccess(resolved, { writeToInbox });
|
|
303
311
|
log('info', `worktree-gc: dispatch-end removed ${path.basename(worktreePath)}`);
|
package/engine.js
CHANGED
|
@@ -1021,9 +1021,21 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
|
|
|
1021
1021
|
statusError: e.message,
|
|
1022
1022
|
},
|
|
1023
1023
|
);
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1024
|
+
// PR #3133 review — honor q.skipped instead of dishonestly
|
|
1025
|
+
// claiming quarantined=true with a null path. When
|
|
1026
|
+
// _quarantineDirtyWorktree skips because shared.isWorktreePathLive
|
|
1027
|
+
// fires on the worktree, no rename happened, no backup ref was
|
|
1028
|
+
// written, and the caller's downstream "quarantined to X" log /
|
|
1029
|
+
// error message would print "quarantined to null". Surface the
|
|
1030
|
+
// skip honestly so the spawn-error path (engine.js:2056-2066) can
|
|
1031
|
+
// render a truthful message and the dispatch stays non-retryable.
|
|
1032
|
+
if (q.skipped) {
|
|
1033
|
+
result.quarantineSkipped = true;
|
|
1034
|
+
} else {
|
|
1035
|
+
result.quarantined = true;
|
|
1036
|
+
result.quarantinedPath = q.quarantinedPath;
|
|
1037
|
+
result.backupRef = q.backupRef;
|
|
1038
|
+
}
|
|
1027
1039
|
} catch (qErr) {
|
|
1028
1040
|
result.quarantineError = qErr.message;
|
|
1029
1041
|
log('error', `assertCleanSharedWorktree: quarantine after status-failed failed for ${worktreePath}: ${qErr.message}`);
|
|
@@ -1143,9 +1155,16 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
|
|
|
1143
1155
|
dirtyFiles: result.dirtyFiles,
|
|
1144
1156
|
},
|
|
1145
1157
|
);
|
|
1146
|
-
result
|
|
1147
|
-
|
|
1148
|
-
|
|
1158
|
+
// PR #3133 review — same honest-result fix as the status-failed
|
|
1159
|
+
// path above. If _quarantineDirtyWorktree was skipped by the
|
|
1160
|
+
// live-guard, do NOT set quarantined=true with a null path.
|
|
1161
|
+
if (q.skipped) {
|
|
1162
|
+
result.quarantineSkipped = true;
|
|
1163
|
+
} else {
|
|
1164
|
+
result.quarantined = true;
|
|
1165
|
+
result.quarantinedPath = q.quarantinedPath;
|
|
1166
|
+
result.backupRef = q.backupRef;
|
|
1167
|
+
}
|
|
1149
1168
|
} catch (qErr) {
|
|
1150
1169
|
result.quarantineError = qErr.message;
|
|
1151
1170
|
log('error', `assertCleanSharedWorktree: quarantine failed for ${worktreePath}: ${qErr.message}`);
|
|
@@ -1214,6 +1233,14 @@ async function _quarantineDirtyWorktree(rootDir, worktreePath, branchName, gitOp
|
|
|
1214
1233
|
|
|
1215
1234
|
// Rename the worktree dir. Once this succeeds the worktree is functionally
|
|
1216
1235
|
// quarantined; subsequent failures only affect ref bookkeeping.
|
|
1236
|
+
// W-mq5rwwss000f30a7 — never quarantine (=rename out from under) a worktree
|
|
1237
|
+
// that a live dispatch still claims. Quarantine breaks the agent's cwd just
|
|
1238
|
+
// as completely as removeWorktree would.
|
|
1239
|
+
if (shared.isWorktreePathLive(worktreePath)) {
|
|
1240
|
+
log('warn', `_quarantineDirtyWorktree: skip — live dispatch in ${worktreePath}`);
|
|
1241
|
+
shared._writeWorktreeSkipLiveInboxNote(worktreePath, '_quarantineDirtyWorktree');
|
|
1242
|
+
return { quarantinedPath: null, backupRef: null, skipped: true };
|
|
1243
|
+
}
|
|
1217
1244
|
fs.renameSync(worktreePath, quarantinedPath);
|
|
1218
1245
|
|
|
1219
1246
|
// Prune git's stale worktree metadata so the next `git worktree add` for
|
|
@@ -2047,14 +2074,14 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2047
2074
|
const failureClassName = isDivergent ? 'WORKTREE_DIVERGENT' : 'WORKTREE_DIRTY';
|
|
2048
2075
|
const reasonMsg = cleanResult.quarantined
|
|
2049
2076
|
? `${failureClassName}: reused worktree at ${worktreePath} was dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} dirty file(s)${previewFiles ? ': ' + previewFiles : ''}) — quarantined to ${cleanResult.quarantinedPath}. Next dispatch will start fresh.`
|
|
2050
|
-
: `${failureClassName}: reused worktree at ${worktreePath} is dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} file(s)${previewFiles ? ': ' + previewFiles : ''}). Quarantine ${cleanResult.quarantineError ? 'errored: ' + cleanResult.quarantineError : 'was not attempted (' + cleanResult.reason + ').'}`;
|
|
2077
|
+
: `${failureClassName}: reused worktree at ${worktreePath} is dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} file(s)${previewFiles ? ': ' + previewFiles : ''}). Quarantine ${cleanResult.quarantineError ? 'errored: ' + cleanResult.quarantineError : (cleanResult.quarantineSkipped ? 'was skipped — another live dispatch claims the worktree (see notes/inbox/ engine-worktree-skip-live note).' : 'was not attempted (' + cleanResult.reason + ').')}`;
|
|
2051
2078
|
log('error', reasonMsg);
|
|
2052
2079
|
_cleanupPromptFiles();
|
|
2053
2080
|
completeDispatch(
|
|
2054
2081
|
id,
|
|
2055
2082
|
DISPATCH_RESULT.ERROR,
|
|
2056
2083
|
reasonMsg.slice(0, 500),
|
|
2057
|
-
`Engine preflight refused to dispatch into a dirty/divergent reused worktree (#2996). Reason: ${cleanResult.reason}.${cleanResult.quarantined ? ` Worktree quarantined to ${cleanResult.quarantinedPath}; backup ref ${cleanResult.backupRef || '(skipped)'}. See notes/inbox/ for recovery instructions.` : ''}`,
|
|
2084
|
+
`Engine preflight refused to dispatch into a dirty/divergent reused worktree (#2996). Reason: ${cleanResult.reason}.${cleanResult.quarantined ? ` Worktree quarantined to ${cleanResult.quarantinedPath}; backup ref ${cleanResult.backupRef || '(skipped)'}. See notes/inbox/ for recovery instructions.` : (cleanResult.quarantineSkipped ? ' Quarantine was skipped because another live dispatch claims this worktree path; this dispatch will not auto-retry until the live claimant clears.' : '')}`,
|
|
2058
2085
|
{ agentRetryable: isStatusProbeFailed && cleanResult.quarantined, failureClass: failureClassValue },
|
|
2059
2086
|
);
|
|
2060
2087
|
cleanupTempAgent(agentId);
|
|
@@ -3743,6 +3770,15 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3743
3770
|
const _projForReturn = project?.name || 'default';
|
|
3744
3771
|
const _poolSizeReturn = worktreePool.getProjectPoolSize(_projForReturn, config);
|
|
3745
3772
|
if (!_keepPidsAlive && !_managedSpawnAlive && _poolSizeReturn > 0) {
|
|
3773
|
+
// W-mq5rwwss000f30a7 — defensive live-worktree check before the
|
|
3774
|
+
// destructive `git reset --hard HEAD → git clean -fd → checkout
|
|
3775
|
+
// --detach` chain. We pass excludeDispatchId so the active row for
|
|
3776
|
+
// THIS dispatch (still in dispatch.active until completeDispatch
|
|
3777
|
+
// fires below) is ignored. Any OTHER live dispatch claiming the
|
|
3778
|
+
// same path is a sign of a concurrency bug and must not be wiped.
|
|
3779
|
+
if (shared.isWorktreePathLive(worktreePath, { excludeDispatchId: id })) {
|
|
3780
|
+
log('warn', `worktree-pool: skip return — another live dispatch claims ${worktreePath}`);
|
|
3781
|
+
} else {
|
|
3746
3782
|
try {
|
|
3747
3783
|
const _mainRefRet = sanitizeBranch(shared.resolveMainBranch(rootDir, project?.mainBranch));
|
|
3748
3784
|
await shared.shellSafeGit(['reset', '--hard', 'HEAD'], { ..._gitOpts, cwd: worktreePath, timeout: 30000 });
|
|
@@ -3771,6 +3807,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3771
3807
|
// dispatch-end GC below will pick it up via the isPoolMember
|
|
3772
3808
|
// check (which now correctly returns false).
|
|
3773
3809
|
}
|
|
3810
|
+
}
|
|
3774
3811
|
} else if (_keepPidsAlive || _managedSpawnAlive) {
|
|
3775
3812
|
// Skip the pool — the worktree is in use by left-running processes
|
|
3776
3813
|
// (keep_processes PIDs or managed-spawn services). Make sure no
|
|
@@ -3803,6 +3840,13 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3803
3840
|
worktreeRoot: _wtRoot,
|
|
3804
3841
|
agentId,
|
|
3805
3842
|
managedSpawnSpawnedCount: Array.isArray(managedSpawnSpawned) ? managedSpawnSpawned.length : 0,
|
|
3843
|
+
// W-mq5rwwss000f30a7 / PR #3133 review — this dispatch's own
|
|
3844
|
+
// active row still claims worktreePath (persisted at pending→active,
|
|
3845
|
+
// line ~3998). Without excludeDispatchId, shared.removeWorktree's
|
|
3846
|
+
// live-guard would skip and silently neuter the GC for the
|
|
3847
|
+
// default worktreePoolSize:0 config. Pool-return uses the same
|
|
3848
|
+
// plumbing; this is the matching wiring on the orphan-GC side.
|
|
3849
|
+
excludeDispatchId: id,
|
|
3806
3850
|
log,
|
|
3807
3851
|
});
|
|
3808
3852
|
if (_gcResult.outcome === 'gc') {
|
|
@@ -3973,6 +4017,11 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3973
4017
|
// route output parsing through the right adapter. Also surfaces the choice
|
|
3974
4018
|
// in dispatch.json for debugging multi-runtime fleets.
|
|
3975
4019
|
item.runtimeName = runtimeName;
|
|
4020
|
+
// W-mq5rwwss000f30a7 — persist the worktree path so the live-worktree
|
|
4021
|
+
// guard (shared.isWorktreePathLive) can correlate destructive callers
|
|
4022
|
+
// (removeWorktree, cleanup orphan-dir sweep, pool-return, quarantine)
|
|
4023
|
+
// back to this active dispatch and skip the wipe.
|
|
4024
|
+
if (worktreePath) item.worktreePath = worktreePath;
|
|
3976
4025
|
delete item.skipReason;
|
|
3977
4026
|
delete item._agentBusySince;
|
|
3978
4027
|
if (!dispatch.active.some(d => d.id === id)) {
|
|
@@ -5836,6 +5885,12 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
|
|
|
5836
5885
|
qa_artifacts_dir: item.meta && item.meta.qaRunId
|
|
5837
5886
|
? path.posix.join('engine', 'qa-artifacts', String(item.meta.qaRunId))
|
|
5838
5887
|
: '',
|
|
5888
|
+
// W-mq16xtdx001a347e — escape hatch for review dispatches that should NOT
|
|
5889
|
+
// be steered toward project-local review skills (e.g. when the diff
|
|
5890
|
+
// under review IS that skill, so a meta-review needs first-principles).
|
|
5891
|
+
// Default OFF — the new "Project review skills" block is the whole point
|
|
5892
|
+
// of W-mq16xtdx and should surface on every review by default.
|
|
5893
|
+
skip_project_review_skills: !!(item.meta && item.meta.skipProjectReviewSkills),
|
|
5839
5894
|
// P-e6b3c2d8 — QA Session template vars. The qa-sessions chain helpers
|
|
5840
5895
|
// (engine/qa-sessions.js#_baseWorkItem) stamp meta.sessionId,
|
|
5841
5896
|
// meta.sessionPhase, and meta.qaSession.{target,flowsRaw,mode,capture,runner}
|
|
@@ -6270,6 +6325,18 @@ function discoverFromWorkItems(config, project) {
|
|
|
6270
6325
|
skipped.noAgent++; continue;
|
|
6271
6326
|
}
|
|
6272
6327
|
|
|
6328
|
+
// W-mq5wfh1v000e0da9 — defense-in-depth: auto-enroll the PR for fix WIs
|
|
6329
|
+
// carrying a structured PR pointer. Primary enrollment runs in the
|
|
6330
|
+
// dashboard `POST /api/work-items` handler, but WIs created via CLI / restored
|
|
6331
|
+
// from disk / older code paths might land here without the PR record.
|
|
6332
|
+
// No-op if the PR is already tracked. Failures swallowed — the gate below
|
|
6333
|
+
// still trips on missing PR records and surfaces `pr_not_found`.
|
|
6334
|
+
try {
|
|
6335
|
+
if (item.type === WORK_TYPE.FIX) {
|
|
6336
|
+
shared.autoEnrollPrFromFixWorkItem(item, project, MINIONS_DIR);
|
|
6337
|
+
}
|
|
6338
|
+
} catch (e) { log('warn', `auto-enroll PR for ${item.id}: ${e.message}`); }
|
|
6339
|
+
|
|
6273
6340
|
const linkedPr = resolveWorkItemPrRecord(item, project);
|
|
6274
6341
|
const promptItem = linkedPr ? withWorkItemPrContext(item, linkedPr) : item;
|
|
6275
6342
|
const prBranch = linkedPr?.branch || '';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2147",
|
|
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"
|
package/playbooks/review.md
CHANGED
|
@@ -27,6 +27,10 @@ Use subagents only for genuinely parallel, independent tasks (e.g., reviewing un
|
|
|
27
27
|
git diff {{main_branch}}...origin/{{pr_branch}}
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
{{#project_review_skills_block}}
|
|
31
|
+
{{project_review_skills_block}}
|
|
32
|
+
|
|
33
|
+
{{/project_review_skills_block}}
|
|
30
34
|
2. Think about deploy risk before commenting:
|
|
31
35
|
- What user-visible behavior changed?
|
|
32
36
|
- What dependencies, callers, or tests could be affected?
|