@yemi33/minions 0.1.2086 → 0.1.2088

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
 
@@ -246,23 +312,99 @@ function _startNextTickTicker() {
246
312
  _engineNextTickTimer = setInterval(_updateNextTickChip, 1000);
247
313
  }
248
314
 
315
+ // Background-poll the sidebar-counter summaries from their dedicated
316
+ // endpoints (issue #2949). Results land on window._last* globals which
317
+ // _pageCounters reads with a /api/status field fallback. Fire-and-forget
318
+ // — no awaits — so this never blocks the synchronous render chain. The
319
+ // fetched values arrive ~1 tick late on the FIRST refresh after page load;
320
+ // subsequent ticks see fresh signatures from the dedicated endpoints with
321
+ // no /api/status outer-cache staleness.
322
+ // Fire the F1/F3 cross-slice triggers when work-items / pull-requests /
323
+ // dispatch resolves with NEW data on this tick. Cross-slice dependency graph:
324
+ // workItems → {prs (+N follow-up chip), plans (derivePlanStatus)}
325
+ // pullRequests → {plans (derivePlanStatus)}
326
+ // dispatch → {plans (derivePlanStatus reads .active/.pending)} (Round-3 #5.)
327
+ //
328
+ // Signature is a CHEAP fingerprint derived from key fields, NOT a full
329
+ // JSON.stringify — the latter allocated multi-MB strings on every tick
330
+ // for the typical WI/PR payloads and contradicted the dashboard event-loop
331
+ // budget. Catch-fallback uses a STABLE token (not Date.now()) so a payload
332
+ // whose getter throws doesn't perpetually re-fire. (Round-4 #1 + #3.)
333
+ const _crossSliceSig = { workItems: '', pullRequests: '', dispatch: '' };
334
+ function _cheapSig(value) {
335
+ try {
336
+ if (!value) return 'null';
337
+ if (Array.isArray(value)) {
338
+ const head = value[0] || {};
339
+ const tail = value[value.length - 1] || {};
340
+ return value.length + '|' + (head.id || '') + ':' + (head.status || head.state || '') + '|' + (tail.id || '') + ':' + (tail.status || tail.state || head.updatedAt || '');
341
+ }
342
+ if (typeof value === 'object') {
343
+ // Dispatch shape: {pending, active, completed} — fingerprint the lengths
344
+ // + first id of each so any insertion/removal advances the sig.
345
+ const p = Array.isArray(value.pending) ? value.pending : [];
346
+ const a = Array.isArray(value.active) ? value.active : [];
347
+ const c = Array.isArray(value.completed) ? value.completed : [];
348
+ return 'd:' + p.length + ':' + (p[0]?.id || '') + '/' + a.length + ':' + (a[0]?.id || '') + '/' + c.length + ':' + (c[c.length - 1]?.id || '');
349
+ }
350
+ return String(value);
351
+ } catch { return '__err__'; }
352
+ }
353
+ // F3 coalescer: batch multiple advancing-key calls into ONE renderPlans per
354
+ // tick. Without this, a tick where all three of workItems/pullRequests/
355
+ // dispatch advance would re-render plans 3-4 times back-to-back for
356
+ // identical output. (Round-4 #2.)
357
+ let _planRerenderQueued = false;
358
+ function _queuePlanRerender() {
359
+ if (_planRerenderQueued) return;
360
+ _planRerenderQueued = true;
361
+ Promise.resolve().then(function () {
362
+ _planRerenderQueued = false;
363
+ if (Array.isArray(window._lastPlans) && typeof renderPlans === 'function') {
364
+ _safeRender('plans:cross-slice', function() { renderPlans(window._lastPlans); });
365
+ }
366
+ });
367
+ }
368
+ function _fireCrossSliceRender(key, value) {
369
+ const sig = _cheapSig(value);
370
+ if (_crossSliceSig[key] === sig) return false;
371
+ _crossSliceSig[key] = sig;
372
+ // F1 — re-render PRs (+N follow-up chip count depends on the WI list).
373
+ // Fires for both workItems and pullRequests keys so a late PR fetch
374
+ // refreshes the +N chip against the cached WI list. (Round-3 #7.)
375
+ if ((key === 'workItems' || key === 'pullRequests') && Array.isArray(window._lastPullRequests)) {
376
+ _safeRender('prs:cross-slice', function() { renderPrs(window._lastPullRequests); });
377
+ }
378
+ // F3 — coalesced via microtask so 3 advancing keys → 1 plans render.
379
+ _queuePlanRerender();
380
+ return true;
381
+ }
382
+
383
+ function _refreshSidebarCounterSummaries() {
384
+ fetch('/api/qa-runs-summary')
385
+ .then(function (r) { return r.ok ? r.json() : null; })
386
+ .then(function (s) { if (s && typeof s === 'object') window._lastQaRunsSummary = s; })
387
+ .catch(function () { /* sidebar counter degrades to /api/status fallback */ });
388
+ fetch('/api/meetings-total')
389
+ .then(function (r) { return r.ok ? r.json() : null; })
390
+ .then(function (s) { if (s && typeof s.total === 'number') window._lastMeetingsTotal = s.total; })
391
+ .catch(function () { /* optional */ });
392
+ // /api/verify-guides is now fetched in parallel with /api/prd inside the
393
+ // prdProgress block so renderPrd's guideByPlan lookup uses the SAME tick's
394
+ // guide list as the PRD progress data. (Round-2 review finding #5.)
395
+ // QA sessions summary was previously fetched here, but no _pageCounters
396
+ // entry read it — drop the dead endpoint + fetch. (Review finding #9.)
397
+ }
398
+
249
399
  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).
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.
255
407
  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;
