@yemi33/minions 0.1.2087 → 0.1.2089

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.
@@ -2,43 +2,109 @@
2
2
 
3
3
  // Sidebar activity indicators — detect changes between refreshes
4
4
  // Registry: add one line per page. Counter returns a value; badge shows when value increases.
5
+ // Sidebar activity-dot signatures. Each function returns a STRING (sig
6
+ // computed from the page's underlying state) OR `null` when the cache
7
+ // isn't populated yet — null suppresses the comparison so a slice going
8
+ // from undefined → populated does NOT flip the dot on the first tick
9
+ // after a fetch resolves. Once a signature is computable, it's compared
10
+ // against the previously-stored one; any change fires the dot.
11
+ //
12
+ // All migrated slices read from window._lastX globals set inside async
13
+ // /api/<x> .then handlers (issue #2949). Returning null while those
14
+ // globals are still undefined prevents the timing race where _detectPage-
15
+ // Changes runs synchronously at the end of _processStatusUpdate before
16
+ // the .then handlers have populated the caches.
17
+ //
18
+ // dispatch.completed is capped at 20 server-side, so length-based counters
19
+ // peg once the cap is hit; we fingerprint the LAST completed entry's id +
20
+ // completed_at instead so a new completion rotates the signature whether
21
+ // the cap is reached or not.
5
22
  const _pageCounters = {
6
- home: function(d) { return (d.dispatch?.completed || []).length; },
7
- // work signature: total + in-flight count. State transitions (pending /
8
- // dispatched → done) flip the dot; per-item title/updatedAt churn doesn't (F9/S4).
9
- work: function(d) { return (d.workItems || []).length + '|' + (d.workItems || []).filter(function(w) { return w.status === 'pending' || w.status === 'dispatched'; }).length; },
10
- plans: function(d) { return (d.prdProgress?.complete || 0) + '|' + (d.plans || []).length + '|' + (d.plans || []).map(function(p) { return p.status || ''; }).join(','); },
11
- prs: function(d) { return (d.pullRequests || []).length + '|' + (d.pullRequests || []).filter(function(p) { return p.status === 'merged'; }).length; },
12
- inbox: function(d) { return (d.inbox || []).length + '|' + (d.notes?.content || '').length; },
13
- // watches signature: count + max(last_triggered). Dot fires when any watch
14
- // triggers or the count changes; triggerCount removed because it advances
15
- // on the same event as last_triggered (F9/S4).
16
- watches: function(d) { return (d.watches || []).length + '|' + (d.watches || []).reduce(function(m, w) { return Math.max(m, new Date(w.last_triggered || 0).getTime() || 0); }, 0); },
17
- // meetings signature: full count + sum of all rounds. Uses meetingsTotal
18
- // (top-level full count of meetings on disk) NOT meetings.length — the
19
- // latter is the slim slice which drops terminal meetings >7d via
20
- // statusMeetingsRetentionDays, so an archived meeting reaching round 3
21
- // would silently fail to flip the dot. Round-sum stays on the slim slice
22
- // (we don't track per-meeting round in meetingsTotal) — operators who
23
- // care about archived-meeting round transitions can crank the retention
24
- // window or set it to 0. (W-mphlrxx6000a8760)
25
- meetings: function(d) { return (d.meetingsTotal ?? (d.meetings || []).length) + '|' + (d.meetings || []).reduce(function(s, m) { return s + (m.round || 0); }, 0); },
26
- pipelines: function(d) { return (d.pipelines || []).length + '|' + (d.pipelines || []).reduce(function(s, p) { return s + (p.runs || []).length; }, 0); },
27
- schedule: function(d) { return (d.schedules || []).length; },
28
- // tools signature: skills count + mcp servers count.
29
- tools: function(d) { return (d.skills || []).length + '|' + (d.mcpServers || []).length; },
30
- engine: function(d) { return (d.dispatch?.completed || []).filter(function(c) { return c.result === 'error'; }).length; },
31
- qa: function(d) { return (d.qaRuns?.total || 0) + '|' + (d.qaRuns?.sig || ''); },
23
+ home: function(d) {
24
+ // dispatch is on /api/dispatch — wait for the cache.
25
+ const dispatch = window._lastDispatch || d.dispatch;
26
+ if (!dispatch) return null;
27
+ const completed = dispatch.completed || [];
28
+ const last = completed[completed.length - 1];
29
+ return completed.length + '|' + (last?.id || '') + '|' + (last?.completed_at || '');
30
+ },
31
+ work: function(d) {
32
+ if (!Array.isArray(window._lastWorkItems) && !Array.isArray(d.workItems)) return null;
33
+ const wis = window._lastWorkItems || d.workItems || [];
34
+ return wis.length + '|' + wis.filter(function(w) { return w.status === 'pending' || w.status === 'dispatched'; }).length;
35
+ },
36
+ plans: function(d) {
37
+ // /api/plans → window._lastPlans (refreshPlans()); /api/prd →
38
+ // window._lastPrdProgress. Both must be populated before we sig.
39
+ if (!Array.isArray(window._lastPlans) || window._lastPrdProgress === undefined) return null;
40
+ const prog = window._lastPrdProgress || {};
41
+ const plans = window._lastPlans;
42
+ return (prog.complete || 0) + '|' + plans.length + '|' + plans.map(function(p) { return (p.file || '') + ':' + (p.status || ''); }).join(',');
43
+ },
44
+ prs: function(d) {
45
+ if (!Array.isArray(window._lastPullRequests) && !Array.isArray(d.pullRequests)) return null;
46
+ const prs = window._lastPullRequests || d.pullRequests || [];
47
+ const counts = {};
48
+ for (const p of prs) counts[p.status || ''] = (counts[p.status || ''] || 0) + 1;
49
+ return prs.length + '|' + Object.keys(counts).sort().map(function(k) { return k + ':' + counts[k]; }).join(',');
50
+ },
51
+ inbox: function(d) {
52
+ // Wait for BOTH inbox and notes to populate — either alone gives a
53
+ // half-stale sig on first arrival.
54
+ if (!Array.isArray(window._lastInbox) || window._lastNotes === undefined) return null;
55
+ const ib = window._lastInbox;
56
+ const notes = window._lastNotes || {};
57
+ return ib.length + '|' + (notes.content || '').length;
58
+ },
59
+ watches: function(d) {
60
+ if (!Array.isArray(window._lastWatches) && !Array.isArray(d.watches)) return null;
61
+ const ws = window._lastWatches || d.watches || [];
62
+ return ws.length + '|' + ws.reduce(function(m, w) { return Math.max(m, new Date(w.last_triggered || 0).getTime() || 0); }, 0);
63
+ },
64
+ meetings: function(d) {
65
+ // Meetings total comes from /api/meetings-total; list from /state/meetings.
66
+ if (window._lastMeetingsTotal === undefined && d.meetingsTotal === undefined) return null;
67
+ const total = window._lastMeetingsTotal ?? d.meetingsTotal ?? 0;
68
+ const list = window._lastMeetings || d.meetings || [];
69
+ return total + '|' + list.reduce(function(s, m) { return s + (m.round || 0); }, 0);
70
+ },
71
+ pipelines: function(d) {
72
+ if (!Array.isArray(window._lastPipelines) && !Array.isArray(d.pipelines)) return null;
73
+ const pls = window._lastPipelines || d.pipelines || [];
74
+ return pls.length + '|' + pls.reduce(function(s, p) { return s + (p.runs || []).length; }, 0);
75
+ },
76
+ schedule: function(d) {
77
+ if (!Array.isArray(window._lastSchedules) && !Array.isArray(d.schedules)) return null;
78
+ const sch = window._lastSchedules || d.schedules || [];
79
+ return sch.length + '|' + sch.map(function(s) { return (s.id || '') + ':' + (s.enabled === false ? '0' : '1') + ':' + (s.cron || ''); }).sort().join(',');
80
+ },
81
+ tools: function(d) {
82
+ // skills + mcpServers are on /api/status directly, no cache to wait for.
83
+ return (d.skills || []).length + '|' + (d.mcpServers || []).length;
84
+ },
85
+ engine: function(d) {
86
+ const dispatch = window._lastDispatch || d.dispatch;
87
+ if (!dispatch) return null;
88
+ const errors = (dispatch.completed || []).filter(function(c) { return c.result === 'error'; });
89
+ const lastErr = errors[errors.length - 1];
90
+ return errors.length + '|' + (lastErr?.id || '') + '|' + (lastErr?.completed_at || '');
91
+ },
92
+ qa: function(d) {
93
+ if (!window._lastQaRunsSummary && !d.qaRuns) return null;
94
+ const q = window._lastQaRunsSummary || d.qaRuns || {};
95
+ return (q.total || 0) + '|' + (q.sig || '');
96
+ },
32
97
  };
