@yemi33/minions 0.1.98 → 0.1.100

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,21 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.98 (2026-04-01)
3
+ ## 0.1.100 (2026-04-01)
4
4
 
5
5
  ### Engine
6
+ - engine.js
6
7
  - engine/ado.js
8
+ - engine/cleanup.js
9
+ - engine/consolidation.js
10
+ - engine/dispatch.js
7
11
  - engine/github.js
12
+ - engine/lifecycle.js
13
+ - engine/preflight.js
14
+ - engine/queries.js
15
+ - engine/shared.js
16
+
17
+ ### Other
18
+ - test/unit.test.js
8
19
 
9
20
  ## 0.1.97 (2026-04-01)
10
21
 
package/engine/cleanup.js CHANGED
@@ -272,6 +272,7 @@ function runCleanup(config, verbose = false) {
272
272
  return allWiIds.has(itemId);
273
273
  });
274
274
  }
275
+ return dp;
275
276
  });
276
277
  }
277
278
  } catch (e) { log('warn', 'prune orphaned dispatches: ' + e.message); }
@@ -179,8 +179,8 @@ function consolidateWithLLM(items, existingNotes, files, config) {
179
179
 
180
180
  proc.on('close', (code) => {
181
181
  clearTimeout(timeout);
182
- safeUnlink(promptPath);
183
- safeUnlink(sysPromptPath);
182
+ try { safeUnlink(promptPath); } catch (err) { log('warn', `Temp file cleanup failed: ${promptPath} — ${err.message}`); }
183
+ try { safeUnlink(sysPromptPath); } catch (err) { log('warn', `Temp file cleanup failed: ${sysPromptPath} — ${err.message}`); }
184
184
 
185
185
  const parsed = parseStreamJsonOutput(stdout);
186
186
  const extractedText = parsed.text;
@@ -231,8 +231,8 @@ function consolidateWithLLM(items, existingNotes, files, config) {
231
231
  proc.on('error', (err) => {
232
232
  clearTimeout(timeout);
233
233
  log('warn', `LLM consolidation spawn error: ${err.message} — falling back to regex`);
234
- safeUnlink(promptPath);
235
- safeUnlink(sysPromptPath);
234
+ try { safeUnlink(promptPath); } catch (unlinkErr) { log('warn', `Temp file cleanup failed: ${promptPath} — ${unlinkErr.message}`); }
235
+ try { safeUnlink(sysPromptPath); } catch (unlinkErr) { log('warn', `Temp file cleanup failed: ${sysPromptPath} — ${unlinkErr.message}`); }
236
236
  consolidateWithRegex(items, files);
237
237
  _clearProcessingState();
238
238
  });
@@ -296,13 +296,15 @@ function consolidateWithRegex(items, files) {
296
296
  const deduped = [];
297
297
  for (const insight of allInsights) {
298
298
  const fpWords = insight.fingerprint.split(' ').filter(w => w.length > 4).slice(0, 5);
299
- if (fpWords.length >= 3 && fpWords.every(w => existingNotes.includes(w))) continue;
299
+ // Use word-boundary regex to avoid substring false positives (e.g. 'fix' matching 'prefix')
300
+ if (fpWords.length >= 3 && fpWords.every(w => new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(existingNotes))) continue;
300
301
  const existing = seen.get(insight.fingerprint);
301
302
  if (existing) { if (!existing.sources.includes(insight.agent)) existing.sources.push(insight.agent); continue; }
302
303
  let isDup = false;
303
304
  for (const [fp, entry] of seen) {
304
- const a = new Set(fp.split(' ')), b = new Set(insight.fingerprint.split(' '));
305
- // Require at least 3 words in both fingerprints for meaningful similarity check
305
+ // Filter to meaningful words (>4 chars) to avoid short-word false positives like 'fix' vs 'prefix'
306
+ const a = new Set(fp.split(' ').filter(w => w.length > 2)), b = new Set(insight.fingerprint.split(' ').filter(w => w.length > 2));
307
+ // Require at least 3 meaningful words in both fingerprints for similarity check
306
308
  if (a.size >= 3 && b.size >= 3 && [...a].filter(w => b.has(w)).length / Math.max(a.size, b.size) > 0.7) {
307
309
  if (!entry.sources.includes(insight.agent)) entry.sources.push(insight.agent); isDup = true; break;
308
310
  }
@@ -352,7 +354,9 @@ function classifyToKnowledgeBase(items) {
352
354
  if (!fs.existsSync(KNOWLEDGE_DIR)) fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
353
355
 
354
356
  const categoryDirs = {};
355
- for (const cat of KB_CATEGORIES) {
357
+ // Include 'general' as fallback category even if not in KB_CATEGORIES
358
+ const allCategories = KB_CATEGORIES.includes('general') ? KB_CATEGORIES : [...KB_CATEGORIES, 'general'];
359
+ for (const cat of allCategories) {
356
360
  categoryDirs[cat] = path.join(KNOWLEDGE_DIR, cat);
357
361
  if (!fs.existsSync(categoryDirs[cat])) fs.mkdirSync(categoryDirs[cat], { recursive: true });
358
362
  }
@@ -360,7 +364,12 @@ function classifyToKnowledgeBase(items) {
360
364
  let classified = 0;
361
365
  for (const item of items) {
362
366
  const content = item.content || '';
363
- const category = classifyInboxItem(item.name, content);
367
+ const rawCategory = classifyInboxItem(item.name, content);
368
+ // Fallback to 'general' if the classified category isn't in our known category map
369
+ const category = categoryDirs[rawCategory] ? rawCategory : 'general';
370
+ if (rawCategory !== category) {
371
+ log('warn', `Unknown KB category '${rawCategory}' for ${item.name} — falling back to 'general'`);
372
+ }
364
373
 
365
374
  const agentMatch = item.name.match(/^(\w+)-/);
366
375
  const agent = agentMatch ? agentMatch[1] : 'unknown';
@@ -38,6 +38,7 @@ function addToDispatch(item) {
38
38
  item.created_at = ts();
39
39
  mutateDispatch((dispatch) => {
40
40
  dispatch.pending.push(item);
41
+ return dispatch;
41
42
  });
42
43
  log('info', `Queued dispatch: ${item.id} (${item.type} → ${item.agent})`);
43
44
  return item.id;
@@ -79,7 +80,7 @@ function completeDispatch(id, result = 'success', reason = '', resultSummary = '
79
80
  if (idx >= 0) item = dispatch.pending.splice(idx, 1)[0];
80
81
  }
81
82
 
82
- if (!item) return;
83
+ if (!item) return dispatch;
83
84
  item.completed_at = ts();
84
85
  item.result = result;
85
86
  if (reason) item.reason = reason;
@@ -89,6 +90,7 @@ function completeDispatch(id, result = 'success', reason = '', resultSummary = '
89
90
  dispatch.completed = dispatch.completed.slice(-99);
90
91
  }
91
92
  dispatch.completed.push(item);
93
+ return dispatch;
92
94
  });
93
95
 
94
96
  if (item) {
@@ -393,7 +393,12 @@ function chainPlanToPrd(dispatchItem, meta, config) {
393
393
  log('info', `Plan chaining: queuing plan-to-prd for next tick (chained from ${dispatchItem.id})`);
394
394
  const wiPath = path.join(MINIONS_DIR, 'work-items.json');
395
395
  let items = [];
396
- try { items = JSON.parse(fs.readFileSync(wiPath, 'utf8')); } catch {}
396
+ try {
397
+ items = JSON.parse(fs.readFileSync(wiPath, 'utf8'));
398
+ } catch (err) {
399
+ log('warn', `Plan chaining: failed to parse ${wiPath}, falling back to empty list: ${err.message}`);
400
+ try { fs.copyFileSync(wiPath, wiPath + '.bak'); } catch (_) { /* backup best-effort */ }
401
+ }
397
402
  items.push({
398
403
  id: 'W-' + shared.uid(),
399
404
  title: `Convert plan to PRD: ${meta?.item?.title || planFile.name}`,
@@ -580,7 +585,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
580
585
  dirtyTargets.set(targetName, { prs: safeJson(prPath) || [], prPath });
581
586
  }
582
587
  const entry = dirtyTargets.get(targetName);
583
- if (entry.prs.some(p => p.id === fullId || String(p.id).includes(prId))) continue;
588
+ if (entry.prs.some(p => p.id === fullId || String(p.id) === String(prId))) continue;
584
589
 
585
590
  let title = meta?.item?.title || '';
586
591
  const titleMatch = output.match(new RegExp(`${prId}[^\\n]*?[—–-]\\s*([^\\n]+)`, 'i'));
@@ -651,7 +656,7 @@ function updatePrAfterReview(agentId, pr, project) {
651
656
  }
652
657
 
653
658
  shared.safeWrite(project ? shared.projectPrPath(project) : path.join(path.resolve(MINIONS_DIR, '..'), '.minions', 'pull-requests.json'), prs);
654
- log('info', `Updated ${pr.id} → minions review: ${minionsVerdict} by ${reviewerName}`);
659
+ log('info', `Updated ${pr.id} → minions review: ${target.reviewStatus} by ${reviewerName}`);
655
660
  createReviewFeedbackForAuthor(agentId, { ...pr, ...target }, config);
656
661
  }
657
662
 
@@ -25,10 +25,11 @@ function findClaudeBinary() {
25
25
  // Fallback: parse the shell wrapper
26
26
  try {
27
27
  const which = execSync('bash -c "which claude"', { encoding: 'utf8', windowsHide: true, timeout: 5000 }).trim();
28
- const wrapper = execSync(`bash -c "cat '${which}'"`, { encoding: 'utf8', windowsHide: true, timeout: 5000 });
28
+ const whichNative = which.replace(/^\/c\//, 'C:/').replace(/\//g, path.sep);
29
+ const wrapper = fs.readFileSync(whichNative, 'utf8');
29
30
  const m = wrapper.match(/node_modules\/@anthropic-ai\/claude-code\/cli\.js/);
30
31
  if (m) {
31
- const basedir = path.dirname(which.replace(/^\/c\//, 'C:/').replace(/\//g, path.sep));
32
+ const basedir = path.dirname(whichNative);
32
33
  const resolved = path.join(basedir, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js');
33
34
  if (fs.existsSync(resolved)) return resolved;
34
35
  }
package/engine/queries.js CHANGED
@@ -540,8 +540,8 @@ function getPrdInfo(config) {
540
540
  const planFiles = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
541
541
  for (const pf of planFiles) {
542
542
  try {
543
- const plan = JSON.parse(fs.readFileSync(path.join(dir, pf), 'utf8'));
544
- if (!plan.missing_features) continue;
543
+ const plan = safeJson(path.join(dir, pf));
544
+ if (!plan || !plan.missing_features) continue;
545
545
  const stat = fs.statSync(path.join(dir, pf));
546
546
  if (!latestStat || stat.mtimeMs > latestStat.mtimeMs) latestStat = stat;
547
547
  // Staleness: compare source plan mtime to recorded sourcePlanModifiedAt
@@ -577,13 +577,13 @@ function getPrdInfo(config) {
577
577
  for (const project of projects) {
578
578
  try {
579
579
  const workItems = safeJson(projectWorkItemsPath(project)) || [];
580
- for (const wi of workItems) { if (wi.sourcePlan) wiById[wi.id] = wi; }
580
+ for (const wi of workItems) { if (!wi.id) { console.warn(`[queries] Skipping work item without id in ${project.name}:`, JSON.stringify(wi).slice(0, 120)); continue; } if (wi.sourcePlan) wiById[wi.id] = wi; }
581
581
  } catch { /* optional */ }
582
582
  }
583
583
  // Also check central work-items.json
584
584
  try {
585
585
  const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
586
- for (const wi of centralWi) { if (wi.sourcePlan && !wiById[wi.id]) wiById[wi.id] = wi; }
586
+ for (const wi of centralWi) { if (!wi.id) { console.warn('[queries] Skipping central work item without id:', JSON.stringify(wi).slice(0, 120)); continue; } if (wi.sourcePlan && !wiById[wi.id]) wiById[wi.id] = wi; }
587
587
  } catch { /* optional */ }
588
588
 
589
589
  // PR-to-PRD linking — primary source is pr-links.json (single-writer, never clobbered by polling)
@@ -595,7 +595,7 @@ function getPrdInfo(config) {
595
595
  const prLinks = shared.getPrLinks(); // { "PR-xxxx": "P-xxxx" }
596
596
  for (const [prId, itemId] of Object.entries(prLinks)) {
597
597
  const pr = prById[prId];
598
- const project = projects.find(p => p.name === pr?._project) || projects[0];
598
+ const project = projects.find(p => p.name === pr?._project) || projects[0] || null;
599
599
  const url = pr?.url || (project?.prUrlBase ? project.prUrlBase + prId.replace('PR-', '') : '');
600
600
  if (!prdToPr[itemId]) prdToPr[itemId] = [];
601
601
  prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || 'active', _project: pr?._project || '' });
package/engine/shared.js CHANGED
@@ -25,7 +25,7 @@ function log(level, msg, meta = {}) {
25
25
  let logData = safeJson(LOG_PATH) || [];
26
26
  if (!Array.isArray(logData)) logData = logData.entries || [];
27
27
  logData.push(entry);
28
- if (logData.length > 2000) logData.splice(0, logData.length - 2000);
28
+ if (logData.length >= 2500) logData.splice(0, logData.length - 2000);
29
29
  safeWrite(LOG_PATH, logData);
30
30
  }
31
31
 
@@ -43,11 +43,13 @@ function safeJson(p) {
43
43
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
44
44
  }
45
45
 
46
+ let _tmpCounter = 0;
47
+
46
48
  function safeWrite(p, data) {
47
49
  const dir = path.dirname(p);
48
50
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
49
51
  const content = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
50
- const tmp = p + '.tmp.' + process.pid;
52
+ const tmp = p + '.tmp.' + process.pid + '.' + (++_tmpCounter);
51
53
  try {
52
54
  fs.writeFileSync(tmp, content);
53
55
  // Atomic rename — retry on Windows EPERM (file locking)
@@ -58,7 +60,7 @@ function safeWrite(p, data) {
58
60
  } catch (e) {
59
61
  if (e.code === 'EPERM' && attempt < 4) {
60
62
  const delay = 50 * (attempt + 1); // 50, 100, 150, 200ms
61
- try { const ab = new SharedArrayBuffer(4); Atomics.wait(new Int32Array(ab), 0, 0, delay); } catch { /* fallback busy-wait */ const start = Date.now(); while (Date.now() - start < delay) {} }
63
+ sleepMs(delay);
62
64
  continue;
63
65
  }
64
66
  // Final attempt failed — throw to let caller retry
@@ -85,11 +87,13 @@ function sleepMs(ms) {
85
87
  const ab = new SharedArrayBuffer(4);
86
88
  Atomics.wait(new Int32Array(ab), 0, 0, ms);
87
89
  } catch {
88
- const start = Date.now();
89
- while (Date.now() - start < ms) {}
90
+ // Fallback: synchronous sleep via child process — avoids busy-wait blocking the event loop
91
+ _spawnSync(process.execPath, ['-e', `setTimeout(()=>{},${Math.max(0, Math.floor(ms))})`], { windowsHide: true });
90
92
  }
91
93
  }
92
94
 
95
+ const LOCK_STALE_MS = 60000; // 60 seconds — force-remove locks older than this
96
+
93
97
  function withFileLock(lockPath, fn, {
94
98
  timeoutMs = 5000,
95
99
  retryDelayMs = 25
@@ -104,6 +108,14 @@ function withFileLock(lockPath, fn, {
104
108
  break;
105
109
  } catch (err) {
106
110
  if (err.code !== 'EEXIST') throw err;
111
+ // Check for stale lock — if lock file is older than LOCK_STALE_MS, force-remove it
112
+ try {
113
+ const stat = fs.statSync(lockPath);
114
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
115
+ try { fs.unlinkSync(lockPath); } catch { /* race: another process removed it */ }
116
+ continue; // retry immediately after removing stale lock
117
+ }
118
+ } catch { /* lock file disappeared between EEXIST and stat — retry will succeed */ }
107
119
  sleepMs(retryDelayMs);
108
120
  }
109
121
  }
@@ -372,6 +384,23 @@ function getAdoOrgBase(project) {
372
384
  : `https://dev.azure.com/${project.adoOrg}`;
373
385
  }
374
386
 
387
+ // ── Path Sanitization ───────────────────────────────────────────────────────
388
+
389
+ /**
390
+ * Resolve a user-supplied path relative to a base directory and verify the
391
+ * result stays within the base. Throws if the resolved path escapes baseDir.
392
+ * Use to prevent path-traversal attacks on any user-facing file endpoint.
393
+ */
394
+ function sanitizePath(baseDir, userInput) {
395
+ const resolvedBase = path.resolve(baseDir);
396
+ const resolvedFull = path.resolve(resolvedBase, userInput);
397
+ // Append path.sep so "/foo" doesn't match "/foobar"
398
+ if (!resolvedFull.startsWith(resolvedBase + path.sep) && resolvedFull !== resolvedBase) {
399
+ throw new Error(`Path traversal blocked: ${userInput} resolves outside ${baseDir}`);
400
+ }
401
+ return resolvedFull;
402
+ }
403
+
375
404
  // ── Branch Sanitization ──────────────────────────────────────────────────────
376
405
 
377
406
  function sanitizeBranch(name) {
@@ -452,7 +481,10 @@ module.exports = {
452
481
  addPrLink,
453
482
  nextWorkItemId,
454
483
  getAdoOrgBase,
484
+ sanitizePath,
455
485
  sanitizeBranch,
456
486
  parseSkillFrontmatter,
487
+ sleepMs,
488
+ LOCK_STALE_MS,
457
489
  };
458
490
 
package/engine.js CHANGED
@@ -708,13 +708,14 @@ function spawnAgent(dispatchItem, config) {
708
708
  // Move pending -> active under a lock to avoid cross-process lost updates (engine/dashboard)
709
709
  mutateDispatch((dispatch) => {
710
710
  const idx = dispatch.pending.findIndex(d => d.id === id);
711
- if (idx < 0) return;
711
+ if (idx < 0) return dispatch;
712
712
  const item = dispatch.pending.splice(idx, 1)[0];
713
713
  item.started_at = startedAt;
714
714
  delete item.skipReason;
715
715
  if (!dispatch.active.some(d => d.id === id)) {
716
716
  dispatch.active.push(item);
717
717
  }
718
+ return dispatch;
718
719
  });
719
720
 
720
721
  return proc;
@@ -1382,9 +1383,8 @@ function discoverFromWorkItems(config, project) {
1382
1383
  // This protects against persisted state drift from old runtime versions.
1383
1384
  try {
1384
1385
  mutateDispatch((dp) => {
1385
- const before = Array.isArray(dp.completed) ? dp.completed.length : 0;
1386
1386
  dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== key) : [];
1387
- return dp.completed.length !== before ? dp : undefined;
1387
+ return dp;
1388
1388
  });
1389
1389
  dispatchCooldowns.delete(key);
1390
1390
  } catch (e) { log('warn', 'self-heal dispatch state: ' + e.message); }
@@ -1973,19 +1973,29 @@ function discoverWork(config) {
1973
1973
 
1974
1974
  // Periodic plan completion sweep — catch PRDs that completed while engine was down
1975
1975
  // or where checkPlanCompletion missed the completion event
1976
- try {
1977
- const lifecycle = require('./engine/lifecycle');
1978
- const prdDir = path.join(MINIONS_DIR, 'prd');
1979
- if (fs.existsSync(prdDir)) {
1980
- for (const f of fs.readdirSync(prdDir).filter(f => f.endsWith('.json'))) {
1981
- const plan = safeJson(path.join(prdDir, f));
1982
- if (!plan?.missing_features || plan.status === 'completed') continue;
1983
- if (plan.status !== 'approved' && plan.status !== 'active') continue;
1984
- // Simulate the meta object checkPlanCompletion expects
1985
- lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
1976
+ // Throttled to every 10 ticks (~5 min) to reduce call volume (P3 decision)
1977
+ if (tickCount % 10 === 0) {
1978
+ try {
1979
+ const lifecycle = require('./engine/lifecycle');
1980
+ const prdDir = path.join(MINIONS_DIR, 'prd');
1981
+ if (fs.existsSync(prdDir)) {
1982
+ for (const f of fs.readdirSync(prdDir).filter(f => f.endsWith('.json'))) {
1983
+ if (completedPlanCache.has(f)) continue;
1984
+ const plan = safeJson(path.join(prdDir, f));
1985
+ if (!plan?.missing_features || plan.status === 'completed') {
1986
+ if (plan?.status === 'completed') completedPlanCache.add(f);
1987
+ continue;
1988
+ }
1989
+ if (plan.status !== 'approved' && plan.status !== 'active') continue;
1990
+ // Simulate the meta object checkPlanCompletion expects
1991
+ lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
1992
+ // If plan transitioned to completed, cache it
1993
+ const after = safeJson(path.join(prdDir, f));
1994
+ if (after?.status === 'completed') completedPlanCache.add(f);
1995
+ }
1986
1996
  }
1987
- }
1988
- } catch (e) { log('warn', 'plan completion sweep: ' + e.message); }
1997
+ } catch (e) { log('warn', 'plan completion sweep: ' + e.message); }
1998
+ }
1989
1999
 
1990
2000
  // Gate reviews and fixes: do not dispatch until all implement items are complete
1991
2001
  const hasIncompleteImplements = projects.some(project => {
@@ -2023,6 +2033,10 @@ function discoverWork(config) {
2023
2033
 
2024
2034
  let tickCount = 0;
2025
2035
 
2036
+ // In-memory cache of plan filenames confirmed completed — avoids redundant
2037
+ // checkPlanCompletion calls. Cleared automatically on engine restart.
2038
+ const completedPlanCache = new Set();
2039
+
2026
2040
  let tickRunning = false;
2027
2041
 
2028
2042
  async function tick() {
@@ -2086,9 +2100,15 @@ async function tickInner() {
2086
2100
  try {
2087
2101
  const prdFiles = safeReadDir(PRD_DIR).filter(f => f.endsWith('.json'));
2088
2102
  for (const file of prdFiles) {
2103
+ if (completedPlanCache.has(file)) continue;
2089
2104
  const plan = safeJson(path.join(PRD_DIR, file));
2090
2105
  if (plan && plan.missing_features && plan.status !== 'completed') {
2091
2106
  checkPlanCompletion({ item: { sourcePlan: file } }, config);
2107
+ // If plan transitioned to completed, cache it
2108
+ const after = safeJson(path.join(PRD_DIR, file));
2109
+ if (after?.status === 'completed') completedPlanCache.add(file);
2110
+ } else if (plan?.status === 'completed') {
2111
+ completedPlanCache.add(file);
2092
2112
  }
2093
2113
  }
2094
2114
  } catch (err) { log('warn', `Plan completion check error: ${err?.message || err}`); }
@@ -2146,9 +2166,8 @@ async function tickInner() {
2146
2166
  try {
2147
2167
  const key = `work-${project.name}-${item.id}`;
2148
2168
  mutateDispatch((dp) => {
2149
- const before = dp.completed.length;
2150
2169
  dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
2151
- if (dp.completed.length !== before) return dp;
2170
+ return dp;
2152
2171
  });
2153
2172
  } catch (e) { log('warn', 'stall recovery clear dispatch: ' + e.message); }
2154
2173
 
@@ -2182,6 +2201,7 @@ async function tickInner() {
2182
2201
  const key = `work-${project.name}-${dep.id}`;
2183
2202
  mutateDispatch((dp) => {
2184
2203
  dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
2204
+ return dp;
2185
2205
  });
2186
2206
  } catch (e) { log('warn', 'stall recovery clear dependent dispatch: ' + e.message); }
2187
2207
  }
@@ -2226,6 +2246,7 @@ async function tickInner() {
2226
2246
  mutateDispatch((dp) => {
2227
2247
  dp.pending = dispatch.pending;
2228
2248
  dp.active = dispatch.active || dp.active;
2249
+ return dp;
2229
2250
  });
2230
2251
 
2231
2252
  // Only dispatch to agents that aren't already busy (one task per agent at a time).
@@ -2244,8 +2265,39 @@ async function tickInner() {
2244
2265
  const dispatched = new Set();
2245
2266
  for (const item of toDispatch) {
2246
2267
  if (!dispatched.has(item.id)) {
2247
- spawnAgent(item, config);
2248
- dispatched.add(item.id);
2268
+ const proc = spawnAgent(item, config);
2269
+ if (proc === null) {
2270
+ // spawnAgent failed (e.g., worktree creation error). It already called
2271
+ // completeDispatch internally which handles retry logic, but log at the
2272
+ // dispatch-loop level for visibility and handle any edge cases where
2273
+ // completeDispatch wasn't called.
2274
+ log('error', `spawnAgent returned null for ${item.id} (${item.type} → ${item.agent}) — spawn failed`);
2275
+ // Defensive: ensure the work item is re-queued if completeDispatch didn't fire
2276
+ if (item.meta?.item?.id) {
2277
+ try {
2278
+ const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
2279
+ ? path.join(ENGINE_DIR, '..', 'work-items.json')
2280
+ : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
2281
+ if (wiPath) {
2282
+ const items = safeJson(wiPath) || [];
2283
+ const wi = items.find(i => i.id === item.meta.item.id);
2284
+ if (wi && wi.status === 'dispatched') {
2285
+ // completeDispatch didn't update the work item — re-queue manually
2286
+ wi.status = 'pending';
2287
+ wi._retryCount = (wi._retryCount || 0) + 1;
2288
+ wi._lastRetryReason = 'spawnAgent returned null';
2289
+ wi._lastRetryAt = ts();
2290
+ delete wi.dispatched_at;
2291
+ delete wi.dispatched_to;
2292
+ safeWrite(wiPath, items);
2293
+ log('info', `Re-queued ${item.meta.item.id} as pending (retry ${wi._retryCount})`);
2294
+ }
2295
+ }
2296
+ } catch (e) { log('warn', `Failed to re-queue work item after spawn failure: ${e.message}`); }
2297
+ }
2298
+ } else {
2299
+ dispatched.add(item.id);
2300
+ }
2249
2301
  }
2250
2302
  }
2251
2303
 
@@ -2268,7 +2320,7 @@ async function tickInner() {
2268
2320
  }
2269
2321
  }
2270
2322
  if (skipReasonChanged) {
2271
- mutateDispatch((dp) => { dp.pending = postDispatch.pending; });
2323
+ mutateDispatch((dp) => { dp.pending = postDispatch.pending; return dp; });
2272
2324
  }
2273
2325
  }
2274
2326
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.98",
3
+ "version": "0.1.100",
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"