Stop idle Comfy after 30 minutes and add a Wake GPU poke.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-29 17:59:18 -05:00
co-authored by Cursor
parent cdc69d2bd6
commit ad1f4f905a
8 changed files with 283 additions and 26 deletions
+2
View File
@@ -9,6 +9,8 @@ COMFY_LAUNCH_CMD=
# Windows host agent (scripts/comfy-host-agent.mjs) so Coolify can start Comfy remotely
COMFY_CONTROL_URL=http://192.168.77.7:8199
COMFY_CONTROL_TOKEN=
# Idle-stop lives on the Beast host agent only (default 30 min). Set on the desktop, not here.
# COMFY_IDLE_MS=1800000
NUXT_SESSION_SECRET=change-me
NUXT_PUBLIC_AUTH_ENABLED=true
+63 -8
View File
@@ -10,6 +10,7 @@
</div>
</div>
<div class="flex items-center gap-3 text-sm">
<div class="flex items-center gap-2">
<span
class="hidden items-center gap-2 rounded-full border px-3 py-1 sm:inline-flex"
:class="rigBadge.className"
@@ -18,6 +19,18 @@
<span class="h-2 w-2 rounded-full" :class="rigBadge.dot" />
{{ rigBadge.label }}
</span>
<button
v-if="showWakeGpu"
type="button"
class="rounded-full border px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white disabled:cursor-wait disabled:opacity-80"
:class="gpuWaking ? 'border-amber-300/40 text-amber-100' : 'border-white/10'"
:disabled="gpuWaking"
:title="gpuWakeError || (gpuWaking ? 'Starting Comfy Desktop on the GPU' : 'Poke / Wake GPU — start Comfy before generating')"
@click="pokeGpu"
>
{{ gpuWaking ? 'Starting GPU…' : 'Wake GPU' }}
</button>
</div>
<span class="hidden md:block text-zinc-400">{{ userName }}</span>
<button type="button" class="rounded-full border border-white/10 px-3 py-1 text-zinc-300 hover:border-amber-300/50 hover:text-white" @click="settingsOpen = true">
Settings
@@ -2358,6 +2371,9 @@ const playerSize = ref({ width: 1344, height: 768 })
const toasts = ref<{ id: string; message: string }[]>([])
const comfyOk = ref(false)
const comfyBusy = ref(false)
const comfyAsleep = ref(false)
const gpuWaking = ref(false)
const gpuWakeError = ref('')
const userName = ref('')
const authEnabled = ref(false)
const authMode = ref('')
@@ -2574,13 +2590,24 @@ const liveMaxStep = computed(() => (
const rigBusy = computed(() => (
comfyBusy.value || imageComfyBusy.value || videoBusy.value || editBusy.value
))
const showWakeGpu = computed(() => gpuWaking.value || (!comfyOk.value && !imageComfyOk.value))
const rigBadge = computed(() => {
if (gpuWaking.value) {
return {
label: 'Starting…',
className: 'border-amber-400/40 text-amber-200',
dot: 'bg-amber-400 animate-pulse',
title: 'Starting Comfy Desktop on the GPU'
}
}
if (!comfyOk.value && !imageComfyOk.value) {
return {
label: 'Offline',
className: 'border-red-400/30 text-red-300',
dot: 'bg-red-400',
title: 'Desktop GPU is unreachable'
title: comfyAsleep.value
? 'Desktop GPU is asleep. Click Wake GPU or generate to start Comfy.'
: 'Desktop GPU is unreachable'
}
}
if (rigBusy.value) {
@@ -2696,7 +2723,7 @@ const recommendHint = computed(() => {
const editBlocked = computed(() => {
if (!file.value || !prompt.value.trim() || !folderId.value) return true
if (editPassQueue.value.some(item => !item.prompt.trim())) return true
if (!comfyOk.value && !imageComfyOk.value) return true
if (!comfyOk.value && !imageComfyOk.value && !gpuWaking.value) return true
return false
})
const editBlockReason = computed(() => {
@@ -2705,7 +2732,7 @@ const editBlockReason = computed(() => {
if (!folderId.value) return 'Choose a library folder before editing.'
if (editPassQueue.value.some(item => !item.prompt.trim())) return 'Each extra pass needs a prompt.'
if (!imageComfyConfigured.value && !comfyOk.value) return 'Image edit is not configured. Set COMFY_HOST.'
if (!comfyOk.value && !imageComfyOk.value) return 'Desktop GPU is offline.'
if (!comfyOk.value && !imageComfyOk.value && !gpuWaking.value) return 'Desktop GPU is offline. Click Wake GPU first.'
return ''
})
const stillBHint = computed(() => {
@@ -2719,7 +2746,7 @@ const editV2Blocked = computed(() => {
if (v2Mode.value !== 'generate' && !file.value) return true
if (v2Mode.value === 'compose' && !editRefFile.value) return true
if (v2Mode.value === 'refine' && !refineMaskDirty.value) return true
if (!comfyOk.value && !imageComfyOk.value) return true
if (!comfyOk.value && !imageComfyOk.value && !gpuWaking.value) return true
return false
})
const editV2BlockReason = computed(() => {
@@ -2729,7 +2756,7 @@ const editV2BlockReason = computed(() => {
if (!prompt.value.trim()) return 'Write a prompt first.'
if (!folderId.value) return 'Choose a library folder before generating.'
if (!imageComfyConfigured.value && !comfyOk.value) return 'Image v2 is not configured. Set COMFY_HOST.'
if (!comfyOk.value && !imageComfyOk.value) return 'Desktop GPU is offline.'
if (!comfyOk.value && !imageComfyOk.value && !gpuWaking.value) return 'Desktop GPU is offline. Click Wake GPU first.'
return ''
})
const editV2SubmitLabel = computed(() => {
@@ -3206,7 +3233,7 @@ function scheduleLibraryRefresh() {
let healthFailStreak = 0
function applyComfyHealth(health: {
comfy?: { ok?: boolean; busy?: boolean }
comfy?: { ok?: boolean; busy?: boolean; asleep?: boolean }
imageComfy?: {
ok?: boolean
busy?: boolean
@@ -3219,7 +3246,11 @@ function applyComfyHealth(health: {
const nextComfyOk = Boolean(health?.comfy?.ok)
const nextImageOk = Boolean(health?.imageComfy?.ok)
const reachable = nextComfyOk || nextImageOk
if (reachable) healthFailStreak = 0
if (reachable) {
healthFailStreak = 0
gpuWaking.value = false
gpuWakeError.value = ''
}
else healthFailStreak += 1
const keepLast = !reachable && (healthFailStreak < 3 || videoBusy.value || editBusy.value)
if (!keepLast) {
@@ -3227,6 +3258,7 @@ function applyComfyHealth(health: {
imageComfyOk.value = nextImageOk
imageBeastOk.value = Boolean(health?.imageComfy?.beast?.ok)
imageSidecarOk.value = Boolean(health?.imageComfy?.sidecar?.ok)
comfyAsleep.value = Boolean(health?.comfy?.asleep) && !reachable
}
comfyBusy.value = Boolean((keepLast ? comfyOk.value : nextComfyOk) && health?.comfy?.busy)
imageComfyConfigured.value = Boolean(health?.imageComfy?.configured)
@@ -3246,10 +3278,33 @@ async function pollLoras() {
}
async function pollComfyHealth() {
const health = await $fetch<{ comfy?: { ok?: boolean; busy?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false, busy: false } }))
const health = await $fetch<{ comfy?: { ok?: boolean; busy?: boolean; asleep?: boolean } }>('/api/health').catch(() => ({ comfy: { ok: false, busy: false } }))
applyComfyHealth(health)
}
async function pokeGpu() {
if (gpuWaking.value) return
gpuWaking.value = true
gpuWakeError.value = ''
try {
await $fetch('/api/comfy/wake', { method: 'POST' })
const deadline = Date.now() + 180_000
while (Date.now() < deadline) {
await pollComfyHealth()
if (comfyOk.value || imageComfyOk.value) {
gpuWaking.value = false
return
}
await new Promise(resolve => setTimeout(resolve, 3000))
}
gpuWakeError.value = 'Started Comfy but it is not answering yet. If the instance picker is showing, open ComfyUI (1).'
} catch (error: any) {
gpuWakeError.value = error?.data?.statusMessage || error?.statusMessage || error?.message || 'Could not wake the GPU.'
} finally {
gpuWaking.value = false
}
}
onMounted(async () => {
try {
withSound.value = localStorage.getItem('aigen-generate-sound') !== 'false'
+141 -4
View File
@@ -10,6 +10,16 @@ const port = Number(process.env.COMFY_CONTROL_PORT || 8199)
const proxyPort = Number(process.env.COMFY_PROXY_PORT || 8198)
const token = process.env.COMFY_CONTROL_TOKEN || ''
const defaultHttp = (process.env.COMFY_HOST || 'http://127.0.0.1:8188').replace(/\/$/, '')
const idleMs = Math.max(60_000, Number(process.env.COMFY_IDLE_MS || 30 * 60 * 1000) || 30 * 60 * 1000)
let lastWorkAt = Date.now()
let lastQueueRunning = 0
let stoppedByAgent = false
function markWork() {
lastWorkAt = Date.now()
stoppedByAgent = false
}
function json(res, status, body) {
const payload = JSON.stringify(body)
@@ -110,7 +120,11 @@ function ensureProxy(targetPort) {
}
client.on('error', fail)
upstream.on('error', fail)
client.once('data', (chunk) => {
if (isWorkHttp(chunk)) markWork()
upstream.write(chunk)
client.pipe(upstream)
})
upstream.pipe(client)
})
proxyServer.on('error', (error) => {
@@ -151,6 +165,7 @@ function launchCommand() {
}
function startComfy() {
markWork()
const command = launchCommand()
const cwd = process.env.COMFY_LAUNCH_CWD || undefined
const child = spawn(command, { shell: true, detached: true, stdio: 'ignore', windowsHide: true, cwd })
@@ -158,6 +173,96 @@ function startComfy() {
return { started: true, command, cwd: cwd || null }
}
async function fetchLocalQueue(portNum) {
if (!portNum) return { running: 0, pending: 0, ok: false }
try {
const res = await fetch(`http://127.0.0.1:${portNum}/queue`, { signal: AbortSignal.timeout(2500) })
if (!res.ok) return { running: 0, pending: 0, ok: false }
const payload = await res.json()
return {
running: Array.isArray(payload?.queue_running) ? payload.queue_running.length : 0,
pending: Array.isArray(payload?.queue_pending) ? payload.queue_pending.length : 0,
ok: true
}
} catch {
return { running: 0, pending: 0, ok: false }
}
}
function isWorkHttp(chunk) {
const head = chunk.toString('latin1', 0, Math.min(chunk.length, 256))
const line = head.split('\r\n')[0] || ''
const match = line.match(/^(GET|HEAD|POST|PUT|PATCH|DELETE)\s+(\S+)/i)
if (!match) return false
const method = match[1].toUpperCase()
let path = String(match[2] || '').split('?')[0] || ''
try { path = decodeURIComponent(path) } catch { /* keep raw */ }
if (method === 'GET' || method === 'HEAD') return false
if (/^\/(system_stats|queue|object_info|internal)\b/i.test(path)) return false
return true
}
async function taskkillImage(image) {
try {
await execFileAsync('taskkill', ['/IM', image, '/F', '/T'], { windowsHide: true, timeout: 8000 })
return true
} catch {
return false
}
}
async function stopComfyProcesses() {
const killed = []
if (await taskkillImage('Comfy Desktop.exe')) killed.push('Comfy Desktop.exe')
if (await taskkillImage('ComfyUI.exe')) killed.push('ComfyUI.exe')
try {
const { stdout } = await execFileAsync('powershell.exe', [
'-NoProfile', '-Command',
"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'ComfyUI\\\\main.py|ComfyUI/main.py' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $_.ProcessId }"
], { windowsHide: true, timeout: 8000 })
if (String(stdout).trim()) killed.push('main.py')
} catch { /* ignore */ }
return killed
}
async function noteQueue(portNum) {
const queue = await fetchLocalQueue(portNum)
if (!queue.ok) return queue
if (queue.running > 0 || queue.pending > 0) {
markWork()
} else if (lastQueueRunning > 0 && queue.running === 0) {
lastWorkAt = Date.now()
}
lastQueueRunning = queue.running
return queue
}
async function maybeIdleStop(healthyPort) {
if (healthyPort) {
stoppedByAgent = false
const queue = await noteQueue(healthyPort)
if (!queue.ok) return
if (queue.running > 0 || queue.pending > 0) return
} else if (await processUp()) {
stoppedByAgent = false
} else {
stoppedByAgent = true
return
}
if (Date.now() - lastWorkAt < idleMs) return
console.log(JSON.stringify({
src: 'comfy-host-agent',
event: 'idle-stop',
idleMs,
lastActivityAt: new Date(lastWorkAt).toISOString(),
http: Boolean(healthyPort)
}))
const killed = await stopComfyProcesses()
stoppedByAgent = true
lastQueueRunning = 0
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'stopped', reason: 'idle', killed }))
}
function sharedRoots() {
const local = process.env.LOCALAPPDATA || ''
return {
@@ -274,10 +379,16 @@ const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', 'http://localhost')
if (req.method === 'GET' && url.pathname === '/status') {
const [healthyPort, process] = await Promise.all([syncProxy(), processUp()])
if (healthyPort) await noteQueue(healthyPort)
const processUpNow = Boolean(process || healthyPort)
return json(res, 200, {
ok: true,
http: Boolean(healthyPort),
process,
process: processUpNow,
processUp: processUpNow,
asleep: Boolean(stoppedByAgent || (!healthyPort && !process)),
lastActivityAt: new Date(lastWorkAt).toISOString(),
idleMs,
port: healthyPort || null,
proxyPort: healthyPort ? proxyPort : null
})
@@ -285,10 +396,28 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && url.pathname === '/start') {
const healthyPort = await syncProxy()
if (healthyPort) {
return json(res, 200, { ok: true, started: false, already: true, port: healthyPort, proxyPort })
markWork()
return json(res, 200, { ok: true, started: false, already: true, asleep: false, port: healthyPort, proxyPort })
}
const launched = startComfy()
return json(res, 200, { ok: true, ...launched })
return json(res, 200, { ok: true, asleep: false, ...launched })
}
if (req.method === 'POST' && url.pathname === '/stop') {
const healthyPort = await syncProxy()
const queue = healthyPort ? await fetchLocalQueue(healthyPort) : { running: 0, pending: 0, ok: false }
if (queue.ok && (queue.running > 0 || queue.pending > 0)) {
return json(res, 409, {
ok: false,
error: 'queue-busy',
running: queue.running,
pending: queue.pending
})
}
const killed = await stopComfyProcesses()
stoppedByAgent = true
lastQueueRunning = 0
console.log(JSON.stringify({ src: 'comfy-host-agent', event: 'stopped', reason: 'stop', killed }))
return json(res, 200, { ok: true, stopped: true, asleep: true, killed })
}
if (req.method === 'POST' && url.pathname === '/purge') {
const body = await readJson(req)
@@ -299,15 +428,23 @@ const server = http.createServer(async (req, res) => {
server.listen(port, '0.0.0.0', async () => {
const healthyPort = await syncProxy()
if (healthyPort) await noteQueue(healthyPort)
console.log(JSON.stringify({
src: 'comfy-host-agent',
event: 'listen',
port,
proxyPort,
comfyPort: healthyPort || null,
idleMs,
candidates: candidatePorts()
}))
let lastIdleCheck = 0
setInterval(() => {
syncProxy().catch(() => {})
syncProxy().then(async (nextPort) => {
const now = Date.now()
if (now - lastIdleCheck < 10_000) return
lastIdleCheck = now
await maybeIdleStop(nextPort)
}).catch(() => {})
}, 3000)
})
+11
View File
@@ -0,0 +1,11 @@
' Launch the Comfy host agent with no console window.
' Window style 0 = hidden. Wait = True so Task Scheduler keeps the task marked Running.
Option Explicit
Dim sh, root, node, agent
Set sh = CreateObject("WScript.Shell")
root = CreateObject("Scripting.FileSystemObject").GetParentFolderName(WScript.ScriptFullName)
root = CreateObject("Scripting.FileSystemObject").GetParentFolderName(root)
node = """C:\Program Files\nodejs\node.exe"""
agent = """" & root & "\scripts\comfy-host-agent.mjs"""
sh.CurrentDirectory = root
sh.Run node & " " & agent, 0, True
+3 -2
View File
@@ -1,3 +1,4 @@
@echo off
cd /d "%~dp0.."
node "%~dp0comfy-host-agent.mjs"
REM Starts the host agent hidden. Do not leave a Command Prompt open for this.
REM Agent stays running after idle-stop (default 30 min, override with COMFY_IDLE_MS).
start "" wscript.exe //B "%~dp0start-comfy-host-agent-hidden.vbs"
+5
View File
@@ -0,0 +1,5 @@
import { requestComfyWake } from '~/server/utils/comfyLifecycle'
export default defineEventHandler(async () => {
return await requestComfyWake()
})
+7 -3
View File
@@ -1,4 +1,4 @@
import { checkComfyHttp, fetchQueue } from '~/server/utils/comfyLifecycle'
import { checkComfyHttp, fetchQueue, fetchComfyControlStatus } from '~/server/utils/comfyLifecycle'
import { comfyConfigured } from '~/server/utils/comfy'
import { imageComfyConfigured, getBeastImageHost, getSidecarImageHost, sameImageHost } from '~/server/utils/imageComfy'
import { checkImageComfyHttp, fetchQueueAt } from '~/server/utils/imageComfyLifecycle'
@@ -9,7 +9,7 @@ export default defineEventHandler(async () => {
const beastHost = getBeastImageHost()
const promptHost = getSidecarImageHost()
const promptIsBeast = Boolean(promptHost && beastHost && sameImageHost(promptHost, beastHost))
const [comfy, queue, beast, beastQueue, prompt, promptQueue] = await Promise.all([
const [comfy, queue, beast, beastQueue, prompt, promptQueue, control] = await Promise.all([
videoConfigured
? checkComfyHttp().catch(() => ({ ok: false, host: '', stats: null }))
: Promise.resolve({ ok: false, host: '', stats: null }),
@@ -21,7 +21,8 @@ export default defineEventHandler(async () => {
promptHost && !promptIsBeast
? checkImageComfyHttp(5000, promptHost).catch(() => ({ ok: false, host: promptHost, stats: null }))
: Promise.resolve({ ok: false, host: promptHost, stats: null }),
promptHost && !promptIsBeast ? fetchQueueAt(promptHost) : Promise.resolve({ running: 0, pending: 0, ok: false })
promptHost && !promptIsBeast ? fetchQueueAt(promptHost) : Promise.resolve({ running: 0, pending: 0, ok: false }),
fetchComfyControlStatus().catch(() => null)
])
const comfyReachable = Boolean(comfy.ok || queue.ok)
const beastReachable = Boolean(beast.ok || beastQueue.ok || comfyReachable)
@@ -43,6 +44,9 @@ export default defineEventHandler(async () => {
host: comfy.host || '',
queueRunning: queue.running,
queuePending: queue.pending,
asleep: Boolean(control?.asleep) && !comfyReachable,
processUp: Boolean(control?.process ?? control?.processUp),
lastActivityAt: control?.lastActivityAt || null,
stats: comfy.ok ? comfy.stats : undefined
},
imageComfy: {
+42
View File
@@ -98,17 +98,59 @@ async function controlRequest(path: string, method = 'GET') {
ok?: boolean
http?: boolean
process?: boolean
processUp?: boolean
asleep?: boolean
lastActivityAt?: string | null
idleMs?: number
pid?: number
started?: boolean
already?: boolean
port?: number | null
proxyPort?: number | null
stopped?: boolean
error?: string
status?: number
}
} catch {
return null
}
}
export async function fetchComfyControlStatus() {
return await controlRequest('/status')
}
export async function requestComfyWake() {
const { controlUrl } = settings()
if (!controlUrl) {
throw createError({
statusCode: 503,
statusMessage: 'COMFY_CONTROL_URL is not set. The studio cannot poke the Beast host agent.'
})
}
const remote = await controlRequest('/start', 'POST')
if (!remote) {
throw createError({
statusCode: 503,
statusMessage: 'Beast host agent is unreachable. Confirm it is running on the desktop.'
})
}
if (remote.ok === false) {
throw createError({
statusCode: Number(remote.status) || 502,
statusMessage: 'Beast host agent refused to start Comfy.'
})
}
return {
ok: true,
started: Boolean(remote.started),
already: Boolean(remote.already),
asleep: false,
port: remote.port ?? null,
proxyPort: remote.proxyPort ?? null
}
}
async function findLocalComfyProcess() {
const platform = process.platform
try {