comfyui-mcp 0.52.29 → 0.52.30

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.
@@ -76,4 +76,82 @@ export function normalizeNodeId(v) {
76
76
  /** The message a rejected id gets. Names the qualified shape explicitly, because a
77
77
  * caller holding `263:78` needs to know whether it is unsupported or malformed. */
78
78
  export const NODE_ID_MESSAGE = "a node id must be an integer (e.g. 42) or a subgraph-qualified id (e.g. 120:104)";
79
+ /**
80
+ * #1889 — a bare `z.union` renders as the word `Invalid input` and nothing else.
81
+ *
82
+ * Zod 4 reports a failed union as ONE `invalid_union` issue whose own `message`
83
+ * is the constant `"Invalid input"`; the per-member messages (NODE_ID_MESSAGE
84
+ * among them) live in a nested `errors` array. Every renderer that reaches an
85
+ * agent flattens `issue.message` and drops that nest — the MCP SDK's
86
+ * `getParseErrorMessage` (1.30) joins `` `${i.message} at ${path}` ``, zod's own
87
+ * `prettifyError` prints `✖ ${i.message}`, and 1.29 JSON-stringifies the issues.
88
+ * So a node-id rejection read:
89
+ *
90
+ * Invalid input at node_id
91
+ * Invalid input: expected string, received undefined at title
92
+ *
93
+ * NODE_ID_MESSAGE was never dead prose — a BAD STRING (`"42px"`) fails only the
94
+ * string member, zod collapses that to a plain `invalid_format` issue, and the
95
+ * message prints. It is unreachable only for inputs that fail EVERY member
96
+ * (undefined, null, 4.5, an object), which is the common case and the reported
97
+ * one. This is what makes the union say the same thing there.
98
+ *
99
+ * `received` is carried too, because the complaint was the asymmetry with the
100
+ * sibling `title`, and half of what `title` said was what it actually got.
101
+ */
102
+ export function unionErrorFor(expected) {
103
+ return (iss) => `${expected}; received ${describeReceived(iss.input)}`;
104
+ }
105
+ /** How long a rendered value may get before it is cut. A node id is a handful of
106
+ * characters; anything near this is a caller error worth showing, not quoting. */
107
+ const RECEIVED_MAX = 60;
108
+ /**
109
+ * Render an arbitrary rejected input for a message, TOTALLY.
110
+ *
111
+ * This runs inside zod's error path, so it must never throw: a formatter that
112
+ * throws converts a clean validation refusal into a 500. `JSON.stringify` alone
113
+ * is not safe here — it throws on a BigInt and on a circular object, returns the
114
+ * bare `undefined` value for a symbol or a function, and renders NaN/Infinity as
115
+ * `null`, which would tell a caller who passed NaN that they passed null. Each
116
+ * of those is handled before the stringify, and the stringify itself is caught.
117
+ */
118
+ export function describeReceived(input) {
119
+ if (input === null)
120
+ return "null";
121
+ switch (typeof input) {
122
+ case "undefined":
123
+ return "undefined";
124
+ case "bigint":
125
+ return `${input}n`;
126
+ case "symbol":
127
+ return input.toString();
128
+ case "function":
129
+ return "a function";
130
+ case "number":
131
+ case "boolean":
132
+ // `String` and not `JSON.stringify`: the latter renders NaN and ±Infinity
133
+ // as `null`, which would tell a caller who passed NaN that they passed null.
134
+ return String(input);
135
+ default:
136
+ break;
137
+ }
138
+ let rendered;
139
+ try {
140
+ rendered = JSON.stringify(input) ?? String(input);
141
+ }
142
+ catch {
143
+ // The fallback needs its own guard: `Array.isArray` THROWS on a revoked
144
+ // proxy ("Cannot perform 'IsArray' on a proxy that has been revoked"), so
145
+ // the naive recovery path was itself a way for this function to throw.
146
+ // Measured, not assumed — it is the one hostile input of ten that got past
147
+ // the outer catch.
148
+ try {
149
+ return Array.isArray(input) ? "an array" : "an object";
150
+ }
151
+ catch {
152
+ return "an object";
153
+ }
154
+ }
155
+ return rendered.length > RECEIVED_MAX ? `${rendered.slice(0, RECEIVED_MAX)}…` : rendered;
156
+ }
79
157
  //# sourceMappingURL=node-id.js.map
@@ -1 +1 @@
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"}
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;AAErF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,UAAU,aAAa,CAAC,QAAgB;IAC5C,OAAO,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,QAAQ,cAAc,gBAAgB,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;AACzE,CAAC;AAED;mFACmF;AACnF,MAAM,YAAY,GAAG,EAAE,CAAC;AAExB;;;;;;;;;GASG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,MAAM,CAAC;IAClC,QAAQ,OAAO,KAAK,EAAE,CAAC;QACrB,KAAK,WAAW;YACd,OAAO,WAAW,CAAC;QACrB,KAAK,QAAQ;YACX,OAAO,GAAG,KAAK,GAAG,CAAC;QACrB,KAAK,QAAQ;YACX,OAAO,KAAK,CAAC,QAAQ,EAAE,CAAC;QAC1B,KAAK,UAAU;YACb,OAAO,YAAY,CAAC;QACtB,KAAK,QAAQ,CAAC;QACd,KAAK,SAAS;YACZ,0EAA0E;YAC1E,6EAA6E;YAC7E,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;QACvB;YACE,MAAM;IACV,CAAC;IACD,IAAI,QAAgB,CAAC;IACrB,IAAI,CAAC;QACH,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC;IACpD,CAAC;IAAC,MAAM,CAAC;QACP,wEAAwE;QACxE,0EAA0E;QAC1E,uEAAuE;QACvE,2EAA2E;QAC3E,mBAAmB;QACnB,IAAI,CAAC;YACH,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,WAAW,CAAC;QACzD,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,WAAW,CAAC;QACrB,CAAC;IACH,CAAC;IACD,OAAO,QAAQ,CAAC,MAAM,GAAG,YAAY,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,YAAY,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC;AAC3F,CAAC"}
@@ -46,7 +46,7 @@ import { compareSemver } from "../services/self-update.js";
46
46
  import { describeInstallPanelAction } from "../services/panel-recovery.js";
47
47
  import { peekResolvedPanelBase, primePanelBase, verifiedPanelDiskVersion, } from "../services/panel-workspace.js";
48
48
  import { conversationOfScopeAddress, isScopeAddress, shortTabId } from "../services/session-scope.js";
49
- import { NODE_ID_MESSAGE, NODE_ID_PATTERN, normalizeNodeId, PLAIN_NODE_ID_PATTERN, } from "./node-id.js";
49
+ import { NODE_ID_MESSAGE, NODE_ID_PATTERN, normalizeNodeId, PLAIN_NODE_ID_PATTERN, unionErrorFor, } from "./node-id.js";
50
50
  import { parseContradictoryPromotedWidgetRefusal, resolveInnerPromotedTarget, } from "./promoted-widget.js";
51
51
  import { includeRequestedCreateGroupMembers } from "./create-group-membership.js";
52
52
  import { fastGroupsFilterPropertyNote, isFastGroupsFilterProperty, } from "./rgthree-fast-groups-property.js";
@@ -78,7 +78,7 @@ function journalTabFor(ctx) {
78
78
  function journalConversationFor(ctx) {
79
79
  return conversationOfScopeAddress(ctx.tabId);
80
80
  }
81
- import { BRIDGE_DEFAULT_TIMEOUT_MS, BRIDGE_READ_DEFAULT_TIMEOUT_MS, dispatchOutcomeOf, GRAPH_CMD_EFFECT, isCapabilityRefusal, isPanelCmdUnsupportedError, isReplyTimeoutTagged, isRoutingAmbiguity, requiresWorkflowStampEnforcement, } from "../services/ui-bridge.js";
81
+ import { BRIDGE_DEFAULT_TIMEOUT_MS, BRIDGE_READ_DEFAULT_TIMEOUT_MS, dispatchOutcomeOf, GRAPH_CMD_EFFECT, isCapabilityRefusal, isPanelCmdUnsupportedError, isMidCommandDisconnectTagged, isReplyTimeoutTagged, isRoutingAmbiguity, requiresWorkflowStampEnforcement, } from "../services/ui-bridge.js";
82
82
  import { withWorkflowTarget, } from "../services/workflow-target-store.js";
83
83
  import { addUserMcpServer, readUserMcpServers, removeUserMcpServer, setUserMcpServerSecret, } from "../services/user-mcp-config.js";
84
84
  import { setComfyuiSecret, setAgentSecret, isAllowedAgentSecretKey, receiptDisclosures, shadowedNote, storeDamageNote, } from "../services/panel-secrets.js";
@@ -580,6 +580,10 @@ export const __panelToolsTestHooks = {
580
580
  setReadVramDevices(fn) {
581
581
  readVramDevicesOverride = fn;
582
582
  },
583
+ /** #1895 tests drive these, not a copy. A mutation of either that the
584
+ * tool-handler suite never reaches must still fail. */
585
+ annotateFreeVramAck,
586
+ deviceStillPinned,
583
587
  /** Direct access to the #742 decline recheck loop so its hard-deadline
584
588
  * guarantee (codex gate r2) can be unit-tested with a custom deadline. */
585
589
  probeDeclineRecovery,
@@ -2075,6 +2079,27 @@ function carryReplyTimeoutMark(err, res) {
2075
2079
  function isReplyTimeoutResult(res) {
2076
2080
  return res?.isError === true && res[REPLY_TIMEOUT_RESULT] === true;
2077
2081
  }
2082
+ /** panel#1524 — carry the bridge's typed mid-command-disconnect marker onto the
2083
+ * ToolResult the same way reply-timeout is carried: an acked panel error can
2084
+ * quote "OUTCOME UNKNOWN", so the reconcile must not key on message text. */
2085
+ const MID_COMMAND_DISCONNECT_RESULT = Symbol("panel.midCommandDisconnectResult");
2086
+ function carryMidCommandDisconnectMark(err, res) {
2087
+ if (!isMidCommandDisconnectTagged(err))
2088
+ return res;
2089
+ Object.defineProperty(res, MID_COMMAND_DISCONNECT_RESULT, {
2090
+ value: true,
2091
+ enumerable: false,
2092
+ configurable: true,
2093
+ });
2094
+ return res;
2095
+ }
2096
+ function isMidCommandDisconnectResult(res) {
2097
+ return (res?.isError === true && res[MID_COMMAND_DISCONNECT_RESULT] === true);
2098
+ }
2099
+ /** A `graph_run` we wrote and then lost the reply for — timeout or disconnect. */
2100
+ function isUnackedGraphRunResult(res) {
2101
+ return isReplyTimeoutResult(res) || isMidCommandDisconnectResult(res);
2102
+ }
2078
2103
  /**
2079
2104
  * #1560 — the same translation problem, for the OTHER fact: did anything answer?
2080
2105
  *
@@ -2291,6 +2316,11 @@ function sampleVramDevice(d) {
2291
2316
  * H3 case was device 2 with the torch pool at ~0.24% free — that occupancy
2292
2317
  * is what this threshold names.
2293
2318
  *
2319
+ * The ratio is pool FULLNESS, not the reserved share of the card
2320
+ * (`torch_vram_total / vram_total`). A 5 GiB reservation with 4 GiB unused
2321
+ * inside the pool is not pinned here — that shape was not observed live
2322
+ * after /free (#1895 item 3).
2323
+ *
2294
2324
  * Unknown/unreadable torch counters are NOT pinned: an unknown answer
2295
2325
  * claims nothing in either direction (#1473). Device-global counters
2296
2326
  * alone also claim nothing — they cannot tell ComfyUI from another process. */
@@ -2312,11 +2342,28 @@ function deviceStillPinned(d) {
2312
2342
  function pinnedVramDevices(devices) {
2313
2343
  return devices.filter(deviceStillPinned);
2314
2344
  }
2315
- /** /free only unloads ComfyUI's model manager. Ray workers, parallel CLIP
2316
- * loaders, and other custom-node allocations survive it and keep the GPU
2317
- * occupied which is why a 2xx from /free is not evidence VRAM is free. */
2318
- function pinnedVramAfterFreeNote(pinned) {
2319
- const named = pinned
2345
+ /** Device-global occupancy (`vram_free` / `vram_total`). Same 1 GiB / 20%
2346
+ * thresholds as the torch pin, applied to the WHOLE card. Used to disclose
2347
+ * an occupied card that is not this ComfyUI's torch pin (#1895) never to
2348
+ * flip freed:true to a /free failure. */
2349
+ function deviceOccupiedGlobally(d) {
2350
+ const total = d.vram_total;
2351
+ const free = d.vram_free;
2352
+ if (typeof total !== "number" || typeof free !== "number")
2353
+ return false;
2354
+ if (!Number.isFinite(total) || !Number.isFinite(free))
2355
+ return false;
2356
+ if (total < PINNED_VRAM_MIN_TOTAL_BYTES)
2357
+ return false;
2358
+ if (free < 0)
2359
+ return true;
2360
+ return free / total <= PINNED_VRAM_FREE_RATIO;
2361
+ }
2362
+ function occupiedVramDevices(devices) {
2363
+ return devices.filter(deviceOccupiedGlobally);
2364
+ }
2365
+ function nameVramDevices(devices) {
2366
+ return devices
2320
2367
  .map((d) => {
2321
2368
  if (typeof d.index === "number")
2322
2369
  return `device ${d.index}`;
@@ -2325,12 +2372,45 @@ function pinnedVramAfterFreeNote(pinned) {
2325
2372
  return "an unnamed device";
2326
2373
  })
2327
2374
  .join(", ");
2375
+ }
2376
+ /** /free only unloads ComfyUI's model manager. Ray workers, parallel CLIP
2377
+ * loaders, and other custom-node allocations survive it and keep the GPU
2378
+ * occupied — which is why a 2xx from /free is not evidence VRAM is free. */
2379
+ function pinnedVramAfterFreeNote(pinned) {
2328
2380
  return (`ComfyUI /free accepted (unload_models + free_memory) but VRAM is STILL PINNED on ` +
2329
- `${named}. /free only unloads ComfyUI's model manager — it does not terminate Ray ` +
2381
+ `${nameVramDevices(pinned)}. /free only unloads ComfyUI's model manager — it does not terminate Ray ` +
2330
2382
  `workers or custom-node allocations (Raylight sequence-parallel, parallel CLIP loaders). ` +
2331
2383
  `This is not a successful VRAM recovery. Next: panel_restart_comfyui (last resort; ` +
2332
2384
  `refuses mid-render) after confirming with get_system_stats (action:"stats").`);
2333
2385
  }
2386
+ /** #1895 — the free DID succeed (torch pool not pinned) but the card is still
2387
+ * occupied. /system_stats is device-global and cannot name the holder, so
2388
+ * the wording asserts neither (a) this ComfyUI outside torch nor (b) another
2389
+ * process. */
2390
+ function occupiedCardAfterFreeNote(occupied) {
2391
+ const named = nameVramDevices(occupied);
2392
+ const verb = occupied.length === 1 ? "is" : "are";
2393
+ return (`ComfyUI /free accepted and THIS instance's torch pool is not pinned, so the free DID succeed. ` +
2394
+ `${named} ${verb} still occupied. /system_stats reports the device globally and cannot name ` +
2395
+ `which process holds it. Two shapes this reading cannot distinguish: (a) allocations this ` +
2396
+ `ComfyUI made outside torch's allocator (Ray workers, sequence-parallel / parallel CLIP ` +
2397
+ `loaders) — panel_restart_comfyui of THIS instance can clear those; (b) a different process ` +
2398
+ `on the host (a second ComfyUI, a trainer, the browser) — restarting THIS instance will not ` +
2399
+ `free that VRAM.`);
2400
+ }
2401
+ function occupiedCardAfterFreeResult(occupied, extra) {
2402
+ return ok({
2403
+ freed: true,
2404
+ unload_models: true,
2405
+ free_memory: true,
2406
+ model_manager_freed: true,
2407
+ occupied_devices: occupied,
2408
+ ...extra,
2409
+ note: occupiedCardAfterFreeNote(occupied),
2410
+ });
2411
+ }
2412
+ const UNOBSERVED_OCCUPANCY_NOTE = `freed:true is the panel's /free receipt, not a measured device — this tab's server could ` +
2413
+ `not be proven as the local boot instance, so /system_stats was never re-read.`;
2334
2414
  function pinnedVramAfterFreeResult(pinned, extra) {
2335
2415
  return {
2336
2416
  content: [
@@ -2437,11 +2517,17 @@ async function settleFreeVramAfterAckTimeout(ctx, timedOut) {
2437
2517
  };
2438
2518
  // #1866 — a 2xx from /free is not a free GPU. Ray/CLIP workers can keep a
2439
2519
  // device pinned. When we have occupancy numbers, they are the verdict.
2520
+ // #1895 — a card that is occupied while THIS instance's torch pool is empty
2521
+ // is not a /free failure; disclose the device instead of a bare freed:true.
2440
2522
  if (direct.after != null) {
2441
2523
  const pinned = pinnedVramDevices(direct.after);
2442
2524
  if (pinned.length > 0) {
2443
2525
  return pinnedVramAfterFreeResult(pinned, extra);
2444
2526
  }
2527
+ const occupied = occupiedVramDevices(direct.after);
2528
+ if (occupied.length > 0) {
2529
+ return occupiedCardAfterFreeResult(occupied, extra);
2530
+ }
2445
2531
  }
2446
2532
  const statsNote = direct.before != null && direct.after != null
2447
2533
  ? "vram_before/vram_after are the server's own /system_stats counters around the free."
@@ -2468,7 +2554,12 @@ async function settleFreeVramAfterAckTimeout(ctx, timedOut) {
2468
2554
  * settle uses (`captureRebootHealthBase(ctx)`), never `getComfyUIBaseUrl()`.
2469
2555
  * A hello from another tab can retarget the global base asynchronously;
2470
2556
  * reporting that GPU as this command's failure is the wrong-target failure
2471
- * the gate exists to prevent. No proven local server → leave the ack.
2557
+ * the gate exists to prevent.
2558
+ *
2559
+ * #1895 — no proven local server must not look like a measured GPU: disclose
2560
+ * that occupancy was never re-read. An occupied card whose torch pool is
2561
+ * empty is not a /free failure (isError stays false, freed stays true) but
2562
+ * MUST name the device and that /system_stats is device-global.
2472
2563
  */
2473
2564
  async function annotateFreeVramAck(ctx, res) {
2474
2565
  if (res.isError)
@@ -2477,18 +2568,31 @@ async function annotateFreeVramAck(ctx, res) {
2477
2568
  if (!parsed || parsed.freed !== true)
2478
2569
  return res;
2479
2570
  const base = captureRebootHealthBase(ctx);
2480
- if (!base)
2481
- return res;
2571
+ if (!base) {
2572
+ return ok({
2573
+ ...parsed,
2574
+ occupancy_reread: false,
2575
+ note: UNOBSERVED_OCCUPANCY_NOTE,
2576
+ });
2577
+ }
2482
2578
  const devices = await readVramDevicesMaybe(base, FREE_VRAM_DIRECT_TIMEOUT_MS);
2483
2579
  if (devices == null)
2484
2580
  return res;
2485
2581
  const pinned = pinnedVramDevices(devices);
2486
- if (pinned.length === 0)
2487
- return res;
2488
- return pinnedVramAfterFreeResult(pinned, {
2489
- acknowledged: true,
2490
- devices,
2491
- });
2582
+ if (pinned.length > 0) {
2583
+ return pinnedVramAfterFreeResult(pinned, {
2584
+ acknowledged: true,
2585
+ devices,
2586
+ });
2587
+ }
2588
+ const occupied = occupiedVramDevices(devices);
2589
+ if (occupied.length > 0) {
2590
+ return occupiedCardAfterFreeResult(occupied, {
2591
+ acknowledged: true,
2592
+ devices,
2593
+ });
2594
+ }
2595
+ return res;
2492
2596
  }
2493
2597
  // ---- panel_install_node: accepted-but-never-enqueued (#1129) ---------------
2494
2598
  // #1143 fixed the pre-queue REFUSAL (403/404 → direct clone). This is the other
@@ -8094,7 +8198,7 @@ export function makePanelToolCtx(bridge, tabId, workflowTargets) {
8094
8198
  const res = await callOnce(cmd, timeoutMs, onDispatchedRid, (err) => {
8095
8199
  failure = err;
8096
8200
  });
8097
- return carryPanelAnsweredMark(failure, res);
8201
+ return carryMidCommandDisconnectMark(failure, carryPanelAnsweredMark(failure, res));
8098
8202
  };
8099
8203
  // Human-in-the-loop confirmation for a DESTRUCTIVE op: render a yes/no card in
8100
8204
  // the panel and block on the user's pick. Returns false on decline, timeout, or
@@ -8804,45 +8908,85 @@ function runLateAckGraceMs() {
8804
8908
  return runLateAckGraceMsOverride ?? RUN_LATE_ACK_GRACE_MS;
8805
8909
  }
8806
8910
  /**
8807
- * Wait, bounded, for the panel to acknowledge a `graph_run` we stopped waiting
8808
- * for — and hand back the reply it would have produced on time (#1175).
8809
- *
8810
- * Returns null when there is nothing to reconcile, which is every case except a
8811
- * retained `graph_run` success WITH its body. The negatives are deliberate:
8812
- *
8813
- * - NOT a reply timeout nothing to wait for. Gated on the bridge's own typed
8814
- * mark, never on message text: an acked panel error can quote our timeout
8815
- * sentence verbatim (#1468 round 2), and the difference between the two is
8816
- * whether the tab answered, which only the bridge knows.
8817
- * - NO retained body leave the entry ALONE. The retry-token layer drains this
8818
- * same rid to tell the caller their earlier attempt landed; consuming it here
8819
- * to answer a question we then could not act on would delete the recovery the
8820
- * caller still had. Hence peek-then-take.
8821
- * - A DIFFERENT command's entry leave it alone for the same reason. The rid is
8822
- * ours, so this should not happen; if it ever does, the safe reading of a
8823
- * surprise is not to spend someone else's notice on it.
8911
+ * Wait, bounded, for evidence that a `graph_run` we stopped waiting for still
8912
+ * queued — and hand back the reply it would have produced on time (#1175,
8913
+ * panel#1524).
8914
+ *
8915
+ * Two receipts, in this order:
8916
+ *
8917
+ * 1. The panel's own late `graph_run` body, retained by the bridge after a
8918
+ * reply-timeout (#1175) or a mid-command disconnect (panel#1524). Stronger:
8919
+ * it is the executor's answer, rid-correlated.
8920
+ * 2. A prompt id that appeared in ComfyUI's queue AFTER this dispatch, which
8921
+ * was not the in-flight prompt when we sent `graph_run`. Weaker: inferred
8922
+ * from the watchdog, not the panel. Used when the tab dropped before
8923
+ * acknowledging a paid render that is observably running.
8924
+ *
8925
+ * Returns null when there is nothing to reconcile. The negatives are deliberate:
8926
+ *
8927
+ * - NOT an unacked post-write (timeout or disconnect) nothing to wait for.
8928
+ * Gated on the bridge's own typed marks, never on message text: an acked
8929
+ * panel error can quote our timeout/OUTCOME UNKNOWN sentence verbatim
8930
+ * (#1468 round 2), and the difference is whether the tab answered, which
8931
+ * only the bridge knows.
8932
+ * - NO retained body AND no new queue prompt ⇒ leave any unusable late-mutation
8933
+ * entry ALONE. The retry-token layer drains this same rid to tell the caller
8934
+ * their earlier attempt landed; consuming it here to answer a question we
8935
+ * then could not act on would delete the recovery the caller still had.
8936
+ * Hence peek-then-take.
8937
+ * - A DIFFERENT command's entry ⇒ leave it alone for the same reason.
8938
+ * - The SAME prompt that was already running at dispatch ⇒ not this command's
8939
+ * receipt. Claiming it would launder a prior render as this run.
8824
8940
  */
8825
- async function reconcileLateRunAck(ctx, res, rid) {
8826
- if (!rid || !isReplyTimeoutResult(res))
8941
+ function promptAppearedAfterDispatch(preRunningPromptId) {
8942
+ const id = QueueMonitor.snapshot().runningPromptId;
8943
+ if (typeof id !== "string")
8827
8944
  return null;
8828
- const bridge = ctx.bridge;
8829
- if (typeof bridge?.peekLateMutation !== "function")
8945
+ const now = id.trim();
8946
+ if (now === "")
8830
8947
  return null;
8831
- if (typeof bridge?.takeLateMutation !== "function")
8948
+ const pre = typeof preRunningPromptId === "string" && preRunningPromptId.trim() !== ""
8949
+ ? preRunningPromptId.trim()
8950
+ : null;
8951
+ if (pre !== null && now === pre)
8952
+ return null;
8953
+ return now;
8954
+ }
8955
+ async function reconcileLateRunAck(ctx, res, rid, preRunningPromptId) {
8956
+ if (!isUnackedGraphRunResult(res))
8832
8957
  return null;
8833
- const deadline = Date.now() + runLateAckGraceMs();
8958
+ const fromQueue = () => {
8959
+ const promptId = promptAppearedAfterDispatch(preRunningPromptId);
8960
+ if (!promptId)
8961
+ return null;
8962
+ return { result: { queued: true, prompt_id: promptId }, lateByMs: 0, via: "queue" };
8963
+ };
8964
+ const bridge = ctx.bridge;
8965
+ const canPeek = Boolean(rid) &&
8966
+ typeof bridge?.peekLateMutation === "function" &&
8967
+ typeof bridge?.takeLateMutation === "function";
8968
+ // Stubbed bridges in unit tests have no late-mutation map. One-shot the queue
8969
+ // (no grace wait) so a timeout whose prompt did not change still returns in
8970
+ // the same tick it always has.
8971
+ if (!canPeek)
8972
+ return fromQueue();
8973
+ const startedAt = Date.now();
8974
+ const deadline = startedAt + runLateAckGraceMs();
8834
8975
  for (;;) {
8835
8976
  const seen = bridge.peekLateMutation(rid);
8836
8977
  if (seen) {
8837
8978
  if (seen.cmd !== "graph_run" || !("result" in seen))
8838
- return null;
8979
+ return fromQueue();
8839
8980
  // Only now is it certain this entry will be USED, so draining it costs the
8840
8981
  // caller nothing: they are about to be handed its contents.
8841
8982
  const taken = bridge.takeLateMutation(rid);
8842
8983
  if (!taken || !("result" in taken))
8843
- return null;
8844
- return { result: taken.result, lateByMs: taken.lateByMs };
8984
+ return fromQueue();
8985
+ return { result: taken.result, lateByMs: taken.lateByMs, via: "ack" };
8845
8986
  }
8987
+ const queued = fromQueue();
8988
+ if (queued)
8989
+ return { ...queued, lateByMs: Date.now() - startedAt };
8846
8990
  const left = deadline - Date.now();
8847
8991
  if (left <= 0)
8848
8992
  return null;
@@ -8861,6 +9005,15 @@ function lateRunAckNote(lateByMs) {
8861
9005
  `this is its outcome. (A queue listing taken in the moments after a run is accepted can still ` +
8862
9006
  `be empty, so an empty queue would not have settled this either way.)`);
8863
9007
  }
9008
+ /** panel#1524 — the panel never acknowledged, but the queue holds a prompt this
9009
+ * dispatch created. Names the id and says we did not re-issue. */
9010
+ function queueRunReceiptNote(promptId) {
9011
+ return (`\n\n[RECOVERED] The panel tab disconnected (or missed its ack) before acknowledging this ` +
9012
+ `graph_run, but ComfyUI's queue now contains prompt ${promptId} — which was not the in-flight ` +
9013
+ `prompt when this command was dispatched. Treating that as this run's receipt (queued:true). ` +
9014
+ `Nothing was dispatched a second time. Do NOT re-run: a second panel_run would bill/queue ` +
9015
+ `another render behind the one already running.`);
9016
+ }
8864
9017
  async function pollLateAskReply(bridge, askId, timing, hardDeadline) {
8865
9018
  const take = bridge
8866
9019
  .takeLateAskReply;
@@ -9252,10 +9405,12 @@ function validatePanelEditNodeArgs(args) {
9252
9405
  * node-id.ts, where truncating `"263:78"` to `263` would edit the wrong node.
9253
9406
  */
9254
9407
  const nodeId = () => z
9255
- .union([
9256
- z.number().int(),
9257
- z.string().regex(NODE_ID_PATTERN, NODE_ID_MESSAGE),
9258
- ])
9408
+ // #1889 — the union-level `error` is what a caller actually reads. Without it
9409
+ // zod names the failed union `"Invalid input"` and buries NODE_ID_MESSAGE in a
9410
+ // nested `errors` array that no renderer prints. See unionErrorFor.
9411
+ .union([z.number().int(), z.string().regex(NODE_ID_PATTERN, NODE_ID_MESSAGE)], {
9412
+ error: unionErrorFor(NODE_ID_MESSAGE),
9413
+ })
9259
9414
  .transform(normalizeNodeId);
9260
9415
  /**
9261
9416
  * #1497 — the ONE node-id argument #845 never reached: panel_run's `to_node_id`.
@@ -9284,10 +9439,10 @@ const nodeId = () => z
9284
9439
  */
9285
9440
  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';
9286
9441
  const runToNodeId = () => z
9287
- .union([
9288
- z.number().int(),
9289
- z.string().regex(PLAIN_NODE_ID_PATTERN, RUN_TO_NODE_ID_MESSAGE),
9290
- ])
9442
+ // #1889, same as nodeId(): #1497 wrote RUN_TO_NODE_ID_MESSAGE so a qualified id
9443
+ // would be "refused now by name, with a reason" — and for the shapes that fail
9444
+ // BOTH members the name and the reason were dropped on the floor.
9445
+ .union([z.number().int(), z.string().regex(PLAIN_NODE_ID_PATTERN, RUN_TO_NODE_ID_MESSAGE)], { error: unionErrorFor(RUN_TO_NODE_ID_MESSAGE) })
9291
9446
  .transform((v) => (typeof v === "number" ? v : Number.parseInt(v, 10)));
9292
9447
  /**
9293
9448
  * #845 — which `panel_canvas` arguments the chosen action actually consumes.
@@ -10674,11 +10829,16 @@ export function buildPanelToolDefs() {
10674
10829
  // guidance. Rebuilding the reply here (rather than describing it) is what
10675
10830
  // makes that possible — `ok()` is exactly what ctx.call would have produced.
10676
10831
  const reconcileRun = async () => {
10677
- const recovered = await reconcileLateRunAck(ctx, res, runRid);
10832
+ const recovered = await reconcileLateRunAck(ctx, res, runRid, pre.runningPromptId);
10678
10833
  if (!recovered)
10679
10834
  return;
10680
10835
  res = ok(recovered.result);
10681
- lateAckNote = lateRunAckNote(recovered.lateByMs);
10836
+ lateAckNote =
10837
+ recovered.via === "queue"
10838
+ ? queueRunReceiptNote(typeof recovered.result.prompt_id === "string"
10839
+ ? recovered.result.prompt_id
10840
+ : "?")
10841
+ : lateRunAckNote(recovered.lateByMs);
10682
10842
  };
10683
10843
  await reconcileRun();
10684
10844
  // Derive the verdict from the AUTHORITATIVE reply, not a bare `queued`
@@ -13999,7 +14159,7 @@ CHECKED FOR YOU: the graph read this message prescribes was just run, and it ` +
13999
14159
  ".") + argvNote + (preflightNote ? ` ${preflightNote}` : ""),
14000
14160
  });
14001
14161
  }),
14002
- 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, THIS instance's torch-pool occupancy is re-read from /system_stats on the server this tab provably fronts: 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. Device-global occupancy held by another process is not this free failing. 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) => {
14162
+ 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, THIS instance's torch-pool occupancy is re-read from /system_stats on the server this tab provably fronts: 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. Device-global occupancy with an empty torch pool is not this free failing (freed:true) but the reply still names the device and that /system_stats cannot say which process holds the card. If this tab's server cannot be proven, freed:true is the panel's /free receipt — occupancy was not re-read. 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) => {
14003
14163
  const res = await ctx.call({ cmd: "free_vram" }, 15000);
14004
14164
  // #1249 — ONLY a no-reply is settled server-side. An acked executor error
14005
14165
  // (the panel's own "Failed to free VRAM: …") is a reply the bridge