comfyui-mcp 0.51.49 → 0.51.51

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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * #1574 — when the completion event and `download_model action:"status"` disagree, say so.
3
+ *
4
+ * The tray raised `transfer completed` for an 11.46GB download while
5
+ * `download_model action:"status"` reported it still streaming, `list_local_models` showed the
6
+ * file absent, and the category count only rose minutes later. The file landed AFTER the
7
+ * event.
8
+ *
9
+ * The completion event is built from a PROGRESS ROW read off disk each tick. `status` answers
10
+ * from the JOB RECORD. Two stores, and the event consults only one — the same split #1545
11
+ * documented from the other side.
12
+ *
13
+ * ## Why this ANNOTATES and never suppresses
14
+ *
15
+ * The first version of this dropped the contradicted event. Review killed it, correctly: a
16
+ * terminal record may legitimately still read `downloading` until the ~15s persistence
17
+ * heartbeat retries (see `persistDownloadJob`'s return value, #1545). The debounce bucket is
18
+ * deleted before filtering and never requeued, so suppressing on a lagging record would
19
+ * PERMANENTLY lose the completion notification for a download that genuinely finished.
20
+ *
21
+ * That trades a confusing message for a missing one, which is worse: the user is waiting on
22
+ * that event. So the event always fires, and a disagreement is disclosed on it. The harm in
23
+ * the report is a CONFIDENT false completion — "the natural next action is to use the file" —
24
+ * and a hedge is precisely what removes that.
25
+ *
26
+ * ## Identity
27
+ *
28
+ * The two stores do not share an id. A progress row's `id` is the progress/tray identity;
29
+ * the job's `id` is its public status handle (`6226e26ba97f8527` in the report, against tray
30
+ * `93015fbfa0fa9933`). The row is matched against the job's `progressId ?? trayId`, which is
31
+ * what writes those rows. An earlier version compared row id to job id and was therefore
32
+ * INERT — it never matched anything, and its unit tests missed that because they fed
33
+ * synthetic rows carrying whatever id the assertion wanted.
34
+ */
35
+ /** The identity a PROGRESS ROW is written under. */
36
+ function progressIdentityOf(job) {
37
+ const progress = typeof job.progressId === "string" ? job.progressId : null;
38
+ const tray = typeof job.trayId === "string" ? job.trayId : null;
39
+ return progress ?? tray;
40
+ }
41
+ /**
42
+ * Does the job record disagree with announcing this row as completed?
43
+ *
44
+ * True ONLY on a positive contradiction: a record exists for this exact progress identity and
45
+ * still says `downloading`. A missing record is not evidence — the record store resets on an
46
+ * orchestrator respawn, which is exactly the reported session.
47
+ */
48
+ export function completionDisagreesWithRecord(row, jobs) {
49
+ if (!row || typeof row !== "object")
50
+ return false;
51
+ // Only COMPLETIONS. A failure event carries its own hedged wording (#1150) and must be
52
+ // left entirely alone.
53
+ if (row.status !== "done")
54
+ return false;
55
+ const id = typeof row.id === "string" ? row.id : null;
56
+ if (!id)
57
+ return false;
58
+ if (!Array.isArray(jobs))
59
+ return false;
60
+ // (id, target) — the SAME key the supersession logic uses, and for the same reason: a
61
+ // concurrent LOCAL and POD transfer of one URL shares an id but is two transfers with two
62
+ // outcomes. Matching on id alone could annotate the wrong completion, or miss a real
63
+ // disagreement by finding the other one first (review).
64
+ //
65
+ // A target is compared only when BOTH sides carry one. Rows and records that predate the
66
+ // field, or a route that never sets it, must not silently stop matching — that would make
67
+ // the check inert again, which is exactly how the first version shipped.
68
+ const target = typeof row.target === "string" ? row.target : null;
69
+ const sameId = jobs.filter((j) => j && typeof j === "object" && progressIdentityOf(j) === id);
70
+ if (!sameId.length)
71
+ return false;
72
+ // PREFER THE EXACT (id, target) MATCH (review, round 3). Taking the first id match in
73
+ // array order let a TARGETLESS record shadow the exact one: a targetless "downloading"
74
+ // sitting before an exact-target "done" reported a disagreement that does not exist, and
75
+ // would have hedged a completion that was perfectly fine.
76
+ //
77
+ // The targetless record still stands in when nothing matches on target — that is what
78
+ // keeps rows and records predating the field from silently going unmatched, which would
79
+ // make the whole check inert again.
80
+ const record = (target ? sameId.find((j) => j.target === target) : undefined) ??
81
+ sameId.find((j) => typeof j.target !== "string") ??
82
+ (target ? undefined : sameId[0]);
83
+ if (!record)
84
+ return false;
85
+ return record.status === "downloading";
86
+ }
87
+ /** The disclosure appended to a completion the record disagrees with. Deliberately states
88
+ * BOTH readings and what to do, rather than picking a winner this cannot establish. */
89
+ export const COMPLETION_DISAGREEMENT_NOTE = "CAVEAT: `download_model action:\"status\"` still reports this transfer as downloading. " +
90
+ "The two are read from different stores and the record can lag a real completion by a few " +
91
+ "seconds, so this may simply be that — but a completion event has also been observed to " +
92
+ "arrive minutes before the file existed (#1574). Check `download_model action:\"status\"` " +
93
+ "and that the file is present before loading it.";
94
+ //# sourceMappingURL=download-done-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download-done-guard.js","sourceRoot":"","sources":["../../src/orchestrator/download-done-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAmBH,oDAAoD;AACpD,SAAS,kBAAkB,CAAC,GAAY;IACtC,MAAM,QAAQ,GAAG,OAAO,GAAG,CAAC,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC;IAC5E,MAAM,IAAI,GAAG,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;IAChE,OAAO,QAAQ,IAAI,IAAI,CAAC;AAC1B,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,6BAA6B,CAAC,GAAY,EAAE,IAAwB;IAClF,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAClD,uFAAuF;IACvF,uBAAuB;IACvB,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM;QAAE,OAAO,KAAK,CAAC;IACxC,MAAM,EAAE,GAAG,OAAO,GAAG,CAAC,EAAE,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;IACtD,IAAI,CAAC,EAAE;QAAE,OAAO,KAAK,CAAC;IACtB,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC;QAAE,OAAO,KAAK,CAAC;IACvC,sFAAsF;IACtF,0FAA0F;IAC1F,qFAAqF;IACrF,wDAAwD;IACxD,EAAE;IACF,yFAAyF;IACzF,0FAA0F;IAC1F,yEAAyE;IACzE,MAAM,MAAM,GAAG,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;IAClE,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,kBAAkB,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;IAC9F,IAAI,CAAC,MAAM,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IACjC,sFAAsF;IACtF,uFAAuF;IACvF,yFAAyF;IACzF,0DAA0D;IAC1D,EAAE;IACF,sFAAsF;IACtF,wFAAwF;IACxF,oCAAoC;IACpC,MAAM,MAAM,GACV,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;QAC9D,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ,CAAC;QAChD,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IACnC,IAAI,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IAC1B,OAAO,MAAM,CAAC,MAAM,KAAK,aAAa,CAAC;AACzC,CAAC;AAED;wFACwF;AACxF,MAAM,CAAC,MAAM,4BAA4B,GACvC,yFAAyF;IACzF,2FAA2F;IAC3F,yFAAyF;IACzF,2FAA2F;IAC3F,iDAAiD,CAAC"}
@@ -36,6 +36,8 @@ import { uploadImageHttp, resetClient } from "../comfyui/client.js";
36
36
  import { setConnectedPanelOrigins } from "../comfyui/fetch.js";
