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
JavaScript
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