@yemi33/minions 0.1.1663 → 0.1.1665

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,9 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1663 (2026-05-01)
3
+ ## 0.1.1665 (2026-05-01)
4
+
5
+ ### Features
6
+ - treat failed task_complete as dispatch failure (#1954)
7
+ - prevent duplicate PR fix dispatch (#1953)
8
+
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
+ ## 0.1.1662 (2026-05-01)
4
14
 
5
15
  ### Other
6
- - test(shared): add unit tests for PR URL parsing & scope helpers (#1949)
7
16
  - test(scheduler): add unit tests for cron parsing edge cases & discovery flow (#1948)
8
17
 
9
18
  ## 0.1.1661 (2026-05-01)
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-01T15:03:53.784Z"
4
+ "cachedAt": "2026-05-01T19:30:38.180Z"
5
5
  }
@@ -59,16 +59,52 @@ function mutateDispatch(mutator) {
59
59
 
60
60
  // ─── Add to Dispatch ─────────────────────────────────────────────────────────
61
61
 
62
- function getPrDispatchDedupeKey(entry) {
63
- if (!entry?.meta?.pr || !entry?.meta?.project || !entry?.type) return null;
64
- const type = entry.type === WORK_TYPE.FIX ? WORK_TYPE.FIX : entry.type;
62
+ function getDispatchProjectKey(project) {
63
+ if (!project) return '';
64
+ return project.name || (project.localPath ? path.resolve(project.localPath).toLowerCase() : '');
65
+ }
66
+
67
+ function getPrDispatchTargetKey(entry) {
68
+ if (!entry?.meta?.pr || !entry?.meta?.project) return null;
65
69
  const project = entry.meta.project;
66
- const projectKey = project.name
67
- || (project.localPath ? path.resolve(project.localPath).toLowerCase() : '');
70
+ const projectKey = getDispatchProjectKey(project);
68
71
  if (!projectKey) return null;
69
72
  const prKey = shared.getCanonicalPrId(project, entry.meta.pr, entry.meta.pr?.url || '');
70
73
  if (!prKey) return null;
71
- return `${projectKey}:${type}:${prKey}`;
74
+ return `${projectKey}:${prKey}`;
75
+ }
76
+
77
+ function getPrDispatchDedupeKey(entry) {
78
+ if (!entry?.type) return null;
79
+ const targetKey = getPrDispatchTargetKey(entry);
80
+ if (!targetKey) return null;
81
+ const type = entry.type === WORK_TYPE.FIX ? WORK_TYPE.FIX : entry.type;
82
+ return `${targetKey}:${type}`;
83
+ }
84
+
85
+ function getBranchDispatchLockKey(entry) {
86
+ const branch = entry?.meta?.branch || entry?.meta?.pr?.branch || '';
87
+ if (!branch) return null;
88
+ const normalizedBranch = shared.sanitizeBranch(branch);
89
+ if (!normalizedBranch) return null;
90
+ const projectKey = getDispatchProjectKey(entry?.meta?.project) || 'default';
91
+ return `${projectKey}:${normalizedBranch}`;
92
+ }
93
+
94
+ function findActivePrOrBranchLock(dispatch, item) {
95
+ if (item?.type !== WORK_TYPE.FIX) return null;
96
+ const active = dispatch.active || [];
97
+ const prTargetKey = getPrDispatchTargetKey(item);
98
+ if (prTargetKey) {
99
+ const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
100
+ if (existing) return { existing, reason: `active PR dispatch ${prTargetKey}` };
101
+ }
102
+
103
+ const branchLockKey = getBranchDispatchLockKey(item);
104
+ if (!branchLockKey) return null;
105
+ const existing = active.find(d => getBranchDispatchLockKey(d) === branchLockKey);
106
+ if (!existing) return null;
107
+ return { existing, reason: `active branch dispatch ${branchLockKey}` };
72
108
  }
73
109
 
74
110
  function addToDispatch(item) {
@@ -106,6 +142,11 @@ function addToDispatch(item) {
106
142
  return dispatch;
107
143
  }
108
144
  }
145
+ const activeLock = findActivePrOrBranchLock(dispatch, item);
146
+ if (activeLock) {
147
+ log('info', `Dedup: skipping ${item.id} — ${activeLock.reason} already in ${activeLock.existing.id}`);
148
+ return dispatch;
149
+ }
109
150
  dispatch.pending.push(item);
110
151
  added = true;
111
152
  return dispatch;
@@ -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,9 +2742,11 @@ 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;
@@ -2680,7 +2754,8 @@ function discoverFromWorkItems(config, project) {
2680
2754
  });
2681
2755
  if (!agentId) {
2682
2756
  if (!budgetBlocked && !hardPinRequested) {
2683
- agentId = resolveAgentReservation(workType, config, { agentHints });
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);
@@ -3872,6 +3921,34 @@ async function tickInner() {
3872
3921
  log('warn', `Duplicate dispatch ID ${item.id} in pending queue — skipping`);
3873
3922
  continue;
3874
3923
  }
3924
+ if (item.agent === routing.ANY_AGENT) {
3925
+ const routedAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
3926
+ if (!routedAgent) {
3927
+ log('debug', `Pending dispatch ${item.id} is waiting for any available agent`);
3928
+ continue;
3929
+ }
3930
+ item.agent = routedAgent;
3931
+ item.agentName = config.agents[routedAgent]?.name || tempAgents.get(routedAgent)?.name || routedAgent;
3932
+ item.agentRole = config.agents[routedAgent]?.role || tempAgents.get(routedAgent)?.role || 'Agent';
3933
+ delete item._agentBusySince;
3934
+ delete item.skipReason;
3935
+ refreshDeferredWorkItemPrompt(item, config);
3936
+ try {
3937
+ mutateDispatch((dp) => {
3938
+ const p = (dp.pending || []).find(d => d.id === item.id);
3939
+ if (p) {
3940
+ p.agent = item.agent;
3941
+ p.agentName = item.agentName;
3942
+ p.agentRole = item.agentRole;
3943
+ p.prompt = item.prompt;
3944
+ if (item.meta) p.meta = item.meta;
3945
+ delete p._agentBusySince;
3946
+ delete p.skipReason;
3947
+ }
3948
+ return dp;
3949
+ });
3950
+ } catch (e) { log('warn', `Persist any-agent resolution for ${item.id} failed: ${e.message}`); }
3951
+ }
3875
3952
  // #1206: Guard against undefined/non-string item.agent. A corrupted dispatch
3876
3953
  // entry (manual edit, serialization round-trip, cleared field) would otherwise
3877
3954
  // be handed to spawnAgent, which crashes with `TypeError: "path" argument must
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1663",
3
+ "version": "0.1.1665",
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"