@yemi33/minions 0.1.1992 → 0.1.1993

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.
@@ -179,48 +179,130 @@ async function _ccDashboardHealth() {
179
179
  }
180
180
  }
181
181
 
182
+ // Pure predicate — extracted so test/unit/dashboard-resilience.test.js can
183
+ // behaviour-test the new-vs-old-instance decision without standing up a DOM.
184
+ // Returns true only when newDashId is provably a NEW dashboard process:
185
+ // - When preRestartDashId is known, any difference proves a different process.
186
+ // - When preRestartDashId is unknown (page just loaded, _lastStatus empty),
187
+ // accept only if newDashId parses to a timestamp strictly AFTER the click.
188
+ // Guards against the still-alive OLD dashboard answering /api/status
189
+ // before its kill lands (~1s into the restart child's lifetime).
190
+ function _ccIsNewDashboardInstance(preRestartDashId, newDashId, clickTimeMs) {
191
+ if (!newDashId) return false;
192
+ if (preRestartDashId) return newDashId !== preRestartDashId;
193
+ var parsed = Date.parse(newDashId);
194
+ return Number.isFinite(parsed) && parsed > clickTimeMs;
195
+ }
196
+
182
197
  // Triggered by the CC "Restart Minions" recovery button when a stale dashboard
183
198
  // connection is killing CC streams with "Failed to fetch". Spawns the same
184
199
  // `minions restart` flow as the CLI command (kills + respawns engine AND