265
- }
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,71 @@ 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
+ _safeRender('dispatch', function() {
691
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
692
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
693
+ window._lastRequestedSeq.dispatch = seq;
694
+ fetch('/api/dispatch')
695
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
696
+ .then(function (fresh) {
697
+ if (seq < (window._lastRequestedSeq.dispatch || 0)) return;
698
+ const d = fresh || window._lastDispatch;
699
+ if (d) window._lastDispatch = d;
700
+ _safeRender('dispatch', function() { renderDispatch(d); });
701
+ // Dispatch is the third cross-slice edge: derivePlanStatus reads
702
+ // window._lastDispatch.active/.pending for executing/converting
703
+ // plan-status badges. Without this trigger, plan-status pills lag
704
+ // by ticks when a dispatch is added/completed but no WI or PR
705
+ // delta accompanies it. (Round-3 #5.)
706
+ _fireCrossSliceRender('dispatch', d);
707
+ })
708
+ .catch(function () {
709
+ if (window._lastDispatch) _safeRender('dispatch', function() { renderDispatch(window._lastDispatch); });
710
+ });
711
+ });
712
+ // prunePrdRequeueState moved into the /api/work-items .then handler so it
713
+ // operates on the fresh fetched list rather than the synchronously-empty
714
+ // window._lastWorkItems. (Review finding #5.)
715
+ // Engine log (last 50 entries) comes from /state/engine/log.json with a
716
+ // client-side .slice(-50) to mirror queries.js getEngineLog (issue #2949).
717
+ _safeRender('engineLog', function() {
718
+ Promise.resolve(fetchEngineLogFromDisk())
719
+ .then(function (fresh) {
720
+ const list = fresh && fresh.length ? fresh : (window._lastEngineLog || []);
721
+ window._lastEngineLog = list;
722
+ _safeRender('engineLog', function() { renderEngineLog(list); });
723
+ })
724
+ .catch(function () {
725
+ if (Array.isArray(window._lastEngineLog)) _safeRender('engineLog', function() { renderEngineLog(window._lastEngineLog); });
726
+ });
727
+ });
728
+ // Metrics now come from /api/metrics (issue #2949) — the enrichment
729
+ // join (PR counts, runtime totals) runs server-side fresh on every
730
+ // request with input-mtime ETag.
731
+ _safeRender('metrics', function() {
732
+ // Seq guard: metrics.json is mutated on every dispatch completion +
733
+ // PR/build state change via trackEngineUsage. Out-of-order tick
734
+ // resolution could revert tasksCompleted / USD spend counters to the
735
+ // pre-completion state. (Round-3 #10.)
736
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
737
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
738
+ window._lastRequestedSeq.metrics = seq;
739
+ fetch('/api/metrics')
740
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
741
+ .then(function (fresh) {
742
+ if (seq < (window._lastRequestedSeq.metrics || 0)) return;
743
+ const m = fresh && typeof fresh === 'object' ? fresh : (window._lastMetrics || {});
744
+ window._lastMetrics = m;
745
+ _safeRender('metrics', function() { renderMetrics(m); });
746
+ })
747
+ .catch(function () {
748
+ if (window._lastMetrics) _safeRender('metrics', function() { renderMetrics(window._lastMetrics); });
749
+ });
750
+ });
400
751
  // managed-processes panel — ETag-gated so unchanged ticks return 304 with
