@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 CHANGED
@@ -1,13 +1,21 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1650 (2026-05-01)
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 new output after the [human-steering] line
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 + 100);
143
- // Look for assistant response (JSON with type:assistant or readable text)
144
- if (afterSteer.length > 200 && (afterSteer.includes('"type":"assistant"') || afterSteer.includes('"type":"text"'))) {
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
- return callback(project, pr, prNum, orgBase, adoRepositoryId);
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) projectUpdated++;
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 updated PRs preserve disk values that may have been changed
395
- // by other writers between poll start and this write
396
- for (const updatedPr of activePrs) {
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 === updatedPr.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' && updatedPr.reviewStatus !== 'approved') {
406
- updatedPr.reviewStatus = 'approved';
411
+ if (currentPrs[idx].reviewStatus === 'approved' && after.reviewStatus !== 'approved') {
412
+ after.reviewStatus = 'approved';
407
413
  }
408
- currentPrs[idx] = updatedPr;
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 false;
800
+ return true;
814
801
  }
815
802
  if (newHumanComments.length === 0) return false;
816
803
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-01T01:28:04.266Z"
4
+ "cachedAt": "2026-05-01T02:33:34.697Z"
5
5
  }
@@ -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
- // Use per-class retry limits from recovery.js when failureClass is available
299
- const classAllowsRetry = failureClass ? recovery().shouldRetry(failureClass, retries) : (retries < maxRetries);
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) projectUpdated++;
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 updated PRs and deduplicate
238
- for (const updatedPr of activePrs) {
239
- const idx = currentPrs.findIndex(p => p.id === updatedPr.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' && updatedPr.reviewStatus !== 'approved') {
243
- updatedPr.reviewStatus = 'approved';
247
+ if (currentPrs[idx].reviewStatus === 'approved' && after.reviewStatus !== 'approved') {
248
+ after.reviewStatus = 'approved';
244
249
  }
245
- currentPrs[idx] = updatedPr;
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
- if (pr.title.includes('polling...') || pr.agent === 'human' || pr.description === undefined) {
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
- if (pr.title.includes('polling...') || /[{}"\[\]]/.test(pr.title) || /^[0-9a-f-]{8,}$/i.test(pr.title)) {
282
- pr.title = (prData.title || pr.title).slice(0, 120);
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 updatedPr of activeCentral) {
303
- const idx = currentPrs.findIndex(p => p.id === updatedPr.id);
304
- if (idx >= 0) currentPrs[idx] = updatedPr;
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 false; // agent comments only — don't trigger fix
616
+ return true; // agent comments only — persist cutoff without triggering fix
615
617
  }
616
618
  if (newComments.length === 0) return false;
617
619