@yemi33/minions 0.1.58 → 0.1.60

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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * engine/routing.js — Agent routing, budget checks, and routing table parsing.
3
+ * Extracted from engine.js.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const shared = require('./shared');
9
+ const queries = require('./queries');
10
+
11
+ const { safeJson, safeRead } = shared;
12
+ const { ENGINE_DIR, DISPATCH_PATH } = queries;
13
+
14
+ const MINIONS_DIR = path.resolve(__dirname, '..');
15
+ const ROUTING_PATH = path.join(MINIONS_DIR, 'routing.md');
16
+
17
+ // Lazy require to avoid circular dependency with engine.js
18
+ let _engine = null;
19
+ function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
20
+
21
+ // ─── Temp Agents ─────────────────────────────────────────────────────────────
22
+
23
+ const tempAgents = new Map(); // tempAgentId → { name, role, createdAt }
24
+
25
+ // ─── Routing Parser ─────────────────────────────────────────────────────────
26
+
27
+ function getRouting() {
28
+ return safeRead(ROUTING_PATH);
29
+ }
30
+
31
+ let _routingCache = null;
32
+ let _routingCacheMtime = 0;
33
+
34
+ function parseRoutingTable() {
35
+ const content = getRouting();
36
+ const routes = {};
37
+ const lines = content.split('\n');
38
+ let inTable = false;
39
+
40
+ for (const line of lines) {
41
+ if (line.startsWith('| Work Type')) { inTable = true; continue; }
42
+ if (line.startsWith('|---')) continue;
43
+ if (!inTable || !line.startsWith('|')) {
44
+ if (inTable && !line.startsWith('|')) inTable = false;
45
+ continue;
46
+ }
47
+ const cells = line.split('|').map(c => c.trim()).filter(Boolean);
48
+ if (cells.length >= 3) {
49
+ routes[cells[0].toLowerCase()] = {
50
+ preferred: cells[1].toLowerCase(),
51
+ fallback: cells[2].toLowerCase()
52
+ };
53
+ }
54
+ }
55
+ return routes;
56
+ }
57
+
58
+ function getRoutingTableCached() {
59
+ let mtime = 0;
60
+ try { mtime = fs.statSync(ROUTING_PATH).mtimeMs; } catch { /* optional */ }
61
+ if (_routingCache && _routingCacheMtime === mtime) return _routingCache;
62
+ _routingCache = parseRoutingTable();
63
+ _routingCacheMtime = mtime;
64
+ return _routingCache;
65
+ }
66
+
67
+ // ─── Budget ──────────────────────────────────────────────────────────────────
68
+
69
+ function getMonthlySpend(agentId) {
70
+ const metrics = safeJson(path.join(ENGINE_DIR, 'metrics.json')) || {};
71
+ const daily = metrics._daily || {};
72
+ const now = new Date();
73
+ const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
74
+ let total = 0;
75
+ for (const [date, data] of Object.entries(daily)) {
76
+ if (date.startsWith(monthPrefix)) {
77
+ total += (data.perAgent?.[agentId]?.costUsd || 0);
78
+ }
79
+ }
80
+ // Fallback: if no per-agent daily data, use cumulative (less accurate for monthly)
81
+ if (total === 0 && metrics[agentId]?.totalCostUsd) {
82
+ // Can't distinguish monthly from cumulative — treat as monthly estimate
83
+ // This path is for backward compat before per-agent daily tracking was added
84
+ }
85
+ return total;
86
+ }
87
+
88
+ function getAgentErrorRate(agentId) {
89
+ const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
90
+ const metrics = safeJson(metricsPath) || {};
91
+ const m = metrics[agentId];
92
+ if (!m) return 0;
93
+ const total = m.tasksCompleted + m.tasksErrored;
94
+ return total > 0 ? m.tasksErrored / total : 0;
95
+ }
96
+
97
+ function isAgentIdle(agentId) {
98
+ // Dispatch queue is the single source of truth for agent availability
99
+ const dispatch = safeJson(DISPATCH_PATH) || {};
100
+ return !(dispatch.active || []).some(d => d.agent === agentId);
101
+ }
102
+
103
+ // ─── Agent Resolution ────────────────────────────────────────────────────────
104
+
105
+ // Track agents claimed during a single discovery pass to distribute work
106
+ const _claimedAgents = new Set();
107
+ function resetClaimedAgents() { _claimedAgents.clear(); }
108
+
109
+ function resolveAgent(workType, config, authorAgent = null) {
110
+ const routes = getRoutingTableCached();
111
+ const route = routes[workType] || routes['implement'];
112
+ const agents = config.agents || {};
113
+
114
+ // Resolve _author_ token
115
+ let preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
116
+ let fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
117
+
118
+ const isAvailable = (id) => {
119
+ if (!agents[id] || !isAgentIdle(id) || _claimedAgents.has(id)) return false;
120
+ // Budget check — no budget means infinite (no limit)
121
+ const budget = agents[id].monthlyBudgetUsd;
122
+ if (budget && budget > 0) {
123
+ if (getMonthlySpend(id) >= budget) return false;
124
+ }
125
+ return true;
126
+ };
127
+
128
+ // Check preferred and fallback first (routing table order)
129
+ if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
130
+ if (fallback && isAvailable(fallback)) { _claimedAgents.add(fallback); return fallback; }
131
+
132
+ // Fall back to any idle agent, preferring lower error rates
133
+ const idle = Object.keys(agents)
134
+ .filter(id => id !== preferred && id !== fallback && isAvailable(id))
135
+ .sort((a, b) => getAgentErrorRate(a) - getAgentErrorRate(b));
136
+
137
+ if (idle[0]) { _claimedAgents.add(idle[0]); return idle[0]; }
138
+
139
+ // No idle configured agent — try temp agent if enabled
140
+ if (config.engine?.allowTempAgents) {
141
+ const tempId = `temp-${shared.uid()}`;
142
+ _claimedAgents.add(tempId);
143
+ tempAgents.set(tempId, { name: `Temp-${tempId.slice(5, 9)}`, role: 'Temporary Agent', createdAt: engine().ts() });
144
+ engine().log('info', `Spawning temp agent ${tempId} — all permanent agents busy`);
145
+ return tempId;
146
+ }
147
+
148
+ // No idle agent available — return null, item stays pending until next tick
149
+ return null;
150
+ }
151
+
152
+ module.exports = {
153
+ tempAgents,
154
+ getRouting,
155
+ parseRoutingTable,
156
+ getRoutingTableCached,
157
+ getMonthlySpend,
158
+ getAgentErrorRate,
159
+ isAgentIdle,
160
+ _claimedAgents,
161
+ resetClaimedAgents,
162
+ resolveAgent,
163
+ };
package/engine/shared.js CHANGED
@@ -38,24 +38,24 @@ function safeWrite(p, data) {
38
38
  } catch (e) {
39
39
  if (e.code === 'EPERM' && attempt < 4) {
40
40
  const delay = 50 * (attempt + 1); // 50, 100, 150, 200ms
41
- try { const ab = new SharedArrayBuffer(4); Atomics.wait(new Int32Array(ab), 0, 0, delay); } catch { const start = Date.now(); while (Date.now() - start < delay) {} }
41
+ 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) {} }
42
42
  continue;
