mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-02 05:56:47 +08:00
refactor: unify capability shutdown convergence
This commit is contained in:
@@ -20,10 +20,7 @@ from app.agent.capabilities.adapter import (
|
||||
build_agent_capability_registry,
|
||||
should_run_agent_service,
|
||||
)
|
||||
from app.runtime.capabilities.model import (
|
||||
CapabilityLifecycleState,
|
||||
CapabilityMaterializationState,
|
||||
)
|
||||
from app.runtime.capabilities.model import CapabilityMaterializationState
|
||||
from app.runtime.capabilities.runtime import CapabilityRuntime
|
||||
|
||||
|
||||
@@ -146,10 +143,8 @@ async def close_materialized_terminal_sessions() -> None:
|
||||
|
||||
|
||||
async def begin_agent_shutdown() -> bool:
|
||||
"""不可逆关闭首用闸门,并返回 Agent service 是否真实收敛。"""
|
||||
"""不可逆关闭首用闸门,并返回全部 Agent 能力是否真实收敛。"""
|
||||
runtime = _ensure_runtime()
|
||||
await runtime.shutdown_async(reason="application_shutdown")
|
||||
return (
|
||||
runtime.snapshot(AGENT_SERVICE_CAPABILITY_ID).lifecycle
|
||||
is CapabilityLifecycleState.STOPPED
|
||||
return await runtime.shutdown_async(
|
||||
reason="application_shutdown",
|
||||
)
|
||||
|
||||
@@ -72,13 +72,17 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]):
|
||||
"""
|
||||
return 0
|
||||
|
||||
def stop(self) -> None:
|
||||
"""停止模块"""
|
||||
def stop(self) -> bool:
|
||||
"""停止全部 Telegram 实例,并返回资源是否全部收敛。"""
|
||||
converged = True
|
||||
for client in self.get_instances().values():
|
||||
try:
|
||||
client.stop()
|
||||
if client.stop() is False:
|
||||
converged = False
|
||||
except Exception as err:
|
||||
logger.error(f"停止Telegram模块实例失败:{err}")
|
||||
converged = False
|
||||
return converged
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
"""
|
||||
|
||||
@@ -84,6 +84,7 @@ class Telegram:
|
||||
_typing_command_max_duration_seconds = 30
|
||||
_typing_callback_max_duration_seconds = 60
|
||||
_typing_join_timeout_seconds = 1
|
||||
_polling_join_timeout_seconds = 10
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1742,21 +1743,32 @@ class Telegram:
|
||||
# 清理菜单命令
|
||||
self._bot.delete_my_commands()
|
||||
|
||||
def stop(self) -> None:
|
||||
def stop(self) -> bool:
|
||||
"""
|
||||
停止Telegram消息接收服务
|
||||
停止 Telegram 消息接收服务,并返回 polling/typing owner 是否收敛。
|
||||
"""
|
||||
converged = True
|
||||
with self._typing_lifecycle_lock:
|
||||
self._typing_accepting = False
|
||||
# 封口与 owner 快照处于同一临界区,停止后不会漏掉并发新增任务。
|
||||
for chat_id in list(self._typing_tasks.keys()):
|
||||
self._stop_typing_task(chat_id)
|
||||
if not self._bot:
|
||||
return
|
||||
if not self._stop_typing_task(chat_id):
|
||||
converged = False
|
||||
|
||||
self._bot.stop_bot()
|
||||
if self._polling_thread:
|
||||
self._polling_thread.join()
|
||||
bot = self._bot
|
||||
polling_thread = self._polling_thread
|
||||
if bot:
|
||||
bot.stop_bot()
|
||||
if (
|
||||
polling_thread
|
||||
and polling_thread.is_alive()
|
||||
and polling_thread is not threading.current_thread()
|
||||
):
|
||||
polling_thread.join(timeout=self._polling_join_timeout_seconds)
|
||||
if polling_thread and polling_thread.is_alive():
|
||||
logger.error("Telegram polling 线程未在关闭预算内退出")
|
||||
return False
|
||||
self._polling_thread = None
|
||||
self._bot = None
|
||||
logger.info("Telegram消息接收服务已停止")
|
||||
return converged
|
||||
|
||||
@@ -1126,7 +1126,8 @@ class CapabilityRuntime:
|
||||
"""同步撤销并停止能力资源;物化实现保留供后续显式重启。"""
|
||||
self._stop_sync(capability_id, reason=reason, shutdown=False)
|
||||
|
||||
def _stop_sync(self, capability_id: str, *, reason: str, shutdown: bool) -> None:
|
||||
def _stop_sync(self, capability_id: str, *, reason: str, shutdown: bool) -> bool:
|
||||
"""停止同步能力并返回资源 owner 是否已经真实收敛。"""
|
||||
state = self._state(capability_id)
|
||||
adapter = self._adapter(state, AdapterExecutionMode.SYNC)
|
||||
while True:
|
||||
@@ -1146,7 +1147,7 @@ class CapabilityRuntime:
|
||||
if stop_owner is None and pending is None:
|
||||
if state.lifecycle is not CapabilityLifecycleState.FAILED:
|
||||
state.lifecycle = CapabilityLifecycleState.STOPPED
|
||||
return
|
||||
return True
|
||||
state.generation += 1
|
||||
generation = state.generation
|
||||
future: Future[Any] = Future()
|
||||
@@ -1166,8 +1167,10 @@ class CapabilityRuntime:
|
||||
except BaseException:
|
||||
if not shutdown:
|
||||
raise
|
||||
if waiter_operation == "stop":
|
||||
return False
|
||||
if waiter_operation == "stop":
|
||||
return
|
||||
return True
|
||||
continue
|
||||
break
|
||||
|
||||
@@ -1207,6 +1210,7 @@ class CapabilityRuntime:
|
||||
reason=reason,
|
||||
started_at=started_at,
|
||||
)
|
||||
return True
|
||||
except BaseException as error:
|
||||
with state.lock:
|
||||
state.lifecycle = CapabilityLifecycleState.FAILED
|
||||
@@ -1229,12 +1233,20 @@ class CapabilityRuntime:
|
||||
)
|
||||
if not shutdown:
|
||||
raise operation_error from error
|
||||
return False
|
||||
|
||||
async def stop_async(self, capability_id: str, *, reason: str) -> None:
|
||||
"""异步撤销并停止能力资源。"""
|
||||
await self._stop_async(capability_id, reason=reason, shutdown=False)
|
||||
|
||||
async def _stop_async(self, capability_id: str, *, reason: str, shutdown: bool) -> None:
|
||||
async def _stop_async(
|
||||
self,
|
||||
capability_id: str,
|
||||
*,
|
||||
reason: str,
|
||||
shutdown: bool,
|
||||
) -> bool:
|
||||
"""停止异步能力并返回资源 owner 是否已经真实收敛。"""
|
||||
state = self._state(capability_id)
|
||||
adapter = self._adapter(state, AdapterExecutionMode.ASYNC)
|
||||
while True:
|
||||
@@ -1254,7 +1266,7 @@ class CapabilityRuntime:
|
||||
if stop_owner is None and pending is None:
|
||||
if state.lifecycle is not CapabilityLifecycleState.FAILED:
|
||||
state.lifecycle = CapabilityLifecycleState.STOPPED
|
||||
return
|
||||
return True
|
||||
state.generation += 1
|
||||
generation = state.generation
|
||||
future: Future[Any] = Future()
|
||||
@@ -1274,8 +1286,10 @@ class CapabilityRuntime:
|
||||
except BaseException:
|
||||
if not shutdown:
|
||||
raise
|
||||
if waiter_operation == "stop":
|
||||
return False
|
||||
if waiter_operation == "stop":
|
||||
return
|
||||
return True
|
||||
continue
|
||||
break
|
||||
|
||||
@@ -1321,6 +1335,7 @@ class CapabilityRuntime:
|
||||
reason=reason,
|
||||
started_at=started_at,
|
||||
)
|
||||
return True
|
||||
except BaseException as error:
|
||||
with state.lock:
|
||||
state.lifecycle = CapabilityLifecycleState.FAILED
|
||||
@@ -1343,9 +1358,10 @@ class CapabilityRuntime:
|
||||
)
|
||||
if not shutdown:
|
||||
raise operation_error from error
|
||||
return False
|
||||
|
||||
def shutdown(self, *, reason: str) -> None:
|
||||
"""不可逆关闭仅含同步 adapter 的 Runtime,并阻止并发首启重新发布。"""
|
||||
def shutdown(self, *, reason: str) -> bool:
|
||||
"""不可逆关闭同步 Runtime,并返回全部 owner 是否收敛。"""
|
||||
async_kinds = {
|
||||
kind
|
||||
for kind, adapter in self._adapters.items()
|
||||
@@ -1357,21 +1373,32 @@ class CapabilityRuntime:
|
||||
)
|
||||
with self._shutdown_lock:
|
||||
self._shutdown = True
|
||||
converged = True
|
||||
for spec in self._registry.list_specs():
|
||||
self._stop_sync(spec.id, reason=reason, shutdown=True)
|
||||
if not self._stop_sync(spec.id, reason=reason, shutdown=True):
|
||||
converged = False
|
||||
return converged
|
||||
|
||||
async def shutdown_async(self, *, reason: str) -> None:
|
||||
"""不可逆关闭混合同步/异步 adapter 的 Runtime。"""
|
||||
async def shutdown_async(self, *, reason: str) -> bool:
|
||||
"""不可逆关闭混合 Runtime,并返回全部 owner 是否收敛。"""
|
||||
with self._shutdown_lock:
|
||||
self._shutdown = True
|
||||
converged = True
|
||||
for spec in self._registry.list_specs():
|
||||
adapter = self._adapters[spec.kind]
|
||||
if getattr(adapter, "execution_mode", None) is AdapterExecutionMode.ASYNC:
|
||||
await self._stop_async(spec.id, reason=reason, shutdown=True)
|
||||
stopped = await self._stop_async(
|
||||
spec.id,
|
||||
reason=reason,
|
||||
shutdown=True,
|
||||
)
|
||||
else:
|
||||
await asyncio.to_thread(
|
||||
stopped = await asyncio.to_thread(
|
||||
self._stop_sync,
|
||||
spec.id,
|
||||
reason=reason,
|
||||
shutdown=True,
|
||||
)
|
||||
if not stopped:
|
||||
converged = False
|
||||
return converged
|
||||
|
||||
@@ -247,8 +247,9 @@ class HostModuleAdapter:
|
||||
@staticmethod
|
||||
def stop(spec: CapabilitySpec, instance: Any, generation: int) -> None:
|
||||
"""停止实例拥有的资源;Runtime 会先撤销其运行态可见性。"""
|
||||
del spec, generation
|
||||
instance.stop()
|
||||
del generation
|
||||
if instance.stop() is False:
|
||||
raise RuntimeError(f"Host Module {spec.id} 资源未完成收口")
|
||||
|
||||
@staticmethod
|
||||
def cleanup(
|
||||
|
||||
@@ -224,13 +224,17 @@ class ModuleManager(metaclass=Singleton):
|
||||
self._refresh_running_projection()
|
||||
logger.info("所有模块停止完成")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""进程关闭时不可逆停止 Runtime,阻止并发能力重新发布。"""
|
||||
def shutdown(self) -> bool:
|
||||
"""不可逆停止 Runtime,并返回所有模块 owner 是否收敛。"""
|
||||
logger.info("正在关闭模块运行时...")
|
||||
with self._lifecycle_lock:
|
||||
self._runtime.shutdown(reason="application_shutdown")
|
||||
converged = self._runtime.shutdown(reason="application_shutdown")
|
||||
self._refresh_running_projection()
|
||||
logger.info("模块运行时关闭完成")
|
||||
if converged:
|
||||
logger.info("模块运行时关闭完成")
|
||||
else:
|
||||
logger.error("模块运行时关闭后仍有资源 owner 未收敛")
|
||||
return converged
|
||||
|
||||
def reload(self) -> None:
|
||||
"""保留旧插件可观察的 stop、load、ModuleReload 同步顺序。"""
|
||||
|
||||
@@ -48,8 +48,8 @@ class ManagedResourceRuntime(Protocol):
|
||||
async def stop_async(self, capability_id: str, *, reason: str) -> None:
|
||||
"""通过异步 adapter 停止资源。"""
|
||||
|
||||
async def shutdown_async(self, *, reason: str) -> None:
|
||||
"""关闭混合同步和异步 adapter 的 Runtime。"""
|
||||
async def shutdown_async(self, *, reason: str) -> bool:
|
||||
"""关闭混合 Runtime,并返回全部资源是否收敛。"""
|
||||
|
||||
|
||||
_runtime_lock = threading.RLock()
|
||||
@@ -174,9 +174,9 @@ async def stop_managed_resource_async(capability_id: str, *, reason: str) -> Non
|
||||
raise RuntimeError(f"未知 Managed Resource kind:{kind}")
|
||||
|
||||
|
||||
async def shutdown_managed_resource_runtime(*, reason: str) -> None:
|
||||
"""关闭已配置 Runtime;未配置时直接返回,绝不因关闭而创建资源。"""
|
||||
async def shutdown_managed_resource_runtime(*, reason: str) -> bool:
|
||||
"""关闭已配置 Runtime,未配置时按已收敛处理。"""
|
||||
runtime = _runtime(required=False)
|
||||
if runtime is None:
|
||||
return
|
||||
await runtime.shutdown_async(reason=reason)
|
||||
return True
|
||||
return await runtime.shutdown_async(reason=reason)
|
||||
|
||||
@@ -38,10 +38,10 @@ def init_managed_resources() -> CapabilityRuntime:
|
||||
return _managed_resource_runtime
|
||||
|
||||
|
||||
async def stop_managed_resources() -> None:
|
||||
"""关闭已经初始化的资源 Runtime;未初始化时不执行发现或激活。"""
|
||||
async def stop_managed_resources() -> bool:
|
||||
"""关闭已初始化的资源 Runtime,并返回 owner 是否收敛。"""
|
||||
with _runtime_lock:
|
||||
runtime = _managed_resource_runtime
|
||||
if runtime is None:
|
||||
return
|
||||
await runtime.shutdown_async(reason="application_shutdown")
|
||||
return True
|
||||
return await runtime.shutdown_async(reason="application_shutdown")
|
||||
|
||||
@@ -601,23 +601,36 @@ async def settle_events() -> bool:
|
||||
return await event_manager.drain_async(seal=False)
|
||||
|
||||
|
||||
async def stop_modules():
|
||||
async def stop_modules() -> bool:
|
||||
"""
|
||||
服务关闭
|
||||
关闭模块服务,并返回全部资源 owner 是否收敛。
|
||||
"""
|
||||
async def run_step(name: str, callback: Callable[[], object]) -> bool:
|
||||
"""单个模块资源关闭失败时继续执行后续阶段"""
|
||||
all_converged = True
|
||||
|
||||
async def run_step(
|
||||
name: str,
|
||||
callback: Callable[[], object],
|
||||
*,
|
||||
record_failure: bool = True,
|
||||
) -> bool:
|
||||
"""执行单个关闭步骤,失败时继续收口并保留诚实结果。"""
|
||||
nonlocal all_converged
|
||||
try:
|
||||
result = callback()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
return True
|
||||
result = await result
|
||||
converged = result is not False
|
||||
if not converged:
|
||||
logger.error("关闭%s未收敛,继续执行后续资源收口", name)
|
||||
except asyncio.CancelledError:
|
||||
logger.warning("关闭%s时收到取消请求,继续执行资源收口", name)
|
||||
return False
|
||||
converged = False
|
||||
except Exception as err:
|
||||
logger.error(f"关闭{name}失败:{err}")
|
||||
return True
|
||||
converged = False
|
||||
if not converged and record_failure:
|
||||
all_converged = False
|
||||
return converged
|
||||
|
||||
await run_step("图片代理安全日志合并器", close_image_proxy_block_log_coalescer)
|
||||
await run_step("模块", lambda: ModuleManager().shutdown())
|
||||
@@ -631,7 +644,9 @@ async def stop_modules():
|
||||
await run_step("异步Redis缓存连接", lambda: AsyncRedisHelper().close())
|
||||
# Web Agent 的取消 finally 可能还要写入最终展示快照,必须先完成任务收尾,再关闭写入准入。
|
||||
web_agent_drained = await run_step(
|
||||
"Web Agent后台任务", shutdown_web_agent_background_tasks
|
||||
"Web Agent后台任务",
|
||||
shutdown_web_agent_background_tasks,
|
||||
record_failure=False,
|
||||
)
|
||||
if not web_agent_drained:
|
||||
web_agent_drained = await run_step(
|
||||
@@ -648,15 +663,18 @@ async def stop_modules():
|
||||
)
|
||||
else:
|
||||
persistence_drained = False
|
||||
all_converged = False
|
||||
logger.error("Web Agent任务未完成收尾,跳过持久化和数据库关闭以保护活动事务")
|
||||
if persistence_drained:
|
||||
await run_step("数据库任务", stop_database_worker)
|
||||
if _database_worker is None:
|
||||
await run_step("数据库连接", close_database)
|
||||
else:
|
||||
all_converged = False
|
||||
logger.error("数据库任务未收敛,跳过数据库连接关闭以避免运行中事务使用已释放连接")
|
||||
await run_step("前端服务", stop_frontend)
|
||||
await run_step("临时文件", clear_temp)
|
||||
return all_converged
|
||||
|
||||
|
||||
async def init_modules() -> HostRuntime:
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
> 文档性质:当前架构复核、优秀 Python 后端实践对标、AI 可执行任务手册
|
||||
> 适用仓库:`MoviePilot`,分支 `v3`
|
||||
> 审计基线:`af9a141f`(2026-08-24)
|
||||
> 审计基线:`2aa41ea8`(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 声明。
|
||||
> 实施进度:阶段 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 同步/异步关闭的诚实收敛结果。
|
||||
> 当前 canonical 状态:API/Application 公共复杂度基线已清零,组合根外 `SystemConfigOper()` 构造和 Model/Oper 隐式事务均为 0;命名 Chain/Agent 数据端口、TaskRegistry owner、Module Contract V2、typed Event、Outbox durable intent、请求关联和插件运行时 getter 已形成当前路径。插件仓适配、未知第三方 fallback 和其它 E1/E3 副作用仍按风险持续治理。
|
||||
> 最新阶段:阶段 60 已统一命令重建任务的唯一关停 owner。
|
||||
> 最新阶段:阶段 61 已统一 Capability Runtime 关闭收敛事实源。
|
||||
|
||||
## 当前复核结论(2026-08-24)
|
||||
|
||||
@@ -631,6 +631,19 @@
|
||||
- `Command` 类身份、Application 命令门面、插件命令 Hook、热更新时序和 SDK/Compat 均未修改;
|
||||
V1/V2/V3 插件仍通过原有注册链路生效,且未修改插件仓。
|
||||
|
||||
### 长期整改阶段 61:Capability Runtime 关闭收敛事实源统一(2026-08-24)
|
||||
|
||||
- Capability Runtime 原本会在 stop 异常时保留 `pending_stop`,但同步 `shutdown()` 把结果丢弃;
|
||||
`HostModuleAdapter` 同时忽略模块显式返回的 `False`,startup 内部 `run_step()` 又把异常视为成功。
|
||||
这三层会让未释放资源仅留下日志,对外却报告整体关闭完成。
|
||||
- 当前以 Capability Runtime 的同步/异步 stop 作为单一事实源:Host Module `stop() is False` 会进入失败
|
||||
路径并保留原 owner,Runtime/ModuleManager/startup 逐层返回未收敛;Agent 与 Managed Resource 关闭入口
|
||||
也直接传播 Runtime 的整体布尔结果,不再用单个 service 快照或无返回包装器形成并行判断。其余模块、
|
||||
数据库和临时资源仍继续尽力关闭,但失败不再被伪装成成功;后续重试成功后同一 owner 才会进入终态。
|
||||
- Telegram polling 是首个接入该合同的长连接:无界 `join()` 改为 10 秒预算,超时时保留 SDK 与线程句柄并
|
||||
返回 `False`,不会因清空句柄而丢失重试能力。Telegram 配置、菜单、消息、typing 语义与类 identity 未变。
|
||||
- 本阶段收口宿主 Capability Runtime 生命周期结果;未修改插件仓、SDK/Compat 映射或 V1/V2/V3 插件 Hook。
|
||||
|
||||
### 总体判断
|
||||
|
||||
当前架构总体合理,已经从跨层混合的遗留单体收敛为**边界清晰的模块化单体**:
|
||||
|
||||
@@ -190,6 +190,11 @@ mechanism remains in `app/adapters/system/resource.py`.
|
||||
应用级启动顺序使用 `app/startup/lifecycle/components.py` 的组件描述声明依赖、
|
||||
normal/safe-mode 范围、start/stop 顺序、超时预算和失败策略。新增进程级资源不得只在
|
||||
`lifespan()` 中追加过程代码,必须先进入可导出的生命周期清单并补顺序快照测试。
|
||||
Host Module 的 `stop()` 可以显式返回 `False` 表示资源 owner 尚未收敛;
|
||||
`HostModuleAdapter` 必须将它视为 stop 失败,Capability Runtime 保留原 owner 供后续重试,
|
||||
ModuleManager 与 startup 组合根继续关闭其余资源但必须向上返回未收敛,不得把记录日志等同于成功。
|
||||
同步和异步 Capability Runtime 的 `shutdown` 必须使用同一布尔收敛合同;Agent、Managed Resource
|
||||
等领域关闭入口必须直接传播 Runtime 的整体结果,不得以单个能力快照或无返回包装器覆盖失败。
|
||||
API 中允许丢失或可重建的进程内任务必须登记到 `app/runtime/tasks.py`;登记器先于其他
|
||||
运行资源启动,并在资源释放前停止接收、取消和有限等待。需要崩溃恢复的 E2/E3 副作用仍应
|
||||
进入 Outbox 或持久任务表,不能把 TaskRegistry 当成 durable queue。
|
||||
|
||||
@@ -91,6 +91,32 @@ async def test_shutdown_retains_nonconverged_agent_service_for_retry(
|
||||
assert manager.close_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shutdown_propagates_runtime_wide_convergence(
|
||||
runtime_loader,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Agent 关闭不得用单个 service 快照覆盖 Runtime 的整体结果。"""
|
||||
|
||||
class RuntimeWithIndependentShutdownResult:
|
||||
"""模拟其它 Agent 能力失败而 service 已停止的 Runtime。"""
|
||||
|
||||
async def shutdown_async(self, *, reason: str) -> bool:
|
||||
"""记录关闭原因并返回 Runtime 级未收敛。"""
|
||||
assert reason == "application_shutdown"
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def snapshot(_capability_id: str) -> types.SimpleNamespace:
|
||||
"""提供旧实现读取的已停止 service 快照。"""
|
||||
return types.SimpleNamespace(lifecycle=CapabilityLifecycleState.STOPPED)
|
||||
|
||||
runtime = RuntimeWithIndependentShutdownResult()
|
||||
monkeypatch.setattr(runtime_loader, "_ensure_runtime", lambda: runtime)
|
||||
|
||||
assert await runtime_loader.begin_agent_shutdown() is False
|
||||
|
||||
|
||||
def _fake_agent_modules(manager: object | None = None) -> dict[str, types.ModuleType]:
|
||||
orchestrator = types.ModuleType("app.agent.orchestrator")
|
||||
orchestrator.agent_manager = manager if manager is not None else object()
|
||||
|
||||
@@ -791,6 +791,29 @@ async def test_stop_async_failure_retains_ownership_for_explicit_retry(tmp_path:
|
||||
assert runtime.snapshot("sample.capability").lifecycle is CapabilityLifecycleState.STOPPED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_async_reports_unreleased_owner_until_retry(tmp_path: Path) -> None:
|
||||
"""异步 shutdown 不得把 stop 失败伪装成整体成功。"""
|
||||
adapter = _AsyncAdapter()
|
||||
runtime = CapabilityRuntime(_registry(tmp_path), adapters={"sample": adapter})
|
||||
activation = asyncio.create_task(
|
||||
runtime.activate_async("sample.capability", reason="initial")
|
||||
)
|
||||
await adapter.start_entered.wait()
|
||||
adapter.start_release.set()
|
||||
instance = await activation
|
||||
adapter.fail_stop = True
|
||||
|
||||
assert await runtime.shutdown_async(reason="application_shutdown") is False
|
||||
assert runtime.snapshot(
|
||||
"sample.capability"
|
||||
).lifecycle is CapabilityLifecycleState.FAILED
|
||||
|
||||
adapter.fail_stop = False
|
||||
assert await runtime.shutdown_async(reason="shutdown_retry") is True
|
||||
assert adapter.stop_instances == [instance, instance]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_reload_uses_reloading_state_and_hides_candidate(tmp_path: Path) -> None:
|
||||
"""异步 reload 与同步入口遵守相同状态和发布边界。"""
|
||||
|
||||
@@ -938,8 +938,21 @@ def test_stop_modules_continues_after_internal_owner_failure(monkeypatch):
|
||||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||||
dependencies["module"].side_effect = RuntimeError("module failed")
|
||||
|
||||
asyncio.run(modules_initializer.stop_modules())
|
||||
converged = asyncio.run(modules_initializer.stop_modules())
|
||||
|
||||
assert converged is False
|
||||
for dependency in dependencies.values():
|
||||
_assert_completed_once(dependency)
|
||||
|
||||
|
||||
def test_stop_modules_propagates_false_without_skipping_later_cleanup(monkeypatch):
|
||||
"""关闭回调显式返回 False 时不得被转换为整体成功。"""
|
||||
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
|
||||
dependencies["module"].return_value = False
|
||||
|
||||
converged = asyncio.run(modules_initializer.stop_modules())
|
||||
|
||||
assert converged is False
|
||||
for dependency in dependencies.values():
|
||||
_assert_completed_once(dependency)
|
||||
|
||||
@@ -963,15 +976,22 @@ def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch):
|
||||
"get_configured_agent_chat_persistence",
|
||||
MagicMock(return_value=persistence),
|
||||
)
|
||||
|
||||
async def stop_database_worker() -> None:
|
||||
"""模拟生产 worker 关闭后释放组合根句柄。"""
|
||||
order.append("database")
|
||||
modules_initializer._database_worker = None
|
||||
|
||||
monkeypatch.setattr(
|
||||
modules_initializer,
|
||||
"stop_database_worker",
|
||||
AsyncMock(side_effect=lambda: order.append("database")),
|
||||
AsyncMock(side_effect=stop_database_worker),
|
||||
)
|
||||
monkeypatch.setattr(modules_initializer, "_database_worker", object())
|
||||
|
||||
asyncio.run(modules_initializer.stop_modules())
|
||||
converged = asyncio.run(modules_initializer.stop_modules())
|
||||
|
||||
assert converged is True
|
||||
assert order == ["web-agent", "persistence-admission", "persistence", "database"]
|
||||
|
||||
|
||||
|
||||
@@ -143,7 +143,10 @@ def test_sync_managed_resource_is_single_flight(
|
||||
if observation.operation == "activate"
|
||||
] == ["started", "succeeded"]
|
||||
|
||||
asyncio.run(shutdown_managed_resource_runtime(reason="test_shutdown"))
|
||||
assert (
|
||||
asyncio.run(shutdown_managed_resource_runtime(reason="test_shutdown"))
|
||||
is True
|
||||
)
|
||||
|
||||
assert SyncResource.instances[0].stopped == 1
|
||||
with pytest.raises(CapabilityRuntimeClosedError):
|
||||
@@ -198,6 +201,55 @@ def test_async_managed_resource_uses_async_adapter(
|
||||
assert AsyncResource.instances == [resource]
|
||||
|
||||
|
||||
def test_shutdown_propagates_stop_failure_and_retains_owner_for_retry(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""托管资源关闭失败必须向 startup 传播,并保留同一 owner 重试。"""
|
||||
module_name = "fixture_retry_stop_managed_resource"
|
||||
module = ModuleType(module_name)
|
||||
|
||||
class RetryStopResource:
|
||||
"""首次停止失败、第二次停止收敛的同步资源。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.stop_calls = 0
|
||||
self.fail_stop = True
|
||||
|
||||
def start(self) -> None:
|
||||
"""资源启动无需额外动作。"""
|
||||
|
||||
def stop(self) -> None:
|
||||
"""按测试开关模拟资源释放失败。"""
|
||||
self.stop_calls += 1
|
||||
if self.fail_stop:
|
||||
raise RuntimeError("stop failed")
|
||||
|
||||
module.RetryStopResource = RetryStopResource
|
||||
monkeypatch.setitem(sys.modules, module_name, module)
|
||||
_write_manifest(
|
||||
tmp_path,
|
||||
capability_id="fixture.retry_stop",
|
||||
kind=MANAGED_RESOURCE_SYNC_KIND,
|
||||
entrypoint=f"{module_name}:RetryStopResource",
|
||||
)
|
||||
configure_managed_resource_runtime(_runtime(tmp_path))
|
||||
resource = acquire_managed_resource("fixture.retry_stop", reason="test")
|
||||
|
||||
assert (
|
||||
asyncio.run(shutdown_managed_resource_runtime(reason="test_shutdown"))
|
||||
is False
|
||||
)
|
||||
assert resource.stop_calls == 1
|
||||
|
||||
resource.fail_stop = False
|
||||
assert (
|
||||
asyncio.run(shutdown_managed_resource_runtime(reason="shutdown_retry"))
|
||||
is True
|
||||
)
|
||||
assert resource.stop_calls == 2
|
||||
|
||||
|
||||
def test_failed_start_is_cleaned_before_explicit_retry(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -334,6 +386,6 @@ def test_startup_shutdown_without_init_does_not_build_registry(monkeypatch) -> N
|
||||
build_registry,
|
||||
)
|
||||
|
||||
asyncio.run(managed_resources_initializer.stop_managed_resources())
|
||||
assert asyncio.run(managed_resources_initializer.stop_managed_resources()) is True
|
||||
|
||||
build_registry.assert_not_called()
|
||||
|
||||
@@ -176,6 +176,7 @@ def test_telegram_stop_closes_sdk_and_waits_for_polling_thread():
|
||||
bot = Mock()
|
||||
client._bot = bot
|
||||
polling_thread = Mock()
|
||||
polling_thread.is_alive.side_effect = [True, False]
|
||||
client._polling_thread = polling_thread
|
||||
client._typing_tasks = {}
|
||||
client._typing_stop_flags = {}
|
||||
@@ -183,10 +184,51 @@ def test_telegram_stop_closes_sdk_and_waits_for_polling_thread():
|
||||
client._typing_lifecycle_lock = threading.RLock()
|
||||
client._typing_accepting = True
|
||||
|
||||
client.stop()
|
||||
client.stop()
|
||||
assert client.stop() is True
|
||||
assert client.stop() is True
|
||||
|
||||
bot.stop_bot.assert_called_once_with()
|
||||
polling_thread.join.assert_called_once_with()
|
||||
polling_thread.join.assert_called_once_with(
|
||||
timeout=client._polling_join_timeout_seconds
|
||||
)
|
||||
assert client._bot is None
|
||||
assert client._polling_thread is None
|
||||
|
||||
|
||||
def test_telegram_stop_keeps_polling_owner_when_thread_misses_deadline():
|
||||
"""polling 超过关闭预算时必须返回未收敛并保留原 owner。"""
|
||||
client = Telegram.__new__(Telegram)
|
||||
bot = Mock()
|
||||
polling_thread = Mock()
|
||||
polling_thread.is_alive.return_value = True
|
||||
client._bot = bot
|
||||
client._polling_thread = polling_thread
|
||||
client._polling_join_timeout_seconds = 0.01
|
||||
client._typing_tasks = {}
|
||||
client._typing_stop_flags = {}
|
||||
client._typing_lock = threading.RLock()
|
||||
client._typing_lifecycle_lock = threading.RLock()
|
||||
client._typing_accepting = True
|
||||
|
||||
assert client.stop() is False
|
||||
|
||||
polling_thread.join.assert_called_once_with(timeout=0.01)
|
||||
assert client._bot is bot
|
||||
assert client._polling_thread is polling_thread
|
||||
|
||||
|
||||
def test_telegram_module_reports_nonconverging_instance_after_stopping_peers():
|
||||
"""单实例未收敛时模块必须继续停止其余实例并返回 False。"""
|
||||
module = TelegramModule()
|
||||
blocked_client = Mock()
|
||||
blocked_client.stop.return_value = False
|
||||
healthy_client = Mock()
|
||||
healthy_client.stop.return_value = True
|
||||
module._instances = {
|
||||
"blocked": blocked_client,
|
||||
"healthy": healthy_client,
|
||||
}
|
||||
|
||||
assert module.stop() is False
|
||||
blocked_client.stop.assert_called_once_with()
|
||||
healthy_client.stop.assert_called_once_with()
|
||||
|
||||
@@ -16,7 +16,7 @@ import pytest
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.runtime.capabilities.errors import CapabilityRuntimeClosedError
|
||||
from app.runtime.capabilities.model import SelectorSchema
|
||||
from app.runtime.capabilities.model import CapabilityLifecycleState, SelectorSchema
|
||||
from app.runtime.capabilities.registry import CapabilityRegistry
|
||||
from app.runtime.events import Event, EventHandlerBinding, eventmanager
|
||||
from app.runtime.extensions import module_manager as module_manager_extension
|
||||
@@ -477,6 +477,23 @@ def test_shutdown_is_irreversible(module_manager_harness) -> None:
|
||||
assert type(running).instances == [running]
|
||||
|
||||
|
||||
def test_shutdown_reports_unreleased_module_owner(module_manager_harness) -> None:
|
||||
"""Host Module 返回 False 时 Runtime 必须保留 owner 并向组合根报告。"""
|
||||
manager = module_manager_harness.manager
|
||||
_enable_sample(module_manager_harness.config_values)
|
||||
manager.load_modules()
|
||||
running = manager.get_running_module("SampleModule")
|
||||
running.stop = Mock(side_effect=[False, None])
|
||||
|
||||
assert manager.shutdown() is False
|
||||
failed = manager._runtime.snapshot("SampleModule")
|
||||
assert failed.lifecycle is CapabilityLifecycleState.FAILED
|
||||
assert failed.visible is False
|
||||
|
||||
assert manager.shutdown() is True
|
||||
assert running.stop.call_count == 2
|
||||
|
||||
|
||||
def test_all_real_host_modules_zero_arg_construct_without_starting_resources(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
||||
@@ -62,6 +62,7 @@ def _telegram_client(bot=None) -> Telegram:
|
||||
"""构造不连接外部服务且持有独立运行状态的 Telegram client。"""
|
||||
telegram = Telegram.__new__(Telegram)
|
||||
telegram._bot = bot or _FakeTelegramBot()
|
||||
telegram._polling_thread = None
|
||||
telegram._telegram_token = "token"
|
||||
telegram._telegram_chat_id = "default-chat"
|
||||
telegram._user_chat_mapping = {}
|
||||
|
||||
Reference in New Issue
Block a user