@yemi33/minions 0.1.1650 → 0.1.1652
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 +11 -3
- package/dashboard/js/live-stream.js +6 -4
- package/engine/ado.js +17 -30
- package/engine/copilot-models.json +1 -1
- package/engine/dispatch.js +14 -6
- package/engine/github.js +24 -22
- package/engine/lifecycle.js +147 -48
- package/engine/runtimes/claude.js +90 -0
- package/engine/runtimes/copilot.js +90 -0
- package/engine/shared.js +45 -3
- package/engine/spawn-agent.js +9 -6
- package/engine/steering.js +23 -0
- package/engine.js +157 -156
- package/package.json +1 -1
- package/playbooks/fix.md +2 -2
- package/playbooks/implement-shared.md +2 -2
- package/playbooks/review.md +2 -3
- package/playbooks/shared-rules.md +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1652 (2026-05-01)
|
|
4
|
+
|
|
5
|
+
### Other
|
|
6
|
+
- Harden agent steering reliability
|
|
7
|
+
|
|
8
|
+
## 0.1.1651 (2026-05-01)
|
|
9
|
+
|
|
10
|
+
### Other
|
|
11
|
+
- refactor: delegate orchestration policy
|
|
12
|
+
|
|
13
|
+
## 0.1.1649 (2026-05-01)
|
|
4
14
|
|
|
5
15
|
### Features
|
|
6
|
-
- reject premature task_complete for nonterminal summaries
|
|
7
16
|
- ADO build poll repositoryId GUID handling
|
|
8
17
|
|
|
9
18
|
### Fixes
|
|
10
|
-
- yemi33/minions#1927
|
|
11
19
|
- yemi33/minions#1925
|
|
12
20
|
|
|
13
21
|
## 0.1.1648 (2026-05-01)
|
|
@@ -136,12 +136,14 @@ async function sendSteering() {
|
|
|
136
136
|
try {
|
|
137
137
|
const liveRes = await fetch('/api/agent/' + encodeURIComponent(currentAgentId) + '/live-output');
|
|
138
138
|
const text = await liveRes.text();
|
|
139
|
-
// Check if there's
|
|
139
|
+
// Check if there's runtime output after the [human-steering] line.
|
|
140
|
+
// Claude emits "assistant"; Copilot emits "assistant.message_delta"
|
|
141
|
+
// / "assistant.message", and either one proves the resume turn ran.
|
|
140
142
|
const steerIdx = text.lastIndexOf('[human-steering]');
|
|
141
143
|
if (steerIdx >= 0) {
|
|
142
|
-
const afterSteer = text.slice(steerIdx +
|
|
143
|
-
|
|
144
|
-
if (afterSteer.length >
|
|
144
|
+
const afterSteer = text.slice(steerIdx + '[human-steering]'.length);
|
|
145
|
+
const sawRuntimeOutput = /"type"\s*:\s*"(assistant(?:\.|")|tool\.|session\.task_complete"|result")/.test(afterSteer);
|
|
146
|
+
if (afterSteer.length > 20 && sawRuntimeOutput) {
|
|
145
147
|
clearInterval(ackInterval);
|
|
146
148
|
pending.textContent = '\u2713 Agent acknowledged';
|
|
147
149
|
pending.style.color = 'var(--green)';
|
package/engine/ado.js
CHANGED
|
@@ -372,6 +372,7 @@ async function forEachActivePr(config, token, callback) {
|
|
|
372
372
|
}
|
|
373
373
|
|
|
374
374
|
let projectUpdated = 0;
|
|
375
|
+
const updatedRecords = [];
|
|
375
376
|
const orgBase = getAdoOrgBase(project);
|
|
376
377
|
|
|
377
378
|
// Parallelize PR polling within each project (max 5 concurrent to avoid rate limits)
|
|
@@ -381,31 +382,36 @@ async function forEachActivePr(config, token, callback) {
|
|
|
381
382
|
const results = await Promise.allSettled(batch.map(async (pr) => {
|
|
382
383
|
const prNum = shared.getPrNumber(pr);
|
|
383
384
|
if (!prNum) return false;
|
|
384
|
-
|
|
385
|
+
const before = shared.snapshotPrRecord(pr);
|
|
386
|
+
const updated = await callback(project, pr, prNum, orgBase, adoRepositoryId);
|
|
387
|
+
if (updated) return { before, after: shared.snapshotPrRecord(pr) };
|
|
388
|
+
return false;
|
|
385
389
|
}));
|
|
386
390
|
for (const r of results) {
|
|
387
|
-
if (r.status === 'fulfilled' && r.value)
|
|
391
|
+
if (r.status === 'fulfilled' && r.value) {
|
|
392
|
+
projectUpdated++;
|
|
393
|
+
updatedRecords.push(r.value);
|
|
394
|
+
}
|
|
388
395
|
if (r.status === 'rejected') log('warn', `PR poll error: ${r.reason?.message || r.reason}`);
|
|
389
396
|
}
|
|
390
397
|
}
|
|
391
398
|
|
|
392
399
|
if (projectUpdated > 0) {
|
|
393
400
|
mutateJsonFileLocked(shared.projectPrPath(project), (currentPrs) => {
|
|
394
|
-
// Merge back
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const updatedPrNumber = shared.getPrNumber(updatedPr);
|
|
401
|
+
// Merge back only fields changed by callbacks; preserve concurrent disk updates.
|
|
402
|
+
for (const { before, after } of updatedRecords) {
|
|
403
|
+
const updatedPrNumber = shared.getPrNumber(after);
|
|
398
404
|
const idx = currentPrs.findIndex(p =>
|
|
399
|
-
p.id ===
|
|
405
|
+
p.id === after.id
|
|
400
406
|
|| (updatedPrNumber != null && shared.getPrNumber(p) === updatedPrNumber)
|
|
401
407
|
);
|
|
402
408
|
if (idx >= 0) {
|
|
403
409
|
// Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
|
|
404
410
|
// The disk version may have been set to 'approved' by another writer after we read
|
|
405
|
-
if (currentPrs[idx].reviewStatus === 'approved' &&
|
|
406
|
-
|
|
411
|
+
if (currentPrs[idx].reviewStatus === 'approved' && after.reviewStatus !== 'approved') {
|
|
412
|
+
after.reviewStatus = 'approved';
|
|
407
413
|
}
|
|
408
|
-
currentPrs[idx]
|
|
414
|
+
shared.applyPrFieldDelta(currentPrs[idx], before, after);
|
|
409
415
|
}
|
|
410
416
|
// Don't push if not found — it was deleted by another writer, respect that
|
|
411
417
|
}
|
|
@@ -579,7 +585,6 @@ async function pollPrStatus(config) {
|
|
|
579
585
|
const mergeCommitId = prData.lastMergeCommit?.commitId;
|
|
580
586
|
let buildStatus = pr.buildStatus || 'none';
|
|
581
587
|
let buildFailReason = pr.buildFailReason || '';
|
|
582
|
-
let buildStatuses = []; // for error log fetching
|
|
583
588
|
let buildStatusResolved = true;
|
|
584
589
|
let buildStatusStaleDetail = '';
|
|
585
590
|
|
|
@@ -603,11 +608,6 @@ async function pollPrStatus(config) {
|
|
|
603
608
|
if (buildStatus === 'failing') {
|
|
604
609
|
const failed = prBuilds.find(b => b.result === 'failed');
|
|
605
610
|
buildFailReason = failed?.definition?.name || 'Build failed';
|
|
606
|
-
// Build fake status objects for error log fetching
|
|
607
|
-
buildStatuses = prBuilds.filter(b => b.result === 'failed').map(b => ({
|
|
608
|
-
state: 'failed', targetUrl: `${orgBase}/${project.adoProject}/_build/results?buildId=${b.id}`,
|
|
609
|
-
_buildId: String(b.id),
|
|
610
|
-
}));
|
|
611
611
|
}
|
|
612
612
|
} else if (allBuilds.length > 0 && pr.buildStatus) {
|
|
613
613
|
// Stale merge-commit fallback — ADO returned builds for this PR's merge ref
|
|
@@ -674,20 +674,7 @@ async function pollPrStatus(config) {
|
|
|
674
674
|
}
|
|
675
675
|
updated = true;
|
|
676
676
|
|
|
677
|
-
// Fetch actual compiler/build error logs when transitioning to failing
|
|
678
677
|
if (buildStatus === 'failing') {
|
|
679
|
-
const failedStatusObjs = buildStatuses.filter(s => s.state === 'failed' || s.state === 'error').slice(0, 10);
|
|
680
|
-
const logParts = [];
|
|
681
|
-
const seenBuildIds = new Set();
|
|
682
|
-
for (const failedStatusObj of failedStatusObjs) {
|
|
683
|
-
const errorLog = await fetchAdoBuildErrorLog(orgBase, project, failedStatusObj, token, pr, seenBuildIds);
|
|
684
|
-
if (errorLog) logParts.push(errorLog);
|
|
685
|
-
}
|
|
686
|
-
if (logParts.length > 0) {
|
|
687
|
-
pr.buildErrorLog = logParts.join('\n\n');
|
|
688
|
-
log('info', `PR ${pr.id}: fetched error logs from ${logParts.length} failing pipeline(s)`);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
678
|
// Teams notification for build failure — non-blocking
|
|
692
679
|
try {
|
|
693
680
|
const teams = require('./teams');
|
|
@@ -810,7 +797,7 @@ async function pollPrHumanComments(config) {
|
|
|
810
797
|
const allNewDates = allHumanComments.filter(c => (new Date(c.date).getTime() || 0) > cutoffMs).map(c => c.date);
|
|
811
798
|
if (allNewDates.length > 0 && newHumanComments.length === 0) {
|
|
812
799
|
pr.humanFeedback = { ...(pr.humanFeedback || {}), lastProcessedCommentDate: allNewDates.sort().pop() };
|
|
813
|
-
return
|
|
800
|
+
return true;
|
|
814
801
|
}
|
|
815
802
|
if (newHumanComments.length === 0) return false;
|
|
816
803
|
|
package/engine/dispatch.js
CHANGED
|
@@ -20,8 +20,6 @@ const MINIONS_DIR = shared.MINIONS_DIR;
|
|
|
20
20
|
// Lazy require to break circular dependency with engine.js
|
|
21
21
|
let _lifecycle = null;
|
|
22
22
|
function lifecycle() { if (!_lifecycle) _lifecycle = require('./lifecycle'); return _lifecycle; }
|
|
23
|
-
let _recovery = null;
|
|
24
|
-
function recovery() { if (!_recovery) _recovery = require('./recovery'); return _recovery; }
|
|
25
23
|
|
|
26
24
|
// ─── Dispatch Mutation ───────────────────────────────────────────────────────
|
|
27
25
|
|
|
@@ -184,6 +182,16 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
|
|
|
184
182
|
return !nonRetryable.some(s => r.includes(s));
|
|
185
183
|
}
|
|
186
184
|
|
|
185
|
+
function normalizeRetryableDecision(value) {
|
|
186
|
+
if (typeof value === 'boolean') return value;
|
|
187
|
+
if (typeof value === 'string') {
|
|
188
|
+
const normalized = value.trim().toLowerCase();
|
|
189
|
+
if (['true', 'yes', '1'].includes(normalized)) return true;
|
|
190
|
+
if (['false', 'no', '0'].includes(normalized)) return false;
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
187
195
|
function isCompletedWorkItemForFailure(item) {
|
|
188
196
|
return !!item && (
|
|
189
197
|
item.status === WI_STATUS.DONE ||
|
|
@@ -234,6 +242,7 @@ function writeFailedAgentReport(item, reason, resultSummary, failureClass) {
|
|
|
234
242
|
|
|
235
243
|
function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', resultSummary = '', opts = {}) {
|
|
236
244
|
const { processWorkItemFailure = true, failureClass } = opts;
|
|
245
|
+
const agentRetryable = normalizeRetryableDecision(opts.agentRetryable ?? opts.retryable);
|
|
237
246
|
let item = null;
|
|
238
247
|
|
|
239
248
|
mutateDispatch((dispatch) => {
|
|
@@ -273,7 +282,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
273
282
|
}
|
|
274
283
|
|
|
275
284
|
// Update source work item status on failure + auto-retry with backoff
|
|
276
|
-
const retryableFailure = isRetryableFailureReason(reason, failureClass);
|
|
285
|
+
const retryableFailure = agentRetryable !== undefined ? agentRetryable : isRetryableFailureReason(reason, failureClass);
|
|
277
286
|
let completedWorkItemFailure = false;
|
|
278
287
|
if (processWorkItemFailure && result === DISPATCH_RESULT.ERROR && item.meta?.item?.id) {
|
|
279
288
|
// If the live item cannot be resolved, keep the existing retry path.
|
|
@@ -295,9 +304,8 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
295
304
|
if (wi) retries = wi._retryCount || 0;
|
|
296
305
|
} catch (e) { log('warn', 'read retry count: ' + e.message); }
|
|
297
306
|
const maxRetries = ENGINE_DEFAULTS.maxRetries;
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (retryableFailure && classAllowsRetry) {
|
|
307
|
+
const withinSafetyCap = retries < maxRetries;
|
|
308
|
+
if (retryableFailure && withinSafetyCap) {
|
|
301
309
|
log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/${maxRetries}${failureClass ? ' [' + failureClass + ']' : ''}`);
|
|
302
310
|
lifecycle().updateWorkItemStatus(item.meta, WI_STATUS.PENDING, '');
|
|
303
311
|
// Remove this dispatch key from completed so dedupe doesn't block immediate redispatch.
|
package/engine/github.js
CHANGED
|
@@ -219,14 +219,19 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
219
219
|
resetSlugBackoff(slug);
|
|
220
220
|
|
|
221
221
|
let projectUpdated = 0;
|
|
222
|
+
const updatedRecords = [];
|
|
222
223
|
|
|
223
224
|
for (const pr of activePrs) {
|
|
224
225
|
const prNum = shared.getPrNumber(pr);
|
|
225
226
|
if (!prNum) continue;
|
|
226
227
|
|
|
227
228
|
try {
|
|
229
|
+
const before = shared.snapshotPrRecord(pr);
|
|
228
230
|
const updated = await callback(project, pr, prNum, slug);
|
|
229
|
-
if (updated)
|
|
231
|
+
if (updated) {
|
|
232
|
+
projectUpdated++;
|
|
233
|
+
updatedRecords.push({ before, after: shared.snapshotPrRecord(pr) });
|
|
234
|
+
}
|
|
230
235
|
} catch (err) {
|
|
231
236
|
log('warn', `GitHub: failed to poll PR ${pr.id}: ${err.message}`);
|
|
232
237
|
}
|
|
@@ -234,15 +239,15 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
234
239
|
|
|
235
240
|
if (projectUpdated > 0) {
|
|
236
241
|
mutateJsonFileLocked(projectPrPath(project), (currentPrs) => {
|
|
237
|
-
// Merge back
|
|
238
|
-
for (const
|
|
239
|
-
const idx = currentPrs.findIndex(p => p.id ===
|
|
242
|
+
// Merge back only fields changed by callbacks; preserve concurrent disk updates.
|
|
243
|
+
for (const { before, after } of updatedRecords) {
|
|
244
|
+
const idx = currentPrs.findIndex(p => p.id === after.id);
|
|
240
245
|
if (idx >= 0) {
|
|
241
246
|
// Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
|
|
242
|
-
if (currentPrs[idx].reviewStatus === 'approved' &&
|
|
243
|
-
|
|
247
|
+
if (currentPrs[idx].reviewStatus === 'approved' && after.reviewStatus !== 'approved') {
|
|
248
|
+
after.reviewStatus = 'approved';
|
|
244
249
|
}
|
|
245
|
-
currentPrs[idx]
|
|
250
|
+
shared.applyPrFieldDelta(currentPrs[idx], before, after);
|
|
246
251
|
}
|
|
247
252
|
}
|
|
248
253
|
// Remove duplicates — prefer merged/abandoned over active
|
|
@@ -265,6 +270,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
265
270
|
const centralPrs = safeJson(centralPath) || [];
|
|
266
271
|
const activeCentral = centralPrs.filter(pr => PR_POLLABLE_STATUSES.has(pr.status) && pr.url);
|
|
267
272
|
let centralUpdated = 0;
|
|
273
|
+
const updatedCentralRecords = [];
|
|
268
274
|
for (const pr of activeCentral) {
|
|
269
275
|
const ghMatch = pr.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
|
|
270
276
|
if (!ghMatch) continue;
|
|
@@ -272,14 +278,17 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
272
278
|
if (isSlugInBackoff(slug)) continue;
|
|
273
279
|
const prNum = ghMatch[2];
|
|
274
280
|
try {
|
|
281
|
+
const before = shared.snapshotPrRecord(pr);
|
|
275
282
|
const updated = await callback(null, pr, prNum, slug);
|
|
276
283
|
if (updated) {
|
|
277
284
|
// Also update title/author/branch if still placeholder
|
|
278
|
-
|
|
285
|
+
const currentTitle = pr.title || '';
|
|
286
|
+
if (!currentTitle || currentTitle.includes('polling...') || pr.agent === 'human' || pr.description === undefined) {
|
|
279
287
|
const prData = await ghApi(`/pulls/${prNum}`, slug);
|
|
280
288
|
if (prData) {
|
|
281
|
-
|
|
282
|
-
|
|
289
|
+
const latestTitle = pr.title || '';
|
|
290
|
+
if (!latestTitle || latestTitle.includes('polling...') || /[{}"\[\]]/.test(latestTitle) || /^[0-9a-f-]{8,}$/i.test(latestTitle)) {
|
|
291
|
+
pr.title = (prData.title || latestTitle).slice(0, 120);
|
|
283
292
|
}
|
|
284
293
|
if (pr.description === undefined) pr.description = (prData.body || '').slice(0, 500);
|
|
285
294
|
if (pr.agent === 'human' && prData.user?.login) pr.agent = prData.user.login;
|
|
@@ -291,6 +300,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
291
300
|
}
|
|
292
301
|
}
|
|
293
302
|
centralUpdated++;
|
|
303
|
+
updatedCentralRecords.push({ before, after: shared.snapshotPrRecord(pr) });
|
|
294
304
|
}
|
|
295
305
|
} catch (err) {
|
|
296
306
|
log('warn', `GitHub: failed to poll central PR ${pr.id}: ${err.message}`);
|
|
@@ -299,9 +309,9 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
299
309
|
if (centralUpdated > 0) {
|
|
300
310
|
mutateJsonFileLocked(centralPath, (currentPrs) => {
|
|
301
311
|
// Only merge back central PRs that the callback actually modified
|
|
302
|
-
for (const
|
|
303
|
-
const idx = currentPrs.findIndex(p => p.id ===
|
|
304
|
-
if (idx >= 0) currentPrs[idx]
|
|
312
|
+
for (const { before, after } of updatedCentralRecords) {
|
|
313
|
+
const idx = currentPrs.findIndex(p => p.id === after.id);
|
|
314
|
+
if (idx >= 0) shared.applyPrFieldDelta(currentPrs[idx], before, after);
|
|
305
315
|
}
|
|
306
316
|
return currentPrs;
|
|
307
317
|
}, { defaultValue: [] });
|
|
@@ -487,15 +497,7 @@ async function pollPrStatus(config) {
|
|
|
487
497
|
}
|
|
488
498
|
updated = true;
|
|
489
499
|
|
|
490
|
-
// Fetch actual compiler/build error logs when transitioning to failing
|
|
491
500
|
if (buildStatus === 'failing') {
|
|
492
|
-
const failedRuns = runs.filter(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
|
|
493
|
-
const errorLog = await fetchGhBuildErrorLog(slug, failedRuns);
|
|
494
|
-
if (errorLog) {
|
|
495
|
-
pr.buildErrorLog = errorLog;
|
|
496
|
-
log('info', `PR ${pr.id}: fetched ${errorLog.split('\n').length} lines of build error log`);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
501
|
// Teams notification for build failure — non-blocking
|
|
500
502
|
try {
|
|
501
503
|
const teams = require('./teams');
|
|
@@ -611,7 +613,7 @@ async function pollPrHumanComments(config) {
|
|
|
611
613
|
const allNewDates = allCommentEntries.filter(c => (new Date(c.date).getTime() || 0) > cutoffMs).map(c => c.date);
|
|
612
614
|
if (allNewDates.length > 0 && newComments.length === 0) {
|
|
613
615
|
pr.humanFeedback = { ...(pr.humanFeedback || {}), lastProcessedCommentDate: allNewDates.sort().pop() };
|
|
614
|
-
return
|
|
616
|
+
return true; // agent comments only — persist cutoff without triggering fix
|
|
615
617
|
}
|
|
616
618
|
if (newComments.length === 0) return false;
|
|
617
619
|
|