comfyui-mcp 0.49.1 → 0.49.2

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.
@@ -40,9 +40,9 @@ import { getNsfwConsent, setNsfwConsent } from "../services/panel-settings.js";
40
40
  import { QueueMonitor } from "../services/queue-monitor.js";
41
41
  import { getClient, getObjectInfo, backfillObjectInfo, resetClient, resetObjectInfoCache, } from "../comfyui/client.js";
42
42
  import { convertUiToApi, collectNodeTypes } from "../services/workflow-converter.js";
43
- import { restartComfyUI } from "../services/process-control.js";
43
+ import { restartComfyUI, preflightLocalRestart, recordRestartDispatch, clearRestartDispatch, getRestartDispatchRecord, RESTART_DISPATCH_CAUSATION_WINDOW_MS, PROCESS_WIDE_RESTART_DISPATCH_TOKEN, __processControlTestHooks, } from "../services/process-control.js";
44
44
  import { resetManagerApiCache } from "../services/manager-api-cache.js";
45
- import { isRemoteMode, isCloudMode, getBootLocalComfyUIBaseUrl, getComfyUIBaseUrl, } from "../config.js";
45
+ import { isRemoteMode, isCloudMode, getBootLocalComfyUIBaseUrl, getComfyUIBaseUrl, getComfyuiTargetGeneration, } from "../config.js";
46
46
  import { sliceWorkflow } from "../services/workflow-slicer.js";
47
47
  import { validateA2UISpecServer } from "../services/a2ui-spec.js";
48
48
  /** Treat these as an affirmative answer to a yes/no confirm card (destructive-op
@@ -206,6 +206,19 @@ const MAX_REBOOT_BUDGET_MS = 240_000; // 240s → settle+budget ≤ 250s < 300s
206
206
  // fast with an actionable retry / restart_comfyui hint. It NEVER shortcuts the
207
207
  // confirmation itself (no auto-confirm): it bounds only how long we wait for the answer.
208
208
  const RESTART_CONFIRM_TIMEOUT_MS = 90_000; // 90s
209
+ // #742 decline-probe recheck window: a single ECONNREFUSED is NOT proof of a
210
+ // lost server — a genuinely restarting instance is refused during its normal
211
+ // down window (codex gate). When the decline path probes before reporting, it
212
+ // rechecks over this SHORT, bounded window and only declares DOWN when the
213
+ // endpoint is STILL refused at the end of it; a recovery inside the window is
214
+ // reported as such. Deliberately small: the turn already ended on the user's
215
+ // decline, so this is a report-time check and must not stall.
216
+ const DECLINE_PROBE_WINDOW_MS = 6_000; // 6s total
217
+ const DECLINE_PROBE_INTERVAL_MS = 2_000; // 2s between samples
218
+ const DECLINE_PROBE_TIMEOUT_MS = 2_000; // per-sample probe ceiling
219
+ // #742 r4/r5: the decline path may name restart causation only against a
220
+ // dispatch RECORDED within RESTART_DISPATCH_CAUSATION_WINDOW_MS (imported from
221
+ // process-control) whose token THIS session holds — see sessionRestartDispatch.
209
222
  function parsePositiveNumberEnv(name, fallback) {
210
223
  const raw = process.env[name];
211
224
  if (raw == null || raw === "")
@@ -245,6 +258,30 @@ export const __panelToolsTestHooks = {
245
258
  setHealthProbe(fn) {
246
259
  healthProbeOverride = fn;
247
260
  },
261
+ /** Inject a fake #742 restart preflight so reboot tests don't probe real
262
+ * processes/ports. null restores the live preflightLocalRestart. */
263
+ setLocalRestartPreflight(fn) {
264
+ localRestartPreflightOverride = fn;
265
+ },
266
+ /** Inject a fast #742 decline-probe recheck window so decline-path tests
267
+ * don't wait the real ~6s. null restores the DECLINE_PROBE_* constants. */
268
+ setDeclineProbeTiming(timing) {
269
+ declineProbeTimingOverride = timing;
270
+ },
271
+ /** Direct access to the #742 decline recheck loop so its hard-deadline
272
+ * guarantee (codex gate r2) can be unit-tested with a custom deadline. */
273
+ probeDeclineRecovery,
274
+ /** r5: the restart-dispatch record THIS session (ctx) holds, or null. */
275
+ getSessionRestartDispatch(ctx) {
276
+ return sessionRestartDispatch(ctx);
277
+ },
278
+ /** r5: seed a restart-dispatch record HELD BY this session (ctx) — with an
279
+ * explicit `at` so fresh/stale shapes don't need a real restart. */
280
+ seedSessionRestartDispatch(ctx, record) {
281
+ const token = randomUUID();
282
+ __processControlTestHooks.setRestartDispatchRecord(token, record);
283
+ sessionRestartDispatchTokens.set(ctx, token);
284
+ },
248
285
  looksLikeSystemStats,