185
- // dashboard). Then polls /api/health until the new dashboard is online before
186
- // reloading — fixed timers either fired too early (reload hits dead port) or
187
- // fired after the fetch threw (dashboard killed mid-response → 500ms reload
188
- // to nothing).
200
+ // dashboard) and reloads once the NEW dashboard is up.
201
+ //
202
+ // W-mpehvbed0018e27d: previous version polled /api/health which returns 200
203
+ // from EITHER the still-alive OLD dashboard (before the spawned restart child
204
+ // finished its kill sequence) OR the NEW one. Reloading on the OLD instance
205
+ // landed the page on a dying port; reloading on the NEW instance with a cold
206
+ // status cache hung the freshly-reloaded page on its first /api/status fetch.
207
+ // Both produced the "click does nothing / page still broken" symptoms.
208
+ //
209
+ // Fix:
210
+ // 1. Capture pre-restart dashboardStartedAt before POSTing the restart
211
+ // (so we can detect the NEW instance vs the still-alive OLD one).
212
+ // 2. Poll /api/status (not /api/health) — a 200 response proves the status
213
+ // cache has been built once, so the post-reload first request is warm.
214
+ // 3. Only reload when _ccIsNewDashboardInstance() confirms a different
215
+ // dashboardStartedAt, with per-poll AbortController so a hanging cold
216
+ // rebuild can't starve the global deadline.
217
+ // 4. Cache-bust the reload (?cb=) so an updated dashboard JS bundle is not
218
+ // served from the browser disk cache. location.replace() so the broken
219
+ // pre-restart URL doesn't pollute history.
189
220
  async function ccRestartMinions(btn) {
190
221
  if (btn) { try { btn.disabled = true; btn.textContent = 'Restarting...'; } catch {} }
191
- // Fire and forget the POST. We do NOT await it — the dashboard often kills
192
- // its own process before the response is flushed, so the fetch throws even
193
- // though the restart child is happily running. Whatever the POST does, the
194
- // next step (wait-for-healthy) is the truth.
222
+ var clickTimeMs = Date.now();
223
+
224
+ // Capture pre-restart dashboard instance ID BEFORE the POST so the polling
225
+ // step has a baseline to compare against. _lastStatus is populated by
226
+ // refresh.js's 4s poll; on a freshly-opened page it may still be null, so
227
+ // we fall back to a bounded /api/status fetch, then to clickTime-based
228
+ // detection inside _ccIsNewDashboardInstance.
229
+ var preRestartDashId = (window._lastStatus && window._lastStatus.version)
230
+ ? window._lastStatus.version.dashboardStartedAt : null;
231
+ if (!preRestartDashId) {
232
+ try {
233
+ var preCtl = new AbortController();
234
+ var preTimer = setTimeout(function() { preCtl.abort(); }, 2000);
235
+ try {
236
+ var preRes = await fetch('/api/status', { cache: 'no-store', signal: preCtl.signal });
237
+ if (preRes && preRes.ok) {
238
+ var preData = await preRes.json().catch(function() { return null; });
239
+ if (preData && preData.version) preRestartDashId = preData.version.dashboardStartedAt || null;
240
+ }
241
+ } finally { clearTimeout(preTimer); }
242
+ } catch { /* best-effort — clickTime fallback inside helper still covers us */ }
243
+ }
244
+
245
+ // Fire-and-forget the restart POST. We do NOT await it — the dashboard often
246
+ // kills its own process before the response is flushed, so the fetch throws
247
+ // even though the restart child (a detached `minions restart`) is happily
248
+ // running. The polling loop is the source of truth for completion.
195
249
  try {
196
250
  fetch('/api/dashboard/restart', { method: 'POST', headers: { 'Content-Type': 'application/json' } })
197
251
  .catch(function() { /* dashboard process likely killed mid-response — expected */ });
198
252
  } catch { /* network layer threw before fetch even queued — also expected */ }
199
253
  if (btn) { try { btn.textContent = 'Restarting Minions — waiting for new dashboard...'; } catch {} }
254
+
200
255
  var startedAt = Date.now();
201
- var DEADLINE_MS = 60000;
256
+ var DEADLINE_MS = 90000; // generous: cold Windows AV + status rebuild can eat 30s
202
257
  var INTERVAL_MS = 500;
203
- function pollHealth() {
204
- fetch('/api/health', { cache: 'no-store' }).then(function(res) {
205
- if (res && res.ok) {
206
- if (btn) { try { btn.textContent = 'Dashboard online — reloading...'; } catch {} }
207
- try { location.reload(); } catch {}
208
- return;
258
+ var POLL_TIMEOUT_MS = 5000;
259
+
260
+ function reloadWithCacheBust(label) {
261
+ if (btn) { try { btn.textContent = label; } catch {} }
262
+ try {
263
+ var url = new URL(location.href);
264
+ url.searchParams.set('cb', String(Date.now()));
265
+ location.replace(url.toString());
266
+ } catch {
267
+ try { location.reload(); } catch {}
268
+ }
269
+ }
270
+
271
+ async function pollStatus() {
272
+ var ctl = new AbortController();
273
+ var pollTimer = setTimeout(function() { ctl.abort(); }, POLL_TIMEOUT_MS);
274
+ var shouldRetry = false;
275
+ try {
276
+ var res = await fetch('/api/status', { cache: 'no-store', signal: ctl.signal });
277
+ if (!res || !res.ok) { shouldRetry = true; }
278
+ else {
279
+ var data = await res.json().catch(function() { return null; });
280
+ var newDashId = (data && data.version) ? data.version.dashboardStartedAt : null;
281
+ if (_ccIsNewDashboardInstance(preRestartDashId, newDashId, clickTimeMs)) {
282
+ reloadWithCacheBust('Dashboard online — reloading...');
283
+ return;
284
+ }
285
+ // Same instance (or unparseable) — old dashboard still answering or
286
+ // new dashboard ID hasn't advanced past clickTime yet. Keep polling.
287
+ shouldRetry = true;
209
288
  }
210
- throw new Error('not ok');
211
- }).catch(function() {
289
+ } catch { shouldRetry = true; }
290
+ finally { clearTimeout(pollTimer); }
291
+ if (shouldRetry) {
212
292
  if (Date.now() - startedAt > DEADLINE_MS) {
213
- if (btn) { try { btn.textContent = 'Restart timed out reloading...'; } catch {} }
214
- try { location.reload(); } catch {}
293
+ // Last-ditch reload. refresh.js's auto-detection (4s poll on
294
+ // dashboardStartedAt change) will pick up the new instance once it
295
+ // does come up.
296
+ reloadWithCacheBust('Restart timed out — reloading anyway...');
215
297
  return;
216
298
  }
217
- setTimeout(pollHealth, INTERVAL_MS);
218
- });
299
+ setTimeout(pollStatus, INTERVAL_MS);
300
+ }
219
301
  }
220
- // Don't poll immediately — give the restart child a moment to actually kill
221
- // the old dashboard. If we polled at t=0 we'd hit the OLD (dying) dashboard
222
- // and prematurely reload before the new one is up.
223
- setTimeout(pollHealth, 2000);
302
+
303
+ // Give the restart child a moment to actually kill the old dashboard before
304
+ // we start polling. Polling at t=0 risks confirming the dying old instance.
305
+ setTimeout(pollStatus, 2000);
224
306
  }
225
307
 
226
308
  function _ccIsReconnectableStreamError(err) {
package/dashboard.js CHANGED
@@ -9379,6 +9379,17 @@ if (require.main === module) {
9379
9379
  } catch { /* warm-up is best-effort */ }
9380
9380
  });
9381
9381
 
9382
+ // Pre-warm /api/status cache (W-mpehvbed0018e27d). The CC "Restart Minions"
9383
+ // recovery button polls /api/status and only reloads once the new dashboard
9384
+ // reports a NEW dashboardStartedAt. Without warming, the first /api/status
9385
+ // request (from the button OR from refresh.js OR from the post-reload page
9386
+ // load) triggers a cold getStatus() rebuild that takes 2-5s on Windows+AV
9387
+ // and stalls the freshly-reloaded page in a spinner. Warming on listen
9388
+ // means the first request hits the cached buffer.
9389
+ setImmediate(() => {
9390
+ try { getStatusJson(); } catch { /* warm-up is best-effort */ }
9391
+ });
9392
+
9382
9393
  // ─── Engine Watchdog ─────────────────────────────────────────────────────
9383
9394
  // Every 30s, check if engine PID is alive. If dead but control.json says
9384
9395
  // running, auto-restart it. Prevents silent engine death.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1992",
3
+ "version": "0.1.1993",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"