@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 +3 -1
- package/engine/github.js +77 -1
- package/engine/shared.js +54 -33
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
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))
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
+
if (fd === null) {
|
|
219
|
+
lastErr = new Error(`Lock timeout: ${lockPath}`);
|
|
220
|
+
continue; // retry if attempts remain
|
|
221
|
+
}
|
|
209
222
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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.
|
|
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"
|