refactor: close plugin shutdown mutation races

This commit is contained in:
jxxghp
2026-08-23 21:55:22 +08:00
parent eb36e6be91
commit 820582ab12
16 changed files with 904 additions and 399 deletions
+87 -79
View File
@@ -22,6 +22,7 @@ from app.application.configuration import (
get_configured_system_config as SystemConfigOper,
get_runtime_settings,
)
from app.application.plugin.runtime import plugin_system_config_mutation
from app.runtime.log import logger
from app.schemas.event import ConfigChangeEventData
from app.schemas.types import EventType
@@ -259,94 +260,101 @@ class UpdateSystemSettingsTool(MoviePilotTool):
ensure_ascii=False,
)
current_value = self._load_setting_value(spec)
next_value = self._prepare_next_value(
spec=spec,
current_value=current_value,
value=value,
operation=operation,
remove_keys=remove_keys,
match_field=match_field,
match_value=match_value,
mutation_key = (
spec.systemconfig_key if spec.source == "systemconfig" else None
)
with plugin_system_config_mutation(mutation_key):
current_value = self._load_setting_value(spec)
next_value = self._prepare_next_value(
spec=spec,
current_value=current_value,
value=value,
operation=operation,
remove_keys=remove_keys,
match_field=match_field,
match_value=match_value,
)
event_value = next_value
changed = False
message = ""
if spec.source == "settings":
success, message = get_runtime_settings().update(spec.key, next_value)
if success is False:
return json.dumps(
{
"success": False,
"message": message or f"更新设置 {spec.key} 失败",
},
ensure_ascii=False,
event_value = next_value
changed = False
message = ""
if spec.source == "settings":
success, message = get_runtime_settings().update(
spec.key,
next_value,
)
changed = success is True
else:
normalized_value = self._normalize_systemconfig_value(next_value)
event_value = normalized_value
success = await self._get_system_config().async_set(
spec.systemconfig_key,
normalized_value,
)
changed = success is True
if success is False:
return json.dumps(
{
"success": False,
"message": message or f"更新设置 {spec.key} 失败",
},
ensure_ascii=False,
)
changed = success is True
else:
normalized_value = self._normalize_systemconfig_value(next_value)
event_value = normalized_value
success = await self._get_system_config().async_set(
spec.systemconfig_key,
normalized_value,
)
changed = success is True
if changed:
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(
key=spec.key,
value=event_value,
change_type="update",
),
)
if changed:
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(
key=spec.key,
value=event_value,
change_type="update",
),
)
saved_value = self._load_setting_value(spec)
redact_values = (
should_redact_setting(spec, saved_value)
or should_redact_setting(spec, current_value)
)
response_previous_value = (
redact_secret_value(
current_value,
redact_scalar=is_secret_setting_key(spec.key),
saved_value = self._load_setting_value(spec)
redact_values = (
should_redact_setting(spec, saved_value)
or should_redact_setting(spec, current_value)
)
if redact_values
else current_value
)
response_saved_value = (
redact_secret_value(
saved_value,
redact_scalar=is_secret_setting_key(spec.key),
response_previous_value = (
redact_secret_value(
current_value,
redact_scalar=is_secret_setting_key(spec.key),
)
if redact_values
else current_value
)
if redact_values
else saved_value
)
if not changed and not message:
message = "配置值未发生变化"
response_saved_value = (
redact_secret_value(
saved_value,
redact_scalar=is_secret_setting_key(spec.key),
)
if redact_values
else saved_value
)
if not changed and not message:
message = "配置值未发生变化"
return json.dumps(
{
"success": True,
"message": message or f"系统设置 {spec.key} 已更新",
"changed": changed,
"operation": operation,
"setting": {
"setting_key": spec.key,
"source": spec.source,
"group": spec.group,
"label": spec.label,
return json.dumps(
{
"success": True,
"message": message or f"系统设置 {spec.key} 已更新",
"changed": changed,
"operation": operation,
"setting": {
"setting_key": spec.key,
"source": spec.source,
"group": spec.group,
"label": spec.label,
},
"values_redacted": redact_values,
"previous_value": response_previous_value,
"saved_value": response_saved_value,
},
"values_redacted": redact_values,
"previous_value": response_previous_value,
"saved_value": response_saved_value,
},
ensure_ascii=False,
indent=2,
default=str,
)
ensure_ascii=False,
indent=2,
default=str,
)
except Exception as e:
logger.error(f"更新系统设置失败: {e}", exc_info=True)
return json.dumps(
+18 -8
View File
@@ -45,6 +45,7 @@ from app.application.configuration import (
get_configured_system_config,
get_runtime_settings,
)
from app.application.plugin.runtime import plugin_system_config_mutation
from app.api.dependencies.auth import (
get_current_active_superuser,
get_current_active_superuser_async,
@@ -67,6 +68,7 @@ from app.runtime.state import SystemHelper
from app.runtime.log import logger
from app.application.scheduling import Scheduler
from app.schemas.event import ConfigChangeEventData
from app.schemas.exception import PluginMutationRejectedError
from app.schemas.types import SystemConfigKey, EventType
from app.foundation.crypto import HashUtils
from app.adapters.network.http import RequestUtils, AsyncRequestUtils
@@ -1070,14 +1072,22 @@ async def set_setting(
if isinstance(value, list):
value = list(filter(None, value))
value = value if value else None
success = await get_configured_system_config().async_set(key, value)
if success:
# 发送配置变更事件
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(key=key, value=value, change_type="update"),
)
return _SchemaResponse(success=True)
try:
with plugin_system_config_mutation(key):
success = await get_configured_system_config().async_set(key, value)
if success:
# 发送配置变更事件
await eventmanager.async_send_event(
etype=EventType.ConfigChanged,
data=ConfigChangeEventData(
key=key,
value=value,
change_type="update",
),
)
return _SchemaResponse(success=True)
except PluginMutationRejectedError as error:
return _SchemaResponse(success=False, message=str(error))
else:
return _SchemaResponse(success=False, message=f"配置项 '{key}' 不存在")
+13 -9
View File
@@ -30,6 +30,16 @@ PluginMutationAdmission = Callable[[str], ContextManager[None]]
PluginPackageWriteGuard = Callable[[str], ContextManager[None]]
async def _await_task_to_terminal(task: asyncio.Task[Any]) -> Any:
"""忽略调用方的重复取消,直到受保护子任务进入真实终态。"""
while not task.done():
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
continue
return task.result()
@dataclass(frozen=True, slots=True)
class PluginInstallRollback:
"""描述失败安装中各类可补偿副作用的恢复结果。"""
@@ -183,7 +193,7 @@ class PluginInstallCommand:
state.checkpoint = checkpoint
except asyncio.CancelledError:
try:
state.checkpoint = await asyncio.shield(checkpoint_task)
state.checkpoint = await _await_task_to_terminal(checkpoint_task)
except BaseException:
pass
raise
@@ -359,14 +369,8 @@ class PluginInstallCommand:
)
)
try:
result = await asyncio.shield(rollback_task)
except asyncio.CancelledError:
try:
result = await asyncio.shield(rollback_task)
except BaseException as err:
logger.error(f"插件 {plugin_id} 取消后的补偿未完成:{err}")
return
except Exception as err:
result = await _await_task_to_terminal(rollback_task)
except BaseException as err:
logger.error(f"插件 {plugin_id} 取消后的补偿失败:{err}")
return
if result.rollback.errors:
+28
View File
@@ -3,8 +3,21 @@
from __future__ import annotations
from collections.abc import Callable
from contextlib import nullcontext
from typing import Any, ContextManager, Protocol
from app.schemas.types import SystemConfigKey
PLUGIN_MUTATION_SYSTEM_CONFIG_KEYS = frozenset(
{
SystemConfigKey.UserInstalledPlugins,
SystemConfigKey.PluginInstances,
SystemConfigKey.PluginFolders,
}
)
class PluginRuntime(Protocol):
"""声明入口层消费的插件宿主能力。"""
@@ -36,3 +49,18 @@ def configure_plugin_runtime(provider: PluginRuntimeProvider) -> None:
def get_plugin_manager() -> PluginRuntime:
"""返回当前组合根提供的插件运行时能力。"""
return _runtime_provider()
def plugin_system_config_mutation(
key: str | SystemConfigKey | None,
) -> ContextManager[None]:
"""仅为会改变插件运行态所有权的系统配置取得 mutation lease。"""
if key is None:
return nullcontext()
try:
normalized_key = key if isinstance(key, SystemConfigKey) else SystemConfigKey(key)
except ValueError:
return nullcontext()
if normalized_key not in PLUGIN_MUTATION_SYSTEM_CONFIG_KEYS:
return nullcontext()
return get_plugin_manager().mutation(f"更新插件系统配置 {normalized_key.value}")
+17 -17
View File
@@ -48,34 +48,34 @@ class PluginMutationAdmission:
def is_held(self) -> bool:
"""判断当前执行上下文是否持有仍有效的本 admission lease。"""
context = self._current_context.get()
return bool(
context
and context.admission is self
and context.open
and context.holders > 0
)
with self._condition:
return bool(
context
and context.admission is self
and context.open
and context.holders > 0
)
@contextmanager
def hold(self, operation: str) -> Iterator[None]:
"""取得可变事务 lease;封口后仅允许已获准事务的嵌套调用。"""
context = self._current_context.get()
nested = bool(
context
and context.admission is self
and context.open
and context.holders > 0
)
context_token = None
if not nested:
context = _MutationContext(admission=self)
context_token = self._current_context.set(context)
assert context is not None
acquired = False
try:
with self._condition:
nested = bool(
context
and context.admission is self
and context.open
and context.holders > 0
)
if not self._accepting and not nested:
raise PluginMutationRejectedError(operation)
if not nested:
context = _MutationContext(admission=self)
context_token = self._current_context.set(context)
assert context is not None
self._active_count += 1
context.holders += 1
acquired = True
+1
View File
@@ -270,6 +270,7 @@ SCHEMA_EXPORTS = {
'PluginMarketSyncData': ('app.schemas.system', 'PluginMarketSyncData'),
'PluginMarketSyncRequest': ('app.schemas.system', 'PluginMarketSyncRequest'),
'PluginMemoryInfo': ('app.schemas.plugin', 'PluginMemoryInfo'),
'PluginMutationRejectedError': ('app.schemas.exception', 'PluginMutationRejectedError'),
'PluginRating': ('app.schemas.plugin', 'PluginRating'),
'PluginRatingMap': ('app.schemas.plugin', 'PluginRatingMap'),
'PluginRatingRequest': ('app.schemas.plugin', 'PluginRatingRequest'),
@@ -15,7 +15,7 @@
### 长期整改阶段 0:治理门禁恢复(2026-08-23
- 宿主依赖基线已审查本阶段归位后的语义差异:当前为 `805` 个模块、`6514` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。
- 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `805` 个模块、`6525` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。
- 启动性能探针会在隔离生命周期中真实创建并释放 TaskRegistrynormal/safe 组件数分别为 `23`/`11`,CI 只读检查使用稳定的宿主模块集合和生命周期组件顺序,不再把 Python/平台模块数量当作硬合同。
- 官方插件快照覆盖 `plugins.v3``plugins.v2` 以及 V3 实际会从 `package.json` 回退加载的 31 个默认实现;`app/plugins/**` 仍只是宿主运行副本,不进入扫描。
- SDK 快照以各模块显式 `__all__` 为公开合同,能够记录赋值别名;`typing``__future__` 等实现期导入不再被误冻结,既有数据库备份门面已补精确导出清单。
@@ -72,7 +72,7 @@
- 继续采用单进程控制面是正确选择,不建议现在拆成微服务;插件、调度器、工作流、事件和数据库共享进程内状态,拆分会放大部署、事务和兼容成本。
- `foundation/domain/runtime/adapters/application/chain/api/startup` 的职责方向基本成立;宿主架构基线、复杂度 ratchet、异步阻塞 ratchet 当前均通过。
- 依赖图当前为 `805` 个 Python 模块、`6514` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。
- 依赖图当前为 `805` 个 Python 模块、`6525` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。
- 当前主要风险已经从“目录和依赖失控”转移到运行时协议、后台副作用的可靠性和遗留兼容面。换言之,下一阶段重点应是**语义收口和可验证性**,而不是继续搬文件或机械拆大文件。
综合评价:架构方向可持续,生产可用性较高;可演进性仍处于中等水平。现阶段没有静态审计发现必须立即推倒重来的 P0 架构问题,但存在需要按 P1/P2 计划治理的真实债务。
+13 -2
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6514,
"edge_sha256": "eda9bc500158baa80dc5013eb4e409aa57cb70446e59a52b7e782cb9c58f9c34",
"edge_count": 6525,
"edge_sha256": "72b9416b5309bb51768b95b3c9da4b167242c3bc3f27d4e561a3f097267ca6a0",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -281,6 +281,7 @@
"app.agent.middleware.skills -> app.agent.middleware",
"app.agent.middleware.skills -> app.agent.middleware.utils",
"app.agent.middleware.skills -> app.agent.policy",
"app.agent.middleware.skills -> app.agent.policy.sanitizer",
"app.agent.middleware.skills -> app.agent.skills",
"app.agent.middleware.skills -> app.agent.skills.metadata",
"app.agent.middleware.skills -> app.agent.tools",
@@ -309,6 +310,7 @@
"app.agent.middleware.summarization -> app.runtime.log",
"app.agent.middleware.tool_selection -> app.agent",
"app.agent.middleware.tool_selection -> app.agent.llm",
"app.agent.middleware.tool_selection -> app.agent.llm.helper",
"app.agent.middleware.tool_selection -> app.agent.tools",
"app.agent.middleware.tool_selection -> app.agent.tools.tags",
"app.agent.middleware.tool_selection -> app.runtime",
@@ -435,6 +437,7 @@
"app.agent.tools.base -> app.schemas.types",
"app.agent.tools.catalog -> app.agent",
"app.agent.tools.catalog -> app.agent.policy",
"app.agent.tools.catalog -> app.agent.policy.contracts",
"app.agent.tools.factory -> app.agent",
"app.agent.tools.factory -> app.agent.llm",
"app.agent.tools.factory -> app.agent.llm.capability",
@@ -883,6 +886,7 @@
"app.agent.tools.impl.query_doctor_report -> app.agent.tools.base",
"app.agent.tools.impl.query_doctor_report -> app.agent.tools.tags",
"app.agent.tools.impl.query_doctor_report -> app.doctor",
"app.agent.tools.impl.query_doctor_report -> app.doctor.runner",
"app.agent.tools.impl.query_doctor_report -> app.runtime",
"app.agent.tools.impl.query_doctor_report -> app.runtime.log",
"app.agent.tools.impl.query_download_tasks -> app.agent",
@@ -1471,6 +1475,8 @@
"app.agent.tools.impl.update_system_settings -> app.agent.tools.tags",
"app.agent.tools.impl.update_system_settings -> app.application",
"app.agent.tools.impl.update_system_settings -> app.application.configuration",
"app.agent.tools.impl.update_system_settings -> app.application.plugin",
"app.agent.tools.impl.update_system_settings -> app.application.plugin.runtime",
"app.agent.tools.impl.update_system_settings -> app.runtime",
"app.agent.tools.impl.update_system_settings -> app.runtime.events",
"app.agent.tools.impl.update_system_settings -> app.runtime.log",
@@ -2261,6 +2267,8 @@
"app.api.endpoints.system -> app.application.messaging.message",
"app.api.endpoints.system -> app.application.module",
"app.api.endpoints.system -> app.application.network",
"app.api.endpoints.system -> app.application.plugin",
"app.api.endpoints.system -> app.application.plugin.runtime",
"app.api.endpoints.system -> app.application.rules",
"app.api.endpoints.system -> app.application.scheduling",
"app.api.endpoints.system -> app.application.security",
@@ -2287,6 +2295,7 @@
"app.api.endpoints.system -> app.schemas",
"app.api.endpoints.system -> app.schemas.common",
"app.api.endpoints.system -> app.schemas.event",
"app.api.endpoints.system -> app.schemas.exception",
"app.api.endpoints.system -> app.schemas.response",
"app.api.endpoints.system -> app.schemas.system",
"app.api.endpoints.system -> app.schemas.token",
@@ -2688,6 +2697,8 @@
"app.application.plugin.install -> app.runtime.log",
"app.application.plugin.install -> app.schemas",
"app.application.plugin.install -> app.schemas.exception",
"app.application.plugin.runtime -> app.schemas",
"app.application.plugin.runtime -> app.schemas.types",
"app.application.recognition -> app.application",
"app.application.recognition -> app.application.configuration",
"app.application.recognition -> app.schemas",
+118 -116
View File
@@ -1,41 +1,41 @@
{
"schema_version": 2,
"generated_at": "2026-08-23T11:06:42.858188+00:00",
"generated_at": "2026-08-23T13:47:08.442528+00:00",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
"python": "3.14.3",
"repeat": 3,
"targets": {
"app.startup.lifecycle": {
"loaded_app_module_count": 362,
"max_ms": 1022.231,
"median_ms": 976.39,
"min_ms": 967.395,
"loaded_app_module_count": 367,
"max_ms": 1591.175,
"median_ms": 1049.37,
"min_ms": 1022.351,
"samples_ms": [
1022.231,
967.395,
976.39
1591.175,
1049.37,
1022.351
]
},
"app.factory": {
"loaded_app_module_count": 374,
"max_ms": 989.21,
"median_ms": 980.605,
"min_ms": 951.564,
"loaded_app_module_count": 379,
"max_ms": 1198.166,
"median_ms": 1083.805,
"min_ms": 1009.309,
"samples_ms": [
980.605,
989.21,
951.564
1009.309,
1198.166,
1083.805
]
},
"app.main": {
"loaded_app_module_count": 376,
"max_ms": 1114.04,
"median_ms": 1088.251,
"min_ms": 1080.26,
"loaded_app_module_count": 381,
"max_ms": 1229.477,
"median_ms": 1179.859,
"min_ms": 1129.055,
"samples_ms": [
1080.26,
1088.251,
1114.04
1129.055,
1179.859,
1229.477
]
}
},
@@ -46,26 +46,26 @@
"samples": [
{
"mode": "normal",
"enabled_component_count": 21,
"startup_ms": 0.619,
"full_lifespan_ms": 0.806,
"enabled_component_count": 23,
"startup_ms": 0.64,
"full_lifespan_ms": 0.805,
"stage_ms": {
"后台任务登记器": 0.075,
"数据库准备": 0.037,
"HTTP 基础能力": 0.031,
"后台任务登记器": 0.079,
"数据库准备": 0.039,
"HTTP 基础能力": 0.029,
"领域依赖装配": 0.027,
"数据库引擎预热": 0.024,
"数据库连接预算": 0.025,
"路由": 0.022,
"模块服务": 0.024,
"插件备份恢复": 0.021,
"插件": 0.02,
"定时器": 0.023,
"监控器": 0.021,
"待处理整理回放": 0.019,
"命令服务": 0.022,
"工作流": 0.022,
"插件同步与启动收尾": 0.034
"数据库连接预算": 0.023,
"路由": 0.023,
"模块服务": 0.021,
"插件备份恢复": 0.024,
"插件": 0.021,
"定时器": 0.026,
"监控器": 0.022,
"待处理整理回放": 0.025,
"命令服务": 0.025,
"工作流": 0.021,
"插件同步与启动收尾": 0.037
},
"threads_before": 2,
"threads_started": 2,
@@ -77,26 +77,26 @@
},
{
"mode": "normal",
"enabled_component_count": 21,
"startup_ms": 0.628,
"enabled_component_count": 23,
"startup_ms": 0.652,
"full_lifespan_ms": 0.808,
"stage_ms": {
"后台任务登记器": 0.073,
"数据库准备": 0.037,
"HTTP 基础能力": 0.039,
"领域依赖装配": 0.027,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.025,
"后台任务登记器": 0.079,
"数据库准备": 0.039,
"HTTP 基础能力": 0.033,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.021,
"路由": 0.022,
"模块服务": 0.022,
"模块服务": 0.023,
"插件备份恢复": 0.021,
"插件": 0.02,
"定时器": 0.024,
"监控器": 0.022,
"待处理整理回放": 0.029,
"命令服务": 0.02,
"工作流": 0.021,
"插件同步与启动收尾": 0.032
"插件": 0.022,
"定时器": 0.026,
"监控器": 0.023,
"待处理整理回放": 0.024,
"命令服务": 0.023,
"工作流": 0.025,
"插件同步与启动收尾": 0.035
},
"threads_before": 2,
"threads_started": 2,
@@ -108,26 +108,26 @@
},
{
"mode": "normal",
"enabled_component_count": 21,
"startup_ms": 0.664,
"full_lifespan_ms": 0.863,
"enabled_component_count": 23,
"startup_ms": 0.739,
"full_lifespan_ms": 0.944,
"stage_ms": {
"后台任务登记器": 0.083,
"数据库准备": 0.047,
"HTTP 基础能力": 0.036,
"后台任务登记器": 0.087,
"数据库准备": 0.037,
"HTTP 基础能力": 0.029,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.029,
"数据库连接预算": 0.025,
"路由": 0.026,
"模块服务": 0.025,
"插件备份恢复": 0.025,
"插件": 0.028,
"定时器": 0.026,
"监控器": 0.021,
"待处理整理回放": 0.021,
"命令服务": 0.024,
"工作流": 0.021,
"插件同步与启动收尾": 0.036
"数据库引擎预热": 0.024,
"数据库连接预算": 0.021,
"路由": 0.02,
"模块服务": 0.02,
"插件备份恢复": 0.024,
"插件": 0.019,
"定时器": 0.025,
"监控器": 0.054,
"待处理整理回放": 0.041,
"命令服务": 0.034,
"工作流": 0.025,
"插件同步与启动收尾": 0.037
},
"threads_before": 2,
"threads_started": 2,
@@ -138,9 +138,9 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.628,
"median_startup_ms": 0.652,
"median_full_lifespan_ms": 0.808,
"enabled_component_count": 21,
"enabled_component_count": 23,
"enabled_components": [
"后台任务登记器",
"数据库准备",
@@ -157,6 +157,8 @@
"监控器",
"整理后台服务",
"AI智能体会话",
"插件事件入口",
"事件尾任务结算",
"插件后台服务",
"事件投递屏障",
"待处理整理回放",
@@ -170,42 +172,18 @@
{
"mode": "safe",
"enabled_component_count": 11,
"startup_ms": 0.464,
"full_lifespan_ms": 0.637,
"startup_ms": 0.475,
"full_lifespan_ms": 0.619,
"stage_ms": {
"后台任务登记器": 0.074,
"后台任务登记器": 0.079,
"数据库准备": 0.038,
"HTTP 基础能力": 0.03,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.025,
"路由": 0.024,
"模块服务": 0.024,
"插件同步与启动收尾": 0.033
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 11,
"startup_ms": 0.451,
"full_lifespan_ms": 0.624,
"stage_ms": {
"后台任务登记器": 0.073,
"数据库准备": 0.035,
"HTTP 基础能力": 0.028,
"领域依赖装配": 0.027,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.024,
"数据库连接预算": 0.023,
"路由": 0.023,
"模块服务": 0.02,
"插件同步与启动收尾": 0.031
"模块服务": 0.022,
"插件同步与启动收尾": 0.036
},
"threads_before": 2,
"threads_started": 2,
@@ -218,18 +196,42 @@
{
"mode": "safe",
"enabled_component_count": 11,
"startup_ms": 0.477,
"full_lifespan_ms": 0.661,
"startup_ms": 0.493,
"full_lifespan_ms": 0.676,
"stage_ms": {
"后台任务登记器": 0.081,
"数据库准备": 0.05,
"后台任务登记器": 0.089,
"数据库准备": 0.037,
"HTTP 基础能力": 0.035,
"领域依赖装配": 0.03,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.024,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.021,
"路由": 0.025,
"模块服务": 0.023,
"插件同步与启动收尾": 0.062
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 11,
"startup_ms": 0.464,
"full_lifespan_ms": 0.638,
"stage_ms": {
"后台任务登记器": 0.075,
"数据库准备": 0.036,
"HTTP 基础能力": 0.028,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.023,
"数据库连接预算": 0.022,
"路由": 0.024,
"模块服务": 0.02,
"插件同步与启动收尾": 0.026
"模块服务": 0.021,
"插件同步与启动收尾": 0.057
},
"threads_before": 2,
"threads_started": 2,
@@ -240,8 +242,8 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.464,
"median_full_lifespan_ms": 0.637,
"median_startup_ms": 0.475,
"median_full_lifespan_ms": 0.638,
"enabled_component_count": 11,
"enabled_components": [
"后台任务登记器",
+108 -154
View File
@@ -69,6 +69,8 @@ def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict:
"stop_agent": AsyncMock(return_value=True),
"stop_transfer": AsyncMock(return_value=True),
"quiesce_plugins": AsyncMock(return_value=True),
"settle_events": AsyncMock(return_value=True),
"quiesce_plugin_services": AsyncMock(return_value=True),
"drain_events": AsyncMock(return_value=True),
"finalize_plugins": MagicMock(return_value=True),
"stop_modules": AsyncMock(),
@@ -94,6 +96,16 @@ def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict:
"quiesce_plugins",
shutdown_steps["quiesce_plugins"],
)
monkeypatch.setattr(
lifecycle,
"settle_events",
shutdown_steps["settle_events"],
)
monkeypatch.setattr(
lifecycle,
"quiesce_plugin_services",
shutdown_steps["quiesce_plugin_services"],
)
monkeypatch.setattr(lifecycle, "drain_events", shutdown_steps["drain_events"])
monkeypatch.setattr(lifecycle, "stop_modules", shutdown_steps["stop_modules"])
monkeypatch.setattr(
@@ -191,166 +203,83 @@ def test_lifespan_validation_failure_does_not_clear_outer_loop_owner(monkeypatch
lifecycle.global_vars.clear_loop.assert_not_called()
def test_lifespan_settles_plugin_handlers_before_legacy_hooks(monkeypatch) -> None:
"""整理尾事件必须在 handler 停用后结算,并先于旧插件停机 hook。"""
shutdown_steps = _patch_lifespan(monkeypatch)
order: list[str] = []
for name in (
"stop_transfer",
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"drain_events",
"finalize_plugins",
):
shutdown_steps[name].side_effect = (
lambda current=name: order.append(current) or True
)
async def run_lifespan() -> None:
"""运行一个完整的隔离生命周期。"""
async with lifecycle.lifespan(FastAPI()):
pass
asyncio.run(run_lifespan())
assert order == [
"stop_transfer",
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"drain_events",
"finalize_plugins",
]
_ORDERED_SHUTDOWN_STEPS = (
"stop_plugin_monitor",
"backup_plugins",
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_agent",
"stop_transfer",
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"drain_events",
"finalize_plugins",
"stop_modules",
"close_http",
)
@pytest.mark.parametrize(
("failing_step", "completed_steps", "blocked_steps"),
[
(
"stop_plugin_monitor",
("stop_plugin_monitor",),
(
"backup_plugins",
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_agent",
"quiesce_plugins",
"stop_transfer",
"drain_events",
"finalize_plugins",
"stop_modules",
"close_http",
),
),
(
"stop_monitor",
(
"stop_plugin_monitor",
"backup_plugins",
"stop_workflow",
"stop_command",
"stop_monitor",
),
(
"stop_scheduler",
"stop_agent",
"quiesce_plugins",
"stop_transfer",
"drain_events",
"finalize_plugins",
"stop_modules",
"close_http",
),
),
(
"stop_scheduler",
(
"stop_plugin_monitor",
"backup_plugins",
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
),
(
"stop_agent",
"quiesce_plugins",
"stop_transfer",
"drain_events",
"finalize_plugins",
"stop_modules",
"close_http",
),
),
(
"stop_agent",
(
"stop_plugin_monitor",
"backup_plugins",
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_agent",
),
(
"quiesce_plugins",
"stop_transfer",
"drain_events",
"finalize_plugins",
"stop_modules",
"close_http",
),
),
(
"quiesce_plugins",
(
"stop_plugin_monitor",
"backup_plugins",
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_agent",
"quiesce_plugins",
),
(
"stop_transfer",
"drain_events",
"finalize_plugins",
"stop_modules",
"close_http",
),
),
(
"stop_transfer",
(
"stop_plugin_monitor",
"backup_plugins",
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_agent",
"quiesce_plugins",
"stop_transfer",
),
("drain_events", "finalize_plugins", "stop_modules", "close_http"),
),
(
"drain_events",
(
"stop_plugin_monitor",
"backup_plugins",
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_agent",
"quiesce_plugins",
"stop_transfer",
"drain_events",
),
("finalize_plugins", "stop_modules", "close_http"),
),
(
"finalize_plugins",
(
"stop_plugin_monitor",
"backup_plugins",
"stop_workflow",
"stop_command",
"stop_monitor",
"stop_scheduler",
"stop_agent",
"quiesce_plugins",
"stop_transfer",
"drain_events",
"finalize_plugins",
),
("stop_modules", "close_http"),
),
],
"failing_step",
(
"stop_plugin_monitor",
"stop_monitor",
"stop_scheduler",
"stop_agent",
"stop_transfer",
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"drain_events",
"finalize_plugins",
),
)
def test_lifespan_stops_releasing_dependencies_when_owner_does_not_converge(
monkeypatch,
failing_step,
completed_steps,
blocked_steps,
):
"""关键 owner 未收敛时不得关闭仍被活任务使用的后续依赖。"""
shutdown_steps = _patch_lifespan(monkeypatch)
shutdown_steps[failing_step].return_value = False
failed_index = _ORDERED_SHUTDOWN_STEPS.index(failing_step)
completed_steps = _ORDERED_SHUTDOWN_STEPS[: failed_index + 1]
blocked_steps = _ORDERED_SHUTDOWN_STEPS[failed_index + 1 :]
async def run_lifespan():
"""启动并关闭隔离后的应用生命周期。"""
@@ -520,6 +449,8 @@ def test_lifespan_safe_mode_skips_optional_runtime(monkeypatch):
"stop_scheduler",
"stop_plugin_monitor",
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"finalize_plugins",
):
shutdown_steps[name].assert_not_called()
@@ -543,6 +474,19 @@ async def test_event_drain_does_not_materialize_manager(monkeypatch) -> None:
event_manager_type.assert_not_called()
@pytest.mark.asyncio
async def test_event_settlement_keeps_tail_event_admission_open(monkeypatch) -> None:
"""中间结算只等待在途 handler,不得提前封死旧 hook 的尾事件。"""
event_manager = MagicMock()
event_manager.drain_async = AsyncMock(return_value=True)
event_manager_type = MagicMock()
event_manager_type.get_existing_instance.return_value = event_manager
monkeypatch.setattr(modules_initializer, "EventManager", event_manager_type)
assert await modules_initializer.settle_events() is True
event_manager.drain_async.assert_awaited_once_with(seal=False)
def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
"""组件清单应显式冻结依赖、模式、启动/关闭顺序和超时预算。"""
app = FastAPI()
@@ -591,8 +535,10 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
"监控器",
"定时器",
"AI智能体会话",
"插件后台服务",
"整理后台服务",
"插件事件入口",
"事件尾任务结算",
"插件后台服务",
"事件投递屏障",
"插件",
"模块服务",
@@ -623,6 +569,8 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
"定时器",
"AI智能体会话",
"整理后台服务",
"插件事件入口",
"事件尾任务结算",
"插件后台服务",
"事件投递屏障",
"插件",
@@ -638,6 +586,8 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
"定时器",
"AI智能体会话",
"整理后台服务",
"插件事件入口",
"事件尾任务结算",
"插件后台服务",
"事件投递屏障",
"插件",
@@ -869,8 +819,10 @@ def test_lifespan_cleans_started_owners_after_late_startup_failure(monkeypatch):
"stop_monitor",
"stop_scheduler",
"stop_agent",
"quiesce_plugins",
"stop_transfer",
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"drain_events",
"finalize_plugins",
"stop_modules",
@@ -905,11 +857,13 @@ def test_startup_failure_cleanup_honors_transfer_fail_fast(monkeypatch):
"stop_monitor",
"stop_scheduler",
"stop_agent",
"quiesce_plugins",
"stop_transfer",
):
_assert_completed_once(shutdown_steps[name])
for name in (
"quiesce_plugins",
"settle_events",
"quiesce_plugin_services",
"drain_events",
"finalize_plugins",
"stop_modules",
+97
View File
@@ -477,6 +477,103 @@ async def test_cancelled_install_waits_for_rollback_before_releasing_lifecycle()
rollback.assert_awaited_once()
@pytest.mark.asyncio
async def test_repeated_checkpoint_cancellation_retains_mutation_owner() -> None:
"""快照等待被连续取消时,lease 必须保留到快照子任务终态。"""
admission = PluginMutationAdmission()
checkpoint_started = asyncio.Event()
checkpoint_release = asyncio.Event()
checkpoint_finished = asyncio.Event()
async def checkpoint(_plugin_id: str) -> object:
"""阻塞快照创建,直到测试确认 owner 仍被持有。"""
checkpoint_started.set()
await checkpoint_release.wait()
checkpoint_finished.set()
return object()
task = asyncio.create_task(
_command(
checkpointer=checkpoint,
mutation=admission.hold,
).execute(
plugin_id="DemoPlugin",
repo_url="https://github.com/demo/plugins",
)
)
await checkpoint_started.wait()
task.cancel()
await asyncio.sleep(0)
task.cancel()
await asyncio.sleep(0)
assert task.done() is False
assert admission.seal() == 1
idle_waiter = asyncio.create_task(asyncio.to_thread(admission.wait_until_idle))
await asyncio.sleep(0.02)
assert idle_waiter.done() is False
checkpoint_release.set()
with pytest.raises(asyncio.CancelledError):
await task
await idle_waiter
assert checkpoint_finished.is_set()
assert admission.active_count == 0
@pytest.mark.asyncio
async def test_repeated_rollback_cancellation_retains_mutation_owner() -> None:
"""补偿等待被再次连续取消时,lease 必须保留到补偿子任务终态。"""
admission = PluginMutationAdmission()
install_started = asyncio.Event()
rollback_started = asyncio.Event()
rollback_release = asyncio.Event()
rollback_finished = asyncio.Event()
async def install(*_args) -> tuple[bool, str]:
"""阻塞包安装,使首次取消进入补偿路径。"""
install_started.set()
await asyncio.Event().wait()
return True, "ok"
async def rollback(_checkpoint: object) -> None:
"""阻塞文件补偿,直到测试确认 owner 仍被持有。"""
rollback_started.set()
await rollback_release.wait()
rollback_finished.set()
task = asyncio.create_task(
_command(
installer=install,
rollback=rollback,
mutation=admission.hold,
).execute(
plugin_id="DemoPlugin",
repo_url="https://github.com/demo/plugins",
)
)
await install_started.wait()
task.cancel()
await rollback_started.wait()
assert admission.seal() == 1
idle_waiter = asyncio.create_task(asyncio.to_thread(admission.wait_until_idle))
task.cancel()
await asyncio.sleep(0)
task.cancel()
await asyncio.sleep(0.02)
assert task.done() is False
assert idle_waiter.done() is False
assert admission.active_count == 1
rollback_release.set()
with pytest.raises(asyncio.CancelledError):
await task
await idle_waiter
assert rollback_finished.is_set()
assert admission.active_count == 0
@pytest.mark.asyncio
async def test_cancelled_persisted_list_is_restored_conservatively() -> None:
"""清单写入已产生副作用但尚未返回时取消,也必须恢复原清单。"""
+48 -5
View File
@@ -100,8 +100,8 @@ def test_lifecycle_records_load_failure_when_loader_returns_no_class():
assert statuses["DemoPlugin"] is PluginRuntimeStatus.LOAD_FAILED
def test_quiesce_keeps_instance_and_events_until_finalize():
"""第一阶段仅停止插件生产者,事件 handler 和实例留到屏障后释放"""
def test_phased_quiesce_disables_events_before_hooks_and_keeps_instance():
"""宿主先停用 handler,再在事件结算后执行旧 hook 并保留实例"""
order: list[str] = []
class DemoPlugin:
@@ -129,14 +129,19 @@ def test_quiesce_keeps_instance_and_events_until_finalize():
lifecycle, classes, running, _statuses = _lifecycle(plugins=[DemoPlugin])
lifecycle.start("DemoPlugin")
lifecycle._disable_events.reset_mock()
lifecycle._disable_events.side_effect = (
lambda _plugin_type: order.append("disable_events")
)
lifecycle._clear_modules.reset_mock()
lifecycle._clear_tools.reset_mock()
assert lifecycle.quiesce() is True
assert order == ["close", "stop_service"]
assert lifecycle.quiesce_handlers() is True
assert order == ["disable_events"]
assert lifecycle.quiesce_services() is True
assert order == ["disable_events", "close", "stop_service"]
assert classes["DemoPlugin"] is DemoPlugin
assert isinstance(running["DemoPlugin"], DemoPlugin)
lifecycle._disable_events.assert_not_called()
lifecycle._disable_events.assert_called_once_with(DemoPlugin)
lifecycle._clear_modules.assert_not_called()
lifecycle._clear_tools.assert_not_called()
@@ -194,6 +199,44 @@ def test_quiesce_runs_stop_service_after_close_failure_and_retries_missing_hook(
assert running == {}
def test_quiesce_does_not_close_resources_until_all_handlers_are_disabled():
"""任一 handler 停用失败时不得调用可能破坏共享资源的旧停机 hook。"""
close = MagicMock()
class DemoPlugin:
"""提供可观察 close hook 的测试插件。"""
plugin_name = "演示插件"
plugin_version = "1.0.0"
def init_plugin(self, _config):
"""接受宿主初始化配置。"""
@staticmethod
def get_state():
"""保持插件事件 handler 启用。"""
return True
def close(self):
"""记录资源关闭调用。"""
close()
lifecycle, classes, running, _statuses = _lifecycle(plugins=[DemoPlugin])
lifecycle.start("DemoPlugin")
lifecycle._disable_events.side_effect = RuntimeError("disable failed")
assert lifecycle.quiesce() is False
close.assert_not_called()
assert lifecycle.finalize() is False
assert "DemoPlugin" in classes
assert "DemoPlugin" in running
lifecycle._disable_events.side_effect = None
assert lifecycle.quiesce() is True
close.assert_called_once_with()
assert lifecycle.finalize() is True
def test_legacy_stop_entry_remains_idempotent():
"""旧 stop 先解绑事件、保持 None 返回,且重复调用不重复执行 hook。"""
order: list[str] = []
+11 -6
View File
@@ -582,11 +582,12 @@ def test_stop_plugin_monitor_returns_existing_manager_result(monkeypatch) -> Non
@pytest.mark.asyncio
async def test_two_phase_plugin_shutdown_does_not_materialize_manager() -> None:
"""两阶段入口在插件管理器尚未创建时都直接视为已收敛。"""
async def test_phased_plugin_shutdown_does_not_materialize_manager() -> None:
"""三个停机入口在插件管理器尚未创建时都直接视为已收敛。"""
_reset_plugin_manager()
assert await plugins_initializer.quiesce_plugins(timeout=0) is True
assert await plugins_initializer.quiesce_plugin_services(timeout=0) is True
assert plugins_initializer.finalize_plugins() is True
assert PluginManager.get_existing_instance() is None
@@ -616,7 +617,10 @@ async def test_quiesce_timeout_retains_future_owner_until_worker_finishes(
release.wait(timeout=2)
return True
manager._plugin_lifecycle.quiesce = MagicMock(side_effect=blocking_quiesce)
manager._plugin_lifecycle.quiesce_handlers = MagicMock(return_value=True)
manager._plugin_lifecycle.quiesce_services = MagicMock(
side_effect=blocking_quiesce,
)
manager._plugin_lifecycle.finalize = MagicMock(return_value=True)
try:
@@ -627,9 +631,10 @@ async def test_quiesce_timeout_retains_future_owner_until_worker_finishes(
lambda: thread_helper,
)
assert await manager.quiesce_plugins(timeout=0.01) is False
assert await manager.quiesce_plugins(timeout=1) is True
assert await manager.quiesce_plugin_services(timeout=0.01) is False
assert started.is_set()
owner = manager._plugin_quiesce_future
owner = manager._plugin_service_quiesce_future
assert owner is not None
assert owner.done() is False
assert manager.finalize_plugins() is False
@@ -659,7 +664,7 @@ async def test_quiesce_seals_runtime_until_new_lifespan_reopens(monkeypatch) ->
),
)
manager = PluginManager()
manager._plugin_lifecycle.quiesce = MagicMock(return_value=True)
manager._plugin_lifecycle.quiesce_handlers = MagicMock(return_value=True)
manager._plugin_lifecycle.start = MagicMock(
return_value={"DemoPlugin": PluginRuntimeStatus.ACTIVE}
)
+78 -1
View File
@@ -1,6 +1,7 @@
"""插件可变事务停机准入的确定性测试。"""
import asyncio
import threading
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor
from contextvars import copy_context
@@ -20,6 +21,42 @@ from app.schemas.plugin import PluginRuntimeStatus
from app.schemas.types import EventType
class _GatedCondition:
"""让指定测试线程在取得真实 Condition 前停住。"""
def __init__(self, blocked_thread_prefix: str) -> None:
"""初始化内部 Condition 和可控的竞态闸门。"""
self._condition = threading.Condition()
self._blocked_thread_prefix = blocked_thread_prefix
self._blocked = False
self.attempted = threading.Event()
self.release = threading.Event()
def __enter__(self):
"""在目标线程首次进入时等待测试放行。"""
if (
threading.current_thread().name.startswith(self._blocked_thread_prefix)
and not self._blocked
):
self._blocked = True
self.attempted.set()
if not self.release.wait(timeout=1):
raise TimeoutError("测试未及时放行 Condition")
return self._condition.__enter__()
def __exit__(self, exc_type, exc_value, traceback):
"""把上下文退出委托给内部 Condition。"""
return self._condition.__exit__(exc_type, exc_value, traceback)
def wait(self) -> bool:
"""等待 admission 活动计数变化。"""
return self._condition.wait()
def notify_all(self) -> None:
"""唤醒全部等待 admission 空闲的线程。"""
self._condition.notify_all()
@pytest.fixture
def plugin_manager() -> Iterator[PluginManager]:
"""构造隔离的插件管理器,并在用例结束后清除单例状态。"""
@@ -60,6 +97,46 @@ def test_seal_rejects_new_root_but_allows_propagated_nested_lease() -> None:
assert admission.reopen() is True
def test_stale_propagated_context_is_rechecked_atomically_after_seal() -> None:
"""外层退出后才取得 Condition 的复制上下文不得冒充嵌套事务。"""
admission = PluginMutationAdmission()
calls: list[str] = []
outer = admission.hold("外层事务")
outer.__enter__()
propagated = copy_context()
condition = _GatedCondition("stale-admission")
admission._condition = condition
outer_closed = False
def mutate() -> None:
"""使用复制上下文尝试执行延迟到封口后的写入。"""
with admission.hold("延迟嵌套事务"):
calls.append("mutated")
try:
with ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="stale-admission",
) as executor:
future = executor.submit(propagated.run, mutate)
assert condition.attempted.wait(timeout=1)
outer.__exit__(None, None, None)
outer_closed = True
assert admission.seal() == 0
admission.wait_until_idle()
condition.release.set()
with pytest.raises(PluginMutationRejectedError):
future.result(timeout=1)
finally:
condition.release.set()
if not outer_closed:
outer.__exit__(None, None, None)
assert calls == []
assert admission.active_count == 0
@pytest.mark.asyncio
async def test_quiesce_timeout_retains_admitted_owner_and_nested_reload(
plugin_manager: PluginManager,
@@ -72,7 +149,7 @@ async def test_quiesce_timeout_retains_admitted_owner_and_nested_reload(
manager._plugin_lifecycle.reload = MagicMock(
return_value=PluginRuntimeStatus.ACTIVE
)
manager._plugin_lifecycle.quiesce = MagicMock(return_value=True)
manager._plugin_lifecycle.quiesce_handlers = MagicMock(return_value=True)
manager._plugin_lifecycle.finalize = MagicMock(return_value=True)
async def mutate() -> PluginRuntimeStatus:
@@ -0,0 +1,263 @@
"""通用系统设置入口的插件 mutation 准入测试。"""
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool
from app.api.endpoints import system as system_endpoint
from app.application.plugin import runtime as plugin_runtime
from app.runtime.extensions.plugin.admission import PluginMutationAdmission
from app.schemas.types import SystemConfigKey
PLUGIN_RUNTIME_KEYS = (
SystemConfigKey.UserInstalledPlugins,
SystemConfigKey.PluginInstances,
SystemConfigKey.PluginFolders,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("config_key", PLUGIN_RUNTIME_KEYS)
async def test_system_http_rejects_plugin_keys_after_admission_seal(
config_key: SystemConfigKey,
monkeypatch,
) -> None:
"""HTTP 通用设置入口在封口后不得写入插件运行态配置。"""
admission = PluginMutationAdmission()
admission.seal()
config = MagicMock()
config.async_set = AsyncMock()
monkeypatch.setattr(
plugin_runtime,
"get_plugin_manager",
lambda: SimpleNamespace(mutation=admission.hold),
)
monkeypatch.setattr(
system_endpoint,
"get_runtime_settings",
lambda: SimpleNamespace(contains=lambda _key: False),
)
monkeypatch.setattr(
system_endpoint,
"get_configured_system_config",
lambda: config,
)
response = await system_endpoint.set_setting(config_key.value, {}, None)
assert response.success is False
assert "停机阶段" in response.message
config.async_set.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("config_key", PLUGIN_RUNTIME_KEYS)
async def test_agent_rejects_plugin_keys_after_admission_seal(
config_key: SystemConfigKey,
monkeypatch,
) -> None:
"""Agent 通用设置入口在封口后不得读取或写入插件运行态配置。"""
admission = PluginMutationAdmission()
admission.seal()
config = MagicMock()
config.async_set = AsyncMock()
monkeypatch.setattr(
plugin_runtime,
"get_plugin_manager",
lambda: SimpleNamespace(mutation=admission.hold),
)
tool = UpdateSystemSettingsTool(
session_id="session-1",
user_id="10001",
system_config=config,
)
payload = json.loads(await tool.run(setting_key=config_key.value, value={}))
assert payload["success"] is False
assert "停机阶段" in payload["message"]
config.get.assert_not_called()
config.async_set.assert_not_awaited()
@pytest.mark.asyncio
async def test_system_http_plugin_write_remains_owned_until_settled(
monkeypatch,
) -> None:
"""HTTP 在途插件配置写入完成前,封口等待不得误判为空闲。"""
admission = PluginMutationAdmission()
write_started = asyncio.Event()
write_release = asyncio.Event()
async def async_set(_key: str, _value: object) -> bool:
"""阻塞配置写入,暴露 quiesce 等待窗口。"""
write_started.set()
await write_release.wait()
return True
config = MagicMock()
config.async_set = AsyncMock(side_effect=async_set)
monkeypatch.setattr(
plugin_runtime,
"get_plugin_manager",
lambda: SimpleNamespace(mutation=admission.hold),
)
monkeypatch.setattr(
system_endpoint,
"get_runtime_settings",
lambda: SimpleNamespace(contains=lambda _key: False),
)
monkeypatch.setattr(
system_endpoint,
"get_configured_system_config",
lambda: config,
)
monkeypatch.setattr(system_endpoint.eventmanager, "async_send_event", AsyncMock())
task = asyncio.create_task(
system_endpoint.set_setting(
SystemConfigKey.UserInstalledPlugins.value,
["DemoPlugin"],
None,
)
)
await write_started.wait()
assert admission.seal() == 1
idle_waiter = asyncio.create_task(asyncio.to_thread(admission.wait_until_idle))
await asyncio.sleep(0.02)
assert idle_waiter.done() is False
write_release.set()
response = await task
await idle_waiter
assert response.success is True
assert admission.active_count == 0
@pytest.mark.asyncio
async def test_agent_plugin_write_remains_owned_until_saved_value_read(
monkeypatch,
) -> None:
"""Agent 插件配置事务在写入和结果读取完成前持续持有 lease。"""
admission = PluginMutationAdmission()
write_started = asyncio.Event()
write_release = asyncio.Event()
async def async_set(_key: SystemConfigKey, _value: object) -> bool:
"""阻塞 Agent 配置写入,暴露 quiesce 等待窗口。"""
write_started.set()
await write_release.wait()
return True
config = MagicMock()
config.get.side_effect = [[], ["DemoPlugin"]]
config.async_set = AsyncMock(side_effect=async_set)
monkeypatch.setattr(
plugin_runtime,
"get_plugin_manager",
lambda: SimpleNamespace(mutation=admission.hold),
)
monkeypatch.setattr(
"app.agent.tools.impl.update_system_settings.eventmanager.async_send_event",
AsyncMock(),
)
tool = UpdateSystemSettingsTool(
session_id="session-1",
user_id="10001",
system_config=config,
)
task = asyncio.create_task(
tool.run(
setting_key=SystemConfigKey.UserInstalledPlugins.value,
value=["DemoPlugin"],
)
)
await write_started.wait()
assert admission.seal() == 1
idle_waiter = asyncio.create_task(asyncio.to_thread(admission.wait_until_idle))
await asyncio.sleep(0.02)
assert idle_waiter.done() is False
write_release.set()
payload = json.loads(await task)
await idle_waiter
assert payload["success"] is True
assert payload["saved_value"] == ["DemoPlugin"]
assert admission.active_count == 0
@pytest.mark.asyncio
async def test_non_plugin_system_config_does_not_resolve_plugin_runtime(
monkeypatch,
) -> None:
"""非插件 SystemConfig 写入保持原路径且不依赖插件组合根。"""
config = MagicMock()
config.async_set = AsyncMock(return_value=True)
def fail_runtime_resolution():
"""若非插件配置误取插件运行时则立即暴露回归。"""
raise AssertionError("非插件配置不应解析插件运行时")
monkeypatch.setattr(plugin_runtime, "get_plugin_manager", fail_runtime_resolution)
monkeypatch.setattr(
system_endpoint,
"get_runtime_settings",
lambda: SimpleNamespace(contains=lambda _key: False),
)
monkeypatch.setattr(
system_endpoint,
"get_configured_system_config",
lambda: config,
)
monkeypatch.setattr(system_endpoint.eventmanager, "async_send_event", AsyncMock())
response = await system_endpoint.set_setting(
SystemConfigKey.Directories.value,
[],
None,
)
assert response.success is True
config.async_set.assert_awaited_once_with(SystemConfigKey.Directories.value, None)
@pytest.mark.asyncio
async def test_agent_non_plugin_config_does_not_resolve_plugin_runtime(
monkeypatch,
) -> None:
"""Agent 非插件配置写入保持既有读写和事件语义。"""
config = MagicMock()
config.get.side_effect = [{}, {"chatgpt": {"enabled": True}}]
config.async_set = AsyncMock(return_value=True)
def fail_runtime_resolution():
"""若 Agent 非插件配置误取插件运行时则立即暴露回归。"""
raise AssertionError("非插件配置不应解析插件运行时")
monkeypatch.setattr(plugin_runtime, "get_plugin_manager", fail_runtime_resolution)
monkeypatch.setattr(
"app.agent.tools.impl.update_system_settings.eventmanager.async_send_event",
AsyncMock(),
)
tool = UpdateSystemSettingsTool(
session_id="session-1",
user_id="10001",
system_config=config,
)
payload = json.loads(
await tool.run(
setting_key=SystemConfigKey.AIAgentConfig.value,
value={"chatgpt": {"enabled": True}},
)
)
assert payload["success"] is True
assert payload["changed"] is True
config.async_set.assert_awaited_once()
+2
View File
@@ -297,6 +297,8 @@ def test_failed_retry_schedule_registers_future_observer(monkeypatch) -> None:
chain.retry_scheduler = MagicMock(schedule_retry=schedule_retry)
future = MagicMock(spec=Future)
event_loop = MagicMock()
event_loop.is_running.return_value = True
event_loop.is_closed.return_value = False
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", event_loop)
def submit(coroutine, loop):