@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 +46 -0
- package/engine/queries.js +44 -15
- package/package.json +1 -1
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
|
-
//
|
|
1688
|
-
//
|
|
1689
|
-
//
|
|
1690
|
-
//
|
|
1691
|
-
//
|
|
1692
|
-
//
|
|
1693
|
-
//
|
|
1694
|
-
// the scan
|
|
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
|
|
1702
|
-
|
|
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.
|
|
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"
|