@yemi33/minions 0.1.379 → 0.1.381

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,6 +1,11 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.379 (2026-04-06)
3
+ ## 0.1.381 (2026-04-06)
4
+
5
+ ### Fixes
6
+ - clear failReason on all retry-to-pending paths
7
+ - clear failReason and noPr on no-PR retry reset
8
+ - PR duplicate race condition — convert safeWrite to mutateJsonFileLocked (#240)
4
9
 
5
10
  ### Other
6
11
  - [E2E] Fix review re-dispatch loop, dashboard write races, crash bugs, and failing tests (#232)
package/dashboard.js CHANGED
@@ -3482,34 +3482,34 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3482
3482
  const projects = shared.getProjects(CONFIG);
3483
3483
  const targetProject = projectName ? projects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) : projects[0];
3484
3484
  const prPath = targetProject ? shared.projectPrPath(targetProject) : path.join(MINIONS_DIR, 'pull-requests.json');
3485
- const prs = JSON.parse(safeRead(prPath) || '[]');
3486
3485
 
3487
3486
  // Extract PR number from URL
3488
3487
  const prNumMatch = url.match(/\/pull\/(\d+)|pullrequest\/(\d+)/);
3489
3488
  const prNum = prNumMatch ? (prNumMatch[1] || prNumMatch[2]) : Date.now().toString().slice(-6);
3490
3489
  const prId = 'PR-' + prNum;
3491
3490
 
3492
- if (prs.some(p => p.id === prId || p.url === url)) return jsonReply(res, 400, { error: 'PR already tracked' });
3493
-
3494
- // Save minimal entry — the existing pollPrStatus (ado.js/github.js) will
3495
- // fetch title, author, branch, build status, and review status on next poll cycle (~6 min).
3496
- // For auto-observe PRs, status='active' makes pollPrStatus process them.
3497
- // For context-only PRs, status='linked' skips polling but shows in agent context.
3498
- prs.push({
3499
- id: prId,
3500
- title: (title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
3501
- agent: 'human',
3502
- branch: '',
3503
- reviewStatus: autoObserve ? 'pending' : 'none',
3504
- status: autoObserve ? 'active' : 'linked',
3505
- created: new Date().toISOString(),
3506
- url,
3507
- prdItems: [],
3508
- _manual: true,
3509
- _autoObserve: !!autoObserve,
3510
- _context: context || '',
3511
- });
3512
- safeWrite(prPath, prs);
3491
+ // Atomic check-and-insert to prevent duplicates and races with polling loops
3492
+ let duplicate = false;
3493
+ mutateJsonFileLocked(prPath, (prs) => {
3494
+ if (!Array.isArray(prs)) prs = [];
3495
+ if (prs.some(p => p.id === prId || p.url === url)) { duplicate = true; return prs; }
3496
+ prs.push({
3497
+ id: prId,
3498
+ title: (title || 'PR #' + prNum + ' (polling...)').slice(0, 120),
3499
+ agent: 'human',
3500
+ branch: '',
3501
+ reviewStatus: autoObserve ? 'pending' : 'none',
3502
+ status: autoObserve ? 'active' : 'linked',
3503
+ created: new Date().toISOString(),
3504
+ url,
3505
+ prdItems: [],
3506
+ _manual: true,
3507
+ _autoObserve: !!autoObserve,
3508
+ _context: context || '',
3509
+ });
3510
+ return prs;
3511
+ }, { defaultValue: [] });
3512
+ if (duplicate) return jsonReply(res, 400, { error: 'PR already tracked' });
3513
3513
  invalidateStatusCache();
3514
3514
  return jsonReply(res, 200, { ok: true, id: prId });
3515
3515
  }},
