@yemi33/minions 0.1.1664 → 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 +6 -1
- package/engine/copilot-models.json +1 -1
- package/engine/lifecycle.js +61 -11
- package/engine/routing.js +8 -6
- package/engine/timeout.js +82 -61
- package/engine.js +116 -39
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1665 (2026-05-01)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
|
+
- treat failed task_complete as dispatch failure (#1954)
|
|
6
7
|
- prevent duplicate PR fix dispatch (#1953)
|
|
7
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
|
+
|
|
8
13
|
## 0.1.1662 (2026-05-01)
|
|
9
14
|
|
|
10
15
|
### Other
|
package/engine/lifecycle.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
1799
|
+
function parseCompletionKeyValues(text) {
|
|
1800
|
+
if (!text || typeof text !== 'string') return null;
|
|
1758
1801
|
const result = {};
|
|
1759
|
-
const
|
|
1802
|
+
const allowedFields = new Set(shared.COMPLETION_FIELDS || []);
|
|
1803
|
+
const lines = text.trim().split('\n');
|
|
1760
1804
|
for (const line of lines) {
|
|
1761
|
-
const
|
|
1805
|
+
const normalizedLine = line.trim().replace(/^[-*]\s+/, '');
|
|
1806
|
+
const colonIdx = normalizedLine.indexOf(':');
|
|
1762
1807
|
if (colonIdx < 1) continue;
|
|
1763
|
-
const key =
|
|
1764
|
-
|
|
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
|
|
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')
|
|
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 || (
|
|
2118
|
-
const outcome =
|
|
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:
|
|
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 ===
|
|
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 ===
|
|
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 !==
|
|
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 ===
|
|
285
|
+
if (preferred === ANY_AGENT) return anyEligible();
|
|
285
286
|
const preferredAgent = eligible(preferred);
|
|
286
287
|
if (preferredAgent) return preferredAgent;
|
|
287
288
|
|
|
288
|
-
if (fallback ===
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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 [
|
|
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
|
|
1369
|
-
errorReason = String(
|
|
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
|
-
|
|
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
|
|
2712
|
-
|
|
2713
|
-
|
|
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 =
|
|
2792
|
+
item._checkpointCount = promptResult.checkpointCount;
|
|
2740
2793
|
needsWrite = true;
|
|
2741
2794
|
continue;
|
|
2742
2795
|
}
|
|
2743
|
-
if (
|
|
2744
|
-
item._checkpointCount =
|
|
2796
|
+
if (promptResult.checkpointCount !== null) {
|
|
2797
|
+
item._checkpointCount = promptResult.checkpointCount;
|
|
2745
2798
|
needsWrite = true;
|
|
2746
2799
|
}
|
|
2747
2800
|
|
|
2748
|
-
const
|
|
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}
|
|
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.
|
|
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"
|