From ceee7c7d5654a250c086927e4e9ac59a3e653efd Mon Sep 17 00:00:00 2001 From: Ntsako Date: Thu, 6 Aug 2026 10:55:56 +0200 Subject: [PATCH] fix: raise ComfyUI video timeout default and add job resume support Non-accelerated local-GPU workflows (e.g. Wan 1.3B at 832x480/81-97 frames) routinely took ~1360-1630s, so the old 900s default false-failed real renders that were still completing server-side. Timeout is now a configurable timeout_seconds input (default 3600s), and ComfyUIError carries the prompt_id on error/timeout so a timed-out-but-still-running job can be resumed via resume_prompt_id instead of resubmitted. Co-Authored-By: Claude Sonnet 5 --- docs/comfyui-adapter-plan.md | 19 ++++- tests/contracts/test_comfyui_tools.py | 112 ++++++++++++++++++++++++++ tools/_comfyui/client.py | 33 ++++++-- tools/video/comfyui_video.py | 34 +++++++- 4 files changed, 189 insertions(+), 9 deletions(-) diff --git a/docs/comfyui-adapter-plan.md b/docs/comfyui-adapter-plan.md index d60fb89d..c0a941e6 100644 --- a/docs/comfyui-adapter-plan.md +++ b/docs/comfyui-adapter-plan.md @@ -257,21 +257,36 @@ output_node: string # required for custom workflows workflow_name: string # optional custom workflow provenance label workflow_model: string # optional custom model/provenance label workflow_model_stack: [] # optional custom dependency provenance +timeout_seconds: integer # optional, default 3600 (see below) +resume_prompt_id: string # optional, resume a timed-out job without resubmitting ``` **execute() flow (i2v):** 1. Upload reference image via `client.upload_image()` 2. Deep-copy i2v workflow template 3. Inject prompt, uploaded image name, seed, dimensions -4. `client.generate(workflow, output_node="108", dest=output_path, timeout=900)` +4. `client.generate(workflow, output_node="108", dest=output_path, timeout=inputs.get("timeout_seconds", 3600), resume_prompt_id=inputs.get("resume_prompt_id"))` 5. Return `ToolResult` **execute() flow (t2v):** 1. Deep-copy t2v workflow template 2. Inject prompt, seed, dimensions -3. `client.generate(workflow, output_node="16", dest=output_path, timeout=900)` +3. `client.generate(workflow, output_node="16", dest=output_path, timeout=inputs.get("timeout_seconds", 3600), resume_prompt_id=inputs.get("resume_prompt_id"))` 4. Return `ToolResult` +**Timeout and resume (added after real-world local-GPU testing):** the +default client wait was raised from 900s to 3600s — non-accelerated custom +Wan 1.3B workflows on modest local GPUs were observed taking ~1360-1630s at +832x480/81-97 frames, and the old 900s default false-failed those jobs even +though ComfyUI kept rendering server-side. `ComfyUIError` now carries a +`prompt_id` on both execution errors and timeouts (`ComfyUIError.prompt_id`), +and `ComfyUIVideo`'s `ToolResult.error`/`.data` surface it on timeout so the +caller isn't left guessing whether the job is dead. Callers recover a +timed-out-but-still-running job by calling `execute()` again with +`resume_prompt_id` set to that `prompt_id` (and a longer `timeout_seconds` if +needed) — `client.generate()` then skips `submit()` entirely and just resumes +polling/downloading the existing job instead of queuing a duplicate. + `comfyui_video` publishes `operation_statuses` in `get_info()` and implements `is_operation_available(operation)` for selector routing. This keeps partial ComfyUI installs useful for the installed mode without advertising unavailable diff --git a/tests/contracts/test_comfyui_tools.py b/tests/contracts/test_comfyui_tools.py index a8ee8304..ff3e279b 100644 --- a/tests/contracts/test_comfyui_tools.py +++ b/tests/contracts/test_comfyui_tools.py @@ -282,6 +282,47 @@ class TestClientHelpers: "folder_type": "temp", } + def test_poll_timeout_carries_prompt_id_for_recovery(self, monkeypatch): + from tools._comfyui.client import ComfyUIClient, ComfyUIError + + client = ComfyUIClient("http://comfy.test") + monkeypatch.setattr( + "tools._comfyui.client.requests.get", + lambda *a, **k: type("R", (), { + "raise_for_status": lambda self: None, + "json": lambda self: {}, + })(), + ) + monkeypatch.setattr("tools._comfyui.client.time.sleep", lambda s: None) + + with pytest.raises(ComfyUIError) as excinfo: + client.poll("prompt-timeout-1", timeout=0, interval=0) + + assert excinfo.value.prompt_id == "prompt-timeout-1" + assert "prompt-timeout-1" in str(excinfo.value) + + def test_generate_resume_prompt_id_skips_resubmit(self, monkeypatch, tmp_path): + from tools._comfyui.client import ComfyUIClient + + client = ComfyUIClient("http://comfy.test") + + def fail_submit(workflow): + raise AssertionError("submit() should not be called when resuming") + + monkeypatch.setattr(client, "submit", fail_submit) + monkeypatch.setattr(client, "poll", lambda prompt_id, **kwargs: { + "outputs": {"9": {"images": [{ + "filename": "resumed.png", "subfolder": "", "type": "output", + }]}} + }) + monkeypatch.setattr(client, "download", lambda filename, subfolder, dest, folder_type="output": Path(dest)) + + paths = client.generate( + {"9": {"inputs": {}}}, "9", tmp_path / "out.png", + resume_prompt_id="already-running-id", + ) + assert paths == [tmp_path / "out.png"] + def test_is_default_url_when_env_not_set(self, monkeypatch): from tools._comfyui.client import ComfyUIClient monkeypatch.delenv("COMFYUI_SERVER_URL", raising=False) @@ -418,6 +459,77 @@ class TestCustomWorkflowContract: assert provenance["model_stack"] == [{"role": "lora", "name": "style.safetensors"}] assert provenance["model_stack_source"] == "caller_supplied" + def test_video_timeout_surfaces_resumable_prompt_id(self, tmp_path): + from tools._comfyui.client import ComfyUIError + + tool = ComfyUIVideo() + tool._client.is_available = lambda: True + + def fake_generate(workflow, output_node, dest, **kwargs): + raise ComfyUIError("Prompt timed-out-id did not complete within 5s", prompt_id="timed-out-id") + + tool._client.generate = fake_generate + + result = tool.execute({ + "prompt": "test", + "workflow_json": json.dumps({"42": {"inputs": {}}}), + "output_node": "42", + "output_path": str(tmp_path / "video.mp4"), + "timeout_seconds": 5, + }) + + assert result.success is False + assert result.data["prompt_id"] == "timed-out-id" + assert "resume_prompt_id" in result.error + assert "timed-out-id" in result.error + + def test_video_passes_timeout_and_resume_prompt_id_through(self, tmp_path): + tool = ComfyUIVideo() + tool._client.is_available = lambda: True + seen = {} + + def fake_generate(workflow, output_node, dest, **kwargs): + seen.update(kwargs) + return [Path(dest)] + + tool._client.generate = fake_generate + + result = tool.execute({ + "prompt": "test", + "workflow_json": json.dumps({"42": {"inputs": {}}}), + "output_node": "42", + "output_path": str(tmp_path / "video.mp4"), + "timeout_seconds": 7200, + "resume_prompt_id": "already-running-id", + }) + + assert result.success is True + assert seen["timeout"] == 7200 + assert seen["resume_prompt_id"] == "already-running-id" + + def test_video_default_timeout_is_generous_not_900s(self, tmp_path): + tool = ComfyUIVideo() + tool._client.is_available = lambda: True + seen = {} + + def fake_generate(workflow, output_node, dest, **kwargs): + seen.update(kwargs) + return [Path(dest)] + + tool._client.generate = fake_generate + + tool.execute({ + "prompt": "test", + "workflow_json": json.dumps({"42": {"inputs": {}}}), + "output_node": "42", + "output_path": str(tmp_path / "video.mp4"), + }) + + # Regression guard: the old hardcoded 900s timeout false-failed real + # renders on modest local GPUs (observed ~1360-1630s for non-accelerated + # custom Wan 1.3B workflows at 832x480/81-97 frames). + assert seen["timeout"] > 900 + def test_image_missing_models_are_structured(self): tool = ComfyUIImage() tool._client.is_available = lambda: True diff --git a/tools/_comfyui/client.py b/tools/_comfyui/client.py index fa80e843..41fee64b 100644 --- a/tools/_comfyui/client.py +++ b/tools/_comfyui/client.py @@ -18,7 +18,17 @@ import requests class ComfyUIError(Exception): - """Raised when ComfyUI returns an error or times out.""" + """Raised when ComfyUI returns an error or times out. + + ``prompt_id`` is set when the error follows a successful ``submit()``, + so callers can recover a timed-out-but-still-running job instead of + losing track of it: poll ``GET /history/{prompt_id}`` directly, or + pass ``resume_prompt_id`` back into ``ComfyUIVideo.execute()``. + """ + + def __init__(self, message: str, prompt_id: str | None = None) -> None: + super().__init__(message) + self.prompt_id = prompt_id class ComfyUIClient: @@ -174,11 +184,18 @@ class ComfyUIClient: status = entry.get("status", {}) if status.get("status_str") == "error": msgs = status.get("messages", []) - raise ComfyUIError(f"Execution error: {msgs}") + raise ComfyUIError(f"Execution error: {msgs}", prompt_id=prompt_id) return entry time.sleep(interval) raise ComfyUIError( - f"Prompt {prompt_id} did not complete within {timeout}s" + f"Prompt {prompt_id} did not complete within {timeout}s. " + f"The job is very likely still running on the ComfyUI server " + f"(local/custom workflows on modest GPUs routinely exceed the " + f"client wait) — it was not cancelled. Poll " + f"GET {{server_url}}/history/{prompt_id} directly, or call " + f"generate()/execute() again with a longer timeout and this " + f"prompt_id to resume waiting without resubmitting.", + prompt_id=prompt_id, ) def download( @@ -229,9 +246,15 @@ class ComfyUIClient: *, timeout: int = 600, interval: int = 5, + resume_prompt_id: str | None = None, ) -> list[Path]: - """Submit → poll → download. Returns list of artifact paths.""" - prompt_id = self.submit(workflow) + """Submit → poll → download. Returns list of artifact paths. + + Pass ``resume_prompt_id`` (from a previous ``ComfyUIError.prompt_id``) + to skip re-submitting an already-queued/running job and just resume + waiting on it — the common recovery path after a timeout. + """ + prompt_id = resume_prompt_id or self.submit(workflow) entry = self.poll(prompt_id, timeout=timeout, interval=interval) outputs = entry.get("outputs", {}) diff --git a/tools/video/comfyui_video.py b/tools/video/comfyui_video.py index 409264f5..5bab999d 100644 --- a/tools/video/comfyui_video.py +++ b/tools/video/comfyui_video.py @@ -186,6 +186,24 @@ class ComfyUIVideo(BaseTool): ), "items": {"type": "object"}, }, + "timeout_seconds": { + "type": "integer", + "description": ( + "How long to wait for the ComfyUI job to finish before giving up. " + "Default 3600s (1hr) covers slow/local GPUs and non-accelerated " + "custom workflows; raise it further for large frame counts or " + "high resolutions. On timeout the job is NOT cancelled server-side " + "and the error's data.prompt_id can be passed back via " + "resume_prompt_id to keep waiting without resubmitting." + ), + }, + "resume_prompt_id": { + "type": "string", + "description": ( + "A prompt_id from a previous timed-out call (see error data on " + "timeout). Skips resubmission and just resumes waiting/downloading." + ), + }, }, } @@ -320,12 +338,24 @@ class ComfyUIVideo(BaseTool): workflow, output_node=output_node, dest=output_path, - timeout=900, + timeout=inputs.get("timeout_seconds", 3600), interval=10, + resume_prompt_id=inputs.get("resume_prompt_id"), ) except ComfyUIError as exc: - return ToolResult(success=False, error=str(exc)) + data = {"prompt_id": exc.prompt_id} if exc.prompt_id else {} + if exc.prompt_id: + error_msg = ( + f"{exc}\n\nThis job was NOT cancelled and is very likely still " + f"running server-side. To recover it without resubmitting, call " + f"execute() again with resume_prompt_id={exc.prompt_id!r} " + f"(and a longer timeout_seconds if it needs more time), or poll " + f"GET {{COMFYUI_SERVER_URL}}/history/{exc.prompt_id} directly." + ) + else: + error_msg = str(exc) + return ToolResult(success=False, error=error_msg, data=data) except Exception as exc: return ToolResult(success=False, error=f"ComfyUI video generation failed: {exc}")