@yemi33/minions 0.1.2007 → 0.1.2009
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/dashboard/js/render-other.js +35 -4
- package/dashboard/js/settings.js +36 -1
- package/dashboard/styles.css +7 -0
- package/dashboard.js +32 -7
- package/engine/ado.js +261 -0
- package/engine/cli.js +14 -2
- package/engine/lifecycle.js +186 -0
- package/engine/queries.js +67 -15
- package/engine.js +10 -1
- package/package.json +1 -1
- package/playbooks/review.md +2 -0
|
@@ -25,18 +25,49 @@ function renderProjects(projects) {
|
|
|
25
25
|
// classifier emitted by getProjectGitStatus() in engine/queries.js.
|
|
26
26
|
// Adds a title attribute carrying the full branch name so users can still
|
|
27
27
|
// read it on hover after the CSS ellipsis-truncate (W-mpayac6d000b7d33).
|
|
28
|
+
//
|
|
29
|
+
// W-mpg3whgp000d09ec / #2732: also surfaces configured mainBranch drift —
|
|
30
|
+
// shows "current → main" when local branch differs from the configured main,
|
|
31
|
+
// a warning chip when branchMismatch (configured ≠ origin/HEAD) OR when the
|
|
32
|
+
// local branch is behind main, and a compact "↑N ↓M" chip when ahead/behind
|
|
33
|
+
// counts are non-zero.
|
|
28
34
|
function _renderProjectBranch(p) {
|
|
29
35
|
if (!p) return '';
|
|
30
36
|
if (p.gitState === 'missing') return '<span class="project-warn" title="Project localPath does not exist on disk">(path not found)</span>';
|
|
31
37
|
if (p.gitState === 'non-git') return '<span class="project-muted" title="Project path exists but is not a git repository">(not a git repo)</span>';
|
|
32
38
|
if (p.gitState !== 'ok' || !p.gitBranch) return '';
|
|
33
39
|
const branch = escHtml(p.gitBranch);
|
|
34
|
-
const
|
|
40
|
+
const mainBranch = p.mainBranch ? escHtml(p.mainBranch) : '';
|
|
41
|
+
const remoteDefault = p.remoteDefaultBranch ? escHtml(p.remoteDefaultBranch) : '';
|
|
42
|
+
const branchMismatch = !!p.branchMismatch;
|
|
43
|
+
const ahead = Number.isFinite(p.ahead) ? p.ahead : null;
|
|
44
|
+
const behind = Number.isFinite(p.behind) ? p.behind : null;
|
|
45
|
+
const branchDiffersFromMain = !!(mainBranch && p.gitBranch !== p.mainBranch);
|
|
46
|
+
const titleBits = ['on: ' + p.gitBranch];
|
|
47
|
+
if (mainBranch) titleBits.push('configured main: ' + p.mainBranch);
|
|
48
|
+
if (remoteDefault) titleBits.push('origin/HEAD: ' + p.remoteDefaultBranch);
|
|
49
|
+
if (ahead !== null && behind !== null) titleBits.push('↑' + ahead + ' ↓' + behind + ' vs origin/' + (p.mainBranch || p.remoteDefaultBranch));
|
|
50
|
+
if (p.gitDetached) titleBits.push('(detached)');
|
|
51
|
+
if (p.gitDirty) titleBits.push('(dirty)');
|
|
52
|
+
const titleAttr = ' title="' + escHtml(titleBits.join(' • ')) + '"';
|
|
35
53
|
const dirty = p.gitDirty ? ' <span class="dot-dirty" title="Working tree has uncommitted changes">●</span>' : '';
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
const mainSuffix = branchDiffersFromMain
|
|
55
|
+
? ' <span class="muted">→ ' + mainBranch + '</span>'
|
|
56
|
+
: '';
|
|
57
|
+
const detachedSuffix = p.gitDetached ? ' <span class="muted">(detached)</span>' : '';
|
|
58
|
+
const aheadBehind = (ahead !== null && behind !== null && (ahead > 0 || behind > 0))
|
|
59
|
+
? ' <span class="project-ab" title="' + escHtml('Ahead ' + ahead + ' / behind ' + behind + ' vs origin/' + (p.mainBranch || p.remoteDefaultBranch)) + '">↑' + ahead + ' ↓' + behind + '</span>'
|
|
60
|
+
: '';
|
|
61
|
+
// Warning chip surfaces when the configured main disagrees with the remote
|
|
62
|
+
// default branch (the #2732 case: config=master, origin/HEAD=main) OR when
|
|
63
|
+
// the local branch is behind the configured main.
|
|
64
|
+
let warnChip = '';
|
|
65
|
+
if (branchMismatch) {
|
|
66
|
+
warnChip = ' <span class="project-warn" title="' + escHtml('Configured mainBranch (' + p.mainBranch + ') differs from origin/HEAD (' + p.remoteDefaultBranch + ') — config is likely stale') + '">⚠ main drift</span>';
|
|
67
|
+
} else if (behind !== null && behind > 0) {
|
|
68
|
+
warnChip = ' <span class="project-warn" title="' + escHtml('Local branch is ' + behind + ' commits behind origin/' + (p.mainBranch || p.remoteDefaultBranch)) + '">⚠ behind</span>';
|
|
38
69
|
}
|
|
39
|
-
return '<span class="project-branch"' + titleAttr + '>on: ' + branch + dirty + '</span>';
|
|
70
|
+
return '<span class="project-branch"' + titleAttr + '>on: ' + branch + mainSuffix + detachedSuffix + dirty + '</span>' + aheadBehind + warnChip;
|
|
40
71
|
}
|
|
41
72
|
|
|
42
73
|
function _projectCachePath(project) {
|
package/dashboard/js/settings.js
CHANGED
|
@@ -132,12 +132,42 @@ async function openSettings() {
|
|
|
132
132
|
'<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Projects</h3>' +
|
|
133
133
|
'<div style="display:flex;flex-direction:column;gap:12px;margin-bottom:16px">' +
|
|
134
134
|
(data.projects || []).map(function(p) {
|
|
135
|
+
// Cross-reference live /api/status to pull in the auto-detected
|
|
136
|
+
// remoteDefaultBranch (read-only) — settings already has localPath +
|
|
137
|
+
// mainBranch from /api/settings, but origin/HEAD is probe-derived and
|
|
138
|
+
// lives in /api/status only. W-mpg3whgp000d09ec / #2732.
|
|
139
|
+
var liveProj = null;
|
|
140
|
+
try {
|
|
141
|
+
if (window._lastStatus && Array.isArray(window._lastStatus.projects)) {
|
|
142
|
+
liveProj = window._lastStatus.projects.find(function(x) { return x.name === p.name; }) || null;
|
|
143
|
+
}
|
|
144
|
+
} catch (e) { liveProj = null; }
|
|
145
|
+
var remoteDefault = (liveProj && liveProj.remoteDefaultBranch) || '';
|
|
146
|
+
var mismatch = !!(liveProj && liveProj.branchMismatch);
|
|
147
|
+
var localBranch = (liveProj && liveProj.gitBranch) || '';
|
|
148
|
+
var driftNote = mismatch
|
|
149
|
+
? '<div style="font-size:10px;color:var(--yellow);margin-top:4px">⚠ Configured main (<code>' + escHtml(p.mainBranch || '') + '</code>) differs from origin/HEAD (<code>' + escHtml(remoteDefault) + '</code>) — config is likely stale.</div>'
|
|
150
|
+
: '';
|
|
151
|
+
var pathRow = p.localPath
|
|
152
|
+
? '<div style="font-size:10px;color:var(--muted);margin-bottom:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace">' + escHtml(p.localPath) + '</div>'
|
|
153
|
+
: '';
|
|
154
|
+
var branchGrid = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:6px">' +
|
|
155
|
+
settingsField('Configured main branch', 'set-mainBranch-' + p.name, p.mainBranch || '', '', 'Used by branch-naming + dependency-merge to identify mainline. Empty = auto-detect from origin/HEAD.') +
|
|
156
|
+
'<div>' +
|
|
157
|
+
'<label style="font-size:10px;color:var(--muted);display:block;margin-bottom:2px">Remote default <span style="opacity:0.6">(read-only)</span></label>' +
|
|
158
|
+
'<input id="set-remoteDefaultBranch-' + p.name + '" value="' + escHtml(remoteDefault || '(unset)') + '" readonly style="width:100%;padding:4px 6px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--muted);font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace">' +
|
|
159
|
+
'<div style="font-size:9px;color:var(--muted);margin-top:1px">Parsed from <code>git symbolic-ref refs/remotes/origin/HEAD</code>' + (localBranch ? '. Local HEAD: <code>' + escHtml(localBranch) + '</code>' : '') + '.</div>' +
|
|
160
|
+
'</div>' +
|
|
161
|
+
'</div>';
|
|
135
162
|
return '<div data-settings-project="' + escHtml(p.name) + '" style="border:1px solid var(--border);border-radius:6px;padding:10px 12px">' +
|
|
136
163
|
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
|
|
137
164
|
'<div style="font-size:12px;font-weight:600">' + escHtml(p.name) + '</div>' +
|
|
138
165
|
'<button onclick="MinionsSettings.removeProject(\'' + escHtml(p.name) + '\')" style="font-size:9px;padding:2px 8px;background:transparent;color:var(--red);border:1px solid var(--red);border-radius:3px;cursor:pointer">Remove</button>' +
|
|
139
166
|
'</div>' +
|
|
140
|
-
|
|
167
|
+
pathRow +
|
|
168
|
+
branchGrid +
|
|
169
|
+
driftNote +
|
|
170
|
+
'<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">' +
|
|
141
171
|
settingsToggle('Discover from PRs', 'set-ws-prs-' + p.name, p.workSources.pullRequests.enabled, 'Discovery gate: scan repo for open PRs and surface them as review tasks. Independent of ADO/GitHub polling — does not affect already-tracked PRs.') +
|
|
142
172
|
settingsToggle('Discover from Work Items', 'set-ws-wi-' + p.name, p.workSources.workItems.enabled, 'Auto-discover work from ADO/GitHub work items') +
|
|
143
173
|
'</div></div>';
|
|
@@ -628,8 +658,13 @@ async function saveSettings() {
|
|
|
628
658
|
|
|
629
659
|
const currentProjects = (_settingsData && Array.isArray(_settingsData.projects)) ? _settingsData.projects : [];
|
|
630
660
|
const projectsPayload = currentProjects.map(function(p) {
|
|
661
|
+
// W-mpg3whgp000d09ec / #2732 — mainBranch is now editable from Settings →
|
|
662
|
+
// Projects. Empty string = clear the override; the field stays optional.
|
|
663
|
+
const mainBranchInput = document.getElementById('set-mainBranch-' + p.name);
|
|
664
|
+
const mainBranchValue = mainBranchInput ? mainBranchInput.value.trim() : (p.mainBranch || '');
|
|
631
665
|
return {
|
|
632
666
|
name: p.name,
|
|
667
|
+
mainBranch: mainBranchValue || null,
|
|
633
668
|
workSources: {
|
|
634
669
|
pullRequests: { enabled: document.getElementById('set-ws-prs-' + p.name)?.checked ?? true },
|
|
635
670
|
workItems: { enabled: document.getElementById('set-ws-wi-' + p.name)?.checked ?? true }
|
package/dashboard/styles.css
CHANGED
|
@@ -726,6 +726,13 @@
|
|
|
726
726
|
}
|
|
727
727
|
.project-branch .muted { color: var(--muted); }
|
|
728
728
|
.project-branch .dot-dirty { color: var(--yellow); margin-left: 2px; }
|
|
729
|
+
/* Ahead/behind compact chip (W-mpg3whgp000d09ec, #2732). Renders as
|
|
730
|
+
* "↑N ↓M" next to the branch chip when local diverges from origin/<main>. */
|
|
731
|
+
.project-ab {
|
|
732
|
+
font-size: 10px; color: var(--muted); font-weight: 400;
|
|
733
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
734
|
+
margin-left: 4px; white-space: nowrap;
|
|
735
|
+
}
|
|
729
736
|
.project-warn {
|
|
730
737
|
font-size: 10px; color: var(--yellow); font-style: italic; font-weight: 400;
|
|
731
738
|
display: inline-block; max-width: 240px; overflow: hidden;
|
package/dashboard.js
CHANGED
|
@@ -105,7 +105,7 @@ ensureConfiguredProjectStateFiles();
|
|
|
105
105
|
function warmProjectGitStatusCache() {
|
|
106
106
|
for (const p of PROJECTS) {
|
|
107
107
|
if (p && p.localPath) {
|
|
108
|
-
try { queries.warmProjectGitStatus(p.localPath); } catch { /* swallow — warming is opportunistic */ }
|
|
108
|
+
try { queries.warmProjectGitStatus(p.localPath, p.mainBranch); } catch { /* swallow — warming is opportunistic */ }
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
}
|
|
@@ -151,6 +151,14 @@ function mergeSettingsConfigUpdate(current, candidate, body, patch = {}) {
|
|
|
151
151
|
const currentProject = shared.findProjectByName(current.projects, update.name);
|
|
152
152
|
if (!candidateProject || !currentProject) continue;
|
|
153
153
|
currentProject.workSources = candidateProject.workSources;
|
|
154
|
+
// W-mpg3whgp000d09ec / #2732 — mirror mainBranch from candidate; empty
|
|
155
|
+
// / unset on candidate clears the field (matches handleSettingsUpdate
|
|
156
|
+
// semantics, which deletes the key when the operator submits blank).
|
|
157
|
+
if (Object.prototype.hasOwnProperty.call(candidateProject, 'mainBranch')) {
|
|
158
|
+
currentProject.mainBranch = candidateProject.mainBranch;
|
|
159
|
+
} else {
|
|
160
|
+
delete currentProject.mainBranch;
|
|
161
|
+
}
|
|
154
162
|
}
|
|
155
163
|
}
|
|
156
164
|
shared.pruneDefaultClaudeConfig(current);
|
|
@@ -1638,12 +1646,19 @@ function _buildStatusSlowState() {
|
|
|
1638
1646
|
})(),
|
|
1639
1647
|
pipelines: (() => { try { const pl = require('./engine/pipeline'); return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) })); } catch { return []; } })(),
|
|
1640
1648
|
pinned: (() => { try { return parsePinnedEntries(safeRead(PINNED_PATH)); } catch { return []; } })(),
|
|
1641
|
-
projects: PROJECTS.map(p =>
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1649
|
+
projects: PROJECTS.map(p => {
|
|
1650
|
+
const status = getProjectGitStatus(p.localPath, p.mainBranch);
|
|
1651
|
+
const mainBranch = p.mainBranch || null;
|
|
1652
|
+
const branchMismatch = !!(mainBranch && status.remoteDefaultBranch && mainBranch !== status.remoteDefaultBranch);
|
|
1653
|
+
return {
|
|
1654
|
+
name: p.name,
|
|
1655
|
+
path: p.localPath,
|
|
1656
|
+
description: p.description || '',
|
|
1657
|
+
...status,
|
|
1658
|
+
mainBranch,
|
|
1659
|
+
branchMismatch,
|
|
1660
|
+
};
|
|
1661
|
+
}),
|
|
1647
1662
|
autoMode: {
|
|
1648
1663
|
approvePlans: !!CONFIG.engine?.autoApprovePlans,
|
|
1649
1664
|
decompose: CONFIG.engine?.autoDecompose !== false,
|
|
@@ -8131,6 +8146,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8131
8146
|
agents: config.agents || {},
|
|
8132
8147
|
projects: (config.projects || []).map(p => ({
|
|
8133
8148
|
name: p.name,
|
|
8149
|
+
localPath: p.localPath || null,
|
|
8150
|
+
mainBranch: p.mainBranch || null,
|
|
8134
8151
|
workSources: {
|
|
8135
8152
|
pullRequests: { enabled: p.workSources?.pullRequests?.enabled !== false, cooldownMinutes: p.workSources?.pullRequests?.cooldownMinutes ?? 30 },
|
|
8136
8153
|
workItems: { enabled: p.workSources?.workItems?.enabled !== false, cooldownMinutes: p.workSources?.workItems?.cooldownMinutes ?? 0 }
|
|
@@ -8444,6 +8461,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8444
8461
|
if (update.workSources.workItems.enabled !== undefined)
|
|
8445
8462
|
proj.workSources.workItems.enabled = !!update.workSources.workItems.enabled;
|
|
8446
8463
|
}
|
|
8464
|
+
// W-mpg3whgp000d09ec / #2732 — operator-editable mainBranch.
|
|
8465
|
+
// Empty string / null clears the override (engine falls back to
|
|
8466
|
+
// origin/HEAD auto-detection); any other string trims + pins.
|
|
8467
|
+
if (Object.prototype.hasOwnProperty.call(update, 'mainBranch')) {
|
|
8468
|
+
const raw = update.mainBranch == null ? '' : String(update.mainBranch).trim();
|
|
8469
|
+
if (raw) proj.mainBranch = raw;
|
|
8470
|
+
else delete proj.mainBranch;
|
|
8471
|
+
}
|
|
8447
8472
|
}
|
|
8448
8473
|
}
|
|
8449
8474
|
|
package/engine/ado.js
CHANGED
|
@@ -415,6 +415,213 @@ function votesToReviewStatus(votes) {
|
|
|
415
415
|
return 'pending';
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
+
// ─── Reviewer Vote Snapshots (W-mpg58wv3) ────────────────────────────────────
|
|
419
|
+
//
|
|
420
|
+
// ADO's reviewer API does NOT include the source commit at which a reviewer
|
|
421
|
+
// cast their vote. Without that signal the engine cannot tell a "still-stuck
|
|
422
|
+
// stale -5" from a "freshly cast -5 the author hasn't responded to yet". This
|
|
423
|
+
// snapshot file is the engine's own vote-time-SHA ledger: every time the
|
|
424
|
+
// authenticated user's vote on a PR transitions (or is observed for the first
|
|
425
|
+
// time), pollPrStatus writes (prId, reviewerId, vote, sourceCommit, observedAt)
|
|
426
|
+
// here. The stuck-vote reaper then compares the snapshot's source commit
|
|
427
|
+
// against the PR's current source commit.
|
|
428
|
+
//
|
|
429
|
+
// File: `engine/ado-vote-snapshots.json` (under MINIONS_DIR). Side-effect-free
|
|
430
|
+
// reaper — warnings go to notes/inbox only; vote state is never mutated.
|
|
431
|
+
//
|
|
432
|
+
// Bounded by entry count and per-snapshot age via _pruneVoteSnapshots; ledger
|
|
433
|
+
// is per-(PR, reviewer) so a re-vote replaces the prior entry in place.
|
|
434
|
+
const VOTE_SNAPSHOT_FILE = 'ado-vote-snapshots.json';
|
|
435
|
+
const VOTE_SNAPSHOT_MAX_ENTRIES = 500;
|
|
436
|
+
const VOTE_SNAPSHOT_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000; // 60 days
|
|
437
|
+
const STUCK_VOTE_AGE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24h — see issue #2739
|
|
438
|
+
const STUCK_VOTE_WARN_INTERVAL_MS = 24 * 60 * 60 * 1000; // dedupe per (PR, head SHA) within 24h
|
|
439
|
+
|
|
440
|
+
function _voteSnapshotsPath() {
|
|
441
|
+
// Resolve lazily so tests that swap MINIONS_DIR via createTestMinionsDir()
|
|
442
|
+
// see the fresh value. shared.MINIONS_DIR is reassigned when MINIONS_TEST_DIR
|
|
443
|
+
// is set, but only when shared is re-required — which the helper does via
|
|
444
|
+
// `require('./shared').MINIONS_DIR`.
|
|
445
|
+
return path.join(require('./shared').MINIONS_DIR, 'engine', VOTE_SNAPSHOT_FILE);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function _normalizeSnapshotKey(prId, reviewerId) {
|
|
449
|
+
return `${String(prId || '').toLowerCase()}|${String(reviewerId || '').toLowerCase()}`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function _pruneVoteSnapshots(snapshots, now) {
|
|
453
|
+
if (!Array.isArray(snapshots)) return [];
|
|
454
|
+
const cutoff = now - VOTE_SNAPSHOT_MAX_AGE_MS;
|
|
455
|
+
const live = snapshots.filter(s => {
|
|
456
|
+
if (!s || typeof s !== 'object') return false;
|
|
457
|
+
const observed = Date.parse(s.observedAt || '');
|
|
458
|
+
return Number.isFinite(observed) && observed >= cutoff;
|
|
459
|
+
});
|
|
460
|
+
// Drop the oldest entries when over the entry cap.
|
|
461
|
+
if (live.length > VOTE_SNAPSHOT_MAX_ENTRIES) {
|
|
462
|
+
live.sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt));
|
|
463
|
+
return live.slice(live.length - VOTE_SNAPSHOT_MAX_ENTRIES);
|
|
464
|
+
}
|
|
465
|
+
return live;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Read+update the (prId, reviewerId) snapshot. Returns the prior snapshot
|
|
470
|
+
* BEFORE this update (or null if first observation) so the reaper can compare
|
|
471
|
+
* head SHA / vote / age against the new observation.
|
|
472
|
+
*
|
|
473
|
+
* Snapshot writes only fire on a transition (vote changed, source commit
|
|
474
|
+
* changed, or first observation). Identity observations are no-ops to keep
|
|
475
|
+
* the file from churning every poll.
|
|
476
|
+
*/
|
|
477
|
+
function _recordVoteSnapshot({ prId, reviewerId, vote, sourceCommit, now }) {
|
|
478
|
+
const path_ = _voteSnapshotsPath();
|
|
479
|
+
const key = _normalizeSnapshotKey(prId, reviewerId);
|
|
480
|
+
const nowIso = new Date(now).toISOString();
|
|
481
|
+
let prior = null;
|
|
482
|
+
try {
|
|
483
|
+
mutateJsonFileLocked(path_, (data) => {
|
|
484
|
+
const snapshots = Array.isArray(data?.snapshots) ? data.snapshots.slice() : [];
|
|
485
|
+
const idx = snapshots.findIndex(s => _normalizeSnapshotKey(s?.prId, s?.reviewerId) === key);
|
|
486
|
+
if (idx >= 0) {
|
|
487
|
+
prior = snapshots[idx];
|
|
488
|
+
if (prior.vote === vote && (prior.sourceCommit || '') === (sourceCommit || '')) {
|
|
489
|
+
// No transition — leave the snapshot untouched so we don't churn
|
|
490
|
+
// observedAt on every poll. Reaper uses the original observedAt.
|
|
491
|
+
return data;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const next = {
|
|
495
|
+
prId,
|
|
496
|
+
reviewerId,
|
|
497
|
+
vote,
|
|
498
|
+
sourceCommit: sourceCommit || '',
|
|
499
|
+
observedAt: nowIso,
|
|
500
|
+
// Carry the prior warning timestamp through transitions so the reaper
|
|
501
|
+
// dedup still fires on the same (PR, head SHA) across vote changes.
|
|
502
|
+
...(prior?.lastWarningAt ? { lastWarningAt: prior.lastWarningAt } : {}),
|
|
503
|
+
...(prior?.lastWarningSha ? { lastWarningSha: prior.lastWarningSha } : {}),
|
|
504
|
+
};
|
|
505
|
+
if (idx >= 0) snapshots[idx] = next;
|
|
506
|
+
else snapshots.push(next);
|
|
507
|
+
return { schemaVersion: 1, snapshots: _pruneVoteSnapshots(snapshots, now) };
|
|
508
|
+
}, { defaultValue: { schemaVersion: 1, snapshots: [] } });
|
|
509
|
+
} catch (e) {
|
|
510
|
+
log('warn', `vote-snapshot write for PR ${prId}: ${e.message}`);
|
|
511
|
+
}
|
|
512
|
+
return prior;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function _readVoteSnapshot(prId, reviewerId) {
|
|
516
|
+
try {
|
|
517
|
+
const data = shared.safeJson(_voteSnapshotsPath());
|
|
518
|
+
if (!data || !Array.isArray(data.snapshots)) return null;
|
|
519
|
+
const key = _normalizeSnapshotKey(prId, reviewerId);
|
|
520
|
+
return data.snapshots.find(s => _normalizeSnapshotKey(s?.prId, s?.reviewerId) === key) || null;
|
|
521
|
+
} catch { return null; }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function _recordVoteWarning(prId, reviewerId, headSha) {
|
|
525
|
+
const path_ = _voteSnapshotsPath();
|
|
526
|
+
const key = _normalizeSnapshotKey(prId, reviewerId);
|
|
527
|
+
try {
|
|
528
|
+
mutateJsonFileLocked(path_, (data) => {
|
|
529
|
+
const snapshots = Array.isArray(data?.snapshots) ? data.snapshots.slice() : [];
|
|
530
|
+
const idx = snapshots.findIndex(s => _normalizeSnapshotKey(s?.prId, s?.reviewerId) === key);
|
|
531
|
+
if (idx < 0) return data;
|
|
532
|
+
snapshots[idx] = { ...snapshots[idx], lastWarningAt: ts(), lastWarningSha: headSha || '' };
|
|
533
|
+
return { schemaVersion: 1, snapshots };
|
|
534
|
+
}, { defaultValue: { schemaVersion: 1, snapshots: [] } });
|
|
535
|
+
} catch (e) {
|
|
536
|
+
log('warn', `vote-snapshot warning timestamp for PR ${prId}: ${e.message}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* W-mpg58wv3 — Stuck-vote reaper. Side-effect-free; writes ONE inbox warning
|
|
542
|
+
* per (PR id, head SHA) within 24h. Does NOT auto-dispatch, NOT touch the
|
|
543
|
+
* vote, NOT post a comment. Returns the slug used (truthy when a note was
|
|
544
|
+
* written, false when deduped/non-stuck).
|
|
545
|
+
*
|
|
546
|
+
* Detection: PR is active AND the authenticated reviewer's vote is < 0 AND
|
|
547
|
+
* - The recorded vote-time SHA differs from the PR's current source SHA
|
|
548
|
+
* (i.e. the author has pushed since the negative vote was cast), OR
|
|
549
|
+
* - The vote is older than 24h AND no pending/in-flight re-review WI exists
|
|
550
|
+
* for that PR.
|
|
551
|
+
*/
|
|
552
|
+
function _scanStuckVote({ pr, reviewerId, vote, sourceCommit, snapshot, now }) {
|
|
553
|
+
if (!pr || pr.status !== PR_STATUS.ACTIVE) return false;
|
|
554
|
+
if (!Number.isFinite(vote) || vote >= 0) return false;
|
|
555
|
+
if (!snapshot) return false;
|
|
556
|
+
const headSha = String(sourceCommit || '').trim();
|
|
557
|
+
const snapshotSha = String(snapshot.sourceCommit || '').trim();
|
|
558
|
+
const observedAt = Date.parse(snapshot.observedAt || '');
|
|
559
|
+
if (!Number.isFinite(observedAt)) return false;
|
|
560
|
+
const aged = (now - observedAt) >= STUCK_VOTE_AGE_THRESHOLD_MS;
|
|
561
|
+
const headMoved = headSha && snapshotSha && headSha !== snapshotSha;
|
|
562
|
+
const hasReReview = _hasPendingReReviewWi(pr);
|
|
563
|
+
const stuck = headMoved || (aged && !hasReReview);
|
|
564
|
+
if (!stuck) return false;
|
|
565
|
+
|
|
566
|
+
// Dedupe per (PR, head SHA) within 24h via the snapshot's lastWarningAt/Sha.
|
|
567
|
+
const lastWarningAt = Date.parse(snapshot.lastWarningAt || '');
|
|
568
|
+
const lastWarningSha = String(snapshot.lastWarningSha || '').trim();
|
|
569
|
+
if (Number.isFinite(lastWarningAt)
|
|
570
|
+
&& lastWarningSha === headSha
|
|
571
|
+
&& (now - lastWarningAt) < STUCK_VOTE_WARN_INTERVAL_MS) {
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const trigger = headMoved ? 'head-sha-moved' : 'aged-no-rereview';
|
|
576
|
+
const slug = `prs-stuck-vote-${shared.safeSlugComponent(String(pr.id), 60)}-${headSha ? headSha.slice(0, 8) : 'nohead'}`;
|
|
577
|
+
const lines = [
|
|
578
|
+
`# Stuck reviewer vote warning: ${pr.id}`,
|
|
579
|
+
'',
|
|
580
|
+
`**PR:** ${pr.id}${pr.url ? ` — ${pr.url}` : ''}`,
|
|
581
|
+
`**Title:** ${pr.title || '(no title)'}`,
|
|
582
|
+
`**Reviewer:** ${reviewerId}`,
|
|
583
|
+
`**Vote:** ${vote}`,
|
|
584
|
+
`**Trigger:** ${trigger}`,
|
|
585
|
+
`**Vote-time SHA:** ${snapshotSha || '(unknown)'}`,
|
|
586
|
+
`**Current head SHA:** ${headSha || '(unknown)'}`,
|
|
587
|
+
`**Vote observed at:** ${snapshot.observedAt}`,
|
|
588
|
+
`**Pending re-review WI?** ${hasReReview ? 'yes' : 'no'}`,
|
|
589
|
+
'',
|
|
590
|
+
'This is a warning ONLY. The reaper does not auto-dispatch, vote, or post comments.',
|
|
591
|
+
'A human should decide whether to dispatch a manual re-review or reset the vote.',
|
|
592
|
+
'',
|
|
593
|
+
'See issue #2739 / W-mpg58wv3 for the design.',
|
|
594
|
+
];
|
|
595
|
+
shared.writeToInbox('engine', slug, lines.join('\n'), null, {
|
|
596
|
+
pr: pr.id,
|
|
597
|
+
category: 'prs.stuck-vote',
|
|
598
|
+
head_sha: headSha,
|
|
599
|
+
trigger,
|
|
600
|
+
});
|
|
601
|
+
_recordVoteWarning(pr.id, reviewerId, headSha);
|
|
602
|
+
log('warn', `Stuck-vote warning for PR ${pr.id}: vote=${vote} trigger=${trigger}; wrote inbox note ${slug}`);
|
|
603
|
+
return slug;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Look for a pending/in-flight review WI bound to the same PR. Used by the
|
|
608
|
+
* reaper to suppress the "aged, no re-review" branch when the closure-loop
|
|
609
|
+
* has already fired. Read-only — uses the queries module to avoid leaking
|
|
610
|
+
* dispatch lock churn into the poll path.
|
|
611
|
+
*/
|
|
612
|
+
function _hasPendingReReviewWi(pr) {
|
|
613
|
+
if (!pr?.id) return false;
|
|
614
|
+
try {
|
|
615
|
+
const dispatchData = require('./queries').getDispatch();
|
|
616
|
+
const all = [...(dispatchData?.pending || []), ...(dispatchData?.active || [])];
|
|
617
|
+
return all.some(d => {
|
|
618
|
+
if (d?.type !== 'review') return false;
|
|
619
|
+
const targetId = d?.meta?.pr?.id || '';
|
|
620
|
+
return targetId === pr.id;
|
|
621
|
+
});
|
|
622
|
+
} catch { return false; }
|
|
623
|
+
}
|
|
624
|
+
|
|
418
625
|
// ─── ADO Token Cache ─────────────────────────────────────────────────────────
|
|
419
626
|
|
|
420
627
|
let _adoTokenCache = { token: null, expiresAt: 0 };
|
|
@@ -905,6 +1112,49 @@ async function pollPrStatus(config) {
|
|
|
905
1112
|
shared.trackReviewMetric(pr, newReviewStatus, config);
|
|
906
1113
|
}
|
|
907
1114
|
|
|
1115
|
+
// W-mpg58wv3 — Vote-time-SHA snapshot + stuck-vote reaper. The ADO reviewer
|
|
1116
|
+
// API doesn't surface the source commit at which a vote was cast, so we
|
|
1117
|
+
// build that ledger ourselves: write a snapshot whenever the authenticated
|
|
1118
|
+
// user's vote on this PR transitions (or is first observed) and check the
|
|
1119
|
+
// stuck-vote rule on every poll. Reaper is side-effect-free — it writes an
|
|
1120
|
+
// inbox warning only; never touches the vote, never auto-dispatches,
|
|
1121
|
+
// never posts a comment. See engine/ado-vote-snapshots.json for storage.
|
|
1122
|
+
try {
|
|
1123
|
+
const identityData = await adoFetch(`${orgBase}/_apis/connectionData?api-version=7.1`, token, { timeout: 4000 }).catch(() => null);
|
|
1124
|
+
const myId = identityData?.authenticatedUser?.id;
|
|
1125
|
+
if (myId) {
|
|
1126
|
+
const myReviewer = reviewers.find(r => String(r?.id || '').toLowerCase() === String(myId).toLowerCase());
|
|
1127
|
+
const myVote = myReviewer && Number.isFinite(myReviewer.vote) ? myReviewer.vote : null;
|
|
1128
|
+
if (myVote != null) {
|
|
1129
|
+
const now = Date.now();
|
|
1130
|
+
const prior = _recordVoteSnapshot({
|
|
1131
|
+
prId: pr.id,
|
|
1132
|
+
reviewerId: myId,
|
|
1133
|
+
vote: myVote,
|
|
1134
|
+
sourceCommit: sourceCommit || '',
|
|
1135
|
+
now,
|
|
1136
|
+
});
|
|
1137
|
+
// Run the reaper using the snapshot the engine had BEFORE this poll
|
|
1138
|
+
// (the prior write). On first observation `prior` is null and the
|
|
1139
|
+
// reaper is a no-op — it needs a prior observedAt to evaluate
|
|
1140
|
+
// "aged > 24h" and a prior sourceCommit to evaluate "head moved".
|
|
1141
|
+
if (prior && myVote < 0) {
|
|
1142
|
+
_scanStuckVote({
|
|
1143
|
+
pr,
|
|
1144
|
+
reviewerId: myId,
|
|
1145
|
+
vote: myVote,
|
|
1146
|
+
sourceCommit: sourceCommit || '',
|
|
1147
|
+
snapshot: prior,
|
|
1148
|
+
now,
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
// Reaper failures are non-fatal — never block the PR poll on them.
|
|
1155
|
+
log('warn', `vote-snapshot/reaper for ${pr.id}: ${e.message}`);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
908
1158
|
if (newStatus !== PR_STATUS.ACTIVE) return updated;
|
|
909
1159
|
|
|
910
1160
|
// Query builds API directly — /statuses is unreliable (stale codecoverage postbacks).
|
|
@@ -1961,4 +2211,15 @@ module.exports = {
|
|
|
1961
2211
|
parseCanonicalAdoPrId,
|
|
1962
2212
|
isAdoRepairCandidateCompatible,
|
|
1963
2213
|
getMissingAdoProjectConfigFields,
|
|
2214
|
+
// W-mpg58wv3 — vote-snapshot / stuck-vote reaper helpers (exported for testing).
|
|
2215
|
+
_recordVoteSnapshot,
|
|
2216
|
+
_readVoteSnapshot,
|
|
2217
|
+
_scanStuckVote,
|
|
2218
|
+
_hasPendingReReviewWi,
|
|
2219
|
+
_pruneVoteSnapshots,
|
|
2220
|
+
_voteSnapshotsPath,
|
|
2221
|
+
VOTE_SNAPSHOT_MAX_ENTRIES,
|
|
2222
|
+
VOTE_SNAPSHOT_MAX_AGE_MS,
|
|
2223
|
+
STUCK_VOTE_AGE_THRESHOLD_MS,
|
|
2224
|
+
STUCK_VOTE_WARN_INTERVAL_MS,
|
|
1964
2225
|
};
|
package/engine/cli.js
CHANGED
|
@@ -448,6 +448,15 @@ const commands = {
|
|
|
448
448
|
codeVersion,
|
|
449
449
|
codeCommit
|
|
450
450
|
}));
|
|
451
|
+
// W-mpg3bcp800075d4f — Prime control.heartbeat the moment this process
|
|
452
|
+
// owns control.json, BEFORE the dispatch-recovery / startupReconcile*
|
|
453
|
+
// chain. Those reconciles are synchronous and on a slow Windows host with
|
|
454
|
+
// many projects/worktrees can run for 15-40s; without an early prime the
|
|
455
|
+
// dashboard's 30s restart-grace window (dashboard/js/render-dispatch.js
|
|
456
|
+
// → renderEngineAlert) expires against the *previous* engine's heartbeat
|
|
457
|
+
// and the 'Engine heartbeat is stale' banner snaps back even though the
|
|
458
|
+
// new engine is healthy. The 15s setInterval below keeps it fresh.
|
|
459
|
+
writeHeartbeatNow();
|
|
451
460
|
// Keep .minions-version in sync so `minions version` stays accurate after git pulls
|
|
452
461
|
if (codeVersion) {
|
|
453
462
|
try { fs.writeFileSync(path.join(shared.MINIONS_DIR, '.minions-version'), codeVersion); } catch {}
|
|
@@ -924,8 +933,11 @@ const commands = {
|
|
|
924
933
|
// surfaced a healthy engine as crashed. We now write every 15s on a
|
|
925
934
|
// dedicated interval — 8× headroom vs the 120s threshold even under
|
|
926
935
|
// event-loop pressure, and orders of magnitude under TICK_TIMEOUT_MS so
|
|
927
|
-
// a hung tick still looks distinct from a wedged event loop.
|
|
928
|
-
|
|
936
|
+
// a hung tick still looks distinct from a wedged event loop. The first
|
|
937
|
+
// (priming) heartbeat write lives near the top of this handler — see the
|
|
938
|
+
// W-mpg3bcp800075d4f comment above mutateControl — so that the dashboard's
|
|
939
|
+
// 30s restart grace window sees a fresh heartbeat regardless of how long
|
|
940
|
+
// the boot reconcile chain takes.
|
|
929
941
|
const heartbeatTimer = setInterval(writeHeartbeatNow, HEARTBEAT_INTERVAL_MS);
|
|
930
942
|
|
|
931
943
|
// Fast poll: check steering every 1s (lightweight — just fs.stat per agent)
|
package/engine/lifecycle.js
CHANGED
|
@@ -1752,12 +1752,19 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
|
|
|
1752
1752
|
}
|
|
1753
1753
|
}
|
|
1754
1754
|
target.lastReviewedAt = ts();
|
|
1755
|
+
// W-mpg58wv3 — best-effort capture of thread ids the review cited. Used
|
|
1756
|
+
// downstream by buildPrDispatch when spawning the review-feedback fix WI
|
|
1757
|
+
// (meta.addresses_threads) so the closure-loop reaper can correlate. Accept
|
|
1758
|
+
// a few common shapes from the completion report (threads / addresses_threads
|
|
1759
|
+
// / thread_ids); fall through silently if none are present.
|
|
1760
|
+
const reviewThreads = extractReviewThreadIds(structuredCompletion);
|
|
1755
1761
|
target.minionsReview = {
|
|
1756
1762
|
reviewer: reviewerName,
|
|
1757
1763
|
reviewedAt: ts(),
|
|
1758
1764
|
note: reviewNote,
|
|
1759
1765
|
dispatchId: dispatchItem?.id || structuredCompletion?.dispatchId || null,
|
|
1760
1766
|
sourceItem: dispatchItem?.meta?.item?.id || null,
|
|
1767
|
+
...(reviewThreads.length > 0 ? { threads: reviewThreads } : {}),
|
|
1761
1768
|
// Preserve fixedAt across re-reviews so the poller guard knows a fix was pushed.
|
|
1762
1769
|
// Drop it when reviewer requests changes again — that starts a new fix cycle.
|
|
1763
1770
|
...(target.minionsReview?.fixedAt && postReviewStatus !== 'changes-requested' ? { fixedAt: target.minionsReview.fixedAt } : {}),
|
|
@@ -3212,6 +3219,37 @@ function reviewVerdictFromCompletion(completion) {
|
|
|
3212
3219
|
return normalizeReviewVerdict(completion.verdict || completion.review_verdict || completion.reviewVerdict);
|
|
3213
3220
|
}
|
|
3214
3221
|
|
|
3222
|
+
/**
|
|
3223
|
+
* W-mpg58wv3 — Best-effort extraction of ADO/GitHub thread ids the review cited.
|
|
3224
|
+
* Accepts a few common shapes the agent may emit on the structured completion:
|
|
3225
|
+
* - addresses_threads: [123, 456] (canonical, matches the meta key on the spawned fix WI)
|
|
3226
|
+
* - threads: [123, 456]
|
|
3227
|
+
* - thread_ids: ["123", "456"]
|
|
3228
|
+
* Scans nested `artifacts[]` entries too for `type: 'thread'` with a numeric id.
|
|
3229
|
+
* Returns a deduped numeric array; empty when no signal is present.
|
|
3230
|
+
*/
|
|
3231
|
+
function extractReviewThreadIds(completion) {
|
|
3232
|
+
if (!completion || typeof completion !== 'object') return [];
|
|
3233
|
+
const ids = new Set();
|
|
3234
|
+
const addCandidate = (value) => {
|
|
3235
|
+
if (value == null) return;
|
|
3236
|
+
if (Array.isArray(value)) { for (const v of value) addCandidate(v); return; }
|
|
3237
|
+
const n = parseInt(String(value).trim(), 10);
|
|
3238
|
+
if (Number.isFinite(n) && n > 0) ids.add(n);
|
|
3239
|
+
};
|
|
3240
|
+
addCandidate(completion.addresses_threads);
|
|
3241
|
+
addCandidate(completion.threads);
|
|
3242
|
+
addCandidate(completion.thread_ids);
|
|
3243
|
+
if (Array.isArray(completion.artifacts)) {
|
|
3244
|
+
for (const a of completion.artifacts) {
|
|
3245
|
+
if (a && typeof a === 'object' && (a.type === 'thread' || a.type === 'ado-thread')) {
|
|
3246
|
+
addCandidate(a.id ?? a.path ?? a.value);
|
|
3247
|
+
}
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
3250
|
+
return Array.from(ids);
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3215
3253
|
function reviewContentMatchesPr(content, pr, project) {
|
|
3216
3254
|
const text = String(content || '').trim();
|
|
3217
3255
|
if (!text) return false;
|
|
@@ -3511,6 +3549,136 @@ function handleDecompositionResult(stdout, meta, config, runtimeName) {
|
|
|
3511
3549
|
return 0;
|
|
3512
3550
|
}
|
|
3513
3551
|
|
|
3552
|
+
/**
|
|
3553
|
+
* W-mpg58wv3 — auto-dispatch a re-review WI when a fix-WI born from a minion
|
|
3554
|
+
* REQUEST_CHANGES marks done. Closure-loop for the shared Yemi reviewer slot:
|
|
3555
|
+
* without a re-review, the stale -5 vote sits on the PR indefinitely and
|
|
3556
|
+
* blocks auto-complete (live repro: ADO PR 5216166, 3 days stuck).
|
|
3557
|
+
*
|
|
3558
|
+
* Behavior:
|
|
3559
|
+
* - Returns silently when meta.addresses_review_wi is unset (this fix WI did
|
|
3560
|
+
* not originate from a minion REQUEST_CHANGES — nothing to close).
|
|
3561
|
+
* - Returns silently when the PR is no longer active, or when its current
|
|
3562
|
+
* reviewStatus is not in {changes-requested, waiting}. An already-approved
|
|
3563
|
+
* PR doesn't need a re-review.
|
|
3564
|
+
* - Idempotent: getPrDispatchDedupeKey + addToDispatch dedup already skips a
|
|
3565
|
+
* second review dispatch for the same (PR, type). No second WI ever lands.
|
|
3566
|
+
* - Soft agent preference: agents whose charter advertises code-review or
|
|
3567
|
+
* design-review skills are tried first via agentHints; resolveAgent falls
|
|
3568
|
+
* back to any idle reviewer (and finally a temp agent) without blocking
|
|
3569
|
+
* on a specific reviewer. The PR author is excluded under the self-review
|
|
3570
|
+
* ban. If even the deferred ANY_AGENT placeholder cannot be built, log and
|
|
3571
|
+
* skip — the existing discovery-driven re-review path (engine.js:~4249)
|
|
3572
|
+
* will re-evaluate on the next tick.
|
|
3573
|
+
* - All RMW on dispatch.json goes through addToDispatch (mutateDispatch), per
|
|
3574
|
+
* the concurrency rules in CLAUDE.md.
|
|
3575
|
+
*/
|
|
3576
|
+
function dispatchReReviewForFix(fixDispatchItem, meta, config) {
|
|
3577
|
+
const addressesReviewWi = meta?.addresses_review_wi || meta?.item?.meta?.addresses_review_wi || null;
|
|
3578
|
+
if (!addressesReviewWi) return null;
|
|
3579
|
+
const pr = meta?.pr;
|
|
3580
|
+
if (!pr?.id) return null;
|
|
3581
|
+
// Re-read live PR state so we don't queue a re-review against an already
|
|
3582
|
+
// merged/abandoned PR. The post-completion fix-update may have just flipped
|
|
3583
|
+
// reviewStatus to 'waiting'; consult that fresh state via the central read.
|
|
3584
|
+
const project = meta.project || null;
|
|
3585
|
+
let livePr = pr;
|
|
3586
|
+
try {
|
|
3587
|
+
const prPath = project ? shared.projectPrPath(project) : null;
|
|
3588
|
+
if (prPath) {
|
|
3589
|
+
const prs = shared.safeJsonArr(prPath);
|
|
3590
|
+
const target = shared.findPrRecord(prs, pr, project);
|
|
3591
|
+
if (target) livePr = target;
|
|
3592
|
+
}
|
|
3593
|
+
} catch { /* fall through to dispatch meta copy */ }
|
|
3594
|
+
if (livePr.status && livePr.status !== PR_STATUS.ACTIVE) {
|
|
3595
|
+
log('info', `Re-review skipped for ${pr.id}: PR status is ${livePr.status}`);
|
|
3596
|
+
return null;
|
|
3597
|
+
}
|
|
3598
|
+
const reviewStatus = livePr.reviewStatus || '';
|
|
3599
|
+
if (reviewStatus !== 'changes-requested' && reviewStatus !== 'waiting') {
|
|
3600
|
+
log('info', `Re-review skipped for ${pr.id}: reviewStatus=${reviewStatus || 'pending'} (only changes-requested/waiting trigger closure-loop)`);
|
|
3601
|
+
return null;
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
const routing = require('./routing');
|
|
3605
|
+
const playbook = require('./playbook');
|
|
3606
|
+
const dispatchModule = require('./dispatch');
|
|
3607
|
+
|
|
3608
|
+
const reviewAuthor = livePr.agent || pr.agent || null;
|
|
3609
|
+
const agentHints = pickReReviewAgentHints(config, { excludeAgent: reviewAuthor });
|
|
3610
|
+
const resolveOpts = reviewAuthor ? { excludeAgent: reviewAuthor, agentHints } : { agentHints };
|
|
3611
|
+
const agentId = routing.resolveAgent('review', config, resolveOpts);
|
|
3612
|
+
const prNumber = shared.getPrNumber(livePr);
|
|
3613
|
+
const prBranch = livePr.branch || livePr._branch || meta.branch || '';
|
|
3614
|
+
const projMeta = project;
|
|
3615
|
+
const headSha = String(livePr.headSha || livePr._adoSourceCommit || livePr._adoHeadCommit || pr.headSha || '').trim();
|
|
3616
|
+
const reReviewKey = `rereview-${project?.name || 'default'}-${shared.getPrDisplayId ? shared.getPrDisplayId(livePr) : pr.id}-${headSha ? headSha.slice(0, 8) : 'nohead'}`;
|
|
3617
|
+
const rereviewMeta = {
|
|
3618
|
+
dispatchKey: reReviewKey,
|
|
3619
|
+
source: 'pr',
|
|
3620
|
+
pr: livePr,
|
|
3621
|
+
branch: prBranch,
|
|
3622
|
+
project: projMeta,
|
|
3623
|
+
rereview_of: fixDispatchItem?.id || null,
|
|
3624
|
+
addresses_review_wi: addressesReviewWi,
|
|
3625
|
+
};
|
|
3626
|
+
const extraVars = {
|
|
3627
|
+
pr_id: livePr.id, pr_number: prNumber, pr_title: livePr.title || '', pr_branch: prBranch,
|
|
3628
|
+
pr_author: reviewAuthor || '', pr_url: livePr.url || '',
|
|
3629
|
+
};
|
|
3630
|
+
if (!agentId) {
|
|
3631
|
+
rereviewMeta.deferReviewerResolution = true;
|
|
3632
|
+
const deferred = playbook.buildPrDispatch(routing.ANY_AGENT, config, project, livePr, 'review', extraVars,
|
|
3633
|
+
`Re-review ${livePr.id}: ${livePr.title || ''} — auto-closure after fix ${fixDispatchItem?.id || ''}`,
|
|
3634
|
+
rereviewMeta);
|
|
3635
|
+
if (!deferred) {
|
|
3636
|
+
log('warn', `Re-review build failed for ${pr.id}: buildPrDispatch returned null (missing playbook vars?)`);
|
|
3637
|
+
return null;
|
|
3638
|
+
}
|
|
3639
|
+
deferred._pendingReason = 'no_non_author_reviewer';
|
|
3640
|
+
const dispatchId = dispatchModule.addToDispatch(deferred);
|
|
3641
|
+
if (dispatchId) {
|
|
3642
|
+
log('info', `Re-review queued (deferred — no non-author reviewer) for ${pr.id} after fix ${fixDispatchItem?.id || ''}: dispatch=${dispatchId}`);
|
|
3643
|
+
}
|
|
3644
|
+
return dispatchId;
|
|
3645
|
+
}
|
|
3646
|
+
const item = playbook.buildPrDispatch(agentId, config, project, livePr, 'review', extraVars,
|
|
3647
|
+
`Re-review ${livePr.id}: ${livePr.title || ''} — auto-closure after fix ${fixDispatchItem?.id || ''}`,
|
|
3648
|
+
rereviewMeta);
|
|
3649
|
+
if (!item) {
|
|
3650
|
+
log('warn', `Re-review build failed for ${pr.id}: buildPrDispatch returned null (missing playbook vars?)`);
|
|
3651
|
+
return null;
|
|
3652
|
+
}
|
|
3653
|
+
const dispatchId = dispatchModule.addToDispatch(item);
|
|
3654
|
+
if (dispatchId) {
|
|
3655
|
+
log('info', `Re-review queued for ${pr.id} after fix ${fixDispatchItem?.id || ''}: dispatch=${dispatchId} agent=${agentId}`);
|
|
3656
|
+
}
|
|
3657
|
+
return dispatchId;
|
|
3658
|
+
}
|
|
3659
|
+
|
|
3660
|
+
/**
|
|
3661
|
+
* W-mpg58wv3 — Soft preference picker for re-review agent assignment. Returns
|
|
3662
|
+
* a list of agent IDs whose configured skills include any of
|
|
3663
|
+
* REVIEW_SKILL_TAGS; the caller passes this to resolveAgent as `agentHints`,
|
|
3664
|
+
* which tries each in order before falling through to the default routing
|
|
3665
|
+
* path. Empty list when no configured agent has a review skill — the standard
|
|
3666
|
+
* review route still wins, so no PR ever blocks waiting for a specific agent.
|
|
3667
|
+
*/
|
|
3668
|
+
const REVIEW_SKILL_TAGS = ['code-review', 'design-review'];
|
|
3669
|
+
function pickReReviewAgentHints(config, opts = {}) {
|
|
3670
|
+
const agents = config?.agents || {};
|
|
3671
|
+
const excludeKey = opts.excludeAgent ? String(opts.excludeAgent).toLowerCase() : null;
|
|
3672
|
+
const out = [];
|
|
3673
|
+
for (const [id, agent] of Object.entries(agents)) {
|
|
3674
|
+
if (excludeKey && String(id).toLowerCase() === excludeKey) continue;
|
|
3675
|
+
const skills = Array.isArray(agent?.skills) ? agent.skills : [];
|
|
3676
|
+
const lowered = skills.map(s => String(s || '').toLowerCase());
|
|
3677
|
+
if (REVIEW_SKILL_TAGS.some(tag => lowered.includes(tag))) out.push(id);
|
|
3678
|
+
}
|
|
3679
|
+
return out;
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3514
3682
|
async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config, opts) {
|
|
3515
3683
|
|
|
3516
3684
|
const detectPhantom = !!(opts && opts.detectPhantom);
|
|
@@ -4146,6 +4314,19 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
4146
4314
|
config,
|
|
4147
4315
|
noopReason: noopRationale || meta?._noopReason || '',
|
|
4148
4316
|
});
|
|
4317
|
+
// W-mpg58wv3 — closure-loop dispatch. When the completed fix WI was spawned
|
|
4318
|
+
// to address a minion REQUEST_CHANGES (its meta carries
|
|
4319
|
+
// addresses_review_wi), queue a re-review WI for the same PR so the Yemi
|
|
4320
|
+
// reviewer slot can't sit at -5 indefinitely. Idempotent via the PR-target
|
|
4321
|
+
// dedup in addToDispatch; agent assignment is a soft preference (review-
|
|
4322
|
+
// skilled agents first, then any idle agent) — NEVER block on a specific
|
|
4323
|
+
// reviewer. See engine/ado.js:resetReviewerNegativeVote for the verdict-
|
|
4324
|
+
// flip reset that fires on the resulting APPROVE.
|
|
4325
|
+
try {
|
|
4326
|
+
dispatchReReviewForFix(dispatchItem, meta, config);
|
|
4327
|
+
} catch (err) {
|
|
4328
|
+
log('warn', `Re-review auto-dispatch for ${meta?.pr?.id || 'unknown PR'}: ${err.message}`);
|
|
4329
|
+
}
|
|
4149
4330
|
// (#984) Sync PRD status for PR-linked features: fix work items have a different ID
|
|
4150
4331
|
// than the original PRD feature, so syncPrdItemStatus(fixWiId, ...) finds nothing.
|
|
4151
4332
|
// Use the PR's prdItems to propagate done status when the original work item is done.
|
|
@@ -4386,4 +4567,9 @@ module.exports = {
|
|
|
4386
4567
|
extractDecompositionJson,
|
|
4387
4568
|
handleDecompositionResult,
|
|
4388
4569
|
processCompletionFollowups,
|
|
4570
|
+
// W-mpg58wv3 — closure-loop dispatch helpers (exported for testing).
|
|
4571
|
+
dispatchReReviewForFix,
|
|
4572
|
+
pickReReviewAgentHints,
|
|
4573
|
+
extractReviewThreadIds,
|
|
4574
|
+
REVIEW_SKILL_TAGS,
|
|
4389
4575
|
};
|
package/engine/queries.js
CHANGED
|
@@ -1633,9 +1633,13 @@ function resetPrdInfoCache() {
|
|
|
1633
1633
|
// the second-Map sync hazard.
|
|
1634
1634
|
const _projectGitStatusCache = new Map();
|
|
1635
1635
|
const PROJECT_GIT_STATUS_TTL = 300000; // 5 minutes
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1636
|
+
// Default null comparator fields so callers can blindly spread the result into
|
|
1637
|
+
// the /api/status payload without per-state branching (W-mpg3whgp000d09ec).
|
|
1638
|
+
// `remoteDefaultBranch` / `ahead` / `behind` are populated only by the `ok`
|
|
1639
|
+
// probe path; the placeholders always serialize as null.
|
|
1640
|
+
const PROJECT_GIT_STATUS_PENDING = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'pending', remoteDefaultBranch: null, ahead: null, behind: null });
|
|
1641
|
+
const PROJECT_GIT_STATUS_MISSING = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing', remoteDefaultBranch: null, ahead: null, behind: null });
|
|
1642
|
+
const PROJECT_GIT_STATUS_NON_GIT = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'non-git', remoteDefaultBranch: null, ahead: null, behind: null });
|
|
1639
1643
|
|
|
1640
1644
|
// Async git invocation. Promise-returning so getProjectGitStatus can fire
|
|
1641
1645
|
// background refreshes without blocking the event loop. Pipes stderr to keep
|
|
@@ -1658,7 +1662,13 @@ function _gitExec(localPath, args) {
|
|
|
1658
1662
|
// Probe a single project. Returns the resolved status value. Used both by the
|
|
1659
1663
|
// background refresh path inside getProjectGitStatus and by test code that
|
|
1660
1664
|
// wants to drive a probe to completion deterministically.
|
|
1661
|
-
|
|
1665
|
+
//
|
|
1666
|
+
// `configuredMainBranch` (W-mpg3whgp000d09ec) — when supplied, the probe
|
|
1667
|
+
// computes ahead/behind counts of HEAD vs `origin/<configuredMainBranch>`.
|
|
1668
|
+
// When omitted, the probe falls back to the auto-detected `remoteDefaultBranch`
|
|
1669
|
+
// for the comparator. Pass null/undefined to skip ahead/behind entirely when
|
|
1670
|
+
// no comparator is resolvable.
|
|
1671
|
+
async function _probeProjectGitStatus(localPath, configuredMainBranch) {
|
|
1662
1672
|
try {
|
|
1663
1673
|
if (!fs.existsSync(localPath)) return PROJECT_GIT_STATUS_MISSING;
|
|
1664
1674
|
let isRepo = false;
|
|
@@ -1682,18 +1692,49 @@ async function _probeProjectGitStatus(localPath) {
|
|
|
1682
1692
|
const status = await _gitExec(localPath, ['status', '--porcelain', '--untracked-files=no']);
|
|
1683
1693
|
dirty = status.length > 0;
|
|
1684
1694
|
} catch { dirty = false; }
|
|
1685
|
-
|
|
1695
|
+
// Remote default branch: `git symbolic-ref --quiet --short refs/remotes/origin/HEAD`
|
|
1696
|
+
// returns `origin/<branch>`. Strip the `origin/` prefix. Null when the
|
|
1697
|
+
// remote HEAD ref isn't set (e.g. after a shallow clone or a freshly-added
|
|
1698
|
+
// remote without `git remote set-head origin --auto`).
|
|
1699
|
+
let remoteDefaultBranch = null;
|
|
1700
|
+
try {
|
|
1701
|
+
const raw = (await _gitExec(localPath, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])).trim();
|
|
1702
|
+
if (raw.startsWith('origin/')) remoteDefaultBranch = raw.slice('origin/'.length) || null;
|
|
1703
|
+
else if (raw) remoteDefaultBranch = raw;
|
|
1704
|
+
} catch { remoteDefaultBranch = null; }
|
|
1705
|
+
// Ahead/behind vs origin/<comparator>. Comparator picks the configured
|
|
1706
|
+
// mainBranch first (since the operator explicitly chose it) and falls
|
|
1707
|
+
// back to the remote default. Skip when no comparator is resolvable.
|
|
1708
|
+
// Compare HEAD (not `branch`) so detached-HEAD probes still work and we
|
|
1709
|
+
// never have to escape branch names with slashes (e.g. `release/2.0`).
|
|
1710
|
+
let ahead = null;
|
|
1711
|
+
let behind = null;
|
|
1712
|
+
const comparator = (configuredMainBranch && String(configuredMainBranch).trim()) || remoteDefaultBranch;
|
|
1713
|
+
if (comparator) {
|
|
1714
|
+
try {
|
|
1715
|
+
const out = (await _gitExec(localPath, ['rev-list', '--left-right', '--count', `HEAD...origin/${comparator}`])).trim();
|
|
1716
|
+
// Output format: "<ahead>\t<behind>". Tab-separated.
|
|
1717
|
+
const parts = out.split(/\s+/);
|
|
1718
|
+
if (parts.length >= 2) {
|
|
1719
|
+
const a = Number.parseInt(parts[0], 10);
|
|
1720
|
+
const b = Number.parseInt(parts[1], 10);
|
|
1721
|
+
if (Number.isFinite(a)) ahead = a;
|
|
1722
|
+
if (Number.isFinite(b)) behind = b;
|
|
1723
|
+
}
|
|
1724
|
+
} catch { /* origin/<comparator> missing — ahead/behind stay null */ }
|
|
1725
|
+
}
|
|
1726
|
+
return { gitBranch: branch, gitDetached: detached, gitDirty: dirty, gitState: 'ok', remoteDefaultBranch, ahead, behind };
|
|
1686
1727
|
} catch {
|
|
1687
1728
|
// Defensive — never let a git probe failure escape this helper.
|
|
1688
1729
|
return PROJECT_GIT_STATUS_MISSING;
|
|
1689
1730
|
}
|
|
1690
1731
|
}
|
|
1691
1732
|
|
|
1692
|
-
function _scheduleProjectGitStatusRefresh(localPath, key) {
|
|
1733
|
+
function _scheduleProjectGitStatusRefresh(localPath, key, configuredMainBranch) {
|
|
1693
1734
|
const existing = _projectGitStatusCache.get(key);
|
|
1694
1735
|
if (existing && existing.promise) return existing.promise;
|
|
1695
1736
|
const entry = existing || { ts: 0, value: PROJECT_GIT_STATUS_PENDING, promise: null };
|
|
1696
|
-
entry.promise = _probeProjectGitStatus(localPath).then(value => {
|
|
1737
|
+
entry.promise = _probeProjectGitStatus(localPath, configuredMainBranch).then(value => {
|
|
1697
1738
|
entry.ts = Date.now();
|
|
1698
1739
|
entry.value = value;
|
|
1699
1740
|
entry.promise = null;
|
|
@@ -1706,9 +1747,19 @@ function _scheduleProjectGitStatusRefresh(localPath, key) {
|
|
|
1706
1747
|
return entry.promise;
|
|
1707
1748
|
}
|
|
1708
1749
|
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1750
|
+
// Cache key combines localPath + comparator so a project whose configured
|
|
1751
|
+
// mainBranch changes (e.g. operator edits config.json from `main` → `master`)
|
|
1752
|
+
// gets fresh ahead/behind counts on the next read instead of waiting out the
|
|
1753
|
+
// TTL on stale comparator output.
|
|
1754
|
+
function _projectGitStatusCacheKey(localPath, configuredMainBranch) {
|
|
1755
|
+
const norm = String(localPath || '').replace(/\\/g, '/');
|
|
1756
|
+
return norm + '::' + (configuredMainBranch ? String(configuredMainBranch).trim() : '');
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
function getProjectGitStatus(localPath, configuredMainBranch = null) {
|
|
1760
|
+
const norm = String(localPath || '').replace(/\\/g, '/');
|
|
1761
|
+
if (!norm) return PROJECT_GIT_STATUS_MISSING;
|
|
1762
|
+
const key = _projectGitStatusCacheKey(localPath, configuredMainBranch);
|
|
1712
1763
|
const now = Date.now();
|
|
1713
1764
|
const cached = _projectGitStatusCache.get(key);
|
|
1714
1765
|
if (cached && cached.ts && (now - cached.ts) < PROJECT_GIT_STATUS_TTL) return cached.value;
|
|
@@ -1721,7 +1772,7 @@ function getProjectGitStatus(localPath) {
|
|
|
1721
1772
|
// Stale or never-populated — kick off a background refresh and return the
|
|
1722
1773
|
// previous value (or pending placeholder on the very first call). The next
|
|
1723
1774
|
// /api/status response after the refresh settles will have fresh data.
|
|
1724
|
-
_scheduleProjectGitStatusRefresh(localPath, key);
|
|
1775
|
+
_scheduleProjectGitStatusRefresh(localPath, key, configuredMainBranch);
|
|
1725
1776
|
return cached ? cached.value : PROJECT_GIT_STATUS_PENDING;
|
|
1726
1777
|
}
|
|
1727
1778
|
|
|
@@ -1729,10 +1780,11 @@ function getProjectGitStatus(localPath) {
|
|
|
1729
1780
|
// pre-warm the cache for every configured project so the first /api/status
|
|
1730
1781
|
// after restart already has data. Also used by tests to settle the async
|
|
1731
1782
|
// probe deterministically.
|
|
1732
|
-
function warmProjectGitStatus(localPath) {
|
|
1733
|
-
const
|
|
1734
|
-
if (!
|
|
1735
|
-
|
|
1783
|
+
function warmProjectGitStatus(localPath, configuredMainBranch = null) {
|
|
1784
|
+
const norm = String(localPath || '').replace(/\\/g, '/');
|
|
1785
|
+
if (!norm) return Promise.resolve();
|
|
1786
|
+
const key = _projectGitStatusCacheKey(localPath, configuredMainBranch);
|
|
1787
|
+
return _scheduleProjectGitStatusRefresh(localPath, key, configuredMainBranch);
|
|
1736
1788
|
}
|
|
1737
1789
|
|
|
1738
1790
|
// Wait for every in-flight probe to settle. Test helper.
|
package/engine.js
CHANGED
|
@@ -4325,7 +4325,16 @@ async function discoverFromPrs(config, project) {
|
|
|
4325
4325
|
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
4326
4326
|
pr_id: pr.id, pr_branch: prBranch,
|
|
4327
4327
|
review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
|
|
4328
|
-
}, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, {
|
|
4328
|
+
}, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, {
|
|
4329
|
+
dispatchKey: key, automationCauseKey: reviewCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta,
|
|
4330
|
+
// W-mpg58wv3 — closure-loop binding. Carries the originating minion review
|
|
4331
|
+
// WI id (and any ADO thread ids it cited) onto the fix WI so the
|
|
4332
|
+
// post-completion path in lifecycle.js can auto-dispatch a re-review
|
|
4333
|
+
// against the same PR. Both fields fall through to null/[] when the
|
|
4334
|
+
// upstream review didn't expose them.
|
|
4335
|
+
addresses_review_wi: pr.minionsReview?.sourceItem || null,
|
|
4336
|
+
addresses_threads: Array.isArray(pr.minionsReview?.threads) ? pr.minionsReview.threads.slice() : [],
|
|
4337
|
+
});
|
|
4329
4338
|
if (item) {
|
|
4330
4339
|
newWork.push(item); setCooldown(key); fixDispatched = true;
|
|
4331
4340
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2009",
|
|
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
|
@@ -70,6 +70,8 @@ You MUST post a review comment with a clear verdict and write the completion rep
|
|
|
70
70
|
|
|
71
71
|
> If you are flipping a prior `REQUEST_CHANGES` verdict to `APPROVE` on re-review, the engine automatically clears your prior negative vote (ADO `set-vote --vote reset`-equivalent) or dismisses your prior `CHANGES_REQUESTED` review (GitHub) so humans don't see a stale red badge — you don't need to reset it manually. (W-mp7b1g8q000fea45)
|
|
72
72
|
|
|
73
|
+
> When a fix-WI born from your `REQUEST_CHANGES` verdict completes, the engine auto-dispatches a re-review WI against the same PR (carrying `meta.rereview_of: <fix WI id>` and `meta.addresses_review_wi: <your review WI id>`). You do NOT need to re-dispatch yourself — the closure-loop is engine-driven. (W-mpg58wv3 / #2739)
|
|
74
|
+
|
|
73
75
|
Your review body **MUST** start with one of these verdict lines (exactly as shown):
|
|
74
76
|
- `VERDICT: APPROVE` — if the code is ready to merge
|
|
75
77
|
- `VERDICT: REQUEST_CHANGES` — if there are issues that must be fixed
|