249
286
  probeComfyHealth,
250
287
  probeComfyEndpoint,
@@ -648,6 +685,66 @@ function normalizeProbe(v) {
648
685
  return "down";
649
686
  return v;
650
687
  }
688
+ /**
689
+ * The #742 decline-path recheck: poll `base` over a short, bounded window
690
+ * (clamped to `deadline`) so a server that is merely mid-restart is not
691
+ * falsely declared lost on a single ECONNREFUSED. Samples immediately, then
692
+ * sleeps intervalMs between samples — "still refused at the END of the
693
+ * window" is the only down verdict, and ONLY when the full window ran: a
694
+ * deadline that truncates the window downgrades the verdict to "ambiguous"
695
+ * regardless of the samples taken (r3). The deadline is HARD (codex gate
696
+ * r2): no awaited probe may START once the remaining budget is exhausted —
697
+ * the loop stops and the verdict falls to the last known state (or
698
+ * "ambiguous" when nothing was sampled). Never throws.
699
+ */
700
+ async function probeDeclineRecovery(base, windowMs, intervalMs, probeTimeoutMs, deadline) {
701
+ const start = Date.now();
702
+ const windowEnd = start + Math.max(1, windowMs);
703
+ const end = Math.min(windowEnd, deadline);
704
+ // r3: a window the deadline cuts short can never prove permanence — DOWN
705
+ // requires the FULL recheck window to have run its course.
706
+ const truncated = end < windowEnd;
707
+ const probe = healthProbeOverride ?? probeComfyEndpoint;
708
+ let attempts = 0;
709
+ let sawDown = false;
710
+ let last = "unknown";
711
+ for (;;) {
712
+ // HARD deadline: NEVER begin an awaited probe with an exhausted remainder
713
+ // (clamping an expired remainder to a 1ms timeout would still start — and
714
+ // await — a probe PAST the deadline, defeating the guarantee).
715
+ const remaining = end - Date.now();
716
+ if (remaining <= 0)
717
+ break;
718
+ attempts++;
719
+ const t = Math.min(probeTimeoutMs, remaining);
720
+ let status = "unknown";
721
+ try {
722
+ status = normalizeProbe(await probe(base, t));
723
+ }
724
+ catch {
725
+ status = "unknown";
726
+ }
727
+ if (status === "healthy") {
728
+ return {
729
+ status: sawDown ? "recovered" : "healthy",
730
+ attempts,
731
+ waited_ms: Date.now() - start,
732
+ };
733
+ }
734
+ if (status === "down")
735
+ sawDown = true;
736
+ last = status;
737
+ const left = end - Date.now();
738
+ if (left <= 0)
739
+ break;
740
+ await new Promise((r) => setTimeout(r, Math.max(1, Math.min(intervalMs, left))));
741
+ }
742
+ return {
743
+ status: last === "down" && !truncated ? "down" : "ambiguous",
744
+ attempts,
745
+ waited_ms: Date.now() - start,
746
+ };
747
+ }
651
748
  /**
652
749
  * The FIXED ComfyUI base URL to health-probe during a reboot readiness wait, or
653
750
  * null when we must fall back to the panel round-trip (as before #509). Captured by
@@ -711,6 +808,49 @@ function captureRebootHealthBase(ctx) {
711
808
  return loopbackProbeUrl(base);
712
809
  }
713
810
  let healthProbeOverride = null;
811
+ /** Test injection for the #742 refuse-safe restart preflight (the real one is
812
+ * preflightLocalRestart in process-control). null → the live preflight. */
813
+ let localRestartPreflightOverride = null;
814
+ /** Test injection for the #742 decline-probe recheck window, so tests don't
815
+ * wait the real ~6s. null → the DECLINE_PROBE_* constants. */
816
+ let declineProbeTimingOverride = null;
817
+ // #742 r5: restart-dispatch tokens held PER SESSION. Each MCP session gets its
818
+ // own PanelToolCtx (one per connection — see the session factory in
819
+ // panel-mcp-http), so keying by the ctx object scopes a dispatch record to the
820
+ // session that dispatched it: session A's failed restart can never ground
821
+ // causation for session B's decline, and A's recovery clears only A's record.
822
+ // WeakMap → entries die with their session's ctx.
823
+ const sessionRestartDispatchTokens = new WeakMap();
824
+ /** Stamp a restart dispatch and hold its token on THIS session (replacing any
825
+ * record the session previously held — a session has at most one live one).
826
+ * Returns the held token so clears can be CLEAR-IF-SAME (r15). */
827
+ function stampSessionRestartDispatch(ctx, base) {
828
+ const prev = sessionRestartDispatchTokens.get(ctx);
829
+ if (prev)
830
+ clearRestartDispatch(prev);
831
+ const token = recordRestartDispatch(base);
832
+ sessionRestartDispatchTokens.set(ctx, token);
833
+ return token;
834
+ }
835
+ /** The dispatch token THIS session currently holds, or undefined. */
836
+ function sessionRestartDispatchToken(ctx) {
837
+ return sessionRestartDispatchTokens.get(ctx);
838
+ }
839
+ /** The restart-dispatch record THIS session holds, or null. */
840
+ function sessionRestartDispatch(ctx) {
841
+ const held = sessionRestartDispatchToken(ctx);
842
+ return held ? getRestartDispatchRecord(held) : null;
843
+ }
844
+ /** Clear ONLY when the session STILL holds `token` (r15): a newer dispatch
845
+ * that landed since `token` was validated keeps its record — a
846
+ * read-current-then-clear would evict the newer dispatch's record and make a
847
+ * later persistent DOWN falsely report "no restart was dispatched". */
848
+ function clearSessionRestartDispatchIfSame(ctx, token) {
849
+ if (sessionRestartDispatchTokens.get(ctx) !== token)
850
+ return;
851
+ sessionRestartDispatchTokens.delete(ctx);
852
+ clearRestartDispatch(token);
853
+ }
714
854
  /**
715
855
  * Observe the boot endpoint's recovery AFTER a reboot was dispatched, and certify ONLY on
716
856
  * an OBSERVED DOWN→UP cycle. Acceptance (dispatch confirmed/dropped) is the guard against
@@ -2518,8 +2658,16 @@ allowStateFallback = false) {
2518
2658
  /* fall through to the actionable error below */
