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

455 lines • 20.9 kB
// Orchestrator self-updater + self-restarter. // // self-update.ts can refresh the package ON DISK, but a long-lived orchestrator // process keeps running the OLD code until someone restarts it — the exact trap // that produced "regression" reports from a days-old process serving a freshly // rebuilt dist. This engine closes the loop for the PANEL ORCHESTRATOR mode // (`connect` / `--panel-orchestrator`) only: // // - PUBLISHED installs (global / local / npx): periodically re-check the npm // registry. global/local self-update on disk (self-update.ts) and then // restart into the new code; an UNPINNED npx launch respawns pinned to the // new version (npx fetches it on launch). An exact npx version pin is a // stability boundary and is never replaced by this process. // - DEV installs (npm link / checkout): NEVER touch the disk — instead watch // the running entry script's mtime and restart when a rebuild lands, so // `npm run build` is all a developer needs. // // Restart discipline: a restart may NEVER eat a reply. We wait until every // agent is idle with nothing queued (plus any extra caller gate — held // messages, a render in flight), announce to the panel, spawn a DETACHED // replacement with the same command line, drop any panel-op.lock we still // hold (#1953 — the successor otherwise waits forever behind a dead pid), // then run the caller's clean teardown and exit. The replacement rides the // bridge's existing bind-retry while our port frees; agent sessions resume // from the durable session store; the panel reconnects on its own. // // Loop safety: restarts are strictly CHANGE-driven (a version newer than the // one running, or an entry mtime newer than the one we booted with), so a // freshly restarted process observes "no change" and settles. A minimum-uptime // guard adds defense in depth. NEVER used in MCP stdio mode — the MCP client // owns that lifecycle. // // Opt-outs (default ON): // COMFYUI_MCP_AUTO_UPDATE_DISABLE=1 master off (checks + restarts; // COMFYUI_MCP_AUTOUPDATE=0 still works) // COMFYUI_MCP_AUTORESTART=0 keep checking/notifying, never restart // COMFYUI_MCP_UPDATE_CHECK_MS registry re-check period (default 1h) import { spawn } from "node:child_process"; import { readFileSync, statSync } from "node:fs"; import { join, resolve } from "node:path"; import { PACKAGE_NAME, checkAndSelfUpdate, detectInstallMode, getLatestPublishedVersion, isAutoUpdateDisabled, isNewer, } from "./self-update.js"; import { releaseOwnedPanelLock } from "./panel-pin-guard.js"; import { logger } from "../utils/logger.js"; import { deferredAutoUpdateNote } from "./auto-update-gate.js"; import { pinBoundBridgePort, resolveBridgePort } from "./bridge-ports.js"; /** Registry re-check period. An hour keeps update latency low at ~24 tiny * registry GETs a day; override for tuning/tests. */ const DEFAULT_UPDATE_CHECK_MS = 60 * 60 * 1000; /** Dev rebuild watch period — a cheap statSync, so it can be tight. */ const DEV_WATCH_MS = 10_000; /** A rebuild writes many files; require the entry mtime to hold still for one * extra poll so we don't restart into a half-written dist. */ const DEV_SETTLE_POLLS = 2; /** How often to re-test the idle gate while a restart is pending. */ const IDLE_POLL_MS = 5_000; /** Defense-in-depth against restart churn: never restart a process younger * than this (change-driven triggers make a true loop impossible anyway). */ const MIN_UPTIME_MS = 2 * 60 * 1000; /** npm's exec runner passes the package spec that selected this process. */ const NPX_PACKAGE_SPEC_ENV = "npm_config_package"; /** An exact version is a pin; tags and ranges retain the normal update policy. */ const EXACT_VERSION_SPEC = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; function envValue(env, name) { const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name); return key === undefined ? undefined : env[key]; } function exactVersionFromSpec(raw) { if (!raw) return undefined; const prefix = `${PACKAGE_NAME}@`; if (!raw.toLowerCase().startsWith(prefix)) return undefined; const requested = raw.slice(prefix.length).trim(); return EXACT_VERSION_SPEC.test(requested) ? requested : undefined; } /** Read the launch spec npm records beside an npx execution cache. */ function npxCachePackageSpec(packageDir) { if (!packageDir) return undefined; try { // npx's package root is `_npx/<hash>`, two levels above the resolved // package (`_npx/<hash>/node_modules/comfyui-mcp`). Its `_npx.packages` // entry preserves an exact positional spec even though the child process // itself receives only the resolved package's argv. const cacheRoot = resolve(packageDir, "..", ".."); const metadata = JSON.parse(readFileSync(join(cacheRoot, "package.json"), "utf8")); const packages = metadata._npx?.packages; if (!Array.isArray(packages)) return undefined; return packages.find((candidate) => typeof candidate === "string" && candidate.toLowerCase().startsWith(`${PACKAGE_NAME}@`)); } catch { return undefined; } } /** Parse a raw npx command line when one is available to the caller. */ function npxArgvPackageSpec(argv) { const npxIndex = argv.findIndex((arg) => /(?:^|[\\/])npx(?:\.cmd)?$/i.test(arg)); if (npxIndex < 0) return undefined; for (const arg of argv.slice(npxIndex + 1)) { if (arg === "--" || arg === "-y" || arg === "--yes") continue; if (arg.startsWith("-")) continue; if (arg.toLowerCase().startsWith(`${PACKAGE_NAME}@`)) return arg; if (arg.toLowerCase() === PACKAGE_NAME) return undefined; break; } return undefined; } /** * Return the exact version requested by the npx invocation, if one is known. * * Positional npx launches do not pass their package spec in the child * environment or argv. npm does preserve it in the npx cache-root manifest; * that is the production source of truth. Environment/raw-command fallbacks * keep this helper useful for alternate npm runners and direct tests. Treat * only an exact semver as a pin: a bare package, `@latest`, and ranges/tags * retain the existing auto-update behavior. */ export function npxVersionPin(env = process.env, packageDir, argv = process.argv) { return (exactVersionFromSpec(envValue(env, NPX_PACKAGE_SPEC_ENV)?.trim()) ?? exactVersionFromSpec(npxArgvPackageSpec(argv)) ?? exactVersionFromSpec(npxCachePackageSpec(packageDir))); } function defaultEntryMtime() { try { const entry = process.argv[1]; if (!entry) return undefined; return statSync(entry).mtimeMs; } catch { return undefined; } } /** * Env handed to a self-restart child. Pins `COMFYUI_MCP_BRIDGE_PORT` to the * port the parent is actually bound to so a compiled-default change cannot * silently move a live session (#2030). */ export function selfRestartChildEnv(parentEnv = process.env, boundPort = resolveBridgePort(parentEnv)) { const gen = Number(parentEnv.COMFYUI_MCP_RESTART_GEN ?? "0") + 1; return pinBoundBridgePort(boundPort, { ...parentEnv, COMFYUI_MCP_RESTART_GEN: String(gen), }); } /** * Spawn the replacement orchestrator, detached, sharing our stdio so it keeps * logging into the same terminal. Same argv for dev/global/local (the on-disk * code is already new); npx respawns via `npx -y comfyui-mcp@<version>` because * re-execing the cached entry would just re-run the OLD cached code. Args after * the version are our own argv tail — typed by the user at launch, not remote * input. * * The bound port is a distinct argument to `selfRestartChildEnv` — not re-derived * from the parent env inside that helper — so a test that passes 9180 against an * unpinned parent goes red if this call drops `boundPort`. */ export function defaultSpawnReplacement(opts = {}) { try { const parentEnv = opts.env ?? process.env; const boundPort = opts.boundPort ?? resolveBridgePort(parentEnv); const env = selfRestartChildEnv(parentEnv, boundPort); const spawnFn = opts.spawn ?? spawn; const isWin = process.platform === "win32"; const child = opts.npxVersion ? spawnFn(isWin ? "npx.cmd" : "npx", ["-y", `${PACKAGE_NAME}@${opts.npxVersion}`, ...process.argv.slice(2)], { detached: true, stdio: "inherit", env, shell: isWin }) : spawnFn(process.execPath, process.argv.slice(1), { detached: true, stdio: "inherit", env, }); if (!child.pid) return false; child.unref(); return true; } catch (err) { logger.error(`[self-restart] spawn failed: ${err instanceof Error ? err.message : String(err)}`); return false; } } export const defaultSelfRestartDeps = { env: () => process.env, detectInstall: () => detectInstallMode(), checkAndSelfUpdate, latestVersion: () => getLatestPublishedVersion(), entryMtime: defaultEntryMtime, allIdle: () => true, applyAllowed: () => true, announce: () => { }, teardown: async () => { }, spawnReplacement: defaultSpawnReplacement, releasePanelLock: () => { releaseOwnedPanelLock(); }, exit: (code) => process.exit(code), uptimeMs: () => process.uptime() * 1000, }; function isRestartDisabled(env) { const v = (env.COMFYUI_MCP_AUTORESTART ?? "").trim().toLowerCase(); return v === "0" || v === "false" || v === "no" || v === "off"; } /** * Can this process restart itself to apply an update? Both switches, in one * place (#875): the master off (COMFYUI_MCP_AUTO_UPDATE_DISABLE) and the * restart-only opt-out (COMFYUI_MCP_AUTORESTART=0). * * Exported so the phone-pairing disclosure can say whether a rotating pair URL * is likely to be invalidated on its own, without re-deriving the rule from the * env vars and drifting from what the restarter actually does. */ export function canSelfRestart(env = process.env) { return !isAutoUpdateDisabled(env) && !isRestartDisabled(env); } export class SelfRestarter { deps; info; timers = []; /** Baseline entry mtime captured at start — a REBUILD is mtime > this. */ bootMtime; /** Last mtime seen by the dev watcher + how many polls it has held still. */ lastSeenMtime; stablePolls = 0; /** Armed restart (reason + optional npx target); only one at a time. */ pending = null; restarting = false; /** Versions already announced when restart is disabled — once each. */ announced = new Set(); /** Exact npx launch pins are intentional and must not trigger a replacement. */ npxPin; /** apply_updates_now: one tick (and its armed restart) may ignore the gate. */ forceApply = false; stopped = false; constructor(deps = {}) { this.deps = { ...defaultSelfRestartDeps, ...deps }; this.info = this.deps.detectInstall(); this.npxPin = this.info.mode === "npx" ? npxVersionPin(this.deps.env(), this.info.packageDir, this.deps.argv?.() ?? process.argv) : undefined; } /** Wire the periodic checks. No-op when the master switch is off. */ start() { const env = this.deps.env(); if (isAutoUpdateDisabled(env)) { logger.info("[self-restart] disabled via COMFYUI_MCP_AUTO_UPDATE_DISABLE/COMFYUI_MCP_AUTOUPDATE"); return; } const gen = Number(env.COMFYUI_MCP_RESTART_GEN ?? "0"); if (gen > 0) { logger.info(`[self-restart] running v${this.info.currentVersion ?? "?"} after self-restart (gen ${gen}, ${this.info.mode})`); } if (this.npxPin) { logger.info(`[self-restart] npx launch is pinned to ${this.npxPin} — automatic version replacement is disabled`); } if (this.info.isDevLink || this.info.mode === "linked") { // DEV: restart-on-rebuild. Never mutates the checkout. this.bootMtime = this.deps.entryMtime(); const t = setInterval(() => this.devTick(), DEV_WATCH_MS); t.unref?.(); this.timers.push(t); logger.info("[self-restart] dev install — watching the built entry for rebuilds (restart-on-build)"); } else if (this.info.mode !== "unknown") { const period = Number(env.COMFYUI_MCP_UPDATE_CHECK_MS) || DEFAULT_UPDATE_CHECK_MS; const t = setInterval(() => void this.updateTick(), period); t.unref?.(); this.timers.push(t); logger.info(`[self-restart] ${this.info.mode} install — checking npm for updates every ${Math.round(period / 60000)}m`); } else { logger.info("[self-restart] install mode unknown — periodic update checks disabled (manual updates only)"); return; } const idleTimer = setInterval(() => this.tryRestart(), IDLE_POLL_MS); idleTimer.unref?.(); this.timers.push(idleTimer); } stop() { this.stopped = true; for (const t of this.timers) clearInterval(t); this.timers = []; } /** DEV: arm a restart once a rebuild lands and the dist has settled. */ devTick() { if (this.stopped || this.pending || this.restarting) return; const m = this.deps.entryMtime(); if (m === undefined || this.bootMtime === undefined || m <= this.bootMtime) return; if (m !== this.lastSeenMtime) { // Fresh write — start (or restart) the settle counter. this.lastSeenMtime = m; this.stablePolls = 1; return; } this.stablePolls += 1; if (this.stablePolls < DEV_SETTLE_POLLS) return; this.arm({ reason: "a new dev build landed" }); } /** * Re-evaluate after the pair-time toggle flips. An armed restart that was * waiting on the tunnel gate can fire; otherwise a fresh check runs so an * update found while deferred is not stuck until the next hourly tick. */ requestApplyPolicyRefresh() { this.tryRestart(); if (!this.pending && !this.restarting && !this.stopped) { void this.updateTick(); } } /** PUBLISHED: CHECK the registry; APPLY only when the transport gate allows. */ async updateTick(opts = {}) { if (this.stopped || this.pending || this.restarting) return; if (opts.forceApply) this.forceApply = true; const apply = this.forceApply || this.deps.applyAllowed(); try { if (this.deps.panelTick) { const panelRes = await this.deps.panelTick({ apply }); if (panelRes?.action === "deferred" && panelRes.from && panelRes.to) { this.noteDeferred("panel", panelRes.from, panelRes.to); } else if (panelRes?.action === "updated" && panelRes.note) { this.deps.announce(`⬆️ ${panelRes.note}`); } } if (!apply) { // CHECK-ONLY. Must not call checkAndSelfUpdate — that mutates on disk. const latest = await this.deps.latestVersion(); const current = this.info.currentVersion; if (latest && current && isNewer(latest, current)) { this.noteDeferred("orchestrator", current, latest); } this.forceApply = false; return; } if (this.info.mode === "npx") { // npx can't be updated on disk — an unpinned launch respawns pinned to // the new version. An exact package spec is an explicit stability // boundary; never turn `@0.52.66` into `@0.52.67` from inside the // healthy process. if (this.npxPin) return; const latest = await this.deps.latestVersion(); const current = this.info.currentVersion; if (!latest || !current || !isNewer(latest, current)) { if (!this.pending) this.forceApply = false; return; } this.arm({ reason: `updating ${current} → ${latest}`, npxVersion: latest }); return; } // global/local: self-update.ts replaces the package on disk; "updated" // means the NEXT process will run the new code. const res = await this.deps.checkAndSelfUpdate(); if (res.action === "updated") { this.arm({ reason: `updated ${res.from ?? "?"} → ${res.to ?? "?"}` }); } else if (!this.pending) { this.forceApply = false; } } catch (err) { if (!this.pending) this.forceApply = false; logger.debug(`[self-restart] update check failed: ${err instanceof Error ? err.message : String(err)}`); } } noteDeferred(component, from, to) { const key = `deferred:${component}:${from}:${to}`; if (this.announced.has(key)) return; this.announced.add(key); const note = deferredAutoUpdateNote({ component, from, to }); logger.info(`[self-restart] ${note}`); this.deps.announce(note); } /** Record the restart intent. With AUTORESTART off, notify once instead. */ arm(next) { if (isRestartDisabled(this.deps.env())) { const key = next.npxVersion ?? next.reason; if (!this.announced.has(key)) { this.announced.add(key); const note = `⬆️ ${PACKAGE_NAME}: ${next.reason} — auto-restart is off (COMFYUI_MCP_AUTORESTART=0); restart the orchestrator to load it.`; logger.info(`[self-restart] ${note}`); this.deps.announce(note); } return; } logger.info(`[self-restart] ${next.reason} — restarting when every agent is idle`); this.pending = next; this.tryRestart(); } /** Fire the armed restart once the process is old enough and ALL idle. */ tryRestart() { if (!this.pending || this.restarting || this.stopped) return; if (this.deps.uptimeMs() < MIN_UPTIME_MS) return; // #1963 — a restart armed at the desk must still refuse to fire after a // phone pairs over a quick-tunnel. Pending stays set; the next policy // refresh or disconnect (pairTunnel cleared) lets it through. if (!this.forceApply && !this.deps.applyAllowed()) return; if (!this.deps.allIdle()) return; this.restarting = true; const { reason, npxVersion } = this.pending; this.pending = null; void this.doRestart(reason, npxVersion); } async doRestart(reason, npxVersion) { logger.info(`[self-restart] restarting now (${reason})`); this.deps.announce(`⬆️ ${reason} — restarting the agent orchestrator. Back in a few seconds; your session resumes automatically.`); // Spawn FIRST so a spawn failure aborts the restart with the old process // fully intact. The child's bridge bind-retry outlasts our teardown. if (!this.deps.spawnReplacement({ npxVersion })) { logger.error("[self-restart] could not spawn the replacement — staying on the current process"); this.deps.announce("⚠️ Restart failed to launch a replacement — still running the current version."); this.restarting = false; this.forceApply = false; return; } // The successor is already running. Drop any panel-op.lock we still hold // so it is not stuck behind a pid that is about to exit (#1953). Spawn // failure must NOT release: we are staying alive and may still own a // legitimate in-flight op. try { this.deps.releasePanelLock(); } catch (err) { logger.debug(`[self-restart] release panel lock: ${err instanceof Error ? err.message : String(err)}`); } this.stop(); try { await this.deps.teardown(); } catch (err) { logger.debug(`[self-restart] teardown: ${err instanceof Error ? err.message : String(err)}`); } this.deps.exit(0); } } //# sourceMappingURL=self-restart.js.map