@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.
@@ -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 titleAttr = ' title="on: ' + branch + (p.gitDetached ? ' (detached)' : '') + (p.gitDirty ? ' (dirty)' : '') + '"';
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
- if (p.gitDetached) {
37
- return '<span class="project-branch"' + titleAttr + '>on: ' + branch + ' <span class="muted">(detached)</span>' + dirty + '</span>';
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) {
@@ -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
- '<div style="display:flex;flex-direction:column;gap:6px">' +
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 }
@@ -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
- name: p.name,
1643
- path: p.localPath,
1644
- description: p.description || '',
1645
- ...getProjectGitStatus(p.localPath),
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
- writeHeartbeatNow(); // prime control.heartbeat immediately
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)
@@ -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
- const PROJECT_GIT_STATUS_PENDING = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'pending' });
1637
- const PROJECT_GIT_STATUS_MISSING = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' });
1638
- const PROJECT_GIT_STATUS_NON_GIT = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'non-git' });
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
- async function _probeProjectGitStatus(localPath) {
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
- return { gitBranch: branch, gitDetached: detached, gitDirty: dirty, gitState: 'ok' };
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
- function getProjectGitStatus(localPath) {
1710
- const key = String(localPath || '').replace(/\\/g, '/');
1711
- if (!key) return PROJECT_GIT_STATUS_MISSING;
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 key = String(localPath || '').replace(/\\/g, '/');
1734
- if (!key) return Promise.resolve();
1735
- return _scheduleProjectGitStatusRefresh(localPath, key);
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`, { dispatchKey: key, automationCauseKey: reviewCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta });
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.2007",
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"
@@ -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