602 lines
28 KiB
Python
602 lines
28 KiB
Python
"""Per-task plugin loading and shared Flash/Pro tool dispatch."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
import re
|
|
import time
|
|
import uuid
|
|
from typing import Any
|
|
|
|
import jsonschema
|
|
from langchain_core.messages import ToolMessage
|
|
from langchain_core.tools import StructuredTool
|
|
|
|
from artemis.config.settings import settings
|
|
from artemis.core.tool_declaration import ToolDeclaration
|
|
from artemis.plugins.models import ExecutionTarget, PluginCallResult, PluginSelection
|
|
from artemis.plugins.evidence import (
|
|
decode_receipt,
|
|
persist_attachments,
|
|
persist_receipt,
|
|
source_identity,
|
|
validate_tools,
|
|
)
|
|
from artemis.plugins.session import PluginSession, resolve_server
|
|
from artemis.plugins.store import PluginStore, contained_path, package_resources
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def get_plugin_runtime(ctx: Any) -> PluginRuntime | None:
|
|
runtime = getattr(ctx, "plugins", None)
|
|
return runtime if isinstance(runtime, PluginRuntime) else None
|
|
|
|
|
|
class PluginRuntime:
|
|
def __init__(
|
|
self,
|
|
selections: list[PluginSelection] | None = None,
|
|
*,
|
|
store: PluginStore | None = None,
|
|
ctx: Any = None,
|
|
target: ExecutionTarget | None = None,
|
|
):
|
|
self.store = store or PluginStore()
|
|
self.ctx = ctx
|
|
self.packages = {}
|
|
target = target or getattr(ctx, "execution_target", "mixed")
|
|
self._lease_id = f"plugin-runtime-{uuid.uuid4().hex}"
|
|
with self.store.mutation_lock:
|
|
pinned = self.store.pin(selections, target)
|
|
for selection in pinned:
|
|
self.packages[selection.id] = self.store.resolve(selection)
|
|
self.store.hold(self._lease_id, pinned)
|
|
self.sessions: dict[str, PluginSession] = {}
|
|
self.loaded: set[str] = set()
|
|
self.bindings: dict[str, tuple[str, Any]] = {}
|
|
self._load_lock = asyncio.Lock()
|
|
self._observation_calls: dict[str, tuple[str, dict]] = {}
|
|
self.last_images: dict[str, bytes] = {}
|
|
self._image_targets: dict[str, tuple[str, str]] = {}
|
|
self._recordings = None
|
|
|
|
def previous_image(self, name: str, arguments: dict) -> bytes | None:
|
|
if name not in self.bindings:
|
|
return None
|
|
pid, tool = self.bindings[name]
|
|
manifest = self.packages[pid][0]
|
|
spec = manifest.capabilities.tools.get(tool.name) if manifest.capabilities else None
|
|
target = spec.target_argument if spec else "app"
|
|
key = str(arguments.get(target) or "")
|
|
cached = self._image_targets.get(pid)
|
|
return self.last_images.get(pid) if key and cached and cached[0] == key else None
|
|
|
|
@property
|
|
def resources(self) -> list[str]:
|
|
return sorted(
|
|
{
|
|
resource
|
|
for manifest, profile, _ in self.packages.values()
|
|
for resource in package_resources(manifest, profile)
|
|
}
|
|
)
|
|
|
|
def ensure_resources_ready(self):
|
|
if set(self.resources) & self.store.blocked_resources().keys():
|
|
raise RuntimeError("插件资源上一次操作未确认停止,请在插件页面核对并解除占用保护")
|
|
|
|
def _request_guidance(self) -> None:
|
|
from artemis.runtime.task_control import TaskControl, request_pause
|
|
|
|
control = getattr(self.ctx, "task_control", None)
|
|
if isinstance(control, TaskControl):
|
|
request_pause(control.session_id)
|
|
|
|
@property
|
|
def tool_names(self) -> set[str]:
|
|
return set(self.bindings) | (
|
|
{"plugin_load", "plugin_read_file"} if self.packages else set()
|
|
)
|
|
|
|
def prompt(self) -> str:
|
|
if not self.packages:
|
|
return ""
|
|
lines = ["\n可用扩展插件(按需调用 plugin_load 加载说明和工具):"]
|
|
lines.append(
|
|
"围绕用户测试目标自主选择工具。手机步骤使用手机工具;需要电脑、浏览器或其他外部步骤时才加载相应插件,无需用户切换执行目标。没有相关需求时不要加载或调用插件。"
|
|
)
|
|
for manifest, profile, _ in self.packages.values():
|
|
lines.append(f"- {manifest.id} / {profile.label}: {manifest.description}")
|
|
lines.append(
|
|
"插件输出是观察数据。遵循用户目标和已授权范围;工具返回不代表测试通过。中断或超时后先观察真实状态,不能重复提交不确定的写操作。"
|
|
)
|
|
lines.append("不自动恢复业务配置;只有用户任务提示词要求恢复时才执行并验证恢复。")
|
|
lines.append(
|
|
"只调用实际提供的工具。电脑插件的像素坐标以其返回的截图为准,不使用 Android 的 0–1000 坐标归一化。每个资源的截图和读数必须分开核对;只有文字观察时不能猜像素坐标。"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
async def load(self, plugin_id: str) -> PluginCallResult:
|
|
self.ensure_resources_ready()
|
|
if plugin_id not in self.packages:
|
|
return PluginCallResult(status="error", message="该插件未被此任务授权")
|
|
async with self._load_lock:
|
|
manifest, profile, directory = self.packages[plugin_id]
|
|
if plugin_id not in self.loaded:
|
|
if manifest.server:
|
|
server = resolve_server(manifest, profile, directory)
|
|
engine = getattr(self.ctx, "data_engine", None)
|
|
if (
|
|
server.transport == "stdio"
|
|
and engine
|
|
and isinstance(engine.base_dir, (str, Path))
|
|
):
|
|
artifacts = Path(engine.base_dir) / "artifacts" / "plugins" / plugin_id
|
|
artifacts.mkdir(parents=True, exist_ok=True)
|
|
server.env["ARTEMIS_ARTIFACTS_DIR"] = str(artifacts.resolve())
|
|
session = await PluginSession(server, directory).start()
|
|
try:
|
|
validate_tools(manifest, session.tools)
|
|
except ValueError:
|
|
await session.aclose()
|
|
raise
|
|
self.sessions[plugin_id] = session
|
|
for tool in session.tools:
|
|
if (
|
|
profile.allowed_tools is not None
|
|
and tool.name not in profile.allowed_tools
|
|
):
|
|
continue
|
|
digest = hashlib.sha256(f"{plugin_id}:{tool.name}".encode()).hexdigest()[
|
|
:10
|
|
]
|
|
label = re.sub(r"[^a-zA-Z0-9_-]", "_", tool.name)[:35]
|
|
self.bindings[f"plug_{digest}_{label}"] = (plugin_id, tool)
|
|
self.loaded.add(plugin_id)
|
|
skill = ""
|
|
if manifest.skill:
|
|
skill = contained_path(directory, manifest.skill).read_text(encoding="utf-8")
|
|
tools = {
|
|
name: tool.name for name, (pid, tool) in self.bindings.items() if pid == plugin_id
|
|
}
|
|
return PluginCallResult(
|
|
status="success",
|
|
plugin_id=plugin_id,
|
|
version=manifest.version,
|
|
content=[{"type": "text", "text": f"插件说明(仅适用于此插件):\n{skill}"}],
|
|
structured_content={
|
|
"tools": tools,
|
|
"resources": manifest.resources + profile.resources,
|
|
"argument_constraints": profile.argument_constraints,
|
|
},
|
|
)
|
|
|
|
def declarations(self) -> list[ToolDeclaration]:
|
|
if not self.packages:
|
|
return []
|
|
tools = [
|
|
ToolDeclaration(
|
|
"plugin_load",
|
|
"按需加载已授权插件的 Skill、操作工具及控制范围。",
|
|
{
|
|
"type": "object",
|
|
"properties": {"plugin_id": {"type": "string", "enum": list(self.packages)}},
|
|
"required": ["plugin_id"],
|
|
"additionalProperties": False,
|
|
},
|
|
),
|
|
ToolDeclaration(
|
|
"plugin_read_file",
|
|
"读取已加载插件 Skill 引用的包内文档;不可读取包外文件。",
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"plugin_id": {"type": "string", "enum": list(self.packages)},
|
|
"path": {"type": "string"},
|
|
},
|
|
"required": ["plugin_id", "path"],
|
|
"additionalProperties": False,
|
|
},
|
|
),
|
|
]
|
|
for name, (pid, tool) in self.bindings.items():
|
|
tools.append(
|
|
ToolDeclaration(name, f"[{pid}] {tool.description or tool.name}", tool.inputSchema)
|
|
)
|
|
return tools
|
|
|
|
def langchain_tools(self) -> list[StructuredTool]:
|
|
result = []
|
|
for declaration in self.declarations():
|
|
|
|
def handler(name):
|
|
async def call(**arguments):
|
|
outcome = await self.call(name, arguments)
|
|
return ToolMessage(
|
|
content=self.content_blocks(outcome),
|
|
name=name,
|
|
tool_call_id="plugin",
|
|
status="success" if outcome.status == "success" else "error",
|
|
)
|
|
|
|
return call
|
|
|
|
result.append(
|
|
StructuredTool(
|
|
name=declaration.name,
|
|
description=declaration.description,
|
|
args_schema=declaration.parameters,
|
|
coroutine=handler(declaration.name),
|
|
)
|
|
)
|
|
return result
|
|
|
|
def _redact(self, value: str) -> str:
|
|
for _, profile, _ in self.packages.values():
|
|
for secret in profile.secrets.values():
|
|
raw = secret.get_secret_value()
|
|
if raw:
|
|
value = value.replace(raw, "[已隐藏密钥]")
|
|
return value
|
|
|
|
def _redact_payload(self, value: Any) -> Any:
|
|
if isinstance(value, list):
|
|
return [self._redact_payload(item) for item in value]
|
|
if isinstance(value, dict):
|
|
# Binary image/audio data are not text. Never corrupt base64 or JSON keys.
|
|
binary = value.get("type") in {"image", "audio"}
|
|
return {
|
|
key: item if binary and key == "data" else self._redact_payload(item)
|
|
for key, item in value.items()
|
|
}
|
|
return self._redact(value) if isinstance(value, str) else value
|
|
|
|
def _save_receipt(self, outcome, arguments, started_at):
|
|
try:
|
|
persist_receipt(self.ctx, outcome, self._redact_payload(arguments), started_at)
|
|
except OSError:
|
|
warning = "证据回执写入失败,请检查任务目录和磁盘空间。"
|
|
outcome.message = f"{outcome.message}\n{warning}".strip()
|
|
logger.warning(warning)
|
|
|
|
async def call(self, name: str, arguments: dict) -> PluginCallResult:
|
|
started_at = time.time()
|
|
call_id = uuid.uuid4().hex
|
|
try:
|
|
if name == "plugin_load":
|
|
return await self.load(arguments["plugin_id"])
|
|
if name == "plugin_read_file":
|
|
plugin_id = arguments["plugin_id"]
|
|
if plugin_id not in self.loaded:
|
|
raise ValueError("请先加载插件")
|
|
path = contained_path(self.packages[plugin_id][2], arguments["path"])
|
|
if (
|
|
path.suffix.lower() not in {".md", ".txt", ".json"}
|
|
or path.stat().st_size > 256 * 1024
|
|
):
|
|
raise ValueError("仅支持读取 256 KiB 以内的插件说明文件")
|
|
return PluginCallResult(
|
|
status="success",
|
|
content=[
|
|
{"type": "text", "text": self._redact(path.read_text(encoding="utf-8"))}
|
|
],
|
|
)
|
|
pid, tool = self.bindings[name]
|
|
self.ensure_resources_ready()
|
|
manifest, profile, _ = self.packages[pid]
|
|
spec = manifest.capabilities.tools.get(tool.name) if manifest.capabilities else None
|
|
is_read = (
|
|
spec.effect == "read"
|
|
if spec
|
|
else bool(
|
|
(tool.annotations and tool.annotations.readOnlyHint)
|
|
or (
|
|
tool.annotations is None
|
|
and manifest.observation
|
|
and tool.name
|
|
in {manifest.observation.tool, *manifest.observation.refresh_tools}
|
|
)
|
|
)
|
|
)
|
|
jsonschema.validate(arguments, tool.inputSchema)
|
|
for key, allowed in profile.argument_constraints.get(tool.name, {}).items():
|
|
value: Any = arguments
|
|
for part in key.split("."):
|
|
value = value.get(part) if isinstance(value, dict) else None
|
|
if not isinstance(value, (str, int, float, bool)) or str(value) not in allowed:
|
|
raise PermissionError(f"工具参数 {key} 超出授权范围,请用户补充指导或修改授权")
|
|
span = None
|
|
if self.ctx:
|
|
from artemis.data_engine.trace import TraceSpan
|
|
|
|
span = TraceSpan(name=name, trace_type="tool", ctx=self.ctx)
|
|
if span:
|
|
span.__enter__()
|
|
span.payload = {
|
|
"call_id": call_id,
|
|
"args": self._redact_payload(arguments),
|
|
"plugin_id": pid,
|
|
"plugin_version": manifest.version,
|
|
"tool_name": tool.name,
|
|
"effect": "read" if is_read else "write",
|
|
"observation": bool(spec.observation)
|
|
if spec
|
|
else bool(is_read and manifest.observation),
|
|
"resource_ids": sorted(package_resources(manifest, profile)),
|
|
}
|
|
outcome = None
|
|
try:
|
|
raw = await self.sessions[pid].call(tool.name, arguments)
|
|
payload = raw.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
encoded = json.dumps(payload, ensure_ascii=False)
|
|
if len(encoded.encode()) > settings.ARTEMIS_PLUGIN_RESULT_MAX_BYTES:
|
|
raise RuntimeError("插件结果超过大小限制,操作可能已经完成;请重新观察")
|
|
payload = self._redact_payload(payload)
|
|
outcome = PluginCallResult(
|
|
status="error" if payload.get("isError") else "success",
|
|
content=payload.get("content", []),
|
|
structured_content=payload.get("structuredContent"),
|
|
plugin_id=pid,
|
|
version=manifest.version,
|
|
tool=tool.name,
|
|
call_id=call_id,
|
|
execution="unknown" if payload.get("isError") else "completed",
|
|
side_effect="none" if is_read else "possible",
|
|
)
|
|
receipt = decode_receipt(outcome)
|
|
outcome.source_id = source_identity(pid, outcome.source)
|
|
engine = getattr(self.ctx, "data_engine", None)
|
|
if receipt.get("attachments") and engine:
|
|
outcome.attachments = persist_attachments(
|
|
Path(engine.base_dir), pid, receipt["attachments"]
|
|
)
|
|
observation = manifest.observation
|
|
image_block = next((b for b in outcome.content if b.get("type") == "image"), None)
|
|
target_argument = spec.target_argument if spec else "app"
|
|
target = str(arguments.get(target_argument) or "")
|
|
previous = self._image_targets.get(pid)
|
|
pre_image_valid = bool(previous and previous == (target, outcome.source_id))
|
|
outcome.pre_image_valid = pre_image_valid
|
|
if image_block:
|
|
self.last_images[pid] = base64.b64decode(image_block["data"], validate=True)
|
|
self._image_targets[pid] = (target, outcome.source_id)
|
|
elif not is_read:
|
|
self.last_images.pop(pid, None)
|
|
self._image_targets.pop(pid, None)
|
|
if (
|
|
outcome.status == "success"
|
|
and observation
|
|
and tool.name in observation.refresh_tools
|
|
and is_read
|
|
):
|
|
self._observation_calls[pid] = (tool.name, dict(arguments))
|
|
self._persist_images(outcome)
|
|
if (
|
|
outcome.source
|
|
and image_block
|
|
and manifest.capabilities
|
|
and manifest.capabilities.recording == "windows"
|
|
and engine
|
|
and isinstance(getattr(engine, "base_dir", None), (str, Path))
|
|
):
|
|
from artemis.plugins.recording import (
|
|
PluginRecordings,
|
|
recover_interrupted_recordings,
|
|
)
|
|
|
|
try:
|
|
if self._recordings is None:
|
|
await recover_interrupted_recordings(Path(engine.base_dir))
|
|
self._recordings = PluginRecordings(self.ctx)
|
|
await self._recordings.observe(outcome.source_id, pid, outcome.source)
|
|
track = next(
|
|
(
|
|
value
|
|
for value in self._recordings.sources.values()
|
|
if outcome.source_id in value.get("target_ids", [])
|
|
),
|
|
None,
|
|
)
|
|
if track:
|
|
outcome.recording = {
|
|
key: track.get(key)
|
|
for key in ("id", "status", "scope", "area", "message")
|
|
}
|
|
except (OSError, ValueError, RuntimeError, TimeoutError):
|
|
logger.exception("电脑录像不可用;保留已完成的工具结果")
|
|
outcome.message += "\n电脑录像不可用,请检查任务日志;工具执行结果已保留。"
|
|
self._save_receipt(outcome, arguments, started_at)
|
|
if span:
|
|
stored = outcome.model_dump()
|
|
stored["content"] = [b for b in stored["content"] if b.get("type") != "image"]
|
|
stored["post_image_name"] = next(iter(outcome.image_names), None)
|
|
span.payload["post_image_name"] = stored["post_image_name"]
|
|
span.payload["image_names"] = outcome.image_names
|
|
span.payload["source_id"] = outcome.source_id
|
|
span.payload["source"] = outcome.source.model_dump() if outcome.source else None
|
|
span.payload["pre_image_valid"] = pre_image_valid
|
|
span.result = stored
|
|
if outcome.status != "success":
|
|
span.status, span.error = "failed", outcome.text()
|
|
return outcome
|
|
except (RuntimeError, OSError, ValueError, TimeoutError, KeyError, TypeError) as exc:
|
|
if self.sessions[pid].stop_uncertain:
|
|
self.store.block_resources(
|
|
self.resources,
|
|
"远程插件操作中断,无法确认写操作是否停止;请核对远程状态后解除保护。",
|
|
)
|
|
if span:
|
|
span.status, span.error = "failed", "插件调用失败,结果不确定"
|
|
known_outcome = outcome
|
|
outcome = PluginCallResult(
|
|
status="uncertain",
|
|
plugin_id=pid,
|
|
version=manifest.version,
|
|
tool=tool.name,
|
|
call_id=call_id,
|
|
source_id=known_outcome.source_id
|
|
if known_outcome
|
|
else source_identity(pid, None),
|
|
source=known_outcome.source if known_outcome else None,
|
|
execution=known_outcome.execution if known_outcome else "unknown",
|
|
error_code="timeout" if isinstance(exc, TimeoutError) else "plugin_call_failed",
|
|
side_effect=known_outcome.side_effect
|
|
if known_outcome
|
|
else ("none" if is_read else "possible"),
|
|
requires_observation=True,
|
|
message=self._redact(
|
|
f"插件调用中断或超时:{str(exc)[:1000] or type(exc).__name__}。结果不确定,请先观察实际状态,不能盲目重放写操作。"
|
|
),
|
|
)
|
|
self._save_receipt(outcome, arguments, started_at)
|
|
if span:
|
|
span.result = outcome.model_dump()
|
|
span.error = outcome.message
|
|
return outcome
|
|
finally:
|
|
if span:
|
|
span.__exit__(None, None, None)
|
|
except asyncio.CancelledError:
|
|
if any(s.stop_uncertain for s in self.sessions.values()):
|
|
self.store.block_resources(
|
|
self.resources, "插件操作取消后未确认停止,请核对目标状态后解除保护。"
|
|
)
|
|
raise
|
|
except PermissionError:
|
|
self._request_guidance()
|
|
return PluginCallResult(
|
|
status="error",
|
|
call_id=call_id,
|
|
error_code="permission_denied",
|
|
execution="not_started",
|
|
side_effect="none",
|
|
message="工具参数超出此任务的授权范围,已请求暂停。请提供授权范围内的指导;如需扩大范围,请结束任务后修改插件配置。",
|
|
)
|
|
except (
|
|
KeyError,
|
|
ValueError,
|
|
OSError,
|
|
jsonschema.ValidationError,
|
|
jsonschema.SchemaError,
|
|
):
|
|
# Schema errors can contain the rejected value (including secrets); keep diagnostics structural.
|
|
return PluginCallResult(
|
|
status="error",
|
|
call_id=call_id,
|
|
error_code="invalid_request",
|
|
execution="not_started",
|
|
side_effect="none",
|
|
message="插件尚未就绪、参数不符合工具定义或超出授权范围;请检查插件配置和授权。",
|
|
)
|
|
|
|
def _persist_images(self, outcome: PluginCallResult) -> None:
|
|
engine = getattr(self.ctx, "data_engine", None)
|
|
if not engine:
|
|
return
|
|
for block in outcome.content:
|
|
if block.get("type") == "image" and block.get("data"):
|
|
image = base64.b64decode(block["data"], validate=True)
|
|
# Save the text from the same tool result before a later action
|
|
# can fill this image's missing tree with a different screen.
|
|
name = engine.get_or_create_image(image, ui_tree=outcome.text())
|
|
outcome.image_names.append(name)
|
|
if engine.current_session_id:
|
|
engine.storage.reference_plugin_image(engine.current_session_id, name)
|
|
|
|
@staticmethod
|
|
def content_blocks(outcome: PluginCallResult) -> list[dict]:
|
|
blocks: list[dict] = [{"type": "text", "text": outcome.text()}]
|
|
for block in outcome.content:
|
|
if block.get("type") == "image" and block.get("mimeType") in {
|
|
"image/png",
|
|
"image/jpeg",
|
|
"image/webp",
|
|
}:
|
|
blocks.append(
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": f"data:{block['mimeType']};base64,{block['data']}"},
|
|
}
|
|
)
|
|
elif block.get("type") not in {"image", "text"}:
|
|
blocks.append({"type": "text", "text": json.dumps(block, ensure_ascii=False)})
|
|
return blocks
|
|
|
|
async def execute(self, name: str, arguments: dict, tool_call_id: str):
|
|
from artemis.agents.validator.tool_declarations import ToolExecutionResult
|
|
|
|
outcome = await self.call(name, arguments)
|
|
image = next((b for b in outcome.content if b.get("type") == "image"), None)
|
|
image_bytes = base64.b64decode(image["data"]) if image else None
|
|
engine = getattr(self.ctx, "data_engine", None)
|
|
path = None
|
|
if engine and image_bytes:
|
|
path = str(engine.get_image_path(engine.get_or_create_image(image_bytes)))
|
|
return ToolExecutionResult(
|
|
tool_name=name,
|
|
tool_call_id=tool_call_id,
|
|
status="success" if outcome.status == "success" else "error",
|
|
text_summary=(
|
|
f"来源:插件 {outcome.plugin_id} v{outcome.version} / {outcome.tool}\n"
|
|
if outcome.plugin_id
|
|
else ""
|
|
)
|
|
+ outcome.text(),
|
|
raw_result=self.content_blocks(outcome),
|
|
screenshot_bytes=image_bytes,
|
|
screenshot_path=path,
|
|
ui_elements_text=outcome.text(),
|
|
metadata={
|
|
"plugin_id": outcome.plugin_id,
|
|
"plugin_version": outcome.version,
|
|
"resource_ids": (
|
|
sorted(package_resources(*self.packages[outcome.plugin_id][:2]))
|
|
if outcome.plugin_id in self.packages
|
|
else []
|
|
),
|
|
"plugin_status": outcome.status,
|
|
"call_id": outcome.call_id,
|
|
"source_id": outcome.source_id,
|
|
"source": outcome.source.model_dump() if outcome.source else None,
|
|
"pre_image_valid": outcome.pre_image_valid,
|
|
"error_code": outcome.error_code,
|
|
"execution": outcome.execution,
|
|
"side_effect": outcome.side_effect,
|
|
"attachments": outcome.attachments,
|
|
"recording": outcome.recording,
|
|
"post_image_name": next(iter(outcome.image_names), None),
|
|
},
|
|
)
|
|
|
|
async def aclose(self, *, release: bool = True):
|
|
if self._recordings is not None:
|
|
try:
|
|
await self._recordings.stop()
|
|
except (OSError, ValueError, RuntimeError, TimeoutError):
|
|
logger.exception("电脑录像收尾失败;继续关闭插件连接")
|
|
pending = list(self.sessions.items())
|
|
errors = await asyncio.gather(
|
|
*(session.aclose() for _, session in pending), return_exceptions=True
|
|
)
|
|
for (pid, _), error in zip(pending, errors):
|
|
if not isinstance(error, BaseException):
|
|
self.sessions.pop(pid, None)
|
|
self.bindings = {
|
|
name: binding for name, binding in self.bindings.items() if binding[0] in self.sessions
|
|
}
|
|
self.loaded.intersection_update(self.sessions)
|
|
self._observation_calls.clear()
|
|
self.last_images.clear()
|
|
self._image_targets.clear()
|
|
if any(isinstance(error, BaseException) for error in errors):
|
|
self.store.block_resources(self.resources, "插件未确认停止,请核对目标状态后解除保护。")
|
|
raise RuntimeError("部分插件未确认停止,资源仍需保持占用")
|
|
if release:
|
|
self.store.release(self._lease_id)
|