@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 CHANGED
@@ -1,10 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1656 (2026-05-01)
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 : 'implement');
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 || 'implement',
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: when the caller (CC, dashboard form,
2742
- // direct API) supplies a single explicit agent either via `agent`
2743
- // (singular) or a one-element `agents` array treat it as a HARD pin
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;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-01T04:21:06.784Z"
4
+ "cachedAt": "2026-05-01T05:22:39.907Z"
5
5
  }
@@ -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,
@@ -1820,70 +1820,39 @@ function normalizeCompletionStatus(status) {
1820
1820
  return String(status || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
1821
1821
  }
1822
1822
 
1823
- function isTerminalPendingValue(value) {
1824
- const text = String(value || '').trim().toLowerCase();
1825
- if (!text) return true;
1826
- return /^(?:none|n\/a|na|no|nothing|not-applicable|not applicable|-)$/.test(text)
1827
- || /^no\s+(?:pending|remaining|outstanding)\b/.test(text)
1828
- || /^(?:all\s+)?(?:pending|remaining|outstanding)\s+(?:work|items?|tasks?)?\s*(?:resolved|complete|completed|done|closed)$/.test(text);
1829
- }
1830
-
1831
- function isTerminalPendingLine(line) {
1832
- const text = String(line || '').trim().toLowerCase();
1833
- return /\bno\s+pending\b/.test(text)
1834
- || /\bpending\s*[:=-]\s*(?:none|n\/a|na|no|nothing|not applicable|-)\b/.test(text)
1835
- || /\bpending\s+(?:work|items?|tasks?)?\s*(?:resolved|complete|completed|done|closed)\b/.test(text);
1836
- }
1837
-
1838
- function detectNonTerminalResultSummary(resultSummary, structuredCompletion) {
1839
- const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
1840
- if (completionStatus) {
1841
- if (/^(?:partial|partially-complete|in-progress|pending|deferred|blocked|incomplete|to-be-continued)/.test(completionStatus)) {
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:${structuredCompletion.status}`,
1844
- reason: `Nonterminal completion summary: structured status is '${structuredCompletion.status}'`,
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);
@@ -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 || 'explore',
310
+ type: routing.normalizeWorkType(item.type || stage.taskType, WORK_TYPE.EXPLORE),
310
311
  priority: item.priority || stage.priority || 'medium',
311
- // Only set agent if explicitly specified otherwise engine routing assigns any available agent
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 || 'implement' }];
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
- // Centralizes the work-item shape used to derive routing hints. Engine code
120
- // previously inlined `item.preferred_agent || item.agents || null` at four
121
- // call sites; hoisting keeps the contract in one place.
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
- return item.preferred_agent || item.agents || null;
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 routes = getRoutingTableCached();
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
  };
@@ -24,7 +24,8 @@
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
26
  const shared = require('./shared');
27
- const { safeJson, safeWrite, mutateJsonFileLocked, ts, dateStamp, WI_STATUS } = shared;
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 || 'implement',
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 || 'implement';
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 agentId = item.agent || resolveAgent(workType, config, { agentHints });
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 || 'implement';
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 agentId = item.agent || resolveAgent(workType, config, { agentHints });
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 || WORK_TYPE.FIX, config, { agentHints: routing.extractAgentHints(item.meta?.item) });
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 isExplicitReassign = !!(item.meta?.item?.agent || routing.extractAgentHints(item.meta?.item));
3911
- if (!isExplicitReassign && reassignMs > 0 && item._agentBusySince) {
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
- // Items explicitly assigned to an agent bypass concurrency cap — dispatch if agent is free
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
- if (!isExplicitAssignment) generalSlots--;
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.1656",
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"