Files
DemonGatanjieu-Anomalous_Mo…/api/ai_tasks.py
T
DemonGatanjieuandClaude Opus 5.5 139380e960 Give the AI what it needs: answers it can read, hints on an empty canvas, AMB always loading
- An answer past Antigravity's size is filed away unread (eight LoRA
  searches in the exam): search_models answers small (loaders once,
  four trigger words, a LoRA's author weight); describe_canvas gives a
  big canvas's last nodes a line each; mcp_fit.py keeps any other list
  answer within the size and names what it left out.
- An empty canvas: run_workflow, describe_canvas and get_situation say
  to open a workflow first; get_image_info says a picture's workflow
  opens with open_workflow.
- ask_user does not put a "test" question to the user; a task's picture
  count counts what its runs made.
- AMB failed to load now and then: ComfyUI loads every file at once and
  ui_ai_workbench.js set ui_ai_toolbox.js's sideHost before it was made
  when the toolbox file ran first. Connected on first draw now;
  tests/load_order.mjs runs every file first.
- input_facts moved to api/picture_mask.py to keep mcp_tools.py short.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:53:50 +08:00

597 lines
28 KiB
Python

"""AI tasks: what AI apps did through Anomalous's MCP endpoint, kept for the AI workbench page.
Every tool call an AI app makes (mcp_server.py) becomes a step of a task: a task is one piece
of work, named by the AI with begin_task, else the calls of one app that follow each other
without a long pause. A step starts in state "running" when the call begins and is updated in
place when it finishes: what was done (the tool, a short summary of its arguments), whether it
worked, and what came out of it, so the page can show it live and lead there: the models it
used, the combo it placed, the canvas change it made (by its activity log entry, for undo),
the pictures a run made (ai_task_runs.py watches ComfyUI's history until the run ends), the
audio it spoke. The AI can also leave a note (report), with pictures it made another way. What
each tool is (kind, stage of the work, gate) is action_catalog.py's; the user's own changes
made while an AI works join its task through ai_task_changes.py. A step a restart left
"running" is marked stopped when the record is read again.
The user can start a task first (New task, POST /anomalous/ai_tasks): it waits for an AI, and
the next app that calls takes it over, so the work it does next starts there instead of joining
the app's last task. Likewise an app whose task ended or paused goes on in the task the user wrote
to (a message, marks, pictures, materials) that it has not read yet, rather than starting one.
Tasks are files in the ComfyUI user folder (anomalous/ai_tasks/<id>.json), the newest
MAX_TASKS kept. Recording never fails a tool call: the record is a convenience. The page
learns of a change through the "anomalous.ai_task" event. Reading is open like the other
pages; renaming and removing are writes (local_guard.py keeps them to this computer). The
routes are in ai_tasks_routes.py.
"""
import json
import logging
import os
import re
import shutil
import threading
import time
import uuid
import folder_paths
from . import action_catalog, activity_log
from .ai_task_handoff import just_done, latest_replied, recovery_target, unresolved
from .path_utils import atomic_write_json
FORMAT = 1
MAX_TASKS = 300
MAX_STEPS = 400
MAX_TASK_BYTES = 2 * 1024 * 1024
IDLE_SECONDS = 20 * 60 # a longer pause between calls of one app starts a new task
MAX_TEXT = 300
MAX_NOTE = 2000
MAX_TITLE = 80
MAX_RESULTS = 40
RUN_WATCH_SECONDS = 30 * 60
MEDIA_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm"}
# What each tool is (its kind, stage and gate) is action_catalog.py's.
OWN_STEPS = {"report", "log_decision", "log_outside", "show_panel", "ask_user", "show_run_map", "propose_repaint_area"} # make their step themselves (with its text or panel)
_lock = threading.RLock()
_tasks = None # id -> task
_current = {} # client/session key -> id of the task its calls go to
_waiting = [None] # id of the task the user started that no AI has taken yet
log = logging.getLogger(__name__)
# ---------------------------------------------------------------- storage
def tasks_dir():
user_dir = folder_paths.get_user_directory() if hasattr(folder_paths, "get_user_directory") else None
if not user_dir:
user_dir = os.path.join(folder_paths.base_path, "user")
directory = os.path.join(user_dir, "anomalous", "ai_tasks")
os.makedirs(directory, exist_ok=True)
return directory
def _path(task_id):
return os.path.join(tasks_dir(), f"{task_id}.json")
def marks_dir(task_id):
"""Where the pictures the user marked for a task are kept (ai_marks.py); gone with the task."""
return os.path.join(os.path.dirname(tasks_dir()), "ai_marks", re.sub(r"[^\w-]", "", task_id))
def _forget(task_id):
"""The task's own files: its record and its marked pictures (the plugin's, not the user's pictures)."""
try:
os.remove(_path(task_id))
except OSError:
pass
shutil.rmtree(marks_dir(task_id), ignore_errors=True)
def _load():
global _tasks
if _tasks is None:
_tasks = {}
for name in os.listdir(tasks_dir()):
if not name.endswith(".json"):
continue
try:
with open(os.path.join(tasks_dir(), name), encoding="utf-8") as source:
task = json.load(source)
except (OSError, ValueError):
continue
if isinstance(task, dict) and task.get("format") == FORMAT and isinstance(task.get("id"), str):
_tasks[task["id"]] = task
if _stop_running(task):
_save(task)
# A task the user started before ComfyUI restarted still waits for the next AI.
waiting = [task for task in _tasks.values() if task.get("waiting") and not task.get("ended")]
if waiting and _waiting[0] is None:
_waiting[0] = max(waiting, key=lambda task: task.get("started", 0))["id"]
return _tasks
def _stop_running(task):
"""Steps a call or a run left "running" when ComfyUI stopped: nothing watches them any more."""
stopped = False
for step in task.get("steps") or []:
if step.get("state") == "asking" and step.get("gate") and not step["gate"].get("timed_out"):
step["gate"]["timed_out"] = True # no old process is still waiting for this decision
stopped = True
if step.get("state") == "running":
step.update(state="stopped", ok=False, error="ComfyUI 重启时这一步还没结束 / ComfyUI restarted before this step ended")
stopped = True
return stopped
def _save(task):
try:
atomic_write_json(_path(task["id"]), task, MAX_TASK_BYTES)
except (OSError, ValueError) as error:
log.warning("Anomalous AI tasks: %s not saved: %s", task["id"], error)
def _drop_oldest():
tasks = _load()
if len(tasks) <= MAX_TASKS:
return
for task in sorted(tasks.values(), key=lambda item: item.get("updated", 0))[: len(tasks) - MAX_TASKS]:
tasks.pop(task["id"], None)
_forget(task["id"]) # the pictures and files it made stay
def _notify(task_id, extra=None):
try:
from server import PromptServer
PromptServer.instance.send_sync("anomalous.ai_task", {"id": task_id, **(extra or {})})
except Exception: # noqa: BLE001 - tests and early start have no server; the page polls on open
pass
# ---------------------------------------------------------------- who is calling (named in ai_callers.py)
def _owner_key(client, session=""):
return f"{client}::{session}" if session else client
def _bind_owner(client, session, task_id):
_current[_owner_key(client, session)] = task_id
if not session or client not in _current:
_current[client] = task_id
# ---------------------------------------------------------------- tasks and steps
def _short(value, limit=MAX_TEXT):
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
return text if len(text) <= limit else text[:limit] + "…"
def _new_task(client, title="", now=None, session=""):
now = time.time() if now is None else now
task = {"format": FORMAT, "id": f"{int(now)}_{uuid.uuid4().hex[:8]}", "title": _short(title, MAX_TITLE),
"title_from": "ai" if title else "auto", "client": client, "started": now, "updated": now,
"ended": None, "steps": []}
if session:
task["session"] = session
_load()[task["id"]] = task
_bind_owner(client, session, task["id"])
_drop_oldest()
return task
def _claim_explicit(tasks, task_id, client, session=""):
if not task_id or task_id not in tasks or tasks[task_id].get("ended"):
return None
picked = tasks[task_id]
if picked.get("waiting"):
if _waiting[0] == task_id:
_waiting[0] = None
picked["waiting"] = False
if not picked.get("client"):
picked["client"] = client
_bind_owner(client, session, task_id)
return picked
def _take_waiting(client, now, session=""):
"""The task the user started, now this app's (its own open task ends), or None."""
task = _load().get(_waiting[0])
_waiting[0] = None
if not task or task.get("ended"): # it waits until an AI takes it, as its page says, or the user starts another
return None
owner = _owner_key(client, session)
previous = _load().get(_current.get(owner) or (None if session else _current.get(client)))
if previous and previous is not task and not previous.get("ended"):
previous["ended"] = previous.get("updated")
_save(previous)
task["client"] = client
if session:
task["session"] = session
task["waiting"] = False
_bind_owner(client, session, task["id"])
return task
ABOUT_TYPES = {"model": "model_id", "image": "image", "combo": "name", "workflow": "name"} # what a task the user starts can be about
def _about(items):
"""What the user handed to the AI with a task (a model, an output image, a combo), checked: plain strings only."""
out = []
for item in items if isinstance(items, list) else []:
key = ABOUT_TYPES.get(item.get("type")) if isinstance(item, dict) else None
if key and isinstance(item.get(key), str) and item[key].strip():
out.append({"type": item["type"], key: _short(item[key].strip(), 300),
**({"label": _short(item["label"], 120)} if isinstance(item.get("label"), str) else {})})
return out[:5]
def start_task(title="", now=None, about=None, from_task=None):
"""A task the user starts; it waits for the next AI app that calls. `about`: what the user
handed to the AI with it (from a model page, an image or a combo); `from_task`: the task it
goes on from (ai_task_links.py). Returns the task."""
now = time.time() if now is None else now
with _lock:
before = _load().get(_waiting[0])
if before and not before.get("steps"): # started before and never taken: this one replaces it
remove_task(before["id"])
task = {"format": FORMAT, "id": f"{int(now)}_{uuid.uuid4().hex[:8]}", "title": _short(str(title or "").strip(), MAX_TITLE),
"title_from": "user", "client": "", "started": now, "updated": now, "ended": None, "steps": [], "waiting": True}
if not task["title"]:
task["title_from"] = "auto"
if _about(about):
task["about"] = _about(about)
if from_task in _load():
task["from"] = from_task
_load()[task["id"]] = task
_waiting[0] = task["id"]
_drop_oldest()
_save(task)
_notify(task["id"])
return task
def active_task_id(client, session="", task_id=None):
"""The active task id for this caller without creating a new task."""
with _lock:
tasks = _load()
if task_id and task_id in tasks:
return task_id
found = _current.get(_owner_key(client, session)) or (None if session else _current.get(client))
return found if found in tasks else None
def current_task(client, now=None, session="", task_id=None, follow_up=False, read_only=False):
"""The task this app's calls go to now: an explicit task, one the user started for the next
AI, else its open one, unless it paused too long. `follow_up`: a panel, question or note, which
right after the AI said its task is done is about that task (ai_task_handoff.just_done).
`read_only` keeps such a task ended while a follow-up reopens it; both honor an explicit id."""
now = time.time() if now is None else now
with _lock:
tasks = _load()
task = tasks.get(task_id or _current.get(_owner_key(client, session)))
if (follow_up or read_only) and just_done(task, tasks, tasks.get(_waiting[0]), client, now, explicit=bool(task_id)):
_bind_owner(client, session, task["id"])
if follow_up:
task["ended"] = None
_save(task)
return task
picked = _claim_explicit(tasks, task_id, client, session)
if picked:
return picked
waiting = _take_waiting(client, now, session=session)
if waiting:
return waiting
task = tasks.get(_current.get(_owner_key(client, session)) or (None if session else _current.get(client)))
if task and not task.get("ended") and now - task.get("updated", 0) < IDLE_SECONDS:
_bind_owner(client, session, task["id"])
return task
if task and not task.get("ended"):
task["ended"] = task.get("updated")
_save(task)
return _take_replied(tasks, client, now, session) or _new_task(client, now=now, session=session)
def _take_replied(tasks, client, now, session="", previous=None):
"""The newest task of this app the user wrote to lately, since its AI last read (an ended one
opens again), now this app's instead of a new one (its own open task `previous` ends), or None."""
replied = latest_replied(tasks, client, now)
if replied is None:
return None
if previous and previous is not replied and not previous.get("ended"):
previous["ended"] = previous.get("updated")
_save(previous)
replied["ended"] = None
_bind_owner(client, session, replied["id"])
_save(replied)
return replied
def begin_task(client, title, now=None, new_task=False, session="", task_id=None):
"""Name or resume the active task; only an explicit new piece of work splits it."""
now = time.time() if now is None else now
with _lock:
tasks = _load()
previous = tasks.get(_current.get(_owner_key(client, session)) or (None if session else _current.get(client)))
task = None if new_task else _claim_explicit(tasks, task_id, client, session)
if task is None:
task = _take_waiting(client, now, session=session)
if task is None and not new_task and previous and not previous.get("ended") and now - previous.get("updated", 0) < IDLE_SECONDS:
task = previous
if task is None and not new_task:
task = _take_replied(tasks, client, now, session, previous)
if task is None:
if previous and not previous.get("ended"):
previous["ended"] = now
_save(previous)
task = _new_task(client, title, now, session=session)
if new_task and previous:
task["from"] = previous["id"] # begun from that one (ai_task_links.py)
elif not task.get("title") or task.get("title_from") == "auto":
said = next((step["text"] for step in task["steps"] if step.get("tool") == "user_message" and step.get("text")), "")
task["title"] = _short(said or title, MAX_TITLE)
task["title_from"] = "user" if said else "ai"
_bind_owner(client, session, task["id"])
task["updated"] = now
_save(task)
_notify(task["id"])
return task
def _add_step(task, step):
task["steps"].append(step)
del task["steps"][:-MAX_STEPS]
task["updated"] = max(task.get("updated", 0), step["at"] + step.get("ms", 0) / 1000)
if task.get("title_from") == "auto" and not task.get("title") and step["kind"] not in ("look", "note", "skill") and step["ok"]:
task["title"] = step["summary"][:MAX_TITLE]
def summary_of(tool, arguments):
"""A few words on what a call asked for: its main argument."""
for key in ("title", "query", "text", "name", "workflow", "character", "model_id", "url", "what", "names", "type", "types"):
if arguments.get(key) not in (None, "", []):
value = arguments[key]
if key == "model_id": # "type:0:folder/file.safetensors": the file, and where it moves to
value = str(value).split(":", 2)[-1].rsplit("/", 1)[-1] + (f" → {arguments['to_type']}" if arguments.get("to_type") else "")
return _short(f"{tool}: {_short(value, 120)}", 160)
return tool
def _model_result(model_id):
from .mcp_tools import _resolve_model
try:
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
except Exception: # noqa: BLE001 - a model that went away is still worth its id
return {"type": "model", "model_id": str(model_id)}
rel = os.path.relpath(file_path, base_dir).replace(os.sep, "/")
return {"type": "model", "model_id": str(model_id), "folder": folder_type, "path_idx": path_idx, "rel": rel,
"name": os.path.basename(file_path)}
def results_of(tool, arguments, data):
"""What came out of a call, for the page to show and lead to."""
data = data if isinstance(data, dict) else {}
out = []
if tool in ("set_model", "add_lora", "open_in_anomalous") and arguments.get("model_id"):
out.append(_model_result(arguments["model_id"]))
if tool == "place_combo" and arguments.get("name"):
out.append({"type": "combo", "name": _short(arguments["name"], 120)})
if isinstance(data.get("_activity"), str):
out.append({"type": "canvas", "activity": data["_activity"]})
if tool == "speak":
out.extend({"type": "audio", "file": _short(f, 200)} for f in data.get("files") or [] if isinstance(f, str))
if tool == "scan_models" and data.get("started"):
out.append({"type": "scan"})
if tool == "download_model" and data.get("started"):
out.append({**_download_result(data), "name": _short(str(data.get("model") or ""), 160)})
if tool == "download_missing_models":
out.extend(_download_result(item) for item in data.get("started") or [] if isinstance(item, dict))
return out[:MAX_RESULTS]
def _download_result(started):
"""A download the AI started (mcp_actions' answer): the file it makes, and its size when the site said."""
size = started.get("size")
return {"type": "download", "file": _short(str(started.get("to") or "").rsplit("/", 1)[-1], 200),
"size": size if isinstance(size, int) and size > 0 else None}
def _count_of(data):
"""How many things a read found: the length of its first list."""
if isinstance(data, dict):
for value in data.values():
if isinstance(value, list):
return len(value)
return None
def begin_step(client, tool, arguments, started=None, read_only=False, session="", task_id=None):
"""Starts an in-flight step in state 'running' before a tool executes. Returns (task_id, step_id) or None."""
if not action_catalog.is_step(tool) or tool in OWN_STEPS:
return None
try:
started = time.time() if started is None else started
args = arguments if isinstance(arguments, dict) else {}
what = action_catalog.entry(tool, read_only)
step = {"id": uuid.uuid4().hex[:10], "at": started, "ms": 0, "tool": tool, "kind": what.kind, "stage": what.stage,
"ok": True, "state": "running", "summary": summary_of(tool, args), "results": [],
"recovery_target": recovery_target(tool, args)}
with _lock:
task = current_task(client, started, session=session, task_id=task_id, read_only=read_only)
_add_step(task, step)
_save(task)
_notify(task["id"])
return task["id"], step["id"]
except Exception: # noqa: BLE001 - the record must never fail the AI's call
log.warning("Anomalous AI tasks: begin_step of %s failed", tool, exc_info=True)
return None
def finish_step(client, tool, arguments, result, started, read_only=False, step_ref=None, session="", task_id=None):
"""Completes an in-flight step (or creates one if step_ref is None) with its final outcome. Never raises."""
if not action_catalog.is_step(tool) or tool in OWN_STEPS:
return None
try:
args = arguments if isinstance(arguments, dict) else {}
data = result.get("structuredContent") if isinstance(result, dict) else None
failed = not isinstance(result, dict) or bool(result.get("isError"))
error = _short(" ".join(item.get("text", "") for item in result.get("content") or [] if isinstance(item, dict))) if failed and isinstance(result, dict) else ""
ms = max(0, int((time.time() - started) * 1000))
what = action_catalog.entry(tool, read_only)
kind = what.kind
waiting = isinstance(data, dict) and data.get("waiting_for_user") # a gate the user has not answered (ai_gates.py)
state = "error" if failed else "asking" if waiting else "running" if tool == "run_workflow" else "done"
summary = summary_of(tool, args)
results = [] if failed else results_of(tool, args, data)
with _lock:
task = _load().get(step_ref[0]) if isinstance(step_ref, tuple) and len(step_ref) == 2 else None
step = next((item for item in (task or {}).get("steps", []) if item.get("id") == step_ref[1]), None) if task else None
# A user can answer just after check() times out, before this call returns.
gate = (step or {}).get("gate") or {}
if waiting and gate.get("decision"):
failed = gate["decision"] == "deny"
state = "error" if failed else "done" if gate.get("used") else "allowed"
error = (step.get("error") or "你没有允许 / You said no") if failed else ""
if task is None:
task = current_task(client, started, session=session, task_id=task_id, read_only=read_only)
if step is None:
step = {"id": uuid.uuid4().hex[:10], "at": started, "ms": ms, "tool": tool, "kind": kind, "stage": what.stage,
"ok": not failed, "state": state, "summary": summary, "results": results}
_add_step(task, step)
else:
step.update(ms=ms, ok=not failed, state=state, summary=summary, results=results)
step["recovery_target"] = recovery_target(tool, args)
if error:
step["error"] = error
else:
step.pop("error", None)
if kind == "look" and not failed and tool.startswith(("search_", "list_")):
step["count"] = _count_of(data)
task["updated"] = max(task.get("updated", 0), step["at"] + ms / 1000)
if task.get("title_from") == "auto" and not task.get("title") and kind not in ("look", "note") and step["ok"]:
task["title"] = summary[:MAX_TITLE]
_save(task)
for result in results:
if result["type"] == "canvas":
activity_log.tag(result["activity"], task["id"]) # the activity page links back to the task
_notify(task["id"])
return step
except Exception: # noqa: BLE001 - the record must never fail the AI's call
log.warning("Anomalous AI tasks: step of %s not recorded", tool, exc_info=True)
return None
def record(client, tool, arguments, result, started, read_only=False, session="", task_id=None):
"""One tool call as a step of the app's current task. Never raises."""
return finish_step(client, tool, arguments, result, started, read_only, session=session, task_id=task_id)
# ---------------------------------------------------------------- notes from the AI
def _output_media(path, folder="output"):
"""An output-folder file the AI names ("subfolder/name.png"), as an image result, or None; with
`folder` "temp", a run's preview there (gone when ComfyUI restarts)."""
root = os.path.realpath(folder_paths.get_temp_directory() if folder == "temp" else folder_paths.get_output_directory())
full = os.path.realpath(os.path.join(root, str(path).replace("\\", "/").lstrip("/")))
if os.path.commonpath((root, full)) != root or not os.path.isfile(full):
return None
if os.path.splitext(full)[1].lower() not in MEDIA_EXTS:
return None
rel = os.path.relpath(full, root).replace(os.sep, "/")
subfolder, _, filename = rel.rpartition("/")
return {"type": "image", "filename": filename, "subfolder": subfolder, "folder": "temp" if folder == "temp" else "output"}
def report(client, text, images=None, done=False, now=None, session="", task_id=None, reply_to=None):
"""A note from the AI, with pictures it made (in ComfyUI's output folder); `reply_to`: the user's
message or marks it answers (both steps then point at each other). Returns the step."""
text = str(text or "").strip()
if not text and not images:
raise ValueError("text or images is needed")
results, missing = [], []
for path in (images or [])[:MAX_RESULTS]:
item = _output_media(path)
(results.append(item) if item else missing.append(str(path)))
now = time.time() if now is None else now
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "report", "kind": "note", "ok": True,
"state": "done", "summary": "report", "text": _short(text, MAX_NOTE), "results": results}
with _lock:
task = current_task(client, now, session=session, task_id=task_id, follow_up=True)
asked = next((item for item in task["steps"] if reply_to and item["id"] == reply_to and item.get("kind") == "user"), None)
if reply_to and not asked:
raise ValueError("reply_to is the id of the user's message or marks (user_messages[].id) in this task")
if asked:
step["reply_to"], asked["replied"] = asked["id"], step["id"]
_add_step(task, step)
if done:
task["ended"] = now
_save(task)
_notify(task["id"])
return step, missing
# ---------------------------------------------------------------- the page's view
def task_summary(task, now=None):
now = time.time() if now is None else now
steps = task.get("steps") or []
kinds = {}
for step in steps:
kinds[step["kind"]] = kinds.get(step["kind"], 0) + 1
images = [item for step in steps for item in step.get("results") or [] if item.get("type") == "image"]
made = {(item.get("folder"), item.get("subfolder"), item.get("filename")) for step in steps if step.get("kind") == "run"
for item in step.get("results") or [] if item.get("type") == "image"} # not a picture a report only shows
asks = sum(1 for step in steps if step.get("kind") == "panel" and not step["panel"].get("closed")
for block in step["panel"]["blocks"] if block.get("type") in ("choices", "input", "picks")
and block.get("id") and block["id"] not in step.get("answers", {}))
asks += sum(1 for step in steps if step.get("state") == "asking")
return {
"id": task["id"], "title": task.get("title") or "", "client": task.get("client") or ("" if task.get("waiting") else "AI"),
"started": task.get("started"), "updated": task.get("updated"), "ended": task.get("ended"),
"waiting": bool(task.get("waiting")) and not task.get("ended"),
"live": any(step.get("state") == "running" and now - step.get("at", 0) < RUN_WATCH_SECONDS for step in steps),
"steps": len(steps), "kinds": kinds, "errors": unresolved(steps),
"images": len(made), "cover": images[-1] if images else None, "asks": asks, "from": task.get("from"),
}
def list_tasks(limit=100):
with _lock:
tasks = sorted(_load().values(), key=lambda task: task.get("updated", 0), reverse=True)
return [task_summary(task) for task in tasks[:limit]]
def get_task(task_id):
with _lock:
task = _load().get(task_id)
return json.loads(json.dumps(task)) if task else None
def rename_task(task_id, title):
with _lock:
task = _load().get(task_id)
if not task:
return None
task["title"] = _short(str(title or "").strip(), MAX_TITLE)
task["title_from"] = "user" if task["title"] else "auto"
_save(task)
return task
def remove_task(task_id):
with _lock:
task = _load().pop(task_id, None)
if not task:
return False
for client, current in list(_current.items()):
if current == task_id:
_current.pop(client, None)
if _waiting[0] == task_id:
_waiting[0] = None
_forget(task_id)
return True