refactor(scheduler): split runtime owners and preserve plugin ABI

This commit is contained in:
jxxghp
2026-08-28 23:17:45 +08:00
parent dd94f1c781
commit 8460e72c42
52 changed files with 3386 additions and 2460 deletions
+1 -1
View File
@@ -190,7 +190,7 @@ class SiteChain(InteractionChainMixin, ChainBase):
# 交互处理器类注入,供 InteractionChainMixin 的 parse_callback 委托
_interaction_handler_type = SiteInteractionHandler
def __init__(self):
def __init__(self) -> None:
"""初始化站点管理处理链及特殊站点测试器"""
super().__init__()
+1 -1
View File
@@ -86,7 +86,7 @@ def get_scoped_session() -> scoped_session:
#
# 做成 __dict__ 里真实存在的函数就两头都成立:导入它不碰引擎,调用它才创建。全仓库对这三个
# 名字的用法都是 `X()` 取一个会话,这一形式的语义与原先的 sessionmaker / scoped_session 实例
# 完全一致;patch("app.scheduler.SessionFactory", ...) 这类既有测试替身也照旧生效
# 完全一致;测试应在实际数据库 owner 边界替换 SessionFactory
#
# 但仅限 `X()` 这一形式:它们不再是 sessionmaker / scoped_session 实例,因此实例上的其余接口
# ScopedSession.remove()、SessionFactory.configure()、AsyncSessionFactory.begin()、
-2158
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
"""调度器的惰性稳定公开入口。"""
from importlib import import_module
from typing import Any
_EXPORTS = {
"Scheduler": ("app.scheduler.facade", "Scheduler"),
"SchedulerChain": ("app.scheduler.chain", "SchedulerChain"),
}
def __getattr__(name: str) -> Any:
"""首次访问时解析旧插件 ABI,避免包根导入宿主实现。"""
contract = _EXPORTS.get(name)
if contract is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name, symbol_name = contract
value = getattr(import_module(module_name), symbol_name)
globals()[name] = value
return value
def __dir__() -> list[str]:
"""向交互式工具暴露稳定公开面。"""
return sorted({*globals(), *_EXPORTS})
__all__ = ["Scheduler", "SchedulerChain"]
+273
View File
@@ -0,0 +1,273 @@
"""调度器事件循环提交与异步句柄桥接。"""
import asyncio
import concurrent.futures
import threading
from typing import Any, Callable, Optional
from app.runtime.loop import main_loop_registry
from app.scheduler.contract import _SchedulerOwnerBase
from app.scheduler.registry import SchedulerHandle
class SchedulerBridgeOwner(_SchedulerOwnerBase):
"""调度器事件循环提交与异步句柄桥接。"""
def _remove_handle(
self,
handle: asyncio.Future[Any] | concurrent.futures.Future[Any],
) -> None:
"""执行句柄完成后从 owner registry 移除。"""
with self._lock:
self._registry.remove_handle(handle)
def _register_handle(
self,
job_id: str,
generation: int,
loop: asyncio.AbstractEventLoop,
handle: asyncio.Future[Any] | concurrent.futures.Future[Any],
completion: asyncio.Future[Any] | concurrent.futures.Future[Any] | None = None,
kind: str = "job",
) -> bool:
"""登记调度器拥有的句柄;关闭竞态下拒绝并取消新句柄。"""
if completion is None:
completion = handle
with self._lock:
if not self._accepts_handle(job_id, generation):
if isinstance(handle, concurrent.futures.Future):
handle.cancel()
elif loop.is_running():
loop.call_soon_threadsafe(handle.cancel)
else:
handle.cancel()
return False
self._registry.register_handle(
job_id=job_id,
generation=generation,
loop=loop,
handle=handle,
completion=completion,
kind=kind,
)
completion.add_done_callback(self._remove_handle)
return True
def _accepts_handle(self, job_id: str, generation: int) -> bool:
"""判断新句柄是否属于当前运行期或热重载中的既有任务。"""
if self._accepting_submissions():
return True
current_job = self._jobs.get(job_id)
return bool(
self._lifecycle_state == "reloading"
and current_job is not None
and current_job.get("_generation", 0) == generation
and current_job.get("running")
)
@staticmethod
def _cancel_handle(handle: SchedulerHandle) -> None:
"""从句柄所属线程安全地请求取消。"""
target = handle.handle
if isinstance(target, concurrent.futures.Future):
target.cancel()
return
if target.done():
return
if target.get_loop().is_running():
target.get_loop().call_soon_threadsafe(target.cancel)
else:
target.cancel()
@staticmethod
async def _wait_handle(handle: SchedulerHandle) -> None:
"""等待取消请求到达协程 finally,而不是只等待提交代理变为 cancelled。"""
target = handle.completion
if isinstance(target, concurrent.futures.Future):
await asyncio.shield(asyncio.wrap_future(target))
return
if target.get_loop() is asyncio.get_running_loop():
await asyncio.shield(target)
async def _await_cancelled_handles(
self,
handles: tuple[SchedulerHandle, ...],
) -> None:
"""等待已投递协程结束,关闭总预算由应用生命周期统一控制。"""
if not handles:
return
await asyncio.gather(
*(self._wait_handle(handle) for handle in handles),
return_exceptions=True,
)
async def _await_progress_handles(self, job_id: str, generation: int) -> None:
"""等待同一轮任务已提交的进度更新,保证最终状态最后写入缓存。"""
with self._lock:
handles = self._registry.handles(
job_id=job_id,
generation=generation,
kind="progress",
)
if not handles:
return
await asyncio.gather(
*(self._wait_handle(handle) for handle in handles),
return_exceptions=True,
)
@staticmethod
def _track_cross_thread_completion(
coro: Any,
completion: concurrent.futures.Future[Any],
started: threading.Event,
) -> Any:
"""把跨线程提交代理与协程真实终态分离。"""
async def _tracked() -> None:
started.set()
try:
result = await coro
except asyncio.CancelledError:
if not completion.done():
completion.cancel()
except Exception as err:
if not completion.done():
completion.set_exception(err)
else:
if not completion.done():
completion.set_result(result)
return _tracked()
def _submit_cross_thread(
self,
coro: Any,
*,
target_loop: asyncio.AbstractEventLoop,
job_id: str,
generation: int,
on_unstarted_cancel: Optional[Callable[[], None]] = None,
kind: str = "job",
) -> bool:
"""向主循环提交协程,并以独立完成信号跟踪真实收尾。"""
completion: concurrent.futures.Future[Any] = concurrent.futures.Future()
handle: concurrent.futures.Future[Any] = concurrent.futures.Future()
started = threading.Event()
tracked = self._track_cross_thread_completion(coro, completion, started)
task_lock = threading.Lock()
target_task: asyncio.Task[Any] | None = None
def complete_target_task(task: asyncio.Task[Any]) -> None:
if task.cancelled() and not started.is_set():
if on_unstarted_cancel:
on_unstarted_cancel()
if not completion.done():
completion.cancel()
elif not completion.done():
error = task.exception()
if error is None:
completion.set_result(None)
else:
completion.set_exception(error)
if not handle.done():
handle.set_result(None)
def start_on_target_loop() -> None:
nonlocal target_task
with task_lock:
if handle.cancelled():
tracked.close()
coro.close()
if on_unstarted_cancel:
on_unstarted_cancel()
completion.cancel()
return
target_task = target_loop.create_task(tracked)
target_task.add_done_callback(complete_target_task)
def cancel_target_task(submitted: concurrent.futures.Future[Any]) -> None:
if not submitted.cancelled():
return
with task_lock:
task = target_task
if task is not None and not task.done():
target_loop.call_soon_threadsafe(task.cancel)
with self._lock:
if not self._accepts_handle(job_id, generation):
tracked.close()
coro.close()
return False
try:
target_loop.call_soon_threadsafe(start_on_target_loop)
except RuntimeError:
tracked.close()
coro.close()
return False
registered = self._register_handle(
job_id=job_id,
generation=generation,
loop=target_loop,
handle=handle,
completion=completion,
kind=kind,
)
handle.add_done_callback(cancel_target_task)
return registered
def _submit_to_loop(
self,
coro: Any,
*,
job_id: str,
generation: int = 0,
on_unstarted_cancel: Optional[Callable[[], None]] = None,
kind: str = "job",
) -> bool:
"""
把协程提交到事件循环执行,兼容以下调用环境:
- 应用主循环可用:统一由主循环拥有任务和关闭顺序
- 仅调用方循环可用:在当前循环排队为独立任务
- 无运行中循环(测试/CLI):新建循环同步执行,确保进度不丢失
job 标识是所有权键;所有句柄都由 Scheduler 持有,关闭时可以取消并等待。
"""
try:
running_loop = asyncio.get_running_loop()
except RuntimeError:
running_loop = None
target_loop = main_loop_registry.current
target_loop_available = target_loop is not None and target_loop.is_running() and not target_loop.is_closed()
if running_loop and (not target_loop_available or running_loop is target_loop):
with self._lock:
if not self._accepts_handle(job_id, generation):
coro.close()
return False
handle = running_loop.create_task(coro)
registered = self._register_handle(
job_id=job_id,
generation=generation,
loop=running_loop,
handle=handle,
kind=kind,
)
if on_unstarted_cancel:
handle.add_done_callback(lambda submitted: on_unstarted_cancel() if submitted.cancelled() else None)
return registered
elif target_loop is not None and target_loop_available:
return self._submit_cross_thread(
coro,
target_loop=target_loop,
job_id=job_id,
generation=generation,
on_unstarted_cancel=on_unstarted_cancel,
kind=kind,
)
elif self._lifecycle_state in {"stopping", "stopped"}:
coro.close()
return False
else:
asyncio.run(coro)
return True
+461
View File
@@ -0,0 +1,461 @@
"""调度器静态任务目录与 APScheduler 投影。"""
import hashlib
from datetime import datetime, timedelta
from typing import List, Optional, TypedDict
import pytz # type: ignore[import-untyped]
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.schedulers.background import BackgroundScheduler
from app.adapters.external.server import MoviePilotServerHelper
from app.adapters.system.update import system_update_manager
from app.application.configuration import (
SchedulerRuntimeConfig,
)
from app.application.mediaserver import get_mediaserver_configs
from app.application.outbox import dispatch_pending_outbox
from app.application.plugin.runtime import get_plugin_manager
from app.application.scheduling import ( # noqa: E402
JobCatalog,
JobRecoveryPolicy,
JobSpec,
)
from app.runtime.scheduling import TimerUtils
from app.scheduler.contract import _SchedulerOwnerBase
from app.schemas.system import MediaServerConf as _SchemaMediaServerConf
class _MediaServerSchedule(TypedDict):
"""媒体服务器同步任务的内部投影。"""
id: str
name: str
server: str
interval: int
class SchedulerCatalogOwner(_SchedulerOwnerBase):
"""调度器静态任务目录与 APScheduler 投影。"""
@staticmethod
def _get_mediaserver_sync_interval(
mediaserver: _SchemaMediaServerConf,
default_interval: Optional[int],
) -> Optional[int]:
"""
获取媒体服务器的有效同步间隔,未设置时回退旧全局配置。
"""
interval = mediaserver.sync_interval
if interval is None:
interval = default_interval
if interval is None:
return None
try:
interval = int(interval)
except TypeError, ValueError:
return None
return interval if interval > 0 else None
@classmethod
def _build_mediaserver_sync_schedules(
cls,
mediaservers: List[_SchemaMediaServerConf],
default_interval: Optional[int],
) -> List[_MediaServerSchedule]:
"""
构建已启用媒体服务器的独立自动同步任务描述。
"""
schedules: List[_MediaServerSchedule] = []
job_ids: set[str] = set()
for mediaserver in mediaservers:
if not mediaserver or not mediaserver.enabled or not mediaserver.name:
continue
interval = cls._get_mediaserver_sync_interval(
mediaserver=mediaserver,
default_interval=default_interval,
)
if not interval:
continue
digest = hashlib.sha256(mediaserver.name.encode("utf-8")).hexdigest()[:12]
job_id = f"mediaserver_sync_{digest}"
if job_id in job_ids:
continue
job_ids.add(job_id)
schedules.append(
{
"id": job_id,
"name": f"同步媒体服务器 - {mediaserver.name}",
"server": mediaserver.name,
"interval": interval,
}
)
return schedules
def _register_database_backup_job(
self,
config: SchedulerRuntimeConfig,
) -> None:
"""在共享调度器中按当前配置维护唯一的数据库备份作业。"""
if not config.db_backup_enable or not config.db_backup_cron.strip():
return
job_id = "database_backup"
job = JobSpec(
job_id,
"数据库备份",
self.database_backup,
"database",
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
).to_runtime_state()
self._assign_job_generation(job_id, job)
self._jobs[job_id] = job
self._scheduler.add_job(
self.start,
trigger=TimerUtils.build_schedule_trigger(
trigger_type="cron",
trigger_value=config.db_backup_cron,
timezone_name=config.timezone,
),
id=job_id,
name="数据库备份",
kwargs={"job_id": job_id},
replace_existing=True,
)
def _initialize_catalog(self, config: SchedulerRuntimeConfig) -> None:
"""构建完整任务目录并投影到尚未启动的 APScheduler。"""
services = self._scheduler_services()
# 各服务的运行状态
self._jobs = JobCatalog(
[
JobSpec("cookiecloud", "同步CookieCloud站点", services.sync_cookies, "site"),
JobSpec("mediaserver_sync", "同步媒体服务器", services.sync_mediaserver, "mediaserver"),
JobSpec("subscribe_tmdb", "订阅元数据更新", services.check_subscribe, "subscription"),
JobSpec(
"subscribe_search", "订阅搜索补全", services.search_subscribe, "subscription", kwargs={"state": "R"}
),
JobSpec(
"new_subscribe_search",
"新增订阅搜索",
services.search_subscribe,
"subscription",
kwargs={"state": "N"},
),
JobSpec("subscribe_refresh", "订阅刷新", services.refresh_subscribe, "subscription"),
JobSpec("subscribe_follow", "关注的订阅分享", services.follow_subscribe, "subscription"),
JobSpec(
"transfer",
"下载文件整理",
services.process_transfer,
"transfer",
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
),
JobSpec(
"clear_cache",
"缓存清理",
self.clear_cache,
"runtime",
manual=True,
recovery=JobRecoveryPolicy.MANUAL_ONLY,
),
JobSpec("data_cleanup", "数据表清理", services.cleanup_data, "database"),
JobSpec("user_auth", "用户认证检查", self.user_auth, "security"),
JobSpec("scheduler_job", "公共定时服务", services.run_modules, "module"),
JobSpec("random_wallpager", "壁纸缓存", services.get_wallpapers, "image"),
JobSpec("sitedata_refresh", "站点数据刷新", services.refresh_site_data, "site"),
JobSpec("recommend_refresh", "推荐缓存", services.refresh_recommend, "recommend"),
JobSpec(
"plugin_market_refresh",
"插件市场缓存",
get_plugin_manager().async_get_online_plugins,
"plugin",
kwargs={"force": True},
),
JobSpec("subscribe_calendar_cache", "订阅日历缓存", services.cache_subscribe_calendar, "subscription"),
JobSpec("full_gc", "主动内存回收", self.full_gc, "runtime"),
JobSpec("agent_heartbeat", "智能体定时任务", self.agent_heartbeat, "agent"),
JobSpec("usage_report", "安装版本统计上报", MoviePilotServerHelper.report_usage, "server"),
JobSpec("system_update_check", "检查系统更新", system_update_manager.check, "system"),
]
).runtime_states()
for job_id, job in self._jobs.items():
self._assign_job_generation(job_id, job)
self._scheduler = BackgroundScheduler(
timezone=config.timezone,
executors={"default": ThreadPoolExecutor(config.scheduler_workers)},
)
self._register_database_backup_job(config)
outbox_job = JobSpec(
"outbox_dispatch",
"恢复待投递副作用",
dispatch_pending_outbox,
"outbox",
recovery=JobRecoveryPolicy.DURABLE_QUEUE,
).to_runtime_state()
self._assign_job_generation("outbox_dispatch", outbox_job)
self._jobs["outbox_dispatch"] = outbox_job
self._scheduler.add_job(
self.start,
"interval",
id="outbox_dispatch",
name="恢复待投递副作用",
seconds=30,
next_run_time=datetime.now(pytz.timezone(config.timezone)),
kwargs={"job_id": "outbox_dispatch"},
replace_existing=True,
)
# CookieCloud定时同步
if config.cookiecloud_interval and str(config.cookiecloud_interval).isdigit():
self._scheduler.add_job(
self.start,
"interval",
id="cookiecloud",
name="同步CookieCloud站点",
minutes=int(config.cookiecloud_interval),
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=5),
kwargs={"job_id": "cookiecloud"},
)
# 按媒体服务器分别注册自动同步任务
mediaserver_schedules = self._build_mediaserver_sync_schedules(
mediaservers=get_mediaserver_configs(include_disabled=True),
default_interval=config.mediaserver_sync_interval,
)
for mediaserver_schedule in mediaserver_schedules:
job_id = mediaserver_schedule["id"]
job = JobSpec(
job_id,
mediaserver_schedule["name"],
services.sync_mediaserver,
"mediaserver",
kwargs={"server": mediaserver_schedule["server"]},
).to_runtime_state()
self._assign_job_generation(job_id, job)
self._jobs[job_id] = job
self._scheduler.add_job(
self.start,
"interval",
id=job_id,
name=mediaserver_schedule["name"],
hours=mediaserver_schedule["interval"],
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=10),
kwargs={"job_id": job_id},
)
# 新增订阅时搜索(5分钟检查一次)
self._scheduler.add_job(
self.start,
"interval",
id="new_subscribe_search",
name="新增订阅搜索",
minutes=5,
kwargs={"job_id": "new_subscribe_search"},
)
# 检查更新订阅TMDB数据(每隔6小时)
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_tmdb",
name="订阅元数据更新",
hours=6,
kwargs={"job_id": "subscribe_tmdb"},
)
# 订阅状态每隔24小时搜索一次
if config.subscribe_search:
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_search",
name="订阅搜索补全",
hours=config.subscribe_search_interval,
kwargs={"job_id": "subscribe_search"},
)
if config.subscribe_mode == "spider":
# 站点首页种子定时刷新模式
triggers = TimerUtils.random_scheduler(num_executions=32)
for trigger in triggers:
self._scheduler.add_job(
self.start,
"cron",
id=f"subscribe_refresh|{trigger.hour}:{trigger.minute}",
name="订阅刷新",
hour=trigger.hour,
minute=trigger.minute,
kwargs={"job_id": "subscribe_refresh"},
)
else:
# RSS订阅模式
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_refresh",
name="RSS订阅刷新",
minutes=config.subscribe_rss_interval,
kwargs={"job_id": "subscribe_refresh"},
)
# 关注订阅分享(每1小时)
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_follow",
name="关注的订阅分享",
hours=1,
kwargs={"job_id": "subscribe_follow"},
)
# 下载器文件转移(每5分钟)
self._scheduler.add_job(
self.start,
"interval",
id="transfer",
name="下载文件整理",
minutes=5,
kwargs={"job_id": "transfer"},
)
# 后台刷新TMDB壁纸
self._scheduler.add_job(
self.start,
"interval",
id="random_wallpager",
name="壁纸缓存",
minutes=30,
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=1),
kwargs={"job_id": "random_wallpager"},
)
# 公共定时服务
self._scheduler.add_job(
self.start,
"interval",
id="scheduler_job",
name="公共定时服务",
minutes=10,
kwargs={"job_id": "scheduler_job"},
)
# 数据表清理服务,每天凌晨执行一次
if config.data_cleanup_enable:
self._scheduler.add_job(
self.start,
"cron",
id="data_cleanup",
name="数据表清理",
hour=3,
minute=30,
kwargs={"job_id": "data_cleanup"},
)
# 定时检查用户认证,每隔10分钟
self._scheduler.add_job(
self.start,
"interval",
id="user_auth",
name="用户认证检查",
minutes=10,
kwargs={"job_id": "user_auth"},
)
# 站点数据刷新
if config.sitedata_refresh_interval:
self._scheduler.add_job(
self.start,
"interval",
id="sitedata_refresh",
name="站点数据刷新",
minutes=config.sitedata_refresh_interval * 60,
kwargs={"job_id": "sitedata_refresh"},
)
# 推荐缓存
self._scheduler.add_job(
self.start,
"interval",
id="recommend_refresh",
name="推荐缓存",
hours=24,
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(seconds=5),
kwargs={"job_id": "recommend_refresh"},
)
# 插件市场缓存
self._scheduler.add_job(
self.start,
"interval",
id="plugin_market_refresh",
name="插件市场缓存",
minutes=30,
kwargs={"job_id": "plugin_market_refresh"},
)
# 更新检查只缓存 Release 元数据,不会在未授权时下载或重启。
self._scheduler.add_job(
self.start,
"interval",
id="system_update_check",
name="检查系统更新",
hours=6,
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=1),
kwargs={"job_id": "system_update_check"},
)
# 订阅日历缓存
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_calendar_cache",
name="订阅日历缓存",
hours=6,
next_run_time=datetime.now(pytz.timezone(config.timezone)) + timedelta(minutes=2),
kwargs={"job_id": "subscribe_calendar_cache"},
)
# 主动内存回收
if config.memory_gc_interval:
self._scheduler.add_job(
self.start,
"interval",
id="full_gc",
name="主动内存回收",
minutes=config.memory_gc_interval,
kwargs={"job_id": "full_gc"},
)
# 智能体定时任务检查
if config.ai_agent_enable and config.ai_agent_job_interval:
self._scheduler.add_job(
self.start,
"interval",
id="agent_heartbeat",
name="智能体定时任务",
hours=config.ai_agent_job_interval,
kwargs={"job_id": "agent_heartbeat"},
)
# 安装版本统计上报
if config.usage_statistic_share:
self._scheduler.add_job(
self.start,
"interval",
id="usage_report",
name="安装版本统计上报",
hours=12,
kwargs={"job_id": "usage_report"},
)
# 初始化工作流服务
self.init_workflow_jobs()
# 恢复 Agent 自主定时任务
if config.ai_agent_enable:
self.init_agent_task_jobs()
# 初始化插件服务
self.init_plugin_jobs()
+27
View File
@@ -0,0 +1,27 @@
"""调度器兼容 Chain。"""
from typing import Any, Callable, Dict, Optional
from app.application.database import get_database_governance
from app.chain.base import ChainBase
class SchedulerChain(ChainBase):
"""保留插件使用的公共定时任务与数据治理入口。"""
DEFAULT_BATCH_SIZE = 500
def cleanup(
self,
batch_size: Optional[int] = None,
progress_callback: Optional[Callable[..., None]] = None,
) -> Dict[str, Any]:
"""按配置保留期执行分批清理。"""
return get_database_governance().cleanup(
batch_size=batch_size,
progress_callback=progress_callback,
)
# 旧插件可能持久化或比较完整类路径,保持迁移前可观察身份。
SchedulerChain.__module__ = "app.scheduler"
+74
View File
@@ -0,0 +1,74 @@
"""Scheduler 职责 owner 的静态组合宿主合同。"""
from typing import TYPE_CHECKING, Any, Callable
if TYPE_CHECKING:
from app.application.agenttask import AgentTaskRepository
from app.scheduler.registry import ExecutionRegistry, SchedulerHandle
from app.scheduler.services import SchedulerServices
class _SchedulerOwnerHost:
"""声明 Scheduler Facade 提供给各职责 owner 的共享状态与入口。"""
_agent_task_interruptions_reconciled: bool
_agent_tasks: AgentTaskRepository | None
_auth_count: int
_auth_message: bool
_auth_plugin_routes_pending: bool
_event: Any
_jobs: dict[str, dict[str, Any]]
_lifecycle_state: str
_lock: Any
_registry: ExecutionRegistry
_scheduler: Any
_services: SchedulerServices | None
_accepting_submissions: Callable[..., bool]
_accepts_handle: Callable[..., bool]
_assign_job_generation: Callable[..., None]
_await_cancelled_handles: Callable[..., Any]
_await_progress_handles: Callable[..., Any]
_build_mediaserver_sync_schedules: Callable[..., Any]
_build_progress_callback: Callable[..., Any]
_cancel_handle: Callable[[SchedulerHandle], None]
_finish_job: Callable[..., Any]
_finish_unsubmitted_job: Callable[..., None]
_format_time: Callable[..., str]
_get_result_error: Callable[..., Any]
_get_progress_key: Callable[[str], str]
_handle_job_error: Callable[..., None]
_initialize_catalog: Callable[..., None]
_is_job_active: Callable[[str], bool]
_register_handle: Callable[..., bool]
_release_job_generation: Callable[..., None]
_scheduler_services: Callable[[], SchedulerServices]
_shutdown_scheduler_sync: Callable[[Any], None]
_supports_progress_callback: Callable[..., bool]
_submit_cross_thread: Callable[..., bool]
_submit_to_loop: Callable[..., bool]
_reconcile_agent_task_interruptions: Callable[..., None]
aget_progress: Callable[..., Any]
agent_heartbeat: Callable[..., Any]
clear_cache: Callable[..., Any]
database_backup: Callable[..., Any]
execute_agent_task: Callable[..., Any]
full_gc: Callable[..., Any]
get_progress: Callable[..., Any]
init_agent_task_jobs: Callable[..., None]
init_plugin_jobs: Callable[..., None]
init_workflow_jobs: Callable[..., None]
list: Callable[..., Any]
remove_agent_task_job: Callable[..., None]
remove_plugin_job: Callable[..., None]
remove_workflow_job: Callable[..., None]
start: Callable[..., bool]
stop: Callable[..., None]
update_agent_task_job: Callable[..., Any]
update_plugin_job: Callable[..., None]
update_workflow_job: Callable[..., None]
user_auth: Callable[..., Any]
class _SchedulerOwnerBase(_SchedulerOwnerHost):
"""仅向静态检查器暴露完整的 Scheduler 组合宿主合同。"""
else:
_SchedulerOwnerBase = object
+429
View File
@@ -0,0 +1,429 @@
"""调度任务准入、执行、进度和事件循环桥接。"""
import asyncio
import inspect
import multiprocessing
import threading
import time
import traceback
from typing import Any, Callable, Optional
from app.application.messaging.message import MessageHelper
from app.application.scheduling import ( # noqa: E402
JobExecutionState,
)
from app.runtime.correlation import call_with_correlation, get_correlation_id
from app.runtime.events import eventmanager
from app.runtime.log import logger
from app.runtime.loop import main_loop_registry
from app.runtime.observability import record_metric
from app.runtime.progress import ProgressHelper
from app.runtime.scheduling import TimerUtils
from app.scheduler.contract import _SchedulerOwnerBase
from app.schemas.dashboard import ScheduleInfo as _SchemaScheduleInfo
from app.schemas.types import EventType
_message_helper_factory: Callable[[], MessageHelper] = MessageHelper
class SchedulerExecutionOwner(_SchedulerOwnerBase):
"""调度任务准入与函数执行语义。"""
def _accepting_submissions(self) -> bool:
"""判断调度器是否仍允许提交新的运行实例。"""
return self._lifecycle_state in {"starting", "running"}
def _next_job_generation(self, job_id: str) -> int:
"""为同一 job 的下一次注册分配单调 generation。"""
return self._registry.next_generation(job_id)
def _assign_job_generation(self, job_id: str, job: dict[str, Any]) -> None:
"""把注册 generation 写入可变运行时状态。"""
self._registry.assign_generation(job_id, job)
def _is_job_active(self, job_id: str) -> bool:
"""判断任一 generation 的同 ID 任务是否仍在真实执行。"""
return self._registry.is_active(job_id)
def _release_job_generation(self, job_id: str, generation: int) -> None:
"""在任务真实收尾后释放对应 generation 的运行所有权。"""
self._registry.release_generation(job_id, generation)
def _prepare_job(self, job_id: str) -> Optional[dict[str, Any]]:
"""
准备定时任务
"""
started_at = self._format_time()
with self._lock:
if not self._accepting_submissions():
return None
if not self._registry.consume_reservation(
job_id,
threading.get_ident(),
):
return None
job = self._jobs.get(job_id)
if not job:
return None
if self._is_job_active(job_id):
logger.warning(f"定时任务 {job_id} - {job.get('name')} 正在运行 ...")
record_metric(
"scheduler.job.overlap_skip",
owner=str(job.get("owner", "unknown")),
)
return None
if not JobExecutionState.begin(job, started_at):
logger.warning(f"定时任务 {job_id} - {job.get('name')} 正在运行 ...")
record_metric(
"scheduler.job.overlap_skip",
owner=str(job.get("owner", "unknown")),
)
return None
generation = job.get("_generation", 0)
if not self._registry.claim_generation(job_id, generation):
JobExecutionState.finish(job, started_at, None)
return None
job["_metric_started_at"] = time.perf_counter()
progress = ProgressHelper(self._get_progress_key(job_id))
progress.start()
progress.update(
value=0,
text=f"{job.get('name') or job_id} 开始执行 ...",
data={
"id": job_id,
"_generation": job.get("_generation", 0),
"name": job.get("name"),
"provider": job.get("provider_name", "[系统]"),
"status": "running",
"success": None,
"started_at": started_at,
"finished_at": None,
"error": None,
},
)
return job
@staticmethod
def _handle_job_error(job_id: str, job: dict[str, Any], error: Exception) -> None:
"""
记录定时任务执行异常并发送系统错误事件。
"""
logger.error(f"定时任务 {job.get('name')} 执行失败:{str(error)} - {traceback.format_exc()}")
_message_helper_factory().put(
title=f"{job.get('name')} 执行失败",
message=str(error),
role="system",
)
eventmanager.send_event(
EventType.SystemError,
{
"type": "scheduler",
"scheduler_id": job_id,
"scheduler_name": job.get("name"),
"error": str(error),
"traceback": traceback.format_exc(),
},
)
@staticmethod
def _supports_progress_callback(func: Callable[..., Any]) -> bool:
"""
判断定时任务函数是否显式支持进度回调参数。
"""
try:
parameters = inspect.signature(func).parameters
except TypeError, ValueError:
return False
return "progress_callback" in parameters
@staticmethod
def _get_result_error(result: Any) -> Optional[str]:
"""
从定时任务标准失败返回值中提取错误信息。
"""
if isinstance(result, tuple) and result and isinstance(result[0], bool) and result[0] is False:
return str(result[1]) if len(result) > 1 and result[1] else "定时任务返回失败"
return None
async def _run_coro_job(
self,
coro_factory: Callable[[], Any],
job_id: str,
job: dict[str, Any],
generation: Optional[int] = None,
) -> None:
"""
在当前事件循环内执行协程定时任务并在真实完成后收敛状态。
"""
generation = job.get("_generation", 0) if generation is None else generation
success = True
error = None
try:
result = await JobExecutionState.await_result(
coro_factory(),
timeout_seconds=job.get("timeout_seconds"),
)
error = self._get_result_error(result)
success = error is None
except asyncio.TimeoutError as err:
success = False
error = f"任务执行超时({job.get('timeout_seconds')} 秒)"
self._handle_job_error(job_id=job_id, job=job, error=err)
except asyncio.CancelledError:
success = False
error = "任务已取消"
raise
except Exception as err:
success = False
error = str(err)
self._handle_job_error(job_id=job_id, job=job, error=err)
finally:
# 协程收尾在事件循环上完成,同步路径(线程池/调用线程)提交到事件循环执行
await self._finish_job(
job_id=job_id,
job=job,
generation=generation,
success=success,
error=error,
)
def start(self, job_id: str, *args: Any, **kwargs: Any) -> bool:
"""
启动定时服务
"""
def __start_coro(
coro_factory: Callable[[], Any],
generation: int,
runtime_job: dict[str, Any],
) -> tuple[bool, bool]:
"""
启动协程,返回是否异步收尾以及本次提交是否被接受。
"""
try:
running_loop = asyncio.get_running_loop()
except RuntimeError:
running_loop = None
target_loop = main_loop_registry.current
target_loop_available = target_loop is not None and target_loop.is_running() and not target_loop.is_closed()
if running_loop and (not target_loop_available or running_loop is target_loop):
started = threading.Event()
async def run_owned_job() -> None:
started.set()
await self._run_coro_job(
coro_factory=coro_factory,
job_id=job_id,
job=runtime_job,
generation=generation,
)
with self._lock:
if not self._accepts_handle(job_id, generation):
return False, False
handle = running_loop.create_task(run_owned_job())
registered = self._register_handle(
job_id=job_id,
generation=generation,
loop=running_loop,
handle=handle,
)
def _finish_cancelled_before_start(
submitted: asyncio.Future[Any],
) -> None:
if submitted.cancelled() and not started.is_set():
self._finish_unsubmitted_job(
job_id=job_id,
job=runtime_job,
generation=generation,
error="任务未提交",
)
handle.add_done_callback(_finish_cancelled_before_start)
return registered, registered
if target_loop_available:
wrapped = self._run_coro_job(
coro_factory=coro_factory,
job_id=job_id,
job=runtime_job,
generation=generation,
)
submitted = self._submit_cross_thread(
wrapped,
target_loop=target_loop,
job_id=job_id,
generation=generation,
on_unstarted_cancel=lambda: self._finish_unsubmitted_job(
job_id=job_id,
job=runtime_job,
generation=generation,
error="任务未提交",
),
)
return submitted, submitted
if self._lifecycle_state in {"stopping", "stopped"}:
return False, False
asyncio.run(coro_factory())
return False, True
# 获取定时任务
job = self._prepare_job(job_id)
if not job:
return False
generation = job.get("_generation", 0)
success = True
error = None
deferred_finish = False
accepted = True
# 开始运行
try:
if not kwargs:
kwargs = dict(job.get("kwargs") or {})
func = job.get("func")
if not func:
return False
if func == self.execute_agent_task:
kwargs.setdefault("scheduler_generation", generation)
if self._supports_progress_callback(func) and "progress_callback" not in kwargs:
kwargs["progress_callback"] = self._build_progress_callback(job_id=job_id, job=job)
# 是否多进程运行
run_in_process = job.get("run_in_process", False)
if inspect.iscoroutinefunction(func):
# 协程函数
deferred_finish, accepted = __start_coro(
lambda: func(*args, **kwargs),
generation,
job,
)
elif run_in_process:
# 多进程运行
p = multiprocessing.Process(
target=call_with_correlation,
args=(get_correlation_id(), func, args, kwargs),
)
p.start()
p.join()
else:
# 普通函数
result = func(*args, **kwargs)
error = self._get_result_error(result)
success = error is None
except Exception as e:
success = False
error = str(e)
self._handle_job_error(job_id=job_id, job=job, error=e)
finally:
if not deferred_finish:
def finish_without_loop() -> None:
self._finish_unsubmitted_job(
job_id=job_id,
job=job,
generation=generation,
error=error if accepted else "任务未提交",
)
# 同步上下文执行异步收尾:优先提交到当前/全局事件循环,无循环时新建循环
finish_submitted = self._submit_to_loop(
self._finish_job(
job_id=job_id,
job=job,
generation=generation,
success=success,
error=error,
),
job_id=job_id,
generation=generation,
on_unstarted_cancel=finish_without_loop,
)
if not finish_submitted:
finish_without_loop()
return accepted
def list(self) -> list[_SchemaScheduleInfo]:
"""
当前所有任务
"""
if not self._scheduler:
return []
with self._lock:
# 返回计时任务
schedulers = []
# 去重
added = []
# 避免_scheduler.shutdown()处于阻塞状态导致的死锁
if not self._scheduler or not self._scheduler.running:
return []
jobs = self._scheduler.get_jobs()
# 按照下次运行时间排序
jobs.sort(key=lambda x: x.next_run_time)
# 将正在运行的任务提取出来 (保障一次性任务正常显示)
for job_id, service in self._jobs.items():
name = service.get("name")
provider_name = service.get("provider_name")
if (self._is_job_active(job_id) or service.get("running")) and name and provider_name:
if job_id not in added:
added.append(job_id)
progress = self.get_progress(job_id)
schedulers.append(
_SchemaScheduleInfo(
id=job_id,
name=name,
provider=provider_name,
status="正在运行",
progress=progress.value if progress else 0,
progress_text=progress.text if progress else None,
progress_enable=progress.enable if progress else False,
progress_detail=progress,
)
)
# 获取其他待执行任务
for job in jobs:
job_id = job.id.split("|")[0]
if job_id not in added:
added.append(job_id)
else:
continue
service_state = self._jobs.get(job_id)
if not service_state:
continue
# 任务状态
status = "正在运行" if self._is_job_active(job_id) or service_state.get("running") else "等待"
# 下次运行时间
next_run = TimerUtils.time_difference(job.next_run_time)
progress = self.get_progress(job_id)
schedulers.append(
_SchemaScheduleInfo(
id=job_id,
name=job.name,
provider=service_state.get("provider_name", "[系统]"),
status=status,
next_run=next_run,
progress=progress.value if progress else 0,
progress_text=progress.text if progress else None,
progress_enable=progress.enable if progress else False,
progress_detail=progress,
)
)
# 仅手动执行的任务(未注册到调度器)
for job_id, service in self._jobs.items():
if not service.get("manual"):
continue
if job_id in added:
continue
added.append(job_id)
progress = self.get_progress(job_id)
schedulers.append(
_SchemaScheduleInfo(
id=job_id,
name=service.get("name"),
provider=service.get("provider_name", "[系统]"),
status=("正在运行" if self._is_job_active(job_id) else "等待"),
progress=progress.value if progress else 0,
progress_text=progress.text if progress else None,
progress_enable=progress.enable if progress else False,
progress_detail=progress,
)
)
return schedulers
+126
View File
@@ -0,0 +1,126 @@
"""调度器具体实现的组合门面与旧插件 ABI。"""
import threading
from collections.abc import Callable
from typing import Any, TypeVar
from app.application.agenttask import AgentTaskRepository
from app.foundation.singleton import SingletonClass
from app.runtime.events import Event, eventmanager
from app.runtime.reload import ConfigReloadMixin
from app.scheduler.bridge import SchedulerBridgeOwner
from app.scheduler.catalog import SchedulerCatalogOwner
from app.scheduler.execution import SchedulerExecutionOwner
from app.scheduler.lifecycle import SchedulerLifecycleOwner
from app.scheduler.maintenance import SchedulerMaintenanceOwner
from app.scheduler.progress import SchedulerProgressOwner
from app.scheduler.reconcile import SchedulerReconcileOwner
from app.scheduler.registry import ExecutionRegistry
from app.scheduler.services import SchedulerServices
from app.schemas.types import EventType, SystemConfigKey
_PUBLIC_MODULE = "app.scheduler"
_Handler = TypeVar("_Handler", bound=Callable[..., Any])
def _public_handler(handler: _Handler) -> _Handler:
"""在事件注册前恢复插件可见的稳定模块身份。"""
handler.__module__ = _PUBLIC_MODULE
return handler
class Scheduler(
SchedulerLifecycleOwner,
SchedulerReconcileOwner,
SchedulerBridgeOwner,
SchedulerProgressOwner,
SchedulerExecutionOwner,
SchedulerCatalogOwner,
SchedulerMaintenanceOwner,
ConfigReloadMixin,
metaclass=SingletonClass,
):
"""
定时任务管理
"""
__module__ = _PUBLIC_MODULE
CONFIG_WATCH = {
"DEV",
"COOKIECLOUD_INTERVAL",
"MEDIASERVER_SYNC_INTERVAL",
SystemConfigKey.MediaServers.value,
"SUBSCRIBE_SEARCH",
"SUBSCRIBE_SEARCH_INTERVAL",
"SUBSCRIBE_MODE",
"SUBSCRIBE_RSS_INTERVAL",
"SITEDATA_REFRESH_INTERVAL",
"AI_AGENT_ENABLE",
"AI_AGENT_JOB_INTERVAL",
"DATA_CLEANUP_ENABLE",
"DATA_CLEANUP_MESSAGE_DAYS",
"DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS",
"DATA_CLEANUP_SITE_USERDATA_DAYS",
"DATA_CLEANUP_TRANSFER_HISTORY_DAYS",
"DATA_CLEANUP_DOWNLOAD_FAILURE_DAYS",
"DATA_CLEANUP_SUBSCRIBE_HISTORY_DAYS",
"DATA_CLEANUP_AGENT_CHAT_DAYS",
"DATA_CLEANUP_AGENT_TASK_RUN_DAYS",
"DATA_CLEANUP_OUTBOX_COMPLETED_DAYS",
"DATA_CLEANUP_OUTBOX_DEAD_DAYS",
"DB_BACKUP_ENABLE",
"DB_BACKUP_CRON",
"USAGE_STATISTIC_SHARE",
}
def __init__(self) -> None:
"""创建调度器状态;后台任务由应用生命周期显式启动。"""
# 定时服务
self._scheduler = None
# 退出事件
self._event = threading.Event()
# 锁
self._lock = threading.RLock()
# 各服务的运行状态
self._jobs = {}
# 生命周期门禁与事件循环句柄由调度器实例独立持有。
self._lifecycle_state = "new"
self._registry = ExecutionRegistry(self._lock)
# 进程启动时只对账一次,配置热重载不得改写仍在执行的任务状态
self._agent_task_interruptions_reconciled = False
# 用户认证失败次数
self._auth_count = 0
# 用户认证失败消息发送
self._auth_message = False
# 插件已按认证结果重建,但动态路由尚未完成投影时保留重试状态。
self._auth_plugin_routes_pending = False
self._agent_tasks: AgentTaskRepository | None = None
self._services: SchedulerServices | None = None
def configure_services(self, services: SchedulerServices) -> None:
"""在调度器启动前绑定由组合根构造的业务能力。"""
if self._lifecycle_state not in {"new", "stopped"}:
raise RuntimeError("Scheduler 已运行,不能替换业务能力")
self._services = services
def _scheduler_services(self) -> SchedulerServices:
"""返回显式注入能力;缺少组合根装配时稳定失败。"""
if self._services is None:
raise RuntimeError("Scheduler 的业务能力尚未注入")
return self._services
def get_reload_name(self) -> str:
"""
获取配置重载日志中的服务名称。
"""
return "定时服务"
@eventmanager.register(EventType.PluginReload) # type: ignore[misc]
@_public_handler
def on_plugin_reload(self, event: Event) -> None:
"""插件重载后按当前实例重新注册全部定时服务"""
plugin_id = event.event_data.get("plugin_id")
if not plugin_id:
return
self.update_plugin_job(plugin_id)
+130
View File
@@ -0,0 +1,130 @@
"""Scheduler 启动、热重载和关闭生命周期。"""
import asyncio
import threading
import traceback
from typing import Any
from app.application.configuration import (
get_scheduler_runtime_config,
)
from app.runtime.log import logger
from app.runtime.stop import runtime_stop_state
from app.scheduler.contract import _SchedulerOwnerBase
from app.scheduler.registry import SchedulerHandle
lock = threading.Lock()
class SchedulerLifecycleOwner(_SchedulerOwnerBase):
"""Scheduler 启动、热重载和关闭生命周期。"""
async def on_config_changed(self) -> None:
"""
配置变更后重新初始化定时服务。
"""
reload_started, scheduler = self._begin_reload()
if not reload_started:
return
await asyncio.to_thread(self._shutdown_scheduler_sync, scheduler)
with self._lock:
if self._lifecycle_state != "reloading":
return
self.init(_already_stopped=True)
def init(self, *, _already_stopped: bool = False) -> None:
"""
初始化定时服务
"""
config = get_scheduler_runtime_config()
# 停止定时服务
if not _already_stopped:
self.stop()
# 调试模式不启动定时服务
if config.dev:
with self._lock:
self._lifecycle_state = "stopped"
return
# 对账上个进程未收口的 Agent 任务;进程内重复初始化不会重复改写状态。
self._reconcile_agent_task_interruptions()
with lock:
with self._lock:
self._event.clear()
self._lifecycle_state = "starting"
self._initialize_catalog(config)
# 启动定时服务
self._scheduler.start()
with self._lock:
self._lifecycle_state = "running"
def _begin_stop(self) -> tuple[Any, tuple[SchedulerHandle, ...]]:
"""关闭提交入口并摘出当前调度器与其拥有的异步句柄。"""
with self._lock:
self._lifecycle_state = "stopping"
self._event.set()
scheduler = self._scheduler
self._scheduler = None
handles = self._registry.stop_snapshot()
if scheduler:
try:
scheduler.remove_all_jobs()
except Exception as err:
logger.error("移除定时任务失败:%s", err)
return scheduler, handles
def _begin_reload(self) -> tuple[bool, Any]:
"""停止旧计划的提交入口,保留已开始任务直到其自然完成。"""
with self._lock:
if runtime_stop_state.is_system_stopped or self._lifecycle_state in {"stopping", "reloading"}:
return False, None
self._lifecycle_state = "reloading"
self._event.set()
scheduler = self._scheduler
self._scheduler = None
self._registry.clear_reservations()
if scheduler:
try:
scheduler.remove_all_jobs()
except Exception as err:
logger.error("移除定时任务失败:%s", err)
return True, scheduler
@staticmethod
def _shutdown_scheduler_sync(scheduler: Any) -> None:
"""等待 APScheduler 自有线程池停止。"""
if scheduler and scheduler.running:
scheduler.shutdown()
def stop(self) -> None:
"""
关闭定时服务的同步兼容入口。
应用生命周期使用 ``stop_async``,以便等待事件循环中的协程句柄;同步
调用方仍可请求取消并等待 APScheduler 自有线程池收口。
"""
with lock:
try:
scheduler, handles = self._begin_stop()
for handle in handles:
self._cancel_handle(handle)
self._shutdown_scheduler_sync(scheduler)
with self._lock:
self._lifecycle_state = "stopped"
logger.info("定时任务停止完成")
except Exception as err:
logger.error(f"停止定时任务失败:{err} - {traceback.format_exc()}")
async def stop_async(self) -> None:
"""关闭调度器并等待已投递协程收口。"""
scheduler, handles = self._begin_stop()
for handle in handles:
self._cancel_handle(handle)
await asyncio.to_thread(self._shutdown_scheduler_sync, scheduler)
await self._await_cancelled_handles(handles)
with self._lock:
self._lifecycle_state = "stopped"
logger.info("定时任务停止完成")
+48
View File
@@ -0,0 +1,48 @@
"""调度器内置维护任务。"""
import gc
from app.application.backup import BackupArtifact
from app.application.database import get_database_governance
from app.runtime.gc import get_memory_usage
from app.runtime.log import logger
from app.scheduler.contract import _SchedulerOwnerBase
class SchedulerMaintenanceOwner(_SchedulerOwnerBase):
"""调度器内置维护任务。"""
@staticmethod
def database_backup() -> BackupArtifact:
"""按当前宿主策略创建一次定时数据库备份。"""
return get_database_governance().create_backup()
def clear_cache(self) -> None:
"""
清理缓存
"""
self._scheduler_services().clear_cache()
@staticmethod
def full_gc() -> None:
"""
主动内存回收
"""
memory_before = get_memory_usage()
collected = gc.collect()
memory_after = get_memory_usage()
memory_freed = memory_before - memory_after
logger.info(f"主动内存回收完成,回收对象数: {collected},释放内存: {memory_freed:.2f} MB")
@staticmethod
async def agent_heartbeat() -> None:
"""
智能体心跳唤醒:检查并执行待处理的定时任务
"""
from app.application.agent import get_running_agent_manager
manager = get_running_agent_manager()
if manager is None:
logger.debug("智能助手服务未运行,跳过心跳任务")
return
await manager.heartbeat_check_jobs()
+246
View File
@@ -0,0 +1,246 @@
"""调度任务进度快照与终态收敛。"""
import time
from datetime import datetime
from typing import Any, Callable, Optional
from app.application.scheduling import ( # noqa: E402
JobExecutionState,
)
from app.runtime.observability import record_metric
from app.runtime.progress import AsyncProgressHelper, ProgressHelper
from app.scheduler.contract import _SchedulerOwnerBase
from app.schemas.dashboard import ScheduleProgress as _SchemaScheduleProgress
SCHEDULER_PROGRESS_PREFIX = "scheduler"
class SchedulerProgressOwner(_SchedulerOwnerBase):
"""调度任务进度快照与终态收敛。"""
def _finish_unsubmitted_job(
self,
job_id: str,
job: dict[str, Any],
generation: int,
error: Optional[str],
) -> None:
"""收尾协程无法提交时同步释放任务状态和运行所有权。"""
finished_at = self._format_time()
metric_started_at = None
with self._lock:
if generation not in self._registry.active_generations(job_id):
return
current_job = self._jobs.get(job_id)
if current_job is job and current_job.get("_generation", 0) == generation:
JobExecutionState.finish(job, finished_at, error)
metric_started_at = job.pop("_metric_started_at", None)
self._release_job_generation(job_id, generation)
if metric_started_at is not None:
record_metric(
"scheduler.job.duration",
time.perf_counter() - metric_started_at,
owner=str(job.get("owner", "unknown")),
outcome="success" if error is None else "error",
)
@staticmethod
def _get_progress_key(job_id: str) -> str:
"""
获取定时服务进度缓存键。
"""
return f"{SCHEDULER_PROGRESS_PREFIX}:{job_id}"
@staticmethod
def _format_time(value: Optional[datetime] = None) -> str:
"""
格式化进度事件时间。
"""
return (value or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")
async def _finish_job(
self,
job_id: str,
job: dict[str, Any],
generation: int,
success: bool = True,
error: Optional[str] = None,
) -> None:
"""
完成定时任务
"""
# 业务函数返回前提交的进度回调可能仍在等待 Redis I/O;先收敛它们,
# 避免迟到的 running 快照覆盖 success/failed 终态。
await self._await_progress_handles(job_id, generation)
finished_at = self._format_time()
with self._lock:
current_job = self._jobs.get(job_id)
if current_job is not job or current_job.get("_generation", 0) != generation:
self._release_job_generation(job_id, generation)
return
JobExecutionState.finish(job, finished_at, error)
metric_started_at = job.pop("_metric_started_at", None)
if metric_started_at is not None:
record_metric(
"scheduler.job.duration",
time.perf_counter() - metric_started_at,
owner=str(job.get("owner", "unknown")),
outcome="success" if success else "error",
)
job_name = job.get("name") if job else job_id
# 收尾可能发生在事件循环上(_run_coro_job),使用异步进度后端避免阻塞
progress = AsyncProgressHelper(self._get_progress_key(job_id))
try:
current_progress = await progress.get() or {}
progress_value = 100 if success else current_progress.get("value", 0)
await progress.end(
text=f"{job_name} {'执行完成' if success else '执行失败'}",
data={
"id": job_id,
"_generation": generation,
"name": job_name,
"provider": job.get("provider_name", "[系统]") if job else None,
"status": "success" if success else "failed",
"success": success,
"finished_at": finished_at,
"error": error,
},
value=progress_value,
)
finally:
with self._lock:
self._release_job_generation(job_id, generation)
def get_progress(self, job_id: str) -> Optional[_SchemaScheduleProgress]:
"""
查询指定定时服务的执行进度。
"""
if not job_id:
return None
with self._lock:
job = self._jobs.get(job_id)
job_name = job.get("name") if job else job_id
provider_name = job.get("provider_name", "[系统]") if job else None
running = bool(job and (self._is_job_active(job_id) or job.get("running")))
last_started_at = job.get("last_started_at") if job else None
last_finished_at = job.get("last_finished_at") if job else None
last_error = job.get("last_error") if job else None
detail = ProgressHelper(self._get_progress_key(job_id)).get() or {}
if not job and not detail:
return None
data = dict(detail.get("data") or {})
progress_generation = data.pop("_generation", None)
if job and progress_generation is not None and progress_generation != job.get("_generation", 0):
detail = {}
data = {}
value = detail.get("value", 0)
try:
value = float(value)
except TypeError, ValueError:
value = 0.0
return _SchemaScheduleProgress(
id=job_id,
name=data.get("name") or job_name,
provider=data.get("provider") or provider_name,
enable=bool(detail.get("enable", running)),
value=max(min(value, 100), 0),
text=detail.get("text"),
status=data.get("status") or ("running" if running else "waiting"),
success=data.get("success"),
started_at=data.get("started_at") or last_started_at,
finished_at=data.get("finished_at") or last_finished_at,
error=data.get("error") or last_error,
data=data,
)
async def aget_progress(self, job_id: str) -> Optional[_SchemaScheduleProgress]:
"""
查询指定定时服务的执行进度(异步版本,供事件循环上的端点使用)。
"""
if not job_id:
return None
with self._lock:
job = self._jobs.get(job_id)
job_name = job.get("name") if job else job_id
provider_name = job.get("provider_name", "[系统]") if job else None
running = bool(job and (self._is_job_active(job_id) or job.get("running")))
last_started_at = job.get("last_started_at") if job else None
last_finished_at = job.get("last_finished_at") if job else None
last_error = job.get("last_error") if job else None
# 异步后端读取,避免在事件循环上阻塞
detail = await AsyncProgressHelper(self._get_progress_key(job_id)).get() or {}
if not job and not detail:
return None
data = dict(detail.get("data") or {})
progress_generation = data.pop("_generation", None)
if job and progress_generation is not None and progress_generation != job.get("_generation", 0):
detail = {}
data = {}
value = detail.get("value", 0)
try:
value = float(value)
except TypeError, ValueError:
value = 0.0
return _SchemaScheduleProgress(
id=job_id,
name=data.get("name") or job_name,
provider=data.get("provider") or provider_name,
enable=bool(detail.get("enable", running)),
value=max(min(value, 100), 0),
text=detail.get("text"),
status=data.get("status") or ("running" if running else "waiting"),
success=data.get("success"),
started_at=data.get("started_at") or last_started_at,
finished_at=data.get("finished_at") or last_finished_at,
error=data.get("error") or last_error,
data=data,
)
def _build_progress_callback(self, job_id: str, job: dict[str, Any]) -> Callable[..., None]:
"""
构建传递给定时任务内部的进度更新回调。
"""
generation = job.get("_generation", 0)
def update_progress(
value: Optional[float] = None,
text: Optional[str] = None,
data: Optional[dict[str, Any]] = None,
) -> None:
"""
更新当前定时任务进度。
"""
progress_data = {
"id": job_id,
"_generation": generation,
"name": job.get("name"),
"provider": job.get("provider_name", "[系统]"),
"status": "running",
"success": None,
}
if data:
progress_data.update(data)
key = self._get_progress_key(job_id)
async def _update() -> None:
with self._lock:
current_job = self._jobs.get(job_id)
if current_job is not job or current_job.get("_generation", 0) != generation:
return
# 异步后端更新,避免任务函数在事件循环内调用回调时阻塞
await AsyncProgressHelper(key).update(
value=value,
text=text,
data=progress_data,
)
# 回调可能在事件循环内(async 任务)或线程池中(sync 任务)被调用,
# 统一经事件循环提交;无运行中循环时同步执行兜底
self._submit_to_loop(
_update(),
job_id=job_id,
generation=job.get("_generation", 0),
kind="progress",
)
return update_progress
+507
View File
@@ -0,0 +1,507 @@
"""Agent、插件和工作流动态任务对账。"""
import threading
import traceback
from typing import Any, Optional, cast
from apscheduler.jobstores.base import JobLookupError
from apscheduler.triggers.cron import CronTrigger
from app.application.agenttask import AgentTaskRepository
from app.application.configuration import (
get_configured_system_config,
get_scheduler_runtime_config,
)
from app.application.plugin.routes import register_plugin_api
from app.application.plugin.runtime import get_plugin_manager
from app.application.scheduling import ( # noqa: E402
AGENT_TASK_JOB_PREFIX,
JobRecoveryPolicy,
JobSpec,
)
from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module
from app.application.workflow import WorkflowSnapshot
from app.runtime.log import logger
from app.runtime.scheduling import TimerUtils
from app.scheduler.contract import _SchedulerOwnerBase
from app.schemas.message import Message
from app.schemas.types import MessageType, SystemConfigKey
class SchedulerReconcileOwner(_SchedulerOwnerBase):
"""Agent、插件和工作流动态任务对账。"""
def configure_agent_tasks(self, repository: AgentTaskRepository) -> None:
"""在调度器启动前绑定唯一自主任务仓储。"""
if self._lifecycle_state not in {"new", "stopped"}:
raise RuntimeError("Scheduler 已运行,不能替换 AgentTask 仓储")
self._agent_tasks = repository
def _agent_task_repository(self) -> AgentTaskRepository:
"""返回显式注入仓储;缺少组合根装配时稳定失败。"""
if self._agent_tasks is None:
raise RuntimeError("Scheduler 的 AgentTask 仓储尚未注入")
return self._agent_tasks
@staticmethod
def _get_agent_task_job_id(task_id: int) -> str:
"""生成 Agent 自主定时任务的调度器 Job ID。"""
return f"{AGENT_TASK_JOB_PREFIX}-{task_id}"
def start_agent_task(self, task_id: int) -> bool:
"""
将指定 Agent 自主定时任务提交到运行时调度器立即执行。
:param task_id: Agent 自主定时任务 ID
:return: 任务存在且未运行时返回 True,否则返回 False
"""
job_id = self._get_agent_task_job_id(task_id)
owner = threading.get_ident()
with self._lock:
job = self._jobs.get(job_id)
if not self._accepting_submissions() or not job or self._is_job_active(job_id) or job.get("running"):
return False
if not self._registry.reserve(job_id, owner):
return False
try:
result = self.start(job_id, task_id=task_id, trigger_source="manual")
return result is not False
finally:
self._registry.release_reservation(job_id, owner)
def init_agent_task_jobs(self) -> None:
"""
按数据库当前状态注册所有启用的 Agent 自主定时任务。
"""
for task in self._agent_task_repository().list(enabled=True):
self.update_agent_task_job(task.id)
def _reconcile_agent_task_interruptions(self) -> None:
"""
将上个进程未收口的 Agent 任务标记为结果未知。
配置变更会在同一进程内重建调度器,因此该对账在实例生命周期内只能
成功执行一次,避免把当前进程仍在运行的任务误判为中断。
"""
with self._lock:
if self._agent_task_interruptions_reconciled:
return
oper = self._agent_task_repository()
for task in oper.list():
if task.last_status == "running":
oper.mark_interrupted(
task_id=task.id,
result=(
"服务重启时任务执行被中断,结果未知,可能已有部分操作;请先检查实际状态,再决定是否重新执行"
),
)
self._agent_task_interruptions_reconciled = True
def update_agent_task_job(self, task_id: int) -> Optional[str]:
"""
按数据库中的最新配置新增或替换 Agent 自主定时任务。
:param task_id: Agent 定时任务 ID
:return: 下一次执行时间,不可调度时返回 None
"""
config = get_scheduler_runtime_config()
self.remove_agent_task_job(task_id)
task = self._agent_task_repository().get(task_id)
if not config.ai_agent_enable or not task or not task.enabled or not self._scheduler:
return None
trigger_value = task.cron_expression if task.trigger_type == "cron" else task.run_at
if trigger_value is None:
logger.error(f"Agent 定时任务 {task_id} 缺少触发配置")
return None
manual_only = task.trigger_type == "date" and task.last_status == "interrupted"
trigger = None
if not manual_only:
try:
trigger = TimerUtils.build_schedule_trigger(
trigger_type=task.trigger_type,
trigger_value=trigger_value,
timezone_name=config.timezone,
)
except (TypeError, ValueError) as err:
logger.error(f"Agent 定时任务 {task_id} 的触发配置无效:{str(err)}")
return None
job_id = self._get_agent_task_job_id(task_id)
with self._lock:
job = JobSpec(
job_id,
task.name,
self.execute_agent_task,
"agent",
recovery=JobRecoveryPolicy.NEXT_SCHEDULE,
kwargs={"task_id": task_id},
).to_runtime_state()
self._assign_job_generation(job_id, job)
job["_agent_task_run_id"] = task.last_run_id
job["_agent_task_status"] = task.last_status
self._jobs[job_id] = job
self._jobs[job_id]["provider_name"] = "[Agent]"
# 已开始的一次任务在重启后结果未知,只保留显式执行入口,不能按
# 过期触发时间自动重放可能已经发生的外部副作用。
if manual_only:
return None
self._scheduler.add_job(
self.start,
trigger=trigger,
id=job_id,
name=task.name,
kwargs={"job_id": job_id, "task_id": task_id},
coalesce=True,
max_instances=1,
misfire_grace_time=None,
replace_existing=True,
)
return self.get_agent_task_next_run(task_id)
def remove_agent_task_job(self, task_id: int) -> None:
"""
从运行时调度器移除 Agent 自主定时任务。
:param task_id: Agent 定时任务 ID
"""
job_id = self._get_agent_task_job_id(task_id)
with self._lock:
self._jobs.pop(job_id, None)
if not self._scheduler:
return
try:
self._scheduler.remove_job(job_id)
except JobLookupError:
pass
def _remove_agent_task_job_generation(
self,
task_id: int,
generation: int,
run_id: str,
) -> bool:
"""移除本次执行或其运行中重载产生的 AgentTask 调度注册。"""
job_id = self._get_agent_task_job_id(task_id)
with self._lock:
job = self._jobs.get(job_id)
if job is None:
return False
if job.get("_generation", 0) != generation and not (
job.get("_agent_task_run_id") == run_id and job.get("_agent_task_status") == "running"
):
return False
self._jobs.pop(job_id, None)
if self._scheduler:
try:
self._scheduler.remove_job(job_id)
except JobLookupError:
pass
return True
def get_agent_task_next_run(self, task_id: int) -> Optional[str]:
"""
查询 Agent 自主定时任务的下一次执行时间。
:param task_id: Agent 定时任务 ID
:return: 带时区的 ISO 8601 时间,不再执行时返回 None
"""
config = get_scheduler_runtime_config()
job_id = self._get_agent_task_job_id(task_id)
if self._scheduler:
job = self._scheduler.get_job(job_id)
next_run_time = getattr(job, "next_run_time", None) if job else None
if next_run_time:
return cast(str, next_run_time.isoformat(timespec="seconds"))
task = self._agent_task_repository().get(task_id)
if not task or not task.enabled:
return None
if task.trigger_type == "date" and task.last_status == "interrupted":
return None
trigger_value = task.cron_expression if task.trigger_type == "cron" else task.run_at
if trigger_value is None:
return None
try:
next_run_time = TimerUtils.get_schedule_next_run_time(
trigger_type=task.trigger_type,
trigger_value=trigger_value,
timezone_name=config.timezone,
)
except TypeError, ValueError:
return None
return next_run_time.isoformat(timespec="seconds") if next_run_time else None
async def execute_agent_task(
self,
task_id: int,
trigger_source: str = "scheduled",
scheduler_generation: int | None = None,
) -> tuple[bool, str]:
"""
唤醒 Agent 执行指定自主定时任务。
:param task_id: Agent 定时任务 ID
:param trigger_source: 触发入口,scheduled-自动调度,manual-显式立即执行
:return: 执行是否成功及结果摘要
"""
from app.application.agent import get_running_agent_manager
manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过 Agent 定时任务")
return False, "智能助手服务未运行"
if scheduler_generation is None:
job_id = self._get_agent_task_job_id(task_id)
with self._lock:
job = self._jobs.get(job_id)
if job is not None:
scheduler_generation = job.get("_generation", 0)
kwargs: dict[str, Any] = {"trigger_source": trigger_source}
if scheduler_generation is not None:
kwargs.update(
scheduler_generation=scheduler_generation,
remove_schedule=self._remove_agent_task_job_generation,
)
return cast(
tuple[bool, str],
await manager.execute_scheduled_task(task_id, **kwargs),
)
def init_plugin_jobs(self) -> None:
"""
初始化插件定时服务
"""
for pid in get_plugin_manager().get_running_plugin_ids():
self.update_plugin_job(pid)
def init_workflow_jobs(self) -> None:
"""
初始化工作流定时服务
"""
for workflow in self._scheduler_services().list_workflows() or []:
self.update_workflow_job(workflow)
def remove_workflow_job(self, workflow: WorkflowSnapshot) -> None:
"""
移除工作流服务
"""
if not self._scheduler:
return
with self._lock:
job_id = f"workflow-{workflow.id}"
service = self._jobs.pop(job_id, {})
if not service:
return
try:
# 在调度器中查找并移除对应的 job
job_removed = False
for job in list(self._scheduler.get_jobs()):
if job_id == job.id:
try:
self._scheduler.remove_job(job.id)
job_removed = True
except JobLookupError:
pass
break
if job_removed:
logger.info(f"移除工作流服务:{service.get('name')}")
except Exception as e:
logger.error(f"移除工作流服务失败:{str(e)} - {job_id}: {service}")
self._scheduler_services().put_message(
title=f"工作流 {workflow.name} 服务移除失败",
message=str(e),
role="system",
)
def remove_plugin_job(self, pid: str, job_id: Optional[str] = None) -> None:
"""
移除定时服务,可以是单个服务(包括默认服务)或整个插件的所有服务
:param pid: 插件 ID
:param job_id: 可选,指定要移除的单个服务的 job_id。如果不提供,则移除该插件的所有服务,当移除单个服务时,默认服务也包含在内
"""
if not self._scheduler:
return
with self._lock:
if job_id:
# 移除单个服务
service = self._jobs.pop(job_id, None)
if not service:
return
jobs_to_remove = [(job_id, service)]
else:
# 移除插件的所有服务
jobs_to_remove = [
(job_id, service) for job_id, service in self._jobs.items() if service.get("pid") == pid
]
for job_id, _ in jobs_to_remove:
self._jobs.pop(job_id, None)
if not jobs_to_remove:
return
plugin_name = get_plugin_manager().get_plugin_attr(pid, "plugin_name")
# 遍历移除任务
for job_id, service in jobs_to_remove:
try:
# 在调度器中查找并移除对应的 job
job_removed = False
for job in list(self._scheduler.get_jobs()):
job_id_from_service = job.id.split("|")[0]
if job_id == job_id_from_service:
try:
self._scheduler.remove_job(job.id)
job_removed = True
except JobLookupError:
pass
if job_removed:
logger.info(f"移除插件服务({plugin_name}){service.get('name')}") # noqa
except Exception as e:
logger.error(f"移除插件服务失败:{str(e)} - {job_id}: {service}")
self._scheduler_services().put_message(
title=f"插件 {plugin_name} 服务移除失败",
message=str(e),
role="system",
)
def update_workflow_job(self, workflow: WorkflowSnapshot) -> None:
"""
更新工作流定时服务
"""
if not self._scheduler:
return
# 移除该工作流的全部服务
self.remove_workflow_job(workflow)
# 添加工作流服务
with self._lock:
try:
job_id = f"workflow-{workflow.id}"
job = JobSpec(
job_id,
workflow.name,
self._scheduler_services().process_workflow,
"workflow",
).to_runtime_state()
self._assign_job_generation(job_id, job)
job["provider_name"] = "工作流"
self._jobs[job_id] = job
self._scheduler.add_job(
self.start,
trigger=CronTrigger.from_crontab(workflow.timer),
id=job_id,
name=workflow.name,
kwargs={"job_id": job_id, "workflow_id": workflow.id},
replace_existing=True,
)
logger.info(f"注册工作流服务:{workflow.name} - {workflow.timer}")
except Exception as e:
logger.error(f"注册工作流服务失败:{workflow.name} - {str(e)}")
self._scheduler_services().put_message(
title=f"工作流 {workflow.name} 服务注册失败",
message=str(e),
role="system",
)
def update_plugin_job(self, pid: str) -> None:
"""
更新插件定时服务
"""
if not self._scheduler or not pid:
return
# 移除该插件的全部服务
self.remove_plugin_job(pid)
# 获取插件服务列表
with self._lock:
plugin_manager = get_plugin_manager()
try:
plugin_services = plugin_manager.get_plugin_services(pid=pid)
except Exception as e:
logger.error(f"运行插件 {pid} 服务失败:{str(e)} - {traceback.format_exc()}")
return
# 获取插件名称
plugin_name = plugin_manager.get_plugin_attr(pid, "plugin_name")
# 开始注册插件服务
for service in plugin_services:
try:
sid = f"{pid}_{service['id']}"
job_id = sid.split("|")[0]
self.remove_plugin_job(pid, job_id)
job = JobSpec(
job_id,
service["name"],
service["func"],
f"plugin:{pid}",
kwargs=service.get("func_kwargs") or {},
).to_runtime_state()
self._assign_job_generation(job_id, job)
job.update(
pid=pid,
provider_name=plugin_name,
)
self._jobs[job_id] = job
self._scheduler.add_job(
self.start,
service["trigger"],
id=sid,
name=service["name"],
**(service.get("kwargs") or {}),
kwargs={"job_id": job_id},
replace_existing=True,
)
logger.info(f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}")
except Exception as e:
logger.error(f"注册插件{plugin_name}服务失败:{str(e)} - {service}")
self._scheduler_services().put_message(
title=f"插件 {plugin_name} 服务注册失败",
message=str(e),
role="system",
)
def user_auth(self) -> None:
"""
用户认证检查
"""
config = get_scheduler_runtime_config()
if SitesHelper().auth_level >= 2:
if self._auth_plugin_routes_pending:
register_plugin_api()
self._auth_plugin_routes_pending = False
return
# 最大重试次数
__max_try__ = 30
if self._auth_count > __max_try__:
if not self._auth_message:
self._scheduler_services().put_message(
title="用户认证失败",
message="用户认证失败次数过多,将不再尝试认证!",
role="system",
)
self._auth_message = True
return
logger.info("用户未认证,正在尝试认证...")
auth_conf = get_configured_system_config().get(SystemConfigKey.UserSiteAuthParams)
if auth_conf:
status, msg = SitesHelper().check_user(**auth_conf)
else:
status, msg = SitesHelper().check_user()
if status:
self._auth_count = 0
logger.info(f"{msg} 用户认证成功")
self._scheduler_services().post_message(
Message(
mtype=MessageType.Manual,
title="MoviePilot用户认证成功",
text=f"使用站点:{msg},如有插件使用异常,请重启MoviePilot。",
link=config.site_link,
)
)
# 认证通过后重新初始化插件
get_plugin_manager().init_config()
self.init_plugin_jobs()
self._auth_plugin_routes_pending = True
register_plugin_api()
self._auth_plugin_routes_pending = False
else:
self._auth_count += 1
logger.error(f"用户认证失败,{msg},共失败 {self._auth_count}")
if self._auth_count >= __max_try__:
logger.error("用户认证失败次数过多,将不再尝试认证!")
+177
View File
@@ -0,0 +1,177 @@
"""Scheduler 执行 generation、预约与异步句柄的唯一状态 owner。"""
import asyncio
import concurrent.futures
import threading
from dataclasses import dataclass
from typing import Any, Optional, Union
_FutureHandle = Union[
asyncio.Future[Any],
concurrent.futures.Future[Any],
]
@dataclass(slots=True)
class SchedulerHandle:
"""记录 Scheduler 投递的执行句柄及其真实完成信号。"""
job_id: str
generation: int
loop: asyncio.AbstractEventLoop
handle: _FutureHandle
completion: _FutureHandle
kind: str
class ExecutionRegistry:
"""原子维护任务 generation、运行所有权、预约和异步句柄。"""
def __init__(self, lock: Optional[Any] = None) -> None:
"""创建空 registry;可复用 Facade 的可重入锁形成统一临界区。"""
self._lock = lock if lock is not None else threading.RLock()
self._handles: dict[int, SchedulerHandle] = {}
self._generations: dict[str, int] = {}
self._active_generations: dict[str, set[int]] = {}
self._reservations: dict[str, int] = {}
def next_generation(self, job_id: str) -> int:
"""为指定任务原子分配单调递增的 generation。"""
with self._lock:
generation = self._generations.get(job_id, 0) + 1
self._generations[job_id] = generation
return generation
def assign_generation(self, job_id: str, job: dict[str, Any]) -> int:
"""分配 generation 并写入兼容 Facade 使用的任务状态。"""
with self._lock:
generation = self._generations.get(job_id, 0) + 1
self._generations[job_id] = generation
job["_generation"] = generation
return generation
def current_generation(self, job_id: str) -> int:
"""返回任务最近分配的 generation,尚未分配时返回零。"""
with self._lock:
return self._generations.get(job_id, 0)
def claim_generation(self, job_id: str, generation: int) -> bool:
"""在同 ID 无活跃任务时取得 generation 的唯一运行所有权。"""
with self._lock:
active = self._active_generations.get(job_id)
if active:
return False
self._active_generations[job_id] = {generation}
return True
def is_active(self, job_id: str) -> bool:
"""判断任一 generation 的同 ID 任务是否仍在执行。"""
with self._lock:
return bool(self._active_generations.get(job_id))
def active_generations(self, job_id: str) -> frozenset[int]:
"""返回指定任务当前活跃 generation 的不可变快照。"""
with self._lock:
return frozenset(self._active_generations.get(job_id, set()))
def release_generation(self, job_id: str, generation: int) -> bool:
"""释放匹配的运行所有权,返回本次是否实际移除 generation。"""
with self._lock:
active = self._active_generations.get(job_id)
if not active or generation not in active:
return False
active.remove(generation)
if not active:
self._active_generations.pop(job_id, None)
return True
def reserve(self, job_id: str, owner: int) -> bool:
"""为手动触发原子预约任务;已活跃或已预约时拒绝。"""
with self._lock:
if self._active_generations.get(job_id) or job_id in self._reservations:
return False
self._reservations[job_id] = owner
return True
def reservation_owner(self, job_id: str) -> Optional[int]:
"""返回当前预约线程标识,无预约时返回 None。"""
with self._lock:
return self._reservations.get(job_id)
def consume_reservation(self, job_id: str, owner: int) -> bool:
"""校验并消费调用方预约;无预约时允许普通调度继续。"""
with self._lock:
reserved_owner = self._reservations.get(job_id)
if reserved_owner is None:
return True
if reserved_owner != owner:
return False
self._reservations.pop(job_id, None)
return True
def release_reservation(self, job_id: str, owner: Optional[int] = None) -> bool:
"""释放预约;指定 owner 时不得清除其他调用方的预约。"""
with self._lock:
reserved_owner = self._reservations.get(job_id)
if reserved_owner is None or (owner is not None and reserved_owner != owner):
return False
self._reservations.pop(job_id, None)
return True
def register_handle(
self,
*,
job_id: str,
generation: int,
loop: asyncio.AbstractEventLoop,
handle: _FutureHandle,
completion: Optional[_FutureHandle] = None,
kind: str = "job",
) -> SchedulerHandle:
"""按真实完成信号登记 Scheduler 拥有的异步句柄。"""
if completion is None:
completion = handle
scheduler_handle = SchedulerHandle(
job_id=job_id,
generation=generation,
loop=loop,
handle=handle,
completion=completion,
kind=kind,
)
with self._lock:
self._handles[id(completion)] = scheduler_handle
return scheduler_handle
def remove_handle(self, completion: _FutureHandle) -> bool:
"""按真实完成信号摘除句柄,返回是否存在对应登记。"""
with self._lock:
return self._handles.pop(id(completion), None) is not None
def handles(
self,
*,
job_id: Optional[str] = None,
generation: Optional[int] = None,
kind: Optional[str] = None,
) -> tuple[SchedulerHandle, ...]:
"""按任务、generation 与用途返回当前句柄的稳定快照。"""
with self._lock:
return tuple(
handle
for handle in self._handles.values()
if (job_id is None or handle.job_id == job_id)
and (generation is None or handle.generation == generation)
and (kind is None or handle.kind == kind)
)
def stop_snapshot(self) -> tuple[SchedulerHandle, ...]:
"""清除尚未消费的预约并返回停止阶段需要收口的句柄快照。"""
with self._lock:
self._reservations.clear()
return tuple(self._handles.values())
def clear_reservations(self) -> None:
"""在热重载封闭提交入口时清除尚未消费的手动预约。"""
with self._lock:
self._reservations.clear()
+33
View File
@@ -0,0 +1,33 @@
"""调度器由启动组合根注入的业务能力。"""
from dataclasses import dataclass
from typing import Any, Callable, Iterable
from app.application.workflow import WorkflowSnapshot
from app.schemas.message import Message
JobCallable = Callable[..., Any]
@dataclass(frozen=True, slots=True)
class SchedulerServices:
"""保存任务目录和动态投影所需的已装配业务 callable。"""
sync_cookies: JobCallable
sync_mediaserver: JobCallable
check_subscribe: JobCallable
search_subscribe: JobCallable
refresh_subscribe: JobCallable
follow_subscribe: JobCallable
process_transfer: JobCallable
clear_cache: JobCallable
cleanup_data: JobCallable
run_modules: JobCallable
get_wallpapers: JobCallable
refresh_site_data: JobCallable
refresh_recommend: JobCallable
cache_subscribe_calendar: JobCallable
list_workflows: Callable[[], Iterable[WorkflowSnapshot]]
process_workflow: JobCallable
put_message: JobCallable
post_message: Callable[[Message], Any]
+26
View File
@@ -0,0 +1,26 @@
"""插件可使用的窄调度服务门面。"""
from app.application.scheduling import (
get_agent_task_next_run,
list_scheduler_jobs,
remove_agent_task_job,
remove_plugin_job,
start_agent_task,
start_scheduler_job,
update_agent_task_job,
update_plugin_job,
)
from app.schemas.dashboard import ScheduleInfo, ScheduleProgress
__all__ = [
"ScheduleInfo",
"ScheduleProgress",
"get_agent_task_next_run",
"list_scheduler_jobs",
"remove_agent_task_job",
"remove_plugin_job",
"start_agent_task",
"start_scheduler_job",
"update_agent_task_job",
"update_plugin_job",
]
+1 -1
View File
@@ -660,7 +660,7 @@ def get_host_event_handler_factories() -> dict[type, Callable[[], object]]:
from app.chain.subscribe.facade import SubscribeChain
from app.chain.workflow import WorkflowChain
from app.command import Command
from app.scheduler import Scheduler
from app.scheduler.facade import Scheduler
return {
Command: Command,
+51 -5
View File
@@ -1,12 +1,24 @@
"""调度器业务能力装配与生命周期入口。"""
import asyncio
from typing import Awaitable, Optional
from app.application.agenttask import AgentTaskRepository
from app.application.image import WallpaperHelper
from app.application.scheduling import (
get_scheduler,
register_scheduler_class,
reset_scheduler_class,
)
from app.scheduler import Scheduler
from app.chain.mediaserver import MediaServerChain
from app.chain.recommend import RecommendChain
from app.chain.site import SiteChain
from app.chain.subscribe.facade import SubscribeChain
from app.chain.transfer.facade import TransferChain
from app.chain.workflow import WorkflowChain
from app.scheduler.chain import SchedulerChain
from app.scheduler.facade import Scheduler
from app.scheduler.services import SchedulerServices
def configure_scheduler_runtime() -> None:
@@ -25,11 +37,45 @@ def configure_scheduler_agent_tasks(repository: AgentTaskRepository) -> None:
get_scheduler().configure_agent_tasks(repository)
def init_scheduler():
def configure_scheduler_services() -> None:
"""构造一次调度任务能力,并在启动前注入 concrete Scheduler。"""
scheduler_chain = SchedulerChain()
site_chain = SiteChain()
mediaserver_chain = MediaServerChain()
subscribe_chain = SubscribeChain()
transfer_chain = TransferChain()
recommend_chain = RecommendChain()
workflow_chain = WorkflowChain()
Scheduler().configure_services(
SchedulerServices(
sync_cookies=site_chain.sync_cookies,
sync_mediaserver=mediaserver_chain.sync,
check_subscribe=subscribe_chain.check,
search_subscribe=subscribe_chain.search,
refresh_subscribe=subscribe_chain.refresh,
follow_subscribe=subscribe_chain.follow,
process_transfer=transfer_chain.process,
clear_cache=scheduler_chain.clear_cache,
cleanup_data=scheduler_chain.cleanup,
run_modules=scheduler_chain.scheduler_job,
get_wallpapers=WallpaperHelper().get_wallpapers,
refresh_site_data=site_chain.refresh_userdatas,
refresh_recommend=recommend_chain.refresh_recommend,
cache_subscribe_calendar=subscribe_chain.cache_calendar,
list_workflows=workflow_chain.get_timer_workflows,
process_workflow=workflow_chain.process,
put_message=scheduler_chain.messagehelper.put,
post_message=scheduler_chain.post_message,
)
)
def init_scheduler() -> None:
"""
初始化定时器
"""
configure_scheduler_runtime()
configure_scheduler_services()
try:
Scheduler().init()
except Exception:
@@ -37,7 +83,7 @@ def init_scheduler():
raise
def stop_scheduler():
def stop_scheduler() -> Optional[Awaitable[None]]:
"""
停止定时器;生命周期事件循环中返回可等待的收口协程。
"""
@@ -61,7 +107,7 @@ def stop_scheduler():
return stop_and_reset()
def restart_scheduler():
def restart_scheduler() -> None:
"""
重启定时器
"""
@@ -69,7 +115,7 @@ def restart_scheduler():
Scheduler().init()
def init_plugin_scheduler():
def init_plugin_scheduler() -> None:
"""
初始化插件定时器
"""
+1 -1
View File
@@ -529,7 +529,7 @@ def build_lifecycle_components(app: FastAPI) -> tuple[LifecycleComponent, ...]:
dependencies=("插件",),
mode=LifecycleMode.NORMAL_ONLY,
start=init_scheduler,
stop=offload_shutdown_callback(stop_scheduler),
stop=stop_scheduler,
start_order=100,
stop_order=50,
start_timeout_seconds=120,
+16 -8
View File
@@ -17,8 +17,9 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
1. **边界有形、合同偏弱**:部分 Application Port 仍是 `Callable[[], Any]`,返回 ORM 或
动态代理;生产路径因此继续依赖无 Session Oper、全局 provider 和隐式构造。
2. **用例编排过度集中**Chain、Scheduler、Plugin、Agent、LLM 和部分 API 文件同时承担决策、
I/O、状态、生命周期与兼容职责,私有长方法又处于复杂度门禁盲区。
2. **用例编排过度集中**部分 Chain、Plugin、Agent、LLM 和 API 文件同时承担决策、I/O、
状态、生命周期与兼容职责;Scheduler 已完成生产代码拆包和综合验收,其他私有长方法仍可能
处于复杂度门禁盲区。
3. **可靠性声明强于实现**:整理 pending 目前只能做最小重放,尚不满足 ADR 中声明的 E3
状态机语义;部分 commit 后副作用仍存在“业务已提交但调用方收到失败”或进程退出后丢失的窗口。
4. **治理事实源不一致**:架构规则、总览、AST 基线和 CI 语义存在漂移;快照门禁能证明“没有变化”,
@@ -62,14 +63,14 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| `db/oper` | Model/Oper 已不自建 Session、不自行提交 | 无 Session Facade 仍被宿主组合根注入,业务操作可能拆成多个事务 |
| `db/adapters` | 已有显式 Session/UoW 的参考切片 | Port 返回类型不够稳定;Outbox 的 stage 与自提交 dispatcher store 混在一个类型 |
| `startup` | 已是 HostRuntime 和生命周期组合根 | `initializers/modules.py` 高扇出,仍有导入期 provider 注册和具体对象目录装配 |
| API/Command/Scheduler | 多数入口已转向 Chain/Application | Agent/System/Plugin 等入口仍承担较多业务与 I/O 编排 |
| API/Command/Scheduler | 多数入口已转向 Chain/Application;Scheduler 单体已拆为同名职责包并由 startup 注入业务 callable | Agent/System/Plugin 等入口仍承担较多业务与 I/O 编排 |
| `sdk`/`compat` | 精确映射、稳定插件 ABI 和宿主 canonical 路径已落地 | SDK 仍暴露部分可变全局对象/具体 Manager,只能渐进收窄,不能直接删除 |
### 3.2 量化快照
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 893 / 7,532 | `dependency-baseline.json` 当前快照 |
| 宿主 Python 模块 / 内部依赖边 | 906 / 7,612 | `dependency-baseline.json` 当前快照 |
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
@@ -77,8 +78,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| Event Contract | 53 | 均已有 payload model,但当前全部是 diagnostic enforcement |
| Python 源码量 | 约 271,400 行 | 60 个文件超过 1,000 行,14 个超过 2,000 行 |
| 长方法 | 281 个超过 80 行 | 67 个超过 150 行,23 个超过 250 行;大量是私有方法 |
| 全量 mypy 历史债务 | 11,734 / 596 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
| Ruff 历史诊断 | 706 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 全量 mypy 历史债务 | 11,179 / 600 文件 | strict frontier 当前覆盖 41 个文件,本批迁移路径的类型债务已清零 |
| Ruff 历史诊断 | 700 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
| 覆盖率低水位 | Application 79.83%Domain 79.29% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
### 3.3 热点文件
@@ -94,7 +95,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| `app/api/endpoints/agent.py` | 2,346 | HTTP/SSE、文件/音频、Agent 会话和事件编排 |
| `app/chain/download.py` | 2,230 | 选择、提交、历史、通知、模块后处理和批量执行 |
| `app/chain/media.py` | 2,191 | 识别、来源投影、缓存、音乐匹配和兼容入口 |
| `app/scheduler.py` | 2,111 | 作业目录、执行状态、恢复、生命周期和领域任务 |
| `app/scheduler/` | Facade 128 行 | 原 2,111 行单体已退役;catalog、execution、bridge、progress、registry、reconcile、lifecycle、maintenance 与注入合同已有独立 owner |
热点不是按行数机械拆文件的依据。只有在提取出稳定合同、保留旧入口委托并有行为测试时,拆分才算
降低复杂度;把长方法原样移动到新目录不算完成。
@@ -114,7 +115,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
| ARCH-106 | P1 | 进行中 | 让线程/队列/日志 writer 由 bootstrap/lifecycle 显式构造 | 日志与消息 owner 已收口;GlobalVar/provider 继续治理 |
| ARCH-107 | P1 | 已验证 | 消除 Chain SCC,强化循环门禁 | SCC 只剩精确豁免的 TMDB 移植包环 |
| ARCH-108 | P1 | 执行中 | 决策并收口 Application/Chain 到 Adapter 与 HTTP 边界 | Application Adapter/DNS 债务已清零;Chain Adapter 与宿主 HTTP 债务继续迁移 |
| ARCH-109 | P1 | 执行 | 按用例拆分超大 Chain、Scheduler 和厚 API | 稳定 Facade 保留,决策/I/O/状态/生命周期各有 owner |
| ARCH-109 | P1 | 执行 | 按用例拆分超大 Chain、Scheduler 和厚 API | Transfer、Subscribe、Scheduler 已验证;Download/Search 与厚 API 尚待执行 |
| ARCH-110 | P1 | 待执行 | Module/Event Contract 分可信级执行 | 宿主 provider 严格,第三方插件仍兼容诊断 |
| ARCH-111 | P1 | 待执行 | 升级复杂度、类型、覆盖率和并发原语门禁 | 高风险私有路径也进入只降不增的治理面 |
| ARCH-201 | P2 | 渐进 | 收窄 PluginHelper/PluginManager 与 SDK 暴露面 | ABI Facade 只委托,构造和具体服务归组合根 |
@@ -551,6 +552,13 @@ Chain 构造点;队列/恢复、规划、执行、结算、历史/通知、请
分别进入同名 package 的单词文件。包根只保留 `TransferChain` 与插件已使用的 `task_lock` 身份,
不重复导出 `JobManager`、durable runner 或内部 owner;旧 `transfer.py``_transfer.py` 均已删除。
`Scheduler` 切片已完成验证:旧 `app/scheduler.py` 已删除,稳定 Facade 与
`SchedulerChain` 兼容类型由 `app.scheduler` 包根惰性导出;catalog、执行、事件循环桥接、进度、
`ExecutionRegistry`、领域 reconcile、生命周期和维护任务分别位于同名 package 的单词文件。
`app/startup/initializers/scheduler.py` 统一构造 Chain 与 `SchedulerServices`Scheduler 包内不再无参
构造业务 Chain;新插件使用 `app.sdk.scheduler` 的窄函数门面,内部 owner 不进入 SDK 或包根 ABI。
功能、生命周期、架构、兼容和文档批次均通过,官方插件基线语义未变化。
### ARCH-110 分可信级执行 Module/Event Contract
**问题与证据**
+13 -3
View File
@@ -73,7 +73,7 @@ flowchart TB
AgentPkg["app/agent<br/>AI Agent 运行时"]
Monitor["app/monitor<br/>目录监控"]
Workflow["app/workflow<br/>工作流"]
Scheduler["app/scheduler<br/>定时任务"]
Scheduler["app/scheduler/<br/>定时任务职责包"]
CLI["app/cli<br/>命令行"]
PluginPkg["插件运行时目录<br/>app/plugins/*(副本/覆盖层)"]
end
@@ -283,6 +283,14 @@ sequenceDiagram
`HostRuntime.settings` 的窄服务读写可变部署设置,业务域不接触 Settings 实例;生产与测试组合根统一
复用 `startup/composition/configuration.py` 的映射。`ApiDataPorts` 仅保留旧导入 ABI,不参与正式请求链路。
- **安全模式**`MOVIEPILOT_SAFE_MODE` 会跳过插件、定时器、监控器、命令与工作流,用于故障自救。
- **Scheduler 同名职责包**:旧 `app/scheduler.py` 单体已退役;`catalog.py` 负责作业目录和计划投影,
`execution.py``bridge.py``progress.py` 分别负责执行、跨循环句柄和进度终态,`registry.py`
唯一持有 generation、active generation、reservation 与 handle`reconcile.py``lifecycle.py`
分别负责动态任务协调与启动/重载/关闭。
- **Scheduler 显式装配**`startup/initializers/scheduler.py` 构造业务 Chain 一次,将绑定 callable
组成 frozen `SchedulerServices` 后注入 SchedulerScheduler 包内不再构造业务 Chain。
`app.scheduler` 包根只惰性保留 `Scheduler`/`SchedulerChain` 旧 ABI,新插件经 `app.sdk.scheduler`
使用窄调度服务,内部 owner 不重复导出。
- **进程拓扑**:全功能 V3 强制 `API_WORKERS=1`,避免每个 worker 重复启动插件和后台控制面;安全模式可临时使用多 worker 诊断,但不是正式扩容方案。
- **健康语义**`/health/live` 只确认进程和事件循环可响应;`/health/ready` 仅在数据库
到达当前 head 且生命周期完成后返回 200,启动失败或关停阶段返回 503。两者不公开路径、
@@ -714,8 +722,8 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 893 |
| 内部导入边 | 7,532 |
| Python 模块 | 906 |
| 内部导入边 | 7,612 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 55(债务已清零,55 条精确 containment |
@@ -748,6 +756,8 @@ flowchart LR
订阅关键副作用经 `app/application/outbox.py``app/db/adapters/outbox.py` 进入同事务 Outbox
搜索逐页任务、Agent/消息事件和插件市场子任务均遵守请求或生命周期 owner,不再由入口模块维护
无法追踪的裸任务集合。
- Scheduler 已从单体迁入 `app.scheduler` 同名职责包,startup 是业务 callable 的唯一构造与注入边界;
功能、生命周期、架构、兼容、文档与官方插件基线均已完成独立验证。
- 判断是否需要新增 manifest 映射的标准:只有当旧物理模块被删除、改名或公开符号迁移时才登记;
物理文件仍是稳定入口的,不应为了目录规整新增“自己映射自己”的别名,也不应在 canonical 包中
保留多余导出。
+2 -2
View File
@@ -136,7 +136,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
|---|---|---|---|
| S3-L1 TransferChain | `VERIFIED` | S1-L1 | 旧 `transfer.py`/`_transfer.py` 单体退役,由 `app.chain.transfer` 同名职责包承接;queue/recovery、plan、execute、settle、history/notify 各有唯一 owner,原 836 行执行方法消失,包根只保留稳定 ABI |
| S3-L2 SubscribeChain | `VERIFIED` | S1-L5 | 旧 `subscribe.py` 单体退役,由 `app.chain.subscribe` 同名职责包承接;search、match、refresh、completion、reference reconciliation、notification 各有唯一 owner,包根只保留稳定 `SubscribeChain` |
| S3-L3 Scheduler | `PLANNED` | S2-L3 | JobCatalog、ExecutionRegistry、reconciler、lifecycle 分离,无参 Chain 构造清零 |
| S3-L3 Scheduler | `VERIFIED` | S2-L3 | `app/scheduler.py` 已退役并迁入 `app.scheduler` 同名包;catalog、execution/bridge/progress、`ExecutionRegistry`、reconciler、lifecycle 与 maintenance 已分离,业务 callable 由 startup 经 `SchedulerServices` 注入,包内无参 Chain 构造清零;功能/生命周期 215 项、架构/兼容/文档 189 项通过,官方插件基线语义未变化 |
| S3-L4 DownloadChain | `PLANNED` | S1-L6 | selection、submission、history、post-processing 分离,提交后动作遵守新完成语义 |
| S3-L5 SearchChain | `PLANNED` | S2-L7 | plan、provider fan-out、result state、pagination 分离,状态 owner 唯一 |
| S3-L6 MediaChain | `PLANNED` | S2-L2 | recognition、source projection、music alignment、cache 分离,兼容仅经统一层 |
@@ -153,7 +153,7 @@ canonical 主程序;兼容只经统一 Compat/SDK 门面提供。
| S4-L2 Event strict contract | `PLANNED` | S0-L2.6,S1-L6 | 宿主事件输入/输出按风险 strict,诊断例外只属于第三方插件兼容 |
| S4-L3 Complexity v2 | `PLANNED` | S3 | 私有方法、class/file、圈复杂度进入门禁;所有超限通过职责拆分归零 |
| S4-L4 全量 mypy 清零 | `PLANNED` | S3,S4-L1,S4-L2 | `mypy-baseline.json` 归零并删除债务接受路径,全宿主 strict 类型通过 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 706 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L5 Ruff 治理债务清零 | `PLANNED` | S3 | 当前受控 700 条诊断归零,规则集扩展经过独立审查且新增诊断为零 |
| S4-L6 Coverage/并发/质量证据 | `PLANNED` | S3,S4-L1,S4-L2 | 高风险包纳入 coverageraw concurrency 分类清零;Module Quality 有真实 evidence test |
### S5Plugin、Agent、Domain、Startup 与最终收口
+17
View File
@@ -125,6 +125,20 @@ API, Scheduler and Chain deployment values are exposed as frozen snapshots from
`HostRuntime.configuration`; canonical callers must not add a fresh direct
`settings` import when the required field belongs to an existing snapshot.
The historical `app/scheduler.py` monolith must not be recreated. Scheduler
implementation lives in the same-named `app/scheduler/` package: `catalog.py`
owns built-in job declarations and APScheduler projection, `execution.py`,
`bridge.py` and `progress.py` own execution and completion mechanics,
`registry.py` is the sole owner of generations, active ownership, reservations
and handles, `reconcile.py` owns dynamic Agent/Workflow/plugin job projection,
and `lifecycle.py` owns init, reload and shutdown. Business Chain instances are
constructed only by `app/startup/initializers/scheduler.py` and injected as the
frozen callable-only `SchedulerServices`; files under `app/scheduler/` must not
construct a business Chain. The `app.scheduler` package root lazily preserves
only the historical `Scheduler` and `SchedulerChain` identities. New plugins use
the narrow `app.sdk.scheduler` facade; internal Scheduler owners must not be
re-exported from the package root, SDK or Compat.
`app.schemas` and the `app.db` package root are compatibility facades, not
implementation dependency hubs. Host code imports concrete schema submodules; the schema root
resolves its generated export manifest lazily for plugins and legacy callers.
@@ -768,6 +782,9 @@ driven workflow registration.
| `app/application/transfer/workflow.py` | Transfer task, durable admission, versioned planning input/checkpoint contracts and queue use case |
| `app/db/adapters/transfer/admission.py` | SQLAlchemy admission/checkpoint persistence, CAS state transition and detached snapshot adapter |
| `app/application/scheduling.py` | Runtime scheduler facade for Agent tools and endpoints; `Scheduler` class registered by `app/startup/initializers/scheduler.py` |
| `app/scheduler/` | Scheduler implementation package: stable facade plus catalog, execution/bridge/progress, registry, reconcile, lifecycle and maintenance owners; no business Chain construction |
| `app/startup/initializers/scheduler.py` | Registers the concrete Scheduler and injects the AgentTask repository plus frozen callable-only `SchedulerServices` before lifecycle start |
| `app/sdk/scheduler.py` | Narrow scheduling facade for new plugins; does not expose concrete Scheduler owners or lifecycle state |
| `app/application/commands.py` | Command registry facade for Agent tools and endpoints; `Command` class registered by `app/startup/initializers/command.py` |
| `app/application/workflow.py` | Workflow use cases, frozen query snapshot and typed runtime ports consumed by API, Agent, Chain and Scheduler; `WorkflowManager` is registered by `app/startup/initializers/workflow.py` |
| `app/db/adapters/workflow.py` | Short-session Workflow query projection and execution-state transaction adapters |
+1 -1
View File
@@ -32,7 +32,7 @@ SCAN_ROOTS = (
"app/command.py",
"app/factory.py",
"app/main.py",
"app/scheduler.py",
"app/scheduler",
)
_SYNC_HTTP_METHODS = {
+4
View File
@@ -34,6 +34,10 @@ MYPY_PATH_MIGRATIONS = {
("app/chain/transfer.py", "app/chain/_transfer.py"),
("app/chain/transfer/",),
),
"scheduler-package": (
("app/scheduler.py",),
("app/scheduler/",),
),
}
# 形如 app/foo.py:12: error: 消息说明 [error-code];个别错误可能缺代码。
+1 -2
View File
@@ -33,10 +33,9 @@ class RuntimeFacadePolicy:
RUNTIME_FACADE_POLICIES = (
RuntimeFacadePolicy(
name="scheduler",
dependency="app.scheduler",
dependency="app.scheduler.facade",
exact_consumers=frozenset(
{
"app.scheduler",
"app.startup.initializers.modules",
"app.startup.initializers.scheduler",
}
+1 -1
View File
@@ -436,7 +436,7 @@ def configure_plugin_system_services():
configure_agent_chat_persistence(agent_chat_persistence)
configure_agent_chat_service(agent_chat_service)
from app.agent.tools.manager import moviepilot_tool_manager
from app.scheduler import Scheduler
from app.scheduler.facade import Scheduler
moviepilot_tool_manager.set_data_context(agent_data_context)
Scheduler().configure_agent_tasks(agent_task_repository)
+145 -52
View File
@@ -1119,8 +1119,8 @@
"runtime_only": true
}
},
"edge_count": 7532,
"edge_sha256": "ae6eff386c809faeb9b9ba2d61d0d66bdf8389a9fe977389a52923fc93ad8d12",
"edge_count": 7612,
"edge_sha256": "15b211a9bc9400fa8a28480223372039344ae8e584bd1ac769359a808e4610be",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -7732,55 +7732,117 @@
"app.runtime.version -> app.runtime",
"app.runtime.version -> app.runtime.log",
"app.runtime.version -> app.runtime.settings",
"app.scheduler -> app.adapters",
"app.scheduler -> app.adapters.external",
"app.scheduler -> app.adapters.external.server",
"app.scheduler -> app.adapters.system",
"app.scheduler -> app.adapters.system.update",
"app.scheduler -> app.application",
"app.scheduler -> app.application.agent",
"app.scheduler -> app.application.agenttask",
"app.scheduler -> app.application.configuration",
"app.scheduler -> app.application.database",
"app.scheduler -> app.application.image",
"app.scheduler -> app.application.mediaserver",
"app.scheduler -> app.application.messaging",
"app.scheduler -> app.application.messaging.message",
"app.scheduler -> app.application.outbox",
"app.scheduler -> app.application.plugin",
"app.scheduler -> app.application.plugin.routes",
"app.scheduler -> app.application.plugin.runtime",
"app.scheduler -> app.application.scheduling",
"app.scheduler -> app.application.site",
"app.scheduler -> app.application.workflow",
"app.scheduler -> app.chain",
"app.scheduler -> app.chain.base",
"app.scheduler -> app.chain.mediaserver",
"app.scheduler -> app.chain.recommend",
"app.scheduler -> app.chain.site",
"app.scheduler -> app.chain.subscribe",
"app.scheduler -> app.chain.subscribe.facade",
"app.scheduler -> app.chain.transfer",
"app.scheduler -> app.chain.transfer.facade",
"app.scheduler -> app.chain.workflow",
"app.scheduler -> app.foundation",
"app.scheduler -> app.foundation.singleton",
"app.scheduler -> app.runtime",
"app.scheduler -> app.runtime.correlation",
"app.scheduler -> app.runtime.events",
"app.scheduler -> app.runtime.gc",
"app.scheduler -> app.runtime.log",
"app.scheduler -> app.runtime.loop",
"app.scheduler -> app.runtime.observability",
"app.scheduler -> app.runtime.progress",
"app.scheduler -> app.runtime.reload",
"app.scheduler -> app.runtime.scheduling",
"app.scheduler -> app.runtime.stop",
"app.scheduler -> app.schemas",
"app.scheduler -> app.schemas.dashboard",
"app.scheduler -> app.schemas.message",
"app.scheduler -> app.schemas.system",
"app.scheduler -> app.schemas.types",
"app.scheduler.bridge -> app.runtime",
"app.scheduler.bridge -> app.runtime.loop",
"app.scheduler.bridge -> app.scheduler",
"app.scheduler.bridge -> app.scheduler.contract",
"app.scheduler.bridge -> app.scheduler.registry",
"app.scheduler.catalog -> app.adapters",
"app.scheduler.catalog -> app.adapters.external",
"app.scheduler.catalog -> app.adapters.external.server",
"app.scheduler.catalog -> app.adapters.system",
"app.scheduler.catalog -> app.adapters.system.update",
"app.scheduler.catalog -> app.application",
"app.scheduler.catalog -> app.application.configuration",
"app.scheduler.catalog -> app.application.mediaserver",
"app.scheduler.catalog -> app.application.outbox",
"app.scheduler.catalog -> app.application.plugin",
"app.scheduler.catalog -> app.application.plugin.runtime",
"app.scheduler.catalog -> app.application.scheduling",
"app.scheduler.catalog -> app.runtime",
"app.scheduler.catalog -> app.runtime.scheduling",
"app.scheduler.catalog -> app.scheduler",
"app.scheduler.catalog -> app.scheduler.contract",
"app.scheduler.catalog -> app.schemas",
"app.scheduler.catalog -> app.schemas.system",
"app.scheduler.chain -> app.application",
"app.scheduler.chain -> app.application.database",
"app.scheduler.chain -> app.chain",
"app.scheduler.chain -> app.chain.base",
"app.scheduler.execution -> app.application",
"app.scheduler.execution -> app.application.messaging",
"app.scheduler.execution -> app.application.messaging.message",
"app.scheduler.execution -> app.application.scheduling",
"app.scheduler.execution -> app.runtime",
"app.scheduler.execution -> app.runtime.correlation",
"app.scheduler.execution -> app.runtime.events",
"app.scheduler.execution -> app.runtime.log",
"app.scheduler.execution -> app.runtime.loop",
"app.scheduler.execution -> app.runtime.observability",
"app.scheduler.execution -> app.runtime.progress",
"app.scheduler.execution -> app.runtime.scheduling",
"app.scheduler.execution -> app.scheduler",
"app.scheduler.execution -> app.scheduler.contract",
"app.scheduler.execution -> app.schemas",
"app.scheduler.execution -> app.schemas.dashboard",
"app.scheduler.execution -> app.schemas.types",
"app.scheduler.facade -> app.application",
"app.scheduler.facade -> app.application.agenttask",
"app.scheduler.facade -> app.foundation",
"app.scheduler.facade -> app.foundation.singleton",
"app.scheduler.facade -> app.runtime",
"app.scheduler.facade -> app.runtime.events",
"app.scheduler.facade -> app.runtime.reload",
"app.scheduler.facade -> app.scheduler",
"app.scheduler.facade -> app.scheduler.bridge",
"app.scheduler.facade -> app.scheduler.catalog",
"app.scheduler.facade -> app.scheduler.execution",
"app.scheduler.facade -> app.scheduler.lifecycle",
"app.scheduler.facade -> app.scheduler.maintenance",
"app.scheduler.facade -> app.scheduler.progress",
"app.scheduler.facade -> app.scheduler.reconcile",
"app.scheduler.facade -> app.scheduler.registry",
"app.scheduler.facade -> app.scheduler.services",
"app.scheduler.facade -> app.schemas",
"app.scheduler.facade -> app.schemas.types",
"app.scheduler.lifecycle -> app.application",
"app.scheduler.lifecycle -> app.application.configuration",
"app.scheduler.lifecycle -> app.runtime",
"app.scheduler.lifecycle -> app.runtime.log",
"app.scheduler.lifecycle -> app.runtime.stop",
"app.scheduler.lifecycle -> app.scheduler",
"app.scheduler.lifecycle -> app.scheduler.contract",
"app.scheduler.lifecycle -> app.scheduler.registry",
"app.scheduler.maintenance -> app.application",
"app.scheduler.maintenance -> app.application.agent",
"app.scheduler.maintenance -> app.application.backup",
"app.scheduler.maintenance -> app.application.database",
"app.scheduler.maintenance -> app.runtime",
"app.scheduler.maintenance -> app.runtime.gc",
"app.scheduler.maintenance -> app.runtime.log",
"app.scheduler.maintenance -> app.scheduler",
"app.scheduler.maintenance -> app.scheduler.contract",
"app.scheduler.progress -> app.application",
"app.scheduler.progress -> app.application.scheduling",
"app.scheduler.progress -> app.runtime",
"app.scheduler.progress -> app.runtime.observability",
"app.scheduler.progress -> app.runtime.progress",
"app.scheduler.progress -> app.scheduler",
"app.scheduler.progress -> app.scheduler.contract",
"app.scheduler.progress -> app.schemas",
"app.scheduler.progress -> app.schemas.dashboard",
"app.scheduler.reconcile -> app.application",
"app.scheduler.reconcile -> app.application.agent",
"app.scheduler.reconcile -> app.application.agenttask",
"app.scheduler.reconcile -> app.application.configuration",
"app.scheduler.reconcile -> app.application.plugin",
"app.scheduler.reconcile -> app.application.plugin.routes",
"app.scheduler.reconcile -> app.application.plugin.runtime",
"app.scheduler.reconcile -> app.application.scheduling",
"app.scheduler.reconcile -> app.application.site",
"app.scheduler.reconcile -> app.application.workflow",
"app.scheduler.reconcile -> app.runtime",
"app.scheduler.reconcile -> app.runtime.log",
"app.scheduler.reconcile -> app.runtime.scheduling",
"app.scheduler.reconcile -> app.scheduler",
"app.scheduler.reconcile -> app.scheduler.contract",
"app.scheduler.reconcile -> app.schemas",
"app.scheduler.reconcile -> app.schemas.message",
"app.scheduler.reconcile -> app.schemas.types",
"app.scheduler.services -> app.application",
"app.scheduler.services -> app.application.workflow",
"app.scheduler.services -> app.schemas",
"app.scheduler.services -> app.schemas.message",
"app.schemas -> app.schemas.exports",
"app.schemas.agent -> app.schemas",
"app.schemas.agent -> app.schemas.common",
@@ -7978,6 +8040,10 @@
"app.sdk.queries -> app.application.query",
"app.sdk.queries -> app.schemas",
"app.sdk.queries -> app.schemas.query",
"app.sdk.scheduler -> app.application",
"app.sdk.scheduler -> app.application.scheduling",
"app.sdk.scheduler -> app.schemas",
"app.sdk.scheduler -> app.schemas.dashboard",
"app.sdk.security -> app.adapters",
"app.sdk.security -> app.adapters.web",
"app.sdk.security -> app.adapters.web.security",
@@ -8299,6 +8365,7 @@
"app.startup.initializers.modules -> app.runtime.tasks",
"app.startup.initializers.modules -> app.runtime.thread",
"app.startup.initializers.modules -> app.scheduler",
"app.startup.initializers.modules -> app.scheduler.facade",
"app.startup.initializers.modules -> app.schemas",
"app.startup.initializers.modules -> app.schemas.message",
"app.startup.initializers.modules -> app.schemas.types",
@@ -8393,8 +8460,21 @@
"app.startup.initializers.routers -> app.api.servcookie",
"app.startup.initializers.scheduler -> app.application",
"app.startup.initializers.scheduler -> app.application.agenttask",
"app.startup.initializers.scheduler -> app.application.image",
"app.startup.initializers.scheduler -> app.application.scheduling",
"app.startup.initializers.scheduler -> app.chain",
"app.startup.initializers.scheduler -> app.chain.mediaserver",
"app.startup.initializers.scheduler -> app.chain.recommend",
"app.startup.initializers.scheduler -> app.chain.site",
"app.startup.initializers.scheduler -> app.chain.subscribe",
"app.startup.initializers.scheduler -> app.chain.subscribe.facade",
"app.startup.initializers.scheduler -> app.chain.transfer",
"app.startup.initializers.scheduler -> app.chain.transfer.facade",
"app.startup.initializers.scheduler -> app.chain.workflow",
"app.startup.initializers.scheduler -> app.scheduler",
"app.startup.initializers.scheduler -> app.scheduler.chain",
"app.startup.initializers.scheduler -> app.scheduler.facade",
"app.startup.initializers.scheduler -> app.scheduler.services",
"app.startup.initializers.site -> app.adapters",
"app.startup.initializers.site -> app.adapters.external",
"app.startup.initializers.site -> app.adapters.external.cookiecloud",
@@ -8655,7 +8735,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 893,
"module_count": 906,
"modules": [
"app",
"app.adapters",
@@ -9440,6 +9520,18 @@
"app.runtime.version",
"app.runtime.webpush",
"app.scheduler",
"app.scheduler.bridge",
"app.scheduler.catalog",
"app.scheduler.chain",
"app.scheduler.contract",
"app.scheduler.execution",
"app.scheduler.facade",
"app.scheduler.lifecycle",
"app.scheduler.maintenance",
"app.scheduler.progress",
"app.scheduler.reconcile",
"app.scheduler.registry",
"app.scheduler.services",
"app.schemas",
"app.schemas.agent",
"app.schemas.cache",
@@ -9500,6 +9592,7 @@
"app.sdk.network",
"app.sdk.plugins",
"app.sdk.queries",
"app.sdk.scheduler",
"app.sdk.security",
"app.sdk.services",
"app.sdk.string",
+4 -22
View File
@@ -708,7 +708,6 @@
"app/agent/tools/impl/test_site.py": {
"misc": 1,
"no-any-return": 1,
"no-untyped-call": 1,
"no-untyped-def": 2,
"override": 1
},
@@ -775,7 +774,6 @@
"app/agent/tools/impl/update_site_cookie.py": {
"misc": 1,
"no-any-return": 1,
"no-untyped-call": 1,
"no-untyped-def": 2,
"override": 1
},
@@ -988,7 +986,7 @@
"assignment": 2,
"attr-defined": 3,
"misc": 26,
"no-untyped-call": 4,
"no-untyped-call": 1,
"no-untyped-def": 2,
"return-value": 3,
"type-arg": 6,
@@ -1389,7 +1387,7 @@
"arg-type": 21,
"assignment": 2,
"no-any-return": 11,
"no-untyped-call": 10,
"no-untyped-call": 8,
"no-untyped-def": 6,
"type-arg": 2,
"union-attr": 1,
@@ -1441,7 +1439,7 @@
"attr-defined": 1,
"misc": 6,
"no-any-return": 1,
"no-untyped-def": 8,
"no-untyped-def": 7,
"return-value": 2,
"type-arg": 5
},
@@ -1639,7 +1637,7 @@
"arg-type": 3,
"assignment": 5,
"misc": 6,
"no-untyped-call": 5,
"no-untyped-call": 4,
"no-untyped-def": 4,
"type-arg": 7,
"valid-type": 1,
@@ -3236,18 +3234,6 @@
"app/runtime/thread.py": {
"unused-ignore": 1
},
"app/scheduler.py": {
"arg-type": 7,
"attr-defined": 1,
"import-untyped": 1,
"misc": 1,
"no-any-return": 3,
"no-untyped-call": 6,
"no-untyped-def": 13,
"return-value": 1,
"type-arg": 7,
"union-attr": 1
},
"app/schemas/agent.py": {
"misc": 26
},
@@ -3418,10 +3404,6 @@
"app/startup/initializers/routers.py": {
"no-untyped-def": 1
},
"app/startup/initializers/scheduler.py": {
"no-untyped-call": 5,
"no-untyped-def": 4
},
"app/startup/initializers/workflow.py": {
"no-untyped-def": 1
},
-18
View File
@@ -975,15 +975,9 @@
"tests/test_dashboard_system_info.py": {
"I001": 1
},
"tests/test_data_cleanup_chain.py": {
"I001": 1
},
"tests/test_database_backup_cli_sdk.py": {
"I001": 1
},
"tests/test_database_backup_scheduler.py": {
"I001": 1
},
"tests/test_database_index_migration.py": {
"I001": 1
},
@@ -1143,9 +1137,6 @@
"tests/test_mediaserver_image_signing.py": {
"I001": 1
},
"tests/test_mediaserver_sync_scheduler.py": {
"I001": 1
},
"tests/test_message_channel_permissions.py": {
"I001": 1
},
@@ -1242,9 +1233,6 @@
"tests/test_plugin_lifecycle_status.py": {
"F401": 1
},
"tests/test_plugin_local_sync.py": {
"I001": 1
},
"tests/test_plugin_market_default.py": {
"I001": 1
},
@@ -1287,12 +1275,6 @@
"tests/test_rust_accel_toggle.py": {
"I001": 1
},
"tests/test_scheduler_cache_expiry.py": {
"I001": 1
},
"tests/test_scheduler_contracts.py": {
"I001": 1
},
"tests/test_search_ai_recommend.py": {
"E402": 8,
"I001": 1
+59 -7
View File
@@ -1433,12 +1433,12 @@
"registration_kind": "listener"
},
{
"caller": "app.scheduler",
"caller": "app.scheduler.facade",
"dynamic": false,
"events": [
"EventType.PluginReload"
],
"fingerprint": "055c11260c15adabe65ddf1c9fd39000a7c1b7e255848e3c028e3985731e0778",
"fingerprint": "8780e71625cff4efebb1e39c32cff3c8cfd29c3cdf5eb1922d5e0d240ebd477a",
"handler": "Scheduler.on_plugin_reload",
"invalid": false,
"method": "register",
@@ -1752,7 +1752,7 @@
},
"EventType.PluginReload": {
"consumer_fingerprints": [
"055c11260c15adabe65ddf1c9fd39000a7c1b7e255848e3c028e3985731e0778"
"8780e71625cff4efebb1e39c32cff3c8cfd29c3cdf5eb1922d5e0d240ebd477a"
],
"producer_fingerprints": []
},
@@ -1839,7 +1839,7 @@
"consumer_fingerprints": [],
"producer_fingerprints": [
"8b725b957d7699c989b20606ab2ed4df85768a16096eba922b9ddf1232f3a9fd",
"a812b122b031e0ed33df0300292fad84360bd09223de07f3d13d0bd3d1a59429",
"b11f2671831e389e6c6ce0ee2d47da41d4ff088376e85ae8350a46a3b48315b5",
"b80e0452e3c7ad08b2c5a83cd7bea4185e0ed3f3012d20f2f1688597e6cefe57",
"dcfe851d124af4d4c3781b65b0c0115934a9c2422ee20aca53d625dda3190289"
]
@@ -2786,15 +2786,15 @@
"receiver_kind": "canonical_singleton"
},
{
"caller": "app.scheduler",
"caller": "app.scheduler.execution",
"dynamic": false,
"events": [
"EventType.SystemError"
],
"fingerprint": "a812b122b031e0ed33df0300292fad84360bd09223de07f3d13d0bd3d1a59429",
"fingerprint": "b11f2671831e389e6c6ce0ee2d47da41d4ff088376e85ae8350a46a3b48315b5",
"invalid": false,
"method": "send_event",
"qualname": "Scheduler.__handle_job_error",
"qualname": "SchedulerExecutionOwner._handle_job_error",
"receiver_kind": "canonical_singleton"
},
{
@@ -10363,6 +10363,58 @@
"target": ""
}
],
"app.sdk.scheduler": [
{
"kind": "import",
"name": "ScheduleInfo",
"target": "app.schemas.dashboard.ScheduleInfo"
},
{
"kind": "import",
"name": "ScheduleProgress",
"target": "app.schemas.dashboard.ScheduleProgress"
},
{
"kind": "import",
"name": "get_agent_task_next_run",
"target": "app.application.scheduling.get_agent_task_next_run"
},
{
"kind": "import",
"name": "list_scheduler_jobs",
"target": "app.application.scheduling.list_scheduler_jobs"
},
{
"kind": "import",
"name": "remove_agent_task_job",
"target": "app.application.scheduling.remove_agent_task_job"
},
{
"kind": "import",
"name": "remove_plugin_job",
"target": "app.application.scheduling.remove_plugin_job"
},
{
"kind": "import",
"name": "start_agent_task",
"target": "app.application.scheduling.start_agent_task"
},
{
"kind": "import",
"name": "start_scheduler_job",
"target": "app.application.scheduling.start_scheduler_job"
},
{
"kind": "import",
"name": "update_agent_task_job",
"target": "app.application.scheduling.update_agent_task_job"
},
{
"kind": "import",
"name": "update_plugin_job",
"target": "app.application.scheduling.update_plugin_job"
}
],
"app.sdk.security": [
{
"kind": "import",
+3 -3
View File
@@ -245,7 +245,7 @@
"reason": "配置重载 Mixin 为声明式宿主类型安装统一刷新处理器。"
},
{
"caller": "app.scheduler",
"caller": "app.scheduler.facade",
"qualname": "Scheduler",
"method": "register",
"receiver_kind": "canonical_singleton",
@@ -257,9 +257,9 @@
"handler": "Scheduler.on_plugin_reload",
"registration_kind": "decorator",
"priority": "<default>",
"fingerprint": "055c11260c15adabe65ddf1c9fd39000a7c1b7e255848e3c028e3985731e0778",
"fingerprint": "8780e71625cff4efebb1e39c32cff3c8cfd29c3cdf5eb1922d5e0d240ebd477a",
"classification": "approved_static_registration",
"owner": "app.scheduler",
"owner": "app.scheduler.facade",
"reason": "插件重载后由 Scheduler 对齐插件提供的定时任务。"
},
{
+8 -16
View File
@@ -44,7 +44,8 @@ from app.db.models.agenttask import AgentTask
from app.db.oper.agenttask import AgentTaskOper
from app.runtime.config import settings
from app.runtime.scheduling import TimerUtils
from app.scheduler import Scheduler
from app.scheduler.facade import Scheduler
from app.scheduler.registry import ExecutionRegistry
from app.schemas import ScheduleInfo
@@ -135,10 +136,7 @@ def _build_agent_task_scheduler(reconcile: bool = False) -> Scheduler:
scheduler._jobs = {}
scheduler._scheduler = BackgroundScheduler(timezone=settings.TZ)
scheduler._lifecycle_state = "running"
scheduler._handles = {}
scheduler._job_generations = {}
scheduler._active_job_generations = {}
scheduler._agent_task_reservations = {}
scheduler._registry = ExecutionRegistry(scheduler._lock)
scheduler._agent_task_interruptions_reconciled = False
scheduler._agent_tasks = TransactionalAgentTaskRepository(SessionFactory)
if reconcile:
@@ -334,10 +332,7 @@ def test_scheduler_registers_and_removes_agent_task_job() -> None:
scheduler._jobs = {}
scheduler._scheduler = BackgroundScheduler(timezone=settings.TZ)
scheduler._lifecycle_state = "running"
scheduler._handles = {}
scheduler._job_generations = {}
scheduler._active_job_generations = {}
scheduler._agent_task_reservations = {}
scheduler._registry = ExecutionRegistry(scheduler._lock)
scheduler._agent_tasks = TransactionalAgentTaskRepository(SessionFactory)
next_run_at = scheduler.update_agent_task_job(task.id)
@@ -422,7 +417,7 @@ async def test_date_task_reload_job_is_removed_after_run_finishes(
release.set()
async def wait_until_released() -> None:
while scheduler._handles:
while scheduler._registry.handles():
await asyncio.sleep(0)
await asyncio.wait_for(wait_until_released(), timeout=1)
@@ -811,12 +806,12 @@ async def test_scheduler_config_reload_preserves_active_agent_task(
await scheduler.on_config_changed()
assert AgentTaskOper().get(task.id).last_status == "running"
assert scheduler._handles
assert scheduler._registry.handles()
release.set()
async def wait_until_released() -> None:
while scheduler._handles:
while scheduler._registry.handles():
await asyncio.sleep(0)
await asyncio.wait_for(wait_until_released(), timeout=1)
@@ -884,10 +879,7 @@ def test_scheduler_starts_registered_agent_task_without_waiting() -> None:
}
}
scheduler._lifecycle_state = "running"
scheduler._handles = {}
scheduler._job_generations = {}
scheduler._active_job_generations = {}
scheduler._agent_task_reservations = {}
scheduler._registry = ExecutionRegistry(scheduler._lock)
scheduler.start = Mock()
assert scheduler.start_agent_task(7) is True
+8 -2
View File
@@ -960,7 +960,13 @@ def test_transfer_admission_chain_context_field_is_typed():
def test_scheduler_does_not_depend_on_database_implementation():
"""Scheduler 只能消费应用端口,不得重新直连 app.db 实现。"""
dependencies = _build_module_graph().get("app.scheduler", set())
dependencies = set().union(
*(
module_dependencies
for module_name, module_dependencies in _build_module_graph().items()
if module_name == "app.scheduler" or module_name.startswith("app.scheduler.")
)
)
assert {
dependency
for dependency in dependencies
@@ -989,7 +995,7 @@ def test_canonical_service_config_consumers_use_application_directory():
APP_ROOT / "chain" / "_messaging.py",
APP_ROOT / "chain" / "mediaserver.py",
APP_ROOT / "api" / "endpoints" / "message.py",
APP_ROOT / "scheduler.py",
*(APP_ROOT / "scheduler").glob("*.py"),
APP_ROOT / "agent" / "llm" / "capability.py",
APP_ROOT / "agent" / "tools" / "base.py",
APP_ROOT / "agent" / "tools" / "impl" / "query_library_latest.py",
+1 -1
View File
@@ -52,7 +52,7 @@ def test_async_blocking_scan_covers_runtime_entrypoints() -> None:
"app/command.py",
"app/factory.py",
"app/main.py",
"app/scheduler.py",
"app/scheduler",
}.issubset({str(path) for path in SCAN_ROOTS})
+7 -7
View File
@@ -7,19 +7,19 @@ from unittest.mock import MagicMock, patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.application.database import DatabaseGovernance, configure_database_governance
from app.application.maintenance import (
DataCleanupService,
read_cleanup_policy,
)
from app.db import Base
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
from app.db.maintenance import DatabaseCleanupRepository
from app.db.models.downloadhistory import DownloadFiles, DownloadHistory
from app.db.models.message import Message
from app.db.models.siteuserdata import SiteUserData
from app.db.models.transferhistory import TransferHistory
from app.runtime.config import settings
from app.scheduler import SchedulerChain
from app.application.maintenance import (
DataCleanupService,
read_cleanup_policy,
)
from app.application.database import DatabaseGovernance, configure_database_governance
from app.db.maintenance import DatabaseCleanupRepository
class DataCleanupChainTest(unittest.TestCase):
+9 -4
View File
@@ -7,7 +7,8 @@ from unittest.mock import MagicMock, patch
import pytest
from app.application.maintenance import CleanupPolicy, DataCleanupService
from app.scheduler import SchedulerChain
from app.scheduler import chain as scheduler_chain
from app.scheduler.chain import SchedulerChain
class FakeCleanupRepository:
@@ -168,7 +169,7 @@ def test_scheduler_cleanup_is_a_compatibility_delegate() -> None:
governance.cleanup.return_value = {"enabled": True}
progress = MagicMock()
with patch("app.scheduler.get_database_governance", return_value=governance):
with patch.object(scheduler_chain, "get_database_governance", return_value=governance):
result = SchedulerChain().cleanup(batch_size=7, progress_callback=progress)
assert result == {"enabled": True}
@@ -180,10 +181,14 @@ def test_scheduler_cleanup_is_a_compatibility_delegate() -> None:
def test_scheduler_does_not_reclaim_database_cleanup_ownership() -> None:
"""调度模块不得重新导入清理模型或数据库会话。"""
scheduler_path = Path(__file__).parents[1] / "app" / "scheduler.py"
tree = ast.parse(scheduler_path.read_text(encoding="utf-8"))
scheduler_root = Path(__file__).parents[1] / "app" / "scheduler"
trees = [
ast.parse(path.read_text(encoding="utf-8"))
for path in scheduler_root.glob("*.py")
]
imports = {
node.module
for tree in trees
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom) and node.module
}
+10 -11
View File
@@ -6,9 +6,11 @@ from dataclasses import replace
from pathlib import Path
from unittest.mock import Mock
from app import scheduler as scheduler_module
from app.scheduler import Scheduler
from app.application.configuration import SchedulerRuntimeConfig
from app.scheduler import catalog as scheduler_catalog
from app.scheduler import maintenance as scheduler_maintenance
from app.scheduler.facade import Scheduler
from app.scheduler.registry import ExecutionRegistry
class _SchedulerStub:
@@ -30,10 +32,7 @@ def _scheduler() -> Scheduler:
scheduler._jobs = {}
scheduler._lock = threading.RLock()
scheduler._lifecycle_state = "running"
scheduler._handles = {}
scheduler._job_generations = {}
scheduler._active_job_generations = {}
scheduler._agent_task_reservations = {}
scheduler._registry = ExecutionRegistry(scheduler._lock)
return scheduler
@@ -93,7 +92,7 @@ def test_enabled_database_backup_without_cron_does_not_register_job() -> None:
def test_enabled_database_backup_registers_single_replaceable_job(monkeypatch) -> None:
scheduler = _scheduler()
trigger = object()
monkeypatch.setattr(scheduler_module.TimerUtils, "build_schedule_trigger", Mock(return_value=trigger))
monkeypatch.setattr(scheduler_catalog.TimerUtils, "build_schedule_trigger", Mock(return_value=trigger))
config = _config(db_backup_enable=True, db_backup_cron="0 3 * * *")
scheduler._register_database_backup_job(config)
@@ -105,7 +104,7 @@ def test_enabled_database_backup_registers_single_replaceable_job(monkeypatch) -
def test_scheduled_backup_uses_registered_database_governance(monkeypatch) -> None:
governance = Mock()
monkeypatch.setattr(scheduler_module, "get_database_governance", lambda: governance)
monkeypatch.setattr(scheduler_maintenance, "get_database_governance", lambda: governance)
result = Scheduler.database_backup()
@@ -114,11 +113,11 @@ def test_scheduled_backup_uses_registered_database_governance(monkeypatch) -> No
def test_scheduler_database_dependencies_are_explicit_module_imports() -> None:
tree = ast.parse(
(Path(__file__).parents[1] / "app" / "scheduler.py").read_text(encoding="utf-8")
)
scheduler_root = Path(__file__).parents[1] / "app" / "scheduler"
trees = [ast.parse(path.read_text(encoding="utf-8")) for path in scheduler_root.glob("*.py")]
function_imports = [
node
for tree in trees
for function in ast.walk(tree)
if isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef))
for node in ast.walk(function)
+23
View File
@@ -371,6 +371,29 @@ _ORDERED_SHUTDOWN_STEPS = (
)
def test_scheduler_shutdown_callback_is_awaited_on_lifecycle_loop(monkeypatch) -> None:
"""定时器关闭必须在生命周期主循环等待异步句柄收口。"""
awaited = False
async def stop_scheduler_async() -> None:
"""记录 manifest 回调返回的协程已被生命周期等待。"""
nonlocal awaited
awaited = True
monkeypatch.setattr(lifecycle, "stop_scheduler", stop_scheduler_async)
component = next(
item
for item in lifecycle.build_lifecycle_components(FastAPI())
if item.name == "定时器"
)
assert component.stop is stop_scheduler_async
assert asyncio.run(
lifecycle.run_shutdown_step("定时器", component.stop)
) is True
assert awaited is True
@pytest.mark.parametrize(
"failing_step",
(
+1 -1
View File
@@ -429,7 +429,7 @@ def test_scheduler_progress_texts_have_english_translations():
"""定时任务相关进度文案应有英文翻译,避免前端切英文后回退中文。"""
untranslated = []
progress_paths = [
Path("app/scheduler.py"),
*sorted(Path("app/scheduler").glob("*.py")),
Path("app/chain/torrents.py"),
Path("app/chain/mediaserver.py"),
Path("app/chain/site.py"),
+1 -1
View File
@@ -1,5 +1,5 @@
from app.scheduler.facade import Scheduler
from app.schemas.system import MediaServerConf
from app.scheduler import Scheduler
def test_build_mediaserver_sync_schedules_uses_server_interval_and_legacy_fallback():
+6 -7
View File
@@ -11,10 +11,12 @@ from watchfiles import Change
from app.adapters.external.market import PluginHelper
from app.foundation.singleton import Singleton
from app.runtime.events import Event, eventmanager
from app.runtime.extensions.plugin_manager import PluginManager
from app.runtime.extensions.plugin.paths import PluginPathResolver
from app.runtime.extensions.plugin.system import get_plugin_system
from app.scheduler import Scheduler
from app.runtime.extensions.plugin_manager import PluginManager
from app.scheduler import reconcile as scheduler_reconcile
from app.scheduler.facade import Scheduler
from app.scheduler.registry import ExecutionRegistry
from app.schemas.types import EventType, SystemConfigKey
@@ -188,10 +190,7 @@ def _build_scheduler_for_plugin_reload(jobs: dict, backend) -> Scheduler:
scheduler._jobs = jobs
scheduler._scheduler = backend
scheduler._lifecycle_state = "running"
scheduler._handles = {}
scheduler._job_generations = {}
scheduler._active_job_generations = {}
scheduler._agent_task_reservations = {}
scheduler._registry = ExecutionRegistry(scheduler._lock)
return scheduler
@@ -835,7 +834,7 @@ def test_plugin_reload_refreshes_scheduler_services_idempotently(monkeypatch):
}
]
plugin_manager.get_plugin_attr.return_value = "测试插件"
monkeypatch.setattr("app.scheduler.get_plugin_manager", lambda: plugin_manager)
monkeypatch.setattr(scheduler_reconcile, "get_plugin_manager", lambda: plugin_manager)
backend = _FakeSchedulerBackend(["DemoPlugin_old"])
scheduler = _build_scheduler_for_plugin_reload(
jobs={
+98
View File
@@ -0,0 +1,98 @@
"""Scheduler 同名包职责与兼容边界门禁。"""
import ast
from pathlib import Path
from app.scheduler import Scheduler, SchedulerChain
PROJECT_ROOT = Path(__file__).resolve().parents[1]
APP_ROOT = PROJECT_ROOT / "app"
SCHEDULER_ROOT = APP_ROOT / "scheduler"
def _tree(path: Path) -> ast.Module:
"""解析指定 Python 文件。"""
return ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
def test_scheduler_monolith_is_retired_and_package_owners_exist() -> None:
"""旧单体必须消失,路线图声明的职责 owner 必须各有文件。"""
assert not (APP_ROOT / "scheduler.py").exists()
assert {
"bridge.py",
"catalog.py",
"execution.py",
"facade.py",
"lifecycle.py",
"progress.py",
"reconcile.py",
"registry.py",
"services.py",
} <= {path.name for path in SCHEDULER_ROOT.glob("*.py")}
def test_scheduler_package_root_is_only_stable_legacy_abi() -> None:
"""包根只延迟公开迁移前的两个类型,不重复承载实现。"""
package_tree = _tree(SCHEDULER_ROOT / "__init__.py")
classes = [node.name for node in package_tree.body if isinstance(node, ast.ClassDef)]
assert classes == []
assert Scheduler.__module__ == "app.scheduler"
assert SchedulerChain.__module__ == "app.scheduler"
def test_scheduler_facade_remains_thin_and_composes_named_owners() -> None:
"""Facade 只组合职责 owner,不重新吸收业务实现。"""
facade_path = SCHEDULER_ROOT / "facade.py"
facade_tree = _tree(facade_path)
scheduler_class = next(
node
for node in facade_tree.body
if isinstance(node, ast.ClassDef) and node.name == "Scheduler"
)
methods = {
node.name
for node in scheduler_class.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
assert len(facade_path.read_text(encoding="utf-8").splitlines()) <= 160
assert methods == {
"__init__",
"_scheduler_services",
"configure_services",
"get_reload_name",
"on_plugin_reload",
}
def test_scheduler_package_does_not_construct_chains() -> None:
"""业务 Chain 只能由 startup 组合根构造后注入 Scheduler。"""
violations: list[str] = []
for path in SCHEDULER_ROOT.glob("*.py"):
for node in ast.walk(_tree(path)):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name):
continue
if node.func.id.endswith("Chain"):
violations.append(f"{path.name}:{node.lineno}:{node.func.id}")
assert violations == []
def test_host_code_does_not_import_scheduler_plugin_abi_root() -> None:
"""除兼容包自身外,宿主只能使用 application 门面或 concrete 子模块。"""
violations: list[str] = []
for path in APP_ROOT.rglob("*.py"):
if "plugins" in path.parts or path == SCHEDULER_ROOT / "__init__.py":
continue
for node in ast.walk(_tree(path)):
if isinstance(node, ast.ImportFrom) and node.module == "app.scheduler":
violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}")
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name == "app.scheduler":
violations.append(
f"{path.relative_to(PROJECT_ROOT)}:{node.lineno}"
)
assert violations == []
+24 -34
View File
@@ -4,9 +4,12 @@ from unittest.mock import AsyncMock, Mock
import pytest
from app import scheduler as scheduler_module
from app.scheduler import Scheduler
from app.application.configuration import SchedulerRuntimeConfig
from app.scheduler import catalog as scheduler_catalog
from app.scheduler import lifecycle as scheduler_lifecycle
from app.scheduler import reconcile as scheduler_reconcile
from app.scheduler.facade import Scheduler
from app.scheduler.registry import ExecutionRegistry
from app.startup.initializers import scheduler as scheduler_initializer
@@ -78,26 +81,15 @@ def test_scheduler_initializer_stop_awaits_in_running_loop(monkeypatch):
def test_clear_cache_is_manual_only(monkeypatch):
"""缓存清理任务应仅手动执行,不注册到调度器自动运行。"""
background_scheduler = _BackgroundSchedulerStub()
generic_chain = Mock()
for name in [
"MediaServerChain",
"RecommendChain",
"SchedulerChain",
"SiteChain",
"SubscribeChain",
"TransferChain",
"WallpaperHelper",
"WorkflowChain",
"get_plugin_manager",
]:
monkeypatch.setattr(scheduler_module, name, lambda: generic_chain)
services = Mock()
monkeypatch.setattr(scheduler_catalog, "get_plugin_manager", lambda: Mock())
monkeypatch.setattr(
scheduler_module,
scheduler_catalog,
"get_mediaserver_configs",
lambda **_kwargs: [],
)
monkeypatch.setattr(
scheduler_module,
scheduler_catalog,
"BackgroundScheduler",
lambda **kwargs: background_scheduler,
)
@@ -106,7 +98,7 @@ def test_clear_cache_is_manual_only(monkeypatch):
monkeypatch.setattr(Scheduler, "init_agent_task_jobs", lambda self: None)
monkeypatch.setattr(Scheduler, "init_plugin_jobs", lambda self: None)
monkeypatch.setattr(
scheduler_module,
scheduler_lifecycle,
"get_scheduler_runtime_config",
lambda: SchedulerRuntimeConfig(
False, "Asia/Shanghai", 1, False, "", 0, None, False, 24,
@@ -120,13 +112,11 @@ def test_clear_cache_is_manual_only(monkeypatch):
scheduler._lock = threading.RLock()
scheduler._jobs = {}
scheduler._lifecycle_state = "new"
scheduler._handles = {}
scheduler._job_generations = {}
scheduler._active_job_generations = {}
scheduler._agent_task_reservations = {}
scheduler._registry = ExecutionRegistry(scheduler._lock)
scheduler._agent_task_interruptions_reconciled = True
scheduler._auth_count = 0
scheduler._auth_message = False
scheduler._services = services
scheduler.init()
@@ -148,28 +138,28 @@ def test_user_auth_refreshes_plugin_routes_after_runtime_reinitialization(monkey
refresh_routes = Mock()
message_chain = Mock()
monkeypatch.setattr(
scheduler_module,
scheduler_reconcile,
"get_scheduler_runtime_config",
lambda: Mock(site_link="https://example.invalid"),
)
monkeypatch.setattr(
scheduler_module,
scheduler_reconcile,
"SitesHelper",
lambda: Mock(auth_level=0, check_user=Mock(return_value=(True, "demo"))),
)
monkeypatch.setattr(
scheduler_module,
scheduler_reconcile,
"get_configured_system_config",
lambda: Mock(get=Mock(return_value=None)),
)
monkeypatch.setattr(scheduler_module, "SchedulerChain", lambda: message_chain)
scheduler._services = Mock(post_message=message_chain.post_message)
monkeypatch.setattr(
scheduler_module,
scheduler_reconcile,
"get_plugin_manager",
lambda: plugin_manager,
)
monkeypatch.setattr(scheduler, "init_plugin_jobs", plugin_jobs)
monkeypatch.setattr(scheduler_module, "register_plugin_api", refresh_routes)
monkeypatch.setattr(scheduler_reconcile, "register_plugin_api", refresh_routes)
scheduler.user_auth()
@@ -191,24 +181,24 @@ def test_user_auth_retries_pending_plugin_route_projection(monkeypatch):
message_chain = Mock()
sites = Mock(auth_level=0, check_user=Mock(return_value=(True, "demo")))
monkeypatch.setattr(
scheduler_module,
scheduler_reconcile,
"get_scheduler_runtime_config",
lambda: Mock(site_link="https://example.invalid"),
)
monkeypatch.setattr(scheduler_module, "SitesHelper", lambda: sites)
monkeypatch.setattr(scheduler_reconcile, "SitesHelper", lambda: sites)
monkeypatch.setattr(
scheduler_module,
scheduler_reconcile,
"get_configured_system_config",
lambda: Mock(get=Mock(return_value=None)),
)
monkeypatch.setattr(scheduler_module, "SchedulerChain", lambda: message_chain)
scheduler._services = Mock(post_message=message_chain.post_message)
monkeypatch.setattr(
scheduler_module,
scheduler_reconcile,
"get_plugin_manager",
lambda: plugin_manager,
)
monkeypatch.setattr(scheduler, "init_plugin_jobs", plugin_jobs)
monkeypatch.setattr(scheduler_module, "register_plugin_api", refresh_routes)
monkeypatch.setattr(scheduler_reconcile, "register_plugin_api", refresh_routes)
with pytest.raises(RuntimeError, match="loop unavailable"):
scheduler.user_auth()
+40
View File
@@ -0,0 +1,40 @@
"""Scheduler 旧插件 ABI 与 concrete 单例身份测试。"""
import importlib
import inspect
from app.scheduler import Scheduler, SchedulerChain
from app.scheduler.facade import Scheduler as ConcreteScheduler
def test_scheduler_package_root_keeps_legacy_type_identity() -> None:
"""旧静态与动态导入必须解析为同一个 concrete 类型。"""
dynamic = getattr(importlib.import_module("app.scheduler"), "Scheduler")
assert Scheduler is ConcreteScheduler
assert dynamic is ConcreteScheduler
assert Scheduler.__module__ == "app.scheduler"
assert SchedulerChain.__module__ == "app.scheduler"
def test_scheduler_legacy_methods_keep_plugin_call_shapes() -> None:
"""旧插件使用的四个方法继续保留原调用参数形态。"""
start = inspect.signature(Scheduler.start)
remove = inspect.signature(Scheduler.remove_plugin_job)
assert list(start.parameters) == ["self", "job_id", "args", "kwargs"]
assert list(remove.parameters) == ["self", "pid", "job_id"]
assert remove.parameters["job_id"].default is None
assert callable(Scheduler.list)
assert callable(Scheduler.update_plugin_job)
def test_scheduler_legacy_constructor_reuses_single_state_owner() -> None:
"""兼容构造必须复用同一 concrete 单例和私有状态桥。"""
first = Scheduler()
second = Scheduler()
assert first is second
assert first._jobs is second._jobs
assert first._lock is second._lock
assert first._scheduler is second._scheduler
-1
View File
@@ -1,7 +1,6 @@
"""Scheduler 声明、catalog 与 execution state 测试。"""
import asyncio
from unittest.mock import MagicMock
import pytest
+88 -72
View File
@@ -5,12 +5,15 @@ import gc
import inspect
import threading
import warnings
from types import SimpleNamespace
import pytest
from app import scheduler as scheduler_module
from app.runtime.config import global_vars
from app.scheduler import Scheduler
from app.scheduler import bridge as scheduler_bridge
from app.scheduler import execution as scheduler_execution
from app.scheduler import progress as scheduler_progress
from app.scheduler.facade import Scheduler
from app.scheduler.registry import ExecutionRegistry
class _ProgressStub:
@@ -47,6 +50,26 @@ class _AsyncProgressStub:
"""记录终态但不访问外部缓存。"""
def _patch_progress(
monkeypatch: pytest.MonkeyPatch,
async_progress: type = _AsyncProgressStub,
) -> None:
"""替换执行启动与进度 owner 各自持有的缓存边界。"""
monkeypatch.setattr(scheduler_execution, "ProgressHelper", _ProgressStub)
monkeypatch.setattr(scheduler_progress, "ProgressHelper", _ProgressStub)
monkeypatch.setattr(scheduler_progress, "AsyncProgressHelper", async_progress)
def _patch_main_loop(
monkeypatch: pytest.MonkeyPatch,
loop: asyncio.AbstractEventLoop,
) -> None:
"""让执行与桥接 owner 共享同一个测试事件循环登记。"""
registry = SimpleNamespace(current=loop)
monkeypatch.setattr(scheduler_execution, "main_loop_registry", registry)
monkeypatch.setattr(scheduler_bridge, "main_loop_registry", registry)
def _scheduler(job_id: str, func) -> Scheduler:
"""构造已启动但不拥有 APScheduler 线程的实例。"""
scheduler = object.__new__(Scheduler)
@@ -63,10 +86,8 @@ def _scheduler(job_id: str, func) -> Scheduler:
}
}
scheduler._lifecycle_state = "running"
scheduler._handles = {}
scheduler._job_generations = {job_id: 1}
scheduler._active_job_generations = {}
scheduler._agent_task_reservations = {}
scheduler._registry = ExecutionRegistry(scheduler._lock)
scheduler._registry.assign_generation(job_id, scheduler._jobs[job_id])
return scheduler
@@ -91,20 +112,19 @@ async def test_stop_async_cancels_and_awaits_scheduler_owned_job(monkeypatch) ->
finally:
cleaned.set()
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
_patch_progress(monkeypatch)
scheduler = _scheduler("lifecycle-job", job)
assert scheduler.start("lifecycle-job") is True
await asyncio.wait_for(started.wait(), timeout=1)
assert scheduler._handles
assert scheduler._registry.handles()
await scheduler.stop_async()
assert cleaned.is_set()
assert scheduler._jobs["lifecycle-job"]["running"] is False
assert scheduler._jobs["lifecycle-job"]["last_error"] == "任务已取消"
assert scheduler._handles == {}
assert scheduler._registry.handles() == ()
assert scheduler._lifecycle_state == "stopped"
@@ -125,12 +145,7 @@ async def test_stop_during_final_progress_does_not_mark_completed_job_unsubmitte
async def job() -> None:
return None
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
monkeypatch.setattr(
scheduler_module,
"AsyncProgressHelper",
BlockingFinishProgress,
)
_patch_progress(monkeypatch, BlockingFinishProgress)
scheduler = _scheduler("final-progress-stop", job)
assert scheduler.start("final-progress-stop") is True
@@ -140,19 +155,18 @@ async def test_stop_during_final_progress_does_not_mark_completed_job_unsubmitte
assert scheduler._jobs["final-progress-stop"]["running"] is False
assert scheduler._jobs["final-progress-stop"]["last_error"] is None
assert scheduler._handles == {}
assert scheduler._active_job_generations == {}
assert scheduler._registry.handles() == ()
assert scheduler._registry.is_active("final-progress-stop") is False
monkeypatch.setattr(
scheduler_module,
"AsyncProgressHelper",
_AsyncProgressStub,
)
_patch_progress(monkeypatch)
scheduler._lifecycle_state = "running"
assert scheduler.start("final-progress-stop") is True
async def wait_until_finished() -> None:
while scheduler._handles or scheduler._active_job_generations:
while (
scheduler._registry.handles()
or scheduler._registry.is_active("final-progress-stop")
):
await asyncio.sleep(0)
await asyncio.wait_for(wait_until_finished(), timeout=1)
@@ -180,9 +194,8 @@ async def test_foreign_loop_submission_runs_on_main_loop_and_finishes_before_sto
await cleanup_release.wait()
raise
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", main_loop)
_patch_progress(monkeypatch)
_patch_main_loop(monkeypatch, main_loop)
scheduler = _scheduler("foreign-loop-job", job)
def submit_from_foreign_loop() -> bool:
@@ -204,18 +217,18 @@ async def test_foreign_loop_submission_runs_on_main_loop_and_finishes_before_sto
stop_task.cancel()
with pytest.raises(asyncio.CancelledError):
await stop_task
assert scheduler._handles
assert scheduler._registry.handles()
assert scheduler._lifecycle_state == "stopping"
cleanup_release.set()
async def wait_until_released() -> None:
while scheduler._handles:
while scheduler._registry.handles():
await asyncio.sleep(0)
await asyncio.wait_for(wait_until_released(), timeout=1)
await scheduler.stop_async()
assert scheduler._handles == {}
assert scheduler._registry.handles() == ()
assert scheduler._lifecycle_state == "stopped"
@@ -231,9 +244,8 @@ async def test_cross_thread_submission_is_registered_before_stop_snapshot(
async def job() -> None:
await asyncio.Event().wait()
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", main_loop)
_patch_progress(monkeypatch)
_patch_main_loop(monkeypatch, main_loop)
scheduler = _scheduler("atomic-submit", job)
register_handle = scheduler._register_handle
@@ -287,12 +299,12 @@ async def test_submit_to_loop_tracks_internal_progress_or_finish_tasks() -> None
)
await asyncio.wait_for(started.wait(), timeout=1)
assert len(scheduler._handles) == 1
assert len(scheduler._registry.handles()) == 1
await scheduler.stop_async()
assert cancelled.is_set()
assert scheduler._handles == {}
assert scheduler._registry.handles() == ()
@pytest.mark.anyio
@@ -331,9 +343,8 @@ async def test_sync_job_callback_and_finish_handles_are_owned(monkeypatch) -> No
async def end(self, **_kwargs) -> None:
pass
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", BlockingProgress)
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", asyncio.get_running_loop())
_patch_progress(monkeypatch, BlockingProgress)
_patch_main_loop(monkeypatch, asyncio.get_running_loop())
def job(progress_callback) -> None:
progress_callback(value=50)
@@ -343,13 +354,13 @@ async def test_sync_job_callback_and_finish_handles_are_owned(monkeypatch) -> No
await asyncio.wait_for(update_started.wait(), timeout=1)
await asyncio.sleep(0)
assert len(scheduler._handles) == 2
assert len(scheduler._registry.handles()) == 2
assert not finish_started.is_set()
await scheduler.stop_async()
assert cancelled == 1
assert scheduler._handles == {}
assert scheduler._registry.handles() == ()
@pytest.mark.anyio
@@ -364,10 +375,10 @@ async def test_stale_progress_cannot_update_replaced_job(monkeypatch) -> None:
async def update(self, **kwargs) -> None:
updates.append(kwargs)
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", RecordingProgress)
monkeypatch.setattr(scheduler_progress, "AsyncProgressHelper", RecordingProgress)
scheduler = _scheduler("generation-progress", lambda: None)
old_job = scheduler._jobs["generation-progress"]
callback = scheduler._Scheduler__build_progress_callback(
callback = scheduler._build_progress_callback(
"generation-progress",
old_job,
)
@@ -383,7 +394,7 @@ async def test_stale_progress_cannot_update_replaced_job(monkeypatch) -> None:
await asyncio.sleep(0)
assert updates == []
assert scheduler._handles == {}
assert scheduler._registry.handles() == ()
@pytest.mark.anyio
@@ -409,8 +420,7 @@ async def test_final_progress_waits_for_pending_update(monkeypatch) -> None:
async def job(progress_callback) -> None:
progress_callback(value=100, text="业务处理完成")
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", BlockingProgress)
_patch_progress(monkeypatch, BlockingProgress)
scheduler = _scheduler("progress-order", job)
assert scheduler.start("progress-order") is True
@@ -451,14 +461,15 @@ async def test_replaced_job_keeps_active_state_without_stale_progress(monkeypatc
async def get(self):
return detail
monkeypatch.setattr(scheduler_module, "ProgressHelper", RecordingProgress)
monkeypatch.setattr(scheduler_execution, "ProgressHelper", RecordingProgress)
monkeypatch.setattr(scheduler_progress, "ProgressHelper", RecordingProgress)
monkeypatch.setattr(
scheduler_module,
scheduler_progress,
"AsyncProgressHelper",
RecordingAsyncProgress,
)
scheduler = _scheduler("generation-cache", lambda: None)
old_job = scheduler._Scheduler__prepare_job("generation-cache")
old_job = scheduler._prepare_job("generation-cache")
assert old_job is not None
assert detail["data"]["_generation"] == 1
@@ -486,7 +497,7 @@ async def test_replaced_job_keeps_active_state_without_stale_progress(monkeypatc
@pytest.mark.anyio
async def test_stale_generation_cannot_finish_replaced_job(monkeypatch) -> None:
"""旧 generation 收尾不得改写同 ID 的新任务状态或进度。"""
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
monkeypatch.setattr(scheduler_progress, "AsyncProgressHelper", _AsyncProgressStub)
scheduler = _scheduler("generation-job", lambda: None)
old_job = scheduler._jobs["generation-job"]
old_job["running"] = True
@@ -498,7 +509,7 @@ async def test_stale_generation_cannot_finish_replaced_job(monkeypatch) -> None:
}
scheduler._jobs["generation-job"] = new_job
await scheduler._Scheduler__finish_job(
await scheduler._finish_job(
job_id="generation-job",
job=old_job,
generation=1,
@@ -539,7 +550,7 @@ def test_agent_task_manual_start_has_single_reservation() -> None:
assert second is False
assert results == [True]
assert scheduler._agent_task_reservations == {}
assert scheduler._registry.reservation_owner("agent-task-1") is None
def test_scheduler_rejects_new_submission_after_stop() -> None:
@@ -624,8 +635,7 @@ async def test_config_reload_preserves_overlap_guard_across_job_generations(
await release.wait()
finished.set()
monkeypatch.setattr(scheduler_module, "ProgressHelper", _ProgressStub)
monkeypatch.setattr(scheduler_module, "AsyncProgressHelper", _AsyncProgressStub)
_patch_progress(monkeypatch)
scheduler = _scheduler("reload-overlap", job)
class ActiveScheduler:
@@ -666,20 +676,26 @@ async def test_config_reload_preserves_overlap_guard_across_job_generations(
assert listed[0].status == "正在运行"
assert scheduler.start("reload-overlap") is False
assert run_count == 1
assert len(scheduler._handles) == 1
assert len(scheduler._registry.handles()) == 1
release.set()
await asyncio.wait_for(finished.wait(), timeout=1)
async def wait_until_released() -> None:
while scheduler._active_job_generations or scheduler._handles:
while (
scheduler._registry.is_active("reload-overlap")
or scheduler._registry.handles()
):
await asyncio.sleep(0)
await asyncio.wait_for(wait_until_released(), timeout=1)
assert scheduler.start("reload-overlap") is True
async def wait_until_second_run_finishes() -> None:
while scheduler._active_job_generations or scheduler._handles:
while (
scheduler._registry.is_active("reload-overlap")
or scheduler._registry.handles()
):
await asyncio.sleep(0)
await asyncio.wait_for(wait_until_second_run_finishes(), timeout=1)
@@ -698,27 +714,27 @@ def test_stop_between_prepare_and_submission_releases_active_generation(
calls += 1
scheduler._jobs["stop-race"]["func"] = job
original_prepare = scheduler._Scheduler__prepare_job
original_prepare = scheduler._prepare_job
def prepare_then_stop(job_id: str):
prepared = original_prepare(job_id)
scheduler._begin_stop()
return prepared
monkeypatch.setattr(scheduler, "_Scheduler__prepare_job", prepare_then_stop)
monkeypatch.setattr(scheduler, "_prepare_job", prepare_then_stop)
assert scheduler.start("stop-race") is False
assert calls == 0
assert scheduler._handles == {}
assert scheduler._active_job_generations == {}
assert scheduler._registry.handles() == ()
assert scheduler._registry.is_active("stop-race") is False
assert scheduler._jobs["stop-race"]["running"] is False
assert scheduler._jobs["stop-race"]["last_error"] == "任务未提交"
monkeypatch.setattr(scheduler, "_Scheduler__prepare_job", original_prepare)
monkeypatch.setattr(scheduler, "_prepare_job", original_prepare)
scheduler._lifecycle_state = "running"
assert scheduler.start("stop-race") is True
assert calls == 1
assert scheduler._active_job_generations == {}
assert scheduler._registry.is_active("stop-race") is False
@pytest.mark.anyio
@@ -737,8 +753,8 @@ async def test_cross_thread_rejection_closes_unstarted_business_coroutine(
calls += 1
scheduler._jobs["cross-thread-stop-race"]["func"] = job
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", main_loop)
original_prepare = scheduler._Scheduler__prepare_job
_patch_main_loop(monkeypatch, main_loop)
original_prepare = scheduler._prepare_job
def prepare_then_wait(job_id: str):
result = original_prepare(job_id)
@@ -746,7 +762,7 @@ async def test_cross_thread_rejection_closes_unstarted_business_coroutine(
release.wait(timeout=1)
return result
monkeypatch.setattr(scheduler, "_Scheduler__prepare_job", prepare_then_wait)
monkeypatch.setattr(scheduler, "_prepare_job", prepare_then_wait)
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always", RuntimeWarning)
@@ -760,8 +776,8 @@ async def test_cross_thread_rejection_closes_unstarted_business_coroutine(
gc.collect()
assert calls == 0
assert scheduler._active_job_generations == {}
assert scheduler._handles == {}
assert scheduler._registry.is_active("cross-thread-stop-race") is False
assert scheduler._registry.handles() == ()
assert not any("was never awaited" in str(item.message) for item in captured)
@@ -795,13 +811,13 @@ def test_cancelled_cross_thread_proxy_waits_for_target_loop_cleanup(
calls += 1
scheduler._jobs["cancel-before-start"]["func"] = business
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", target_loop)
_patch_main_loop(monkeypatch, target_loop)
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always", RuntimeWarning)
try:
assert scheduler.start("cancel-before-start") is True
scheduler_handle = next(iter(scheduler._handles.values()))
scheduler_handle = scheduler._registry.handles()[0]
scheduler._cancel_handle(scheduler_handle)
assert not scheduler_handle.completion.done()
@@ -817,6 +833,6 @@ def test_cancelled_cross_thread_proxy_waits_for_target_loop_cleanup(
assert calls == 0
assert loop_errors == []
assert scheduler._active_job_generations == {}
assert scheduler._handles == {}
assert scheduler._registry.is_active("cancel-before-start") is False
assert scheduler._registry.handles() == ()
assert not any("was never awaited" in str(item.message) for item in captured)
+13 -15
View File
@@ -5,7 +5,8 @@ from uuid import uuid4
import pytest
from app.runtime.loop import main_loop_registry
from app.scheduler import Scheduler
from app.scheduler.facade import Scheduler
from app.scheduler.registry import ExecutionRegistry
@pytest.fixture(autouse=True)
@@ -31,10 +32,7 @@ def _build_scheduler(job_id, func):
}
}
scheduler._lifecycle_state = "running"
scheduler._handles = {}
scheduler._job_generations = {}
scheduler._active_job_generations = {}
scheduler._agent_task_reservations = {}
scheduler._registry = ExecutionRegistry(scheduler._lock)
return scheduler
@@ -77,7 +75,7 @@ def test_scheduler_failure_preserves_last_progress(monkeypatch):
scheduler = _build_scheduler(job_id, task)
monkeypatch.setattr(
scheduler,
"_Scheduler__handle_job_error",
"_handle_job_error",
lambda **kwargs: None,
)
@@ -147,7 +145,10 @@ def test_scheduler_runs_async_job_from_current_event_loop(monkeypatch):
async def wait_until_finished() -> None:
"""等待任务及其异步进度句柄全部收敛。"""
while scheduler._handles or scheduler._active_job_generations:
while (
scheduler._registry.handles()
or scheduler._registry.is_active(job_id)
):
await asyncio.sleep(0)
await asyncio.wait_for(wait_until_finished(), timeout=1)
@@ -176,9 +177,9 @@ def test_scheduler_records_cancelled_async_job_as_failed():
raise asyncio.CancelledError
async def run_task():
job = scheduler._Scheduler__prepare_job(job_id)
job = scheduler._prepare_job(job_id)
with pytest.raises(asyncio.CancelledError):
await scheduler._Scheduler__run_coro_job(task, job_id, job)
await scheduler._run_coro_job(task, job_id, job)
scheduler = _build_scheduler(job_id, task)
asyncio.run(run_task())
@@ -211,13 +212,13 @@ def test_scheduler_stop_async_cancels_owned_async_jobs():
"""在当前事件循环启动并收口异步作业。"""
scheduler.start(job_id)
await started.wait()
assert len(scheduler._handles) == 1
assert len(scheduler._registry.handles()) == 1
await scheduler.stop_async()
asyncio.run(run_task())
assert cancelled.is_set()
assert scheduler._handles == {}
assert scheduler._registry.handles() == ()
def test_scheduler_returns_none_for_unknown_job():
@@ -227,9 +228,6 @@ def test_scheduler_returns_none_for_unknown_job():
scheduler._lock = threading.RLock()
scheduler._jobs = {}
scheduler._lifecycle_state = "running"
scheduler._handles = {}
scheduler._job_generations = {}
scheduler._active_job_generations = {}
scheduler._agent_task_reservations = {}
scheduler._registry = ExecutionRegistry(scheduler._lock)
assert scheduler.get_progress(job_id) is None
+119
View File
@@ -0,0 +1,119 @@
"""Scheduler ExecutionRegistry 的状态所有权与原子操作测试。"""
import asyncio
import concurrent.futures
from app.scheduler.registry import ExecutionRegistry
def test_registry_assigns_monotonic_generations() -> None:
"""同 ID generation 单调递增,不同任务分别计数。"""
registry = ExecutionRegistry()
state: dict[str, object] = {}
assert registry.assign_generation("job", state) == 1
assert state["_generation"] == 1
assert registry.next_generation("job") == 2
assert registry.next_generation("other") == 1
assert registry.current_generation("job") == 2
assert registry.current_generation("missing") == 0
def test_registry_allocates_unique_generations_across_threads() -> None:
"""并发分配同 ID 时每个调用都必须取得唯一 generation。"""
registry = ExecutionRegistry()
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
generations = list(
executor.map(lambda _index: registry.next_generation("job"), range(64))
)
assert sorted(generations) == list(range(1, 65))
def test_registry_claims_and_releases_one_active_generation() -> None:
"""旧 generation 活跃期间拒绝同 ID 新 generation,并精确释放 owner。"""
registry = ExecutionRegistry()
assert registry.claim_generation("job", 1) is True
assert registry.claim_generation("job", 2) is False
assert registry.is_active("job") is True
assert registry.active_generations("job") == frozenset({1})
assert registry.release_generation("job", 2) is False
assert registry.release_generation("job", 1) is True
assert registry.is_active("job") is False
def test_registry_reservation_preserves_owner_identity() -> None:
"""预约只能由原 owner 消费或释放,且活跃任务不能再次预约。"""
registry = ExecutionRegistry()
assert registry.reserve("job", owner=11) is True
assert registry.reserve("job", owner=12) is False
assert registry.reservation_owner("job") == 11
assert registry.consume_reservation("job", owner=12) is False
assert registry.release_reservation("job", owner=12) is False
assert registry.consume_reservation("job", owner=11) is True
assert registry.consume_reservation("job", owner=12) is True
assert registry.claim_generation("job", 1) is True
assert registry.reserve("job", owner=11) is False
def test_registry_filters_and_removes_handles_by_completion_identity() -> None:
"""句柄查询按 owner 字段过滤,摘除键使用真实完成信号身份。"""
registry = ExecutionRegistry()
loop = asyncio.new_event_loop()
try:
submitted: concurrent.futures.Future[object] = concurrent.futures.Future()
completed: concurrent.futures.Future[object] = concurrent.futures.Future()
progress: concurrent.futures.Future[object] = concurrent.futures.Future()
job_handle = registry.register_handle(
job_id="job",
generation=1,
loop=loop,
handle=submitted,
completion=completed,
)
progress_handle = registry.register_handle(
job_id="job",
generation=1,
loop=loop,
handle=progress,
kind="progress",
)
assert registry.handles(job_id="job", generation=1) == (
job_handle,
progress_handle,
)
assert registry.handles(job_id="job", kind="progress") == (
progress_handle,
)
assert registry.remove_handle(submitted) is False
assert registry.remove_handle(completed) is True
assert registry.handles() == (progress_handle,)
finally:
loop.close()
def test_registry_stop_snapshot_clears_reservations_but_retains_handles() -> None:
"""停止快照封存当前句柄并清理尚未消费的手动预约。"""
registry = ExecutionRegistry()
loop = asyncio.new_event_loop()
try:
completion: concurrent.futures.Future[object] = concurrent.futures.Future()
handle = registry.register_handle(
job_id="job",
generation=1,
loop=loop,
handle=completion,
)
assert registry.reserve("manual", owner=11) is True
assert registry.stop_snapshot() == (handle,)
assert registry.reservation_owner("manual") is None
assert registry.handles() == (handle,)
assert registry.remove_handle(completion) is True
assert registry.handles() == ()
finally:
loop.close()
+22
View File
@@ -0,0 +1,22 @@
"""插件调度 SDK 的公开面测试。"""
import app.sdk.scheduler as scheduler_sdk
def test_scheduler_sdk_exposes_only_narrow_service_contracts() -> None:
"""新插件 SDK 不得泄漏 concrete Scheduler 或其可变运行状态。"""
assert set(scheduler_sdk.__all__) == {
"ScheduleInfo",
"ScheduleProgress",
"get_agent_task_next_run",
"list_scheduler_jobs",
"remove_agent_task_job",
"remove_plugin_job",
"start_agent_task",
"start_scheduler_job",
"update_agent_task_job",
"update_plugin_job",
}
assert not hasattr(scheduler_sdk, "Scheduler")
assert not hasattr(scheduler_sdk, "BackgroundScheduler")
assert not hasattr(scheduler_sdk, "_jobs")
+2 -2
View File
@@ -17,7 +17,7 @@ def test_host_runtime_consumers_use_application_facades() -> None:
def test_service_locator_gate_detects_each_concrete_runtime_family() -> None:
"""单一扫描器必须覆盖五类运行时,并保留明确兼容边界。"""
graph = {
"app.api.scheduler_bypass": {"app.scheduler"},
"app.api.scheduler_bypass": {"app.scheduler.facade"},
"app.api.module_bypass": {"app.runtime.extensions.module_manager"},
"app.api.plugin_bypass": {"app.runtime.extensions.plugin_manager"},
"app.api.command_bypass": {"app.command"},
@@ -30,7 +30,7 @@ def test_service_locator_gate_detects_each_concrete_runtime_family() -> None:
"app.command",
"app.runtime.extensions.module_manager",
"app.runtime.extensions.plugin_manager",
"app.scheduler",
"app.scheduler.facade",
},
"app.workflow.manager": {"app.workflow"},
}