@yemi33/minions 0.1.1034 → 0.1.1035

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,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1035 (2026-04-16)
4
+
5
+ ### Features
6
+ - Doc-chat performance — debounced persistence, session TTL, smart disk reads (#1164)
7
+ - split getStatus() into fast/slow state tiers with separate TTLs (#1170)
8
+ - Cache _countWorktrees() with 30s TTL (#1166)
9
+ - Pre-cache gzipped status buffer alongside JSON cache (#1171)
10
+ - parallelize ADO and GitHub PR polling with Promise.allSettled (#1172)
11
+ - route implement items to dedicated implement playbook (#1115)
12
+
13
+ ### Fixes
14
+ - sidecar oversized dispatch prompts to prevent dashboard OOM (closes #1167) (#1183)
15
+ - use locked write for failed-with-PR reconciliation in cleanup.js (#1169)
16
+ - prevent test from corrupting live meeting state
17
+ - scheduler enabled field falsy check treats undefined as disabled (#1160)
18
+ - update branch-dedup tests to use canonical PR IDs after merge with master (#1146)
19
+ - auto-review not firing for manually-linked PRs with autoObserve=true
20
+ - mark PR abandoned on 404 instead of silently retrying each tick
21
+ - replace undefined PROJECTS with config.projects in checkWatches (#1108)
22
+ - write permission for publish workflow
23
+ - run tests inline and post check runs for publish PRs
24
+ - add maxBuffer to all GitHub CLI spawns + paginate PR list (#1130)
25
+ - add required CI checks for PRs + update publish for auto-merge
26
+ - revert to PR merge now that stale status check is removed
27
+ - guard live review check against undefined vote/state values (#1132)
28
+ - push version bump directly to master instead of via PR
29
+ - add push-triggered CI for chore/publish branches
30
+ - publish workflow chore PRs failing to merge
31
+ - harden KB ordering
32
+ - harden audited state transitions
33
+
34
+ ### Other
35
+ - [E2E] Dashboard & engine perf: gzip cache, tiered status, lock backoff, polling parallelization (#1174)
36
+ - Fix doc chat session isolation
37
+ - test(pipeline): add unit tests for CRUD, stage execution, run lifecycle (#1162)
38
+ - chore: test publish after removing stale status check
39
+ - chore: trigger publish test
40
+ - chore: test publish workflow fix
41
+ - chore: trigger publish workflow test
42
+
3
43
  ## 0.1.1034 (2026-04-16)
4
44
 
5
45
  ### Features
package/dashboard.js CHANGED
@@ -32,6 +32,24 @@ const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
32
32
  getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
33
33
  MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, DISPATCH_PATH, PRD_DIR } = queries;
34
34
 
35
+ // Startup size guard (#1167): fail fast with a clear error when dispatch.json /
36
+ // cooldowns.json have ballooned past ENGINE_DEFAULTS.maxStateFileBytes. Without
37
+ // this, V8 silently OOMs on JSON.parse(~1 GB) and the operator has no hint as to
38
+ // which file is bloated. The thrown error names the file and directs to
39
+ // engine/contexts/ where sidecars live.
40
+ (() => {
41
+ const stateFiles = [
42
+ DISPATCH_PATH,
43
+ path.join(ENGINE_DIR, 'cooldowns.json'),
44
+ ];
45
+ for (const fp of stateFiles) {
46
+ try { shared.assertStateFileSize(fp); } catch (e) {
47
+ console.error('\n[dashboard] STARTUP ABORTED — ' + e.message + '\n');
48
+ process.exit(78); // 78 = configuration error; consistent with spawn-agent.js
49
+ }
50
+ }
51
+ })();
52
+
35
53
  const PORT = parseInt(process.env.PORT || process.argv[2]) || 7331;
36
54
  let CONFIG = queries.getConfig();
37
55
  let PROJECTS = _getProjects(CONFIG);
package/engine/cli.js CHANGED
@@ -51,6 +51,18 @@ function handleCommand(cmd, args) {
51
51
 
52
52
  const commands = {
53
53
  start() {
54
+ // Startup state-file size guard (#1167): dispatch.json / cooldowns.json
55
+ // bloated past 100 MB silently OOMed V8 on JSON.parse at startup. Fail fast
56
+ // with an actionable message instead.
57
+ try {
58
+ for (const fp of [DISPATCH_PATH, path.join(ENGINE_DIR, 'cooldowns.json')]) {
59
+ shared.assertStateFileSize(fp);
60
+ }
61
+ } catch (stateErr) {
62
+ console.error('\n[engine] STARTUP ABORTED — ' + stateErr.message + '\n');
63
+ process.exit(78); // 78 = configuration error
64
+ }
65
+
54
66
  // Run preflight checks (warn but don't block — engine may still be useful)
55
67
  try {
56
68
  const { runPreflight, printPreflight } = require('./preflight');
@@ -10,6 +10,31 @@ const queries = require('./queries');
10
10
  const { safeJson, safeWrite, log, ENGINE_DEFAULTS } = shared;
11
11
  const { ENGINE_DIR } = queries;
12
12
 
13
+ /**
14
+ * Truncate any string fields on a pendingContexts entry so a single huge PR
15
+ * comment / build log cannot bloat cooldowns.json to hundreds of MB (#1167).
16
+ * Returns a new entry object (does not mutate the caller's copy).
17
+ */
18
+ function _truncateContextEntry(entry, maxBytes) {
19
+ if (entry == null) return entry;
20
+ const limit = Number(maxBytes) > 0 ? Number(maxBytes) : ENGINE_DEFAULTS.maxPendingContextEntryBytes;
21
+ if (typeof entry === 'string') {
22
+ return Buffer.byteLength(entry, 'utf8') > limit
23
+ ? entry.slice(0, limit) + `\n\n... [truncated: context exceeded ${Math.round(limit / 1024)} KB]`
24
+ : entry;
25
+ }
26
+ if (typeof entry !== 'object') return entry;
27
+ const out = Array.isArray(entry) ? [] : {};
28
+ for (const [k, v] of Object.entries(entry)) {
29
+ if (typeof v === 'string' && Buffer.byteLength(v, 'utf8') > limit) {
30
+ out[k] = v.slice(0, limit) + `\n\n... [truncated: ${k} exceeded ${Math.round(limit / 1024)} KB]`;
31
+ } else {
32
+ out[k] = v;
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
13
38
  const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
14
39
  const dispatchCooldowns = new Map(); // key → { timestamp, failures }
15
40
 
@@ -39,9 +64,15 @@ function saveCooldowns() {
39
64
  }
40
65
  // Trim pendingContexts arrays before writing to prevent bloat
41
66
  const cap = ENGINE_DEFAULTS.maxPendingContexts;
67
+ const entryLimit = ENGINE_DEFAULTS.maxPendingContextEntryBytes;
42
68
  for (const [, v] of dispatchCooldowns) {
43
- if (Array.isArray(v.pendingContexts) && v.pendingContexts.length > cap) {
44
- v.pendingContexts = v.pendingContexts.slice(-cap);
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));
45
76
  }
46
77
  }
47
78
  const obj = Object.fromEntries(dispatchCooldowns);
@@ -69,7 +100,12 @@ function setCooldown(key) {
69
100
  function setCooldownWithContext(key, context) {
70
101
  const existing = dispatchCooldowns.get(key);
71
102
  let pendingContexts = existing?.pendingContexts || [];
72
- if (context) pendingContexts.push(context);
103
+ if (context) {
104
+ // Truncate oversized string fields per-entry BEFORE storing so a single
105
+ // huge payload (PR diff, build log, full repo dump) cannot bloat
106
+ // cooldowns.json regardless of array cap (#1167).
107
+ pendingContexts.push(_truncateContextEntry(context, ENGINE_DEFAULTS.maxPendingContextEntryBytes));
108
+ }
73
109
  // Cap to last N entries to prevent unbounded growth (cooldowns.json bloat)
74
110
  const cap = ENGINE_DEFAULTS.maxPendingContexts;
75
111
  if (pendingContexts.length > cap) pendingContexts = pendingContexts.slice(-cap);
@@ -11,6 +11,7 @@ const { setCooldownFailure } = require('./cooldown');
11
11
 
12
12
  const { safeJson, safeWrite, safeReadDir, mutateJsonFileLocked, mutateWorkItems,
13
13
  mutatePullRequests, getProjects, projectWorkItemsPath, projectPrPath, log, ts, dateStamp,
14
+ sidecarDispatchPrompt, deleteDispatchPromptSidecar,
14
15
  WI_STATUS, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS } = shared;
15
16
  const { getConfig, getDispatch, DISPATCH_PATH, INBOX_DIR } = queries;
16
17
 
@@ -24,13 +25,34 @@ function recovery() { if (!_recovery) _recovery = require('./recovery'); return
24
25
 
25
26
  // ─── Dispatch Mutation ───────────────────────────────────────────────────────
26
27
 
28
+ /**
29
+ * Sweep pending + active dispatch entries and move any oversized prompts to
30
+ * sidecar files. Keeps dispatch.json from bloating to hundreds of MB when
31
+ * fix-type prompts inline PR diffs / build logs / coalesced feedback (#1167).
32
+ * Safe to call on every mutation: small prompts are untouched.
33
+ */
34
+ function _sidecarOversizedPrompts(dispatch) {
35
+ const threshold = ENGINE_DEFAULTS.maxDispatchPromptBytes;
36
+ const lists = [dispatch.pending, dispatch.active];
37
+ for (const list of lists) {
38
+ if (!Array.isArray(list)) continue;
39
+ for (const item of list) {
40
+ if (item && typeof item.prompt === 'string') sidecarDispatchPrompt(item, threshold);
41
+ }
42
+ }
43
+ }
44
+
27
45
  function mutateDispatch(mutator) {
28
46
  const defaultDispatch = { pending: [], active: [], completed: [] };
29
47
  const result = mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
30
48
  dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
31
49
  dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
32
50
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
33
- return mutator(dispatch) ?? dispatch;
51
+ const next = mutator(dispatch) ?? dispatch;
52
+ // Prompt-size guard: runs on every write so a single bad item cannot bloat
53
+ // dispatch.json. Sidecars live in engine/contexts/<id>.md.
54
+ _sidecarOversizedPrompts(next);
55
+ return next;
34
56
  }, { defaultValue: defaultDispatch });
35
57
  // Invalidate the read cache so next getDispatch() sees fresh data
36
58
  try { require('./queries').invalidateDispatchCache(); } catch {}
@@ -116,7 +138,12 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
116
138
  if (reason) item.reason = reason;
117
139
  if (resultSummary) item.resultSummary = resultSummary;
118
140
  if (failureClass && result === DISPATCH_RESULT.ERROR) item.failureClass = failureClass;
141
+ // Drop prompt (and sidecar file, if any) — completed entries don't need
142
+ // replayable content and it would accumulate forever (#1167).
143
+ try { deleteDispatchPromptSidecar(item); } catch { /* best-effort */ }
119
144
  delete item.prompt;
145
+ delete item._promptFile;
146
+ delete item._promptBytes;
120
147
  if (dispatch.completed.length >= 100) {
121
148
  dispatch.completed = dispatch.completed.slice(-100);
122
149
  }
package/engine/shared.js CHANGED
@@ -162,6 +162,118 @@ function safeUnlink(p) {
162
162
  try { fs.unlinkSync(p); } catch { /* cleanup */ }
163
163
  }
164
164
 
165
+ // ── Dispatch Prompt Sidecar (#1167) ─────────────────────────────────────────
166
+ // Large prompts (PR diffs, build error logs, coalesced human feedback) inlined
167
+ // into dispatch.json caused hundreds-of-MB bloat per entry and eventual V8 OOM
168
+ // at startup. Sidecar files keep dispatch.json small while preserving full
169
+ // content for the agent at spawn time.
170
+
171
+ // Resolve lazily so MINIONS_TEST_DIR overrides work in tests.
172
+ function _promptContextsDir() {
173
+ return path.join(MINIONS_DIR, 'engine', 'contexts');
174
+ }
175
+ // Keep the constant for callers that expect a stable export; callers that need
176
+ // the current value (tests) should call _promptContextsDir().
177
+ const PROMPT_CONTEXTS_DIR = _promptContextsDir();
178
+
179
+ /** Absolute path to the sidecar prompt file for a given dispatch id. */
180
+ function dispatchPromptSidecarPath(dispatchId) {
181
+ if (!dispatchId) return null;
182
+ const safeId = String(dispatchId).replace(/[^a-zA-Z0-9._-]/g, '-');
183
+ return path.join(_promptContextsDir(), `${safeId}.md`);
184
+ }
185
+
186
+ /**
187
+ * If the dispatch item's prompt exceeds thresholdBytes, write the full prompt
188
+ * to engine/contexts/<id>.md and replace `item.prompt` with a short stub
189
+ * + `_promptFile` reference. Mutates item in place and returns true when
190
+ * sidecaring happened, false otherwise.
191
+ */
192
+ function sidecarDispatchPrompt(item, thresholdBytes) {
193
+ if (!item || typeof item.prompt !== 'string') return false;
194
+ const threshold = Number(thresholdBytes) > 0
195
+ ? Number(thresholdBytes)
196
+ : ENGINE_DEFAULTS.maxDispatchPromptBytes;
197
+ const byteLen = Buffer.byteLength(item.prompt, 'utf8');
198
+ if (byteLen <= threshold) return false;
199
+ if (!item.id) return false; // can't sidecar without a stable id
200
+ try {
201
+ const ctxDir = _promptContextsDir();
202
+ if (!fs.existsSync(ctxDir)) fs.mkdirSync(ctxDir, { recursive: true });
203
+ const sidecar = dispatchPromptSidecarPath(item.id);
204
+ safeWrite(sidecar, item.prompt);
205
+ const relPath = path.relative(MINIONS_DIR, sidecar).replace(/\\/g, '/');
206
+ item._promptFile = relPath;
207
+ item._promptBytes = byteLen;
208
+ item.prompt = `[Prompt sidecarred to ${relPath} — ${Math.round(byteLen / 1024)} KB. The engine reads the sidecar when spawning this agent.]`;
209
+ try { log('warn', `Sidecarred oversized dispatch prompt: ${item.id} (${Math.round(byteLen / 1024)} KB → ${relPath})`); } catch { /* logger may not be ready */ }
210
+ return true;
211
+ } catch (e) {
212
+ try { log('warn', `sidecarDispatchPrompt failed for ${item.id}: ${e.message}`); } catch { /* cleanup */ }
213
+ return false;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Read the effective prompt for a dispatch item. Prefers the sidecar file when
219
+ * `_promptFile` is set so spawnAgent always sees the full prompt even though
220
+ * dispatch.json only stores a small stub.
221
+ */
222
+ function resolveDispatchPrompt(item) {
223
+ if (!item) return '';
224
+ if (item._promptFile) {
225
+ const candidates = [
226
+ path.isAbsolute(item._promptFile) ? item._promptFile : path.resolve(MINIONS_DIR, item._promptFile),
227
+ dispatchPromptSidecarPath(item.id),
228
+ ].filter(Boolean);
229
+ for (const c of candidates) {
230
+ try {
231
+ const content = fs.readFileSync(c, 'utf8');
232
+ if (content) return content;
233
+ } catch { /* try next candidate */ }
234
+ }
235
+ }
236
+ return item.prompt || '';
237
+ }
238
+
239
+ /** Remove the sidecar prompt file for a completed/cancelled dispatch. */
240
+ function deleteDispatchPromptSidecar(item) {
241
+ if (!item) return;
242
+ const paths = new Set();
243
+ if (item._promptFile) {
244
+ paths.add(path.isAbsolute(item._promptFile) ? item._promptFile : path.resolve(MINIONS_DIR, item._promptFile));
245
+ }
246
+ const idPath = dispatchPromptSidecarPath(item.id);
247
+ if (idPath) paths.add(idPath);
248
+ for (const p of paths) safeUnlink(p);
249
+ }
250
+
251
+ /**
252
+ * Startup guard: throw a clear error when a state file has grown past
253
+ * maxStateFileBytes. Without this the dashboard silently OOMs on JSON.parse
254
+ * (seen on a 491 MB dispatch.json + 509 MB cooldowns.json — #1167).
255
+ * The thrown error points at the bloated file so operators can act instead
256
+ * of chasing V8 heap traces.
257
+ */
258
+ function assertStateFileSize(filePath, maxBytes) {
259
+ const limit = Number(maxBytes) > 0 ? Number(maxBytes) : ENGINE_DEFAULTS.maxStateFileBytes;
260
+ try {
261
+ const stat = fs.statSync(filePath);
262
+ if (stat.size > limit) {
263
+ throw new Error(
264
+ `State file too large: ${filePath} is ${Math.round(stat.size / (1024 * 1024))} MB ` +
265
+ `(limit ${Math.round(limit / (1024 * 1024))} MB). ` +
266
+ `This usually means dispatch prompts or cooldown contexts were inlined and not sidecarred. ` +
267
+ `Inspect/trim the file manually, then restart. See engine/contexts/ for sidecar files.`
268
+ );
269
+ }
270
+ } catch (e) {
271
+ if (e.code === 'ENOENT') return; // file absent is fine
272
+ if (e.message && e.message.startsWith('State file too large:')) throw e;
273
+ // Other stat errors (permission etc.) — do not block startup
274
+ }
275
+ }
276
+
165
277
  function sleepMs(ms) {
166
278
  try {
167
279
  const ab = new SharedArrayBuffer(4);
@@ -571,6 +683,9 @@ const ENGINE_DEFAULTS = {
571
683
  ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
572
684
  heartbeatTimeouts: {}, // populated after WORK_TYPE is defined (below)
573
685
  maxPendingContexts: 20, // cap pendingContexts arrays in cooldowns.json to prevent unbounded growth
686
+ maxPendingContextEntryBytes: 256 * 1024, // 256 KB — cap each pendingContexts entry to prevent huge PR comments from bloating cooldowns.json
687
+ maxDispatchPromptBytes: 1024 * 1024, // 1 MB — dispatch items with prompts larger than this sidecar to engine/contexts/ to prevent dispatch.json OOM (#1167)
688
+ maxStateFileBytes: 100 * 1024 * 1024, // 100 MB — fail startup with a clear error when dispatch.json / cooldowns.json exceed this, rather than silently OOMing on JSON.parse (#1167)
574
689
  ccMaxTurns: 50, // max tool-use turns for CC/doc-chat before CLI stops
575
690
  // Teams integration — config.teams shape: { enabled, appId, appPassword, certPath, privateKeyPath, tenantId, notifyEvents, ccMirror, inboxPollInterval }
576
691
  // Auth modes: (1) appId + appPassword (client secret), or (2) appId + certPath + privateKeyPath + tenantId (certificate)
@@ -1399,6 +1514,12 @@ module.exports = {
1399
1514
  safeJson, safeJsonObj, safeJsonArr,
1400
1515
  safeWrite,
1401
1516
  safeUnlink,
1517
+ PROMPT_CONTEXTS_DIR,
1518
+ dispatchPromptSidecarPath,
1519
+ sidecarDispatchPrompt,
1520
+ resolveDispatchPrompt,
1521
+ deleteDispatchPromptSidecar,
1522
+ assertStateFileSize,
1402
1523
  withFileLock,
1403
1524
  mutateJsonFileLocked,
1404
1525
  mutateWorkItems,
package/engine.js CHANGED
@@ -355,7 +355,11 @@ async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts
355
355
  }
356
356
 
357
357
  async function spawnAgent(dispatchItem, config) {
358
- const { id, agent: agentId, prompt: taskPrompt, type, meta } = dispatchItem;
358
+ const { id, agent: agentId, type, meta } = dispatchItem;
359
+ // Resolve prompt — prefers sidecar file when dispatchItem._promptFile is set
360
+ // (large prompts are written to engine/contexts/<id>.md to keep dispatch.json
361
+ // small — see shared.sidecarDispatchPrompt / #1167).
362
+ const taskPrompt = shared.resolveDispatchPrompt(dispatchItem);
359
363
  const claudeConfig = config.claude || {};
360
364
  const engineConfig = config.engine || {};
361
365
  const startedAt = ts();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1034",
3
+ "version": "0.1.1035",
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"