feat: enhance session ID calculation and plugin management

- Updated `calculate_session_id` method in `SessionUtils` to include optional `account_id` and `scope` parameters for more granular session ID generation.
- Added new environment variables in `plugin_runtime` for external plugin dependencies and global configuration snapshots.
- Introduced methods in `RuntimeComponentManagerProtocol` for loading and reloading plugins globally, accommodating external dependencies.
- Enhanced `PluginRunnerSupervisor` to manage external available plugin IDs during plugin reloads.
- Implemented dependency extraction and management in `PluginRuntimeManager` to handle cross-supervisor dependencies.
- Added tests for session ID calculation and message registration in `ChatManager` to ensure correct behavior with new parameters.
This commit is contained in:
DrSmoothl
2026-03-23 21:48:19 +08:00
parent 7a304ba549
commit 0c508995dd
12 changed files with 765 additions and 170 deletions

View File

@@ -15,6 +15,7 @@ from typing import Any, Callable, Dict, List, Optional, Protocol, Set, Tuple, ca
import asyncio
import contextlib
import inspect
import json
import logging as stdlib_logging
import os
import signal
@@ -23,7 +24,13 @@ import time
import tomllib
from src.common.logger import get_console_handler, get_logger, initialize_logging
from src.plugin_runtime import ENV_HOST_VERSION, ENV_IPC_ADDRESS, ENV_PLUGIN_DIRS, ENV_SESSION_TOKEN
from src.plugin_runtime import (
ENV_EXTERNAL_PLUGIN_IDS,
ENV_HOST_VERSION,
ENV_IPC_ADDRESS,
ENV_PLUGIN_DIRS,
ENV_SESSION_TOKEN,
)
from src.plugin_runtime.protocol.envelope import (
BootstrapPluginPayload,
ComponentDeclaration,
@@ -112,6 +119,7 @@ class PluginRunner:
host_address: str,
session_token: str,
plugin_dirs: List[str],
external_available_plugin_ids: Optional[List[str]] = None,
) -> None:
"""初始化 Runner。
@@ -119,10 +127,16 @@ class PluginRunner:
host_address: Host 的 IPC 地址。
session_token: 握手用会话令牌。
plugin_dirs: 当前 Runner 负责扫描的插件目录列表。
external_available_plugin_ids: 视为已满足的外部依赖插件 ID 列表。
"""
self._host_address: str = host_address
self._session_token: str = session_token
self._plugin_dirs: List[str] = plugin_dirs
self._external_available_plugin_ids: Set[str] = {
str(plugin_id or "").strip()
for plugin_id in (external_available_plugin_ids or [])
if str(plugin_id or "").strip()
}
self._rpc_client: RPCClient = RPCClient(host_address, session_token)
self._loader: PluginLoader = PluginLoader(host_version=os.getenv(ENV_HOST_VERSION, ""))
@@ -150,7 +164,10 @@ class PluginRunner:
self._register_handlers()
# 3. 加载插件
plugins = self._loader.discover_and_load(self._plugin_dirs)
plugins = self._loader.discover_and_load(
self._plugin_dirs,
extra_available=self._external_available_plugin_ids,
)
logger.info(f"已加载 {len(plugins)} 个插件")
# 4. 注入 PluginContext + 调用 on_load 生命周期钩子
@@ -379,6 +396,7 @@ class PluginRunner:
plugin_version=meta.version,
components=components,
capabilities_required=meta.capabilities_required,
dependencies=meta.dependencies,
config_reload_subscriptions=config_reload_subscriptions,
)
@@ -485,18 +503,20 @@ class PluginRunner:
self._loader.set_loaded_plugin(meta)
return True
async def _unload_plugin(self, meta: PluginMeta, reason: str) -> None:
async def _unload_plugin(self, meta: PluginMeta, reason: str, *, purge_modules: bool = True) -> None:
"""卸载单个插件并清理 Host/Runner 两侧状态。
Args:
meta: 待卸载的插件元数据。
reason: 卸载原因。
purge_modules: 是否在卸载完成后清理插件模块缓存。
"""
await self._invoke_plugin_on_unload(meta)
await self._unregister_plugin(meta.plugin_id, reason)
await self._deactivate_plugin(meta)
self._loader.remove_loaded_plugin(meta.plugin_id)
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
if purge_modules:
self._loader.purge_plugin_modules(meta.plugin_id, meta.plugin_dir)
def _collect_reverse_dependents(self, plugin_id: str) -> Set[str]:
"""收集依赖指定插件的所有已加载插件。
@@ -564,18 +584,52 @@ class PluginRunner:
return list(reversed(load_order))
async def _reload_plugin_by_id(self, plugin_id: str, reason: str) -> ReloadPluginResultPayload:
@staticmethod
def _finalize_failed_reload_messages(
failed_plugins: Dict[str, str],
rollback_failures: Dict[str, str],
) -> Dict[str, str]:
"""在重载失败后补充回滚结果说明。"""
finalized_failures: Dict[str, str] = {}
for failed_plugin_id, failure_reason in failed_plugins.items():
rollback_failure = rollback_failures.get(failed_plugin_id)
if rollback_failure:
finalized_failures[failed_plugin_id] = (
f"{failure_reason};且旧版本恢复失败: {rollback_failure}"
)
else:
finalized_failures[failed_plugin_id] = f"{failure_reason}(已恢复旧版本)"
for failed_plugin_id, rollback_failure in rollback_failures.items():
if failed_plugin_id not in finalized_failures:
finalized_failures[failed_plugin_id] = f"旧版本恢复失败: {rollback_failure}"
return finalized_failures
async def _reload_plugin_by_id(
self,
plugin_id: str,
reason: str,
external_available_plugins: Optional[Set[str]] = None,
) -> ReloadPluginResultPayload:
"""按插件 ID 在 Runner 进程内执行精确重载。
Args:
plugin_id: 目标插件 ID。
reason: 重载原因。
external_available_plugins: 视为已满足的外部依赖插件 ID 集合。
Returns:
ReloadPluginResultPayload: 结构化重载结果。
"""
candidates, duplicate_candidates = self._loader.discover_candidates(self._plugin_dirs)
failed_plugins: Dict[str, str] = {}
normalized_external_available = {
str(candidate_plugin_id or "").strip()
for candidate_plugin_id in (external_available_plugins or set())
if str(candidate_plugin_id or "").strip()
}
if plugin_id in duplicate_candidates:
conflict_paths = ", ".join(str(path) for path in duplicate_candidates[plugin_id])
@@ -603,29 +657,32 @@ class PluginRunner:
unload_order = self._build_unload_order(target_plugin_ids & loaded_plugin_ids)
unloaded_plugins: List[str] = []
retained_plugin_ids = loaded_plugin_ids - set(unload_order)
rollback_metas: Dict[str, PluginMeta] = {}
for unload_plugin_id in unload_order:
meta = self._loader.get_plugin(unload_plugin_id)
if meta is None:
continue
await self._unload_plugin(meta, reason=reason)
rollback_metas[unload_plugin_id] = meta
await self._unload_plugin(meta, reason=reason, purge_modules=False)
self._loader.purge_plugin_modules(unload_plugin_id, meta.plugin_dir)
unloaded_plugins.append(unload_plugin_id)
reload_candidates: Dict[str, Tuple[Path, Dict[str, Any], Path]] = {}
for target_plugin_id in target_plugin_ids:
candidate = candidates.get(target_plugin_id)
if candidate is None:
failed_plugins[target_plugin_id] = "插件目录已不存在,已保持卸载状态"
failed_plugins[target_plugin_id] = "插件目录已不存在"
continue
reload_candidates[target_plugin_id] = candidate
load_order, dependency_failures = self._loader.resolve_dependencies(
reload_candidates,
extra_available=retained_plugin_ids,
extra_available=retained_plugin_ids | normalized_external_available,
)
failed_plugins.update(dependency_failures)
available_plugins = set(retained_plugin_ids)
available_plugins = set(retained_plugin_ids) | normalized_external_available
reloaded_plugins: List[str] = []
for load_plugin_id in load_order:
@@ -656,7 +713,48 @@ class PluginRunner:
available_plugins.add(load_plugin_id)
reloaded_plugins.append(load_plugin_id)
requested_plugin_success = plugin_id in reloaded_plugins and not failed_plugins
if failed_plugins:
rollback_failures: Dict[str, str] = {}
for reloaded_plugin_id in reversed(reloaded_plugins):
reloaded_meta = self._loader.get_plugin(reloaded_plugin_id)
if reloaded_meta is None:
continue
try:
await self._unload_plugin(
reloaded_meta,
reason=f"{reason}_rollback_cleanup",
purge_modules=False,
)
except Exception as exc:
rollback_failures[reloaded_plugin_id] = f"清理失败: {exc}"
finally:
self._loader.purge_plugin_modules(reloaded_plugin_id, reloaded_meta.plugin_dir)
for rollback_plugin_id in reversed(unload_order):
rollback_meta = rollback_metas.get(rollback_plugin_id)
if rollback_meta is None:
continue
try:
restored = await self._activate_plugin(rollback_meta)
except Exception as exc:
rollback_failures[rollback_plugin_id] = str(exc)
continue
if not restored:
rollback_failures[rollback_plugin_id] = "无法重新激活旧版本"
return ReloadPluginResultPayload(
success=False,
requested_plugin_id=plugin_id,
reloaded_plugins=[],
unloaded_plugins=unloaded_plugins,
failed_plugins=self._finalize_failed_reload_messages(failed_plugins, rollback_failures),
)
requested_plugin_success = plugin_id in reloaded_plugins
return ReloadPluginResultPayload(
success=requested_plugin_success,
@@ -978,7 +1076,11 @@ class PluginRunner:
)
async with self._reload_lock:
result = await self._reload_plugin_by_id(payload.plugin_id, payload.reason)
result = await self._reload_plugin_by_id(
payload.plugin_id,
payload.reason,
external_available_plugins=set(payload.external_available_plugins),
)
return envelope.make_response(payload=result.model_dump())
def request_capability(self) -> RPCClient:
@@ -1073,6 +1175,7 @@ def _isolate_sys_path(plugin_dirs: List[str]) -> None:
async def _async_main() -> None:
"""异步主入口"""
host_address = os.environ.get(ENV_IPC_ADDRESS, "")
external_plugin_ids_raw = os.environ.get(ENV_EXTERNAL_PLUGIN_IDS, "")
session_token = os.environ.get(ENV_SESSION_TOKEN, "")
plugin_dirs_str = os.environ.get(ENV_PLUGIN_DIRS, "")
@@ -1081,11 +1184,24 @@ async def _async_main() -> None:
sys.exit(1)
plugin_dirs = [d for d in plugin_dirs_str.split(os.pathsep) if d]
try:
external_plugin_ids = json.loads(external_plugin_ids_raw) if external_plugin_ids_raw else []
except json.JSONDecodeError:
logger.warning("解析外部依赖插件列表失败,已回退为空列表")
external_plugin_ids = []
if not isinstance(external_plugin_ids, list):
logger.warning("外部依赖插件列表格式非法,已回退为空列表")
external_plugin_ids = []
# sys.path 隔离: 只保留标准库、SDK 包、插件目录
_isolate_sys_path(plugin_dirs)
runner = PluginRunner(host_address, session_token, plugin_dirs)
runner = PluginRunner(
host_address,
session_token,
plugin_dirs,
external_available_plugin_ids=[str(plugin_id) for plugin_id in external_plugin_ids],
)
# 注册信号处理
def _mark_runner_shutting_down() -> None: