@yemi33/minions 0.1.1735 → 0.1.1737

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1737 (2026-05-05)
4
+
5
+ ### Other
6
+ - perf: buffered metrics, snapshot-diff sidecar, parallel worktree branch probes
7
+
8
+ ## 0.1.1736 (2026-05-05)
9
+
10
+ ### Other
11
+ - test(lifecycle): add unit tests for review-verdict, completion-field, and item-completed parsers (#2093)
12
+
3
13
  ## 0.1.1735 (2026-05-05)
4
14
 
5
15
  ### Other
package/engine/cleanup.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 { exec, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
11
+ const { exec, execAsync, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
12
12
  const { safeJson, safeWrite, safeReadDir, mutateCooldowns, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
13
13
  sanitizeBranch, KB_CATEGORIES } = shared;
14
14
  const { getDispatch, getAgentStatus } = queries;
@@ -56,6 +56,15 @@ function getWorktreeBranch(wtPath) {
56
56
  }
57
57
  }
58
58
 
59
+ async function getWorktreeBranchAsync(wtPath) {
60
+ try {
61
+ const out = await execAsync(`git -C "${wtPath}" branch --show-current`, { encoding: 'utf8', timeout: 5000 });
62
+ return (out || '').toString().trim();
63
+ } catch {
64
+ return '';
65
+ }
66
+ }
67
+
59
68
  let _orphanPidProcessNamesCache = null;
60
69
  function _orphanPidProcessNames() {
61
70
  if (_orphanPidProcessNamesCache) return _orphanPidProcessNamesCache;
@@ -134,7 +143,7 @@ function _killProcessInWorktree(dir, activeProcesses, activeIds) {
134
143
 
135
144
  // ─── Cleanup Orchestrator ────────────────────────────────────────────────────
136
145
 
137
- function runCleanup(config, verbose = false) {
146
+ async function runCleanup(config, verbose = false) {
138
147
  const activeProcesses = engine().activeProcesses;
139
148
  const projects = getProjects(config);
140
149
  let cleaned = { tempFiles: 0, liveOutputs: 0, worktrees: 0, zombies: 0 };
@@ -248,11 +257,26 @@ function runCleanup(config, verbose = false) {
248
257
  const dispatch = getDispatch();
249
258
  const activeDispatchIds = new Set((dispatch.active || []).map(d => d.id));
250
259
 
260
+ // Probe `git branch --show-current` for every worktree in chunks of 5.
261
+ // Sequential probing was the dominant cost in the cleanup phase
262
+ // (5–15s tick stall every 10 ticks at 50+ worktrees), but unbounded
263
+ // Promise.all would spawn 50+ concurrent git children — bad on Windows
264
+ // where each fork pays AV-scan overhead. Mirrors engine/ado.js:611.
265
+ const BRANCH_PROBE_CONCURRENCY = 5;
266
+ const branchMap = new Map();
267
+ for (let i = 0; i < allDirs.length; i += BRANCH_PROBE_CONCURRENCY) {
268
+ const batch = allDirs.slice(i, i + BRANCH_PROBE_CONCURRENCY);
269
+ const pairs = await Promise.all(
270
+ batch.map(async ({ wtPath }) => [wtPath, await getWorktreeBranchAsync(wtPath)])
271
+ );
272
+ for (const [wtPath, branch] of pairs) branchMap.set(wtPath, branch);
273
+ }
274
+
251
275
  for (const { dir, wtPath } of allDirs) {
252
276
 
253
277
  let shouldClean = false;
254
278
  let isProtected = false;
255
- const actualBranch = getWorktreeBranch(wtPath);
279
+ const actualBranch = branchMap.get(wtPath) || '';
256
280
 
257
281
  // Check if this worktree's branch is merged/abandoned
258
282
  // Prefer actual git branch metadata; compact Windows dirs intentionally omit branch names.
package/engine/cli.js CHANGED
@@ -745,6 +745,16 @@ const commands = {
745
745
  }
746
746
  watchForWorkChanges();
747
747
 
748
+ // Drain in-memory buffers (metrics, logs) before any process.exit. Logs
749
+ // a stderr line if a flush fails so a refactor mistake (typo, missing
750
+ // export) doesn't go silent.
751
+ function drainBuffers() {
752
+ try { require('./llm').flushMetricsBuffer(); }
753
+ catch (err) { try { console.error(`[shutdown] flushMetricsBuffer failed: ${err.message}`); } catch {} }
754
+ try { shared.flushLogs(); }
755
+ catch (err) { try { console.error(`[shutdown] flushLogs failed: ${err.message}`); } catch {} }
756
+ }
757
+
748
758
  // Graceful shutdown — wait for active agents before exiting
749
759
  let shuttingDown = false;
750
760
  function gracefulShutdown(signal) {
@@ -768,7 +778,7 @@ const commands = {
768
778
  e.log('warn', 'Graceful shutdown skipped control.json stopped transition; control file is owned by a different engine process');
769
779
  }
770
780
  e.log('info', 'Graceful shutdown complete (no active agents)');
771
- shared.flushLogs(); // drain buffered log entries before exit
781
+ drainBuffers();
772
782
  console.log('No active agents — stopped.');
773
783
  process.exit(0);
774
784
  }
@@ -785,7 +795,7 @@ const commands = {
785
795
  e.log('warn', 'Graceful shutdown skipped control.json stopped transition; control file is owned by a different engine process');
786
796
  }
787
797
  e.log('info', 'Graceful shutdown complete (all agents finished)');
788
- shared.flushLogs(); // drain buffered log entries before exit
798
+ drainBuffers();
789
799
  console.log('All agents finished — stopped.');
790
800
  process.exit(0);
791
801
  }
@@ -796,7 +806,7 @@ const commands = {
796
806
  e.log('warn', 'Graceful shutdown skipped control.json stopped transition; control file is owned by a different engine process');
797
807
  }
798
808
  e.log('warn', `Graceful shutdown timed out after ${timeout / 1000}s with ${e.activeProcesses.size} agent(s) still active`);
799
- shared.flushLogs(); // drain buffered log entries before exit
809
+ drainBuffers();
800
810
  console.log(`Shutdown timeout (${timeout / 1000}s) — force exiting with ${e.activeProcesses.size} agent(s) still running.`);
801
811
  process.exit(1);
802
812
  }
@@ -811,7 +821,7 @@ const commands = {
811
821
  const msg = reason instanceof Error ? reason.stack || reason.message : String(reason);
812
822
  console.error(`[FATAL] Unhandled promise rejection: ${msg}`);
813
823
  try { shared.log('fatal', `Unhandled promise rejection: ${msg}`); } catch { /* best effort */ }
814
- try { shared.flushLogs(); } catch { /* best effort */ }
824
+ drainBuffers();
815
825
  process.exit(1);
816
826
  });
817
827
 
@@ -819,7 +829,7 @@ const commands = {
819
829
  const msg = err instanceof Error ? err.stack || err.message : String(err);
820
830
  console.error(`[FATAL] Uncaught exception: ${msg}`);
821
831
  try { shared.log('fatal', `Uncaught exception: ${msg}`); } catch { /* best effort */ }
822
- try { shared.flushLogs(); } catch { /* best effort */ }
832
+ drainBuffers();
823
833
  process.exit(1);
824
834
  });
825
835
  },
@@ -1359,11 +1369,11 @@ const commands = {
1359
1369
  console.log(`\nDone: ${killed.length} dispatches killed, agents reset.`);
1360
1370
  },
1361
1371
 
1362
- cleanup() {
1372
+ async cleanup() {
1363
1373
  const e = engine();
1364
1374
  const config = getConfig();
1365
1375
  console.log('\n=== Cleanup ===\n');
1366
- const result = e.runCleanup(config, true);
1376
+ const result = await e.runCleanup(config, true);
1367
1377
  console.log(`\nDone: ${result.tempFiles} temp files, ${result.liveOutputs} live outputs, ${result.worktrees} worktrees, ${result.zombies} zombies cleaned.`);
1368
1378
  },
1369
1379
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-05T21:22:01.417Z"
4
+ "cachedAt": "2026-05-05T22:02:28.540Z"
5
5
  }
@@ -24,18 +24,35 @@ function lifecycle() { if (!_lifecycle) _lifecycle = require('./lifecycle'); ret
24
24
  // ─── Dispatch Mutation ───────────────────────────────────────────────────────
25
25
 
26
26
  /**
27
- * Sweep pending + active dispatch entries and move any oversized prompts to
28
- * sidecar files. Keeps dispatch.json from bloating to hundreds of MB when
29
- * fix-type prompts inline PR diffs / build logs / coalesced feedback (#1167).
30
- * Safe to call on every mutation: small prompts are untouched.
27
+ * Walk pending + active dispatch entries and snapshot {id prompt} for items
28
+ * that have a string prompt. Used to diff before/after a mutation so we only
29
+ * re-check items the mutator actually touched (#1167).
31
30
  */
32
- function _sidecarOversizedPrompts(dispatch) {
31
+ function _snapshotPrompts(dispatch) {
32
+ const snap = new Map();
33
+ for (const list of [dispatch.pending, dispatch.active]) {
34
+ if (!Array.isArray(list)) continue;
35
+ for (const item of list) {
36
+ if (item && item.id && typeof item.prompt === 'string') snap.set(item.id, item.prompt);
37
+ }
38
+ }
39
+ return snap;
40
+ }
41
+
42
+ /**
43
+ * Sidecar oversized prompts only for items the mutator added or modified.
44
+ * Keeps dispatch.json safe from bloat (#1167) without paying O(n) on every
45
+ * unrelated mutation (e.g. status flips, completion-marking).
46
+ */
47
+ function _sidecarChangedPrompts(dispatch, prevSnap) {
33
48
  const threshold = ENGINE_DEFAULTS.maxDispatchPromptBytes;
34
- const lists = [dispatch.pending, dispatch.active];
35
- for (const list of lists) {
49
+ for (const list of [dispatch.pending, dispatch.active]) {
36
50
  if (!Array.isArray(list)) continue;
37
51
  for (const item of list) {
38
- if (item && typeof item.prompt === 'string') sidecarDispatchPrompt(item, threshold);
52
+ if (!item || typeof item.prompt !== 'string') continue;
53
+ const prev = item.id ? prevSnap.get(item.id) : undefined;
54
+ if (prev === item.prompt) continue; // untouched — already validated on insert
55
+ sidecarDispatchPrompt(item, threshold);
39
56
  }
40
57
  }
41
58
  }
@@ -46,10 +63,11 @@ function mutateDispatch(mutator) {
46
63
  dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
47
64
  dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
48
65
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
66
+ const prevSnap = _snapshotPrompts(dispatch);
49
67
  const next = mutator(dispatch) ?? dispatch;
50
- // Prompt-size guard: runs on every write so a single bad item cannot bloat
51
- // dispatch.json. Sidecars live in engine/contexts/<id>.md.
52
- _sidecarOversizedPrompts(next);
68
+ // Prompt-size guard: only scan items whose prompt changed (or new items),
69
+ // so a 100-item status-flip doesn't re-byte-count every prompt.
70
+ _sidecarChangedPrompts(next, prevSnap);
53
71
  return next;
54
72
  }, { defaultValue: defaultDispatch });
55
73
  // Invalidate the read cache so next getDispatch() sees fresh data
@@ -475,7 +475,8 @@ function resolveWorkItemPath(meta) {
475
475
 
476
476
  /** Check if a work item is in a terminal completed state. */
477
477
  function isItemCompleted(item) {
478
- return item.status === WI_STATUS.DONE || !!item.completedAt;
478
+ if (!item || typeof item !== 'object') return false;
479
+ return DONE_STATUSES.has(item.status) || !!item.completedAt;
479
480
  }
480
481
 
481
482
  // ─── Work Item Status ────────────────────────────────────────────────────────
package/engine/llm.js CHANGED
@@ -26,41 +26,119 @@ const COPILOT_TASK_COMPLETE_GRACE_MS = 3000;
26
26
  const MISSING_RUNTIME_EXIT_CODE = 78;
27
27
 
28
28
  // ─── Engine-Usage Metrics ────────────────────────────────────────────────────
29
+ //
30
+ // Updates accumulate in an in-memory buffer and flush every
31
+ // metricsFlushIntervalMs (default 10s). Replaces the per-call mutateJsonFileLocked
32
+ // that was both serializing the LLM hot path and bumping metrics.json mtime
33
+ // on every call — defeating the dashboard fast-state mtime-based early-exit.
34
+
35
+ let _pendingMetrics = { engine: Object.create(null), daily: Object.create(null) };
36
+ let _flushTimer = null;
37
+
38
+ function _emptyEngineDelta() {
39
+ return { calls: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0, totalDurationMs: 0, timedCalls: 0 };
40
+ }
41
+
42
+ function _emptyDailyDelta() {
43
+ return { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0 };
44
+ }
45
+
46
+ function _ensureFlushTimer() {
47
+ if (_flushTimer) return;
48
+ const interval = shared.ENGINE_DEFAULTS.metricsFlushIntervalMs || 10000;
49
+ _flushTimer = setInterval(flushMetricsBuffer, interval);
50
+ if (typeof _flushTimer.unref === 'function') _flushTimer.unref();
51
+ }
29
52
 
30
53
  function trackEngineUsage(category, usage) {
31
54
  if (!usage) return;
32
55
  if (category && (category.startsWith('_test') || category.startsWith('test-'))) return;
56
+
57
+ if (!_pendingMetrics.engine[category]) _pendingMetrics.engine[category] = _emptyEngineDelta();
58
+ const cat = _pendingMetrics.engine[category];
59
+ cat.calls++;
60
+ cat.costUsd += usage.costUsd || 0;
61
+ cat.inputTokens += usage.inputTokens || 0;
62
+ cat.outputTokens += usage.outputTokens || 0;
63
+ cat.cacheRead += usage.cacheRead || 0;
64
+ cat.cacheCreation += usage.cacheCreation || 0;
65
+ if (usage.durationMs) {
66
+ cat.totalDurationMs += usage.durationMs;
67
+ cat.timedCalls += 1;
68
+ }
69
+
70
+ const today = ts().slice(0, 10);
71
+ if (!_pendingMetrics.daily[today]) _pendingMetrics.daily[today] = _emptyDailyDelta();
72
+ const daily = _pendingMetrics.daily[today];
73
+ daily.costUsd += usage.costUsd || 0;
74
+ daily.inputTokens += usage.inputTokens || 0;
75
+ daily.outputTokens += usage.outputTokens || 0;
76
+ daily.cacheRead += usage.cacheRead || 0;
77
+
78
+ _ensureFlushTimer();
79
+ }
80
+
81
+ function flushMetricsBuffer() {
82
+ const pending = _pendingMetrics;
83
+ if (!Object.keys(pending.engine).length && !Object.keys(pending.daily).length) return;
84
+ _pendingMetrics = { engine: Object.create(null), daily: Object.create(null) };
85
+
33
86
  try {
34
87
  const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
35
88
  mutateJsonFileLocked(metricsPath, (metrics) => {
36
89
  if (!metrics._engine) metrics._engine = {};
37
- if (!metrics._engine[category]) {
38
- metrics._engine[category] = { calls: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0 };
39
- }
40
- const cat = metrics._engine[category];
41
- cat.calls++;
42
- cat.costUsd += usage.costUsd || 0;
43
- cat.inputTokens += usage.inputTokens || 0;
44
- cat.outputTokens += usage.outputTokens || 0;
45
- cat.cacheRead += usage.cacheRead || 0;
46
- cat.cacheCreation = (cat.cacheCreation || 0) + (usage.cacheCreation || 0);
47
- if (usage.durationMs) {
48
- cat.totalDurationMs = (cat.totalDurationMs || 0) + usage.durationMs;
49
- cat.timedCalls = (cat.timedCalls || 0) + 1;
90
+ for (const [category, delta] of Object.entries(pending.engine)) {
91
+ if (!metrics._engine[category]) {
92
+ metrics._engine[category] = { calls: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreation: 0 };
93
+ }
94
+ const cat = metrics._engine[category];
95
+ cat.calls = (cat.calls || 0) + delta.calls;
96
+ cat.costUsd = (cat.costUsd || 0) + delta.costUsd;
97
+ cat.inputTokens = (cat.inputTokens || 0) + delta.inputTokens;
98
+ cat.outputTokens = (cat.outputTokens || 0) + delta.outputTokens;
99
+ cat.cacheRead = (cat.cacheRead || 0) + delta.cacheRead;
100
+ cat.cacheCreation = (cat.cacheCreation || 0) + delta.cacheCreation;
101
+ if (delta.timedCalls > 0) {
102
+ cat.totalDurationMs = (cat.totalDurationMs || 0) + delta.totalDurationMs;
103
+ cat.timedCalls = (cat.timedCalls || 0) + delta.timedCalls;
104
+ }
50
105
  }
51
-
52
- const today = ts().slice(0, 10);
53
106
  if (!metrics._daily) metrics._daily = {};
54
- if (!metrics._daily[today]) metrics._daily[today] = { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, tasks: 0 };
55
- const daily = metrics._daily[today];
56
- daily.costUsd += usage.costUsd || 0;
57
- daily.inputTokens += usage.inputTokens || 0;
58
- daily.outputTokens += usage.outputTokens || 0;
59
- daily.cacheRead += usage.cacheRead || 0;
60
-
107
+ for (const [day, delta] of Object.entries(pending.daily)) {
108
+ if (!metrics._daily[day]) {
109
+ metrics._daily[day] = { costUsd: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, tasks: 0 };
110
+ }
111
+ const d = metrics._daily[day];
112
+ d.costUsd += delta.costUsd;
113
+ d.inputTokens += delta.inputTokens;
114
+ d.outputTokens += delta.outputTokens;
115
+ d.cacheRead += delta.cacheRead;
116
+ }
61
117
  return metrics;
62
118
  });
63
- } catch (e) { console.error('metrics update:', e.message); }
119
+ } catch (e) {
120
+ // Re-merge pending so the next flush retries — never drop counters silently.
121
+ for (const [category, delta] of Object.entries(pending.engine)) {
122
+ if (!_pendingMetrics.engine[category]) _pendingMetrics.engine[category] = _emptyEngineDelta();
123
+ const c = _pendingMetrics.engine[category];
124
+ c.calls += delta.calls; c.costUsd += delta.costUsd;
125
+ c.inputTokens += delta.inputTokens; c.outputTokens += delta.outputTokens;
126
+ c.cacheRead += delta.cacheRead; c.cacheCreation += delta.cacheCreation;
127
+ c.totalDurationMs += delta.totalDurationMs; c.timedCalls += delta.timedCalls;
128
+ }
129
+ for (const [day, delta] of Object.entries(pending.daily)) {
130
+ if (!_pendingMetrics.daily[day]) _pendingMetrics.daily[day] = _emptyDailyDelta();
131
+ const d = _pendingMetrics.daily[day];
132
+ d.costUsd += delta.costUsd; d.inputTokens += delta.inputTokens;
133
+ d.outputTokens += delta.outputTokens; d.cacheRead += delta.cacheRead;
134
+ }
135
+ console.error('metrics flush:', e.message);
136
+ }
137
+ }
138
+
139
+ function _resetMetricsBufferForTest() {
140
+ _pendingMetrics = { engine: Object.create(null), daily: Object.create(null) };
141
+ if (_flushTimer) { clearInterval(_flushTimer); _flushTimer = null; }
64
142
  }
65
143
 
66
144
  // ─── Runtime Binary Resolution (TTL-cached) ──────────────────────────────────
@@ -83,7 +161,10 @@ function _resolveBin(runtime) {
83
161
  if (!runtime) return null;
84
162
  const key = runtime.name;
85
163
  const cached = _binCache.get(key);
86
- if (cached && Date.now() - cached.ts < _BIN_TTL && fs.existsSync(cached.bin)) {
164
+ // Trust the 30-min TTL skip per-call existsSync (10-50ms on Windows w/ AV).
165
+ // If the binary disappears mid-window, spawn fails with ENOENT and the
166
+ // adapter's resolveBinary() reprobes on the next cache miss.
167
+ if (cached && Date.now() - cached.ts < _BIN_TTL) {
87
168
  return { bin: cached.bin, native: cached.native, leadingArgs: cached.leadingArgs };
88
169
  }
89
170
  let resolved = null;
@@ -700,10 +781,12 @@ module.exports = {
700
781
  callLLM,
701
782
  callLLMStreaming,
702
783
  trackEngineUsage,
784
+ flushMetricsBuffer,
703
785
  // Exposed for unit tests — engine code MUST use the runtime adapter contract.
704
786
  _buildSpawnAgentFlags,
705
787
  _resolveBin,
706
788
  _resetBinCache,
789
+ _resetMetricsBufferForTest,
707
790
  _resolveRuntimeFor,
708
791
  _resolveModelFor,
709
792
  _resolveModelForRuntime,
package/engine/shared.js CHANGED
@@ -918,6 +918,7 @@ const ENGINE_DEFAULTS = {
918
918
  docSessionTtlMs: 7 * 24 * 60 * 60 * 1000, // 7d — longer-lived doc sessions, still bounded
919
919
  docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity
920
920
  ccLiveStreamMaxAgeMs: 30 * 60 * 1000, // hard cap reconnect buffers if abort/cleanup stalls
921
+ metricsFlushIntervalMs: 10000, // batch trackEngineUsage writes to metrics.json — flushed every 10s instead of per-call to cut lock contention and dashboard mtime churn
921
922
  maxLlmRawBytes: 256 * 1024, // keep only a bounded stdout tail from direct Claude calls
922
923
  maxLlmStderrBytes: 64 * 1024, // keep only a bounded stderr tail from direct Claude calls
923
924
  maxLlmLineBufferBytes: 128 * 1024, // cap the incremental JSON line buffer to avoid malformed-stream OOMs
package/engine.js CHANGED
@@ -4071,7 +4071,7 @@ async function tickInner() {
4071
4071
 
4072
4072
  // 2.5. Periodic cleanup + MCP sync (every 10 ticks = ~5 minutes)
4073
4073
  if (tickCount % 10 === 0) {
4074
- safe('runCleanup', () => runCleanup(config));
4074
+ try { await runCleanup(config); } catch (e) { log('warn', `runCleanup: ${e.message}`); }
4075
4075
  }
4076
4076
 
4077
4077
  // 2.55. Check persistent watches (3 tick-equivalents, default ~3 minutes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1735",
3
+ "version": "0.1.1737",
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"