@yemi33/minions 0.1.1567 → 0.1.1569

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,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1569 (2026-04-27)
4
+
5
+ ### Fixes
6
+ - avoid duplicate review retry loops
7
+
8
+ ## 0.1.1568 (2026-04-27)
9
+
10
+ ### Fixes
11
+ - smooth project add and scan UX
12
+
3
13
  ## 0.1.1567 (2026-04-27)
4
14
 
5
15
  ### Other
@@ -21,6 +21,28 @@ function renderProjects(projects) {
21
21
 
22
22
  }
23
23
 
24
+ function _projectCachePath(project) {
25
+ return String((project && (project.localPath || project.path)) || '').replace(/\\/g, '/');
26
+ }
27
+
28
+ function optimisticallyAddProject(project) {
29
+ if (!project || !project.name) return;
30
+ const next = {
31
+ name: project.name,
32
+ description: project.description || '',
33
+ localPath: _projectCachePath(project),
34
+ path: _projectCachePath(project),
35
+ };
36
+ if (!window._lastStatus) window._lastStatus = {};
37
+ const current = Array.isArray(window._lastStatus.projects) ? window._lastStatus.projects.slice() : [];
38
+ const nextPath = _projectCachePath(next);
39
+ if (current.some(function(p) { return p.name === next.name || (nextPath && _projectCachePath(p) === nextPath); })) return;
40
+ current.push(next);
41
+ window._lastStatus.projects = current;
42
+ if (typeof cmdUpdateProjectList === 'function') cmdUpdateProjectList(current);
43
+ renderProjects(current);
44
+ }
45
+
24
46
  async function projectChipRemove(name) {
25
47
  if (!confirm('Remove project "' + name + '"? Pending work cancels, active agents are killed, data dir is archived to projects/.archived/.')) return;
26
48
  showToast('cmd-toast', 'Removing project "' + name + '"...', true);
@@ -385,7 +407,18 @@ async function _addSelectedProjects() {
385
407
  method: 'POST', headers: { 'Content-Type': 'application/json' },
386
408
  body: JSON.stringify({ path: repo.path })
387
409
  });
388
- if (res.ok) { added++; cb.disabled = true; cb.closest('label').style.opacity = '0.5'; }
410
+ var data = await res.json().catch(function() { return {}; });
411
+ if (res.ok) {
412
+ added++;
413
+ optimisticallyAddProject({
414
+ name: data.name || repo.name,
415
+ description: (data.detected && data.detected.description) || repo.description || '',
416
+ path: data.path || repo.path,
417
+ localPath: data.path || repo.path,
418
+ });
419
+ cb.disabled = true;
420
+ cb.closest('label').style.opacity = '0.5';
421
+ }
389
422
  } catch { /* continue with next */ }
390
423
  }
391
424
  if (added > 0) {
@@ -394,4 +427,4 @@ async function _addSelectedProjects() {
394
427
  }
395
428
  }
396
429
 
397
- window.MinionsOther = { renderProjects, projectChipRemove, renderMcpServers, renderMetrics, renderLlmPerf, renderTokenUsage, openScanProjectsModal };
430
+ window.MinionsOther = { renderProjects, optimisticallyAddProject, projectChipRemove, renderMcpServers, renderMetrics, renderLlmPerf, renderTokenUsage, openScanProjectsModal };
@@ -384,6 +384,19 @@ async function addProject() {
384
384
  });
385
385
  const addData = await addRes.json();
386
386
  if (!addRes.ok) { alert('Failed: ' + (addData.error || 'unknown')); return; }
387
+ const addedProject = {
388
+ name: addData.name,
389
+ description: (addData.detected && addData.detected.description) || '',
390
+ path: addData.path,
391
+ localPath: addData.path,
392
+ };
393
+ if (_settingsData && Array.isArray(_settingsData.projects)) {
394
+ const exists = _settingsData.projects.some(function(p) {
395
+ return p.name === addedProject.name || String(p.localPath || p.path || '').replace(/\\/g, '/') === String(addedProject.localPath || '').replace(/\\/g, '/');
396
+ });
397
+ if (!exists) _settingsData.projects = _settingsData.projects.concat([addedProject]);
398
+ }
399
+ if (typeof optimisticallyAddProject === 'function') optimisticallyAddProject(addedProject);
387
400
  try { showToast('cmd-toast', 'Project "' + addData.name + '" added — restart engine to pick it up', true); } catch { /* expected */ }
