comfyui-mcp 0.52.26 → 0.52.28

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.
@@ -21,8 +21,18 @@
21
21
  * refused to risk. So a qualified id is never converted: it stays the string the
22
22
  * reader printed, all the way to the panel.
23
23
  */
24
- /** A plain integer id, the form the wire has always carried. */
25
- const PLAIN = /^-?\d+$/;
24
+ /**
25
+ * A plain integer id, the form the wire has always carried.
26
+ *
27
+ * Exported because one argument needs this shape ON ITS OWN: panel_run's
28
+ * `to_node_id` (#1497). Everything else takes NODE_ID_PATTERN, which admits the
29
+ * qualified form too — but a run-to-node target is resolved by NUMBER all the way
30
+ * down (the panel's findNodeInScopes does `Number(id)`, and its reply reports
31
+ * `ran_to_node: Number(to_node_id)`), so admitting `"120:104"` there would trade a
32
+ * clear schema refusal for a panel-side "node not found" about a node that exists.
33
+ * The shape lives here so the two patterns cannot drift apart.
34
+ */
35
+ export const PLAIN_NODE_ID_PATTERN = /^-?\d+$/;
26
36
  /**
27
37
  * A subgraph-qualified id: integer segments joined by colons (`120:104`,
28
38
  * `120:113:78`). No depth limit — nesting is arbitrary, and a limit would fail the
@@ -1 +1 @@
1
- {"version":3,"file":"node-id.js","sourceRoot":"","sources":["../../src/orchestrator/node-id.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,gEAAgE;AAChE,MAAM,KAAK,GAAG,SAAS,CAAC;AAExB;;;;;;GAMG;AACH,MAAM,SAAS,GAAG,kBAAkB,CAAC;AAErC;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,kBAAkB,CAAC;AAElD,6DAA6D;AAC7D,MAAM,UAAU,cAAc,CAAC,CAAS;IACtC,OAAO,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AACjC,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,iBAAiB,CAAC,CAAS;IACzC,OAAO,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAC3B,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,CAAkB;IAChD,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,CAAC,CAAC;IACpC,IAAI,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IAChC,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAChC,CAAC;AAED;oFACoF;AACpF,MAAM,CAAC,MAAM,eAAe,GAC1B,kFAAkF,CAAC"}
1
+ {"version":3,"file":"node-id.js","sourceRoot":"","sources":["../../src/orchestrator/node-id.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,SAAS,CAAC;AAE/C;;;;;;GAMG;AACH,MAAM,SAAS,GAAG,kBAAkB,CAAC;AAErC;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,kBAAkB,CAAC;AAElD,6DAA6D;AAC7D,MAAM,UAAU,cAAc,CAAC,CAAS;IACtC,OAAO,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AACjC,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,iBAAiB,CAAC,CAAS;IACzC,OAAO,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAC3B,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,CAAkB;IAChD,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,CAAC,CAAC;IACpC,IAAI,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;QAAE,OAAO,CAAC,CAAC;IAChC,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAChC,CAAC;AAED;oFACoF;AACpF,MAAM,CAAC,MAAM,eAAe,GAC1B,kFAAkF,CAAC"}
@@ -34,6 +34,7 @@ import { fileURLToPath } from "node:url";
34
34
  import { comfyuiFetch } from "../comfyui/fetch.js";
35
35
  import { assertPanelNotTargetedUnverifiable } from "../services/panel-pin-guard.js";
36
36
  import { nodesInstallCommandArgs } from "../services/node-management.js";
37
+ import { sanitizePanelUpdateNodeResult } from "../services/manager-update-error.js";
37
38
  import { formatQueueStatusPartialNote, getManifestPartialLeftover, } from "../services/manifest-partial.js";
38
39
  import { searchPanelNodes } from "../services/manager-node-search.js";
39
40
  import { isPanelAnsweredError } from "../services/panel-answered.js";
@@ -45,7 +46,7 @@ import { compareSemver } from "../services/self-update.js";
45
46
  import { describeInstallPanelAction } from "../services/panel-recovery.js";
46
47
  import { peekResolvedPanelBase, primePanelBase, verifiedPanelDiskVersion, } from "../services/panel-workspace.js";
47
48
  import { conversationOfScopeAddress, isScopeAddress, shortTabId } from "../services/session-scope.js";
48
- import { NODE_ID_MESSAGE, NODE_ID_PATTERN, normalizeNodeId } from "./node-id.js";
49
+ import { NODE_ID_MESSAGE, NODE_ID_PATTERN, normalizeNodeId, PLAIN_NODE_ID_PATTERN, } from "./node-id.js";
49
50
  import { parseContradictoryPromotedWidgetRefusal, resolveInnerPromotedTarget, } from "./promoted-widget.js";
50
51
  import { fastGroupsFilterPropertyNote, isFastGroupsFilterProperty, } from "./rgthree-fast-groups-property.js";
51
52
  import { isPlainObject, isStampMismatchSaveRefusal, openLiveMatchesDestContent, patchOpenIdentity, shouldRebindOpenIdentity, workflowFromSerializeReply, } from "./open-identity-normalization.js";
@@ -573,6 +574,11 @@ export const __panelToolsTestHooks = {
573
574
  setFreeVramDirect(fn) {
574
575
  freeVramDirectOverride = fn;
575
576
  },
577
+ /** Inject a fake post-/free /system_stats read so #1866 occupancy checks can
578
+ * be driven without real HTTP. null restores the live readVramDevices. */
579
+ setReadVramDevices(fn) {
580
+ readVramDevicesOverride = fn;
581
+ },
576
582
  /** Direct access to the #742 decline recheck loop so its hard-deadline
577
583
  * guarantee (codex gate r2) can be unit-tested with a custom deadline. */
