Files
DemonGatanjieu-Anomalous_Mo…/api/ai_task_runs.py
T
DemonGatanjieuandClaude Opus 5.5 dd9c3ee2f5 Tags from a picture, run words to the AI, colour matching after upscales
- tags_from_picture: WD14 tagger (the source's model and thresholds) on a
  picture the user handed; run_status now carries what nodes showed as words
  (texts: a tagger's tags), with a summary and next step for a run that made
  words but no pictures.
- Optional nodes: a block node marked optional goes when its pack is missing,
  and a link "match|decode" takes the first one there. The four upscales end in
  KJNodes' ColorMatch (mkl, the source's) when it is installed; the panel says
  "better with" it otherwise.
- LanPaint's next step keeps the prompt about the whole picture; Follow a pose
  says OpenPose misreads anime (use control_depth), as the source's author did.
- The directory has a size cap: past it, block names alone, so collected ways
  never make begin_task long; shorter headings and few-word lines.

Live-tested on the isolated 8199 ComfyUI: LanPaint (area changed 14.6, outside
0.0, 82 s), tiled 2x (167 s), depth ControlNet (follows the layout), OpenPose
(misreads an anime picture), background removal (8 s), tags (166 s with the
model download), upscale with ColorMatch (73 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 03:31:04 +08:00

465 lines
24 KiB
Python

"""What a workflow run an AI started made: after run_workflow (mcp_server.py), the prompts
ComfyUI queued for it are watched in its history until they end, and the pictures and videos
they saved become the step's results (ai_tasks.py keeps the step). A thread per run, polling
every RUN_POLL_SECONDS, for at most ai_tasks.RUN_WATCH_SECONDS; nothing runs when no AI ran
anything. While it runs, where it stands (queued and how many are ahead, the node running and
its steps, nodes done; read from ComfyUI's queue and its progress registry, never changed) is
the step's "progress", sent to the page with each change, and status() answers the AI's
run_status with it, waiting for the end when asked. tell() puts the same news in the AI's
other answers: where the run stands while it goes, and once that it ended. The console's lines,
the time left and the card's memory join the progress, and the size each sampler works at with
its warnings is the step's "estimate" (ai_run_watch.py), the run's shape in boxes its "map" with
what changed since the task's run before it (ai_run_map.py); a run the user or the AI stopped
(ai_run_control.py) ends "cancelled"."""
import threading
import time
from . import ai_gates, ai_run_map, ai_run_watch, ai_tasks
from .mcp_tools import ToolError, _image_content, _resolve_image, retext, tool_text
RUN_POLL_SECONDS = 2.0
MAX_STATUS_WAIT = 55
MAX_PREVIEWS = 8 # the last PreviewImage pictures of a run the AI is told of
MAX_SHOWN = 4 # thumbnails of a finished run's pictures that come with the answer first telling of its end
MAX_TEXTS = 4 # what a run's nodes showed as words (a tagger's tags), the last few
TEXT_CHARS = 2000
def _queue():
from server import PromptServer
return PromptServer.instance.prompt_queue
def outputs_of(record_, folder="output"):
"""Saved pictures and videos in one history record; with `folder` "temp", what PreviewImage
nodes showed (a pose skeleton, a mask): the AI's to look at, not the user's results."""
out = []
for output in (record_.get("outputs") or {}).values():
for key in ("images", "gifs", "videos"):
for item in output.get(key) or []:
if isinstance(item, dict) and item.get("type") == folder and item.get("filename"):
out.append({"type": "image", "filename": item["filename"], "subfolder": item.get("subfolder") or "",
"folder": folder})
return out
def texts_of(record_):
"""What a history record's nodes showed as words (a tagger's tags, a text display): [{node, text}]."""
out = []
for node, output in (record_.get("outputs") or {}).items():
for key in ("tags", "text", "string"):
words = [item for item in output.get(key) or [] if isinstance(item, str) and item.strip()]
if words:
out.append({"node": str(node), "text": " | ".join(words)[:TEXT_CHARS]})
return out
def _registry():
"""ComfyUI's per-node progress of the prompt running now, or None (an older ComfyUI has none)."""
try:
from comfy_execution import progress
except ImportError:
return None
return progress.global_progress_registry
def progress_of(prompts):
"""Where the run of `prompts` stands: queued (how many are ahead), running (the node and its
steps, nodes done of all), or None when ComfyUI cannot say."""
try:
running, pending = _queue().get_current_queue()
except Exception: # noqa: BLE001 - no server (tests)
return None
running_ids = [item[1] for item in running]
order = [item[1] for item in sorted(pending, key=lambda item: item[0])]
current = next((pid for pid in prompts if pid in running_ids), None)
if not current:
waiting = [pid for pid in prompts if pid in order]
if not waiting:
return None
return {"state": "queued", "ahead": order.index(waiting[0]) + len(running_ids)}
out = {"state": "running", "prompt": prompts.index(current) + 1, "prompts": len(prompts)}
registry = _registry()
if registry is None or registry.prompt_id != current:
return out
nodes = dict(registry.nodes)
out["nodes_done"] = sum(1 for node in nodes.values() if node["state"].value == "finished")
out["nodes"] = len(registry.dynprompt.original_prompt)
active = next(((node_id, node) for node_id, node in nodes.items() if node["state"].value == "running"), None)
if active:
try:
info = registry.dynprompt.get_node(active[0]) or {}
except Exception: # noqa: BLE001 - a node of the run that is gone already
info = {}
out["node"] = str((info.get("_meta") or {}).get("title") or info.get("class_type") or active[0])[:80]
out["value"], out["max"] = int(active[1]["value"]), int(active[1]["max"])
# Like ComfyUI's own tab title: the running node's steps; without steps, the nodes done.
if out.get("max", 0) > 1:
out["percent"] = max(0, min(100, round(100 * out["value"] / out["max"])))
elif out["nodes"]:
out["percent"] = max(0, min(100, round(100 * out["nodes_done"] / out["nodes"])))
return out
def _show_progress(task_id, step_id, progress):
"""The run's progress on its step (kept in memory; the page hears of each change)."""
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
if step and step.get("stop"):
progress = {**progress, "stopping": step["stop"]}
if not step or step.get("progress") == progress or step.get("state") != "running":
return
step["progress"] = progress
ai_tasks._notify(task_id, {"progress": {"step": step_id, **progress}})
def _finish_run(task_id, step_id, prompts, history, queued=None):
if not prompts:
_stop_run(task_id, step_id, "没有找到已排队的运行 / No queued run was found; completion cannot be confirmed")
return True
images, previews, texts, failed, done = [], [], [], False, 0
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
stopped = next((item.get("stop") for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
for prompt_id in prompts:
record_ = history.get(prompt_id)
if not record_:
if stopped and queued is not None and prompt_id not in queued:
done += 1 # taken out of the queue before it started: it never gets a history
continue
if stopped and (record_.get("status") or {}).get("status_str") == "error":
done += 1 # the interrupt ends a prompt "error"; it was stopped, not broken
continue
status = record_.get("status") or {}
if status.get("status_str") == "error":
failed = True
done += 1
elif status.get("completed"):
done += 1
for image in outputs_of(record_):
if image not in images: # a prompt ComfyUI answers from its cache repeats the same file
images.append(image)
previews += [image for image in outputs_of(record_, "temp") if image not in previews]
texts += [text for text in texts_of(record_) if text not in texts]
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
if not step:
return True
failed = failed or bool(step.get("submission_error"))
stopped = step.get("stop")
results = [item for item in step["results"] if item["type"] != "image"] + images[:ai_tasks.MAX_RESULTS]
previews, texts = previews[-MAX_PREVIEWS:], texts[-MAX_TEXTS:]
if (done < len(prompts) and results == step["results"] and step.get("prompts") == len(prompts)
and previews == step.get("previews", []) and texts == step.get("texts", [])
and (not failed or step.get("ok") is False)):
return False # nothing new to keep: no write
step["results"] = results
if previews:
step["previews"] = previews
if texts:
step["texts"] = texts
step["state"] = "running" if done < len(prompts) else ("cancelled" if stopped else "error" if failed else "done")
step["prompts"] = len(prompts)
if failed:
step["ok"] = False
step["error"] = step.get("submission_error") or "ComfyUI 运行出错 / The run failed"
if done >= len(prompts):
step.pop("progress", None)
step["ms"] = int((time.time() - step["at"]) * 1000)
if step["state"] in ("done", "error") and not step.get("told") and ai_gates.run_wait() == "call":
step["call_ai"] = True # the workbench offers the user a line to paste to the AI
task["updated"] = max(task["updated"], time.time())
ai_tasks._save(task)
ai_tasks._notify(task_id)
return done >= len(prompts)
def _stop_run(task_id, step_id, reason):
"""Monitoring ended without proof of completion; never pretend the run succeeded."""
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
if not step or step.get("state") != "running":
return
step.update(state="stopped", ok=False, error=reason, ms=max(0, int((time.time() - step["at"]) * 1000)))
step.pop("progress", None)
task["updated"] = max(task["updated"], time.time())
ai_tasks._save(task)
ai_tasks._notify(task_id)
def watch(step_id, receipt):
"""Follow only IDs returned by this submission, in batch order; never infer ownership from the queue."""
with ai_tasks._lock:
task_id = next((task["id"] for task in ai_tasks._load().values()
if any(step["id"] == step_id for step in task.get("steps") or [])), None)
if not task_id:
return
prompts = receipt.get("prompt_ids") or []
if not prompts:
_stop_run(task_id, step_id, "缺少运行 ID / Missing submission IDs; refresh ComfyUI before retrying")
return
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
if not step:
return # The user may remove the task while its submission is returning.
step["prompt_ids"] = list(prompts)
step_at = step["at"]
for other in task.get("steps", []): # a new run: no line to paste for an older one any more
if other is not step:
other.pop("call_ai", None)
if receipt.get("submission_error"):
step.update(submission_error=receipt["submission_error"], error=receipt["submission_error"], ok=False)
ai_tasks._save(task)
def follow():
start = time.monotonic()
pace, estimated = ai_run_watch.Pace(), False
while time.monotonic() - start < ai_tasks.RUN_WATCH_SECONDS:
try:
history = {pid: _queue().get_history(prompt_id=pid).get(pid) for pid in prompts}
except Exception: # noqa: BLE001 - stop the receipt, not the actual ComfyUI run
_stop_run(task_id, step_id, "无法读取运行记录 / Run history could not be read; check ComfyUI's queue")
return
try:
running, pending = _queue().get_current_queue()
queued = {item[1]: item for item in [*running, *pending]}
except Exception: # noqa: BLE001 - the queue only helps (estimate, a stopped run); history decides
queued = None
if not estimated: # from the queue, or from the history of a run that was quicker than this look
item = next((queued[pid] for pid in prompts if pid in (queued or {})), None) or next(((history.get(pid) or {}).get("prompt") for pid in prompts if (history.get(pid) or {}).get("prompt")), None)
estimated = _estimate(task_id, step_id, item)
if _finish_run(task_id, step_id, prompts, {k: v for k, v in history.items() if v},
None if queued is None else set(queued)):
return
try:
progress = progress_of(prompts)
if progress and progress["state"] == "running":
_more_progress(progress, step_at, pace)
except (AttributeError, KeyError, TypeError, ValueError):
progress = None # progress is optional; keep watching the authoritative history
if progress:
_show_progress(task_id, step_id, progress)
time.sleep(RUN_POLL_SECONDS)
_stop_run(task_id, step_id, "运行跟踪已超时,未停止出图 / Monitoring timed out; the workflow was not cancelled. Check ComfyUI's queue")
threading.Thread(target=follow, name="anomalous-ai-run", daemon=True).start()
def _estimate(task_id, step_id, item):
"""The run's estimate (ai_run_watch.estimate) and map (ai_run_map) from its queued prompt `item`,
kept on the step once, with what changed since the task's run before."""
if not item or len(item) < 3 or not isinstance(item[2], dict):
return False
memory = ai_run_watch.vram()
try:
found = ai_run_watch.estimate(item[2], (memory or {}).get("total_mb"))
shape = ai_run_map.map_of(item[2])
except Exception: # noqa: BLE001 - an odd prompt leaves the run without an estimate, nothing more
return True
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
steps = (task or {}).get("steps", [])
at = next((index for index, entry in enumerate(steps) if entry["id"] == step_id), None)
if at is None:
return True
before = next((entry["map"] for entry in reversed(steps[:at]) if entry.get("tool") == "run_workflow" and entry.get("map")), None)
steps[at].update(estimate=found, map=shape, changes=ai_run_map.changes(before, shape))
ai_tasks._save(task)
ai_tasks._notify(task_id)
return True
def _more_progress(progress, started, pace):
"""The console's last lines, its bar's time left (else the pace of the steps), the card's memory."""
lines = ai_run_watch.console_since(started)
if lines:
progress["console"] = lines
bar = ai_run_watch.bar_of(lines)
if bar and bar.get("note"):
progress["note"] = bar["note"]
timing = None
if bar and bar["max"] == progress.get("max"):
timing = {key: bar[key] for key in ("left", "rate") if key in bar}
if not timing and progress.get("max", 0) > 1:
timing = pace.update((progress.get("prompt"), progress.get("node")), progress["value"], progress["max"])
if timing:
progress.update(timing)
memory = ai_run_watch.vram()
if memory:
progress["vram"] = memory
def summary(run):
"""One sentence for the AI on where run step `run` stands."""
state, progress = run.get("state"), run.get("progress") or {}
warned = " ".join(ai_run_watch.warning_words(item) for item in (run.get("estimate") or {}).get("warnings", []))
warning = (f" Warning: {warned} Tell the user (report) and ask_user whether to stop it (stop_run)." if warned and state == "running" else "")
if state == "running" and progress.get("state") == "queued":
return f"Your run is queued, {progress.get('ahead', 0)} ahead of it; it has not started. Do not run it again.{warning}"
if state == "running":
parts = []
if progress.get("prompts", 0) > 1:
parts.append(f"run {progress['prompt']} of {progress['prompts']}")
if progress.get("node"):
parts.append(f"{progress['node']} step {progress['value']}/{progress['max']}" if progress.get("max", 0) > 1
else progress["node"])
if "percent" in progress:
parts.append(f"{progress['percent']}%")
if progress.get("nodes"):
parts.append(f"{progress['nodes_done']} of {progress['nodes']} nodes done")
if progress.get("left") is not None:
parts.append(f"about {progress['left']} s left in this node")
parts.append(f"{round(time.time() - run['at'])} s so far")
if progress.get("note"):
parts.append(f"console says: {progress['note']}")
return (f"Your run is still going ({', '.join(parts)}). It has not finished: {waiting_words()}; "
"do not look in the output folder or guess." + warning)
if state == "cancelled":
return ("The user stopped your run in Anomalous; do not start it again unless they ask." if run.get("stop") == "user"
else "Your run was stopped (stop_run).")
if state == "done":
pictures = [item["filename"] for item in run.get("results") or [] if item.get("type") == "image"]
made = f"{len(pictures)} picture(s): {', '.join(pictures[:8])}" if pictures else "no pictures"
words = f" and words from {len(run['texts'])} node(s) (texts)" if run.get("texts") else ""
return f"Your run finished in {round(run.get('ms', 0) / 1000)} s with {made}{words}."
if state in ("asking", "allowed"):
return "Your run waits for the user's OK in Anomalous; it has not started."
return f"Your run did not finish: {run.get('error') or state}."
def news(task_id):
"""The run news for the AI working on task `task_id`: while its latest run goes, and once after it ends."""
with ai_tasks._lock:
task = ai_tasks._load().get(task_id) if task_id else None
run = next((step for step in reversed((task or {}).get("steps", [])) if step.get("tool") == "run_workflow"), None)
if not run or not run.get("prompt_ids") or run.get("told"):
return None
answer = {"state": run.get("state"), "summary": summary(run)}
if run.get("state") == "running" and run.get("progress"):
answer["progress"] = run["progress"]
if run.get("state") not in ("running", "asking", "allowed"):
run["told"], run["shown"] = True, False # show_pictures attaches them to this answer
run.pop("call_ai", None)
ai_tasks._save(task)
return answer
def waiting_words():
"""How the AI is to wait for a run still going, as the user chose (ai_gates.run_wait)."""
if ai_gates.run_wait() == "call":
return ("the user tells you when it is done (Anomalous gives them a line to paste to you): say it is running "
"and end your turn; do not wait with run_status")
return "call run_status with wait_seconds to wait for it"
def show_pictures(result, task_id):
"""Attaches thumbnails of the latest run's pictures to the answer that first tells the AI it ended
(status() or news() marked it), so it sees what came out without get_image_info; once per run."""
if not isinstance(result, dict) or result.get("isError") or not task_id:
return result
with ai_tasks._lock:
task = ai_tasks._load().get(task_id)
run = next((step for step in reversed((task or {}).get("steps", [])) if step.get("tool") == "run_workflow"), None)
if not run or run.get("shown") is not False:
return result
run["shown"] = True
ai_tasks._save(task)
shown, items = [], []
for item in run.get("results") or []:
rel = "/".join(part for part in (item.get("subfolder"), item.get("filename")) if part)
if item.get("type") != "image" or len(items) >= MAX_SHOWN:
continue
try:
picture = _image_content(_resolve_image(rel)[1])
except ToolError:
picture = None
if picture:
shown.append(rel)
items.append(picture)
if not items:
return result
data = dict(result.get("structuredContent") or {})
data["pictures_attached"] = shown
retext(result, data)
result["content"] = [*result["content"], *items]
return result
def tell(result, task_id):
"""Puts the run news (news()) into a tool result, so the AI need not go and look."""
run = news(task_id) if isinstance(result, dict) and not result.get("isError") else None
if not run:
return result
data = result.get("structuredContent")
data = dict(data) if isinstance(data, dict) else {}
data["your_run"] = run
retext(result, data)
return result
def status(client, session="", task_id=None, wait_seconds=0, step_id=None):
"""The AI's run_status: its task's latest run, where it stands, and what it made; with
`wait_seconds`, waits (up to MAX_STATUS_WAIT) until the run ends. No run yet is an answer, not
an error (an AI asks before it runs, and the task would show a failed step)."""
task_id = ai_tasks.active_task_id(client, session, task_id)
deadline = time.monotonic() + max(0.0, min(float(wait_seconds or 0), MAX_STATUS_WAIT))
while True:
task = ai_tasks.get_task(task_id) if task_id else None
run = next((step for step in reversed((task or {}).get("steps", []))
if step.get("tool") == "run_workflow" and (not step_id or step["id"] == step_id)), None)
if not run:
return {"state": "none", "summary": "No run in this task yet.",
"next": "run_workflow starts one (workflow: a saved one, with the prompt, seed or size to change)."}
if run.get("state") not in ("running", "asking", "allowed") or time.monotonic() >= deadline:
break
time.sleep(1)
answer = {"state": run.get("state"), "seconds": round(run.get("ms", 0) / 1000) if run.get("state") != "running"
else round(time.time() - run["at"]), "summary": summary(run)}
if run.get("state") not in ("running", "asking", "allowed") and not run.get("told"):
with ai_tasks._lock: # told here: the next answers need not repeat it
task = ai_tasks._load().get(task_id)
step = next((item for item in (task or {}).get("steps", []) if item["id"] == run["id"]), None)
if step is not None:
step["told"], step["shown"] = True, False # show_pictures attaches them to this answer
step.pop("call_ai", None)
ai_tasks._save(task)
if run.get("progress"):
answer["progress"] = run["progress"]
if (run.get("estimate") or {}).get("samplers"):
answer["estimate"] = run["estimate"]
if run.get("prompt_ids"):
answer["prompt_ids"] = run["prompt_ids"]
pictures = ["/".join(part for part in (item.get("subfolder"), item["filename"]) if part)
for item in run.get("results") or [] if item.get("type") == "image"]
if pictures:
answer["pictures"] = pictures
previews = ["/".join(part for part in (item.get("subfolder"), item["filename"]) if part) for item in run.get("previews") or []]
if previews:
answer["previews"] = previews
answer["previews_note"] = ("What the run's PreviewImage nodes showed (pose skeletons, masks…), in ComfyUI's temp "
"folder: zoom_image(image, folder='temp') or get_image_info(image, folder='temp', "
"include_image=true) shows them; there is no need to run a preprocessor yourself.")
if run.get("texts"):
answer["texts"] = run["texts"] # what nodes showed as words: a tagger's tags
if run.get("error"):
answer["error"] = run["error"]
# What to do now, in one line a small model can follow (get_situation and the canvas tools say theirs too).
answer["next"] = {
"running": (f"It is still running: {waiting_words()}. Do not guess it is done or run it again."
if ai_gates.run_wait() == "call" else
"run_status(wait_seconds=55): it is still running; do not guess it is done or run it again."),
"allowed": "run_status(wait_seconds=55): it is about to run.",
"done": ("The first answer telling you it ended has its pictures attached (pictures_attached): look at them, "
"then report what came out with images= its pictures; show_panel only to compare or ask. "
"zoom_image looks closer.") if pictures or not run.get("texts") else
("It made no pictures: texts has what its nodes showed (a tagger's tags); use them (set_prompt), then report."),
"asking": "get_answers(wait_seconds=55): the run waits for the user's OK in Anomalous.",
"cancelled": "It was stopped and nothing more comes of it: ask_user what to change before running again.",
}.get(run.get("state"), "get_situation shows what went wrong (a missing model: check_workflow_models); fix it, then run again.")
return answer