feat(cron): bind scheduled automations to sessions

This commit is contained in:
chengyongru
2026-06-11 19:48:07 +08:00
parent ffae1dca6d
commit a326ba40f4
28 changed files with 1277 additions and 82 deletions
+97 -1
View File
@@ -39,6 +39,11 @@ from nanobot.bus.runtime_events import (
)
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.cron.automation import (
automation_run_id,
automation_trigger,
defer_until_session_idle,
)
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot
from nanobot.security.workspace_access import (
@@ -53,6 +58,7 @@ from nanobot.session.goal_state import (
sustained_goal_active,
)
from nanobot.session.manager import Session, SessionManager
from nanobot.session.routing import persist_routing_context
from nanobot.utils.document import extract_documents, reference_non_image_attachments
from nanobot.utils.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn
@@ -300,6 +306,10 @@ class AgentLoop:
# When a session has an active task, new messages for that session
# are routed here instead of creating a new task.
self._pending_queues: dict[str, asyncio.Queue] = {}
# Scheduled automations wait for the current visible turn to finish.
# They must not be injected into the active model call as follow-up text.
self._deferred_automation_queues: dict[str, list[InboundMessage]] = {}
self._automation_waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
# NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3.
_max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3"))
self._concurrency_gate: asyncio.Semaphore | None = (
@@ -565,6 +575,55 @@ class AgentLoop:
def _runtime_events(self) -> RuntimeEventPublisher:
return ensure_runtime_event_publisher(self)
async def submit_automation_turn(self, msg: InboundMessage) -> OutboundMessage | None:
"""Submit a scheduled automation as an internal session turn and wait for it."""
run_id = automation_run_id(msg.metadata)
if not run_id:
raise ValueError("automation turn metadata must include a run_id")
loop = asyncio.get_running_loop()
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
if run_id in self._automation_waiters:
raise RuntimeError(f"automation run {run_id!r} is already pending")
self._automation_waiters[run_id] = future
try:
if self._running:
await self.bus.publish_inbound(msg)
else:
await self._dispatch(msg)
return await future
finally:
self._automation_waiters.pop(run_id, None)
def _complete_automation_turn(
self,
msg: InboundMessage,
*,
response: OutboundMessage | None = None,
error: BaseException | None = None,
) -> None:
run_id = automation_run_id(msg.metadata)
if not run_id:
return
future = self._automation_waiters.get(run_id)
if future is None or future.done():
return
if error is not None:
future.set_exception(error)
else:
future.set_result(response)
def _defer_automation_turn(self, session_key: str, msg: InboundMessage) -> None:
self._deferred_automation_queues.setdefault(session_key, []).append(msg)
async def _publish_next_deferred_automation(self, session_key: str) -> None:
queue = self._deferred_automation_queues.get(session_key)
if not queue:
return
msg = queue.pop(0)
if not queue:
self._deferred_automation_queues.pop(session_key, None)
await self.bus.publish_inbound(msg)
def _persist_user_message_early(
self,
msg: InboundMessage,
@@ -583,6 +642,17 @@ class AgentLoop:
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else ""
if trigger := automation_trigger(msg.metadata):
persist_content = trigger.get("persist_content")
if isinstance(persist_content, str) and persist_content.strip():
text = persist_content
extra.update({
"_automation_trigger": True,
"automation_id": trigger.get("job_id"),
"automation_name": trigger.get("job_name"),
"automation_run_id": trigger.get("run_id"),
"automation_prompt_ref": trigger.get("prompt_ref"),
})
session.add_message("user", text, **extra)
self._mark_pending_user_turn(session)
self.sessions.save(session)
@@ -883,6 +953,22 @@ class AgentLoop:
self.commands.dispatch_priority,
)
continue
if (
defer_until_session_idle(msg.metadata)
and effective_key in self._pending_queues
):
pending_msg = msg
if effective_key != msg.session_key:
pending_msg = dataclasses.replace(
msg,
session_key_override=effective_key,
)
self._defer_automation_turn(effective_key, pending_msg)
logger.info(
"Deferred automation turn for active session {}",
effective_key,
)
continue
# If this session already has an active pending queue (i.e. a task
# is processing this session), route the message there for mid-turn
# injection instead of creating a competing task.
@@ -996,7 +1082,12 @@ class AgentLoop:
session_key=session_key,
metadata=msg.metadata,
)
self._complete_automation_turn(msg, response=response)
except asyncio.CancelledError:
self._complete_automation_turn(
msg,
error=asyncio.CancelledError(),
)
logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so
# the user does not lose tool results and assistant
@@ -1022,7 +1113,7 @@ class AgentLoop:
exc_info=True,
)
raise
except Exception:
except Exception as exc:
logger.exception("Error processing message for session {}", session_key)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
@@ -1035,6 +1126,7 @@ class AgentLoop:
session_key=session_key,
metadata=msg.metadata,
)
self._complete_automation_turn(msg, error=exc)
finally:
# Drain any messages still in the pending queue and re-publish
# them to the bus so they are processed as fresh inbound messages
@@ -1065,12 +1157,14 @@ class AgentLoop:
msg, session_key, "idle"
)
self._runtime_events().clear_turn(session_key)
await self._publish_next_deferred_automation(session_key)
finally:
if pending is None:
await self._runtime_events().run_status_changed(
msg, session_key, "idle"
)
self._runtime_events().clear_turn(session_key)
await self._publish_next_deferred_automation(session_key)
async def close_mcp(self) -> None:
"""Drain pending background archives, then close MCP connections."""
@@ -1342,6 +1436,8 @@ class AgentLoop:
ctx.session = self.sessions.get_or_create(ctx.session_key)
await self._runtime_events().session_turn_started(msg, ctx.session_key)
self.workspace_scopes.persist_message_scope(ctx.session, msg)
if persist_routing_context(ctx.session, msg):
self.sessions.save(ctx.session)
if self._restore_runtime_checkpoint(ctx.session):
self.sessions.save(ctx.session)
+6 -16
View File
@@ -9,7 +9,6 @@ from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
@@ -38,10 +37,6 @@ _CRON_PARAMETERS = tool_parameters_schema(
"ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00'). "
"Naive values use the tool's default timezone."
),
deliver=BooleanSchema(
description="Whether to deliver the execution result to the user channel (default true)",
default=True,
),
job_id=StringSchema("REQUIRED when action='remove'. Job ID to remove (obtain via action='list')."),
required=["action"],
description=(
@@ -76,11 +71,11 @@ class CronTool(Tool, ContextAware):
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone)
def set_context(self, ctx: RequestContext) -> None:
"""Set the current session context for delivery."""
"""Set the current session context for scheduled automation ownership."""
self._channel.set(ctx.channel)
self._chat_id.set(ctx.chat_id)
self._metadata.set(ctx.metadata)
self._session_key.set(ctx.session_key or f"{ctx.channel}:{ctx.chat_id}")
self._session_key.set(ctx.session_key or "")
def set_cron_context(self, active: bool):
"""Mark whether the tool is executing inside a cron job callback."""
@@ -170,10 +165,9 @@ class CronTool(Tool, ContextAware):
"describing what to do when the job triggers "
"(e.g. the reminder text). Retry including message=\"...\"."
)
channel = self._channel.get()
chat_id = self._chat_id.get()
if not channel or not chat_id:
return "Error: no session context (channel/chat_id)"
session_key = self._session_key.get()
if not session_key:
return "Error: scheduled automations must be created from a chat session"
if tz and not cron_expr:
return "Error: tz can only be used with cron_expr"
if tz:
@@ -210,12 +204,8 @@ class CronTool(Tool, ContextAware):
name=name or message[:30],
schedule=schedule,
message=message,
deliver=deliver,
channel=channel,
to=chat_id,
delete_after_run=delete_after,
channel_meta=self._metadata.get(),
session_key=self._session_key.get() or None,
session_key=session_key,
)
return f"Created job '{job.name}' (id: {job.id})"
+159 -1
View File
@@ -1,10 +1,12 @@
"""CLI commands for nanobot."""
import asyncio
import hashlib
import os
import select
import signal
import sys
import time
import uuid
from collections.abc import Callable
from contextlib import nullcontext, suppress
@@ -975,12 +977,19 @@ def _run_gateway(
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.channels.manager import ChannelManager
from nanobot.cron.automation import (
AUTOMATION_DEFER_UNTIL_IDLE_META,
AUTOMATION_TRIGGER_META,
)
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.providers.factory import build_provider_snapshot, load_provider_snapshot
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.session.manager import SessionManager
from nanobot.session.routing import read_routing_context
from nanobot.session.webui_turns import WebuiTurnCoordinator
from nanobot.utils.prompt_templates import render_template
from nanobot.webui.token_usage import TokenUsageHook
port = port if port is not None else config.gateway.port
@@ -1025,7 +1034,7 @@ def _run_gateway(
).subscribe(runtime_events)
from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.bus.events import OutboundMessage
from nanobot.bus.events import InboundMessage, OutboundMessage
def _channel_session_key(channel: str, chat_id: str) -> str:
return (
@@ -1034,6 +1043,152 @@ def _run_gateway(
else f"{channel}:{chat_id}"
)
def _session_metadata(session_key: str) -> dict[str, Any]:
data = session_manager.read_session_file(session_key)
metadata = data.get("metadata", {}) if isinstance(data, dict) else {}
return dict(metadata) if isinstance(metadata, dict) else {}
def _bound_session_delivery_context(
session_key: str,
*,
turn_seed: str,
source_label: str | None,
) -> tuple[str, str, dict[str, Any]]:
if ":" not in session_key:
raise ValueError(f"bound cron session_key is invalid: {session_key!r}")
channel, rest = session_key.split(":", 1)
if not channel or not rest:
raise ValueError(f"bound cron session_key is invalid: {session_key!r}")
session_metadata = _session_metadata(session_key)
routed = read_routing_context(session_metadata)
if routed is not None:
channel, rest, metadata = routed
else:
metadata: dict[str, Any] = {}
if channel == "websocket":
metadata["webui"] = True
scope = session_metadata.get(WORKSPACE_SCOPE_METADATA_KEY)
if isinstance(scope, dict):
metadata[WORKSPACE_SCOPE_METADATA_KEY] = dict(scope)
metadata.update(
_proactive_delivery_metadata(
"websocket",
metadata,
turn_seed=turn_seed,
source_label=source_label,
)
)
return channel, rest, metadata
if channel == "slack" and ":" in rest:
chat_id, thread_ts = rest.split(":", 1)
if thread_ts:
metadata["slack"] = {"thread_ts": thread_ts}
return channel, chat_id, metadata
return channel, rest, metadata
def _automation_prompt_ref(prompt: str) -> dict[str, Any]:
return {
"id": "cron.agent_turn.reminder",
"version": 1,
"sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
}
async def _run_bound_cron_job(job: CronJob) -> str | None:
session_key = job.payload.session_key
if not session_key:
raise ValueError(f"cron job {job.id} is missing payload.session_key")
prompt = render_template(
"agent/cron_reminder.md",
strip=True,
message=job.payload.message,
)
prompt_ref = _automation_prompt_ref(prompt)
run_id = f"{job.id}:{int(time.time() * 1000)}:{uuid.uuid4().hex[:8]}"
channel, chat_id, metadata = _bound_session_delivery_context(
session_key,
turn_seed=f"cron:{job.id}",
source_label=job.name,
)
metadata[AUTOMATION_TRIGGER_META] = {
"job_id": job.id,
"job_name": job.name,
"run_id": run_id,
"prompt_ref": prompt_ref,
"persist_content": (
f"Scheduled automation triggered: {job.name}\n\n{job.payload.message}"
),
}
metadata[AUTOMATION_DEFER_UNTIL_IDLE_META] = True
cron.write_run_record(
run_id,
{
"job_id": job.id,
"job_name": job.name,
"session_key": session_key,
"status": "queued",
"prompt_ref": prompt_ref,
"prompt_vars": {"message": job.payload.message},
"rendered_prompt": prompt,
},
)
cron_tool = agent.tools.get("cron")
cron_token = None
if isinstance(cron_tool, CronTool):
cron_token = cron_tool.set_cron_context(True)
try:
resp = await agent.submit_automation_turn(
InboundMessage(
channel=channel,
sender_id="cron",
chat_id=chat_id,
content=prompt,
metadata=metadata,
session_key_override=session_key,
)
)
except (Exception, asyncio.CancelledError) as exc:
error_text = str(exc) or exc.__class__.__name__
cron.write_run_record(
run_id,
{
"job_id": job.id,
"job_name": job.name,
"session_key": session_key,
"status": "error",
"error": error_text,
"prompt_ref": prompt_ref,
"prompt_vars": {"message": job.payload.message},
"rendered_prompt": prompt,
},
)
raise
finally:
if isinstance(cron_tool, CronTool) and cron_token is not None:
cron_tool.reset_cron_context(cron_token)
response = resp.content if resp else ""
cron.write_run_record(
run_id,
{
"job_id": job.id,
"job_name": job.name,
"session_key": session_key,
"status": "ok",
"prompt_ref": prompt_ref,
"prompt_vars": {"message": job.payload.message},
"rendered_prompt": prompt,
"response": response,
},
)
return response
async def _deliver_to_channel(
msg: OutboundMessage, *, record: bool = False, session_key: str | None = None,
) -> None:
@@ -1194,6 +1349,9 @@ def _run_gateway(
logger.info("Heartbeat: silenced by post-run evaluation")
return response
if job.payload.kind == "agent_turn" and job.payload.session_key:
return await _run_bound_cron_job(job)
reminder_note = (
"The scheduled time has arrived. Deliver this reminder to the user now, "
"as a brief and natural message in their language. Speak directly to them — "
+33
View File
@@ -0,0 +1,33 @@
"""Shared metadata helpers for scheduled automation turns."""
from __future__ import annotations
from typing import Any, Mapping
AUTOMATION_TRIGGER_META = "_automation_trigger"
AUTOMATION_DEFER_UNTIL_IDLE_META = "_defer_until_session_idle"
def automation_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""Return structured automation trigger metadata when present."""
raw = (metadata or {}).get(AUTOMATION_TRIGGER_META)
return raw if isinstance(raw, dict) else None
def is_automation_turn(metadata: Mapping[str, Any] | None) -> bool:
return automation_trigger(metadata) is not None
def defer_until_session_idle(metadata: Mapping[str, Any] | None) -> bool:
return bool(
is_automation_turn(metadata)
and (metadata or {}).get(AUTOMATION_DEFER_UNTIL_IDLE_META) is True
)
def automation_run_id(metadata: Mapping[str, Any] | None) -> str | None:
trigger = automation_trigger(metadata)
if not trigger:
return None
value = trigger.get("run_id")
return value if isinstance(value, str) and value else None
+32
View File
@@ -84,6 +84,7 @@ class CronService:
):
self.store_path = store_path
self._action_path = store_path.parent / "action.jsonl"
self._run_records_dir = store_path.parent / "runs"
self._lock = FileLock(str(self._action_path.parent) + ".lock")
self.on_job = on_job
self._store: CronStore | None = None
@@ -325,6 +326,23 @@ class CronService:
tmp_path.unlink(missing_ok=True)
raise
@staticmethod
def _safe_run_record_name(run_id: str) -> str:
return "".join(c if c.isalnum() or c in "._-" else "_" for c in run_id)
def write_run_record(self, run_id: str, record: dict[str, Any]) -> None:
"""Write an internal audit record for one cron execution."""
name = self._safe_run_record_name(run_id)
if not name:
name = str(uuid.uuid4())
path = self._run_records_dir / f"{name}.json"
payload = {
**record,
"run_id": run_id,
"updated_at_ms": _now_ms(),
}
self._atomic_write(path, json.dumps(payload, indent=2, ensure_ascii=False))
async def start(self) -> None:
"""Start the cron service."""
self._running = True
@@ -473,6 +491,20 @@ class CronService:
jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled]
return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf'))
def list_bound_agent_jobs_for_session(
self,
session_key: str,
*,
include_disabled: bool = True,
) -> list[CronJob]:
"""Return user-created bound automation jobs owned by *session_key*."""
return [
job
for job in self.list_jobs(include_disabled=include_disabled)
if job.payload.kind == "agent_turn"
and job.payload.session_key == session_key
]
def add_job(
self,
name: str,
+1
View File
@@ -36,6 +36,7 @@ _FORK_VOLATILE_METADATA_KEYS = {
"pending_user_turn",
"runtime_checkpoint",
"thread_goal",
"_routing_context",
"title",
"title_user_edited",
}
+105
View File
@@ -0,0 +1,105 @@
"""Persisted session routing context for proactive turns."""
from __future__ import annotations
from typing import Any, Mapping
from nanobot.bus.events import InboundMessage
from nanobot.cron.automation import is_automation_turn
from nanobot.session.manager import Session
SESSION_ROUTING_METADATA_KEY = "_routing_context"
_ROUTING_METADATA_KEYS = {
"chat_type",
"context_chat_id",
"conversation_type",
"event_id",
"message_thread_id",
"msg_type",
"parent_channel_id",
"parent_id",
"platform",
"root_id",
"thread_id",
"thread_reply_to_event_id",
"thread_root_event_id",
}
_CHANNEL_ROUTING_METADATA_KEYS = {
# Feishu needs a message anchor to reply into an existing topic. Other
# channels should avoid stale reply anchors for scheduled automation turns.
"feishu": {"message_id"},
}
_SLACK_ROUTING_KEYS = {"channel_type", "thread_ts"}
def _scalar(value: Any) -> str | int | float | bool | None:
if value is None or isinstance(value, (str, int, float, bool)):
return value
return None
def _routing_metadata(channel: str, metadata: Mapping[str, Any] | None) -> dict[str, Any]:
if not isinstance(metadata, Mapping):
return {}
out: dict[str, Any] = {}
keys = _ROUTING_METADATA_KEYS | _CHANNEL_ROUTING_METADATA_KEYS.get(channel, set())
for key in keys:
if key not in metadata:
continue
value = _scalar(metadata.get(key))
if value is not None:
out[key] = value
slack = metadata.get("slack")
if isinstance(slack, Mapping):
slack_out = {
key: value
for key in _SLACK_ROUTING_KEYS
if (value := _scalar(slack.get(key))) is not None
}
if slack_out:
out["slack"] = slack_out
return out
def routing_context_for_message(msg: InboundMessage) -> dict[str, Any]:
"""Return the stable routing context needed to deliver future session turns."""
return {
"channel": msg.channel,
"chat_id": msg.chat_id,
"metadata": _routing_metadata(msg.channel, msg.metadata),
}
def persist_routing_context(session: Session, msg: InboundMessage) -> bool:
"""Persist the latest non-automation delivery context for a session."""
if is_automation_turn(msg.metadata):
return False
context = routing_context_for_message(msg)
if session.metadata.get(SESSION_ROUTING_METADATA_KEY) == context:
return False
session.metadata[SESSION_ROUTING_METADATA_KEY] = context
return True
def read_routing_context(metadata: Mapping[str, Any] | None) -> tuple[str, str, dict[str, Any]] | None:
"""Decode a persisted routing context from session metadata."""
if not isinstance(metadata, Mapping):
return None
raw = metadata.get(SESSION_ROUTING_METADATA_KEY)
if not isinstance(raw, Mapping):
return None
channel = raw.get("channel")
chat_id = raw.get("chat_id")
if not isinstance(channel, str) or not channel:
return None
if not isinstance(chat_id, str) or not chat_id:
return None
route_meta = raw.get("metadata")
metadata_out = dict(route_meta) if isinstance(route_meta, Mapping) else {}
return channel, chat_id, metadata_out
+9
View File
@@ -0,0 +1,9 @@
The scheduled time has arrived. Execute this scheduled automation now and report the result to the user in the same session.
Rules:
- Speak directly to the user in their language.
- Do not narrate internal progress.
- Do not include user IDs.
- Do not add status reports like "Done" or "Reminded" unless they are the natural response.
Automation: {{ message }}
+22 -15
View File
@@ -8,7 +8,25 @@ from nanobot.cron.types import CronJob
class _CronServiceLike(Protocol):
def list_jobs(self, *, include_disabled: bool = False) -> list[CronJob]: ...
def list_bound_agent_jobs_for_session(
self,
session_key: str,
*,
include_disabled: bool = True,
) -> list[CronJob]: ...
def bound_session_automation_jobs(
cron_service: _CronServiceLike | None,
session_key: str,
) -> list[CronJob]:
"""Return agent-turn automation jobs explicitly bound to *session_key*."""
if cron_service is None:
return []
return cron_service.list_bound_agent_jobs_for_session(
session_key,
include_disabled=True,
)
def session_automations_payload(
@@ -16,22 +34,11 @@ def session_automations_payload(
session_key: str,
) -> dict[str, Any]:
"""Return user-created automation jobs attached to a WebUI session."""
jobs: list[CronJob] = []
if cron_service is not None:
all_jobs = cron_service.list_jobs(include_disabled=True)
jobs = [job for job in all_jobs if _job_matches_session(job, session_key)]
return {"jobs": [_serialize_job(job) for job in jobs]}
return {"jobs": serialize_automation_jobs(bound_session_automation_jobs(cron_service, session_key))}
def _job_matches_session(job: CronJob, session_key: str) -> bool:
payload = job.payload
if payload.kind != "agent_turn":
return False
if payload.session_key:
return payload.session_key == session_key
if payload.channel and payload.to:
return f"{payload.channel}:{payload.to}" == session_key
return False
def serialize_automation_jobs(jobs: list[CronJob]) -> list[dict[str, Any]]:
return [_serialize_job(job) for job in jobs]
def _serialize_job(job: CronJob) -> dict[str, Any]:
+19 -1
View File
@@ -61,7 +61,11 @@ from nanobot.webui.http_utils import (
safe_host_header as _safe_host_header,
)
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.session_automations import session_automations_payload
from nanobot.webui.session_automations import (
bound_session_automation_jobs,
serialize_automation_jobs,
session_automations_payload,
)
from nanobot.webui.session_list_index import list_webui_sessions
from nanobot.webui.sidebar_state import (
read_webui_sidebar_state,
@@ -446,6 +450,20 @@ class GatewayHTTPHandler:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
query = _parse_query(request.path)
delete_automations = (_query_first(query, "delete_automations") or "").lower()
bound_jobs = bound_session_automation_jobs(self.cron_service, decoded_key)
if bound_jobs and delete_automations not in {"1", "true", "yes"}:
return _http_json_response(
{
"deleted": False,
"blocked_by_automations": True,
"automations": serialize_automation_jobs(bound_jobs),
}
)
if bound_jobs and self.cron_service is not None:
for job in bound_jobs:
self.cron_service.remove_job(job.id)
deleted = self.session_manager.delete_session(decoded_key)
delete_webui_thread(decoded_key)
return _http_json_response({"deleted": bool(deleted)})