From 22848eeda27e2455eb2aab9e43bfa95e93352c68 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 24 Aug 2026 03:20:45 +0800 Subject: [PATCH] refactor: unify protected task completion --- app/adapters/external/market.py | 10 ++---- app/application/plugin/install.py | 23 +++----------- app/runtime/execution.py | 19 +++++++++++- .../adr/0007-background-action-reliability.md | 2 ++ .../backend-architecture-next-stage.md | 8 +++-- .../architecture/dependency-baseline.json | 5 +-- tests/test_runtime_execution.py | 31 ++++++++++++++++++- 7 files changed, 64 insertions(+), 34 deletions(-) diff --git a/app/adapters/external/market.py b/app/adapters/external/market.py index e33279e83..77fd2befa 100644 --- a/app/adapters/external/market.py +++ b/app/adapters/external/market.py @@ -45,6 +45,7 @@ from app.adapters.system.plugin.manifest import ( from app.runtime.log import logger from app.runtime.observability import observe_compat_facade from app.runtime.execution import ( + await_task_to_terminal, run_in_threadpool_to_completion as _await_thread_operation, ) from app.runtime.tasks import get_task_registry @@ -1499,15 +1500,8 @@ class PluginHelper(metaclass=WeakSingleton): await asyncio.to_thread(created_file.unlink, missing_ok=True) cleanup_task = asyncio.create_task(cleanup_created_file()) - while not cleanup_task.done(): - try: - await asyncio.shield(cleanup_task) - except asyncio.CancelledError: - continue - except Exception: - break try: - await cleanup_task + await await_task_to_terminal(cleanup_task) except Exception as err: logger.warning(f"[UV] 取消后清理运行环境约束文件失败:{err}") raise diff --git a/app/application/plugin/install.py b/app/application/plugin/install.py index 5379f0ef3..268d84c94 100644 --- a/app/application/plugin/install.py +++ b/app/application/plugin/install.py @@ -9,6 +9,7 @@ from typing import Any, ContextManager, Optional from app.schemas.exception import PersistenceUnavailableError from app.application.plugin.lifecycle import plugin_lifecycle +from app.runtime.execution import await_task_to_terminal from app.runtime.log import logger from app.schemas.exception import PluginMutationRejectedError @@ -29,17 +30,6 @@ PluginRegistrationRefresher = Callable[[str], Awaitable[object]] 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: """描述失败安装中各类可补偿副作用的恢复结果。""" @@ -193,7 +183,7 @@ class PluginInstallCommand: state.checkpoint = checkpoint except asyncio.CancelledError: try: - state.checkpoint = await _await_task_to_terminal(checkpoint_task) + state.checkpoint = await await_task_to_terminal(checkpoint_task) except BaseException: pass raise @@ -369,7 +359,7 @@ class PluginInstallCommand: ) ) try: - result = await _await_task_to_terminal(rollback_task) + result = await await_task_to_terminal(rollback_task) except BaseException as err: logger.error(f"插件 {plugin_id} 取消后的补偿失败:{err}") return @@ -410,12 +400,7 @@ class PluginInstallCommand: cleanup_task = asyncio.create_task( self._restore_refreshed_runtime(plugin_id) ) - while not cleanup_task.done(): - try: - await asyncio.shield(cleanup_task) - except asyncio.CancelledError: - continue - rollback = await cleanup_task + rollback = await await_task_to_terminal(cleanup_task) state.refresh_compensated = True if rollback.errors: logger.error( diff --git a/app/runtime/execution.py b/app/runtime/execution.py index 48839734f..e9aad5506 100644 --- a/app/runtime/execution.py +++ b/app/runtime/execution.py @@ -3,12 +3,29 @@ import inspect import time from contextvars import copy_context from functools import partial, wraps -from typing import Any, Callable +from typing import Any, Callable, TypeVar from app.schemas.exception import ImmediateException from anyio.to_thread import run_sync +TaskResult = TypeVar("TaskResult") + + +async def await_task_to_terminal( + task: asyncio.Future[TaskResult], +) -> TaskResult: + """忽略当前调用方的重复取消,直到受保护任务进入真实终态。""" + while not task.done(): + try: + await asyncio.shield(task) + except asyncio.CancelledError: + continue + except BaseException: + break + return task.result() + + async def run_in_threadpool( func: Callable[..., Any], *args: Any, diff --git a/docs/adr/0007-background-action-reliability.md b/docs/adr/0007-background-action-reliability.md index 12d70b7b6..2c17253b2 100644 --- a/docs/adr/0007-background-action-reliability.md +++ b/docs/adr/0007-background-action-reliability.md @@ -102,6 +102,8 @@ Event Contract Registry 是 53 个事件的逐项机器清单。下表按相同 - 市场适配器和插件包适配器统一复用 `runtime.execution.run_in_threadpool_to_completion`;两个模块内的 `_await_thread_operation` 私有接缝保留为同一函数别名,不改变 `PluginHelper` 或 `PluginPackageManager` 的同步/异步调用合同。 +- 插件安装快照、取消补偿和临时约束文件清理统一复用 `runtime.execution.await_task_to_terminal`,连续取消 + 不再由 Application 与 Adapter 各自维护近似循环;数据库 worker 的可中断队列等待仍保留独立职责。 ### Transfer pending / 文件整理 diff --git a/docs/refactor/backend-architecture-next-stage.md b/docs/refactor/backend-architecture-next-stage.md index f10bb26ad..51db78759 100644 --- a/docs/refactor/backend-architecture-next-stage.md +++ b/docs/refactor/backend-architecture-next-stage.md @@ -6,7 +6,7 @@ > 审计范围:宿主后端;排除 `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 已统一插件文件操作的取消完成语义。 +> 实施进度:阶段 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 已统一插件协程补偿的终态等待。 ## 当前复核结论(2026-08-24) @@ -15,7 +15,7 @@ ### 长期整改阶段 0:治理门禁恢复(2026-08-23) -- 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `806` 个模块、`6531` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。 +- 宿主依赖基线已审查 TaskRegistry、有界后台 owner 与插件变更准入接入后的语义差异:当前为 `806` 个模块、`6532` 条内部导入边,12 组重点禁止边继续全部为 `0`,唯一非平凡 SCC 仍是隔离的 TMDB 移植包。 - 启动性能探针会在隔离生命周期中真实创建并释放 TaskRegistry;normal/safe 组件数分别为 `23`/`11`,CI 只读检查使用稳定的宿主模块集合和生命周期组件顺序,不再把 Python/平台模块数量当作硬合同。 - 官方插件快照覆盖 `plugins.v3`、`plugins.v2` 以及 V3 实际会从 `package.json` 回退加载的 31 个默认实现;`app/plugins/**` 仍只是宿主运行副本,不进入扫描。 - SDK 快照以各模块显式 `__all__` 为公开合同,能够记录赋值别名;`typing`、`__future__` 等实现期导入不再被误冻结,既有数据库备份门面已补精确导出清单。 @@ -112,7 +112,7 @@ - 继续采用单进程控制面是正确选择,不建议现在拆成微服务;插件、调度器、工作流、事件和数据库共享进程内状态,拆分会放大部署、事务和兼容成本。 - `foundation/domain/runtime/adapters/application/chain/api/startup` 的职责方向基本成立;宿主架构基线、复杂度 ratchet、异步阻塞 ratchet 当前均通过。 -- 依赖图当前为 `806` 个 Python 模块、`6531` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。 +- 依赖图当前为 `806` 个 Python 模块、`6532` 条内部导入边;唯一非平凡 SCC 位于隔离的 TMDB 第三方移植包内部,不应为了指标归零重写。 - 当前主要风险已经从“目录和依赖失控”转移到运行时协议、后台副作用的可靠性和遗留兼容面。换言之,下一阶段重点应是**语义收口和可验证性**,而不是继续搬文件或机械拆大文件。 综合评价:架构方向可持续,生产可用性较高;可演进性仍处于中等水平。现阶段没有静态审计发现必须立即推倒重来的 P0 架构问题,但存在需要按 P1/P2 计划治理的真实债务。 @@ -138,6 +138,8 @@ `stop_modules()` 会刷新未到期摘要并等待已启动回调;它属于 E1 观测,不扩大 TaskRegistry 或 Outbox 范围。 插件市场与插件包适配器原有两套线程取消 wrapper 也已统一到 `runtime.execution`:连续取消必须等同步 文件 worker 到达终态后再传播,避免提前释放 mutation owner;旧模块内私有名称保留为 canonical 别名。 + 插件安装快照、取消补偿与市场临时文件清理原有两套协程终态循环也已合并为 + `await_task_to_terminal`;数据库 worker 的 interruptible 等待属于队列 owner,未被机械合并。 2. **Model/Base 的数据库装饰器和隐式会话 ABI 已全部清零。** 查询、写事务和 `legacy_*` 装饰器均为 `0`;所有 Model `db` 参数要求显式 Session,Base CRUD 仅在调用方事务内查询或 stage。可无会话构造的入口统一留在 Oper,经组合根事务执行器运行;插件 SDK 不再导出宿主 Model。后续重点转为减少 ORM 对象跨层流转,并保持 Model 隐式事务零回退。 Oper 内部的执行入口也已统一:最后一处 `AgentTaskOper` 直接 transaction runner 调用已迁入 diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index d07e64b52..a5ce4ddbf 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6531, - "edge_sha256": "86b2ad0b585deba78c5e0e5e7e4b55cd66e05a2d5e988ed28ac7f6a4ae15ff2f", + "edge_count": 6532, + "edge_sha256": "3fdba2c6862f9ea28ab4f059c04dd6bb819b86fa9d9fb74d09a0cfcf69f6b711", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -2704,6 +2704,7 @@ "app.application.plugin.install -> app.application.plugin", "app.application.plugin.install -> app.application.plugin.lifecycle", "app.application.plugin.install -> app.runtime", + "app.application.plugin.install -> app.runtime.execution", "app.application.plugin.install -> app.runtime.log", "app.application.plugin.install -> app.schemas", "app.application.plugin.install -> app.schemas.exception", diff --git a/tests/test_runtime_execution.py b/tests/test_runtime_execution.py index 403bf3685..9da20d3e7 100644 --- a/tests/test_runtime_execution.py +++ b/tests/test_runtime_execution.py @@ -8,7 +8,10 @@ from anyio.to_thread import current_default_thread_limiter from app.adapters.external import market as market_adapter from app.adapters.system.plugin import package as plugin_package_adapter -from app.runtime.execution import run_in_threadpool_to_completion +from app.runtime.execution import ( + await_task_to_terminal, + run_in_threadpool_to_completion, +) def test_plugin_file_adapters_share_runtime_completion_contract() -> None: @@ -20,6 +23,32 @@ def test_plugin_file_adapters_share_runtime_completion_contract() -> None: ) +@pytest.mark.asyncio +async def test_await_task_to_terminal_ignores_repeated_cancellation() -> None: + """调用方连续取消时,受保护任务仍须结束并返回真实结果。""" + started = asyncio.Event() + release = asyncio.Event() + + async def protected_operation() -> str: + """阻塞到测试释放,用于观察受保护任务的真实终态。""" + started.set() + await release.wait() + return "completed" + + protected_task = asyncio.create_task(protected_operation()) + waiter = asyncio.create_task(await_task_to_terminal(protected_task)) + await started.wait() + + waiter.cancel() + await asyncio.sleep(0) + waiter.cancel() + await asyncio.sleep(0) + assert waiter.done() is False + + release.set() + assert await waiter == "completed" + + @pytest.mark.asyncio async def test_threadpool_capacity_is_held_until_cancelled_call_finishes() -> None: """调用方取消后,执行令牌必须由真实同步调用持有到终态。"""