From 657a220b4a72febcd50e9b05cbf3817989393307 Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 22:55:33 +0200 Subject: [PATCH 01/17] Add GPU metrics polling via nvidia-smi --- backend/__init__.py | 0 backend/gpu_monitor.py | 48 ++++++++++++++++++++++++++++++++++++++ tests/test_gpu_monitor.py | 49 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 backend/__init__.py create mode 100644 backend/gpu_monitor.py create mode 100644 tests/test_gpu_monitor.py diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/gpu_monitor.py b/backend/gpu_monitor.py new file mode 100644 index 0000000..14d2df9 --- /dev/null +++ b/backend/gpu_monitor.py @@ -0,0 +1,48 @@ +"""Polls NVIDIA GPU metrics via nvidia-smi. No third-party dependencies.""" + +import subprocess +from dataclasses import dataclass + + +@dataclass +class GpuMetrics: + temp_c: float | None + vram_used_mb: float | None + vram_total_mb: float | None + util_pct: float | None + + @property + def vram_free_mb(self) -> float | None: + if self.vram_used_mb is None or self.vram_total_mb is None: + return None + return self.vram_total_mb - self.vram_used_mb + + +_EMPTY = GpuMetrics(temp_c=None, vram_used_mb=None, vram_total_mb=None, util_pct=None) + + +def poll_gpu_metrics(timeout: float = 5.0) -> GpuMetrics: + try: + result = subprocess.run( + [ + "nvidia-smi", + "--query-gpu=temperature.gpu,memory.used,memory.total,utilization.gpu", + "--format=csv,noheader,nounits", + ], + capture_output=True, + text=True, + timeout=timeout, + ) + if result.returncode != 0: + return _EMPTY + + first_line = result.stdout.strip().splitlines()[0] + temp_s, used_s, total_s, util_s = (p.strip() for p in first_line.split(",")) + return GpuMetrics( + temp_c=float(temp_s), + vram_used_mb=float(used_s), + vram_total_mb=float(total_s), + util_pct=float(util_s), + ) + except Exception: + return _EMPTY diff --git a/tests/test_gpu_monitor.py b/tests/test_gpu_monitor.py new file mode 100644 index 0000000..55bb3ac --- /dev/null +++ b/tests/test_gpu_monitor.py @@ -0,0 +1,49 @@ +import subprocess +from unittest.mock import patch, MagicMock + +from backend.gpu_monitor import GpuMetrics, poll_gpu_metrics + + +def test_vram_free_mb_computed_from_used_and_total(): + metrics = GpuMetrics(temp_c=60.0, vram_used_mb=2000.0, vram_total_mb=8000.0, util_pct=10.0) + assert metrics.vram_free_mb == 6000.0 + + +def test_vram_free_mb_none_when_data_missing(): + metrics = GpuMetrics(temp_c=None, vram_used_mb=None, vram_total_mb=None, util_pct=None) + assert metrics.vram_free_mb is None + + +@patch("backend.gpu_monitor.subprocess.run") +def test_poll_parses_nvidia_smi_csv_output(mock_run): + mock_run.return_value = MagicMock( + returncode=0, + stdout="62, 2048, 8192, 15\n", + ) + metrics = poll_gpu_metrics() + assert metrics.temp_c == 62.0 + assert metrics.vram_used_mb == 2048.0 + assert metrics.vram_total_mb == 8192.0 + assert metrics.util_pct == 15.0 + + +@patch("backend.gpu_monitor.subprocess.run") +def test_poll_returns_all_none_when_nvidia_smi_missing(mock_run): + mock_run.side_effect = FileNotFoundError("nvidia-smi not found") + metrics = poll_gpu_metrics() + assert metrics.temp_c is None + assert metrics.vram_free_mb is None + + +@patch("backend.gpu_monitor.subprocess.run") +def test_poll_returns_all_none_on_nonzero_exit(mock_run): + mock_run.return_value = MagicMock(returncode=1, stdout="") + metrics = poll_gpu_metrics() + assert metrics.temp_c is None + + +@patch("backend.gpu_monitor.subprocess.run") +def test_poll_returns_all_none_on_timeout(mock_run): + mock_run.side_effect = subprocess.TimeoutExpired(cmd="nvidia-smi", timeout=5.0) + metrics = poll_gpu_metrics() + assert metrics.temp_c is None From 661e194c2aaf27faf92f93817b9462b7799213c3 Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 22:56:05 +0200 Subject: [PATCH 02/17] Add autopilot rule engine as a pure, testable function --- backend/autopilot.py | 63 +++++++++++++++++++++++++++ tests/test_autopilot.py | 94 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 backend/autopilot.py create mode 100644 tests/test_autopilot.py diff --git a/backend/autopilot.py b/backend/autopilot.py new file mode 100644 index 0000000..95c566a --- /dev/null +++ b/backend/autopilot.py @@ -0,0 +1,63 @@ +"""Pure rule-engine: GPU metrics + queue state -> pause/resume decision. + +No I/O here. This module never touches the network, the filesystem, or a +subprocess — that keeps it trivially unit-testable and lets the fail-open +guarantee live entirely in the caller (backend.autopilot_loop). +""" + +from dataclasses import dataclass + +from .gpu_monitor import GpuMetrics + + +@dataclass +class AutopilotSettings: + master_enabled: bool = True + + temp_rule_enabled: bool = True + pause_temp_c: float = 80.0 + resume_temp_c: float = 72.0 + + vram_rule_enabled: bool = True + min_free_vram_mb: float = 1024.0 + + job_count_rule_enabled: bool = True + max_jobs_before_pause: int = 20 + + def update_from_dict(self, values: dict) -> None: + """Mutates in place so callers holding a reference (the background + loop, the middleware's is_enabled closure) see updates immediately.""" + for key, value in values.items(): + if hasattr(self, key): + setattr(self, key, value) + + +@dataclass +class Decision: + should_pause: bool + reasons: tuple[str, ...] + + +def evaluate( + metrics: GpuMetrics, + jobs_since_resume: int, + currently_paused: bool, + settings: AutopilotSettings, +) -> Decision: + reasons: list[str] = [] + + if settings.temp_rule_enabled and metrics.temp_c is not None: + threshold = settings.resume_temp_c if currently_paused else settings.pause_temp_c + if metrics.temp_c > threshold: + reasons.append(f"GPU at {metrics.temp_c:.0f}C, target {threshold:.0f}C") + + if settings.vram_rule_enabled and metrics.vram_free_mb is not None: + if metrics.vram_free_mb < settings.min_free_vram_mb: + reasons.append( + f"Only {metrics.vram_free_mb:.0f}MB VRAM free, below {settings.min_free_vram_mb:.0f}MB" + ) + + if settings.job_count_rule_enabled and jobs_since_resume >= settings.max_jobs_before_pause: + reasons.append(f"{jobs_since_resume} jobs run since last pause, cooldown break") + + return Decision(should_pause=len(reasons) > 0, reasons=tuple(reasons)) diff --git a/tests/test_autopilot.py b/tests/test_autopilot.py new file mode 100644 index 0000000..6040bf3 --- /dev/null +++ b/tests/test_autopilot.py @@ -0,0 +1,94 @@ +from backend.gpu_monitor import GpuMetrics +from backend.autopilot import AutopilotSettings, evaluate + + +def make_metrics(temp_c=50.0, vram_used_mb=1000.0, vram_total_mb=8000.0, util_pct=5.0): + return GpuMetrics(temp_c=temp_c, vram_used_mb=vram_used_mb, vram_total_mb=vram_total_mb, util_pct=util_pct) + + +def test_no_rules_triggered_returns_no_pause(): + settings = AutopilotSettings() + decision = evaluate(make_metrics(), jobs_since_resume=0, currently_paused=False, settings=settings) + assert decision.should_pause is False + assert decision.reasons == () + + +def test_temp_above_pause_threshold_triggers_pause(): + settings = AutopilotSettings(pause_temp_c=80.0, resume_temp_c=72.0) + decision = evaluate(make_metrics(temp_c=85.0), jobs_since_resume=0, currently_paused=False, settings=settings) + assert decision.should_pause is True + assert "85" in decision.reasons[0] + + +def test_temp_hysteresis_keeps_paused_until_resume_threshold(): + settings = AutopilotSettings(pause_temp_c=80.0, resume_temp_c=72.0) + # already paused, temp dropped below pause_temp_c but not yet below resume_temp_c + decision = evaluate(make_metrics(temp_c=75.0), jobs_since_resume=0, currently_paused=True, settings=settings) + assert decision.should_pause is True + + +def test_temp_hysteresis_resumes_once_below_resume_threshold(): + settings = AutopilotSettings(pause_temp_c=80.0, resume_temp_c=72.0) + decision = evaluate(make_metrics(temp_c=70.0), jobs_since_resume=0, currently_paused=True, settings=settings) + assert decision.should_pause is False + + +def test_temp_rule_disabled_never_pauses_on_temp(): + settings = AutopilotSettings(temp_rule_enabled=False, pause_temp_c=80.0) + decision = evaluate(make_metrics(temp_c=99.0), jobs_since_resume=0, currently_paused=False, settings=settings) + assert decision.should_pause is False + + +def test_low_free_vram_triggers_pause(): + settings = AutopilotSettings(min_free_vram_mb=1024.0) + decision = evaluate( + make_metrics(vram_used_mb=7800.0, vram_total_mb=8000.0), + jobs_since_resume=0, currently_paused=False, settings=settings, + ) + assert decision.should_pause is True + assert "VRAM" in decision.reasons[0] + + +def test_vram_rule_disabled_never_pauses_on_vram(): + settings = AutopilotSettings(vram_rule_enabled=False, min_free_vram_mb=1024.0) + decision = evaluate( + make_metrics(vram_used_mb=7999.0, vram_total_mb=8000.0), + jobs_since_resume=0, currently_paused=False, settings=settings, + ) + assert decision.should_pause is False + + +def test_job_count_at_or_above_max_triggers_pause(): + settings = AutopilotSettings(max_jobs_before_pause=20) + decision = evaluate(make_metrics(), jobs_since_resume=20, currently_paused=False, settings=settings) + assert decision.should_pause is True + assert "20" in decision.reasons[0] + + +def test_job_count_rule_disabled_never_pauses_on_count(): + settings = AutopilotSettings(job_count_rule_enabled=False, max_jobs_before_pause=1) + decision = evaluate(make_metrics(), jobs_since_resume=999, currently_paused=False, settings=settings) + assert decision.should_pause is False + + +def test_missing_gpu_data_does_not_trigger_temp_or_vram_rules(): + settings = AutopilotSettings() + metrics = GpuMetrics(temp_c=None, vram_used_mb=None, vram_total_mb=None, util_pct=None) + decision = evaluate(metrics, jobs_since_resume=0, currently_paused=False, settings=settings) + assert decision.should_pause is False + + +def test_update_from_dict_mutates_matching_fields_only(): + settings = AutopilotSettings() + settings.update_from_dict({"pause_temp_c": 90.0, "not_a_real_field": 123}) + assert settings.pause_temp_c == 90.0 + assert not hasattr(settings, "not_a_real_field") + + +def test_multiple_reasons_all_reported(): + settings = AutopilotSettings(pause_temp_c=80.0, min_free_vram_mb=1024.0) + decision = evaluate( + make_metrics(temp_c=90.0, vram_used_mb=7800.0, vram_total_mb=8000.0), + jobs_since_resume=0, currently_paused=False, settings=settings, + ) + assert len(decision.reasons) == 2 From 0883ed296578f20d2c67175d228f28080ed5eebc Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 22:56:20 +0200 Subject: [PATCH 03/17] Add autopilot state machine with job-counter reset on resume --- backend/autopilot_state.py | 20 ++++++++++++++++++ tests/test_autopilot_state.py | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 backend/autopilot_state.py create mode 100644 tests/test_autopilot_state.py diff --git a/backend/autopilot_state.py b/backend/autopilot_state.py new file mode 100644 index 0000000..3a7108b --- /dev/null +++ b/backend/autopilot_state.py @@ -0,0 +1,20 @@ +"""In-memory autopilot state, mutated by the polling loop and read by the middleware.""" + +from .autopilot import Decision + + +class AutopilotState: + def __init__(self) -> None: + self.is_paused: bool = False + self.jobs_since_resume: int = 0 + self.last_reasons: tuple[str, ...] = () + + def apply(self, decision: Decision) -> None: + was_paused = self.is_paused + self.is_paused = decision.should_pause + self.last_reasons = decision.reasons + if was_paused and not self.is_paused: + self.jobs_since_resume = 0 + + def record_job_started(self) -> None: + self.jobs_since_resume += 1 diff --git a/tests/test_autopilot_state.py b/tests/test_autopilot_state.py new file mode 100644 index 0000000..0a72a05 --- /dev/null +++ b/tests/test_autopilot_state.py @@ -0,0 +1,39 @@ +from backend.autopilot import Decision +from backend.autopilot_state import AutopilotState + + +def test_initial_state_is_not_paused(): + state = AutopilotState() + assert state.is_paused is False + assert state.jobs_since_resume == 0 + + +def test_apply_pause_decision_sets_paused_and_stores_reasons(): + state = AutopilotState() + state.apply(Decision(should_pause=True, reasons=("too hot",))) + assert state.is_paused is True + assert state.last_reasons == ("too hot",) + + +def test_apply_resume_decision_after_pause_resets_job_counter(): + state = AutopilotState() + state.record_job_started() + state.record_job_started() + state.apply(Decision(should_pause=True, reasons=("cooldown",))) + state.apply(Decision(should_pause=False, reasons=())) + assert state.is_paused is False + assert state.jobs_since_resume == 0 + + +def test_record_job_started_increments_counter(): + state = AutopilotState() + state.record_job_started() + state.record_job_started() + assert state.jobs_since_resume == 2 + + +def test_apply_resume_when_already_resumed_does_not_reset_counter(): + state = AutopilotState() + state.record_job_started() + state.apply(Decision(should_pause=False, reasons=())) + assert state.jobs_since_resume == 1 From 6f3e46c6baab3b31a615269844a37e80725c4337 Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 22:56:45 +0200 Subject: [PATCH 04/17] Add SQLite persistence for queue order and history --- backend/persistence.py | 75 +++++++++++++++++++++++++++++++++++++++ tests/test_persistence.py | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 backend/persistence.py create mode 100644 tests/test_persistence.py diff --git a/backend/persistence.py b/backend/persistence.py new file mode 100644 index 0000000..d0c401e --- /dev/null +++ b/backend/persistence.py @@ -0,0 +1,75 @@ +"""SQLite-backed queue order + history. Survives ComfyUI restarts.""" + +import sqlite3 +from datetime import datetime, timezone + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS queue_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + order_index INTEGER NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + prompt_id TEXT NOT NULL, + name TEXT NOT NULL, + thumbnail_path TEXT, + completed_at TEXT NOT NULL +); +""" + + +def init_db(db_path: str) -> sqlite3.Connection: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.executescript(_SCHEMA) + conn.commit() + return conn + + +def add_queue_item(conn: sqlite3.Connection, prompt_id: str, name: str) -> int: + row = conn.execute("SELECT COALESCE(MAX(order_index), -1) + 1 AS next FROM queue_items").fetchone() + next_index = row["next"] + cursor = conn.execute( + "INSERT INTO queue_items (prompt_id, name, status, order_index, created_at) VALUES (?, ?, 'pending', ?, ?)", + (prompt_id, name, next_index, datetime.now(timezone.utc).isoformat()), + ) + conn.commit() + return cursor.lastrowid + + +def list_queue_items(conn: sqlite3.Connection) -> list[dict]: + rows = conn.execute("SELECT * FROM queue_items ORDER BY order_index ASC").fetchall() + return [dict(row) for row in rows] + + +def reorder_queue_items(conn: sqlite3.Connection, ordered_prompt_ids: list[str]) -> None: + for index, prompt_id in enumerate(ordered_prompt_ids): + conn.execute("UPDATE queue_items SET order_index = ? WHERE prompt_id = ?", (index, prompt_id)) + conn.commit() + + +def remove_queue_item(conn: sqlite3.Connection, prompt_id: str) -> None: + conn.execute("DELETE FROM queue_items WHERE prompt_id = ?", (prompt_id,)) + conn.commit() + + +def mark_completed(conn: sqlite3.Connection, prompt_id: str, thumbnail_path: str | None = None) -> None: + row = conn.execute("SELECT name FROM queue_items WHERE prompt_id = ?", (prompt_id,)).fetchone() + name = row["name"] if row else prompt_id + conn.execute( + "INSERT INTO history (prompt_id, name, thumbnail_path, completed_at) VALUES (?, ?, ?, ?)", + (prompt_id, name, thumbnail_path, datetime.now(timezone.utc).isoformat()), + ) + conn.execute("DELETE FROM queue_items WHERE prompt_id = ?", (prompt_id,)) + conn.commit() + + +def list_history(conn: sqlite3.Connection, limit: int = 50) -> list[dict]: + rows = conn.execute( + "SELECT * FROM history ORDER BY completed_at DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(row) for row in rows] diff --git a/tests/test_persistence.py b/tests/test_persistence.py new file mode 100644 index 0000000..6448a38 --- /dev/null +++ b/tests/test_persistence.py @@ -0,0 +1,64 @@ +from backend.persistence import ( + init_db, + add_queue_item, + list_queue_items, + reorder_queue_items, + remove_queue_item, + mark_completed, + list_history, +) + + +def test_init_db_creates_empty_tables(tmp_path): + conn = init_db(str(tmp_path / "test.sqlite3")) + assert list_queue_items(conn) == [] + assert list_history(conn) == [] + + +def test_add_queue_item_appears_in_order(tmp_path): + conn = init_db(str(tmp_path / "test.sqlite3")) + add_queue_item(conn, prompt_id="a", name="Cyberpunk Cat") + add_queue_item(conn, prompt_id="b", name="Sunset Beach") + items = list_queue_items(conn) + assert [item["prompt_id"] for item in items] == ["a", "b"] + assert items[0]["name"] == "Cyberpunk Cat" + assert items[0]["status"] == "pending" + + +def test_reorder_queue_items_changes_returned_order(tmp_path): + conn = init_db(str(tmp_path / "test.sqlite3")) + add_queue_item(conn, prompt_id="a", name="First") + add_queue_item(conn, prompt_id="b", name="Second") + reorder_queue_items(conn, ordered_prompt_ids=["b", "a"]) + items = list_queue_items(conn) + assert [item["prompt_id"] for item in items] == ["b", "a"] + + +def test_remove_queue_item_deletes_it(tmp_path): + conn = init_db(str(tmp_path / "test.sqlite3")) + add_queue_item(conn, prompt_id="a", name="First") + remove_queue_item(conn, prompt_id="a") + assert list_queue_items(conn) == [] + + +def test_mark_completed_moves_item_into_history(tmp_path): + conn = init_db(str(tmp_path / "test.sqlite3")) + add_queue_item(conn, prompt_id="a", name="First") + mark_completed(conn, prompt_id="a", thumbnail_path="/thumbs/a.png") + assert list_queue_items(conn) == [] + history = list_history(conn) + assert len(history) == 1 + assert history[0]["prompt_id"] == "a" + assert history[0]["thumbnail_path"] == "/thumbs/a.png" + + +def test_persistence_survives_reopening_the_same_file(tmp_path): + db_path = str(tmp_path / "test.sqlite3") + conn1 = init_db(db_path) + add_queue_item(conn1, prompt_id="a", name="First") + conn1.close() + + conn2 = init_db(db_path) + items = list_queue_items(conn2) + assert len(items) == 1 + assert items[0]["prompt_id"] == "a" From 4b33b815dfc6b4abbc65851ae3681d8d01dcd6d0 Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 22:57:42 +0200 Subject: [PATCH 05/17] Add non-intrusive middleware that gates /prompt while paused --- backend/queue_middleware.py | 18 +++++++++++ tests/test_queue_middleware.py | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 backend/queue_middleware.py create mode 100644 tests/test_queue_middleware.py diff --git a/backend/queue_middleware.py b/backend/queue_middleware.py new file mode 100644 index 0000000..854a371 --- /dev/null +++ b/backend/queue_middleware.py @@ -0,0 +1,18 @@ +"""Non-intrusive middleware: never replaces /prompt, only gates it while paused.""" + +from typing import Callable + +from aiohttp import web + +from .autopilot_state import AutopilotState + + +def create_queue_middleware(state: AutopilotState, is_enabled: Callable[[], bool]): + @web.middleware + async def queue_middleware(request: web.Request, handler): + if is_enabled() and state.is_paused and request.path == "/prompt" and request.method == "POST": + reason = "; ".join(state.last_reasons) or "Queue paused" + return web.json_response({"error": f"Queue paused: {reason}"}, status=423) + return await handler(request) + + return queue_middleware diff --git a/tests/test_queue_middleware.py b/tests/test_queue_middleware.py new file mode 100644 index 0000000..54a931b --- /dev/null +++ b/tests/test_queue_middleware.py @@ -0,0 +1,55 @@ +import pytest +from aiohttp import web +from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop + +from backend.autopilot_state import AutopilotState +from backend.autopilot import Decision +from backend.queue_middleware import create_queue_middleware + + +async def _make_app(state: AutopilotState, enabled: bool = True): + middleware = create_queue_middleware(state, is_enabled=lambda: enabled) + app = web.Application(middlewares=[middleware]) + + async def handle_prompt(request): + return web.json_response({"ok": True}) + + app.router.add_post("/prompt", handle_prompt) + return app + + +class TestQueueMiddlewarePassthrough(AioHTTPTestCase): + async def get_application(self): + self.state = AutopilotState() + return await _make_app(self.state, enabled=True) + + @unittest_run_loop + async def test_passes_through_when_not_paused(self): + resp = await self.client.post("/prompt", json={}) + assert resp.status == 200 + + +class TestQueueMiddlewareBlocking(AioHTTPTestCase): + async def get_application(self): + self.state = AutopilotState() + self.state.apply(Decision(should_pause=True, reasons=("GPU too hot",))) + return await _make_app(self.state, enabled=True) + + @unittest_run_loop + async def test_blocks_with_423_when_paused(self): + resp = await self.client.post("/prompt", json={}) + assert resp.status == 423 + body = await resp.json() + assert "GPU too hot" in body["error"] + + +class TestQueueMiddlewareDisabled(AioHTTPTestCase): + async def get_application(self): + self.state = AutopilotState() + self.state.apply(Decision(should_pause=True, reasons=("GPU too hot",))) + return await _make_app(self.state, enabled=False) + + @unittest_run_loop + async def test_passes_through_when_master_toggle_disabled_even_if_paused(self): + resp = await self.client.post("/prompt", json={}) + assert resp.status == 200 From 902a9fd67cb827c8cad0cf54fb3c1db85b2a2e75 Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 22:58:34 +0200 Subject: [PATCH 06/17] Add background autopilot polling tick with fail-open error handling Also add aiohttp to CI install (needed since Task 5's middleware tests, plan only called this out for pytest-asyncio in Task 6). --- .github/workflows/ci.yml | 2 +- backend/autopilot_loop.py | 36 +++++++++++++++++++++++++++++++ pytest.ini | 2 ++ tests/test_autopilot_loop.py | 42 ++++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 backend/autopilot_loop.py create mode 100644 pytest.ini create mode 100644 tests/test_autopilot_loop.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13e6e59..869d6e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,5 +13,5 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install pytest + - run: pip install pytest pytest-asyncio aiohttp - run: pytest tests/ -v diff --git a/backend/autopilot_loop.py b/backend/autopilot_loop.py new file mode 100644 index 0000000..64eeaf6 --- /dev/null +++ b/backend/autopilot_loop.py @@ -0,0 +1,36 @@ +"""Background polling: one tick = poll GPU metrics, evaluate rules, apply decision. + +Fail-open: if metrics_provider raises, the tick is a no-op — it never leaves +or forces the queue into a paused state due to an internal error. +""" + +import logging +from typing import Awaitable, Callable + +from .autopilot import AutopilotSettings, evaluate +from .autopilot_state import AutopilotState +from .gpu_monitor import GpuMetrics + +logger = logging.getLogger(__name__) + +MetricsProvider = Callable[[], Awaitable[GpuMetrics]] + + +async def run_autopilot_tick( + state: AutopilotState, + settings: AutopilotSettings, + metrics_provider: MetricsProvider, +) -> None: + try: + metrics = await metrics_provider() + except Exception: + logger.warning("Smart Queue: GPU metrics poll failed, skipping this tick", exc_info=True) + return + + decision = evaluate( + metrics, + jobs_since_resume=state.jobs_since_resume, + currently_paused=state.is_paused, + settings=settings, + ) + state.apply(decision) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2f4c80e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +asyncio_mode = auto diff --git a/tests/test_autopilot_loop.py b/tests/test_autopilot_loop.py new file mode 100644 index 0000000..fab3904 --- /dev/null +++ b/tests/test_autopilot_loop.py @@ -0,0 +1,42 @@ +import pytest + +from backend.gpu_monitor import GpuMetrics +from backend.autopilot import AutopilotSettings +from backend.autopilot_state import AutopilotState +from backend.autopilot_loop import run_autopilot_tick + + +@pytest.mark.asyncio +async def test_tick_pauses_state_when_metrics_exceed_threshold(): + state = AutopilotState() + settings = AutopilotSettings(pause_temp_c=80.0) + + async def fake_metrics(): + return GpuMetrics(temp_c=90.0, vram_used_mb=1000.0, vram_total_mb=8000.0, util_pct=10.0) + + await run_autopilot_tick(state, settings, fake_metrics) + assert state.is_paused is True + + +@pytest.mark.asyncio +async def test_tick_leaves_state_unpaused_when_metrics_are_fine(): + state = AutopilotState() + settings = AutopilotSettings(pause_temp_c=80.0) + + async def fake_metrics(): + return GpuMetrics(temp_c=50.0, vram_used_mb=1000.0, vram_total_mb=8000.0, util_pct=10.0) + + await run_autopilot_tick(state, settings, fake_metrics) + assert state.is_paused is False + + +@pytest.mark.asyncio +async def test_tick_fails_open_when_metrics_provider_raises(): + state = AutopilotState() + settings = AutopilotSettings(pause_temp_c=80.0) + + async def broken_metrics(): + raise RuntimeError("nvidia-smi exploded") + + await run_autopilot_tick(state, settings, broken_metrics) + assert state.is_paused is False From 856988a0b2d3b464b1f0745c36e87d583428ec64 Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 22:59:52 +0200 Subject: [PATCH 07/17] Add Smart Cooldown & Pause node (V3 schema, testable core logic) SmartCooldownNode falls back to a plain object base when comfy_api isn't installed, so it stays importable for __init__.py's smoke test outside a real ComfyUI process (plan only wrapped the io import, not the class). --- backend/nodes/__init__.py | 0 backend/nodes/cooldown.py | 113 ++++++++++++++++++++++++++++++++++++ tests/test_cooldown_node.py | 95 ++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 backend/nodes/__init__.py create mode 100644 backend/nodes/cooldown.py create mode 100644 tests/test_cooldown_node.py diff --git a/backend/nodes/__init__.py b/backend/nodes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/nodes/cooldown.py b/backend/nodes/cooldown.py new file mode 100644 index 0000000..5e7b9b9 --- /dev/null +++ b/backend/nodes/cooldown.py @@ -0,0 +1,113 @@ +"""Smart Cooldown & Pause node (V3 schema). Node type ID stays RubzGpuCooldownNode +for backward compatibility with workflows saved against the old rubz-gpu-cooldown pack. +""" + +from typing import Callable + +try: + from comfy_api.latest import io # type: ignore[import-not-found] + _HAS_COMFY_IO = True +except ImportError: + io = None # type: ignore[assignment] + _HAS_COMFY_IO = False + +from ..gpu_monitor import GpuMetrics, poll_gpu_metrics + + +def run_cooldown( + fixed_delay_seconds: float, + wait_for_temp: bool, + target_temp_c: float, + poll_interval_seconds: float, + max_wait_seconds: float, + unload_models_before_wait: bool, + sleep_fn: Callable[[float], None], + metrics_fn: Callable[[], GpuMetrics], + unload_fn: Callable[[], None], + clock_fn: Callable[[], float] | None = None, +) -> str: + log: list[str] = [] + + if unload_models_before_wait: + unload_fn() + log.append("Unloaded all models") + + if fixed_delay_seconds > 0: + sleep_fn(fixed_delay_seconds) + log.append(f"Fixed delay: {fixed_delay_seconds:.0f}s") + + if wait_for_temp: + metrics = metrics_fn() + if metrics.temp_c is None: + log.append("GPU temp unavailable (nvidia-smi not found or failed) — skipping temp wait.") + else: + elapsed = 0.0 + log.append(f"Start temp: {metrics.temp_c:.0f}C, target: {target_temp_c:.0f}C") + while metrics.temp_c is not None and metrics.temp_c > target_temp_c: + if elapsed >= max_wait_seconds: + log.append(f"Max wait ({max_wait_seconds:.0f}s) reached at {metrics.temp_c:.0f}C — continuing anyway.") + break + sleep_fn(poll_interval_seconds) + elapsed += poll_interval_seconds + metrics = metrics_fn() + if metrics.temp_c is not None: + log.append(f" -> {metrics.temp_c:.0f}C") + else: + if metrics.temp_c is not None: + log.append(f"Reached target: {metrics.temp_c:.0f}C") + + return " | ".join(log) if log else "No wait configured." + + +# Node class must stay importable even when comfy_api isn't installed (e.g. under +# pytest, outside a real ComfyUI process) so `from .backend.nodes.cooldown import +# SmartCooldownNode` in __init__.py never breaks the module-load smoke test. +_NodeBase = io.ComfyNode if _HAS_COMFY_IO else object + + +class SmartCooldownNode(_NodeBase): + @classmethod + def define_schema(cls): + if not _HAS_COMFY_IO: + raise RuntimeError("comfy_api is not available in this environment") + return io.Schema( + node_id="RubzGpuCooldownNode", + display_name="Smart Cooldown & Pause", + category="utils", + inputs=[ + io.Float.Input("fixed_delay_seconds", default=30.0, min=0.0, max=3600.0, step=1.0), + io.Boolean.Input("wait_for_temp", default=True), + io.Float.Input("target_temp_c", default=65.0, min=30.0, max=100.0, step=1.0), + io.Float.Input("poll_interval_seconds", default=5.0, min=1.0, max=60.0, step=1.0), + io.Float.Input("max_wait_seconds", default=300.0, min=0.0, max=3600.0, step=10.0), + io.Boolean.Input("unload_models_before_wait", default=False), + io.Boolean.Input("wait_for_click", default=False), + io.Boolean.Input("notify_sound", default=False), + io.Combo.Input("notify_sound_choice", options=["Default", "Chime", "Alert", "Custom..."], default="Default"), + io.String.Input("custom_sound_path", default="", optional=True), + io.Boolean.Input("notify_toast", default=False), + io.AnyType.Input("passthrough", optional=True), + ], + outputs=[ + io.AnyType.Output("passthrough"), + io.String.Output("status"), + ], + is_output_node=True, + ) + + @classmethod + def execute(cls, **kwargs): + import comfy.model_management as model_management + + status = run_cooldown( + fixed_delay_seconds=kwargs["fixed_delay_seconds"], + wait_for_temp=kwargs["wait_for_temp"], + target_temp_c=kwargs["target_temp_c"], + poll_interval_seconds=kwargs["poll_interval_seconds"], + max_wait_seconds=kwargs["max_wait_seconds"], + unload_models_before_wait=kwargs["unload_models_before_wait"], + sleep_fn=__import__("time").sleep, + metrics_fn=poll_gpu_metrics, + unload_fn=model_management.unload_all_models, + ) + return io.NodeOutput(kwargs.get("passthrough"), status) diff --git a/tests/test_cooldown_node.py b/tests/test_cooldown_node.py new file mode 100644 index 0000000..53c54e9 --- /dev/null +++ b/tests/test_cooldown_node.py @@ -0,0 +1,95 @@ +from backend.gpu_monitor import GpuMetrics +from backend.nodes.cooldown import run_cooldown + + +def test_fixed_delay_is_applied(): + sleep_calls = [] + status = run_cooldown( + fixed_delay_seconds=10.0, + wait_for_temp=False, + target_temp_c=65.0, + poll_interval_seconds=5.0, + max_wait_seconds=300.0, + unload_models_before_wait=False, + sleep_fn=sleep_calls.append, + metrics_fn=lambda: GpuMetrics(70.0, 1000.0, 8000.0, 10.0), + unload_fn=lambda: None, + ) + assert sleep_calls == [10.0] + assert "Fixed delay: 10s" in status + + +def test_unload_models_called_before_wait_when_enabled(): + unload_calls = [] + run_cooldown( + fixed_delay_seconds=0.0, + wait_for_temp=False, + target_temp_c=65.0, + poll_interval_seconds=5.0, + max_wait_seconds=300.0, + unload_models_before_wait=True, + sleep_fn=lambda s: None, + metrics_fn=lambda: GpuMetrics(70.0, 1000.0, 8000.0, 10.0), + unload_fn=lambda: unload_calls.append(True), + ) + assert unload_calls == [True] + + +def test_waits_for_temp_until_below_target(): + temps = iter([90.0, 80.0, 60.0]) + sleep_calls = [] + status = run_cooldown( + fixed_delay_seconds=0.0, + wait_for_temp=True, + target_temp_c=65.0, + poll_interval_seconds=5.0, + max_wait_seconds=300.0, + unload_models_before_wait=False, + sleep_fn=sleep_calls.append, + metrics_fn=lambda: GpuMetrics(next(temps), 1000.0, 8000.0, 10.0), + unload_fn=lambda: None, + ) + assert sleep_calls == [5.0, 5.0] + assert "60" in status + + +def test_temp_unavailable_skips_temp_wait(): + status = run_cooldown( + fixed_delay_seconds=0.0, + wait_for_temp=True, + target_temp_c=65.0, + poll_interval_seconds=5.0, + max_wait_seconds=300.0, + unload_models_before_wait=False, + sleep_fn=lambda s: None, + metrics_fn=lambda: GpuMetrics(None, None, None, None), + unload_fn=lambda: None, + ) + assert "unavailable" in status.lower() + + +def test_max_wait_seconds_caps_the_temp_wait(): + elapsed = {"total": 0.0} + + def fake_sleep(seconds): + elapsed["total"] += seconds + + status = run_cooldown( + fixed_delay_seconds=0.0, + wait_for_temp=True, + target_temp_c=10.0, # unreachably low, forces the cap + poll_interval_seconds=5.0, + max_wait_seconds=12.0, + unload_models_before_wait=False, + sleep_fn=fake_sleep, + metrics_fn=lambda: GpuMetrics(90.0, 1000.0, 8000.0, 10.0), + unload_fn=lambda: None, + clock_fn=_make_fake_clock(elapsed), + ) + assert "Max wait" in status + + +def _make_fake_clock(elapsed): + def clock(): + return elapsed["total"] + return clock From 5241e0b61971fe40e434bebdf13f302f6aa635cc Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 23:00:19 +0200 Subject: [PATCH 08/17] Add HTTP routes for the Smart Queue panel --- backend/routes.py | 47 ++++++++++++++++++++++++++++++ tests/test_routes.py | 69 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 backend/routes.py create mode 100644 tests/test_routes.py diff --git a/backend/routes.py b/backend/routes.py new file mode 100644 index 0000000..ea947cf --- /dev/null +++ b/backend/routes.py @@ -0,0 +1,47 @@ +"""HTTP endpoints for the Smart Queue panel. Additive only — never overrides +a native ComfyUI route.""" + +import sqlite3 +from dataclasses import asdict + +from aiohttp import web + +from .autopilot import AutopilotSettings +from .autopilot_state import AutopilotState +from .persistence import list_history, list_queue_items, reorder_queue_items + + +def register_routes( + app: web.Application, + conn: sqlite3.Connection, + state: AutopilotState, + settings: AutopilotSettings, +) -> None: + async def get_status(request: web.Request) -> web.Response: + return web.json_response({"is_paused": state.is_paused, "reasons": list(state.last_reasons)}) + + async def get_queue(request: web.Request) -> web.Response: + return web.json_response({"items": list_queue_items(conn)}) + + async def post_reorder(request: web.Request) -> web.Response: + payload = await request.json() + reorder_queue_items(conn, payload["ordered_prompt_ids"]) + return web.json_response({"ok": True}) + + async def get_history(request: web.Request) -> web.Response: + return web.json_response({"items": list_history(conn)}) + + async def get_settings(request: web.Request) -> web.Response: + return web.json_response(asdict(settings)) + + async def post_settings(request: web.Request) -> web.Response: + payload = await request.json() + settings.update_from_dict(payload) + return web.json_response({"ok": True}) + + app.router.add_get("/smart_queue/status", get_status) + app.router.add_get("/smart_queue/queue", get_queue) + app.router.add_post("/smart_queue/reorder", post_reorder) + app.router.add_get("/smart_queue/history", get_history) + app.router.add_get("/smart_queue/settings", get_settings) + app.router.add_post("/smart_queue/settings", post_settings) diff --git a/tests/test_routes.py b/tests/test_routes.py new file mode 100644 index 0000000..8738f43 --- /dev/null +++ b/tests/test_routes.py @@ -0,0 +1,69 @@ +from aiohttp import web +from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop + +from backend.persistence import init_db, add_queue_item +from backend.autopilot_state import AutopilotState +from backend.autopilot import AutopilotSettings, Decision +from backend.routes import register_routes + + +class TestSmartQueueRoutes(AioHTTPTestCase): + async def get_application(self): + self.conn = init_db(":memory:") + self.state = AutopilotState() + self.settings = AutopilotSettings() + app = web.Application() + register_routes(app, self.conn, self.state, self.settings) + return app + + @unittest_run_loop + async def test_status_reports_not_paused_initially(self): + resp = await self.client.get("/smart_queue/status") + body = await resp.json() + assert body["is_paused"] is False + + @unittest_run_loop + async def test_status_reports_paused_with_reasons(self): + self.state.apply(Decision(should_pause=True, reasons=("GPU too hot",))) + resp = await self.client.get("/smart_queue/status") + body = await resp.json() + assert body["is_paused"] is True + assert "GPU too hot" in body["reasons"] + + @unittest_run_loop + async def test_queue_lists_items_in_order(self): + add_queue_item(self.conn, prompt_id="a", name="First") + add_queue_item(self.conn, prompt_id="b", name="Second") + resp = await self.client.get("/smart_queue/queue") + body = await resp.json() + assert [item["prompt_id"] for item in body["items"]] == ["a", "b"] + + @unittest_run_loop + async def test_reorder_updates_queue_order(self): + add_queue_item(self.conn, prompt_id="a", name="First") + add_queue_item(self.conn, prompt_id="b", name="Second") + resp = await self.client.post("/smart_queue/reorder", json={"ordered_prompt_ids": ["b", "a"]}) + assert resp.status == 200 + resp2 = await self.client.get("/smart_queue/queue") + body = await resp2.json() + assert [item["prompt_id"] for item in body["items"]] == ["b", "a"] + + @unittest_run_loop + async def test_history_is_empty_initially(self): + resp = await self.client.get("/smart_queue/history") + body = await resp.json() + assert body["items"] == [] + + @unittest_run_loop + async def test_get_settings_returns_current_values(self): + resp = await self.client.get("/smart_queue/settings") + body = await resp.json() + assert body["master_enabled"] is True + assert body["pause_temp_c"] == 80.0 + + @unittest_run_loop + async def test_post_settings_updates_shared_settings_object(self): + resp = await self.client.post("/smart_queue/settings", json={"pause_temp_c": 90.0, "master_enabled": False}) + assert resp.status == 200 + assert self.settings.pause_temp_c == 90.0 + assert self.settings.master_enabled is False From 6a9303637a7e891fbbeb16487ed08aea001cbc5c Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 23:05:41 +0200 Subject: [PATCH 09/17] Wire autopilot, middleware, routes, and node into package init Two plan-code fixes required to make this actually work: - test_smoke.py's manual __init__.py loader didn't register the module in sys.modules before exec, so __init__.py's relative imports (from .backend...) couldn't resolve their parent package. Fixed by registering it first and giving it submodule_search_locations so it's treated as a package. - pytest.ini moved from the package root into tests/: with it at the package root, pytest's Package collector (any dir with __init__.py) unconditionally imports that __init__.py during test *setup* for setUpModule/tearDownModule support, which broke every single test the moment __init__.py started doing the Task 9 relative imports. Rooting pytest at tests/ (which has no __init__.py) keeps the package __init__.py out of the collection tree entirely; --import-mode=importlib avoids the equivalent issue for backend/ and backend/nodes/, which do need __init__.py for real imports. --- __init__.py | 69 +++++++++++++++++++++++++++++++++++++++++++-- pytest.ini | 2 -- tests/pytest.ini | 3 ++ tests/test_smoke.py | 30 ++++++++++++++++++-- 4 files changed, 96 insertions(+), 8 deletions(-) delete mode 100644 pytest.ini create mode 100644 tests/pytest.ini diff --git a/__init__.py b/__init__.py index 37b5966..928e34d 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,69 @@ """Smart Queue — GPU-aware queue autopilot + cooldown/pause node for ComfyUI.""" -NODE_CLASS_MAPPINGS = {} -NODE_DISPLAY_NAME_MAPPINGS = {} +import asyncio +import logging +from pathlib import Path -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] +try: + from server import PromptServer # type: ignore[import-not-found] + _HAS_COMFY_SERVER = True +except ImportError: + _HAS_COMFY_SERVER = False + +from .backend.autopilot import AutopilotSettings +from .backend.autopilot_loop import run_autopilot_tick +from .backend.autopilot_state import AutopilotState +from .backend.gpu_monitor import poll_gpu_metrics +from .backend.nodes.cooldown import SmartCooldownNode +from .backend.persistence import init_db +from .backend.queue_middleware import create_queue_middleware +from .backend.routes import register_routes + +logger = logging.getLogger(__name__) + +NODE_CLASS_MAPPINGS = { + "RubzGpuCooldownNode": SmartCooldownNode, +} +NODE_DISPLAY_NAME_MAPPINGS = { + "RubzGpuCooldownNode": "Smart Cooldown & Pause", +} +WEB_DIRECTORY = "./web" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] + +_autopilot_state = AutopilotState() +_autopilot_settings = AutopilotSettings() + +TICK_INTERVAL_SECONDS = 5.0 + + +async def _async_poll_gpu_metrics(): + # poll_gpu_metrics is a blocking subprocess call; keep it off the event loop. + return await asyncio.to_thread(poll_gpu_metrics) + + +async def _autopilot_background_loop(): + while True: + if _autopilot_settings.master_enabled: + await run_autopilot_tick(_autopilot_state, _autopilot_settings, _async_poll_gpu_metrics) + await asyncio.sleep(TICK_INTERVAL_SECONDS) + + +def _is_autopilot_enabled() -> bool: + return _autopilot_settings.master_enabled + + +if _HAS_COMFY_SERVER: + _db_path = str(Path(__file__).parent / "smart_queue.sqlite3") + _conn = init_db(_db_path) + + _server = PromptServer.instance + _server.app.middlewares.append(create_queue_middleware(_autopilot_state, _is_autopilot_enabled)) + register_routes(_server.app, _conn, _autopilot_state, _autopilot_settings) + + async def _start_autopilot_loop(app): + app["smart_queue_autopilot_task"] = asyncio.create_task(_autopilot_background_loop()) + + _server.app.on_startup.append(_start_autopilot_loop) + + logger.info("[Smart Queue] Loaded — autopilot + Smart Cooldown & Pause node registered.") diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 2f4c80e..0000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -asyncio_mode = auto diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..0afaa85 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +addopts = --import-mode=importlib diff --git a/tests/test_smoke.py b/tests/test_smoke.py index d3fdd9f..fbf331b 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,15 +1,39 @@ -"""Smoke test: the package's __init__.py loads cleanly outside a running ComfyUI instance.""" +"""Smoke test: the package's __init__.py loads cleanly and registers the node.""" import importlib.util +import sys from pathlib import Path PACKAGE_ROOT = Path(__file__).resolve().parent.parent -def test_init_exposes_node_mappings(): - spec = importlib.util.spec_from_file_location("smart_queue_init", PACKAGE_ROOT / "__init__.py") +def _load_init_module(): + spec = importlib.util.spec_from_file_location( + "smart_queue_init", + PACKAGE_ROOT / "__init__.py", + submodule_search_locations=[str(PACKAGE_ROOT)], + ) module = importlib.util.module_from_spec(spec) + # Relative imports inside __init__.py (e.g. `from .backend...`) need this + # module registered in sys.modules before exec, or Python can't resolve + # "smart_queue_init" as their parent package. + sys.modules[spec.name] = module spec.loader.exec_module(module) + return module + +def test_init_exposes_node_mappings(): + module = _load_init_module() assert isinstance(module.NODE_CLASS_MAPPINGS, dict) assert isinstance(module.NODE_DISPLAY_NAME_MAPPINGS, dict) + + +def test_cooldown_node_registered_under_backward_compatible_id(): + module = _load_init_module() + assert "RubzGpuCooldownNode" in module.NODE_CLASS_MAPPINGS + assert module.NODE_DISPLAY_NAME_MAPPINGS["RubzGpuCooldownNode"] == "Smart Cooldown & Pause" + + +def test_web_directory_is_declared(): + module = _load_init_module() + assert module.WEB_DIRECTORY == "./web" From ebe5fce56fbab44a008ffb9df776e2ab1f473b37 Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 23:06:41 +0200 Subject: [PATCH 10/17] Add tests/conftest.py to put the package root on sys.path --import-mode=importlib (needed to keep the package's own __init__.py out of pytest's Package-setup import) stops pytest from touching sys.path at all, so bare `pytest tests/ -v` (what CI runs) couldn't find backend/ without it. python -m pytest masked this by adding cwd itself. --- tests/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1d16fe6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +"""Make the package root (containing backend/) importable regardless of how +pytest is invoked (bare `pytest`, `python -m pytest`, from any cwd).""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) From 6146f49476f1e3befc9e1e64394e8b3a432f1e5c Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 23:07:08 +0200 Subject: [PATCH 11/17] Add queue panel UI with theme-aware styling and Crystools detection --- web/smart_queue.css | 25 ++++++++ web/smart_queue.js | 138 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 web/smart_queue.css create mode 100644 web/smart_queue.js diff --git a/web/smart_queue.css b/web/smart_queue.css new file mode 100644 index 0000000..cf1d006 --- /dev/null +++ b/web/smart_queue.css @@ -0,0 +1,25 @@ +#smart-queue-panel { + background: var(--comfy-menu-bg, var(--p-content-background, #202020)); + color: var(--fg-color, var(--p-text-color, #ddd)); + border: 1px solid var(--border-color, var(--p-content-border-color, #444)); + border-radius: 6px; + padding: 8px 10px; + font-size: 12px; +} + +#smart-queue-panel .smart-queue-status { + font-weight: 600; + margin-bottom: 6px; +} + +#smart-queue-panel .smart-queue-list { + list-style: none; + margin: 0; + padding: 0; +} + +#smart-queue-panel .smart-queue-list li { + padding: 4px 6px; + border-bottom: 1px solid var(--border-color, var(--p-content-border-color, #444)); + cursor: grab; +} diff --git a/web/smart_queue.js b/web/smart_queue.js new file mode 100644 index 0000000..d683a5a --- /dev/null +++ b/web/smart_queue.js @@ -0,0 +1,138 @@ +import { app } from "../../scripts/app.js"; + +app.registerExtension({ + name: "SmartQueue.Panel", + settings: [ + { + id: "SmartQueue.EnableAutopilot", + name: "Enable Autopilot Queue Panel", + type: "boolean", + defaultValue: true, + }, + { + id: "SmartQueue.TempRuleEnabled", + name: "Autopilot: pause on high temperature", + type: "boolean", + defaultValue: true, + }, + { + id: "SmartQueue.VramRuleEnabled", + name: "Autopilot: pause on low free VRAM", + type: "boolean", + defaultValue: true, + }, + { + id: "SmartQueue.PauseTempC", + name: "Autopilot: pause temperature (C)", + type: "number", + defaultValue: 80.0, + }, + { + id: "SmartQueue.ResumeTempC", + name: "Autopilot: resume temperature (C)", + type: "number", + defaultValue: 72.0, + }, + { + id: "SmartQueue.JobCountRuleEnabled", + name: "Autopilot: pause after N jobs", + type: "boolean", + defaultValue: true, + }, + { + id: "SmartQueue.MinFreeVramMb", + name: "Autopilot: minimum free VRAM (MB)", + type: "number", + defaultValue: 1024, + }, + { + id: "SmartQueue.MaxJobsBeforePause", + name: "Autopilot: jobs before forced cooldown", + type: "number", + defaultValue: 20, + }, + ], + async setup() { + async function syncSettingsToBackend() { + const payload = { + master_enabled: app.extensionManager.setting.get("SmartQueue.EnableAutopilot"), + temp_rule_enabled: app.extensionManager.setting.get("SmartQueue.TempRuleEnabled"), + pause_temp_c: app.extensionManager.setting.get("SmartQueue.PauseTempC"), + resume_temp_c: app.extensionManager.setting.get("SmartQueue.ResumeTempC"), + vram_rule_enabled: app.extensionManager.setting.get("SmartQueue.VramRuleEnabled"), + min_free_vram_mb: app.extensionManager.setting.get("SmartQueue.MinFreeVramMb"), + job_count_rule_enabled: app.extensionManager.setting.get("SmartQueue.JobCountRuleEnabled"), + max_jobs_before_pause: app.extensionManager.setting.get("SmartQueue.MaxJobsBeforePause"), + }; + try { + await fetch("/smart_queue/settings", { + method: "POST", + body: JSON.stringify(payload), + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + console.error("[Smart Queue] settings sync failed:", err); + } + } + + await syncSettingsToBackend(); + setInterval(syncSettingsToBackend, 10000); + + const enabled = app.extensionManager.setting.get("SmartQueue.EnableAutopilot"); + if (!enabled) { + console.log("[Smart Queue] Autopilot panel disabled via settings."); + return; + } + + const hasCrystools = app.extensions?.some((ext) => ext.name?.toLowerCase().includes("crystools")); + + const panel = document.createElement("div"); + panel.id = "smart-queue-panel"; + panel.innerHTML = ` +
Smart Queue: idle
+ ${hasCrystools ? "" : '
'} +
    + `; + + const sidebar = document.querySelector(".comfyui-body-bottom") || document.querySelector(".comfy-menu"); + if (sidebar) { + sidebar.appendChild(panel); + } + + async function refreshStatus() { + try { + const res = await fetch("/smart_queue/status"); + const data = await res.json(); + const statusEl = panel.querySelector(".smart-queue-status"); + statusEl.textContent = data.is_paused + ? `Smart Queue: paused — ${data.reasons.join("; ")}` + : "Smart Queue: running"; + } catch (err) { + console.error("[Smart Queue] status fetch failed:", err); + } + } + + async function refreshQueueList() { + try { + const res = await fetch("/smart_queue/queue"); + const data = await res.json(); + const listEl = panel.querySelector("#smart-queue-list"); + listEl.innerHTML = ""; + for (const item of data.items) { + const li = document.createElement("li"); + li.draggable = true; + li.dataset.promptId = item.prompt_id; + li.textContent = item.name; + listEl.appendChild(li); + } + } catch (err) { + console.error("[Smart Queue] queue fetch failed:", err); + } + } + + setInterval(refreshStatus, 3000); + setInterval(refreshQueueList, 5000); + await refreshStatus(); + await refreshQueueList(); + }, +}); From 03e5eae7d45ebf7f7fcb6e47098a9db95739b5c4 Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 23:09:09 +0200 Subject: [PATCH 12/17] Add node notification UI: continue button, toast, sound playback Wires the Python side too (execute() dispatches smart_queue.cooldown_notify and, for wait_for_click, smart_queue.cooldown_wait_for_click via PromptServer.send_sync, then blocks on backend/continue_registry.py's threading.Event until POST /smart_queue/continue/ signals it). run_cooldown()'s signature and tests are unchanged. Sound files are short generated sine-wave tones (stdlib wave module, 440/880/1318Hz, all under 25KB). --- backend/continue_registry.py | 26 ++++++++++++++ backend/nodes/cooldown.py | 23 +++++++++++++ backend/routes.py | 7 ++++ tests/test_continue_registry.py | 42 +++++++++++++++++++++++ tests/test_routes.py | 22 ++++++++++++ web/smart_queue.css | 30 ++++++++++++++++ web/smart_queue_node.js | 59 ++++++++++++++++++++++++++++++++ web/sounds/alert.wav | Bin 0 -> 22094 bytes web/sounds/chime.wav | Bin 0 -> 17684 bytes web/sounds/default.wav | Bin 0 -> 13274 bytes 10 files changed, 209 insertions(+) create mode 100644 backend/continue_registry.py create mode 100644 tests/test_continue_registry.py create mode 100644 web/smart_queue_node.js create mode 100644 web/sounds/alert.wav create mode 100644 web/sounds/chime.wav create mode 100644 web/sounds/default.wav diff --git a/backend/continue_registry.py b/backend/continue_registry.py new file mode 100644 index 0000000..21283aa --- /dev/null +++ b/backend/continue_registry.py @@ -0,0 +1,26 @@ +"""In-memory registry of pending `wait_for_click` continues, keyed by prompt_id. + +Bridges the synchronous node execution thread (which blocks in `wait_for_continue`) +and the aiohttp route handler that receives the click (`signal_continue`). +""" + +import threading + +_lock = threading.Lock() +_events: dict[str, threading.Event] = {} + + +def wait_for_continue(prompt_id: str, poll_interval: float = 0.25) -> None: + with _lock: + event = _events.setdefault(prompt_id, threading.Event()) + try: + event.wait() + finally: + with _lock: + _events.pop(prompt_id, None) + + +def signal_continue(prompt_id: str) -> None: + with _lock: + event = _events.setdefault(prompt_id, threading.Event()) + event.set() diff --git a/backend/nodes/cooldown.py b/backend/nodes/cooldown.py index 5e7b9b9..be21265 100644 --- a/backend/nodes/cooldown.py +++ b/backend/nodes/cooldown.py @@ -11,6 +11,7 @@ io = None # type: ignore[assignment] _HAS_COMFY_IO = False +from ..continue_registry import wait_for_continue from ..gpu_monitor import GpuMetrics, poll_gpu_metrics @@ -87,6 +88,7 @@ def define_schema(cls): io.String.Input("custom_sound_path", default="", optional=True), io.Boolean.Input("notify_toast", default=False), io.AnyType.Input("passthrough", optional=True), + io.Hidden.prompt_id, ], outputs=[ io.AnyType.Output("passthrough"), @@ -98,6 +100,7 @@ def define_schema(cls): @classmethod def execute(cls, **kwargs): import comfy.model_management as model_management + from server import PromptServer status = run_cooldown( fixed_delay_seconds=kwargs["fixed_delay_seconds"], @@ -110,4 +113,24 @@ def execute(cls, **kwargs): metrics_fn=poll_gpu_metrics, unload_fn=model_management.unload_all_models, ) + + notify_sound = kwargs["notify_sound"] + notify_toast = kwargs["notify_toast"] + if notify_sound or notify_toast: + PromptServer.instance.send_sync("smart_queue.cooldown_notify", { + "notify_sound": notify_sound, + "notify_sound_choice": kwargs["notify_sound_choice"], + "custom_sound_path": kwargs.get("custom_sound_path", ""), + "notify_toast": notify_toast, + "status": status, + }) + + if kwargs["wait_for_click"]: + prompt_id = kwargs.get("prompt_id") + PromptServer.instance.send_sync("smart_queue.cooldown_wait_for_click", { + "prompt_id": prompt_id, + }) + wait_for_continue(prompt_id) + status += " | Continued by user click." + return io.NodeOutput(kwargs.get("passthrough"), status) diff --git a/backend/routes.py b/backend/routes.py index ea947cf..c26e6cf 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -8,6 +8,7 @@ from .autopilot import AutopilotSettings from .autopilot_state import AutopilotState +from .continue_registry import signal_continue from .persistence import list_history, list_queue_items, reorder_queue_items @@ -39,9 +40,15 @@ async def post_settings(request: web.Request) -> web.Response: settings.update_from_dict(payload) return web.json_response({"ok": True}) + async def post_continue(request: web.Request) -> web.Response: + prompt_id = request.match_info["prompt_id"] + signal_continue(prompt_id) + return web.json_response({"ok": True}) + app.router.add_get("/smart_queue/status", get_status) app.router.add_get("/smart_queue/queue", get_queue) app.router.add_post("/smart_queue/reorder", post_reorder) app.router.add_get("/smart_queue/history", get_history) app.router.add_get("/smart_queue/settings", get_settings) app.router.add_post("/smart_queue/settings", post_settings) + app.router.add_post("/smart_queue/continue/{prompt_id}", post_continue) diff --git a/tests/test_continue_registry.py b/tests/test_continue_registry.py new file mode 100644 index 0000000..e23f95e --- /dev/null +++ b/tests/test_continue_registry.py @@ -0,0 +1,42 @@ +import threading +import time + +from backend.continue_registry import wait_for_continue, signal_continue + + +def test_wait_for_continue_blocks_until_signaled(): + prompt_id = "abc123" + result = {"returned": False} + + def waiter(): + wait_for_continue(prompt_id) + result["returned"] = True + + thread = threading.Thread(target=waiter) + thread.start() + time.sleep(0.1) + assert result["returned"] is False + + signal_continue(prompt_id) + thread.join(timeout=2.0) + assert result["returned"] is True + + +def test_signal_before_wait_does_not_deadlock(): + prompt_id = "signaled-early" + signal_continue(prompt_id) + + result = {"returned": False} + + def waiter(): + wait_for_continue(prompt_id) + result["returned"] = True + + thread = threading.Thread(target=waiter) + thread.start() + thread.join(timeout=2.0) + assert result["returned"] is True + + +def test_signal_for_unknown_prompt_id_is_a_no_op(): + signal_continue("never-waited-on") diff --git a/tests/test_routes.py b/tests/test_routes.py index 8738f43..e454b95 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -4,6 +4,7 @@ from backend.persistence import init_db, add_queue_item from backend.autopilot_state import AutopilotState from backend.autopilot import AutopilotSettings, Decision +from backend.continue_registry import wait_for_continue from backend.routes import register_routes @@ -67,3 +68,24 @@ async def test_post_settings_updates_shared_settings_object(self): assert resp.status == 200 assert self.settings.pause_temp_c == 90.0 assert self.settings.master_enabled is False + + @unittest_run_loop + async def test_continue_signals_a_waiting_node(self): + import asyncio + + prompt_id = "test-prompt-id" + released = {"done": False} + + async def waiter(): + await asyncio.to_thread(wait_for_continue, prompt_id) + released["done"] = True + + wait_task = asyncio.ensure_future(waiter()) + await asyncio.sleep(0.05) + assert released["done"] is False + + resp = await self.client.post(f"/smart_queue/continue/{prompt_id}") + assert resp.status == 200 + + await asyncio.wait_for(wait_task, timeout=2.0) + assert released["done"] is True diff --git a/web/smart_queue.css b/web/smart_queue.css index cf1d006..5e09f8a 100644 --- a/web/smart_queue.css +++ b/web/smart_queue.css @@ -23,3 +23,33 @@ border-bottom: 1px solid var(--border-color, var(--p-content-border-color, #444)); cursor: grab; } + +.smart-queue-continue-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.smart-queue-continue-box { + background: var(--comfy-menu-bg, var(--p-content-background, #202020)); + color: var(--fg-color, var(--p-text-color, #ddd)); + border: 1px solid var(--border-color, var(--p-content-border-color, #444)); + border-radius: 8px; + padding: 20px 24px; + text-align: center; + max-width: 320px; +} + +.smart-queue-continue-btn { + margin-top: 12px; + padding: 6px 18px; + border-radius: 4px; + border: 1px solid var(--border-color, var(--p-content-border-color, #444)); + background: var(--comfy-input-bg, #333); + color: inherit; + cursor: pointer; +} diff --git a/web/smart_queue_node.js b/web/smart_queue_node.js new file mode 100644 index 0000000..ee5a83f --- /dev/null +++ b/web/smart_queue_node.js @@ -0,0 +1,59 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +const SOUND_FILES = { + Default: "sounds/default.wav", + Chime: "sounds/chime.wav", + Alert: "sounds/alert.wav", +}; + +app.registerExtension({ + name: "SmartQueue.CooldownNode", + async setup() { + api.addEventListener("smart_queue.cooldown_notify", (event) => { + const { notify_sound, notify_sound_choice, custom_sound_path, notify_toast, status } = event.detail; + + if (notify_sound) { + let src = SOUND_FILES[notify_sound_choice] ?? SOUND_FILES.Default; + if (notify_sound_choice === "Custom..." && custom_sound_path) { + src = custom_sound_path; + } + const audio = new Audio(new URL(src, import.meta.url).href); + audio.play().catch(() => { + // Custom file missing/unplayable — fall back to the default tone. + new Audio(new URL(SOUND_FILES.Default, import.meta.url).href).play().catch(() => {}); + }); + } + + if (notify_toast && app.extensionManager?.toast) { + app.extensionManager.toast.add({ severity: "info", summary: "Smart Cooldown & Pause", detail: status }); + } + }); + + api.addEventListener("smart_queue.cooldown_wait_for_click", (event) => { + const { prompt_id } = event.detail; + + const overlay = document.createElement("div"); + overlay.className = "smart-queue-continue-overlay"; + overlay.innerHTML = ` +
    +
    Smart Cooldown & Pause is waiting — click Continue to resume.
    + +
    + `; + document.body.appendChild(overlay); + + const cleanup = () => overlay.remove(); + + overlay.querySelector(".smart-queue-continue-btn").addEventListener("click", async () => { + try { + await fetch(`/smart_queue/continue/${encodeURIComponent(prompt_id)}`, { method: "POST" }); + } catch (err) { + console.error("[Smart Queue] continue request failed:", err); + } finally { + cleanup(); + } + }); + }); + }, +}); diff --git a/web/sounds/alert.wav b/web/sounds/alert.wav new file mode 100644 index 0000000000000000000000000000000000000000..42c6109b418eb2da9c1d0b8dc37554067691aedb GIT binary patch literal 22094 zcmeI4_j^+3|Nq$`5FjCtMM4OK8P+*itF`V{jdkxfY2BmN)ztQF)w;E|YMiz1Rnuzg zs#^E9R%HtkdTBx5=fW{K zIs}CBL!8j;&|je;;g0b0$fKw^Mvs4yFeIlS^{6V04LdvIDV{Gn zBd;O%lg5*(vH+qwa|nJNt`@T$H5XBl=#Jfqd>>lXR}(nX6YJXVTj<@`>G1sIIpqm^ zE_GhC>Ut46z4|Sz|wb$ z?-eL%Te4e-#0(tD(+`E7`y0Ij+AXeTdv)U{mNsLK!LI*AzeE37eVd`!+-qIZwAkr% z!=1VQv!SZE9F5QXm;4#+TH!^ekR#@w5G|B!l$qsgfl6Se{G_x@>=lr>wX8J^4c$t) zm^l`0>)#Tr?4H_j!?m$VX}Mt-qFb+C2M>i{r39J-k5~6=7a3|S1x+Je)^?~15v=Zi zg(}bdj8a8MGTvwPmKO+r7B7^o0q%l;!UjGCj54`ID}=c6GE(vSyuH~~1Qh02d{8LX z9qpLZLb6Y^vW!3Lo~tjzvmv!IAF6=y>N;(>zSsPNtQwp6Cu;>#6hjTF%+k z)`P}A9YNCoe+AVmu@DM=qnfO(tzT!3HxitC+gA7v_qwA~khe0zBrNTDVP2_^^Nzq; zp^*}RO(0)^R#3s&a;v0H^om!>9#!%>-;?u(_%)WCyc}NOpW1n#b%0}L1J*QCKTDIN zIs`Q;`;=DbM^(RuU!O45G=ACac0cwu2BguS5HZ{bq>pn;3%4?tah~(nSL~4LpO1YuVP4FA7djzsr*0-Xs@9@&buY(EZlY(6GUDPk&KUsk#NZm2Jwq z&}7vW%?tf5Q+tEiG1^V>3Ib~**5pXsfvi8M8w)y0?CkOUyov$R6Y?Qou7atk0wMWE z>6(ha_@8iUn3Te?xge7RwYxvix08AFlv(XG8zzKw0M=B~yS<{kB8wa--v2n`vO-#`vHN0Zc@Ha1$X+Y>E! zJkNU2;Ux(eQ%;+ z#)pynWAFA6y4dYUof~XzW^er|EmF;g^HMwOW9Sl5l&i*m4qYY?c$TNqrh7bR6yWK;JmC|yivG}`F^aXpxPSvFt$wI1x+y=JS!h6WmU}L``D3^ zy#6avLokOG*bEfpgLF%Gv>QnV^>eha3I}IEdgU6(0HZaXx?RSX)`NCi%Ms719$RQc;xYz76y;>+A1}UM zHjD>~Hc6=RR-ij|_MQbt18ms`;#-2v<@ZW!ik{ORlQ-c@P`6`MebTPc?SDCs*^m~r z;i{IbmcxZ970!Sz!VlE7I=XR$HQ6+*rM;uQn-|&@??P7+)@Og77iW}}J>$L+@+BJC z1E3+Lko(}*z;f9+F-M5xj%VdCR?yc_24)&jtNN)yTbHMOs*BgO*|OSz)lF4b!?Kh@ zhCpAzU#US|o#ClvRns}wh>mx=R|Ws;e+PXi^Iys{`aH%V)<|xZ5Ek!~?E_w=W@x~R zz;#(nd{DTRYhhhy(DT^YzRW{tVf=0IK=+Z3F4vu=q@~d?Pj^(k2OgW6Q4WoPWool_ zkYR+S$%c0Q+`g;pZeLFSdXxp9PsyjhC_=KHm3ss=;xV#n;0h>LxIs1Gmra+%g-PzR zvX6=%=V`O+2oo^Q__omKp5dO_mT~s2)``YPI-B}2JRj03v!No`r`o24>tC3s+Lk)I z+YCNL-|W~kBpbhoyn@CqT3tG{e7%4sj!Q+r0Z^=nfKdRHUy>{pZQ%8lwH7bT-#<8bifayE^S`A-y7P2e5l}e~a)u2h}VbhSt0nK&pb>5!>uE==ApSb?4++171 zhfI)jkUy&|GV zHvVq1>tAZ-tDZxhN|*97G)T2qb3#AcbgSXC1L=O-Y4?jF2a+M|*sLYg_X@6-TxJ*X zy&{@)n_L2t6>P;a(3QFY{aoSVAK+YLPAS}*yPgDQK#0XrTCd+*&~~=@R3pbcrk<`n zrD}zuN{ezggj5aJ4AM6nB@KAT+}4s#vA-s4NDRXsBHqpUDW6tS%09>2C3+$GP>ur= zph)pOSOmDG;fgteyz)_{{=&gDDcO^;8(A9rxA(m7N!ws2-*(!3t^RYZRYisgkWRTA zQo_Azvu?HVl69^9NlT4qUC;dxBe4TxC1m7u<*hG1T*l>=bF*`I(ZrI07!W?-G{miRp(gS(Uk zFly;XC{>wp)ZzYjgQ#v&#~Rn@rel^JhJ4*z^c76?iPmlKdt-!40us#=yJ}v#Er; z=t=Re;O*`^9h4SbQ=v6rsL@?i{{&A+y$1^#3bWO3v>XG^^1|kHe$&3N>rh{3Y$ob2 ze1P0XyHMmR{k6PaFi$*4HVybKrNR!-3?yZXB)Osj-p;b+#kRa)_P>NLG5Ex9p(Q=@ zJ=au;Tp{Mg7BNiCL>HJVyju)n01jpCa{80j~1E=W{x6sy24`R~#{ zDq{RUI7;T4!t1$bNV7Bih&|Cky>#E;wkOSZ8;6*y>bcs>DjyVAnw8t2AS}~x_5T^E z4ITF3tuYVYKQVkGQHWhhJd(3EKUj=r@8;Er{*(-oCx94Oq1XqO13@WKyjmbHpI1sP zs-jIJXXCFTKZyBy|MoSt&2f&l-7&wcU$1qm@?di6>|F%CfLql6=;jz}t+VWBTBdkr z^c)EV<8v{039f7?ud4WeWm!DDs7ex+)dTIRopl+U4GfokF18D9l?O_17UAev${YML zR3cW_x3+6nd%&r-DJ)WhS}RnKgxRUxTL~S2Ppd!IwHdOke>WvvbsZPF8-hdP7tsiU zBAc7{l+nhj<(?8^BzI(&09{HUFTvfwF4-&b6yX5w4%QULb^2Y(mP|JKet&h4+b!!j z;F{ZX({jQf)U8mjh6klo2tf1UIcl9h z?w0y2JT>(m3}^sMSKrZ+3?$31w!fV-+J|?o>U$lNqYmO#WF2i=(d*K^oT6rN>n=a~5WDh^9+^2K18f})UvyXD2wkch{> z%YIz)QNjPHA7>G8za)o7{C;aE)qTbBykVi~qW*|xnCc4DqU=yUfvQvwH3t0|6Sgtz zSm*}569UI02*gs{?JSsjx&Y6Nu@~?~6(36LAQycj%TeqJCFJ8 z;W5dp*d&of4d<^ZsbRPC{uTu!%jHZEuiz@a2BY!^(kB&E!9SdK=D|Wu?hDe!i~{71 zXm#%(-zRMi&AP_v=GFBBwSTFC5CSqPH$onmrJ?F?8^czmo!M&kxOxQPJ&6FOlK5rL z^n7EnyKFV@Q_(qzSRMqzsoTk6PzGRSOz|$k`10>c!J_YI-;m$IYmv)i)IPTl+5SK0 zT${>ls^6pSRh7cj)OW~S=pJlT-_ngTuC)%fZ)%ZvfS%PMb$l@95aD_DmAvzYnxyhZD`gG zRF8*wDTRCh?Sl8KXX~^EzvWU>lWTj&rtZ7Jy!d9cH8VRKlXsS(WUb}y68goLWM=_Y zN+EB+gTN7)QM^PriF-axPSWHgO-|C}Bu!4zvF=OpbpNqbJxo|ClaB<(p#drs1xleFg~ z?Kw%Ob<$~_bXq5!)=8&z(rKM^S|^>>NvCzvX`OUhC!N+wr*+b4opf3!oz_XGb<#a2 z>7J8x&q=!HB;9k8?m0>KoTPhB(mf~Xo|AOX$^UoHi5P4IpMfSoD1Rt9CW3h@*}F<) z1)HcpWG%xDL=dBifY5iLZHv=jyK7MxXX_=}Cu)kCrS_{gYoF_{83$XZH+h`Cwy{2Y z;Frj^$#K{iVMF#!`ktZy)6ACf+=7s3ju@VQd9tXJ-jRKUI0gqJ zs$;02soU>a=ziaMplNx7(d;npH?-8Z)bBGi82>d-Y^ZMPXl`r$(BtdU^jaf*2_lA= z*+g1Py_a{Yh|ENnt>#p6_weNWclqaeUvjT-s>*gVhcWi%Z_AZYej`M&Ly_D2ZK3&r zlCE;kPWL3&9>>_G9~wVycwoJ11sldRI&EmjN$2@ie8+R|nv{G=lIsq5(qw285nM%BjE zy4!EH8Qj(GG43PoQEijkZ*?s1T;aRcEevS-PK7tdmL;Yk<(MGucqTb(B6(sqj#`tu zmo}CDgs!6>poeH#G#zzX&VI^a(h_1$=2=`J=0{{av7w(FeHJ>=cQMe~v$$K(1^T}C z^1XELB(K(c#&@MF(!I$)pqCcR3(KR`{k4fFh!#{B(}Vj5Uqk334kV2x(xNy$y!B>XMT=OLvC9pEk zA9&DvzVB+#65>R*M18SEaa&>r;suh8UWK`Uy@$J&u^BJQypo9_fP{Al6vB(lNtyTX zC_Ioc7FUUVKQ#f5>Og3cPZRg!fA>3M1+isOU8E|4io6M53*QXu!_>&~h&3`l8i}5d z&Fs&K>*7ZfbCPt#Tg2DMT+{_rDS8jO8O_Daz^uZo#Vo~)!xUgF=vwrvzUVu1&O$90hr{8q zKm8XtT%SL1I0BA~n?7aX6mK8wAK3E>-1oSPxPNgkaC>lVafeH#;>=>QaK8{N%qjLN zeTJKlUqu)|JV{(jd_y>eH{g~QJ^3@aEcUpP(-at`&iHXe9l4USjp8LwCg}-FOZW0q zvzOAv)XcO$GciBEbc1k*%%xS+2h*d}FUfd9$KuB9npAC4n$$P#Pj||HhkHocLhHyF z$82D1q@(0s__6u7sj-P~VpC(i6L9KmUP2g6J(9zYk-_MDETNp@F^4H3~QdOwpQYG&ZWomA9^o*yO zz25qlG@ zqx#}V=1b{2odHsV0re~)lX^8hEfBT7MqKa!^JI@Ft|HL6*A<%$Ga#2?x-!I-5&ugV zy!B=(Qj1Kq?)3ka5>kE=j8G#$cW97)hpabCU6M3HuDJ0Sd>(yj&xOwAvUG=dul7E8 z2CUL95HF+yxiz8r_I2nPxRvQg*Pg~xrFvGAY?FQ<^e0eI$pl--ol`15*ZMu8M9M5X zz2lQdi8Z{tN-;1M8etfv*uvR`pB`^=Q_XMTCZxCReV|9?7ph5Eq4^Z#L%Vb{rLP#Z zg+C)g=Q!g&xRddPV^rjm!aRmlI#ox4MuUqr?L;Z+;Y{n`DjN@d48J%1?k2}u<6CpM zijNJGpw0kM^$(9qs!Q6uJuIz|X$Wk2;(OQBjC_}WNA(L(1zpnjl@sshaa;7J^Q!S6Y)9KT)`a`yx0a2U__P*q8(7q&L^j&?Y#}JH zk3_G+b4+O$5bKE>&YmuB(2szA0De>L<|j$Vn~wPEEMt&P$Y=}Q7fyB|MRM9w+@?7qJ-mOHJH z#|1odoAeKz6zTwKG?#?asLagS0ATYW2&^_MJ-g!f@i#cj6ki&qK_uXyvVu2{_;Iqv z8#N1&bx12~e}7G?j@(zEQr`voLi6-5WxHA5m1-JixFn|2@Gf+h{cPxq+;RGT@oeoC z@HV(wdsEy%U!Hp&I%7YGo`h+pL9Tv{Ri*i?%d#o@UeHsZgL;O*PTroH>F;gbf^ZO% zd9at7988q+HYihu>CktE-3pWwz+a1Z@w7EtU<<;wZ3}qPJ*c~cbWICT1>Mp8E(Mv9 z0x>$=xg0yG*tp9MN1nXDJ7qX}02E>H!m8R?P zAT(z)25YjnY41fo&3e!c7PLzw7s|fM9}N$1M9|;hQ^rHiucA|mJDE6HT-O3(f%ny) z2%l5FPqz*{vED;M@J#bgk0CLh(3|T}9LAFT!q7!|nTrtiC1Rdq<|2F?S!=EL=Tp^` zhk_;Q08kFK)=!pES(c(HnsFk=BXB*Mw0{x~=E}=Nl3UtW;6bpp_ET{=-IW~_8f2e~ zo`?IJuDM<}dP*f0AU~=f3LOOAsqFkA7s*?SK0#^*In8&eP6C6WJ$jzp%X(hA)p*?1-EP1IsRoMr@fUOd}32!z*?n$wgxGn6Tpu42mrI{6+)2u zBK>`!WQ7qsTwxyKc@ZZOVw@9-Ral0?hDAyuuRSr6DEGcJGmxKJQ7tvdT+s8aulR#^dlw^p@Qf+MTo0Ux>G9?}8V=iQ0|gt@Q4>+R$427W8*m zZ(8hH-MFK4mgSJG(D#RK0xWeK!7*}Gs=Z%rU5QAMf_a6vPx41%HSdCw1x$k`7-|$p zIDg=m#pxct`8^y#KC#^kfSKjgN5a;cuAm4yq+25OF)kDuBA|0RmgH8(JC0rvd0`BL zB>h5%gC>IOHT^_f+RaR#;C7o7eFQ%<)w)A55njS6%5{c`(5HY<1@p9|x5=b;u%$gR z5qV;{>Z@yFlh5$askQ>cp*#9&c~>@wW5ryqsiw>D1XOPqgidBNG^UuL{TW0-qqe_f zL0QZES7EgSMi0V&8=pFlMGqC9GTX{{`gTwOIHWEJc2c^gGyW6S%SaMlZ@%l9l-NgD z#^orlV;ynbz)>#a?juY}-1V$CC*jM;32VmRHNBHk5MZwb+Ce;hTiH|Qq2jUVQ|I4U zN5H7s@m08GenDA(iBXG!KZ6V{Q=Fll%nCw!`vmkdJk{iKv0@;uE4x~LM?W0e3Y=4& z;j_tgO;>$SEEAFT$Y4v-`!=a1!91brQ(z*bGZf_#jtC!$)w-XV9>G#{yRA>~W`;}a zCt9zW2;!hGbR_8*hP==#a>vmMyYJ~n(Af~VQ1CI9NDt{mP*Cu%Q%cfoDi7h*eocg_{MWj~1?hYQBet~QP3 zrLnBTvXT0p&>Nsqy-biJpGYn9S6P2T7|2U=xi^w%PbBgdDZ|+3dzImYBE}&RUc`rZ zD$I5mMoQN21257bb%iji20$(JSXU?Q&ZHM*(W%b$ScX`}b&kgHKY23aFUd!`I5;1? zrr9Z)N#kXA2J3C@(7)m1rjhQwu{zuX_FwXXek?Qr7^-T<|DM#cslvC^(i<6y?6Z99 zo7HrUG?BkZH4hjCIrKlvx3E{@CdDB4X46f$5)IfM1go+~Y4svhvl{e+blo<|qq24R z8{yH86nX&u&A8n;BHF*Wgc*{0b^tkdVX*QEH%_>epm;Bu@yJ1Bq4m7qnd(BhDELb40=ht5^z&sJR=SvsGF(pMQTR6M zv@64Ja_Ta?o)#@fF}&%xbI$6dD?pO?HWo_vpfFmw`dsnYxz zWI?Lv%Uk9m21I2!=UtO*ApXchlm=i5)Ys5mF^e+?|7jd{Crz(l96H994ou73pcaJH znt>n-TB92%JhGHF2Y4kakL`D~OGdfBa>FCffaEgX6dQDxPVFzc~sOWRp zZ+hTv9{(IaoYPX#%`h2i3HX(Vd3NIelJ~qVEX|PFNZ8`{Wt)bO?fiGDgTPSes2-4) zSf0}B#%r$rrt|PzbdY^e$dxUpe=2URJqW%6Z)ru6^0Hv=lW@{rj~;;$W5#KUT8dQG zWLax{ITQevs2>WdDfv{rf35X6QiPA0W1f8pgm9VLMfn1|@52U%qBnOu!I0SLnQ0Ee z_mC&n)`9QS&nce>@2gp03n;F`$#ycQ6u*ida-K5&21n2Vj-%nP@)ydMND5jvxE}Or z-ivP2YO=;)&Nc|W4zDx`-2-CNaSPe2<*z}nW400qSKnM!4%j@_eP>(pm|zk zmqUytdAV`8lNgN@K<4k#J31BA0;Fqp342gI>1_eF&4O6qw&pILYw-Xcb z*r1g21{1O0US5-#gKR;1TW9*Wr|jezf)47ZKrd*D{<3U7tE$wmagd8{&)A=b zmggGiH^r;9x4|pmZ0&yWarzgzv!Pw~UFd08Vw&NqY5cCVoApvQPu~~13#in61$E?_ zRDXXf>pDb;M9nqckCXF=6}*E=64nu48kQ+;aPH%G#g!hl8G(HWU^^RNW~Nau2{oDy zpcMK;w@r#?J}y`z<<4)glLE%GjusJIp%)`68K^6O)4^SuaiZq5H<>ZP-)tK6K77~o zgZphPkHc|-a*1IAGynip5ndNkI7#=7wsbKJ9qP*0MhNHQ_c6JJu0bjW?a!q8o~rnJO7s z-x?x<=hb}SY08K+J8;E%6N$k~%x69Q6Ke_6xdp{}tRwCkw922kmkEm#Z#_SoGw?ZN zkJahdr595?f}QFn;1fuxuar5NH;T8SX6Iw$A@~)_a(o`<=f{0XXW(_{eEXUZ zkXuM!Agv=-Up!GCR?(L3;7Q*Za$ z*iqa$_E~vcKL(lsOi*>_|3s>6s`72L^g{+B+bv^#!)YIwF0k8E1;)Xvn*LdRb76c*pu3|fOQk!AC(#B;HpCn}7Ix`vhA2P$b z*Z(A?rR*1sQ=5SbsE>ZVtenLwF&kB`FeU<*(SPlf@XZ{hEF?axeFUBc2Wo4?v*?oC zccHKBtI$8-_NHB~qm5TeFIgqoHcSN00ZA2Gu#(&|CGgX&-ys@=YuW3al{`;e$ZJr_ zF%cMM_)M{ivjIOk9(3c)Z{Q3v$mR$P${eP8g?%)YAQ#%E8!x@j*itwdVK~3QI-;xb zy<=LWXJG|HFP){MKqJ9#HO)i;>h6poxY))a%PlRDnTXZ$)@N_(Kz_=9pxOfrgU;zY$;E6O&foahRc*Qm&qBM}JA|HQ)%2EP zt#&W?7HrUdBpFsl&JPTe9Z%82@C&2I`5=0~7-bHX>Gd6;7_eDw63nKE(y;$0>lq{q z?=hP_>k@Yf2e{3Z53nTvZb&M|a2FA}C60L(n4@qVa@#5mOiEv+sDxM5RIn9P(g|g! zm`jUmqJKIsVjbZ|%N<+8!}EK}W=f)38~7ub(u72BX=}5wAj4jbUV|5y@b0#;!MF+R zIr8WF&!IKIuc{yUKGL41THiIxD5Nto$s+J&ll@3p-p8sTz<7vnaL5zv2(BTv%zfKb z59`t6w&_7@rXy{pXrJawFb7uZJd%!#L|zj)=FnhCo^R~w3`X7+Sj<}KpE^0z4wPz6 z2}eIqMW(V;zA5YL#ty(}~*TB(LAhM>ZfGti%0_QVrxP z0ido2Dxt6S4`rKKHKhrS<6V5yDYzEhXTKhrmb*wlA)c?j2HpghXfKHC=-=krPAIKp-I2}I_l6z;AFIa+-jUa&#{0WjHzQocVIJufCPx!N-gadkdl&Nz zTNN)kX8f6WTTctK3AQ65+nzuoJ&1Zh$knt470`9vA*qb%D5N3-oQtuODvbLbf`}*o z5yK>@&}G0H@Q7xvs244m`8Igg2BG)htEPGGJ25-X$42BV!+2;o&_PA!SCc4BD&I^? zH)JGo!LrS_waG|Y#ow-4fr-Ey{de+N?9sT2Seg5K(>3@rlxX(^7iAyO9HOXZ6X*n+ zv~wlL$|mG%!#y2-Oa#svPdgVy=M?ud%VZf{OQ;OIqi!zzhw^pW5U98QjfCMj=DnUa zi5kLZ+@Rt(mgL8VcFNP-XM`OIuje;&9zKO^w!ZMw(~~H#1#8q1pcB+w|Aj2ad|iAU zjXM7|9)=&G1$&QhCRbUek^G~510DdIX*-Hr(BW*)P;dL!=mmJF>8{J%m?$yWt>mZl zpF#V9=c@nspOV8(7N5sbgM5Uvvpn-2Plkzq@Z!ogm;sdpD3=>o&+C*r?e?CzwBX-7dG0jV>pk4{2wL)d31`SKD;CUZ&|H` zs|$iFz&Dy3qFP#I_Eykg>xtficbM9`XUBfT)w1`=eV7Q$0p_Si@P8+bYntxcZ5fR8 zK~`J(`O2GqAhqX@R80ZKKxzF|`Azl(-1gWI_d(NNa0m3MZC_BGoln~Y!sC@uP9J6O4+A1DOw{FLgRd2gcM3gfi+s={bRv^&R4Xdz-)Tpm83d zz&Wq@0XwPRFh`l@f<#B6mG`lkj{Jg*vaa`EN>M2r1cTM@fF4klevj-kR*RCMv5hN@ ziNG23o;?~moD0)o@h{qY;5l%Vc7=E~y=`u5XsLZ8rpC=ot6f_g_m{4-g0i*x0nk-| ztOf+Nq;Kh5u**06~A&0;^)S39+vqX97np@o(0-x zHc?*-J8HUv0%)&puGGXhR=5*UIVWLBZfAVx7!rXBGZ<{?cpU*62d>mqh)A?^nJ&SN zHWAtYzcw9mXJR_MfkRb5m7w~AXAXnmcM-sO$zc={#DgZOayM| z2g+NsWjK7y=$c@<1W!R**wvxSSqfb&=4*F@F!)Z}UGim_IzK5acD%*Z_`dP2^L+G= z;(KOSnONT*$^d)RF~JX%*6FbSp!Fh>g14BTcxER~5jJuq$~vqgE*U7wx!mo95s7P_ zRpvN+4LN5e2KuKDP-w!FY8==GV(6R6{$}nf?up)W-oiw{ghGz*!sYo@Wz`ag)&%YZ zNm_z9MBA68g%tKN=oNUDDdLjG+T;4N$I0*MtD#N63DqG!fqb#)4`01yJkkNFwv@c~ zWGj-Br&RU9)L3aq${8FQ-WJ>LerS3KE6`tU!-5YpO4@MIR?QT!1P;?>B|{mkLdVDz zM|14HYmBX(ZzFYuBx9ZQgiZ{VgOH|9_$^hIxgKa~3nDNqH48i&<2UeUIr9}$F%h5x zr_mO*{j9 ziC6>f75kB#h^g@ypsz~7pG{IXweu~o^hBzVBbFt;g-!QJHT;#TMZg!3S-)AnnmrFU zBBpY$Gu?pup>f-*;P~tX+H;XzvljG%MBO^c?XvIk=fYKvI3@x|jkV5+(a($PnJH;l z*Bs)3&(z(8DCLKA=fG?010(=XHgEJu5>ulsvqUHV7}T5bcR0B*T@8{K+zW^xGb0&|BVLi&BE4P%QA-KiuO5p1nj6CB(9{# zvLi#)_Jx=l_con#J!nKrah5{kwl z;L@S-`c1Ep$609ULE|}BZ%hOhqSf}1p;)$(K1keA zdjxzAUePinEz7LAX5payZ%hO%MuN*14HN~e8rdiM&X5V2swn4H5hRI?p2_9_`~Z1v?Hu?ajZ(S`pQ(9Zb11A!N!K$! zFHVfsI*(!<5l5>W=fdCRZx1O%SlW8g1T%@Eu%nS?uYEz&O;fJQ}Zd;6oXK<2oB_;xW0Hw;wYeljrOWtZr z2V^`_Z~4P_v57!F#6O|h1XM%!^yB1x+3j)CSj08UbOj!RD(tk-zHEp_5R~Ed^ff5t!QD=bmkXYYU_MZ7i@qF~H z^FAg5Z&9&hQdph;vaGx0ofZangM6)6Orc-SszWX8Q_xHB1e4K)kI8VY*#qS_Fg4x@ zTvc7=E65E^fBRlrrXX#QzLtQuAt@w1<1thfm>NR{s=~n0;WM#A?$@RUScGo0bqSu! zkZ2X6m6~xN0UEDkOJ^{k!jQ;AM?37kCmB`FJCS1r6JxG)uTB7U2RmwB3pY{QXPyPR z+2Y7Mm}SO!=Ee`=f8|V8jK)NO2V7T9;QdOhN^bNLEplWTBC$62_es@~0RdTk6&L`m z)dywQS^G;{8&|uUVIr^*U25MNYMWb4Um+f)JqO+cf6>C?F#T{Y8oFmcgQ;=a*v4gO zY*G4*wMSN^?*Y972CFv+sFX{o_5M-TUl2O-*xbtNNPxsNcaGAJCHV)#c}0Q4BcSmw zJiW~h_#Kk6&I$aJmQfc7V`?3!gI?+$N(VAUMKC(s`6HGgx^bz)9lnysetSs_x<+sj zct^8WG@qu8|1++4_K$We&S5&Gb{zysK%;tuFhMzx9vZM&pCVrPbMpdEDc*^ogUjw`63|E@ELSU-%8G4 zCrakVyRM<83-H%yZ+p)WoNYnxC~l@b0KNhL(P|`>Wtm)$aKZiv6M=t?QRnOE>tc@i zg{-;06BGf~s9y^vQRwLx{>|1?NFM&p?DgzOJR_Xuwo^XF?)$hQsQ8RqLuixO>zQK? z!+#_7Rzu+H^gop5!aM3RuqBkymC5!q=M)!3Pdm?IBH%}RI%>lc^2f^NN}9Aza1$8S zI7E+Vi?W^|(f%1G0^gg;+!e9WxLNG)PO=Tbs7|E?7n)-H@3Um5m&cY9hTSAhOvJ6Wqew)^jFq#=qojRm{UWB5&BP z1bL&0!sJM=!^}lCBi*dy{cBS1$m0Ybs~-Wqp_%$SvX!g}rRv5JE*7T7`_QBId!cW0 zb@U73CEA161@legT@xB>N}E{^Wnb$np?UyNR|y)(i&De= z9jqG=KH@h|@@kXQiEVkcN*pj9`r5EgQO|jeKNyERfY}JU5vA>D0GAm>JtdTC+JSQD zPhG8)#e7!?MtVBuV<*)ZF$0Mt@*No-Nu@3aehKc=%oKH`Su@jv$8CDF9=>f_=5B~Z za0#|U&c{Sx2=K8g%fq}SDez6QbVf!Y*DSTZJxxB+kNjU%Yk<$8=lVJF3GBhRwlTbW z0VV>~D8n8LuFbxsg+wXMkDv{VYG+FJlnu{s4VOFImaNSGAS($+_u&sQFwv*n5SD}5n&8BsrVi1hzEw|$^+cHgmnqCXOB4xpFw`I!hTVD zHpL{^td0R4AiaL5EXurJd=T|GUtl8e6eT+_+sY3s`$*EDeGBddwc3_qHT`L}L#V5L z7J3n`Ha&Lv8*w->yOaE!ei*a|c%XX9??ARUz4ciwGm(}^x#d6a#bl0%S(~y0rpE0I zGDTNTH#|3f$!#`0h1uw0n<2P66QDK|eXAJ>QlMEny>tbmXJK08y`w9ZhZ5iYZj5i0}qvIR!t2m!2hG88c2O5+MdFP3e ziw5})p<4z1mZ_CwwjilGhpF*4eM$C`b)|H)ahI#T=?{1n`j!2=kR&&YULzi;JqDuT;)jFYBYPfXu)+^?ret@+7s_Kg0SzgpAagW!@(V zCeg;l3>8cAcEeqTklTU~kJorUGrQpzh}Swga5{~nP7pfPau9%wIz&2!*}7N}UE%x% z>j<2&#_=+|KW{BNED`I%;8O6Z=DcVNtvq`nXtY(Jb?`5yF7Cy#THI0gF?m=&8ma+i zs0Q#4kcKsl^=-EdMEW5cEmgkCrk_aN`4dz#FcFCB&&toSkK)$Gdb{_U?!cYVe{IKu zEwZa<$3*u(?8rXs$Uf}IKJ3Uo?8rXs$Uf}IKJ3Uo?8rXs$Uf}IKJ3Uo?8rXs$Uf}I zKJ3Uo?8rXs$Uf}IKJ3Uo?8rXs$Uf}IKJ3Uo?8yFKc4W(iCbb8608Z6Skd_$a;*@CC zIn8tu(V6Bue~JY2N6TJ{|Iu^@P8t;InL-zJeD+MJ*71X>uMuzk*!x@jChjcjl(d=F zU?|s}lP~5HNSxH{z$LrP+`}w#gaYeQT}T7CM7cnFOt)U$LUM>X71um*)Hl$suxzka zxsOLWu6t)-Ra{*VQVX1eVy+yOvBjM@Ln*uR)8fS7 z_nwLF|GYnhcQ<)UAJY}Qed3_>AIVTbTjn>!%50+XRG^#J;2j_Mrg3>@3c+6Xl2TRGIiFzme3DspT&dy%FgI&scjXn~Jxa21bR!Gyd+u zAEU{pUy7eo02YrwU)UtL%x%t4khbPGC27$o!QR2eVQG9x<`S-+dXe=z4-_PL{n;mJ zR=hWRIiZU_3sJ&%qT7=9b889z(d?`k_b?CTxEUiTkuA zev6gl#5mVk2k4ziLyBk9`oy5dAEKVdu1R8cS4l_q(JNWE*Z})~j3w0ZgnV9-9-HVF z)5W~;(Wz*Th7Xd%^tH@tRzK!u`a1F>96tX(Wl1cHzmGeU!!r8|tq4;nN9pSr^^B=y zzfvC%SC_`;f~iwUW8z@)c}kiil!}Bk6gIu8OjEXz)<7Oe*i>ZXhNjOpO>CN(+M79< zSKyWthf})H60~n5*$HcyK3)|B+i#ms0 zY!1nE&-|Qe$X>|fN>lLbh;nixIZ9TO4-ik`HB8aEez27es?CBA@Lj?0!dmC8%360_8*^kwOfQn;kY^~Mdx^}@Bp@p1nL Djet!U literal 0 HcmV?d00001 diff --git a/web/sounds/default.wav b/web/sounds/default.wav new file mode 100644 index 0000000000000000000000000000000000000000..b0018352dd93068b92c11663779abd7d90cb46d9 GIT binary patch literal 13274 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 f06kzTA1f~}PcB!Nr Date: Fri, 28 Aug 2026 23:09:33 +0200 Subject: [PATCH 13/17] Ignore pytest cache directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 905cc91..798ecda 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ node_modules/ *.log .DS_Store *.sqlite3 +.pytest_cache/ From 2f07a80516c4e4c25a11ae3dddcee39c1d36da17 Mon Sep 17 00:00:00 2001 From: rubz Date: Fri, 28 Aug 2026 23:11:18 +0200 Subject: [PATCH 14/17] Update spec status: v1 implemented (Tasks 1-9), manual UI checks pending --- docs/specs/2026-08-28-smart-queue-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/2026-08-28-smart-queue-design.md b/docs/specs/2026-08-28-smart-queue-design.md index 8496206..33e662f 100644 --- a/docs/specs/2026-08-28-smart-queue-design.md +++ b/docs/specs/2026-08-28-smart-queue-design.md @@ -1,6 +1,6 @@ # Smart Queue — Design Spec -Status: Draft, awaiting user review +Status: v1 implemented (Tasks 1-9 automated, all tests green; Tasks 10-11 frontend/UI still need manual verification against a running ComfyUI instance per spec §9) Date: 2026-08-28 Repo: `comfyui-smart-queue` (D:\AI\ComfyUI-AGAIN\ComfyUI\custom_nodes\comfyui-smart-queue) GitHub: `github.com/CraftopiaStudio/comfyui-smart-queue` (alongside ComfyUI-WorkflowOrganizer and CraftKit) From 224853a71c57aaa1c16a7014f485544a2f499f52 Mon Sep 17 00:00:00 2001 From: rubz Date: Sat, 29 Aug 2026 01:03:39 +0200 Subject: [PATCH 15/17] Wire queue tracking to ComfyUI's live prompt queue, add manual pause Manual verification against a running ComfyUI instance surfaced four gaps unit tests couldn't catch: the queue panel and history were never actually populated (add_queue_item/mark_completed/record_job_started existed only in tests), smart_queue.css was never linked into the page, no manual pause control existed despite spec Section 6 requiring one, and RubzGpuCooldownNode is shadowed by the old rubz-gpu-cooldown pack's registration. - Add backend/queue_tracker.py: mirrors PromptServer's live prompt_queue into Smart Queue's own persistence and the autopilot job counter, including jobs that start and finish within a single poll tick. - Add manual pause/resume: AutopilotState.manual_paused/effective_paused, POST /smart_queue/manual_pause, middleware gates on the combined state. - Fix CSS not being loaded; land the pause control as a small icon button inserted into ComfyUI's own Run-button toolbar row (real flex child, not a position:fixed overlay guessing coordinates) after two placements that overlapped other UI were tried and rejected. - Document all findings in spec Section 13 and update plan checkboxes to match actual verified state. --- __init__.py | 22 ++- backend/autopilot_state.py | 16 ++ backend/queue_middleware.py | 4 +- backend/queue_tracker.py | 78 ++++++++ backend/routes.py | 13 +- docs/plans/2026-08-28-smart-queue-v1.md | 133 +++++++------ docs/specs/2026-08-28-smart-queue-design.md | 52 ++++- tests/test_autopilot_state.py | 30 +++ tests/test_queue_middleware.py | 14 ++ tests/test_queue_tracker.py | 114 +++++++++++ tests/test_routes.py | 18 ++ web/smart_queue.css | 94 +++++++++- web/smart_queue.js | 198 ++++++++++++++++---- 13 files changed, 680 insertions(+), 106 deletions(-) create mode 100644 backend/queue_tracker.py create mode 100644 tests/test_queue_tracker.py diff --git a/__init__.py b/__init__.py index 928e34d..e49fb1a 100644 --- a/__init__.py +++ b/__init__.py @@ -17,6 +17,7 @@ from .backend.nodes.cooldown import SmartCooldownNode from .backend.persistence import init_db from .backend.queue_middleware import create_queue_middleware +from .backend.queue_tracker import sync_queue_tracker from .backend.routes import register_routes logger = logging.getLogger(__name__) @@ -33,6 +34,8 @@ _autopilot_state = AutopilotState() _autopilot_settings = AutopilotSettings() +_seen_running: set = set() +_seen_completed: set = set() TICK_INTERVAL_SECONDS = 5.0 @@ -42,10 +45,25 @@ async def _async_poll_gpu_metrics(): return await asyncio.to_thread(poll_gpu_metrics) -async def _autopilot_background_loop(): +def _sync_queue_tracker_tick(conn) -> None: + global _seen_running, _seen_completed + running, queued = _server.prompt_queue.get_current_queue_volatile() + history = _server.prompt_queue.get_history() + _seen_running, _seen_completed = sync_queue_tracker( + conn, running, queued, history, _autopilot_state, _seen_running, _seen_completed + ) + + +async def _autopilot_background_loop(conn): while True: if _autopilot_settings.master_enabled: await run_autopilot_tick(_autopilot_state, _autopilot_settings, _async_poll_gpu_metrics) + try: + # Sync sqlite3 connections are bound to the thread that created them + # (this loop's thread), so this must run inline, not via asyncio.to_thread. + _sync_queue_tracker_tick(conn) + except Exception: + logger.warning("Smart Queue: queue tracker sync failed, skipping this tick", exc_info=True) await asyncio.sleep(TICK_INTERVAL_SECONDS) @@ -62,7 +80,7 @@ def _is_autopilot_enabled() -> bool: register_routes(_server.app, _conn, _autopilot_state, _autopilot_settings) async def _start_autopilot_loop(app): - app["smart_queue_autopilot_task"] = asyncio.create_task(_autopilot_background_loop()) + app["smart_queue_autopilot_task"] = asyncio.create_task(_autopilot_background_loop(_conn)) _server.app.on_startup.append(_start_autopilot_loop) diff --git a/backend/autopilot_state.py b/backend/autopilot_state.py index 3a7108b..018f882 100644 --- a/backend/autopilot_state.py +++ b/backend/autopilot_state.py @@ -8,6 +8,7 @@ def __init__(self) -> None: self.is_paused: bool = False self.jobs_since_resume: int = 0 self.last_reasons: tuple[str, ...] = () + self.manual_paused: bool = False def apply(self, decision: Decision) -> None: was_paused = self.is_paused @@ -18,3 +19,18 @@ def apply(self, decision: Decision) -> None: def record_job_started(self) -> None: self.jobs_since_resume += 1 + + def set_manual_pause(self, paused: bool) -> None: + self.manual_paused = paused + + @property + def effective_paused(self) -> bool: + """What the middleware actually gates on: autopilot OR a manual pause.""" + return self.is_paused or self.manual_paused + + @property + def effective_reasons(self) -> tuple[str, ...]: + reasons = self.last_reasons + if self.manual_paused: + reasons = ("Manually paused",) + reasons + return reasons diff --git a/backend/queue_middleware.py b/backend/queue_middleware.py index 854a371..06c630f 100644 --- a/backend/queue_middleware.py +++ b/backend/queue_middleware.py @@ -10,8 +10,8 @@ def create_queue_middleware(state: AutopilotState, is_enabled: Callable[[], bool]): @web.middleware async def queue_middleware(request: web.Request, handler): - if is_enabled() and state.is_paused and request.path == "/prompt" and request.method == "POST": - reason = "; ".join(state.last_reasons) or "Queue paused" + if is_enabled() and state.effective_paused and request.path == "/prompt" and request.method == "POST": + reason = "; ".join(state.effective_reasons) or "Queue paused" return web.json_response({"error": f"Queue paused: {reason}"}, status=423) return await handler(request) diff --git a/backend/queue_tracker.py b/backend/queue_tracker.py new file mode 100644 index 0000000..00bebf8 --- /dev/null +++ b/backend/queue_tracker.py @@ -0,0 +1,78 @@ +"""Mirrors ComfyUI's live prompt queue into Smart Queue's own persistence and +autopilot job counter. + +Read-only against ComfyUI's own queue (get_current_queue_volatile / get_history) +so this never intercepts or delays /prompt itself, consistent with the +"non-intrusive" architecture (spec Section 5). Called on a periodic tick from +the autopilot background loop; any exception here must not affect that loop +(fail-open lives in the caller, same pattern as run_autopilot_tick). +""" + +from datetime import datetime + +from .autopilot_state import AutopilotState +from .persistence import add_queue_item, list_queue_items, mark_completed + + +def extract_job_name(extra_data: dict) -> str: + """Best-effort name from the workflow metadata ComfyUI already sends with + every /prompt call. Falls back to a timestamp if nothing usable is found.""" + try: + workflow = extra_data.get("extra_pnginfo", {}).get("workflow", {}) or {} + extra = workflow.get("extra") + name = extra.get("workflow_name") if isinstance(extra, dict) else None + if not name: + name = workflow.get("name") + if name: + return str(name) + except AttributeError: + pass + return datetime.now().strftime("Job %Y-%m-%d %H:%M:%S") + + +def sync_queue_tracker( + conn, + running: list, + queued: list, + history: dict, + autopilot_state: AutopilotState, + seen_running: set, + seen_completed: set, +) -> tuple[set, set]: + """One sync tick. Returns the updated (seen_running, seen_completed) sets. + + seen_running: prompt_ids already counted via record_job_started(), so a + job still running on the next tick isn't double-counted. + seen_completed: prompt_ids already written to the history table, so a + prompt_id lingering in ComfyUI's in-memory history isn't re-processed + every tick. Also covers jobs that start and finish inside a single tick + gap (never observed as running/queued) by adding them retroactively from + the history entry's own metadata before marking them complete. + """ + known_ids = {item["prompt_id"] for item in list_queue_items(conn)} + + for item in running + queued: + prompt_id = item[1] + extra_data = item[3] if len(item) > 3 else {} + if prompt_id not in known_ids and prompt_id not in seen_completed: + add_queue_item(conn, prompt_id=prompt_id, name=extract_job_name(extra_data)) + known_ids = known_ids | {prompt_id} + + for item in running: + prompt_id = item[1] + if prompt_id not in seen_running: + autopilot_state.record_job_started() + seen_running = seen_running | {prompt_id} + + for prompt_id, entry in history.items(): + if prompt_id in seen_completed: + continue + if prompt_id not in known_ids: + # Started and finished inside one tick gap: never seen running/queued. + history_item = entry.get("prompt") if isinstance(entry, dict) else None + extra_data = history_item[3] if history_item and len(history_item) > 3 else {} + add_queue_item(conn, prompt_id=prompt_id, name=extract_job_name(extra_data)) + mark_completed(conn, prompt_id=prompt_id) + seen_completed = seen_completed | {prompt_id} + + return seen_running, seen_completed diff --git a/backend/routes.py b/backend/routes.py index c26e6cf..0009f23 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -19,7 +19,17 @@ def register_routes( settings: AutopilotSettings, ) -> None: async def get_status(request: web.Request) -> web.Response: - return web.json_response({"is_paused": state.is_paused, "reasons": list(state.last_reasons)}) + return web.json_response({ + "is_paused": state.effective_paused, + "reasons": list(state.effective_reasons), + "manual_paused": state.manual_paused, + "autopilot_paused": state.is_paused, + }) + + async def post_manual_pause(request: web.Request) -> web.Response: + payload = await request.json() + state.set_manual_pause(bool(payload.get("paused", True))) + return web.json_response({"ok": True, "manual_paused": state.manual_paused}) async def get_queue(request: web.Request) -> web.Response: return web.json_response({"items": list_queue_items(conn)}) @@ -52,3 +62,4 @@ async def post_continue(request: web.Request) -> web.Response: app.router.add_get("/smart_queue/settings", get_settings) app.router.add_post("/smart_queue/settings", post_settings) app.router.add_post("/smart_queue/continue/{prompt_id}", post_continue) + app.router.add_post("/smart_queue/manual_pause", post_manual_pause) diff --git a/docs/plans/2026-08-28-smart-queue-v1.md b/docs/plans/2026-08-28-smart-queue-v1.md index be977b1..6a84bbe 100644 --- a/docs/plans/2026-08-28-smart-queue-v1.md +++ b/docs/plans/2026-08-28-smart-queue-v1.md @@ -1,6 +1,6 @@ # Smart Queue v1 Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. **Goal:** Build v1 of Smart Queue — a ComfyUI custom node pack combining (a) a queue-level "autopilot" that pauses/resumes the render queue based on live GPU temperature/VRAM/job-count, and (b) a consolidated "Smart Cooldown & Pause" in-graph node that folds in cooldown timing, GPU temp waiting, manual pause, notifications, and model unloading. @@ -68,7 +68,7 @@ comfyui-smart-queue/ **Interfaces:** - Produces: `GpuMetrics` dataclass with fields `temp_c: float | None`, `vram_used_mb: float | None`, `vram_total_mb: float | None`, `util_pct: float | None`, and a `vram_free_mb` property. `poll_gpu_metrics(timeout: float = 5.0) -> GpuMetrics`. -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** ```python # tests/test_gpu_monitor.py @@ -123,12 +123,12 @@ def test_poll_returns_all_none_on_timeout(mock_run): assert metrics.temp_c is None ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_gpu_monitor.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'backend'` -- [ ] **Step 3: Write the implementation** +- [x] **Step 3: Write the implementation** ```python # backend/gpu_monitor.py @@ -182,12 +182,12 @@ def poll_gpu_metrics(timeout: float = 5.0) -> GpuMetrics: return _EMPTY ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/test_gpu_monitor.py -v` Expected: 6 passed -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add backend/__init__.py backend/gpu_monitor.py tests/test_gpu_monitor.py @@ -206,7 +206,7 @@ git commit -m "Add GPU metrics polling via nvidia-smi" - Consumes: `GpuMetrics` from Task 1 (`backend.gpu_monitor`). - Produces: `AutopilotSettings` dataclass, `Decision` dataclass (`should_pause: bool`, `reasons: tuple[str, ...]`), and `evaluate(metrics: GpuMetrics, jobs_since_resume: int, currently_paused: bool, settings: AutopilotSettings) -> Decision`. -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** ```python # tests/test_autopilot.py @@ -306,12 +306,12 @@ def test_multiple_reasons_all_reported(): assert len(decision.reasons) == 2 ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_autopilot.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'backend.autopilot'` -- [ ] **Step 3: Write the implementation** +- [x] **Step 3: Write the implementation** ```python # backend/autopilot.py @@ -380,12 +380,12 @@ def evaluate( return Decision(should_pause=len(reasons) > 0, reasons=tuple(reasons)) ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/test_autopilot.py -v` Expected: 12 passed -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add backend/autopilot.py tests/test_autopilot.py @@ -404,7 +404,7 @@ git commit -m "Add autopilot rule engine as a pure, testable function" - Consumes: `Decision` from Task 2 (`backend.autopilot`). - Produces: `AutopilotState` class with `is_paused: bool`, `jobs_since_resume: int`, `last_reasons: tuple[str, ...]` attributes, and a method `apply(decision: Decision) -> None` that mutates state (resets `jobs_since_resume` to 0 on transition from paused to resumed) plus a method `record_job_started() -> None` that increments `jobs_since_resume`. -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** ```python # tests/test_autopilot_state.py @@ -449,12 +449,12 @@ def test_apply_resume_when_already_resumed_does_not_reset_counter(): assert state.jobs_since_resume == 1 ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_autopilot_state.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'backend.autopilot_state'` -- [ ] **Step 3: Write the implementation** +- [x] **Step 3: Write the implementation** ```python # backend/autopilot_state.py @@ -480,12 +480,12 @@ class AutopilotState: self.jobs_since_resume += 1 ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/test_autopilot_state.py -v` Expected: 5 passed -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add backend/autopilot_state.py tests/test_autopilot_state.py @@ -503,7 +503,7 @@ git commit -m "Add autopilot state machine with job-counter reset on resume" **Interfaces:** - Produces: `init_db(db_path: str) -> sqlite3.Connection`, `add_queue_item(conn, prompt_id: str, name: str) -> int`, `list_queue_items(conn) -> list[dict]`, `reorder_queue_items(conn, ordered_prompt_ids: list[str]) -> None`, `remove_queue_item(conn, prompt_id: str) -> None`, `mark_completed(conn, prompt_id: str, thumbnail_path: str | None = None) -> None`, `list_history(conn, limit: int = 50) -> list[dict]`. -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** ```python # tests/test_persistence.py @@ -573,12 +573,12 @@ def test_persistence_survives_reopening_the_same_file(tmp_path): assert items[0]["prompt_id"] == "a" ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_persistence.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'backend.persistence'` -- [ ] **Step 3: Write the implementation** +- [x] **Step 3: Write the implementation** ```python # backend/persistence.py @@ -659,12 +659,12 @@ def list_history(conn: sqlite3.Connection, limit: int = 50) -> list[dict]: return [dict(row) for row in rows] ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/test_persistence.py -v` Expected: 6 passed -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add backend/persistence.py tests/test_persistence.py @@ -683,7 +683,7 @@ git commit -m "Add SQLite persistence for queue order and history" - Consumes: `AutopilotState` from Task 3 (reads `.is_paused` and `.last_reasons`). - Produces: `create_queue_middleware(state: AutopilotState, is_enabled: Callable[[], bool]) -> Callable` — an aiohttp middleware factory. -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** ```python # tests/test_queue_middleware.py @@ -744,12 +744,12 @@ class TestQueueMiddlewareDisabled(AioHTTPTestCase): assert resp.status == 200 ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_queue_middleware.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'backend.queue_middleware'` -- [ ] **Step 3: Write the implementation** +- [x] **Step 3: Write the implementation** ```python # backend/queue_middleware.py @@ -773,12 +773,12 @@ def create_queue_middleware(state: AutopilotState, is_enabled: Callable[[], bool return queue_middleware ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/test_queue_middleware.py -v` Expected: 3 passed -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add backend/queue_middleware.py tests/test_queue_middleware.py @@ -797,7 +797,7 @@ git commit -m "Add non-intrusive middleware that gates /prompt while paused" - Consumes: `poll_gpu_metrics` (Task 1, injected as a callable so tests can fake it), `evaluate` (Task 2), `AutopilotState` (Task 3), `AutopilotSettings` (Task 2). - Produces: `async def run_autopilot_tick(state: AutopilotState, settings: AutopilotSettings, metrics_provider: Callable[[], GpuMetrics]) -> None` — runs exactly one poll-evaluate-apply cycle, fail-open on any exception from `metrics_provider`. -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** ```python # tests/test_autopilot_loop.py @@ -845,14 +845,14 @@ async def test_tick_fails_open_when_metrics_provider_raises(): assert state.is_paused is False ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_autopilot_loop.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'backend.autopilot_loop'` (Note: this task needs `pytest-asyncio`. Add it to CI in Step 5 alongside the commit — see the CI update below.) -- [ ] **Step 3: Write the implementation** +- [x] **Step 3: Write the implementation** ```python # backend/autopilot_loop.py @@ -894,12 +894,12 @@ async def run_autopilot_tick( state.apply(decision) ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `pip install pytest-asyncio && pytest tests/test_autopilot_loop.py -v` Expected: 3 passed -- [ ] **Step 5: Update CI to install pytest-asyncio, then commit** +- [x] **Step 5: Update CI to install pytest-asyncio, then commit** ```yaml # .github/workflows/ci.yml — change the "pip install pytest" line to: @@ -932,7 +932,7 @@ git commit -m "Add background autopilot polling tick with fail-open error handli - Consumes: `poll_gpu_metrics` from Task 1. - Produces: `SmartCooldownNode` class (V3 schema, node type ID `RubzGpuCooldownNode`) and a plain function `run_cooldown(fixed_delay_seconds, wait_for_temp, target_temp_c, poll_interval_seconds, max_wait_seconds, unload_models_before_wait, sleep_fn, metrics_fn, unload_fn) -> str` (the testable core logic, wrapped by the V3 node class so tests don't need a real ComfyUI runtime). -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** ```python # tests/test_cooldown_node.py @@ -1033,12 +1033,12 @@ def _make_fake_clock(elapsed): return clock ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_cooldown_node.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'backend.nodes.cooldown'` -- [ ] **Step 3: Write the implementation** +- [x] **Step 3: Write the implementation** ```python # backend/nodes/cooldown.py @@ -1146,13 +1146,13 @@ class SmartCooldownNode(io.ComfyNode): **Note for the implementer:** `comfy_api.latest.io` is only importable inside a running ComfyUI installation. `run_cooldown` (the function under test) has zero ComfyUI imports, which is why the tests above import only `backend.nodes.cooldown.run_cooldown` and never instantiate `SmartCooldownNode` — that class is verified manually in Task 9's smoke check, not by pytest. If the exact `io.*` schema API names above don't match the ComfyUI version installed at build time, consult `https://docs.comfy.org/custom-nodes/v3_migration` and adjust `define_schema`/`execute` accordingly — `run_cooldown`'s signature and behavior must not change to accommodate that. -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/test_cooldown_node.py -v` Expected: 5 passed (This will fail at import time if `comfy_api.latest` isn't installed — if so, wrap the `from comfy_api.latest import io` import in a `try/except ImportError` at module scope so `run_cooldown` remains importable standalone; the tests only need `run_cooldown`.) -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add backend/nodes/__init__.py backend/nodes/cooldown.py tests/test_cooldown_node.py @@ -1171,7 +1171,7 @@ git commit -m "Add Smart Cooldown & Pause node (V3 schema, testable core logic)" - Consumes: `persistence` module (Task 4), `AutopilotState` (Task 3), `AutopilotSettings` (Task 2). - Produces: `register_routes(app: web.Application, conn: sqlite3.Connection, state: AutopilotState, settings: AutopilotSettings) -> None`, adding `GET /smart_queue/status`, `GET /smart_queue/queue`, `POST /smart_queue/reorder`, `GET /smart_queue/history`, `GET /smart_queue/settings`, `POST /smart_queue/settings`. -- [ ] **Step 1: Write the failing tests** +- [x] **Step 1: Write the failing tests** ```python # tests/test_routes.py @@ -1246,12 +1246,12 @@ class TestSmartQueueRoutes(AioHTTPTestCase): assert self.settings.master_enabled is False ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_routes.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'backend.routes'` -- [ ] **Step 3: Write the implementation** +- [x] **Step 3: Write the implementation** ```python # backend/routes.py @@ -1304,12 +1304,12 @@ def register_routes( app.router.add_post("/smart_queue/settings", post_settings) ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/test_routes.py -v` Expected: 7 passed -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add backend/routes.py tests/test_routes.py @@ -1328,7 +1328,7 @@ git commit -m "Add HTTP routes for the Smart Queue panel" - Consumes: `SmartCooldownNode` (Task 7), `create_queue_middleware` (Task 5), `register_routes` (Task 8), `run_autopilot_tick` (Task 6), `AutopilotSettings` (Task 2), `init_db` (Task 4). - Produces: `NODE_CLASS_MAPPINGS = {"RubzGpuCooldownNode": SmartCooldownNode}`, `NODE_DISPLAY_NAME_MAPPINGS = {"RubzGpuCooldownNode": "Smart Cooldown & Pause"}`, `WEB_DIRECTORY = "./web"`, plus a running background asyncio task that ticks the autopilot loop every 5 seconds for as long as ComfyUI's server is up. -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** ```python # tests/test_smoke.py — replace the existing body with: @@ -1364,12 +1364,12 @@ def test_web_directory_is_declared(): assert module.WEB_DIRECTORY == "./web" ``` -- [ ] **Step 2: Run tests to verify they fail** +- [x] **Step 2: Run tests to verify they fail** Run: `pytest tests/test_smoke.py -v` Expected: FAIL — `RubzGpuCooldownNode` not in `NODE_CLASS_MAPPINGS` (currently empty dict) -- [ ] **Step 3: Write the implementation** +- [x] **Step 3: Write the implementation** ```python # __init__.py @@ -1444,12 +1444,12 @@ if _HAS_COMFY_SERVER: logger.info("[Smart Queue] Loaded — autopilot + Smart Cooldown & Pause node registered.") ``` -- [ ] **Step 4: Run tests to verify they pass** +- [x] **Step 4: Run tests to verify they pass** Run: `pytest tests/ -v` Expected: all tests pass (the `_HAS_COMFY_SERVER` guard means `__init__.py` degrades gracefully outside a real ComfyUI process, which is exactly the environment pytest runs in) -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add __init__.py tests/test_smoke.py @@ -1466,7 +1466,7 @@ git commit -m "Wire autopilot, middleware, routes, and node into package init" This task has no automated test — it requires a running ComfyUI instance (per spec §9, the panel/theme/Nodes-2.0 checks are explicitly manual). Do not skip the manual verification steps below; they are the acceptance criteria for this task. -- [ ] **Step 1: Write `web/smart_queue.js`** +- [x] **Step 1: Write `web/smart_queue.js`** ```javascript import { app } from "../../scripts/app.js"; @@ -1609,7 +1609,7 @@ app.registerExtension({ }); ``` -- [ ] **Step 2: Write `web/smart_queue.css`** +- [x] **Step 2: Write `web/smart_queue.css`** ```css #smart-queue-panel { @@ -1639,19 +1639,19 @@ app.registerExtension({ } ``` -- [ ] **Step 3: Manual verification against a running ComfyUI instance** +- [x] **Step 3: Manual verification against a running ComfyUI instance** *(items 1,2,3,5,6 verified 2026-08-29; item 4 — Nodes 2.0 toggle — still pending; item 7 verified via the master-toggle-off code path, not re-tested live after the toolbar-button rework)* Use the `run` skill to launch ComfyUI with this pack installed, then check: -1. Open **Settings → Smart Queue** — confirm the master toggle, 3 rule toggles, and 4 threshold fields from Step 1 appear with their correct defaults (80/72°C, 1024MB, 20 jobs), and confirm `GET /smart_queue/settings` reflects them after a few seconds (the periodic sync). -2. With the master toggle ON, confirm the panel appears somewhere in the UI and shows "Smart Queue: running". -3. Queue a workflow; confirm it appears in the panel's list with its actual name, not a timestamp. -4. Switch **Settings → Comfy → Modern Node Design (Nodes 2.0)** OFF, reload, repeat steps 2-3. Then switch it back ON, reload, repeat again. Both must work identically. -5. Switch **Settings → Appearance** to a different theme (e.g. light, or any installed custom theme), reload, and visually confirm the panel's colors follow the theme rather than staying hardcoded. -6. If ComfyUI-Crystools is installed and enabled, confirm the panel's GPU readout is hidden (queue status still shows). If not installed, confirm the GPU readout is shown. -7. Turn the master toggle OFF, reload, confirm the panel does not appear at all. +1. [x] Open **Settings → Smart Queue** — confirm the master toggle, 3 rule toggles, and 4 threshold fields from Step 1 appear with their correct defaults (80/72°C, 1024MB, 20 jobs), and confirm `GET /smart_queue/settings` reflects them after a few seconds (the periodic sync). +2. [x] With the master toggle ON, confirm the panel appears somewhere in the UI and shows "Smart Queue: running". +3. [x] Queue a workflow; confirm it appears in the panel's list with its actual name, not a timestamp. *(Required building `backend/queue_tracker.py` — this wasn't wired up at all before manual testing; see spec §13.)* +4. [ ] Switch **Settings → Comfy → Modern Node Design (Nodes 2.0)** OFF, reload, repeat steps 2-3. Then switch it back ON, reload, repeat again. Both must work identically. **Not yet done.** +5. [x] Switch **Settings → Appearance** to a different theme (e.g. light, or any installed custom theme), reload, and visually confirm the panel's colors follow the theme rather than staying hardcoded. +6. [x] If ComfyUI-Crystools is installed and enabled, confirm the panel's GPU readout is hidden (queue status still shows). If not installed, confirm the GPU readout is shown. +7. [x] Turn the master toggle OFF, reload, confirm the panel does not appear at all. -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add web/smart_queue.js web/smart_queue.css @@ -1668,7 +1668,15 @@ git commit -m "Add queue panel UI with theme-aware styling and Crystools detecti This task also has no automated test (browser audio/DOM only) — manual verification is the acceptance criteria, per spec §9. -- [ ] **Step 1: Write `web/smart_queue_node.js`** +**Blocked (2026-08-29):** `RubzGpuCooldownNode` is registered by both this +package and the old `rubz-gpu-cooldown` pack under the same node-type ID; +the old V1 node currently wins (loads later alphabetically, overwrites the +mapping) so the new node with sound/toast/continue-button support can't be +placed on a canvas to test. Old folder renamed to +`rubz-gpu-cooldown.disabled` to unblock the next verification pass — see +spec §13. Once a restart confirms the new node loads, resume at Step 2. + +- [x] **Step 1: Write `web/smart_queue_node.js`** *(also wired the Python side — `execute()` in `backend/nodes/cooldown.py` dispatches the `smart_queue.cooldown_notify` and `smart_queue.cooldown_wait_for_click` events and blocks via `backend/continue_registry.py` — beyond what this step's snippet below shows.)* ```javascript import { app } from "../../scripts/app.js"; @@ -1729,4 +1737,13 @@ git commit -m "Add node notification UI: continue button, toast, sound playback" Once all 11 tasks are verified (automated tests green in CI for Tasks 1-9, manual checks passed for Tasks 10-11): delete `D:\AI\ComfyUI-AGAIN\ComfyUI\custom_nodes\rubz-gpu-cooldown` per spec §10, and update the spec's "Status" line from "Draft" to "v1 implemented". +**Status as of 2026-08-29:** Tasks 1-9 done (70 automated tests green — up +from the original plan's count, four bugs found only by manual testing +required new code: `backend/queue_tracker.py`, the manual-pause additions to +`autopilot_state.py`/`queue_middleware.py`/`routes.py`, and CSS-loading / +toolbar-button fixes in `web/`). Task 10 verified except the Nodes 2.0 +toggle check. Task 11 blocked on the `rubz-gpu-cooldown` name collision — +old folder renamed to `.disabled`, not yet deleted; do that only after Task +11 passes and the toggle check is done. See spec §13 for full detail. + v2 features (rename jobs, filter/search, auto-archive, bulk operations, priority adjustment, cancel-and-requeue — spec §10) are out of scope for this plan and should get their own plan once v1 is in daily use. diff --git a/docs/specs/2026-08-28-smart-queue-design.md b/docs/specs/2026-08-28-smart-queue-design.md index 33e662f..cb014d8 100644 --- a/docs/specs/2026-08-28-smart-queue-design.md +++ b/docs/specs/2026-08-28-smart-queue-design.md @@ -1,7 +1,7 @@ # Smart Queue — Design Spec -Status: v1 implemented (Tasks 1-9 automated, all tests green; Tasks 10-11 frontend/UI still need manual verification against a running ComfyUI instance per spec §9) -Date: 2026-08-28 +Status: v1 implemented and manually verified against a running ComfyUI instance (Tasks 1-9 automated, 70 tests green; Task 10 UI verified — panel, settings sync, Crystools detection, theme, manual pause; Task 11 node-notification checks pending, blocked on the `rubz-gpu-cooldown` name collision, see §13) +Date: 2026-08-29 Repo: `comfyui-smart-queue` (D:\AI\ComfyUI-AGAIN\ComfyUI\custom_nodes\comfyui-smart-queue) GitHub: `github.com/CraftopiaStudio/comfyui-smart-queue` (alongside ComfyUI-WorkflowOrganizer and CraftKit) Display name: **Smart Queue** @@ -268,3 +268,51 @@ then purely as a safety net, not because it's still in use. - Hard dependency on Crystools for GPU data — Smart Queue always polls its own GPU metrics independently; Crystools detection (§6) only affects what's *displayed*, never the autopilot logic itself. + +## 13. Manual verification findings (2026-08-29) + +Live-tested against a running ComfyUI instance (Task 10 acceptance criteria, +§9). Confirmed working: settings panel + defaults, `/smart_queue/settings` +sync, Crystools GPU-readout suppression, theme-aware panel styling, queue and +history persistence including the fast-job edge case, and manual pause/resume +gating the middleware. Four real gaps were found this way — none caught by +the unit tests, since each was an integration/wiring gap, not a rule-engine +bug: + +- **Queue/history were never populated.** `add_queue_item` / + `mark_completed` / `record_job_started` existed only in tests — nothing in + `__init__.py` ever called them. Fixed by `backend/queue_tracker.py` + (Section 5-adjacent, read-only against `PromptServer.instance.prompt_queue` + via `get_current_queue_volatile()`/`get_history()`), ticked from the same + background loop as the autopilot poll. Handles the case of a job that + starts and finishes inside one tick gap (never observed "running") by + reading it retroactively from the history entry. +- **`web/smart_queue.css` was never linked into the page.** The panel ran on + browser-default styling. Fixed with a `` injected in `setup()`. +- **No manual pause/resume existed**, despite §6 requiring one. Added + `AutopilotState.manual_paused` / `effective_paused` / `effective_reasons`, + `POST /smart_queue/manual_pause`, and the middleware now gates on + `effective_paused` (autopilot OR manual). +- **UI placement for the manual-pause control took three iterations** before + landing: (1) inside the sidebar tab — blocked by an unrelated installed + extension's `position:fixed` overlay (`hNodeAlignKit` / "NodeAlignPro") + sitting in a higher ancestor stacking context that a child `z-index` can't + escape; (2) anchored beside ComfyUI's own Job Queue toggle button — the top + toolbar has zero free space at any window width once other extensions' + icons are accounted for, verified by measurement (a wide 1600px window + still had only ~34px free, not enough for a labeled button). Landed on: a + small icon-only square button (`.smart-queue-toolbar-btn`, matching the + native "Cancel current run" button's size) inserted as a real DOM sibling + inside ComfyUI's own button-group flex row, right after the batch-count/Run + group — letting the browser's own flex layout make room instead of + computing pixel offsets. A periodic re-insert check guards against Vue + dropping the manually-inserted node on re-render. +- **Blocked**: Task 11's node-notification checks (sound/toast/continue + button) could not be run — `RubzGpuCooldownNode` is registered by *both* + packages, and `rubz-gpu-cooldown` (old, V1-schema) wins over + `comfyui-smart-queue`'s new V3 node because it loads later alphabetically, + shadowing the new node's `NODE_CLASS_MAPPINGS` entry entirely. Confirmed via + `GET /object_info/RubzGpuCooldownNode` → `python_module: + custom_nodes.rubz-gpu-cooldown`. The old folder was renamed to + `rubz-gpu-cooldown.disabled` (ComfyUI's standard disable convention, fully + reversible) rather than deleted, pending the next verification pass. diff --git a/tests/test_autopilot_state.py b/tests/test_autopilot_state.py index 0a72a05..37fb8cd 100644 --- a/tests/test_autopilot_state.py +++ b/tests/test_autopilot_state.py @@ -37,3 +37,33 @@ def test_apply_resume_when_already_resumed_does_not_reset_counter(): state.record_job_started() state.apply(Decision(should_pause=False, reasons=())) assert state.jobs_since_resume == 1 + + +def test_manual_pause_alone_sets_effective_paused(): + state = AutopilotState() + state.set_manual_pause(True) + assert state.effective_paused is True + assert state.is_paused is False + assert "Manually paused" in state.effective_reasons + + +def test_effective_paused_false_when_neither_autopilot_nor_manual(): + state = AutopilotState() + assert state.effective_paused is False + assert state.effective_reasons == () + + +def test_effective_reasons_combines_manual_and_autopilot_reasons(): + state = AutopilotState() + state.apply(Decision(should_pause=True, reasons=("too hot",))) + state.set_manual_pause(True) + assert state.effective_reasons == ("Manually paused", "too hot") + + +def test_manual_resume_clears_manual_paused_but_not_autopilot(): + state = AutopilotState() + state.apply(Decision(should_pause=True, reasons=("too hot",))) + state.set_manual_pause(True) + state.set_manual_pause(False) + assert state.manual_paused is False + assert state.effective_paused is True # autopilot still says pause diff --git a/tests/test_queue_middleware.py b/tests/test_queue_middleware.py index 54a931b..878eee6 100644 --- a/tests/test_queue_middleware.py +++ b/tests/test_queue_middleware.py @@ -43,6 +43,20 @@ async def test_blocks_with_423_when_paused(self): assert "GPU too hot" in body["error"] +class TestQueueMiddlewareManualPause(AioHTTPTestCase): + async def get_application(self): + self.state = AutopilotState() + self.state.set_manual_pause(True) + return await _make_app(self.state, enabled=True) + + @unittest_run_loop + async def test_blocks_with_423_when_manually_paused_even_without_autopilot_decision(self): + resp = await self.client.post("/prompt", json={}) + assert resp.status == 423 + body = await resp.json() + assert "Manually paused" in body["error"] + + class TestQueueMiddlewareDisabled(AioHTTPTestCase): async def get_application(self): self.state = AutopilotState() diff --git a/tests/test_queue_tracker.py b/tests/test_queue_tracker.py new file mode 100644 index 0000000..c39c374 --- /dev/null +++ b/tests/test_queue_tracker.py @@ -0,0 +1,114 @@ +from backend.autopilot_state import AutopilotState +from backend.persistence import init_db, list_history, list_queue_items +from backend.queue_tracker import extract_job_name, sync_queue_tracker + + +def make_item(prompt_id, extra_data=None): + # Mirrors execution.PromptQueue's item tuple: (number, prompt_id, prompt, extra_data, outputs_to_execute, sensitive) + return (0, prompt_id, {}, extra_data or {}, [], {}) + + +def test_extract_job_name_from_workflow_extra(): + extra_data = {"extra_pnginfo": {"workflow": {"extra": {"workflow_name": "Cyberpunk Cat"}}}} + assert extract_job_name(extra_data) == "Cyberpunk Cat" + + +def test_extract_job_name_from_workflow_name_fallback(): + extra_data = {"extra_pnginfo": {"workflow": {"name": "Sunset Beach"}}} + assert extract_job_name(extra_data) == "Sunset Beach" + + +def test_extract_job_name_falls_back_to_timestamp_when_missing(): + name = extract_job_name({}) + assert name.startswith("Job ") + + +def test_sync_adds_new_running_and_queued_items(tmp_path): + conn = init_db(str(tmp_path / "test.sqlite3")) + state = AutopilotState() + running = [make_item("a")] + queued = [make_item("b")] + + sync_queue_tracker(conn, running, queued, history={}, autopilot_state=state, seen_running=set(), seen_completed=set()) + + items = list_queue_items(conn) + assert {item["prompt_id"] for item in items} == {"a", "b"} + + +def test_sync_records_job_started_once_per_running_item(tmp_path): + conn = init_db(str(tmp_path / "test.sqlite3")) + state = AutopilotState() + running = [make_item("a")] + + seen_running, seen_completed = sync_queue_tracker( + conn, running, [], history={}, autopilot_state=state, seen_running=set(), seen_completed=set() + ) + assert state.jobs_since_resume == 1 + + # same item still running on the next tick: must not double-count + sync_queue_tracker( + conn, running, [], history={}, autopilot_state=state, seen_running=seen_running, seen_completed=seen_completed + ) + assert state.jobs_since_resume == 1 + + +def test_sync_does_not_record_job_started_for_queued_items(tmp_path): + conn = init_db(str(tmp_path / "test.sqlite3")) + state = AutopilotState() + queued = [make_item("a")] + + sync_queue_tracker(conn, [], queued, history={}, autopilot_state=state, seen_running=set(), seen_completed=set()) + assert state.jobs_since_resume == 0 + + +def test_sync_marks_completed_when_item_leaves_queue_and_is_in_history(tmp_path): + conn = init_db(str(tmp_path / "test.sqlite3")) + state = AutopilotState() + running = [make_item("a")] + seen_running, seen_completed = sync_queue_tracker( + conn, running, [], history={}, autopilot_state=state, seen_running=set(), seen_completed=set() + ) + + # job finished: no longer running/queued, now in history + sync_queue_tracker( + conn, [], [], history={"a": {"prompt": make_item("a")}}, + autopilot_state=state, seen_running=seen_running, seen_completed=seen_completed, + ) + + assert list_queue_items(conn) == [] + history_rows = list_history(conn) + assert len(history_rows) == 1 + assert history_rows[0]["prompt_id"] == "a" + + +def test_sync_handles_job_that_starts_and_finishes_within_one_tick(tmp_path): + """A fast job never observed running/queued by us, only ever seen in history.""" + conn = init_db(str(tmp_path / "test.sqlite3")) + state = AutopilotState() + extra_data = {"extra_pnginfo": {"workflow": {"name": "Quick Render"}}} + history = {"a": {"prompt": make_item("a", extra_data)}} + + sync_queue_tracker(conn, [], [], history=history, autopilot_state=state, seen_running=set(), seen_completed=set()) + + assert list_queue_items(conn) == [] + history_rows = list_history(conn) + assert len(history_rows) == 1 + assert history_rows[0]["prompt_id"] == "a" + assert history_rows[0]["name"] == "Quick Render" + + +def test_sync_does_not_reprocess_already_completed_history_entries(tmp_path): + conn = init_db(str(tmp_path / "test.sqlite3")) + state = AutopilotState() + history = {"a": {"prompt": make_item("a")}} + + seen_running, seen_completed = sync_queue_tracker( + conn, [], [], history=history, autopilot_state=state, seen_running=set(), seen_completed=set() + ) + assert len(list_history(conn)) == 1 + + # same history entry still present on the next tick: must not duplicate + sync_queue_tracker( + conn, [], [], history=history, autopilot_state=state, seen_running=seen_running, seen_completed=seen_completed + ) + assert len(list_history(conn)) == 1 diff --git a/tests/test_routes.py b/tests/test_routes.py index e454b95..5f05272 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -69,6 +69,24 @@ async def test_post_settings_updates_shared_settings_object(self): assert self.settings.pause_temp_c == 90.0 assert self.settings.master_enabled is False + @unittest_run_loop + async def test_manual_pause_sets_state_and_reflects_in_status(self): + resp = await self.client.post("/smart_queue/manual_pause", json={"paused": True}) + assert resp.status == 200 + assert self.state.manual_paused is True + + status_resp = await self.client.get("/smart_queue/status") + body = await status_resp.json() + assert body["is_paused"] is True + assert "Manually paused" in body["reasons"] + + @unittest_run_loop + async def test_manual_resume_clears_manual_pause(self): + self.state.set_manual_pause(True) + resp = await self.client.post("/smart_queue/manual_pause", json={"paused": False}) + assert resp.status == 200 + assert self.state.manual_paused is False + @unittest_run_loop async def test_continue_signals_a_waiting_node(self): import asyncio diff --git a/web/smart_queue.css b/web/smart_queue.css index 5e09f8a..701a661 100644 --- a/web/smart_queue.css +++ b/web/smart_queue.css @@ -1,15 +1,62 @@ #smart-queue-panel { background: var(--comfy-menu-bg, var(--p-content-background, #202020)); color: var(--fg-color, var(--p-text-color, #ddd)); - border: 1px solid var(--border-color, var(--p-content-border-color, #444)); - border-radius: 6px; - padding: 8px 10px; - font-size: 12px; + padding: 10px 12px; + font-size: 13px; + height: 100%; + box-sizing: border-box; + overflow-y: auto; + /* Some third-party extensions render position:fixed floating toolbars near + the canvas top-left that can coincidentally overlap the sidebar area + (e.g. node-alignment helper tools). Keep our own controls clickable. */ + position: relative; + z-index: 1100; +} + +#smart-queue-panel .smart-queue-status-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; } #smart-queue-panel .smart-queue-status { font-weight: 600; - margin-bottom: 6px; +} + +#smart-queue-panel .smart-queue-status.smart-queue-paused { + color: var(--error-text, #e06c75); +} + +#smart-queue-panel .smart-queue-pause-btn { + padding: 4px 10px; + border-radius: 4px; + border: 1px solid var(--border-color, var(--p-content-border-color, #444)); + background: var(--comfy-input-bg, #333); + color: inherit; + cursor: pointer; + font-size: 12px; + white-space: nowrap; +} + +#smart-queue-panel .smart-queue-pause-btn-active { + background: var(--error-text, #e06c75); + color: #fff; + border-color: var(--error-text, #e06c75); +} + +#smart-queue-panel .smart-queue-gpu-readout { + margin-bottom: 8px; + opacity: 0.8; + font-size: 12px; +} + +#smart-queue-panel .smart-queue-section-title { + font-size: 11px; + text-transform: uppercase; + opacity: 0.6; + margin: 10px 0 4px; } #smart-queue-panel .smart-queue-list { @@ -24,6 +71,43 @@ cursor: grab; } +#smart-queue-panel .smart-queue-history li { + cursor: default; + opacity: 0.8; +} + +#smart-queue-panel .smart-queue-empty { + opacity: 0.5; + font-style: italic; + cursor: default; +} + +.smart-queue-toolbar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + margin: 0 4px; + border: none; + border-radius: 6px; + background: var(--comfy-input-bg, #333); + color: var(--fg-color, var(--p-text-color, #ddd)); + cursor: pointer; + font-size: 14px; + line-height: 1; + flex-shrink: 0; +} + +.smart-queue-toolbar-btn:hover { + filter: brightness(1.15); +} + +.smart-queue-toolbar-btn-paused { + background: var(--error-text, #e06c75); + color: #fff; +} + .smart-queue-continue-overlay { position: fixed; inset: 0; diff --git a/web/smart_queue.js b/web/smart_queue.js index d683a5a..c0b577e 100644 --- a/web/smart_queue.js +++ b/web/smart_queue.js @@ -53,6 +53,14 @@ app.registerExtension({ }, ], async setup() { + if (!document.getElementById("smart-queue-stylesheet")) { + const link = document.createElement("link"); + link.id = "smart-queue-stylesheet"; + link.rel = "stylesheet"; + link.href = new URL("smart_queue.css", import.meta.url).href; + document.head.appendChild(link); + } + async function syncSettingsToBackend() { const payload = { master_enabled: app.extensionManager.setting.get("SmartQueue.EnableAutopilot"), @@ -86,53 +94,171 @@ app.registerExtension({ const hasCrystools = app.extensions?.some((ext) => ext.name?.toLowerCase().includes("crystools")); - const panel = document.createElement("div"); - panel.id = "smart-queue-panel"; - panel.innerHTML = ` -
    Smart Queue: idle
    - ${hasCrystools ? "" : '
    '} -
      - `; - - const sidebar = document.querySelector(".comfyui-body-bottom") || document.querySelector(".comfy-menu"); - if (sidebar) { - sidebar.appendChild(panel); + // A small square icon button inserted as a real sibling next to + // ComfyUI's own Run button group, matching the size/style of its + // "Cancel current run" button. Because it's a genuine DOM child of + // that flex row (not a position:fixed overlay with guessed pixel + // coordinates), the browser's own flex layout makes room for it — + // no manual placement math, and nothing gets covered. + // Risk: that row is Vue-managed, so a re-render could in principle + // drop this manually-inserted node; a periodic check re-inserts it + // if that ever happens. + let manualPaused = false; + + function findToolbarRow() { + const toggle = document.querySelector('[data-testid="queue-overlay-toggle"]'); + return toggle ? toggle.parentElement : null; + } + + function ensureToolbarPauseButton() { + const row = findToolbarRow(); + if (!row) return null; + + let btn = document.getElementById("smart-queue-toolbar-btn"); + if (btn && row.contains(btn)) return btn; + + btn = document.createElement("button"); + btn.id = "smart-queue-toolbar-btn"; + btn.className = "smart-queue-toolbar-btn"; + btn.setAttribute("aria-label", "Pause queue (Smart Queue)"); + btn.setAttribute("data-pd-tooltip", "true"); + btn.title = "Pause queue (Smart Queue)"; + btn.textContent = "⏸"; + + btn.addEventListener("click", async () => { + manualPaused = !manualPaused; + try { + await fetch("/smart_queue/manual_pause", { + method: "POST", + body: JSON.stringify({ paused: manualPaused }), + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + console.error("[Smart Queue] manual pause toggle failed:", err); + } + await refreshToolbarButton(); + }); + + // Inserted right after the batch-count/Run button group (the + // row's first child) so it reads as "next to Run". + const runGroup = row.children[0]; + if (runGroup && runGroup.nextSibling) { + row.insertBefore(btn, runGroup.nextSibling); + } else { + row.insertBefore(btn, row.firstChild); + } + return btn; } - async function refreshStatus() { + async function refreshToolbarButton() { + const btn = ensureToolbarPauseButton(); + if (!btn) return; try { const res = await fetch("/smart_queue/status"); const data = await res.json(); - const statusEl = panel.querySelector(".smart-queue-status"); - statusEl.textContent = data.is_paused - ? `Smart Queue: paused — ${data.reasons.join("; ")}` - : "Smart Queue: running"; + manualPaused = data.manual_paused; + btn.textContent = manualPaused ? "▶" : "⏸"; + btn.title = data.is_paused + ? `Paused — ${data.reasons.join("; ")}. Click to resume.` + : "Pause queue (Smart Queue)"; + btn.classList.toggle("smart-queue-toolbar-btn-paused", data.is_paused); } catch (err) { console.error("[Smart Queue] status fetch failed:", err); } } - async function refreshQueueList() { - try { - const res = await fetch("/smart_queue/queue"); - const data = await res.json(); - const listEl = panel.querySelector("#smart-queue-list"); - listEl.innerHTML = ""; - for (const item of data.items) { - const li = document.createElement("li"); - li.draggable = true; - li.dataset.promptId = item.prompt_id; - li.textContent = item.name; - listEl.appendChild(li); + setInterval(refreshToolbarButton, 3000); + refreshToolbarButton(); + + app.extensionManager.registerSidebarTab({ + id: "smart-queue", + icon: "pi pi-pause-circle", + title: "Smart Queue", + tooltip: "Smart Queue: GPU autopilot + render queue", + type: "custom", + render: (el) => { + el.innerHTML = ` +
      +
      +
      Smart Queue: idle
      +
      + ${hasCrystools ? "" : '
      '} +
      Pending / running
      +
        +
        History
        +
          +
          + `; + + const panel = el.querySelector("#smart-queue-panel"); + + async function refreshStatus() { + try { + const res = await fetch("/smart_queue/status"); + const data = await res.json(); + const statusEl = panel.querySelector(".smart-queue-status"); + statusEl.textContent = data.is_paused + ? `Paused — ${data.reasons.join("; ")}` + : "Running"; + statusEl.classList.toggle("smart-queue-paused", data.is_paused); + } catch (err) { + console.error("[Smart Queue] status fetch failed:", err); + } } - } catch (err) { - console.error("[Smart Queue] queue fetch failed:", err); - } - } - setInterval(refreshStatus, 3000); - setInterval(refreshQueueList, 5000); - await refreshStatus(); - await refreshQueueList(); + async function refreshQueueList() { + try { + const res = await fetch("/smart_queue/queue"); + const data = await res.json(); + const listEl = panel.querySelector("#smart-queue-list"); + listEl.innerHTML = ""; + if (data.items.length === 0) { + listEl.innerHTML = '
        • Nothing queued
        • '; + } + for (const item of data.items) { + const li = document.createElement("li"); + li.draggable = true; + li.dataset.promptId = item.prompt_id; + li.textContent = item.name; + listEl.appendChild(li); + } + } catch (err) { + console.error("[Smart Queue] queue fetch failed:", err); + } + } + + async function refreshHistory() { + try { + const res = await fetch("/smart_queue/history"); + const data = await res.json(); + const listEl = panel.querySelector("#smart-queue-history"); + listEl.innerHTML = ""; + if (data.items.length === 0) { + listEl.innerHTML = '
        • No history yet
        • '; + } + for (const item of data.items) { + const li = document.createElement("li"); + li.textContent = item.name; + listEl.appendChild(li); + } + } catch (err) { + console.error("[Smart Queue] history fetch failed:", err); + } + } + + const statusTimer = setInterval(refreshStatus, 3000); + const queueTimer = setInterval(refreshQueueList, 5000); + const historyTimer = setInterval(refreshHistory, 5000); + refreshStatus(); + refreshQueueList(); + refreshHistory(); + + return () => { + clearInterval(statusTimer); + clearInterval(queueTimer); + clearInterval(historyTimer); + }; + }, + }); }, }); From 486d7732747e992e2aec2468ea48239c2d1d9aaa Mon Sep 17 00:00:00 2001 From: rubz Date: Sat, 29 Aug 2026 01:19:20 +0200 Subject: [PATCH 16/17] Fix invalid io.Hidden.prompt_id and complete Task 11 manual verification define_schema() referenced a Hidden enum member that doesn't exist in this ComfyUI version, crashing /object_info for the node. Read the running prompt's id from PromptServer.instance.last_prompt_id instead, set once per prompt by ComfyUI's own execution loop. Verified against a live instance: node sound/toast/fallback/wait-for-click/ unload-before-wait all work, and the queue panel + node UI hold up with Nodes 2.0 toggled on and off. v1 is now fully verified end to end. --- backend/nodes/cooldown.py | 3 +- docs/plans/2026-08-28-smart-queue-v1.md | 45 +++++++++++-------- docs/specs/2026-08-28-smart-queue-design.md | 48 +++++++++++++++++---- 3 files changed, 68 insertions(+), 28 deletions(-) diff --git a/backend/nodes/cooldown.py b/backend/nodes/cooldown.py index be21265..7e01446 100644 --- a/backend/nodes/cooldown.py +++ b/backend/nodes/cooldown.py @@ -88,7 +88,6 @@ def define_schema(cls): io.String.Input("custom_sound_path", default="", optional=True), io.Boolean.Input("notify_toast", default=False), io.AnyType.Input("passthrough", optional=True), - io.Hidden.prompt_id, ], outputs=[ io.AnyType.Output("passthrough"), @@ -126,7 +125,7 @@ def execute(cls, **kwargs): }) if kwargs["wait_for_click"]: - prompt_id = kwargs.get("prompt_id") + prompt_id = PromptServer.instance.last_prompt_id PromptServer.instance.send_sync("smart_queue.cooldown_wait_for_click", { "prompt_id": prompt_id, }) diff --git a/docs/plans/2026-08-28-smart-queue-v1.md b/docs/plans/2026-08-28-smart-queue-v1.md index 6a84bbe..690a87f 100644 --- a/docs/plans/2026-08-28-smart-queue-v1.md +++ b/docs/plans/2026-08-28-smart-queue-v1.md @@ -1639,7 +1639,7 @@ app.registerExtension({ } ``` -- [x] **Step 3: Manual verification against a running ComfyUI instance** *(items 1,2,3,5,6 verified 2026-08-29; item 4 — Nodes 2.0 toggle — still pending; item 7 verified via the master-toggle-off code path, not re-tested live after the toolbar-button rework)* +- [x] **Step 3: Manual verification against a running ComfyUI instance** *(all items verified 2026-08-29, including item 4 — Nodes 2.0 toggle on/off, re-tested against a live instance; item 7 verified via the master-toggle-off code path, not re-tested live after the toolbar-button rework)* Use the `run` skill to launch ComfyUI with this pack installed, then check: @@ -1716,15 +1716,17 @@ app.registerExtension({ **Note for the implementer:** the Python side of `wait_for_click` and the `smart_queue.cooldown_notify` event dispatch are not yet wired in Task 7's `execute()` method — that requires `PromptServer.instance.send_sync(...)` for the notify event and a small polling loop against a `/smart_queue/continue/` endpoint for `wait_for_click`. Add both directly to `backend/nodes/cooldown.py`'s `execute()` (not `run_cooldown()`, which must stay pure/testable) and to `backend/routes.py` before running the manual verification below. Keep `run_cooldown()`'s signature and existing tests unchanged. -- [ ] **Step 2: Manual verification against a running ComfyUI instance** +- [x] **Step 2: Manual verification against a running ComfyUI instance** *(all 5 items verified 2026-08-29 against the live instance, via MCP-enqueued isolated test workflows rather than editing the user's open canvas)* -1. Build a small test workflow using the "Smart Cooldown & Pause" node with `notify_sound=True`, `notify_sound_choice="Default"`. Run it — confirm a tone plays when the node finishes. -2. Set `notify_sound_choice="Custom..."` with an empty `custom_sound_path`. Run it — confirm it falls back to the default tone instead of erroring. -3. Set `notify_toast=True`. Run it — confirm a toast appears with the status text. -4. Set `wait_for_click=True`. Run it — confirm execution blocks and a "Continue" button appears; clicking it lets execution proceed. -5. Set `unload_models_before_wait=True` on a workflow with a loaded checkpoint. Run it — confirm (via Crystools or `nvidia-smi` externally) that VRAM usage drops before the wait begins. +1. Build a small test workflow using the "Smart Cooldown & Pause" node with `notify_sound=True`, `notify_sound_choice="Default"`. Run it — confirm a tone plays when the node finishes. *(Verified: toast + `GET .../sounds/default.wav → 206 Partial Content` network request confirmed on job completion.)* +2. Set `notify_sound_choice="Custom..."` with an empty `custom_sound_path`. Run it — confirm it falls back to the default tone instead of erroring. *(Verified: `custom_sound_path` empty string is falsy, so the JS never attempts the bad path — resolves straight to `sounds/default.wav`; no error, toast fired normally.)* +3. Set `notify_toast=True`. Run it — confirm a toast appears with the status text. *(Verified: "Smart Cooldown & Pause — No wait configured." toast rendered top-right.)* +4. Set `wait_for_click=True`. Run it — confirm execution blocks and a "Continue" button appears; clicking it lets execution proceed. *(Verified: modal "Smart Cooldown & Pause is waiting — click Continue to resume" appeared, Job Queue showed "Running"; clicking Continue completed the job at 13.92s — matching the artificial wait — with a "Job completed" toast.)* +5. Set `unload_models_before_wait=True` on a workflow with a loaded checkpoint. Run it — confirm (via Crystools or `nvidia-smi` externally) that VRAM usage drops before the wait begins. *(Verified indirectly: ran a full SD1.5 txt2img graph — checkpoint load, KSampler, VAEDecode — feeding into the cooldown node with `unload_models_before_wait=True`. The load→sample→unload cycle completed in ~3s, too fast for polling to catch the live transition, but `nvidia-smi` read 1720MiB used immediately after — matching the pre-test idle baseline, confirming no leaked model weights.)* -- [ ] **Step 3: Commit** +Also found and fixed a real bug this step was blocked on: `define_schema()` referenced `io.Hidden.prompt_id`, which doesn't exist on this ComfyUI version's V3 `Hidden` enum (`AttributeError`, node crashed on `/object_info`) — only `unique_id`/`prompt`/`extra_pnginfo`/etc. exist, and none of them is the running prompt's UUID. Removed the hidden input entirely; `execute()` now reads `PromptServer.instance.last_prompt_id` (set once per prompt by ComfyUI's own execution loop in `main.py`) instead of a schema-supplied `prompt_id` kwarg. See spec §13. + +- [x] **Step 3: Commit** ```bash git add web/smart_queue_node.js web/sounds/ @@ -1735,15 +1737,22 @@ git commit -m "Add node notification UI: continue button, toast, sound playback" ## Post-implementation -Once all 11 tasks are verified (automated tests green in CI for Tasks 1-9, manual checks passed for Tasks 10-11): delete `D:\AI\ComfyUI-AGAIN\ComfyUI\custom_nodes\rubz-gpu-cooldown` per spec §10, and update the spec's "Status" line from "Draft" to "v1 implemented". - -**Status as of 2026-08-29:** Tasks 1-9 done (70 automated tests green — up -from the original plan's count, four bugs found only by manual testing -required new code: `backend/queue_tracker.py`, the manual-pause additions to -`autopilot_state.py`/`queue_middleware.py`/`routes.py`, and CSS-loading / -toolbar-button fixes in `web/`). Task 10 verified except the Nodes 2.0 -toggle check. Task 11 blocked on the `rubz-gpu-cooldown` name collision — -old folder renamed to `.disabled`, not yet deleted; do that only after Task -11 passes and the toggle check is done. See spec §13 for full detail. +Once all 11 tasks are verified (automated tests green in CI for Tasks 1-9, manual checks passed for Tasks 10-11): delete `D:\AI\ComfyUI-AGAIN\ComfyUI\custom_nodes\rubz-gpu-cooldown.disabled` per spec §10, and update the spec's "Status" line to reflect full v1 completion. + +**Status as of 2026-08-29:** All 11 tasks done. Tasks 1-9: 70 automated +tests green (up from the original plan's count — four bugs found only by +manual testing required new code: `backend/queue_tracker.py`, the +manual-pause additions to `autopilot_state.py`/`queue_middleware.py`/ +`routes.py`, and CSS-loading/toolbar-button fixes in `web/`). Task 10: all +manual checks passed including the Nodes 2.0 toggle (on and off, live). +Task 11: unblocked after renaming the colliding `rubz-gpu-cooldown` folder +to `.disabled`; a further real bug (`io.Hidden.prompt_id` doesn't exist — +see Task 11 Step 2) was found and fixed during this pass, then all 5 +manual checks (sound, fallback, toast, wait-for-click, unload-before-wait) +passed against the live instance. See spec §13 for full detail. + +**Remaining before this plan is fully closed out:** delete the +`rubz-gpu-cooldown.disabled` folder (kept only as a safety net per spec +§10 — everything it did now lives in this pack, verified working). v2 features (rename jobs, filter/search, auto-archive, bulk operations, priority adjustment, cancel-and-requeue — spec §10) are out of scope for this plan and should get their own plan once v1 is in daily use. diff --git a/docs/specs/2026-08-28-smart-queue-design.md b/docs/specs/2026-08-28-smart-queue-design.md index cb014d8..5f93866 100644 --- a/docs/specs/2026-08-28-smart-queue-design.md +++ b/docs/specs/2026-08-28-smart-queue-design.md @@ -1,6 +1,6 @@ # Smart Queue — Design Spec -Status: v1 implemented and manually verified against a running ComfyUI instance (Tasks 1-9 automated, 70 tests green; Task 10 UI verified — panel, settings sync, Crystools detection, theme, manual pause; Task 11 node-notification checks pending, blocked on the `rubz-gpu-cooldown` name collision, see §13) +Status: v1 fully implemented and manually verified against a running ComfyUI instance (Tasks 1-9 automated, 70 tests green; Task 10 UI verified — panel, settings sync, Crystools detection, theme, manual pause, Nodes 2.0 toggle on/off; Task 11 node-notification checks verified — sound, toast, fallback, wait-for-click, unload-before-wait; see §13). Remaining: delete `rubz-gpu-cooldown.disabled` per §10. Date: 2026-08-29 Repo: `comfyui-smart-queue` (D:\AI\ComfyUI-AGAIN\ComfyUI\custom_nodes\comfyui-smart-queue) GitHub: `github.com/CraftopiaStudio/comfyui-smart-queue` (alongside ComfyUI-WorkflowOrganizer and CraftKit) @@ -307,12 +307,44 @@ bug: group — letting the browser's own flex layout make room instead of computing pixel offsets. A periodic re-insert check guards against Vue dropping the manually-inserted node on re-render. -- **Blocked**: Task 11's node-notification checks (sound/toast/continue - button) could not be run — `RubzGpuCooldownNode` is registered by *both* - packages, and `rubz-gpu-cooldown` (old, V1-schema) wins over - `comfyui-smart-queue`'s new V3 node because it loads later alphabetically, - shadowing the new node's `NODE_CLASS_MAPPINGS` entry entirely. Confirmed via +- **Resolved**: Task 11's node-notification checks were initially blocked — + `RubzGpuCooldownNode` was registered by *both* packages, and + `rubz-gpu-cooldown` (old, V1-schema) won over `comfyui-smart-queue`'s new + V3 node because it loaded later alphabetically, shadowing the new node's + `NODE_CLASS_MAPPINGS` entry entirely. Confirmed via `GET /object_info/RubzGpuCooldownNode` → `python_module: - custom_nodes.rubz-gpu-cooldown`. The old folder was renamed to + custom_nodes.rubz-gpu-cooldown`. Fixed by renaming the old folder to `rubz-gpu-cooldown.disabled` (ComfyUI's standard disable convention, fully - reversible) rather than deleted, pending the next verification pass. + reversible) and restarting ComfyUI. +- **A second, real bug surfaced immediately after the restart**: the new + node's `define_schema()` referenced `io.Hidden.prompt_id`, which doesn't + exist on this ComfyUI version's V3 `Hidden` enum (only `unique_id`, + `prompt`, `extra_pnginfo`, `dynprompt`, `auth_token_comfy_org`, + `api_key_comfy_org`, `comfy_usage_source`) — every `/object_info` request + for the node threw `AttributeError: type object 'Hidden' has no attribute + 'prompt_id'`, confirmed in `user/comfyui.log`. None of the real `Hidden` + members carry the running prompt's UUID. Fixed by dropping the hidden + schema input entirely; `execute()` now reads + `PromptServer.instance.last_prompt_id` — set once per prompt by + ComfyUI's own single-threaded execution loop (`main.py`) right before a + prompt's nodes run, so it is stable for the duration of that node's + `execute()` call. Unit tests were unaffected (they target `run_cooldown()` + only, not `execute()`/`define_schema()`). +- **Task 11 manual checks (2026-08-29), all passed** against the live + instance, via isolated test workflows submitted through the ComfyUI MCP + rather than editing the user's open canvas: default-sound tone + toast + fired and logged a real `sounds/default.wav` network fetch; + `notify_sound_choice="Custom..."` with an empty path resolved straight to + the default tone (the empty string is falsy, so the JS path never even + attempts the bad URL — no error path exercised, none needed); + `wait_for_click=True` blocked execution behind a "Continue" modal that, + once clicked, completed the job; and a full SD1.5 txt2img graph piped into + the node with `unload_models_before_wait=True` left `nvidia-smi` reporting + VRAM back at the pre-test idle baseline afterward (the load→unload cycle + itself was too fast, ~3s, for polling to catch the live transition). +- **Task 10's previously-pending Nodes 2.0 toggle check also completed** + during this pass: toggled "Modern Node Design (Nodes 2.0)" on, confirmed + the queue panel, manual-pause toolbar button, cooldown node's + Continue-modal and toast all still render and function correctly under + the new Vue-based rendering, then toggled it back off (restoring the + user's original setting). From 1d0d443790a81e9f0e36c79c58c547b6668f803e Mon Sep 17 00:00:00 2001 From: rubz Date: Sat, 29 Aug 2026 01:38:08 +0200 Subject: [PATCH 17/17] Mark v1 plan complete: old rubz-gpu-cooldown pack deleted Fully superseded and never published, so no external users to migrate. Confirmed the node still resolves correctly from this pack after removal. --- docs/plans/2026-08-28-smart-queue-v1.md | 5 ++--- docs/specs/2026-08-28-smart-queue-design.md | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-08-28-smart-queue-v1.md b/docs/plans/2026-08-28-smart-queue-v1.md index 690a87f..0224b5c 100644 --- a/docs/plans/2026-08-28-smart-queue-v1.md +++ b/docs/plans/2026-08-28-smart-queue-v1.md @@ -1751,8 +1751,7 @@ see Task 11 Step 2) was found and fixed during this pass, then all 5 manual checks (sound, fallback, toast, wait-for-click, unload-before-wait) passed against the live instance. See spec §13 for full detail. -**Remaining before this plan is fully closed out:** delete the -`rubz-gpu-cooldown.disabled` folder (kept only as a safety net per spec -§10 — everything it did now lives in this pack, verified working). +`rubz-gpu-cooldown.disabled` has now been deleted per spec §10 — everything +it did lives in this pack, verified working. Plan fully closed out. v2 features (rename jobs, filter/search, auto-archive, bulk operations, priority adjustment, cancel-and-requeue — spec §10) are out of scope for this plan and should get their own plan once v1 is in daily use. diff --git a/docs/specs/2026-08-28-smart-queue-design.md b/docs/specs/2026-08-28-smart-queue-design.md index 5f93866..3903474 100644 --- a/docs/specs/2026-08-28-smart-queue-design.md +++ b/docs/specs/2026-08-28-smart-queue-design.md @@ -1,6 +1,6 @@ # Smart Queue — Design Spec -Status: v1 fully implemented and manually verified against a running ComfyUI instance (Tasks 1-9 automated, 70 tests green; Task 10 UI verified — panel, settings sync, Crystools detection, theme, manual pause, Nodes 2.0 toggle on/off; Task 11 node-notification checks verified — sound, toast, fallback, wait-for-click, unload-before-wait; see §13). Remaining: delete `rubz-gpu-cooldown.disabled` per §10. +Status: v1 fully implemented and manually verified against a running ComfyUI instance (Tasks 1-9 automated, 70 tests green; Task 10 UI verified — panel, settings sync, Crystools detection, theme, manual pause, Nodes 2.0 toggle on/off; Task 11 node-notification checks verified — sound, toast, fallback, wait-for-click, unload-before-wait; see §13). `rubz-gpu-cooldown.disabled` deleted per §10 — this plan is complete. Date: 2026-08-29 Repo: `comfyui-smart-queue` (D:\AI\ComfyUI-AGAIN\ComfyUI\custom_nodes\comfyui-smart-queue) GitHub: `github.com/CraftopiaStudio/comfyui-smart-queue` (alongside ComfyUI-WorkflowOrganizer and CraftKit)