@yemi33/minions 0.1.10 → 0.1.12
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 +32 -0
- package/dashboard.html +338 -100
- package/dashboard.js +281 -119
- package/engine/ado.js +14 -0
- package/engine/cli.js +11 -0
- package/engine/github.js +14 -0
- package/engine/lifecycle.js +47 -36
- package/engine/scheduler.js +30 -39
- package/engine.js +117 -28
- package/package.json +1 -1
- package/routing.md +1 -1
package/engine/ado.js
CHANGED
|
@@ -149,6 +149,20 @@ async function pollPrStatus(config) {
|
|
|
149
149
|
e.log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
|
|
150
150
|
pr.reviewStatus = newReviewStatus;
|
|
151
151
|
updated = true;
|
|
152
|
+
// Update author metrics when verdict changes to approved/rejected
|
|
153
|
+
if (newReviewStatus === 'approved' || newReviewStatus === 'changes-requested') {
|
|
154
|
+
const authorId = (pr.agent || '').toLowerCase();
|
|
155
|
+
if (authorId) {
|
|
156
|
+
try {
|
|
157
|
+
const metricsPath = path.join(__dirname, 'metrics.json');
|
|
158
|
+
const metrics = shared.safeJson(metricsPath) || {};
|
|
159
|
+
if (!metrics[authorId]) metrics[authorId] = {};
|
|
160
|
+
if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
|
|
161
|
+
else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
|
|
162
|
+
shared.safeWrite(metricsPath, metrics);
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
if (newStatus !== 'active') return updated;
|
package/engine/cli.js
CHANGED
|
@@ -306,6 +306,17 @@ const commands = {
|
|
|
306
306
|
|
|
307
307
|
// Start tick loop
|
|
308
308
|
const tickTimer = setInterval(() => e.tick(), interval);
|
|
309
|
+
|
|
310
|
+
// Fast poll for immediate wakeup signals (checks control.json every 2s)
|
|
311
|
+
setInterval(() => {
|
|
312
|
+
const ctrl = getControl();
|
|
313
|
+
if (ctrl._wakeupAt && Date.now() - ctrl._wakeupAt < 5000) {
|
|
314
|
+
delete ctrl._wakeupAt;
|
|
315
|
+
safeWrite(CONTROL_PATH, ctrl);
|
|
316
|
+
e.tick();
|
|
317
|
+
}
|
|
318
|
+
}, 2000);
|
|
319
|
+
|
|
309
320
|
console.log(`Tick interval: ${interval / 1000}s | Max concurrent: ${config.engine?.maxConcurrent || 5}`);
|
|
310
321
|
console.log('Press Ctrl+C to stop');
|
|
311
322
|
|
package/engine/github.js
CHANGED
|
@@ -127,6 +127,20 @@ async function pollPrStatus(config) {
|
|
|
127
127
|
e.log('info', `PR ${pr.id} reviewStatus: ${pr.reviewStatus} → ${newReviewStatus}`);
|
|
128
128
|
pr.reviewStatus = newReviewStatus;
|
|
129
129
|
updated = true;
|
|
130
|
+
// Update author metrics when verdict changes to approved/rejected
|
|
131
|
+
if (newReviewStatus === 'approved' || newReviewStatus === 'changes-requested') {
|
|
132
|
+
const authorId = (pr.agent || '').toLowerCase();
|
|
133
|
+
if (authorId) {
|
|
134
|
+
try {
|
|
135
|
+
const metricsPath = path.join(__dirname, 'metrics.json');
|
|
136
|
+
const metrics = shared.safeJson(metricsPath) || {};
|
|
137
|
+
if (!metrics[authorId]) metrics[authorId] = {};
|
|
138
|
+
if (newReviewStatus === 'approved') metrics[authorId].prsApproved = (metrics[authorId].prsApproved || 0) + 1;
|
|
139
|
+
else metrics[authorId].prsRejected = (metrics[authorId].prsRejected || 0) + 1;
|
|
140
|
+
shared.safeWrite(metricsPath, metrics);
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
130
144
|
}
|
|
131
145
|
}
|
|
132
146
|
|
package/engine/lifecycle.js
CHANGED
|
@@ -617,28 +617,25 @@ function updatePrAfterReview(agentId, pr, project) {
|
|
|
617
617
|
const dispatch = getDispatch();
|
|
618
618
|
const completedEntry = (dispatch.completed || []).find(d => d.agent === agentId && d.type === 'review');
|
|
619
619
|
|
|
620
|
+
// Set reviewStatus to 'waiting' (single source of truth — synced from ADO/GitHub votes on next poll)
|
|
621
|
+
target.reviewStatus = 'waiting';
|
|
620
622
|
target.minionsReview = {
|
|
621
|
-
status: 'waiting',
|
|
622
623
|
reviewer: reviewerName,
|
|
623
624
|
reviewedAt: e.ts(),
|
|
624
625
|
note: completedEntry?.task || ''
|
|
625
626
|
};
|
|
626
|
-
|
|
627
|
+
// Metrics update: don't track 'waiting' as a verdict — metrics are updated
|
|
628
|
+
// when pollPrStatus syncs the actual vote to minionsReview.status.
|
|
629
|
+
// The reviewer's reviewsDone counter is incremented in the main updateMetrics call.
|
|
627
630
|
|
|
631
|
+
// Track reviewer for metrics purposes
|
|
628
632
|
const authorAgentId = (pr.agent || '').toLowerCase();
|
|
629
633
|
if (authorAgentId && config.agents?.[authorAgentId]) {
|
|
630
634
|
const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
|
|
631
635
|
const metrics = safeJson(metricsPath) || {};
|
|
632
636
|
if (!metrics[authorAgentId]) metrics[authorAgentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
|
|
633
|
-
if (!metrics[
|
|
634
|
-
|
|
635
|
-
if (prevVerdict !== minionsVerdict) {
|
|
636
|
-
if (prevVerdict === 'approved') metrics[authorAgentId].prsApproved = Math.max(0, (metrics[authorAgentId].prsApproved || 0) - 1);
|
|
637
|
-
else if (prevVerdict === 'changes-requested') metrics[authorAgentId].prsRejected = Math.max(0, (metrics[authorAgentId].prsRejected || 0) - 1);
|
|
638
|
-
if (minionsVerdict === 'approved') metrics[authorAgentId].prsApproved++;
|
|
639
|
-
else if (minionsVerdict === 'changes-requested') metrics[authorAgentId].prsRejected++;
|
|
640
|
-
metrics[authorAgentId]._reviewedPrs[pr.id] = minionsVerdict;
|
|
641
|
-
}
|
|
637
|
+
if (!metrics[agentId]) metrics[agentId] = { tasksCompleted:0, tasksErrored:0, prsCreated:0, prsApproved:0, prsRejected:0, reviewsDone:0, lastTask:null, lastCompleted:null };
|
|
638
|
+
metrics[agentId].reviewsDone = (metrics[agentId].reviewsDone || 0) + 1;
|
|
642
639
|
shared.safeWrite(metricsPath, metrics);
|
|
643
640
|
}
|
|
644
641
|
|
|
@@ -654,25 +651,15 @@ function updatePrAfterFix(pr, project, source) {
|
|
|
654
651
|
const target = prs.find(p => p.id === pr.id);
|
|
655
652
|
if (!target) return;
|
|
656
653
|
|
|
654
|
+
// Reset reviewStatus to 'waiting' for re-review (single source of truth)
|
|
655
|
+
target.reviewStatus = 'waiting';
|
|
657
656
|
if (source === 'pr-human-feedback') {
|
|
658
|
-
// Human feedback fix: clear pendingFix AND reset to waiting for re-review
|
|
659
657
|
if (target.humanFeedback) target.humanFeedback.pendingFix = false;
|
|
660
|
-
target.minionsReview = {
|
|
661
|
-
...target.minionsReview,
|
|
662
|
-
status: 'waiting',
|
|
663
|
-
note: 'Fixed human feedback, awaiting re-review',
|
|
664
|
-
fixedAt: e.ts()
|
|
665
|
-
};
|
|
658
|
+
target.minionsReview = { ...target.minionsReview, note: 'Fixed human feedback, awaiting re-review', fixedAt: e.ts() };
|
|
666
659
|
e.log('info', `Updated ${pr.id} → cleared humanFeedback.pendingFix, reset to waiting for re-review`);
|
|
667
660
|
} else {
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
...target.minionsReview,
|
|
671
|
-
status: 'waiting',
|
|
672
|
-
note: 'Fixed, awaiting re-review',
|
|
673
|
-
fixedAt: e.ts()
|
|
674
|
-
};
|
|
675
|
-
e.log('info', `Updated ${pr.id} → minions review: waiting (fix pushed)`);
|
|
661
|
+
target.minionsReview = { ...target.minionsReview, note: 'Fixed, awaiting re-review', fixedAt: e.ts() };
|
|
662
|
+
e.log('info', `Updated ${pr.id} → reviewStatus: waiting (fix pushed)`);
|
|
676
663
|
}
|
|
677
664
|
|
|
678
665
|
shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
|
|
@@ -932,8 +919,8 @@ function updateMetrics(agentId, dispatchItem, result, taskUsage, prsCreatedCount
|
|
|
932
919
|
// ─── Agent Output Parsing ────────────────────────────────────────────────────
|
|
933
920
|
|
|
934
921
|
function parseAgentOutput(stdout) {
|
|
935
|
-
const
|
|
936
|
-
return { resultSummary:
|
|
922
|
+
const { text, usage, sessionId } = shared.parseStreamJsonOutput(stdout, { maxTextLength: 2000 });
|
|
923
|
+
return { resultSummary: text, taskUsage: usage, sessionId };
|
|
937
924
|
}
|
|
938
925
|
|
|
939
926
|
/**
|
|
@@ -1018,19 +1005,26 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
|
1018
1005
|
const meta = dispatchItem.meta;
|
|
1019
1006
|
const isSuccess = code === 0;
|
|
1020
1007
|
const result = isSuccess ? 'success' : 'error';
|
|
1021
|
-
const { resultSummary, taskUsage } = parseAgentOutput(stdout);
|
|
1008
|
+
const { resultSummary, taskUsage, sessionId } = parseAgentOutput(stdout);
|
|
1009
|
+
|
|
1010
|
+
// Save session for potential resume on next dispatch
|
|
1011
|
+
if (isSuccess && sessionId && agentId && !agentId.startsWith('temp-')) {
|
|
1012
|
+
try {
|
|
1013
|
+
shared.safeWrite(path.join(AGENTS_DIR, agentId, 'session.json'), {
|
|
1014
|
+
sessionId, dispatchId: dispatchItem.id, savedAt: new Date().toISOString()
|
|
1015
|
+
});
|
|
1016
|
+
} catch {}
|
|
1017
|
+
}
|
|
1022
1018
|
|
|
1023
1019
|
// Handle decomposition results — create sub-items from decompose agent output
|
|
1020
|
+
let skipDoneStatus = false;
|
|
1024
1021
|
if (type === 'decompose' && isSuccess && meta?.item?.id) {
|
|
1025
1022
|
const subCount = handleDecompositionResult(stdout, meta, config);
|
|
1026
|
-
if (subCount > 0)
|
|
1027
|
-
|
|
1028
|
-
return { resultSummary: `Decomposed into ${subCount} sub-items`, taskUsage };
|
|
1029
|
-
}
|
|
1030
|
-
// Fallback: if decomposition produced nothing, mark parent as done to avoid stuck state
|
|
1023
|
+
if (subCount > 0) skipDoneStatus = true; // parent already marked 'decomposed' by handler
|
|
1024
|
+
// If decomposition produced nothing, fall through to mark parent as done
|
|
1031
1025
|
}
|
|
1032
1026
|
|
|
1033
|
-
if (isSuccess && meta?.item?.id) updateWorkItemStatus(meta, 'done', '');
|
|
1027
|
+
if (isSuccess && meta?.item?.id && !skipDoneStatus) updateWorkItemStatus(meta, 'done', '');
|
|
1034
1028
|
if (!isSuccess && meta?.item?.id) {
|
|
1035
1029
|
// Auto-retry: read fresh _retryCount from file (not stale dispatch-time snapshot)
|
|
1036
1030
|
let retries = (meta.item._retryCount || 0);
|
|
@@ -1055,12 +1049,29 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
|
1055
1049
|
if (wiPath) {
|
|
1056
1050
|
const items = safeJson(wiPath) || [];
|
|
1057
1051
|
const wi = items.find(i => i.id === meta.item.id);
|
|
1058
|
-
if (wi) {
|
|
1052
|
+
if (wi) {
|
|
1053
|
+
wi._retryCount = retries + 1; wi.status = 'pending'; delete wi.dispatched_at; delete wi.dispatched_to;
|
|
1054
|
+
if (type === 'decompose') delete wi._decomposing; // clear so item can retry decomposition
|
|
1055
|
+
shared.safeWrite(wiPath, items);
|
|
1056
|
+
}
|
|
1059
1057
|
}
|
|
1060
1058
|
} catch {}
|
|
1061
1059
|
} else {
|
|
1062
1060
|
updateWorkItemStatus(meta, 'failed', 'Agent failed (3 retries exhausted)');
|
|
1063
1061
|
}
|
|
1062
|
+
// Clear _decomposing flag on failure so item doesn't get permanently stuck
|
|
1063
|
+
if (type === 'decompose') {
|
|
1064
|
+
try {
|
|
1065
|
+
const wiPath = meta.source === 'central-work-item' || meta.source === 'central-work-item-fanout'
|
|
1066
|
+
? path.join(MINIONS_DIR, 'work-items.json')
|
|
1067
|
+
: meta.project?.name ? path.join(MINIONS_DIR, 'projects', meta.project.name, 'work-items.json') : null;
|
|
1068
|
+
if (wiPath) {
|
|
1069
|
+
const items = safeJson(wiPath) || [];
|
|
1070
|
+
const wi = items.find(i => i.id === meta.item.id);
|
|
1071
|
+
if (wi) { delete wi._decomposing; shared.safeWrite(wiPath, items); }
|
|
1072
|
+
}
|
|
1073
|
+
} catch {}
|
|
1074
|
+
}
|
|
1064
1075
|
}
|
|
1065
1076
|
// Plan chaining removed — user must explicitly execute plan-to-prd after reviewing the plan
|
|
1066
1077
|
if (isSuccess && meta?.item?.sourcePlan) checkPlanCompletion(meta, config);
|
package/engine/scheduler.js
CHANGED
|
@@ -96,16 +96,11 @@ function shouldRunNow(schedule, lastRunAt) {
|
|
|
96
96
|
const now = new Date();
|
|
97
97
|
if (!cron.matches(now)) return false;
|
|
98
98
|
|
|
99
|
-
// Don't fire again if already ran
|
|
99
|
+
// Don't fire again if already ran within the last 55 seconds
|
|
100
|
+
// (uses elapsed time instead of field comparison to handle DST/clock adjustments)
|
|
100
101
|
if (lastRunAt) {
|
|
101
102
|
const last = new Date(lastRunAt);
|
|
102
|
-
if (
|
|
103
|
-
last.getMonth() === now.getMonth() &&
|
|
104
|
-
last.getDate() === now.getDate() &&
|
|
105
|
-
last.getHours() === now.getHours() &&
|
|
106
|
-
last.getMinutes() === now.getMinutes()) {
|
|
107
|
-
return false; // already fired this minute
|
|
108
|
-
}
|
|
103
|
+
if (Date.now() - last.getTime() < 55000) return false;
|
|
109
104
|
}
|
|
110
105
|
|
|
111
106
|
return true;
|
|
@@ -120,38 +115,34 @@ function discoverScheduledWork(config) {
|
|
|
120
115
|
const schedules = config.schedules;
|
|
121
116
|
if (!Array.isArray(schedules) || schedules.length === 0) return [];
|
|
122
117
|
|
|
123
|
-
|
|
118
|
+
// Use file-locked mutation to prevent race conditions on rapid calls
|
|
124
119
|
const work = [];
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
// Persist run times if any schedules fired
|
|
152
|
-
if (work.length > 0) {
|
|
153
|
-
safeWrite(SCHEDULE_RUNS_PATH, runs);
|
|
154
|
-
}
|
|
120
|
+
mutateJsonFileLocked(SCHEDULE_RUNS_PATH, (runs) => {
|
|
121
|
+
for (const sched of schedules) {
|
|
122
|
+
if (!sched.id || !sched.cron || !sched.title) continue;
|
|
123
|
+
if (sched.enabled === false) continue;
|
|
124
|
+
|
|
125
|
+
const lastRun = runs[sched.id] || null;
|
|
126
|
+
if (!shouldRunNow(sched, lastRun)) continue;
|
|
127
|
+
|
|
128
|
+
work.push({
|
|
129
|
+
id: `sched-${sched.id}-${Date.now()}`,
|
|
130
|
+
title: sched.title,
|
|
131
|
+
type: sched.type || 'implement',
|
|
132
|
+
priority: sched.priority || 'medium',
|
|
133
|
+
description: sched.description || sched.title,
|
|
134
|
+
status: 'pending',
|
|
135
|
+
created: new Date().toISOString(),
|
|
136
|
+
createdBy: 'scheduler',
|
|
137
|
+
agent: sched.agent || null,
|
|
138
|
+
project: sched.project || null,
|
|
139
|
+
_scheduleId: sched.id,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Record run time inside the lock
|
|
143
|
+
runs[sched.id] = new Date().toISOString();
|
|
144
|
+
}
|
|
145
|
+
}, { defaultValue: {} });
|
|
155
146
|
|
|
156
147
|
return work;
|
|
157
148
|
}
|
package/engine.js
CHANGED
|
@@ -166,6 +166,25 @@ function getRoutingTableCached() {
|
|
|
166
166
|
return _routingCache;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
function getMonthlySpend(agentId) {
|
|
170
|
+
const metrics = safeJson(path.join(ENGINE_DIR, 'metrics.json')) || {};
|
|
171
|
+
const daily = metrics._daily || {};
|
|
172
|
+
const now = new Date();
|
|
173
|
+
const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
174
|
+
let total = 0;
|
|
175
|
+
for (const [date, data] of Object.entries(daily)) {
|
|
176
|
+
if (date.startsWith(monthPrefix)) {
|
|
177
|
+
total += (data.perAgent?.[agentId]?.costUsd || 0);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Fallback: if no per-agent daily data, use cumulative (less accurate for monthly)
|
|
181
|
+
if (total === 0 && metrics[agentId]?.totalCostUsd) {
|
|
182
|
+
// Can't distinguish monthly from cumulative — treat as monthly estimate
|
|
183
|
+
// This path is for backward compat before per-agent daily tracking was added
|
|
184
|
+
}
|
|
185
|
+
return total;
|
|
186
|
+
}
|
|
187
|
+
|
|
169
188
|
function getAgentErrorRate(agentId) {
|
|
170
189
|
const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
|
|
171
190
|
const metrics = safeJson(metricsPath) || {};
|
|
@@ -194,7 +213,15 @@ function resolveAgent(workType, config, authorAgent = null) {
|
|
|
194
213
|
let preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
|
|
195
214
|
let fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
|
|
196
215
|
|
|
197
|
-
const isAvailable = (id) =>
|
|
216
|
+
const isAvailable = (id) => {
|
|
217
|
+
if (!agents[id] || !isAgentIdle(id) || _claimedAgents.has(id)) return false;
|
|
218
|
+
// Budget check — no budget means infinite (no limit)
|
|
219
|
+
const budget = agents[id].monthlyBudgetUsd;
|
|
220
|
+
if (budget && budget > 0) {
|
|
221
|
+
if (getMonthlySpend(id) >= budget) return false;
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
};
|
|
198
225
|
|
|
199
226
|
// Check preferred and fallback first (routing table order)
|
|
200
227
|
if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
|
|
@@ -905,6 +932,20 @@ function spawnAgent(dispatchItem, config) {
|
|
|
905
932
|
args.push('--allowedTools', claudeConfig.allowedTools);
|
|
906
933
|
}
|
|
907
934
|
|
|
935
|
+
// Session resume: reuse last session if recent enough (< 2 hours)
|
|
936
|
+
if (!agentId.startsWith('temp-')) {
|
|
937
|
+
try {
|
|
938
|
+
const sessionFile = safeJson(path.join(AGENTS_DIR, agentId, 'session.json'));
|
|
939
|
+
if (sessionFile?.sessionId && sessionFile.savedAt) {
|
|
940
|
+
const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
|
|
941
|
+
if (sessionAge < 2 * 60 * 60 * 1000) { // 2 hour TTL
|
|
942
|
+
args.push('--resume', sessionFile.sessionId);
|
|
943
|
+
log('info', `Resuming session ${sessionFile.sessionId} for ${agentId} (age: ${Math.round(sessionAge / 60000)}min)`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
} catch {}
|
|
947
|
+
}
|
|
948
|
+
|
|
908
949
|
// MCP servers: agents inherit from ~/.claude.json directly as Claude Code processes.
|
|
909
950
|
// No --mcp-config needed — avoids redundant config and ensures agents always have latest servers.
|
|
910
951
|
|
|
@@ -1968,6 +2009,27 @@ function setCooldown(key) {
|
|
|
1968
2009
|
saveCooldowns();
|
|
1969
2010
|
}
|
|
1970
2011
|
|
|
2012
|
+
function setCooldownWithContext(key, context) {
|
|
2013
|
+
const existing = dispatchCooldowns.get(key);
|
|
2014
|
+
const pendingContexts = existing?.pendingContexts || [];
|
|
2015
|
+
if (context) pendingContexts.push(context);
|
|
2016
|
+
dispatchCooldowns.set(key, {
|
|
2017
|
+
timestamp: Date.now(),
|
|
2018
|
+
failures: existing?.failures || 0,
|
|
2019
|
+
pendingContexts
|
|
2020
|
+
});
|
|
2021
|
+
saveCooldowns();
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
function getCoalescedContexts(key) {
|
|
2025
|
+
const entry = dispatchCooldowns.get(key);
|
|
2026
|
+
const contexts = entry?.pendingContexts || [];
|
|
2027
|
+
if (contexts.length > 0 && entry) {
|
|
2028
|
+
entry.pendingContexts = []; // Clear after retrieval
|
|
2029
|
+
}
|
|
2030
|
+
return contexts;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
1971
2033
|
function setCooldownFailure(key) {
|
|
1972
2034
|
const existing = dispatchCooldowns.get(key);
|
|
1973
2035
|
const failures = (existing?.failures || 0) + 1;
|
|
@@ -2390,19 +2452,16 @@ function discoverFromPrs(config, project) {
|
|
|
2390
2452
|
if (activePrIds.has(pr.id)) continue; // Skip PRs with active dispatch (prevent race)
|
|
2391
2453
|
|
|
2392
2454
|
const prNumber = (pr.id || '').replace(/^PR-/, '');
|
|
2393
|
-
|
|
2455
|
+
// Use reviewStatus as single source of truth (synced from ADO/GitHub votes)
|
|
2456
|
+
// minionsReview tracks metadata (reviewer, note) but not the authoritative status
|
|
2457
|
+
const reviewStatus = pr.reviewStatus || 'pending';
|
|
2394
2458
|
|
|
2395
|
-
// PRs needing review
|
|
2396
|
-
const needsReview =
|
|
2459
|
+
// PRs needing review: pending or waiting (review dispatched but no verdict yet)
|
|
2460
|
+
const needsReview = reviewStatus === 'pending' || reviewStatus === 'waiting';
|
|
2397
2461
|
if (needsReview) {
|
|
2398
2462
|
const key = `review-${project?.name || 'default'}-${pr.id}`;
|
|
2399
2463
|
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2400
|
-
|
|
2401
|
-
const prAuthor = (pr.agent || '').toLowerCase();
|
|
2402
|
-
let agentId = resolveAgent('review', config);
|
|
2403
|
-
if (agentId && agentId === prAuthor) {
|
|
2404
|
-
agentId = resolveAgent('review', config); // retry — prAuthor now claimed, gets skipped
|
|
2405
|
-
}
|
|
2464
|
+
const agentId = resolveAgent('review', config);
|
|
2406
2465
|
if (!agentId) continue;
|
|
2407
2466
|
|
|
2408
2467
|
const item = buildPrDispatch(agentId, config, project, pr, 'review', {
|
|
@@ -2413,7 +2472,7 @@ function discoverFromPrs(config, project) {
|
|
|
2413
2472
|
}
|
|
2414
2473
|
|
|
2415
2474
|
// PRs with changes requested → route back to author for fix
|
|
2416
|
-
if (
|
|
2475
|
+
if (reviewStatus === 'changes-requested') {
|
|
2417
2476
|
const key = `fix-${project?.name || 'default'}-${pr.id}`;
|
|
2418
2477
|
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2419
2478
|
const agentId = resolveAgent('fix', config, pr.agent);
|
|
@@ -2426,22 +2485,37 @@ function discoverFromPrs(config, project) {
|
|
|
2426
2485
|
if (item) { newWork.push(item); setCooldown(key); }
|
|
2427
2486
|
}
|
|
2428
2487
|
|
|
2429
|
-
// PRs with pending human feedback
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2488
|
+
// PRs with pending human feedback (or coalesced comments from while agent was fixing)
|
|
2489
|
+
const humanFixKey = `human-fix-${project?.name || 'default'}-${pr.id}`;
|
|
2490
|
+
const hasCoalescedFeedback = (dispatchCooldowns.get(humanFixKey)?.pendingContexts || []).length > 0;
|
|
2491
|
+
if (pr.humanFeedback?.pendingFix || hasCoalescedFeedback) {
|
|
2492
|
+
const key = humanFixKey;
|
|
2493
|
+
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) {
|
|
2494
|
+
// Coalesce: save feedback for next dispatch
|
|
2495
|
+
if (pr.humanFeedback?.feedbackContent) {
|
|
2496
|
+
setCooldownWithContext(key, { feedbackContent: pr.humanFeedback.feedbackContent, timestamp: new Date().toISOString() });
|
|
2497
|
+
}
|
|
2498
|
+
continue;
|
|
2499
|
+
}
|
|
2433
2500
|
const agentId = resolveAgent('fix', config, pr.agent);
|
|
2434
2501
|
if (!agentId) continue;
|
|
2435
2502
|
|
|
2503
|
+
const coalesced = getCoalescedContexts(key);
|
|
2504
|
+
let reviewNote = pr.humanFeedback.feedbackContent || 'See PR thread comments';
|
|
2505
|
+
if (coalesced.length > 0) {
|
|
2506
|
+
const earlier = coalesced.map(c => c.feedbackContent).filter(Boolean).join('\n\n---\n\n');
|
|
2507
|
+
if (earlier) reviewNote = earlier + '\n\n---\n\n' + reviewNote;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2436
2510
|
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
2437
2511
|
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
|
|
2438
2512
|
reviewer: 'Human Reviewer',
|
|
2439
|
-
review_note:
|
|
2513
|
+
review_note: reviewNote,
|
|
2440
2514
|
}, `Fix PR ${pr.id} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: pr.branch, project: projMeta });
|
|
2441
2515
|
if (item) { newWork.push(item); setCooldown(key); }
|
|
2442
2516
|
}
|
|
2443
2517
|
|
|
2444
|
-
// PRs with build failures
|
|
2518
|
+
// PRs with build failures — any agent can pick this up
|
|
2445
2519
|
if (pr.status === 'active' && pr.buildStatus === 'failing') {
|
|
2446
2520
|
const key = `build-fix-${project?.name || 'default'}-${pr.id}`;
|
|
2447
2521
|
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
@@ -2565,7 +2639,17 @@ function discoverFromWorkItems(config, project) {
|
|
|
2565
2639
|
}
|
|
2566
2640
|
const agentId = item.agent || resolveAgent(workType, config);
|
|
2567
2641
|
if (!agentId) {
|
|
2568
|
-
|
|
2642
|
+
// Check if reason is budget
|
|
2643
|
+
const cfgAgents = config.agents || {};
|
|
2644
|
+
const budgetBlocked = Object.keys(cfgAgents).some(id => {
|
|
2645
|
+
const b = cfgAgents[id].monthlyBudgetUsd;
|
|
2646
|
+
return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
|
|
2647
|
+
});
|
|
2648
|
+
if (budgetBlocked) {
|
|
2649
|
+
if (item._pendingReason !== 'budget_exceeded') { item._pendingReason = 'budget_exceeded'; needsWrite = true; }
|
|
2650
|
+
} else {
|
|
2651
|
+
if (item._pendingReason !== 'no_agent') { item._pendingReason = 'no_agent'; needsWrite = true; }
|
|
2652
|
+
}
|
|
2569
2653
|
skipped.noAgent++; continue;
|
|
2570
2654
|
}
|
|
2571
2655
|
|
|
@@ -2626,8 +2710,8 @@ function discoverFromWorkItems(config, project) {
|
|
|
2626
2710
|
newWork.push({
|
|
2627
2711
|
type: workType,
|
|
2628
2712
|
agent: agentId,
|
|
2629
|
-
agentName: config.agents[agentId]?.name,
|
|
2630
|
-
agentRole: config.agents[agentId]?.role,
|
|
2713
|
+
agentName: config.agents[agentId]?.name || tempAgents.get(agentId)?.name || agentId,
|
|
2714
|
+
agentRole: config.agents[agentId]?.role || tempAgents.get(agentId)?.role || 'Agent',
|
|
2631
2715
|
task: `[${project?.name || 'project'}] ${item.title || item.description?.slice(0, 80) || item.id}`,
|
|
2632
2716
|
prompt,
|
|
2633
2717
|
meta: { dispatchKey: key, source: 'work-item', branch: branchName, branchStrategy: item.branchStrategy || 'parallel', useExistingBranch: !!(item.branchStrategy === 'shared-branch' && item.featureBranch), item, project: { name: project?.name, localPath: project?.localPath } }
|
|
@@ -3064,16 +3148,18 @@ function discoverWork(config) {
|
|
|
3064
3148
|
try {
|
|
3065
3149
|
const { discoverScheduledWork } = require('./engine/scheduler');
|
|
3066
3150
|
const scheduledWork = discoverScheduledWork(config);
|
|
3067
|
-
|
|
3068
|
-
// Write scheduled items to central work-items.json so they persist across ticks
|
|
3151
|
+
if (scheduledWork.length > 0) {
|
|
3069
3152
|
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
3070
3153
|
const items = safeJson(centralPath) || [];
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
items.
|
|
3074
|
-
|
|
3075
|
-
|
|
3154
|
+
let added = 0;
|
|
3155
|
+
for (const item of scheduledWork) {
|
|
3156
|
+
if (!items.some(i => i._scheduleId === item._scheduleId && i.status !== 'done' && i.status !== 'failed')) {
|
|
3157
|
+
items.push(item);
|
|
3158
|
+
added++;
|
|
3159
|
+
log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
|
|
3160
|
+
}
|
|
3076
3161
|
}
|
|
3162
|
+
if (added > 0) safeWrite(centralPath, items);
|
|
3077
3163
|
}
|
|
3078
3164
|
} catch {}
|
|
3079
3165
|
|
|
@@ -3394,7 +3480,10 @@ module.exports = {
|
|
|
3394
3480
|
updateWorkItemStatus, runCleanup, handlePostMerge,
|
|
3395
3481
|
|
|
3396
3482
|
// Cooldowns
|
|
3397
|
-
loadCooldowns,
|
|
3483
|
+
loadCooldowns, setCooldownWithContext, getCoalescedContexts,
|
|
3484
|
+
|
|
3485
|
+
// Budget
|
|
3486
|
+
getMonthlySpend,
|
|
3398
3487
|
|
|
3399
3488
|
// Tick
|
|
3400
3489
|
tick,
|
package/package.json
CHANGED
package/routing.md
CHANGED
|
@@ -27,7 +27,7 @@ Notes:
|
|
|
27
27
|
## Rules
|
|
28
28
|
|
|
29
29
|
1. **Eager by default** — spawn all agents who can start work, not one at a time
|
|
30
|
-
2. **
|
|
30
|
+
2. **Self-review is allowed** — agents can review their own PRs (useful for single-agent setups)
|
|
31
31
|
3. **Exploration gates implementation** — when exploring, finish before implementing
|
|
32
32
|
4. **Implementation informs PRD** — Lambert reads build summaries before writing PRD
|
|
33
33
|
5. **All rules in `notes.md` apply** — engine injects them into every playbook
|