@yemi33/minions 0.1.2251 → 0.1.2252

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/dashboard.js CHANGED
@@ -299,6 +299,51 @@ try {
299
299
  });
300
300
  } catch { /* defensive — wiring is best-effort, queries probes still work without it */ }
301
301
 
302
+ // opg-microsoft/minions#381 — watch each project's .git/HEAD with fs.watch so
303
+ // a bare `git checkout` / `git switch` outside the dashboard immediately busts
304
+ // the status cache rather than waiting for the next 4-second SPA poll to detect
305
+ // the mtime change. When HEAD changes the watcher calls invalidateStatusCache()
306
+ // which (a) clears _statusCache and (b) debounce-pushes the updated state to
307
+ // connected SSE clients within ~500ms — zero polling lag for SSE consumers.
308
+ //
309
+ // The mtime-based polling in getStatusSlowStateMtimePaths + _projectGitRefFiles
310
+ // (queries.js) remains as a belt-and-suspenders fallback for environments where
311
+ // fs.watch is unreliable (network drives, some WSL mounts).
312
+ const _projectHeadWatchers = new Map(); // localPath → fs.FSWatcher
313
+
314
+ function _setupProjectHeadWatchers() {
315
+ const currentPaths = new Set(PROJECTS.filter(p => p && p.localPath).map(p => p.localPath));
316
+ // Close watchers for projects that are no longer configured.
317
+ for (const [localPath, watcher] of _projectHeadWatchers) {
318
+ if (!currentPaths.has(localPath)) {
319
+ try { watcher.close(); } catch { /* ignore close errors */ }
320
+ _projectHeadWatchers.delete(localPath);
321
+ }
322
+ }
323
+ // Add watchers for newly-configured projects.
324
+ for (const project of PROJECTS) {
325
+ if (!project || !project.localPath || _projectHeadWatchers.has(project.localPath)) continue;
326
+ let headPath = null;
327
+ try {
328
+ const resolvedGitDir = queries._resolveGitDir(project.localPath);
329
+ if (resolvedGitDir) headPath = path.join(resolvedGitDir, 'HEAD');
330
+ } catch { /* _resolveGitDir is best-effort */ }
331
+ if (!headPath) continue;
332
+ try {
333
+ const watcher = fs.watch(headPath, () => {
334
+ try { invalidateStatusCache(); } catch { /* best-effort */ }
335
+ });
336
+ watcher.on('error', () => {
337
+ // Silently discard watch errors (e.g. path deleted) — polling fallback handles it.
338
+ try { watcher.close(); } catch { /* ignore */ }
339
+ _projectHeadWatchers.delete(project.localPath);
340
+ });
341
+ _projectHeadWatchers.set(project.localPath, watcher);
342
+ } catch { /* fs.watch not available on this fs (network drive etc.) — polling covers it */ }
343
+ }
344
+ }
345
+ try { _setupProjectHeadWatchers(); } catch { /* best-effort */ }
346
+
302
347
  function resolveScheduleProjectValue(project, projects = PROJECTS) {
303
348
  if (project === undefined) return { project: undefined };
304
349
  const target = shared.resolveConfiguredProject(project, projects);
@@ -9042,6 +9087,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9042
9087
 
9043
9088
  reloadConfig(); // Update in-memory project list immediately
9044
9089
  warmProjectGitStatusCache(); // Probe the new project's git status in the background
9090
+ try { _setupProjectHeadWatchers(); } catch { /* best-effort */ } // opg#381: watch new project's HEAD
9045
9091
  // includeSlow: PROJECTS lives in the slow-state cache (60s TTL); without
9046
9092
  // flushing it, /api/status keeps returning the previous project list for
9047
9093
  // up to a minute after the add. Matches handleProjectsRemove's behavior.
package/engine/queries.js CHANGED
@@ -1658,6 +1658,11 @@ let _kbCache = null; // last good snapshot — never nulled by invalidate
1658
1658
  let _kbCacheTs = 0;
1659
1659
  let _kbCacheStale = true; // invalidate marks stale; snapshot kept for sync readers
1660
1660
  let _kbRefreshPromise = null; // in-flight scan dedupe
1661
+ // Incremental-scan cache: filePath -> { mtimeMs, byteSize, entry }. Lets a
1662
+ // rescan reuse the derived entry for any file whose mtime+size are unchanged,
1663
+ // so the expensive readFile+parse only runs for genuinely-changed files. Keyed
1664
+ // off the live stat, so in-place edits (agent memory appends) are still caught.
1665
+ let _kbFileMetaCache = new Map();
1661
1666
  const KB_CACHE_TTL = 30000; // 30s — KB changes infrequently
1662
1667
 
1663
1668
  function invalidateKnowledgeBaseCache() {
@@ -1684,32 +1689,28 @@ async function _scanKnowledgeBase() {
1684
1689
  // ~30 large KB entries. `_flat()` materialises a fresh flat string via
1685
1690
  // Buffer round-trip so the cached entry no longer pins the parent file.
1686
1691
  const _flat = (s) => Buffer.from(String(s || ''), 'utf8').toString('utf8');
1687
- // Build the full (category, file) work list first, THEN process it with a
1688
- // bounded concurrency window. The KB tree holds thousands of files; the old
1689
- // unbounded `Promise.all(files.map(...))` per category fired thousands of
1690
- // concurrent readFile+stat ops at once, flooding the (default-4, now 16)
1691
- // libuv thread pool and starving every other fs-dependent dashboard request
1692
- // including the /api/status poll for tens of seconds, which is what
1693
- // tripped the "stale / unreachable" banner. Capping in-flight reads keeps
1694
- // the scan from monopolizing the pool. (Load-reduction audit 2026-06-24.)
1692
+ // Incremental, bounded-concurrency scan. The KB tree holds thousands of files;
1693
+ // re-reading every file's full content on each cache rebuild was the dominant
1694
+ // disk load behind dashboard staleness (the readFile + parse + string-alloc of
1695
+ // ~3.5k files, repeated every TTL expiry, on a disk already contended by live
1696
+ // agents). Two defenses: (1) a per-file metadata cache keyed on mtime+size, so
1697
+ // a rescan only re-reads files that actually changed steady state is one
1698
+ // light stat() per file and ~zero readFile; (2) a bounded in-flight window so
1699
+ // the scan can't flood the libuv thread pool and starve /api/status (which
1700
+ // trips the "stale" banner). (Load-reduction audit 2026-06-24.)
1695
1701
  const work = [];
1696
1702
  for (const cat of KB_CATEGORIES) {
1697
1703
  const catDir = path.join(KNOWLEDGE_DIR, cat);
1698
1704
  const files = (await fsp.readdir(catDir).catch(() => [])).filter(f => f.endsWith('.md'));
1699
1705
  for (const f of files) work.push({ cat, catDir, f });
1700
1706
  }
1701
- const scanOne = async ({ cat, catDir, f }) => {
1702
- const filePath = path.join(catDir, f);
1703
- const [content, stat] = await Promise.all([
1704
- fsp.readFile(filePath, 'utf8').catch(() => ''),
1705
- fsp.stat(filePath).catch(() => null),
1706
- ]);
1707
+ const nextMetaCache = new Map();
1708
+ const deriveEntry = (cat, f, content, sortTs) => {
1707
1709
  const titleMatch = content.match(/^#\s+(.+)/m);
1708
1710
  const title = _flat(titleMatch ? titleMatch[1].trim() : f.replace(/\.md$/, ''));
1709
1711
  const agentMatch = f.match(/^\d{4}-\d{2}-\d{2}-(\w+)-/);
1710
1712
  const dateMatch = f.match(/^(\d{4}-\d{2}-\d{2})/) || content.match(/^date:\s*(\d{4}-\d{2}-\d{2})$/m);
1711
1713
  const sourceMatch = content.match(/^source:\s*(.+)/m);
1712
- const sortTs = (stat && stat.mtimeMs) || 0;
1713
1714
  const displayDate = dateMatch ? _flat(dateMatch[1]) : (sortTs ? new Date(sortTs).toISOString().slice(0, 10) : '');
1714
1715
  return {
1715
1716
  cat, file: f, title,
@@ -1721,6 +1722,22 @@ async function _scanKnowledgeBase() {
1721
1722
  size: content.length,
1722
1723
  };
1723
1724
  };
1725
+ const scanOne = async ({ cat, catDir, f }) => {
1726
+ const filePath = path.join(catDir, f);
1727
+ const stat = await fsp.stat(filePath).catch(() => null);
1728
+ const sortTs = (stat && stat.mtimeMs) || 0;
1729
+ // Reuse the cached derived entry when mtime+size are unchanged — skips the
1730
+ // readFile + regex parse + string allocations entirely for stable files.
1731
+ const cached = _kbFileMetaCache.get(filePath);
1732
+ if (cached && stat && cached.mtimeMs === stat.mtimeMs && cached.byteSize === stat.size) {
1733
+ nextMetaCache.set(filePath, cached);
1734
+ return cached.entry;
1735
+ }
1736
+ const content = await fsp.readFile(filePath, 'utf8').catch(() => '');
1737
+ const entry = deriveEntry(cat, f, content, sortTs);
1738
+ nextMetaCache.set(filePath, { mtimeMs: sortTs, byteSize: stat ? stat.size : Buffer.byteLength(content), entry });
1739
+ return entry;
1740
+ };
1724
1741
  const entries = [];
1725
1742
  const KB_SCAN_CONCURRENCY = 16;
1726
1743
  let _next = 0;
@@ -1731,6 +1748,8 @@ async function _scanKnowledgeBase() {
1731
1748
  }
1732
1749
  };
1733
1750
  await Promise.all(Array.from({ length: Math.min(KB_SCAN_CONCURRENCY, work.length) }, worker));
1751
+ // Swap in the rebuilt cache (drops metadata for files that were deleted).
1752
+ _kbFileMetaCache = nextMetaCache;
1734
1753
  entries.sort((a, b) =>
1735
1754
  (b.sortTs || 0) - (a.sortTs || 0) ||
1736
1755
  (b.date || '').localeCompare(a.date || '') ||
@@ -2758,6 +2777,11 @@ function _projectGitRefFiles(localPath, configuredMainBranch) {
2758
2777
  if (!gitDir) return null;
2759
2778
  const commonGitDir = _resolveCommonGitDir(gitDir);
2760
2779
  const files = [
2780
+ // opg-microsoft/minions#381 — track HEAD directly so a bare `git checkout`
2781
+ // (which rewrites .git/HEAD but may not advance logs/HEAD when reflog is
2782
+ // disabled or when the snapshot already reflects the post-checkout mtime)
2783
+ // still triggers refsAdvanced=true and the gitStale marker on the next call.
2784
+ path.join(gitDir, 'HEAD'),
2761
2785
  path.join(gitDir, 'logs', 'HEAD'),
2762
2786
  path.join(commonGitDir, 'FETCH_HEAD'),
2763
2787
  ];
@@ -3149,10 +3173,14 @@ function getStatusSlowStateMtimePaths(config) {
3149
3173
  // not into the per-worktree subdir. For the main worktree both
3150
3174
  // resolvers return the same gitdir, so the behavior collapses to the
3151
3175
  // expected `<localPath>/.git/{logs/HEAD,FETCH_HEAD}` pair.
3176
+ // opg-microsoft/minions#381 — also track HEAD itself so `git checkout`
3177
+ // (which always rewrites HEAD) busts the cache even when logs/HEAD is
3178
+ // absent or was already captured at the post-checkout mtime.
3152
3179
  for (const project of projects) {
3153
3180
  if (!project || !project.localPath) continue;
3154
3181
  const gitDir = _resolveGitDir(project.localPath) || path.join(project.localPath, '.git');
3155
3182
  const commonGitDir = _resolveCommonGitDir(gitDir);
3183
+ files.push(path.join(gitDir, 'HEAD'));
3156
3184
  files.push(path.join(gitDir, 'logs', 'HEAD'));
3157
3185
  files.push(path.join(commonGitDir, 'FETCH_HEAD'));
3158
3186
  }
@@ -3183,6 +3211,7 @@ module.exports = {
3183
3211
  _setProbeWatchdogForTest,
3184
3212
  _getProbeTimeoutsForTest,
3185
3213
  _getProjectGitStatusCacheForTest,
3214
+ _resolveGitDir, // opg-microsoft/minions#381 — dashboard.js uses this to locate HEAD for fs.watch
3186
3215
  // W-mpftp7na000td0f4 — engine→dashboard cache-invalidation registry
3187
3216
  getStatusFastStateMtimePaths,
3188
3217
  getStatusSlowStateMtimePaths,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2251",
3
+ "version": "0.1.2252",
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"