@yemi33/minions 0.1.1656 → 0.1.1658
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 +14 -2
- package/dashboard.js +7 -10
- package/engine/copilot-models.json +1 -1
- package/engine/dispatch.js +25 -3
- package/engine/lifecycle.js +30 -61
- package/engine/pipeline.js +5 -3
- package/engine/routing.js +76 -8
- package/engine/scheduler.js +4 -2
- package/engine/timeout.js +2 -2
- package/engine.js +32 -20
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1658 (2026-05-01)
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
- trust structured completion status, drop prose regex
|
|
7
|
+
|
|
8
|
+
### Other
|
|
9
|
+
- Fix soft routing hints
|
|
10
|
+
|
|
11
|
+
## 0.1.1657 (2026-05-01)
|
|
12
|
+
|
|
13
|
+
### Other
|
|
14
|
+
- Fix dispatch dedupe and routing starvation
|
|
15
|
+
|
|
16
|
+
## 0.1.1655 (2026-05-01)
|
|
4
17
|
|
|
5
18
|
### Features
|
|
6
19
|
- ADO manual PR link metadata and branch race
|
|
7
|
-
- gate non-terminal task completions
|
|
8
20
|
|
|
9
21
|
## 0.1.1654 (2026-05-01)
|
|
10
22
|
|
package/dashboard.js
CHANGED
|
@@ -31,7 +31,7 @@ const dispatchMod = require('./engine/dispatch');
|
|
|
31
31
|
const steering = require('./engine/steering');
|
|
32
32
|
const os = require('os');
|
|
33
33
|
|
|
34
|
-
const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, reopenWorkItem } = shared;
|
|
34
|
+
const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, reopenWorkItem } = shared;
|
|
35
35
|
const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
|
|
36
36
|
getSkills, getInbox, getNotesWithMeta, getPullRequests,
|
|
37
37
|
getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
|
|
@@ -1392,7 +1392,7 @@ async function executeCCActions(actions) {
|
|
|
1392
1392
|
try {
|
|
1393
1393
|
switch (action.type) {
|
|
1394
1394
|
case 'dispatch': case 'fix': case 'implement': case 'explore': case 'review': case 'test': {
|
|
1395
|
-
const workType = action.workType || (action.type !== 'dispatch' ? action.type :
|
|
1395
|
+
const workType = routing.normalizeWorkType(action.workType || (action.type !== 'dispatch' ? action.type : WORK_TYPE.IMPLEMENT), WORK_TYPE.IMPLEMENT);
|
|
1396
1396
|
const id = 'W-' + shared.uid();
|
|
1397
1397
|
const project = action.project || '';
|
|
1398
1398
|
|
|
@@ -2733,22 +2733,19 @@ const server = http.createServer(async (req, res) => {
|
|
|
2733
2733
|
}
|
|
2734
2734
|
const id = 'W-' + shared.uid();
|
|
2735
2735
|
const item = {
|
|
2736
|
-
id, title: body.title.trim(), type: body.type
|
|
2736
|
+
id, title: body.title.trim(), type: routing.normalizeWorkType(body.type, WORK_TYPE.IMPLEMENT),
|
|
2737
2737
|
priority: body.priority || 'medium', description: body.description || '',
|
|
2738
2738
|
status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard',
|
|
2739
2739
|
};
|
|
2740
2740
|
if (body.scope) item.scope = body.scope;
|
|
2741
|
-
// Agent assignment normalization:
|
|
2742
|
-
//
|
|
2743
|
-
//
|
|
2744
|
-
// by setting `item.agent`. The engine reads `item.agent || resolveAgent(…)`,
|
|
2745
|
-
// so a hard-pinned item bypasses routing entirely and queues until that
|
|
2746
|
-
// exact agent is free. Multi-agent arrays remain `item.agents` (hints
|
|
2747
|
-
// for resolveAgent or fan-out scope).
|
|
2741
|
+
// Agent assignment normalization: `agent` and `agents` are routing hints.
|
|
2742
|
+
// Use agentLock/hardAgent only for the rare case where an item must wait
|
|
2743
|
+
// for one exact agent instead of falling through to another idle agent.
|
|
2748
2744
|
const _agentsArr = Array.isArray(body.agents) ? body.agents.filter(Boolean) : (typeof body.agents === 'string' && body.agents ? [body.agents] : []);
|
|
2749
2745
|
if (body.agent) item.agent = String(body.agent);
|
|
2750
2746
|
else if (_agentsArr.length === 1 && body.scope !== 'fan-out') item.agent = String(_agentsArr[0]);
|
|
2751
2747
|
if (_agentsArr.length > 0) item.agents = _agentsArr;
|
|
2748
|
+
if (body.agentLock === true || body.hardAgent === true) item.agentLock = true;
|
|
2752
2749
|
if (body.references) item.references = body.references;
|
|
2753
2750
|
if (body.acceptanceCriteria) item.acceptanceCriteria = body.acceptanceCriteria;
|
|
2754
2751
|
if (body.skipPr) item.skipPr = true;
|
package/engine/dispatch.js
CHANGED
|
@@ -12,7 +12,7 @@ const { setCooldownFailure } = require('./cooldown');
|
|
|
12
12
|
const { safeJson, safeWrite, safeReadDir, mutateJsonFileLocked, mutateWorkItems,
|
|
13
13
|
mutatePullRequests, getProjects, projectWorkItemsPath, projectPrPath, log, ts, dateStamp,
|
|
14
14
|
sidecarDispatchPrompt, deleteDispatchPromptSidecar,
|
|
15
|
-
WI_STATUS, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS, PR_STATUS } = shared;
|
|
15
|
+
WI_STATUS, WORK_TYPE, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS, PR_STATUS } = shared;
|
|
16
16
|
const { getConfig, getDispatch, DISPATCH_PATH, INBOX_DIR } = queries;
|
|
17
17
|
|
|
18
18
|
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
@@ -59,8 +59,20 @@ 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;
|
|
65
|
+
const project = entry.meta.project;
|
|
66
|
+
const projectKey = project.name
|
|
67
|
+
|| (project.localPath ? path.resolve(project.localPath).toLowerCase() : '');
|
|
68
|
+
if (!projectKey) return null;
|
|
69
|
+
const prKey = shared.getCanonicalPrId(project, entry.meta.pr, entry.meta.pr?.url || '');
|
|
70
|
+
if (!prKey) return null;
|
|
71
|
+
return `${projectKey}:${type}:${prKey}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
function addToDispatch(item) {
|
|
63
|
-
item.id = item.id || `${item.agent}-${item.type}-${shared.uid()}`;
|
|
75
|
+
item.id = item.id || `${item.agent || 'unassigned'}-${item.type}-${shared.uid()}`;
|
|
64
76
|
item.created_at = ts();
|
|
65
77
|
item.meta = item.meta && typeof item.meta === 'object' ? item.meta : {};
|
|
66
78
|
if (!item.meta.completionReportPath) {
|
|
@@ -69,7 +81,7 @@ function addToDispatch(item) {
|
|
|
69
81
|
let added = false;
|
|
70
82
|
mutateDispatch((dispatch) => {
|
|
71
83
|
// Dedup: skip if same work item ID is already pending or active
|
|
72
|
-
const wiId = item.meta?.item?.id;
|
|
84
|
+
const wiId = item.meta?.source === 'central-work-item-fanout' ? null : item.meta?.item?.id;
|
|
73
85
|
if (wiId) {
|
|
74
86
|
const existing = [...dispatch.pending, ...(dispatch.active || [])].find(d => d.meta?.item?.id === wiId);
|
|
75
87
|
if (existing) {
|
|
@@ -85,6 +97,15 @@ function addToDispatch(item) {
|
|
|
85
97
|
return dispatch;
|
|
86
98
|
}
|
|
87
99
|
}
|
|
100
|
+
const prDedupeKey = getPrDispatchDedupeKey(item);
|
|
101
|
+
if (prDedupeKey) {
|
|
102
|
+
const existing = [...dispatch.pending, ...(dispatch.active || [])]
|
|
103
|
+
.find(d => getPrDispatchDedupeKey(d) === prDedupeKey);
|
|
104
|
+
if (existing) {
|
|
105
|
+
log('info', `Dedup: skipping ${item.id} — PR dispatch ${prDedupeKey} already in ${existing.id}`);
|
|
106
|
+
return dispatch;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
88
109
|
dispatch.pending.push(item);
|
|
89
110
|
added = true;
|
|
90
111
|
return dispatch;
|
|
@@ -568,6 +589,7 @@ function cancelPendingWorkItems(wiPath, matchFn, reason) {
|
|
|
568
589
|
module.exports = {
|
|
569
590
|
mutateDispatch,
|
|
570
591
|
addToDispatch,
|
|
592
|
+
getPrDispatchDedupeKey,
|
|
571
593
|
isRetryableFailureReason,
|
|
572
594
|
completeDispatch,
|
|
573
595
|
writeInboxAlert,
|
package/engine/lifecycle.js
CHANGED
|
@@ -1820,70 +1820,39 @@ function normalizeCompletionStatus(status) {
|
|
|
1820
1820
|
return String(status || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
|
|
1821
1821
|
}
|
|
1822
1822
|
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1823
|
+
// Trust the agent's explicit structured `status` field as the only signal that
|
|
1824
|
+
// a completion is non-terminal. Earlier versions also scanned the agent's
|
|
1825
|
+
// resultSummary prose with regex (looking for "pending", "in progress",
|
|
1826
|
+
// "partial", "wake up", etc.), but that produced false positives on benign
|
|
1827
|
+
// phrases like "I checked the pending PRs" or "build is in progress on CI"
|
|
1828
|
+
// and burned 3-9 minutes of agent time per false-positive retry.
|
|
1829
|
+
//
|
|
1830
|
+
// Both structured signals (the JSON completion report at MINIONS_COMPLETION_REPORT
|
|
1831
|
+
// and the fenced ```completion block in stdout) carry a `status` field. If the
|
|
1832
|
+
// agent explicitly says they're not done, honor it; otherwise accept the
|
|
1833
|
+
// dispatch. The PR attachment contract still catches silent-failure cases
|
|
1834
|
+
// for PR-producing work.
|
|
1835
|
+
const NON_TERMINAL_COMPLETION_STATUSES = new Set([
|
|
1836
|
+
'partial', 'partially-complete', 'in-progress', 'pending', 'deferred',
|
|
1837
|
+
'blocked', 'incomplete', 'to-be-continued',
|
|
1838
|
+
'failed', 'failure', 'error',
|
|
1839
|
+
]);
|
|
1840
|
+
|
|
1841
|
+
function detectNonTerminalResultSummary(_resultSummary, structuredCompletion, completionReport) {
|
|
1842
|
+
const candidates = [completionReport?.status, structuredCompletion?.status];
|
|
1843
|
+
for (const status of candidates) {
|
|
1844
|
+
const norm = normalizeCompletionStatus(status);
|
|
1845
|
+
if (!norm) continue;
|
|
1846
|
+
if (NON_TERMINAL_COMPLETION_STATUSES.has(norm)) {
|
|
1847
|
+
const isFailure = norm === 'failed' || norm === 'failure' || norm === 'error';
|
|
1842
1848
|
return {
|
|
1843
|
-
phrase: `status:${
|
|
1844
|
-
reason:
|
|
1849
|
+
phrase: `status:${status}`,
|
|
1850
|
+
reason: isFailure
|
|
1851
|
+
? `Nonterminal completion summary: structured status is '${status}', not a successful terminal state`
|
|
1852
|
+
: `Nonterminal completion summary: structured status is '${status}'`,
|
|
1845
1853
|
};
|
|
1846
1854
|
}
|
|
1847
|
-
if (/^(?:fail|failed|failure|error)/.test(completionStatus)) {
|
|
1848
|
-
return {
|
|
1849
|
-
phrase: `status:${structuredCompletion.status}`,
|
|
1850
|
-
reason: `Nonterminal completion summary: structured status is '${structuredCompletion.status}', not a successful terminal state`,
|
|
1851
|
-
};
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
|
|
1855
|
-
if (structuredCompletion?.pending && !isTerminalPendingValue(structuredCompletion.pending)) {
|
|
1856
|
-
return {
|
|
1857
|
-
phrase: 'pending',
|
|
1858
|
-
reason: `Nonterminal completion summary: pending work remains (${String(structuredCompletion.pending).slice(0, 160)})`,
|
|
1859
|
-
};
|
|
1860
1855
|
}
|
|
1861
|
-
|
|
1862
|
-
const text = String(resultSummary || '').replace(/\r/g, '').trim();
|
|
1863
|
-
if (!text) return null;
|
|
1864
|
-
|
|
1865
|
-
const patterns = [
|
|
1866
|
-
{ phrase: 'still running', re: /\b(?:still|currently|continues?\s+to\s+be)\s+(?:running|ongoing|in\s+progress)\b/i },
|
|
1867
|
-
{ phrase: 'will check later', re: /\b(?:i(?:'|’)ll|i\s+will|we(?:'|’)ll|we\s+will|will)\s+(?:check|verify|review|follow\s+up|revisit)\s+(?:again\s+)?(?:later|soon|in\b|after\b|when\b)/i },
|
|
1868
|
-
{ phrase: 'wake up', re: /\bwake(?:\s|-)?up\b|\bwake\b.*\b(?:check|verify|review)\b/i },
|
|
1869
|
-
{ phrase: 'not yet complete', re: /\b(?:not\s+yet|isn(?:'|’)t|not|incomplete|not\s+fully|not\s+completely)\s+(?:complete|completed|done|finished|validated|verified)\b/i },
|
|
1870
|
-
{ phrase: 'partial', re: /\bpartial(?:ly)?\b/i },
|
|
1871
|
-
{ phrase: 'to be continued', re: /\bto\s+be\s+continued\b|\btbc\b/i },
|
|
1872
|
-
{ phrase: 'in progress', re: /\bin\s+progress\b|\bongoing\b|\bincomplete\b/i },
|
|
1873
|
-
];
|
|
1874
|
-
for (const { phrase, re } of patterns) {
|
|
1875
|
-
if (re.test(text)) {
|
|
1876
|
-
return { phrase, reason: `Nonterminal completion summary: matched '${phrase}'` };
|
|
1877
|
-
}
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
const pendingLines = text.split('\n').filter(line => /\bpending\b/i.test(line));
|
|
1881
|
-
for (const line of pendingLines) {
|
|
1882
|
-
if (!isTerminalPendingLine(line)) {
|
|
1883
|
-
return { phrase: 'pending', reason: `Nonterminal completion summary: matched 'pending'` };
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
1856
|
return null;
|
|
1888
1857
|
}
|
|
1889
1858
|
|
|
@@ -2256,7 +2225,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2256
2225
|
|
|
2257
2226
|
let completionContractFailure = null;
|
|
2258
2227
|
if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
|
|
2259
|
-
const nonTerminalCompletion = detectNonTerminalResultSummary(completionGateSummary, structuredCompletion);
|
|
2228
|
+
const nonTerminalCompletion = detectNonTerminalResultSummary(completionGateSummary, structuredCompletion, reportCompletion);
|
|
2260
2229
|
if (nonTerminalCompletion) {
|
|
2261
2230
|
skipDoneStatus = true;
|
|
2262
2231
|
const reason = deferNonTerminalCompletion(meta, nonTerminalCompletion);
|
package/engine/pipeline.js
CHANGED
|
@@ -9,6 +9,7 @@ const path = require('path');
|
|
|
9
9
|
const shared = require('./shared');
|
|
10
10
|
const queries = require('./queries');
|
|
11
11
|
const { safeJson, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
|
|
12
|
+
const routing = require('./routing');
|
|
12
13
|
const http = require('http');
|
|
13
14
|
const { parseCronExpr, shouldRunNow } = require('./scheduler');
|
|
14
15
|
|
|
@@ -306,10 +307,11 @@ function executeTaskStage(stage, stageState, run, config) {
|
|
|
306
307
|
id,
|
|
307
308
|
title: item.title || stage.title,
|
|
308
309
|
description: item.description || stage.description || '',
|
|
309
|
-
type: item.type || stage.taskType
|
|
310
|
+
type: routing.normalizeWorkType(item.type || stage.taskType, WORK_TYPE.EXPLORE),
|
|
310
311
|
priority: item.priority || stage.priority || 'medium',
|
|
311
|
-
//
|
|
312
|
+
// Agent is a soft routing hint unless agentLock/hardAgent is set.
|
|
312
313
|
...(item.agent || stage.agent ? { agent: item.agent || stage.agent } : {}),
|
|
314
|
+
...(item.agentLock === true || stage.agentLock === true || item.hardAgent === true || stage.hardAgent === true ? { agentLock: true } : {}),
|
|
313
315
|
status: WI_STATUS.PENDING,
|
|
314
316
|
created: ts(),
|
|
315
317
|
createdBy: 'pipeline:' + run.pipelineId,
|
|
@@ -587,7 +589,7 @@ function executeMergePrsStage(stage, stageState, run, config) {
|
|
|
587
589
|
|
|
588
590
|
function executeScheduleStage(stage, stageState, config) {
|
|
589
591
|
// Create/update schedules in config
|
|
590
|
-
const schedules = stage.schedules || [{ id: stage.id + '-sched', cron: stage.cron, title: stage.title, type: stage.taskType
|
|
592
|
+
const schedules = stage.schedules || [{ id: stage.id + '-sched', cron: stage.cron, title: stage.title, type: routing.normalizeWorkType(stage.taskType, WORK_TYPE.IMPLEMENT) }];
|
|
591
593
|
// Write to config via shared
|
|
592
594
|
for (const sched of schedules) {
|
|
593
595
|
const existing = (config.schedules || []).find(s => s.id === sched.id);
|
package/engine/routing.js
CHANGED
|
@@ -8,7 +8,7 @@ const path = require('path');
|
|
|
8
8
|
const shared = require('./shared');
|
|
9
9
|
const queries = require('./queries');
|
|
10
10
|
|
|
11
|
-
const { safeJson, safeRead, log, ts } = shared;
|
|
11
|
+
const { safeJson, safeRead, log, ts, WORK_TYPE } = shared;
|
|
12
12
|
const { ENGINE_DIR, DISPATCH_PATH } = queries;
|
|
13
13
|
|
|
14
14
|
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
@@ -116,12 +116,43 @@ function setTempBudget(n) {
|
|
|
116
116
|
}
|
|
117
117
|
function getTempBudget() { return _tempBudget; }
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
function normalizeWorkType(workType, fallback = WORK_TYPE.IMPLEMENT) {
|
|
120
|
+
const type = String(workType || fallback || '').trim();
|
|
121
|
+
if (!type) return fallback;
|
|
122
|
+
return type;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function routeForWorkType(workType) {
|
|
126
|
+
const routes = getRoutingTableCached();
|
|
127
|
+
return routes[normalizeWorkType(workType)] || routes[WORK_TYPE.IMPLEMENT] || { preferred: '_any_', fallback: '_any_' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isAgentHardPinned(item) {
|
|
131
|
+
return !!(item && (
|
|
132
|
+
item.agentLock === true ||
|
|
133
|
+
item.agent_locked === true ||
|
|
134
|
+
item.hardAgent === true ||
|
|
135
|
+
item.hard_agent === true
|
|
136
|
+
));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getHardPinnedAgent(item, agents = null) {
|
|
140
|
+
if (!isAgentHardPinned(item) || !item?.agent) return null;
|
|
141
|
+
const normalized = normalizeAgentHints(item.agent, null, agents);
|
|
142
|
+
return normalized[0] || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Centralizes the work-item shape used to derive routing hints. `agent`,
|
|
146
|
+
// `preferred_agent`, and `agents` are soft preferences unless an explicit
|
|
147
|
+
// hard-pin flag is present (agentLock / hardAgent variants).
|
|
122
148
|
function extractAgentHints(item) {
|
|
123
149
|
if (!item || typeof item !== 'object') return null;
|
|
124
|
-
|
|
150
|
+
const hints = [];
|
|
151
|
+
if (!isAgentHardPinned(item) && item.agent) hints.push(item.agent);
|
|
152
|
+
if (item.preferred_agent) hints.push(item.preferred_agent);
|
|
153
|
+
if (Array.isArray(item.agents)) hints.push(...item.agents);
|
|
154
|
+
else if (item.agents) hints.push(item.agents);
|
|
155
|
+
return hints.length > 0 ? hints : null;
|
|
125
156
|
}
|
|
126
157
|
|
|
127
158
|
// Normalize a list of agent-hint inputs. Accepts:
|
|
@@ -158,8 +189,7 @@ function normalizeAgentHints(agentHints, authorAgent = null, agents = null) {
|
|
|
158
189
|
|
|
159
190
|
function resolveAgent(workType, config, opts = {}) {
|
|
160
191
|
const { authorAgent = null, agentHints = null } = opts || {};
|
|
161
|
-
const
|
|
162
|
-
const route = routes[workType] || routes['implement'] || { preferred: '_any_', fallback: '_any_' };
|
|
192
|
+
const route = routeForWorkType(workType);
|
|
163
193
|
const agents = config.agents || {};
|
|
164
194
|
|
|
165
195
|
// Resolve _author_ token
|
|
@@ -191,7 +221,6 @@ function resolveAgent(workType, config, opts = {}) {
|
|
|
191
221
|
for (const id of hintedAgents) {
|
|
192
222
|
if (isAvailable(id)) { _claimedAgents.add(id); return id; }
|
|
193
223
|
}
|
|
194
|
-
return null;
|
|
195
224
|
}
|
|
196
225
|
|
|
197
226
|
// Resolve _any_ token — pick any available agent (#480)
|
|
@@ -228,6 +257,41 @@ function resolveAgent(workType, config, opts = {}) {
|
|
|
228
257
|
return null;
|
|
229
258
|
}
|
|
230
259
|
|
|
260
|
+
function resolveAgentReservation(workType, config, opts = {}) {
|
|
261
|
+
const { authorAgent = null, agentHints = null } = opts || {};
|
|
262
|
+
const route = routeForWorkType(workType);
|
|
263
|
+
const agents = config.agents || {};
|
|
264
|
+
const hintedAgents = normalizeAgentHints(agentHints, authorAgent, agents);
|
|
265
|
+
|
|
266
|
+
const hasBudget = (id) => {
|
|
267
|
+
if (!agents[id]) return false;
|
|
268
|
+
const budget = agents[id].monthlyBudgetUsd;
|
|
269
|
+
return !(budget && budget > 0 && getMonthlySpend(id) >= budget);
|
|
270
|
+
};
|
|
271
|
+
const eligible = (id) => (id && id !== '_any_' && hasBudget(id)) ? id : null;
|
|
272
|
+
const anyEligible = (exclude = []) => {
|
|
273
|
+
const excludeSet = new Set(exclude.filter(Boolean));
|
|
274
|
+
return Object.keys(agents)
|
|
275
|
+
.filter(id => !excludeSet.has(id) && hasBudget(id))
|
|
276
|
+
.sort((a, b) => getAgentErrorRate(a) - getAgentErrorRate(b))[0] || null;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
if (hintedAgents.length > 0) return hintedAgents.find(hasBudget) || null;
|
|
280
|
+
|
|
281
|
+
const preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
|
|
282
|
+
const fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
|
|
283
|
+
|
|
284
|
+
if (preferred === '_any_') return anyEligible();
|
|
285
|
+
const preferredAgent = eligible(preferred);
|
|
286
|
+
if (preferredAgent) return preferredAgent;
|
|
287
|
+
|
|
288
|
+
if (fallback === '_any_') return anyEligible([preferred]);
|
|
289
|
+
const fallbackAgent = eligible(fallback);
|
|
290
|
+
if (fallbackAgent) return fallbackAgent;
|
|
291
|
+
|
|
292
|
+
return anyEligible([preferred, fallback]);
|
|
293
|
+
}
|
|
294
|
+
|
|
231
295
|
module.exports = {
|
|
232
296
|
tempAgents,
|
|
233
297
|
getRouting,
|
|
@@ -238,9 +302,13 @@ module.exports = {
|
|
|
238
302
|
isAgentIdle,
|
|
239
303
|
normalizeAgentHints,
|
|
240
304
|
extractAgentHints,
|
|
305
|
+
isAgentHardPinned,
|
|
306
|
+
getHardPinnedAgent,
|
|
307
|
+
normalizeWorkType,
|
|
241
308
|
_claimedAgents,
|
|
242
309
|
resetClaimedAgents,
|
|
243
310
|
resolveAgent,
|
|
311
|
+
resolveAgentReservation,
|
|
244
312
|
setTempBudget,
|
|
245
313
|
getTempBudget,
|
|
246
314
|
};
|
package/engine/scheduler.js
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
const fs = require('fs');
|
|
25
25
|
const path = require('path');
|
|
26
26
|
const shared = require('./shared');
|
|
27
|
-
const
|
|
27
|
+
const routing = require('./routing');
|
|
28
|
+
const { safeJson, safeWrite, mutateJsonFileLocked, ts, dateStamp, WI_STATUS, WORK_TYPE } = shared;
|
|
28
29
|
|
|
29
30
|
const SCHEDULE_RUNS_PATH = path.join(shared.MINIONS_DIR, 'engine', 'schedule-runs.json');
|
|
30
31
|
|
|
@@ -167,13 +168,14 @@ function discoverScheduledWork(config) {
|
|
|
167
168
|
work.push({
|
|
168
169
|
id: workItemId,
|
|
169
170
|
title: resolveScheduleTemplateVars(sched.title),
|
|
170
|
-
type: sched.type
|
|
171
|
+
type: routing.normalizeWorkType(sched.type, WORK_TYPE.IMPLEMENT),
|
|
171
172
|
priority: sched.priority || 'medium',
|
|
172
173
|
description: resolveScheduleTemplateVars(sched.description || sched.title),
|
|
173
174
|
status: WI_STATUS.PENDING,
|
|
174
175
|
created: ts(),
|
|
175
176
|
createdBy: 'scheduler',
|
|
176
177
|
agent: sched.agent || null,
|
|
178
|
+
...(sched.agentLock === true || sched.hardAgent === true ? { agentLock: true } : {}),
|
|
177
179
|
project: sched.project || null,
|
|
178
180
|
_scheduleId: sched.id,
|
|
179
181
|
});
|
package/engine/timeout.js
CHANGED
|
@@ -147,7 +147,7 @@ function checkTimeouts(config) {
|
|
|
147
147
|
const engineRestartGraceUntil = engine().engineRestartGraceUntil;
|
|
148
148
|
const engineRestartGraceExempt = engine().engineRestartGraceExempt;
|
|
149
149
|
const { completeDispatch } = dispatch();
|
|
150
|
-
const { runPostCompletionHooks, parseAgentOutput, parseStructuredCompletion, detectNonTerminalResultSummary } = require('./lifecycle');
|
|
150
|
+
const { runPostCompletionHooks, parseAgentOutput, parseStructuredCompletion, parseCompletionReportFile, detectNonTerminalResultSummary } = require('./lifecycle');
|
|
151
151
|
|
|
152
152
|
const timeout = config.engine?.agentTimeout || ENGINE_DEFAULTS.agentTimeout;
|
|
153
153
|
const defaultStaleOrphanTimeout = config.engine?.heartbeatTimeout || ENGINE_DEFAULTS.heartbeatTimeout;
|
|
@@ -265,7 +265,7 @@ function checkTimeouts(config) {
|
|
|
265
265
|
outputResultSummary = parseAgentOutput(fullLogForHooks, runtimeName).resultSummary || '';
|
|
266
266
|
const gateSummary = outputResultSummary || (!fullLogForHooks.includes('"type":') ? fullLogForHooks : '');
|
|
267
267
|
completionDetection = isSuccess
|
|
268
|
-
? detectNonTerminalResultSummary(gateSummary, parseStructuredCompletion(fullLogForHooks, runtimeName))
|
|
268
|
+
? detectNonTerminalResultSummary(gateSummary, parseStructuredCompletion(fullLogForHooks, runtimeName), parseCompletionReportFile(item))
|
|
269
269
|
: null;
|
|
270
270
|
} catch (e) { log('warn', 'completion summary gate: ' + e.message); }
|
|
271
271
|
|
package/engine.js
CHANGED
|
@@ -125,7 +125,7 @@ const { getConfig, getControl, getDispatch, getNotes,
|
|
|
125
125
|
const routing = require('./engine/routing');
|
|
126
126
|
const { getRouting, parseRoutingTable, getRoutingTableCached, getMonthlySpend,
|
|
127
127
|
getAgentErrorRate, isAgentIdle, resolveAgent, resetClaimedAgents,
|
|
128
|
-
setTempBudget, tempAgents } = routing;
|
|
128
|
+
resolveAgentReservation, setTempBudget, tempAgents } = routing;
|
|
129
129
|
|
|
130
130
|
// ─── Playbook, system prompt, agent context (extracted to engine/playbook.js) ─
|
|
131
131
|
|
|
@@ -2644,7 +2644,7 @@ function discoverFromWorkItems(config, project) {
|
|
|
2644
2644
|
skipped.gated++; continue;
|
|
2645
2645
|
}
|
|
2646
2646
|
|
|
2647
|
-
let workType = item.type
|
|
2647
|
+
let workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
|
|
2648
2648
|
if (workType === WORK_TYPE.IMPLEMENT && (item.complexity === 'large' || item.estimated_complexity === 'large')) {
|
|
2649
2649
|
workType = WORK_TYPE.IMPLEMENT_LARGE;
|
|
2650
2650
|
}
|
|
@@ -2655,14 +2655,24 @@ function discoverFromWorkItems(config, project) {
|
|
|
2655
2655
|
needsWrite = true;
|
|
2656
2656
|
}
|
|
2657
2657
|
const agentHints = routing.extractAgentHints(item);
|
|
2658
|
-
const
|
|
2658
|
+
const hardPinRequested = routing.isAgentHardPinned(item);
|
|
2659
|
+
let agentId = routing.getHardPinnedAgent(item, config.agents || {})
|
|
2660
|
+
|| (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null);
|
|
2661
|
+
const cfgAgents = config.agents || {};
|
|
2662
|
+
const budgetBlocked = Object.keys(cfgAgents).some(id => {
|
|
2663
|
+
const b = cfgAgents[id].monthlyBudgetUsd;
|
|
2664
|
+
return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
|
|
2665
|
+
});
|
|
2666
|
+
if (!agentId) {
|
|
2667
|
+
if (!budgetBlocked && !hardPinRequested) {
|
|
2668
|
+
agentId = resolveAgentReservation(workType, config, { agentHints });
|
|
2669
|
+
}
|
|
2670
|
+
if (agentId) {
|
|
2671
|
+
delete item._pendingReason;
|
|
2672
|
+
needsWrite = true;
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2659
2675
|
if (!agentId) {
|
|
2660
|
-
// Check if reason is budget
|
|
2661
|
-
const cfgAgents = config.agents || {};
|
|
2662
|
-
const budgetBlocked = Object.keys(cfgAgents).some(id => {
|
|
2663
|
-
const b = cfgAgents[id].monthlyBudgetUsd;
|
|
2664
|
-
return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
|
|
2665
|
-
});
|
|
2666
2676
|
if (budgetBlocked) {
|
|
2667
2677
|
if (item._pendingReason !== 'budget_exceeded') { item._pendingReason = 'budget_exceeded'; needsWrite = true; }
|
|
2668
2678
|
} else {
|
|
@@ -3095,7 +3105,7 @@ function discoverCentralWorkItems(config) {
|
|
|
3095
3105
|
}
|
|
3096
3106
|
if (isOnCooldown(key, 0)) continue;
|
|
3097
3107
|
|
|
3098
|
-
const workType = item.type
|
|
3108
|
+
const workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
|
|
3099
3109
|
const isFanOut = item.scope === 'fan-out';
|
|
3100
3110
|
|
|
3101
3111
|
if (isFanOut) {
|
|
@@ -3176,7 +3186,10 @@ function discoverCentralWorkItems(config) {
|
|
|
3176
3186
|
} else {
|
|
3177
3187
|
// ─── Normal: single agent dispatch ──────────────────────────────
|
|
3178
3188
|
const agentHints = routing.extractAgentHints(item);
|
|
3179
|
-
const
|
|
3189
|
+
const hardPinRequested = routing.isAgentHardPinned(item);
|
|
3190
|
+
const agentId = routing.getHardPinnedAgent(item, config.agents || {})
|
|
3191
|
+
|| (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null)
|
|
3192
|
+
|| (!hardPinRequested ? resolveAgentReservation(workType, config, { agentHints }) : null);
|
|
3180
3193
|
if (!agentId) continue;
|
|
3181
3194
|
|
|
3182
3195
|
const agentName = config.agents[agentId]?.name || agentId;
|
|
@@ -3837,6 +3850,7 @@ async function tickInner() {
|
|
|
3837
3850
|
const seenPendingIds = new Set();
|
|
3838
3851
|
const toDispatch = [];
|
|
3839
3852
|
let generalSlots = slotsAvailable;
|
|
3853
|
+
resetClaimedAgents(); // Pending agent resolution is a fresh allocation phase after discovery claims.
|
|
3840
3854
|
|
|
3841
3855
|
for (const item of dispatch.pending) {
|
|
3842
3856
|
if (seenPendingIds.has(item.id)) {
|
|
@@ -3849,7 +3863,7 @@ async function tickInner() {
|
|
|
3849
3863
|
// be of type string. Received undefined` and re-queues — every tick. Try to
|
|
3850
3864
|
// resolve a fallback via routing; if none is available, skip this tick.
|
|
3851
3865
|
if (!item.agent || typeof item.agent !== 'string') {
|
|
3852
|
-
const fallback = resolveAgent(item.type
|
|
3866
|
+
const fallback = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
|
|
3853
3867
|
if (!fallback) {
|
|
3854
3868
|
log('warn', `Pending dispatch ${item.id} has no agent and routing returned no fallback — skipping`);
|
|
3855
3869
|
continue;
|
|
@@ -3880,7 +3894,7 @@ async function tickInner() {
|
|
|
3880
3894
|
// them eagerly before the busy check so an idle named agent can pick up.
|
|
3881
3895
|
const isUnspawnedTemp = item.agent?.startsWith('temp-') && !busyAgents.has(item.agent);
|
|
3882
3896
|
if (isUnspawnedTemp) {
|
|
3883
|
-
const altAgent = resolveAgent(item.type, config);
|
|
3897
|
+
const altAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config);
|
|
3884
3898
|
if (altAgent && altAgent !== item.agent) {
|
|
3885
3899
|
const prevAgent = item.agent;
|
|
3886
3900
|
item.agent = altAgent;
|
|
@@ -3907,12 +3921,12 @@ async function tickInner() {
|
|
|
3907
3921
|
// Agent busy reassignment: if item has been waiting on a busy agent past the threshold,
|
|
3908
3922
|
// try to find an alternative agent via routing. Skip explicitly assigned items.
|
|
3909
3923
|
const reassignMs = config.engine?.agentBusyReassignMs ?? ENGINE_DEFAULTS.agentBusyReassignMs;
|
|
3910
|
-
const
|
|
3911
|
-
if (!
|
|
3924
|
+
const isHardPinned = routing.isAgentHardPinned(item.meta?.item);
|
|
3925
|
+
if (!isHardPinned && reassignMs > 0 && item._agentBusySince) {
|
|
3912
3926
|
const busySinceMs = new Date(item._agentBusySince).getTime();
|
|
3913
3927
|
if (Date.now() - busySinceMs > reassignMs) {
|
|
3914
3928
|
const originalAgent = item.agent;
|
|
3915
|
-
const altAgent = resolveAgent(item.type, config);
|
|
3929
|
+
const altAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
|
|
3916
3930
|
if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
|
|
3917
3931
|
log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent} — agent busy > ${reassignMs}ms`);
|
|
3918
3932
|
item.agent = altAgent;
|
|
@@ -3946,14 +3960,12 @@ async function tickInner() {
|
|
|
3946
3960
|
// Branch mutex: skip items targeting a branch already locked by an active or newly-dispatched task
|
|
3947
3961
|
const itemBranch = item.meta?.branch ? sanitizeBranch(item.meta.branch) : null;
|
|
3948
3962
|
if (itemBranch && lockedBranches.has(itemBranch)) continue;
|
|
3949
|
-
|
|
3950
|
-
const isExplicitAssignment = !!item.meta?.item?.agent;
|
|
3951
|
-
if (!isExplicitAssignment && generalSlots <= 0) continue;
|
|
3963
|
+
if (generalSlots <= 0) continue;
|
|
3952
3964
|
seenPendingIds.add(item.id);
|
|
3953
3965
|
toDispatch.push(item);
|
|
3954
3966
|
busyAgents.add(item.agent);
|
|
3955
3967
|
if (itemBranch) lockedBranches.add(itemBranch);
|
|
3956
|
-
|
|
3968
|
+
generalSlots--;
|
|
3957
3969
|
}
|
|
3958
3970
|
|
|
3959
3971
|
// Dispatch items — spawnAgent moves each from pending→active on disk.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1658",
|
|
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"
|