578
584
  probeDeclineRecovery,
@@ -2185,17 +2191,7 @@ async function readVramDevices(base, timeoutMs) {
2185
2191
  const devices = body.devices;
2186
2192
  if (!Array.isArray(devices))
2187
2193
  return null;
2188
- return devices.map((d) => {
2189
- const dev = (d ?? {});
2190
- const sample = {};
2191
- if (typeof dev.name === "string")
2192
- sample.name = dev.name;
2193
- if (typeof dev.vram_total === "number")
2194
- sample.vram_total = dev.vram_total;
2195
- if (typeof dev.vram_free === "number")
2196
- sample.vram_free = dev.vram_free;
2197
- return sample;
2198
- });
2194
+ return devices.map((d) => sampleVramDevice(d));
2199
2195
  }
2200
2196
  catch {
2201
2197
  return null; // unreachable/timed out — no numbers to report
@@ -2204,12 +2200,98 @@ async function readVramDevices(base, timeoutMs) {
2204
2200
  clearTimeout(timer);
2205
2201
  }
2206
2202
  }
2203
+ function sampleVramDevice(d) {
2204
+ const dev = (d ?? {});
2205
+ const sample = {};
2206
+ if (typeof dev.name === "string")
2207
+ sample.name = dev.name;
2208
+ if (typeof dev.index === "number")
2209
+ sample.index = dev.index;
2210
+ if (typeof dev.vram_total === "number")
2211
+ sample.vram_total = dev.vram_total;
2212
+ if (typeof dev.vram_free === "number")
2213
+ sample.vram_free = dev.vram_free;
2214
+ if (typeof dev.torch_vram_total === "number")
2215
+ sample.torch_vram_total = dev.torch_vram_total;
2216
+ if (typeof dev.torch_vram_free === "number")
2217
+ sample.torch_vram_free = dev.torch_vram_free;
2218
+ return sample;
2219
+ }
2220
+ /** A device with at least 1 GiB of VRAM is still PINNED when less than 20% is
2221
+ * free after /free. CUDA context leftover on an unloaded GPU is a few hundred
2222
+ * MB to a couple of GiB, not 80%+ of a 21 GiB card. The reporter's Raylight
2223
+ * MiniMax H3 case was device 2 at ~0.8% free (~179 MiB of 21 GiB) next to
2224
+ * siblings at ~44% free — that occupancy is what this threshold names.
2225
+ *
2226
+ * Unknown/unreadable counters are NOT pinned: an unknown answer claims
2227
+ * nothing in either direction (#1473). */
2228
+ const PINNED_VRAM_MIN_TOTAL_BYTES = 1024 * 1024 * 1024;
2229
+ const PINNED_VRAM_FREE_RATIO = 0.2;
2230
+ function deviceStillPinned(d) {
2231
+ const total = d.vram_total;
2232
+ const free = d.vram_free;
2233
+ if (typeof total !== "number" || typeof free !== "number")
2234
+ return false;
2235
+ if (!Number.isFinite(total) || !Number.isFinite(free))
2236
+ return false;
2237
+ if (total < PINNED_VRAM_MIN_TOTAL_BYTES)
2238
+ return false;
2239
+ if (free < 0)
2240
+ return true;
2241
+ return free / total <= PINNED_VRAM_FREE_RATIO;
2242
+ }
2243
+ function pinnedVramDevices(devices) {
2244
+ return devices.filter(deviceStillPinned);
2245
+ }
2246
+ /** /free only unloads ComfyUI's model manager. Ray workers, parallel CLIP
2247
+ * loaders, and other custom-node allocations survive it and keep the GPU
2248
+ * occupied — which is why a 2xx from /free is not evidence VRAM is free. */
2249
+ function pinnedVramAfterFreeNote(pinned) {
2250
+ const named = pinned
2251
+ .map((d) => {
2252
+ if (typeof d.index === "number")
2253
+ return `device ${d.index}`;
2254
+ if (typeof d.name === "string" && d.name)
2255
+ return d.name;
2256
+ return "an unnamed device";
2257
+ })
2258
+ .join(", ");
2259
+ return (`ComfyUI /free accepted (unload_models + free_memory) but VRAM is STILL PINNED on ` +
2260
+ `${named}. /free only unloads ComfyUI's model manager — it does not terminate Ray ` +
2261
+ `workers or custom-node allocations (Raylight sequence-parallel, parallel CLIP loaders). ` +
2262
+ `This is not a successful VRAM recovery. Next: panel_restart_comfyui (last resort; ` +
2263
+ `refuses mid-render) after confirming with get_system_stats (action:"stats").`);
2264
+ }
2265
+ function pinnedVramAfterFreeResult(pinned, extra) {
2266
+ return {
2267
+ content: [
2268
+ {
2269
+ type: "text",
2270
+ text: JSON.stringify({
2271
+ freed: false,
2272
+ unload_models: true,
2273
+ free_memory: true,
2274
+ model_manager_freed: true,
2275
+ pinned_devices: pinned,
2276
+ ...extra,
2277
+ note: pinnedVramAfterFreeNote(pinned),
2278
+ }, null, 2),
2279
+ },
2280
+ ],
2281
+ isError: true,
2282
+ };
2283
+ }
2284
+ /** Test injection for the post-/free /system_stats occupancy read (#1866). */
2285
+ let readVramDevicesOverride = null;
2286
+ async function readVramDevicesMaybe(base, timeoutMs) {
2287
+ return (readVramDevicesOverride ?? readVramDevices)(base, timeoutMs);
2288
+ }
2207
2289
  /** Issue ComfyUI's /free DIRECTLY against a proven-local base and read the
2208
2290
  * VRAM counters around it. Never throws — every failure is a value, so the
2209
2291
  * settle can degrade to the honest outcome-unknown instead of masking the
2210
2292
  * original timeout behind a new error. */
