@yemi33/minions 0.1.2040 → 0.1.2042

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.
@@ -14,7 +14,15 @@ const _pageCounters = {
14
14
  // triggers or the count changes; triggerCount removed because it advances
15
15
  // on the same event as last_triggered (F9/S4).
16
16
  watches: function(d) { return (d.watches || []).length + '|' + (d.watches || []).reduce(function(m, w) { return Math.max(m, new Date(w.last_triggered || 0).getTime() || 0); }, 0); },
17
- meetings: function(d) { return (d.meetings || []).length + '|' + (d.meetings || []).reduce(function(s, m) { return s + (m.round || 0); }, 0); },
17
+ // meetings signature: full count + sum of all rounds. Uses meetingsTotal
18
+ // (top-level full count of meetings on disk) NOT meetings.length — the
19
+ // latter is the slim slice which drops terminal meetings >7d via
20
+ // statusMeetingsRetentionDays, so an archived meeting reaching round 3
21
+ // would silently fail to flip the dot. Round-sum stays on the slim slice
22
+ // (we don't track per-meeting round in meetingsTotal) — operators who
23
+ // care about archived-meeting round transitions can crank the retention
24
+ // window or set it to 0. (W-mphlrxx6000a8760)
25
+ meetings: function(d) { return (d.meetingsTotal ?? (d.meetings || []).length) + '|' + (d.meetings || []).reduce(function(s, m) { return s + (m.round || 0); }, 0); },
18
26
  pipelines: function(d) { return (d.pipelines || []).length + '|' + (d.pipelines || []).reduce(function(s, p) { return s + (p.runs || []).length; }, 0); },
19
27
  schedule: function(d) { return (d.schedules || []).length; },
20
28
  // tools signature: skills count + mcp servers count.
@@ -89,6 +89,7 @@ async function openSettings() {
89
89
  settingsField('Meeting Round Timeout', 'set-meetingRoundTimeout', e.meetingRoundTimeout || 900000, 'ms', 'Auto-advance meeting round after this') +
90
90
  settingsField('Operator login (used in branch names)', 'set-operatorLogin', e.operatorLogin || '', '', 'Override the human operator login used in user/<loginname>/<wi-id>-<slug> branches. Empty = auto-resolve via gh / git email / OS username (currently resolves to: ' + (e._resolvedOperatorLogin || 'unknown') + ')') +
91
91
  settingsField('Status WorkItems Retention', 'set-statusWorkItemsRetentionDays', e.statusWorkItemsRetentionDays ?? 7, 'days', 'Trim done/failed/cancelled work items older than N days from the /api/status workItems slice (active items are always shipped). Cuts SPA payload from ~3MB to <500KB. Set to 0 to disable trimming (full list shipped, restoring legacy behavior).') +
92
+ settingsField('Status Meetings Retention', 'set-statusMeetingsRetentionDays', e.statusMeetingsRetentionDays ?? 7, 'days', 'Trim completed/archived meetings older than N days from the /api/status meetings slice (active meetings are always shipped). Cuts SPA payload from ~4.3MB to <500KB. Detail modal still fetches full transcripts via /api/meetings/:id. Set to 0 to disable trimming (full list shipped, restoring legacy behavior).') +
92
93
  '</div>' +
93
94
  '<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Automation</h3>' +
94
95
  '<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:16px">' +
@@ -599,6 +600,7 @@ async function saveSettings() {
599
600
  meetingRoundTimeout: document.getElementById('set-meetingRoundTimeout').value,
600
601
  operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
601
602
  statusWorkItemsRetentionDays: document.getElementById('set-statusWorkItemsRetentionDays').value,
603
+ statusMeetingsRetentionDays: document.getElementById('set-statusMeetingsRetentionDays').value,
602
604
  autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
603
605
  evalLoop: document.getElementById('set-evalLoop').checked,
604
606
  autoDecompose: document.getElementById('set-autoDecompose').checked,
package/dashboard.js CHANGED
@@ -23,6 +23,7 @@ const shared = require('./engine/shared');
23
23
  const queries = require('./engine/queries');
24
24
  const ado = require('./engine/ado');
25
25
  const gh = require('./engine/github');
26
+ const ghToken = require('./engine/gh-token');
26
27
  const issues = require('./engine/issues');
27
28
  const watchesMod = require('./engine/watches');
28
29
  const meetingMod = require('./engine/meeting');
@@ -1710,7 +1711,14 @@ function _buildStatusFastState() {
1710
1711
  metrics: getMetrics(),
1711
1712
  workItems: _slimWorkItemsForStatus(getWorkItems()),
1712
1713
  watches: watchesMod.getWatches(),
1713
- meetings: _safeStatusSlice('meetings', () => meetingMod.getMeetings(), []),
1714
+ meetings: _safeStatusSlice('meetings', () => _slimMeetingsForStatus(meetingMod.getMeetings()), []),
1715
+ // Top-level full meeting count (NOT slim slice length). Surfaced so the
1716
+ // sidebar activity-dot counter (dashboard/js/refresh.js _pageCounters.meetings)
1717
+ // still fires when ANY meeting — including old/archived ones dropped from
1718
+ // the slim slice by statusMeetingsRetentionDays — gains a new round.
1719
+ // Without this, completing the third round of an archived meeting would
1720
+ // silently fail to light the sidebar dot. (W-mphlrxx6000a8760)
1721
+ meetingsTotal: _safeStatusSlice('meetingsTotal', () => _countMeetingsForStatus(), 0),
1714
1722
  // QA runs — surfaced for the sidebar activity-dot counter and any future
1715
1723
  // CC/aggregate view. Tab-level rendering keeps its own /api/qa/runs poll
1716
1724
  // (5 s while the QA page is mounted). qa-runs.json is in the mtime tracker
@@ -1844,6 +1852,101 @@ function _slimWorkItemsForStatus(items) {
1844
1852
  return surviving;
1845
1853
  }
1846
1854
 
1855
+ // ── /api/status meetings slimming (W-mphlrxx6000a8760) ──────────────────────
1856
+ // Mirrors the workItems trim above (PR #2816). Meetings are the second
1857
+ // largest /api/status slice after workItems — live measurement: 22 meetings
1858
+ // / 4.3MB (60% of the 7.2MB payload). The list renderer in
1859
+ // dashboard/js/render-meetings.js:renderMeetings only needs:
1860
+ // - id, title, status, round, participants, agenda(short), createdAt,
1861
+ // completedAt
1862
+ // - per-participant booleans of findings/debate (used to pick the
1863
+ // ✓/⏳/○ icon — `m.findings?.[p]` truthy check, line 48-50)
1864
+ // The detail modal calls `/api/meetings/:id` which serves the full record
1865
+ // (findings.content + debate.content + conclusion + transcript bodies), so
1866
+ // dropping those from the slice is safe.
1867
+ //
1868
+ // Active meetings (investigating/debating/concluding) are ALWAYS kept
1869
+ // regardless of age. Terminal meetings (completed/archived) only survive
1870
+ // if their completedAt/roundStartedAt/createdAt is within the window.
1871
+ // Set engine.statusMeetingsRetentionDays = 0 to disable trimming entirely
1872
+ // (returns the full list — but still slim-shaped — restoring legacy size).
1873
+ const _ACTIVE_MEETING_STATUSES_FOR_STATUS = new Set(['investigating', 'debating', 'concluding']);
1874
+ const _TERMINAL_MEETING_STATUSES_FOR_STATUS = new Set(['completed', 'archived']);
1875
+ function _resolveStatusMeetingsRetentionDays() {
1876
+ const raw = CONFIG?.engine?.statusMeetingsRetentionDays;
1877
+ if (raw === 0 || raw === '0') return 0;
1878
+ const n = Number(raw);
1879
+ if (Number.isFinite(n) && n >= 0) return n;
1880
+ return shared.ENGINE_DEFAULTS.statusMeetingsRetentionDays;
1881
+ }
1882
+ function _slimMeetingForStatus(meeting) {
1883
+ // Reduce findings/debate objects to {agentId: true} sentinels — the list
1884
+ // renderer only checks `m.findings?.[p]` for truthiness when picking the
1885
+ // participant-badge icon. Keeping just the keys preserves that contract
1886
+ // while dropping the per-round agent transcript bodies (~95KB+ each).
1887
+ const findingsKeys = meeting.findings && typeof meeting.findings === 'object'
1888
+ ? Object.keys(meeting.findings) : [];
1889
+ const debateKeys = meeting.debate && typeof meeting.debate === 'object'
1890
+ ? Object.keys(meeting.debate) : [];
1891
+ const findings = {};
1892
+ for (const k of findingsKeys) findings[k] = true;
1893
+ const debate = {};
1894
+ for (const k of debateKeys) debate[k] = true;
1895
+ const slim = {
1896
+ id: meeting.id,
1897
+ title: meeting.title,
1898
+ status: meeting.status,
1899
+ round: meeting.round,
1900
+ participants: Array.isArray(meeting.participants) ? meeting.participants : [],
1901
+ agenda: meeting.agenda,
1902
+ createdAt: meeting.createdAt,
1903
+ findings,
1904
+ debate,
1905
+ };
1906
+ if (meeting.completedAt !== undefined) slim.completedAt = meeting.completedAt;
1907
+ if (meeting.roundStartedAt !== undefined) slim.roundStartedAt = meeting.roundStartedAt;
1908
+ if (meeting.createdBy !== undefined) slim.createdBy = meeting.createdBy;
1909
+ return slim;
1910
+ }
1911
+ function _slimMeetingsForStatus(meetings) {
1912
+ if (!Array.isArray(meetings)) return meetings;
1913
+ const retentionDays = _resolveStatusMeetingsRetentionDays();
1914
+ if (retentionDays <= 0) {
1915
+ // Trimming disabled — keep full list but still flatten via slim shape
1916
+ // so wire format is consistent and the heavy bodies never ship via
1917
+ // /api/status regardless of operator config.
1918
+ return meetings.map(_slimMeetingForStatus);
1919
+ }
1920
+ const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
1921
+ const surviving = [];
1922
+ for (const meeting of meetings) {
1923
+ if (!meeting) continue;
1924
+ const status = meeting.status || 'investigating';
1925
+ if (_TERMINAL_MEETING_STATUSES_FOR_STATUS.has(status)) {
1926
+ const ts = meeting.completedAt || meeting.roundStartedAt || meeting.createdAt || '';
1927
+ const tsMs = ts ? Date.parse(ts) : NaN;
1928
+ // Drop only when we have a parseable timestamp and it's beyond the
1929
+ // window. Meetings with missing/unparseable timestamps stay visible —
1930
+ // we'd rather over-include than silently hide them.
1931
+ if (Number.isFinite(tsMs) && tsMs < cutoffMs) {
1932
+ continue;
1933
+ }
1934
+ } else if (!_ACTIVE_MEETING_STATUSES_FOR_STATUS.has(status)) {
1935
+ // Unknown status — keep, so a future round name isn't silently
1936
+ // hidden until the constant set is updated.
1937
+ }
1938
+ surviving.push(_slimMeetingForStatus(meeting));
1939
+ }
1940
+ return surviving;
1941
+ }
1942
+ // Count meetings on disk without rehydrating bodies — backs the sidebar
1943
+ // activity dot signature so new rounds in trimmed/archived meetings still
1944
+ // flip the counter (refresh.js _pageCounters.meetings reads meetingsTotal).
1945
+ function _countMeetingsForStatus() {
1946
+ const meetings = meetingMod.getMeetings();
1947
+ return Array.isArray(meetings) ? meetings.length : 0;
1948
+ }
1949
+
1847
1950
  // Build the slow-state slice (rarely-changing data: ~60s TTL).
1848
1951
  function _buildStatusSlowState() {
1849
1952
  const prdInfo = getPrdInfo();
@@ -8538,6 +8641,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8538
8641
  _setEngineConfig('statusWorkItemsRetentionDays', val);
8539
8642
  }
8540
8643
  }
8644
+ // W-mphlrxx6000a8760: /api/status meetings retention window. Same
8645
+ // shape as statusWorkItemsRetentionDays — 0 must persist literally
8646
+ // (disables trim), so handled outside the numericFields loop.
8647
+ if (e.statusMeetingsRetentionDays !== undefined) {
8648
+ const raw = e.statusMeetingsRetentionDays;
8649
+ if (raw === '' || raw === null) {
8650
+ _deleteEngineConfig('statusMeetingsRetentionDays');
8651
+ } else {
8652
+ let val = Number(raw);
8653
+ if (!Number.isFinite(val) || val < 0) val = D.statusMeetingsRetentionDays;
8654
+ if (val > 365) { _clamped.push(`statusMeetingsRetentionDays: ${val} → 365 (range: 0–365)`); val = 365; }
8655
+ _setEngineConfig('statusMeetingsRetentionDays', val);
8656
+ }
8657
+ }
8541
8658
  // W-mpejf0fq000e84d6: operator login override. Empty string clears
8542
8659
  // the override (engine falls back to gh/git/os resolution); any other
8543
8660
  // value pins the login used in `user/<login>/<wi-id>-<slug>` branches.
@@ -9915,7 +10032,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9915
10032
  // a regex match against untrusted PR-link input (the body of POST
9916
10033
  // /api/pull-requests/link); validate before exec. `prNum` is already
9917
10034
  // a number; coerce to string for argv.
9918
- const result = await shared.shellSafeGh(['api', `repos/${shared.validateGhSlug(slug)}/pulls/${String(prNum)}`], { timeout: 15000 });
10035
+ //
10036
+ // W-mphm0kt0000cebc3 (Bug A): route the GH PAT per slug. Without this,
10037
+ // cross-account scopes (e.g. opg-microsoft/minions when active gh is
10038
+ // yemi33) 404 silently and enrichment never lands — title stays
10039
+ // "PR #N (polling...)" indefinitely. Mirrors engine/github.js:316.
10040
+ const token = ghToken.resolveTokenForSlug(slug);
10041
+ const ghOpts = { timeout: 15000 };
10042
+ if (token) ghOpts.env = { ...process.env, GH_TOKEN: token };
10043
+ const result = await shared.shellSafeGh(['api', `repos/${shared.validateGhSlug(slug)}/pulls/${String(prNum)}`], ghOpts);
9919
10044
  const d = JSON.parse(result);
9920
10045
  prData = { title: d.title, description: d.body, branch: d.head?.ref, author: d.user?.login };
9921
10046
  } else if (adoTarget && !initialPrData) {
@@ -111,14 +111,28 @@ function getBranchDispatchLockKey(entry) {
111
111
  }
112
112
 
113
113
  function findActivePrOrBranchLock(dispatch, item) {
114
- if (item?.type !== WORK_TYPE.FIX) return null;
115
114
  const active = dispatch.active || [];
116
- const prTargetKey = getPrDispatchTargetKey(item);
117
- if (prTargetKey) {
118
- const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
119
- if (existing) return { existing, reason: `active PR dispatch ${prTargetKey}` };
115
+
116
+ // PR-target dedup is FIX-only: a FIX shouldn't stack on top of another FIX
117
+ // for the same PR, but a REVIEW + FIX pair targeting the same PR is the
118
+ // normal review-then-fix flow and must not be dedup'd here.
119
+ if (item?.type === WORK_TYPE.FIX) {
120
+ const prTargetKey = getPrDispatchTargetKey(item);
121
+ if (prTargetKey) {
122
+ const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
123
+ if (existing) return { existing, reason: `active PR dispatch ${prTargetKey}` };
124
+ }
120
125
  }
121
126
 
127
+ // Branch-lock applies to EVERY type, not just FIX (W-mphll3py0006234e —
128
+ // issue #2817). Any dispatch that carries meta.branch is, by definition,
129
+ // claiming ownership of that branch for the duration of its run — if two
130
+ // such dispatches overlap on the same branch they race the eventual
131
+ // `git push` and the spawn-time stale-HEAD guard fails whichever push
132
+ // lost the race. Mirrors the dispatch-loop's `lockedBranches` mutex
133
+ // (engine.js ~6577-6731) at queue-time so maintenance-class dispatches
134
+ // (setup / test / docs / implement / verify / decompose / review …) get
135
+ // the same coordination FIX already had.
122
136
  const branchLockKey = getBranchDispatchLockKey(item);
123
137
  if (!branchLockKey) return null;
124
138
  const existing = active.find(d => getBranchDispatchLockKey(d) === branchLockKey);
package/engine/github.js CHANGED
@@ -669,6 +669,35 @@ async function pollPrStatus(config) {
669
669
  }
670
670
  }
671
671
 
672
+ // W-mphm0kt0000cebc3 (Bug B): backfill title/description/agent for
673
+ // project-local PRs that are still on the link-time placeholder. The
674
+ // central poller (above, ~lines 561-583) does this already; without
675
+ // parity here a manually-linked project-local PR whose initial
676
+ // enrichment IIFE failed (cross-account auth, transient blip) would
677
+ // stay stuck on "PR #N (polling...)" forever. `prData` is already in
678
+ // hand from line 622 — no extra API call needed.
679
+ const currentTitleForBackfill = pr.title || '';
680
+ if (!currentTitleForBackfill
681
+ || currentTitleForBackfill.includes('polling...')
682
+ || /[{}"\[\]]/.test(currentTitleForBackfill)
683
+ || /^[0-9a-f-]{8,}$/i.test(currentTitleForBackfill)) {
684
+ if (prData.title) {
685
+ const nextTitle = String(prData.title).slice(0, 120);
686
+ if (pr.title !== nextTitle) {
687
+ pr.title = nextTitle;
688
+ updated = true;
689
+ }
690
+ }
691
+ }
692
+ if (pr.description === undefined) {
693
+ pr.description = (prData.body || '').slice(0, 500);
694
+ updated = true;
695
+ }
696
+ if (pr.agent === 'human' && prData.user?.login) {
697
+ pr.agent = prData.user.login;
698
+ updated = true;
699
+ }
700
+
672
701
  // Map GitHub PR state to minions status
673
702
  let newStatus = pr.status;
674
703
  if (prData.merged) newStatus = PR_STATUS.MERGED;
package/engine/shared.js CHANGED
@@ -2041,6 +2041,19 @@ const ENGINE_DEFAULTS = {
2041
2041
  // via GET /api/work-items/<id> when description/references/AC are needed.
2042
2042
  // 0 disables the trim (full list shipped, restoring legacy behavior).
2043
2043
  statusWorkItemsRetentionDays: 7,
2044
+
2045
+ // ── /api/status meetings retention (W-mphlrxx6000a8760) ─────────────────────
2046
+ // Same shape as statusWorkItemsRetentionDays — mirrors the trim+slim pass
2047
+ // for the meetings slice (live: 22 meetings / 4.3MB → ~5 meetings / <500KB
2048
+ // typical). Active meetings (investigating/debating/concluding) are ALWAYS
2049
+ // shipped regardless of age — only terminal meetings (completed/archived)
2050
+ // past the window are dropped. The detail modal fetches the full record
2051
+ // (findings, debate, conclusion, transcript bodies) on demand via
2052
+ // GET /api/meetings/<id> when opened. A top-level meetingsTotal field is
2053
+ // synthesized so the sidebar activity dot still fires when ANY meeting
2054
+ // (including those dropped from the slim slice) gains a new round.
2055
+ // 0 disables the trim (full list shipped, restoring legacy behavior).
2056
+ statusMeetingsRetentionDays: 7,
2044
2057
  };
2045
2058
 
2046
2059
  // ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
package/engine.js CHANGED
@@ -698,6 +698,35 @@ async function runWorktreeAdd(rootDir, worktreePath, addArgs, gitOpts, worktreeC
698
698
  if (lastErr) throw lastErr;
699
699
  }
700
700
 
701
+ // W-mphnm6a1000281b8: probe whether origin already advertises a head for
702
+ // `branchName`. Used by the fresh-create worktree path to choose between
703
+ // `git worktree add <path> <branch>` (checkout, local-track origin) and
704
+ // `git worktree add -b <branch> origin/<mainRef>` (fresh branch off main).
705
+ // Without this probe, PR-targeted fix/review/test/verify dispatches whose
706
+ // source branch is N commits ahead of main upstream (mirror branches like
707
+ // `sync/yemi33-master`, force-pushed PR branches) start their worktree on
708
+ // origin/<mainRef>; the stale-HEAD guard at the top of spawn-agent then
709
+ // trips on every dispatch and the cooldown machinery starves the PR.
710
+ //
711
+ // Returns true only on a confirmed advertised head; ls-remote exit code 2
712
+ // (no matching ref) and any other failure (network/auth) return false so
713
+ // the caller falls back to the existing `-b origin/<mainRef>` path.
714
+ async function probeBranchOnRemote(rootDir, branchName, gitOpts) {
715
+ if (!branchName || !rootDir) return false;
716
+ try {
717
+ await shared.shellSafeGit(
718
+ ['ls-remote', '--exit-code', '--heads', 'origin', branchName],
719
+ { ...gitOpts, cwd: rootDir, timeout: 10000 },
720
+ );
721
+ return true;
722
+ } catch (e) {
723
+ if (e && e.code !== 2) {
724
+ log('warn', `probeBranchOnRemote: ls-remote --heads origin ${branchName} failed: ${(e.message || '').split('\n')[0].slice(0, 200)} — treating as not-on-remote`);
725
+ }
726
+ return false;
727
+ }
728
+ }
729
+
701
730
  // Detect and remove worktree registrations whose backing directory is missing
702
731
  // on disk. `git worktree add` fails with "branch is already used by worktree
703
732
  // at <path>" when a prior crash left such an entry behind, sometimes still in
@@ -1188,84 +1217,143 @@ async function spawnAgent(dispatchItem, config) {
1188
1217
  } else {
1189
1218
  log('info', `Creating worktree: ${worktreePath} on branch ${branchName}`);
1190
1219
  const mainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
1191
- // W-mph6n4p00006ce38: mirror the pool-borrow path (~line 1110-1114)
1192
- // fetch fresh origin/<mainRef> and start the new branch off it,
1193
- // not the local ref. Without this, fresh-create dispatches inherit
1194
- // whatever stale local master the engine clone happens to be on
1195
- // (most painful: long-lived engine processes between restarts).
1196
- // Non-fatal: if the fetch fails (network blip, transient auth),
1197
- // fall back to local mainRef so the dispatch still progresses;
1198
- // the dep-merge phase's own fetch + the on-failure
1199
- // `git reset --hard origin/<mainRef>` recovery remain as safety nets.
1200
- let _freshCreateBase = mainRef;
1201
- try {
1202
- await shared.shellSafeGit(['fetch', 'origin', mainRef], { ..._gitOpts, cwd: rootDir, timeout: 30000 });
1203
- _freshCreateBase = `origin/${mainRef}`;
1204
- } catch (mainFetchErr) {
1205
- log('warn', `Failed to fetch origin/${mainRef} before fresh-create worktree for ${branchName}: ${mainFetchErr.message} — falling back to local ${mainRef}`);
1206
- }
1207
- try {
1208
- await runWorktreeAdd(rootDir, worktreePath, ['-b', branchName, _freshCreateBase], _worktreeGitOpts, worktreeCreateRetries);
1209
- } catch (e1) {
1210
- const branchExists = e1.message?.includes('already exists');
1211
- log('warn', `Worktree -b failed for ${branchName}: ${e1.message?.split('\n')[0]}`);
1212
- if (!branchExists) {
1213
- // Transient error (lock, timeout) prune, clean, and retry -b once more
1214
- log('info', `Retrying -b create after prune for ${branchName}`);
1215
- try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 15000 }); } catch { /* optional */ }
1216
- removeStaleIndexLock(rootDir);
1217
- // Clean up partial worktree directory from failed attempt
1218
- try { if (fs.existsSync(worktreePath)) fs.rmSync(worktreePath, { recursive: true, force: true }); } catch { /* optional */ }
1219
- try {
1220
- await runWorktreeAdd(rootDir, worktreePath, ['-b', branchName, _freshCreateBase], _worktreeGitOpts, 0);
1221
- } catch (e1b) {
1222
- log('error', `Worktree -b retry also failed for ${branchName}: ${e1b.message?.split('\n')[0]}`);
1223
- throw e1b;
1224
- }
1225
- } else {
1226
- // Branch already exists — try checkout without -b
1227
- try { await shared.shellSafeGit(['fetch', 'origin', branchName], { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
1228
- try {
1229
- await runWorktreeAdd(rootDir, worktreePath, [branchName], _worktreeGitOpts, worktreeCreateRetries);
1230
- log('info', `Reusing existing branch: ${branchName}`);
1231
- } catch (e2) {
1232
- // "already checked out" or "already used by worktree" — find and reuse or recover
1233
- const alreadyUsed = e2.message?.includes('already checked out') || e2.message?.includes('already used by worktree')
1234
- || e1.message?.includes('already checked out') || e1.message?.includes('already used by worktree');
1235
- if (alreadyUsed) {
1236
- const existingWtPath = await findExistingWorktree(rootDir, branchName);
1237
- if (existingWtPath && fs.existsSync(existingWtPath)) {
1238
- // Bug fix: read dispatch under file lock so check-and-act is atomic
1239
- let activelyUsed = false;
1240
- mutateDispatch((dp) => {
1241
- activelyUsed = (dp.active || []).some(d => {
1242
- const dBranch = d.meta?.branch ? sanitizeBranch(d.meta.branch) : '';
1243
- return dBranch === branchName && d.id !== id;
1244
- });
1245
- return dp;
1220
+
1221
+ // W-mphnm6a1000281b8: probe whether origin already has this branch.
1222
+ // If yes, prefer `git worktree add <path> <branch>` (checkout +
1223
+ // local-track origin/<branch>) over `-b <branch> origin/<mainRef>`.
1224
+ // The shared-branch path (~line 1158) already does this. PR-targeted
1225
+ // fix/review/test/verify dispatches hit this path with branchName =
1226
+ // the PR's source branch; branching off main when the PR branch is
1227
+ // N commits ahead upstream guarantees the stale-HEAD guard
1228
+ // (~line 1777) trips and the dispatch errors. Live repro:
1229
+ // opg-microsoft/minions PR #57 (sync/yemi33-master, 109 commits
1230
+ // ahead of main) — two consecutive fix dispatches errored on
1231
+ // STALE_HEAD over 10 min and the engine then starved the PR for
1232
+ // the cooldown window (60 min effective).
1233
+ const _branchOnRemote = await probeBranchOnRemote(rootDir, branchName, _gitOpts);
1234
+
1235
+ if (_branchOnRemote) {
1236
+ // Mirror shared-branch fetch+add (~line 1157-1159).
1237
+ log('info', `origin/${branchName} exists checking out remote branch instead of -b from ${mainRef}`);
1238
+ try { await shared.shellSafeGit(['fetch', 'origin', branchName], { ..._gitOpts, cwd: rootDir, timeout: 30000 }); } catch (e) { log('warn', 'git: ' + e.message); }
1239
+ try {
1240
+ await runWorktreeAdd(rootDir, worktreePath, [branchName], _worktreeGitOpts, worktreeCreateRetries);
1241
+ } catch (eRemote) {
1242
+ const alreadyUsed = eRemote.message?.includes('already used by worktree') || eRemote.message?.includes('already checked out');
1243
+ if (alreadyUsed) {
1244
+ const existingWtPath = await findExistingWorktree(rootDir, branchName);
1245
+ if (existingWtPath && fs.existsSync(existingWtPath)) {
1246
+ let activelyUsed = false;
1247
+ mutateDispatch((dp) => {
1248
+ activelyUsed = (dp.active || []).some(d => {
1249
+ const dBranch = d.meta?.branch ? sanitizeBranch(d.meta.branch) : '';
1250
+ return dBranch === branchName && d.id !== id;
1246
1251
  });
1247
- if (activelyUsed) {
1248
- log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
1249
- throw e2;
1250
- }
1251
- try { shared.assertWorktreeOutsideProject(existingWtPath, rootDir); }
1252
- catch (assertErr) {
1253
- if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
1254
- throw assertErr;
1252
+ return dp;
1253
+ });
1254
+ if (activelyUsed) {
1255
+ log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
1256
+ throw eRemote;
1257
+ }
1258
+ try { shared.assertWorktreeOutsideProject(existingWtPath, rootDir); }
1259
+ catch (assertErr) {
1260
+ if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
1261
+ throw assertErr;
1262
+ }
1263
+ log('info', `Branch ${branchName} already checked out at ${existingWtPath} — reusing`);
1264
+ worktreePath = existingWtPath;
1265
+ } else {
1266
+ const pruned = await pruneStaleWorktreeForBranch(rootDir, branchName, _gitOpts);
1267
+ if (pruned > 0) {
1268
+ log('info', `Pruned ${pruned} stale worktree entry(ies) for ${branchName}; retrying worktree add`);
1269
+ await runWorktreeAdd(rootDir, worktreePath, [branchName], _worktreeGitOpts, 0);
1270
+ } else { throw eRemote; }
1271
+ }
1272
+ } else { throw eRemote; }
1273
+ }
1274
+ } else {
1275
+ // Branch isn't on origin (or probe failed) — start a fresh local
1276
+ // branch from origin/<mainRef>. This is the dominant path for new
1277
+ // implement/decompose dispatches.
1278
+ // W-mph6n4p00006ce38: mirror the pool-borrow path (~line 1110-1114)
1279
+ // — fetch fresh origin/<mainRef> and start the new branch off it,
1280
+ // not the local ref. Without this, fresh-create dispatches inherit
1281
+ // whatever stale local master the engine clone happens to be on
1282
+ // (most painful: long-lived engine processes between restarts).
1283
+ // Non-fatal: if the fetch fails (network blip, transient auth),
1284
+ // fall back to local mainRef so the dispatch still progresses;
1285
+ // the dep-merge phase's own fetch + the on-failure
1286
+ // `git reset --hard origin/<mainRef>` recovery remain as safety nets.
1287
+ let _freshCreateBase = mainRef;
1288
+ try {
1289
+ await shared.shellSafeGit(['fetch', 'origin', mainRef], { ..._gitOpts, cwd: rootDir, timeout: 30000 });
1290
+ _freshCreateBase = `origin/${mainRef}`;
1291
+ } catch (mainFetchErr) {
1292
+ log('warn', `Failed to fetch origin/${mainRef} before fresh-create worktree for ${branchName}: ${mainFetchErr.message} — falling back to local ${mainRef}`);
1293
+ }
1294
+ try {
1295
+ await runWorktreeAdd(rootDir, worktreePath, ['-b', branchName, _freshCreateBase], _worktreeGitOpts, worktreeCreateRetries);
1296
+ } catch (e1) {
1297
+ const branchExists = e1.message?.includes('already exists');
1298
+ log('warn', `Worktree -b failed for ${branchName}: ${e1.message?.split('\n')[0]}`);
1299
+ if (!branchExists) {
1300
+ // Transient error (lock, timeout) — prune, clean, and retry -b once more
1301
+ log('info', `Retrying -b create after prune for ${branchName}`);
1302
+ try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 15000 }); } catch { /* optional */ }
1303
+ removeStaleIndexLock(rootDir);
1304
+ // Clean up partial worktree directory from failed attempt
1305
+ try { if (fs.existsSync(worktreePath)) fs.rmSync(worktreePath, { recursive: true, force: true }); } catch { /* optional */ }
1306
+ try {
1307
+ await runWorktreeAdd(rootDir, worktreePath, ['-b', branchName, _freshCreateBase], _worktreeGitOpts, 0);
1308
+ } catch (e1b) {
1309
+ log('error', `Worktree -b retry also failed for ${branchName}: ${e1b.message?.split('\n')[0]}`);
1310
+ throw e1b;
1311
+ }
1312
+ } else {
1313
+ // Branch already exists — try checkout without -b
1314
+ try { await shared.shellSafeGit(['fetch', 'origin', branchName], { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
1315
+ try {
1316
+ await runWorktreeAdd(rootDir, worktreePath, [branchName], _worktreeGitOpts, worktreeCreateRetries);
1317
+ log('info', `Reusing existing branch: ${branchName}`);
1318
+ } catch (e2) {
1319
+ // "already checked out" or "already used by worktree" — find and reuse or recover
1320
+ const alreadyUsed = e2.message?.includes('already checked out') || e2.message?.includes('already used by worktree')
1321
+ || e1.message?.includes('already checked out') || e1.message?.includes('already used by worktree');
1322
+ if (alreadyUsed) {
1323
+ const existingWtPath = await findExistingWorktree(rootDir, branchName);
1324
+ if (existingWtPath && fs.existsSync(existingWtPath)) {
1325
+ // Bug fix: read dispatch under file lock so check-and-act is atomic
1326
+ let activelyUsed = false;
1327
+ mutateDispatch((dp) => {
1328
+ activelyUsed = (dp.active || []).some(d => {
1329
+ const dBranch = d.meta?.branch ? sanitizeBranch(d.meta.branch) : '';
1330
+ return dBranch === branchName && d.id !== id;
1331
+ });
1332
+ return dp;
1333
+ });
1334
+ if (activelyUsed) {
1335
+ log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
1336
+ throw e2;
1337
+ }
1338
+ try { shared.assertWorktreeOutsideProject(existingWtPath, rootDir); }
1339
+ catch (assertErr) {
1340
+ if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
1341
+ throw assertErr;
1342
+ }
1343
+ log('info', `Branch ${branchName} already checked out at ${existingWtPath} — reusing`);
1344
+ worktreePath = existingWtPath;
1345
+ } else if (existingWtPath && !fs.existsSync(existingWtPath)) {
1346
+ log('warn', `Branch ${branchName} tracked in missing dir ${existingWtPath} — pruning and recreating`);
1347
+ try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
1348
+ await runWorktreeAdd(rootDir, worktreePath, [branchName], _worktreeGitOpts, worktreeCreateRetries);
1349
+ log('info', `Recovered worktree for ${branchName} after stale entry prune`);
1350
+ } else {
1351
+ try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
1352
+ await runWorktreeAdd(rootDir, worktreePath, [branchName], _worktreeGitOpts, worktreeCreateRetries);
1255
1353
  }
1256
- log('info', `Branch ${branchName} already checked out at ${existingWtPath} — reusing`);
1257
- worktreePath = existingWtPath;
1258
- } else if (existingWtPath && !fs.existsSync(existingWtPath)) {
1259
- log('warn', `Branch ${branchName} tracked in missing dir ${existingWtPath} — pruning and recreating`);
1260
- try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
1261
- await runWorktreeAdd(rootDir, worktreePath, [branchName], _worktreeGitOpts, worktreeCreateRetries);
1262
- log('info', `Recovered worktree for ${branchName} after stale entry prune`);
1263
1354
  } else {
1264
- try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
1265
- await runWorktreeAdd(rootDir, worktreePath, [branchName], _worktreeGitOpts, worktreeCreateRetries);
1355
+ throw e2;
1266
1356
  }
1267
- } else {
1268
- throw e2;
1269
1357
  }
1270
1358
  }
1271
1359
  }
@@ -4063,6 +4151,18 @@ function ensurePrBranchForDispatch(project, pr, automationType) {
4063
4151
  }
4064
4152
  return branch;
4065
4153
  }
4154
+ // W-mphm0kt0000cebc3 (Bug C): suppress the red "missing branch" badge during
4155
+ // the link grace window. User reports the badge "is so loud as a warning -
4156
+ // makes the user think something is wrong when it's just taking its time".
4157
+ // A just-linked PR has `created` set within the last few seconds and has
4158
+ // never been polled (no `headSha`); the enrichment IIFE + first poll cycle
4159
+ // need 10-30s to land the real `branch`. During that window, return ''
4160
+ // silently so dispatch is deferred without flipping any UI state. The next
4161
+ // tick retries; if branch still missing past the grace window, fall through
4162
+ // to the existing _branchResolutionError path (red badge as before).
4163
+ if (isWithinLinkGraceWindow(pr)) {
4164
+ return '';
4165
+ }
4066
4166
  const reason = `Cannot dispatch ${automationType} for ${shared.getPrDisplayId(pr)}: missing pr_branch/source branch metadata. Link or refresh the PR so the source branch is known.`;
4067
4167
  if (updatePrBranchResolutionState(project, pr, { reason })) {
4068
4168
  log('warn', `PR ${pr.id}: ${reason}`);
@@ -4070,6 +4170,21 @@ function ensurePrBranchForDispatch(project, pr, automationType) {
4070
4170
  return '';
4071
4171
  }
4072
4172
 
4173
+ // W-mphm0kt0000cebc3 (Bug C): a PR is "freshly linked" if it was created in
4174
+ // the last 120s AND has never been successfully polled (no `headSha`). The
4175
+ // enrichment IIFE in dashboard.js's POST /api/pull-requests/link runs async
4176
+ // after returning the response, and the first GH/ADO poll completes within
4177
+ // 10-30s. The 120s window comfortably covers both without masking PRs whose
4178
+ // branch is genuinely missing.
4179
+ const PR_LINK_GRACE_WINDOW_MS = 120 * 1000;
4180
+ function isWithinLinkGraceWindow(pr) {
4181
+ if (!pr || !pr.created) return false;
4182
+ if (pr.headSha) return false; // poll has run successfully — past the grace window
4183
+ const createdMs = Date.parse(pr.created);
4184
+ if (!Number.isFinite(createdMs)) return false;
4185
+ return (Date.now() - createdMs) < PR_LINK_GRACE_WINDOW_MS;
4186
+ }
4187
+
4073
4188
  function prCausePart(value, fallback = 'unknown') {
4074
4189
  const raw = String(value || '').trim();
4075
4190
  return shared.safeSlugComponent(raw || fallback, 80);
@@ -4502,29 +4617,48 @@ async function discoverFromPrs(config, project) {
4502
4617
  && !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.REVIEW_FEEDBACK)) {
4503
4618
  const reviewCauseKey = getPrAutomationCauseKey('review-feedback', pr);
4504
4619
  const key = getPrAutomationDispatchKey(`fix-${project?.name || 'default'}-${prDisplayId}`, reviewCauseKey);
4505
- if (isPrAutomationCauseHandledOrPending(project, pr, reviewCauseKey)) continue;
4506
- if (fixThrottled || isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
4507
- const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
4508
- if (!agentId) continue;
4509
- const prBranch = ensurePrBranchForDispatch(project, pr, 'fix');
4510
- if (!prBranch) continue;
4511
-
4512
- const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
4513
- pr_id: pr.id, pr_branch: prBranch,
4514
- review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
4515
- }, `Fix ${pr.id}: ${pr.title || ''} review feedback`, {
4516
- dispatchKey: key, cooldownKey: key, automationCauseKey: reviewCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta,
4517
- // W-mpg58wv3 — closure-loop binding. Carries the originating minion review
4518
- // WI id (and any ADO thread ids it cited) onto the fix WI so the
4519
- // post-completion path in lifecycle.js can auto-dispatch a re-review
4520
- // against the same PR. Both fields fall through to null/[] when the
4521
- // upstream review didn't expose them.
4522
- addresses_review_wi: pr.minionsReview?.sourceItem || null,
4523
- addresses_threads: Array.isArray(pr.minionsReview?.threads) ? pr.minionsReview.threads.slice() : [],
4524
- });
4525
- if (item) {
4526
- newWork.push(item); fixDispatched = true;
4527
- }
4620
+ // W-mphnm6a1000281b8: cause-local skip mirroring the human-feedback
4621
+ // (#2632) and build-failure (#2632 audit) guards above. A previous bare
4622
+ // `continue` here aborted the entire PR iteration when the
4623
+ // review-feedback dispatch was throttled, on cooldown, or already
4624
+ // dispatched starving the build-fix and conflict-fix blocks below
4625
+ // even though their own dedupe keys had not been hit. Live repro on
4626
+ // opg-microsoft/minions PR #57: two STALE_HEAD-errored review-feedback
4627
+ // dispatches stamped a 60-min effective cooldown, and the PR received
4628
+ // zero build-fix dispatches for the rest of the cooldown window even
4629
+ // though buildStatus stayed `failing` and the build-fix key was clean.
4630
+ // Skip ONLY this cause; let iteration fall through to downstream
4631
+ // blocks (re-review already ran above; build-fix + conflict-fix run
4632
+ // below).
4633
+ const skipReviewFeedback =
4634
+ isPrAutomationCauseHandledOrPending(project, pr, reviewCauseKey)
4635
+ || fixThrottled
4636
+ || isAlreadyDispatched(key)
4637
+ || isOnCooldown(key, cooldownMs);
4638
+ if (!skipReviewFeedback) {
4639
+ const agentId = resolveAgent('fix', config, { authorAgent: pr.agent });
4640
+ if (agentId) {
4641
+ const prBranch = ensurePrBranchForDispatch(project, pr, 'fix');
4642
+ if (prBranch) {
4643
+ const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
4644
+ pr_id: pr.id, pr_branch: prBranch,
4645
+ review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
4646
+ }, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, {
4647
+ dispatchKey: key, cooldownKey: key, automationCauseKey: reviewCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta,
4648
+ // W-mpg58wv3 — closure-loop binding. Carries the originating minion review
4649
+ // WI id (and any ADO thread ids it cited) onto the fix WI so the
4650
+ // post-completion path in lifecycle.js can auto-dispatch a re-review
4651
+ // against the same PR. Both fields fall through to null/[] when the
4652
+ // upstream review didn't expose them.
4653
+ addresses_review_wi: pr.minionsReview?.sourceItem || null,
4654
+ addresses_threads: Array.isArray(pr.minionsReview?.threads) ? pr.minionsReview.threads.slice() : [],
4655
+ });
4656
+ if (item) {
4657
+ newWork.push(item); fixDispatched = true;
4658
+ }
4659
+ }
4660
+ }
4661
+ } // end if (!skipReviewFeedback) — cause-local guard for W-mphnm6a1000281b8
4528
4662
  }
4529
4663
 
4530
4664
  // PRs with build failures — route to author (has session context from implementing)
@@ -6872,9 +7006,11 @@ module.exports = {
6872
7006
  isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, // exported for testing
6873
7007
  pruneStaleWorktreeForBranch, // exported for testing
6874
7008
  findExistingWorktree, // exported for testing
7009
+ probeBranchOnRemote, // exported for testing (W-mphnm6a1000281b8)
6875
7010
  _maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
6876
7011
  promoteCheckpointSteeringForClose, // exported for testing
6877
7012
  normalizePrBranch, resolvePrBranch, prCausePart, getPrCauseHead, getPrCauseBase, getPrAutomationCauseKey, getPrAutomationDispatchKey, // exported for testing
7013
+ ensurePrBranchForDispatch, isWithinLinkGraceWindow, PR_LINK_GRACE_WINDOW_MS, // exported for testing (W-mphm0kt0000cebc3)
6878
7014
 
6879
7015
  // Playbooks
6880
7016
  renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS, buildWorkItemDispatchVars,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2040",
3
+ "version": "0.1.2042",
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"