package/engine/ado.js CHANGED
@@ -101,11 +101,12 @@ async function forEachActivePr(config, token, callback) {
101
101
 
102
102
  if (projectUpdated > 0) {
103
103
  mutateJsonFileLocked(shared.projectPrPath(project), (currentPrs) => {
104
- // Merge updated PRs into the locked copy by ID
105
- for (const updatedPr of prs) {
104
+ // Only merge back PRs that the callback actually modified — not the entire
105
+ // stale snapshot, which would overwrite concurrent writes from other code paths
106
+ for (const updatedPr of activePrs) {
106
107
  const idx = currentPrs.findIndex(p => p.id === updatedPr.id);
107
108
  if (idx >= 0) currentPrs[idx] = updatedPr;
108
- else currentPrs.push(updatedPr);
109
+ // Don't push if not found — it was deleted by another writer, respect that
109
110
  }
110
111
  return currentPrs;
111
112
  }, { defaultValue: [] });
package/engine/github.js CHANGED
@@ -72,10 +72,10 @@ async function forEachActiveGhPr(config, callback) {
72
72
 
73
73
  if (projectUpdated > 0) {
74
74
  mutateJsonFileLocked(projectPrPath(project), (currentPrs) => {
75
- for (const updatedPr of prs) {
75
+ // Only merge back PRs that the callback actually modified
76
+ for (const updatedPr of activePrs) {
76
77
  const idx = currentPrs.findIndex(p => p.id === updatedPr.id);
77
78
  if (idx >= 0) currentPrs[idx] = updatedPr;
78
- else currentPrs.push(updatedPr);
79
79
  }
80
80
  return currentPrs;
81
81
  }, { defaultValue: [] });
@@ -113,10 +113,10 @@ async function forEachActiveGhPr(config, callback) {
113
113
  }
114
114
  if (centralUpdated > 0) {
115
115
  mutateJsonFileLocked(centralPath, (currentPrs) => {
116
- for (const updatedPr of centralPrs) {
116
+ // Only merge back central PRs that the callback actually modified
117
+ for (const updatedPr of activeCentral) {
117
118
  const idx = currentPrs.findIndex(p => p.id === updatedPr.id);
118
119
  if (idx >= 0) currentPrs[idx] = updatedPr;
119
- else currentPrs.push(updatedPr);
120
120
  }
121
121
  return currentPrs;
122
122
  }, { defaultValue: [] });
@@ -714,66 +714,63 @@ function syncPrsFromOutput(output, agentId, meta, config) {
714
714
  function updatePrAfterReview(agentId, pr, project) {
715
715
 
716
716
  if (!pr?.id) return;
717
- const prs = getPrs(project);
718
- const target = prs.find(p => p.id === pr.id);
719
- if (!target) return;
720
717
 
721
718
  const config = getConfig();
722
719
  const reviewerName = config.agents[agentId]?.name || agentId;
723
- // Record the reviewer — actual verdict comes from ADO/GitHub votes via pollPrStatus.
724
- // Set to 'waiting' so pollPrStatus updates it with the real vote on next cycle.
725
720
  const dispatch = getDispatch();
726
721
  const completedEntry = (dispatch.completed || []).find(d => d.agent === agentId && d.type === 'review');
727
722
 
728
- // Set reviewStatus to 'waiting' (single source of truth synced from ADO/GitHub votes on next poll)
729
- target.reviewStatus = 'waiting';
730
- // Track when this PR was last reviewed — used by engine.js to prevent re-dispatch
731
- // until new commits are pushed (lastPushedAt > lastReviewedAt)
732
- target.lastReviewedAt = ts();
733
- target.minionsReview = {
734
- reviewer: reviewerName,
735
- reviewedAt: ts(),
736
- note: completedEntry?.task || ''
737
- };
738
- // Metrics update: don't track 'waiting' as a verdict — metrics are updated
739
- // when pollPrStatus syncs the actual vote to minionsReview.status.
740
- // The reviewer's reviewsDone counter is incremented in the main updateMetrics call.
741
-
742
- // Track reviewer for metrics purposes
723
+ const prPath = project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json');
724
+ let updatedTarget = null;
725
+ shared.mutateJsonFileLocked(prPath, (prs) => {
726
+ if (!Array.isArray(prs)) return prs;
727
+ const target = prs.find(p => p.id === pr.id);
728
+ if (!target) return prs;
729
+ target.reviewStatus = 'waiting';
730
+ target.lastReviewedAt = ts();
731
+ target.minionsReview = {
732
+ reviewer: reviewerName,
733
+ reviewedAt: ts(),
734
+ note: completedEntry?.task || ''
735
+ };
736
+ updatedTarget = { ...pr, ...target };
737
+ return prs;
738
+ }, { defaultValue: [] });
739
+
740
+ // Track reviewer for metrics purposes (separate file, separate lock)
743
741
  const authorAgentId = (pr.agent || '').toLowerCase();
744
742
  if (authorAgentId && config.agents?.[authorAgentId]) {
745
- const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
746
- const metrics = safeJson(metricsPath) || {};
747
- if (!metrics[authorAgentId]) metrics[authorAgentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
748
- if (!metrics[agentId]) metrics[agentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
749
- metrics[agentId].reviewsDone = (metrics[agentId].reviewsDone || 0) + 1;
750
- shared.safeWrite(metricsPath, metrics);
743
+ shared.mutateJsonFileLocked(path.join(ENGINE_DIR, 'metrics.json'), (metrics) => {
744
+ if (!metrics[authorAgentId]) metrics[authorAgentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
745
+ if (!metrics[agentId]) metrics[agentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
746
+ metrics[agentId].reviewsDone = (metrics[agentId].reviewsDone || 0) + 1;
747
+ return metrics;
748
+ }, { defaultValue: {} });
751
749
  }
752
750
 
753
- shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
754
- log('info', `Updated ${pr.id} → minions review: ${target.reviewStatus} by ${reviewerName}`);
755
- createReviewFeedbackForAuthor(agentId, { ...pr, ...target }, config);
751
+ log('info', `Updated ${pr.id} minions review: waiting by ${reviewerName}`);
752
+ if (updatedTarget) createReviewFeedbackForAuthor(agentId, updatedTarget, config);
756
753
  }
757
754
 
758
755
  function updatePrAfterFix(pr, project, source) {
759
756
 
760
757
  if (!pr?.id) return;
761
- const prs = getPrs(project);
762
- const target = prs.find(p => p.id === pr.id);
763
- if (!target) return;
764
-
765
- // Reset reviewStatus to 'waiting' for re-review (single source of truth)
766
- target.reviewStatus = 'waiting';
767
- if (source === 'pr-human-feedback') {
768
- if (target.humanFeedback) target.humanFeedback.pendingFix = false;
769
- target.minionsReview = { ...target.minionsReview, note: 'Fixed human feedback, awaiting re-review', fixedAt: ts() };
770
- log('info', `Updated ${pr.id} → cleared humanFeedback.pendingFix, reset to waiting for re-review`);
771
- } else {
772
- target.minionsReview = { ...target.minionsReview, note: 'Fixed, awaiting re-review', fixedAt: ts() };
773
- log('info', `Updated ${pr.id} → reviewStatus: waiting (fix pushed)`);
774
- }
775
-
776
- shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
758
+ const prPath = project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json');
759
+ shared.mutateJsonFileLocked(prPath, (prs) => {
760
+ if (!Array.isArray(prs)) return prs;
761
+ const target = prs.find(p => p.id === pr.id);
762
+ if (!target) return prs;
763
+ target.reviewStatus = 'waiting';
764
+ if (source === 'pr-human-feedback') {
765
+ if (target.humanFeedback) target.humanFeedback.pendingFix = false;
766
+ target.minionsReview = { ...target.minionsReview, note: 'Fixed human feedback, awaiting re-review', fixedAt: ts() };
767
+ log('info', `Updated ${pr.id} → cleared humanFeedback.pendingFix, reset to waiting for re-review`);
768
+ } else {
769
+ target.minionsReview = { ...target.minionsReview, note: 'Fixed, awaiting re-review', fixedAt: ts() };
770
+ log('info', `Updated ${pr.id} → reviewStatus: waiting (fix pushed)`);
771
+ }
772
+ return prs;
773
+ }, { defaultValue: [] });
777
774
  }
778
775
 
779
776
  // ─── Post-Merge / Post-Close Hooks ───────────────────────────────────────────
@@ -1196,7 +1193,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1196
1193
  if (wi.status === WI_STATUS.DONE || wi.completedAt) {
1197
1194
  log('info', `Skip retry for ${meta.item.id} — already completed`);
1198
1195
  } else {
1199
- wi._retryCount = retries + 1; wi.status = WI_STATUS.PENDING; delete wi.dispatched_at; delete wi.dispatched_to;
1196
+ wi._retryCount = retries + 1; wi.status = WI_STATUS.PENDING; delete wi.dispatched_at; delete wi.dispatched_to; delete wi.failReason;
1200
1197
  if (type === WORK_TYPE.DECOMPOSE) delete wi._decomposing;
1201
1198
  shared.safeWrite(wiPath, items);
1202
1199
  }
@@ -1317,6 +1314,8 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
1317
1314
  wi._retryCount = retries + 1;
1318
1315
  delete wi.dispatched_at;
1319
1316
  delete wi.dispatched_to;
1317
+ delete wi.failReason;
1318
+ delete wi.noPr;
1320
1319
  log('info', `Auto-retry ${retries + 1}/${ENGINE_DEFAULTS.maxRetries} for ${meta.item.id} (no output, no PR)`);
1321
1320
  } else if (hasOutput) {
1322
1321
  // Agent ran successfully but chose not to create a PR — mark done
package/engine/timeout.js CHANGED
@@ -254,6 +254,7 @@ function checkTimeouts(config) {
254
254
  delete item.dispatched_at;
255
255
  delete item.dispatched_to;
256
256
  delete item._pendingReason;
257
+ delete item.failReason;
257
258
  } else {
258
259
  log('warn', `Reconcile: work item ${item.id} failed after ${retries} retries — marking as failed`);
259
260
  item.status = WI_STATUS.FAILED;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.379",
3
+ "version": "0.1.381",
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"