@yemi33/minions 0.1.1665 → 0.1.1667

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,15 +1,20 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1665 (2026-05-01)
3
+ ## 0.1.1667 (2026-05-01)
4
+
5
+ ### Other
6
+ - Harden PR attachment completion handling
7
+
8
+ ## 0.1.1666 (2026-05-01)
9
+
10
+ ### Other
11
+ - Fix soft routing for fix dispatches
12
+
13
+ ## 0.1.1664 (2026-05-01)
4
14
 
5
15
  ### Features
6
- - treat failed task_complete as dispatch failure (#1954)
7
16
  - prevent duplicate PR fix dispatch (#1953)
8
17
 
9
- ### Fixes
10
- - pre-dispatch agent starvation when only one agent is idle (closes #1940) (#1956)
11
- - Engine clean-exit agent runs incorrectly marked Orphaned/silent (#1955)
12
-
13
18
  ## 0.1.1662 (2026-05-01)
14
19
 
15
20
  ### Other
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-01T19:30:38.180Z"
4
+ "cachedAt": "2026-05-01T21:02:29.699Z"
5
5
  }
@@ -943,7 +943,8 @@ function isPrAttachmentRequired(type, item, meta = {}) {
943
943
  || item.prRequired === true
944
944
  || item.requiresPullRequest === true
945
945
  || item.itemType === 'pr';
946
- if (meta.branchStrategy === 'shared-branch' && item.itemType !== 'pr' && !explicit) return false;
946
+ const branchStrategy = meta.branchStrategy || item.branchStrategy;
947
+ if (branchStrategy === 'shared-branch' && item.itemType !== 'pr' && !explicit) return false;
947
948
 
948
949
  // Fix/test work items dispatched against an existing PR don't produce a new
949
950
  // PR — the agent updates meta.pr in place. Only require fresh PR attachment
@@ -960,21 +961,144 @@ function isPrAttachmentRequired(type, item, meta = {}) {
960
961
  || type === WORK_TYPE.TEST;
961
962
  }
962
963
 
964
+ function readOptionalJsonStrict(filePath, label, validate) {
965
+ if (!filePath) return null;
966
+ let raw;
967
+ try {
968
+ raw = fs.readFileSync(filePath, 'utf8');
969
+ } catch (err) {
970
+ if (err.code === 'ENOENT') return null;
971
+ throw new Error(`Cannot read ${label} JSON at ${filePath}: ${err.message}`);
972
+ }
973
+ let parsed;
974
+ try {
975
+ parsed = JSON.parse(raw);
976
+ } catch (err) {
977
+ throw new Error(`Corrupt ${label} JSON at ${filePath}: ${err.message}`);
978
+ }
979
+ if (validate && !validate(parsed)) {
980
+ throw new Error(`Invalid ${label} JSON shape at ${filePath}`);
981
+ }
982
+ return parsed;
983
+ }
984
+
963
985
  function hasCanonicalPrAttachment(itemId, config) {
964
986
  if (!itemId) return false;
987
+ // Cheapest probe first — getPrLinks() is in-process cached and merges canonical IDs.
965
988
  if (Object.values(getPrLinks()).some(linkedIds => (linkedIds || []).includes(itemId))) return true;
966
989
  const projects = shared.getProjects(config);
967
990
  for (const p of projects) {
968
- const prs = safeJson(shared.projectPrPath(p)) || [];
991
+ const prs = readOptionalJsonStrict(shared.projectPrPath(p), 'project pull-requests', Array.isArray) || [];
969
992
  if (prs.some(pr => (pr.prdItems || []).includes(itemId))) return true;
970
993
  }
971
- const centralPrs = safeJson(path.join(MINIONS_DIR, 'pull-requests.json')) || [];
994
+ const centralPrs = readOptionalJsonStrict(path.join(MINIONS_DIR, 'pull-requests.json'), 'central pull-requests', Array.isArray) || [];
972
995
  return centralPrs.some(pr => (pr.prdItems || []).includes(itemId));
973
996
  }
974
997
 
998
+ function resolvePrFallbackProject(meta, config) {
999
+ const projects = shared.getProjects(config);
1000
+ if (meta?.project?.name) {
1001
+ const match = projects.find(p => p.name === meta.project.name);
1002
+ if (match) return match;
1003
+ }
1004
+ if (meta?.project?.localPath) {
1005
+ const metaPath = path.resolve(meta.project.localPath);
1006
+ const match = projects.find(p => p.localPath && path.resolve(p.localPath) === metaPath);
1007
+ if (match) return match;
1008
+ }
1009
+ if (meta?.item?.project) {
1010
+ const match = projects.find(p => p.name === meta.item.project);
1011
+ if (match) return match;
1012
+ }
1013
+ return projects.length === 1 ? projects[0] : null;
1014
+ }
1015
+
1016
+ function runFileCapture(file, args, opts = {}) {
1017
+ const { timeout = 30000, ...spawnOpts } = opts;
1018
+ const MAX_BUFFER = 4 * 1024 * 1024; // 4MB — generous for gh/git output
1019
+ return new Promise((resolve, reject) => {
1020
+ let child;
1021
+ let settled = false;
1022
+ let timedOut = false;
1023
+ let killedForBuffer = false;
1024
+ let stdout = '';
1025
+ let stderr = '';
1026
+ let hardKillTimer = null;
1027
+ const finish = (fn, value) => {
1028
+ if (settled) return;
1029
+ settled = true;
1030
+ if (timer) clearTimeout(timer);
1031
+ if (hardKillTimer) clearTimeout(hardKillTimer);
1032
+ fn(value);
1033
+ };
1034
+ const timer = timeout ? setTimeout(() => {
1035
+ timedOut = true;
1036
+ if (!child) return;
1037
+ shared.killGracefully(child, 1000);
1038
+ // Hard-kill fallback if the child ignores SIGTERM. Cleared on close.
1039
+ hardKillTimer = setTimeout(() => {
1040
+ try { shared.killImmediate(child); } catch {}
1041
+ }, 2500);
1042
+ }, timeout) : null;
1043
+ try {
1044
+ child = shared.runFile(file, args, { ...spawnOpts, stdio: ['ignore', 'pipe', 'pipe'] });
1045
+ } catch (err) {
1046
+ finish(reject, err);
1047
+ return;
1048
+ }
1049
+ child.stdout?.setEncoding('utf8');
1050
+ child.stderr?.setEncoding('utf8');
1051
+ child.stdout?.on('data', chunk => {
1052
+ if (stdout.length + chunk.length > MAX_BUFFER) {
1053
+ if (!killedForBuffer) { killedForBuffer = true; try { shared.killImmediate(child); } catch {} }
1054
+ return;
1055
+ }
1056
+ stdout += chunk;
1057
+ });
1058
+ child.stderr?.on('data', chunk => {
1059
+ if (stderr.length + chunk.length > MAX_BUFFER) {
1060
+ if (!killedForBuffer) { killedForBuffer = true; try { shared.killImmediate(child); } catch {} }
1061
+ return;
1062
+ }
1063
+ stderr += chunk;
1064
+ });
1065
+ child.on('error', err => {
1066
+ err.stdout = stdout;
1067
+ err.stderr = stderr;
1068
+ finish(reject, err);
1069
+ });
1070
+ child.once('close', () => {
1071
+ if (hardKillTimer) { clearTimeout(hardKillTimer); hardKillTimer = null; }
1072
+ });
1073
+ child.on('close', code => {
1074
+ if (code === 0 && !timedOut && !killedForBuffer) {
1075
+ finish(resolve, stdout);
1076
+ return;
1077
+ }
1078
+ let message;
1079
+ let errCode;
1080
+ if (timedOut) {
1081
+ message = `${file} timed out after ${timeout}ms`;
1082
+ errCode = 'ETIMEDOUT';
1083
+ } else if (killedForBuffer) {
1084
+ message = `${file} exceeded max buffer of ${MAX_BUFFER} bytes`;
1085
+ errCode = 'ERR_OUT_OF_RANGE';
1086
+ } else {
1087
+ message = `${file} exited with code ${code}`;
1088
+ errCode = code;
1089
+ }
1090
+ const err = new Error(message);
1091
+ err.code = errCode;
1092
+ err.stdout = stdout;
1093
+ err.stderr = stderr;
1094
+ finish(reject, err);
1095
+ });
1096
+ });
1097
+ }
1098
+
975
1099
  async function findOpenPrForBranch(meta, config) {
976
1100
  if (!meta?.branch) return null;
977
- const projectObj = shared.getProjects(config).find(p => p.name === meta?.project?.name);
1101
+ const projectObj = resolvePrFallbackProject(meta, config);
978
1102
  if (!projectObj) return null;
979
1103
  const host = projectObj.repoHost || 'ado';
980
1104
  if (host === 'github') {
@@ -984,7 +1108,7 @@ async function findOpenPrForBranch(meta, config) {
984
1108
  if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
985
1109
  let raw = '';
986
1110
  try {
987
- raw = await execAsync(`gh pr list --head "${meta.branch}" --repo ${ghSlug} --json number,url,state --limit 1`, { timeout: 15000, windowsHide: true });
1111
+ raw = await runFileCapture('gh', ['pr', 'list', '--head', String(meta.branch), '--repo', ghSlug, '--json', 'number,url,state', '--limit', '1'], { timeout: 15000 });
988
1112
  const parsed = JSON.parse(raw || '[]');
989
1113
  const hits = Array.isArray(parsed) ? parsed : [];
990
1114
  if (hits.length > 0 && hits[0].state === 'OPEN') {
@@ -1024,6 +1148,7 @@ function _outputContainsPrUrl(output) {
1024
1148
  function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity) {
1025
1149
  const noPrWiPath = resolveWorkItemPath(meta);
1026
1150
  const isHard = severity !== 'soft';
1151
+ let syncNeedsReviewToPrd = false;
1027
1152
  if (noPrWiPath) {
1028
1153
  mutateJsonFileLocked(noPrWiPath, data => {
1029
1154
  if (!Array.isArray(data)) return data;
@@ -1034,6 +1159,7 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity)
1034
1159
  w._missingPrAttachment = true;
1035
1160
  w.failReason = reason;
1036
1161
  w._lastReviewReason = reason;
1162
+ syncNeedsReviewToPrd = !!meta.item?.sourcePlan;
1037
1163
  delete w.completedAt;
1038
1164
  delete w._noPr;
1039
1165
  delete w._noPrReason;
@@ -1047,6 +1173,9 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity)
1047
1173
  return data;
1048
1174
  }, { skipWriteIfUnchanged: true });
1049
1175
  }
1176
+ if (isHard && syncNeedsReviewToPrd) {
1177
+ syncPrdItemStatus(meta.item.id, WI_STATUS.NEEDS_REVIEW, meta.item.sourcePlan);
1178
+ }
1050
1179
  if (isHard) {
1051
1180
  shared.writeToInbox('engine', `missing-pr-attachment-${meta.item.id}`,
1052
1181
  `# PR attachment missing for ${meta.item.id}\n\n` +
@@ -1075,9 +1204,50 @@ function markMissingPrAttachment(meta, agentId, reason, resultSummary, severity)
1075
1204
  }
1076
1205
  }
1077
1206
 
1207
+ function markPrAttachmentVerificationError(meta, agentId, reason, resultSummary) {
1208
+ const wiPath = resolveWorkItemPath(meta);
1209
+ let syncNeedsReviewToPrd = false;
1210
+ if (wiPath) {
1211
+ mutateJsonFileLocked(wiPath, data => {
1212
+ if (!Array.isArray(data)) return data;
1213
+ const w = data.find(i => i.id === meta.item.id);
1214
+ if (!w) return data;
1215
+ w.status = WI_STATUS.NEEDS_REVIEW;
1216
+ w._prAttachmentStateError = true;
1217
+ w.failReason = reason;
1218
+ w._lastReviewReason = reason;
1219
+ syncNeedsReviewToPrd = !!meta.item?.sourcePlan;
1220
+ delete w.completedAt;
1221
+ delete w._missingPrAttachment;
1222
+ delete w._unverifiedPrAttachment;
1223
+ return data;
1224
+ }, { skipWriteIfUnchanged: true });
1225
+ }
1226
+ if (syncNeedsReviewToPrd) {
1227
+ syncPrdItemStatus(meta.item.id, WI_STATUS.NEEDS_REVIEW, meta.item.sourcePlan);
1228
+ }
1229
+ shared.writeToInbox('engine', `pr-attachment-state-error-${meta.item.id}`,
1230
+ `# PR attachment verification blocked for ${meta.item.id}\n\n` +
1231
+ `**Agent:** ${agentId}\n` +
1232
+ `**Work item:** \`${meta.item.id}\` — ${meta.item.title || ''}\n` +
1233
+ `**Type:** ${meta.item.type || 'unknown'}\n` +
1234
+ `**Branch:** ${meta.branch || '(none)'}\n\n` +
1235
+ `${reason}\n` +
1236
+ (resultSummary ? `\n## Agent summary\n${resultSummary}\n` : ''),
1237
+ null,
1238
+ { sourceItem: meta.item.id, reason: 'pr-attachment-state-error' });
1239
+ }
1240
+
1078
1241
  async function enforcePrAttachmentContract(type, meta, agentId, config, resultSummary, output) {
1079
1242
  if (!isPrAttachmentRequired(type, meta?.item, meta)) return null;
1080
- if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
1243
+ try {
1244
+ if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
1245
+ } catch (err) {
1246
+ const reason = `${meta.item.id} completed but PR attachment verification could not read PR tracking state: ${err.message}`;
1247
+ markPrAttachmentVerificationError(meta, agentId, reason, resultSummary);
1248
+ log('warn', reason);
1249
+ return { reason, itemId: meta.item.id, severity: 'hard', stateError: true };
1250
+ }
1081
1251
 
1082
1252
  const found = await findOpenPrForBranch(meta, config);
1083
1253
  if (found) {
@@ -1100,7 +1270,14 @@ async function enforcePrAttachmentContract(type, meta, agentId, config, resultSu
1100
1270
  itemId: meta.item.id,
1101
1271
  });
1102
1272
  log('info', `Auto-linked existing PR ${entry.id} on branch ${meta.branch} for ${meta.item.id}`);
1103
- if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
1273
+ try {
1274
+ if (hasCanonicalPrAttachment(meta.item.id, config)) return null;
1275
+ } catch (err) {
1276
+ const reason = `${meta.item.id} auto-linked a PR but PR attachment verification could not read PR tracking state: ${err.message}`;
1277
+ markPrAttachmentVerificationError(meta, agentId, reason, resultSummary);
1278
+ log('warn', reason);
1279
+ return { reason, itemId: meta.item.id, severity: 'hard', stateError: true };
1280
+ }
1104
1281
  }
1105
1282
 
1106
1283
  // Distinguish "agent never claimed a PR" (hard — silent failure the contract
@@ -1737,13 +1914,7 @@ function parseStructuredCompletion(stdout, runtimeName) {
1737
1914
  if (!stdout || typeof stdout !== 'string') return null;
1738
1915
 
1739
1916
  // Extract text from stream-json output if needed
1740
- let text = stdout;
1741
- if (stdout.includes('"type":')) {
1742
- try {
1743
- const parsed = shared.parseStreamJsonOutput(stdout, runtimeName);
1744
- if (parsed.text) text = parsed.text;
1745
- } catch {}
1746
- }
1917
+ const text = extractCompletionText(stdout, runtimeName);
1747
1918
 
1748
1919
  // Find all ```completion blocks, take the last one
1749
1920
  const blockPattern = /```completion\s*\n([\s\S]*?)```/g;
@@ -1760,6 +1931,22 @@ function parseStructuredCompletion(stdout, runtimeName) {
1760
1931
  return parseCompletionKeyValues(lastMatch);
1761
1932
  }
1762
1933
 
1934
+ function extractCompletionText(stdout, runtimeName) {
1935
+ let text = stdout;
1936
+ if (typeof stdout === 'string' && stdout.includes('"type":')) {
1937
+ try {
1938
+ const parsed = shared.parseStreamJsonOutput(stdout, runtimeName);
1939
+ if (parsed.text) text = parsed.text;
1940
+ } catch {}
1941
+ }
1942
+ return text;
1943
+ }
1944
+
1945
+ function hasCompletionFence(stdout, runtimeName) {
1946
+ const text = extractCompletionText(stdout, runtimeName);
1947
+ return /```completion\s*\n[\s\S]*?```/.test(text);
1948
+ }
1949
+
1763
1950
  function extractTaskCompleteSummary(stdout) {
1764
1951
  if (!stdout || typeof stdout !== 'string') return '';
1765
1952
  let summary = '';
@@ -1817,6 +2004,29 @@ function parseCompletionKeyValues(text) {
1817
2004
  return result;
1818
2005
  }
1819
2006
 
2007
+ function parseCompletionFieldSummary(text) {
2008
+ if (!text || typeof text !== 'string') return null;
2009
+
2010
+ const allowedFields = new Set(shared.COMPLETION_FIELDS || []);
2011
+ const result = {};
2012
+ for (const rawLine of text.split(/\r?\n/)) {
2013
+ const line = rawLine.trim().replace(/^[-*]\s+/, '');
2014
+ const colonIdx = line.indexOf(':');
2015
+ if (colonIdx < 1) continue;
2016
+ const key = line.slice(0, colonIdx).trim().toLowerCase().replace(/[\s-]+/g, '_');
2017
+ if (!allowedFields.has(key)) continue;
2018
+ const value = line.slice(colonIdx + 1).trim().replace(/^["'`]+|["'`]+$/g, '');
2019
+ if (value) result[key] = value;
2020
+ }
2021
+
2022
+ if (!result.status) return null;
2023
+ const fieldCount = Object.keys(result).length;
2024
+ const status = normalizeCompletionStatus(result.status);
2025
+ const explicitlyFailed = status.startsWith('fail') || status === 'error';
2026
+ if (fieldCount < 2 && !explicitlyFailed) return null;
2027
+ return result;
2028
+ }
2029
+
1820
2030
  function parseCompletionReportFile(dispatchItem, opts = {}) {
1821
2031
  const reportPath = dispatchItem?.meta?.completionReportPath || shared.dispatchCompletionReportPath(dispatchItem?.id);
1822
2032
  if (!reportPath || !fs.existsSync(reportPath)) {
@@ -1875,10 +2085,11 @@ function normalizeCompletionStatus(status) {
1875
2085
  // and burned 3-9 minutes of agent time per false-positive retry.
1876
2086
  //
1877
2087
  // Both structured signals (the JSON completion report at MINIONS_COMPLETION_REPORT
1878
- // and the fenced ```completion block in stdout) carry a `status` field. If the
1879
- // agent explicitly says they're not done, honor it; otherwise accept the
1880
- // dispatch. The PR attachment contract still catches silent-failure cases
1881
- // for PR-producing work.
2088
+ // and the fenced ```completion block in stdout) carry a `status` field. A plain
2089
+ // task_complete summary made only of completion fields is accepted as a narrow
2090
+ // compatibility fallback. If the agent explicitly says they're not done, honor
2091
+ // it; otherwise accept the dispatch. The PR attachment contract still catches
2092
+ // silent-failure cases for PR-producing work.
1882
2093
  const NON_TERMINAL_COMPLETION_STATUSES = new Set([
1883
2094
  'partial', 'partially-complete', 'in-progress', 'pending', 'deferred',
1884
2095
  'blocked', 'incomplete', 'to-be-continued',
@@ -2110,8 +2321,11 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2110
2321
 
2111
2322
  // Prefer the sidecar completion report; keep fenced output as a compatibility fallback.
2112
2323
  const reportCompletion = parseCompletionReportFile(dispatchItem, { warnIfMissing: true });
2113
- const fallbackCompletion = reportCompletion ? null : parseStructuredCompletion(stdout, runtimeName);
2114
- const structuredCompletion = reportCompletion || persistCompletionReport(dispatchItem, fallbackCompletion, 'fenced-completion');
2324
+ const fencedCompletion = reportCompletion ? null : parseStructuredCompletion(stdout, runtimeName);
2325
+ const summaryCompletion = reportCompletion || fencedCompletion ? null : parseCompletionFieldSummary(resultSummary);
2326
+ const fallbackCompletion = fencedCompletion || summaryCompletion;
2327
+ const fallbackSource = fencedCompletion && hasCompletionFence(stdout, runtimeName) ? 'fenced-completion' : 'summary-completion';
2328
+ const structuredCompletion = reportCompletion || persistCompletionReport(dispatchItem, fallbackCompletion, fallbackSource);
2115
2329
  if (structuredCompletion) {
2116
2330
  if (structuredCompletion.summary) resultSummary = String(structuredCompletion.summary);
2117
2331
  log('info', `Structured completion from ${agentId}: status=${structuredCompletion.status}, pr=${structuredCompletion.pr || 'N/A'}${structuredCompletion._source ? ` (${structuredCompletion._source})` : ''}`);
@@ -2625,6 +2839,7 @@ module.exports = {
2625
2839
  parseReviewVerdict,
2626
2840
  isReviewBailout,
2627
2841
  parseStructuredCompletion,
2842
+ parseCompletionFieldSummary,
2628
2843
  detectNonTerminalResultSummary,
2629
2844
  parseCompletionReportFile,
2630
2845
  persistCompletionReport,
package/engine.js CHANGED
@@ -1365,13 +1365,15 @@ async function spawnAgent(dispatchItem, config) {
1365
1365
  let errorReason = '';
1366
1366
  if (hardContractFail) {
1367
1367
  errorReason = completionContractFailure.reason || 'PR attachment contract failed';
1368
- } else if (agentReportedFailure && structuredCompletion) {
1369
- errorReason = String(
1370
- structuredCompletion.summary
1371
- || structuredCompletion.pending
1372
- || structuredCompletion.failure_class
1373
- || `Agent reported ${structuredCompletion.status || 'failure'}`
1374
- ).slice(0, 300);
1368
+ } else if (agentReportedFailure) {
1369
+ errorReason = structuredCompletion
1370
+ ? String(
1371
+ structuredCompletion.summary
1372
+ || structuredCompletion.pending
1373
+ || structuredCompletion.failure_class
1374
+ || `Agent reported ${structuredCompletion.status || 'failure'}`
1375
+ ).slice(0, 300)
1376
+ : 'Agent reported failure';
1375
1377
  } else if (effectiveResult === DISPATCH_RESULT.ERROR) {
1376
1378
  errorReason = stderr.split('\n').filter(l => l.trim()).slice(-5).join(' | ').trim().slice(0, 300);
1377
1379
  // W-mo3zul9pirjb — when claude CLI exits in <3s with code 1 and no output (the
@@ -2753,7 +2755,7 @@ function discoverFromWorkItems(config, project) {
2753
2755
  return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
2754
2756
  });
2755
2757
  if (!agentId) {
2756
- if (!budgetBlocked && !hardPinRequested) {
2758
+ if (!budgetBlocked && !hardPinRequested && workType !== WORK_TYPE.FIX) {
2757
2759
  reservedAgentId = resolveAgentReservation(workType, config, { agentHints });
2758
2760
  agentId = reservedAgentId && !hasAgentHints ? routing.ANY_AGENT : reservedAgentId;
2759
2761
  }
@@ -3253,7 +3255,7 @@ function discoverCentralWorkItems(config) {
3253
3255
  const hardPinRequested = routing.isAgentHardPinned(item);
3254
3256
  const agentId = routing.getHardPinnedAgent(item, config.agents || {})
3255
3257
  || (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null)
3256
- || (!hardPinRequested ? resolveAgentReservation(workType, config, { agentHints }) : null);
3258
+ || (!hardPinRequested && workType !== WORK_TYPE.FIX ? resolveAgentReservation(workType, config, { agentHints }) : null);
3257
3259
  if (!agentId) continue;
3258
3260
 
3259
3261
  const agentName = config.agents[agentId]?.name || agentId;
@@ -3561,6 +3563,62 @@ async function discoverWork(config) {
3561
3563
  return allWork.length;
3562
3564
  }
3563
3565
 
3566
+ function getPendingDispatchRoutingOpts(item) {
3567
+ const opts = { agentHints: routing.extractAgentHints(item?.meta?.item) };
3568
+ const authorAgent = item?.meta?.pr?.agent;
3569
+ if (authorAgent) opts.authorAgent = authorAgent;
3570
+ return opts;
3571
+ }
3572
+
3573
+ function resolvePendingDispatchAgent(item, config) {
3574
+ return resolveAgent(
3575
+ routing.normalizeWorkType(item?.type, WORK_TYPE.IMPLEMENT),
3576
+ config,
3577
+ getPendingDispatchRoutingOpts(item)
3578
+ );
3579
+ }
3580
+
3581
+ function assignPendingDispatchAgent(item, agentId, config) {
3582
+ const agents = config.agents || {};
3583
+ item.agent = agentId;
3584
+ item.agentName = agents[agentId]?.name || tempAgents.get(agentId)?.name || agentId;
3585
+ item.agentRole = agents[agentId]?.role || tempAgents.get(agentId)?.role || 'Agent';
3586
+ delete item._agentBusySince;
3587
+ delete item.skipReason;
3588
+ }
3589
+
3590
+ function clearPendingDispatchAgent(item) {
3591
+ delete item.agent;
3592
+ delete item.agentName;
3593
+ delete item.agentRole;
3594
+ delete item._agentBusySince;
3595
+ delete item.skipReason;
3596
+ }
3597
+
3598
+ function persistPendingDispatchAgent(item) {
3599
+ mutateDispatch((dp) => {
3600
+ const p = (dp.pending || []).find(d => d.id === item.id);
3601
+ if (p) {
3602
+ if (item.agent) {
3603
+ p.agent = item.agent;
3604
+ p.agentName = item.agentName;
3605
+ p.agentRole = item.agentRole;
3606
+ } else {
3607
+ delete p.agent;
3608
+ delete p.agentName;
3609
+ delete p.agentRole;
3610
+ }
3611
+ delete p._agentBusySince;
3612
+ delete p.skipReason;
3613
+ }
3614
+ return dp;
3615
+ });
3616
+ }
3617
+
3618
+ function isSoftFixDispatch(item) {
3619
+ return item?.type === WORK_TYPE.FIX && !routing.isAgentHardPinned(item.meta?.item);
3620
+ }
3621
+
3564
3622
  // ─── Main Tick ──────────────────────────────────────────────────────────────
3565
3623
 
3566
3624
  let tickCount = 0;
@@ -3955,27 +4013,17 @@ async function tickInner() {
3955
4013
  // be of type string. Received undefined` and re-queues — every tick. Try to
3956
4014
  // resolve a fallback via routing; if none is available, skip this tick.
3957
4015
  if (!item.agent || typeof item.agent !== 'string') {
3958
- const fallback = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
4016
+ const fallback = resolvePendingDispatchAgent(item, config);
3959
4017
  if (!fallback) {
3960
4018
  log('warn', `Pending dispatch ${item.id} has no agent and routing returned no fallback — skipping`);
3961
4019
  continue;
3962
4020
  }
3963
4021
  log('info', `Pending dispatch ${item.id} missing agent; routed → ${fallback} (#1206 guard)`);
3964
- item.agent = fallback;
3965
- item.agentName = config.agents[fallback]?.name || tempAgents.get(fallback)?.name || fallback;
3966
- item.agentRole = config.agents[fallback]?.role || tempAgents.get(fallback)?.role || 'Agent';
4022
+ assignPendingDispatchAgent(item, fallback, config);
3967
4023
  // Persist so the fix survives across ticks even if this dispatch is skipped
3968
4024
  // later in the loop (branch lock, concurrency cap, agent busy, etc.).
3969
4025
  try {
3970
- mutateDispatch((dp) => {
3971
- const p = (dp.pending || []).find(d => d.id === item.id);
3972
- if (p) {
3973
- p.agent = item.agent;
3974
- p.agentName = item.agentName;
3975
- p.agentRole = item.agentRole;
3976
- }
3977
- return dp;
3978
- });
4026
+ persistPendingDispatchAgent(item);
3979
4027
  } catch (e) { log('warn', `Persist agent resolution for ${item.id} failed: ${e.message}`); }
3980
4028
  }
3981
4029
  // #1204: Pre-assigned unspawned temp agents never unblock naturally.
@@ -3986,27 +4034,13 @@ async function tickInner() {
3986
4034
  // them eagerly before the busy check so an idle named agent can pick up.
3987
4035
  const isUnspawnedTemp = item.agent?.startsWith('temp-') && !busyAgents.has(item.agent);
3988
4036
  if (isUnspawnedTemp) {
3989
- const altAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config);
4037
+ const altAgent = resolvePendingDispatchAgent(item, config);
3990
4038
  if (altAgent && altAgent !== item.agent) {
3991
4039
  const prevAgent = item.agent;
3992
- item.agent = altAgent;
3993
- item.agentName = config.agents[altAgent]?.name || tempAgents.get(altAgent)?.name || altAgent;
3994
- item.agentRole = config.agents[altAgent]?.role || tempAgents.get(altAgent)?.role || 'Agent';
3995
- delete item._agentBusySince;
3996
- delete item.skipReason;
4040
+ assignPendingDispatchAgent(item, altAgent, config);
3997
4041
  log('info', `Reassigning ${item.id} from unspawned temp ${prevAgent} to ${altAgent} — temp agent never spawned`);
3998
4042
  // Persist reassignment to dispatch.json so it survives restarts/ticks
3999
- mutateDispatch((dp) => {
4000
- const p = (dp.pending || []).find(d => d.id === item.id);
4001
- if (p) {
4002
- p.agent = altAgent;
4003
- p.agentName = item.agentName;
4004
- p.agentRole = item.agentRole;
4005
- delete p._agentBusySince;
4006
- delete p.skipReason;
4007
- }
4008
- return dp;
4009
- });
4043
+ persistPendingDispatchAgent(item);
4010
4044
  }
4011
4045
  }
4012
4046
  if (busyAgents.has(item.agent)) {
@@ -4014,30 +4048,30 @@ async function tickInner() {
4014
4048
  // try to find an alternative agent via routing. Skip explicitly assigned items.
4015
4049
  const reassignMs = config.engine?.agentBusyReassignMs ?? ENGINE_DEFAULTS.agentBusyReassignMs;
4016
4050
  const isHardPinned = routing.isAgentHardPinned(item.meta?.item);
4017
- if (!isHardPinned && reassignMs > 0 && item._agentBusySince) {
4051
+ if (isSoftFixDispatch(item)) {
4052
+ const originalAgent = item.agent;
4053
+ const altAgent = resolvePendingDispatchAgent(item, config);
4054
+ if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
4055
+ log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent} — soft fix suggestion unavailable`);
4056
+ assignPendingDispatchAgent(item, altAgent, config);
4057
+ persistPendingDispatchAgent(item);
4058
+ // Fall through to branch mutex / concurrency checks below.
4059
+ } else {
4060
+ log('info', `Clearing busy soft fix agent on ${item.id} (${originalAgent}) — waiting for any available agent`);
4061
+ clearPendingDispatchAgent(item);
4062
+ persistPendingDispatchAgent(item);
4063
+ continue;
4064
+ }
4065
+ } else if (!isHardPinned && reassignMs > 0 && item._agentBusySince) {
4018
4066
  const busySinceMs = new Date(item._agentBusySince).getTime();
4019
4067
  if (Date.now() - busySinceMs > reassignMs) {
4020
4068
  const originalAgent = item.agent;
4021
- const altAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
4069
+ const altAgent = resolvePendingDispatchAgent(item, config);
4022
4070
  if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
4023
4071
  log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent} — agent busy > ${reassignMs}ms`);
4024
- item.agent = altAgent;
4025
- item.agentName = config.agents[altAgent]?.name || tempAgents.get(altAgent)?.name || altAgent;
4026
- item.agentRole = config.agents[altAgent]?.role || tempAgents.get(altAgent)?.role || 'Agent';
4027
- delete item._agentBusySince;
4028
- delete item.skipReason;
4072
+ assignPendingDispatchAgent(item, altAgent, config);
4029
4073
  // Persist reassignment to dispatch.json
4030
- mutateDispatch((dp) => {
4031
- const p = (dp.pending || []).find(d => d.id === item.id);
4032
- if (p) {
4033
- p.agent = altAgent;
4034
- p.agentName = item.agentName;
4035
- p.agentRole = item.agentRole;
4036
- delete p._agentBusySince;
4037
- delete p.skipReason;
4038
- }
4039
- return dp;
4040
- });
4074
+ persistPendingDispatchAgent(item);
4041
4075
  // Fall through to branch mutex / concurrency checks below
4042
4076
  } else {
4043
4077
  continue; // No alternative agent available — keep waiting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1665",
3
+ "version": "0.1.1667",
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"