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),

446 lines • 21.5 kB
// Pin-write handling of pending panel-affecting work (#689). // // update-all and deferred snapshot restores are handed to ComfyUI-Manager and // applied out of band (see panel-pin-guard.ts). Before this fix, a pin written // inside their window only WARNED that the queued work could still move the // now-pinned panel — violating the owner's constraint "never auto-update over // an explicit pin". This module is what the pin-write path calls, inside the // pin's critical section, to actually CANCEL that work: // // update-all → POST <prefix>/manager/queue/reset on the server the op // was queued on (the marker's recorded base), which drops // every PENDING Manager task. A task the worker already // dequeued keeps running and CANNOT be cancelled — both // halves are reported, never just the convenient one. // snapshot-restore → delete <manager files>/startup-scripts/ // restore-snapshot.json before the next ComfyUI restart // consumes it (local installs only). // // HONESTY RULES (the point of the exercise): // - SHARED queue counts never identify OUR update-all: unrelated pending // tasks look exactly like it. A cancel is only PROVEN on v4, where the // panel's per-pack task id (`${uiId}_${pack}`) can be checked against the // queue history and this orchestrator's tasks are countable via the // client_id filter. On 3.x / legacy-UI, or without a recorded ui_id, a // reset would be BLIND — it could wipe others' pending work while the // update-all runs on regardless — so none is sent (could-not-verify). // - Queue counts are read BEFORE and AFTER any reset; the report names the // pending-dropped numbers rather than claiming the panel was saved. // - The marker is cleared ONLY for what was provably cancelled or proven no // longer pending. Everything else — in-flight work, remote hosts, reset // failures, unreadable state — keeps the marker and its warning. // - A marker proves things ONLY about the server recorded in it. A marker // with no recorded base, or whose base is not the current target, must // NEVER be resolved on the current target's evidence: a queue reset or a // local file deletion there may hit the WRONG ComfyUI while the original // work still runs. Those paths act best-effort at most, report UNVERIFIED // / cannot-cancel, and keep the marker. // - A queue reset is BLUNT: it drops ALL pending Manager tasks, not just the // update-all. The report says so. import { getComfyUIBaseUrl } from "../config.js"; import { detectManagerApi, fetchManagerClientQueueCounts, fetchManagerQueueCounts, fetchManagerTaskHistoryEntry, } from "./node-management.js"; import { clearPanelPendingOp, PANEL_PACK_ALIASES, } from "./panel-pin-guard.js"; import { logger } from "../utils/logger.js"; /** Clear the marker and fold the outcome into the report when even THAT * fails — a proven cancel with an unremovable marker must keep warning. */ function finalize(op, report) { const cleared = clearPanelPendingOp(op); return { ...report, op, markerCleared: cleared, outcome: cleared ? report.outcome : "could-not-verify", detail: cleared ? report.detail : `${report.detail} — HOWEVER the pending-op marker itself could not be ` + `cleared, so its warning remains.`, }; } async function cancelUpdateAll(op) { // A marker proves things only about the server RECORDED in it. Without a // base (markers written before the capture existed), the current target may // be a different ComfyUI entirely — its queue state says NOTHING about the // server the update-all was queued on. Never a proven cancel from that. if (!op.base) return cancelUpdateAllUnbased(op); const base = op.base; // A reset is only worth sending — and only REPORTABLE as a cancel — when // OUR update-all's state is provable. Shared queue counts can never provide // that (unrelated pending tasks look exactly like ours), so the dialect // decides what is provable at all. let api; try { api = await detectManagerApi(base); } catch { return { op, outcome: "could-not-verify", markerCleared: false, detail: `could not reach the ComfyUI-Manager on ${base} — NOTHING was sent. ` + `The queued update-all may still be pending; the warning stands.`, }; } if (api !== "v2") return cancelUpdateAllUnattributable(op, base, api); if (!op.uiId) return cancelUpdateAllNoUiId(op, base); return cancelUpdateAllProvable(op, base, op.uiId); } /** * 3.x and legacy-UI (v2-batch) Managers expose no task history and no client * filter, so OUR update-all can never be told apart from unrelated pending * work — and a queue reset there is always BLIND (it could wipe someone * else's queued tasks while ours runs on). Never send one (#689 round 3). */ async function cancelUpdateAllUnattributable(op, base, api) { const counts = await fetchManagerQueueCounts(base); const dialect = api === "legacy" ? "3.x" : "legacy-UI"; if (!counts) { return { op, outcome: "could-not-verify", markerCleared: false, detail: `could not read the ComfyUI-Manager queue on ${base} — NOTHING was ` + `sent. The queued update-all may still be pending; the warning stands.`, }; } if (counts.pending === 0 && counts.inProgress === 0 && !counts.processing) { return finalize(op, { outcome: "already-drained", detail: `the Manager queue on ${base} was already empty and idle, so there was ` + `NOTHING left to cancel — the update-all already drained (or never ` + `reached the queue). The panel may ALREADY have been moved — check ` + `install_comfyui(action:'panel', panel_action:'status').`, pendingBefore: 0, pendingAfter: 0, inProgress: 0, }); } if (counts.pending === 0) { const busy = counts.inProgress > 0 ? `${counts.inProgress} task(s) are RUNNING` : `the queue worker is busy`; return { op, outcome: "already-running", markerCleared: false, detail: `cannot cancel — the Manager queue on ${base} has nothing pending but ` + `${busy}, and in-flight work cannot be cancelled (a queue reset only ` + `drops PENDING work). The update-all may STILL move the panel after ` + `this pin — check install_comfyui(action:'panel', panel_action:'status') once it settles.`, pendingBefore: 0, pendingAfter: 0, inProgress: counts.inProgress, }; } return { op, outcome: "could-not-verify", markerCleared: false, detail: `${counts.pending} task(s) are pending on this ${dialect} ComfyUI-Manager, ` + `which exposes no task history — the queued update-all cannot be told ` + `apart from UNRELATED pending work, and a queue reset would be blind: it ` + `could wipe someone else's queued tasks while the update-all runs on ` + `regardless. NO reset was sent; the warning stands.`, pendingBefore: counts.pending, inProgress: counts.inProgress, }; } /** * v4 without a recorded ui_id: the update-all's tasks can be narrowed to this * orchestrator (client filter) but not identified individually, so a reset * still cannot be PROVEN to drop them — and it always drops unrelated work * too. Only queue states that need no reset are provable here (#689 round 3). */ async function cancelUpdateAllNoUiId(op, base) { const mine = await fetchManagerClientQueueCounts(base); if (!mine) { return { op, outcome: "could-not-verify", markerCleared: false, detail: `could not read this orchestrator's queue state on ${base} — NOTHING ` + `was sent. The queued update-all may still be pending; the warning stands.`, }; } if (mine.pending === 0 && mine.inProgress === 0 && !mine.processing) { return finalize(op, { outcome: "already-drained", detail: `nothing enqueued by this orchestrator is pending or running on ${base} ` + `— the update-all already drained (or never queued), so there was ` + `NOTHING left to cancel. The panel may ALREADY have been moved — check ` + `install_comfyui(action:'panel', panel_action:'status').`, pendingBefore: 0, pendingAfter: 0, inProgress: 0, }); } // In-flight first, as in the ui_id path: running work cannot be cancelled // regardless of what else is pending, and no reset may be sent into it. if (mine.inProgress > 0 || mine.processing) { const busy = mine.inProgress > 0 ? `${mine.inProgress} task(s) from this orchestrator are RUNNING` : `the queue worker is busy`; return { op, outcome: "already-running", markerCleared: false, detail: `cannot cancel — ${busy} on ${base} and the update-all's tasks may be ` + `among them; in-flight work cannot be cancelled (a queue reset only ` + `drops PENDING work). NO reset was sent. The update-all may STILL move ` + `the panel after this pin.`, pendingBefore: mine.pending, inProgress: mine.inProgress, }; } return { op, outcome: "could-not-verify", markerCleared: false, detail: `the marker recorded no ui_id, so the update-all cannot be told apart ` + `from the ${mine.pending} task(s) this orchestrator has pending on ${base} ` + `(other installs/updates included) — a queue reset could wipe unrelated ` + `work and STILL not provably drop it. NO reset was sent; the warning stands.`, pendingBefore: mine.pending, inProgress: mine.inProgress, }; } /** * The panel's per-pack update task in the v4 queue history: the entry when it * COMPLETED, null when it provably did not, undefined when history is * unreadable. update-all names each per-pack task `${uiId}_${packKey}`, and * the panel's pack key is one of its two aliases. */ async function panelTaskState(uiId, base) { let unknown = false; for (const alias of PANEL_PACK_ALIASES) { const entry = await fetchManagerTaskHistoryEntry(`${uiId}_${alias}`, base); if (entry) return entry; if (entry === undefined) unknown = true; } return unknown ? undefined : null; } /** * v4 with a recorded ui_id — the only PROVABLE cancel on a shared queue * (#689 round 3). The panel's per-pack task id is checked against the queue * history (completed ⇒ already-drained), and this orchestrator's tasks are * counted via the client filter (none pending/running ⇒ already-drained; * running ⇒ already-running; provably pending ⇒ a reset that is then * RE-PROVEN to have removed the panel's task). */ async function cancelUpdateAllProvable(op, base, uiId) { // 1. Did the panel's task already RUN? (Queue history = completed tasks.) const panelLookup = await panelTaskState(uiId, base); if (panelLookup === undefined) { return { op, outcome: "could-not-verify", markerCleared: false, detail: `the Manager's queue history on ${base} could not be read, so whether ` + `the panel's update task already ran is UNKNOWN — NOTHING was sent; ` + `the warning stands.`, }; } if (panelLookup) { return finalize(op, { outcome: "already-drained", detail: `the panel's update task (${panelLookup.uiId}) is in the Manager's ` + `queue history on ${base} — it already RAN` + `${panelLookup.result ? ` (${panelLookup.result})` : ""}, so there was ` + `NOTHING left to cancel. The panel may ALREADY have been moved — check ` + `install_comfyui(action:'panel', panel_action:'status').`, pendingBefore: 0, pendingAfter: 0, inProgress: 0, }); } // 2. Not completed. Is anything of OURS still pending or running? const mine = await fetchManagerClientQueueCounts(base); if (!mine) { return { op, outcome: "could-not-verify", markerCleared: false, detail: `this orchestrator's queue state on ${base} could not be read — ` + `NOTHING was sent; the warning stands.`, }; } // In-flight FIRST: a running task of ours may be the panel's update, and // in-flight work cannot be cancelled — regardless of what else is pending. // Checking pending first would send a reset into a live update (#689 round 4). if (mine.inProgress > 0 || mine.processing) { const busy = mine.inProgress > 0 ? `${mine.inProgress} task(s) from this orchestrator are RUNNING` : `the queue worker is busy`; return { op, outcome: "already-running", markerCleared: false, detail: `cannot cancel — ${busy} on ${base} and the panel's task may be among ` + `them; in-flight work cannot be cancelled (a queue reset only drops ` + `PENDING work, so it would only wipe unrelated queued tasks). NO reset ` + `was sent. The update-all may STILL move the panel after this pin.`, pendingBefore: mine.pending, inProgress: mine.inProgress, }; } if (mine.pending === 0) { return finalize(op, { outcome: "already-drained", detail: `the panel's update task is not pending, not running, and not in the ` + `Manager's (bounded) queue history on ${base} — it never ran or already ` + `finished, so there was NOTHING left to cancel. The panel may ALREADY ` + `have been moved — check install_comfyui(action:'panel', panel_action:'status').`, pendingBefore: 0, pendingAfter: 0, inProgress: 0, }); } // A v4 history entry proves only that the marked task has not completed; it // does not prove which pending queue entry belongs to this update-all. The // only available cancellation endpoint resets the ENTIRE shared queue, so // using it here can delete unrelated work. Keep the marker and warning until // Manager exposes an owned, atomic per-task cancellation API. return { op, outcome: "could-not-verify", markerCleared: false, detail: `the panel task has not reached the Manager history and ${mine.pending} ` + `task(s) from this orchestrator are pending on ${base}, but ComfyUI-Manager ` + `offers only a queue-wide reset. That reset could delete unrelated pending ` + `work and cannot prove it cancelled this update-all, so NOTHING was sent; ` + `the warning stands.`, pendingBefore: mine.pending, inProgress: mine.inProgress, }; } /** * A base-less update-all marker (written before the base capture existed). * The current target may be a different ComfyUI entirely, so its queue state * proves NOTHING about the server the update-all was queued on — and a reset * there could wipe an innocent queue while the original update still runs. * NO reset is ever sent from here; every outcome is UNVERIFIED and keeps the * marker (#689 round 3 — round 2's best-effort reset retired for exactly the * blind-reset reason above). */ async function cancelUpdateAllUnbased(op) { const base = getComfyUIBaseUrl(); const counts = await fetchManagerQueueCounts(base); if (!counts) { return { op, outcome: "could-not-verify", markerCleared: false, detail: `the marker recorded NO server, and the queue on the current target ` + `${base} could not be read — the update-all may be queued on a ` + `DIFFERENT ComfyUI. NOTHING was sent; the warning stands.`, }; } if (counts.pending === 0) { const state = counts.inProgress > 0 || counts.processing ? `has nothing pending but ${counts.inProgress} task(s) running` : `is empty and idle`; return { op, outcome: "could-not-verify", markerCleared: false, detail: `the marker recorded NO server. The current target ${base} ${state}, ` + `which proves NOTHING about the ComfyUI the update-all was actually ` + `queued on — no reset was sent. If ${base} is not that server, the ` + `update may still land after this pin; the warning stands.`, pendingBefore: 0, inProgress: counts.inProgress, }; } return { op, outcome: "could-not-verify", markerCleared: false, detail: `the marker recorded NO server, and ${counts.pending} task(s) are ` + `pending on the current target ${base}. Resetting that queue could wipe ` + `an innocent server's work while the update-all runs on its real host — ` + `so NO reset was sent; the warning stands.`, pendingBefore: counts.pending, inProgress: counts.inProgress, }; } function cancelSnapshotRestore(op) { // The deferred restore file lives on the host the restore was REQUESTED // against. Local filesystem state proves things only about the CURRENT // target: a marker recorded for a different server (e.g. before a // remote→local retarget) must NEVER be cleared on local evidence — the // remote restore is still scheduled whatever the local disk says. const currentBase = getComfyUIBaseUrl(); if (op.base && op.base !== currentBase) { return { op, outcome: "cannot-cancel", markerCleared: false, detail: `cannot cancel from here — the deferred restore was scheduled on ` + `${op.base}, but this orchestrator now targets ${currentBase}. The ` + `restore file (<ComfyUI>/user/**/startup-scripts/restore-snapshot.json) ` + `lives on ${op.base} and must be deleted THERE before its next ComfyUI ` + `restart; the current target's local state says nothing about that ` + `host. The warning stands.`, }; } // The deferred file has no operation identity: a stale marker for this base // is indistinguishable from a later restore requested by another operation. // Deleting it would therefore risk cancelling the wrong restore. Keep the // marker until Manager provides an owned restore identifier or cancellation // endpoint. return { op, outcome: "cannot-cancel", markerCleared: false, detail: `cannot safely cancel the deferred restore on ${currentBase}: Manager's ` + `restore-snapshot.json has no operation identity, so deleting it could ` + `remove a later unrelated restore. NOTHING was deleted; the warning stands.`, }; } /** Attempt to cancel ONE pending panel-affecting op, honestly reported. */ export async function cancelPanelPendingOp(op) { try { if (op.kind === "update-all") return await cancelUpdateAll(op); if (op.kind === "snapshot-restore") return cancelSnapshotRestore(op); return { op, outcome: "cannot-cancel", markerCleared: false, detail: `no cancellation route exists for "${op.kind}" pending ops — the ` + `warning stands.`, }; } catch (err) { // A cancel must NEVER break the pin write itself: the pin governs every // future op either way, and the warning only fails closed. logger.warn("[panel] pending-op cancellation threw; keeping the marker", { kind: op.kind, error: err instanceof Error ? err.message : String(err), }); return { op, outcome: "could-not-verify", markerCleared: false, detail: `cancellation of the pending ${op.kind} failed unexpectedly (${err instanceof Error ? err.message : String(err)}) — the warning stands.`, }; } } /** * Attempt to cancel every active pending op, sequentially (Manager state is * measured before/after each reset, so parallel attempts would make the * counts uninterpretable). One report per op, in the same order. */ export async function cancelPanelPendingOps(ops) { const reports = []; for (const op of ops) { reports.push(await cancelPanelPendingOp(op)); } return reports; } //# sourceMappingURL=panel-pending-cancel.js.map