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),
124 lines • 5.91 kB
JavaScript
/**
* comfyui-mcp-panel#1097 — HOW LONG the panel has been refusing for.
*
* The panel refuses commands while it is switching or reloading the canvas
* workflow, which is right: a command that landed mid-switch could apply to the
* wrong graph. The orchestrator retries once and then explains, and it already
* names the stuck case — "a load dialog or an unsaved-changes prompt can hold it
* open awaiting the user".
*
* What it could not say is how long. A switch holding for 200ms and one holding
* for four minutes produced the identical refusal, ending in "it normally clears
* in well under a second, so simply retry". The reporter retried, and retried, and
* the tool never had a way to notice it was telling them to do something that was
* not working. "Never clears" was invisible to the only thing in a position to see
* it.
*
* The orchestrator sees every one of those refusals, so it can time the RUN of
* them per tab without the panel changing at all. Nothing here decides anything —
* no guard is bypassed and no command is retried differently. It only lets the
* message distinguish "wait a moment" from "this has been held for four minutes,
* go and look at the canvas".
*/
const holds = new Map();
/** A run nobody has touched for this long is over — the tab closed, rebound, or
* simply moved on without a routed success to clear it. Without eviction this map
* keeps an entry per tab id for the life of the process (codex review), and a
* reused id would inherit a stale age. */
const HOLD_TTL_MS = 10 * 60 * 1000;
/** A hard ceiling for the pathological case (many short-lived tab ids). Oldest
* runs go first: the freshest are the ones a message could still be built from. */
const MAX_HOLDS = 200;
function evict(now) {
for (const [tab, hold] of holds) {
if (now - hold.last > HOLD_TTL_MS)
holds.delete(tab);
}
if (holds.size <= MAX_HOLDS)
return;
const byAge = [...holds.entries()].sort((a, b) => a[1].last - b[1].last);
for (const [tab] of byAge.slice(0, holds.size - MAX_HOLDS))
holds.delete(tab);
}
/**
* Record a switch-guard refusal for `tabId` and return the run so far.
*
* `now` is injected so the wording is testable without faking a clock.
*/
export function recordSwitchHold(tabId, now = Date.now()) {
evict(now);
const prev = holds.get(tabId);
// A clock that jumped backwards must not produce a negative age: restart the run
// rather than report something that cannot be true.
const next = prev && now >= prev.since
? { since: prev.since, last: now, count: prev.count + 1 }
: { since: now, last: now, count: 1 };
holds.set(tabId, next);
return next;
}
/**
* Forget a tab's run.
*
* Called when a command SUCCEEDS on that tab: the switch cleared, so the next
* refusal starts a fresh run rather than inheriting an age from an unrelated one.
* Without this the elapsed time would only ever grow, and a message built on it
* would eventually be as misleading as no message at all.
*/
export function clearSwitchHold(tabId) {
holds.delete(tabId);
}
/** Test seam. */
export function switchHoldFor(tabId) {
return holds.get(tabId);
}
/** Test seam: drop all state between tests. */
export function resetSwitchHolds() {
holds.clear();
}
/**
* How long a switch has been holding, in words, or "" when it is too brief to be
* worth saying — which is the common case and must not be cluttered.
*
* The threshold is deliberately low: the refusal itself claims a switch "normally
* clears in well under a second", so anything past a few seconds already
* contradicts that claim and is worth surfacing.
*/
export function describeSwitchHold(hold, now = Date.now()) {
if (!hold)
return "";
const ms = now - hold.since;
if (!Number.isFinite(ms) || ms < 3000 || hold.count < 2)
return "";
const secs = Math.round(ms / 1000);
const howLong = secs < 90 ? `${secs}s` : `${Math.round(secs / 60)}m`;
// States the OBSERVATION and both explanations, never a verdict (codex review).
// Elapsed time does not prove a modal: a very large graph, a slow disk or a
// remote canvas can legitimately switch for longer than this, and telling that
// user the canvas is "not busy" and that retrying cannot work would be wrong in
// the one direction that costs them the thing that would have worked.
return (` THIS HAS BEEN HELD FOR ${howLong} across ${hold.count} failed calls, which is longer than a ` +
`switch usually takes. Two things look like this: it may still be loading — a very large ` +
`graph, a slow disk, a remote canvas — or it may be held open by something on the ComfyUI ` +
`tab awaiting a person, such as a load dialog or an unsaved-changes prompt. Retrying clears ` +
`the first and never the second, so if it keeps growing, look at the canvas.`);
}
/**
* Is this command one the switch guard would refuse — i.e. does its success prove
* the switch has cleared?
*
* Two review rounds pulled in opposite directions and this is what they agree on.
* Clearing on EVERY routed success let an unguarded status read wipe the evidence
* while graph commands stayed refused (round 2). Clearing only on the retry of a
* switch refusal left a stale run behind after an ordinary first-attempt success,
* so a later, unrelated switch inherited its age and was announced as stuck
* immediately (round 4).
*
* The panel refuses "every graph and workflow executor" while switching — that is
* the domain, and the success of one of those is exactly the evidence that the
* switch is over.
*/
export function successProvesSwitchCleared(cmd) {
const name = typeof cmd === "string" ? cmd : undefined;
return typeof name === "string" && /^(graph_|workflow_)/.test(name);
}
//# sourceMappingURL=switch-hold.js.map