@yemi33/minions 0.1.1664 → 0.1.1666

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,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1666 (2026-05-01)
4
+
5
+ ### Other
6
+ - Fix soft routing for fix dispatches
7
+
3
8
  ## 0.1.1664 (2026-05-01)
4
9
 
5
10
  ### Features
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-01T19:29:49.074Z"
4
+ "cachedAt": "2026-05-01T20:53:59.616Z"
5
5
  }
@@ -1752,20 +1752,67 @@ function parseStructuredCompletion(stdout, runtimeName) {
1752
1752
  while ((m = blockPattern.exec(text)) !== null) {
1753
1753
  lastMatch = m[1];
1754
1754
  }
1755
- if (!lastMatch) return null;
1755
+ if (!lastMatch) {
1756
+ const taskCompleteSummary = extractTaskCompleteSummary(stdout);
1757
+ return taskCompleteSummary ? parseCompletionKeyValues(taskCompleteSummary) : null;
1758
+ }
1759
+
1760
+ return parseCompletionKeyValues(lastMatch);
1761
+ }
1762
+
1763
+ function extractTaskCompleteSummary(stdout) {
1764
+ if (!stdout || typeof stdout !== 'string') return '';
1765
+ let summary = '';
1766
+ for (const rawLine of stdout.split('\n')) {
1767
+ const line = rawLine.trim();
1768
+ if (!line || !line.startsWith('{')) continue;
1769
+ let obj;
1770
+ try { obj = JSON.parse(line); } catch { continue; }
1771
+ if (!obj || typeof obj !== 'object') continue;
1772
+ if (obj.type === 'session.task_complete') {
1773
+ const value = obj.data?.summary;
1774
+ if (typeof value === 'string' && value.trim()) summary = value;
1775
+ continue;
1776
+ }
1777
+ if (obj.type === 'tool.execution_start' && obj.data?.toolName === 'task_complete') {
1778
+ const value = obj.data?.arguments?.summary;
1779
+ if (typeof value === 'string' && value.trim()) summary = value;
1780
+ continue;
1781
+ }
1782
+ if (obj.type === 'assistant.message' && Array.isArray(obj.data?.toolRequests)) {
1783
+ for (const tr of obj.data.toolRequests) {
1784
+ if (tr?.name !== 'task_complete') continue;
1785
+ const value = tr.arguments?.summary || tr.intentionSummary;
1786
+ if (typeof value === 'string' && value.trim()) summary = value;
1787
+ }
1788
+ }
1789
+ }
1790
+ return summary;
1791
+ }
1792
+
1793
+ function hasActionableFailureClass(value) {
1794
+ const normalized = String(value || '').trim().toLowerCase();
1795
+ if (!normalized) return false;
1796
+ return !['n/a', 'na', 'none', 'null', 'no', 'false', 'not-applicable'].includes(normalized);
1797
+ }
1756
1798
 
1757
- // Parse key: value pairs
1799
+ function parseCompletionKeyValues(text) {
1800
+ if (!text || typeof text !== 'string') return null;
1758
1801
  const result = {};
1759
- const lines = lastMatch.trim().split('\n');
1802
+ const allowedFields = new Set(shared.COMPLETION_FIELDS || []);
1803
+ const lines = text.trim().split('\n');
1760
1804
  for (const line of lines) {
1761
- const colonIdx = line.indexOf(':');
1805
+ const normalizedLine = line.trim().replace(/^[-*]\s+/, '');
1806
+ const colonIdx = normalizedLine.indexOf(':');
1762
1807
  if (colonIdx < 1) continue;
1763
- const key = line.slice(0, colonIdx).trim().toLowerCase();
1764
- const value = line.slice(colonIdx + 1).trim();
1808
+ const key = normalizedLine.slice(0, colonIdx).trim().toLowerCase();
1809
+ if (allowedFields.size > 0 && !allowedFields.has(key)) continue;
1810
+ const value = normalizedLine.slice(colonIdx + 1).trim();
1765
1811
  if (key && value) result[key] = value;
1766
1812
  }
1767
1813
 
1768
- // Must have at least the status field to be valid
1814
+ // Must have at least status, or an actionable failure_class that implies failure.
1815
+ if (!result.status && hasActionableFailureClass(result.failure_class)) result.status = 'failed';
1769
1816
  if (!result.status) return null;
1770
1817
  return result;
1771
1818
  }
@@ -2102,7 +2149,10 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2102
2149
 
2103
2150
  const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
2104
2151
  const agentNeedsRerun = parseCompletionBoolean(structuredCompletion?.needs_rerun ?? structuredCompletion?.needsRerun) === true;
2105
- const agentReportedFailure = completionStatus.startsWith('fail') || agentNeedsRerun;
2152
+ const agentReportedFailure = completionStatus.startsWith('fail')
2153
+ || completionStatus === 'error'
2154
+ || hasActionableFailureClass(structuredCompletion?.failure_class)
2155
+ || agentNeedsRerun;
2106
2156
  const agentRetryable = parseCompletionBoolean(structuredCompletion?.retryable);
2107
2157
 
2108
2158
  // Auto-recover: if a failed implement/fix/test agent created PRs, it likely succeeded before the failure surfaced.
@@ -2114,8 +2164,8 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2114
2164
  const effectiveSuccess = (isSuccess && !agentReportedFailure) || autoRecovered;
2115
2165
 
2116
2166
  let nonCleanReportWritten = false;