43
43
  }
44
44
  // Final attempt failed — fall through to direct write
45
45
  }
46
46
  }
47
47
  // All rename attempts failed — direct write as fallback (not atomic but won't lose data)
48
- try { fs.unlinkSync(tmp); } catch {}
48
+ try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
49
49
  fs.writeFileSync(p, content);
50
50
  } catch (err) {
51
51
  // Even direct write failed — log and clean up tmp
52
52
  console.error(`[safeWrite] FAILED to write ${p}: ${err.message}`);
53
- try { fs.unlinkSync(tmp); } catch {}
53
+ try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
54
54
  }
55
55
  }
56
56
 
57
57
  function safeUnlink(p) {
58
- try { fs.unlinkSync(p); } catch {}
58
+ try { fs.unlinkSync(p); } catch { /* cleanup */ }
59
59
  }
60
60
 
61
61
  function sleepMs(ms) {
@@ -90,8 +90,8 @@ function withFileLock(lockPath, fn, {
90
90
  try {
91
91
  return fn();
92
92
  } finally {
93
- try { fs.closeSync(fd); } catch {}
94
- try { fs.unlinkSync(lockPath); } catch {}
93
+ try { fs.closeSync(fd); } catch { /* cleanup */ }
94
+ try { fs.unlinkSync(lockPath); } catch { /* cleanup */ }
95
95
  }
96
96
  }
97
97
 
@@ -45,7 +45,7 @@ if (!claudeBin) {
45
45
  const basedir = path.dirname(which.replace(/^\/c\//, 'C:/').replace(/\//g, path.sep));
46
46
  claudeBin = path.join(basedir, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js');
47
47
  }
48
- } catch {}
48
+ } catch { /* optional */ }
49
49
  }
50
50
 
51
51
  // Debug log
@@ -86,7 +86,7 @@ if (_sysPromptFileSupported === null) {
86
86
  const { spawnSync } = require('child_process');
87
87
  const testResult = spawnSync(process.execPath, [claudeBin, '--help'], { encoding: 'utf8', timeout: 10000, windowsHide: true });
88
88
  _sysPromptFileSupported = (testResult.stdout || '').includes('system-prompt-file');
89
- try { fs.writeFileSync(capsCachePath, JSON.stringify({ claudeBin, sysPromptFile: _sysPromptFileSupported, checkedAt: new Date().toISOString() })); } catch {}
89
+ try { fs.writeFileSync(capsCachePath, JSON.stringify({ claudeBin, sysPromptFile: _sysPromptFileSupported, checkedAt: new Date().toISOString() })); } catch { /* optional */ }
90
90
  } catch { _sysPromptFileSupported = true; /* assume supported */ }
91
91
  }
92
92
  if (!isResume) try {
@@ -131,7 +131,7 @@ if (!isResume && Buffer.byteLength(sysPrompt) >= 30000) {
131
131
  proc.stdin.end();
132
132
 
133
133
  // Clean up temp file (only created for non-resume sessions)
134
- if (!isResume) setTimeout(() => { try { fs.unlinkSync(sysTmpPath); } catch {} }, 5000);
134
+ if (!isResume) setTimeout(() => { try { fs.unlinkSync(sysTmpPath); } catch { /* cleanup */ } }, 5000);
135
135
 
136
136
  // Capture stderr separately for debugging
137
137
  let stderrBuf = '';