388
401
  refresh();
389
402
  } catch (e) { alert('Error: ' + e.message); }
package/dashboard.js CHANGED
@@ -3807,6 +3807,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3807
3807
  return jsonReply(res, 200, { confirmToken: token, ttlMs: PROJECT_CONFIRM_TOKEN_TTL_MS });
3808
3808
  }
3809
3809
 
3810
+ function _execGitInRepo(repoPath, args, timeoutMs) {
3811
+ const { execFileSync } = require('child_process');
3812
+ return execFileSync('git', args, {
3813
+ cwd: repoPath,
3814
+ encoding: 'utf8',
3815
+ timeout: timeoutMs || 5000,
3816
+ stdio: ['ignore', 'pipe', 'pipe'],
3817
+ windowsHide: true,
3818
+ }).trim();
3819
+ }
3820
+
3810
3821
  async function handleProjectsAdd(req, res) {
3811
3822
  try {
3812
3823
  const body = await readBody(req);
@@ -3840,14 +3851,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3840
3851
  }
3841
3852
 
3842
3853
  // Auto-discover from git repo
3843
- const { execSync: ex } = require('child_process');
3844
3854
  const detected = { name: path.basename(target), _found: [] };
3845
3855
  try {
3846
- const head = ex('git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || git symbolic-ref HEAD', { cwd: target, encoding: 'utf8', timeout: 5000 }).trim();
3856
+ let head = '';
3857
+ try { head = _execGitInRepo(target, ['symbolic-ref', 'refs/remotes/origin/HEAD'], 5000); }
3858
+ catch { head = _execGitInRepo(target, ['symbolic-ref', 'HEAD'], 5000); }
3859
+ if (!head) throw new Error('empty git ref');
3847
3860
  detected.mainBranch = head.replace('refs/remotes/origin/', '').replace('refs/heads/', '');
3848
3861
  } catch { detected.mainBranch = 'main'; }
3849
3862
  try {
3850
- const remoteUrl = ex('git remote get-url origin', { cwd: target, encoding: 'utf8', timeout: 5000 }).trim();
3863
+ const remoteUrl = _execGitInRepo(target, ['remote', 'get-url', 'origin'], 5000);
3851
3864
  if (remoteUrl.includes('github.com')) {
3852
3865
  detected.repoHost = 'github';
3853
3866
  const m = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
@@ -3906,6 +3919,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3906
3919
  config.projects.push(project);
3907
3920
  safeWrite(configPath, config);
3908
3921
  reloadConfig(); // Update in-memory project list immediately
3922
+ invalidateStatusCache();
3909
3923
 
3910
3924
  // Create project-local state files
3911
3925
  const minionsDir = path.join(target, '.minions');
@@ -3969,7 +3983,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3969
3983
  const results = repos.map(repoPath => {
3970
3984
  const result = { path: repoPath.replace(/\\/g, '/'), name: path.basename(repoPath), host: 'git', linked: existingPaths.has(path.resolve(repoPath)) };
3971
3985
  try {
3972
- const remoteUrl = require('child_process').execSync('git remote get-url origin', { cwd: repoPath, encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
3986
+ const remoteUrl = _execGitInRepo(repoPath, ['remote', 'get-url', 'origin'], 3000);
3973
3987
  const gh = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
3974
3988
  const ado = remoteUrl.match(/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/\s]+)/) || remoteUrl.match(/([^.]+)\.visualstudio\.com.*?\/([^/]+)\/_git\/([^/\s]+)/);
3975
3989
  if (gh) { result.host = 'GitHub'; result.org = gh[1]; result.name = gh[2]; }
@@ -892,6 +892,23 @@ function parseReviewVerdict(text) {
892
892
  return null;
893
893
  }
894
894
 
895
+ /**
896
+ * Detect "idempotent bailout" output from a review agent — the agent saw a
897
+ * prior review on the PR (or the same dispatchKey re-fired) and chose to bail
898
+ * rather than spam a duplicate comment.
899
+ *
900
+ * Such output is intentionally short and contains no VERDICT keyword. Treating
901
+ * it as a retryable failure burns _retryCount and eventually flips the WI to
902
+ * status=failed even though the original review was successfully posted (#1770).
903
+ *
904
+ * @param {string} text - Agent output / resultSummary
905
+ * @returns {boolean}
906
+ */
907
+ function isReviewBailout(text) {
908
+ if (!text || typeof text !== 'string') return false;
909
+ return /bail(ing)?\s+out/i.test(text) || /already\s+posted/i.test(text);
910
+ }
911
+
895
912
  async function updatePrAfterReview(agentId, pr, project, config, resultSummary) {
896
913
 
897
914
  if (!pr?.id) return;
@@ -1657,9 +1674,18 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1657
1674
  // same pattern as plan-to-prd (#893): updateWorkItemStatus deletes _retryCount, so the check
1658
1675
  // must read/increment it before that happens. Also sets skipDoneStatus so completedAt isn't
1659
1676
  // written and then left dangling when status is reset to pending for retry.
1677
+ //
1678
+ // (#1770) Idempotent bailout: if the agent explicitly bailed because a review was
1679
+ // already posted (e.g. the WI got re-dispatched before lifecycle marked the first
1680
+ // run done), treat the run as success — fall through to mark DONE without retry.
1681
+ // Without this, the second run produces no VERDICT, _retryCount increments,
1682
+ // and after 3 such bailouts the WI flips to status=failed even though the
1683
+ // original review was posted on the first run.
1660
1684
  if (effectiveSuccess && type === WORK_TYPE.REVIEW && meta?.item?.id) {
1661
1685
  const verdict = parseReviewVerdict(resultSummary);
1662
- if (!verdict) {
1686
+ if (!verdict && isReviewBailout(resultSummary)) {
1687
+ log('info', `Review ${meta.item.id} bailed out (review already posted) — treating as DONE without retry`);
1688
+ } else if (!verdict) {
1663
1689
  skipDoneStatus = true;
1664
1690
  const wiPath = resolveWorkItemPath(meta);
1665
1691
  if (wiPath) {
@@ -1672,6 +1698,8 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
1672
1698
  if (retries < ENGINE_DEFAULTS.maxRetries) {
1673
1699
  w.status = WI_STATUS.PENDING;
1674
1700
  w._retryCount = retries + 1;
1701
+ w._lastRetryAt = ts();
1702
+ w._lastRetryReason = 'no review verdict';
1675
1703
  delete w.dispatched_at;
1676
1704
  delete w.completedAt;
1677
1705
  log('warn', `Review ${meta.item.id} completed without verdict — auto-retry ${retries + 1}/${ENGINE_DEFAULTS.maxRetries}`);
@@ -2162,6 +2190,7 @@ module.exports = {
2162
2190
  updateMetrics,
2163
2191
  parseAgentOutput,
2164
2192
  parseReviewVerdict,
2193
+ isReviewBailout,
2165
2194
  parseStructuredCompletion,
2166
2195
  runPostCompletionHooks,
2167
2196
  syncPrdFromPrs,
package/engine/shared.js CHANGED
@@ -758,6 +758,7 @@ const ENGINE_DEFAULTS = {
758
758
  evalMaxIterations: 3, // max review→fix cycles before escalating to human
759
759
  evalMaxCost: null, // USD ceiling per work item across all eval iterations; null = no limit (gather baseline data first)
760
760
  maxRetries: 3, // max dispatch retries before marking work item as failed
761
+ minRetryGapMs: 120000, // 2min — minimum gap between retry dispatches for the same work item; prevents tight retry loops when an idempotent agent (e.g. review bailing out on a duplicate) cannot produce the expected output (#1770)
761
762
  pipelineApiRetries: 2, // max attempts for pipeline API calls
762
763
  pipelineApiRetryDelay: 2000, // ms delay between pipeline API retries
763
764
  versionCheckInterval: 3600000, // 1 hour — how often to check npm for updates (ms)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1567",
3
+ "version": "0.1.1569",
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"