401
752
  // no body (P-6e2a8b13). Sequenced BEFORE the keep-processes call below via
402
753
  // .then() so the keep renderer reads a populated managed-PID cache for
@@ -415,28 +766,150 @@ function _processStatusUpdate(data) {
415
766
  .catch(function () { /* keep render even if managed fetch failed — getLastItems() returns the last good cache (or []) */ })
416
767
  .then(function () { try { renderKeepProcesses(); } catch {} });
417
768
  }
418
- _safeRender('workItems', function() { renderWorkItems(data.workItems || []); });
769
+ // Work items now come from /api/work-items — a dedicated fresh-JSON
770
+ // endpoint that re-runs getWorkItems() server-side on every request
771
+ // with input-mtime ETag (issue #2949). The previous /state/ + stale-
772
+ // enrichment-overlay path is gone: enrichment runs server-side fresh,
773
+ // so _pr/_prUrl/_pendingReason/_skipReason are guaranteed current.
774
+ _safeRender('workItems', function() {
775
+ // Stamp the request with a monotonic seq — late arrivals from a prior
776
+ // tick can't clobber fresh state. (Round-2 review finding #6.)
777
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
778
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
779
+ window._lastRequestedSeq.workItems = seq;
780
+ fetch('/api/work-items')
781
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
782
+ .then(function (fresh) {
783
+ if (seq < (window._lastRequestedSeq.workItems || 0)) return;
784
+ const list = Array.isArray(fresh) ? fresh : (window._lastWorkItems || []);
785
+ window._lastWorkItems = list;
786
+ _safeRender('workItems', function() { renderWorkItems(list); });
787
+ // prunePrdRequeueState reads the FRESH fetched list rather than the
788
+ // synchronously-empty window._lastWorkItems. (Review finding #5.)
789
+ _safeRender('prunePrdRequeueState', function() { prunePrdRequeueState(list); });
790
+ // F1/F3 cross-slice trigger fires here so the +N follow-up chip on
791
+ // PRs and the plan-status badges update when WI state actually
792
+ // changes. (Round-2 review findings #1 + #2.)
793
+ _fireCrossSliceRender('workItems', list);
794
+ })
795
+ .catch(function () {
796
+ // Re-render against the last known good list rather than blanking
797
+ // the panel. Skip prunePrdRequeueState entirely on the catch arm —
798
+ // calling it against a stale list could delete optimistic UI chips
799
+ // the user just set between the fetch dispatch and the failure.
800
+ // (Round-2 review finding #8.)
801
+ if (Array.isArray(window._lastWorkItems)) {
802
+ _safeRender('workItems', function() { renderWorkItems(window._lastWorkItems); });
803
+ }
804
+ });
805
+ });
419
806
  _changed('skills', data.skills);
420
807
  _safeRender('skills', function() { renderSkills(data.skills || []); });
421
808
  _changed('mcpServers', data.mcpServers);
422
809
  _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);