2519
2659
  }
2520
2660
  }
2521
- throw new Error(`Couldn't capture the live canvas (${msg}). ` +
2522
- `An older panel version may not support graph_serialize — pass pack, path, or graph instead.`);
2661
+ // #721: only blame an old panel when the error actually IS an
2662
+ // unsupported-command rejection. Anything else (e.g. the panel's graph
2663
+ // desync guard, whose remedy is rebinding/opening the workflow) already
2664
+ // carries its own remedy in the message — appending the version hint
2665
+ // there misdirects the agent to pack/path/graph instead.
2666
+ if (isPanelCmdUnsupportedError(err, "graph_serialize")) {
2667
+ throw new Error(`Couldn't capture the live canvas (${msg}). ` +
2668
+ `An older panel version may not support graph_serialize — pass pack, path, or graph instead.`);
2669
+ }
2670
+ throw new Error(`Couldn't capture the live canvas (${msg}).`);
2523
2671
  }
2524
2672
  const wf = reply?.workflow;
2525
2673
  if (!wf || typeof wf !== "object") {
@@ -3027,7 +3175,7 @@ export function buildPanelToolDefs() {
3027
3175
  mode: args.mode,
3028
3176
  limit: args.limit,
3029
3177
  })),
3030
- def("panel_add_node", "Add a node to the user's OPEN ComfyUI graph by class_type (e.g. 'KSampler', 'CheckpointLoaderSimple'). The user sees it appear live; Ctrl+Z undoes it. Returns the created node's id, slots, and default widget values.", {
3178
+ def("panel_add_node", "Add a node to the user's OPEN ComfyUI graph by class_type (e.g. 'KSampler', 'CheckpointLoaderSimple'). The user sees it appear live; Ctrl+Z undoes it. Returns the created node's id, slots, and default widget values. Frontend-only virtual types are addable too: 'Note' and 'MarkdownNote' — the supported way to ANNOTATE a workflow with on-canvas instructions (add the node, then put the text in its 'text' widget via panel_set_widget) — plus 'Reroute' and 'PrimitiveNode'. These are LiteGraph-native and never appear in the backend node registry, so they legitimately bypass the backend class_type check.", {
3031
3179
  class_type: z.string().describe("Exact ComfyUI node class_type to create."),
3032
3180
  pos: xy()
3033
3181
  .optional()
@@ -4327,7 +4475,7 @@ export function buildPanelToolDefs() {
4327
4475
  return ctx.call({ cmd: "graph_update_node", id: args.id, version: args.version, channel: args.channel, mode: args.mode }, 30000);
4328
4476
  }),
4329
4477
  def("panel_node_queue_status", "Check the built-in Manager's install/update queue status (to see if a queued install finished). Read-only.", {}, async (_args, ctx) => ctx.call({ cmd: "nodes_queue_status" }, 20000)),
4330
- def("panel_restart_comfyui", "Restart the user's ComfyUI server via the built-in Manager — needed to load newly installed/updated custom nodes. CALL THIS DIRECTLY when a restart is needed: it pops a confirm card and only restarts on a yes (don't ask separately first). ComfyUI and this agent go down briefly, then the panel auto-reconnects and you resume. ⚠️ BUSY GUARD: a restart ABORTS any in-progress or queued generation — if ComfyUI is generating, this tool REFUSES and tells you (it does NOT restart). When that happens, tell the user a render is running and WAIT for it (poll panel_node_queue_status), or pass force:true ONLY if the user explicitly confirms they want to kill the running generation. Best practice: before restarting after an install, check the queue is idle first. Only call when a restart is actually needed.", { force: z.boolean().optional() }, async ({ force }, ctx) => {
4478
+ def("panel_restart_comfyui", "Restart the user's ComfyUI server via the built-in Manager — needed to load newly installed/updated custom nodes. CALL THIS DIRECTLY when a restart is needed: it pops a confirm card and only restarts on a yes (don't ask separately first). ComfyUI and this agent go down briefly, then the panel auto-reconnects and you resume. ⚠️ BUSY GUARD: a restart ABORTS any in-progress or queued generation — if ComfyUI is generating, this tool REFUSES and tells you (it does NOT restart). When that happens, tell the user a render is running and WAIT for it (poll panel_node_queue_status), or pass force:true ONLY if the user explicitly confirms they want to kill the running generation. Best practice: before restarting after an install, check the queue is idle first. Only call when a restart is actually needed. On an externally-managed install whose relaunch can't be proven from here (e.g. Pinokio), the restart is REFUSED before anything is stopped — restart from the launcher that owns the server instead.", { force: z.boolean().optional() }, async ({ force }, ctx) => {
4331
4479
  // Whole-handler budget (#536): confirm + dispatch + readiness — INCLUDING
4332
4480
  // the legacy path's UNPREEMPTIBLE synchronous execSync blocks — must ALL finish
4333
4481
  // under the outer ~300s tools/call limit. 255s + the legacy admission rule below
@@ -4363,6 +4511,124 @@ export function buildPanelToolDefs() {
4363
4511
  "panel card.");
4364
4512
  }
4365
4513
  if (decision !== "yes") {
4514
+ // #742: NEVER claim "not restarted" while the server is actually DOWN —
4515
+ // and NEVER declare a loss from ONE probe (codex gate): a genuinely
4516
+ // restarting server is refused during its normal down window, and a
4517
+ // transport/card error mapping to "no" is exactly how a tab dying
4518
+ // during a healthy prior reboot lands here. So recheck over a short,
4519
+ // bounded window and report DOWN only when the endpoint is STILL
4520
+ // refused at the end of it; a recovery inside the window is reported
4521
+ // as such (bound instance) or keeps the plain cancel line (unbound).
4522
+ // CAUSATION ("a restart took it down") is named ONLY when the probed
4523
+ // target is PROVABLY the same boot instance the restart would have
4524
+ // stopped — the same binding the refuse-safe preflight uses; an
4525
+ // unbound configured endpoint gets a generic unreachable report that
4526
+ // never claims "we stopped it".
4527
+ const declineBootBase = captureRebootHealthBase(ctx);
4528
+ const boundToRestartTarget = declineBootBase != null && sameHttpBase(getComfyUIBaseUrl(), declineBootBase);
4529
+ const declineProbeBase = boundToRestartTarget
4530
+ ? declineBootBase
4531
+ : getComfyUIBaseUrl();
4532
+ // r15: capture the session's held dispatch TOKEN before the probe
4533
+ // awaits — the exoneration clear (r13) and the recovery claim (r14)
4534
+ // must key on the token VALIDATED HERE, never whichever token is
4535
+ // current after the awaits: a concurrent accepted restart stamping a
4536
+ // fresh token mid-probe keeps its record (clear-if-same below), so a
4537
+ // later persistent DOWN can still attribute to it.
4538
+ const declineHeldToken = sessionRestartDispatchToken(ctx);
4539
+ const declineTiming = declineProbeTimingOverride ?? {
4540
+ windowMs: DECLINE_PROBE_WINDOW_MS,
4541
+ intervalMs: DECLINE_PROBE_INTERVAL_MS,
4542
+ probeTimeoutMs: DECLINE_PROBE_TIMEOUT_MS,
4543
+ };
4544
+ const outcome = await probeDeclineRecovery(declineProbeBase, declineTiming.windowMs, declineTiming.intervalMs, declineTiming.probeTimeoutMs, overallDeadline);
4545
+ // The VALIDATED token's record (null when it was superseded by a
4546
+ // newer stamp during the probe — the conservative outcome: neither
4547
+ // claim nor clear keys on a record this probe didn't validate).
4548
+ const declineHeldRecord = declineHeldToken
4549
+ ? getRestartDispatchRecord(declineHeldToken)
4550
+ : null;
4551
+ // r13: a HEALTHY/RECOVERED observation exonerates any dispatch THIS
4552
+ // session has on record — the restart explained itself, so its token
4553
+ // must never ground causation for a LATER, independent failure.
4554
+ // Base-matched: a healthy observation only exonerates the instance
4555
+ // it was taken on (an unbound healthy endpoint says nothing about a
4556
+ // boot-instance record). The 10-minute causation window stays as the
4557
+ // backstop for records never observed back.
4558
+ // r14: the record is captured BEFORE the clear — the recovery CLAIM
4559
+ // below ("a restart initiated earlier appears to have completed")
4560
+ // requires the token to have been present at this moment.
4561
+ // r15: CLEAR-IF-SAME — the clear fires only when the session still
4562
+ // holds the SAME token validated before the probe; a newer dispatch
4563
+ // stamped mid-probe keeps its record.
4564
+ if (outcome.status === "healthy" || outcome.status === "recovered") {
4565
+ if (declineHeldToken != null &&
4566
+ declineHeldRecord != null &&
4567
+ (declineHeldRecord.base == null ||
4568
+ sameHttpBase(declineHeldRecord.base, declineProbeBase))) {
4569
+ clearSessionRestartDispatchIfSame(ctx, declineHeldToken);
4570
+ }
4571
+ }
4572
+ if (outcome.status === "down") {
4573
+ const secs = Math.max(1, Math.round(outcome.waited_ms / 1000));
4574
+ if (boundToRestartTarget) {
4575
+ // r4: causation may be named ONLY against a RECORDED restart
4576
+ // dispatch — recent enough to plausibly be the cause, and
4577
+ // targeting THIS instance. r5: the record must be one THIS
4578
+ // SESSION dispatched (holds the token of) — another session's
4579
+ // failed restart never grounds causation here. An unrelated
4580
+ // crash / manual stop (no record, a stale record, another
4581
+ // instance's record, or another session's record) gets the
4582
+ // causation-free report.
4583
+ const dispatch = sessionRestartDispatch(ctx);
4584
+ const causative = dispatch != null &&
4585
+ Date.now() - dispatch.at <= RESTART_DISPATCH_CAUSATION_WINDOW_MS &&
4586
+ (dispatch.base == null || sameHttpBase(dispatch.base, declineBootBase));
4587
+ if (causative) {
4588
+ return ok("⚠️ ComfyUI is DOWN — it was STOPPED and did not come back (still " +
4589
+ `unreachable after a ${secs}s recheck window), so do NOT treat this as ` +
4590
+ "\"nothing happened\". The restart just declined was NOT what stopped " +
4591
+ "it (nothing was dispatched after the decline), but a restart initiated " +
4592
+ "earlier already took the server down and it never returned. Start " +
4593
+ "ComfyUI manually from whatever launches it (e.g. Pinokio, the Desktop " +
4594
+ "app, or your terminal), then reload the panel tab so it reconnects. " +
4595
+ "restart_comfyui can attempt the relaunch for you when the install's " +
4596
+ "launch path is resolvable.");
4597
+ }
4598
+ return ok("⚠️ ComfyUI is DOWN — still unreachable after " +
4599
+ `a ${secs}s recheck window — and no restart was dispatched through me ` +
4600
+ "that would explain it (the restart just declined was NOT dispatched, " +
4601
+ "and none is on record recently for this instance). Something else " +
4602
+ "stopped it (a crash, a manual stop, or its launcher). Start ComfyUI " +
4603
+ "manually from whatever launches it (e.g. Pinokio, the Desktop app, or " +
4604
+ "your terminal), then reload the panel tab so it reconnects.");
4605
+ }
4606
+ return ok("⚠️ ComfyUI appears to be DOWN — the configured endpoint was still " +
4607
+ `unreachable after a ${secs}s recheck window. The restart just declined ` +
4608
+ "was NOT dispatched, and I can't confirm from here whether the server " +
4609
+ "was actually stopped — this endpoint isn't provably the instance the " +
4610
+ "restart would have cycled. Check ComfyUI on its host and start it " +
4611
+ "manually if it is down, then reload the panel tab so it reconnects.");
4612
+ }
4613
+ if (outcome.status === "recovered" && boundToRestartTarget) {
4614
+ // r14: the recovery CLAIM ("a restart initiated earlier appears to
4615
+ // have completed") passes the SAME causation gate as the DOWN
4616
+ // report — a session-held, bound-confirmed record, recent, and
4617
+ // base-matched, captured BEFORE the r13 exoneration clear (which
4618
+ // fires either way). Without it, the down→healthy cycle alone
4619
+ // proves nothing about what caused the down: report the recovery
4620
+ // causation-free.
4621
+ const recoveryCausative = declineHeldRecord != null &&
4622
+ Date.now() - declineHeldRecord.at <= RESTART_DISPATCH_CAUSATION_WINDOW_MS &&
4623
+ (declineHeldRecord.base == null ||
4624
+ sameHttpBase(declineHeldRecord.base, declineBootBase));
4625
+ return ok(recoveryCausative
4626
+ ? "Cancelled — no new restart was dispatched. Note: ComfyUI was briefly " +
4627
+ "unreachable but is healthy again — a restart initiated earlier " +
4628
+ "appears to have completed."
4629
+ : "Cancelled — no new restart was dispatched. ComfyUI was briefly " +
4630
+ "unreachable but is healthy again.");
4631
+ }
4366
4632
  return ok("Cancelled — ComfyUI was not restarted.");
4367
4633
  }
4368
4634
  // Heal an orphaned session onto the live tab FIRST, then bind the reboot dispatch
@@ -4371,6 +4637,99 @@ export function buildPanelToolDefs() {
4371
4637
  // server-authorized + immutable, bound to the exact host FAMILY the reboot goes
4372
4638
  // to (null unless the bound tab provably fronts our boot instance).
4373
4639
  ctx.ensureReachable?.();
4640
+ // #742 REFUSE-SAFE PREFLIGHT: a Manager reboot stops ComfyUI OUT-OF-BAND —
4641
+ // it never goes through our validated kill+relaunch — so before dispatching
4642
+ // anything, the stop must be provable survivable (#368/#370: losing a restart
4643
+ // is cheap, losing the server is not). On a Pinokio-style install (externally
4644
+ // supervised; no main.py/interpreter resolvable from here) a plain Manager
4645
+ // restart kills the process and the supervisor does NOT re-launch it — the
4646
+ // exact #742 lost-server. When the reboot would target OUR local boot
4647
+ // instance (the same instance binding the legacy fallback uses), prove a
4648
+ // relaunch is possible FIRST and refuse BEFORE any stop when it isn't. Only
4649
+ // the PROVEN-dangerous shape refuses (a reachable local non-Desktop process
4650
+ // with an unbuildable/unvalidatable relaunch); every other shape — Desktop
4651
+ // (Electron-supervised, #400), unverifiable, or remote — proceeds exactly as
4652
+ // before. The binding for this DECISION is captured pre-await; nothing
4653
+ // downstream may reuse it (r7).
4654
+ const preflightHealthBase = captureRebootHealthBase(ctx);
4655
+ if (preflightHealthBase != null && sameHttpBase(getComfyUIBaseUrl(), preflightHealthBase)) {
4656
+ // Snapshot the target GENERATION at the decision (r11): a final-state
4657
+ // base comparison (A vs A) cannot detect an intervening A→B→A
4658
+ // retarget, so stability is judged by the monotonic epoch bumped on
4659
+ // EVERY retarget — any mutation, including a round trip back to the
4660
+ // same base, is caught.
4661
+ const preflightTargetGeneration = getComfyuiTargetGeneration();
4662
+ const preflight = await (localRestartPreflightOverride ?? preflightLocalRestart)();
4663
+ // r8/r9/r10: the preflight AWAIT makes the pre-decision captures
4664
+ // STALE — and the preflight itself reads MUTABLE config (target URL,
4665
+ // port, COMFYUI_PATH) throughout, so a config retarget during the
4666
+ // await can re-point the whole assessment at a DIFFERENT install
4667
+ // while the tab still fronts the original one. Re-heal and
4668
+ // re-capture BEFORE trusting the result either way. The guarantee
4669
+ // that must hold: a stop/reboot is only ever sent when the preflight
4670
+ // validated THE instance the dispatch will cycle.
4671
+ ctx.ensureReachable?.();
4672
+ const postPreflightHealthBase = captureRebootHealthBase(ctx);
4673
+ const tabFrontsSameInstance = postPreflightHealthBase != null &&
4674
+ sameHttpBase(preflightHealthBase, postPreflightHealthBase);
4675
+ const configStable = getComfyuiTargetGeneration() === preflightTargetGeneration;
4676
+ if (!configStable && tabFrontsSameInstance) {
4677
+ // r10: the target config moved MID-CHECK, so the preflight result
4678
+ // — pass OR fail — cannot vouch for the tab-fronted instance (a
4679
+ // PASS may have validated a different, safe install; it must never
4680
+ // bless a stop of this one). Its relaunch is UNPROVEN → refuse; an
4681
+ // instance with an unproven relaunch is never sent a stop.
4682
+ return ok({
4683
+ rebooting: false,
4684
+ ready: false,
4685
+ confirmed_cycle: false,
4686
+ refused: true,
4687
+ note: "Refusing to restart ComfyUI: the ComfyUI target configuration changed " +
4688
+ "while the restart safety check was running, so the check cannot vouch " +
4689
+ "for a safe relaunch of the instance this panel fronts. A stop is never " +
4690
+ "sent to an instance whose relaunch is unproven — ComfyUI was NOT " +
4691
+ "stopped (it is still running). Let the target settle, then retry " +
4692
+ "panel_restart_comfyui.",
4693
+ });
4694
+ }
4695
+ if (!preflight.ok && tabFrontsSameInstance) {
4696
+ // r9: the danger proof follows the INSTANCE the tab fronts, NOT the
4697
+ // mutable runtime config — a config-only retarget mid-await must
4698
+ // not wash out the proof that the tab-fronted boot instance is
4699
+ // unrelaunchable. The tab STILL fronts that same instance, so the
4700
+ // proof is still valid and the refusal stands: an instance proven
4701
+ // unrelaunchable is NEVER sent a stop/reboot, regardless of
4702
+ // config/tab shuffling mid-flight. Only a genuinely different,
4703
+ // unconfirmable target falls through to the honest-unconfirmed
4704
+ // dispatch (nothing was ever proved dangerous for it — and nothing
4705
+ // was ever stopped, the preflight never stops anything).
4706
+ return ok({
4707
+ rebooting: false,
4708
+ ready: false,
4709
+ confirmed_cycle: false,
4710
+ refused: true,
4711
+ note: `Refusing to restart ComfyUI: ${preflight.reason} This looks like an ` +
4712
+ "externally-managed install (e.g. Pinokio): a restart from here would STOP " +
4713
+ "ComfyUI and nothing would bring it back automatically, so it was refused " +
4714
+ "BEFORE anything was stopped — ComfyUI is still running. Restart it from the " +
4715
+ "launcher that owns it (e.g. Pinokio's own controls), or point COMFYUI_PATH " +
4716
+ "at the live install so a relaunch can be proven and use restart_comfyui.",
4717
+ });
4718
+ }
4719
+ // Otherwise: a PASS with a stable config (proven safe for THE
4720
+ // tab-fronted instance — proceed), or the tab now fronts a genuinely
4721
+ // different, unconfirmable target (nothing provable about it — the
4722
+ // dispatch path below treats that honestly, r6/r7). Nothing was
4723
+ // stopped in any case — the preflight never stops anything.
4724
+ }
4725
+ // r7: the preflight AWAIT sits between the binding capture and the dispatch,
4726
+ // breaking the no-await invariant above — a tab/connection rebind during
4727
+ // that await would make every pre-await capture stale (the dispatch would
4728
+ // go to an unconfirmable/different target while the causation stamp, the
4729
+ // recovery observer, and the legacy-fallback gate all read the STALE bound
4730
+ // base). Re-heal and re-capture the tab id, panel identity, and health base
4731
+ // AT THE DISPATCH POINT; everything below uses ONLY these fresh captures.
4732
+ ctx.ensureReachable?.();
4374
4733
  const boundTabId = ctx.tabId;
4375
4734
  // Snapshot the exact browser-tab registration that is about to receive the
4376
4735
  // reboot. A post-restart success must observe a strictly newer hello from
@@ -4539,6 +4898,18 @@ export function buildPanelToolDefs() {
4539
4898
  " — restart ComfyUI on the host, then reconnect.");
4540
4899
  }
4541
4900
  clearTimeout(restartTimer);
4901
+ // #742 r5/r6: the managed restart stopped the process — record the
4902
+ // dispatch with THIS session holding the token, stamped with the
4903
+ // BOUND-CONFIRMED base (this fallback only runs when the instance
4904
+ // binding held, so healthBase is non-null here). restartComfyUI
4905
+ // also stamped its own process-wide record, which never grounds
4906
+ // causation. Only a PROVEN stop is recorded; a refusal/timeout
4907
+ // (restart undefined, or stopped!==true) records nothing. The
4908
+ // token is kept so the recovery clear below is CLEAR-IF-SAME (r15).
4909
+ let legacyDispatchToken;
4910
+ if (restart?.stopped === true) {
4911
+ legacyDispatchToken = stampSessionRestartDispatch(ctx, healthBase);
4912
+ }
4542
4913
  // DEFINITIVE no-restart: a spawn failure, OR restartComfyUI refused before
4543
4914
  // stopping anything (no process found / unsafe relaunch → stopped:false &&
4544
4915
  // started:false). The process was NOT cycled, so the still-healthy endpoint is
@@ -4554,6 +4925,14 @@ export function buildPanelToolDefs() {
4554
4925
  // Otherwise (the process WAS stopped/started, or restartComfyUI's own readiness
4555
4926
  // poll merely expired — neither terminal) DEFER to OUR OWN observed DOWN→UP.
4556
4927
  const recovery = await proofPromise;
4928
+ // #742 r4/r5/r15: the managed restart was observed back — clear THIS
4929
+ // session's record, CLEAR-IF-SAME: only when the session still holds
4930
+ // the token THIS restart stamped (a concurrent dispatch's newer
4931
+ // record survives). restartComfyUI also clears its own process-wide
4932
+ // record on success; this covers only-observer-saw-it recoveries.
4933
+ if (recovery.ready && legacyDispatchToken != null) {
4934
+ clearSessionRestartDispatchIfSame(ctx, legacyDispatchToken);
4935
+ }
4557
4936
  const observed = recovery.via === "observed-cycle";
4558
4937
  // The legacy Manager path restarts ComfyUI out-of-band too. Server recovery alone
4559
4938
  // is not graph-tool readiness: wait for the browser tab to reconnect, then verify
@@ -4608,6 +4987,25 @@ export function buildPanelToolDefs() {
4608
4987
  resetClient();
4609
4988
  resetObjectInfoCache();
4610
4989
  resetManagerApiCache("panel Manager reboot");
4990
+ // #742 r4/r5/r6: record the ACTUAL dispatch (acceptance proven — a refusal
4991
+ // never reaches here). ONLY a BOUND-CONFIRMED target (the same binding
4992
+ // the r1 causation scoping and the refuse-safe preflight use) may stamp a
4993
+ // causation-capable record — held on THIS session with the BOUND base at
4994
+ // stamp time, never the mutable configured one. An unbound/unconfirmable
4995
+ // target (r6) stamps only the shared PROCESS-WIDE slot, which never
4996
+ // grounds causation: the dispatch can't be proven to have hit the
4997
+ // instance a later decline would probe, and a session that rebinds to
4998
+ // the boot tab afterward can't claim it either (a re-targeted session
4999
+ // can't prove the earlier dispatch hit its current instance — no claim
5000
+ // is the truthful answer). It is cleared below if observed back —
5001
+ // CLEAR-IF-SAME on the token stamped here (r15).
5002
+ let acceptedDispatchToken;
5003
+ if (healthBase != null && sameHttpBase(getComfyUIBaseUrl(), healthBase)) {
5004
+ acceptedDispatchToken = stampSessionRestartDispatch(ctx, healthBase);
5005
+ }
5006
+ else {
5007
+ recordRestartDispatch(getComfyUIBaseUrl(), PROCESS_WIDE_RESTART_DISPATCH_TOKEN);
5008
+ }
4611
5009
  // Observe recovery. There is exactly ONE sound proof that THIS ComfyUI instance
4612
5010
  // actually cycled: a directly OBSERVED down→up on the server-authorized, immutable,
4613
5011
  // family-bound boot endpoint (observeRecovery). We do NOT fabricate a second proof
@@ -4642,6 +5040,12 @@ export function buildPanelToolDefs() {
4642
5040
  // down→up, which the observer has been (and continues) watching for.
4643
5041
  gate.deadline = Math.min(Date.now() + timing.budgetMs, overallDeadline);
4644
5042
  const recovery = await recoveryPromise;
5043
+ // #742 r4/r5/r15: the dispatched restart was observed back — clear the
5044
+ // record, CLEAR-IF-SAME: only when the session still holds the token
5045
+ // THIS dispatch stamped (a concurrent dispatch's newer record survives).
5046
+ if (recovery.ready && acceptedDispatchToken != null) {
5047
+ clearSessionRestartDispatchIfSame(ctx, acceptedDispatchToken);
5048
+ }
4645
5049
  if (!recovery.ready) {
4646
5050
  const waited = Math.round(recovery.waited_ms / 1000);
4647
5051
  return ok({