@yemi33/minions 0.1.493 → 0.1.495

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,10 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.493 (2026-04-07)
3
+ ## 0.1.495 (2026-04-07)
4
4
 
5
5
  ### Fixes
6
+ - lock timeout retry with backoff to prevent tick cascade failures (#389)
7
+ - GitHub PR poll backoff for inaccessible repos (closes #377) (#386)
6
8
  - reconcile agent completions during engine downtime (closes #376) (#385)
7
9
  - settings button shows modal immediately with loading state
8
10
  - CC respects user scroll position — no auto-scroll when reading history
package/engine/github.js CHANGED
@@ -30,6 +30,44 @@ function getRepoSlug(project) {
30
30
  return `${org}/${repo}`;
31
31
  }
32
32
 
33
+ // ─── Per-Repo Poll Backoff ──────────────────────────────────────────────────
34
+ // Tracks consecutive poll failures per repo slug to avoid spamming logs when
35
+ // a repo is inaccessible. Backoff doubles each failure: 2min, 4min, 8min, 16min, max 30min.
36
+ const _ghPollBackoff = new Map(); // slug → { failures, backoffUntil }
37
+ const GH_POLL_BACKOFF_BASE_MS = 2 * 60 * 1000; // 2 minutes (one poll cycle)
38
+ const GH_POLL_BACKOFF_MAX_MS = 30 * 60 * 1000; // 30 minutes cap
39
+
40
+ /** Check if a repo slug is currently in backoff. Returns true if should skip. */
41
+ function isSlugInBackoff(slug) {
42
+ const entry = _ghPollBackoff.get(slug);
43
+ if (!entry) return false;
44
+ return Date.now() < entry.backoffUntil;
45
+ }
46
+
47
+ /** Record a poll failure for a repo slug, applying exponential backoff. */
48
+ function recordSlugFailure(slug) {
49
+ const existing = _ghPollBackoff.get(slug);
50
+ const failures = (existing?.failures || 0) + 1;
51
+ const backoffMs = Math.min(GH_POLL_BACKOFF_BASE_MS * Math.pow(2, failures - 1), GH_POLL_BACKOFF_MAX_MS);
52
+ _ghPollBackoff.set(slug, { failures, backoffUntil: Date.now() + backoffMs });
53
+ if (failures === 1) {
54
+ log('warn', `GitHub poll: repo ${slug} failed — will retry in ${Math.round(backoffMs / 1000)}s`);
55
+ } else {
56
+ log('warn', `GitHub poll: repo ${slug} failed ${failures} times — backoff ${Math.round(backoffMs / 1000)}s`);
57
+ }
58
+ }
59
+
60
+ /** Reset backoff for a repo slug after a successful poll. */
61
+ function resetSlugBackoff(slug) {
62
+ if (_ghPollBackoff.has(slug)) {
63
+ const entry = _ghPollBackoff.get(slug);
64
+ if (entry.failures > 0) {
65
+ log('info', `GitHub poll: repo ${slug} recovered after ${entry.failures} failure(s)`);
66
+ }
67
+ _ghPollBackoff.delete(slug);
68
+ }
69
+ }
70
+
33
71
  /** Run a `gh api` call and parse JSON result. Returns null on failure. */
34
72
  function ghApi(endpoint, slug) {
35
73
  try {
@@ -42,6 +80,20 @@ function ghApi(endpoint, slug) {
42
80
  }
43
81
  }
44
82
 
83
+ /**
84
+ * Run a `gh api` call with per-slug backoff tracking. Returns null on failure.
85
+ * On success, resets the slug's backoff. On failure, increments it.
86
+ */
87
+ function ghApiWithBackoff(endpoint, slug) {
88
+ const result = ghApi(endpoint, slug);
89
+ if (result === null) {
90
+ recordSlugFailure(slug);
91
+ } else {
92
+ resetSlugBackoff(slug);
93
+ }
94
+ return result;
95
+ }
96
+
45
97
  // ─── Shared PR Polling Loop ─────────────────────────────────────────────────
46
98
 
47
99
  async function forEachActiveGhPr(config, callback) {
@@ -52,10 +104,21 @@ async function forEachActiveGhPr(config, callback) {
52
104
  const slug = getRepoSlug(project);
53
105
  if (!slug) continue;
54
106
 
107
+ // Skip projects in backoff (inaccessible repo)
108
+ if (isSlugInBackoff(slug)) continue;
109
+
55
110
  const prs = getPrs(project);
56
111
  const activePrs = prs.filter(pr => pr.status === PR_STATUS.ACTIVE);
57
112
  if (activePrs.length === 0) continue;
58
113
 
114
+ // Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo
115
+ const probe = ghApi('', slug);
116
+ if (probe === null) {
117
+ recordSlugFailure(slug);
118
+ continue;
119
+ }
120
+ resetSlugBackoff(slug);
121
+
59
122
  let projectUpdated = 0;
60
123
 
61
124
  for (const pr of activePrs) {
@@ -101,6 +164,7 @@ async function forEachActiveGhPr(config, callback) {
101
164
  const ghMatch = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
102
165
  if (!ghMatch) continue;
103
166
  const slug = ghMatch[1];
167
+ if (isSlugInBackoff(slug)) continue;
104
168
  const prNum = ghMatch[2];
105
169
  try {
106
170
  const updated = await callback(null, pr, prNum, slug);
@@ -370,9 +434,16 @@ async function reconcilePrs(config) {
370
434
  const slug = getRepoSlug(project);
371
435
  if (!slug) continue;
372
436
 
437
+ // Skip projects in backoff (inaccessible repo)
438
+ if (isSlugInBackoff(slug)) continue;
439
+
373
440
  // Fetch open PRs
374
441
  const prsData = ghApi('/pulls?state=open&per_page=100', slug);
375
- if (!prsData || !Array.isArray(prsData)) continue;
442
+ if (!prsData || !Array.isArray(prsData)) {
443
+ recordSlugFailure(slug);
444
+ continue;
445
+ }
446
+ resetSlugBackoff(slug);
376
447
 
377
448
  const ghPrs = prsData.filter(pr => {
378
449
  const branch = pr.head?.ref || '';
@@ -498,5 +569,10 @@ module.exports = {
498
569
  pollPrHumanComments,
499
570
  reconcilePrs,
500
571
  checkLiveReviewStatus,
572
+ // Exported for testing
573
+ isSlugInBackoff,
574
+ recordSlugFailure,
575
+ resetSlugBackoff,
576
+ _ghPollBackoff,
501
577
  };
502
578
 
package/engine/shared.js CHANGED
@@ -174,51 +174,70 @@ const LOCK_STALE_MS = 60000; // 60 seconds — force-remove locks older than thi
174
174
 
175
175
  function withFileLock(lockPath, fn, {
176
176
  timeoutMs = 5000,
177
- retryDelayMs = 25
177
+ retryDelayMs = 25,
178
+ retries = 0,
179
+ retryBackoffMs = 1000
178
180
  } = {}) {
179
- const start = Date.now();
180
- let fd = null;
181
- while (Date.now() - start < timeoutMs) {
182
- try {
183
- const dir = path.dirname(lockPath);
184
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
185
- fd = fs.openSync(lockPath, 'wx');
186
- break;
187
- } catch (err) {
188
- if (err.code !== 'EEXIST') throw err;
189
- // Check for stale lock if lock file is older than LOCK_STALE_MS, force-remove it
181
+ let lastErr = null;
182
+ const maxAttempts = 1 + Math.max(0, retries);
183
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
184
+ if (attempt > 0) {
185
+ // Exponential backoff between retry attempts: retryBackoffMs * 2^(attempt-1)
186
+ const backoff = retryBackoffMs * Math.pow(2, attempt - 1);
187
+ sleepMs(backoff);
188
+ }
189
+ const start = Date.now();
190
+ let fd = null;
191
+ while (Date.now() - start < timeoutMs) {
190
192
  try {
191
- const stat = fs.statSync(lockPath);
192
- if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
193
- try {
194
- fs.unlinkSync(lockPath);
195
- } catch (unlinkErr) {
196
- // ENOENT: another process deleted the lock between stat and unlink — safe to retry
197
- if (unlinkErr.code !== 'ENOENT') throw unlinkErr;
193
+ const dir = path.dirname(lockPath);
194
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
195
+ fd = fs.openSync(lockPath, 'wx');
196
+ break;
197
+ } catch (err) {
198
+ if (err.code !== 'EEXIST') throw err;
199
+ // Check for stale lock — if lock file is older than LOCK_STALE_MS, force-remove it
200
+ try {
201
+ const stat = fs.statSync(lockPath);
202
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
203
+ try {
204
+ fs.unlinkSync(lockPath);
205
+ } catch (unlinkErr) {
206
+ // ENOENT: another process deleted the lock between stat and unlink — safe to retry
207
+ if (unlinkErr.code !== 'ENOENT') throw unlinkErr;
208
+ }
209
+ continue; // lock just removed — retry immediately
198
210
  }
199
- continue; // lock just removed — retry immediately
211
+ } catch (staleErr) {
212
+ // ENOENT from statSync: lock file disappeared between EEXIST and stat — retry will succeed
213
+ if (staleErr.code !== 'ENOENT') throw staleErr;
200
214
  }
201
- } catch (staleErr) {
202
- // ENOENT from statSync: lock file disappeared between EEXIST and stat — retry will succeed
203
- if (staleErr.code !== 'ENOENT') throw staleErr;
215
+ sleepMs(retryDelayMs);
204
216
  }
205
- sleepMs(retryDelayMs);
206
217
  }
207
- }
208
- if (fd === null) throw new Error(`Lock timeout: ${lockPath}`);
218
+ if (fd === null) {
219
+ lastErr = new Error(`Lock timeout: ${lockPath}`);
220
+ continue; // retry if attempts remain
221
+ }
209
222
 
210
- try {
211
- return fn();
212
- } finally {
213
- try { fs.closeSync(fd); } catch { /* cleanup */ }
214
- try { fs.unlinkSync(lockPath); } catch { /* cleanup */ }
223
+ try {
224
+ return fn();
225
+ } finally {
226
+ try { fs.closeSync(fd); } catch { /* cleanup */ }
227
+ try { fs.unlinkSync(lockPath); } catch { /* cleanup */ }
228
+ }
215
229
  }
230
+ throw lastErr;
216
231
  }
217
232
 
218
233
  function mutateJsonFileLocked(filePath, mutateFn, {
219
- defaultValue = {}
234
+ defaultValue = {},
235
+ lockRetries,
236
+ lockRetryBackoffMs
220
237
  } = {}) {
221
238
  const lockPath = `${filePath}.lock`;
239
+ const retries = lockRetries ?? ENGINE_DEFAULTS.lockRetries;
240
+ const retryBackoffMs = lockRetryBackoffMs ?? ENGINE_DEFAULTS.lockRetryBackoffMs;
222
241
  return withFileLock(lockPath, () => {
223
242
  let data = safeJson(filePath);
224
243
  if (data === null || typeof data !== 'object') data = Array.isArray(defaultValue) ? [...defaultValue] : { ...defaultValue };
@@ -229,7 +248,7 @@ function mutateJsonFileLocked(filePath, mutateFn, {
229
248
  const finalData = next === undefined ? data : next;
230
249
  safeWrite(filePath, finalData);
231
250
  return finalData;
232
- });
251
+ }, { retries, retryBackoffMs });
233
252
  }
234
253
 
235
254
  /**
@@ -479,6 +498,8 @@ const ENGINE_DEFAULTS = {
479
498
  versionCheckInterval: 3600000, // 1 hour — how often to check npm for updates (ms)
480
499
  logFlushInterval: 5000, // 5s — how often to flush buffered log entries to disk
481
500
  logBufferSize: 50, // flush immediately when buffer exceeds this many entries
501
+ lockRetries: 2, // retry lock acquisition this many times after initial timeout (total attempts = 1 + lockRetries)
502
+ lockRetryBackoffMs: 500, // base backoff between lock retries (doubles each attempt: 500ms, 1s, 2s, ...)
482
503
  };
483
504
 
484
505
  // ─── Status & Type Constants ─────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.493",
3
+ "version": "0.1.495",
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"