37
37
  import { publishConnectedPanelOrigins } from "../services/panel-origin-channel.js";
38
38
  import { logger } from "../utils/logger.js";
39
+ import { listDownloadJobs } from "../services/download-jobs.js";
40
+ import { completionDisagreesWithRecord } from "./download-done-guard.js";
39
41
  import { assembleVocabularyHash, describeVocabularySkew } from "../tools/vocabulary.js";
40
42
  import { buildPanelToolDefs } from "./panel-tools.js";
41
43
  /** The panel vocabulary hashes whose MISMATCH has already been reported (#236).
@@ -5225,13 +5227,23 @@ export async function runPanelOrchestrator() {
5225
5227
  const key = resolveDownloadAgentKey(row);
5226
5228
  if (key && manager.hasLiveAgent(key)) {
5227
5229
  const bucket = downloadDonePending.get(key) ??
5228
- { downloads: new Map(), flushAt: 0 };
5230
+ {
5231
+ downloads: new Map(),
5232
+ flushAt: 0,
5233
+ };
5229
5234
  // Identify each pending download by its (id, target) supersession key — NOT
5230
5235
  // the id alone: a concurrent LOCAL + POD transfer of the same URL shares an id
5231
5236
  // but must produce TWO #547 outcomes, and the same key lets a newer attempt
5232
5237
  // evict this entry above. Fall back to the file path when the row has no id.
5233
5238
  const supKey = downloadAttemptKey(row) ?? ` ${full}`;
5234
5239
  bucket.downloads.set(supKey, {
5240
+ // #1574 — CARRY THE ROW ID. Without it the record cross-check at the flush has
5241
+ // nothing to match a job against, silently agrees with everything, and the
5242
+ // whole disclosure is a no-op. That is exactly how the first version shipped.
5243
+ id: typeof row.id === "string" ? row.id : undefined,
5244
+ // (id, target) is the row identity — id alone collides for a concurrent
5245
+ // LOCAL + POD transfer of the same URL (review).
5246
+ target: typeof row.target === "string" ? row.target : undefined,
5235
5247
  name: String(row.name ?? row.id ?? "model"),
5236
5248
  status: String(status),
5237
5249
  attempt: typeof row.attempt === "number" ? row.attempt : undefined,
@@ -5288,6 +5300,38 @@ export async function runPanelOrchestrator() {
5288
5300
  // filename, so the (id, target) eviction above cannot see it. The live rows
5289
5301
  // are already in hand this tick; markSupersededByLive asks them by name.
5290
5302
  markSupersededByLive(settled, downloads);
5303
+ // #1574 — DROP a completion this orchestrator's own status tool would contradict.
5304
+ //
5305
+ // The event is built from the progress ROW; `download_model action:"status"` answers
5306
+ // from the job RECORD. A reporter got "transfer completed" for an 11.46GB file while
5307
+ // status said it was still streaming and the file was not on disk — it landed minutes
5308
+ // later. Whatever wrote that row, the record is in hand right here.
5309
+ //
5310
+ // Only a POSITIVE contradiction drops it (the record exists, same id, still
5311
+ // "downloading"). An absent record means nothing: the record store resets on a
5312
+ // respawn, which is the reported session, and treating absence as in-flight would
5313
+ // silence every completion after any respawn.
5314
+ const records = (() => {
5315
+ try {
5316
+ return listDownloadJobs();
5317
+ }
5318
+ catch {
5319
+ // Never let the guard break the event path — a completion we cannot check is
5320
+ // still a completion worth delivering.
5321
+ return [];
5322
+ }
5323
+ })();
5324
+ // ANNOTATE, never suppress (review). A terminal record can legitimately still read
5325
+ // "downloading" until the ~15s persistence heartbeat retries (#1545), and this bucket
5326
+ // is deleted before this point and never requeued — so dropping the event here would
5327
+ // permanently lose the completion for a download that genuinely finished. That trades
5328
+ // a confusing message for a missing one, which is worse: the user is waiting on it.
5329
+ const disagreeing = settled.filter((d) => completionDisagreesWithRecord(d, records));
5330
+ for (const d of disagreeing)
5331
+ d.recordDisagrees = true;
5332
+ if (disagreeing.length) {
5333
+ logger.warn("[panel-orchestrator] a download completion disagrees with the job record; disclosing rather than suppressing (#1574)", { ids: disagreeing.map((d) => String(d.id ?? "")) });
5334
+ }
5291
5335
  // #884 — a download has no originating TAB (its row names the owning
5292
5336
  // conversation), so its turn INHERITS the conversation's LAST
5293
5337
  // ESTABLISHED origin — never the active tab (confirming gate 2, P0 rule: