@yemi33/minions 0.1.555 → 0.1.557

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,11 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.555 (2026-04-07)
3
+ ## 0.1.557 (2026-04-08)
4
4
 
5
5
  ### Features
6
6
  - add pin/unpin button to inbox and KB document modals
7
7
 
8
8
  ### Fixes
9
+ - add max retry cap and escalation for build fix cycles (#497)
10
+ - defer dispatched status until spawnAgent succeeds + add _any_ routing token (#482)
9
11
  - move pin button to modal header next to Copy button
10
12
  - fetch actual build error logs instead of just reason string (#489)
11
13
 
@@ -12,8 +12,8 @@ function prRow(pr) {
12
12
  const reviewSource = sq.status || effectiveReviewStatus || 'pending';
13
13
  const reviewClass = reviewSource === 'approved' ? 'approved' : (reviewSource === 'changes-requested' || reviewSource === 'rejected') ? 'rejected' : reviewSource === 'waiting' ? 'building' : 'draft';
14
14
  const reviewLabel = sq.status === 'waiting' ? 'reviewing (minions)' : sq.status ? sq.status + ' (minions)' : (effectiveReviewStatus || 'pending');
15
- const buildClass = pr._buildStatusStale ? 'build-stale' : pr.buildStatus === 'passing' ? 'build-pass' : pr.buildStatus === 'failing' ? 'build-fail' : pr.buildStatus === 'running' ? 'building' : 'no-build';
16
- const buildLabel = (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '');
15
+ const buildClass = pr.buildFixEscalated ? 'build-escalated' : pr._buildStatusStale ? 'build-stale' : pr.buildStatus === 'passing' ? 'build-pass' : pr.buildStatus === 'failing' ? 'build-fail' : pr.buildStatus === 'running' ? 'building' : 'no-build';
16
+ const buildLabel = pr.buildFixEscalated ? 'escalated (' + (pr.buildFixAttempts || '?') + ' fixes)' : (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '');
17
17
  const statusClass = pr.status === 'merged' ? 'merged' : pr.status === 'abandoned' ? 'rejected' : pr.status === 'active' ? 'active' : 'draft';
18
18
  const statusLabel = pr.status || 'active';
19
19
  const url = pr.url || '#';
@@ -257,6 +257,7 @@
257
257
  .pr-badge.build-fail { background: rgba(248,81,73,0.15); color: var(--red); border: 1px solid var(--red); }
258
258
  .pr-badge.no-build { background: var(--surface); color: var(--muted); border: 1px solid var(--border); }
259
259
  .pr-badge.build-stale { background: rgba(210,153,34,0.15); color: var(--orange); border: 1px dashed var(--orange); }
260
+ .pr-badge.build-escalated { background: rgba(248,81,73,0.25); color: var(--red); border: 2px solid var(--red); font-weight: 600; }
260
261
  .error-details-btn { font-size: var(--text-xs); padding: var(--space-1) var(--space-3); margin-left: var(--space-2); background: rgba(248,81,73,0.15); color: var(--red); border: 1px solid var(--red); border-radius: var(--radius-lg); cursor: pointer; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; }
261
262
  .error-details-btn:hover { background: rgba(248,81,73,0.3); }
262
263
  .pr-empty { color: var(--muted); font-style: italic; font-size: var(--text-md); padding: var(--space-6) 0; }
package/dashboard.js CHANGED
@@ -3533,6 +3533,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3533
3533
  idleAlertMinutes: [1], shutdownTimeout: [30000], restartGracePeriod: [60000],
3534
3534
  meetingRoundTimeout: [60000],
3535
3535
  versionCheckInterval: [60000],
3536
+ maxBuildFixAttempts: [1, 10],
3536
3537
  };
3537
3538
  for (const [key, [min, max]] of Object.entries(numericFields)) {
3538
3539
  if (e[key] !== undefined) {
package/engine/ado.js CHANGED
@@ -254,6 +254,8 @@ async function pollPrStatus(config) {
254
254
  delete pr.buildFailReason;
255
255
  delete pr.buildErrorLog;
256
256
  delete pr._buildFailNotified;
257
+ delete pr.buildFixAttempts;
258
+ delete pr.buildFixEscalated;
257
259
  }
258
260
  await engine().handlePostMerge(pr, project, config, newStatus);
259
261
  }
@@ -365,6 +367,8 @@ async function pollPrStatus(config) {
365
367
  if (buildStatus !== 'failing') {
366
368
  delete pr._buildFailNotified;
367
369
  delete pr.buildErrorLog;
370
+ // Reset build fix retry counter on recovery — allows fresh auto-fix cycles if build breaks again
371
+ if (pr.buildFixAttempts) { delete pr.buildFixAttempts; delete pr.buildFixEscalated; }
368
372
  }
369
373
  updated = true;
370
374
 
package/engine/github.js CHANGED
@@ -294,6 +294,8 @@ async function pollPrStatus(config) {
294
294
  delete pr.buildFailReason;
295
295
  delete pr.buildErrorLog;
296
296
  delete pr._buildFailNotified;
297
+ delete pr.buildFixAttempts;
298
+ delete pr.buildFixEscalated;
297
299
  }
298
300
  await engine().handlePostMerge(pr, project, config, newStatus);
299
301
  }
@@ -393,6 +395,8 @@ async function pollPrStatus(config) {
393
395
  if (buildStatus !== 'failing') {
394
396
  delete pr._buildFailNotified;
395
397
  delete pr.buildErrorLog;
398
+ // Reset build fix retry counter on recovery — allows fresh auto-fix cycles if build breaks again
399
+ if (pr.buildFixAttempts) { delete pr.buildFixAttempts; delete pr.buildFixEscalated; }
396
400
  }
397
401
  updated = true;
398
402
 
package/engine/routing.js CHANGED
@@ -121,16 +121,26 @@ function resolveAgent(workType, config, authorAgent = null) {
121
121
  return true;
122
122
  };
123
123
 
124
- // Check preferred and fallback first (routing table order)
125
- if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
126
- if (fallback && isAvailable(fallback)) { _claimedAgents.add(fallback); return fallback; }
124
+ // Helper: pick any idle agent sorted by error rate
125
+ const pickAnyIdle = (exclude = []) => {
126
+ const excludeSet = new Set(exclude.filter(Boolean));
127
+ const idle = Object.keys(agents)
128
+ .filter(id => !excludeSet.has(id) && isAvailable(id))
129
+ .sort((a, b) => getAgentErrorRate(a) - getAgentErrorRate(b));
130
+ if (idle[0]) { _claimedAgents.add(idle[0]); return idle[0]; }
131
+ return null;
132
+ };
127
133
 
128
- // Fall back to any idle agent, preferring lower error rates
129
- const idle = Object.keys(agents)
130
- .filter(id => id !== preferred && id !== fallback && isAvailable(id))
131
- .sort((a, b) => getAgentErrorRate(a) - getAgentErrorRate(b));
134
+ // Resolve _any_ token — pick any available agent (#480)
135
+ if (preferred === '_any_') { const pick = pickAnyIdle(); if (pick) return pick; }
136
+ else if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
132
137
 
133
- if (idle[0]) { _claimedAgents.add(idle[0]); return idle[0]; }
138
+ if (fallback === '_any_') { const pick = pickAnyIdle([preferred]); if (pick) return pick; }
139
+ else if (fallback && isAvailable(fallback)) { _claimedAgents.add(fallback); return fallback; }
140
+
141
+ // Fall back to any idle agent, preferring lower error rates
142
+ const anyIdle = pickAnyIdle([preferred, fallback]);
143
+ if (anyIdle) return anyIdle;
134
144
 
135
145
  // No idle configured agent — try temp agent if enabled
136
146
  if (config.engine?.allowTempAgents) {
package/engine/shared.js CHANGED
@@ -525,6 +525,7 @@ const ENGINE_DEFAULTS = {
525
525
  logBufferSize: 50, // flush immediately when buffer exceeds this many entries
526
526
  lockRetries: 2, // retry lock acquisition this many times after initial timeout (total attempts = 1 + lockRetries)
527
527
  lockRetryBackoffMs: 500, // base backoff between lock retries (doubles each attempt: 500ms, 1s, 2s, ...)
528
+ maxBuildFixAttempts: 3, // max consecutive auto-fix dispatch cycles per PR before escalation to human
528
529
  };
529
530
 
530
531
  // ─── Status & Type Constants ─────────────────────────────────────────────────
package/engine.js CHANGED
@@ -1498,6 +1498,29 @@ async function discoverFromPrs(config, project) {
1498
1498
 
1499
1499
  // PRs with build failures — route to author (has session context from implementing)
1500
1500
  if (pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing') {
1501
+ const maxBuildFix = config.engine?.maxBuildFixAttempts ?? ENGINE_DEFAULTS.maxBuildFixAttempts;
1502
+
1503
+ // Check if max retry cap reached — escalate to human instead of dispatching another fix
1504
+ if ((pr.buildFixAttempts || 0) >= maxBuildFix) {
1505
+ if (!pr.buildFixEscalated) {
1506
+ writeInboxAlert(`build-fix-escalated-${pr.agent || 'unassigned'}-${pr.id}`,
1507
+ `# Build Fix Escalation\n\n` +
1508
+ `**PR ${pr.id}** on branch \`${pr.branch || 'unknown'}\` has failed **${pr.buildFixAttempts}** consecutive auto-fix attempts.\n` +
1509
+ `**Last failure:** ${pr.buildFailReason || 'Check CI pipeline for details'}\n\n` +
1510
+ `Auto-fix dispatch has been suspended. Please investigate manually.\n`
1511
+ );
1512
+ try {
1513
+ const prPath = projectPrPath(project);
1514
+ mutatePullRequests(prPath, prs => {
1515
+ const target = prs.find(p => p.id === pr.id);
1516
+ if (target) target.buildFixEscalated = true;
1517
+ });
1518
+ } catch (e) { log('warn', 'mark build fix escalated: ' + e.message); }
1519
+ log('warn', `PR ${pr.id}: build fix escalated after ${pr.buildFixAttempts} attempts — suspending auto-dispatch`);
1520
+ }
1521
+ continue;
1522
+ }
1523
+
1501
1524
  const key = `build-fix-${project?.name || 'default'}-${pr.id}`;
1502
1525
  if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
1503
1526
  const agentId = resolveAgent('fix', config, pr.agent);
@@ -1512,7 +1535,17 @@ async function discoverFromPrs(config, project) {
1512
1535
  pr_id: pr.id, pr_branch: pr.branch || '',
1513
1536
  review_note: reviewNote,
1514
1537
  }, `Fix build failure on PR ${pr.id}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
1515
- if (item) { newWork.push(item); setCooldown(key); }
1538
+ if (item) {
1539
+ newWork.push(item); setCooldown(key);
1540
+ // Increment build fix attempts counter
1541
+ try {
1542
+ const prPath = projectPrPath(project);
1543
+ mutatePullRequests(prPath, prs => {
1544
+ const target = prs.find(p => p.id === pr.id);
1545
+ if (target) target.buildFixAttempts = (target.buildFixAttempts || 0) + 1;
1546
+ });
1547
+ } catch (e) { log('warn', 'increment build fix attempts: ' + e.message); }
1548
+ }
1516
1549
 
1517
1550
  // Notify the author agent about the build failure
1518
1551
  if (pr.agent && !pr._buildFailNotified) {
@@ -1556,7 +1589,7 @@ function discoverFromWorkItems(config, project) {
1556
1589
  const items = safeJson(projectWorkItemsPath(project)) || [];
1557
1590
  const cooldownMs = (src.cooldownMinutes || 0) * 60 * 1000;
1558
1591
  const newWork = [];
1559
- const prdSyncQueue = [];
1592
+ // PRD sync for dispatched status deferred to spawnAgent success (#480)
1560
1593
  const skipped = { gated: 0, noAgent: 0 };
1561
1594
  let needsWrite = false;
1562
1595
  const selfHealKeys = new Set(); // Collect keys for batched self-heal (1 lock instead of N)
@@ -1603,10 +1636,11 @@ function discoverFromWorkItems(config, project) {
1603
1636
  needsWrite = true;
1604
1637
  }
1605
1638
  if (isAlreadyDispatched(key)) {
1606
- if (item.status === WI_STATUS.PENDING) { item.status = WI_STATUS.DISPATCHED; needsWrite = true; }
1607
- if (!item.dispatched_to) {
1608
- const existing = getDispatch().active?.find(d => d.meta?.dispatchKey === key);
1609
- if (existing?.agent) { item.dispatched_to = existing.agent; needsWrite = true; }
1639
+ // Only self-heal to DISPATCHED if actually in dispatch.active (agent spawned) (#480)
1640
+ const existingActive = getDispatch().active?.find(d => d.meta?.dispatchKey === key);
1641
+ if (existingActive) {
1642
+ if (item.status === WI_STATUS.PENDING) { item.status = WI_STATUS.DISPATCHED; needsWrite = true; }
1643
+ if (!item.dispatched_to && existingActive.agent) { item.dispatched_to = existingActive.agent; needsWrite = true; }
1610
1644
  }
1611
1645
  if (item._pendingReason !== 'already_dispatched') { item._pendingReason = 'already_dispatched'; needsWrite = true; }
1612
1646
  skipped.gated++; continue;
@@ -1691,12 +1725,11 @@ function discoverFromWorkItems(config, project) {
1691
1725
  continue;
1692
1726
  }
1693
1727
 
1694
- // Mark item as dispatched BEFORE adding to newWork (prevents race on next tick)
1695
- item.status = WI_STATUS.DISPATCHED;
1696
- item.dispatched_at = ts();
1697
- item.dispatched_to = agentId;
1728
+ // Don't mark dispatched here item stays pending until spawnAgent succeeds.
1729
+ // spawnAgent() atomically stamps dispatched_to/dispatched_at/status via locked write (#480).
1730
+ // isAlreadyDispatched(key) prevents re-discovery on subsequent ticks.
1698
1731
  delete item._pendingReason;
1699
- prdSyncQueue.push({ id: item.id, sourcePlan: item.sourcePlan });
1732
+ needsWrite = true;
1700
1733
 
1701
1734
  newWork.push({
1702
1735
  type: workType,
@@ -1738,12 +1771,9 @@ function discoverFromWorkItems(config, project) {
1738
1771
  }
1739
1772
  }
1740
1773
 
1741
- // Write back updated statuses (always, since we mark items dispatched before newWork check)
1742
- if (newWork.length > 0 || needsWrite) {
1774
+ // Write back updated statuses (pendingReason clears, checkpoint counts, decompose flags, etc.)
1775
+ if (needsWrite) {
1743
1776
  mutateWorkItems(projectWorkItemsPath(project), () => items);
1744
- if (newWork.length > 0) {
1745
- for (const s of prdSyncQueue) syncPrdItemStatus(s.id, 'dispatched', s.sourcePlan);
1746
- }
1747
1777
  }
1748
1778
 
1749
1779
  const skipTotal = skipped.gated + skipped.noAgent;
@@ -2030,13 +2060,14 @@ function discoverCentralWorkItems(config) {
2030
2060
  const key = `central-work-${item.id}`;
2031
2061
  // Self-heal: if already dispatched but work item is still pending, fix the status
2032
2062
  if (isAlreadyDispatched(key)) {
2033
- const m = {};
2034
- if (item.status === WI_STATUS.PENDING) { m.status = WI_STATUS.DISPATCHED; }
2035
- if (!item.dispatched_to) {
2036
- const existing = getDispatch().active?.find(d => d.meta?.dispatchKey === key);
2037
- if (existing?.agent) { m.dispatched_to = existing.agent; }
2063
+ // Only self-heal to DISPATCHED if actually in dispatch.active (agent spawned) (#480)
2064
+ const existingActive = getDispatch().active?.find(d => d.meta?.dispatchKey === key);
2065
+ if (existingActive) {
2066
+ const m = {};
2067
+ if (item.status === WI_STATUS.PENDING) { m.status = WI_STATUS.DISPATCHED; }
2068
+ if (!item.dispatched_to && existingActive.agent) { m.dispatched_to = existingActive.agent; }
2069
+ if (Object.keys(m).length > 0) mutations.set(item.id, m);
2038
2070
  }
2039
- if (Object.keys(m).length > 0) mutations.set(item.id, m);
2040
2071
  continue;
2041
2072
  }
2042
2073
  if (isOnCooldown(key, 0)) continue;
@@ -2109,15 +2140,13 @@ function discoverCentralWorkItems(config) {
2109
2140
  });
2110
2141
  }
2111
2142
 
2143
+ // Don't mark dispatched here — spawnAgent stamps status atomically (#480)
2112
2144
  mutations.set(item.id, {
2113
- status: WI_STATUS.DISPATCHED,
2114
- dispatched_at: ts(),
2115
- dispatched_to: idleAgents.map(a => a.id).join(', '),
2116
2145
  scope: 'fan-out',
2117
2146
  fanOutAgents: idleAgents.map(a => a.id),
2118
2147
  });
2119
2148
  setCooldown(key);
2120
- log('info', `Fan-out: ${item.id} dispatched to ${idleAgents.length} agents: ${idleAgents.map(a => a.name).join(', ')}`);
2149
+ log('info', `Fan-out: ${item.id} queued for ${idleAgents.length} agents: ${idleAgents.map(a => a.name).join(', ')}`);
2121
2150
 
2122
2151
  } else {
2123
2152
  // ─── Normal: single agent dispatch ──────────────────────────────
@@ -2215,12 +2244,7 @@ function discoverCentralWorkItems(config) {
2215
2244
  continue;
2216
2245
  }
2217
2246
 
2218
- const dispatchMutation = {
2219
- status: WI_STATUS.DISPATCHED,
2220
- dispatched_at: ts(),
2221
- dispatched_to: agentId,
2222
- };
2223
- mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, dispatchMutation));
2247
+ // Don't mark dispatched here — spawnAgent stamps status atomically (#480)
2224
2248
 
2225
2249
  newWork.push({
2226
2250
  type: workType,
@@ -2675,6 +2699,11 @@ async function tickInner() {
2675
2699
  }
2676
2700
  } else {
2677
2701
  dispatched.add(item.id);
2702
+ // Sync PRD item status after successful spawn (#480)
2703
+ if (item.meta?.item?.sourcePlan) {
2704
+ try { syncPrdItemStatus(item.meta.item.id, WI_STATUS.DISPATCHED, item.meta.item.sourcePlan); }
2705
+ catch (e) { log('warn', `prd sync after spawn: ${e.message}`); }
2706
+ }
2678
2707
  }
2679
2708
  }
2680
2709
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.555",
3
+ "version": "0.1.557",
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"
package/routing.md CHANGED
@@ -10,7 +10,7 @@ How the engine decides who handles what. Parsed by engine.js — keep the table
10
10
  | implement | dallas | ralph |
11
11
  | implement:large | rebecca | dallas |
12
12
  | review | ripley | lambert |
13
- | fix | _author_ | dallas |
13
+ | fix | _author_ | _any_ |
14
14
  | plan | ripley | rebecca |
15
15
  | plan-to-prd | lambert | rebecca |
16
16
  | explore | ripley | rebecca |
@@ -22,6 +22,7 @@ How the engine decides who handles what. Parsed by engine.js — keep the table
22
22
 
23
23
  Notes:
24
24
  - `_author_` means route to the PR author
25
+ - `_any_` means route to any available idle agent (lowest error rate first)
25
26
  - `implement:large` is for items with `estimated_complexity: "large"`
26
27
  - Engine falls back to any idle agent if both preferred and fallback are busy
27
28