refactor: expand async and type safety gates

This commit is contained in:
jxxghp
2026-08-22 13:21:52 +08:00
parent 677f6a66c2
commit 7bc5e01afd
12 changed files with 103 additions and 33 deletions
+11 -2
View File
@@ -2772,8 +2772,11 @@ class PluginHelper(metaclass=WeakSingleton):
await async_dest_path.mkdir(parents=True, exist_ok=True) await async_dest_path.mkdir(parents=True, exist_ok=True)
continue continue
await async_dest_path.parent.mkdir(parents=True, exist_ok=True) await async_dest_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info, 'r') as src: data = await asyncio.to_thread(
data = src.read() self.__read_release_zip_member,
zf,
info,
)
async with aiofiles.open(dest_path, 'wb') as dst: async with aiofiles.open(dest_path, 'wb') as dst:
await dst.write(data) await dst.write(data)
wrote_any = True wrote_any = True
@@ -2784,6 +2787,12 @@ class PluginHelper(metaclass=WeakSingleton):
logger.error(f"解压 Release 压缩包失败:{e}") logger.error(f"解压 Release 压缩包失败:{e}")
return False, f"解压 Release 压缩包失败:{e}" return False, f"解压 Release 压缩包失败:{e}"
@staticmethod
def __read_release_zip_member(zf: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes:
"""在线程池读取并解压单个 Release 文件,避免阻塞事件循环。"""
with zf.open(info, "r") as source:
return source.read()
# 公开 Release 查询的缓存管理统一指向仓库级分页缓存。 # 公开 Release 查询的缓存管理统一指向仓库级分页缓存。
PluginHelper.get_plugin_release_versions.cache_clear = PluginHelper._get_plugin_repo_releases.cache_clear PluginHelper.get_plugin_release_versions.cache_clear = PluginHelper._get_plugin_repo_releases.cache_clear
+17 -6
View File
@@ -87,6 +87,17 @@ SUMMARY_PROMPT = """请判断以下 AI 助手与用户的对话是否值得写
ACTIVITY_ENTRY_PATTERN = re.compile(r"^-\s+\*\*(?P<time>\d{2}:\d{2})\*\*\s+(?P<summary>.+)$") ACTIVITY_ENTRY_PATTERN = re.compile(r"^-\s+\*\*(?P<time>\d{2}:\d{2})\*\*\s+(?P<summary>.+)$")
def _write_activity_log_exclusive(path: Path, content: str) -> bool:
"""同步独占创建日志文件;调用方必须在线程池中执行本函数。"""
try:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
except FileExistsError:
return False
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(content)
return True
class QueryActivityLogInput(BaseModel): class QueryActivityLogInput(BaseModel):
"""查询活动日志工具的输入参数模型。""" """查询活动日志工具的输入参数模型。"""
@@ -565,18 +576,18 @@ class ActivityLogMiddleware(AgentMiddleware[ActivityLogState, ContextT, Response
await stream.write(entry) await stream.write(entry)
else: else:
header = f"# {today_str} 活动日志\n\n" header = f"# {today_str} 活动日志\n\n"
try: created = await anyio.to_thread.run_sync(
fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) _write_activity_log_exclusive,
except FileExistsError: Path(log_path),
header + entry,
)
if not created:
async with await anyio.open_file( async with await anyio.open_file(
log_path, log_path,
mode="a", mode="a",
encoding="utf-8", encoding="utf-8",
) as stream: ) as stream:
await stream.write(entry) await stream.write(entry)
else:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(header + entry)
logger.debug(f"Activity logged: {summarize_result(summary, max_chars=80)}") logger.debug(f"Activity logged: {summarize_result(summary, max_chars=80)}")
except Exception as e: except Exception as e:
logger.warning(f"Failed to append activity log: {summarize_error(e)}") logger.warning(f"Failed to append activity log: {summarize_error(e)}")
+2 -2
View File
@@ -16,7 +16,7 @@ from app.api.context import (
from app.api.data import get_async_db, get_db from app.api.data import get_async_db, get_db
from app.api.dependencies.data import repository from app.api.dependencies.data import repository
from app.application.outbox import AsyncOutboxTransaction from app.application.outbox import AsyncOutboxTransaction
from app.application.scheduling import Scheduler from app.application.scheduling import start_scheduler_job
from app.application.servarr import ServarrSubscriptionService from app.application.servarr import ServarrSubscriptionService
from app.application.subscription.delete import ( from app.application.subscription.delete import (
AsyncUnitOfWork as DeleteUnitOfWork, AsyncUnitOfWork as DeleteUnitOfWork,
@@ -99,7 +99,7 @@ def get_search_subscriptions_command(
def schedule_search(subscribe_id: int | None, state: str | None) -> None: def schedule_search(subscribe_id: int | None, state: str | None) -> None:
"""按历史参数提交订阅搜索调度任务。""" """按历史参数提交订阅搜索调度任务。"""
background_tasks.add_task( background_tasks.add_task(
Scheduler().start, start_scheduler_job,
job_id="subscribe_search", job_id="subscribe_search",
sid=subscribe_id, sid=subscribe_id,
state=state, state=state,
+5 -5
View File
@@ -12,7 +12,7 @@ Scheduler 实现由 startup 组合根在导入期注册,避免 application 层
import asyncio import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import StrEnum from enum import StrEnum
from typing import Any, Awaitable, Callable, List, Optional from typing import Any, Awaitable, Callable, List, Optional, cast
# Agent 自主定时任务在运行时调度器中的任务 ID 前缀。 # Agent 自主定时任务在运行时调度器中的任务 ID 前缀。
AGENT_TASK_JOB_PREFIX = "agent-task" AGENT_TASK_JOB_PREFIX = "agent-task"
@@ -143,12 +143,12 @@ class Scheduler:
def list_scheduler_jobs() -> List[Any]: def list_scheduler_jobs() -> List[Any]:
"""列出运行时调度器的全部任务。""" """列出运行时调度器的全部任务。"""
return get_scheduler().list() return cast(List[Any], get_scheduler().list())
def start_scheduler_job(job_id: str) -> None: def start_scheduler_job(job_id: str, **kwargs: Any) -> None:
"""立即运行指定的运行时定时任务。""" """立即运行指定的运行时定时任务。"""
get_scheduler().start(job_id) get_scheduler().start(job_id, **kwargs)
def update_plugin_job(plugin_id: str) -> None: def update_plugin_job(plugin_id: str) -> None:
@@ -163,7 +163,7 @@ def remove_plugin_job(plugin_id: str) -> None:
def start_agent_task(task_id: int) -> bool: def start_agent_task(task_id: int) -> bool:
"""立即执行 Agent 自主定时任务。""" """立即执行 Agent 自主定时任务。"""
return get_scheduler().start_agent_task(task_id) return cast(bool, get_scheduler().start_agent_task(task_id))
def get_agent_task_next_run(task_id: int) -> Optional[Any]: def get_agent_task_next_run(task_id: int) -> Optional[Any]:
+1 -1
View File
@@ -20,7 +20,7 @@ class EventErrorPolicy:
self, self,
*, *,
notifier: Callable[[], Optional[EventErrorNotifier]], notifier: Callable[[], Optional[EventErrorNotifier]],
emit_system_error: Callable[[dict], object], emit_system_error: Callable[[dict[str, Any]], object],
) -> None: ) -> None:
"""注入通知读取器和 SystemError 发送回调。""" """注入通知读取器和 SystemError 发送回调。"""
self._notifier = notifier self._notifier = notifier
+2 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import inspect import inspect
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from typing import Any, Protocol from typing import Any, Protocol, cast
from app.foundation.reflection import ObjectUtils from app.foundation.reflection import ObjectUtils
from app.runtime.execution import run_in_threadpool from app.runtime.execution import run_in_threadpool
@@ -324,7 +324,7 @@ class ModuleInvocationDispatcher:
def _module_name(module: Any, fallback: str) -> str: def _module_name(module: Any, fallback: str) -> str:
"""读取模块展示名,失败时回退到稳定类名。""" """读取模块展示名,失败时回退到稳定类名。"""
try: try:
return module.get_name() return cast(str, module.get_name())
except Exception as err: except Exception as err:
logger.debug("获取模块名称出错:%s", str(err)) logger.debug("获取模块名称出错:%s", str(err))
return fallback return fallback
@@ -899,6 +899,9 @@ OTel 初始化只能位于 Startup/AdapterDomain/Application 只依赖 no-op-
Workflow 执行状态 UoW 切片将 `app/application/workflow.py``app/startup/workflow.py` 纳入 strict 清单, Workflow 执行状态 UoW 切片将 `app/application/workflow.py``app/startup/workflow.py` 纳入 strict 清单,
治理范围扩大到 22 个源文件;事务命令、仓储 Protocol 和短会话适配器保持零错误。 治理范围扩大到 22 个源文件;事务命令、仓储 Protocol 和短会话适配器保持零错误。
异步安全与契约收口继续纳管 scheduling facade、Event error policy、Module dispatcher 和 async blocking
scannerstrict 清单扩大到 26 个源文件;已登记范围保持零错误,未使用全文件 ignore 或 `cast(Any, ...)`
#### ARCH-271:复杂度和端点预算 ratchet #### ARCH-271:复杂度和端点预算 ratchet
**目标**:阻止大方法继续增长,并让拆分对应真实阶段,而不是机械 helper 化。 **目标**:阻止大方法继续增长,并让拆分对应真实阶段,而不是机械 helper 化。
@@ -949,6 +952,9 @@ Workflow 执行状态 UoW 切片将 `app/application/workflow.py` 与 `app/start
通过。同步第三方 Module 仍由 dispatcher 的 `app.runtime.execution.run_in_threadpool` 兼容。 通过。同步第三方 Module 仍由 dispatcher 的 `app.runtime.execution.run_in_threadpool` 兼容。
- 2026-08-22 扫描范围扩大到 `app/chain``app/modules``app/startup``app/scheduler.py`;扩大后未发现 - 2026-08-22 扫描范围扩大到 `app/chain``app/modules``app/startup``app/scheduler.py`;扩大后未发现
新存量,仍只保留 ActivityLog 的一处原子 `os.open` 精确债务,并由测试锁定扫描根目录。 新存量,仍只保留 ActivityLog 的一处原子 `os.open` 精确债务,并由测试锁定扫描根目录。
- 扫描进一步覆盖 `adapters/db/doctor/domain/foundation/monitor/runtime/schemas/workflow` 及 CLI、Command、
Factory、Main 顶层入口,明确排除插件源码和 SDK。AsyncPath 条件派生识别已修正;Release zip 解压读取和
ActivityLog `O_EXCL` 独占创建移入线程池,扩围后 async 阻塞 baseline 从 1 降为 0。
## 6. 推荐执行队列 ## 6. 推荐执行队列
+5 -1
View File
@@ -14,9 +14,12 @@ files =
app/runtime/correlation.py, app/runtime/correlation.py,
app/runtime/observability/__init__.py, app/runtime/observability/__init__.py,
app/runtime/event/contracts.py, app/runtime/event/contracts.py,
app/runtime/event/errors.py,
app/runtime/extensions/module/contracts.py, app/runtime/extensions/module/contracts.py,
app/runtime/extensions/module/dispatcher.py,
app/application/outbox.py, app/application/outbox.py,
app/application/configuration.py, app/application/configuration.py,
app/application/scheduling.py,
app/application/workflow.py, app/application/workflow.py,
app/application/chain/context.py, app/application/chain/context.py,
app/application/chain/durable_events.py, app/application/chain/durable_events.py,
@@ -28,4 +31,5 @@ files =
app/startup/download_failure.py, app/startup/download_failure.py,
app/startup/workflow.py, app/startup/workflow.py,
app/api/context.py, app/api/context.py,
app/api/dependencies/subscription.py app/api/dependencies/subscription.py,
scripts/architecture/async_blocking.py
+33 -9
View File
@@ -6,17 +6,31 @@ import argparse
import ast import ast
import json import json
from collections import Counter from collections import Counter
from collections.abc import Iterator
from pathlib import Path from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/async-blocking-baseline.json" DEFAULT_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/async-blocking-baseline.json"
SCAN_ROOTS = ( SCAN_ROOTS = (
"app/adapters",
"app/api", "app/api",
"app/agent", "app/agent",
"app/application", "app/application",
"app/chain", "app/chain",
"app/db",
"app/doctor",
"app/domain",
"app/foundation",
"app/monitor",
"app/modules", "app/modules",
"app/runtime",
"app/schemas",
"app/startup", "app/startup",
"app/workflow",
"app/cli.py",
"app/command.py",
"app/factory.py",
"app/main.py",
"app/scheduler.py", "app/scheduler.py",
) )
BLOCKING_EXACT = { BLOCKING_EXACT = {
@@ -83,20 +97,28 @@ class _AsyncPathCollector(ast.NodeVisitor):
def visit_Assign(self, node: ast.Assign) -> None: def visit_Assign(self, node: ast.Assign) -> None:
"""识别 AsyncPath 构造和已知路径的 `/` 派生赋值。""" """识别 AsyncPath 构造和已知路径的 `/` 派生赋值。"""
is_async_path = ( is_async_path = self._is_async_path_expression(node.value)
isinstance(node.value, ast.Call)
and _call_name(node.value).endswith("AsyncPath")
) or (
isinstance(node.value, ast.BinOp)
and isinstance(node.value.left, ast.Name)
and node.value.left.id in self.paths
)
if is_async_path: if is_async_path:
for target in node.targets: for target in node.targets:
if isinstance(target, ast.Name): if isinstance(target, ast.Name):
self.paths.add(target.id) self.paths.add(target.id)
self.generic_visit(node) self.generic_visit(node)
def _is_async_path_expression(self, expression: ast.expr) -> bool:
"""识别 AsyncPath 构造及任意层级的 `/` 路径派生表达式。"""
if isinstance(expression, ast.Call):
return _call_name(expression).endswith("AsyncPath")
if isinstance(expression, ast.Name):
return expression.id in self.paths
if isinstance(expression, ast.BinOp):
return self._is_async_path_expression(expression.left)
if isinstance(expression, ast.IfExp):
return (
self._is_async_path_expression(expression.body)
and self._is_async_path_expression(expression.orelse)
)
return False
def visit_AnnAssign(self, node: ast.AnnAssign) -> None: def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
"""识别 `list[AsyncPath]` 等路径集合。""" """识别 `list[AsyncPath]` 等路径集合。"""
if isinstance(node.target, ast.Name): if isinstance(node.target, ast.Name):
@@ -160,7 +182,9 @@ class _AsyncCallVisitor(ast.NodeVisitor):
"""嵌套异步函数由模块级收集器单独治理。""" """嵌套异步函数由模块级收集器单独治理。"""
def _async_functions(tree: ast.Module): def _async_functions(
tree: ast.Module,
) -> Iterator[tuple[str, ast.AsyncFunctionDef]]:
"""产出模块顶层及类直接拥有的 async 函数限定名。""" """产出模块顶层及类直接拥有的 async 函数限定名。"""
for node in tree.body: for node in tree.body:
if isinstance(node, ast.AsyncFunctionDef): if isinstance(node, ast.AsyncFunctionDef):
+1 -3
View File
@@ -1,3 +1 @@
{ {}
"app/agent/middleware/activity_log.py:ActivityLogMiddleware._append_activity:os.open": 1
}
+15 -1
View File
@@ -8,13 +8,27 @@ from scripts.architecture.async_blocking import SCAN_ROOTS, compare_async_blocki
def test_async_blocking_scan_covers_runtime_entrypoints() -> None: def test_async_blocking_scan_covers_runtime_entrypoints() -> None:
"""扫描范围必须覆盖 API、Scheduler、Chain 及其启动组合根""" """扫描范围必须覆盖全部 canonical 宿主目录和顶层运行入口"""
assert { assert {
"app/adapters",
"app/api", "app/api",
"app/agent",
"app/application", "app/application",
"app/chain", "app/chain",
"app/db",
"app/doctor",
"app/domain",
"app/foundation",
"app/monitor",
"app/modules", "app/modules",
"app/runtime",
"app/schemas",
"app/startup", "app/startup",
"app/workflow",
"app/cli.py",
"app/command.py",
"app/factory.py",
"app/main.py",
"app/scheduler.py", "app/scheduler.py",
}.issubset({str(path) for path in SCAN_ROOTS}) }.issubset({str(path) for path in SCAN_ROOTS})
+5 -1
View File
@@ -23,12 +23,16 @@ def test_mypy_gate_has_explicit_strict_scope_without_global_ignore() -> None:
assert settings.getboolean("strict") is True assert settings.getboolean("strict") is True
assert "app/runtime/event/contracts.py" in governed_files assert "app/runtime/event/contracts.py" in governed_files
assert "app/runtime/extensions/module/contracts.py" in governed_files assert "app/runtime/extensions/module/contracts.py" in governed_files
assert "app/runtime/extensions/module/dispatcher.py" in governed_files
assert "app/runtime/event/errors.py" in governed_files
assert "app/application/scheduling.py" in governed_files
assert "scripts/architecture/async_blocking.py" in governed_files
assert "app/startup/context.py" in governed_files assert "app/startup/context.py" in governed_files
assert "app/startup/download_failure.py" in governed_files assert "app/startup/download_failure.py" in governed_files
assert "app/startup/workflow.py" in governed_files assert "app/startup/workflow.py" in governed_files
assert "app/application/workflow.py" in governed_files assert "app/application/workflow.py" in governed_files
assert "app/api/context.py" in governed_files assert "app/api/context.py" in governed_files
assert len(governed_files) >= 22 assert len(governed_files) >= 26
assert any(path.startswith("app/domain/") for path in governed_files) assert any(path.startswith("app/domain/") for path in governed_files)
assert "ignore_errors" not in MYPY_CONFIG.read_text(encoding="utf-8") assert "ignore_errors" not in MYPY_CONFIG.read_text(encoding="utf-8")