mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 12:06:51 +08:00
refactor: unify message channel shutdown convergence
This commit is contained in:
@@ -93,10 +93,10 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def stop(self) -> None:
|
||||
def stop(self) -> Optional[bool]:
|
||||
"""
|
||||
如果关闭时模块有服务需要停止,需要实现此方法
|
||||
:return: None,该方法可被多个模块同时处理
|
||||
:return: False 表示资源尚未收敛;None/True 表示本次停止完成
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@@ -73,6 +73,21 @@ class _MessageChannelModuleBase(_ModuleBase, _MessageBase[TService]):
|
||||
"""
|
||||
return bool(client.get_state()), ""
|
||||
|
||||
def _stop_service_instances(self) -> bool:
|
||||
"""停止全部渠道实例,并聚合客户端返回的资源收敛结果。"""
|
||||
converged = True
|
||||
for client in self.get_instances().values():
|
||||
stop = getattr(client, "stop", None)
|
||||
if not callable(stop):
|
||||
continue
|
||||
try:
|
||||
if stop() is False:
|
||||
converged = False
|
||||
except Exception as err:
|
||||
logger.error("停止%s模块实例失败:%s", self.get_name(), err)
|
||||
converged = False
|
||||
return converged
|
||||
|
||||
def register_commands(self, commands: Dict[str, dict]) -> None:
|
||||
"""
|
||||
注册命令,实现这个函数接收系统可用的命令菜单
|
||||
|
||||
@@ -96,13 +96,9 @@ class DiscordModule(_MessageChannelModuleBase[Discord]):
|
||||
"""
|
||||
return 4
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for client in self.get_instances().values():
|
||||
try:
|
||||
client.stop()
|
||||
except Exception as err:
|
||||
logger.error(f"停止Discord模块实例失败:{err}")
|
||||
def stop(self) -> bool:
|
||||
"""停止全部 Discord 实例,并返回资源是否全部收敛。"""
|
||||
return self._stop_service_instances()
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
@@ -39,6 +39,8 @@ class Discord:
|
||||
"""
|
||||
|
||||
_MAX_SLASH_COMMANDS = 100
|
||||
_client_close_timeout_seconds = 10
|
||||
_thread_join_timeout_seconds = 5
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -247,10 +249,10 @@ class Discord:
|
||||
self._thread = threading.Thread(target=runner, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""停止 Discord 客户端,并在关闭事件循环前收口 typing owner。"""
|
||||
def stop(self) -> bool:
|
||||
"""停止 Discord 客户端,并返回事件循环线程是否已经终止。"""
|
||||
if not self._client or not self._loop or not self._thread:
|
||||
return
|
||||
return True
|
||||
self._stop_requested.set()
|
||||
loop = self._loop
|
||||
thread = self._thread
|
||||
@@ -264,19 +266,23 @@ class Discord:
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._client.close(), loop
|
||||
).result(timeout=10)
|
||||
).result(timeout=self._client_close_timeout_seconds)
|
||||
except Exception as err:
|
||||
logger.error(f"关闭 Discord Bot 失败:{err}")
|
||||
self._ready_event.clear()
|
||||
thread.join(timeout=5)
|
||||
if thread is not threading.current_thread():
|
||||
thread.join(timeout=self._thread_join_timeout_seconds)
|
||||
if thread.is_alive():
|
||||
try:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
except Exception as err:
|
||||
logger.error(f"停止 Discord 事件循环失败:{err}")
|
||||
thread.join(timeout=5)
|
||||
if thread is not threading.current_thread():
|
||||
thread.join(timeout=self._thread_join_timeout_seconds)
|
||||
if thread.is_alive():
|
||||
logger.error("Discord Bot 线程未在超时内停止")
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._ready_event.is_set() and self._client is not None
|
||||
|
||||
@@ -48,13 +48,9 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]):
|
||||
"""
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for client in self.get_instances().values():
|
||||
try:
|
||||
client.stop()
|
||||
except Exception as err:
|
||||
logger.error(f"停止飞书模块实例失败:{err}")
|
||||
def stop(self) -> bool:
|
||||
"""停止全部飞书实例,并返回资源是否全部收敛。"""
|
||||
return self._stop_service_instances()
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
"""通知模块通过系统通知配置控制实例化,这里不额外设置环境开关。"""
|
||||
|
||||
@@ -129,6 +129,8 @@ class Feishu:
|
||||
"""飞书通知客户端,负责长连接收消息与主动发送通知。"""
|
||||
|
||||
PROCESSING_REACTION_EMOJI = "GLANCE"
|
||||
_ws_shutdown_timeout_seconds = 5
|
||||
_ws_join_timeout_seconds = 5
|
||||
STREAM_CARD_TITLE_ELEMENT_ID = "mp_stream_title"
|
||||
STREAM_CARD_BODY_ELEMENT_ID = "mp_stream_body"
|
||||
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".tiff", ".heic"}
|
||||
@@ -678,12 +680,13 @@ class Feishu:
|
||||
"""返回飞书客户端是否已就绪。"""
|
||||
return self._ready.is_set() and self._api_client is not None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止飞书客户端并结束长连接线程。"""
|
||||
def stop(self) -> bool:
|
||||
"""停止飞书客户端,并返回长连接线程是否已经终止。"""
|
||||
self._stop_event.set()
|
||||
self._ready.clear()
|
||||
ws_client = self._ws_client
|
||||
ws_loop = self._ws_loop
|
||||
ws_thread = self._ws_thread
|
||||
if ws_client:
|
||||
try:
|
||||
ws_client._auto_reconnect = False
|
||||
@@ -692,11 +695,19 @@ class Feishu:
|
||||
self._shutdown_ws_client(),
|
||||
ws_loop,
|
||||
)
|
||||
shutdown_future.result(timeout=5)
|
||||
shutdown_future.result(timeout=self._ws_shutdown_timeout_seconds)
|
||||
except Exception as err:
|
||||
logger.debug(f"停止飞书客户端失败:{err}")
|
||||
if self._ws_thread and self._ws_thread.is_alive():
|
||||
self._ws_thread.join(timeout=5)
|
||||
if (
|
||||
ws_thread
|
||||
and ws_thread.is_alive()
|
||||
and ws_thread is not threading.current_thread()
|
||||
):
|
||||
ws_thread.join(timeout=self._ws_join_timeout_seconds)
|
||||
if ws_thread and ws_thread.is_alive():
|
||||
logger.error("飞书长连接线程未在关闭预算内退出")
|
||||
return False
|
||||
return True
|
||||
|
||||
def parse_message(self, body: Any) -> Optional[IncomingMessage]:
|
||||
"""解析飞书转发到消息入口的 JSON 报文。"""
|
||||
|
||||
@@ -90,13 +90,9 @@ class QQBotModule(_MessageChannelModuleBase[QQBot]):
|
||||
"""
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for client in self.get_instances().values():
|
||||
try:
|
||||
client.stop()
|
||||
except Exception as err:
|
||||
logger.error(f"停止QQ Bot模块实例失败:{err}")
|
||||
def stop(self) -> bool:
|
||||
"""停止全部 QQ Bot 实例,并返回资源是否全部收敛。"""
|
||||
return self._stop_service_instances()
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
@@ -38,6 +38,8 @@ _MAX_IMAGE_SIZE: Tuple[int, int] = (512, 512)
|
||||
class QQBot:
|
||||
"""QQ Bot 通知客户端"""
|
||||
|
||||
_gateway_join_timeout_seconds = 20
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
QQ_APP_ID: Optional[str] = None,
|
||||
@@ -161,8 +163,8 @@ class QQBot:
|
||||
except Exception as e:
|
||||
logger.error(f"QQ Bot Gateway 启动失败: {e}")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止 Gateway 连接"""
|
||||
def stop(self) -> bool:
|
||||
"""停止 Gateway 连接,并返回后台线程是否已经终止。"""
|
||||
if self._gateway_stop is not None:
|
||||
self._gateway_stop.set()
|
||||
try:
|
||||
@@ -170,12 +172,19 @@ class QQBot:
|
||||
self._gateway_ws_holder[0].close()
|
||||
except Exception as e:
|
||||
logger.debug(f"QQ Bot Gateway WebSocket close: {e}")
|
||||
if self._gateway_thread is not None and self._gateway_thread.is_alive():
|
||||
self._gateway_thread.join(timeout=20)
|
||||
if self._gateway_thread.is_alive():
|
||||
logger.warning(
|
||||
"QQ Bot Gateway 线程在 stop 后仍未退出,可能存在重复收消息,请重启进程"
|
||||
)
|
||||
gateway_thread = self._gateway_thread
|
||||
if (
|
||||
gateway_thread is not None
|
||||
and gateway_thread.is_alive()
|
||||
and gateway_thread is not threading.current_thread()
|
||||
):
|
||||
gateway_thread.join(timeout=self._gateway_join_timeout_seconds)
|
||||
if gateway_thread is not None and gateway_thread.is_alive():
|
||||
logger.warning(
|
||||
"QQ Bot Gateway 线程在 stop 后仍未退出,可能存在重复收消息,请重启进程"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""获取就绪状态"""
|
||||
|
||||
@@ -77,13 +77,9 @@ class SlackModule(_MessageChannelModuleBase[Slack]):
|
||||
"""
|
||||
return 3
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for client in self.get_instances().values():
|
||||
try:
|
||||
client.stop()
|
||||
except Exception as err:
|
||||
logger.error(f"停止Slack模块实例失败:{err}")
|
||||
def stop(self) -> bool:
|
||||
"""停止全部 Slack 实例,并返回资源是否全部收敛。"""
|
||||
return self._stop_service_instances()
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
@@ -117,13 +117,16 @@ class Slack:
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
def stop(self) -> bool:
|
||||
"""关闭 Socket Mode 服务,并返回资源是否成功释放。"""
|
||||
if self._service:
|
||||
try:
|
||||
self._service.close()
|
||||
logger.info("Slack消息接收服务已停止")
|
||||
except Exception as err:
|
||||
logger.error("Slack消息接收服务停止失败: %s" % str(err))
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
|
||||
@@ -74,15 +74,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]):
|
||||
|
||||
def stop(self) -> bool:
|
||||
"""停止全部 Telegram 实例,并返回资源是否全部收敛。"""
|
||||
converged = True
|
||||
for client in self.get_instances().values():
|
||||
try:
|
||||
if client.stop() is False:
|
||||
converged = False
|
||||
except Exception as err:
|
||||
logger.error(f"停止Telegram模块实例失败:{err}")
|
||||
converged = False
|
||||
return converged
|
||||
return self._stop_service_instances()
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
"""
|
||||
|
||||
@@ -73,14 +73,9 @@ class WechatModule(_MessageChannelModuleBase[WeChat]):
|
||||
"""
|
||||
return 1
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for client in self.get_instances().values():
|
||||
try:
|
||||
if hasattr(client, "stop"):
|
||||
client.stop()
|
||||
except Exception as err:
|
||||
logger.error(f"停止微信模块实例失败:{err}")
|
||||
def stop(self) -> bool:
|
||||
"""停止全部微信实例,并返回长连接资源是否全部收敛。"""
|
||||
return self._stop_service_instances()
|
||||
|
||||
@staticmethod
|
||||
def _is_bot_mode(config: dict) -> bool:
|
||||
|
||||
@@ -38,6 +38,8 @@ class WeChatBot:
|
||||
_default_ws_url = "wss://openws.work.weixin.qq.com"
|
||||
_heartbeat_interval = 30
|
||||
_ack_timeout = 10
|
||||
_gateway_join_timeout_seconds = 5
|
||||
_heartbeat_join_timeout_seconds = 2
|
||||
|
||||
def __init__(self,
|
||||
WECHAT_BOT_ID: Optional[str] = None,
|
||||
@@ -126,7 +128,8 @@ class WeChatBot:
|
||||
self._heartbeat_thread.start()
|
||||
logger.info(f"企业微信智能机器人长连接已启动:{self._config_name}")
|
||||
|
||||
def stop(self) -> None:
|
||||
def stop(self) -> bool:
|
||||
"""停止网关与心跳线程,并返回两个 owner 是否均已终止。"""
|
||||
self._stop_event.set()
|
||||
self._authenticated.clear()
|
||||
if self._ws_app:
|
||||
@@ -134,10 +137,27 @@ class WeChatBot:
|
||||
self._ws_app.close()
|
||||
except Exception as err:
|
||||
logger.debug(f"关闭企业微信智能机器人连接失败:{err}")
|
||||
if self._ws_thread and self._ws_thread.is_alive():
|
||||
self._ws_thread.join(timeout=5)
|
||||
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
|
||||
self._heartbeat_thread.join(timeout=2)
|
||||
ws_thread = self._ws_thread
|
||||
heartbeat_thread = self._heartbeat_thread
|
||||
if (
|
||||
ws_thread
|
||||
and ws_thread.is_alive()
|
||||
and ws_thread is not threading.current_thread()
|
||||
):
|
||||
ws_thread.join(timeout=self._gateway_join_timeout_seconds)
|
||||
if (
|
||||
heartbeat_thread
|
||||
and heartbeat_thread.is_alive()
|
||||
and heartbeat_thread is not threading.current_thread()
|
||||
):
|
||||
heartbeat_thread.join(timeout=self._heartbeat_join_timeout_seconds)
|
||||
converged = not any(
|
||||
thread and thread.is_alive()
|
||||
for thread in (ws_thread, heartbeat_thread)
|
||||
)
|
||||
if not converged:
|
||||
logger.error("企业微信智能机器人线程未在关闭预算内退出")
|
||||
return converged
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._ready and self._authenticated.is_set()
|
||||
|
||||
@@ -69,13 +69,9 @@ class WechatClawBotModule(_MessageChannelModuleBase[WechatClawBot]):
|
||||
"""
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
for client in self.get_instances().values():
|
||||
try:
|
||||
client.stop()
|
||||
except Exception as err:
|
||||
logger.error(f"停止微信 ClawBot 模块实例失败:{err}")
|
||||
def stop(self) -> bool:
|
||||
"""停止全部微信 ClawBot 实例,并返回资源是否全部收敛。"""
|
||||
return self._stop_service_instances()
|
||||
|
||||
def _test_connection(self, client) -> Tuple[bool, str]:
|
||||
"""微信 ClawBot 的连接探测返回 (状态, 信息)。"""
|
||||
|
||||
@@ -1441,6 +1441,7 @@ class WechatClawBot:
|
||||
_default_base_url = "https://ilinkai.weixin.qq.com"
|
||||
_qrcode_ttl_seconds = 240
|
||||
_active_target_ttl_seconds = 24 * 60 * 60
|
||||
_poll_join_timeout_seconds = 5
|
||||
|
||||
@classmethod
|
||||
def _build_cache_key(cls, config_name: str) -> str:
|
||||
@@ -1706,12 +1707,21 @@ class WechatClawBot:
|
||||
"""获取当前登录状态。"""
|
||||
return bool(self._state.get("bot_token"))
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止消息轮询。"""
|
||||
def stop(self) -> bool:
|
||||
"""停止消息轮询,并保留超时线程 owner 供后续重试。"""
|
||||
self._stop_event.set()
|
||||
if self._poll_thread and self._poll_thread.is_alive():
|
||||
self._poll_thread.join(timeout=5)
|
||||
poll_thread = self._poll_thread
|
||||
if (
|
||||
poll_thread
|
||||
and poll_thread.is_alive()
|
||||
and poll_thread is not threading.current_thread()
|
||||
):
|
||||
poll_thread.join(timeout=self._poll_join_timeout_seconds)
|
||||
if poll_thread and poll_thread.is_alive():
|
||||
logger.error("微信 ClawBot 消息轮询线程未在关闭预算内退出")
|
||||
return False
|
||||
self._poll_thread = None
|
||||
return True
|
||||
|
||||
def _start_polling(self) -> None:
|
||||
"""启动消息轮询线程。"""
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
> 文档性质:当前架构复核、优秀 Python 后端实践对标、AI 可执行任务手册
|
||||
> 适用仓库:`MoviePilot`,分支 `v3`
|
||||
> 审计基线:`2aa41ea8`(2026-08-24)
|
||||
> 审计基线:`009631b8`(2026-08-24)
|
||||
> 审计范围:宿主后端;排除 `app/plugins/**` 运行时插件副本
|
||||
> 规范优先级:`AGENTS.md` 与 `docs/rules/` 高于本文
|
||||
> 相关文档:`docs/architecture-overview.md`、`docs/refactor/backend-architecture-governance.md`、`docs/refactor/backend-module-refactor-compatibility.md`
|
||||
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界;阶段 23 已补齐媒体服务器 API 遗留的类形配置读取路径;阶段 24 已清除 Scheduler 内部无 owner 的协程提交双轨;阶段 25 已补齐 TaskRegistry 跨线程 owner 并迁移整理 AI 接管;阶段 26 已统一 Agent 会话清理提交;阶段 27 已统一历史 AI 进度 owner;阶段 28 已托管旧插件订阅统计线程;阶段 29 已统一 Emby 系条目转换并清零重复代码白名单;阶段 30 已收口插件市场请求级子任务;阶段 31 已托管搜索 AI 推荐任务;阶段 32 已清除事件调度器绕过生命周期 owner 的投递回退;阶段 33 已统一宿主 Agent 运行时的获取路径;阶段 34 已统一 durable-required 事件与 Outbox topic 事实源;阶段 35 已统一 LLM provider 管理 API 的运行时解析路径;阶段 36 已统一 WebAgent 音频能力访问边界;阶段 37 已统一插件输入事件发布路径;阶段 38 已统一 WebAgent 通知事件监听与队列边界;阶段 39 已补齐搜索 SSE 断线时的上游任务清理;阶段 40 已补齐异步防抖取消的终态所有权;阶段 41 已统一优雅重启兜底线程的唯一所有权;阶段 42 已补齐 Telegram typing 的多实例隔离和终态 owner;阶段 43 已统一 Discord typing 的异步 owner 和 shutdown 收尾;阶段 44 已清除 WebAgent 测试临时事件循环提前关闭产生的 CI 红注解;阶段 45 已统一影视与字幕搜索的请求级逐页任务编排;阶段 46 已收口启动性能门禁的托管 runner 假失败与诊断输出;阶段 47 已补齐 Agent 渠道流式刷新任务的重入 owner;阶段 48 已统一工件上传 action 的 Node 24 主版本;阶段 49 已统一插件安装的同步/异步代际解析事实源;阶段 50 已统一插件市场 GitHub 请求降级策略;阶段 51 已统一插件索引请求与响应三态策略;阶段 52 已统一插件 Release 分页策略;阶段 53 已统一远端插件安装模式决策;阶段 54 已补齐同步安装成功后的临时回滚备份清理;阶段 55~56 已收口官方插件观察基线与报告保留策略;阶段 57 已统一进程级运行时 Facade 门禁并补齐 ModuleManager 边界;阶段 58 已消除 AgentTask 关闭回归的跨线程零时长等待竞态;阶段 59 已统一 Feishu 多实例长连接的 SDK 循环路由;阶段 60 已清除命令服务虚假的关停 owner 声明;阶段 61 已统一 Capability Runtime 同步/异步关闭的诚实收敛结果。
|
||||
> 实施进度:阶段 0~6 的宿主架构能力已完成收口;API/Application 公共复杂度基线已清零,启动组合根的 SystemConfigOper 构造点已由 14 降至 1;API 进程内后台任务已完成首批统一登记,插件仓适配和 Outbox 外围扩展仍按风险切片推进。Model/Base 查询与写装饰器、legacy 隐式会话外壳均已清零,插件 SDK 也不再导出宿主 Model。2026-08-23 的长期整改阶段 0 已恢复宿主、启动性能、官方插件和 SDK 契约门禁的可信基线;阶段 1a 已补齐 TaskRegistry owner 零债务门禁和诚实的关停超时语义;阶段 1b1 已收口整理 worker、pending 回放、失败通知、进程内 AI 重试、插件监控与事件投递的生命周期所有权;2026-08-24 的阶段 2 已将 212 个已观察宿主模块方法的 legacy aggregation 清零,并补齐可执行 fanout 与下载器文件 DTO 边界;阶段 3 已将消息交互和远程命令的订阅删除统一到 Application/UoW/outbox,宿主不再调用裸线程统计入口;阶段 4 已统一七种消息渠道的宿主回环与后台执行边界;阶段 5 已补齐事件窗口聚合任务的生命周期所有权;阶段 6 已统一插件文件操作的取消完成语义;阶段 7 已统一插件协程补偿的终态等待;阶段 8 已统一宿主同步函数的异步线程池入口;阶段 9 已统一工作流运行时的宿主获取路径;阶段 10 已统一模块、插件与调度运行时的显式 getter 调用;阶段 11 已清除系统配置 getter 的 Oper 形别名;阶段 12 已完成工作流域的显式 Chain 数据端口迁移;阶段 13 已收口用户、交互与消息链的数据端口;阶段 14 已收口音乐订阅数据端口;阶段 15 已收口站点数据端口;阶段 16 已收口媒体服务器数据端口;阶段 17 已收口下载数据端口;阶段 18 已收口主订阅数据端口;阶段 19 已收口整理数据端口;阶段 20 已收口 Agent 数据端口;阶段 21 已收口监控历史端口;阶段 22 已统一服务配置应用边界;阶段 23 已补齐媒体服务器 API 遗留的类形配置读取路径;阶段 24 已清除 Scheduler 内部无 owner 的协程提交双轨;阶段 25 已补齐 TaskRegistry 跨线程 owner 并迁移整理 AI 接管;阶段 26 已统一 Agent 会话清理提交;阶段 27 已统一历史 AI 进度 owner;阶段 28 已托管旧插件订阅统计线程;阶段 29 已统一 Emby 系条目转换并清零重复代码白名单;阶段 30 已收口插件市场请求级子任务;阶段 31 已托管搜索 AI 推荐任务;阶段 32 已清除事件调度器绕过生命周期 owner 的投递回退;阶段 33 已统一宿主 Agent 运行时的获取路径;阶段 34 已统一 durable-required 事件与 Outbox topic 事实源;阶段 35 已统一 LLM provider 管理 API 的运行时解析路径;阶段 36 已统一 WebAgent 音频能力访问边界;阶段 37 已统一插件输入事件发布路径;阶段 38 已统一 WebAgent 通知事件监听与队列边界;阶段 39 已补齐搜索 SSE 断线时的上游任务清理;阶段 40 已补齐异步防抖取消的终态所有权;阶段 41 已统一优雅重启兜底线程的唯一所有权;阶段 42 已补齐 Telegram typing 的多实例隔离和终态 owner;阶段 43 已统一 Discord typing 的异步 owner 和 shutdown 收尾;阶段 44 已清除 WebAgent 测试临时事件循环提前关闭产生的 CI 红注解;阶段 45 已统一影视与字幕搜索的请求级逐页任务编排;阶段 46 已收口启动性能门禁的托管 runner 假失败与诊断输出;阶段 47 已补齐 Agent 渠道流式刷新任务的重入 owner;阶段 48 已统一工件上传 action 的 Node 24 主版本;阶段 49 已统一插件安装的同步/异步代际解析事实源;阶段 50 已统一插件市场 GitHub 请求降级策略;阶段 51 已统一插件索引请求与响应三态策略;阶段 52 已统一插件 Release 分页策略;阶段 53 已统一远端插件安装模式决策;阶段 54 已补齐同步安装成功后的临时回滚备份清理;阶段 55~56 已收口官方插件观察基线与报告保留策略;阶段 57 已统一进程级运行时 Facade 门禁并补齐 ModuleManager 边界;阶段 58 已消除 AgentTask 关闭回归的跨线程零时长等待竞态;阶段 59 已统一 Feishu 多实例长连接的 SDK 循环路由;阶段 60 已清除命令服务虚假的关停 owner 声明;阶段 61 已统一 Capability Runtime 同步/异步关闭的诚实收敛结果;阶段 62 已统一消息渠道长连接的多实例关闭收敛合同。
|
||||
> 当前 canonical 状态:API/Application 公共复杂度基线已清零,组合根外 `SystemConfigOper()` 构造和 Model/Oper 隐式事务均为 0;命名 Chain/Agent 数据端口、TaskRegistry owner、Module Contract V2、typed Event、Outbox durable intent、请求关联和插件运行时 getter 已形成当前路径。插件仓适配、未知第三方 fallback 和其它 E1/E3 副作用仍按风险持续治理。
|
||||
> 最新阶段:阶段 61 已统一 Capability Runtime 关闭收敛事实源。
|
||||
> 最新阶段:阶段 62 已统一消息渠道长连接的关闭收敛合同。
|
||||
|
||||
## 当前复核结论(2026-08-24)
|
||||
|
||||
@@ -644,6 +644,19 @@
|
||||
返回 `False`,不会因清空句柄而丢失重试能力。Telegram 配置、菜单、消息、typing 语义与类 identity 未变。
|
||||
- 本阶段收口宿主 Capability Runtime 生命周期结果;未修改插件仓、SDK/Compat 映射或 V1/V2/V3 插件 Hook。
|
||||
|
||||
### 长期整改阶段 62:消息渠道长连接关闭收敛合同统一(2026-08-24)
|
||||
|
||||
- 阶段 61 只让 Telegram polling 接入了 Host Module 的布尔收敛合同;QQBot、企业微信、
|
||||
WeChatClawBot、飞书和 Discord 的 Gateway/WebSocket/polling 线程仍在 join 超时后返回 `None`,
|
||||
Slack 的 Socket Mode close 异常也只写日志。WeChatClawBot 还会无条件清空仍存活的轮询线程句柄,形成同一目标的两套实现。
|
||||
- 当前七个消息渠道模块统一复用 `_MessageChannelModuleBase._stop_service_instances()`:逐实例继续尽力停止,
|
||||
任一客户端异常或显式 `False` 都聚合为模块未收敛,再经 HostModuleAdapter 与 Capability Runtime 向 startup
|
||||
传播。各长连接客户端使用既有有界等待预算,只有线程真实终止才返回成功;超时 owner 和句柄继续保留供
|
||||
后续 shutdown 重试。
|
||||
- 渠道名称、配置字段、优先级、消息解析、发送与命令 Hook、类 identity 均未改变;抽象 Module `stop()`
|
||||
只扩展为可选布尔结果,既有返回 `None` 的宿主模块和 V1/V2/V3 插件仍按成功兼容处理。未修改插件仓、
|
||||
SDK/Compat 映射或事件 payload。
|
||||
|
||||
### 总体判断
|
||||
|
||||
当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**:
|
||||
|
||||
@@ -195,6 +195,8 @@ Host Module 的 `stop()` 可以显式返回 `False` 表示资源 owner 尚未收
|
||||
ModuleManager 与 startup 组合根继续关闭其余资源但必须向上返回未收敛,不得把记录日志等同于成功。
|
||||
同步和异步 Capability Runtime 的 `shutdown` 必须使用同一布尔收敛合同;Agent、Managed Resource
|
||||
等领域关闭入口必须直接传播 Runtime 的整体结果,不得以单个能力快照或无返回包装器覆盖失败。
|
||||
消息渠道模块必须通过 `_MessageChannelModuleBase._stop_service_instances()` 聚合多实例关闭结果;
|
||||
长连接、轮询或 Socket 服务只有在真实终止后才能返回成功,超时 owner 不得清空句柄。
|
||||
API 中允许丢失或可重建的进程内任务必须登记到 `app/runtime/tasks.py`;登记器先于其他
|
||||
运行资源启动,并在资源释放前停止接收、取消和有限等待。需要崩溃恢复的 E2/E3 副作用仍应
|
||||
进入 Outbox 或持久任务表,不能把 TaskRegistry 当成 durable queue。
|
||||
|
||||
@@ -5,18 +5,24 @@ import pytest
|
||||
|
||||
from app.modules import _MessageBase
|
||||
from app.modules.discord import DiscordModule
|
||||
from app.modules.discord.discord import Discord
|
||||
from app.modules.feishu import FeishuModule
|
||||
from app.modules.feishu.feishu import Feishu
|
||||
from app.modules.filter import FilterModule
|
||||
from app.modules.plex import PlexModule
|
||||
from app.modules.qqbot.module import QQBotModule
|
||||
from app.modules.qqbot.qqbot import QQBot
|
||||
from app.modules.slack import SlackModule
|
||||
from app.modules.slack.slack import Slack
|
||||
from app.modules.telegram.module import TelegramModule
|
||||
from app.modules.telegram.telegram import Telegram
|
||||
from app.modules.themoviedb import TheMovieDbModule
|
||||
from app.modules.trimemedia.module import TrimeMediaModule
|
||||
from app.modules.ugreen.module import UgreenModule
|
||||
from app.modules.wechat import WechatModule
|
||||
from app.modules.wechat.wechatbot import WeChatBot
|
||||
from app.modules.wechatclawbot import WechatClawBotModule
|
||||
from app.modules.wechatclawbot.wechatclawbot import WechatClawBot
|
||||
|
||||
|
||||
def test_config_reload_stops_before_initializing_latest_generation():
|
||||
@@ -217,9 +223,23 @@ def test_telegram_stop_keeps_polling_owner_when_thread_misses_deadline():
|
||||
assert client._polling_thread is polling_thread
|
||||
|
||||
|
||||
def test_telegram_module_reports_nonconverging_instance_after_stopping_peers():
|
||||
"""单实例未收敛时模块必须继续停止其余实例并返回 False。"""
|
||||
module = TelegramModule()
|
||||
@pytest.mark.parametrize(
|
||||
"module_type",
|
||||
[
|
||||
DiscordModule,
|
||||
FeishuModule,
|
||||
QQBotModule,
|
||||
SlackModule,
|
||||
TelegramModule,
|
||||
WechatModule,
|
||||
WechatClawBotModule,
|
||||
],
|
||||
)
|
||||
def test_message_channel_module_reports_nonconverging_instance_after_peers(
|
||||
module_type,
|
||||
):
|
||||
"""渠道单实例未收敛时必须继续停止其余实例并返回 False。"""
|
||||
module = module_type()
|
||||
blocked_client = Mock()
|
||||
blocked_client.stop.return_value = False
|
||||
healthy_client = Mock()
|
||||
@@ -232,3 +252,114 @@ def test_telegram_module_reports_nonconverging_instance_after_stopping_peers():
|
||||
assert module.stop() is False
|
||||
blocked_client.stop.assert_called_once_with()
|
||||
healthy_client.stop.assert_called_once_with()
|
||||
|
||||
|
||||
def test_qqbot_stop_retains_gateway_thread_until_retry() -> None:
|
||||
"""QQ Gateway 超时后不得把活线程伪装成已收敛。"""
|
||||
client = QQBot.__new__(QQBot)
|
||||
client._gateway_stop = threading.Event()
|
||||
client._gateway_ws_holder = []
|
||||
gateway_thread = Mock()
|
||||
gateway_thread.is_alive.return_value = True
|
||||
client._gateway_thread = gateway_thread
|
||||
client._gateway_join_timeout_seconds = 0.01
|
||||
|
||||
assert client.stop() is False
|
||||
assert client._gateway_thread is gateway_thread
|
||||
|
||||
gateway_thread.is_alive.return_value = False
|
||||
assert client.stop() is True
|
||||
|
||||
|
||||
def test_wechat_bot_stop_reports_each_live_thread_until_retry() -> None:
|
||||
"""企业微信网关或心跳任一存活时都必须返回未收敛。"""
|
||||
client = WeChatBot.__new__(WeChatBot)
|
||||
client._stop_event = threading.Event()
|
||||
client._authenticated = threading.Event()
|
||||
client._ws_app = None
|
||||
ws_thread = Mock()
|
||||
heartbeat_thread = Mock()
|
||||
ws_thread.is_alive.return_value = True
|
||||
heartbeat_thread.is_alive.return_value = True
|
||||
client._ws_thread = ws_thread
|
||||
client._heartbeat_thread = heartbeat_thread
|
||||
client._gateway_join_timeout_seconds = 0.01
|
||||
client._heartbeat_join_timeout_seconds = 0.01
|
||||
|
||||
assert client.stop() is False
|
||||
assert client._ws_thread is ws_thread
|
||||
assert client._heartbeat_thread is heartbeat_thread
|
||||
|
||||
ws_thread.is_alive.return_value = False
|
||||
heartbeat_thread.is_alive.return_value = False
|
||||
assert client.stop() is True
|
||||
|
||||
|
||||
def test_wechat_clawbot_stop_keeps_poll_owner_until_retry() -> None:
|
||||
"""ClawBot 轮询超时后必须保留线程句柄供后续关闭重试。"""
|
||||
client = WechatClawBot.__new__(WechatClawBot)
|
||||
client._stop_event = threading.Event()
|
||||
poll_thread = Mock()
|
||||
poll_thread.is_alive.return_value = True
|
||||
client._poll_thread = poll_thread
|
||||
client._poll_join_timeout_seconds = 0.01
|
||||
|
||||
assert client.stop() is False
|
||||
assert client._poll_thread is poll_thread
|
||||
|
||||
poll_thread.is_alive.return_value = False
|
||||
assert client.stop() is True
|
||||
assert client._poll_thread is None
|
||||
|
||||
|
||||
def test_feishu_stop_reports_live_ws_thread_until_retry() -> None:
|
||||
"""飞书 SDK 清理后线程仍存活时不得报告关闭完成。"""
|
||||
client = Feishu.__new__(Feishu)
|
||||
client._stop_event = threading.Event()
|
||||
client._ready = threading.Event()
|
||||
client._ws_client = None
|
||||
client._ws_loop = None
|
||||
ws_thread = Mock()
|
||||
ws_thread.is_alive.return_value = True
|
||||
client._ws_thread = ws_thread
|
||||
client._ws_join_timeout_seconds = 0.01
|
||||
|
||||
assert client.stop() is False
|
||||
assert client._ws_thread is ws_thread
|
||||
|
||||
ws_thread.is_alive.return_value = False
|
||||
assert client.stop() is True
|
||||
|
||||
|
||||
def test_discord_stop_reports_live_event_loop_thread_until_retry() -> None:
|
||||
"""Discord 强制停止循环后线程仍存活时必须返回未收敛。"""
|
||||
client = Discord.__new__(Discord)
|
||||
client._client = Mock()
|
||||
client._loop = Mock()
|
||||
client._loop.is_running.return_value = False
|
||||
event_loop_thread = Mock()
|
||||
event_loop_thread.is_alive.return_value = True
|
||||
client._thread = event_loop_thread
|
||||
client._stop_requested = threading.Event()
|
||||
client._ready_event = threading.Event()
|
||||
client._thread_join_timeout_seconds = 0.01
|
||||
|
||||
assert client.stop() is False
|
||||
assert client._thread is event_loop_thread
|
||||
|
||||
event_loop_thread.is_alive.return_value = False
|
||||
assert client.stop() is True
|
||||
|
||||
|
||||
def test_slack_stop_propagates_socket_close_failure() -> None:
|
||||
"""Slack Socket Mode close 失败必须保留给 Runtime 重试。"""
|
||||
client = Slack.__new__(Slack)
|
||||
service = Mock()
|
||||
service.close.side_effect = RuntimeError("close failed")
|
||||
client._service = service
|
||||
|
||||
assert client.stop() is False
|
||||
assert client._service is service
|
||||
|
||||
service.close.side_effect = None
|
||||
assert client.stop() is True
|
||||
|
||||
Reference in New Issue
Block a user