33
98
  let _prevCounts = {};
34
99
  function _detectPageChanges(data) {
35
100
  var changes = {};
36
- var counts = {};
37
101
  for (var page in _pageCounters) {
38
- counts[page] = String(_pageCounters[page](data));
39
- if (_prevCounts[page] !== undefined && counts[page] !== _prevCounts[page]) changes[page] = true;
102
+ var sig = _pageCounters[page](data);
103
+ if (sig === null) continue; // cache not ready yet — skip both compare and record
104
+ var sigStr = String(sig);
105
+ if (_prevCounts[page] !== undefined && sigStr !== _prevCounts[page]) changes[page] = true;
106
+ _prevCounts[page] = sigStr;
40
107
  }
41
- _prevCounts = counts;
42
108
  return changes;
43
109
  }
44
110
 
@@ -116,7 +182,6 @@ let _lastChangedFlags = null;
116
182
  function _safeRender(name, fn) {
117
183
  try { fn(); }
118
184
  catch (e) {
119
- // eslint-disable-next-line no-console
120
185
  console.error('[render] ' + name + ' threw:', e);
121
186
  }
122
187
  }
@@ -246,23 +311,100 @@ function _startNextTickTicker() {
246
311
  _engineNextTickTimer = setInterval(_updateNextTickChip, 1000);
247
312
  }
248
313
 
