@yemi33/minions 0.1.2062 → 0.1.2064

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.
@@ -4,11 +4,6 @@ function closeModal() {
4
4
  const modalEl = document.querySelector('#modal .modal');
5
5
  if (modalEl) modalEl.classList.remove('modal-wide');
6
6
  document.getElementById('modal').classList.remove('open');
7
- // Clear the WI-detail auto-refresh tracker so renderWorkItems stops
8
- // rewriting #modal-body each tick. Safe to do unconditionally — if the
9
- // closed modal wasn't a WI detail, these vars were already null.
10
- if (typeof _wiModalOpenId !== 'undefined') { _wiModalOpenId = null; }
11
- if (typeof _wiModalHydratedFields !== 'undefined') { _wiModalHydratedFields = null; }
12
7
  clearModalBackStack();
13
8
  // Hide Q&A section (only shown for document modals)
14
9
  document.getElementById('modal-qa').style.display = 'none';
@@ -69,18 +69,24 @@ function renderAgents(agents) {
69
69
  `).join('');
70
70
  }
71
71
 
72
- // Re-render the detail-panel header (name + emoji + runtime/model chips +
73
- // status badge + lastAction + blocking-tool / resultSummary blocks) from a
74
- // slim agentData record. Extracted so renderAgents can call it every poll
75
- // tick to keep an open detail panel live without re-fetching the expensive
76
- // /api/agent/<id> charter/history/output payload. SEC-03 Phase A rules
77
- // still apply: emoji/name/role go through textContent.
78
- function _renderAgentDetailHeader(agent) {
72
+ async function openAgentDetail(id) {
73
+ const agent = agentData.find(a => a.id === id);
74
+ if (!agent) return;
75
+ currentAgentId = id;
76
+ currentTab = (agent.status === 'working') ? 'live' : 'thought-process';
77
+
78
+ // SEC-03 Phase A: Build the detail header via DOM + textContent instead of innerHTML.
79
+ // Emoji, name and role are all user-controlled fields; routing them through textContent
80
+ // guarantees no HTML interpretation even if the escape function were ever bypassed.
79
81
  const nameEl = document.getElementById('detail-agent-name');
80
- if (!nameEl) return;
81
82
  const emojiSpan = document.createElement('span');
82
83
  emojiSpan.style.fontSize = '22px';
83
84
  emojiSpan.textContent = agent.emoji || '';
85
+ // Runtime tag \u2014 uses the inline-SVG logo from the same RUNTIME_TAGS map the
86
+ // card uses, so the visual is consistent. The container's user-controlled
87
+ // text fields stay on the textContent path; the SVG is a hardcoded literal
88
+ // from RUNTIME_TAGS keyed by the runtime string (server-controlled, finite
89
+ // set), so injecting via innerHTML on the icon-only span is safe.
84
90
  const runtimeMeta = RUNTIME_TAGS[agent.runtime];
85
91
  const runtimeSpan = document.createElement('span');
86
92
  runtimeSpan.title = 'Runtime: ' + (runtimeMeta?.label || agent.runtime || 'unknown');
@@ -94,6 +100,9 @@ function _renderAgentDetailHeader(agent) {
94
100
  runtimeSpan.style.cssText += ';font-size:10px;font-weight:600;letter-spacing:0.4px;text-transform:uppercase;padding:2px 6px;border:1px solid var(--muted);border-radius:3px;color:var(--muted)';
95
101
  runtimeSpan.textContent = agent.runtime || 'unknown';
96
102
  }
103
+ // W-mpmwxk4y00053271 — mirror the model chip the card shows so the detail
104
+ // header stays in sync. textContent path keeps the model string from being
105
+ // interpreted as HTML.
97
106
  const modelSpan = (agent.model && typeof agent.model === 'string')
98
107
  ? (() => {
99
108
  const s = document.createElement('span');
@@ -112,27 +121,12 @@ function _renderAgentDetailHeader(agent) {
112
121
  nameEl.replaceChildren(...children);
113
122
 
114
123
  const badgeClass = agent.status;
115
- const statusEl = document.getElementById('detail-status-line');
116
- if (statusEl) {
117
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; status is an internal bounded enum and all user data is wrapped in escapeHtml()/renderMd() (fields: lastAction, blocking tool, resultSummary)
118
- statusEl.innerHTML =
119
- '<span class="status-badge ' + badgeClass + '">' + agent.status.toUpperCase() + '</span> ' +
120
- '<span style="color:var(--muted)">' + escapeHtml(agent.lastAction) + '</span>' +
121
- (agent._blockingToolCall ? '<div style="margin-top:4px;padding:4px 8px;background:rgba(130,160,210,0.13);border:1px solid rgba(130,160,210,0.3);border-radius:4px;font-size:11px;color:var(--muted)">&#x23F3; Blocking tool call (' + escapeHtml(agent._blockingToolCall.tool) + ') &mdash; silent ' + Math.round(agent._blockingToolCall.silentMs/60000) + 'min, timeout in ' + Math.round(agent._blockingToolCall.remainingMs/60000) + 'min</div>' : '') +
122
- (agent.resultSummary ? '<div style="margin-top:4px;font-size:11px;color:var(--text);line-height:1.4">' + renderMd(agent.resultSummary.slice(0, 300)) + '</div>' : '');
123
- }
124
- }
125
-
126
- async function openAgentDetail(id) {
127
- const agent = agentData.find(a => a.id === id);
128
- if (!agent) return;
129
- currentAgentId = id;
130
- currentTab = (agent.status === 'working') ? 'live' : 'thought-process';
131
-
132
- // SEC-03 Phase A: Build the detail header via DOM + textContent instead of innerHTML.
133
- // Emoji, name and role are all user-controlled fields; routing them through textContent
134
- // guarantees no HTML interpretation even if the escape function were ever bypassed.
135
- _renderAgentDetailHeader(agent);
124
+ // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; status is an internal bounded enum and all user data is wrapped in escapeHtml()/renderMd() (fields: lastAction, blocking tool, resultSummary)
125
+ document.getElementById('detail-status-line').innerHTML =
126
+ '<span class="status-badge ' + badgeClass + '">' + agent.status.toUpperCase() + '</span> ' +
127
+ '<span style="color:var(--muted)">' + escapeHtml(agent.lastAction) + '</span>' +
128
+ (agent._blockingToolCall ? '<div style="margin-top:4px;padding:4px 8px;background:rgba(130,160,210,0.13);border:1px solid rgba(130,160,210,0.3);border-radius:4px;font-size:11px;color:var(--muted)">&#x23F3; Blocking tool call (' + escapeHtml(agent._blockingToolCall.tool) + ') &mdash; silent ' + Math.round(agent._blockingToolCall.silentMs/60000) + 'min, timeout in ' + Math.round(agent._blockingToolCall.remainingMs/60000) + 'min</div>' : '') +
129
+ (agent.resultSummary ? '<div style="margin-top:4px;font-size:11px;color:var(--text);line-height:1.4">' + renderMd(agent.resultSummary.slice(0, 300)) + '</div>' : '');
136
130
 
137
131
  // Show panel immediately with loading state — don't wait for API
138
132
  document.getElementById('detail-content').innerHTML = '<div style="padding:24px;text-align:center;color:var(--muted)">Loading...</div>';
@@ -167,12 +161,7 @@ function _tickAgentRuntimes() {
167
161
  el.textContent = 'Running: ' + (hr > 0 ? hr + 'h ' : '') + min + 'm ' + sec + 's';
168
162
  });
169
163
  }
170
- // Start ticker after each render if working agents exist, and refresh the
171
- // open agent-detail panel header so status / lastAction / blocking-tool /
172
- // resultSummary track the latest /api/status slice without re-fetching the
173
- // expensive /api/agent/<id> charter/history/output payload. The body tabs
174
- // (thought-process, live, output, etc.) are loaded once on open via
175
- // openAgentDetail's safeFetch — they stay as-is until the user reopens.
164
+ // Start ticker after each render if working agents exist
176
165
  var _origRenderAgents = renderAgents;
177
166
  renderAgents = function(agents) {
178
167
  _origRenderAgents(agents);
@@ -181,10 +170,6 @@ renderAgents = function(agents) {
181
170
  _tickAgentRuntimes();
182
171
  _agentRuntimeTimer = setInterval(_tickAgentRuntimes, 1000);
183
172
  }
184
- if (currentAgentId) {
185
- var open = agents.find(function(a) { return a.id === currentAgentId; });
186
- if (open) _renderAgentDetailHeader(open);
187
- }
188
173
  };
189
174
 
190
175
  window.MinionsAgents = { renderAgents, openAgentDetail };
@@ -4,18 +4,6 @@ let allWorkItems = [];
4
4
  let wiPage = 0;
5
5
  const WI_PER_PAGE = 20;
6
6
 
7
- // Track open WI detail modal so renderWorkItems can re-render its body
8
- // every poll tick. Without this the modal stays frozen on the snapshot
9
- // from the moment it was opened — status flips / agent assignments /
10
- // PR link arrival are invisible until the user closes + reopens or
11
- // hard-refreshes. Mirrors the same fix that drove the section-render
12
- // gate sweep (8ad48509 / W-mpn7keq9000302c9). `_wiModalHydratedFields`
13
- // caches the heavy free-text fields (description, acceptanceCriteria,
14
- // references) loaded once by openWorkItemDetail's GET /api/work-items/<id>
15
- // hydration call so per-tick re-renders don't lose them.
16
- let _wiModalOpenId = null;
17
- let _wiModalHydratedFields = null;
18
-
19
7
  // Track retry state per work item so loading/success/error survives re-renders
20
8
  const _wiRetryState = {}; // { [id]: { status: 'pending'|'done'|'error', message?, until? } }
21
9
  function setWiRetryState(id, state) { _wiRetryState[id] = state; }
@@ -167,27 +155,6 @@ function renderWorkItems(items) {
167
155
  const newWrap = el.querySelector('.pr-table-wrap');
168
156
  if (newWrap) newWrap.scrollLeft = savedScroll;
169
157
  }
170
- // Refresh the open WI detail modal in-place so its status badge,
171
- // agent assignment, PR link, etc. reflect the latest /api/status slice.
172
- // The heavy free-text fields (description, AC, references) live in
173
- // `_wiModalHydratedFields` from the one-time GET /api/work-items/<id>
174
- // hydration and survive across these re-renders.
175
- if (_wiModalOpenId) {
176
- const slim = items.find(i => i.id === _wiModalOpenId);
177
- if (slim) {
178
- const merged = Object.assign({}, _wiModalHydratedFields || {}, slim);
179
- const body = document.getElementById('modal-body');
180
- if (body) {
181
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail()
182
- body.innerHTML = _wiRenderDetail(merged);
183
- }
184
- const title = document.getElementById('modal-title');
185
- if (title) title.textContent = slim.title || slim.id;
186
- }
187
- // If the WI dropped out of the slim slice (deleted/archived), leave
188
- // the modal as-is — the user will close it normally; we don't want
189
- // to auto-dismiss in case they're still reading.
190
- }
191
158
  }
192
159
 
193
160
  async function editWorkItem(id, source) {
@@ -682,15 +649,6 @@ function openWorkItemDetail(id) {
682
649
  (cached.referencesCount > 0 && !Array.isArray(cached.references));
683
650
 
684
651
  const initial = needsHydration ? Object.assign({}, cached, { _descriptionLoading: true }) : cached;
685
- // Track which WI's modal is open so renderWorkItems can re-render the
686
- // modal body each poll tick. Reset the hydrated cache; openWorkItemDetail
687
- // is the only authoritative source of the heavy free-text fields.
688
- _wiModalOpenId = id;
689
- _wiModalHydratedFields = needsHydration ? null : {
690
- description: cached.description,
691
- acceptanceCriteria: Array.isArray(cached.acceptanceCriteria) ? cached.acceptanceCriteria : undefined,
692
- references: Array.isArray(cached.references) ? cached.references : undefined,
693
- };
694
652
  document.getElementById('modal-title').textContent = initial.title || initial.id;
695
653
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail() (fields: title, description, agent, source, reasons, references, artifacts, PR links)
696
654
  document.getElementById('modal-body').innerHTML = _wiRenderDetail(initial);
@@ -704,22 +662,18 @@ function openWorkItemDetail(id) {
704
662
  .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
705
663
  .then(function(data) {
706
664
  // Guard against modal navigation away from this WI during the fetch.
707
- if (_wiModalOpenId !== id) return;
665
+ var title = document.getElementById('modal-title');
666
+ if (!title || title.textContent !== (initial.title || initial.id)) return;
708
667
  var full = data && data.item;
709
668
  if (!full) return;
710
- // Cache the heavy free-text fields so per-tick re-renders preserve
711
- // them. We keep them separate from the slim slice (which renderWorkItems
712
- // refreshes on every tick).
713
- _wiModalHydratedFields = {
714
- description: full.description || cached.description || '',
715
- acceptanceCriteria: Array.isArray(full.acceptanceCriteria) ? full.acceptanceCriteria : undefined,
716
- references: Array.isArray(full.references) ? full.references : undefined,
717
- };
718
669
  // Merge: cached cross-slice fields (_pr, _artifacts, etc.) WIN over
719
670
  // the on-disk record so we don't lose engine enrichment that lives
720
671
  // only on the in-memory pass. The full record contributes description,
721
672
  // acceptanceCriteria, and references back to the rendered shape.
722
- var merged = Object.assign({}, full, cached, _wiModalHydratedFields);
673
+ var merged = Object.assign({}, full, cached);
674
+ merged.description = full.description || cached.description || '';
675
+ if (Array.isArray(full.acceptanceCriteria)) merged.acceptanceCriteria = full.acceptanceCriteria;
676
+ if (Array.isArray(full.references)) merged.references = full.references;
723
677
  // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() or renderMd() by _wiRenderDetail() (fields: title, description, agent, source, reasons, references, artifacts, PR links)
724
678
  document.getElementById('modal-body').innerHTML = _wiRenderDetail(merged);
725
679
  })
package/dashboard.js CHANGED
@@ -1540,18 +1540,26 @@ function parsePinnedEntries(content) {
1540
1540
  return entries;
1541
1541
  }
1542
1542
 
1543
- // Two-tier status cache: fast state (10s) for frequently-changing data, slow state (60s) for rarely-changing data.
1544
- // Combined into _statusCache for API/SSE consumers — no API contract change.
1545
- let _fastState = null;
1546
- let _fastStateTs = 0;
1547
- const FAST_STATE_TTL = 10000; // 10s dispatch, agents, metrics, work items, etc.
1548
- let _slowState = null;
1549
- let _slowStateTs = 0;
1550
- const SLOW_STATE_TTL = 60000; // 60s skills, PRDs, pinned, version, projects, etc.
1551
- // Heartbeat staleness threshold. Computed server-side at snapshot build time so
1552
- // the client renders off a boolean instead of doing wall-clock math against a
1553
- // possibly-cached heartbeat (which produced false-positive STALE banners after
1554
- // control.json was dropped from the mtime tracker in W-mpg8aapw001d7e0c).
1543
+ // /api/status cache single comprehensive layer.
1544
+ //
1545
+ // Pre-2026-05-27 the cache was split into fast (10s TTL) and slow (60s TTL)
1546
+ // tiers with separate mtime registries. Each layer was added for a real
1547
+ // reason (perf, staleness, race) but the result was 6 caches disagreeing
1548
+ // about freshness — every "I had to hard refresh" bug we shipped fixes for
1549
+ // (agents, workItems, prdProgress, …) came from a layer not knowing about
1550
+ // a change that another layer had already noticed.
1551
+ //
1552
+ // New model: ONE cache, ONE comprehensive mtime registry (union of the
1553
+ // former fast+slow registries via queries.getStatus{Fast,Slow}StateMtimePaths).
1554
+ // Steady state = single mtime check. Any file change cache invalidated
1555
+ // full rebuild on the next call → fresh response on the next /api/status
1556
+ // poll, always. No TTL fallback — the mtime registry is the source of
1557
+ // truth; missing a file there is a bug to fix, not a cost to amortize.
1558
+ //
1559
+ // _buildStatusFastState + _buildStatusSlowState stay as organizational
1560
+ // helpers below so the build code stays grouped + the async path can yield
1561
+ // the event loop between them (CC SSE heartbeats keep flowing during the
1562
+ // slower slice).
1555
1563
  const ENGINE_HEARTBEAT_STALE_MS = 120000;
1556
1564
  let _statusCache = null;
1557
1565
  let _statusCacheJson = null; // cached JSON.stringify(_statusCache) — avoids double-serialization for SSE
@@ -1601,27 +1609,33 @@ function _ifNoneMatchHasEtag(headerValue, currentEtag) {
1601
1609
  return false;
1602
1610
  }
1603
1611
 
1604
- // mtime-based cache invalidation (W-mpftp7na000td0f4).
1612
+ // mtime-based cache invalidation.
1605
1613
  //
1606
1614
  // Engine and dashboard are independent processes; `invalidateStatusCache()`
1607
- // lives in dashboard.js memory and is unreachable from engine code. The
1608
- // fast-state TTL is 10 s, but we want sub-second visibility for engine-side
1609
- // state flips (work-item pending→done, PR status changes, dispatch.json
1610
- // mutations). The fix: every `getStatus()` call statSyncs a small set of
1611
- // tracked files; if any mtime advanced since the last rebuild, fast-state
1612
- // is rebuilt and `_statusCacheVersion` bumps (which busts the ETag, so the
1613
- // next /api/status poll sees a 200 + fresh body instead of a 304).
1615
+ // lives in dashboard.js memory and is unreachable from engine code. We
1616
+ // detect engine-side state flips (WI pending→done, PR status changes,
1617
+ // dispatch.json mutations, PRD progress derived from work-items, …) by
1618
+ // statSync'ing a registry of input files on every status request. Any
1619
+ // mtime advance cache busted fresh rebuild on the same poll.
1614
1620
  //
1615
- // The tracked list lives in `engine/queries.js → getStatusFastStateMtimePaths()`
1616
- // as a single source of truth colocated with the read-side aggregation
1617
- // layer that owns the rest of the fast-state assembly. Add a new tracked
1618
- // file there (one line), NOT here. The dashboard side stays a thin
1619
- // delegate so any module that contributes to `_buildStatusFastState()` can
1620
- // register its mtime inputs in one place.
1621
- const _mtimeTrackedFiles = () => queries.getStatusFastStateMtimePaths(CONFIG);
1622
- const _slowMtimeTrackedFiles = () => queries.getStatusSlowStateMtimePaths(CONFIG);
1623
- let _lastMtimes = {}; // { filePath: mtimeMs } fast-state baseline
1624
- let _lastSlowMtimes = {}; // { filePath: mtimeMs } — slow-state baseline
1621
+ // The two source-of-truth lists live in `engine/queries.js`:
1622
+ // - getStatusFastStateMtimePathsengine-driven hot writes (dispatch,
1623
+ // work-items, PR files, watches, qa runs, inbox, notes, meetings, …).
1624
+ // - getStatusSlowStateMtimePaths slower-cadence writes (PRD JSON,
1625
+ // schedule/pipeline runs, pinned, skill discovery roots, MCP configs,
1626
+ // git refs, …).
1627
+ // We union them here. Adding a new tracked file means adding ONE line to
1628
+ // the appropriate list in queries.js — the dashboard side stays a thin
1629
+ // delegate that doesn't need to know about per-source semantics anymore.
1630
+ function _mtimeTrackedFiles() {
1631
+ const fast = queries.getStatusFastStateMtimePaths(CONFIG);
1632
+ const slow = queries.getStatusSlowStateMtimePaths(CONFIG);
1633
+ // Dedup with a Set — many paths (per-project work-items, git refs) appear
1634
+ // in both registries since both fast (workItems slice) and slow (PRD
1635
+ // progress derived from same data) read them.
1636
+ return Array.from(new Set([...fast, ...slow]));
1637
+ }
1638
+ let _lastMtimes = {}; // { filePath: mtimeMs } — baseline since last build
1625
1639
 
1626
1640
  // Stat a tracked path with transient-error tolerance. ENOENT (file/dir doesn't
1627
1641
  // exist) is normal — fresh installs, deleted projects, empty PRD dirs all hit
@@ -1648,23 +1662,12 @@ function _getMtimes() {
1648
1662
  return result;
1649
1663
  }
1650
1664
 
1651
- function _getSlowMtimes() {
1652
- const result = {};
1653
- for (const fp of _slowMtimeTrackedFiles()) {
1654
- result[fp] = _statMtimeMs(fp, _lastSlowMtimes);
1655
- }
1656
- return result;
1657
- }
1658
-
1659
- // Reset the per-source caches that outlive the slow-state TTL when a tracked
1660
- // source file changes (W-mphfdgwv000bf549). The slow-state mtime tracker now
1661
- // covers skill discovery dirs and MCP config files, but
1662
- // `queries._skillsCache` (30 s) and the local `_mcpServersCache` (5 min)
1663
- // would still serve stale data into `_buildStatusSlowState()` — defeating
1664
- // the <4 s freshness goal. Only call this when an mtime delta is detected;
1665
- // TTL-driven rebuilds keep using the inner caches so we don't pay disk-scan
1666
- // cost on every 60 s slow-state rollover.
1667
- function _invalidateSlowInnerCachesForMtimeChange() {
1665
+ // Reset the per-source caches whose TTL would otherwise serve stale data
1666
+ // across rebuilds. Skill files (`queries._skillsCache` 30s) and MCP servers
1667
+ // (`_mcpServersCache` 5min) are the two with TTLs longer than our poll
1668
+ // cadence, and both back slices the user sees on screen. Called on every
1669
+ // mtime-detected rebuild so the rebuild always reads fresh disk state.
1670
+ function _invalidateInnerCachesForRebuild() {
1668
1671
  try { queries.invalidateSkillsCache(); } catch { /* optional */ }
1669
1672
  _mcpServersCache = null;
1670
1673
  _mcpServersCacheTs = 0;
@@ -1681,18 +1684,15 @@ function _mtimesChanged(prev, curr) {
1681
1684
  return false;
1682
1685
  }
1683
1686
 
1684
- function invalidateStatusCache(opts) {
1685
- _fastState = null;
1686
- _fastStateTs = 0;
1687
- // Slow state continues on its own TTL by default mutations of slow-state data
1688
- // (pinned.md, schedules, etc.) must opt in via { includeSlow: true } for immediate visibility.
1689
- if (opts && opts.includeSlow) {
1690
- _slowState = null;
1691
- _slowStateTs = 0;
1692
- }
1687
+ // Flush the single status cache. `opts` is accepted (and ignored) for
1688
+ // backward-compat — callers used to pass `{ includeSlow: true }` when they
1689
+ // wanted to bust the slow tier; with a unified cache the option is moot.
1690
+ // Renamed to `_opts` so eslint stops warning about the unused param.
1691
+ function invalidateStatusCache(_opts) {
1693
1692
  _statusCache = null;
1694
1693
  _statusCacheJson = null;
1695
1694
  _statusCacheGzip = null;
1695
+ _lastMtimes = {};
1696
1696
  // Tell any in-flight refreshStatusAsync() that its result is stale and must
1697
1697
  // not be published. Bumping the generation also forces the next ETag to
1698
1698
  // differ from anything a client already has cached.
@@ -2098,64 +2098,26 @@ function _markStatusCacheBuilt() {
2098
2098
  }
2099
2099
 
2100
2100
  function getStatus() {
2101
- const now = Date.now();
2102
-
2103
- // Fast state: 10s TTL with mtime-based validation for early exit
2104
- let fastStale = !_fastState || (now - _fastStateTs) >= FAST_STATE_TTL;
2105
- if (!fastStale) {
2106
- // Within TTL — check mtimes for early return (skip rebuild if no tracked files changed)
2101
+ // Steady-state fast path: cache present + no tracked file changed → return
2102
+ // cached snapshot. Single mtime check covers both fast + slow tracker
2103
+ // contributions (see _mtimeTrackedFiles).
2104
+ if (_statusCache) {
2107
2105
  const currMtimes = _getMtimes();
2108
- if (_mtimesChanged(_lastMtimes, currMtimes)) fastStale = true;
2109
- }
2110
-
2111
- // Slow state: 60s TTL with mtime-based validation for early bust.
2112
- // The mtime tracker covers engine-driven slow-state writes (PRD updates,
2113
- // pipeline-runs.json, schedule-runs.json, verify guides, project skills)
2114
- // so changes surface within one SPA poll (~4 s) instead of waiting up to
2115
- // 60 s for TTL. Same pre-build snapshot semantics as fast-state below.
2116
- let slowStale = !_slowState || (now - _slowStateTs) >= SLOW_STATE_TTL;
2117
- let slowMtimeChanged = false;
2118
- if (!slowStale) {
2119
- const currSlowMtimes = _getSlowMtimes();
2120
- if (_mtimesChanged(_lastSlowMtimes, currSlowMtimes)) {
2121
- slowStale = true;
2122
- slowMtimeChanged = true;
2123
- }
2124
- }
2125
-
2126
- // If nothing stale, return cached merged result
2127
- if (!fastStale && !slowStale && _statusCache) return _statusCache;
2128
-
2129
- // Rebuild fast state (frequently-changing data: ~12-15 reads)
2130
- if (fastStale) {
2131
- // Reload config on fast-state miss — picks up external changes (minions init, minions add)
2132
- reloadConfig();
2133
- // Snapshot mtimes BEFORE the rebuild reads disk. If an engine write lands
2134
- // mid-rebuild (dispatch.json, work-items.json, pull-requests.json), the
2135
- // snapshot reflects pre-write state, but capturing _lastMtimes AFTER would
2136
- // record the post-write mtime — silently masking the change. Subsequent
2137
- // polls would then see no mtime delta and return 304 with stale data until
2138
- // FAST_STATE_TTL expires. Capturing pre-build at worst forces one extra
2139
- // (no-op) rebuild on the next poll; capturing post-build loses the update.
2140
- const preBuildMtimes = _getMtimes();
2141
- _fastState = _buildStatusFastState();
2142
- _fastStateTs = now;
2143
- _lastMtimes = preBuildMtimes;
2144
- }
2145
-
2146
- // Rebuild slow state (rarely-changing data: ~8-15 reads, 60s TTL).
2147
- // Same pre-build snapshot pattern as fast-state — capture mtimes BEFORE
2148
- // disk reads so any write landing mid-build busts the next poll.
2149
- if (slowStale) {
2150
- if (slowMtimeChanged) _invalidateSlowInnerCachesForMtimeChange();
2151
- const preBuildSlowMtimes = _getSlowMtimes();
2152
- _slowState = _buildStatusSlowState();
2153
- _slowStateTs = now;
2154
- _lastSlowMtimes = preBuildSlowMtimes;
2155
- }
2156
-
2157
- // Merge both tiers — no API contract change
2158
- _statusCache = { ..._fastState, ..._slowState, timestamp: new Date().toISOString() };
2106
+ if (!_mtimesChanged(_lastMtimes, currMtimes)) return _statusCache;
2107
+ }
2108
+ // Stale or first-call: rebuild everything. Reload config first so newly-
2109
+ // added projects / agents land before the slice builders read them.
2110
+ reloadConfig();
2111
+ // Snapshot mtimes BEFORE the rebuild reads disk. If an engine write lands
2112
+ // mid-rebuild, the post-build _getMtimes() would record the post-write
2113
+ // mtime silently masking the change. Pre-build snapshot at worst forces
2114
+ // one extra (no-op) rebuild on the next poll; post-build loses the update.
2115
+ const preBuildMtimes = _getMtimes();
2116
+ _invalidateInnerCachesForRebuild();
2117
+ const fast = _buildStatusFastState();
2118
+ const slow = _buildStatusSlowState();
2119
+ _statusCache = { ...fast, ...slow, timestamp: new Date().toISOString() };
2120
+ _lastMtimes = preBuildMtimes;
2159
2121
  _markStatusCacheBuilt();
2160
2122
  return _statusCache;
2161
2123
  }
@@ -2221,74 +2183,50 @@ function refreshStatusAsync() {
2221
2183
  let fastBuildMs = 0;
2222
2184
  let slowBuildMs = 0;
2223
2185
  try {
2224
- const now = Date.now();
2225
2186
  const startGeneration = _statusInvalidationGeneration;
2226
2187
 
2227
- let fastStale = !_fastState || (now - _fastStateTs) >= FAST_STATE_TTL;
2228
- if (!fastStale) {
2188
+ // Steady-state fast path same single mtime check as the sync getStatus.
2189
+ if (_statusCache) {
2229
2190
  const currMtimes = _getMtimes();
2230
- if (_mtimesChanged(_lastMtimes, currMtimes)) fastStale = true;
2231
- }
2232
- let slowStale = !_slowState || (now - _slowStateTs) >= SLOW_STATE_TTL;
2233
- let slowMtimeChanged = false;
2234
- if (!slowStale) {
2235
- const currSlowMtimes = _getSlowMtimes();
2236
- if (_mtimesChanged(_lastSlowMtimes, currSlowMtimes)) {
2237
- slowStale = true;
2238
- slowMtimeChanged = true;
2191
+ if (!_mtimesChanged(_lastMtimes, currMtimes)) {
2192
+ if (profile) {
2193
+ _emitStatusTiming({
2194
+ phase: 'cache-hit',
2195
+ total: Number(process.hrtime.bigint() - tOverall) / 1e6,
2196
+ cacheV: _statusCacheVersion,
2197
+ });
2198
+ }
2199
+ return _statusCache;
2239
2200
  }
2240
2201
  }
2241
2202
 
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
- }
2252
-
2253
- let fast = _fastState;
2254
- // Pre-build mtime snapshot (see getStatus() for the rationale). The async
2255
- // path has an even wider race window than the sync path because of the
2256
- // `await _yieldEventLoop()` below — any write that lands between disk
2257
- // reads and the post-build `_getMtimes()` capture would be lost.
2258
- let preBuildMtimes = null;
2259
- if (fastStale) {
2260
- reloadConfig();
2261
- preBuildMtimes = _getMtimes();
2262
- const tFast = profile ? process.hrtime.bigint() : null;
2263
- fast = _buildStatusFastState();
2264
- if (profile) fastBuildMs = Number(process.hrtime.bigint() - tFast) / 1e6;
2265
- }
2266
-
2267
- // Unconditional cooperative yield between phases — guarantees the event
2268
- // loop is available to SSE heartbeats / other I/O at least once mid-rebuild
2269
- // regardless of whether the test hook is installed. Combined with the
2270
- // optional async hook below, also lets stress tests inject artificial
2271
- // delay to simulate a slow filesystem without freezing the loop.
2203
+ // Stale or first-call rebuild. Reload config first so newly-added
2204
+ // projects/agents land before the slice builders see them. Pre-build
2205
+ // mtime snapshot (rationale: a write that lands mid-rebuild would be
2206
+ // silently masked if we snapshotted post-build).
2207
+ reloadConfig();
2208
+ const preBuildMtimes = _getMtimes();
2209
+ _invalidateInnerCachesForRebuild();
2210
+ const tFast = profile ? process.hrtime.bigint() : null;
2211
+ const fast = _buildStatusFastState();
2212
+ if (profile) fastBuildMs = Number(process.hrtime.bigint() - tFast) / 1e6;
2213
+
2214
+ // Cooperative yield between the fast + slow halves — guarantees the
2215
+ // event loop is available to SSE heartbeats / other I/O at least once
2216
+ // mid-rebuild regardless of whether the test hook is installed.
2272
2217
  await _yieldEventLoop();
2273
2218
  if (typeof _statusRefreshHook === 'function') {
2274
2219
  try { await _statusRefreshHook(); } catch { /* hook errors must not break rebuild */ }
2275
2220
  }
2276
2221
 
2277
- let slow = _slowState;
2278
- let preBuildSlowMtimes = null;
2279
- if (slowStale) {
2280
- if (slowMtimeChanged) _invalidateSlowInnerCachesForMtimeChange();
2281
- preBuildSlowMtimes = _getSlowMtimes();
2282
- const tSlow = profile ? process.hrtime.bigint() : null;
2283
- slow = _buildStatusSlowState();
2284
- if (profile) slowBuildMs = Number(process.hrtime.bigint() - tSlow) / 1e6;
2285
- }
2222
+ const tSlow = profile ? process.hrtime.bigint() : null;
2223
+ const slow = _buildStatusSlowState();
2224
+ if (profile) slowBuildMs = Number(process.hrtime.bigint() - tSlow) / 1e6;
2286
2225
 
2287
2226
  // Invalidation-race guard: if an invalidation fired during the await
2288
2227
  // window, this rebuild was based on potentially stale signals — drop
2289
2228
  // the result silently. _statusCache stays as invalidated (null) so the
2290
- // next sync getStatus() OR async refreshStatusAsync() rebuilds fresh
2291
- // against the post-invalidate generation.
2229
+ // next sync getStatus() OR async refreshStatusAsync() rebuilds fresh.
2292
2230
  if (_statusInvalidationGeneration !== startGeneration) {
2293
2231
  if (profile) {
2294
2232
  _emitStatusTiming({
@@ -2301,24 +2239,15 @@ function refreshStatusAsync() {
2301
2239
  return _statusCache;
2302
2240
  }
2303
2241
 
2304
- if (fastStale) {
2305
- _fastState = fast;
2306
- _fastStateTs = now;
2307
- _lastMtimes = preBuildMtimes;
2308
- }
2309
- if (slowStale) {
2310
- _slowState = slow;
2311
- _slowStateTs = now;
2312
- _lastSlowMtimes = preBuildSlowMtimes;
2313
- }
2314
- _statusCache = { ..._fastState, ..._slowState, timestamp: new Date().toISOString() };
2242
+ _statusCache = { ...fast, ...slow, timestamp: new Date().toISOString() };
2243
+ _lastMtimes = preBuildMtimes;
2315
2244
  _markStatusCacheBuilt();
2316
2245
  if (profile) {
2317
2246
  _emitStatusTiming({
2318
2247
  phase: 'rebuilt',
2319
2248
  fastBuild: fastBuildMs, slowBuild: slowBuildMs,
2320
2249
  total: Number(process.hrtime.bigint() - tOverall) / 1e6,
2321
- fastStale, slowStale, cacheV: _statusCacheVersion,
2250
+ cacheV: _statusCacheVersion,
2322
2251
  });
2323
2252
  }
2324
2253
  return _statusCache;
@@ -2340,10 +2269,6 @@ function _getStatusCacheVersion() {
2340
2269
  return _statusCacheVersion;
2341
2270
  }
2342
2271
  function _resetStatusCacheForTesting() {
2343
- _fastState = null;
2344
- _fastStateTs = 0;
2345
- _slowState = null;
2346
- _slowStateTs = 0;
2347
2272
  _statusCache = null;
2348
2273
  _statusCacheJson = null;
2349
2274
  _statusCacheGzip = null;
@@ -2352,7 +2277,6 @@ function _resetStatusCacheForTesting() {
2352
2277
  _statusInvalidationGeneration = 0;
2353
2278
  _statusRefreshHook = null;
2354
2279
  _lastMtimes = {};
2355
- _lastSlowMtimes = {};
2356
2280
  }
2357
2281
 
2358
2282
  /** Return cached JSON string of status — single stringify, reused by SSE and /api/status */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2062",
3
+ "version": "0.1.2064",
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"