@yemi33/minions 0.1.2008 → 0.1.2010

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>';
@@ -219,8 +249,9 @@ async function openSettings() {
219
249
  '<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">' +
220
250
  settingsToggle('Claude bare mode', 'set-claudeBareMode', !!e.claudeBareMode, '--bare suppresses CLAUDE.md auto-discovery; pair with explicit ccSystemPrompt or context will be lost') +
221
251
  '</div>' +
222
- '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px">' +
223
- settingsField('Claude fallback model', 'set-claudeFallbackModel', e.claudeFallbackModel || '', '', 'Used by --fallback-model on rate-limit / overload (Claude only)') +
252
+ '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:8px">' +
253
+ settingsField('Claude fallback model', 'set-claudeFallbackModel', e.claudeFallbackModel || '', 'e.g. sonnet', 'Passed via Claude --fallback-model. The Claude CLI swaps to this only on rate-limit (429). Used together with engine.copilotFallbackModel for the engine-level MODEL_UNAVAILABLE (overloaded/503) retry — see CLAUDE.md.') +
254
+ settingsField('Copilot fallback model', 'set-copilotFallbackModel', e.copilotFallbackModel || '', 'e.g. gpt-5.4', 'Copilot has no --fallback-model flag. On a MODEL_UNAVAILABLE (overloaded/503) retry, the engine OVERRIDES --model with this value (Copilot only).') +
224
255
  settingsField('Max budget (USD)', 'set-maxBudgetUsd', e.maxBudgetUsd != null ? String(e.maxBudgetUsd) : '', '', 'Fleet ceiling for --max-budget-usd. 0 is a valid cap (read-only / dry-run). Empty = no cap. Claude only.') +
225
256
  '</div>' +
226
257
  '<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">' +
@@ -597,6 +628,7 @@ async function saveSettings() {
597
628
  ccEffort: document.getElementById('set-ccEffort').value || null,
598
629
  claudeBareMode: !!document.getElementById('set-claudeBareMode')?.checked,
599
630
  claudeFallbackModel: (document.getElementById('set-claudeFallbackModel')?.value ?? '').trim(),
631
+ copilotFallbackModel: (document.getElementById('set-copilotFallbackModel')?.value ?? '').trim(),
600
632
  copilotDisableBuiltinMcps: !!document.getElementById('set-copilotDisableBuiltinMcps')?.checked,
601
633
  copilotSuppressAgentsMd: !!document.getElementById('set-copilotSuppressAgentsMd')?.checked,
602
634
  copilotStreamMode: document.getElementById('set-copilotStreamMode')?.value || 'on',
@@ -628,8 +660,13 @@ async function saveSettings() {
628
660
 
629
661
  const currentProjects = (_settingsData && Array.isArray(_settingsData.projects)) ? _settingsData.projects : [];
630
662
  const projectsPayload = currentProjects.map(function(p) {
663
+ // W-mpg3whgp000d09ec / #2732 — mainBranch is now editable from Settings →
664
+ // Projects. Empty string = clear the override; the field stays optional.
665
+ const mainBranchInput = document.getElementById('set-mainBranch-' + p.name);
666
+ const mainBranchValue = mainBranchInput ? mainBranchInput.value.trim() : (p.mainBranch || '');
631
667
  return {
632
668
  name: p.name,
669
+ mainBranch: mainBranchValue || null,
633
670
  workSources: {
634
671
  pullRequests: { enabled: document.getElementById('set-ws-prs-' + p.name)?.checked ?? true },
635
672
  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 }
@@ -8291,6 +8308,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8291
8308
  if (_isClear(e.claudeFallbackModel)) _deleteEngineConfig('claudeFallbackModel');
8292
8309
  else _setEngineConfig('claudeFallbackModel', String(e.claudeFallbackModel));
8293
8310
  }
8311
+ // W-mpg6isvy000xca4d — Copilot fallback model. Mirrors the
8312
+ // claudeFallbackModel handler above. Empty / null clears; any string
8313
+ // value is stored verbatim. The dispatch retry path (engine.js
8314
+ // spawnAgent) reads engine.copilotFallbackModel when the previous
8315
+ // failure was FAILURE_CLASS.MODEL_UNAVAILABLE and the runtime has
8316
+ // capabilities.fallbackModel === false (Copilot has no --fallback-model
8317
+ // flag, so we override --model directly).
8318
+ if (e.copilotFallbackModel !== undefined) {
8319
+ if (_isClear(e.copilotFallbackModel)) _deleteEngineConfig('copilotFallbackModel');
8320
+ else _setEngineConfig('copilotFallbackModel', String(e.copilotFallbackModel));
8321
+ }
8294
8322
  if (e.copilotStreamMode !== undefined) {
8295
8323
  const valid = ['on', 'off'];
8296
8324
  if (_isClear(e.copilotStreamMode)) _deleteEngineConfig('copilotStreamMode');
@@ -8444,6 +8472,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8444
8472
  if (update.workSources.workItems.enabled !== undefined)
8445
8473
  proj.workSources.workItems.enabled = !!update.workSources.workItems.enabled;
8446
8474
  }
8475
+ // W-mpg3whgp000d09ec / #2732 — operator-editable mainBranch.
8476
+ // Empty string / null clears the override (engine falls back to
8477
+ // origin/HEAD auto-detection); any other string trims + pins.
8478
+ if (Object.prototype.hasOwnProperty.call(update, 'mainBranch')) {
8479
+ const raw = update.mainBranch == null ? '' : String(update.mainBranch).trim();
8480
+ if (raw) proj.mainBranch = raw;
8481
+ else delete proj.mainBranch;
8482
+ }
8447
8483
  }
8448
8484
  }
8449
8485
 
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
  };
@@ -654,6 +654,12 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
654
654
  wi.status = WI_STATUS.PENDING;
655
655
  wi._lastRetryReason = reason || '';
656
656
  wi._lastRetryAt = ts();
657
+ // W-mpg6isvy000xca4d — surface the previous failure class so the
658
+ // next spawn can swap in a runtime-appropriate fallback model
659
+ // when the previous attempt was bounced for MODEL_UNAVAILABLE
660
+ // (overloaded_error / 503). Empty string clears any stale
661
+ // value from an earlier failure cycle.
662
+ wi._lastFailureClass = failureClass || '';
657
663
  delete wi.failReason;
658
664
  delete wi.failedAt;
659
665
  delete wi.dispatched_at;
@@ -683,6 +689,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
683
689
  [FAILURE_CLASS.INVALID_MANAGED_SPAWN]: 'managed-spawn.json failed validation (bad schema, workdir, or allowlist — see inbox alert)',
684
690
  [FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED]: 'managed-spawn spec(s) failed healthcheck within timeout (failing PIDs killed; surviving siblings stay alive)',
685
691
  [FAILURE_CLASS.INJECTION_FLAGGED]: 'agent flagged a prompt-injection attempt in spliced untrusted content — human review of the listed sources required before re-dispatch',
692
+ [FAILURE_CLASS.MODEL_UNAVAILABLE]: 'requested model returned overloaded/503 — fallback model swapped in for retry',
686
693
  [FAILURE_CLASS.UNKNOWN]: 'unknown error',
687
694
  };
688
695
  const classLabel = failureClass ? (CLASS_LABELS[failureClass] || failureClass) : '';
@@ -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.
@@ -412,6 +412,7 @@ function usesSystemPromptFile({ isResume } = {}) {
412
412
  function _runtimeFailureClass(code) {
413
413
  if (code === 'auth-failure' || code === 'budget-exceeded') return FAILURE_CLASS.PERMISSION_BLOCKED;
414
414
  if (code === 'context-limit') return FAILURE_CLASS.OUT_OF_CONTEXT;
415
+ if (code === 'model-unavailable') return FAILURE_CLASS.MODEL_UNAVAILABLE;
415
416
  if (code === 'crash') return FAILURE_CLASS.SPAWN_ERROR;
416
417
  return null;
417
418
  }
@@ -552,6 +553,16 @@ function parseError(rawOutput) {
552
553
  if (/budget.*exceed|max.budget.usd.*reach|cost.*limit.*exceed/i.test(lower)) {
553
554
  return { message: 'Claude budget cap exceeded — check your Claude account spending limit.', code: 'budget-exceeded', retriable: false };
554
555
  }
556
+ // W-mpg6isvy000xca4d — Anthropic overload / 503 / service-unavailable. Claude's
557
+ // own `--fallback-model` only fires on 429 (rate-limit); these failure modes
558
+ // hang the agent until the 5h timeout. Classify as MODEL_UNAVAILABLE so the
559
+ // dispatch loop retries with the runtime-appropriate fallback model. Match
560
+ // before the crash branch — Anthropic's overloaded responses can include
561
+ // "internal error" / "panic"-style phrasing that would otherwise be misread
562
+ // as a CLI crash.
563
+ if (/overloaded_error|service[_ ]unavailable|model.*(?:unavailable|overloaded)|\b503\b|temporarily unavailable/i.test(text)) {
564
+ return { message: 'Claude model temporarily unavailable (overloaded / 503)', code: 'model-unavailable', retriable: true };
565
+ }
555
566
  if (/internal error|panic|segmentation fault|claude.*crashed|fatal: claude/i.test(lower)) {
556
567
  return { message: 'Claude CLI crashed unexpectedly. Try again.', code: 'crash', retriable: true };
557
568
  }
@@ -602,6 +602,7 @@ function usesSystemPromptFile() {
602
602
  function _runtimeFailureClass(code) {
603
603
  if (code === 'auth-failure' || code === 'budget-exceeded') return FAILURE_CLASS.PERMISSION_BLOCKED;
604
604
  if (code === 'unknown-model') return FAILURE_CLASS.CONFIG_ERROR;
605
+ if (code === 'model-unavailable') return FAILURE_CLASS.MODEL_UNAVAILABLE;
605
606
  if (code === 'rate-limit') return FAILURE_CLASS.NETWORK_ERROR;
606
607
  if (code === 'crash') return FAILURE_CLASS.SPAWN_ERROR;
607
608
  return null;
@@ -844,6 +845,14 @@ function parseError(rawOutput) {
844
845
  if (hasExplicitAuthFailure || hasAuthStatusCode) {
845
846
  return { message: text, code: 'auth-failure', retriable: false };
846
847
  }
848
+ // W-mpg6isvy000xca4d — Copilot has no --fallback-model flag; classify
849
+ // overloaded / 503 / service_unavailable as MODEL_UNAVAILABLE so the engine
850
+ // retry can OVERRIDE --model with engine.copilotFallbackModel. Match before
851
+ // rate-limit so 503/overload never gets misread as a 429 (which would
852
+ // bucket into NETWORK_ERROR and re-spawn against the same broken model).
853
+ if (/overloaded_error|service[_ ]unavailable|model.*(?:unavailable|overloaded)|\b503\b|temporarily unavailable/i.test(text)) {
854
+ return { message: text, code: 'model-unavailable', retriable: true };
855
+ }
847
856
  if (/rate limit|too many requests|\b429\b/i.test(text)) {
848
857
  return { message: text, code: 'rate-limit', retriable: true };
849
858
  }
package/engine/shared.js CHANGED
@@ -1764,7 +1764,8 @@ const ENGINE_DEFAULTS = {
1764
1764
  ccCli: undefined, // CC/doc-chat CLI override; undefined = inherit defaultCli (independent of agent path)
1765
1765
  ccModel: undefined, // CC/doc-chat model override; undefined = inherit defaultModel
1766
1766
  claudeBareMode: false, // Claude --bare: suppress CLAUDE.md auto-discovery (per-agent override: agents.<id>.bareMode)
1767
- claudeFallbackModel: undefined,// Claude --fallback-model on rate-limit / overload (Claude-only)
1767
+ claudeFallbackModel: undefined,// Claude --fallback-model — Claude CLI honors this on rate-limit (429) only; engine retry on FAILURE_CLASS.MODEL_UNAVAILABLE keeps the flag passed so the CLI can swap internally
1768
+ copilotFallbackModel: undefined,// W-mpg6isvy000xca4d: Copilot has no --fallback-model flag; engine retry on FAILURE_CLASS.MODEL_UNAVAILABLE OVERRIDES --model directly with this value (separate knob from claudeFallbackModel because model namespaces differ across runtimes)
1768
1769
  copilotDisableBuiltinMcps: true, // Copilot --disable-builtin-mcps: keep github-mcp-server out so it can't bypass pull-requests.json tracking
1769
1770
  copilotSuppressAgentsMd: true, // Copilot --no-custom-instructions: stop AGENTS.md auto-load from fighting Minions playbook prompts
1770
1771
  copilotStreamMode: 'on', // Copilot --stream <on|off>: 'on' streams assistant.message_delta events live; 'off' batches them
@@ -2606,6 +2607,7 @@ const FAILURE_CLASS = {
2606
2607
  INVALID_MANAGED_SPAWN: 'invalid-managed-spawn', // P-7a3b1c92: agents/<id>/managed-spawn.json failed validator (bad schema, broken workdir, executable/env not on allowlist, healthcheck shape wrong). Engine refuses to spawn any spec — agent must fix file; never retryable as-is.
2607
2608
  MANAGED_SPAWN_HEALTHCHECK_FAILED: 'managed-spawn-healthcheck-failed', // P-7a3b1c92: at least one managed-spawn spec was spawned but failed its healthcheck within timeout_s. Engine killed the failing PIDs; siblings stay alive. Dispatch ERROR with the failing spec name + log tail surfaced in the inbox alert.
2608
2609
  INJECTION_FLAGGED: 'injection-flagged', // F5 (W-mpeklod3000we69c): the agent set `securityFlags.injectionAttempt:true` in its completion report after spotting a prompt-injection attempt inside an <UNTRUSTED-INPUT> fence. Engine writes a security inbox note + stamps `_securityFlag` on the WI and treats the dispatch as non-retryable so a human can review the source before the agent re-runs.
2610
+ MODEL_UNAVAILABLE: 'model-unavailable', // W-mpg6isvy000xca4d: requested model returned overloaded_error / 503 / service_unavailable. Retriable — engine swaps in the runtime-appropriate fallback model on next spawn (Claude leans on --fallback-model already plumbed; Copilot overrides --model with engine.copilotFallbackModel).
2609
2611
  UNKNOWN: 'unknown', // Unclassified failure
2610
2612
  };
2611
2613
  const ESCALATION_POLICY = {
package/engine.js CHANGED
@@ -148,6 +148,12 @@ const { renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS,
148
148
  buildBaseVars, buildPrDispatch, resolveTaskContext,
149
149
  getRepoHost, getRepoHostLabel, getRepoHostToolRule } = require('./engine/playbook');
150
150
 
151
+ // Per-slug GitHub PAT resolution — mirrors getAdoToken/MINIONS_ADO_TOKEN.
152
+ // Used at agent spawn time to inject GH_TOKEN for GitHub projects so child
153
+ // `gh`/`git push` calls authenticate as the right account without falling
154
+ // through to an interactive `gh auth login` device-code flow.
155
+ const ghToken = require('./engine/gh-token');
156
+
151
157
  // sanitizeBranch imported from shared.js
152
158
 
153
159
  // ─── Lifecycle (extracted to engine/lifecycle.js) ────────────────────────────
@@ -1681,6 +1687,27 @@ async function spawnAgent(dispatchItem, config) {
1681
1687
  const resolvedMaxBudget = shared.resolveAgentMaxBudget(agentConfig, engineConfig);
1682
1688
  const resolvedBare = shared.resolveAgentBareMode(agentConfig, engineConfig);
1683
1689
 
1690
+ // W-mpg6isvy000xca4d — On retry after FAILURE_CLASS.MODEL_UNAVAILABLE, swap
1691
+ // to the runtime-appropriate fallback model. Two paths gated on
1692
+ // `runtime.capabilities.fallbackModel` (no `runtime.name === ...` branches):
1693
+ // - Capability TRUE (Claude): the CLI's own --fallback-model handles
1694
+ // the swap on 429. We keep `engineConfig.claudeFallbackModel` plumbed
1695
+ // unconditionally via the fallbackModel opt below; no model override
1696
+ // needed at the engine layer.
1697
+ // - Capability FALSE (Copilot): no --fallback-model flag exists, so we
1698
+ // OVERRIDE the --model arg directly with engine.copilotFallbackModel
1699
+ // for this retry attempt only. The work item's _lastFailureClass is
1700
+ // written by dispatch.js's retry block; the next normal dispatch loop
1701
+ // pick-up re-reads it through meta.item.
1702
+ let effectiveModel = resolvedModel;
1703
+ const prevFailureClass = meta?.item?._lastFailureClass || null;
1704
+ if (prevFailureClass === FAILURE_CLASS.MODEL_UNAVAILABLE
1705
+ && runtime.capabilities?.fallbackModel === false
1706
+ && engineConfig.copilotFallbackModel) {
1707
+ effectiveModel = engineConfig.copilotFallbackModel;
1708
+ log('info', `MODEL_UNAVAILABLE retry: ${runtimeName} ${id} — overriding --model to ${effectiveModel}`);
1709
+ }
1710
+
1684
1711
  const requestedEffort = engineConfig.agentEffort || null;
1685
1712
 
1686
1713
  let cachedSessionId = null;
@@ -1698,7 +1725,7 @@ async function spawnAgent(dispatchItem, config) {
1698
1725
  }
1699
1726
 
1700
1727
  const args = _buildAgentSpawnFlags(runtime, {
1701
- model: resolvedModel,
1728
+ model: effectiveModel,
1702
1729
  maxTurns: _maxTurnsForType(type, engineConfig),
1703
1730
  allowedTools: claudeConfig.allowedTools,
1704
1731
  effort: requestedEffort,
@@ -1735,6 +1762,17 @@ async function spawnAgent(dispatchItem, config) {
1735
1762
  childEnv.MINIONS_KEEP_PROCESSES_SKIP_WORKDIR_CHECK = '1';
1736
1763
  }
1737
1764
 
1765
+ // W-mpg54mi2000n7b7e — suppress Git's interactive credential prompts and
1766
+ // Git Credential Manager's GUI dialog for every agent spawn. Without these,
1767
+ // a child `git push` against a private repo whose cached PAT expired pops a
1768
+ // Windows credential window the agent can never dismiss, hanging the
1769
+ // dispatch until the 5h agentTimeout wall-clock kill. Mirrors
1770
+ // shared.gitEnv() which already sets these for the engine's own git ops.
1771
+ // These vars are cheap and only take effect when git is invoked, so we set
1772
+ // them unconditionally regardless of repo host.
1773
+ childEnv.GIT_TERMINAL_PROMPT = '0';
1774
+ childEnv.GCM_INTERACTIVE = 'never';
1775
+
1738
1776
  if (getRepoHost(project) === 'ado') {
1739
1777
  // Inject cached ADO token so ADO agents skip re-authentication (#998).
1740
1778
  // getAdoToken() returns cached token (30-min TTL) or null — never blocks on browser auth.
@@ -1742,6 +1780,19 @@ async function spawnAgent(dispatchItem, config) {
1742
1780
  const adoToken = await getAdoToken();
1743
1781
  if (adoToken) childEnv.MINIONS_ADO_TOKEN = adoToken;
1744
1782
  } catch { /* non-fatal — agent can still authenticate on its own */ }
1783
+ } else if (getRepoHost(project) === 'github') {
1784
+ // W-mpg54mi2000n7b7e — inject a per-slug GitHub PAT so child `gh`/`git`
1785
+ // calls authenticate as the right account without any `gh auth login`
1786
+ // interactive flow. Resolution honors config.engine.ghAccounts via
1787
+ // engine/gh-token.js (exact owner → owner-glob → fleet default → null).
1788
+ // Mirrors the MINIONS_ADO_TOKEN injection above for ADO projects.
1789
+ try {
1790
+ const slug = shared.getProjectOrg(project) && project?.repoName
1791
+ ? `${shared.getProjectOrg(project)}/${project.repoName}`
1792
+ : null;
1793
+ const ghTok = slug ? ghToken.resolveTokenForSlug(slug) : null;
1794
+ if (ghTok) childEnv.GH_TOKEN = ghTok;
1795
+ } catch { /* non-fatal — agent can still authenticate on its own */ }
1745
1796
  }
1746
1797
 
1747
1798
  // Spawn via wrapper script — node directly (no bash intermediary)
@@ -2017,7 +2068,7 @@ async function spawnAgent(dispatchItem, config) {
2017
2068
  }
2018
2069
 
2019
2070
  const resumeArgs = _buildAgentSpawnFlags(runtime, {
2020
- model: resolvedModel,
2071
+ model: effectiveModel,
2021
2072
  maxTurns: engineConfig?.maxTurns || ENGINE_DEFAULTS.maxTurns,
2022
2073
  allowedTools: claudeConfig?.allowedTools,
2023
2074
  sessionId: steerSessionId,
@@ -2048,12 +2099,28 @@ async function spawnAgent(dispatchItem, config) {
2048
2099
  if (completionNonce) childEnv.MINIONS_COMPLETION_NONCE = completionNonce;
2049
2100
  childEnv.MINIONS_LIVE_OUTPUT_PATH = liveOutputPath;
2050
2101
  childEnv.MINIONS_REPO_HOST = getRepoHost(project);
2102
+ // W-mpg54mi2000n7b7e — same Git non-interactive guards as the initial
2103
+ // spawn path. Steering-resumed agents are equally susceptible to GCM
2104
+ // credential dialogs on `git push` against stale PATs.
2105
+ childEnv.GIT_TERMINAL_PROMPT = '0';
2106
+ childEnv.GCM_INTERACTIVE = 'never';
2051
2107
  if (getRepoHost(project) === 'ado') {
2052
2108
  // Inject cached ADO token for steering session too (#998)
2053
2109
  try {
2054
2110
  const adoToken = await getAdoToken();
2055
2111
  if (adoToken) childEnv.MINIONS_ADO_TOKEN = adoToken;
2056
2112
  } catch { /* non-fatal */ }
2113
+ } else if (getRepoHost(project) === 'github') {
2114
+ // W-mpg54mi2000n7b7e — same per-slug GH_TOKEN injection as the
2115
+ // initial spawn path so steering-resumed GitHub agents don't fall
2116
+ // through to an interactive `gh auth login` device-code flow.
2117
+ try {
2118
+ const slug = shared.getProjectOrg(project) && project?.repoName
2119
+ ? `${shared.getProjectOrg(project)}/${project.repoName}`
2120
+ : null;
2121
+ const ghTok = slug ? ghToken.resolveTokenForSlug(slug) : null;
2122
+ if (ghTok) childEnv.GH_TOKEN = ghTok;
2123
+ } catch { /* non-fatal */ }
2057
2124
  }
2058
2125
  // W-mp6k7ywi000fa33c — propagate keep_processes workdir-check override across steering resume.
2059
2126
  if (dispatchItem.meta?.item?.meta?.keep_processes_skip_workdir_check
@@ -4325,7 +4392,16 @@ async function discoverFromPrs(config, project) {
4325
4392
  const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
4326
4393
  pr_id: pr.id, pr_branch: prBranch,
4327
4394
  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 });
4395
+ }, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, {
4396
+ dispatchKey: key, automationCauseKey: reviewCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta,
4397
+ // W-mpg58wv3 — closure-loop binding. Carries the originating minion review
4398
+ // WI id (and any ADO thread ids it cited) onto the fix WI so the
4399
+ // post-completion path in lifecycle.js can auto-dispatch a re-review
4400
+ // against the same PR. Both fields fall through to null/[] when the
4401
+ // upstream review didn't expose them.
4402
+ addresses_review_wi: pr.minionsReview?.sourceItem || null,
4403
+ addresses_threads: Array.isArray(pr.minionsReview?.threads) ? pr.minionsReview.threads.slice() : [],
4404
+ });
4329
4405
  if (item) {
4330
4406
  newWork.push(item); setCooldown(key); fixDispatched = true;
4331
4407
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2008",
3
+ "version": "0.1.2010",
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