810
+ // Schedule definitions stay on /api/status (config-derived), but the
811
+ // _lastRun/_lastResult overlay is re-applied client-side from
812
+ // /state/engine/schedule-runs.json so a freshly-fired schedule lights up
813
+ // within one 4 s poll (issue #2949).
814
+ // Schedules now come from /api/schedules — a dedicated fresh-JSON endpoint
815
+ // that merges config.schedules with schedule-runs.json server-side, ETag
816
+ // off input mtimes (issue #2949).
817
+ _safeRender('schedules', function() {
818
+ fetch('/api/schedules')
819
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
820
+ .then(function (fresh) {
821
+ const list = Array.isArray(fresh) ? fresh : (data.schedules || []);
822
+ window._lastSchedules = list;
823
+ _safeRender('schedules', function() { renderSchedules(list); });
824
+ })
825
+ .catch(function () {
826
+ if (Array.isArray(window._lastSchedules)) _safeRender('schedules', function() { renderSchedules(window._lastSchedules); });
827
+ });
828
+ });
829
+ // Watches array comes from /state/engine/watches.json (issue #2949).
830
+ _safeRender('watches', function() {
831
+ // Seq guard — watches.json is mutated every 3 ticks by engine/watches.js
832
+ // (mutateWatches on watch-fire / pause). (Round-4 #10.)
833
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
834
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
835
+ window._lastRequestedSeq.watches = seq;
836
+ Promise.resolve(fetchWatchesFromDisk())
837
+ .then(function (fresh) {
838
+ if (seq < (window._lastRequestedSeq.watches || 0)) return;
839
+ const list = fresh && fresh.length ? fresh : (window._lastWatches || []);
840
+ window._lastWatches = list;
841
+ _safeRender('watches', function() { renderWatches(list); });
842
+ })
843
+ .catch(function () {
844
+ if (Array.isArray(window._lastWatches)) _safeRender('watches', function() { renderWatches(window._lastWatches); });
845
+ });
846
+ });
847
+ // Meetings come from /state/meetings (dir listing + per-file fetch),
848
+ // slimmed client-side to match dashboard.js _slimMeetingForStatus.
849
+ // Issue #2949 — same outer-cache staleness fix as work-items + PRs.
850
+ _safeRender('meetings', function() {
851
+ Promise.resolve(fetchMeetingsFromDisk())
852
+ .then(function (fresh) {
853
+ const list = fresh && fresh.length ? fresh : (window._lastMeetings || []);
854
+ window._lastMeetings = list;
855
+ _safeRender('meetings', function() { renderMeetings(list); });
856
+ })
857
+ .catch(function () {
858
+ if (Array.isArray(window._lastMeetings)) _safeRender('meetings', function() { renderMeetings(window._lastMeetings); });
859
+ });
860
+ });
430
861
  if (typeof renderPipelines === 'function') {
431
- _safeRender('pipelines', function() { renderPipelines(data.pipelines || []); });
862
+ // Definitions stay on /api/status, runs overlay (last 5 per pipeline)
863
+ // is re-applied client-side from /state/engine/pipeline-runs.json so a
864
+ // newly-started run lights the panel within one 4 s poll (issue #2949).
865
+ // Pipelines now come from /api/pipelines — a dedicated fresh-JSON
866
+ // endpoint that merges pipelines/*.json with pipeline-runs.json
867
+ // (last 5 runs per pipeline) server-side (issue #2949).
868
+ _safeRender('pipelines', function() {
869
+ // Seq guard — pipeline-runs.json is mutated on every pipeline run
870
+ // start/completion; out-of-order resolution could revert run state.
871
+ // (Round-4 #10.)
872
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
873
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
874
+ window._lastRequestedSeq.pipelines = seq;
875
+ fetch('/api/pipelines')
876
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
877
+ .then(function (fresh) {
878
+ if (seq < (window._lastRequestedSeq.pipelines || 0)) return;
879
+ const list = Array.isArray(fresh) ? fresh : (window._lastPipelines || []);
880
+ window._lastPipelines = list;
881
+ _safeRender('pipelines', function() { renderPipelines(list); });
882
+ })
883
+ .catch(function () {
884
+ if (Array.isArray(window._lastPipelines)) _safeRender('pipelines', function() { renderPipelines(window._lastPipelines); });
885
+ });
886
+ });
432
887
  }
433
- _changed('pinned', data.pinned);
434
- _safeRender('pinned', function() { renderPinned(data.pinned || []); });
435
- // Sidebar counts (cheap)
888
+ // Pinned notes come from /api/pinned (issue #2949).
889
+ _safeRender('pinned', function() {
890
+ // Seq guard — pinned.md is user-mutated via POST /api/pinned. (Round-4 #10.)
891
+ const seq = (window._refreshSeq = (window._refreshSeq || 0) + 1);
892
+ window._lastRequestedSeq = window._lastRequestedSeq || {};
893
+ window._lastRequestedSeq.pinned = seq;
894
+ fetch('/api/pinned')
895
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
896
+ .then(function (fresh) {
897
+ if (seq < (window._lastRequestedSeq.pinned || 0)) return;
898
+ const list = Array.isArray(fresh) ? fresh : (window._lastPinned || []);
899
+ window._lastPinned = list;
900
+ _safeRender('pinned', function() { renderPinned(list); });
901
+ })
902
+ .catch(function () {
903
+ if (Array.isArray(window._lastPinned)) _safeRender('pinned', function() { renderPinned(window._lastPinned); });
904
+ });
905
+ });
906
+ // Sidebar counts — read from the dedicated-endpoint caches; data.workItems
907
+ // / data.pullRequests are undefined post-slim. Renders empty until the
908
+ // first /api/work-items + /api/pull-requests fetches resolve.
436
909
  const swi = document.getElementById('sidebar-wi');
437
- if (swi) swi.textContent = (data.workItems || []).length || '';
910
+ if (swi) swi.textContent = (window._lastWorkItems || []).length || '';
438
911
  const spr = document.getElementById('sidebar-pr');
