@yemi33/minions 0.1.2057 → 0.1.2059
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/refresh.js +8 -1
- package/dashboard/js/render-agents.js +0 -1
- package/dashboard/js/render-other.js +1 -1
- package/dashboard/js/render-plans.js +0 -2
- package/dashboard/js/render-work-items.js +0 -1
- package/dashboard/js/utils.js +1 -0
- package/dashboard.js +101 -3
- package/engine/meeting.js +44 -2
- package/engine/queries.js +62 -1
- package/engine/shared.js +7 -1
- package/package.json +1 -1
package/dashboard/js/refresh.js
CHANGED
|
@@ -374,7 +374,14 @@ function _processStatusUpdate(data) {
|
|
|
374
374
|
.catch(function () { /* keep render even if managed fetch failed — getLastItems() returns the last good cache (or []) */ })
|
|
375
375
|
.then(function () { try { renderKeepProcesses(); } catch {} });
|
|
376
376
|
}
|
|
377
|
-
|
|
377
|
+
// workItems is exempt from the _changed gate, mirroring the agents fix
|
|
378
|
+
// (W-mpn7keq9000302c9). Real-time correctness on /work — a CC-dispatched
|
|
379
|
+
// work item should appear within one poll, not "after a hard refresh" —
|
|
380
|
+
// beats the cost of re-rendering one 20-row table per 4s tick. The
|
|
381
|
+
// _workItemsChanged flag is still captured above so the F1/F3 cross-slice
|
|
382
|
+
// triggers below (renderPrs + renderPlans) still gate correctly, and the
|
|
383
|
+
// diag ring-buffer keeps seeing the per-tick changed signal.
|
|
384
|
+
renderWorkItems(data.workItems || []);
|
|
378
385
|
if (_changed('skills', data.skills)) renderSkills(data.skills || []);
|
|
379
386
|
if (_changed('mcpServers', data.mcpServers)) renderMcpServers(data.mcpServers || []);
|
|
380
387
|
if (_changed('schedules', data.schedules)) renderSchedules(data.schedules || []);
|
|
@@ -138,7 +138,6 @@ async function openAgentDetail(id) {
|
|
|
138
138
|
renderDetailTabs(detail);
|
|
139
139
|
renderDetailContent(detail, currentTab);
|
|
140
140
|
} catch(e) {
|
|
141
|
-
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: error message, agent id)
|
|
142
141
|
document.getElementById('detail-content').innerHTML =
|
|
143
142
|
'<div style="padding:24px;text-align:center">' +
|
|
144
143
|
'<div style="color:var(--red);margin-bottom:12px">Error loading agent detail: ' + escapeHtml(e.message) + '</div>' +
|
|
@@ -296,7 +296,7 @@ function renderTokenUsage(metrics) {
|
|
|
296
296
|
const engAgg = _aggregateEngineUsageForTokenTile(engine);
|
|
297
297
|
const engineCost = engAgg.cost, engineInput = engAgg.input,
|
|
298
298
|
engineOutput = engAgg.output, engineCache = engAgg.cache,
|
|
299
|
-
engineCalls = engAgg.calls;
|
|
299
|
+
engineCalls = engAgg.calls;
|
|
300
300
|
|
|
301
301
|
const totalCost = agentCost + engineCost;
|
|
302
302
|
const totalInput = agentInput + engineInput;
|
|
@@ -459,7 +459,6 @@ async function planExecute(file, project, btn) {
|
|
|
459
459
|
function planReexecuteModal(file, project) {
|
|
460
460
|
const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
|
|
461
461
|
document.getElementById('modal-title').textContent = 'Re-execute Plan';
|
|
462
|
-
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: plan file, project)
|
|
463
462
|
document.getElementById('modal-body').innerHTML =
|
|
464
463
|
'<div style="display:flex;flex-direction:column;gap:10px">' +
|
|
465
464
|
'<div style="font-size:12px;color:var(--muted)">' + escapeHtml(file) + '</div>' +
|
|
@@ -549,7 +548,6 @@ function _renderPlanModal(normalizedFile, raw, lastMod) {
|
|
|
549
548
|
if (normalizedFile.endsWith('.json')) {
|
|
550
549
|
let plan;
|
|
551
550
|
try { plan = JSON.parse(raw); } catch (e) {
|
|
552
|
-
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: parse error message, raw plan excerpt)
|
|
553
551
|
document.getElementById('modal-body').innerHTML = '<p style="color:var(--red)">Failed to parse plan JSON: ' + escapeHtml(e.message) + '</p><pre style="font-size:10px;max-height:200px;overflow:auto">' + escapeHtml((raw || '').slice(0, 500)) + '</pre>';
|
|
554
552
|
return;
|
|
555
553
|
}
|
|
@@ -374,7 +374,6 @@ let _feedbackRating = null;
|
|
|
374
374
|
function feedbackWorkItem(id, source) {
|
|
375
375
|
_feedbackRating = null;
|
|
376
376
|
document.getElementById('modal-title').textContent = 'Feedback on ' + id;
|
|
377
|
-
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: work item id, source)
|
|
378
377
|
document.getElementById('modal-body').innerHTML =
|
|
379
378
|
'<div style="display:flex;flex-direction:column;gap:16px">' +
|
|
380
379
|
'<div style="display:flex;gap:16px;justify-content:center">' +
|
package/dashboard/js/utils.js
CHANGED
|
@@ -460,6 +460,7 @@ function _renderMdChunked(fullText) {
|
|
|
460
460
|
el.remove();
|
|
461
461
|
return;
|
|
462
462
|
}
|
|
463
|
+
// eslint-disable-next-line no-unsanitized/method -- reason: _renderMdCore() escapes input via escHtml() at the top (utils.js:242) before applying safe markdown transforms; chunks are slices of the same fullText whose first chunk is already injected via firstHtml above (utils.js:445)
|
|
463
464
|
el.insertAdjacentHTML('beforebegin', _renderMdCore(chunks[idx]));
|
|
464
465
|
idx++;
|
|
465
466
|
if (idx >= chunks.length) {
|
package/dashboard.js
CHANGED
|
@@ -1717,6 +1717,18 @@ function _buildStatusFastState() {
|
|
|
1717
1717
|
// outside so the async path can yield between reload and rebuild.
|
|
1718
1718
|
const engineState = getEngineState();
|
|
1719
1719
|
const hbAge = engineState.heartbeat ? Date.now() - engineState.heartbeat : 0;
|
|
1720
|
+
// W-mpof1xxe000ac689 — Two-signal liveness check. heartbeat (15s cadence,
|
|
1721
|
+
// engine/cli.js writeHeartbeatNow) and lastTickAt (tickInterval cadence,
|
|
1722
|
+
// engine.js#tickInner) are written by independent setIntervals. If the
|
|
1723
|
+
// heartbeat writer silently dies but tickInner keeps running, the heartbeat
|
|
1724
|
+
// ages past 120s while lastTickAt stays fresh — the dashboard used to lie
|
|
1725
|
+
// ("STALE") on a provably-ticking engine. Engine is alive iff ANY signal is
|
|
1726
|
+
// fresh; only when BOTH age past threshold do we trip the badge. The tick
|
|
1727
|
+
// threshold is floored by ENGINE_HEARTBEAT_STALE_MS so a misconfigured 5s
|
|
1728
|
+
// tickInterval cannot trip the boolean in 10s.
|
|
1729
|
+
const tickInterval = Number(CONFIG?.engine?.tickInterval) || shared.ENGINE_DEFAULTS.tickInterval;
|
|
1730
|
+
const tickStaleThresholdMs = Math.max(ENGINE_HEARTBEAT_STALE_MS, 2 * tickInterval);
|
|
1731
|
+
const tickAge = engineState.lastTickAt ? Date.now() - engineState.lastTickAt : Infinity;
|
|
1720
1732
|
return {
|
|
1721
1733
|
agents: getAgents(),
|
|
1722
1734
|
inbox: getInbox(),
|
|
@@ -1729,12 +1741,14 @@ function _buildStatusFastState() {
|
|
|
1729
1741
|
// off these instead of recomputing Date.now() - heartbeat against a
|
|
1730
1742
|
// possibly-cached payload (false-positive banner regression #2754).
|
|
1731
1743
|
heartbeatAgeMs: engineState.heartbeat ? hbAge : null,
|
|
1732
|
-
heartbeatStale: !!(engineState.heartbeat
|
|
1744
|
+
heartbeatStale: !!(engineState.heartbeat
|
|
1745
|
+
&& hbAge > ENGINE_HEARTBEAT_STALE_MS
|
|
1746
|
+
&& tickAge > tickStaleThresholdMs),
|
|
1733
1747
|
// W-mpnc4u8c001d9d6c — Surface the tick cadence so the client's
|
|
1734
1748
|
// #engine-quick-stats "Next tick in Xs" countdown reads the SAME value
|
|
1735
1749
|
// the engine actually uses (Settings parity rule). Paired with
|
|
1736
1750
|
// control.lastTickAt (above) stamped at the start of every tickInner.
|
|
1737
|
-
tickInterval:
|
|
1751
|
+
tickInterval: tickInterval,
|
|
1738
1752
|
},
|
|
1739
1753
|
adoThrottle: ado.getAdoThrottleState(),
|
|
1740
1754
|
ghThrottle: gh.getGhThrottleState(),
|
|
@@ -2165,10 +2179,47 @@ function getStatus() {
|
|
|
2165
2179
|
// next caller — including the synchronous fallback inside
|
|
2166
2180
|
// `_handleStatusRequest` — rebuilds against the post-invalidate signal
|
|
2167
2181
|
// and bumps the version then.
|
|
2182
|
+
// ── /api/status profiling (W-mpodww9h000b460a) ─────────────────────────────
|
|
2183
|
+
// Feature-flagged per-phase timing emitter. Off by default (no perf cost).
|
|
2184
|
+
// Enable with:
|
|
2185
|
+
// DEBUG_STATUS_TIMING=1 — log every rebuild forever
|
|
2186
|
+
// DEBUG_STATUS_TIMING_SAMPLES=N — log the next N rebuilds then
|
|
2187
|
+
// auto-disable (one-shot mode
|
|
2188
|
+
// for live triage; preferred)
|
|
2189
|
+
// Output goes to stderr as a tagged single line so it's easy to grep:
|
|
2190
|
+
// [status-timing] fastBuild=350ms slowBuild=120ms stringify=45ms gzip=180ms
|
|
2191
|
+
// total=695ms fastStale=true slowStale=false cacheV=42
|
|
2192
|
+
// All four phases are timed regardless of which actually ran — phases that
|
|
2193
|
+
// were skipped (cache hit) report 0ms, so the columns line up across calls.
|
|
2194
|
+
const _STATUS_TIMING_ENABLED = !!process.env.DEBUG_STATUS_TIMING;
|
|
2195
|
+
let _statusTimingSamplesRemaining = (() => {
|
|
2196
|
+
const raw = process.env.DEBUG_STATUS_TIMING_SAMPLES;
|
|
2197
|
+
if (!raw) return null;
|
|
2198
|
+
const n = Number.parseInt(raw, 10);
|
|
2199
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
2200
|
+
})();
|
|
2201
|
+
function _statusTimingActive() {
|
|
2202
|
+
if (_STATUS_TIMING_ENABLED) return true;
|
|
2203
|
+
return _statusTimingSamplesRemaining != null && _statusTimingSamplesRemaining > 0;
|
|
2204
|
+
}
|
|
2205
|
+
function _emitStatusTiming(parts) {
|
|
2206
|
+
if (_statusTimingSamplesRemaining != null && _statusTimingSamplesRemaining > 0) {
|
|
2207
|
+
_statusTimingSamplesRemaining -= 1;
|
|
2208
|
+
}
|
|
2209
|
+
const cols = Object.entries(parts)
|
|
2210
|
+
.map(([k, v]) => `${k}=${typeof v === 'number' ? v.toFixed(1) + 'ms' : v}`)
|
|
2211
|
+
.join(' ');
|
|
2212
|
+
try { process.stderr.write('[status-timing] ' + cols + '\n'); } catch { /* stderr unavailable */ }
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2168
2215
|
function refreshStatusAsync() {
|
|
2169
2216
|
if (_statusRebuildPromise) return _statusRebuildPromise;
|
|
2170
2217
|
|
|
2171
2218
|
_statusRebuildPromise = (async () => {
|
|
2219
|
+
const profile = _statusTimingActive();
|
|
2220
|
+
const tOverall = profile ? process.hrtime.bigint() : null;
|
|
2221
|
+
let fastBuildMs = 0;
|
|
2222
|
+
let slowBuildMs = 0;
|
|
2172
2223
|
try {
|
|
2173
2224
|
const now = Date.now();
|
|
2174
2225
|
const startGeneration = _statusInvalidationGeneration;
|
|
@@ -2188,7 +2239,16 @@ function refreshStatusAsync() {
|
|
|
2188
2239
|
}
|
|
2189
2240
|
}
|
|
2190
2241
|
|
|
2191
|
-
if (!fastStale && !slowStale && _statusCache)
|
|
2242
|
+
if (!fastStale && !slowStale && _statusCache) {
|
|
2243
|
+
if (profile) {
|
|
2244
|
+
_emitStatusTiming({
|
|
2245
|
+
phase: 'cache-hit',
|
|
2246
|
+
total: Number(process.hrtime.bigint() - tOverall) / 1e6,
|
|
2247
|
+
fastStale: false, slowStale: false, cacheV: _statusCacheVersion,
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
return _statusCache;
|
|
2251
|
+
}
|
|
2192
2252
|
|
|
2193
2253
|
let fast = _fastState;
|
|
2194
2254
|
// Pre-build mtime snapshot (see getStatus() for the rationale). The async
|
|
@@ -2199,7 +2259,9 @@ function refreshStatusAsync() {
|
|
|
2199
2259
|
if (fastStale) {
|
|
2200
2260
|
reloadConfig();
|
|
2201
2261
|
preBuildMtimes = _getMtimes();
|
|
2262
|
+
const tFast = profile ? process.hrtime.bigint() : null;
|
|
2202
2263
|
fast = _buildStatusFastState();
|
|
2264
|
+
if (profile) fastBuildMs = Number(process.hrtime.bigint() - tFast) / 1e6;
|
|
2203
2265
|
}
|
|
2204
2266
|
|
|
2205
2267
|
// Unconditional cooperative yield between phases — guarantees the event
|
|
@@ -2217,7 +2279,9 @@ function refreshStatusAsync() {
|
|
|
2217
2279
|
if (slowStale) {
|
|
2218
2280
|
if (slowMtimeChanged) _invalidateSlowInnerCachesForMtimeChange();
|
|
2219
2281
|
preBuildSlowMtimes = _getSlowMtimes();
|
|
2282
|
+
const tSlow = profile ? process.hrtime.bigint() : null;
|
|
2220
2283
|
slow = _buildStatusSlowState();
|
|
2284
|
+
if (profile) slowBuildMs = Number(process.hrtime.bigint() - tSlow) / 1e6;
|
|
2221
2285
|
}
|
|
2222
2286
|
|
|
2223
2287
|
// Invalidation-race guard: if an invalidation fired during the await
|
|
@@ -2226,6 +2290,14 @@ function refreshStatusAsync() {
|
|
|
2226
2290
|
// next sync getStatus() OR async refreshStatusAsync() rebuilds fresh
|
|
2227
2291
|
// against the post-invalidate generation.
|
|
2228
2292
|
if (_statusInvalidationGeneration !== startGeneration) {
|
|
2293
|
+
if (profile) {
|
|
2294
|
+
_emitStatusTiming({
|
|
2295
|
+
phase: 'discarded-invalidation-race',
|
|
2296
|
+
fastBuild: fastBuildMs, slowBuild: slowBuildMs,
|
|
2297
|
+
total: Number(process.hrtime.bigint() - tOverall) / 1e6,
|
|
2298
|
+
cacheV: _statusCacheVersion,
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2229
2301
|
return _statusCache;
|
|
2230
2302
|
}
|
|
2231
2303
|
|
|
@@ -2241,6 +2313,14 @@ function refreshStatusAsync() {
|
|
|
2241
2313
|
}
|
|
2242
2314
|
_statusCache = { ..._fastState, ..._slowState, timestamp: new Date().toISOString() };
|
|
2243
2315
|
_markStatusCacheBuilt();
|
|
2316
|
+
if (profile) {
|
|
2317
|
+
_emitStatusTiming({
|
|
2318
|
+
phase: 'rebuilt',
|
|
2319
|
+
fastBuild: fastBuildMs, slowBuild: slowBuildMs,
|
|
2320
|
+
total: Number(process.hrtime.bigint() - tOverall) / 1e6,
|
|
2321
|
+
fastStale, slowStale, cacheV: _statusCacheVersion,
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2244
2324
|
return _statusCache;
|
|
2245
2325
|
} finally {
|
|
2246
2326
|
_statusRebuildPromise = null;
|
|
@@ -2279,8 +2359,22 @@ function _resetStatusCacheForTesting() {
|
|
|
2279
2359
|
function getStatusJson() {
|
|
2280
2360
|
getStatus(); // ensure _statusCache is fresh
|
|
2281
2361
|
if (!_statusCacheJson) {
|
|
2362
|
+
const profile = _statusTimingActive();
|
|
2363
|
+
const tStringify = profile ? process.hrtime.bigint() : null;
|
|
2282
2364
|
_statusCacheJson = JSON.stringify(_statusCache);
|
|
2365
|
+
const stringifyMs = profile ? Number(process.hrtime.bigint() - tStringify) / 1e6 : 0;
|
|
2366
|
+
const tGzip = profile ? process.hrtime.bigint() : null;
|
|
2283
2367
|
_statusCacheGzip = zlib.gzipSync(_statusCacheJson); // pre-compute gzip once per cache rebuild
|
|
2368
|
+
if (profile) {
|
|
2369
|
+
const gzipMs = Number(process.hrtime.bigint() - tGzip) / 1e6;
|
|
2370
|
+
_emitStatusTiming({
|
|
2371
|
+
phase: 'serialize',
|
|
2372
|
+
stringify: stringifyMs, gzip: gzipMs,
|
|
2373
|
+
jsonKB: (_statusCacheJson.length / 1024).toFixed(1),
|
|
2374
|
+
gzipKB: (_statusCacheGzip.length / 1024).toFixed(1),
|
|
2375
|
+
cacheV: _statusCacheVersion,
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2284
2378
|
}
|
|
2285
2379
|
return _statusCacheJson;
|
|
2286
2380
|
}
|
|
@@ -11082,6 +11176,10 @@ module.exports = {
|
|
|
11082
11176
|
_setStatusRefreshHook,
|
|
11083
11177
|
_resetStatusCacheForTesting,
|
|
11084
11178
|
_ifNoneMatchHasEtag,
|
|
11179
|
+
// Exported for the engine-stale-two-signal test (W-mpof1xxe000ac689) — the
|
|
11180
|
+
// staleness verdict it stamps on engine.heartbeatStale is the contract under
|
|
11181
|
+
// test. No production caller imports this; it is a test seam.
|
|
11182
|
+
_buildStatusFastState,
|
|
11085
11183
|
};
|
|
11086
11184
|
|
|
11087
11185
|
// Start the HTTP server only when run directly (node dashboard.js).
|
package/engine/meeting.js
CHANGED
|
@@ -334,12 +334,48 @@ function writeMeetingTranscriptToInbox(meeting, meetingId, agents) {
|
|
|
334
334
|
} catch (e) { log('warn', `Meeting ${meetingId} inbox write: ${e.message}`); }
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
+
// Cache for getMeetings (W-mpodww9h000b460a). Dashboard fast-state calls
|
|
338
|
+
// getMeetings() twice per rebuild — once for the slim slice and once for
|
|
339
|
+
// the meetingsTotal sidebar counter — and each call reads + parses every
|
|
340
|
+
// meetings/<id>.json on disk (~9 MB total at task time → ~30–45 ms).
|
|
341
|
+
// 1 s TTL mirrors `_dispatchCache` / `_prsCache` / `_workItemsCache`:
|
|
342
|
+
// short enough that the next SPA poll (~4 s) reflects new rounds, and the
|
|
343
|
+
// dashboard mtime tracker on meetings/*.json busts the upstream
|
|
344
|
+
// `_statusCache` on real writes anyway.
|
|
345
|
+
//
|
|
346
|
+
// mtime gate: a single statSync on MEETINGS_DIR catches new/deleted
|
|
347
|
+
// meetings (NTFS + ext4 both advance dir mtime on entry add/remove),
|
|
348
|
+
// covering tests and any code path that bypasses mutateMeeting.
|
|
349
|
+
let _meetingsCache = null;
|
|
350
|
+
let _meetingsCacheAt = 0;
|
|
351
|
+
let _meetingsCacheDirMtime = 0;
|
|
352
|
+
function invalidateMeetingsCache() {
|
|
353
|
+
_meetingsCache = null;
|
|
354
|
+
_meetingsCacheAt = 0;
|
|
355
|
+
_meetingsCacheDirMtime = 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
337
358
|
function getMeetings() {
|
|
338
|
-
|
|
339
|
-
|
|
359
|
+
const now = Date.now();
|
|
360
|
+
if (_meetingsCache && (now - _meetingsCacheAt) < 1000) {
|
|
361
|
+
let currDirMtime = 0;
|
|
362
|
+
try { currDirMtime = fs.statSync(MEETINGS_DIR).mtimeMs; } catch { /* missing */ }
|
|
363
|
+
if (currDirMtime === _meetingsCacheDirMtime) return _meetingsCache;
|
|
364
|
+
}
|
|
365
|
+
if (!fs.existsSync(MEETINGS_DIR)) {
|
|
366
|
+
_meetingsCache = [];
|
|
367
|
+
_meetingsCacheAt = now;
|
|
368
|
+
_meetingsCacheDirMtime = 0;
|
|
369
|
+
return _meetingsCache;
|
|
370
|
+
}
|
|
371
|
+
const result = fs.readdirSync(MEETINGS_DIR)
|
|
340
372
|
.filter(f => f.endsWith('.json'))
|
|
341
373
|
.map(f => safeJson(path.join(MEETINGS_DIR, f)))
|
|
342
374
|
.filter(Boolean);
|
|
375
|
+
_meetingsCache = result;
|
|
376
|
+
_meetingsCacheAt = now;
|
|
377
|
+
try { _meetingsCacheDirMtime = fs.statSync(MEETINGS_DIR).mtimeMs; } catch { _meetingsCacheDirMtime = 0; }
|
|
378
|
+
return result;
|
|
343
379
|
}
|
|
344
380
|
|
|
345
381
|
function getMeeting(id) {
|
|
@@ -397,6 +433,10 @@ function mutateMeeting(id, fn) {
|
|
|
397
433
|
}
|
|
398
434
|
return userResult;
|
|
399
435
|
}, { defaultValue: {}, skipWriteIfUnchanged: true });
|
|
436
|
+
// Invalidate the cached getMeetings() list so subsequent dashboard reads
|
|
437
|
+
// observe the write (W-mpodww9h000b460a). Mirrors the
|
|
438
|
+
// mutateDispatch → invalidateDispatchCache pattern.
|
|
439
|
+
invalidateMeetingsCache();
|
|
400
440
|
return userResult === undefined ? null : userResult;
|
|
401
441
|
}
|
|
402
442
|
|
|
@@ -818,6 +858,7 @@ function deleteMeeting(id) {
|
|
|
818
858
|
// mutateMeeting writes a .backup sidecar; safeJson auto-restores from it
|
|
819
859
|
// when the primary is missing, so deletion must also drop the backup.
|
|
820
860
|
try { fs.unlinkSync(filePath + '.backup'); } catch { /* sidecar may not exist */ }
|
|
861
|
+
invalidateMeetingsCache();
|
|
821
862
|
return true;
|
|
822
863
|
}
|
|
823
864
|
|
|
@@ -914,6 +955,7 @@ function checkMeetingTimeouts(config) {
|
|
|
914
955
|
}
|
|
915
956
|
module.exports = {
|
|
916
957
|
MEETINGS_DIR, getMeetings, getMeeting, saveMeeting, mutateMeeting, createMeeting,
|
|
958
|
+
invalidateMeetingsCache,
|
|
917
959
|
discoverMeetingWork, collectMeetingFindings, checkMeetingTimeouts,
|
|
918
960
|
addMeetingNote, advanceMeetingRound, endMeeting, archiveMeeting, unarchiveMeeting, deleteMeeting,
|
|
919
961
|
EMPTY_OUTPUT_PATTERNS,
|
package/engine/queries.js
CHANGED
|
@@ -1159,8 +1159,66 @@ function getKnowledgeBaseIndex() {
|
|
|
1159
1159
|
|
|
1160
1160
|
// ── Work Items ──────────────────────────────────────────────────────────────
|
|
1161
1161
|
|
|
1162
|
+
// Cache: getWorkItems is called 5–7x per /api/status fast-state rebuild
|
|
1163
|
+
// (W-mpodww9h000b460a). The dashboard's `_buildStatusFastState` calls it
|
|
1164
|
+
// directly for the `workItems` slice, AND every idle agent's
|
|
1165
|
+
// `getAgentStatus()` fallback re-reads it to derive a work-item-marker
|
|
1166
|
+
// "working" state when dispatch.json briefly desyncs from per-project
|
|
1167
|
+
// work-items.json. With 5 agents (3 idle) and a 890 KB central
|
|
1168
|
+
// work-items.json, the pre-cache cost was ~300 ms per fast-state rebuild —
|
|
1169
|
+
// the single biggest hot-path offender. Mirrors `_prsCache` / `_dispatchCache`
|
|
1170
|
+
// (1 s TTL eliminates intra-request duplication without masking real
|
|
1171
|
+
// updates from the next 4 s SPA poll cycle).
|
|
1172
|
+
//
|
|
1173
|
+
// mtime-gated freshness (W-mpodww9h000b460a): bare TTL is not enough because
|
|
1174
|
+
// callers like tests and `safeWrite` write the central + per-project files
|
|
1175
|
+
// without going through `mutateWorkItems` (which busts the cache via
|
|
1176
|
+
// `invalidateWorkItemsCache`). One cheap `statSync` per file per cache hit
|
|
1177
|
+
// catches those writes within ~10 ms regardless of the TTL. Files that
|
|
1178
|
+
// don't exist record `null` so subsequent appearance still busts.
|
|
1179
|
+
let _workItemsCache = null;
|
|
1180
|
+
let _workItemsCacheAt = 0;
|
|
1181
|
+
let _workItemsCacheMtimes = null;
|
|
1182
|
+
function invalidateWorkItemsCache() {
|
|
1183
|
+
_workItemsCache = null;
|
|
1184
|
+
_workItemsCacheAt = 0;
|
|
1185
|
+
_workItemsCacheMtimes = null;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function _workItemsFilePaths(config) {
|
|
1189
|
+
const projects = getProjects(config);
|
|
1190
|
+
const out = [path.join(MINIONS_DIR, 'work-items.json')];
|
|
1191
|
+
for (const p of projects) {
|
|
1192
|
+
try { out.push(projectWorkItemsPath(p)); } catch { /* missing path helper output */ }
|
|
1193
|
+
}
|
|
1194
|
+
return out;
|
|
1195
|
+
}
|
|
1196
|
+
function _snapshotWorkItemsMtimes(config) {
|
|
1197
|
+
const out = Object.create(null);
|
|
1198
|
+
for (const fp of _workItemsFilePaths(config)) {
|
|
1199
|
+
try { out[fp] = fs.statSync(fp).mtimeMs; }
|
|
1200
|
+
catch { out[fp] = null; /* ENOENT → null; flipping to present must bust */ }
|
|
1201
|
+
}
|
|
1202
|
+
return out;
|
|
1203
|
+
}
|
|
1204
|
+
function _workItemsMtimesDiffer(prev, curr) {
|
|
1205
|
+
if (!prev || !curr) return true;
|
|
1206
|
+
for (const fp of Object.keys(curr)) {
|
|
1207
|
+
if (prev[fp] !== curr[fp]) return true;
|
|
1208
|
+
}
|
|
1209
|
+
for (const fp of Object.keys(prev)) {
|
|
1210
|
+
if (!(fp in curr)) return true;
|
|
1211
|
+
}
|
|
1212
|
+
return false;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1162
1215
|
function getWorkItems(config) {
|
|
1216
|
+
const now = Date.now();
|
|
1163
1217
|
config = config || getConfig();
|
|
1218
|
+
if (_workItemsCache && (now - _workItemsCacheAt) < 1000) {
|
|
1219
|
+
const currMtimes = _snapshotWorkItemsMtimes(config);
|
|
1220
|
+
if (!_workItemsMtimesDiffer(_workItemsCacheMtimes, currMtimes)) return _workItemsCache;
|
|
1221
|
+
}
|
|
1164
1222
|
const projects = getProjects(config);
|
|
1165
1223
|
const allItems = [];
|
|
1166
1224
|
|
|
@@ -1326,6 +1384,9 @@ function getWorkItems(config) {
|
|
|
1326
1384
|
return (b.created || '').localeCompare(a.created || '');
|
|
1327
1385
|
});
|
|
1328
1386
|
|
|
1387
|
+
_workItemsCache = allItems;
|
|
1388
|
+
_workItemsCacheAt = now;
|
|
1389
|
+
_workItemsCacheMtimes = _snapshotWorkItemsMtimes(config);
|
|
1329
1390
|
return allItems;
|
|
1330
1391
|
}
|
|
1331
1392
|
|
|
@@ -2367,5 +2428,5 @@ module.exports = {
|
|
|
2367
2428
|
getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getKnowledgeBaseIndex,
|
|
2368
2429
|
|
|
2369
2430
|
// Work items & PRD
|
|
2370
|
-
getWorkItems, getPrdInfo,
|
|
2431
|
+
getWorkItems, invalidateWorkItemsCache, getPrdInfo,
|
|
2371
2432
|
};
|
package/engine/shared.js
CHANGED
|
@@ -4564,10 +4564,16 @@ function listProcessReachable(rootPids, allProcesses = null) {
|
|
|
4564
4564
|
* @param {Function} mutator - Receives the array, mutates in place or returns new value
|
|
4565
4565
|
*/
|
|
4566
4566
|
function mutateWorkItems(filePath, mutator) {
|
|
4567
|
-
|
|
4567
|
+
const result = mutateJsonFileLocked(filePath, (data) => {
|
|
4568
4568
|
if (!Array.isArray(data)) data = [];
|
|
4569
4569
|
return mutator(data) || data;
|
|
4570
4570
|
}, { defaultValue: [], skipWriteIfUnchanged: true });
|
|
4571
|
+
// Invalidate the read cache so the next getWorkItems() sees fresh data
|
|
4572
|
+
// (W-mpodww9h000b460a). Mirrors dispatch.js's invalidateDispatchCache
|
|
4573
|
+
// call after mutateDispatch. Lazy-required to avoid the queries→shared
|
|
4574
|
+
// require cycle.
|
|
4575
|
+
try { require('./queries').invalidateWorkItemsCache(); } catch { /* queries not loaded */ }
|
|
4576
|
+
return result;
|
|
4571
4577
|
}
|
|
4572
4578
|
|
|
4573
4579
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2059",
|
|
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"
|