@yemi33/minions 0.1.477 → 0.1.479

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,8 +1,10 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.477 (2026-04-07)
3
+ ## 0.1.479 (2026-04-07)
4
4
 
5
5
  ### Features
6
+ - Buffer log() writes to reduce lock contention
7
+ - Cache getStatus() JSON serialization and add mtime-based invalidation
6
8
  - Add mtime-based caching to getPrdInfo()
7
9
  - Optimize getAgentStatus() to read only head+tail of live-output.log
8
10
  - Fix unlocked metrics.json in lifecycle.js post-merge hook
package/dashboard.js CHANGED
@@ -264,20 +264,59 @@ function parsePinnedEntries(content) {
264
264
  }
265
265
 
266
266
  let _statusCache = null;
267
+ let _statusCacheJson = null; // cached JSON.stringify(_statusCache) — avoids double-serialization for SSE
267
268
  let _statusCacheTs = 0;
268
269
  const STATUS_CACHE_TTL = 10000; // 10s — reduces expensive aggregation frequency; mutations call invalidateStatusCache()
269
270
  const _statusStreamClients = new Set();
270
271
  let _statusPushTimer = null;
271
272
  let _lastStatusHash = '';
272
273
 
274
+ // mtime-based cache invalidation — skip full rebuild if no tracked files changed
275
+ const _mtimeTrackedFiles = () => {
276
+ const files = [
277
+ path.join(ENGINE_DIR, 'dispatch.json'),
278
+ path.join(ENGINE_DIR, 'control.json'),
279
+ path.join(ENGINE_DIR, 'log.json'),
280
+ path.join(ENGINE_DIR, 'metrics.json'),
281
+ ];
282
+ // Add per-project work-items.json
283
+ for (const p of PROJECTS) {
284
+ if (p.localPath) files.push(path.join(p.localPath, '.minions', 'work-items.json'));
285
+ }
286
+ // Central work-items.json
287
+ files.push(path.join(MINIONS_DIR, 'work-items.json'));
288
+ return files;
289
+ };
290
+ let _lastMtimes = {}; // { filePath: mtimeMs }
291
+
292
+ function _getMtimes() {
293
+ const result = {};
294
+ for (const fp of _mtimeTrackedFiles()) {
295
+ try { result[fp] = fs.statSync(fp).mtimeMs; } catch { result[fp] = 0; }
296
+ }
297
+ return result;
298
+ }
299
+
300
+ function _mtimesChanged(prev, curr) {
301
+ for (const fp of Object.keys(curr)) {
302
+ if (prev[fp] !== curr[fp]) return true;
303
+ }
304
+ // Also check if keys differ (new files appeared)
305
+ for (const fp of Object.keys(prev)) {
306
+ if (!(fp in curr)) return true;
307
+ }
308
+ return false;
309
+ }
310
+
273
311
  function invalidateStatusCache() {
274
312
  _statusCache = null;
313
+ _statusCacheJson = null;
275
314
  // Push to SSE clients (debounced 500ms to avoid flooding during batch mutations)
276
315
  if (_statusPushTimer) return;
277
316
  _statusPushTimer = setTimeout(() => {
278
317
  _statusPushTimer = null;
279
318
  if (_statusStreamClients.size === 0) return;
280
- const data = JSON.stringify(getStatus());
319
+ const data = getStatusJson();
281
320
  for (const res of _statusStreamClients) {
282
321
  try { res.write('data: ' + data + '\n\n'); } catch { _statusStreamClients.delete(res); }
283
322
  }
@@ -286,7 +325,11 @@ function invalidateStatusCache() {
286
325
 
287
326
  function getStatus() {
288
327
  const now = Date.now();
289
- if (_statusCache && (now - _statusCacheTs) < STATUS_CACHE_TTL) return _statusCache;
328
+ if (_statusCache && (now - _statusCacheTs) < STATUS_CACHE_TTL) {
329
+ // Within TTL — check mtimes for early return (skip full rebuild if nothing changed)
330
+ const currMtimes = _getMtimes();
331
+ if (!_mtimesChanged(_lastMtimes, currMtimes)) return _statusCache;
332
+ }
290
333
 
291
334
  // Reload config on each cache miss — picks up external changes (minions init, minions add)
292
335
  reloadConfig();
@@ -352,17 +395,27 @@ function getStatus() {
352
395
  timestamp: new Date().toISOString(),
353
396
  };
354
397
  _statusCacheTs = now;
398
+ _statusCacheJson = null; // invalidate cached JSON — will be lazily rebuilt by getStatusJson()
399
+ _lastMtimes = _getMtimes();
355
400
  return _statusCache;
356
401
  }
357
402
 
403
+ /** Return cached JSON string of status — single stringify, reused by SSE and /api/status */
404
+ function getStatusJson() {
405
+ getStatus(); // ensure _statusCache is fresh
406
+ if (!_statusCacheJson) {
407
+ _statusCacheJson = JSON.stringify(_statusCache);
408
+ }
409
+ return _statusCacheJson;
410
+ }
411
+
358
412
  // Periodic push for engine-driven changes (dispatch.json, control.json) that bypass invalidateStatusCache
359
413
  setInterval(() => {
360
414
  if (_statusStreamClients.size === 0) return;
361
- const status = getStatus();
362
- const hash = require('crypto').createHash('md5').update(JSON.stringify(status)).digest('hex');
415
+ const data = getStatusJson();
416
+ const hash = require('crypto').createHash('md5').update(data).digest('hex');
363
417
  if (hash === _lastStatusHash) return;
364
418
  _lastStatusHash = hash;
365
- const data = JSON.stringify(status);
366
419
  for (const res of _statusStreamClients) {
367
420
  try { res.write('data: ' + data + '\n\n'); } catch { _statusStreamClients.delete(res); }
368
421
  }
@@ -3500,7 +3553,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3500
3553
 
3501
3554
  async function handleStatus(req, res) {
3502
3555
  try {
3503
- return jsonReply(res, 200, getStatus(), req);
3556
+ // Use pre-serialized JSON to avoid double-stringify in jsonReply
3557
+ const json = getStatusJson();
3558
+ res.setHeader('Content-Type', 'application/json');
3559
+ res.setHeader('Access-Control-Allow-Origin', '*');
3560
+ res.statusCode = 200;
3561
+ const ae = req && req.headers && req.headers['accept-encoding'] || '';
3562
+ if (ae.includes('gzip') && json.length > 1024) {
3563
+ res.setHeader('Content-Encoding', 'gzip');
3564
+ res.end(zlib.gzipSync(json));
3565
+ } else {
3566
+ res.end(json);
3567
+ }
3504
3568
  } catch (e) {
3505
3569
  return jsonReply(res, 500, { error: e.message }, req);
3506
3570
  }
@@ -3548,7 +3612,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3548
3612
  { method: 'GET', path: '/api/status', desc: 'Full dashboard status snapshot (agents, PRDs, work items, dispatch, etc.)', handler: handleStatus },
3549
3613
  { method: 'GET', path: '/api/status-stream', desc: 'SSE stream of real-time status updates', handler: (req, res) => {
3550
3614
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
3551
- res.write('data: ' + JSON.stringify(getStatus()) + '\n\n');
3615
+ res.write('data: ' + getStatusJson() + '\n\n');
3552
3616
  _statusStreamClients.add(res);
3553
3617
  req.on('close', () => _statusStreamClients.delete(res));
3554
3618
  }},
package/engine/cli.js CHANGED
@@ -391,6 +391,7 @@ const commands = {
391
391
  if (e.activeProcesses.size === 0) {
392
392
  safeWrite(CONTROL_PATH, { state: 'stopped', stopped_at: e.ts() });
393
393
  e.log('info', 'Graceful shutdown complete (no active agents)');
394
+ shared.flushLogs(); // drain buffered log entries before exit
394
395
  console.log('No active agents — stopped.');
395
396
  process.exit(0);
396
397
  }
@@ -404,6 +405,7 @@ const commands = {
404
405
  clearInterval(poll);
405
406
  safeWrite(CONTROL_PATH, { state: 'stopped', stopped_at: e.ts() });
406
407
  e.log('info', 'Graceful shutdown complete (all agents finished)');
408
+ shared.flushLogs(); // drain buffered log entries before exit
407
409
  console.log('All agents finished — stopped.');
408
410
  process.exit(0);
409
411
  }
@@ -411,6 +413,7 @@ const commands = {
411
413
  clearInterval(poll);
412
414
  safeWrite(CONTROL_PATH, { state: 'stopped', stopped_at: e.ts() });
413
415
  e.log('warn', `Graceful shutdown timed out after ${timeout / 1000}s with ${e.activeProcesses.size} agent(s) still active`);
416
+ shared.flushLogs(); // drain buffered log entries before exit
414
417
  console.log(`Shutdown timeout (${timeout / 1000}s) — force exiting with ${e.activeProcesses.size} agent(s) still running.`);
415
418
  process.exit(1);
416
419
  }
package/engine/shared.js CHANGED
@@ -18,20 +18,56 @@ function ts() { return new Date().toISOString(); }
18
18
  function logTs() { return new Date().toLocaleTimeString(); }
19
19
  function dateStamp() { return new Date().toISOString().slice(0, 10); }
20
20
 
21
+ // ── Log Buffering ──────────────────────────────────────────────────────────
22
+ // Buffer log entries in memory and flush to disk periodically to reduce lock
23
+ // contention (~139 calls/tick → 1 lock acquisition per flush).
24
+ const _logBuffer = [];
25
+ let _logFlushTimer = null;
26
+
21
27
  function log(level, msg, meta = {}) {
22
28
  const entry = { timestamp: ts(), level, message: msg, ...meta };
29
+ // Console output remains immediate
23
30
  console.log(`[${logTs()}] [${level}] ${msg}`);
24
31
 
32
+ _logBuffer.push(entry);
33
+
34
+ // Start the flush timer lazily on first buffered entry
35
+ if (!_logFlushTimer) {
36
+ _logFlushTimer = setInterval(() => {
37
+ _flushLogBuffer();
38
+ }, ENGINE_DEFAULTS.logFlushInterval);
39
+ // Unref so the timer doesn't keep the process alive during shutdown
40
+ if (_logFlushTimer.unref) _logFlushTimer.unref();
41
+ }
42
+
43
+ // Flush immediately when buffer exceeds threshold
44
+ if (_logBuffer.length >= ENGINE_DEFAULTS.logBufferSize) {
45
+ _flushLogBuffer();
46
+ }
47
+ }
48
+
49
+ function _flushLogBuffer() {
50
+ if (_logBuffer.length === 0) return;
51
+ const entries = _logBuffer.splice(0);
25
52
  try {
26
53
  mutateJsonFileLocked(LOG_PATH, (logData) => {
27
54
  if (!Array.isArray(logData)) logData = logData?.entries || [];
28
- logData.push(entry);
55
+ logData.push(...entries);
29
56
  if (logData.length >= 2500) logData.splice(0, logData.length - 2000);
30
57
  return logData;
31
58
  }, { defaultValue: [] });
32
59
  } catch { /* logging should never crash the caller */ }
33
60
  }
34
61
 
62
+ /** Flush buffered log entries to disk. Call during graceful shutdown to drain the buffer. */
63
+ function flushLogs() {
64
+ _flushLogBuffer();
65
+ if (_logFlushTimer) {
66
+ clearInterval(_logFlushTimer);
67
+ _logFlushTimer = null;
68
+ }
69
+ }
70
+
35
71
  // ── File I/O ─────────────────────────────────────────────────────────────────
36
72
 
37
73
  function safeRead(p) {
@@ -441,6 +477,8 @@ const ENGINE_DEFAULTS = {
441
477
  pipelineApiRetries: 2, // max attempts for pipeline API calls
442
478
  pipelineApiRetryDelay: 2000, // ms delay between pipeline API retries
443
479
  versionCheckInterval: 3600000, // 1 hour — how often to check npm for updates (ms)
480
+ logFlushInterval: 5000, // 5s — how often to flush buffered log entries to disk
481
+ logBufferSize: 50, // flush immediately when buffer exceeds this many entries
444
482
  };
445
483
 
446
484
  // ─── Status & Type Constants ─────────────────────────────────────────────────
@@ -729,5 +767,7 @@ module.exports = {
729
767
  killGracefully,
730
768
  killImmediate,
731
769
  LOCK_STALE_MS,
770
+ flushLogs,
771
+ _logBuffer, // exported for testing
732
772
  };
733
773
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.477",
3
+ "version": "0.1.479",
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"