UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

680 lines • 35.3 kB
import { getClient, getHistory, getQueue as clientGetQueue, getQueueVerified as clientGetQueueVerified, interrupt as clientInterrupt, deleteQueueItem as clientDeleteQueueItem, clearQueue as clientClearQueue, enqueuePrompt as clientEnqueuePrompt, freeMemory as clientFreeMemory, } from "../comfyui/client.js"; import { isComfyTransportFailure } from "../comfyui/fetch.js"; import * as cloudClient from "../comfyui/cloud-client.js"; import { isCloudMode } from "../config.js"; import { logger } from "../utils/logger.js"; import { ComfyUIError, ValidationError } from "../utils/errors.js"; import { analyzeHistoryEntry, } from "./job-history.js"; import { JobWatcher } from "./job-watcher.js"; /** True when history enrichment failed because the headless target (and any * panel fallback) could not be reached — not a parse/HTTP-status failure. */ function historyUnreachableFromHere(err) { if (isComfyTransportFailure(err)) return true; const msg = err instanceof Error ? err.message : String(err); return msg.includes("read fallback failed safely"); } const LOCAL_CACHE_DONE_NOTE = 'done:true is from this client\'s cached prompt status; ComfyUI /history was unreachable from this process (COMFYUI_URL). A connected sidebar panel can still read the completed run. Retry get_history (action:"list") for this prompt_id — it uses a panel-origin fallback when available — or inspect the live canvas.'; function asRecord(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : undefined; } function queueWorkflow(item) { const prompt = asRecord(item[2]); return prompt ? prompt : undefined; } function queueExtraData(item) { return asRecord(item[3]); } function extractJobInfo(items, includeWorkflow = false) { return items.map((item) => { const out = { number: item[0], prompt_id: item[1], }; if (includeWorkflow) { const workflow = queueWorkflow(item); const extraData = queueExtraData(item); if (workflow) out.workflow = workflow; if (extraData && Object.keys(extraData).length > 0) out.extra_data = extraData; } return out; }); } export async function getQueueSummary(opts = {}) { const queue = await clientGetQueue(); const includeWorkflow = !!opts.include_workflows; return { running: queue.queue_running.length, pending: queue.queue_pending.length, running_jobs: extractJobInfo(queue.queue_running, includeWorkflow), pending_jobs: extractJobInfo(queue.queue_pending, includeWorkflow), }; } function findPending(queue, promptId) { const idx = queue.queue_pending.findIndex((item) => item[1] === promptId); if (idx < 0) { throw new ComfyUIError(`Pending job ${promptId} was not found. Only pending jobs can be edited or requeued; running jobs must be interrupted.`, "QUEUE_JOB_NOT_FOUND", { prompt_id: promptId }); } return { item: queue.queue_pending[idx], position: idx + 1 }; } export async function getQueuedWorkflow(promptId) { const queue = await clientGetQueue(); const { item, position } = findPending(queue, promptId); const workflow = queueWorkflow(item); if (!workflow) { throw new ComfyUIError(`Pending job ${promptId} did not include a workflow payload in /queue.`, "QUEUE_PAYLOAD_UNAVAILABLE", { prompt_id: promptId }); } const extraData = queueExtraData(item); return { number: item[0], prompt_id: item[1], position, workflow, ...(extraData && Object.keys(extraData).length > 0 ? { extra_data: extraData } : {}), }; } function cloneWorkflow(workflow) { return JSON.parse(JSON.stringify(workflow)); } function applyInputUpdates(workflow, updates) { if (!updates) return workflow; for (const [nodeId, inputs] of Object.entries(updates)) { if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) { throw new ValidationError(`node_inputs.${nodeId} must be an object.`); } const node = workflow[nodeId]; if (!node) throw new ValidationError(`Cannot edit queued workflow: node ${nodeId} does not exist.`); node.inputs = { ...(node.inputs ?? {}), ...inputs }; } return workflow; } /** * Compute the real number of jobs remaining in the queue (running + pending), * clamped to >= 0. We do NOT trust the `number` field ComfyUI returns from * POST /prompt: that is the queue's monotonic priority counter, and it is * NEGATIVE when a job is enqueued at the front (front:true). Reusing it as a * "remaining" count produced nonsensical values like -17. A direct /queue read * is the authoritative count. Falls back to the (clamped) enqueue hint if the * queue can't be read. */ async function computeQueueRemaining(fallback) { try { const queue = await clientGetQueue(); return queue.queue_running.length + queue.queue_pending.length; } catch (err) { logger.debug("Could not read /queue for remaining count; using enqueue hint", { err }); if (typeof fallback !== "number" || !Number.isFinite(fallback)) return undefined; return Math.max(0, fallback); } } async function requeuePendingJob(promptId, workflow, extraData, position) { await clientDeleteQueueItem(promptId); const result = await clientEnqueuePrompt(workflow, extraData, { front: position === "front" }); JobWatcher.watch(result.prompt_id, workflow); const queue_remaining = await computeQueueRemaining(result.queue_remaining); return { old_prompt_id: promptId, new_prompt_id: result.prompt_id, queue_remaining, position, message: `Pending job ${promptId} was requeued at the ${position}; new prompt_id is ${result.prompt_id}.`, }; } export async function moveQueuedJob(promptId, position) { const queued = await getQueuedWorkflow(promptId); const workflow = cloneWorkflow(queued.workflow); return requeuePendingJob(promptId, workflow, queued.extra_data, position); } export async function editQueuedJob(opts) { const queued = await getQueuedWorkflow(opts.prompt_id); const base = opts.workflow ? cloneWorkflow(opts.workflow) : cloneWorkflow(queued.workflow); const workflow = applyInputUpdates(base, opts.node_inputs); return requeuePendingJob(opts.prompt_id, workflow, queued.extra_data, opts.position ?? "back"); } async function cloudJobStatus(promptId) { // Cloud /api/job/<id>/status returns // { status: "pending" | "in_progress" | "completed" | "failed", error?, prompt_id? } // Map onto local JobStatus shape so callers don't care about the backend. const cloud = await cloudClient.getJobStatus(promptId); const done = cloud.status === "completed" || cloud.status === "failed"; const base = { running: cloud.status === "in_progress", pending: cloud.status === "pending", done, status_str: cloud.status, }; if (!done) return base; // Try to enrich completed jobs from /api/history_v2/<id>; if that fails, // fall back to the bare cloud status (with the error message if present). try { const history = await getHistory(promptId); const entry = history[promptId]; if (!entry) { return cloud.error ? { ...base, error: { node_id: "", node_type: "", exception_message: cloud.error, }, } : base; } const analysis = analyzeHistoryEntry(entry); return { ...base, status_str: entry.status?.status_str ?? cloud.status, error: analysis.error, execution_stats: analysis.execution_stats, }; } catch (err) { logger.warn("Cloud: could not enrich job status from history", { prompt_id: promptId, error: err instanceof Error ? err.message : err, }); return cloud.error ? { ...base, error: { node_id: "", node_type: "", exception_message: cloud.error, }, } : base; } } export async function getJobStatus(promptId) { if (isCloudMode()) return cloudJobStatus(promptId); const client = getClient(); const status = await client.getPromptStatus(promptId); if (!status.done) return status; try { const history = await getHistory(promptId); const entry = history[promptId]; if (!entry) { // Both reads answered and neither has seen this prompt — not a // completion. `done = !running && !pending` used to call a lost job // (restart wipe, mistyped id) finished. Absence is only claimed on a // read that succeeded; a failed history read falls to the catch. return { running: false, pending: false, done: false, found: false, message: `ComfyUI has no record of this prompt — not running, not queued, and absent ` + `from /history. It may have been lost to a restart or was never queued. Do not ` + `wait for outputs; verify with get_history (action:"diagnose") and ` + `get_image (action:"list_outputs").`, }; } const analysis = analyzeHistoryEntry(entry); return { ...status, status_str: entry.status.status_str, error: analysis.error, execution_stats: analysis.execution_stats, ...(analysis.text_outputs ? { text_outputs: analysis.text_outputs } : {}), }; } catch (err) { logger.warn("Could not enrich job status from history", { prompt_id: promptId, error: err instanceof Error ? err.message : err, }); if (status.done && historyUnreachableFromHere(err)) { return { ...status, done_from: "local_cache", note: LOCAL_CACHE_DONE_NOTE, }; } return status; } } export async function cancelRunningJob(promptId) { await clientInterrupt(promptId); logger.info("Job interrupted", { prompt_id: promptId ?? "current" }); } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** How long to wait for an interrupt to actually stop the running job before * escalating. ComfyUI only checks the interrupt flag BETWEEN nodes/steps, so a * multi-minute single step won't honor it — that wait is what detects the wedge. * Tunable via COMFYUI_MCP_INTERRUPT_S (seconds); default 30. */ function interruptHonorMs() { const s = Number(process.env.COMFYUI_MCP_INTERRUPT_S); return Number.isFinite(s) && s > 0 ? Math.round(s * 1000) : 30000; } /** Poll /queue until the target running job is gone (or any-running is gone when * no id given), or the timeout elapses. * * THREE outcomes, because a failed poll is not "still running": if ComfyUI * died mid-job, /queue stops answering AND the job is gone — folding that * into "still-running" ends in a confident "wedged, restart ComfyUI, do NOT * queue another run" verdict about a job that no longer exists. "Cleared" * and "still-running" both require a live observation; anything else is * "unobservable". */ async function waitForRunningCleared(promptId, timeoutMs) { const start = Date.now(); let everObserved = false; let lastPollFailed = false; // Sticky, unlike `lastPollFailed`: a later successful poll re-establishes the // PRESENT state, but it cannot retroactively fill in what happened while the // queue was not answering. let anyPollFailed = false; while (Date.now() - start < timeoutMs) { await sleep(1500); // unknown-ok: a failed poll is recorded as a FAILED POLL rather than as an // empty queue — `lastPollFailed` / `anyPollFailed` carry the unknown forward // into the returned `continuous` and `observed` flags. const q = await getQueueSummary().catch(() => null); if (!q) { lastPollFailed = true; anyPollFailed = true; continue; } everObserved = true; lastPollFailed = false; if (q.running === 0) return { outcome: "cleared", observed: true, continuous: !anyPollFailed }; // A DIFFERENT job is now running → the one we targeted has cleared. if (promptId && !q.running_jobs.some((j) => j.prompt_id === promptId)) { return { outcome: "cleared", observed: true, continuous: !anyPollFailed }; } } // Timed out. "Still running" is a claim about NOW, and only a poll that is // still answering at the end of the window can make it — a run of failures // (or never observing at all) means the queue's state is simply unknown. return { outcome: everObserved && !lastPollFailed ? "still-running" : "unobservable", observed: everObserved, continuous: !anyPollFailed, }; } /** * Cancel the running job ROBUSTLY: optionally clear all pending first (so a * re-queue can't stack behind a backlog), interrupt, then WAIT and verify the * job actually stopped. If the interrupt isn't honored within the window, escalate * to POST /free and re-check; if it STILL won't die it's wedged inside a single * step — HTTP can't kill that, so report that a ComfyUI restart is required rather * than letting the agent re-queue on top of a zombie. */ export async function cancelRunningJobEscalating(opts) { let pending_cleared; let clearPendingFailed = false; if (opts.clear_pending) { // unknown-ok: null suppresses the COUNT rather than reporting a wrong one — see // pending_cleared below, which stays undefined so the message says "cleared" // without a number it never observed. const before = await getQueueSummary().catch(() => null); await clearAllQueued().catch((err) => { clearPendingFailed = true; logger.warn("clear_pending failed (continuing)", { err }); }); // Only a clear that did not fail may report a count — "cleared (N)" on a // failed request is a confident false statement. pending_cleared = clearPendingFailed ? undefined : before?.pending; } // Identify the job we're trying to stop so we can verify it actually clears. // unknown-ok: null makes targetSeenRunning false, and "interrupted" is only // claimed about a job we OBSERVED running (see the comment below). A failed // summary therefore withholds the claim instead of fabricating it. const pre = await getQueueSummary().catch(() => null); const runningId = opts.prompt_id ?? pre?.running_jobs?.[0]?.prompt_id; // "Interrupted" is only a claim we may make about a job we OBSERVED running. const targetSeenRunning = opts.prompt_id ? !!pre?.running_jobs.some((j) => j.prompt_id === opts.prompt_id) : !!pre && pre.running > 0; // A clear whose REQUEST failed is only a real failure when pending jobs may // still be queued: a live read showing none settles the goal the clear was // for (#2517 — an idle queue was told "clearing FAILED" its own verified // empty list disproved one poll later). const pendingClearFailed = clearPendingFailed && (!pre || pre.pending > 0); // The pending half of every verdict message, true to what actually happened. const pendingNote = !opts.clear_pending ? `Pending jobs were NOT cleared — pass clear_pending:true or call queue (action:"clear").` : pendingClearFailed ? `Clearing pending jobs FAILED — they may still be queued; check queue (action:"list").` : clearPendingFailed ? `The clear request failed, but /queue now shows no pending jobs.` : pending_cleared != null ? `Pending jobs were cleared (${pending_cleared}).` : `Pending jobs were cleared.`; if (pre && pre.running === 0 && !opts.prompt_id) { return { interrupted: false, honored: true, freed_vram: false, wedged: false, target_state: "stopped", pending_clear_failed: pendingClearFailed || undefined, pending_cleared, message: `No job is running.${opts.clear_pending ? ` ${pendingNote}` : ""}`, }; } if (pre && opts.prompt_id && !targetSeenRunning) { return { interrupted: false, honored: true, freed_vram: false, wedged: false, target_state: "stopped", pending_clear_failed: pendingClearFailed || undefined, pending_cleared, message: `${opts.prompt_id} is not running (verified via /queue) — nothing to interrupt.` + `${opts.clear_pending ? ` ${pendingNote}` : ""}`, }; } await clientInterrupt(opts.prompt_id); logger.info("Interrupt sent (escalating cancel)", { prompt_id: runningId ?? "current" }); const firstClearance = await waitForRunningCleared(runningId, interruptHonorMs()); if (firstClearance.outcome === "cleared") { if (!targetSeenRunning) { // The queue is verifiably empty NOW, but the pre-interrupt read failed — // so "the interrupt stopped it" asserts a start state nobody observed. return { interrupted: true, honored: false, freed_vram: false, wedged: false, unverified: true, target_state: "stopped", pending_clear_failed: pendingClearFailed || undefined, pending_cleared, running_prompt_id: runningId, message: `Interrupt sent${runningId ? ` (${runningId})` : ""}, and nothing is running now ` + `(verified via /queue) — but the queue could not be read BEFORE the interrupt, so ` + `whether a job was running, and whether the interrupt stopped it, is UNKNOWN.` + `${opts.clear_pending ? ` ${pendingNote}` : ""}`, }; } return { interrupted: true, honored: true, freed_vram: false, wedged: false, target_state: "stopped", pending_clear_failed: pendingClearFailed || undefined, pending_cleared, running_prompt_id: runningId, message: `Interrupted the running job${runningId ? ` (${runningId})` : ""}.${opts.clear_pending ? ` ${pendingNote}` : ""}`, }; } // Not honored — the step is long-running. Free VRAM and re-check. The /free // result is tracked, not swallowed into the narration: a failed escalation // must not be described as a performed one — and a throw only means no // successful completion was OBSERVED (the request may have applied and the // response been lost), so "never reached the server" is not ours to say // either. On Comfy Cloud the call is a deliberate no-op, which no message // may narrate as a VRAM free. logger.warn("Interrupt not honored in window; escalating to /free", { prompt_id: runningId ?? "current" }); const freeRan = !isCloudMode(); let freeFailed = false; await clientFreeMemory({ unload_models: true, free_memory: true }).catch((err) => { freeFailed = true; logger.warn("/free during cancel escalation failed (continuing)", { err }); }); // freed_vram is true only when the escalation RAN and did not fail. const freedVram = freeRan && !freeFailed; const freeFailedPhrase = "the VRAM-free escalation did not complete — whether it freed anything is unknown"; const finalClearance = await waitForRunningCleared(runningId, Math.min(interruptHonorMs(), 12000)); // The duration the message may claim: continuous verified polling only when // the FIRST window was observed end-to-end; otherwise only the final check // was a live observation, and the message must not claim more. // // "End-to-end" means EVERY poll answered. A window with a hole in it still // supports the present-tense reading its last live poll made — but calling it // "~Ns of verified polling" is a sampled observation with a gap narrated as // continuous verification, and this claim is load-bearing: it is what makes // "restart ComfyUI, do NOT queue another run" sound settled. A restart or a // disconnect inside the gap is exactly the case that guidance would be wrong // about, so the gap is named rather than smoothed over. // BOTH windows, because the duration claimed spans both: a hole anywhere in // the stated span makes the span not continuously verified, and checking only // the first window would leave the same claim standing over a gap in the // second (codex gate). const escalationWindowS = Math.round(Math.min(interruptHonorMs(), 12000) / 1000); const watchedDuration = (first, final) => { if (first.outcome !== "still-running") { // One check, not a window — a gap before it does not touch this claim. return ` at a verified check ~${escalationWindowS}s after the escalation`; } const totalS = Math.round((interruptHonorMs() + Math.min(interruptHonorMs(), 12000)) / 1000); return first.continuous && final.continuous ? ` across ~${totalS}s of verified polling` : ` across ~${totalS}s of polling that /queue did NOT answer throughout — it was ` + `seen running by the polls that did answer, including the most recent one, but ` + `the gap could hide a restart`; }; // "The job stopped" (or "this job is wedged") is only ours to claim when the // target was seen running BEFORE the interrupt. A named prompt first sighted // in a later window may have STARTED after the interrupt — the interrupt was // a no-op for it, and its later stop is not our doing. const stopVerified = targetSeenRunning; if (finalClearance.outcome === "cleared") { // The queue is verifiably empty — but WHY is only knowable if we watched // it the whole time, and WHETHER a job was running at all is only knowable // if the start was observed. // A job was SEEN during the window, but never tied to the interrupt. const sawUntiedJob = !stopVerified && (firstClearance.outcome === "still-running" || firstClearance.observed); const message = firstClearance.outcome === "unobservable" && targetSeenRunning ? `The running job${runningId ? ` (${runningId})` : ""} has STOPPED (verified via /queue), ` + `but what stopped it is unknown — the queue was unreachable for a while after the ` + `interrupt${freeFailed ? `, and ${freeFailedPhrase} — so the interrupt may have worked late or ComfyUI restarted` : freeRan ? `, so the interrupt may have worked late, the VRAM free may have done it, or ComfyUI restarted` : `, so the interrupt may have worked late or ComfyUI restarted`}.${opts.clear_pending ? ` ${pendingNote}` : ""}` : sawUntiedJob ? `A job was running during the interrupt window and nothing is running now (verified ` + `via /queue) — but the queue could not be read BEFORE the interrupt, so whether the ` + `job that stopped is the one that was interrupted is UNKNOWN.${opts.clear_pending ? ` ${pendingNote}` : ""}` : !stopVerified ? `Nothing is running now (verified via /queue), but /queue never answered from before ` + `the interrupt through the honor window — so whether a job was running at all, and ` + `what stopped it if one was, is UNKNOWN.${opts.clear_pending ? ` ${pendingNote}` : ""}` : freeFailed ? `The job didn't stop on interrupt and ${freeFailedPhrase}, but the job ` + `HAS now stopped anyway (verified via /queue)${runningId ? ` (${runningId})` : ""}.${opts.clear_pending ? ` ${pendingNote}` : ""}` : freeRan ? `The job didn't stop within the interrupt window; after the VRAM-free escalation it ` + `has now STOPPED (verified via /queue)${runningId ? ` (${runningId})` : ""}.${opts.clear_pending ? ` ${pendingNote}` : ""}` : `The job didn't stop within the interrupt window and has now STOPPED ` + `(verified via /queue)${runningId ? ` (${runningId})` : ""}.${opts.clear_pending ? ` ${pendingNote}` : ""}`; return { interrupted: true, honored: stopVerified, freed_vram: freedVram, wedged: false, unverified: stopVerified ? undefined : true, target_state: "stopped", pending_clear_failed: pendingClearFailed || undefined, pending_cleared, running_prompt_id: runningId, message, }; } if (finalClearance.outcome === "unobservable") { const subject = targetSeenRunning ? `⚠️ The running job${runningId ? ` (${runningId})` : ""} was sent an interrupt` : `⚠️ An interrupt was sent${runningId ? ` for ${runningId}` : ""}`; const unknownWhat = targetSeenRunning ? `whether it is still running is UNKNOWN — ComfyUI may be down or restarting, which would ` + `ALSO have stopped the job` : `whether a job was running — and whether one still is — is UNKNOWN. ComfyUI may be down ` + `or restarting`; // "Stopped answering" is only a claim we may make if it answered at all. const reachability = finalClearance.observed ? `stopped answering partway through verification` : `did not answer during verification`; return { interrupted: true, honored: false, freed_vram: freedVram, wedged: false, unverified: true, target_state: "unknown", pending_clear_failed: pendingClearFailed || undefined, pending_cleared, running_prompt_id: runningId, message: subject + (freeFailed ? ` (${freeFailedPhrase})` : freeRan && targetSeenRunning ? ` (and a VRAM free)` : ``) + `, but /queue ${reachability}, so ${unknownWhat}. This is not a confirmed wedge. ` + `Check ComfyUI is up (install_comfyui (action:"environment")) and inspect the queue before deciding anything. ` + (opts.clear_pending ? pendingNote : `Pending jobs were NOT cleared — pass clear_pending:true or call queue (action:"clear").`), }; } if (!stopVerified) { // A job is verifiably wedged — but nothing established it is the job the // interrupt addressed (no pre-interrupt read; a named prompt may have // STARTED after it). The wedge itself is real (interrupt + VRAM free did // not stop it); only the IDENTITY is unknown. return { interrupted: true, honored: false, freed_vram: freedVram, wedged: true, unverified: true, // Only a NAMED target gets "running": waitForRunningCleared matched that // prompt_id in the running slot, so the job this call addressed is the one // observed. Without an id and with no pre-interrupt read, `runningId` is // undefined and the poll only established that SOME job is running — which // is what the message below already says. Calling that "running" would // make the field claim the identity the prose disclaims (codex gate). target_state: opts.prompt_id ? "running" : "unknown", pending_clear_failed: pendingClearFailed || undefined, pending_cleared, running_prompt_id: runningId, message: `⚠️ ${opts.prompt_id ? `The job ${opts.prompt_id}` : "A job"} is still running after interrupt` + (freeFailed ? ` (${freeFailedPhrase})` : freeRan ? ` + VRAM free` : ``) + watchedDuration(firstClearance, finalClearance) + ` — it is wedged inside a single step (ComfyUI only honors interrupts ` + `BETWEEN steps). Whether this IS the job the interrupt addressed is UNKNOWN — ` + (opts.prompt_id ? `it was never observed running before the interrupt, so it may have started after. ` : `the queue could not be read beforehand. `) + `An HTTP cancel cannot kill a wedged step; restart ` + `ComfyUI (panel_restart_comfyui, or restart_comfyui) to clear it. ` + `${pendingNote} Do NOT queue another run until this is gone.`, }; } return { interrupted: true, honored: false, freed_vram: freedVram, wedged: true, target_state: "running", pending_clear_failed: pendingClearFailed || undefined, pending_cleared, running_prompt_id: runningId, message: `⚠️ The running job${runningId ? ` (${runningId})` : ""} did NOT stop after interrupt` + (freeFailed ? ` (${freeFailedPhrase})` : freeRan ? ` + VRAM free` : ``) + watchedDuration(firstClearance, finalClearance) + ` — it is wedged inside a single step (ComfyUI only honors interrupts ` + `BETWEEN steps, so a multi-minute step ignores cancel). An HTTP cancel cannot kill this; restart ComfyUI ` + `(panel_restart_comfyui, or restart_comfyui) to clear it. ` + `${pendingNote} Do NOT queue another run until this is gone.`, }; } /** * Remove ONE pending job, and report the state we actually observed. * * VERIFY, DON'T ASSUME. ComfyUI's /queue delete silently no-ops for a prompt * that has already started running: the render keeps going and its outputs are * still delivered. Firing the delete and returning void left every caller with * nothing to branch on, so the tool hardcoded "removed successfully." — a FALSE * report for exactly the case an agent cares about, superseding a job it just * queued. In #1632 the job won the race by one second, the agent told the user * it was cancelled, and four stale images arrived 70s later. * * So: read /queue on BOTH sides of the delete. The read BEFORE separates a job * that was never pending from one we removed (after the delete those two look * identical), and the read AFTER catches the race that produced the bug — the * job starting between our check and the delete landing. * * This mirrors the verify-then-report contract action:"cancel" already has; it * is the one queue mutation that never got it. */ export async function cancelQueuedJob(promptId) { // Comfy Cloud has NO /queue endpoint: `cloudClient.getQueue()` returns a // hardcoded empty queue without making a request. A "not pending" read there // is not an observation, it is the absence of an endpoint — and the two are // byte-identical to the checks below, so verifying against it would report // every cloud job as `absent` ("it already finished"), swallow the delete, // and replace a correct CLOUD_UNSUPPORTED error that names action:"cancel" // with the exact false report this function exists to prevent. Nothing about // a stub can be verified, so don't pretend: go straight to the delete. if (isCloudMode()) { await clientDeleteQueueItem(promptId); logger.info("Queued job removed", { prompt_id: promptId, cloud: true }); return { removed: true, state: "removed", verified: false }; } const holds = (items) => items.some((item) => item[1] === promptId); // Deliberately NOT getQueue(): that one resolves an EMPTY queue for a 500, an // HTML proxy page and a dead port alike, so "this job is not pending" and "I // could not look" are the same value. Reading the guard below off it would // report a live pending job as `absent` — "it already finished, its outputs // already exist" — with ComfyUI simply unreachable, and would skip the delete // that the old code at least always attempted. getQueueVerified() throws // instead, which is what makes `null` below mean ignorance and nothing else. // // null = we did not get to look. A look we did not get NEVER decides an early // return and NEVER counts toward `verified`; it only ever costs disclosure. const readQueue = async (when) => await clientGetQueueVerified().catch((err) => { logger.debug(`Could not read /queue ${when} cancel_queued`, { err, prompt_id: promptId }); return null; }); const before = await readQueue("before"); if (before) { if (holds(before.queue_running)) { logger.info("Queued job is already running; not removed", { prompt_id: promptId }); return { removed: false, state: "running", verified: true }; } if (!holds(before.queue_pending)) { logger.info("Queued job is not in the queue; nothing to remove", { prompt_id: promptId }); return { removed: false, state: "absent", verified: true }; } } await clientDeleteQueueItem(promptId); const after = await readQueue("after"); if (after) { if (holds(after.queue_running)) { logger.info("Queued job started before the removal landed", { prompt_id: promptId }); return { removed: false, state: "running", verified: true }; } if (holds(after.queue_pending)) { logger.info("Queued job still pending after the removal", { prompt_id: promptId }); return { removed: false, state: "pending", verified: true }; } } logger.info("Queued job removed", { prompt_id: promptId }); // BOTH reads are required for `verified`: the after-read alone cannot tell a // job we removed from one that was never queued, and calling the second case // "removed" is the same false report in a narrower window. return { removed: true, state: "removed", verified: !!before && !!after }; } export async function clearAllQueued() { await clientClearQueue(); logger.info("All pending queue items cleared"); } //# sourceMappingURL=queue-manager.js.map