From 5cd5780d365989fdad732dccce47b823dc718739 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 30 Aug 2026 12:15:21 +0800 Subject: [PATCH] fix(architecture): harden complexity and concurrency gates --- scripts/architecture/complexity.py | 56 +- scripts/architecture/concurrency.py | 332 ++++- .../architecture/complexity-v2-baseline.json | 2 + .../architecture/concurrency-baseline.json | 1274 +++++++++-------- tests/test_complexity_gate.py | 68 +- tests/test_concurrency_gate.py | 178 ++- 6 files changed, 1141 insertions(+), 769 deletions(-) diff --git a/scripts/architecture/complexity.py b/scripts/architecture/complexity.py index ec506a3f1..95e1c53d8 100644 --- a/scripts/architecture/complexity.py +++ b/scripts/architecture/complexity.py @@ -47,46 +47,36 @@ def _iter_python_files(root: Path, roots: tuple[str, ...]) -> Iterable[Path]: yield candidate -def _walk_functions( +def _walk_owned_nodes( nodes: Iterable[ast.AST], prefix: str = "", -) -> Iterable[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]]: - """递归收集函数、方法和嵌套编排器,避免只检查公开入口。""" +) -> Iterable[tuple[str, ast.AST]]: + """通过完整 AST 子节点遍历收集类、方法和任意控制流中的嵌套 owner。""" for node in nodes: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + qualname = f"{prefix}.{node.name}" if prefix else node.name + yield qualname, node + yield from _walk_owned_nodes(ast.iter_child_nodes(node), qualname) + else: + yield from _walk_owned_nodes(ast.iter_child_nodes(node), prefix) + + +def _walk_functions( + nodes: Iterable[ast.AST], +) -> Iterable[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]]: + """收集含 dunder、Match、TryStar 在内的全部函数与方法 owner。""" + for qualname, node in _walk_owned_nodes(nodes): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - qualname = f"{prefix}.{node.name}" if prefix else node.name - if not node.name.startswith("__"): - yield qualname, node - yield from _walk_functions(node.body, qualname) - elif isinstance(node, ast.ClassDef): - qualname = f"{prefix}.{node.name}" if prefix else node.name - yield from _walk_functions(node.body, qualname) - elif isinstance(node, ast.If): - yield from _walk_functions(node.body, prefix) - yield from _walk_functions(node.orelse, prefix) - elif isinstance(node, (ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith)): - yield from _walk_functions(node.body, prefix) - yield from _walk_functions(node.orelse, prefix) if isinstance(node, (ast.For, ast.AsyncFor)) else () - elif isinstance(node, ast.Try): - yield from _walk_functions(node.body, prefix) - yield from _walk_functions(node.orelse, prefix) - yield from _walk_functions(node.finalbody, prefix) - for handler in node.handlers: - yield from _walk_functions(handler.body, prefix) + yield qualname, node def _walk_classes( nodes: Iterable[ast.AST], - prefix: str = "", ) -> Iterable[tuple[str, ast.ClassDef]]: - """递归收集类体长度,覆盖 Scheduler 和私有 owner。""" - for node in nodes: + """收集任意控制流和函数内部的类,并保留完整嵌套 owner。""" + for qualname, node in _walk_owned_nodes(nodes): if isinstance(node, ast.ClassDef): - qualname = f"{prefix}.{node.name}" if prefix else node.name yield qualname, node - yield from _walk_classes(node.body, qualname) - elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - yield from _walk_classes(node.body, prefix) def collect_complexity_v2(root: Path = PROJECT_ROOT) -> dict[str, dict[str, int]]: @@ -99,12 +89,12 @@ def collect_complexity_v2(root: Path = PROJECT_ROOT) -> dict[str, dict[str, int] if len(source_lines) > 1000: report["file"][relative] = len(source_lines) tree = ast.parse("\n".join(source_lines), filename=str(path)) - for qualname, node in _walk_functions(tree.body): - line_count = (node.end_lineno or node.lineno) - node.lineno + 1 + for qualname, method_node in _walk_functions(tree.body): + line_count = (method_node.end_lineno or method_node.lineno) - method_node.lineno + 1 if line_count > 150: report["method"][f"{relative}:{qualname}"] = line_count - for qualname, node in _walk_classes(tree.body): - line_count = (node.end_lineno or node.lineno) - node.lineno + 1 + for qualname, class_node in _walk_classes(tree.body): + line_count = (class_node.end_lineno or class_node.lineno) - class_node.lineno + 1 if line_count > 500: report["class"][f"{relative}:{qualname}"] = line_count return report diff --git a/scripts/architecture/concurrency.py b/scripts/architecture/concurrency.py index ef5163cf3..00194c643 100644 --- a/scripts/architecture/concurrency.py +++ b/scripts/architecture/concurrency.py @@ -7,58 +7,73 @@ import ast import json from dataclasses import dataclass from pathlib import Path -from typing import Iterable +from typing import Iterable, TypedDict, cast PROJECT_ROOT = Path(__file__).resolve().parents[2] DEFAULT_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/concurrency-baseline.json" -SCAN_ROOTS = ( - "app/adapters", - "app/api", - "app/agent", - "app/application", - "app/chain", - "app/command.py", - "app/db", - "app/domain", - "app/foundation", - "app/main.py", - "app/monitor", - "app/modules", - "app/runtime", - "app/scheduler", - "app/startup", - "app/workflow", -) +SCAN_ROOTS = ("app",) +EXCLUDED_ROOTS = ("app/plugins", "app/sdk", "app/runtime/compat") -_CALL_SUFFIXES = frozenset({ - "create_task", "ensure_future", "to_thread", "run_in_executor", "run_in_threadpool", +_RECORDED_CALLS = frozenset({ + "app.runtime.execution.run_in_threadpool", + "app.runtime.execution.run_in_threadpool_to_completion", + "asyncio.TaskGroup", + "asyncio.create_task", + "asyncio.ensure_future", + "asyncio.to_thread", + "concurrent.futures.ProcessPoolExecutor", + "concurrent.futures.ThreadPoolExecutor", + "multiprocessing.Process", + "starlette.concurrency.run_in_threadpool", + "threading.Thread", + "threading.Timer", }) -_CONSTRUCTOR_NAMES = frozenset({ - "Thread", "Timer", "ThreadPoolExecutor", "ProcessPoolExecutor", "Process", +_LOOP_FACTORIES = frozenset({ + "asyncio.get_event_loop", + "asyncio.get_running_loop", + "asyncio.new_event_loop", }) +_LOOP_TYPES = frozenset({"asyncio.AbstractEventLoop", "asyncio.BaseEventLoop"}) +_EXECUTOR_TYPES = frozenset({ + "concurrent.futures.Executor", + "concurrent.futures.ProcessPoolExecutor", + "concurrent.futures.ThreadPoolExecutor", +}) +_EVENT_LOOP_KIND = "asyncio.AbstractEventLoop" +_TASK_GROUP_KIND = "asyncio.TaskGroup" +_EXECUTOR_KIND = "concurrent.futures.Executor" +_RECEIVER_CALLS = { + (_EVENT_LOOP_KIND, "create_task"): "asyncio.AbstractEventLoop.create_task", + (_EVENT_LOOP_KIND, "run_in_executor"): "asyncio.AbstractEventLoop.run_in_executor", + (_TASK_GROUP_KIND, "create_task"): "asyncio.TaskGroup.create_task", + (_EXECUTOR_KIND, "map"): "concurrent.futures.Executor.map", + (_EXECUTOR_KIND, "submit"): "concurrent.futures.Executor.submit", +} + + +class ConcurrencyRecord(TypedDict): + """描述一种原生并发调用在文件内的 owner 计数。""" + + target: str + owners: dict[str, int] @dataclass(frozen=True, slots=True) class ConcurrencyFact: - """描述一个原生并发调用及其静态 owner。""" + """描述一个已确认来源的原生并发调用及其静态 owner。""" path: str - line: int target: str owner: str @property def key(self) -> str: - """返回跨运行稳定的事实键。""" - return f"{self.path}:{self.line}:{self.target}" - - def as_dict(self) -> dict[str, str]: - """转换为基线文件中的稳定记录。""" - return {"owner": self.owner, "target": self.target} + """返回不依赖物理行号的事实键。""" + return f"{self.path}:{self.target}" def _iter_python_files(root: Path, scan_roots: Iterable[str]) -> Iterable[Path]: - """遍历宿主源码并排除插件副本、SDK 和兼容实现。""" + """遍历完整宿主源码,并只排除有独立治理契约的兼容表面。""" for scan_root in scan_roots: path = root / scan_root if path.is_file(): @@ -71,63 +86,203 @@ def _iter_python_files(root: Path, scan_roots: Iterable[str]) -> Iterable[Path]: relative = candidate.relative_to(root).as_posix() if any( relative == excluded or relative.startswith(f"{excluded}/") - for excluded in ("app/plugins", "app/sdk", "app/runtime/compat") + for excluded in EXCLUDED_ROOTS ): continue yield candidate -def _target_name(node: ast.Call) -> str | None: - """识别约定的原生并发 API,避免按普通方法名误报。""" - if not isinstance(node.func, (ast.Name, ast.Attribute)): - return None - target = ast.unparse(node.func) - terminal = target.rsplit(".", 1)[-1] - if terminal in _CALL_SUFFIXES or terminal in _CONSTRUCTOR_NAMES: - return terminal - return None - - class _ConcurrencyVisitor(ast.NodeVisitor): - """收集函数、类和模块级并发调用的词法 owner。""" + """按 canonical import、局部来源和词法 owner 收集并发调用。""" def __init__(self, relative_path: str) -> None: - """初始化文件位置和 owner 栈。""" + """初始化文件位置、owner 栈和逐作用域名称绑定。""" self._relative_path = relative_path self._owners: list[str] = [] + self._class_owners: list[str] = [] + self._bindings: list[dict[str, str | None]] = [{}] + self._attribute_bindings: dict[tuple[str, str], str] = {} self.facts: list[ConcurrencyFact] = [] @property def _owner(self) -> str: """返回最具体的可定位 owner。""" - return ".".join(self._owners) if self._owners else self._relative_path + return ".".join(self._owners) if self._owners else "" + + @property + def _class_owner(self) -> str: + """返回当前类路径,用于区分不同类的实例属性。""" + return ".".join(self._class_owners) + + def _bind_name(self, name: str, origin: str | None) -> None: + """在当前作用域绑定名称;None 表示显式遮蔽外层导入。""" + self._bindings[-1][name] = origin + + def _lookup_name(self, name: str) -> str | None: + """按 Python 词法作用域顺序解析名称来源。""" + for scope in reversed(self._bindings): + if name in scope: + return scope[name] + return None + + def _attribute_key(self, node: ast.Attribute) -> tuple[str, str]: + """为实例属性生成带类 owner 的稳定绑定键。""" + return self._class_owner, ast.unparse(node) + + def _resolve_expression(self, node: ast.AST) -> str | None: + """解析名称、属性或构造结果的 canonical 来源。""" + if isinstance(node, ast.Name): + return self._lookup_name(node.id) + if isinstance(node, ast.Attribute): + bound = self._attribute_bindings.get(self._attribute_key(node)) + if bound: + return bound + base = self._resolve_expression(node.value) + return f"{base}.{node.attr}" if base else None + if isinstance(node, ast.Call): + target = self._resolve_expression(node.func) + if target in _LOOP_FACTORIES: + return _EVENT_LOOP_KIND + if target == _TASK_GROUP_KIND: + return _TASK_GROUP_KIND + if target in _EXECUTOR_TYPES: + return _EXECUTOR_KIND + return None + + def _resolve_call_target(self, node: ast.Call) -> str | None: + """仅在调用来源可证明时返回受治理的 canonical target。""" + direct = self._resolve_expression(node.func) + if direct in _RECORDED_CALLS: + return direct + if isinstance(node.func, ast.Attribute): + receiver = self._resolve_expression(node.func.value) + return _RECEIVER_CALLS.get((receiver or "", node.func.attr)) + return None + + def _bind_target(self, target: ast.expr, origin: str | None) -> None: + """把赋值目标关联到可证明来源,并显式遮蔽未知名称。""" + if isinstance(target, ast.Name): + self._bind_name(target.id, origin) + elif isinstance(target, ast.Attribute): + key = self._attribute_key(target) + if origin: + self._attribute_bindings[key] = origin + else: + self._attribute_bindings.pop(key, None) + elif isinstance(target, (ast.Tuple, ast.List)): + for item in target.elts: + self._bind_target(item, None) + + def _bind_annotated_arguments(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + """根据参数注解标记事件循环、TaskGroup 和 Executor 来源。""" + arguments = [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs] + if node.args.vararg: + arguments.append(node.args.vararg) + if node.args.kwarg: + arguments.append(node.args.kwarg) + for argument in arguments: + if argument.annotation is None: + continue + origins = { + origin + for item in ast.walk(argument.annotation) + if (origin := self._resolve_expression(item)) + } + if origins & _LOOP_TYPES: + self._bind_name(argument.arg, _EVENT_LOOP_KIND) + elif _TASK_GROUP_KIND in origins: + self._bind_name(argument.arg, _TASK_GROUP_KIND) + elif origins & _EXECUTOR_TYPES: + self._bind_name(argument.arg, _EXECUTOR_KIND) + + def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + """进入函数 owner 和局部作用域后扫描全部子节点。""" + self._bind_name(node.name, None) + self._owners.append(node.name) + self._bindings.append({}) + self._bind_annotated_arguments(node) + self.generic_visit(node) + self._bindings.pop() + self._owners.pop() + + def visit_Import(self, node: ast.Import) -> None: + """登记模块导入及其别名。""" + for alias in node.names: + bound_name = alias.asname or alias.name.split(".", 1)[0] + origin = alias.name if alias.asname else bound_name + self._bind_name(bound_name, origin) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + """登记直接导入符号及其别名。""" + if not node.module: + return + for alias in node.names: + if alias.name == "*": + continue + self._bind_name(alias.asname or alias.name, f"{node.module}.{alias.name}") def visit_ClassDef(self, node: ast.ClassDef) -> None: - """把类名纳入 owner 路径后继续扫描方法体。""" + """把类名纳入 owner 路径并隔离类体名称绑定。""" + self._bind_name(node.name, None) self._owners.append(node.name) + self._class_owners.append(node.name) + self._bindings.append({}) self.generic_visit(node) + self._bindings.pop() + self._class_owners.pop() self._owners.pop() def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - """把同步函数纳入 owner 路径后扫描其嵌套并发。""" - self._owners.append(node.name) - self.generic_visit(node) - self._owners.pop() + """扫描同步函数及其嵌套 owner。""" + self._visit_function(node) def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: - """把异步函数纳入 owner 路径后扫描其嵌套并发。""" - self._owners.append(node.name) - self.generic_visit(node) - self._owners.pop() + """扫描异步函数及其嵌套 owner。""" + self._visit_function(node) + + def visit_Assign(self, node: ast.Assign) -> None: + """记录赋值右侧调用并传播可证明的运行时来源。""" + self.visit(node.value) + origin = self._resolve_expression(node.value) + for target in node.targets: + self._bind_target(target, origin) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + """处理带注解赋值并传播右侧来源。""" + self.visit(node.annotation) + if node.value: + self.visit(node.value) + self._bind_target(node.target, self._resolve_expression(node.value) if node.value else None) + + def visit_NamedExpr(self, node: ast.NamedExpr) -> None: + """处理海象表达式中的来源传播。""" + self.visit(node.value) + self._bind_target(node.target, self._resolve_expression(node.value)) + + def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: + """先解析上下文管理器,再让其绑定在语句体内可见。""" + for item in node.items: + self.visit(item.context_expr) + if item.optional_vars: + self._bind_target(item.optional_vars, self._resolve_expression(item.context_expr)) + for statement in node.body: + self.visit(statement) + + def visit_With(self, node: ast.With) -> None: + """扫描同步上下文管理器及其绑定。""" + self._visit_with(node) + + def visit_AsyncWith(self, node: ast.AsyncWith) -> None: + """扫描异步上下文管理器及其绑定。""" + self._visit_with(node) def visit_Call(self, node: ast.Call) -> None: - """记录已识别原语并继续访问参数中的嵌套调用。""" - target = _target_name(node) + """记录来源明确的原语并继续访问参数中的嵌套调用。""" + target = self._resolve_call_target(node) if target: self.facts.append( ConcurrencyFact( path=self._relative_path, - line=node.lineno, target=target, owner=self._owner, ) @@ -139,36 +294,54 @@ def collect_concurrency( root: Path = PROJECT_ROOT, *, scan_roots: Iterable[str] = SCAN_ROOTS, -) -> dict[str, dict[str, str]]: - """收集全部宿主原生并发调用及静态 owner。""" - facts: dict[str, dict[str, str]] = {} +) -> dict[str, ConcurrencyRecord]: + """按文件、canonical target 和 owner 计数收集原生并发事实。""" + records: dict[str, ConcurrencyRecord] = {} for path in _iter_python_files(root, scan_roots): relative = path.relative_to(root).as_posix() tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) visitor = _ConcurrencyVisitor(relative) visitor.visit(tree) for fact in visitor.facts: - facts[fact.key] = fact.as_dict() - return dict(sorted(facts.items())) + record = records.setdefault(fact.key, {"target": fact.target, "owners": {}}) + record["owners"][fact.owner] = record["owners"].get(fact.owner, 0) + 1 + return dict(sorted(records.items())) def compare_concurrency( - baseline: dict[str, dict[str, str]], - current: dict[str, dict[str, str]], + baseline: dict[str, ConcurrencyRecord], + current: dict[str, ConcurrencyRecord], ) -> list[str]: - """拒绝新增原语、owner 丢失或事实 owner 漂移。""" + """拒绝新增/增长,并要求事实减少时同步下调低水位基线。""" problems: list[str] = [] - for key, record in current.items(): - if not record.get("owner"): - problems.append(f"并发事实缺少 owner:{key}") - continue + for key in sorted(baseline.keys() | current.keys()): previous = baseline.get(key) - if previous is None: - problems.append(f"新增原生并发事实:{key}") - elif previous.get("owner") != record.get("owner"): - problems.append( - f"并发 owner 漂移:{key}={record.get('owner')}(基线为 {previous.get('owner')})" - ) + record = current.get(key) + if previous is None and record is not None: + problems.append(f"新增原生并发事实:{key} owners={record['owners']}") + continue + if record is None and previous is not None: + problems.append(f"并发低水位已下降,请刷新基线:{key}") + continue + if previous is None or record is None: + continue + if record.get("target") != previous.get("target"): + problems.append(f"并发 target 漂移:{key}") + previous_owners = previous.get("owners", {}) + current_owners = record.get("owners", {}) + for owner in sorted(previous_owners.keys() | current_owners.keys()): + old_count = previous_owners.get(owner, 0) + new_count = current_owners.get(owner, 0) + if not owner: + problems.append(f"并发事实缺少 owner:{key}") + elif new_count > old_count: + problems.append( + f"并发调用新增或增长:{key} owner={owner} {new_count}>{old_count}" + ) + elif new_count < old_count: + problems.append( + f"并发低水位已下降,请刷新基线:{key} owner={owner} {new_count}<{old_count}" + ) return problems @@ -187,7 +360,10 @@ def main() -> int: ) print(f"已写入 {args.baseline.relative_to(PROJECT_ROOT)}") return 0 - baseline = json.loads(args.baseline.read_text(encoding="utf-8")) + baseline = cast( + dict[str, ConcurrencyRecord], + json.loads(args.baseline.read_text(encoding="utf-8")), + ) problems = compare_concurrency(baseline, current) if problems: print("\n".join(problems)) diff --git a/tests/fixtures/architecture/complexity-v2-baseline.json b/tests/fixtures/architecture/complexity-v2-baseline.json index 578e1f72c..21fe3b32b 100644 --- a/tests/fixtures/architecture/complexity-v2-baseline.json +++ b/tests/fixtures/architecture/complexity-v2-baseline.json @@ -58,8 +58,10 @@ "app/application/messaging/skill.py:SkillInteractionHandler._handle_text_interaction": 296, "app/application/messaging/subscribe.py:SubscribeInteractionHandler._handle_text_interaction": 205, "app/application/network.py:NetworkTestService._build_rules": 206, + "app/application/plugin/install.py:PluginInstallCommand.__execute_locked": 277, "app/application/rss.py:RssHelper._parse_impl": 213, "app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl": 241, + "app/application/security/cookie.py:CookieHelper._get_site_cookie_ua_impl.__page_handler": 214, "app/chain/download/batch.py:DownloadBatchOwner._execute_batch_download": 478, "app/chain/download/submission.py:DownloadSubmissionOwner._execute_download_single": 176, "app/chain/message.py:MessageChain._handle_ai_message": 202, diff --git a/tests/fixtures/architecture/concurrency-baseline.json b/tests/fixtures/architecture/concurrency-baseline.json index 7856a369f..99ec10d8a 100644 --- a/tests/fixtures/architecture/concurrency-baseline.json +++ b/tests/fixtures/architecture/concurrency-baseline.json @@ -1,622 +1,658 @@ { - "app/adapters/external/plugin/client.py:1372:to_thread": { - "owner": "PluginMarketTransport.async_get_plugin_system_version_check_message", - "target": "to_thread" - }, - "app/adapters/external/server.py:1005:to_thread": { - "owner": "MoviePilotServerHelper.sub_reg_async", - "target": "to_thread" - }, - "app/adapters/external/server.py:1019:to_thread": { - "owner": "MoviePilotServerHelper.sub_done_async", - "target": "to_thread" - }, - "app/adapters/network/browser.py:461:ThreadPoolExecutor": { - "owner": "BrowserSessionHelper._get_session_executor", - "target": "ThreadPoolExecutor" - }, - "app/adapters/network/http.py:224:create_task": { - "owner": "_get_shared_async_transport", - "target": "create_task" - }, - "app/adapters/system/fsproxy.py:399:Thread": { - "owner": "FileSystemProxy._read_line_windows", - "target": "Thread" - }, - "app/adapters/system/host.py:1012:Process": { - "owner": "SystemUtils.memory_usage", - "target": "Process" - }, - "app/adapters/system/host.py:255:Process": { - "owner": "SystemUtils._process_tree", - "target": "Process" - }, - "app/adapters/system/host.py:329:create_task": { - "owner": "SystemUtils.execute_with_subprocess_async", - "target": "create_task" - }, - "app/adapters/system/host.py:950:Process": { - "owner": "SystemUtils.dashboard_system_info", - "target": "Process" - }, - "app/adapters/system/plugin/dependency.py:414:to_thread": { - "owner": "PluginDependencyInstaller.async_find_missing", - "target": "to_thread" - }, - "app/adapters/system/plugin/health.py:403:create_task": { - "owner": "PluginRuntimeHealth.__async_create_runtime_constraints_file", - "target": "create_task" - }, - "app/adapters/system/plugin/health.py:404:to_thread": { - "owner": "PluginRuntimeHealth.__async_create_runtime_constraints_file", - "target": "to_thread" - }, - "app/adapters/system/plugin/health.py:417:to_thread": { - "owner": "PluginRuntimeHealth.__async_create_runtime_constraints_file.cleanup_created_file", - "target": "to_thread" - }, - "app/adapters/system/plugin/health.py:419:create_task": { - "owner": "PluginRuntimeHealth.__async_create_runtime_constraints_file", - "target": "create_task" - }, - "app/adapters/system/plugin/package.py:2130:to_thread": { - "owner": "PluginPackageManager.__async_install_from_release", - "target": "to_thread" - }, - "app/agent/callback/__init__.py:239:create_task": { - "owner": "StreamingHandler._start_streaming", - "target": "create_task" - }, - "app/agent/callback/__init__.py:271:run_in_threadpool": { - "owner": "StreamingHandler._stop_streaming", - "target": "run_in_threadpool" - }, - "app/agent/callback/__init__.py:606:run_in_threadpool": { - "owner": "StreamingHandler._flush", - "target": "run_in_threadpool" - }, - "app/agent/callback/__init__.py:653:run_in_threadpool": { - "owner": "StreamingHandler._flush", - "target": "run_in_threadpool" - }, - "app/agent/callback/__init__.py:691:run_in_threadpool": { - "owner": "StreamingHandler._flush", - "target": "run_in_threadpool" - }, - "app/agent/capabilities/adapter.py:117:to_thread": { - "owner": "AgentServiceAdapter.materialize", - "target": "to_thread" - }, - "app/agent/lifecycle.py:104:create_task": { - "owner": "AgentLifecycleOwner.close", - "target": "create_task" - }, - "app/agent/lifecycle.py:33:create_task": { - "owner": "AgentLifecycleOwner.initialize", - "target": "create_task" - }, - "app/agent/llm/discovery.py:195:to_thread": { - "owner": "_ProviderDiscovery._load_models_dev_from_disk", - "target": "to_thread" - }, - "app/agent/llm/discovery.py:223:to_thread": { - "owner": "_ProviderDiscovery._write_models_dev_to_disk", - "target": "to_thread" - }, - "app/agent/llm/discovery.py:684:to_thread": { - "owner": "_ProviderDiscovery._list_models_from_bedrock", - "target": "to_thread" - }, - "app/agent/llm/discovery.py:691:to_thread": { - "owner": "_ProviderDiscovery._list_models_from_bedrock", - "target": "to_thread" - }, - "app/agent/mcp.py:215:create_task": { - "owner": "_StdioMcpSession.__aenter__", - "target": "create_task" - }, - "app/agent/memory/__init__.py:64:create_task": { - "owner": "MemoryManager.initialize", - "target": "create_task" - }, - "app/agent/middleware/subagents.py:1091:create_task": { - "owner": "SubAgentTaskControlMiddleware._run_pipeline", - "target": "create_task" - }, - "app/agent/middleware/subagents.py:841:create_task": { - "owner": "SubAgentTaskControlMiddleware._start_tasks", - "target": "create_task" - }, - "app/agent/orchestrator.py:1050:run_in_threadpool": { - "owner": "MoviePilotAgent._deliver_private_channel_message", - "target": "run_in_threadpool" - }, - "app/agent/session.py:374:create_task": { - "owner": "AgentSessionOwner.process_message", - "target": "create_task" - }, - "app/agent/session.py:655:create_task": { - "owner": "AgentSessionOwner._stop_current_task_locked", - "target": "create_task" - }, - "app/agent/session.py:71:run_in_threadpool": { - "owner": "_async_start_processing_status", - "target": "run_in_threadpool" - }, - "app/agent/session.py:755:create_task": { - "owner": "AgentSessionOwner._schedule_deferred_session_cleanup", - "target": "create_task" - }, - "app/agent/session.py:83:run_in_threadpool": { - "owner": "_async_finish_processing_status", - "target": "run_in_threadpool" - }, - "app/agent/tools/base.py:199:ThreadPoolExecutor": { - "owner": "_submit_blocking_call", - "target": "ThreadPoolExecutor" - }, - "app/agent/tools/impl/_terminal_session.py:257:create_task": { - "owner": "_TerminalSessionManager.start", - "target": "create_task" - }, - "app/agent/tools/impl/_terminal_session.py:302:create_task": { - "owner": "_TerminalSessionManager._start_pty_session", - "target": "create_task" - }, - "app/agent/tools/impl/_terminal_session.py:303:create_task": { - "owner": "_TerminalSessionManager._start_pty_session", - "target": "create_task" - }, - "app/agent/tools/impl/_terminal_session.py:326:create_task": { - "owner": "_TerminalSessionManager._start_pipe_session", - "target": "create_task" - }, - "app/agent/tools/impl/_terminal_session.py:330:create_task": { - "owner": "_TerminalSessionManager._start_pipe_session", - "target": "create_task" - }, - "app/agent/tools/impl/_terminal_session.py:332:create_task": { - "owner": "_TerminalSessionManager._start_pipe_session", - "target": "create_task" - }, - "app/agent/tools/impl/_terminal_session.py:372:to_thread": { - "owner": "_TerminalSessionManager._wait_pty_process", - "target": "to_thread" - }, - "app/agent/tools/impl/_terminal_session.py:477:to_thread": { - "owner": "_TerminalSessionManager.write", - "target": "to_thread" - }, - "app/agent/tools/impl/execute_command.py:475:create_task": { - "owner": "ExecuteCommandTool._run_once", - "target": "create_task" - }, - "app/agent/tools/impl/execute_command.py:477:create_task": { - "owner": "ExecuteCommandTool._run_once", - "target": "create_task" - }, - "app/agent/tools/impl/execute_command.py:478:create_task": { - "owner": "ExecuteCommandTool._run_once", - "target": "create_task" - }, - "app/api/endpoints/dashboard.py:183:run_in_threadpool": { - "owner": "schedule", - "target": "run_in_threadpool" - }, - "app/api/endpoints/dashboard.py:214:run_in_threadpool": { - "owner": "schedule2", - "target": "run_in_threadpool" - }, - "app/api/endpoints/history.py:111:to_thread": { - "owner": "_request_batch_durable_retries", - "target": "to_thread" - }, - "app/api/endpoints/history.py:447:to_thread": { - "owner": "ai_redo_transfer_history", - "target": "to_thread" - }, - "app/api/endpoints/search.py:218:create_task": { - "owner": "_iter_batched_search_events", - "target": "create_task" - }, - "app/application/agenttask.py:286:create_task": { - "owner": "AgentTaskExecutionService.claim", - "target": "create_task" - }, - "app/application/agenttask.py:293:create_task": { - "owner": "AgentTaskExecutionService.claim", - "target": "create_task" - }, - "app/application/agenttask.py:359:create_task": { - "owner": "AgentTaskExecutionService._finalize", - "target": "create_task" - }, - "app/application/messaging/agent.py:1023:run_in_threadpool": { - "owner": "get_web_agent_upload_dir", - "target": "run_in_threadpool" - }, - "app/application/messaging/agent.py:1047:run_in_threadpool": { - "owner": "save_web_agent_upload", - "target": "run_in_threadpool" - }, - "app/application/messaging/agent.py:1263:run_in_threadpool": { - "owner": "prepare_web_agent_audio_attachment_path_async", - "target": "run_in_threadpool" - }, - "app/application/messaging/agent.py:1271:run_in_threadpool": { - "owner": "prepare_web_agent_audio_attachment_path_async", - "target": "run_in_threadpool" - }, - "app/application/messaging/agent.py:1278:run_in_threadpool": { - "owner": "prepare_web_agent_audio_attachment_path_async", - "target": "run_in_threadpool" - }, - "app/application/messaging/agent.py:1289:run_in_threadpool": { - "owner": "prepare_web_agent_audio_attachment_path_async.cleanup_conversion_output", - "target": "run_in_threadpool" - }, - "app/application/messaging/agent.py:1311:run_in_threadpool": { - "owner": "prepare_web_agent_audio_attachment_path_async", - "target": "run_in_threadpool" - }, - "app/application/messaging/agent.py:1417:to_thread": { - "owner": "transcribe_web_agent_audio_input", - "target": "to_thread" - }, - "app/application/messaging/agent.py:1772:run_in_threadpool": { - "owner": "collect_web_agent_traditional_events", - "target": "run_in_threadpool" - }, - "app/application/messaging/agent.py:1802:to_thread": { - "owner": "collect_web_agent_traditional_events", - "target": "to_thread" - }, - "app/application/messaging/agent.py:1875:create_task": { - "owner": "_build_traditional_web_agent_stream.event_generator", - "target": "create_task" - }, - "app/application/messaging/agent.py:715:create_task": { - "owner": "WebAgentEventPublisher.__init__", - "target": "create_task" - }, - "app/application/messaging/message.py:892:Thread": { - "owner": "MessageQueueManager.start", - "target": "Thread" - }, - "app/application/plugin/catalog.py:195:ThreadPoolExecutor": { - "owner": "PluginCatalogService.collect", - "target": "ThreadPoolExecutor" - }, - "app/application/plugin/catalog.py:255:create_task": { - "owner": "PluginCatalogService.async_collect", - "target": "create_task" - }, - "app/application/plugin/catalog.py:260:create_task": { - "owner": "PluginCatalogService.async_collect", - "target": "create_task" - }, - "app/application/plugin/install.py:661:create_task": { - "owner": "PluginInstallCommand.__resolve_journal_created", - "target": "create_task" - }, - "app/application/plugin/install.py:696:create_task": { - "owner": "PluginInstallCommand.__resolve_commit_outcome", - "target": "create_task" - }, - "app/application/plugin/install.py:851:create_task": { - "owner": "PluginInstallCommand.__rollback_cancelled", - "target": "create_task" - }, - "app/application/plugin/install.py:871:ensure_future": { - "owner": "PluginInstallCommand.__await_side_effect", - "target": "ensure_future" - }, - "app/application/plugin/inventory.py:116:create_task": { - "owner": "PluginCandidateInventoryReader.async_load", - "target": "create_task" - }, - "app/application/plugin/inventory.py:131:to_thread": { - "owner": "PluginCandidateInventoryReader.async_load", - "target": "to_thread" - }, - "app/application/plugin/inventory.py:181:to_thread": { - "owner": "PluginCandidateInventoryReader._async_read_market_generation", - "target": "to_thread" - }, - "app/chain/_recognition.py:527:run_in_threadpool": { - "owner": "RecognitionMixin._async_run_recognition_steps", - "target": "run_in_threadpool" - }, - "app/chain/media/album.py:180:run_in_threadpool": { - "owner": "MediaAlbumOwner._async_match_music_album_directory", - "target": "run_in_threadpool" - }, - "app/chain/media/album.py:216:run_in_threadpool": { - "owner": "MediaAlbumOwner.async_recognize_music_album_directory", - "target": "run_in_threadpool" - }, - "app/chain/media/album.py:218:run_in_threadpool": { - "owner": "MediaAlbumOwner.async_recognize_music_album_directory", - "target": "run_in_threadpool" - }, - "app/chain/media/path.py:381:run_in_threadpool": { - "owner": "MediaPathOwner._async_music_album_dir_fallback", - "target": "run_in_threadpool" - }, - "app/chain/media/path.py:427:run_in_threadpool": { - "owner": "MediaPathOwner.async_recognize_music_by_path", - "target": "run_in_threadpool" - }, - "app/chain/search/media.py:734:run_in_threadpool": { - "owner": "SearchMediaOwner._run_media_process_async", - "target": "run_in_threadpool" - }, - "app/chain/search/media.py:936:run_in_threadpool": { - "owner": "SearchMediaOwner.async_process_stream", - "target": "run_in_threadpool" - }, - "app/chain/search/music.py:236:run_in_threadpool": { - "owner": "SearchMusicOwner._async_process_music", - "target": "run_in_threadpool" - }, - "app/chain/search/music.py:282:run_in_threadpool": { - "owner": "SearchMusicOwner._async_process_music_stream", - "target": "run_in_threadpool" - }, - "app/chain/search/subtitle.py:429:run_in_threadpool": { - "owner": "SearchSubtitleOwner._async_search_subtitles_for_media", - "target": "run_in_threadpool" - }, - "app/chain/search/subtitle.py:526:run_in_threadpool": { - "owner": "SearchSubtitleOwner._async_search_subtitles_for_media_stream", - "target": "run_in_threadpool" - }, - "app/chain/search/title.py:248:run_in_threadpool": { - "owner": "SearchTitleOwner._run_title_search_async", - "target": "run_in_threadpool" - }, - "app/chain/search/title.py:382:run_in_threadpool": { - "owner": "SearchTitleOwner.async_search_by_title_stream", - "target": "run_in_threadpool" - }, - "app/chain/transfer/queue.py:149:Thread": { - "owner": "TransferQueueOwner._TransferChain__init", - "target": "Thread" - }, - "app/chain/transfer/queue.py:317:to_thread": { - "owner": "TransferQueueOwner.close", - "target": "to_thread" - }, - "app/chain/transfer/queue.py:405:Thread": { - "owner": "TransferQueueOwner._TransferChain__ensure_recovery_scheduler", - "target": "Thread" - }, - "app/chain/transfer/queue.py:469:Thread": { - "owner": "TransferQueueOwner._TransferChain__start_lease_heartbeat_owner_locked", - "target": "Thread" - }, - "app/chain/transfer/queue.py:717:Thread": { - "owner": "TransferQueueOwner._TransferChain__start_lease_release_owner_locked", - "target": "Thread" - }, - "app/db/worker.py:82:ThreadPoolExecutor": { - "owner": "DatabaseWorker.start", - "target": "ThreadPoolExecutor" - }, - "app/main.py:179:Thread": { - "owner": "start_tray", - "target": "Thread" - }, - "app/modules/acoustid/__init__.py:148:run_in_threadpool": { - "owner": "AcoustIdModule.async_identify_music_by_fingerprint", - "target": "run_in_threadpool" - }, - "app/modules/discord/discord.py:220:create_task": { - "owner": "Discord._start.runner", - "target": "create_task" - }, - "app/modules/discord/discord.py:248:Thread": { - "owner": "Discord._start", - "target": "Thread" - }, - "app/modules/discord/discord.py:731:create_task": { - "owner": "Discord._start_typing_task", - "target": "create_task" - }, - "app/modules/discord/discord.py:924:run_in_threadpool": { - "owner": "Discord._send_file", - "target": "run_in_threadpool" - }, - "app/modules/feishu/feishu.py:215:Thread": { - "owner": "Feishu._start_ws_client", - "target": "Thread" - }, - "app/modules/indexer/spider/__init__.py:400:run_in_threadpool": { - "owner": "SiteSpider.async_get_torrents", - "target": "run_in_threadpool" - }, - "app/modules/qqbot/gateway.py:134:Thread": { - "owner": "run_gateway.start_heartbeat", - "target": "Thread" - }, - "app/modules/qqbot/qqbot.py:144:Thread": { - "owner": "QQBot._start_gateway", - "target": "Thread" - }, - "app/modules/telegram/telegram.py:221:Thread": { - "owner": "Telegram.__init__", - "target": "Thread" - }, - "app/modules/telegram/telegram.py:544:Thread": { - "owner": "Telegram._start_typing_task", - "target": "Thread" - }, - "app/modules/thetvdb/__init__.py:291:run_in_threadpool": { - "owner": "TheTvDbModule._async_load_auxiliary_candidates", - "target": "run_in_threadpool" - }, - "app/modules/wechat/wechatbot.py:122:Thread": { - "owner": "WeChatBot._start_gateway", - "target": "Thread" - }, - "app/modules/wechat/wechatbot.py:124:Thread": { - "owner": "WeChatBot._start_gateway", - "target": "Thread" - }, - "app/modules/wechatclawbot/wechatclawbot.py:1732:Thread": { - "owner": "WechatClawBot._start_polling", - "target": "Thread" - }, - "app/monitor/monitor.py:1111:Thread": { - "owner": "Monitor.__request_scheduler_stop", - "target": "Thread" - }, - "app/monitor/monitor.py:735:Thread": { - "owner": "Monitor.__start_compensation", - "target": "Thread" - }, - "app/monitor/recovery.py:168:Thread": { - "owner": "RecoveryExecutor._start", - "target": "Thread" - }, - "app/monitor/watcher.py:126:Thread": { - "owner": "LocalDirectoryWatcher.start", - "target": "Thread" - }, - "app/runtime/capabilities/runtime.py:1514:to_thread": { - "owner": "CapabilityRuntime.shutdown_async", - "target": "to_thread" - }, - "app/runtime/coalesce.py:171:create_task": { - "owner": "EventCoalescer._on_flush_timer", - "target": "create_task" - }, - "app/runtime/debounce.py:136:Timer": { - "owner": "Debouncer._call_leading", - "target": "Timer" - }, - "app/runtime/debounce.py:161:Timer": { - "owner": "Debouncer._call_trailing", - "target": "Timer" - }, - "app/runtime/debounce.py:221:create_task": { - "owner": "AsyncDebouncer._call_leading", - "target": "create_task" - }, - "app/runtime/debounce.py:241:create_task": { - "owner": "AsyncDebouncer._call_trailing", - "target": "create_task" - }, - "app/runtime/event/dispatch.py:260:run_in_threadpool": { - "owner": "EventDispatcher.invoke_async", - "target": "run_in_threadpool" - }, - "app/runtime/event/dispatch.py:292:run_in_threadpool": { - "owner": "EventDispatcher.invoke_async_strict", - "target": "run_in_threadpool" - }, - "app/runtime/events.py:229:Thread": { - "owner": "EventManager.start", - "target": "Thread" - }, - "app/runtime/events.py:262:to_thread": { - "owner": "EventManager.stop_async", - "target": "to_thread" - }, - "app/runtime/execution.py:153:create_task": { - "owner": "run_in_threadpool_to_completion", - "target": "create_task" - }, - "app/runtime/execution.py:153:run_in_threadpool": { - "owner": "run_in_threadpool_to_completion", - "target": "run_in_threadpool" - }, - "app/runtime/extensions/plugin/monitor.py:55:Thread": { - "owner": "PluginMonitorController.start", - "target": "Thread" - }, - "app/runtime/extensions/plugin/sync.py:108:ThreadPoolExecutor": { - "owner": "PluginSyncService.sync", - "target": "ThreadPoolExecutor" - }, - "app/runtime/extensions/resource.py:129:to_thread": { - "owner": "AsyncManagedResourceAdapter.materialize", - "target": "to_thread" - }, - "app/runtime/gc.py:78:Process": { - "owner": "get_memory_usage", - "target": "Process" - }, - "app/runtime/log.py:143:Thread": { - "owner": "NonBlockingFileHandler.__init__", - "target": "Thread" - }, - "app/runtime/log.py:303:Thread": { - "owner": "NonBlockingFileHandler._close_handlers_bounded", - "target": "Thread" - }, - "app/runtime/resources.py:133:to_thread": { - "owner": "acquire_managed_resource_async", - "target": "to_thread" - }, - "app/runtime/resources.py:189:to_thread": { - "owner": "stop_managed_resource_async", - "target": "to_thread" - }, - "app/runtime/state.py:335:Thread": { - "owner": "SystemHelper._start_graceful_shutdown_monitor", - "target": "Thread" - }, - "app/runtime/state.py:99:Process": { - "owner": "SystemHelper._is_local_cli_managed", - "target": "Process" - }, - "app/runtime/tasks.py:63:create_task": { - "owner": "TaskRegistry.create", - "target": "create_task" - }, - "app/runtime/tasks.py:80:to_thread": { - "owner": "TaskRegistry.create_sync", - "target": "to_thread" - }, - "app/scheduler/bridge.py:186:create_task": { - "owner": "SchedulerBridgeOwner._submit_cross_thread.start_on_target_loop", - "target": "create_task" - }, - "app/scheduler/bridge.py:248:create_task": { - "owner": "SchedulerBridgeOwner._submit_to_loop", - "target": "create_task" - }, - "app/scheduler/catalog.py:187:ThreadPoolExecutor": { - "owner": "SchedulerCatalogOwner._initialize_catalog", - "target": "ThreadPoolExecutor" - }, - "app/scheduler/execution.py:224:create_task": { - "owner": "SchedulerExecutionOwner.start.__start_coro", - "target": "create_task" - }, - "app/scheduler/execution.py:301:Process": { - "owner": "SchedulerExecutionOwner.start", - "target": "Process" - }, - "app/scheduler/lifecycle.py:126:to_thread": { - "owner": "SchedulerLifecycleOwner.stop_async", - "target": "to_thread" - }, - "app/scheduler/lifecycle.py:29:to_thread": { - "owner": "SchedulerLifecycleOwner.on_config_changed", - "target": "to_thread" - }, - "app/startup/composition/subscription.py:93:to_thread": { - "owner": "async_rule_group_mutation_scope", - "target": "to_thread" - }, - "app/startup/lifecycle/__init__.py:161:create_task": { - "owner": "run_shutdown_step", - "target": "create_task" - }, - "app/startup/lifecycle/__init__.py:365:create_task": { - "owner": "schedule_plugin_settlement", - "target": "create_task" + "app/adapters/external/plugin/client.py:asyncio.to_thread": { + "owners": { + "PluginMarketTransport.async_get_plugin_system_version_check_message": 1 + }, + "target": "asyncio.to_thread" + }, + "app/adapters/external/server.py:asyncio.to_thread": { + "owners": { + "MoviePilotServerHelper.sub_done_async": 1, + "MoviePilotServerHelper.sub_reg_async": 1 + }, + "target": "asyncio.to_thread" + }, + "app/adapters/network/browser.py:concurrent.futures.ThreadPoolExecutor": { + "owners": { + "BrowserSessionHelper._get_session_executor": 1 + }, + "target": "concurrent.futures.ThreadPoolExecutor" + }, + "app/adapters/network/http.py:asyncio.AbstractEventLoop.create_task": { + "owners": { + "_get_shared_async_transport": 1 + }, + "target": "asyncio.AbstractEventLoop.create_task" + }, + "app/adapters/system/fsproxy.py:threading.Thread": { + "owners": { + "FileSystemProxy._read_line_windows": 1 + }, + "target": "threading.Thread" + }, + "app/adapters/system/host.py:asyncio.create_task": { + "owners": { + "SystemUtils.execute_with_subprocess_async": 1 + }, + "target": "asyncio.create_task" + }, + "app/adapters/system/plugin/dependency.py:asyncio.to_thread": { + "owners": { + "PluginDependencyInstaller.async_find_missing": 1 + }, + "target": "asyncio.to_thread" + }, + "app/adapters/system/plugin/health.py:app.runtime.execution.run_in_threadpool_to_completion": { + "owners": { + "PluginRuntimeHealth.__async_install_packages_with_fallback": 7, + "PluginRuntimeHealth.__async_repair_main_runtime_dependencies": 3 + }, + "target": "app.runtime.execution.run_in_threadpool_to_completion" + }, + "app/adapters/system/plugin/health.py:asyncio.create_task": { + "owners": { + "PluginRuntimeHealth.__async_create_runtime_constraints_file": 2 + }, + "target": "asyncio.create_task" + }, + "app/adapters/system/plugin/health.py:asyncio.to_thread": { + "owners": { + "PluginRuntimeHealth.__async_create_runtime_constraints_file": 1, + "PluginRuntimeHealth.__async_create_runtime_constraints_file.cleanup_created_file": 1 + }, + "target": "asyncio.to_thread" + }, + "app/adapters/system/plugin/package.py:app.runtime.execution.run_in_threadpool_to_completion": { + "owners": { + "PluginPackageManager.__async_install_dependencies_if_required": 1, + "PluginPackageManager.async_activate_persistent_backup": 1, + "PluginPackageManager.async_checkpoint": 1, + "PluginPackageManager.async_cleanup": 1, + "PluginPackageManager.async_commit": 1, + "PluginPackageManager.async_committed_payload_receipt": 1, + "PluginPackageManager.async_finalize_persistent_backup": 1, + "PluginPackageManager.async_install_raw": 1, + "PluginPackageManager.async_native_dependency_changes": 1, + "PluginPackageManager.async_payload_receipt": 1, + "PluginPackageManager.async_restore": 1, + "PluginPackageManager.async_rollback": 1, + "PluginPackageManager.async_stage_persistent_backup": 1 + }, + "target": "app.runtime.execution.run_in_threadpool_to_completion" + }, + "app/adapters/system/plugin/package.py:asyncio.to_thread": { + "owners": { + "PluginPackageManager.__async_install_from_release": 1 + }, + "target": "asyncio.to_thread" + }, + "app/agent/callback/__init__.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "StreamingHandler._flush": 3, + "StreamingHandler._stop_streaming": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/agent/callback/__init__.py:asyncio.create_task": { + "owners": { + "StreamingHandler._start_streaming": 1 + }, + "target": "asyncio.create_task" + }, + "app/agent/capabilities/adapter.py:asyncio.to_thread": { + "owners": { + "AgentServiceAdapter.materialize": 1 + }, + "target": "asyncio.to_thread" + }, + "app/agent/lifecycle.py:asyncio.create_task": { + "owners": { + "AgentLifecycleOwner.close": 1, + "AgentLifecycleOwner.initialize": 1 + }, + "target": "asyncio.create_task" + }, + "app/agent/llm/discovery.py:asyncio.to_thread": { + "owners": { + "_ProviderDiscovery._list_models_from_bedrock": 2, + "_ProviderDiscovery._load_models_dev_from_disk": 1, + "_ProviderDiscovery._write_models_dev_to_disk": 1 + }, + "target": "asyncio.to_thread" + }, + "app/agent/mcp.py:asyncio.create_task": { + "owners": { + "_StdioMcpSession.__aenter__": 1 + }, + "target": "asyncio.create_task" + }, + "app/agent/memory/__init__.py:asyncio.create_task": { + "owners": { + "MemoryManager.initialize": 1 + }, + "target": "asyncio.create_task" + }, + "app/agent/middleware/subagents.py:asyncio.create_task": { + "owners": { + "SubAgentTaskControlMiddleware._run_pipeline": 1, + "SubAgentTaskControlMiddleware._start_tasks": 1 + }, + "target": "asyncio.create_task" + }, + "app/agent/orchestrator.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "MoviePilotAgent._deliver_private_channel_message": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/agent/session.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "_async_finish_processing_status": 1, + "_async_start_processing_status": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/agent/session.py:asyncio.create_task": { + "owners": { + "AgentSessionOwner._schedule_deferred_session_cleanup": 1, + "AgentSessionOwner._stop_current_task_locked": 1, + "AgentSessionOwner.process_message": 1 + }, + "target": "asyncio.create_task" + }, + "app/agent/tools/base.py:concurrent.futures.ThreadPoolExecutor": { + "owners": { + "_submit_blocking_call": 1 + }, + "target": "concurrent.futures.ThreadPoolExecutor" + }, + "app/agent/tools/impl/_terminal_session.py:asyncio.create_task": { + "owners": { + "_TerminalSessionManager._start_pipe_session": 3, + "_TerminalSessionManager._start_pty_session": 2, + "_TerminalSessionManager.start": 1 + }, + "target": "asyncio.create_task" + }, + "app/agent/tools/impl/_terminal_session.py:asyncio.to_thread": { + "owners": { + "_TerminalSessionManager._wait_pty_process": 1, + "_TerminalSessionManager.write": 1 + }, + "target": "asyncio.to_thread" + }, + "app/agent/tools/impl/execute_command.py:asyncio.create_task": { + "owners": { + "ExecuteCommandTool._run_once": 3 + }, + "target": "asyncio.create_task" + }, + "app/api/endpoints/dashboard.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "schedule": 1, + "schedule2": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/api/endpoints/history.py:asyncio.to_thread": { + "owners": { + "_request_batch_durable_retries": 1, + "ai_redo_transfer_history": 1 + }, + "target": "asyncio.to_thread" + }, + "app/api/endpoints/search.py:asyncio.create_task": { + "owners": { + "_iter_batched_search_events": 1 + }, + "target": "asyncio.create_task" + }, + "app/api/endpoints/system.py:app.runtime.execution.run_in_threadpool_to_completion": { + "owners": { + "create_database_backup": 1, + "delete_database_backup": 1, + "list_database_backups": 1, + "verify_database_backup": 1 + }, + "target": "app.runtime.execution.run_in_threadpool_to_completion" + }, + "app/application/agenttask.py:asyncio.create_task": { + "owners": { + "AgentTaskExecutionService._finalize": 1, + "AgentTaskExecutionService.claim": 2 + }, + "target": "asyncio.create_task" + }, + "app/application/messaging/agent.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "collect_web_agent_traditional_events": 1, + "get_web_agent_upload_dir": 1, + "prepare_web_agent_audio_attachment_path_async": 4, + "prepare_web_agent_audio_attachment_path_async.cleanup_conversion_output": 1, + "save_web_agent_upload": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/application/messaging/agent.py:asyncio.create_task": { + "owners": { + "WebAgentEventPublisher.__init__": 1, + "_build_traditional_web_agent_stream.event_generator": 1 + }, + "target": "asyncio.create_task" + }, + "app/application/messaging/agent.py:asyncio.to_thread": { + "owners": { + "collect_web_agent_traditional_events": 1, + "transcribe_web_agent_audio_input": 1 + }, + "target": "asyncio.to_thread" + }, + "app/application/messaging/message.py:threading.Thread": { + "owners": { + "MessageQueueManager.start": 1 + }, + "target": "threading.Thread" + }, + "app/application/plugin/catalog.py:asyncio.create_task": { + "owners": { + "PluginCatalogService.async_collect": 2 + }, + "target": "asyncio.create_task" + }, + "app/application/plugin/catalog.py:concurrent.futures.Executor.submit": { + "owners": { + "PluginCatalogService.collect": 2 + }, + "target": "concurrent.futures.Executor.submit" + }, + "app/application/plugin/catalog.py:concurrent.futures.ThreadPoolExecutor": { + "owners": { + "PluginCatalogService.collect": 1 + }, + "target": "concurrent.futures.ThreadPoolExecutor" + }, + "app/application/plugin/install.py:asyncio.create_task": { + "owners": { + "PluginInstallCommand.__resolve_commit_outcome": 1, + "PluginInstallCommand.__resolve_journal_created": 1, + "PluginInstallCommand.__rollback_cancelled": 1 + }, + "target": "asyncio.create_task" + }, + "app/application/plugin/install.py:asyncio.ensure_future": { + "owners": { + "PluginInstallCommand.__await_side_effect": 1 + }, + "target": "asyncio.ensure_future" + }, + "app/application/plugin/inventory.py:asyncio.create_task": { + "owners": { + "PluginCandidateInventoryReader.async_load": 1 + }, + "target": "asyncio.create_task" + }, + "app/application/plugin/inventory.py:asyncio.to_thread": { + "owners": { + "PluginCandidateInventoryReader._async_read_market_generation": 1, + "PluginCandidateInventoryReader.async_load": 1 + }, + "target": "asyncio.to_thread" + }, + "app/chain/_recognition.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "RecognitionMixin._async_run_recognition_steps": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/chain/media/album.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "MediaAlbumOwner._async_match_music_album_directory": 1, + "MediaAlbumOwner.async_recognize_music_album_directory": 2 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/chain/media/path.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "MediaPathOwner._async_music_album_dir_fallback": 1, + "MediaPathOwner.async_recognize_music_by_path": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/chain/search/media.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "SearchMediaOwner._run_media_process_async": 1, + "SearchMediaOwner.async_process_stream": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/chain/search/music.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "SearchMusicOwner._async_process_music": 1, + "SearchMusicOwner._async_process_music_stream": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/chain/search/subtitle.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "SearchSubtitleOwner._async_search_subtitles_for_media": 1, + "SearchSubtitleOwner._async_search_subtitles_for_media_stream": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/chain/search/title.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "SearchTitleOwner._run_title_search_async": 1, + "SearchTitleOwner.async_search_by_title_stream": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/chain/transfer/queue.py:asyncio.to_thread": { + "owners": { + "TransferQueueOwner.close": 1 + }, + "target": "asyncio.to_thread" + }, + "app/chain/transfer/queue.py:threading.Thread": { + "owners": { + "TransferQueueOwner._TransferChain__ensure_recovery_scheduler": 1, + "TransferQueueOwner._TransferChain__init": 1, + "TransferQueueOwner._TransferChain__start_lease_heartbeat_owner_locked": 1, + "TransferQueueOwner._TransferChain__start_lease_release_owner_locked": 1 + }, + "target": "threading.Thread" + }, + "app/db/worker.py:concurrent.futures.Executor.submit": { + "owners": { + "DatabaseWorker.run": 1 + }, + "target": "concurrent.futures.Executor.submit" + }, + "app/db/worker.py:concurrent.futures.ThreadPoolExecutor": { + "owners": { + "DatabaseWorker.start": 1 + }, + "target": "concurrent.futures.ThreadPoolExecutor" + }, + "app/main.py:threading.Thread": { + "owners": { + "start_tray": 1 + }, + "target": "threading.Thread" + }, + "app/modules/acoustid/__init__.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "AcoustIdModule.async_identify_music_by_fingerprint": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/modules/discord/discord.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "Discord._send_file": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/modules/discord/discord.py:asyncio.AbstractEventLoop.create_task": { + "owners": { + "Discord._start.runner": 1 + }, + "target": "asyncio.AbstractEventLoop.create_task" + }, + "app/modules/discord/discord.py:asyncio.create_task": { + "owners": { + "Discord._start_typing_task": 1 + }, + "target": "asyncio.create_task" + }, + "app/modules/discord/discord.py:threading.Thread": { + "owners": { + "Discord._start": 1 + }, + "target": "threading.Thread" + }, + "app/modules/feishu/feishu.py:threading.Thread": { + "owners": { + "Feishu._start_ws_client": 1 + }, + "target": "threading.Thread" + }, + "app/modules/indexer/spider/__init__.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "SiteSpider.async_get_torrents": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/modules/qqbot/gateway.py:threading.Thread": { + "owners": { + "run_gateway.start_heartbeat": 1 + }, + "target": "threading.Thread" + }, + "app/modules/qqbot/qqbot.py:threading.Thread": { + "owners": { + "QQBot._start_gateway": 1 + }, + "target": "threading.Thread" + }, + "app/modules/telegram/telegram.py:threading.Thread": { + "owners": { + "Telegram.__init__": 1, + "Telegram._start_typing_task": 1 + }, + "target": "threading.Thread" + }, + "app/modules/thetvdb/__init__.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "TheTvDbModule._async_load_auxiliary_candidates": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/modules/wechat/wechatbot.py:threading.Thread": { + "owners": { + "WeChatBot._start_gateway": 2 + }, + "target": "threading.Thread" + }, + "app/modules/wechatclawbot/wechatclawbot.py:threading.Thread": { + "owners": { + "WechatClawBot._start_polling": 1 + }, + "target": "threading.Thread" + }, + "app/monitor/monitor.py:threading.Thread": { + "owners": { + "Monitor.__request_scheduler_stop": 1, + "Monitor.__start_compensation": 1 + }, + "target": "threading.Thread" + }, + "app/monitor/recovery.py:threading.Thread": { + "owners": { + "RecoveryExecutor._start": 1 + }, + "target": "threading.Thread" + }, + "app/monitor/watcher.py:threading.Thread": { + "owners": { + "LocalDirectoryWatcher.start": 1 + }, + "target": "threading.Thread" + }, + "app/runtime/capabilities/runtime.py:asyncio.to_thread": { + "owners": { + "CapabilityRuntime.shutdown_async": 1 + }, + "target": "asyncio.to_thread" + }, + "app/runtime/coalesce.py:asyncio.AbstractEventLoop.create_task": { + "owners": { + "EventCoalescer._on_flush_timer": 1 + }, + "target": "asyncio.AbstractEventLoop.create_task" + }, + "app/runtime/debounce.py:asyncio.create_task": { + "owners": { + "AsyncDebouncer._call_leading": 1, + "AsyncDebouncer._call_trailing": 1 + }, + "target": "asyncio.create_task" + }, + "app/runtime/debounce.py:threading.Timer": { + "owners": { + "Debouncer._call_leading": 1, + "Debouncer._call_trailing": 1 + }, + "target": "threading.Timer" + }, + "app/runtime/event/dispatch.py:app.runtime.execution.run_in_threadpool": { + "owners": { + "EventDispatcher.invoke_async": 1, + "EventDispatcher.invoke_async_strict": 1 + }, + "target": "app.runtime.execution.run_in_threadpool" + }, + "app/runtime/events.py:asyncio.to_thread": { + "owners": { + "EventManager.stop_async": 1 + }, + "target": "asyncio.to_thread" + }, + "app/runtime/events.py:threading.Thread": { + "owners": { + "EventManager.start": 1 + }, + "target": "threading.Thread" + }, + "app/runtime/execution.py:asyncio.create_task": { + "owners": { + "run_in_threadpool_to_completion": 1 + }, + "target": "asyncio.create_task" + }, + "app/runtime/execution.py:concurrent.futures.Executor.submit": { + "owners": { + "submit_with_context.submit": 1 + }, + "target": "concurrent.futures.Executor.submit" + }, + "app/runtime/extensions/plugin/manager.py:app.runtime.execution.run_in_threadpool_to_completion": { + "owners": { + "PluginManager.async_run_plugin_method": 1 + }, + "target": "app.runtime.execution.run_in_threadpool_to_completion" + }, + "app/runtime/extensions/plugin/monitor.py:threading.Thread": { + "owners": { + "PluginMonitorController.start": 1 + }, + "target": "threading.Thread" + }, + "app/runtime/extensions/plugin/sync.py:concurrent.futures.Executor.submit": { + "owners": { + "PluginSyncService.sync": 1 + }, + "target": "concurrent.futures.Executor.submit" + }, + "app/runtime/extensions/plugin/sync.py:concurrent.futures.ThreadPoolExecutor": { + "owners": { + "PluginSyncService.sync": 1 + }, + "target": "concurrent.futures.ThreadPoolExecutor" + }, + "app/runtime/extensions/resource.py:asyncio.to_thread": { + "owners": { + "AsyncManagedResourceAdapter.materialize": 1 + }, + "target": "asyncio.to_thread" + }, + "app/runtime/log.py:threading.Thread": { + "owners": { + "NonBlockingFileHandler.__init__": 1, + "NonBlockingFileHandler._close_handlers_bounded": 1 + }, + "target": "threading.Thread" + }, + "app/runtime/resources.py:asyncio.to_thread": { + "owners": { + "acquire_managed_resource_async": 1, + "stop_managed_resource_async": 1 + }, + "target": "asyncio.to_thread" + }, + "app/runtime/state.py:threading.Thread": { + "owners": { + "SystemHelper._start_graceful_shutdown_monitor": 1 + }, + "target": "threading.Thread" + }, + "app/runtime/tasks.py:asyncio.create_task": { + "owners": { + "TaskRegistry.create": 1 + }, + "target": "asyncio.create_task" + }, + "app/runtime/tasks.py:asyncio.to_thread": { + "owners": { + "TaskRegistry.create_sync": 1 + }, + "target": "asyncio.to_thread" + }, + "app/scheduler/bridge.py:asyncio.AbstractEventLoop.create_task": { + "owners": { + "SchedulerBridgeOwner._submit_cross_thread.start_on_target_loop": 1 + }, + "target": "asyncio.AbstractEventLoop.create_task" + }, + "app/scheduler/execution.py:multiprocessing.Process": { + "owners": { + "SchedulerExecutionOwner.start": 1 + }, + "target": "multiprocessing.Process" + }, + "app/scheduler/lifecycle.py:asyncio.to_thread": { + "owners": { + "SchedulerLifecycleOwner.on_config_changed": 1, + "SchedulerLifecycleOwner.stop_async": 1 + }, + "target": "asyncio.to_thread" + }, + "app/startup/composition/subscription.py:asyncio.to_thread": { + "owners": { + "async_rule_group_mutation_scope": 1 + }, + "target": "asyncio.to_thread" + }, + "app/startup/initializers/modules.py:app.runtime.execution.run_in_threadpool_to_completion": { + "owners": { + "stop_modules.run_step": 1 + }, + "target": "app.runtime.execution.run_in_threadpool_to_completion" + }, + "app/startup/initializers/monitor.py:app.runtime.execution.run_in_threadpool_to_completion": { + "owners": { + "stop_monitor": 1 + }, + "target": "app.runtime.execution.run_in_threadpool_to_completion" + }, + "app/startup/initializers/plugins.py:app.runtime.execution.run_in_threadpool_to_completion": { + "owners": { + "configure_plugin_services.refresh_plugin_registrations": 1, + "configure_plugin_services.reload_plugin_tree": 1, + "execute_task": 1 + }, + "target": "app.runtime.execution.run_in_threadpool_to_completion" + }, + "app/startup/lifecycle/__init__.py:app.runtime.execution.run_in_threadpool_to_completion": { + "owners": { + "offload_shutdown_callback.invoke": 1, + "prepare_plugin_restore": 1 + }, + "target": "app.runtime.execution.run_in_threadpool_to_completion" + }, + "app/startup/lifecycle/__init__.py:asyncio.create_task": { + "owners": { + "run_shutdown_step": 1, + "schedule_plugin_settlement": 1 + }, + "target": "asyncio.create_task" } } diff --git a/tests/test_complexity_gate.py b/tests/test_complexity_gate.py index 00b376294..8ba35c088 100644 --- a/tests/test_complexity_gate.py +++ b/tests/test_complexity_gate.py @@ -1,6 +1,17 @@ """复杂度只降不增 ratchet 测试。""" -from scripts.architecture.complexity import compare_complexity, compare_complexity_v2 +from pathlib import Path + +from scripts.architecture.complexity import ( + collect_complexity_v2, + compare_complexity, + compare_complexity_v2, +) + + +def _assignments(indent: str, count: int) -> str: + """生成足够长的临时语句体,精确触发复杂度阈值。""" + return "\n".join(f"{indent}value_{index} = {index}" for index in range(count)) def test_complexity_ratchet_allows_removal_and_reduction() -> None: @@ -54,3 +65,58 @@ def test_complexity_v2_ratchet_checks_categories_outside_public_rules() -> None: assert any("method: 既有超限增长" in problem for problem in problems) assert any("class: 新增超限" in problem for problem in problems) assert any("file: 新增超限" in problem for problem in problems) + + +def test_complexity_v2_walks_dunder_match_trystar_and_nested_class_owners( + tmp_path: Path, +) -> None: + """完整 AST 遍历必须覆盖 dunder、Match、TryStar 和函数内嵌套类。""" + scheduler = tmp_path / "app/scheduler" + scheduler.mkdir(parents=True) + (scheduler / "dunder.py").write_text( + "class Runner:\n" + " def __hidden(self):\n" + f"{_assignments(' ', 151)}\n", + encoding="utf-8", + ) + (scheduler / "control_flow.py").write_text( + "def dispatch(value):\n" + " match value:\n" + " case 1:\n" + " def matched():\n" + f"{_assignments(' ', 151)}\n" + " return matched()\n\n" + "def guarded():\n" + " try:\n" + " raise ExceptionGroup('failure', [ValueError()])\n" + " except* ValueError:\n" + " def recovered():\n" + f"{_assignments(' ', 151)}\n" + " return recovered()\n", + encoding="utf-8", + ) + nested_classes = ( + "class Outer:\n" + " def first(self):\n" + " class Local:\n" + f"{_assignments(' ', 501)}\n\n" + " def second(self):\n" + " class Local:\n" + f"{_assignments(' ', 501)}\n" + ) + (scheduler / "nested_classes.py").write_text(nested_classes, encoding="utf-8") + (scheduler / "conditional.py").write_text( + "if enabled:\n" + " class Conditional:\n" + f"{_assignments(' ', 501)}\n", + encoding="utf-8", + ) + + report = collect_complexity_v2(tmp_path) + + assert "app/scheduler/dunder.py:Runner.__hidden" in report["method"] + assert "app/scheduler/control_flow.py:dispatch.matched" in report["method"] + assert "app/scheduler/control_flow.py:guarded.recovered" in report["method"] + assert "app/scheduler/conditional.py:Conditional" in report["class"] + assert "app/scheduler/nested_classes.py:Outer.first.Local" in report["class"] + assert "app/scheduler/nested_classes.py:Outer.second.Local" in report["class"] diff --git a/tests/test_concurrency_gate.py b/tests/test_concurrency_gate.py index 57348cbc7..386607057 100644 --- a/tests/test_concurrency_gate.py +++ b/tests/test_concurrency_gate.py @@ -6,57 +6,159 @@ from pathlib import Path from scripts.architecture.concurrency import collect_concurrency, compare_concurrency -def test_concurrency_gate_tracks_nested_owners_and_known_primitives(tmp_path: Path) -> None: - """嵌套函数、类方法和常见原生并发入口都必须被枚举。""" - source_path = tmp_path / "app/scheduler/sample.py" - source_path.parent.mkdir(parents=True) - source_path.write_text( - textwrap.dedent( - """ - import asyncio - import threading - from concurrent.futures import ThreadPoolExecutor +def _write_source(root: Path, relative: str, source: str) -> None: + """写入临时宿主源码,供 AST collector 做结构性验证。""" + source_path = root / relative + source_path.parent.mkdir(parents=True, exist_ok=True) + source_path.write_text(textwrap.dedent(source), encoding="utf-8") - class Runner: - async def run(self): - asyncio.create_task(self.child()) - threading.Timer(1, self.child).start() - with ThreadPoolExecutor() as executor: - executor.submit(self.child) - async def child(self): - await asyncio.to_thread(lambda: None) - """ - ), - encoding="utf-8", +def test_concurrency_gate_resolves_aliases_and_runtime_sources(tmp_path: Path) -> None: + """canonical 别名、TaskGroup、事件循环和 Executor 来源必须完整枚举。""" + _write_source( + tmp_path, + "app/doctor/sample.py", + """ + import asyncio as aio + import concurrent.futures as cf + from asyncio import AbstractEventLoop, TaskGroup as TG, create_task as spawn + from concurrent.futures import ThreadPoolExecutor as Pool + from threading import Thread as WorkerThread + + async def run(job, typed_loop: AbstractEventLoop): + loop = aio.get_running_loop() + spawn(job()) + aio.create_task(job()) + loop.create_task(job()) + typed_loop.run_in_executor(None, job) + async with TG() as group: + group.create_task(job()) + with cf.ThreadPoolExecutor() as executor: + executor.submit(job) + with Pool() as executor: + executor.map(job, []) + WorkerThread(target=job) + """, ) - facts = collect_concurrency(tmp_path, scan_roots=("app/scheduler",)) - assert facts["app/scheduler/sample.py:8:create_task"]["owner"] == "Runner.run" - assert facts["app/scheduler/sample.py:9:Timer"]["owner"] == "Runner.run" - assert facts["app/scheduler/sample.py:10:ThreadPoolExecutor"]["owner"] == "Runner.run" - assert facts["app/scheduler/sample.py:14:to_thread"]["owner"] == "Runner.child" + facts = collect_concurrency(tmp_path) + + assert facts["app/doctor/sample.py:asyncio.create_task"]["owners"] == {"run": 2} + assert facts["app/doctor/sample.py:asyncio.AbstractEventLoop.create_task"]["owners"] == { + "run": 1 + } + assert facts["app/doctor/sample.py:asyncio.AbstractEventLoop.run_in_executor"][ + "owners" + ] == {"run": 1} + assert facts["app/doctor/sample.py:asyncio.TaskGroup"]["owners"] == {"run": 1} + assert facts["app/doctor/sample.py:asyncio.TaskGroup.create_task"]["owners"] == { + "run": 1 + } + assert facts["app/doctor/sample.py:concurrent.futures.ThreadPoolExecutor"][ + "owners" + ] == {"run": 2} + assert facts["app/doctor/sample.py:concurrent.futures.Executor.submit"]["owners"] == { + "run": 1 + } + assert facts["app/doctor/sample.py:concurrent.futures.Executor.map"]["owners"] == { + "run": 1 + } + assert facts["app/doctor/sample.py:threading.Thread"]["owners"] == {"run": 1} -def test_concurrency_ratchet_rejects_new_facts_and_owner_drift() -> None: - """新增并发调用和 owner 漂移必须阻断 ratchet。""" +def test_concurrency_gate_excludes_unproven_same_named_calls(tmp_path: Path) -> None: + """普通同名函数和对象方法没有 canonical 来源时不得误报。""" + _write_source( + tmp_path, + "app/testing/fakes.py", + """ + def create_task(): + return None + + def run_in_threadpool(): + return None + + def run(obj): + create_task() + run_in_threadpool() + obj.create_task() + obj.submit() + """, + ) + + assert collect_concurrency(tmp_path) == {} + + +def test_concurrency_facts_ignore_line_drift_and_preserve_counts(tmp_path: Path) -> None: + """空行漂移不得改变事实键,同一行多个调用必须保留精确计数。""" + relative = "app/runtime/sample.py" + _write_source( + tmp_path, + relative, + """ + import asyncio + async def run(first, second): + asyncio.create_task(first()); asyncio.create_task(second()) + """, + ) + baseline = collect_concurrency(tmp_path) + _write_source( + tmp_path, + relative, + """ + + + import asyncio + async def run(first, second): + asyncio.create_task(first()); asyncio.create_task(second()) + """, + ) + + current = collect_concurrency(tmp_path) + assert current == baseline + assert current["app/runtime/sample.py:asyncio.create_task"]["owners"] == {"run": 2} + + +def test_concurrency_gate_scans_complete_app_and_keeps_exact_exclusions( + tmp_path: Path, +) -> None: + """默认扫描必须覆盖完整 app,同时排除 Plugin、SDK 和 Compat 表面。""" + source = """ + import asyncio + asyncio.create_task(job()) + """ + _write_source(tmp_path, "app/doctor/check.py", source) + _write_source(tmp_path, "app/plugins/copy.py", source) + _write_source(tmp_path, "app/sdk/facade.py", source) + _write_source(tmp_path, "app/runtime/compat/legacy.py", source) + + facts = collect_concurrency(tmp_path) + assert list(facts) == ["app/doctor/check.py:asyncio.create_task"] + assert facts["app/doctor/check.py:asyncio.create_task"]["owners"] == {"": 1} + + +def test_concurrency_ratchet_rejects_growth_and_stale_low_water() -> None: + """新增 owner、调用增长和未刷新的下降事实都必须阻断 ratchet。""" baseline = { - "app/runtime/tasks.py:10:create_task": { - "owner": "Runtime.run", - "target": "create_task", + "app/runtime/tasks.py:asyncio.create_task": { + "target": "asyncio.create_task", + "owners": {"Runtime.run": 2, "Runtime.old": 1}, }, } current = { - "app/runtime/tasks.py:10:create_task": { - "owner": "Runtime.changed", - "target": "create_task", + "app/runtime/tasks.py:asyncio.create_task": { + "target": "asyncio.create_task", + "owners": {"Runtime.run": 3, "Runtime.new": 1}, }, - "app/api/system.py:20:Thread": { - "owner": "system", - "target": "Thread", + "app/api/system.py:threading.Thread": { + "target": "threading.Thread", + "owners": {"system": 1}, }, } problems = compare_concurrency(baseline, current) - assert any("owner 漂移" in problem for problem in problems) + assert any("新增原生并发事实" in problem for problem in problems) + assert any("Runtime.run 3>2" in problem for problem in problems) + assert any("Runtime.new 1>0" in problem for problem in problems) + assert any("Runtime.old 0<1" in problem for problem in problems)