@yemi33/minions 0.1.1657 → 0.1.1659

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,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1659 (2026-05-01)
4
+
5
+ ### Other
6
+ - Harden runtime state writes
7
+
8
+ ## 0.1.1658 (2026-05-01)
9
+
10
+ ### Fixes
11
+ - trust structured completion status, drop prose regex
12
+
13
+ ### Other
14
+ - Fix soft routing hints
15
+
3
16
  ## 0.1.1657 (2026-05-01)
4
17
 
5
18
  ### Other
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, mutateControl, mutateCooldowns, 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
 
@@ -2116,12 +2116,10 @@ const { cleanDispatchEntries } = require('./engine/dispatch');
2116
2116
  // ── Engine Restart Helpers (used by watchdog + API) ─────────────────────────
2117
2117
 
2118
2118
  function spawnEngine() {
2119
- const controlPath = path.join(ENGINE_DIR, 'control.json');
2120
2119
  // Don't pre-write 'stopped' — let the new engine process own its state transition.
2121
2120
  // The engine start code already handles state:'running' with a dead PID gracefully.
2122
2121
  // Only set restarted_at + clear stale pid so dashboard shows the restart timestamp.
2123
- const control = safeJson(controlPath) || {};
2124
- safeWrite(controlPath, { ...control, pid: null, restarted_at: new Date().toISOString() });
2122
+ mutateControl(control => ({ ...control, pid: null, restarted_at: new Date().toISOString() }));
2125
2123
  const { spawn: cpSpawn } = require('child_process');
2126
2124
  const childEnv = { ...process.env };
2127
2125
  for (const key of Object.keys(childEnv)) {
@@ -2413,12 +2411,10 @@ const server = http.createServer(async (req, res) => {
2413
2411
  }, { defaultValue: { pending: [], active: [], completed: [] } });
2414
2412
  } catch (e) { console.error('dispatch cleanup:', e.message); }
2415
2413
  try {
2416
- const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
2417
- const cooldowns = safeJsonObj(cooldownPath);
2418
- if (cooldowns[dispatchKey]) {
2414
+ mutateCooldowns(cooldowns => {
2419
2415
  delete cooldowns[dispatchKey];
2420
- safeWrite(cooldownPath, cooldowns);
2421
- }
2416
+ return cooldowns;
2417
+ });
2422
2418
  } catch (e) { console.error('cooldown cleanup:', e.message); }
2423
2419
 
2424
2420
  return jsonReply(res, 200, { ok: true, id, rematerialized: true });
@@ -2463,12 +2459,10 @@ const server = http.createServer(async (req, res) => {
2463
2459
 
2464
2460
  // Clear cooldown so item isn't blocked by exponential backoff
2465
2461
  try {
2466
- const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
2467
- const cooldowns = safeJsonObj(cooldownPath);
2468
- if (cooldowns[dispatchKey]) {
2462
+ mutateCooldowns(cooldowns => {
2469
2463
  delete cooldowns[dispatchKey];
2470
- safeWrite(cooldownPath, cooldowns);
2471
- }
2464
+ return cooldowns;
2465
+ });
2472
2466
  } catch (e) { console.error('cooldown cleanup:', e.message); }
2473
2467
 
2474
2468
  return jsonReply(res, 200, { ok: true, id });
@@ -2514,13 +2508,12 @@ const server = http.createServer(async (req, res) => {
2514
2508
 
2515
2509
  // Clean cooldown entries so item can be re-created immediately
2516
2510
  try {
2517
- const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
2518
- const cooldowns = safeJsonObj(cooldownPath);
2519
- let cleaned = false;
2520
- for (const key of Object.keys(cooldowns)) {
2521
- if (key.includes(id)) { delete cooldowns[key]; cleaned = true; }
2522
- }
2523
- if (cleaned) safeWrite(cooldownPath, cooldowns);
2511
+ mutateCooldowns(cooldowns => {
2512
+ for (const key of Object.keys(cooldowns)) {
2513
+ if (key.includes(id)) delete cooldowns[key];
2514
+ }
2515
+ return cooldowns;
2516
+ });
2524
2517
  } catch (e) { console.error('cooldown cleanup:', e.message); }
2525
2518
 
2526
2519
  // Reset PRD item status so it doesn't stay 'dispatched' with no work item (#779)
@@ -2579,13 +2572,12 @@ const server = http.createServer(async (req, res) => {
2579
2572
 
2580
2573
  // Clean cooldown entries
2581
2574
  try {
2582
- const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
2583
- const cooldowns = safeJsonObj(cooldownPath);
2584
- let cleaned = false;
2585
- for (const key of Object.keys(cooldowns)) {
2586
- if (key.includes(id)) { delete cooldowns[key]; cleaned = true; }
2587
- }
2588
- if (cleaned) safeWrite(cooldownPath, cooldowns);
2575
+ mutateCooldowns(cooldowns => {
2576
+ for (const key of Object.keys(cooldowns)) {
2577
+ if (key.includes(id)) delete cooldowns[key];
2578
+ }
2579
+ return cooldowns;
2580
+ });
2589
2581
  } catch (e) { console.error('cooldown cleanup on cancel:', e.message); }
2590
2582
 
2591
2583
  invalidateStatusCache();
@@ -2704,12 +2696,10 @@ const server = http.createServer(async (req, res) => {
2704
2696
  }, { defaultValue: { pending: [], active: [], completed: [] } });
2705
2697
  } catch (e) { console.error('dispatch cleanup on reopen:', e.message); }
2706
2698
  try {
2707
- const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
2708
- const cooldowns = safeJsonObj(cooldownPath);
2709
- if (cooldowns[dispatchKey]) {
2699
+ mutateCooldowns(cooldowns => {
2710
2700
  delete cooldowns[dispatchKey];
2711
- safeWrite(cooldownPath, cooldowns);
2712
- }
2701
+ return cooldowns;
2702
+ });
2713
2703
  } catch (e) { console.error('cooldown cleanup on reopen:', e.message); }
2714
2704
 
2715
2705
  invalidateStatusCache();
@@ -2733,22 +2723,19 @@ const server = http.createServer(async (req, res) => {
2733
2723
  }
2734
2724
  const id = 'W-' + shared.uid();
2735
2725
  const item = {
2736
- id, title: body.title.trim(), type: body.type || 'implement',
2726
+ id, title: body.title.trim(), type: routing.normalizeWorkType(body.type, WORK_TYPE.IMPLEMENT),
2737
2727
  priority: body.priority || 'medium', description: body.description || '',
2738
2728
  status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard',
2739
2729
  };
2740
2730
  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).
2731
+ // Agent assignment normalization: `agent` and `agents` are routing hints.
2732
+ // Use agentLock/hardAgent only for the rare case where an item must wait
2733
+ // for one exact agent instead of falling through to another idle agent.
2748
2734
  const _agentsArr = Array.isArray(body.agents) ? body.agents.filter(Boolean) : (typeof body.agents === 'string' && body.agents ? [body.agents] : []);
2749
2735
  if (body.agent) item.agent = String(body.agent);
2750
2736
  else if (_agentsArr.length === 1 && body.scope !== 'fan-out') item.agent = String(_agentsArr[0]);
2751
2737
  if (_agentsArr.length > 0) item.agents = _agentsArr;
2738
+ if (body.agentLock === true || body.hardAgent === true) item.agentLock = true;
2752
2739
  if (body.references) item.references = body.references;
2753
2740
  if (body.acceptanceCriteria) item.acceptanceCriteria = body.acceptanceCriteria;
2754
2741
  if (body.skipPr) item.skipPr = true;
@@ -6449,10 +6436,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6449
6436
 
6450
6437
  // Engine
6451
6438
  { method: 'POST', path: '/api/engine/wakeup', desc: 'Trigger immediate engine tick via control.json signal', handler: async (req, res) => {
6452
- const controlPath = path.join(MINIONS_DIR, 'engine', 'control.json');
6453
- const control = shared.safeJson(controlPath) || {};
6454
- control._wakeupAt = Date.now();
6455
- shared.safeWrite(controlPath, control);
6439
+ shared.mutateControl(control => ({ ...control, _wakeupAt: Date.now() }));
6456
6440
  return jsonReply(res, 200, { ok: true, message: 'Wakeup signal sent' });
6457
6441
  }},
6458
6442
  { method: 'POST', path: '/api/engine/restart', desc: 'Force-kill engine and restart immediately', handler: handleEngineRestart },
package/engine/cleanup.js CHANGED
@@ -9,7 +9,7 @@ const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
11
  const { exec, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
12
- const { safeJson, safeWrite, safeReadDir, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
12
+ const { safeJson, safeWrite, safeReadDir, mutateCooldowns, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
13
13
  sanitizeBranch, KB_CATEGORIES } = shared;
14
14
  const { getDispatch, getAgentStatus } = queries;
15
15
 
@@ -675,9 +675,9 @@ function runCleanup(config, verbose = false) {
675
675
  entries.sort((a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0));
676
676
  const keep = Object.fromEntries(entries.slice(0, COOLDOWN_CAP));
677
677
  cleaned.cooldowns = entries.length - COOLDOWN_CAP;
678
- safeWrite(cooldownPath, keep);
678
+ mutateCooldowns(() => keep);
679
679
  } else if (dirty) {
680
- safeWrite(cooldownPath, cooldowns);
680
+ mutateCooldowns(() => cooldowns);
681
681
  }
682
682
  }
683
683
  } catch (e) { log('warn', 'cap cooldowns: ' + e.message); }
package/engine/cli.js CHANGED
@@ -6,7 +6,7 @@
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
  const shared = require('./shared');
9
- const { safeRead, safeJson, safeWrite, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
9
+ const { safeRead, safeJson, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
10
10
  const queries = require('./queries');
11
11
  const { getConfig, getControl, getDispatch, getAgentStatus,
12
12
  MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH, DISPATCH_PATH } = queries;
@@ -312,7 +312,7 @@ const commands = {
312
312
  }
313
313
  let codeCommit = null;
314
314
  try { codeCommit = require('child_process').execSync('git rev-parse --short HEAD', { cwd: path.resolve(__dirname, '..'), encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); } catch {}
315
- safeWrite(CONTROL_PATH, { state: 'running', pid: process.pid, started_at: e.ts(), codeVersion, codeCommit });
315
+ mutateControl(() => ({ state: 'running', pid: process.pid, started_at: e.ts(), codeVersion, codeCommit }));
316
316
  // Keep .minions-version in sync so `minions version` stays accurate after git pulls
317
317
  if (codeVersion) {
318
318
  try { fs.writeFileSync(path.join(shared.MINIONS_DIR, '.minions-version'), codeVersion); } catch {}
@@ -599,7 +599,10 @@ const commands = {
599
599
  const ctrl = getControl();
600
600
  if (ctrl._wakeupAt && Date.now() - ctrl._wakeupAt < 5000) {
601
601
  delete ctrl._wakeupAt;
602
- safeWrite(CONTROL_PATH, ctrl);
602
+ mutateControl((control) => {
603
+ delete control._wakeupAt;
604
+ return control;
605
+ });
603
606
  e.tick();
604
607
  }
605
608
  }, 1000);
@@ -669,11 +672,11 @@ const commands = {
669
672
  clearInterval(fastPollTimer);
670
673
  if (teamsInboxTimer) clearInterval(teamsInboxTimer);
671
674
  for (const f of _watchedFiles) { try { fs.unwatchFile(f); } catch { /* cleanup */ } }
672
- safeWrite(CONTROL_PATH, { state: 'stopping', pid: process.pid, stopping_at: e.ts() });
675
+ mutateControl(() => ({ state: 'stopping', pid: process.pid, stopping_at: e.ts() }));
673
676
  e.log('info', `Graceful shutdown initiated (${signal})`);
674
677
 
675
678
  if (e.activeProcesses.size === 0) {
676
- safeWrite(CONTROL_PATH, { state: 'stopped', stopped_at: e.ts() });
679
+ mutateControl(() => ({ state: 'stopped', stopped_at: e.ts() }));
677
680
  e.log('info', 'Graceful shutdown complete (no active agents)');
678
681
  shared.flushLogs(); // drain buffered log entries before exit
679
682
  console.log('No active agents — stopped.');
@@ -687,7 +690,7 @@ const commands = {
687
690
  const poll = setInterval(() => {
688
691
  if (e.activeProcesses.size === 0) {
689
692
  clearInterval(poll);
690
- safeWrite(CONTROL_PATH, { state: 'stopped', stopped_at: e.ts() });
693
+ mutateControl(() => ({ state: 'stopped', stopped_at: e.ts() }));
691
694
  e.log('info', 'Graceful shutdown complete (all agents finished)');
692
695
  shared.flushLogs(); // drain buffered log entries before exit
693
696
  console.log('All agents finished — stopped.');
@@ -695,7 +698,7 @@ const commands = {
695
698
  }
696
699
  if (Date.now() >= deadline) {
697
700
  clearInterval(poll);
698
- safeWrite(CONTROL_PATH, { state: 'stopped', stopped_at: e.ts() });
701
+ mutateControl(() => ({ state: 'stopped', stopped_at: e.ts() }));
699
702
  e.log('warn', `Graceful shutdown timed out after ${timeout / 1000}s with ${e.activeProcesses.size} agent(s) still active`);
700
703
  shared.flushLogs(); // drain buffered log entries before exit
701
704
  console.log(`Shutdown timeout (${timeout / 1000}s) — force exiting with ${e.activeProcesses.size} agent(s) still running.`);
@@ -742,14 +745,14 @@ const commands = {
742
745
  if (control.pid && control.pid !== process.pid) {
743
746
  try { process.kill(control.pid); } catch { /* process may be dead */ }
744
747
  }
745
- safeWrite(CONTROL_PATH, { state: 'stopped', stopped_at: e.ts() });
748
+ mutateControl(() => ({ state: 'stopped', stopped_at: e.ts() }));
746
749
  e.log('info', 'Engine stopped');
747
750
  console.log('Engine stopped.');
748
751
  },
749
752
 
750
753
  pause() {
751
754
  const e = engine();
752
- safeWrite(CONTROL_PATH, { state: 'paused', paused_at: e.ts() });
755
+ mutateControl(() => ({ state: 'paused', paused_at: e.ts() }));
753
756
  e.log('info', 'Engine paused');
754
757
  console.log('Engine paused. Run `node .minions/engine.js resume` to resume.');
755
758
  },
@@ -761,7 +764,7 @@ const commands = {
761
764
  console.log('Engine is already running.');
762
765
  return;
763
766
  }
764
- safeWrite(CONTROL_PATH, { state: 'running', resumed_at: e.ts() });
767
+ mutateControl(() => ({ state: 'running', resumed_at: e.ts() }));
765
768
  e.log('info', 'Engine resumed');
766
769
  console.log('Engine resumed.');
767
770
  },
@@ -932,7 +935,7 @@ const commands = {
932
935
  dispatch() {
933
936
  const control = getControl();
934
937
  if (control.state === 'running' && isEngineProcessAlive(control)) {
935
- safeWrite(CONTROL_PATH, { ...control, _wakeupAt: Date.now() });
938
+ mutateControl((c) => ({ ...c, _wakeupAt: Date.now() }));
936
939
  console.log(`Dispatch wakeup requested from running engine (PID ${control.pid}).`);
937
940
  return;
938
941
  }
@@ -7,7 +7,7 @@ const path = require('path');
7
7
  const shared = require('./shared');
8
8
  const queries = require('./queries');
9
9
 
10
- const { safeJson, safeWrite, log, ENGINE_DEFAULTS } = shared;
10
+ const { safeJson, mutateCooldowns, log, ENGINE_DEFAULTS } = shared;
11
11
  const { ENGINE_DIR } = queries;
12
12
 
13
13
  /**
@@ -37,6 +37,7 @@ function _truncateContextEntry(entry, maxBytes) {
37
37
 
38
38
  const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
39
39
  const dispatchCooldowns = new Map(); // key → { timestamp, failures }
40
+ let _lastDiskCooldownKeys = new Set();
40
41
 
41
42
  function loadCooldowns() {
42
43
  const saved = safeJson(COOLDOWN_PATH);
@@ -48,6 +49,7 @@ function loadCooldowns() {
48
49
  dispatchCooldowns.set(k, v);
49
50
  }
50
51
  }
52
+ _lastDiskCooldownKeys = new Set(dispatchCooldowns.keys());
51
53
  log('info', `Loaded ${dispatchCooldowns.size} cooldowns from disk`);
52
54
  }
53
55
 
@@ -57,27 +59,35 @@ function saveCooldowns() {
57
59
  if (_cooldownWriteTimer) clearTimeout(_cooldownWriteTimer);
58
60
  _cooldownWriteTimer = setTimeout(() => {
59
61
  _cooldownWriteTimer = null;
60
- // Prune expired entries (>24h) before saving
61
- const now = Date.now();
62
- for (const [k, v] of dispatchCooldowns) {
63
- if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
64
- }
65
- // Trim pendingContexts arrays before writing to prevent bloat
66
- const cap = ENGINE_DEFAULTS.maxPendingContexts;
67
- const entryLimit = ENGINE_DEFAULTS.maxPendingContextEntryBytes;
68
- for (const [, v] of dispatchCooldowns) {
69
- if (Array.isArray(v.pendingContexts)) {
70
- if (v.pendingContexts.length > cap) {
71
- v.pendingContexts = v.pendingContexts.slice(-cap);
72
- }
73
- // Also truncate oversized individual entries — #1167 showed
74
- // 20 entries × 25 MB each still produced a 500 MB cooldowns.json.
75
- v.pendingContexts = v.pendingContexts.map(e => _truncateContextEntry(e, entryLimit));
76
- }
77
- }
78
- const obj = Object.fromEntries(dispatchCooldowns);
79
62
  try {
80
- safeWrite(COOLDOWN_PATH, obj);
63
+ mutateCooldowns((diskCooldowns) => {
64
+ for (const key of Array.from(dispatchCooldowns.keys())) {
65
+ if (_lastDiskCooldownKeys.has(key) && !Object.prototype.hasOwnProperty.call(diskCooldowns, key)) {
66
+ dispatchCooldowns.delete(key);
67
+ }
68
+ }
69
+ // Prune expired entries (>24h) before saving
70
+ const now = Date.now();
71
+ for (const [k, v] of dispatchCooldowns) {
72
+ if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
73
+ }
74
+ // Trim pendingContexts arrays before writing to prevent bloat
75
+ const cap = ENGINE_DEFAULTS.maxPendingContexts;
76
+ const entryLimit = ENGINE_DEFAULTS.maxPendingContextEntryBytes;
77
+ for (const [, v] of dispatchCooldowns) {
78
+ if (Array.isArray(v.pendingContexts)) {
79
+ if (v.pendingContexts.length > cap) {
80
+ v.pendingContexts = v.pendingContexts.slice(-cap);
81
+ }
82
+ // Also truncate oversized individual entries — #1167 showed
83
+ // 20 entries × 25 MB each still produced a 500 MB cooldowns.json.
84
+ v.pendingContexts = v.pendingContexts.map(e => _truncateContextEntry(e, entryLimit));
85
+ }
86
+ }
87
+ const obj = Object.fromEntries(dispatchCooldowns);
88
+ _lastDiskCooldownKeys = new Set(Object.keys(obj));
89
+ return obj;
90
+ });
81
91
  } catch (err) {
82
92
  log('warn', `saveCooldowns failed writing ${COOLDOWN_PATH}: ${err.message}`);
83
93
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-01T04:46:00.686Z"
4
+ "cachedAt": "2026-05-01T05:34:43.680Z"
5
5
  }
@@ -81,7 +81,7 @@ function addToDispatch(item) {
81
81
  let added = false;
82
82
  mutateDispatch((dispatch) => {
83
83
  // Dedup: skip if same work item ID is already pending or active
84
- const wiId = item.meta?.item?.id;
84
+ const wiId = item.meta?.source === 'central-work-item-fanout' ? null : item.meta?.item?.id;
85
85
  if (wiId) {
86
86
  const existing = [...dispatch.pending, ...(dispatch.active || [])].find(d => d.meta?.item?.id === wiId);
87
87
  if (existing) {
@@ -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/queries.js CHANGED
@@ -101,7 +101,21 @@ function timeSince(ms) {
101
101
  }
102
102
 
103
103
  function readJsonNoRestore(filePath) {
104
- try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
104
+ let raw;
105
+ try {
106
+ raw = fs.readFileSync(filePath, 'utf8');
107
+ } catch (e) {
108
+ if (e && e.code !== 'ENOENT') {
109
+ console.warn(`[queries] failed to read ${_relativeStatePath(filePath)}: ${e.message}`);
110
+ }
111
+ return null;
112
+ }
113
+ try {
114
+ return JSON.parse(raw);
115
+ } catch (e) {
116
+ console.warn(`[queries] corrupt JSON in ${_relativeStatePath(filePath)}: ${e.message}`);
117
+ return null;
118
+ }
105
119
  }
106
120
 
107
121
  // ── Core State Readers ──────────────────────────────────────────────────────
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)
@@ -230,8 +259,7 @@ function resolveAgent(workType, config, opts = {}) {
230
259
 
231
260
  function resolveAgentReservation(workType, config, opts = {}) {
232
261
  const { authorAgent = null, agentHints = null } = opts || {};
233
- const routes = getRoutingTableCached();
234
- const route = routes[workType] || routes['implement'] || { preferred: '_any_', fallback: '_any_' };
262
+ const route = routeForWorkType(workType);
235
263
  const agents = config.agents || {};
236
264
  const hintedAgents = normalizeAgentHints(agentHints, authorAgent, agents);
237
265
 
@@ -274,6 +302,9 @@ module.exports = {
274
302
  isAgentIdle,
275
303
  normalizeAgentHints,
276
304
  extractAgentHints,
305
+ isAgentHardPinned,
306
+ getHardPinnedAgent,
307
+ normalizeWorkType,
277
308
  _claimedAgents,
278
309
  resetClaimedAgents,
279
310
  resolveAgent,
@@ -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/shared.js CHANGED
@@ -8,6 +8,9 @@ const path = require('path');
8
8
  const crypto = require('crypto');
9
9
 
10
10
  const MINIONS_DIR = process.env.MINIONS_TEST_DIR || path.resolve(__dirname, '..');
11
+ const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
12
+ const CONTROL_PATH = path.join(ENGINE_DIR, 'control.json');
13
+ const COOLDOWNS_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
11
14
  const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
12
15
  const PINNED_ITEMS_PATH = path.join(MINIONS_DIR, 'engine', 'kb-pins.json');
13
16
  const LOG_PATH = path.join(MINIONS_DIR, 'engine', 'log.json');
@@ -428,6 +431,20 @@ function mutateJsonFileLocked(filePath, mutateFn, {
428
431
  }, { retries, retryBackoffMs });
429
432
  }
430
433
 
434
+ function mutateControl(mutator) {
435
+ return mutateJsonFileLocked(CONTROL_PATH, (data) => {
436
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
437
+ return mutator(data) || data;
438
+ }, { defaultValue: { state: 'stopped', pid: null }, skipWriteIfUnchanged: true });
439
+ }
440
+
441
+ function mutateCooldowns(mutator) {
442
+ return mutateJsonFileLocked(COOLDOWNS_PATH, (data) => {
443
+ if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
444
+ return mutator(data) || data;
445
+ }, { defaultValue: {}, skipWriteIfUnchanged: true });
446
+ }
447
+
431
448
  /**
432
449
  * Generate a unique ID suffix: timestamp + 4 random chars.
433
450
  * Use for filenames that could collide (dispatch IDs, temp files, etc.)
@@ -2304,6 +2321,9 @@ function createThrottleTracker({ label, baseBackoffMs = 60000, maxBackoffMs = 32
2304
2321
 
2305
2322
  module.exports = {
2306
2323
  MINIONS_DIR,
2324
+ ENGINE_DIR,
2325
+ CONTROL_PATH,
2326
+ COOLDOWNS_PATH,
2307
2327
  PR_LINKS_PATH,
2308
2328
  PINNED_ITEMS_PATH,
2309
2329
  LOG_PATH,
@@ -2325,6 +2345,8 @@ module.exports = {
2325
2345
  assertStateFileSize,
2326
2346
  withFileLock,
2327
2347
  mutateJsonFileLocked,
2348
+ mutateControl,
2349
+ mutateCooldowns,
2328
2350
  mutateWorkItems,
2329
2351
  reopenWorkItem,
2330
2352
  mutatePullRequests,
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
@@ -95,6 +95,7 @@ const safeRead = shared.safeRead;
95
95
  const safeWrite = shared.safeWrite;
96
96
  const safeUnlink = shared.safeUnlink;
97
97
  const mutateJsonFileLocked = shared.mutateJsonFileLocked;
98
+ const mutateControl = shared.mutateControl;
98
99
  const mutateWorkItems = shared.mutateWorkItems;
99
100
  const mutatePullRequests = shared.mutatePullRequests;
100
101
  const withFileLock = shared.withFileLock;
@@ -2644,7 +2645,7 @@ function discoverFromWorkItems(config, project) {
2644
2645
  skipped.gated++; continue;
2645
2646
  }
2646
2647
 
2647
- let workType = item.type || 'implement';
2648
+ let workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
2648
2649
  if (workType === WORK_TYPE.IMPLEMENT && (item.complexity === 'large' || item.estimated_complexity === 'large')) {
2649
2650
  workType = WORK_TYPE.IMPLEMENT_LARGE;
2650
2651
  }
@@ -2655,16 +2656,17 @@ function discoverFromWorkItems(config, project) {
2655
2656
  needsWrite = true;
2656
2657
  }
2657
2658
  const agentHints = routing.extractAgentHints(item);
2658
- const hasExplicitAgentHint = !!(item.agent || routing.normalizeAgentHints(agentHints, null, config.agents || {}).length);
2659
- let agentId = item.agent || resolveAgent(workType, config, { agentHints });
2659
+ const hardPinRequested = routing.isAgentHardPinned(item);
2660
+ let agentId = routing.getHardPinnedAgent(item, config.agents || {})
2661
+ || (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null);
2660
2662
  const cfgAgents = config.agents || {};
2661
2663
  const budgetBlocked = Object.keys(cfgAgents).some(id => {
2662
2664
  const b = cfgAgents[id].monthlyBudgetUsd;
2663
2665
  return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
2664
2666
  });
2665
2667
  if (!agentId) {
2666
- if (!budgetBlocked && !hasExplicitAgentHint) {
2667
- agentId = resolveAgentReservation(workType, config);
2668
+ if (!budgetBlocked && !hardPinRequested) {
2669
+ agentId = resolveAgentReservation(workType, config, { agentHints });
2668
2670
  }
2669
2671
  if (agentId) {
2670
2672
  delete item._pendingReason;
@@ -3104,7 +3106,7 @@ function discoverCentralWorkItems(config) {
3104
3106
  }
3105
3107
  if (isOnCooldown(key, 0)) continue;
3106
3108
 
3107
- const workType = item.type || 'implement';
3109
+ const workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
3108
3110
  const isFanOut = item.scope === 'fan-out';
3109
3111
 
3110
3112
  if (isFanOut) {
@@ -3185,10 +3187,10 @@ function discoverCentralWorkItems(config) {
3185
3187
  } else {
3186
3188
  // ─── Normal: single agent dispatch ──────────────────────────────
3187
3189
  const agentHints = routing.extractAgentHints(item);
3188
- const hasExplicitAgentHint = !!(item.agent || routing.normalizeAgentHints(agentHints, null, config.agents || {}).length);
3189
- const agentId = item.agent
3190
- || resolveAgent(workType, config, { agentHints })
3191
- || (!hasExplicitAgentHint ? resolveAgentReservation(workType, config) : null);
3190
+ const hardPinRequested = routing.isAgentHardPinned(item);
3191
+ const agentId = routing.getHardPinnedAgent(item, config.agents || {})
3192
+ || (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null)
3193
+ || (!hardPinRequested ? resolveAgentReservation(workType, config, { agentHints }) : null);
3192
3194
  if (!agentId) continue;
3193
3195
 
3194
3196
  const agentName = config.agents[agentId]?.name || agentId;
@@ -3556,7 +3558,7 @@ async function tickInner() {
3556
3558
  }
3557
3559
 
3558
3560
  // Write heartbeat so dashboard can detect stale engine
3559
- try { safeWrite(CONTROL_PATH, { ...control, heartbeat: Date.now() }); } catch (e) { log('warn', 'write heartbeat: ' + e.message); }
3561
+ try { mutateControl(c => ({ ...c, heartbeat: Date.now() })); } catch (e) { log('warn', 'write heartbeat: ' + e.message); }
3560
3562
 
3561
3563
  const config = getConfig();
3562
3564
  tickCount++;
@@ -3862,7 +3864,7 @@ async function tickInner() {
3862
3864
  // be of type string. Received undefined` and re-queues — every tick. Try to
3863
3865
  // resolve a fallback via routing; if none is available, skip this tick.
3864
3866
  if (!item.agent || typeof item.agent !== 'string') {
3865
- const fallback = resolveAgent(item.type || WORK_TYPE.FIX, config, { agentHints: routing.extractAgentHints(item.meta?.item) });
3867
+ const fallback = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
3866
3868
  if (!fallback) {
3867
3869
  log('warn', `Pending dispatch ${item.id} has no agent and routing returned no fallback — skipping`);
3868
3870
  continue;
@@ -3893,7 +3895,7 @@ async function tickInner() {
3893
3895
  // them eagerly before the busy check so an idle named agent can pick up.
3894
3896
  const isUnspawnedTemp = item.agent?.startsWith('temp-') && !busyAgents.has(item.agent);
3895
3897
  if (isUnspawnedTemp) {
3896
- const altAgent = resolveAgent(item.type, config);
3898
+ const altAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config);
3897
3899
  if (altAgent && altAgent !== item.agent) {
3898
3900
  const prevAgent = item.agent;
3899
3901
  item.agent = altAgent;
@@ -3920,12 +3922,12 @@ async function tickInner() {
3920
3922
  // Agent busy reassignment: if item has been waiting on a busy agent past the threshold,
3921
3923
  // try to find an alternative agent via routing. Skip explicitly assigned items.
3922
3924
  const reassignMs = config.engine?.agentBusyReassignMs ?? ENGINE_DEFAULTS.agentBusyReassignMs;
3923
- const isExplicitReassign = !!(item.meta?.item?.agent || routing.extractAgentHints(item.meta?.item));
3924
- if (!isExplicitReassign && reassignMs > 0 && item._agentBusySince) {
3925
+ const isHardPinned = routing.isAgentHardPinned(item.meta?.item);
3926
+ if (!isHardPinned && reassignMs > 0 && item._agentBusySince) {
3925
3927
  const busySinceMs = new Date(item._agentBusySince).getTime();
3926
3928
  if (Date.now() - busySinceMs > reassignMs) {
3927
3929
  const originalAgent = item.agent;
3928
- const altAgent = resolveAgent(item.type, config);
3930
+ const altAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
3929
3931
  if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
3930
3932
  log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent} — agent busy > ${reassignMs}ms`);
3931
3933
  item.agent = altAgent;
@@ -3959,14 +3961,12 @@ async function tickInner() {
3959
3961
  // Branch mutex: skip items targeting a branch already locked by an active or newly-dispatched task
3960
3962
  const itemBranch = item.meta?.branch ? sanitizeBranch(item.meta.branch) : null;
3961
3963
  if (itemBranch && lockedBranches.has(itemBranch)) continue;
3962
- // Items explicitly assigned to an agent bypass concurrency cap — dispatch if agent is free
3963
- const isExplicitAssignment = !!item.meta?.item?.agent;
3964
- if (!isExplicitAssignment && generalSlots <= 0) continue;
3964
+ if (generalSlots <= 0) continue;
3965
3965
  seenPendingIds.add(item.id);
3966
3966
  toDispatch.push(item);
3967
3967
  busyAgents.add(item.agent);
3968
3968
  if (itemBranch) lockedBranches.add(itemBranch);
3969
- if (!isExplicitAssignment) generalSlots--;
3969
+ generalSlots--;
3970
3970
  }
3971
3971
 
3972
3972
  // 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.1657",
3
+ "version": "0.1.1659",
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"