refactor: unify message channel shutdown convergence

This commit is contained in:
jxxghp
2026-08-24 14:08:06 +08:00
parent 009631b8ee
commit 415335b215
18 changed files with 276 additions and 89 deletions
+2 -2
View File
@@ -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
+15
View File
@@ -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:
"""
注册命令,实现这个函数接收系统可用的命令菜单
+3 -7
View File
@@ -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
+12 -6
View File
@@ -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
+3 -7
View File
@@ -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]]:
"""通知模块通过系统通知配置控制实例化,这里不额外设置环境开关。"""
+16 -5
View File
@@ -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 报文。"""
+3 -7
View File
@@ -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
+17 -8
View File
@@ -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:
"""获取就绪状态"""
+3 -7
View File
@@ -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
+4 -1
View File
@@ -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:
"""
+1 -9
View File
@@ -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]]:
"""
+3 -8
View File
@@ -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:
+25 -5
View File
@@ -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()
+3 -7
View File
@@ -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 的连接探测返回 (状态, 信息)。"""
+14 -4
View File
@@ -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。
### 总体判断
当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**:
+2
View File
@@ -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。
+134 -3
View File
@@ -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