2117
- if (completionStatus.startsWith('partial') || autoRecovered || (completionStatus.startsWith('fail') && isSuccess)) {
2118
- const outcome = completionStatus.startsWith('fail') ? 'failure' : 'partial';
2167
+ if (completionStatus.startsWith('partial') || autoRecovered || (agentReportedFailure && isSuccess)) {
2168
+ const outcome = agentReportedFailure ? 'failure' : 'partial';
2119
2169
  writeNonCleanAgentReport(dispatchItem, agentId, outcome, structuredCompletion, completionGateSummary, code);
2120
2170
  nonCleanReportWritten = true;
2121
2171
  }
@@ -2347,7 +2397,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
2347
2397
  // Review verdict check similarly moved before updateWorkItemStatus(DONE) — same root cause.
2348
2398
 
2349
2399
  if (type === WORK_TYPE.REVIEW) await updatePrAfterReview(agentId, meta?.pr, meta?.project, config, resultSummary, structuredCompletion);
2350
- if (type === WORK_TYPE.FIX) {
2400
+ if (type === WORK_TYPE.FIX && effectiveSuccess) {
2351
2401
  updatePrAfterFix(meta?.pr, meta?.project, meta?.source);
2352
2402
  // (#984) Sync PRD status for PR-linked features: fix work items have a different ID
2353
2403
  // than the original PRD feature, so syncPrdItemStatus(fixWiId, ...) finds nothing.
package/engine/routing.js CHANGED
@@ -13,6 +13,7 @@ const { ENGINE_DIR, DISPATCH_PATH } = queries;
13
13
 
14
14
  const MINIONS_DIR = shared.MINIONS_DIR;
15
15
  const ROUTING_PATH = path.join(MINIONS_DIR, 'routing.md');
16
+ const ANY_AGENT = '_any_';
16
17
 
17
18
  // ─── Temp Agents ─────────────────────────────────────────────────────────────
18
19
 
@@ -124,7 +125,7 @@ function normalizeWorkType(workType, fallback = WORK_TYPE.IMPLEMENT) {
124
125
 
125
126
  function routeForWorkType(workType) {
126
127
  const routes = getRoutingTableCached();
127
- return routes[normalizeWorkType(workType)] || routes[WORK_TYPE.IMPLEMENT] || { preferred: '_any_', fallback: '_any_' };
128
+ return routes[normalizeWorkType(workType)] || routes[WORK_TYPE.IMPLEMENT] || { preferred: ANY_AGENT, fallback: ANY_AGENT };
128
129
  }
129
130
 
130
131
  function isAgentHardPinned(item) {
@@ -224,10 +225,10 @@ function resolveAgent(workType, config, opts = {}) {
224
225
  }
225
226
 
226
227
  // Resolve _any_ token — pick any available agent (#480)
227
- if (preferred === '_any_') { const pick = pickAnyIdle(); if (pick) return pick; }
228
+ if (preferred === ANY_AGENT) { const pick = pickAnyIdle(); if (pick) return pick; }
228
229
  else if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
229
230
 
230
- if (fallback === '_any_') { const pick = pickAnyIdle([preferred]); if (pick) return pick; }
231
+ if (fallback === ANY_AGENT) { const pick = pickAnyIdle([preferred]); if (pick) return pick; }
231
232
  else if (fallback && isAvailable(fallback)) { _claimedAgents.add(fallback); return fallback; }
232
233
 
233
234
  // Fall back to any idle agent, preferring lower error rates
@@ -268,7 +269,7 @@ function resolveAgentReservation(workType, config, opts = {}) {
268
269
  const budget = agents[id].monthlyBudgetUsd;
269
270
  return !(budget && budget > 0 && getMonthlySpend(id) >= budget);
270
271
  };
271
- const eligible = (id) => (id && id !== '_any_' && hasBudget(id)) ? id : null;
272
+ const eligible = (id) => (id && id !== ANY_AGENT && hasBudget(id)) ? id : null;
272
273
  const anyEligible = (exclude = []) => {
273
274
  const excludeSet = new Set(exclude.filter(Boolean));
274
275
  return Object.keys(agents)
@@ -281,11 +282,11 @@ function resolveAgentReservation(workType, config, opts = {}) {
281
282
  const preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
282
283
  const fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
283
284
 
284
- if (preferred === '_any_') return anyEligible();
285
+ if (preferred === ANY_AGENT) return anyEligible();
285
286
  const preferredAgent = eligible(preferred);
286
287
  if (preferredAgent) return preferredAgent;
287
288
 
288
- if (fallback === '_any_') return anyEligible([preferred]);
289
+ if (fallback === ANY_AGENT) return anyEligible([preferred]);
289
290
  const fallbackAgent = eligible(fallback);
290
291
  if (fallbackAgent) return fallbackAgent;
291
292
 
@@ -305,6 +306,7 @@ module.exports = {
305
306
  isAgentHardPinned,
306
307
  getHardPinnedAgent,
307
308
  normalizeWorkType,
309
+ ANY_AGENT,
308
310
  _claimedAgents,
309
311
  resetClaimedAgents,
310
312
  resolveAgent,
package/engine/timeout.js CHANGED
@@ -194,6 +194,29 @@ function isOsPidAliveForDispatch(itemId) {
194
194
  catch { return false; }
195
195
  }
196
196
 
197
+ function readFileTail(filePath, maxBytes) {
198
+ const fd = fs.openSync(filePath, 'r');
199
+ try {
200
+ const stat = fs.fstatSync(fd);
201
+ const tailSize = Math.min(stat.size, maxBytes);
202
+ const buf = Buffer.alloc(tailSize);
203
+ fs.readSync(fd, buf, 0, tailSize, Math.max(0, stat.size - tailSize));
204
+ return buf.toString('utf8');
205
+ } finally {
206
+ fs.closeSync(fd);
207
+ }
208
+ }
209
+
210
+ function parseProcessExitCode(logText) {
211
+ if (!logText) return null;
212
+ const exitPattern = /(?:^|\n)\[process-exit\]\s+(?:code=)?(-?\d+|spawn-failed)(?=\s|$)/g;
213
+ let lastMatch = null;
214
+ let m;
215
+ while ((m = exitPattern.exec(logText)) !== null) lastMatch = m;
216
+ if (!lastMatch) return null;
217
+ return lastMatch[1] === 'spawn-failed' ? -1 : parseInt(lastMatch[1], 10);
218
+ }
219
+
197
220
  function checkTimeouts(config) {
198
221
  const activeProcesses = engine().activeProcesses;
199
222
  const engineRestartGraceUntil = engine().engineRestartGraceUntil;
@@ -225,6 +248,45 @@ function checkTimeouts(config) {
225
248
  const deadItems = [];
226
249
  const legacyAnnotationClears = new Set();
227
250
 
251
+ function completeFromOutput(item, liveLogPath, processExitCode, detectedLogText, hasProcess) {
252
+ const isSuccess = processExitCode === 0;
253
+ log('info', `Agent ${item.agent} (${item.id}) completed via output detection (exit code ${processExitCode}, ${isSuccess ? 'success' : 'error'})`);
254
+
255
+ // Extract output text for the output.log — read full file for complete parsing
256
+ const outputLogPath = path.join(AGENTS_DIR, item.agent, 'output.log');
257
+ try {
258
+ const fullLog = safeRead(liveLogPath) || detectedLogText;
259
+ const { text } = shared.parseStreamJsonOutput(fullLog);
260
+ safeWrite(outputLogPath, `# Output for dispatch ${item.id}\n# Exit code: ${processExitCode}\n# Completed: ${ts()}\n# Detected via output scan\n\n## Result\n${text || '(no text)'}\n`);
261
+ } catch (e) { log('warn', 'parse output result: ' + e.message); }
262
+
263
+ const fullLogForHooks = safeRead(liveLogPath) || detectedLogText;
264
+ let completionDetection = null;
265
+ let outputResultSummary = '';
266
+ try {
267
+ const runtimeName = item.meta?.runtimeName || item.runtimeName || 'claude';
268
+ outputResultSummary = parseAgentOutput(fullLogForHooks, runtimeName).resultSummary || '';
269
+ const gateSummary = outputResultSummary || (!fullLogForHooks.includes('"type":') ? fullLogForHooks : '');
270
+ completionDetection = isSuccess
271
+ ? detectNonTerminalResultSummary(gateSummary, parseStructuredCompletion(fullLogForHooks, runtimeName), parseCompletionReportFile(item))
272
+ : null;
273
+ } catch (e) { log('warn', 'completion summary gate: ' + e.message); }
274
+
275
+ completeDispatch(item.id, completionDetection ? DISPATCH_RESULT.ERROR : (isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR),
276
+ completionDetection ? completionDetection.reason : (isSuccess ? 'Completed (detected from output)' : `Exited with code ${processExitCode} (detected from output)`),
277
+ outputResultSummary,
278
+ completionDetection ? { processWorkItemFailure: false } : {});
279
+
280
+ // Run post-completion hooks via shared helper (async — fire and forget in timeout context).
281
+ // Pass the actual exit code so autoRecovery (PR-created-but-failed) still works correctly.
282
+ runPostCompletionHooks(item, item.agent, processExitCode, fullLogForHooks, config).catch(e => log('warn', 'post-completion hooks: ' + e.message));
283
+
284
+ if (hasProcess) {
285
+ shared.killImmediate(activeProcesses.get(item.id)?.proc);
286
+ activeProcesses.delete(item.id);
287
+ }
288
+ }
289
+
228
290
  for (const item of (dispatchData.active || [])) {
229
291
  if (!item.agent) continue;
230
292
 
@@ -265,75 +327,21 @@ function checkTimeouts(config) {
265
327
  //
266
328
  // We tail 64KB — process-exit is always the last non-empty line of the file.
267
329
  // No time cap: a stuck dispatch whose process has exited must always be detected (#716).
268
- let completedViaOutput = false;
330
+ // completedViaOutput detection is gated on a [process-exit] code=N sentinel;
331
+ // a "type":"result" event alone can race engine.js's close handler (#1792).
269
332
  try {
270
333
  let liveLogTail;
271
334
  try {
272
- const fd = fs.openSync(liveLogPath, 'r');
273
- try {
274
- const stat = fs.fstatSync(fd);
275
- const TAIL_SIZE = 65536; // 64KB
276
- const tailSize = Math.min(stat.size, TAIL_SIZE);
277
- const buf = Buffer.alloc(tailSize);
278
- fs.readSync(fd, buf, 0, tailSize, Math.max(0, stat.size - tailSize));
279
- liveLogTail = buf.toString('utf8');
280
- } finally { fs.closeSync(fd); }
335
+ liveLogTail = readFileTail(liveLogPath, 65536); // 64KB
281
336
  } catch { /* ENOENT or read failure — liveLogTail stays undefined */ }
282
337
 
283
338
  // Parse the LAST [process-exit] sentinel — code=N or "spawn-failed".
284
339
  // Use the global regex with a manual loop so we always pick up the latest occurrence,
285
340
  // not the first (defends against logs that somehow contain stale sentinel lines).
286
- let processExited = false;
287
- let processExitCode = null;
288
- if (liveLogTail) {
289
- const exitPattern = /\n\[process-exit\]\s+(?:code=)?(-?\d+|spawn-failed)/g;
290
- let lastMatch = null;
291
- let m;
292
- while ((m = exitPattern.exec(liveLogTail)) !== null) lastMatch = m;
293
- if (lastMatch) {
294
- processExited = true;
295
- processExitCode = lastMatch[1] === 'spawn-failed' ? -1 : parseInt(lastMatch[1], 10);
296
- }
297
- }
298
-
299
- if (processExited) {
300
- completedViaOutput = true;
301
- const isSuccess = processExitCode === 0;
302
- log('info', `Agent ${item.agent} (${item.id}) completed via output detection (exit code ${processExitCode}, ${isSuccess ? 'success' : 'error'})`);
341
+ const processExitCode = parseProcessExitCode(liveLogTail);
303
342
 
304
- // Extract output text for the output.log — read full file for complete parsing
305
- const outputLogPath = path.join(AGENTS_DIR, item.agent, 'output.log');
306
- try {
307
- const fullLog = safeRead(liveLogPath) || liveLogTail;
308
- const { text } = shared.parseStreamJsonOutput(fullLog);
309
- safeWrite(outputLogPath, `# Output for dispatch ${item.id}\n# Exit code: ${processExitCode}\n# Completed: ${ts()}\n# Detected via output scan\n\n## Result\n${text || '(no text)'}\n`);
310
- } catch (e) { log('warn', 'parse output result: ' + e.message); }
311
-
312
- const fullLogForHooks = safeRead(liveLogPath) || liveLogTail;
313
- let completionDetection = null;
314
- let outputResultSummary = '';
315
- try {
316
- const runtimeName = item.meta?.runtimeName || item.runtimeName || 'claude';
317
- outputResultSummary = parseAgentOutput(fullLogForHooks, runtimeName).resultSummary || '';
318
- const gateSummary = outputResultSummary || (!fullLogForHooks.includes('"type":') ? fullLogForHooks : '');
319
- completionDetection = isSuccess
320
- ? detectNonTerminalResultSummary(gateSummary, parseStructuredCompletion(fullLogForHooks, runtimeName), parseCompletionReportFile(item))
321
- : null;
322
- } catch (e) { log('warn', 'completion summary gate: ' + e.message); }
323
-
324
- completeDispatch(item.id, completionDetection ? DISPATCH_RESULT.ERROR : (isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR),
325
- completionDetection ? completionDetection.reason : (isSuccess ? 'Completed (detected from output)' : `Exited with code ${processExitCode} (detected from output)`),
326
- outputResultSummary,
327
- completionDetection ? { processWorkItemFailure: false } : {});
328
-
329
- // Run post-completion hooks via shared helper (async — fire and forget in timeout context).
330
- // Pass the actual exit code so autoRecovery (PR-created-but-failed) still works correctly.
331
- runPostCompletionHooks(item, item.agent, processExitCode, fullLogForHooks, config).catch(e => log('warn', 'post-completion hooks: ' + e.message));
332
-
333
- if (hasProcess) {
334
- shared.killImmediate(activeProcesses.get(item.id)?.proc);
335
- activeProcesses.delete(item.id);
336
- }
343
+ if (processExitCode !== null) {
344
+ completeFromOutput(item, liveLogPath, processExitCode, liveLogTail, hasProcess);
337
345
  continue; // Skip orphan/hung detection — we handled it
338
346
  }
339
347
  // Note: we DO NOT trigger on `"type":"result"` alone. There is a ~1s race between
@@ -392,8 +400,21 @@ function checkTimeouts(config) {
392
400
  log('info', `Orphan check: ${item.agent} (${item.id}) silent ${silentSec}s but OS PID is alive — keeping [${_logState}]`);
393
401
  continue;
394
402
  }
403
+ // Final safety scan: the normal 64KB tail scan can miss a clean exit if
404
+ // later runtime payloads or diagnostics push the sentinel outside the tail.
405
+ // Before declaring an orphan, inspect the full log and route terminal exits
406
+ // through the same completion path.
407
+ try {
408
+ const fullLog = safeRead(liveLogPath);
409
+ const processExitCode = parseProcessExitCode(fullLog);
410
+ if (processExitCode !== null) {
411
+ completeFromOutput(item, liveLogPath, processExitCode, fullLog, hasProcess);
412
+ continue;
413
+ }
414
+ } catch (e) { log('warn', 'orphan final output completion scan: ' + e.message); }
415
+
395
416
  // No tracked process AND no recent output past stale-orphan timeout AND (grace period expired OR confirmed-dead at restart) → orphaned
396
- log('warn', `Orphan detected: ${item.agent} (${item.id}) — no live process tracked, silent for ${silentSec}s [${_logState}]`);
417
+ log('warn', `Orphan detected: ${item.agent} (${item.id}) — no live process tracked, silent for ${silentSec}s [logExists/logSize=${_logState}]`);
397
418
  dispatch().updateAgentStatus(item.id, AGENT_STATUS.TIMED_OUT, `Orphaned — no process, silent for ${silentSec}s`);
398
419
  // Clear session so retry starts fresh
399
420
  try { shared.safeUnlink(path.join(AGENTS_DIR, item.agent, 'session.json')); } catch {}
package/engine.js CHANGED
@@ -1365,8 +1365,13 @@ 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?.summary) {
1369
- errorReason = String(structuredCompletion.summary).slice(0, 300);
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);
1370
1375
  } else if (effectiveResult === DISPATCH_RESULT.ERROR) {
1371
1376
  errorReason = stderr.split('\n').filter(l => l.trim()).slice(-5).join(' | ').trim().slice(0, 300);
1372
1377
  // W-mo3zul9pirjb — when claude CLI exits in <3s with code 1 and no output (the
@@ -2563,6 +2568,73 @@ async function discoverFromPrs(config, project) {
2563
2568
  /**
2564
2569
  * Scan work-items.json for manually queued tasks
2565
2570
  */
2571
+ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, project, root, branchName) {
2572
+ const vars = {
2573
+ ...buildBaseVars(agentId, config, project),
2574
+ item_id: item.id,
2575
+ item_name: item.title || item.id,
2576
+ item_priority: item.priority || 'medium',
2577
+ item_description: item.description || '',
2578
+ item_complexity: item.complexity || item.estimated_complexity || 'medium',
2579
+ task_description: item.title + (item.description ? '\n\n' + item.description : ''),
2580
+ task_id: item.id,
2581
+ work_type: workType,
2582
+ source_plan: item.sourcePlan || '',
2583
+ plan_slug: (item.sourcePlan || '').replace('.json', ''),
2584
+ additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
2585
+ scope_section: `## Scope: Project — ${project?.name || 'default'}\n\nThis task is scoped to a single project.`,
2586
+ branch_name: branchName,
2587
+ project_path: root,
2588
+ worktree_path: path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`),
2589
+ commit_message: item.commitMessage || `feat: ${item.title || item.id}`,
2590
+ notes_content: '',
2591
+ };
2592
+ const cpResult = buildWorkItemDispatchVars(item, vars, config, {
2593
+ worktreePath: vars.worktree_path || root,
2594
+ workType,
2595
+ });
2596
+ if (cpResult.needsReview) {
2597
+ return { needsReview: true, checkpointCount: cpResult.checkpointCount, prompt: null };
2598
+ }
2599
+
2600
+ const playbookName = selectPlaybook(workType, item);
2601
+ if (playbookName === 'work-item' && workType === WORK_TYPE.REVIEW) {
2602
+ log('info', `Work item ${item.id} is type "review" but has no PR — using work-item playbook`);
2603
+ }
2604
+ return {
2605
+ needsReview: false,
2606
+ checkpointCount: cpResult.checkpointCount,
2607
+ prompt: item.prompt || renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars) || item.description,
2608
+ };
2609
+ }
2610
+
2611
+ function projectFromDispatchMeta(metaProject, config) {
2612
+ if (!metaProject) return null;
2613
+ const projects = getProjects(config);
2614
+ if (metaProject.name) {
2615
+ const byName = projects.find(p => p.name === metaProject.name);
2616
+ if (byName) return byName;
2617
+ }
2618
+ if (metaProject.localPath) {
2619
+ const refPath = path.resolve(metaProject.localPath);
2620
+ const byPath = projects.find(p => p.localPath && path.resolve(p.localPath) === refPath);
2621
+ if (byPath) return byPath;
2622
+ }
2623
+ return metaProject;
2624
+ }
2625
+
2626
+ function refreshDeferredWorkItemPrompt(item, config) {
2627
+ if (!item?.meta?.deferAgentResolution || item.meta.source !== 'work-item' || !item.meta.item) return;
2628
+ if (!item.agent || item.agent === routing.ANY_AGENT) return;
2629
+ const project = projectFromDispatchMeta(item.meta.project, config);
2630
+ const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
2631
+ const workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
2632
+ const branchName = item.meta.branch || item.meta.item.branch || `work/${item.meta.item.id}`;
2633
+ const rendered = renderProjectWorkItemPromptForAgent(item.meta.item, workType, item.agent, config, project, root, branchName);
2634
+ if (rendered.prompt) item.prompt = rendered.prompt;
2635
+ item.meta.deferAgentResolution = false;
2636
+ }
2637
+
2566
2638
  function discoverFromWorkItems(config, project) {
2567
2639
  const src = project?.workSources?.workItems || config.workSources?.workItems;
2568
2640
  if (!src?.enabled) return [];
@@ -2670,17 +2742,20 @@ function discoverFromWorkItems(config, project) {
2670
2742
  needsWrite = true;
2671
2743
  }
2672
2744
  const agentHints = routing.extractAgentHints(item);
2745
+ const hasAgentHints = Array.isArray(agentHints) && agentHints.length > 0;
2673
2746
  const hardPinRequested = routing.isAgentHardPinned(item);
2674
2747
  let agentId = routing.getHardPinnedAgent(item, config.agents || {})
2675
2748
  || (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null);
2749
+ let reservedAgentId = agentId;
2676
2750
  const cfgAgents = config.agents || {};
2677
2751
  const budgetBlocked = Object.keys(cfgAgents).some(id => {
2678
2752
  const b = cfgAgents[id].monthlyBudgetUsd;
2679
2753
  return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
2680
2754
  });
2681
2755
  if (!agentId) {
2682
- if (!budgetBlocked && !hardPinRequested) {
2683
- agentId = resolveAgentReservation(workType, config, { agentHints });
2756
+ if (!budgetBlocked && !hardPinRequested && workType !== WORK_TYPE.FIX) {
2757
+ reservedAgentId = resolveAgentReservation(workType, config, { agentHints });
2758
+ agentId = reservedAgentId && !hasAgentHints ? routing.ANY_AGENT : reservedAgentId;
2684
2759
  }
2685
2760
  if (agentId) {
2686
2761
  delete item._pendingReason;
@@ -2698,6 +2773,7 @@ function discoverFromWorkItems(config, project) {
2698
2773
 
2699
2774
  const isShared = item.branchStrategy === 'shared-branch' && item.featureBranch;
2700
2775
  const branchName = isShared ? item.featureBranch : (item.branch || `work/${item.id}`);
2776
+ const deferredAgentResolution = agentId === routing.ANY_AGENT;
2701
2777
 
2702
2778
  // Branch mutex: skip if target branch is locked by an active dispatch
2703
2779
  const branchConflict = isBranchActive(branchName);
@@ -2708,50 +2784,23 @@ function discoverFromWorkItems(config, project) {
2708
2784
  continue;
2709
2785
  }
2710
2786
 
2711
- const vars = {
2712
- ...buildBaseVars(agentId, config, project),
2713
- item_id: item.id,
2714
- item_name: item.title || item.id,
2715
- item_priority: item.priority || 'medium',
2716
- item_description: item.description || '',
2717
- item_complexity: item.complexity || item.estimated_complexity || 'medium',
2718
- task_description: item.title + (item.description ? '\n\n' + item.description : ''),
2719
- task_id: item.id,
2720
- work_type: workType,
2721
- source_plan: item.sourcePlan || '',
2722
- plan_slug: (item.sourcePlan || '').replace('.json', ''),
2723
- additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
2724
- scope_section: `## Scope: Project — ${project?.name || 'default'}\n\nThis task is scoped to a single project.`,
2725
- branch_name: branchName,
2726
- project_path: root,
2727
- worktree_path: path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`),
2728
- commit_message: item.commitMessage || `feat: ${item.title || item.id}`,
2729
- notes_content: '',
2730
- };
2731
- // Build common vars: references, acceptance criteria, checkpoint, notes, task context
2732
- const cpResult = buildWorkItemDispatchVars(item, vars, config, {
2733
- worktreePath: vars.worktree_path || root,
2734
- workType,
2735
- });
2736
- if (cpResult.needsReview) {
2787
+ const promptAgentId = deferredAgentResolution ? reservedAgentId : agentId;
2788
+ const promptResult = renderProjectWorkItemPromptForAgent(item, workType, promptAgentId, config, project, root, branchName);
2789
+ if (promptResult.needsReview) {
2737
2790
  log('warn', `Work item ${item.id} exceeded 3 checkpoint-resumes — marking as needs-human-review`);
2738
2791
  item.status = WI_STATUS.NEEDS_REVIEW;
2739
- item._checkpointCount = cpResult.checkpointCount;
2792
+ item._checkpointCount = promptResult.checkpointCount;
2740
2793
  needsWrite = true;
2741
2794
  continue;
2742
2795
  }
2743
- if (cpResult.checkpointCount !== null) {
2744
- item._checkpointCount = cpResult.checkpointCount;
2796
+ if (promptResult.checkpointCount !== null) {
2797
+ item._checkpointCount = promptResult.checkpointCount;
2745
2798
  needsWrite = true;
2746
2799
  }
2747
2800
 
2748
- const playbookName = selectPlaybook(workType, item);
2749
- if (playbookName === 'work-item' && workType === WORK_TYPE.REVIEW) {
2750
- log('info', `Work item ${item.id} is type "review" but has no PR — using work-item playbook`);
2751
- }
2752
- const prompt = item.prompt || renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars) || item.description;
2801
+ const prompt = promptResult.prompt;
2753
2802
  if (!prompt) {
2754
- log('warn', `No playbook rendered for ${item.id} (type: ${workType}, playbook: ${playbookName}) — skipping`);
2803
+ log('warn', `No playbook rendered for ${item.id} (type: ${workType}) — skipping`);
2755
2804
  continue;
2756
2805
  }
2757
2806
 
@@ -2768,7 +2817,7 @@ function discoverFromWorkItems(config, project) {
2768
2817
  agentRole: config.agents[agentId]?.role || tempAgents.get(agentId)?.role || 'Agent',
2769
2818
  task: `[${project?.name || 'project'}] ${item.title || item.description?.slice(0, 80) || item.id}`,
2770
2819
  prompt,
2771
- 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 } }
2820
+ 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 }, deferAgentResolution: deferredAgentResolution }
2772
2821
  });
2773
2822
 
2774
2823
  setCooldown(key);
@@ -3204,7 +3253,7 @@ function discoverCentralWorkItems(config) {
3204
3253
  const hardPinRequested = routing.isAgentHardPinned(item);
3205
3254
  const agentId = routing.getHardPinnedAgent(item, config.agents || {})
3206
3255
  || (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null)
3207
- || (!hardPinRequested ? resolveAgentReservation(workType, config, { agentHints }) : null);
3256
+ || (!hardPinRequested && workType !== WORK_TYPE.FIX ? resolveAgentReservation(workType, config, { agentHints }) : null);
3208
3257
  if (!agentId) continue;
3209
3258
 
3210
3259
  const agentName = config.agents[agentId]?.name || agentId;
@@ -3512,6 +3561,62 @@ async function discoverWork(config) {
3512
3561
  return allWork.length;
3513
3562
  }
3514
3563
 
3564
+ function getPendingDispatchRoutingOpts(item) {
3565
+ const opts = { agentHints: routing.extractAgentHints(item?.meta?.item) };
3566
+ const authorAgent = item?.meta?.pr?.agent;
3567
+ if (authorAgent) opts.authorAgent = authorAgent;
3568
+ return opts;
3569
+ }
3570
+
3571
+ function resolvePendingDispatchAgent(item, config) {
3572
+ return resolveAgent(
3573
+ routing.normalizeWorkType(item?.type, WORK_TYPE.IMPLEMENT),
3574
+ config,
3575
+ getPendingDispatchRoutingOpts(item)
3576
+ );
3577
+ }
3578
+
3579
+ function assignPendingDispatchAgent(item, agentId, config) {
3580
+ const agents = config.agents || {};
3581
+ item.agent = agentId;
3582
+ item.agentName = agents[agentId]?.name || tempAgents.get(agentId)?.name || agentId;
3583
+ item.agentRole = agents[agentId]?.role || tempAgents.get(agentId)?.role || 'Agent';
3584
+ delete item._agentBusySince;
3585
+ delete item.skipReason;
3586
+ }
3587
+
3588
+ function clearPendingDispatchAgent(item) {
3589
+ delete item.agent;
3590
+ delete item.agentName;
3591
+ delete item.agentRole;
3592
+ delete item._agentBusySince;
3593
+ delete item.skipReason;
3594
+ }
3595
+
3596
+ function persistPendingDispatchAgent(item) {
3597
+ mutateDispatch((dp) => {
3598
+ const p = (dp.pending || []).find(d => d.id === item.id);
3599
+ if (p) {
3600
+ if (item.agent) {
3601
+ p.agent = item.agent;
3602
+ p.agentName = item.agentName;
3603
+ p.agentRole = item.agentRole;
3604
+ } else {
3605
+ delete p.agent;
3606
+ delete p.agentName;
3607
+ delete p.agentRole;
3608
+ }
3609
+ delete p._agentBusySince;
3610
+ delete p.skipReason;
3611
+ }
3612
+ return dp;
3613
+ });
3614
+ }
3615
+
3616
+ function isSoftFixDispatch(item) {
3617
+ return item?.type === WORK_TYPE.FIX && !routing.isAgentHardPinned(item.meta?.item);
3618
+ }
3619
+
3515
3620
  // ─── Main Tick ──────────────────────────────────────────────────────────────
3516
3621
 
3517
3622
  let tickCount = 0;
@@ -3872,33 +3977,51 @@ async function tickInner() {
3872
3977
  log('warn', `Duplicate dispatch ID ${item.id} in pending queue — skipping`);
3873
3978
  continue;
3874
3979
  }
3980
+ if (item.agent === routing.ANY_AGENT) {
3981
+ const routedAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
3982
+ if (!routedAgent) {
3983
+ log('debug', `Pending dispatch ${item.id} is waiting for any available agent`);
3984
+ continue;
3985
+ }
3986
+ item.agent = routedAgent;
3987
+ item.agentName = config.agents[routedAgent]?.name || tempAgents.get(routedAgent)?.name || routedAgent;
3988
+ item.agentRole = config.agents[routedAgent]?.role || tempAgents.get(routedAgent)?.role || 'Agent';
3989
+ delete item._agentBusySince;
3990
+ delete item.skipReason;
3991
+ refreshDeferredWorkItemPrompt(item, config);
3992
+ try {
3993
+ mutateDispatch((dp) => {
3994
+ const p = (dp.pending || []).find(d => d.id === item.id);
3995
+ if (p) {
3996
+ p.agent = item.agent;
3997
+ p.agentName = item.agentName;
3998
+ p.agentRole = item.agentRole;
3999
+ p.prompt = item.prompt;
4000
+ if (item.meta) p.meta = item.meta;
4001
+ delete p._agentBusySince;
4002
+ delete p.skipReason;
4003
+ }
4004
+ return dp;
4005
+ });
4006
+ } catch (e) { log('warn', `Persist any-agent resolution for ${item.id} failed: ${e.message}`); }
4007
+ }
3875
4008
  // #1206: Guard against undefined/non-string item.agent. A corrupted dispatch
3876
4009
  // entry (manual edit, serialization round-trip, cleared field) would otherwise
3877
4010
  // be handed to spawnAgent, which crashes with `TypeError: "path" argument must
3878
4011
  // be of type string. Received undefined` and re-queues — every tick. Try to
3879
4012
  // resolve a fallback via routing; if none is available, skip this tick.
3880
4013
  if (!item.agent || typeof item.agent !== 'string') {
3881
- const fallback = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
4014
+ const fallback = resolvePendingDispatchAgent(item, config);
3882
4015
  if (!fallback) {
3883
4016
  log('warn', `Pending dispatch ${item.id} has no agent and routing returned no fallback — skipping`);
3884
4017
  continue;
3885
4018
  }
3886
4019
  log('info', `Pending dispatch ${item.id} missing agent; routed → ${fallback} (#1206 guard)`);
3887
- item.agent = fallback;
3888
- item.agentName = config.agents[fallback]?.name || tempAgents.get(fallback)?.name || fallback;
3889
- item.agentRole = config.agents[fallback]?.role || tempAgents.get(fallback)?.role || 'Agent';
4020
+ assignPendingDispatchAgent(item, fallback, config);
3890
4021
  // Persist so the fix survives across ticks even if this dispatch is skipped
3891
4022
  // later in the loop (branch lock, concurrency cap, agent busy, etc.).
3892
4023
  try {
3893
- mutateDispatch((dp) => {
3894
- const p = (dp.pending || []).find(d => d.id === item.id);
3895
- if (p) {
3896
- p.agent = item.agent;
3897
- p.agentName = item.agentName;
3898
- p.agentRole = item.agentRole;
3899
- }
3900
- return dp;
3901
- });
4024
+ persistPendingDispatchAgent(item);
3902
4025
  } catch (e) { log('warn', `Persist agent resolution for ${item.id} failed: ${e.message}`); }
3903
4026
  }
3904
4027
  // #1204: Pre-assigned unspawned temp agents never unblock naturally.
@@ -3909,27 +4032,13 @@ async function tickInner() {
3909
4032
  // them eagerly before the busy check so an idle named agent can pick up.
3910
4033
  const isUnspawnedTemp = item.agent?.startsWith('temp-') && !busyAgents.has(item.agent);
3911
4034
  if (isUnspawnedTemp) {
3912
- const altAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config);
4035
+ const altAgent = resolvePendingDispatchAgent(item, config);
3913
4036
  if (altAgent && altAgent !== item.agent) {
3914
4037
  const prevAgent = item.agent;
3915
- item.agent = altAgent;
3916
- item.agentName = config.agents[altAgent]?.name || tempAgents.get(altAgent)?.name || altAgent;
3917
- item.agentRole = config.agents[altAgent]?.role || tempAgents.get(altAgent)?.role || 'Agent';
3918
- delete item._agentBusySince;
3919
- delete item.skipReason;
4038
+ assignPendingDispatchAgent(item, altAgent, config);
3920
4039
  log('info', `Reassigning ${item.id} from unspawned temp ${prevAgent} to ${altAgent} — temp agent never spawned`);
3921
4040
  // Persist reassignment to dispatch.json so it survives restarts/ticks
3922
- mutateDispatch((dp) => {
3923
- const p = (dp.pending || []).find(d => d.id === item.id);
3924
- if (p) {
3925
- p.agent = altAgent;
3926
- p.agentName = item.agentName;
3927
- p.agentRole = item.agentRole;
3928
- delete p._agentBusySince;
3929
- delete p.skipReason;
3930
- }
3931
- return dp;
3932
- });
4041
+ persistPendingDispatchAgent(item);
3933
4042
  }
3934
4043
  }
3935
4044
  if (busyAgents.has(item.agent)) {
@@ -3937,30 +4046,30 @@ async function tickInner() {
3937
4046
  // try to find an alternative agent via routing. Skip explicitly assigned items.
3938
4047
  const reassignMs = config.engine?.agentBusyReassignMs ?? ENGINE_DEFAULTS.agentBusyReassignMs;
3939
4048
  const isHardPinned = routing.isAgentHardPinned(item.meta?.item);
3940
- if (!isHardPinned && reassignMs > 0 && item._agentBusySince) {
4049
+ if (isSoftFixDispatch(item)) {
4050
+ const originalAgent = item.agent;
4051
+ const altAgent = resolvePendingDispatchAgent(item, config);
4052
+ if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
4053
+ log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent} — soft fix suggestion unavailable`);
4054
+ assignPendingDispatchAgent(item, altAgent, config);
4055
+ persistPendingDispatchAgent(item);
4056
+ // Fall through to branch mutex / concurrency checks below.
4057
+ } else {
4058
+ log('info', `Clearing busy soft fix agent on ${item.id} (${originalAgent}) — waiting for any available agent`);
4059
+ clearPendingDispatchAgent(item);
4060
+ persistPendingDispatchAgent(item);
4061
+ continue;
4062
+ }
4063
+ } else if (!isHardPinned && reassignMs > 0 && item._agentBusySince) {
3941
4064
  const busySinceMs = new Date(item._agentBusySince).getTime();
3942
4065
  if (Date.now() - busySinceMs > reassignMs) {
3943
4066
  const originalAgent = item.agent;
3944
- const altAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
4067
+ const altAgent = resolvePendingDispatchAgent(item, config);
3945
4068
  if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
3946
4069
  log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent} — agent busy > ${reassignMs}ms`);
3947
- item.agent = altAgent;
3948
- item.agentName = config.agents[altAgent]?.name || tempAgents.get(altAgent)?.name || altAgent;
3949
- item.agentRole = config.agents[altAgent]?.role || tempAgents.get(altAgent)?.role || 'Agent';
3950
- delete item._agentBusySince;
3951
- delete item.skipReason;
4070
+ assignPendingDispatchAgent(item, altAgent, config);
3952
4071
  // Persist reassignment to dispatch.json
3953
- mutateDispatch((dp) => {
3954
- const p = (dp.pending || []).find(d => d.id === item.id);
3955
- if (p) {
3956
- p.agent = altAgent;
3957
- p.agentName = item.agentName;
3958
- p.agentRole = item.agentRole;
3959
- delete p._agentBusySince;
3960
- delete p.skipReason;
3961
- }
3962
- return dp;
3963
- });
4072
+ persistPendingDispatchAgent(item);
3964
4073
  // Fall through to branch mutex / concurrency checks below
3965
4074
  } else {
3966
4075
  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.1664",
3
+ "version": "0.1.1666",
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"