249
- function _processStatusUpdate(data) {
250
- // Detect fresh install — clear stale browser state AND reload so module-scoped
251
- // JS caches (_sectionCache, _prevCounts, _managedProcessesLastItems,
252
- // _pipelinePollHash, _meetingPollHash, etc.) reinitialise against the new install's
253
- // data shape. localStorage.clear() alone leaves those caches stale after a
254
- // MINIONS_HOME swap that doesn't restart the dashboard (F11 / W-mpgcijjo000ce878).
255
- if (data.installId) {
256
- const prev = localStorage.getItem('minions-install-id');
257
- if (prev && prev !== data.installId) {
258
- localStorage.clear();
259
- // Write the new install-id BEFORE reload — the post-reload poll must see
260
- // a matching id so it stops looping. clear() above wiped it, so set it again.
261
- localStorage.setItem('minions-install-id', data.installId);
262
- console.log('Minions: fresh install detected, reloading to reset module caches');
263
- location.reload();
264
- return;
314
+ // Background-poll the sidebar-counter summaries from their dedicated
315
+ // endpoints (issue #2949). Results land on window._last* globals which
316
+ // _pageCounters reads with a /api/status field fallback. Fire-and-forget
317
+ // — no awaits — so this never blocks the synchronous render chain. The
318
+ // fetched values arrive ~1 tick late on the FIRST refresh after page load;
319
+ // subsequent ticks see fresh signatures from the dedicated endpoints with
320
+ // no /api/status outer-cache staleness.
321
+ // Fire the F1/F3 cross-slice triggers when work-items / pull-requests /
322
+ // dispatch resolves with NEW data on this tick. Cross-slice dependency graph:
323
+ // workItems → {prs (+N follow-up chip), plans (derivePlanStatus)}
324
+ // pullRequests → {plans (derivePlanStatus)}
325
+ // dispatch → {plans (derivePlanStatus reads .active/.pending)} (Round-3 #5.)
326
+ //
327
+ // Signature is a CHEAP fingerprint derived from key fields, NOT a full
328
+ // JSON.stringify — the latter allocated multi-MB strings on every tick
329
+ // for the typical WI/PR payloads and contradicted the dashboard event-loop
330
+ // budget. Catch-fallback uses a STABLE token (not Date.now()) so a payload
331
+ // whose getter throws doesn't perpetually re-fire. (Round-4 #1 + #3.)
332
+ const _crossSliceSig = { workItems: '', pullRequests: '', dispatch: '' };
333
+ function _cheapSig(value) {
334
+ try {
335
+ if (!value) return 'null';
336
+ if (Array.isArray(value)) {
337
+ const head = value[0] || {};
338
+ const tail = value[value.length - 1] || {};
339
+ return value.length + '|' + (head.id || '') + ':' + (head.status || head.state || '') + '|' + (tail.id || '') + ':' + (tail.status || tail.state || head.updatedAt || '');
265
340
  }
341
+ if (typeof value === 'object') {
342
+ // Dispatch shape: {pending, active, completed} — fingerprint the lengths
343
+ // + first id of each so any insertion/removal advances the sig.
344
+ const p = Array.isArray(value.pending) ? value.pending : [];
345
+ const a = Array.isArray(value.active) ? value.active : [];
346
+ const c = Array.isArray(value.completed) ? value.completed : [];
347
+ return 'd:' + p.length + ':' + (p[0]?.id || '') + '/' + a.length + ':' + (a[0]?.id || '') + '/' + c.length + ':' + (c[c.length - 1]?.id || '');
348
+ }
349
+ return String(value);
350
+ } catch { return '__err__'; }
351
+ }
352
+ // F3 coalescer: batch multiple advancing-key calls into ONE renderPlans per
353
+ // tick. Without this, a tick where all three of workItems/pullRequests/
354
+ // dispatch advance would re-render plans 3-4 times back-to-back for
355
+ // identical output. (Round-4 #2.)
356
+ let _planRerenderQueued = false;
357
+ function _queuePlanRerender() {
358
+ if (_planRerenderQueued) return;
359
+ _planRerenderQueued = true;
360
+ Promise.resolve().then(function () {
361
+ _planRerenderQueued = false;
362
+ if (Array.isArray(window._lastPlans) && typeof renderPlans === 'function') {
363
+ _safeRender('plans:cross-slice', function() { renderPlans(window._lastPlans); });
364
+ }
365
+ });
366
+ }
367
+ function _fireCrossSliceRender(key, value) {
368
+ const sig = _cheapSig(value);
369
+ if (_crossSliceSig[key] === sig) return false;
370
+ _crossSliceSig[key] = sig;
371
+ // F1 — re-render PRs (+N follow-up chip count depends on the WI list).
372
+ // Fires for both workItems and pullRequests keys so a late PR fetch
373
+ // refreshes the +N chip against the cached WI list. (Round-3 #7.)
374
+ if ((key === 'workItems' || key === 'pullRequests') && Array.isArray(window._lastPullRequests)) {
375
+ _safeRender('prs:cross-slice', function() { renderPrs(window._lastPullRequests); });
376
+ }
377
+ // F3 — coalesced via microtask so 3 advancing keys → 1 plans render.
378
+ _queuePlanRerender();
379
+ return true;
380
+ }
381
+
382
+ function _refreshSidebarCounterSummaries() {
383
+ fetch('/api/qa-runs-summary')
384
+ .then(function (r) { return r.ok ? r.json() : null; })
385
+ .then(function (s) { if (s && typeof s === 'object') window._lastQaRunsSummary = s; })
386
+ .catch(function () { /* sidebar counter degrades to /api/status fallback */ });
387
+ fetch('/api/meetings-total')
388
+ .then(function (r) { return r.ok ? r.json() : null; })
389
+ .then(function (s) { if (s && typeof s.total === 'number') window._lastMeetingsTotal = s.total; })
390
+ .catch(function () { /* optional */ });
391
+ // /api/verify-guides is now fetched in parallel with /api/prd inside the
392
+ // prdProgress block so renderPrd's guideByPlan lookup uses the SAME tick's
393
+ // guide list as the PRD progress data. (Round-2 review finding #5.)
394
+ // QA sessions summary was previously fetched here, but no _pageCounters
395
+ // entry read it — drop the dead endpoint + fetch. (Review finding #9.)
396
+ }
397
+
398
+ function _processStatusUpdate(data, opts) {
399
+ opts = opts || {};
400
+ // Kick off the dedicated-endpoint summary refreshes (issue #2949). They
401
+ // run in the background and update window._last* globals that the sidebar
402
+ // _pageCounters read. No await — the current tick continues immediately.
403
+ _refreshSidebarCounterSummaries();
404
+ // Fresh install id tracking — kept ONLY to update localStorage so the
405
+ // value is available to other code that introspects it; the tab does
406
+ // NOT auto-reload on a MINIONS_HOME swap. User decides when to refresh.
407
+ if (data.installId) {
266
408
  localStorage.setItem('minions-install-id', data.installId);
267
409
  }
268
410
  // Always update cheap elements
@@ -305,8 +447,12 @@ function _processStatusUpdate(data) {
305
447
  // refactor that adds a window._last* read to those renderers will see
306
448
  // fresh data on the same tick, not the previous one. Covered by
307
449
  // dashboard-resilience.test.js source-inspection assertions.
308
- window._lastDispatch = data.dispatch;
309
- window._lastWorkItems = data.workItems || [];
450
+ // window._lastDispatch + window._lastWorkItems are now exclusively set
451
+ // inside the /api/dispatch + /api/work-items .then handlers (issue #2949).
452
+ // The hoist here used to read data.dispatch / data.workItems from the
453
+ // /api/status slim slices — both undefined post-slim — and overwrite the
454
+ // freshly-fetched values from the prior tick with undefined/[], which
455
+ // caused first-tick sidebar-dot flashes on every migrated page.
310
456
  window._lastStatus = data;
311
457
 
312
458
 
@@ -318,24 +464,113 @@ function _processStatusUpdate(data) {
318
464
  // cycled out of the slim /api/status payload. Per-call try/catch keeps
319
465
  // each renderer independent; throws log to Console (no UI banner).
320
466
  //
321
- // We KEEP the `_changed(...)` CALLS (their side effect of populating
322
- // `_lastChangedFlags` is still load-bearing for the diag ring-buffer
323
- // and for the `_workItemsChanged` / `_prsChanged` cross-slice triggers
324
- // below — F1 / F3 / W-mpgb0xbh000e3b86) but no longer use the return
325
- // value to gate the render. Each renderer is a contained DOM rewrite
326
- // (~ a few KB of HTML); ~10–20 of them per 4s tick is well under one
327
- // frame's budget.
328
- _changed('agents', data.agents);
329
- _safeRender('agents', function() { renderAgents(data.agents); });
330
- _safeRender('cmdUpdateAgentList', function() { cmdUpdateAgentList(data.agents); });
331
- // prdProgress + prdPrs are captured together so both flags publish to
332
- // the diag buffer; the renderer + cachePrdItems run unconditionally.
333
- _changed('prdProgress', data.prdProgress);
334
- _changed('prdPrs', data.pullRequests?.length);
335
- _safeRender('prdProgress', function() { renderPrdProgress(data.prdProgress); });
336
- _safeRender('cachePrdItems', function() { _cachePrdItems(data.prdProgress); });
337
- _changed('inbox', data.inbox);
338
- _safeRender('inbox', function() { renderInbox(data.inbox || []); });
467
+ // The per-slice _changed(key, data[key]) calls were dropped for every
468
+ // migrated slice (issue #2949) — data[key] is undefined post-slim, so
469
+ // every call fed a permanently-stable 'undefined' signature into the
470
+ // section cache that no consumer reads. The F1/F3 cross-slice triggers
471
+ // that previously gated on _workItemsChanged / _prsChanged moved into
472
+ // the /api/work-items + /api/pull-requests .then handlers
473
+ // (_fireCrossSliceRender) where the FRESH fetched lists are available.
474
+ // Agents now come from /api/agents — a dedicated fresh-JSON endpoint with
475
+ // input-mtime ETag (issue #2949). No /api/status outer cache in the path.
476
+ // cmdUpdateAgentList consumes the same slice but renders the command-line
477
+ // chip pop-up so we feed it the fresh array too.
478
+ _safeRender('agents', function() {
479
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
480
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
481
+ window._lastRequestedSeq.agents = seq;
482
+ fetch('/api/agents')
483
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
484
+ .then(function (fresh) {
485
+ if (seq < (window._lastRequestedSeq.agents || 0)) return;
486
+ const list = Array.isArray(fresh) ? fresh : window._lastAgents;
487
+ if (list) window._lastAgents = list;
488
+ _safeRender('agents', function() { renderAgents(list); });
489
+ _safeRender('cmdUpdateAgentList', function() { cmdUpdateAgentList(list); });
490
+ })
491
+ .catch(function () {
492
+ // Re-render against last known good data; leave DOM as-is on cold
493
+ // start. (Review finding #4.)
494
+ if (window._lastAgents) {
495
+ _safeRender('agents', function() { renderAgents(window._lastAgents); });
496
+ _safeRender('cmdUpdateAgentList', function() { cmdUpdateAgentList(window._lastAgents); });
497
+ }
498
+ });
499
+ });
500
+ // PRD slice now comes from /api/prd — a dedicated fresh-JSON endpoint
501
+ // that re-runs getPrdInfo() on every request with input-mtime ETag.
502
+ // Returns { status, progress }. prdProgress + the standalone PRD
503
+ // section share one fetch so they always agree on the same tick.
504
+ // /api/prd returns { status, progress }. prdProgress, the standalone PRD
505
+ // section, and the cross-tick item cache all run inside ONE .then so they
506
+ // see the same fresh data on the same tick — previously the standalone
507
+ // renderPrd block ran synchronously, BEFORE this .then resolved, and
508
+ // always saw the prior tick's stash (or undefined on first render).
509
+ // (Review finding #10.)
510
+ _safeRender('prdProgress', function() {
511
+ // Fetch /api/prd and /api/verify-guides IN PARALLEL so renderPrd's
512
+ // verifyGuides lookup uses the SAME tick's guide list as the PRD
513
+ // progress data. Previously /api/verify-guides was fetched separately
514
+ // by _refreshSidebarCounterSummaries with no ordering guarantee,
515
+ // causing a one-tick lag whenever a fresh guide dropped alongside a
516
+ // PRD status update. (Round-2 review finding #5.)
517
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
518
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
519
+ window._lastRequestedSeq.prd = seq;
520
+ Promise.all([
521
+ fetch('/api/prd').then(function (r) { return r.ok ? r.json() : null; }),
522
+ fetch('/api/verify-guides').then(function (r) { return r.ok ? r.json() : null; }),
523
+ ])
524
+ .then(function (results) {
525
+ // Drop late arrivals — out-of-order resolution from a prior tick
526
+ // must not clobber the current tick's window._last* state.
527
+ // (Round-2 review finding #6.)
528
+ if (seq < (window._lastRequestedSeq.prd || 0)) return;
529
+ const prdInfo = results[0];
530
+ const guides = results[1];
531
+ const status = prdInfo && prdInfo.status != null ? prdInfo.status : window._lastPrdStatus;
532
+ const progress = prdInfo && prdInfo.progress != null ? prdInfo.progress : window._lastPrdProgress;
533
+ window._lastPrdStatus = status;
534
+ window._lastPrdProgress = progress;
535
+ if (Array.isArray(guides)) window._lastVerifyGuides = guides;
536
+ _safeRender('prdProgress', function() { renderPrdProgress(progress); });
537
+ _safeRender('cachePrdItems', function() { _cachePrdItems(progress); });
538
+ // renderPrd handles null status/progress gracefully (early-returns
539
+ // on falsy prd, uses progress?.items || []). Calling it
540
+ // unconditionally is required for the "last PRD archived" case to
541
+ // clear the previously-rendered list — round-2 #10's guard was
542
+ // too aggressive and trapped stale content on screen.
543
+ // (Round-3 #3.)
544
+ _safeRender('prd', function() { renderPrd(status, progress); });
545
+ })
546
+ .catch(function () {
547
+ // `!== undefined` (NOT a truthy check) so the cache-was-null
548
+ // post-archive state still triggers a re-render against null —
549
+ // renderPrd's own null-handling paints the empty 'No PRD found'
550
+ // affordance. Truthy gating would conflate cache-was-null with
551
+ // never-fetched. (Round-3 #8.)
552
+ if (window._lastPrdProgress !== undefined) {
553
+ _safeRender('prdProgress', function() { renderPrdProgress(window._lastPrdProgress); });
554
+ _safeRender('cachePrdItems', function() { _cachePrdItems(window._lastPrdProgress); });
555
+ _safeRender('prd', function() { renderPrd(window._lastPrdStatus, window._lastPrdProgress); });
556
+ }
557
+ });
558
+ });
559
+ // Inbox is now sourced from /state/notes/inbox (dir listing + per-file
560
+ // content fetch) instead of the /api/status inbox slice (issue #2949).
561
+ // Falls back to data.inbox when the fetch returns null (offline, network
562
+ // race, dir missing) so the renderer is never starved.
563
+ _safeRender('inbox', function() {
564
+ Promise.resolve(fetchInboxFromDisk())
565
+ .then(function (fresh) {
566
+ const list = fresh && fresh.length ? fresh : (window._lastInbox || []);
567
+ window._lastInbox = list;
568
+ _safeRender('inbox', function() { renderInbox(list); });
569
+ })
570
+ .catch(function () {
571
+ if (window._lastInbox) _safeRender('inbox', function() { renderInbox(window._lastInbox); });
572
+ });
573
+ });
339
574
  _changed('projects', data.projects);
340
575
  _safeRender('cmdUpdateProjectList', function() { cmdUpdateProjectList(data.projects || []); });
341
576
  _safeRender('projects', function() { renderProjects(data.projects || []); });
@@ -346,18 +581,76 @@ function _processStatusUpdate(data) {
346
581
  if (typeof renderFre === 'function') {
347
582
  _safeRender('fre', function() { renderFre(data); });
348
583
  }
349
- _changed('notes', data.notes);
350
- _safeRender('notes', function() { renderNotes(data.notes); });
351
- _changed('prd', [data.prd, data.prdProgress]);
352
- _safeRender('prd', function() { renderPrd(data.prd, data.prdProgress); });
353
- // Capture prs + workItems change signals once — also reused by the cross-slice
354
- // render triggers at the bottom of this function (F1/F3, W-mpgb0xbh000e3b86).
355
- // _changed mutates _sectionCache so it must be called exactly once per key.
356
- var _prsChanged = _changed('prs', data.pullRequests);
357
- var _workItemsChanged = _changed('workItems', data.workItems);
358
- _safeRender('prs', function() { renderPrs(data.pullRequests || []); });
359
- _changed('archivedPrds', data.archivedPrds);
360
- _safeRender('archiveButtons', function() { renderArchiveButtons(data.archivedPrds || []); });
584
+ // Notes file (notes.md) is now sourced from /state/notes.md. Same shape
585
+ // as the legacy /api/status notes slice ({ content, updatedAt }); the
586
+ // ETag mtime is parsed out of the response header for updatedAt.
587
+ _safeRender('notes', function() {
588
+ Promise.resolve(fetchNotesFromDisk())
589
+ .then(function (fresh) {
590
+ const notes = fresh || window._lastNotes;
591
+ if (notes) window._lastNotes = notes;
592
+ _safeRender('notes', function() { renderNotes(notes); });
593
+ })
594
+ .catch(function () {
595
+ if (window._lastNotes) _safeRender('notes', function() { renderNotes(window._lastNotes); });
596
+ });
597
+ });
598
+ // _changed('prd', ...) used to gate a now-deleted synchronous renderPrd
599
+ // block. Dropped along with the renderer (issue #2949 + Round-2 finding
600
+ // #1) — leaving a permanently-stable signature in _sectionCache would
601
+ // silently break any future renderer wired to the same key.
602
+ //
603
+ // F1/F3 cross-slice triggers used to be driven by _changed('workItems',
604
+ // data.workItems) + _changed('prs', data.pullRequests). After the slim,
605
+ // data.workItems / data.pullRequests are both undefined every tick so
606
+ // those signatures never advance and the triggers never fire. They are
607
+ // now fired inline from the /api/work-items + /api/pull-requests .then
608
+ // handlers (see _fireCrossSliceRender below) where the FRESH fetched
609
+ // lists are available. (Round-2 review findings #1 + #2.)
610
+ // Pull requests now come from /api/pull-requests — a dedicated fresh-
611
+ // JSON endpoint that re-runs getPullRequests() server-side on every
612
+ // request with input-mtime ETag (issue #2949).
613
+ _safeRender('prs', function() {
614
+ // Stamp the request with a monotonic seq so late arrivals from a
615
+ // prior tick can't clobber fresh state. (Round-2 review finding #6.)
616
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
617
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
618
+ window._lastRequestedSeq.prs = seq;
619
+ fetch('/api/pull-requests')
620
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
621
+ .then(function (fresh) {
622
+ if (seq < (window._lastRequestedSeq.prs || 0)) return;
623
+ const list = Array.isArray(fresh) ? fresh : (window._lastPullRequests || []);
624
+ const wasFirstNonEmpty = !Array.isArray(window._lastPullRequests);
625
+ window._lastPullRequests = list;
626
+ _safeRender('prs', function() { renderPrs(list); });
627
+ _fireCrossSliceRender('pullRequests', list);
628
+ // First-arrival case: if /api/work-items already fired its
629
+ // cross-slice trigger before _lastPullRequests was populated, the
630
+ // F1 +N follow-up chip on PRs reads a stale cached count. Force
631
+ // a one-shot re-fire by clearing the workItems sig. (Round-3 #7.)
632
+ if (wasFirstNonEmpty && Array.isArray(window._lastWorkItems)) {
633
+ _crossSliceSig.workItems = '';
634
+ _fireCrossSliceRender('workItems', window._lastWorkItems);
635
+ }
636
+ })
637
+ .catch(function () {
638
+ if (Array.isArray(window._lastPullRequests)) _safeRender('prs', function() { renderPrs(window._lastPullRequests); });
639
+ });
640
+ });
641
+ // Archived PRDs come from /api/archived-prds (issue #2949).
642
+ _safeRender('archiveButtons', function() {
643
+ fetch('/api/archived-prds')
644
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
645
+ .then(function (fresh) {
646
+ const list = Array.isArray(fresh) ? fresh : (window._lastArchivedPrds || []);
647
+ window._lastArchivedPrds = list;
648
+ _safeRender('archiveButtons', function() { renderArchiveButtons(list); });
649
+ })
650
+ .catch(function () {
651
+ if (Array.isArray(window._lastArchivedPrds)) _safeRender('archiveButtons', function() { renderArchiveButtons(window._lastArchivedPrds); });
652
+ });
653
+ });
361
654
  _changed('engine', data.engine);
362
655
  if (data.engine) _safeRender('engineStatus', function() { renderEngineStatus(data.engine); });
363
656
  _safeRender('engineQuickStats', function() {
@@ -390,13 +683,79 @@ function _processStatusUpdate(data) {
390
683
  _safeRender('adoThrottle', function() { renderAdoThrottleAlert(data.adoThrottle); });
391
684
  _changed('ghThrottle', data.ghThrottle);
392
685
  _safeRender('ghThrottle', function() { renderGhThrottleAlert(data.ghThrottle); });
393
- _changed('dispatch', data.dispatch);
394
- _safeRender('dispatch', function() { renderDispatch(data.dispatch); });
395
- _safeRender('prunePrdRequeueState', function() { prunePrdRequeueState(window._lastWorkItems); });
396
- _changed('engineLog', data.engineLog);
397
- _safeRender('engineLog', function() { renderEngineLog(data.engineLog || []); });
398
- _changed('metrics', data.metrics);
399
- _safeRender('metrics', function() { renderMetrics(data.metrics || {}); });
686
+ // Dispatch now comes from /api/dispatch — a dedicated fresh-JSON
687
+ // endpoint that re-runs getDispatchQueue() server-side on every
688
+ // request (issue #2949). Completion-report sidecars are now loaded
689
+ // server-side too, so the client no longer needs the cached overlay.
690
+ // Scroll-preservation opts (preserveCompletedScroll) are threaded
691
+ // through to renderDispatch so the completed-dispatch pane keeps its
692
+ // scroll position across refreshes (W-mpp75wkg000b3018).
693
+ _safeRender('dispatch', function() {
694
+ const dispatchOpts = { preserveCompletedScroll: opts.preserveCompletedScroll };
695
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
696
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
697
+ window._lastRequestedSeq.dispatch = seq;
698
+ fetch('/api/dispatch')
699
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
700
+ .then(function (fresh) {
701
+ if (seq < (window._lastRequestedSeq.dispatch || 0)) return;
702
+ const d = fresh || window._lastDispatch;
703
+ if (d) window._lastDispatch = d;
704
+ _safeRender('dispatch', function() { renderDispatch(d, dispatchOpts); });
705
+ // Dispatch is the third cross-slice edge: derivePlanStatus reads
706
+ // window._lastDispatch.active/.pending for executing/converting
707
+ // plan-status badges. Without this trigger, plan-status pills lag
708
+ // by ticks when a dispatch is added/completed but no WI or PR
709
+ // delta accompanies it. (Round-3 #5.)
710
+ _fireCrossSliceRender('dispatch', d);
711
+ })
712
+ .catch(function () {
713
+ if (window._lastDispatch) _safeRender('dispatch', function() { renderDispatch(window._lastDispatch, dispatchOpts); });
714
+ });
715
+ });
716
+ // prunePrdRequeueState moved into the /api/work-items .then handler so it
717
+ // operates on the fresh fetched list rather than the synchronously-empty
718
+ // window._lastWorkItems. (Review finding #5.)
719
+ // Engine log (last 50 entries) comes from /state/engine/log.json with a
720
+ // client-side .slice(-50) to mirror queries.js getEngineLog (issue #2949).
721
+ // Scroll-preservation opts (preserveEngineLogScroll) are threaded through
722
+ // to renderEngineLog so the log pane keeps its scroll position across
723
+ // refreshes (W-mpp75wkg000b3018).
724
+ _safeRender('engineLog', function() {
725
+ const logOpts = { preserveEngineLogScroll: opts.preserveEngineLogScroll };
726
+ Promise.resolve(fetchEngineLogFromDisk())
727
+ .then(function (fresh) {
728
+ const list = fresh && fresh.length ? fresh : (window._lastEngineLog || []);
729
+ window._lastEngineLog = list;
730
+ _safeRender('engineLog', function() { renderEngineLog(list, logOpts); });
731
+ })
732
+ .catch(function () {
733
+ if (Array.isArray(window._lastEngineLog)) _safeRender('engineLog', function() { renderEngineLog(window._lastEngineLog, logOpts); });
734
+ });
735
+ });
736
+ // Metrics now come from /api/metrics (issue #2949) — the enrichment
737
+ // join (PR counts, runtime totals) runs server-side fresh on every
738
+ // request with input-mtime ETag.
739
+ _safeRender('metrics', function() {
740
+ // Seq guard: metrics.json is mutated on every dispatch completion +
741
+ // PR/build state change via trackEngineUsage. Out-of-order tick
742
+ // resolution could revert tasksCompleted / USD spend counters to the
743
+ // pre-completion state. (Round-3 #10.)
744
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
745
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
746
+ window._lastRequestedSeq.metrics = seq;
747
+ fetch('/api/metrics')
748
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
749
+ .then(function (fresh) {
750
+ if (seq < (window._lastRequestedSeq.metrics || 0)) return;
751
+ const m = fresh && typeof fresh === 'object' ? fresh : (window._lastMetrics || {});
752
+ window._lastMetrics = m;
753
+ _safeRender('metrics', function() { renderMetrics(m); });
754
+ })
755
+ .catch(function () {
756
+ if (window._lastMetrics) _safeRender('metrics', function() { renderMetrics(window._lastMetrics); });
757
+ });
758
+ });
400
759
  // managed-processes panel — ETag-gated so unchanged ticks return 304 with
401
760
  // no body (P-6e2a8b13). Sequenced BEFORE the keep-processes call below via
402
761
  // .then() so the keep renderer reads a populated managed-PID cache for
@@ -415,28 +774,150 @@ function _processStatusUpdate(data) {
415
774
  .catch(function () { /* keep render even if managed fetch failed — getLastItems() returns the last good cache (or []) */ })
416
775
  .then(function () { try { renderKeepProcesses(); } catch {} });
417
776
  }
418
- _safeRender('workItems', function() { renderWorkItems(data.workItems || []); });
777
+ // Work items now come from /api/work-items — a dedicated fresh-JSON
778
+ // endpoint that re-runs getWorkItems() server-side on every request
779
+ // with input-mtime ETag (issue #2949). The previous /state/ + stale-
780
+ // enrichment-overlay path is gone: enrichment runs server-side fresh,
781
+ // so _pr/_prUrl/_pendingReason/_skipReason are guaranteed current.
782
+ _safeRender('workItems', function() {
783
+ // Stamp the request with a monotonic seq — late arrivals from a prior
784
+ // tick can't clobber fresh state. (Round-2 review finding #6.)
785
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
786
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
787
+ window._lastRequestedSeq.workItems = seq;
788
+ fetch('/api/work-items')
789
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
790
+ .then(function (fresh) {
791
+ if (seq < (window._lastRequestedSeq.workItems || 0)) return;
792
+ const list = Array.isArray(fresh) ? fresh : (window._lastWorkItems || []);
793
+ window._lastWorkItems = list;
794
+ _safeRender('workItems', function() { renderWorkItems(list); });
795
+ // prunePrdRequeueState reads the FRESH fetched list rather than the
796
+ // synchronously-empty window._lastWorkItems. (Review finding #5.)
797
+ _safeRender('prunePrdRequeueState', function() { prunePrdRequeueState(list); });
798
+ // F1/F3 cross-slice trigger fires here so the +N follow-up chip on
799
+ // PRs and the plan-status badges update when WI state actually
800
+ // changes. (Round-2 review findings #1 + #2.)
801
+ _fireCrossSliceRender('workItems', list);
802
+ })
803
+ .catch(function () {
804
+ // Re-render against the last known good list rather than blanking
805
+ // the panel. Skip prunePrdRequeueState entirely on the catch arm —
806
+ // calling it against a stale list could delete optimistic UI chips
807
+ // the user just set between the fetch dispatch and the failure.
808
+ // (Round-2 review finding #8.)
809
+ if (Array.isArray(window._lastWorkItems)) {
810
+ _safeRender('workItems', function() { renderWorkItems(window._lastWorkItems); });
811
+ }
812
+ });
813
+ });
419
814
  _changed('skills', data.skills);
420
815
  _safeRender('skills', function() { renderSkills(data.skills || []); });
421
816
  _changed('mcpServers', data.mcpServers);
422
817
  _safeRender('mcpServers', function() { renderMcpServers(data.mcpServers || []); });
423
- _changed('schedules', data.schedules);
424
- _safeRender('schedules', function() { renderSchedules(data.schedules || []); });
425
- _changed('watches', data.watches);
426
- _safeRender('watches', function() { renderWatches(data.watches || []); });
427
- _changed('meetings', data.meetings);
428
- _safeRender('meetings', function() { renderMeetings(data.meetings || []); });
429
- _changed('pipelines', data.pipelines);
818
+ // Schedule definitions stay on /api/status (config-derived), but the
819
+ // _lastRun/_lastResult overlay is re-applied client-side from
820
+ // /state/engine/schedule-runs.json so a freshly-fired schedule lights up
821
+ // within one 4 s poll (issue #2949).
822
+ // Schedules now come from /api/schedules — a dedicated fresh-JSON endpoint
823
+ // that merges config.schedules with schedule-runs.json server-side, ETag
824
+ // off input mtimes (issue #2949).
825
+ _safeRender('schedules', function() {
826
+ fetch('/api/schedules')
827
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
828
+ .then(function (fresh) {
829
+ const list = Array.isArray(fresh) ? fresh : (data.schedules || []);
830
+ window._lastSchedules = list;
831
+ _safeRender('schedules', function() { renderSchedules(list); });
832
+ })
833
+ .catch(function () {
834
+ if (Array.isArray(window._lastSchedules)) _safeRender('schedules', function() { renderSchedules(window._lastSchedules); });
835
+ });
836
+ });
837
+ // Watches array comes from /state/engine/watches.json (issue #2949).
838
+ _safeRender('watches', function() {
839
+ // Seq guard — watches.json is mutated every 3 ticks by engine/watches.js
840
+ // (mutateWatches on watch-fire / pause). (Round-4 #10.)
841
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
842
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
843
+ window._lastRequestedSeq.watches = seq;
844
+ Promise.resolve(fetchWatchesFromDisk())
845
+ .then(function (fresh) {
846
+ if (seq < (window._lastRequestedSeq.watches || 0)) return;
847
+ const list = fresh && fresh.length ? fresh : (window._lastWatches || []);
848
+ window._lastWatches = list;
849
+ _safeRender('watches', function() { renderWatches(list); });
850
+ })
851
+ .catch(function () {
852
+ if (Array.isArray(window._lastWatches)) _safeRender('watches', function() { renderWatches(window._lastWatches); });
853
+ });
854
+ });
855
+ // Meetings come from /state/meetings (dir listing + per-file fetch),
856
+ // slimmed client-side to match dashboard.js _slimMeetingForStatus.
857
+ // Issue #2949 — same outer-cache staleness fix as work-items + PRs.
858
+ _safeRender('meetings', function() {
859
+ Promise.resolve(fetchMeetingsFromDisk())
860
+ .then(function (fresh) {
861
+ const list = fresh && fresh.length ? fresh : (window._lastMeetings || []);
862
+ window._lastMeetings = list;
863
+ _safeRender('meetings', function() { renderMeetings(list); });
864
+ })
865
+ .catch(function () {
866
+ if (Array.isArray(window._lastMeetings)) _safeRender('meetings', function() { renderMeetings(window._lastMeetings); });
867
+ });
868
+ });
430
869
  if (typeof renderPipelines === 'function') {
431
- _safeRender('pipelines', function() { renderPipelines(data.pipelines || []); });
870
+ // Definitions stay on /api/status, runs overlay (last 5 per pipeline)
871
+ // is re-applied client-side from /state/engine/pipeline-runs.json so a
872
+ // newly-started run lights the panel within one 4 s poll (issue #2949).
873
+ // Pipelines now come from /api/pipelines — a dedicated fresh-JSON
874
+ // endpoint that merges pipelines/*.json with pipeline-runs.json
875
+ // (last 5 runs per pipeline) server-side (issue #2949).
876
+ _safeRender('pipelines', function() {
877
+ // Seq guard — pipeline-runs.json is mutated on every pipeline run
878
+ // start/completion; out-of-order resolution could revert run state.
879
+ // (Round-4 #10.)
880
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
881
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
882
+ window._lastRequestedSeq.pipelines = seq;
883
+ fetch('/api/pipelines')
884
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
885
+ .then(function (fresh) {
886
+ if (seq < (window._lastRequestedSeq.pipelines || 0)) return;
887
+ const list = Array.isArray(fresh) ? fresh : (window._lastPipelines || []);
888
+ window._lastPipelines = list;
889
+ _safeRender('pipelines', function() { renderPipelines(list); });
890
+ })
891
+ .catch(function () {
892
+ if (Array.isArray(window._lastPipelines)) _safeRender('pipelines', function() { renderPipelines(window._lastPipelines); });
893
+ });
894
+ });
432
895
  }
433
- _changed('pinned', data.pinned);
434
- _safeRender('pinned', function() { renderPinned(data.pinned || []); });
435
- // Sidebar counts (cheap)
896
+ // Pinned notes come from /api/pinned (issue #2949).
897
+ _safeRender('pinned', function() {
898
+ // Seq guard — pinned.md is user-mutated via POST /api/pinned. (Round-4 #10.)
899
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
900
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
901
+ window._lastRequestedSeq.pinned = seq;
902
+ fetch('/api/pinned')
903
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
904
+ .then(function (fresh) {
905
+ if (seq < (window._lastRequestedSeq.pinned || 0)) return;
906
+ const list = Array.isArray(fresh) ? fresh : (window._lastPinned || []);
907
+ window._lastPinned = list;
908
+ _safeRender('pinned', function() { renderPinned(list); });
909
+ })
910
+ .catch(function () {
911
+ if (Array.isArray(window._lastPinned)) _safeRender('pinned', function() { renderPinned(window._lastPinned); });
912
+ });
913
+ });
914
+ // Sidebar counts — read from the dedicated-endpoint caches; data.workItems
915
+ // / data.pullRequests are undefined post-slim. Renders empty until the
916
+ // first /api/work-items + /api/pull-requests fetches resolve.
436
917
  const swi = document.getElementById('sidebar-wi');
437
- if (swi) swi.textContent = (data.workItems || []).length || '';
918
+ if (swi) swi.textContent = (window._lastWorkItems || []).length || '';
438
919
  const spr = document.getElementById('sidebar-pr');
439
- if (spr) spr.textContent = (data.pullRequests || []).length || '';
920
+ if (spr) spr.textContent = (window._lastPullRequests || []).length || '';
440
921
  // Refresh KB and plans every status cycle (~4s) so plan status flips
441
922
  // (approve/archive/complete) and KB additions surface within the SPA's
442
923
  // 4s poll target. Server-side caches keep this cheap:
@@ -450,26 +931,11 @@ function _processStatusUpdate(data) {
450
931
  _safeRender('refreshKnowledgeBase', function() { refreshKnowledgeBase(); });
451
932
  _safeRender('refreshPlans', function() { refreshPlans(); });
452
933
 
453
- // Cross-slice render triggers (F1/F3, W-mpgb0xbh000e3b86): renderPrs reads
454
- // window._lastWorkItems for the +N follow-up chip count and derivePlanStatus
455
- // reads window._lastStatus.pullRequests for verify-follow-up reconciliation.
456
- // When workItems OR pullRequests change, re-render the dependents against the
457
- // freshest cached data — without waiting for the next refreshPlans cycle (F3)
458
- // or for a PR object to mutate (F1). Runs after the window._last*
459
- // assignments above so the cached globals these renderers consult are fresh.
460
- if (_workItemsChanged && !_prsChanged) {
461
- // F1: only the work-item slice moved this tick — renderPrs wasn't called
462
- // above, so the +N follow-up chip would otherwise stay stale until the
463
- // next PR mutation.
464
- _safeRender('prs:cross-slice', function() { renderPrs(data.pullRequests || []); });
465
- }
466
- if ((_workItemsChanged || _prsChanged) && Array.isArray(window._lastPlans) && typeof renderPlans === 'function') {
467
- // F3: derivePlanStatus + _renderVerifyBadge derive from pullRequests +
468
- // workItems. Re-render against cached plans so plan status flips within
469
- // one /api/status tick (~4s) instead of one refreshPlans poll. No-op
470
- // until _lastPlans is populated.
471
- _safeRender('plans:cross-slice', function() { renderPlans(window._lastPlans); });
472
- }
934
+ // F1/F3 cross-slice triggers moved into the /api/work-items + /api/pull-
935
+ // requests .then handlers (see _fireCrossSliceRender helper). The
936
+ // synchronous-tick versions used to fire off _changed signatures derived
937
+ // from data.workItems / data.pullRequests, both undefined after the
938
+ // /api/status slim. (Round-2 review findings #1 + #2.)
473
939
 
474
940
  // Sidebar activity indicators — show red dot on pages with new activity
475
941
  try {
@@ -482,22 +948,12 @@ function _processStatusUpdate(data) {
482
948
  } catch { /* expected on first load */ }
483
949
  }
484
950
 
951
+ // Auto-reload policy (2026-05-29):
952
+ // Reload only on dashboardStartedAt change (signal that `minions restart`
953
+ // happened and the process is new). dashboardBuildId / runningCommit /
954
+ // installId mismatch DO NOT auto-reload anymore — code edits and engine
955
+ // commits must not silently interrupt the operator's open session.
485
956
  let _knownDashboardStartId = null;
486
- // Hard-reload trigger when the assembled dashboard HTML (and therefore any
487
- // renderer body) has changed since the page was first loaded. Mirrors the
488
- // _knownDashboardStartId pattern (R3, W-mpgb0xgc000hf1d3): catches restart-
489
- // crossing renderer drift that RENDER_VERSIONS (in-process bump) can't see,
490
- // since a server restart wipes the JS module identity entirely. data.version.
491
- // dashboardBuildId is the md5 of the assembled HTML — bumps automatically on
492
- // hot-reload + on cold restart with any /dashboard/** change.
493
- let _knownDashboardBuildId = null;
494
- // Hard-reload trigger when the *engine* commit changes — `minions restart`
495
- // after a `git pull` swaps engine code but doesn't touch any
496
- // /dashboard/** asset, so `dashboardBuildId` stays the same and the
497
- // existing buildId reload above never fires. Without this, the stale
498
- // "Engine running v… — disk has v…" banner persists in already-open tabs
499
- // until the (hourly by default) /api/version repoll catches up.
500
- let _knownEngineRunningCommit = null;
501
957
  // /api/status ETag cache (W-mpehsyhv0017085a). The dashboard polls every 4 s
502
958
  // but the server-side cache only changes every 10–60 s. Sending If-None-Match
503
959
  // lets the server short-circuit ~60 %+ of polls into a 304 with no body —
@@ -696,7 +1152,8 @@ document.addEventListener('visibilitychange', function() {
696
1152
  _lastVisibilityChangeAt = now;
697
1153
  });
698
1154
 
699
- async function refresh() {
1155
+ async function refresh(opts) {
1156
+ opts = opts || {};
700
1157
  if (_refreshInFlight) return;
701
1158
  // Backoff gate — only active while the unreachable banner is up. Skips
702
1159
  // setInterval ticks until _nextPollAllowedAt is reached so a downed
@@ -761,7 +1218,19 @@ async function refresh() {
761
1218
  _diagEntry.bytes_received = cl != null && cl !== '' ? Number(cl) : null;
762
1219
  }
763
1220
  }
764
- // Auto-reload when dashboard restarts (stale connections cause "Failed to fetch" on CC/doc-chat)
1221
+ // Auto-reload policy (2026-05-29):
1222
+ // - dashboardStartedAt change → reload (covers `minions restart`)
1223
+ // - dashboardBuildId change → DO NOT reload (dashboard/** edits
1224
+ // while dev'ing the UI must not silently kick connected tabs)
1225
+ // - runningCommit change → DO NOT reload (engine git-pull is
1226
+ // not a dashboard restart — engine code change doesn't justify
1227
+ // interrupting an open dashboard session)
1228
+ // - installId change → DO NOT reload (MINIONS_HOME swap
1229
+ // is the operator's call to refresh, not ours)
1230
+ //
1231
+ // The user explicitly wants `minions restart` to reload the tab so
1232
+ // they pick up any updated code; they do NOT want any other code-
1233
+ // change signal to trigger a reload.
765
1234
  const dashId = (data.version && data.version.dashboardStartedAt) || null;
766
1235
  if (dashId && _knownDashboardStartId && dashId !== _knownDashboardStartId) {
767
1236
  console.log('Dashboard restarted — reloading page');
@@ -769,31 +1238,7 @@ async function refresh() {
769
1238
  return;
770
1239
  }
771
1240
  if (dashId) _knownDashboardStartId = dashId;
772
- // Auto-reload when the assembled dashboard HTML changed (renderer body or
773
- // any other concat'd JS) — catches drift the in-process RENDER_VERSIONS
774
- // bump can't see (R3, W-mpgb0xgc000hf1d3).
775
- const buildId = (data.version && data.version.dashboardBuildId) || null;
776
- if (buildId && _knownDashboardBuildId && buildId !== _knownDashboardBuildId) {
777
- console.log('Dashboard build changed — reloading page');
778
- location.reload();
779
- return;
780
- }
781
- if (buildId) _knownDashboardBuildId = buildId;
782
- // Auto-reload when the engine's commit changed (engine code was updated
783
- // and process restarted, e.g. via `minions restart` after `git pull`).
784
- // dashboardBuildId only hashes HTML/JS/CSS assets, so engine-only code
785
- // updates don't trigger that path — leaving stale "Engine running v… —
786
- // disk has v…" banners until the hourly /api/version repoll.
787
- const engineCommit = (data.version && data.version.runningCommit) || null;
788
- if (engineCommit && _knownEngineRunningCommit && engineCommit !== _knownEngineRunningCommit) {
789
- console.log('Engine code changed — reloading page');
790
- location.reload();
791
- return;
792
- }
793
- if (engineCommit) _knownEngineRunningCommit = engineCommit;
794
- // Successful poll — clear unreachable state. Placed AFTER the reload
795
- // guards above so a dashboard restart still triggers location.reload()
796
- // instead of just dismissing the banner.
1241
+ // Successful poll — clear unreachable state.
797
1242
  _lastStatusOkAt = Date.now();
798
1243
  _consecutiveStatusFails = 0;
799
1244
  _nextPollAllowedAt = 0;
@@ -805,14 +1250,16 @@ async function refresh() {
805
1250
  _lastChangedFlags = _diagChanges;
806
1251
  }
807
1252
  try {
808
- _processStatusUpdate(data);
1253
+ _processStatusUpdate(data, opts);
809
1254
  } finally {
810
1255
  if (_diagOn) {
811
1256
  _lastChangedFlags = null;
812
1257
  _diagEntry.render_duration_ms = Date.now() - _renderStart;
813
1258
  _diagEntry.changed = _diagChanges;
814
1259
  _diagEntry.workItems_changed = !!(_diagChanges && _diagChanges.workItems);
815
- _diagEntry.workItems_count = (data.workItems || []).length;
1260
+ // Post-slim, data.workItems is undefined; read from the dedicated
1261
+ // endpoint cache.
1262
+ _diagEntry.workItems_count = (window._lastWorkItems || []).length;
816
1263
  _diagEntry.statusCacheVersion = (data.version && data.version.statusCacheVersion) != null
817
1264
  ? data.version.statusCacheVersion
818
1265
  : null;