@yemi33/minions 0.1.492 → 0.1.494

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.492 (2026-04-07)
3
+ ## 0.1.494 (2026-04-07)
4
4
 
5
5
  ### Fixes
6
+ - GitHub PR poll backoff for inaccessible repos (closes #377) (#386)
7
+ - reconcile agent completions during engine downtime (closes #376) (#385)
6
8
  - settings button shows modal immediately with loading state
7
9
  - CC respects user scroll position — no auto-scroll when reading history
8
10
  - steering feedback — toast notification, ensure polling resumes
package/engine/cli.js CHANGED
@@ -221,17 +221,43 @@ const commands = {
221
221
  const hasError = output.includes('"is_error":true') || output.includes('"is_error": true');
222
222
  if (!hasResult && !hasError) continue;
223
223
 
224
- const isSuccess = hasResult && !hasError;
225
- const result = isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
226
-
227
- e.log('info', `Orphan recovery: ${agentId} (${item.id}) completed while engine was down — result: ${result}`);
224
+ let isSuccess = hasResult && !hasError;
228
225
 
229
- // Extract PRs from output
226
+ // Extract PRs from output first — if PRs were created, the agent succeeded
227
+ // regardless of intermediate error lines in the log
230
228
  let prsCreated = 0;
231
229
  try {
232
230
  prsCreated = lifecycle.syncPrsFromOutput(output, agentId, item.meta, config);
233
231
  } catch (err) { e.log('warn', `Orphan PR sync: ${err.message}`); }
234
232
 
233
+ // If PRs were created or a matching PR exists, treat as success
234
+ if (!isSuccess && prsCreated > 0) {
235
+ e.log('info', `Orphan recovery: ${agentId} (${item.id}) has ${prsCreated} PR(s) — overriding to success`);
236
+ isSuccess = true;
237
+ }
238
+
239
+ // Fallback: check pull-requests.json for a matching PR by work item ID
240
+ if (!isSuccess && item.meta?.item?.id) {
241
+ try {
242
+ const projName = item.meta.project?.name;
243
+ if (projName) {
244
+ const prPath = path.join(MINIONS_DIR, 'projects', projName, 'pull-requests.json');
245
+ const prs = safeJson(prPath) || [];
246
+ const matchingPr = prs.find(pr =>
247
+ (pr.prdItems || []).includes(item.meta.item.id) &&
248
+ pr.status !== 'abandoned' && pr.status !== 'closed'
249
+ );
250
+ if (matchingPr) {
251
+ e.log('info', `Orphan recovery: ${agentId} (${item.id}) has matching PR ${matchingPr.id} — overriding to success`);
252
+ isSuccess = true;
253
+ }
254
+ }
255
+ } catch (err) { e.log('warn', `Orphan PR lookup: ${err.message}`); }
256
+ }
257
+
258
+ const result = isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR;
259
+ e.log('info', `Orphan recovery: ${agentId} (${item.id}) completed while engine was down — result: ${result}`);
260
+
235
261
  // Update work item status
236
262
  if (item.meta?.item?.id) {
237
263
  const status = isSuccess ? WI_STATUS.DONE : WI_STATUS.FAILED;
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.js CHANGED
@@ -838,7 +838,10 @@ function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
838
838
  const prLinks = shared.getPrLinks();
839
839
  let reconciled = 0;
840
840
  for (const wi of items) {
841
- if (wi.status !== WI_STATUS.PENDING || wi._pr) continue;
841
+ // Reconcile pending items AND failed items that have a matching PR
842
+ // (failed items may have been incorrectly marked during engine downtime)
843
+ if (wi._pr && wi.status !== WI_STATUS.FAILED) continue;
844
+ if (wi.status !== WI_STATUS.PENDING && wi.status !== WI_STATUS.FAILED) continue;
842
845
  if (onlyIds && !onlyIds.has(wi.id)) continue;
843
846
 
844
847
  let exactPr = allPrs.find(pr => (pr.prdItems || []).includes(wi.id));
@@ -849,6 +852,10 @@ function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
849
852
  if (exactPr) {
850
853
  wi.status = WI_STATUS.DONE;
851
854
  wi._pr = exactPr.id;
855
+ // Clear failure artifacts if reconciling a previously failed item
856
+ if (wi.failReason) delete wi.failReason;
857
+ if (wi.failedAt) delete wi.failedAt;
858
+ if (!wi.completedAt) wi.completedAt = new Date().toISOString();
852
859
  reconciled++;
853
860
  }
854
861
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.492",
3
+ "version": "0.1.494",
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"