439
- if (spr) spr.textContent = (data.pullRequests || []).length || '';
912
+ if (spr) spr.textContent = (window._lastPullRequests || []).length || '';
440
913
  // Refresh KB and plans every status cycle (~4s) so plan status flips
441
914
  // (approve/archive/complete) and KB additions surface within the SPA's
442
915
  // 4s poll target. Server-side caches keep this cheap:
@@ -450,26 +923,11 @@ function _processStatusUpdate(data) {
450
923
  _safeRender('refreshKnowledgeBase', function() { refreshKnowledgeBase(); });
451
924
  _safeRender('refreshPlans', function() { refreshPlans(); });
452
925
 
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
- }
926
+ // F1/F3 cross-slice triggers moved into the /api/work-items + /api/pull-
927
+ // requests .then handlers (see _fireCrossSliceRender helper). The
928
+ // synchronous-tick versions used to fire off _changed signatures derived
929
+ // from data.workItems / data.pullRequests, both undefined after the
930
+ // /api/status slim. (Round-2 review findings #1 + #2.)
473
931
 
474
932
  // Sidebar activity indicators — show red dot on pages with new activity
475
933
  try {
@@ -482,22 +940,12 @@ function _processStatusUpdate(data) {
482
940
  } catch { /* expected on first load */ }
483
941
  }
484
942
 
943
+ // Auto-reload policy (2026-05-29):
944
+ // Reload only on dashboardStartedAt change (signal that `minions restart`
945
+ // happened and the process is new). dashboardBuildId / runningCommit /
946
+ // installId mismatch DO NOT auto-reload anymore — code edits and engine
947
+ // commits must not silently interrupt the operator's open session.
485
948
  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
949
  // /api/status ETag cache (W-mpehsyhv0017085a). The dashboard polls every 4 s
502
950
  // but the server-side cache only changes every 10–60 s. Sending If-None-Match
503
951
  // lets the server short-circuit ~60 %+ of polls into a 304 with no body —
@@ -761,7 +1209,19 @@ async function refresh() {
761
1209
  _diagEntry.bytes_received = cl != null && cl !== '' ? Number(cl) : null;
762
1210
  }
763
1211
  }
764
- // Auto-reload when dashboard restarts (stale connections cause "Failed to fetch" on CC/doc-chat)
1212
+ // Auto-reload policy (2026-05-29):
1213
+ // - dashboardStartedAt change → reload (covers `minions restart`)
1214
+ // - dashboardBuildId change → DO NOT reload (dashboard/** edits
1215
+ // while dev'ing the UI must not silently kick connected tabs)
1216
+ // - runningCommit change → DO NOT reload (engine git-pull is
1217
+ // not a dashboard restart — engine code change doesn't justify
1218
+ // interrupting an open dashboard session)
1219
+ // - installId change → DO NOT reload (MINIONS_HOME swap
1220
+ // is the operator's call to refresh, not ours)
1221
+ //
1222
+ // The user explicitly wants `minions restart` to reload the tab so
1223
+ // they pick up any updated code; they do NOT want any other code-
1224
+ // change signal to trigger a reload.
765
1225
  const dashId = (data.version && data.version.dashboardStartedAt) || null;
766
1226
  if (dashId && _knownDashboardStartId && dashId !== _knownDashboardStartId) {
767
1227
  console.log('Dashboard restarted — reloading page');
@@ -769,31 +1229,7 @@ async function refresh() {
769
1229
  return;
770
1230
  }
771
1231
  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.
1232
+ // Successful poll clear unreachable state.
797
1233
  _lastStatusOkAt = Date.now();
798
1234
  _consecutiveStatusFails = 0;
799
1235
  _nextPollAllowedAt = 0;
@@ -812,7 +1248,9 @@ async function refresh() {
812
1248
  _diagEntry.render_duration_ms = Date.now() - _renderStart;
813
1249
  _diagEntry.changed = _diagChanges;
814
1250
  _diagEntry.workItems_changed = !!(_diagChanges && _diagChanges.workItems);
815
- _diagEntry.workItems_count = (data.workItems || []).length;
1251
+ // Post-slim, data.workItems is undefined; read from the dedicated
1252
+ // endpoint cache.
1253
+ _diagEntry.workItems_count = (window._lastWorkItems || []).length;
816
1254
  _diagEntry.statusCacheVersion = (data.version && data.version.statusCacheVersion) != null
817
1255
  ? data.version.statusCacheVersion
818
1256
  : null;