@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 +3 -1
- package/dashboard/js/render-prs.js +2 -2
- package/dashboard/styles.css +1 -0
- package/dashboard.js +1 -0
- package/engine/ado.js +4 -0
- package/engine/github.js +4 -0
- package/engine/routing.js +18 -8
- package/engine/shared.js +1 -0
- package/engine.js +61 -32
- package/package.json +1 -1
- package/routing.md +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
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 || '#';
|
package/dashboard/styles.css
CHANGED
|
@@ -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
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
//
|
|
129
|
-
const
|
|
130
|
-
|
|
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 (
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
if (
|
|
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
|
-
//
|
|
1695
|
-
|
|
1696
|
-
|
|
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
|
-
|
|
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 (
|
|
1742
|
-
if (
|
|
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
|
-
|
|
2034
|
-
|
|
2035
|
-
if (
|
|
2036
|
-
const
|
|
2037
|
-
if (
|
|
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}
|
|
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
|
-
|
|
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.
|
|
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_ |
|
|
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
|
|