@yemi33/minions 0.1.953 → 0.1.955

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.953 (2026-04-14)
3
+ ## 0.1.955 (2026-04-14)
4
4
 
5
5
  ### Features
6
+ - ADO throttle detection, poll guards, and dashboard banner (#1051)
6
7
  - gate auto-fix conflict dispatch behind autoFixConflicts flag
7
8
  - add PR/build status CLI shim and agent guidance
8
9
  - make ADO poll frequency configurable and ungate reconcilePrs
@@ -22,7 +23,6 @@
22
23
  - add missing branch_name to central dispatch vars (#967)
23
24
  - fix dispatch.js mutator fallback to use nullish coalescing (#966)
24
25
  - fix stall recovery nested lock violation (#965)
25
- - fix pending-rebases.json race condition with file locking (#964)
26
26
 
27
27
  ### Fixes
28
28
  - fix ENGINE_DEFAULTS ref and derive boolean settings from defaults
@@ -47,6 +47,7 @@
47
47
  - 429 retry on abort race applies to all sends, not just queued
48
48
 
49
49
  ### Other
50
+ - test(engine): add regression tests for autoFixConflicts flag and DEFAULTS alias
50
51
  - refactor(ado): simplify ado-status and extract build/review status helpers
51
52
  - refactor(ado): extract stripRefsHeads helper, deduplicate refs/heads/ stripping
52
53
  - refactor(ado): simplify comments, fix declaration order, promote ado import
@@ -75,6 +75,8 @@ function _processStatusUpdate(data) {
75
75
  }
76
76
  }
77
77
  if (_changed('version', data.version)) renderVersionBanner(data.version);
78
+ if (_changed('adoThrottle', data.adoThrottle)) renderAdoThrottleAlert(data.adoThrottle);
79
+ if (_changed('ghThrottle', data.ghThrottle)) renderGhThrottleAlert(data.ghThrottle);
78
80
  if (_changed('dispatch', data.dispatch)) renderDispatch(data.dispatch);
79
81
  window._lastDispatch = data.dispatch;
80
82
  window._lastWorkItems = data.workItems || [];
@@ -72,6 +72,34 @@ function renderEngineAlert(state, staleMs) {
72
72
  el.style.display = 'flex';
73
73
  }
74
74
 
75
+ function renderAdoThrottleAlert(adoThrottle) {
76
+ const el = document.getElementById('ado-throttle-alert');
77
+ if (!el) return;
78
+ if (!adoThrottle || !adoThrottle.throttled) {
79
+ el.style.display = 'none';
80
+ el.innerHTML = '';
81
+ return;
82
+ }
83
+ const resumeTime = new Date(adoThrottle.retryAfter).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
84
+ el.innerHTML =
85
+ '<span class="engine-alert-msg">&#x26A0;&#xFE0F; ADO rate-limited — resume ~' + resumeTime + ' (' + adoThrottle.consecutiveHits + ' consecutive hit' + (adoThrottle.consecutiveHits !== 1 ? 's' : '') + ')</span>';
86
+ el.style.display = 'flex';
87
+ }
88
+
89
+ function renderGhThrottleAlert(ghThrottle) {
90
+ const el = document.getElementById('gh-throttle-alert');
91
+ if (!el) return;
92
+ if (!ghThrottle || !ghThrottle.throttled) {
93
+ el.style.display = 'none';
94
+ el.innerHTML = '';
95
+ return;
96
+ }
97
+ const resumeTime = new Date(ghThrottle.retryAfter).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
98
+ el.innerHTML =
99
+ '<span class="engine-alert-msg">&#x26A0;&#xFE0F; GitHub rate-limited — resume ~' + resumeTime + ' (' + ghThrottle.consecutiveHits + ' consecutive hit' + (ghThrottle.consecutiveHits !== 1 ? 's' : '') + ')</span>';
100
+ el.style.display = 'flex';
101
+ }
102
+
75
103
  function renderDispatch(dispatch) {
76
104
  if (!dispatch) return;
77
105
 
@@ -247,4 +275,4 @@ function renderVersionBanner(version) {
247
275
  }
248
276
  }
249
277
 
250
- window.MinionsDispatch = { renderEngineStatus, renderEngineAlert, renderVersionBanner, renderDispatch, renderEngineLog, shortTime, showErrorDetails };
278
+ window.MinionsDispatch = { renderEngineStatus, renderEngineAlert, renderAdoThrottleAlert, renderGhThrottleAlert, renderVersionBanner, renderDispatch, renderEngineLog, shortTime, showErrorDetails };
@@ -1,3 +1,5 @@
1
+ <div class="engine-alert" id="ado-throttle-alert" style="display:none"></div>
2
+ <div class="engine-alert" id="gh-throttle-alert" style="display:none"></div>
1
3
  <section>
2
4
  <div id="engine-quick-stats" style="display:flex;gap:16px;margin-bottom:12px;font-size:11px;color:var(--muted)"></div>
3
5
  </section>
package/dashboard.js CHANGED
@@ -22,6 +22,7 @@ const shared = require('./engine/shared');
22
22
  const queries = require('./engine/queries');
23
23
  const teams = require('./engine/teams');
24
24
  const ado = require('./engine/ado');
25
+ const gh = require('./engine/github');
25
26
  const os = require('os');
26
27
 
27
28
  const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, reopenWorkItem } = shared;
@@ -382,6 +383,8 @@ function getStatus() {
382
383
  verifyGuides: getVerifyGuides(),
383
384
  archivedPrds: getArchivedPrds(),
384
385
  engine: { ...getEngineState(), worktreeCount: _countWorktrees() },
386
+ adoThrottle: ado.getAdoThrottleState(),
387
+ ghThrottle: gh.getGhThrottleState(),
385
388
  dispatch: getDispatchQueue(),
386
389
  engineLog: getEngineLog(),
387
390
  metrics: getMetrics(),
package/engine/ado.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  const path = require('path');
7
7
  const shared = require('./shared');
8
- const { exec, execAsync, getAdoOrgBase, addPrLink, log, ts, dateStamp, PR_STATUS } = shared;
8
+ const { exec, execAsync, getAdoOrgBase, addPrLink, log, ts, dateStamp, PR_STATUS, createThrottleTracker } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
  const { mutateJsonFileLocked } = shared;
11
11
 
@@ -47,6 +47,11 @@ function votesToReviewStatus(votes) {
47
47
  let _adoTokenCache = { token: null, expiresAt: 0 };
48
48
  let _adoTokenFailedUntil = 0; // backoff: skip azureauth calls until this timestamp
49
49
 
50
+ // ─── ADO Throttle State ─────────────────────────────────────────────────────
51
+ // Tracks rate-limiting (HTTP 429/503) from ADO API responses.
52
+ // Uses shared createThrottleTracker factory: backoffMs starts at 60s, doubles, caps at 32 min.
53
+ const _adoThrottle = createThrottleTracker({ label: 'ado', baseBackoffMs: 60000, maxBackoffMs: 32 * 60000 });
54
+
50
55
  // ─── Auth Failure Tracking ──────────────────────────────────────────────────
51
56
  // Set when pollPrStatus encounters auth errors mid-loop. The engine checks this
52
57
  // to bypass the normal 6-tick cadence and re-poll on the next tick.
@@ -97,6 +102,14 @@ async function adoFetch(url, token, opts = {}) {
97
102
  signal: AbortSignal.timeout(30000),
98
103
  body,
99
104
  });
105
+ // ── Throttle detection: intercept 429/503 BEFORE generic !res.ok ──
106
+ if (res.status === 429 || res.status === 503) {
107
+ const retryAfterSec = parseInt(res.headers.get('Retry-After'), 10);
108
+ const retryAfterMs = (retryAfterSec > 0) ? retryAfterSec * 1000 : 0;
109
+ _adoThrottle.recordThrottle(retryAfterMs);
110
+ const state = _adoThrottle.getState();
111
+ throw new Error(`ADO API throttled (${res.status}): retry after ${Math.round((state.retryAfter - Date.now()) / 1000)}s`);
112
+ }
100
113
  if (!res.ok) throw new Error(`ADO API ${method} ${res.status}: ${res.statusText}`);
101
114
  const text = await res.text();
102
115
  if (!text || text.trimStart().startsWith('<')) {
@@ -110,7 +123,10 @@ async function adoFetch(url, token, opts = {}) {
110
123
  }
111
124
  throw new Error(`ADO returned HTML instead of JSON (likely auth redirect) for ${url.split('?')[0]}`);
112
125
  }
113
- return JSON.parse(text);
126
+ const json = JSON.parse(text);
127
+ // ── Success decay: decrement consecutiveHits, reset when fully recovered ──
128
+ _adoThrottle.recordSuccess();
129
+ return json;
114
130
  }
115
131
 
116
132
  /** Fetch raw text from ADO API (for build logs which aren't JSON). */
@@ -119,6 +135,14 @@ async function adoFetchText(url, token) {
119
135
  headers: { 'Authorization': `Bearer ${token}` },
120
136
  signal: AbortSignal.timeout(30000),
121
137
  });
138
+ // ── Throttle detection: intercept 429/503 BEFORE generic !res.ok ──
139
+ if (res.status === 429 || res.status === 503) {
140
+ const retryAfterSec = parseInt(res.headers.get('Retry-After'), 10);
141
+ const retryAfterMs = (retryAfterSec > 0) ? retryAfterSec * 1000 : 0;
142
+ _adoThrottle.recordThrottle(retryAfterMs);
143
+ const state = _adoThrottle.getState();
144
+ throw new Error(`ADO API throttled (${res.status}): retry after ${Math.round((state.retryAfter - Date.now()) / 1000)}s`);
145
+ }
122
146
  if (!res.ok) throw new Error(`ADO API ${res.status}: ${res.statusText}`);
123
147
  return res.text();
124
148
  }
@@ -858,6 +882,24 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
858
882
  };