2211
2293
  async function freeVramDirect(base) {
2212
- const before = await readVramDevices(base, FREE_VRAM_DIRECT_TIMEOUT_MS);
2294
+ const before = await readVramDevicesMaybe(base, FREE_VRAM_DIRECT_TIMEOUT_MS);
2213
2295
  const controller = new AbortController();
2214
2296
  const timer = setTimeout(() => controller.abort(), FREE_VRAM_DIRECT_TIMEOUT_MS);
2215
2297
  timer.unref?.();
@@ -2234,7 +2316,7 @@ async function freeVramDirect(base) {
2234
2316
  finally {
2235
2317
  clearTimeout(timer);
2236
2318
  }
2237
- const after = await readVramDevices(base, FREE_VRAM_DIRECT_TIMEOUT_MS);
2319
+ const after = await readVramDevicesMaybe(base, FREE_VRAM_DIRECT_TIMEOUT_MS);
2238
2320
  return { ok: true, before, after };
2239
2321
  }
2240
2322
  /** Test injection for the direct server-side /free, so the settle can be
@@ -2277,6 +2359,21 @@ async function settleFreeVramAfterAckTimeout(ctx, timedOut) {
2277
2359
  ],
2278
2360
  };
2279
2361
  }
2362
+ const extra = {
2363
+ acknowledged: false,
2364
+ verified: "server-side",
2365
+ via: `POST ${base}/free`,
2366
+ ...(direct.before != null ? { vram_before: direct.before } : {}),
2367
+ ...(direct.after != null ? { vram_after: direct.after } : {}),
2368
+ };
2369
+ // #1866 — a 2xx from /free is not a free GPU. Ray/CLIP workers can keep a
2370
+ // device pinned. When we have occupancy numbers, they are the verdict.
2371
+ if (direct.after != null) {
2372
+ const pinned = pinnedVramDevices(direct.after);
2373
+ if (pinned.length > 0) {
2374
+ return pinnedVramAfterFreeResult(pinned, extra);
2375
+ }
2376
+ }
2280
2377
  const statsNote = direct.before != null && direct.after != null
2281
2378
  ? "vram_before/vram_after are the server's own /system_stats counters around the free."
2282
2379
  : "The /system_stats read around it did not answer, so no VRAM counters are reported — the 2xx from /free is the verification, not a measured delta.";
@@ -2284,11 +2381,7 @@ async function settleFreeVramAfterAckTimeout(ctx, timedOut) {
2284
2381
  freed: true,
2285
2382
  unload_models: true,
2286
2383
  free_memory: true,
2287
- acknowledged: false,
2288
- verified: "server-side",
2289
- via: `POST ${base}/free`,
2290
- ...(direct.before != null ? { vram_before: direct.before } : {}),
2291
- ...(direct.after != null ? { vram_after: direct.after } : {}),
2384
+ ...extra,
2292
2385
  note: `The panel tab never acknowledged (frozen or backgrounded), so the free was issued ` +
2293
2386
  `DIRECTLY to the ComfyUI server this tab provably fronts — the same /free endpoint the ` +
2294
2387
  `panel would have called — and the server confirmed it. /free is idempotent: if the tab's ` +
@@ -2296,6 +2389,32 @@ async function settleFreeVramAfterAckTimeout(ctx, timedOut) {
2296
2389
  `twice. ${statsNote}`,
2297
2390
  });
2298
2391
  }
2392
+ /**
2393
+ * #1866 — a panel ack of `{freed:true}` is the /free HTTP 2xx, not a measured
2394
+ * GPU. After the tab says it posted /free, re-read /system_stats and refuse to
2395
+ * claim VRAM was freed when a device is still occupied. Unreadable stats leave
2396
+ * the original ack UNTOUCHED: an unknown answer claims nothing extra.
2397
+ */
2398
+ async function annotateFreeVramAck(res) {
2399
+ if (res.isError)
2400
+ return res;
2401
+ const parsed = parseToolResultJson(res);
2402
+ if (!parsed || parsed.freed !== true)
2403
+ return res;
2404
+ const base = (getComfyUIBaseUrl() || "").replace(/\/+$/, "");
2405
+ if (!base)
2406
+ return res;
2407
+ const devices = await readVramDevicesMaybe(base, FREE_VRAM_DIRECT_TIMEOUT_MS);
2408
+ if (devices == null)
2409
+ return res;
2410
+ const pinned = pinnedVramDevices(devices);
2411
+ if (pinned.length === 0)
2412
+ return res;
2413
+ return pinnedVramAfterFreeResult(pinned, {
2414
+ acknowledged: true,
2415
+ devices,
2416
+ });
2417
+ }
2299
2418
  // ---- panel_install_node: accepted-but-never-enqueued (#1129) ---------------
2300
2419
  // #1143 fixed the pre-queue REFUSAL (403/404 → direct clone). This is the other
2301
2420
  // half of the same family: legacy Manager 3.x answers the install POST with
@@ -4973,8 +5092,18 @@ panelGapNote = "") {
4973
5092
  // A panel that cannot fence a WRITE gives reads only, however good the stamp is.
4974
5093
  const mutationsRefused = canMutate === false;
4975
5094
  const mutationsUnknown = canMutate === undefined;
5095
+ // #1494 — `reads_only` would be a FALSE consolation for this one cause. The
5096
+ // dispatch-time agreement gate covers `graph_*` READS as well as mutations
5097
+ // (requiresStampTargetAgreement), so a session in that state has neither working
5098
+ // graph reads NOR writes — only the canvas-INDEPENDENT ops that predicate exempts
5099
+ // (graph_update_node, the virtual-type reads) still dispatch, which is not what a
5100
+ // caller reads "reads_only" as. And the whole report is that this call answered
5101
+ // "bound" while everything the gate covers was being refused. It is
5102
+ // `not_recovered`: the caller must not read success out of it.
4976
5103
  const okBinding = mutationsRefused
4977
- ? "reads_only"
5104
+ ? refusalCause === "target_disagreement"
5105
+ ? "not_recovered"
5106
+ : "reads_only"
4978
5107
  : mutationsUnknown
4979
5108
  ? "unverified"
4980
5109
  : "bound";
@@ -5000,23 +5129,37 @@ panelGapNote = "") {
5000
5129
  `\n\nWHAT TO DO: call this tool again to bind an identity. If it keeps coming back ` +
5001
5130
  `without one, have the user click into the workflow tab so the panel reports an active ` +
5002
5131
  `canvas, then call this again.`
5003
- : mutationsRefused && refusalCause === "unroutable"
5004
- ? `\n\nBUT this tab is NOT REACHABLE from the orchestrator right now, so graph mutations ` +
5005
- `are refused and reads are not working either, whatever this note says about them. ` +
5006
- `This is NOT a panel-version problem: updating the pack and hard-refreshing cannot help, ` +
5007
- `because there is nothing to refresh until the tab is connected again.` +
5008
- `\n\nWHAT TO DO: check that the ComfyUI browser tab is still open and its panel is ` +
5009
- `connected, then call this again.`
5010
- : mutationsRefused
5011
- ? `\n\nBUT graph MUTATIONS are still refused for this tab: its panel build does not ` +
5012
- `advertise the write-boundary workflow fence a graph edit requires, and no rebind ` +
5013
- `including calling this tool again can add it. Reads work now (this call just read the ` +
5014
- `live canvas).\n\nWHAT TO DO FOR EDITS: update the comfyui-mcp-panel pack, then have the ` +
5015
- `user HARD-refresh (Ctrl+Shift+R) the ComfyUI browser taban open tab keeps running its ` +
5016
- `cached bundle, so the capability lags the version on disk until it does. Do NOT use ` +
5017
- `panel_reload for this: it has been observed to leave the tab permanently unresponsive ` +
5018
- `(#803).`
5019
- : "";
5132
+ : mutationsRefused && refusalCause === "target_disagreement"
5133
+ ? `\n\nBUT THIS DID NOT CLEAR THE REFUSAL, and saying otherwise is the bug this ` +
5134
+ `wording exists to stop (#1494). Graph commands are refused BEFORE they are ` +
5135
+ `dispatched, by a comparison this call does not perform: the session's stamp against ` +
5136
+ `the identity the ROUTED TAB last advertised in its handshake. This call compared the ` +
5137
+ `stamp against the LIVE canvas instead, and those are different facts the live ` +
5138
+ `canvas agreeing is exactly why nothing above says "rebound". Graph READS are refused ` +
5139
+ `by the same gate, so this is not a reads-only session.` +
5140
+ `\n\nWHAT TO DO: re-open the workflow you mean with panel_open_workflow(<path>) an ` +
5141
+ `open proves an identity for the tab under its current id, which is what the gate is ` +
5142
+ `missing; for a never-saved canvas use its routing_key from panel_list_workflows. The ` +
5143
+ `panel also re-advertises on its own when it notices the drift, so a command that ` +
5144
+ `keeps failing here may start working without you doing anything do NOT read that as ` +
5145
+ `this call having repaired it.`
5146
+ : mutationsRefused && refusalCause === "unroutable"
5147
+ ? `\n\nBUT this tab is NOT REACHABLE from the orchestrator right now, so graph mutations ` +
5148
+ `are refused — and reads are not working either, whatever this note says about them. ` +
5149
+ `This is NOT a panel-version problem: updating the pack and hard-refreshing cannot help, ` +
5150
+ `because there is nothing to refresh until the tab is connected again.` +
5151
+ `\n\nWHAT TO DO: check that the ComfyUI browser tab is still open and its panel is ` +
5152
+ `connected, then call this again.`
5153
+ : mutationsRefused
5154
+ ? `\n\nBUT graph MUTATIONS are still refused for this tab: its panel build does not ` +
5155
+ `advertise the write-boundary workflow fence a graph edit requires, and no rebind — ` +
5156
+ `including calling this tool again — can add it. Reads work now (this call just read the ` +
5157
+ `live canvas).\n\nWHAT TO DO FOR EDITS: update the comfyui-mcp-panel pack, then have the ` +
5158
+ `user HARD-refresh (Ctrl+Shift+R) the ComfyUI browser tab — an open tab keeps running its ` +
5159
+ `cached bundle, so the capability lags the version on disk until it does. Do NOT use ` +
5160
+ `panel_reload for this: it has been observed to leave the tab permanently unresponsive ` +
5161
+ `(#803).`
5162
+ : "";
5020
5163
  switch (r.status) {
5021
5164
  case "diverged":
5022
5165
  // #1646 — produced ONLY by a read-only probe (`adopt:false`), which the
@@ -7427,14 +7570,53 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
7427
7570
  let verdict;
7428
7571
  try {
7429
7572
  const probe = await rebindWorkflowFence(ctx, { adopt: false });
7573
+ // #1494 — WHICH COMPARISON IS REFUSING, asked of the gate itself rather than
7574
+ // inferred from the probe.
7575
+ //
7576
+ // The probe reads the LIVE canvas. The dispatch-time agreement gate refuses on
7577
+ // the identity the ROUTED TAB last advertised. When the advertisement is the
7578
+ // stale side, those two disagree — the probe reports `already_current` on the
7579
+ // very uuid the command carried, which reads as "transient, retry" while every
7580
+ // retry is compared against the same unchanged advertisement and refused
7581
+ // identically. That is the reported contradiction: an immediate rebind
7582
+ // confirming the original instance, next to a refusal naming another one.
7583
+ //
7584
+ // Typed, from `tabGraphMutationCapability` (the bridge's own verdict, sharing
7585
+ // one predicate with `send()`), never a second reading of the refusal's prose.
7586
+ let gateStillRefuses = false;
7587
+ try {
7588
+ const cap = ctx.tabGraphMutationCapability?.();
7589
+ gateStillRefuses =
7590
+ cap?.known === true &&
7591
+ cap.canMutate === false &&
7592
+ cap.because === "target_disagreement";
7593
+ }
7594
+ catch {
7595
+ gateStillRefuses = false; // a diagnostic must never replace the outcome
7596
+ }
7430
7597
  verdict =
7431
7598
  probe.status === "already_current" && stamped && probe.uuid === stamped
7432
- ? `\n\nCHECKED: the live canvas now reports the SAME workflow instance this ` +
7433
- `command carried (${stamped}), so the mismatch was TRANSIENT: the identity ` +
7434
- `flipped and settled back, which happens while a new unsaved workflow is still ` +
7435
- `materialising. RETRY THIS EXACT CALL ONCE. Nothing was applied, so a retry ` +
7436
- `cannot double-apply, and re-issuing the whole build would duplicate the work ` +
7437
- `that already succeeded.`
7599
+ ? gateStillRefuses
7600
+ ? `\n\nCHECKED, and the two claims are BOTH true of different things: the live ` +
7601
+ `canvas does report the SAME workflow instance this command carried ` +
7602
+ `(${stamped}), and the refusal above is not about the live canvas it is ` +
7603
+ `the pre-dispatch check on the identity the ROUTED TAB last advertised, ` +
7604
+ `which is the stale side here. So a bare retry is compared against that ` +
7605
+ `same unchanged value and is refused the same way; ` +
7606
+ `panel_set_workflow_target({mode:"current"}) reports "already current" for ` +
7607
+ `the same reason and does not clear it either. Nothing was applied. WHAT TO ` +
7608
+ `DO: re-open the workflow you mean with panel_open_workflow(<path>) — that ` +
7609
+ `proves an identity for the tab under its CURRENT id, which is what is ` +
7610
+ `missing; for a never-saved canvas use its routing_key from ` +
7611
+ `panel_list_workflows. The panel also re-advertises on its own once it ` +
7612
+ `notices the drift, so a retry may start working shortly — that is the ` +
7613
+ `panel repairing it, not this call.`
7614
+ : `\n\nCHECKED: the live canvas now reports the SAME workflow instance this ` +
7615
+ `command carried (${stamped}), so the mismatch was TRANSIENT: the identity ` +
7616
+ `flipped and settled back, which happens while a new unsaved workflow is still ` +
7617
+ `materialising. RETRY THIS EXACT CALL ONCE. Nothing was applied, so a retry ` +
7618
+ `cannot double-apply, and re-issuing the whole build would duplicate the work ` +
7619
+ `that already succeeded.`
7438
7620
  : probe.status === "already_current"
7439
7621
  ? `\n\nCHECKED: the session's fence already names the live canvas ` +
7440
7622
  `(${probe.uuid}), so it was not the stale side. Retry once — if it refuses ` +
@@ -8966,6 +9148,38 @@ const nodeId = () => z
8966
9148
  z.string().regex(NODE_ID_PATTERN, NODE_ID_MESSAGE),
8967
9149
  ])
8968
9150
  .transform(normalizeNodeId);
9151
+ /**
9152
+ * #1497 — the ONE node-id argument #845 never reached: panel_run's `to_node_id`.
9153
+ *
9154
+ * #845 made every panel tool accept back an id it had PRINTED, because the graph
9155
+ * readers hand out ids as strings (`summarizeNode` returns `id: node.id`, and on
9156
+ * the modern ComfyUI frontend that is `"11"`, not `11`). Twenty-seven node-id
9157
+ * arguments adopted `nodeId()`; this one kept `z.number().int()` and was missed,
9158
+ * so the documented round trip — take the id `panel_add_node` just returned, hand
9159
+ * it to panel_run — failed with a raw `expected number, received string`, exactly
9160
+ * as #845 described and #427 hit for panel_create_group. A canonical validator is
9161
+ * adopted per CALL SITE, and this call site never adopted it.
9162
+ *
9163
+ * NOT `nodeId()`, deliberately. That helper also admits the subgraph-QUALIFIED
9164
+ * shape (`"120:104"`, #1425) and passes it through as a string, which is right for
9165
+ * a write that addresses a node by key. A run-to-node target is not addressed by
9166
+ * key — it is an EXECUTION ROOT, resolved numerically the whole way down:
9167
+ * the panel's `findNodeInScopes` matches on `Number(id)`, its reply reports
9168
+ * `ran_to_node: Number(to_node_id)`, and two branches here (#772's stamp-race
9169
+ * re-issue and #468's run ticket) gate on `typeof args.to_node_id === "number"`.
9170
+ * Widening this argument to the qualified form would therefore turn a clear schema
9171
+ * refusal into a panel-side "node 120:104 was not found" about a node that plainly
9172
+ * exists, and silently drop the run ticket that correlates the completion. So the
9173
+ * spelling widens and the SHAPE does not: `11` and `"11"` both mean node 11, and
9174
+ * a qualified id is still refused — now by name, with a reason.
9175
+ */
9176
+ const RUN_TO_NODE_ID_MESSAGE = 'a run-to-node target must be a plain integer node id — 42 or "42" both work. A subgraph-qualified id (e.g. "120:104") is not an execution root: pass the output node\'s own plain id, which is what panel_query_graph prints for it even when it is nested inside a subgraph';
9177
+ const runToNodeId = () => z
9178
+ .union([
9179
+ z.number().int(),
9180
+ z.string().regex(PLAIN_NODE_ID_PATTERN, RUN_TO_NODE_ID_MESSAGE),
9181
+ ])
9182
+ .transform((v) => (typeof v === "number" ? v : Number.parseInt(v, 10)));
8969
9183
  /**
8970
9184
  * #845 — which `panel_canvas` arguments the chosen action actually consumes.
8971
9185
  *
@@ -10238,11 +10452,9 @@ export function buildPanelToolDefs() {
10238
10452
  .max(100)
10239
10453
  .optional()
10240
10454
  .describe("Times to queue (default 1)."),
10241
- to_node_id: z
10242
- .number()
10243
- .int()
10455
+ to_node_id: runToNodeId()
10244
10456
  .optional()
10245
- .describe("Output node id to render UP TO (partial execution). Omit to run the whole graph. Must be an OUTPUT node — one with is_output:true in panel_query_graph's detail rows. May be nested inside a subgraph (pass the node's own id)."),
10457
+ .describe("Output node id to render UP TO (partial execution). Omit to run the whole graph. Must be an OUTPUT node — one with is_output:true in panel_query_graph's detail rows. May be nested inside a subgraph (pass the node's own id). Takes the id EXACTLY as the readers print it: 11 and \"11\" both mean node 11 (#1497). A subgraph-qualified id (\"120:104\") is not an execution root and is refused."),
10246
10458
  allow_duplicate: z
10247
10459
  .boolean()
10248
10460
  .optional()
@@ -10623,7 +10835,32 @@ export function buildPanelToolDefs() {
10623
10835
  return appendToolResultText(res, lateAckNote);
10624
10836
  return res;
10625
10837
  }),
10626
- def("panel_get_errors", "WHY IS THAT NODE RED / WHY DID THE RUN FAIL? The single error surface for the user's open tab: every errored node JOINED TO ITS CAUSE, which ComfyUI itself does not show — LiteGraph only paints a red outline and stores no reason, which is why users report \"red node, no error message\". Call this whenever the user mentions a red/highlighted/erroring node, a failed run, or \"required models are missing\" — instead of guessing from widget values. Each entry in `nodes[]` is the node's full detail summary plus `red_outline` and `reasons[]`, drawn from every source: `missing_model` (exact file, its models directory, the widget holding it, and a download URL when known), `missing_media` (a referenced input image/video that isn't on disk — the usual cause of a red LoadImage), `validation` (per-input errors from the last queue attempt: message, details, offending input), and `execution` (runtime failure with `exception_type`, e.g. PIL.UnidentifiedImageError). TWO THINGS THAT MAKE THIS ESSENTIAL: (1) missing model/media assets paint nodes red AS SOON AS THE WORKFLOW LOADS, long before any queue attempt — so the raw validation map is still EMPTY while the user is staring at red nodes; (2) a node that throws AT RUNTIME is never painted red at all, so it can't be spotted on the canvas — it appears here with red_outline:false. Also returns graph-level `missing_models`, `missing_media`, `missing_node_types` (or `missing_node_count`), plus the raw `node_errors` map and `last_execution_error` for reference. A ⚠️ GRAPH VALIDATION block is auto-injected at your turn start when this state changes; call this to re-check on demand (e.g. after you edit widgets/links). Read-only.", {}, async (_args, ctx) => withTruncationHints(await ctx.call({ cmd: "graph_get_errors" }), [
10838
+ def("panel_get_errors", "WHY IS THAT NODE RED / WHY DID THE RUN FAIL? The single error surface for the user's open tab: every errored node JOINED TO ITS CAUSE, which ComfyUI itself does not show — LiteGraph only paints a red outline and stores no reason, which is why users report \"red node, no error message\". Call this whenever the user mentions a red/highlighted/erroring node, a failed run, or \"required models are missing\" — instead of guessing from widget values. Each entry in `nodes[]` is the node's full detail summary plus `red_outline` and `reasons[]`, drawn from every source: `missing_model` (exact file, its models directory, the widget holding it, and a download URL when known), `missing_media` (a referenced input image/video that isn't on disk — the usual cause of a red LoadImage), `validation` (per-input errors from the last queue attempt: message, details, offending input), and `execution` (runtime failure with `exception_type`, e.g. PIL.UnidentifiedImageError). TWO THINGS THAT MAKE THIS ESSENTIAL: (1) missing model/media assets paint nodes red AS SOON AS THE WORKFLOW LOADS, long before any queue attempt — so the raw validation map is still EMPTY while the user is staring at red nodes; (2) a node that throws AT RUNTIME is never painted red at all, so it can't be spotted on the canvas — it appears here with red_outline:false. Also returns graph-level `missing_models`, `missing_media`, `missing_node_types` (or `missing_node_count`), plus the raw `node_errors` map and `last_execution_error` for reference. A ⚠️ GRAPH VALIDATION block is auto-injected at your turn start when this state changes; call this to re-check on demand (e.g. after you edit widgets/links). Read-only.", {},
10839
+ // #1493 — graph_get_errors belongs to the #599 refresh-ack cohort above, and was the
10840
+ // one member of it left on the generic read default.
10841
+ //
10842
+ // Its frontend handler deliberately awaits a FORCED /object_info re-register before it
10843
+ // will trust a combo to clear a resolved missing-model candidate (#610), and it spends a
10844
+ // shared 18 000 ms elective budget across that refresh race, the /system_stats probe and the
10845
+ // /view media probes (GET_ERRORS_TOTAL_BUDGET_MS, panel web/js/lib/get-errors-budget.js).
10846
+ // That budget was sized to sit under BRIDGE_READ_DEFAULT_TIMEOUT_MS — which leaves 2 000 ms
10847
+ // for everything the call still has to do AFTER the waits: collectMissingAssets over the
10848
+ // whole graph, the per-node live combo scan, the subgraph walk, and serializing a reply big
10849
+ // enough that this tool ships two truncation riders for it.
10850
+ //
10851
+ // The reported sequence spends that budget exactly as designed — panel_refresh_nodes, three
10852
+ // promoted-widget writes on a model-loader subgraph, a save — and panel_get_errors was then
10853
+ // declared "backgrounded or frozen" at 20 000 ms by a tab that had just answered four
10854
+ // commands and did reply, late. That is the precise failure the shared budget exists to
10855
+ // prevent (#589): an agent left with NO error surface for a workflow whose nodes are red.
10856
+ //
10857
+ // The margin has to come from THIS side. The panel's 18 s bound must stay where it is — it
10858
+ // is what keeps a current panel safe in front of an OLDER orchestrator still using the 20 s
10859
+ // default — so the orchestrator widens its own wait to the same bounded budget its
10860
+ // refresh-before-validate siblings already use. This read is idempotent, so waiting longer
10861
+ // costs a slow reply and never a double-applied write; it stays BOUNDED (never Infinity), so
10862
+ // a genuinely frozen tab still fails, just at 30 s instead of 20 s.
10863
+ async (_args, ctx) => withTruncationHints(await ctx.call({ cmd: "graph_get_errors" }, OBJECT_INFO_REFRESH_ACK_TIMEOUT_MS), [
10627
10864
  {
10628
10865
  flag: "truncated",
10629
10866
  key: "truncation_hint",
@@ -12363,7 +12600,16 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
12363
12600
  mode: z.enum(["remote", "local", "cache"]).optional().describe("DB source (default 'remote')."),
12364
12601
  }, async (args, ctx) => {
12365
12602
  assertPanelNotTargetedUnverifiable("panel_update_node", args.id);
12366
- return ctx.call({ cmd: "graph_update_node", id: args.id, version: args.version, channel: args.channel, mode: args.mode }, 30000);
12603
+ const res = await ctx.call({ cmd: "graph_update_node", id: args.id, version: args.version, channel: args.channel, mode: args.mode }, 30000);
12604
+ // #1870 — the panel attaches /internal/logs/raw as "Manager traceback"
12605
+ // when do_update stores only the generic sentence. That log also holds
12606
+ // the previous generation error, which names the same pack, so a zip
12607
+ // update-git miss was reported as FalApiError. Keep Manager evidence
12608
+ // (res.action=update-git) and drop execution history.
12609
+ return sanitizePanelUpdateNodeResult(res, {
12610
+ id: typeof args.id === "string" ? args.id : "",
12611
+ version: typeof args.version === "string" ? args.version : undefined,
12612
+ });
12367
12613
  }),
12368
12614
  def("panel_node_queue_status", "Check the built-in Manager's install/update queue status (to see if a queued install finished). Read-only. " +
12369
12615
  "A drained queue (total_count: 0, is_processing: false) only means THIS queue is idle — " +
@@ -13622,16 +13868,18 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
13622
13868
  ".") + argvNote + (preflightNote ? ` ${preflightNote}` : ""),
13623
13869
  });
13624
13870
  }),
13625
- def("panel_free_vram", "Unload all loaded models and free VRAM (ComfyUI /free). Use to unwedge a stuck/OOM ComfyUI when a cancel didn't free memory — before retrying or, last resort, restarting (panel_restart_comfyui). Does NOT restart ComfyUI; it just drops resident models and frees cached memory. If the panel tab is frozen and cannot acknowledge, the free is instead issued DIRECTLY to the ComfyUI server and verified there (same /free, idempotent) whenever the tab provably fronts the local server — otherwise the outcome is reported unknown rather than claimed.", {}, async (_args, ctx) => {
13871
+ def("panel_free_vram", "Unload all loaded models and free VRAM (ComfyUI /free). Use to unwedge a stuck/OOM ComfyUI when a cancel didn't free memory — before retrying or, last resort, restarting (panel_restart_comfyui). Does NOT restart ComfyUI; it just drops resident models and frees cached memory. After /free, occupancy is re-read from /system_stats: if a device remains pinned (Ray workers, parallel CLIP, custom-node allocations /free cannot terminate), the reply names those devices and does NOT claim VRAM was freed — next step is panel_restart_comfyui. If the panel tab is frozen and cannot acknowledge, the free is instead issued DIRECTLY to the ComfyUI server and verified there (same /free, idempotent) whenever the tab provably fronts the local server — otherwise the outcome is reported unknown rather than claimed.", {}, async (_args, ctx) => {
13626
13872
  const res = await ctx.call({ cmd: "free_vram" }, 15000);
13627
13873
  // #1249 — ONLY a no-reply is settled server-side. An acked executor error
13628
13874
  // (the panel's own "Failed to free VRAM: …") is a reply the bridge
13629
13875
  // received and relayed; it already says what failed, and re-issuing from
13630
13876
  // out here would fire a second mutation behind a verdict the caller was
13631
13877
  // given. A tagged reply-timeout is the one case where nothing answered.
13632
- if (!isReplyTimeoutResult(res))
13633
- return res;
13634
- return settleFreeVramAfterAckTimeout(ctx, res);
13878
+ if (isReplyTimeoutResult(res))
13879
+ return settleFreeVramAfterAckTimeout(ctx, res);
13880
+ // #1866 — a successful /free ack is not a free GPU. Re-read occupancy
13881
+ // and refuse to report freed:true when a device is still pinned.
13882
+ return annotateFreeVramAck(res);
13635
13883
  }),
13636
13884
  def("panel_show_media", "Display one or more images or videos directly in the panel chat. Use this whenever the user asks to SEE or SHOW a file — a disk path you composited/downloaded/generated (absolute path on the orchestrator host) OR a ComfyUI output ref ({ filename, subfolder?, type? }). Items are rendered as media cards in the agent chat area; supply optional captions. Max 8 items per call. A path item OVER the 20 MB inline cap that is not under any directory ComfyUI serves can still be shown by passing stage:true on that item — the orchestrator COPIES it into <output>/_panel_staged (an opt-in, persistent disk write; 512 MB per-file and 2 GB total caps) and displays the copy by reference. NEVER describe an image with emoji or text placeholders — call this tool instead.", {
13637
13885
  items: z