comfyui-mcp 0.48.30 → 0.48.31

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.
@@ -59,7 +59,7 @@ import { readComfyuiCrashLog, formatCrashNote } from "../services/crash-log.js";
59
59
  import { QueueMonitor } from "../services/queue-monitor.js";
60
60
  import { initRunpodWatcher, getRunpodWatcher } from "../services/runpod-watch.js";
61
61
  import { getPod } from "../services/runpod-client.js";
62
- import { listTargetChangeRequests, consumeTargetChange, ackTargetChange, setProgressDir, CONTROL_PREFIX } from "../services/download-progress.js";
62
+ import { listTargetChangeRequests, consumeTargetChange, ackTargetChange, setProgressDir, CONTROL_PREFIX, newestAttemptEpochs, isSupersededAttempt, downloadAttemptKey } from "../services/download-progress.js";
63
63
  import { hasActiveTrainingJob, reconcileStaleTrainingJobs } from "../services/training-jobs.js";
64
64
  import { buildQueueStatusFrame, createQueueStatusBroadcaster, } from "../services/queue-status-broadcast.js";
65
65
  import { unloadAllOllama, warmOllama, resolveOllamaHost } from "../services/ollama-vram.js";
@@ -3453,6 +3453,9 @@ export async function runPanelOrchestrator() {
3453
3453
  // at which we emit. Keyed by download IDENTITY (row.id), not display name, so
3454
3454
  // two distinct downloads sharing a filename in one batch don't overwrite each
3455
3455
  // other and hide a failure (codex).
3456
+ // Each pending terminal carries its attempt epoch + (id,target) supersession key so a
3457
+ // newer live attempt for the SAME logical download can evict a queued FAILED/done
3458
+ // before it fires (panel#489).
3456
3459
  const downloadDonePending = new Map();
3457
3460
  // Resolve which agent to wake for a settled download row: the stamped tab's
3458
3461
  // agent when it's still live; else the SINGLE live agent (pre-fix/in-process
@@ -3559,7 +3562,10 @@ export async function runPanelOrchestrator() {
3559
3562
  files = []; // dir not created yet — nothing downloading
3560
3563
  }
3561
3564
  const now = Date.now();
3562
- const downloads = [];
3565
+ // Phase 1: parse every tray row up front (skip control-channel + corrupt files),
3566
+ // so the attempt-supersession map (panel#489) is computed over the WHOLE tray
3567
+ // before any terminal row is acted on.
3568
+ const parsed = [];
3563
3569
  for (const f of files) {
3564
3570
  if (f.startsWith(CONTROL_PREFIX))
3565
3571
  continue; // control channel, not a download row
@@ -3573,8 +3579,57 @@ export async function runPanelOrchestrator() {
3573
3579
  }
3574
3580
  if (!row || typeof row !== "object")
3575
3581
  continue;
3576
- const status = row.status;
3577
3582
  const updated = typeof row.updated === "number" ? row.updated : now;
3583
+ parsed.push({ full, row, status: row.status, updated });
3584
+ }
3585
+ // Phase 2: newest attempt epoch per (id, target) (panel#489). A same-URL retry
3586
+ // reuses the deterministic id, so this distinguishes attempt N+1 from attempt N's
3587
+ // abandoned transfer — while the (id, target) scope keeps a concurrent LOCAL and
3588
+ // POD download of the same URL (#269) independent (neither supersedes the other).
3589
+ // Dead "downloading" writers (>60s stale) are excluded so a crashed attempt can't
3590
+ // shadow a real terminal; terminal rows are always kept (the newer attempt that
3591
+ // supersedes may itself have already finished).
3592
+ const freshForAttempts = parsed
3593
+ .filter((p) => p.status !== "downloading" || now - p.updated <= 60000)
3594
+ .map((p) => p.row);
3595
+ const newestAttemptByKey = newestAttemptEpochs(freshForAttempts);
3596
+ // A newer attempt for an (id, target) invalidates any terminal event STILL PENDING
3597
+ // for that same download from an abandoned attempt (the cross-tick case: attempt N's
3598
+ // terminal was observed and queued on an earlier poll, then attempt N+1's row
3599
+ // appeared). Evict the superseded entry from every agent's debounce bucket so no
3600
+ // "download FAILED" turn fires against a download that is actually still progressing.
3601
+ for (const bucket of downloadDonePending.values()) {
3602
+ for (const [idKey, entry] of bucket.downloads) {
3603
+ if (entry.attempt === undefined)
3604
+ continue;
3605
+ const newest = newestAttemptByKey.get(entry.supKey);
3606
+ if (newest !== undefined && newest > entry.attempt)
3607
+ bucket.downloads.delete(idKey);
3608
+ }
3609
+ }
3610
+ const downloads = [];
3611
+ for (const { full, row, status, updated } of parsed) {
3612
+ // panel#489: a row from a SUPERSEDED attempt (older `attempt` epoch than the newest
3613
+ // attempt for this (id, target)) is a late artifact of the abandoned attempt a retry
3614
+ // replaced. Drop it entirely — a superseded TERMINAL fires no FAILED/done agent
3615
+ // event, and a superseded "downloading" row is kept off the tray + idle-stop veto so
3616
+ // it can't contradict or duplicate the live retry. Re-read before unlinking so a
3617
+ // writer that replaced the file between the parse above and here isn't clobbered
3618
+ // (only remove a file that STILL belongs to a superseded attempt). Per-attempt files
3619
+ // mean the retry writes a DIFFERENT file, so both coexist and this is deterministic
3620
+ // — never a shared-file race. A genuinely-current row (no newer attempt) is
3621
+ // unaffected and still shows/emits.
3622
+ if (isSupersededAttempt(row, newestAttemptByKey)) {
3623
+ try {
3624
+ const cur = JSON.parse(readFileSync(full, "utf8"));
3625
+ if (isSupersededAttempt(cur, newestAttemptByKey)) {
3626
+ unlinkSync(full);
3627
+ downloadRemoveAt.delete(full);
3628
+ }
3629
+ }
3630
+ catch { /* gone or mid-write — nothing to remove */ }
3631
+ continue;
3632
+ }
3578
3633
  if (status === "done" || status === "error") {
3579
3634
  const due = downloadRemoveAt.get(full);
3580
3635
  if (due == null) {
@@ -3587,8 +3642,17 @@ export async function runPanelOrchestrator() {
3587
3642
  if (key && manager.hasLiveAgent(key)) {
3588
3643
  const bucket = downloadDonePending.get(key) ??
3589
3644
  { downloads: new Map(), flushAt: 0 };
3590
- const idKey = String(row.id ?? full);
3591
- bucket.downloads.set(idKey, { name: String(row.name ?? row.id ?? "model"), status: String(status) });
3645
+ // Identify each pending download by its (id, target) supersession key — NOT
3646
+ // the id alone: a concurrent LOCAL + POD transfer of the same URL shares an id
3647
+ // but must produce TWO #547 outcomes, and the same key lets a newer attempt
3648
+ // evict this entry above. Fall back to the file path when the row has no id.
3649
+ const supKey = downloadAttemptKey(row) ?? ` ${full}`;
3650
+ bucket.downloads.set(supKey, {
3651
+ name: String(row.name ?? row.id ?? "model"),
3652
+ status: String(status),
3653
+ attempt: typeof row.attempt === "number" ? row.attempt : undefined,
3654
+ supKey,
3655
+ });
3592
3656
  bucket.flushAt = now + DOWNLOAD_DONE_DEBOUNCE_MS;
3593
3657
  downloadDonePending.set(key, bucket);
3594
3658
  }
@@ -3633,6 +3697,11 @@ export async function runPanelOrchestrator() {
3633
3697
  continue;
3634
3698
  downloadDonePending.delete(key);
3635
3699
  const settled = [...bucket.downloads.values()];
3700
+ // The bucket can be emptied by the supersession eviction above (panel#489) —
3701
+ // a queued terminal cancelled by a newer live attempt. Don't fire an empty
3702
+ // "download_done" turn in that case.
3703
+ if (settled.length === 0)
3704
+ continue;
3636
3705
  manager.injectEvent(key, { kind: "download_done", downloads: settled });
3637
3706
  }
3638
3707
  // MCP-child control channel (#269): runpod_* tools that ran in spawned