859
883
  }
860
884
 
885
+ // ─── ADO Throttle Queries ────────────────────────────────────────────────────
886
+
887
+ /** Returns true if ADO is throttled and retryAfter hasn't elapsed. Auto-clears when retryAfter passes. */
888
+ const isAdoThrottled = () => _adoThrottle.isThrottled();
889
+
890
+ /** Returns a snapshot of the current throttle state. Calls isAdoThrottled() for a fresh value. */
891
+ const getAdoThrottleState = () => _adoThrottle.getState();
892
+
893
+ /** Reset throttle state — exported for testing only. */
894
+ function _resetAdoThrottle() {
895
+ _adoThrottle._reset();
896
+ }
897
+
898
+ /** Set throttle state directly — exported for testing only. */
899
+ function _setAdoThrottleForTest(state) {
900
+ _adoThrottle._setForTest(state);
901
+ }
902
+
861
903
  module.exports = {
862
904
  getAdoToken,
863
905
  adoFetch,
@@ -867,7 +909,11 @@ module.exports = {
867
909
  checkLiveReviewStatus,
868
910
  needsAdoPollRetry,
869
911
  isAdoAuthError, // exported for testing
912
+ isAdoThrottled,
913
+ getAdoThrottleState,
870
914
  fetchAdoPrMetadata,
871
915
  fetchSinglePrBuildStatus,
916
+ _resetAdoThrottle, // exported for testing
917
+ _setAdoThrottleForTest, // exported for testing
872
918
  };
873
919
 
package/engine/github.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  const shared = require('./shared');
8
- const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, addPrLink, getPrLinks, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES } = shared;
8
+ const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, addPrLink, getPrLinks, log, ts, dateStamp, PR_STATUS, PR_POLLABLE_STATUSES, createThrottleTracker } = shared;
9
9
  const { getPrs } = require('./queries');
