@yemi33/minions 0.1.181 → 0.1.183
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 +21 -0
- package/dashboard/js/render-pipelines.js +8 -32
- package/dashboard.js +10 -1
- package/engine/lifecycle.js +108 -132
- package/engine/shared.js +20 -39
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.183 (2026-04-02)
|
|
4
|
+
|
|
5
|
+
### Dashboard
|
|
6
|
+
- dashboard.js
|
|
7
|
+
|
|
8
|
+
### Other
|
|
9
|
+
- test/unit.test.js
|
|
10
|
+
|
|
11
|
+
## 0.1.182 (2026-04-02)
|
|
12
|
+
|
|
13
|
+
### Engine
|
|
14
|
+
- engine/lifecycle.js
|
|
15
|
+
- engine/shared.js
|
|
16
|
+
|
|
17
|
+
### Dashboard
|
|
18
|
+
- dashboard.js
|
|
19
|
+
- dashboard/js/render-pipelines.js
|
|
20
|
+
|
|
21
|
+
### Other
|
|
22
|
+
- test/unit.test.js
|
|
23
|
+
|
|
3
24
|
## 0.1.181 (2026-04-02)
|
|
4
25
|
|
|
5
26
|
### Engine
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
// render-pipelines.js — Pipeline list, run detail, and create modal
|
|
2
2
|
|
|
3
3
|
let _pipelinesData = [];
|
|
4
|
-
const PIPELINES_PER_PAGE = 10;
|
|
5
|
-
let _pipelinesPage = 0;
|
|
6
|
-
|
|
7
|
-
function _pipelinesPrev() { if (_pipelinesPage > 0) { _pipelinesPage--; refresh(); } }
|
|
8
|
-
function _pipelinesNext() { _pipelinesPage++; refresh(); }
|
|
9
4
|
|
|
10
5
|
/**
|
|
11
6
|
* Render clickable artifact links for a pipeline stage.
|
|
@@ -70,8 +65,7 @@ function _collectRunArtifacts(run) {
|
|
|
70
65
|
}
|
|
71
66
|
|
|
72
67
|
function renderPipelines(pipelines) {
|
|
73
|
-
_pipelinesData =
|
|
74
|
-
pipelines = _pipelinesData;
|
|
68
|
+
_pipelinesData = pipelines || [];
|
|
75
69
|
const el = document.getElementById('pipelines-content');
|
|
76
70
|
const countEl = document.getElementById('pipelines-count');
|
|
77
71
|
if (!el) return;
|
|
@@ -82,24 +76,7 @@ function renderPipelines(pipelines) {
|
|
|
82
76
|
}
|
|
83
77
|
countEl.textContent = pipelines.length;
|
|
84
78
|
|
|
85
|
-
|
|
86
|
-
if (_pipelinesPage >= totalPipelinePages) _pipelinesPage = totalPipelinePages - 1;
|
|
87
|
-
if (_pipelinesPage < 0) _pipelinesPage = 0;
|
|
88
|
-
const pipStart = _pipelinesPage * PIPELINES_PER_PAGE;
|
|
89
|
-
const pagePipelines = pipelines.slice(pipStart, pipStart + PIPELINES_PER_PAGE);
|
|
90
|
-
|
|
91
|
-
var pipelinePagerHtml = '';
|
|
92
|
-
if (pipelines.length > PIPELINES_PER_PAGE) {
|
|
93
|
-
pipelinePagerHtml = '<div class="pr-pager">' +
|
|
94
|
-
'<span class="pr-page-info">' + (pipStart + 1) + '-' + Math.min(pipStart + PIPELINES_PER_PAGE, pipelines.length) + ' of ' + pipelines.length + '</span>' +
|
|
95
|
-
'<div class="pr-pager-btns">' +
|
|
96
|
-
'<button class="pr-pager-btn ' + (_pipelinesPage === 0 ? 'disabled' : '') + '" onclick="_pipelinesPrev()">Prev</button>' +
|
|
97
|
-
'<button class="pr-pager-btn ' + (_pipelinesPage >= totalPipelinePages - 1 ? 'disabled' : '') + '" onclick="_pipelinesNext()">Next</button>' +
|
|
98
|
-
'</div>' +
|
|
99
|
-
'</div>';
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
el.innerHTML = pagePipelines.map(function(p) {
|
|
79
|
+
el.innerHTML = pipelines.map(function(p) {
|
|
103
80
|
const activeRun = (p.runs || []).find(function(r) { return r.status === 'running'; });
|
|
104
81
|
const lastRun = (p.runs || []).slice(-1)[0];
|
|
105
82
|
const statusColor = activeRun ? 'var(--blue)' : lastRun?.status === 'completed' ? 'var(--green)' : lastRun?.status === 'failed' ? 'var(--red)' : 'var(--muted)';
|
|
@@ -116,7 +93,7 @@ function renderPipelines(pipelines) {
|
|
|
116
93
|
|
|
117
94
|
// Build step-progress indicator for pipelines with a run
|
|
118
95
|
var progressHtml = '';
|
|
119
|
-
var displayRun = activeRun;
|
|
96
|
+
var displayRun = activeRun || lastRun;
|
|
120
97
|
if (displayRun && (p.stages || []).length > 0) {
|
|
121
98
|
var totalStages = (p.stages || []).length;
|
|
122
99
|
var completedCount = 0;
|
|
@@ -165,7 +142,7 @@ function renderPipelines(pipelines) {
|
|
|
165
142
|
'<div style="margin-top:6px;display:flex;gap:4px;align-items:center;flex-wrap:wrap">' + stageFlow + '</div>' +
|
|
166
143
|
progressHtml +
|
|
167
144
|
'</div>';
|
|
168
|
-
}).join('')
|
|
145
|
+
}).join('');
|
|
169
146
|
}
|
|
170
147
|
|
|
171
148
|
function openPipelineDetail(id) {
|
|
@@ -187,7 +164,7 @@ function openPipelineDetail(id) {
|
|
|
187
164
|
'</div>';
|
|
188
165
|
|
|
189
166
|
// Stage detail with progress bar
|
|
190
|
-
var detailRun = activeRun;
|
|
167
|
+
var detailRun = activeRun || (p.runs || []).slice(-1)[0];
|
|
191
168
|
if (detailRun && (p.stages || []).length > 0) {
|
|
192
169
|
var dtotal = (p.stages || []).length;
|
|
193
170
|
var ddone = 0, drun = 0, dfail = 0;
|
|
@@ -271,8 +248,8 @@ async function _togglePipelineEnabled(id, enabled, btn) {
|
|
|
271
248
|
try {
|
|
272
249
|
var res = await fetch('/api/pipelines/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id, enabled: enabled }) });
|
|
273
250
|
if (res.ok) { showToast('cmd-toast', enabled ? 'Pipeline enabled' : 'Pipeline disabled', true); refresh(); }
|
|
274
|
-
else { alert('Failed');
|
|
275
|
-
} catch (e) { alert('Error: ' + e.message);
|
|
251
|
+
else { alert('Failed'); }
|
|
252
|
+
} catch (e) { alert('Error: ' + e.message); }
|
|
276
253
|
if (btn) { btn.textContent = enabled ? 'Disable' : 'Enable'; btn.style.pointerEvents = ''; }
|
|
277
254
|
}
|
|
278
255
|
|
|
@@ -371,13 +348,12 @@ function _updatePlCronPreview() {
|
|
|
371
348
|
}
|
|
372
349
|
|
|
373
350
|
async function _submitCreatePipeline() {
|
|
374
|
-
var btn = event?.target; if (btn) { btn.disabled = true; btn.textContent = 'Creating...'; }
|
|
375
351
|
var id = document.getElementById('pl-id')?.value?.trim();
|
|
376
352
|
var title = document.getElementById('pl-title')?.value?.trim();
|
|
377
353
|
var useCron = document.getElementById('pl-use-cron')?.checked;
|
|
378
354
|
var cron = useCron ? (window._plComputedCron || '') : '';
|
|
379
355
|
var stagesRaw = document.getElementById('pl-stages')?.value?.trim();
|
|
380
|
-
if (!id || !title) {
|
|
356
|
+
if (!id || !title) { alert('ID and title required'); return; }
|
|
381
357
|
var stages;
|
|
382
358
|
try { stages = JSON.parse(stagesRaw); } catch (e) { alert('Invalid JSON in stages: ' + e.message); return; }
|
|
383
359
|
if (!Array.isArray(stages) || stages.length === 0) { alert('Stages must be a non-empty array'); return; }
|
package/dashboard.js
CHANGED
|
@@ -370,7 +370,16 @@ I'll save that as a note and dispatch dallas to fix the bug.
|
|
|
370
370
|
If no actions are needed (just answering a question, or you handled it directly), do NOT include the ===ACTIONS=== line.
|
|
371
371
|
|
|
372
372
|
Available action types:
|
|
373
|
-
- **dispatch**: Create a work item for an agent. Fields: title, workType
|
|
373
|
+
- **dispatch**: Create a work item for an agent. Fields: title, workType, priority (low/medium/high), agents (array of IDs, optional), project, description.
|
|
374
|
+
workType values — choose carefully, this determines the playbook and whether a PR is expected:
|
|
375
|
+
- \`explore\` — research, investigate, read code, gather information, assess quality, write findings (NO PR expected)
|
|
376
|
+
- \`ask\` — answer a question, analyze something, produce a report (NO PR expected)
|
|
377
|
+
- \`implement\` — write new code, add a feature, create something (PR expected)
|
|
378
|
+
- \`fix\` — fix a bug, address review feedback on an existing PR (PR expected)
|
|
379
|
+
- \`review\` — code review an existing PR (NO PR expected)
|
|
380
|
+
- \`test\` — run tests, write test cases (PR expected if new tests written)
|
|
381
|
+
- \`verify\` — build PRs locally, merge branches, start dev server, get localhost URL to test (NO PR expected)
|
|
382
|
+
If unsure, prefer \`explore\` for read-only tasks and \`implement\` for write tasks.
|
|
374
383
|
- **note**: Save a note/decision. Fields: title, content
|
|
375
384
|
- **plan**: Create a multi-step plan. Fields: title, description, project, branchStrategy (parallel/shared-branch)
|
|
376
385
|
- **cancel**: Cancel a running agent. Fields: agent (agent ID), reason
|
package/engine/lifecycle.js
CHANGED
|
@@ -58,7 +58,7 @@ function checkPlanCompletion(meta, config) {
|
|
|
58
58
|
const unmaterialized = [...planFeatureIds].filter(id => {
|
|
59
59
|
if (workItemById[id]) return false;
|
|
60
60
|
const prdItem = (plan.missing_features || []).find(f => f.id === id);
|
|
61
|
-
return !(prdItem && prdItem.status === 'done');
|
|
61
|
+
return !(prdItem && (prdItem.status === 'done'));
|
|
62
62
|
});
|
|
63
63
|
if (unmaterialized.length > 0) {
|
|
64
64
|
log('info', `Plan ${planFile}: ${unmaterialized.length}/${planFeatureIds.size} feature(s) not yet materialized as work items: ${unmaterialized.join(', ')}`);
|
|
@@ -68,9 +68,9 @@ function checkPlanCompletion(meta, config) {
|
|
|
68
68
|
// Check 2: every feature's work item must be done (or PRD item marked done externally)
|
|
69
69
|
const notDone = [...planFeatureIds].filter(id => {
|
|
70
70
|
const w = workItemById[id];
|
|
71
|
-
if (w && w.status === 'done') return false;
|
|
71
|
+
if (w && (w.status === 'done')) return false; // in-pr accepted for backward compat
|
|
72
72
|
const prdItem = (plan.missing_features || []).find(f => f.id === id);
|
|
73
|
-
return !(prdItem && prdItem.status === 'done');
|
|
73
|
+
return !(prdItem && (prdItem.status === 'done'));
|
|
74
74
|
});
|
|
75
75
|
if (notDone.length > 0) {
|
|
76
76
|
log('info', `Plan ${planFile}: waiting for done on ${notDone.length}/${planFeatureIds.size} item(s): ${notDone.join(', ')}`);
|
|
@@ -478,7 +478,6 @@ function updateWorkItemStatus(meta, status, reason) {
|
|
|
478
478
|
if (status === 'done') {
|
|
479
479
|
delete target.failReason;
|
|
480
480
|
delete target.failedAt;
|
|
481
|
-
delete target._retryCount;
|
|
482
481
|
target.completedAt = ts();
|
|
483
482
|
} else if (status === 'failed') {
|
|
484
483
|
if (reason) target.failReason = reason;
|
|
@@ -495,13 +494,12 @@ function updateWorkItemStatus(meta, status, reason) {
|
|
|
495
494
|
}
|
|
496
495
|
|
|
497
496
|
function syncPrdItemStatus(itemId, status, sourcePlan) {
|
|
498
|
-
if (!itemId
|
|
497
|
+
if (!itemId) return;
|
|
499
498
|
try {
|
|
500
499
|
const prdDir = path.join(MINIONS_DIR, 'prd');
|
|
501
|
-
const files = [sourcePlan];
|
|
500
|
+
const files = sourcePlan ? [sourcePlan] : require('fs').readdirSync(prdDir).filter(f => f.endsWith('.json'));
|
|
502
501
|
for (const pf of files) {
|
|
503
502
|
const fpath = path.join(prdDir, pf);
|
|
504
|
-
if (!fs.existsSync(fpath)) continue; // skip archived/deleted PRDs
|
|
505
503
|
mutateJsonFileLocked(fpath, (plan) => {
|
|
506
504
|
if (!plan?.missing_features) return plan;
|
|
507
505
|
const feature = plan.missing_features.find(f => f.id === itemId);
|
|
@@ -605,14 +603,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
605
603
|
dirtyTargets.set(targetName, { prs: safeJson(prPath) || [], prPath });
|
|
606
604
|
}
|
|
607
605
|
const entry = dirtyTargets.get(targetName);
|
|
608
|
-
|
|
609
|
-
if (existing) {
|
|
610
|
-
// Backfill prdItems if the entry was added by the poller before syncPrsFromOutput ran
|
|
611
|
-
if (meta?.item?.id && !existing.prdItems?.includes(meta.item.id)) {
|
|
612
|
-
existing.prdItems = [...(existing.prdItems || []), meta.item.id];
|
|
613
|
-
}
|
|
614
|
-
continue;
|
|
615
|
-
}
|
|
606
|
+
if (entry.prs.some(p => p.id === fullId || String(p.id) === String(prId))) continue;
|
|
616
607
|
|
|
617
608
|
let title = meta?.item?.title || '';
|
|
618
609
|
const titleMatch = output.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
|
|
@@ -633,7 +624,10 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
633
624
|
sourcePlan: meta?.item?.sourcePlan || '',
|
|
634
625
|
itemType: meta?.item?.itemType || ''
|
|
635
626
|
});
|
|
636
|
-
if (meta?.item?.id)
|
|
627
|
+
if (meta?.item?.id) {
|
|
628
|
+
const project = config ? shared.getProjects(config)[0] : null;
|
|
629
|
+
if (project) shared.linkPrToItem(project, fullId, meta.item.id);
|
|
630
|
+
}
|
|
637
631
|
added++;
|
|
638
632
|
}
|
|
639
633
|
|
|
@@ -646,26 +640,10 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
646
640
|
|
|
647
641
|
// ─── Post-Completion Hooks ──────────────────────────────────────────────────
|
|
648
642
|
|
|
649
|
-
/**
|
|
650
|
-
* Resolve which project's pull-requests.json contains a given PR ID.
|
|
651
|
-
* Returns the project object, or null if not found in any project file.
|
|
652
|
-
*/
|
|
653
|
-
function resolveProjectForPr(prId) {
|
|
654
|
-
const config = getConfig();
|
|
655
|
-
for (const p of shared.getProjects(config)) {
|
|
656
|
-
const prs = safeJson(projectPrPath(p)) || [];
|
|
657
|
-
if (prs.some(pr => pr.id === prId)) return p;
|
|
658
|
-
}
|
|
659
|
-
return null;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
643
|
function updatePrAfterReview(agentId, pr, project) {
|
|
663
644
|
|
|
664
645
|
if (!pr?.id) return;
|
|
665
|
-
|
|
666
|
-
const resolvedProject = project || resolveProjectForPr(pr.id);
|
|
667
|
-
if (!resolvedProject) { log('warn', `updatePrAfterReview: cannot resolve project for ${pr.id}`); return; }
|
|
668
|
-
const prs = getPrs(resolvedProject);
|
|
646
|
+
const prs = getPrs(project);
|
|
669
647
|
const target = prs.find(p => p.id === pr.id);
|
|
670
648
|
if (!target) return;
|
|
671
649
|
|
|
@@ -699,7 +677,7 @@ function updatePrAfterReview(agentId, pr, project) {
|
|
|
699
677
|
shared.safeWrite(metricsPath, metrics);
|
|
700
678
|
}
|
|
701
679
|
|
|
702
|
-
shared.safeWrite(shared.projectPrPath(
|
|
680
|
+
shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
|
|
703
681
|
log('info', `Updated ${pr.id} → minions review: ${target.reviewStatus} by ${reviewerName}`);
|
|
704
682
|
createReviewFeedbackForAuthor(agentId, { ...pr, ...target }, config);
|
|
705
683
|
}
|
|
@@ -707,10 +685,7 @@ function updatePrAfterReview(agentId, pr, project) {
|
|
|
707
685
|
function updatePrAfterFix(pr, project, source) {
|
|
708
686
|
|
|
709
687
|
if (!pr?.id) return;
|
|
710
|
-
|
|
711
|
-
const resolvedProject = project || resolveProjectForPr(pr.id);
|
|
712
|
-
if (!resolvedProject) { log('warn', `updatePrAfterFix: cannot resolve project for ${pr.id}`); return; }
|
|
713
|
-
const prs = getPrs(resolvedProject);
|
|
688
|
+
const prs = getPrs(project);
|
|
714
689
|
const target = prs.find(p => p.id === pr.id);
|
|
715
690
|
if (!target) return;
|
|
716
691
|
|
|
@@ -725,7 +700,7 @@ function updatePrAfterFix(pr, project, source) {
|
|
|
725
700
|
log('info', `Updated ${pr.id} → reviewStatus: waiting (fix pushed)`);
|
|
726
701
|
}
|
|
727
702
|
|
|
728
|
-
shared.safeWrite(shared.projectPrPath(
|
|
703
|
+
shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
|
|
729
704
|
}
|
|
730
705
|
|
|
731
706
|
// ─── Post-Merge / Post-Close Hooks ───────────────────────────────────────────
|
|
@@ -768,7 +743,7 @@ async function handlePostMerge(pr, project, config, newStatus) {
|
|
|
768
743
|
}
|
|
769
744
|
|
|
770
745
|
if (mergedItemId) {
|
|
771
|
-
// Mark PRD feature as
|
|
746
|
+
// Mark PRD feature as implemented
|
|
772
747
|
const prdDir = path.join(MINIONS_DIR, 'prd');
|
|
773
748
|
try {
|
|
774
749
|
const planFiles = fs.readdirSync(prdDir).filter(f => f.endsWith('.json'));
|
|
@@ -817,17 +792,13 @@ async function handlePostMerge(pr, project, config, newStatus) {
|
|
|
817
792
|
|
|
818
793
|
const teamsUrl = process.env.TEAMS_PLAN_FLOW_URL;
|
|
819
794
|
if (teamsUrl) {
|
|
820
|
-
const ac = new AbortController();
|
|
821
|
-
const t = setTimeout(() => ac.abort(), 5000);
|
|
822
795
|
try {
|
|
823
796
|
await fetch(teamsUrl, {
|
|
824
797
|
method: 'POST',
|
|
825
|
-
signal: ac.signal,
|
|
826
798
|
headers: { 'Content-Type': 'application/json' },
|
|
827
799
|
body: JSON.stringify({ text: `PR ${pr.id} merged: ${pr.title} (${project.name}) by ${pr.agent || 'unknown'}` })
|
|
828
800
|
});
|
|
829
801
|
} catch (err) { log('warn', `Teams post-merge notify failed: ${err.message}`); }
|
|
830
|
-
clearTimeout(t);
|
|
831
802
|
}
|
|
832
803
|
|
|
833
804
|
log('info', `Post-merge hooks completed for ${pr.id}`);
|
|
@@ -1043,8 +1014,7 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
|
|
|
1043
1014
|
if (taskUsage.numTurns > cp.maxTurns) cp.maxTurns = taskUsage.numTurns;
|
|
1044
1015
|
// Check if this dispatch hit the turn limit
|
|
1045
1016
|
const engineConfig = require('./queries').getConfig()?.engine || {};
|
|
1046
|
-
|
|
1047
|
-
const turnLimit = engineConfig.maxTurns || shared.ENGINE_DEFAULTS.maxTurns;
|
|
1017
|
+
const turnLimit = engineConfig.maxTurns || 100;
|
|
1048
1018
|
if (taskUsage.numTurns >= turnLimit) cp.turnLimitHits++;
|
|
1049
1019
|
}
|
|
1050
1020
|
|
|
@@ -1073,7 +1043,7 @@ function resolveWiPath(meta) {
|
|
|
1073
1043
|
}
|
|
1074
1044
|
|
|
1075
1045
|
/**
|
|
1076
|
-
* Parse structured eval verdict from
|
|
1046
|
+
* Parse structured eval verdict from evaluate agent output.
|
|
1077
1047
|
* Looks for a JSON block with { pass, build, tests, criteria_met, criteria_failed, feedback }.
|
|
1078
1048
|
* Returns parsed object or null if not found.
|
|
1079
1049
|
*/
|
|
@@ -1193,11 +1163,6 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
|
1193
1163
|
? path.join(MINIONS_DIR, 'work-items.json')
|
|
1194
1164
|
: meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
|
|
1195
1165
|
if (wiPath) {
|
|
1196
|
-
// Cost ceiling circuit breaker config — resolved outside lock for clarity
|
|
1197
|
-
const engineCfg = config?.engine || {};
|
|
1198
|
-
const evalMaxCost = engineCfg.evalMaxCost != null ? engineCfg.evalMaxCost : shared.ENGINE_DEFAULTS.evalMaxCost;
|
|
1199
|
-
|
|
1200
|
-
// Single lock: accumulate cost AND check ceiling atomically (no TOCTOU)
|
|
1201
1166
|
mutateJsonFileLocked(wiPath, (items) => {
|
|
1202
1167
|
if (!Array.isArray(items)) return items;
|
|
1203
1168
|
const wi = items.find(i => i.id === meta.item.id);
|
|
@@ -1205,17 +1170,29 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
|
1205
1170
|
wi._totalCostUsd = (wi._totalCostUsd || 0) + (taskUsage.costUsd || 0);
|
|
1206
1171
|
wi._totalInputTokens = (wi._totalInputTokens || 0) + (taskUsage.inputTokens || 0);
|
|
1207
1172
|
wi._totalOutputTokens = (wi._totalOutputTokens || 0) + (taskUsage.outputTokens || 0);
|
|
1208
|
-
|
|
1209
|
-
// Cost ceiling circuit breaker — treat like evalMaxIterations exceeded
|
|
1210
|
-
if (evalMaxCost != null && evalMaxCost > 0 &&
|
|
1211
|
-
wi._totalCostUsd > evalMaxCost && wi.status !== 'needs-human-review') {
|
|
1212
|
-
wi.status = 'needs-human-review';
|
|
1213
|
-
wi.failReason = `Cumulative cost $${wi._totalCostUsd.toFixed(2)} exceeds evalMaxCost ceiling $${evalMaxCost.toFixed(2)}`;
|
|
1214
|
-
log('warn', `Work item ${meta.item.id} exceeded cost ceiling ($${wi._totalCostUsd.toFixed(2)} > $${evalMaxCost.toFixed(2)}) — needs-human-review`);
|
|
1215
|
-
}
|
|
1216
1173
|
}
|
|
1217
1174
|
return items;
|
|
1218
1175
|
}, { defaultValue: [] });
|
|
1176
|
+
|
|
1177
|
+
// Cost ceiling circuit breaker — treat like evalMaxIterations exceeded
|
|
1178
|
+
const engineCfg = config?.engine || {};
|
|
1179
|
+
const evalMaxCost = engineCfg.evalMaxCost != null ? engineCfg.evalMaxCost : shared.ENGINE_DEFAULTS.evalMaxCost;
|
|
1180
|
+
if (evalMaxCost != null && evalMaxCost > 0) {
|
|
1181
|
+
const freshItems = safeJson(wiPath) || [];
|
|
1182
|
+
const wi = freshItems.find(i => i.id === meta.item.id);
|
|
1183
|
+
if (wi && wi._totalCostUsd > evalMaxCost && wi.status !== 'needs-human-review') {
|
|
1184
|
+
mutateJsonFileLocked(wiPath, (items) => {
|
|
1185
|
+
if (!Array.isArray(items)) return items;
|
|
1186
|
+
const target = items.find(i => i.id === meta.item.id);
|
|
1187
|
+
if (target) {
|
|
1188
|
+
target.status = 'needs-human-review';
|
|
1189
|
+
target.failReason = `Cumulative cost $${wi._totalCostUsd.toFixed(2)} exceeds evalMaxCost ceiling $${evalMaxCost.toFixed(2)}`;
|
|
1190
|
+
log('warn', `Work item ${meta.item.id} exceeded cost ceiling ($${wi._totalCostUsd.toFixed(2)} > $${evalMaxCost.toFixed(2)}) — needs-human-review`);
|
|
1191
|
+
}
|
|
1192
|
+
return items;
|
|
1193
|
+
}, { defaultValue: [] });
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1219
1196
|
}
|
|
1220
1197
|
} catch (err) { log('warn', `Cost accumulation: ${err.message}`); }
|
|
1221
1198
|
}
|
|
@@ -1232,8 +1209,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
|
1232
1209
|
if (meta?.item?.id) {
|
|
1233
1210
|
try {
|
|
1234
1211
|
const engineConfig = (config.engine || {});
|
|
1235
|
-
|
|
1236
|
-
const turnLimit = engineConfig.maxTurns || shared.ENGINE_DEFAULTS.maxTurns;
|
|
1212
|
+
const turnLimit = engineConfig.maxTurns || 100;
|
|
1237
1213
|
const turnCount = taskUsage?.numTurns || 0;
|
|
1238
1214
|
const hitTurnLimit = turnCount >= turnLimit;
|
|
1239
1215
|
let outputLogSizeBytes = 0;
|
|
@@ -1248,48 +1224,40 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
|
1248
1224
|
|
|
1249
1225
|
if (isSuccess && meta?.item?.id && !skipDoneStatus) updateWorkItemStatus(meta, 'done', '');
|
|
1250
1226
|
|
|
1251
|
-
// Auto-dispatch review work item after implement
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
const evalLoop = config.engine?.evalLoop !== false;
|
|
1256
|
-
if (evalLoop) {
|
|
1227
|
+
// Auto-dispatch review work item after implement completes successfully
|
|
1228
|
+
if (isSuccess && !skipDoneStatus && type === 'implement' && meta?.item?.id) {
|
|
1229
|
+
const autoReview = config.engine?.autoReview ?? shared.ENGINE_DEFAULTS.autoReview;
|
|
1230
|
+
if (autoReview) {
|
|
1257
1231
|
try {
|
|
1258
1232
|
const wiPath = resolveWiPath(meta);
|
|
1259
1233
|
if (wiPath) {
|
|
1260
1234
|
const items = safeJson(wiPath) || [];
|
|
1261
|
-
// For fix items, the eval parent is the original implement item
|
|
1262
|
-
const evalParentId = type === 'fix' ? meta.item._evalParentId : meta.item.id;
|
|
1263
1235
|
// Dedup: skip if a review item already exists for this parent
|
|
1264
|
-
const existing = items.find(i => i._evalParentId ===
|
|
1236
|
+
const existing = items.find(i => i._evalParentId === meta.item.id && i.type === 'review');
|
|
1265
1237
|
if (existing) {
|
|
1266
|
-
log('info', `Eval loop: review item ${existing.id} already exists for ${
|
|
1238
|
+
log('info', `Eval loop: review item ${existing.id} already exists for ${meta.item.id}, skipping`);
|
|
1267
1239
|
} else {
|
|
1268
|
-
const parentItem = items.find(i => i.id ===
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
items.push(evalItem);
|
|
1290
|
-
shared.safeWrite(wiPath, items);
|
|
1291
|
-
log('info', `Eval loop: created ${evalItem.id} for completed ${type} ${meta.item.id} (parent: ${evalParentId})`);
|
|
1292
|
-
}
|
|
1240
|
+
const parentItem = items.find(i => i.id === meta.item.id);
|
|
1241
|
+
const evalItem = {
|
|
1242
|
+
id: 'W-' + shared.uid(),
|
|
1243
|
+
title: `Review: ${meta.item.title || meta.item.id}`,
|
|
1244
|
+
type: 'review',
|
|
1245
|
+
priority: meta.item.priority || 'high',
|
|
1246
|
+
status: 'pending',
|
|
1247
|
+
created: ts(),
|
|
1248
|
+
createdBy: 'engine:eval-loop',
|
|
1249
|
+
project: meta.project?.name || meta.item.project,
|
|
1250
|
+
branch_name: parentItem?.branch_name || meta.branch || null,
|
|
1251
|
+
pr_url: parentItem?.pr_url || null,
|
|
1252
|
+
acceptance_criteria: parentItem?.acceptance_criteria || meta.item.acceptance_criteria || null,
|
|
1253
|
+
_evalParentId: meta.item.id,
|
|
1254
|
+
};
|
|
1255
|
+
if (parentItem?.sourcePlan) evalItem.sourcePlan = parentItem.sourcePlan;
|
|
1256
|
+
// Mark parent as eval-dispatched before writing to prevent duplicates on re-run
|
|
1257
|
+
if (parentItem) parentItem._evalDispatched = true;
|
|
1258
|
+
items.push(evalItem);
|
|
1259
|
+
shared.safeWrite(wiPath, items);
|
|
1260
|
+
log('info', `Eval loop: created ${evalItem.id} for completed implement ${meta.item.id}`);
|
|
1293
1261
|
}
|
|
1294
1262
|
}
|
|
1295
1263
|
} catch (err) {
|
|
@@ -1302,10 +1270,10 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
|
1302
1270
|
if (isSuccess && type === 'review' && meta?.item?._evalParentId) {
|
|
1303
1271
|
try {
|
|
1304
1272
|
const verdict = parseEvalVerdict(resultSummary || stdout);
|
|
1305
|
-
const
|
|
1273
|
+
const autoReview = config.engine?.autoReview ?? shared.ENGINE_DEFAULTS.autoReview;
|
|
1306
1274
|
const maxIter = config.engine?.evalMaxIterations ?? shared.ENGINE_DEFAULTS.evalMaxIterations;
|
|
1307
1275
|
|
|
1308
|
-
if (verdict && !verdict.pass &&
|
|
1276
|
+
if (verdict && !verdict.pass && autoReview) {
|
|
1309
1277
|
const wiPath = resolveWiPath(meta);
|
|
1310
1278
|
if (wiPath) {
|
|
1311
1279
|
const items = safeJson(wiPath) || [];
|
|
@@ -1356,44 +1324,52 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
|
1356
1324
|
}
|
|
1357
1325
|
|
|
1358
1326
|
if (!isSuccess && meta?.item?.id) {
|
|
1359
|
-
// Auto-retry
|
|
1327
|
+
// Auto-retry: read fresh _retryCount from file (not stale dispatch-time snapshot)
|
|
1328
|
+
let retries = (meta.item._retryCount || 0);
|
|
1360
1329
|
try {
|
|
1361
|
-
const wiPath =
|
|
1330
|
+
const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
|
|
1331
|
+
? path.join(MINIONS_DIR, 'work-items.json')
|
|
1332
|
+
: meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
|
|
1362
1333
|
if (wiPath) {
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1334
|
+
const items = safeJson(wiPath) || [];
|
|
1335
|
+
const wi = items.find(i => i.id === meta.item.id);
|
|
1336
|
+
if (wi) retries = (wi._retryCount || 0); // Use fresh value from file
|
|
1337
|
+
}
|
|
1338
|
+
} catch { /* optional */ }
|
|
1339
|
+
|
|
1340
|
+
if (retries < 3) {
|
|
1341
|
+
log('info', `Agent failed for ${meta.item.id} — auto-retry ${retries + 1}/3`);
|
|
1342
|
+
updateWorkItemStatus(meta, 'pending', '');
|
|
1343
|
+
try {
|
|
1344
|
+
const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
|
|
1345
|
+
? path.join(MINIONS_DIR, 'work-items.json')
|
|
1346
|
+
: meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
|
|
1347
|
+
if (wiPath) {
|
|
1348
|
+
const items = safeJson(wiPath) || [];
|
|
1366
1349
|
const wi = items.find(i => i.id === meta.item.id);
|
|
1367
|
-
if (
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
wi._retryCount = retries + 1;
|
|
1372
|
-
wi.status = 'pending';
|
|
1373
|
-
delete wi.dispatched_at;
|
|
1374
|
-
delete wi.dispatched_to;
|
|
1375
|
-
if (type === 'decompose') delete wi._decomposing;
|
|
1376
|
-
} else {
|
|
1377
|
-
retriesExhausted = true;
|
|
1350
|
+
if (wi) {
|
|
1351
|
+
wi._retryCount = retries + 1; wi.status = 'pending'; delete wi.dispatched_at; delete wi.dispatched_to;
|
|
1352
|
+
if (type === 'decompose') delete wi._decomposing; // clear so item can retry decomposition
|
|
1353
|
+
shared.safeWrite(wiPath, items);
|
|
1378
1354
|
}
|
|
1379
|
-
// Clear _decomposing flag on any decompose failure to prevent permanent stuck
|
|
1380
|
-
if (type === 'decompose') delete wi._decomposing;
|
|
1381
|
-
return items;
|
|
1382
|
-
}, { defaultValue: [] });
|
|
1383
|
-
if (retriesExhausted) {
|
|
1384
|
-
updateWorkItemStatus(meta, 'failed', 'Agent failed (3 retries exhausted)');
|
|
1385
1355
|
}
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1356
|
+
} catch (err) { log('warn', `Retry update: ${err.message}`); }
|
|
1357
|
+
} else {
|
|
1358
|
+
updateWorkItemStatus(meta, 'failed', 'Agent failed (3 retries exhausted)');
|
|
1359
|
+
}
|
|
1360
|
+
// Clear _decomposing flag on failure so item doesn't get permanently stuck
|
|
1361
|
+
if (type === 'decompose') {
|
|
1362
|
+
try {
|
|
1363
|
+
const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
|
|
1364
|
+
? path.join(MINIONS_DIR, 'work-items.json')
|
|
1365
|
+
: meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
|
|
1366
|
+
if (wiPath) {
|
|
1367
|
+
const items = safeJson(wiPath) || [];
|
|
1368
|
+
const wi = items.find(i => i.id === meta.item.id);
|
|
1369
|
+
if (wi) { delete wi._decomposing; shared.safeWrite(wiPath, items); }
|
|
1394
1370
|
}
|
|
1395
|
-
}
|
|
1396
|
-
}
|
|
1371
|
+
} catch (err) { log('warn', `Decompose cleanup: ${err.message}`); }
|
|
1372
|
+
}
|
|
1397
1373
|
}
|
|
1398
1374
|
// Meeting post-completion: collect findings/debate/conclusion
|
|
1399
1375
|
if (type === 'meeting' && meta?.meetingId) {
|
|
@@ -1445,7 +1421,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
|
1445
1421
|
}
|
|
1446
1422
|
|
|
1447
1423
|
// Detect implement tasks that completed without creating a PR
|
|
1448
|
-
if (isSuccess && (type === 'implement' || type === 'implement:large' || type === 'fix') && prsCreatedCount === 0 && meta?.item?.id
|
|
1424
|
+
if (isSuccess && (type === 'implement' || type === 'implement:large' || type === 'fix') && prsCreatedCount === 0 && meta?.item?.id) {
|
|
1449
1425
|
// Check if a PR already exists linked to this work item (from a previous attempt)
|
|
1450
1426
|
const projects = shared.getProjects(config);
|
|
1451
1427
|
const existingPrFound = Object.values(getPrLinks()).includes(meta.item.id);
|
package/engine/shared.js
CHANGED
|
@@ -7,7 +7,7 @@ const fs = require('fs');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
|
|
9
9
|
const MINIONS_DIR = path.resolve(__dirname, '..');
|
|
10
|
-
|
|
10
|
+
const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
|
|
11
11
|
const LOG_PATH = path.join(__dirname, 'log.json');
|
|
12
12
|
|
|
13
13
|
// ── Timestamps & Logging ────────────────────────────────────────────────────
|
|
@@ -68,15 +68,7 @@ function safeWrite(p, data) {
|
|
|
68
68
|
const content = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
69
69
|
const tmp = p + '.tmp.' + process.pid + '.' + (++_tmpCounter);
|
|
70
70
|
try {
|
|
71
|
-
|
|
72
|
-
fs.writeFileSync(tmp, content);
|
|
73
|
-
} catch (writeErr) {
|
|
74
|
-
if (writeErr.code === 'ENOSPC') {
|
|
75
|
-
try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
|
|
76
|
-
throw new Error(`[ENOSPC] Disk full — cannot write ${path.basename(p)}`);
|
|
77
|
-
}
|
|
78
|
-
throw writeErr;
|
|
79
|
-
}
|
|
71
|
+
fs.writeFileSync(tmp, content);
|
|
80
72
|
// Atomic rename — retry on Windows EPERM (file locking)
|
|
81
73
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
82
74
|
try {
|
|
@@ -176,7 +168,7 @@ function mutateJsonFileLocked(filePath, mutateFn, {
|
|
|
176
168
|
* Use for filenames that could collide (dispatch IDs, temp files, etc.)
|
|
177
169
|
*/
|
|
178
170
|
function uid() {
|
|
179
|
-
return Date.now().toString(36) + Math.random().toString(36).slice(2,
|
|
171
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
180
172
|
}
|
|
181
173
|
|
|
182
174
|
/**
|
|
@@ -363,9 +355,10 @@ const ENGINE_DEFAULTS = {
|
|
|
363
355
|
allowTempAgents: false, // opt-in: spawn ephemeral agents when all permanent agents are busy
|
|
364
356
|
autoDecompose: true, // auto-decompose implement:large items into sub-tasks
|
|
365
357
|
autoApprovePlans: false, // auto-approve PRDs without waiting for human approval
|
|
358
|
+
autoReview: true, // auto-dispatch review agents for new PRs (disable for manual review workflow)
|
|
366
359
|
meetingRoundTimeout: 600000, // 10min per meeting round before auto-advance
|
|
367
|
-
evalLoop: true, // enable
|
|
368
|
-
evalMaxIterations: 3, // max
|
|
360
|
+
evalLoop: true, // enable evaluate→fix loop after implementation completes
|
|
361
|
+
evalMaxIterations: 3, // max evaluate→fix cycles before escalating to human
|
|
369
362
|
evalMaxCost: null, // USD ceiling per work item across all eval iterations; null = no limit (gather baseline data first)
|
|
370
363
|
};
|
|
371
364
|
|
|
@@ -411,26 +404,10 @@ function projectStateDir(project) {
|
|
|
411
404
|
return dir;
|
|
412
405
|
}
|
|
413
406
|
|
|
414
|
-
const CENTRAL_WI_PATH = path.join(MINIONS_DIR, 'work-items.json');
|
|
415
|
-
|
|
416
407
|
function projectWorkItemsPath(project) {
|
|
417
408
|
return path.join(projectStateDir(project), 'work-items.json');
|
|
418
409
|
}
|
|
419
410
|
|
|
420
|
-
/**
|
|
421
|
-
* Resolve work-items.json path from dispatch meta.
|
|
422
|
-
* Central items → CENTRAL_WI_PATH; project items → projects/<name>/work-items.json.
|
|
423
|
-
*/
|
|
424
|
-
function resolveWiPath(meta) {
|
|
425
|
-
if (meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout') {
|
|
426
|
-
return CENTRAL_WI_PATH;
|
|
427
|
-
}
|
|
428
|
-
if (meta.project?.name) {
|
|
429
|
-
return path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json');
|
|
430
|
-
}
|
|
431
|
-
return null;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
411
|
function projectPrPath(project) {
|
|
435
412
|
return path.join(projectStateDir(project), 'pull-requests.json');
|
|
436
413
|
}
|
|
@@ -524,11 +501,10 @@ function parseSkillFrontmatter(content, filename) {
|
|
|
524
501
|
// Never touched by polling loops — only written when a PR is first linked to a PRD item.
|
|
525
502
|
|
|
526
503
|
function getPrLinks() {
|
|
527
|
-
// Derive from PR.prdItems (single source of truth)
|
|
504
|
+
// Derive from PR.prdItems (single source of truth) + legacy pr-links.json as fallback
|
|
528
505
|
const links = {};
|
|
529
506
|
try {
|
|
530
|
-
const
|
|
531
|
-
const projects = getProjects(config);
|
|
507
|
+
const projects = getProjects();
|
|
532
508
|
for (const project of projects) {
|
|
533
509
|
const prs = safeJson(projectPrPath(project)) || [];
|
|
534
510
|
for (const pr of prs) {
|
|
@@ -538,16 +514,22 @@ function getPrLinks() {
|
|
|
538
514
|
}
|
|
539
515
|
}
|
|
540
516
|
} catch { /* optional */ }
|
|
517
|
+
// Merge legacy pr-links.json for items not yet in PR.prdItems
|
|
518
|
+
try {
|
|
519
|
+
const legacy = JSON.parse(require('fs').readFileSync(PR_LINKS_PATH, 'utf8'));
|
|
520
|
+
for (const [prId, itemId] of Object.entries(legacy)) {
|
|
521
|
+
if (!links[prId]) links[prId] = itemId;
|
|
522
|
+
}
|
|
523
|
+
} catch { /* optional */ }
|
|
541
524
|
return links;
|
|
542
525
|
}
|
|
543
526
|
|
|
544
527
|
function addPrLink(prId, itemId) {
|
|
545
528
|
if (!prId || !itemId) return;
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
} catch { /* optional */ }
|
|
529
|
+
const links = getPrLinks();
|
|
530
|
+
if (links[prId] === itemId) return; // already correct, no write needed
|
|
531
|
+
links[prId] = itemId;
|
|
532
|
+
safeWrite(PR_LINKS_PATH, links);
|
|
551
533
|
}
|
|
552
534
|
|
|
553
535
|
/**
|
|
@@ -579,6 +561,7 @@ function linkPrToItem(project, prId, itemId) {
|
|
|
579
561
|
|
|
580
562
|
module.exports = {
|
|
581
563
|
MINIONS_DIR,
|
|
564
|
+
PR_LINKS_PATH,
|
|
582
565
|
LOG_PATH,
|
|
583
566
|
ts,
|
|
584
567
|
logTs,
|
|
@@ -609,9 +592,7 @@ module.exports = {
|
|
|
609
592
|
getProjects,
|
|
610
593
|
projectRoot,
|
|
611
594
|
projectStateDir,
|
|
612
|
-
CENTRAL_WI_PATH,
|
|
613
595
|
projectWorkItemsPath,
|
|
614
|
-
resolveWiPath,
|
|
615
596
|
projectPrPath,
|
|
616
597
|
getPrLinks,
|
|
617
598
|
addPrLink,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.183",
|
|
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"
|