10
10
  const path = require('path');
11
11
 
@@ -68,13 +68,34 @@ function resetSlugBackoff(slug) {
68
68
  }
69
69
  }
70
70
 
71
+ // ─── GitHub Rate-Limit Throttle ────────────────────────────────────────────
72
+ // Tracks rate-limiting from GitHub API (gh CLI exits non-zero with rate-limit messages).
73
+ // GitHub rate limits reset hourly, so cap at 60 min.
74
+ const _ghThrottle = createThrottleTracker({ label: 'gh', baseBackoffMs: 60000, maxBackoffMs: 60 * 60000 });
75
+
76
+ /** Returns true if GitHub is rate-limited and retryAfter hasn't elapsed. */
77
+ const isGhThrottled = () => _ghThrottle.isThrottled();
78
+
79
+ /** Returns a snapshot of the current GitHub throttle state. */
80
+ const getGhThrottleState = () => _ghThrottle.getState();
81
+
71
82
  /** Run a `gh api` call and parse JSON result. Returns null on failure. */
72
83
  async function ghApi(endpoint, slug) {
73
84
  try {
74
85
  const cmd = `gh api "repos/${slug}${endpoint}"`;
75
86
  const result = await execAsync(cmd, { timeout: 30000, encoding: 'utf-8' });
76
- return JSON.parse(result);
87
+ const parsed = JSON.parse(result);
88
+ _ghThrottle.recordSuccess();
89
+ return parsed;
77
90
  } catch (e) {
91
+ const msg = e?.message || e?.stderr || '';
92
+ // Detect GitHub rate-limit errors from gh CLI output
93
+ if (/rate.?limit|secondary rate|429/i.test(msg)) {
94
+ // Try to extract retry seconds from "N seconds" pattern in error message
95
+ const secMatch = msg.match(/(\d+)\s*seconds?/i);
96
+ const retryAfterMs = secMatch ? parseInt(secMatch[1], 10) * 1000 : 0;
97
+ _ghThrottle.recordThrottle(retryAfterMs);
98
+ }
78
99
  log('warn', `GitHub API error (${endpoint}): ${e.message}`);
79
100
  return null;
80
101
  }
@@ -731,10 +752,13 @@ module.exports = {
731
752
  pollPrHumanComments,
732
753
  reconcilePrs,
733
754
  checkLiveReviewStatus,
755
+ isGhThrottled,
756
+ getGhThrottleState,
734
757
  // Exported for testing
735
758
  isSlugInBackoff,
736
759
  recordSlugFailure,
737
760
  resetSlugBackoff,
738
761
  _ghPollBackoff,
762
+ _ghThrottle, // exported for testing
739
763
  };
740
764
 
package/engine/shared.js CHANGED
@@ -1050,6 +1050,59 @@ function formatTranscriptEntry(t) {
1050
1050
  return '### ' + (t.agent || 'agent') + ' (' + (t.type || '') + ', Round ' + (t.round || '?') + ')\n\n' + (t.content || '');
1051
1051
  }
1052
1052
 
1053
+ // ── Throttle Tracker Factory ────────────────────────────────────────────────
1054
+ // Generic rate-limit tracker reusable by both ADO and GitHub integrations.
1055
+ // Returns an object with recordThrottle, recordSuccess, isThrottled, getState.
1056
+
1057
+ function createThrottleTracker({ label, baseBackoffMs = 60000, maxBackoffMs = 32 * 60000 } = {}) {
1058
+ let state = { throttled: false, retryAfter: 0, consecutiveHits: 0, backoffMs: baseBackoffMs };
1059
+
1060
+ function recordThrottle(retryAfterMs) {
1061
+ state.consecutiveHits++;
1062
+ state.backoffMs = Math.min(state.backoffMs * 2, maxBackoffMs);
1063
+ const waitMs = (retryAfterMs > 0) ? retryAfterMs : state.backoffMs;
1064
+ state.throttled = true;
1065
+ state.retryAfter = Date.now() + waitMs;
1066
+ log('warn', `[${label}] Throttled — retry after ${Math.round(waitMs / 1000)}s, consecutive hits: ${state.consecutiveHits}`);
1067
+ }
1068
+
1069
+ function recordSuccess() {
1070
+ if (state.consecutiveHits > 0) {
1071
+ state.consecutiveHits--;
1072
+ if (state.consecutiveHits === 0) {
1073
+ state.throttled = false;
1074
+ state.backoffMs = baseBackoffMs;
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ function isThrottled() {
1080
+ if (!state.throttled) return false;
1081
+ if (Date.now() >= state.retryAfter) {
1082
+ // Auto-clear ALL state when retryAfter has elapsed
1083
+ state.throttled = false;
1084
+ state.backoffMs = baseBackoffMs;
1085
+ state.consecutiveHits = 0;
1086
+ return false;
1087
+ }
1088
+ return true;
1089
+ }
1090
+
1091
+ function getState() {
1092
+ return { throttled: isThrottled(), retryAfter: state.retryAfter, consecutiveHits: state.consecutiveHits };
1093
+ }
1094
+
1095
+ // Testing helpers
1096
+ function _reset() {
1097
+ state = { throttled: false, retryAfter: 0, consecutiveHits: 0, backoffMs: baseBackoffMs };
1098
+ }
1099
+ function _setForTest(overrides) {
1100
+ Object.assign(state, overrides);
1101
+ }
1102
+
1103
+ return { recordThrottle, recordSuccess, isThrottled, getState, _reset, _setForTest };
1104
+ }
1105
+
1053
1106
  module.exports = {
1054
1107
  MINIONS_DIR,
1055
1108
  PR_LINKS_PATH,
@@ -1113,5 +1166,6 @@ module.exports = {
1113
1166
  slugify,
1114
1167
  formatTranscriptEntry,
1115
1168
  _logBuffer, // exported for testing
1169
+ createThrottleTracker,
1116
1170
  };
1117
1171
 
package/engine.js CHANGED
@@ -1445,8 +1445,8 @@ function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
1445
1445
  // ─── Inbox Consolidation (extracted to engine/consolidation.js) ──────────────
1446
1446
 
1447
1447
  const { consolidateInbox } = require('./engine/consolidation');
1448
- const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview, needsAdoPollRetry, getAdoToken } = require('./engine/ado');
1449
- const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs, checkLiveReviewStatus: ghCheckLiveReview } = require('./engine/github');
1448
+ const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview, needsAdoPollRetry, getAdoToken, isAdoThrottled } = require('./engine/ado');
1449
+ const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs, checkLiveReviewStatus: ghCheckLiveReview, isGhThrottled } = require('./engine/github');
1450
1450
 
1451
1451
  // ─── State Snapshot ─────────────────────────────────────────────────────────
1452
1452
 
@@ -3130,11 +3130,15 @@ async function tickInner() {
3130
3130
  // Awaited so PR state is consistent before discoverWork reads it
3131
3131
  // Also re-polls early if previous tick had ADO auth failures (stale build status recovery)
3132
3132
  if (tickCount % adoPollStatusEvery === 0 || needsAdoPollRetry()) {
3133
- if (adoPollEnabled) {
3133
+ if (adoPollEnabled && !isAdoThrottled()) {
3134
3134
  try { await pollPrStatus(config); } catch (err) { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3135
+ } else if (adoPollEnabled && isAdoThrottled()) {
3136
+ log('info', '[ado] PR status poll skipped — throttled');
3135
3137
  }
3136
- if (ghPollEnabled) {
3138
+ if (ghPollEnabled && !isGhThrottled()) {
3137
3139
  try { await ghPollPrStatus(config); } catch (err) { log('warn', `GitHub PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3140
+ } else if (ghPollEnabled && isGhThrottled()) {
3141
+ log('info', '[gh] PR status poll skipped — throttled');
3138
3142
  }
3139
3143
  try { await processPendingRebases(config); } catch (err) { log('warn', `Pending rebase processing error: ${err?.message || err}`); }
3140
3144
  // Sync PR status back to PRD items (missing → done when active PR exists)
@@ -3157,11 +3161,15 @@ async function tickInner() {
3157
3161
 
3158
3162
  // 2.7. Poll PR threads for human comments (every adoPollCommentsEvery ticks, default ~12 minutes)
3159
3163
  if (tickCount % adoPollCommentsEvery === 0) {
3160
- if (adoPollEnabled) {
3164
+ if (adoPollEnabled && !isAdoThrottled()) {
3161
3165
  try { await pollPrHumanComments(config); } catch (err) { log('warn', `ADO PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3166
+ } else if (adoPollEnabled && isAdoThrottled()) {
3167
+ log('info', '[ado] PR comment poll skipped — throttled');
3162
3168
  }
3163
- if (ghPollEnabled) {
3169
+ if (ghPollEnabled && !isGhThrottled()) {
3164
3170
  try { await ghPollPrHumanComments(config); } catch (err) { log('warn', `GitHub PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
3171
+ } else if (ghPollEnabled && isGhThrottled()) {
3172
+ log('info', '[gh] PR comment poll skipped — throttled');
3165
3173
  }
3166
3174
  // Reconciliation runs regardless of poll flags — it's a recovery sweep, not a convenience poll
3167
3175
  try { await reconcilePrs(config); } catch (err) { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.953",
3
+ "version": "0.1.955",
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"