Skip to content

feat(plugin): 插件实例绑定版本,多实例并行运行不同版本 - #6566

Open
Aqr-K wants to merge 35 commits into
jxxghp:v3from
Aqr-K:feat/plugin-instance-clones
Open

feat(plugin): 插件实例绑定版本,多实例并行运行不同版本#6566
Aqr-K wants to merge 35 commits into
jxxghp:v3from
Aqr-K:feat/plugin-instance-clones

Conversation

@Aqr-K

@Aqr-K Aqr-K commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

背景

插件分身当前只能跟随源插件的当前版本运行。PluginCloneRequest.version 是死参数,app.plugins.<id> 模块名不带版本,磁盘上一个插件只存一份代码。这带来两个实际问题:升级插件是全局动作,任何一个分身依赖旧行为,整个插件就不能升;想验证新版本,只能先把所有使用者一起切过去。

另一个独立问题:安装流水线先备份删除旧目录、再准备新内容,拿到新内容之前旧的已经没了,准备阶段失败会留下不可回退的中间态。

本 PR 让每个插件实例可以绑定自己的版本、多个实例并行运行不同版本,并把安装改成先暂存后原子换入。24 个提交,每个提交自带动机说明,建议按提交顺序审阅。

主要改动

1. 版本目录布局(app/runtime/extensions/plugin/version.py

插件源码可落在 app/plugins/<插件ID>/v<版本>/,元信息记在 versions.json(目录名不是权威真值,清单才是)。版本号到目录名是双射,含下划线的版本号直接拒绝以免两个版本撞同一目录;已装版本做大小写不敏感比对,避免大小写不敏感文件系统上撞目录。

没有版本目录时解析回落插件根目录,即现存全部插件的平铺布局行为逐字不变。版本化是可选升级,不是强制迁移。

2. 安装先暂存后原子落盘

下载与解压先进临时暂存目录,内容完全就绪后再决定目标目录并原子换入。四条安装路径(市场、release、git、本地仓库)都覆盖,写入过程中任一步失败,插件目录逐字节回到安装前状态。这一条独立于多版本成立,修的是现有流水线的失败面。

3. 多版本并存守卫(app/runtime/compat/readiness.py

AST 静态扫描三类阻断:插件自引用绝对 import(版本化后模块路径变化必然失败)、跨插件依赖、在宿主共享声明基类上建模(同一插件两个版本会映射同名表)。安装第二个版本前命中阻断即拒绝,把故障从运行期提前到安装时,并对第一类生成「改用相对 import」的建议文案。

对官方插件仓库 171 个插件实测:v3 插件 93%、v2 插件 91%、旧版 86% 可并存,受阻的几乎全是自引用绝对 import 这一类可机械修复的写法,共享基类建模一次都没出现。

4. 实例描述符迁入独立表(plugininstance

实例描述符原先整体存在 SystemConfigKey.PluginInstances 一个键里,所有插件的所有实例共用一个 JSON 大对象:改一个实例要整体读改写,没有索引,也无法让数据库强制任何不变量。改为一实例一行,instance_id 唯一、source_plugin_id 建索引、mode 用 CheckConstraint 限定为 virtual(分身)或 host(源插件本体的绑定载体),两类视图互不可见,本体记录不会出现在分身列表或已安装清单里。

迁移只复制不删除原键(留作回滚依据),并有一层兜底:表为空而旧键非空时首次访问导入一次,幂等。

插件业务配置不动,它本来就按 plugin.<实例ID> 一行一实例存放,原子且无大对象问题,迁移它是高风险低收益。

5. 实例绑定版本(binding.py

描述符记录已生效版本与是否跟随当前版本。源插件本体与各分身都能钉在指定版本上。启动选版本覆盖三种情形:从未成功启动过按当前版本启动并如实登记;绑定的版本目录已不存在则告警回落当前版本并登记实际启动的版本;目标版本加载失败则保持已生效版本不动、用它重启完成回退。

切换绑定走停止再启动,不做热替换——热替换等于在运行期换掉一个已注册事件、已起定时任务、可能有在途请求的实例。切换前校验目标版本已安装,会形成多版本并行时先跑并存检查。

前端与静态资源路径同步按实例绑定的版本解析(分身仍读源插件目录,只是落到正确的版本子目录)。

6. 版本回收

保留判据满足其一即保留:清单当前版本、被任一实例引用、落在最近两个已登记版本内。删除前三重校验(在插件目录内、不等于插件目录本身、目录名能反解回待删版本号)。单个目录删除失败不阻断其余,标注下次重试。引用集合收集失败直接上抛不吞——凑不齐引用集合时按空集回收会删掉仍在用的版本且无从恢复,跳过本次回收才是安全的失效方向。回收时机固定在启动流程插件加载完成之后,不放在安装期(那时用户可能正打算回退)。

7. 实例日志等级(loglevel.py

描述符加等级与失效时间两列,可以给单个实例设等级且不随全局等级变更,过期在读取时判定,不起后台清理。运行期用 ContextVar 绑定当前执行的实例,覆盖实例构造与 init_plugin、配置页重新生效的初始化、事件处理器回调(含跨线程池场景)、定时服务回调、动态 API 端点回调。插件自建的原生线程不在覆盖范围,contextvars 传不进去,已在文档与测试中写明。

8. 默认调用目标(target.py

is_default_target 列配条件唯一索引(SQLite 与 PostgreSQL 各一份方言谓词),把「同一插件至多一个默认目标」交给数据库判定而非应用层纪律。裁决规则:显式指定实例优先;否则用已设置且正在运行的默认目标;否则报错并列出候选,绝不取第一个或按注册顺序猜。没有分身的插件直接用本体,单实例场景不被这套机制打扰。

调用点排查后只有一处真正需要裁决:app/workflow/actions/invoke_plugin.py 的插件 ID 来自工作流保存的历史配置,源插件停用而仅分身启用时原逻辑会静默查不到。其余按插件 ID 的路径(前端按具体卡片发起的端点、调度器遍历运行实例、Agent 工具会话建立时逐实例物化)都已是具体实例,不需要裁决,未接入。

新增端点

均要求超级管理员:

方法 路径
GET /plugin/versions/{plugin_id}
PUT /plugin/versions/{plugin_id}/{instance_id}
POST /plugin/versions/{plugin_id}/recycle
GET /plugin/loglevel/{plugin_id}
PUT / DELETE /plugin/loglevel/{plugin_id}/{instance_id}
PUT / DELETE /plugin/instances/{plugin_id}/{instance_id}/default_target

全部落在新文件 app/api/endpoints/pluginversion.pyapp/api/endpoints/plugin.py 净增量为 0(它恰好卡在 complexity.py --v2 的 1000 行阈值上)。Agent 契约(operation specs、MCP 描述、api_mcp_schema、surface audit、SKILL 文档)均用仓库既有生成脚本重跑同步。

兼容性

  • 未使用版本目录的插件行为逐字不变;平铺布局永久支持,版本化是可选升级。
  • 实例描述符迁移只复制不删除原 systemconfig 键,可回滚。
  • 三条 Alembic 迁移接在当前 head 之后,保持单 head。
  • 插件业务配置存储不变。

测试

新增约 200 个用例,覆盖:版本目录双射与清单权威性、存量布局迁移(含中断续做、跨设备放弃)、四条安装路径落盘与失败逐字节回滚、三类阻断的检出与建议生成、启动选版本三种回退、并存判定、版本回收的保留判据与三重校验与引用集合收集失败上抛、实例表迁移与兜底导入幂等、本体与分身视图隔离、日志等级的过期回落与四类入口绑定与跨实例不串扰、条件唯一索引在两种方言下的谓词编译与真实拒绝第二条默认目标、裁决的三条规则。

全量 8435 passed。架构门禁 218 passed;baseline.py --check-host、ruff / mypy / complexity 棘轮、启动性能契约均通过。

已知限制

  • PostgreSQL 路径为 mock 级测试(与仓内 test_db_engine_postgresql.py 现状一致),真实 PG 上的 schema 与条件索引行为建议合并前验证一次。
  • 每个分身独立执行一遍源码,多版本会再乘一层,内存随实例数与子模块数增长;实测单实例加载耗时在亚毫秒级,时间开销可忽略。
  • 分身的前端产物在开发态热重载下匹配不到(分身没有独立目录,属既有设计,本 PR 未扩大也未修复)。
  • 版本回收目前由启动流程与手动端点触发,无周期性触发。

PR-Agent 摘要

🤖 Generated by PR Agent at c6dd865

  • 支持插件实例绑定独立版本
  • 多版本源码目录与版本清单
  • 存量平铺布局按需迁移
  • 安装改为暂存后原子换入
  • 失败仅回滚本次版本内容
  • 保持未版本化插件兼容运行
  • 覆盖同步异步安装与迁移测试
  • 风险:跨设备回退依赖文件系统

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR-Agent Code Review

本 PR 将插件安装改为暂存后换入,并加入版本目标解析、版本登记及多版本实例支持。当前回滚逻辑在跨文件系统复制失败和强制安装失败时可能删除已有插件版本,存在高风险的数据丢失问题。

审查提交:80eb743

Comment thread app/adapters/system/plugin/package.py
Comment thread app/adapters/system/plugin/package.py
Aqr-K added 29 commits September 3, 2026 08:59
新增 app/runtime/extensions/plugin/version.py:版本目录名双射、versions.json
读写、已装版本枚举、声明版本静态解析与 resolve_plugin_version_dir 核心解析,
平铺布局(无版本目录)时回落插件根目录本身,行为与今天逐字一致。加载器
load/load_instance 改经该解析函数取源码目录,非平铺布局下按版本目录手动
构造模块规格,不改变模块命名。

同步刷新架构依赖基线(新增 1 模块 945/边 7880)、ruff 低水位(loader.py
导入排序修复后 -1)与启动性能基线的三个入口模块计数(各 +1),并更新
architecture-overview.md、optimization-checklist.md 对应数字。
标准 importlib 执行失败会移除预置的 sys.modules 键,按版本目录手动构造模块
规格的分支之前没有复现这一行为,导致失败后的半成品模块对象残留在缓存里,
后续加载直接命中坏对象。
version.py 新增 register_plugin_version 与 migrate_legacy_plugin_layout,
把已就位的版本目录登记进 versions.json,并把平铺布局原地迁移为版本化布局
(两段式原子改名、中断续做、跨设备放弃迁移保留旧布局),迁移只允许在安装
第二个版本时触发,不挂到加载路径上。

新增 app/runtime/compat/readiness.py 静态扫描插件源码对版本化目录布局的
适配情况:自引用绝对 import、跨插件依赖、宿主共享声明基类建模三类判据。

PluginPackageManager 新增 version_switch_guard 注入端口,本地插件安装在
覆盖运行目录前调用该端口;命中自引用绝对导入或共享基类建模时拒绝把已装
版本切换到另一版本,把故障从运行期提前到安装时,首个版本的安装不受影响。
组合根 app/startup/composition/plugin.py 装配真正的检查实现,适配器层与
运行时扩展包本身都不直接依赖兼容层。

同步刷新架构依赖基线与启动性能基线的模块计数。
release/文件列表/本地仓库三条内容获取路径不再直接写入插件根目录,改为先落到
临时暂存目录;内容齐备后才依次执行并存检查、备份、目标目录决策与原子换入,
避免先删旧目录、后知道新内容是否可用导致的不可回退窗口。

PluginPackageManager 新增 install_target_resolver/install_version_registrar
两个注入端口,默认落回今天的平铺覆盖安装行为;真实实现落在组合根
app/startup/composition/plugin.py,决定暂存内容写入插件根目录本身还是某个
版本子目录,声明版本号相同时留在平铺布局,不同时先迁移存量源码再写入新版本
目录,写入完成后登记 versions.json 并置为当前版本。原有的
version_switch_guard 注入点从只覆盖本地安装路径推广到全部安装路径,统一在
暂存内容就绪后调用。
test_plugin_version_install.py 新增四条安装路径落到版本目录、无版本号回落
平铺、同版本重装幂等、首次多版本触发存量迁移、写法体检阻断保留旧版本、目标
解析或依赖安装失败时插件根目录回滚到改动前状态等用例。

同步既有测试到 package.py 新增的 dest_root/content_dir/staging_dir 形参与
暂存编排语义:安装成功路径不再有提前删除旧目录的中间态,相关调用顺序断言
按新流程改写;两处名称与断言仍描述旧的"先删后备份再恢复"语义的用例改名并
改写 docstring,准确反映"内容准备失败时插件根目录从未被触碰"。
分身在独立模块命名空间里执行源码,源码目录改按该实例绑定的版本解析,同一插件的
多个实例因此可以各自跑在不同版本上。实例描述符记录已生效版本与是否跟随当前版本,
旧描述符缺字段时按跟随当前版本处理。

启动选版本覆盖三种情形:未成功启动过的实例按当前版本启动并如实登记;绑定版本的
目录已不存在时回落当前版本并登记实际启动的版本;目标版本启动失败时保持已生效版本
不变、用它重启回退。切换绑定走停止再启动,不做热替换——热替换等于在运行期换掉一个
已注册事件、已起定时任务、可能有在途请求的实例。切换前校验目标版本已安装,并在会
形成多版本并行时先跑并存检查。
插件静态文件端点与开发态联邦构建产物监控此前只按插件根目录拼路径,版本
化布局下产物落在 <pid>/v<版本>/ 子目录,二者因而失配:分身固定读根目录,
现网可能出现代码是旧版、资源是另一版的错配;本地开发时联邦产物变化也因
路径对不上而检测不到,热重载失效。

version.py 新增 resolve_instance_version_dir,按实例的跟随开关与绑定版本
解析源码目录,绑定版本目录缺失时回落当前版本,语义与加载器一致。

app/api/endpoints/plugin.py 的静态文件端点接入该解析,分身按自身绑定读
源插件对应版本目录;受恰好 1000 行的复杂度门禁约束,改动净行数为零。

PluginPathResolver 新增 get_instance 注入端口,federated_change 按解析出
的版本目录而非插件根目录圈定联邦入口边界,由组合点 build_plugin_runtime
用既有的 instances.get 装配,未引入新的跨层依赖。

已排查 manager.py 的 get_plugin_remote_entry(只拼 URL,不解析文件系统路
径,走向已修复的静态文件端点)、projection.py(同样只组装 URL 元数据)、
package.py 的 clone/_modify_federation_files(复制目录改类名的物理分身路
径已被 clone.py 的实例化机制取代,确认未被任何生产调用点引用),均无需
改动。
多版本并存能力落地后已装版本只增不减,磁盘会持续堆积。version.py 新增
recycle_plugin_version_directories:保留判据满足其一即保留(当前安装版本
/被实例引用/按登记时间落在最近 PLUGIN_VERSION_RETENTION_WINDOW 个以
内),删除前三重校验目录仍在插件目录之内、不等于插件目录本身、目录名能
反解回待删版本号,任一不过即跳过并记错误日志;单个目录删除失败不阻断其
余版本的回收,失败版本在返回结果里标注下次重试。

PluginVersionBinding 新增 recycle_versions,引用集合并入全部实例的已生效
版本与按跟随开关解析出的期望版本;引用集合收集失败(如实例存储不可用)
直接向上抛出,不吞异常按空集继续——那样会把仍在用的版本判定为无引用而
误删且无从恢复。PluginManager 新增单插件与全量两个入口,全量入口逐插件
隔离失败,单个插件回收失败只记日志、不阻断其余插件。

回收调用时机固定在启动流程 init_extra 的插件同步收尾阶段(set_plugin_
settling(False) 之后),不放进安装流程——安装期用户可能正打算回退到旧版
本。新增手动触发端点 POST /versions/{plugin_id}/recycle,超管权限,返回
删除与保留清单。

新增端点同步契约计数:app/agent/policy/api.py 增 plugin.versions.recycle
操作规格与固定路由,app/agent/policy/mcp.py 增英文描述,重新生成
api_mcp_schema.json、agent-api-surface-audit.{json,md}、
skills/moviepilot-api/SKILL.md,tests/test_agent_api_gateway.py 与
tests/test_agent_skills_middleware.py 的操作计数同步 205→206、121→122。

新增 PluginVersionRecycleOutcome schema 后同步刷新 schema 导出清单
(app/schemas/exports.py)与架构依赖基线(新增 5 条模块间导入边),
docs/architecture-overview.md 与 optimization-checklist.md 的依赖边计数
同步 7,908→7,915。
实例描述符(分身与源插件本体的版本绑定)此前整体挤在 systemconfig 的
PluginInstances 单键里,没有按实例的原子更新、没有索引、无法让数据库强
制不变量。新增 plugin_instance 表:instance_id 唯一、source_plugin_id
建索引,mode 字段区分分身与本体;新增 PluginInstanceOper 提供按实例 ID
取、按源插件列举、全量列举、写入、删除;新增迁移建表并把旧
systemconfig 单键的现有内容逐条搬入,只搬迁不删除原键,留作回滚依据。
app.runtime 不得依赖 app.db,PluginInstanceStore 改为经新增的
PluginInstanceDirectory 注入端口访问独立表,真实实现在启动组合根
app/startup/initializers/plugins.py 装配到新 Oper。all()/get()/save()/
delete()/for_source() 保持只服务分身的既有合同不变;新增 get_host()/
save_host() 服务源插件本体的版本绑定,本体记录与分身记录共用同一张表,
只靠 mode 字段区分,互不进入对方视图。首次访问时若新表为空而旧
systemconfig 单键非空,按旧内容导入一次,用表非空这一实测事实做幂等判据。
mode 字段类型相应放宽为 virtual/host 两种取值。
源插件本体过去没有实例描述,加载永远跟随当前版本、无法钉在旧版本。
PluginLoader.load() 加载本体时改按其版本绑定解析源码目录,语义与
load_instance 对分身绑定的三情形一致:跟随当前版本、钉住某版本、绑定
目录缺失时回落当前版本并告警。PluginVersionBinding 的版本总览与实例
切换统一经 instance_id 解析分身或本体,本体从未绑定过版本时按跟随当前
版本的默认视图呈现;并存判定与版本回收的引用集合都并入本体的期望版本
与已生效版本,避免误判并存或误删本体正在用的版本。
新增 plugin_instance 模型与 Oper 各带来一个模块和四条依赖边,宿主模块数
948->950、内部依赖边 7,915->7,924,同步 dependency-baseline.json 与两份
文档的快照数字。启动性能基线只把三个冷导入入口的 loaded_app_module_count
各 +2,其余平台、Python 版本与耗时样本原样保留,不按本机重采样。
plugin_instance 表名与同族 plugindata/pluginidentity/plugininstallation
的无下划线命名不一致,且约束名早已按无下划线拼写,改名为 plugininstance
消除这处不一致。database-operation Skill 逐表登记 Purpose/Useful
queries/Write boundary/Columns 四项,测试按 Base.metadata.tables 逐表核对
存在性,补上这张新表的登记,否则该测试失败。
log_level/log_expires_at 落在既有 plugininstance 表,为空表示跟随全局日志
等级;新迁移接在 281965691a20 之后并保持单 head,downgrade 可删列。
app.runtime.log 新增按实例 ID 的等级覆盖缓存(bind_plugin_instance /
wrap_for_plugin_instance),过期判定在读取时惰性执行;在插件实例的构造与
init_plugin、事件处理器回调、定时服务回调、HTTP API 端点回调这四类宿主受控
调用点接线绑定。启动组合根新增缓存预热,配置变更后运行期立即生效。
GET/PUT/DELETE /plugin/loglevel/{plugin_id}[/{instance_id}],均超管权限;
非法等级 400,未知插件或实例 404,清除幂等。查询响应包一层插件级总览对象
而非裸列表,避免触发宿主集合分页契约门禁。
新增 plugin.loglevel.get/set/clear 三个 Agent API 操作规格、路由与 MCP 描述,
重新生成 api_mcp_schema.json、agent-api-surface-audit 与 moviepilot-api
SKILL.md;顺带补上此前遗漏的 plugininstance 数据库技能文档条目。刷新宿主
依赖基线(新增 loglevel.py 模块)与启动性能基线的模块计数。
新增 is_default_target 布尔列与 ux_plugininstance_default_target 条件唯一索引,
把「同一源插件至多一个默认调用目标」这条不变量交给数据库强制而非应用层纪律:
索引只对置位的行生效,SQLite 编译为 IS 1、PostgreSQL 编译为 IS true,两个方言
各给一份谓词。PluginInstanceOper 新增 set_default_target(同一事务内清旧置新,
目标行不存在时原样返回不动原有置位)与 clear_default_target(幂等清除)。

日志等级迁移测试原先按当前模型全列比对,新增列后即被顶穿,改为按该迁移自身
的落点显式列出预期列集合,不再随后续迁移的列演进跟着变红。
新增 PluginDefaultTargetControl(app/runtime/extensions/plugin/target.py):
resolve() 裁决未指定实例的调用应落到哪个实例——插件没有任何分身时直接用本体,
不打扰单实例场景;已有分身时必须命中已设置且正在运行的默认调用目标,否则
报错并在错误里列出全部候选实例及其启用状态,绝不取第一个、绝不在默认目标
停用时静默改走另一个实例。set_target()/clear_target() 管理置位与清除,本体
从未落盘过时先落盘一条默认视图记录再置位,清除仅在请求的实例正是当前置位
时才动作。经 PluginManager.resolve_plugin_call_target 等三个方法暴露。

排查了插件调用点:app/api/endpoints/plugin.py 与 plugin.py 各处按 {plugin_id}
路径参数操作的端点、app/agent 下的工具装配,plugin_id 均已是调用方明确选定
的具体实例 ID,不存在「未指定实例」的歧义,故不接入。唯一真实缺口是
app/workflow/actions/invoke_plugin.py:工作流保存的 plugin_id 可能是分身出现
前保存的物理插件 ID,源插件本体停用、仅分身启用后会静默查不到动作,现在先
经裁决解析实际调用目标。

PluginVersionOverview 的实例绑定补 is_default_target 字段。
PUT/DELETE /plugin/instances/{plugin_id}/{instance_id}/default_target,均超管
权限。设置端点目标实例不归属该插件(或插件不存在)时返回 404;清除端点仅当
当前置位的正是请求的实例时才真正清除,请求的实例不是当前默认或插件本就没有
置位都按空操作处理,重复调用保持幂等。
新增 plugin.default_target.set/clear 两个 Agent API 操作规格、路由与 MCP 描述,
重新生成 api_mcp_schema.json、agent-api-surface-audit 与 moviepilot-api
SKILL.md;database-operation SKILL.md 的 plugininstance 表列清单与用途说明
一并刷新。刷新宿主依赖基线(新增 target.py 模块)与启动性能基线的模块计数。
官方 v3 变基后自带 3.0.24-3.0.28 五条迁移,与我们的三条同版本号迁移各自
独立分叉自 a7d9e2c4f6b1。插件实例描述符表迁移的 down_revision 重接到官方
新链头 d8f2b6a4c1e7,恢复单一迁移链。
变基到官方 v3(c406454c2)后统一重跑生成脚本,替换变基过程中暂取官方侧的
生成物快照:

- baseline.py --write-host:宿主模块 968->976、内部依赖边 8,127->8,189
  (新增 plugininstance model/oper、pluginversion 端点、binding/loglevel/
  target/version/readiness 六个运行期模块)
- generate_agent_api_mcp_schema.py、generate_agent_api_surface_audit.py、
  generate_agent_skill_docs.py:同步 Agent API MCP schema、surface audit
  与 moviepilot-api/database-operation 两份 SKILL 契约到当前 210 个网关
  operation
- ruff_ratchet.py --write:插件加载器合并后 import 顺序问题随之消失,低
  水位下调 549->548
- 启动性能基线仅回填三个冷导入入口的 loaded_app_module_count(535->542、
  547->554、549->556),platform/python/耗时样本保留官方原采样
- 同步 docs/architecture-overview.md、docs/architecture/
  optimization-checklist.md 里的模块数/边数/Ruff 诊断数,以及
  tests/test_agent_skills_middleware.py 的 allowed_api_operations 计数
变基后 database/versions/ 中三条我方迁移文件与官方迁移文件语义版本号重复
(3.0.24/3.0.25/3.0.26 各出现两次),仅顺延我方文件的语义版本号到官方已占用
的 3.0.28 之后(3.0.29/3.0.30/3.0.31),revision id 与 down_revision 及迁移链
路一律不动。同步更新三条迁移文件 docstring 首行版本号,以及
tests/test_plugin_instance_migration.py、
tests/test_plugin_instance_log_level_migration.py、
tests/test_plugin_instance_default_target_migration.py 中按模块名引用的
MIGRATION_MODULE 常量。
跨设备退化为复制加删除时,__swap_staged_plugin_content 只在
copytree 中途失败后判断 final_dir 是否不存在就决定要不要换回旧内容;
final_dir 因复制中断而部分创建时该判断恒为假,导致旧内容被 finally
无条件删除、半份新内容却残留,违反该函数自身承诺的"任一步失败都保留
换入前的目录内容"。

改为显式状态机:复制失败先清掉半成品 final_dir,再尝试把旧内容换回
原位,只有确认新内容完整落地才删除旧内容;回滚换回旧内容本身也可能
失败,此时改为保留旧内容的暂存位置供人工恢复,并把回滚异常串联在原始
异常之后一并抛出,不吞掉换入失败的根因。

测试覆盖:跨设备复制中途失败后旧内容逐字节完好、无残留半份新内容、
原始异常向上抛出(首次安装与已有旧内容两种起点);原子改名成功路径
不受影响;回滚换回旧内容本身也失败时不吞掉原始异常且保留恢复材料。
多版本布局下,落位失败或依赖安装失败且没有临时备份可还原时,同步与
异步安装流程都直接调用整根删除的 __remove_old_plugin,删的是插件根
目录(app/plugins/<pid>/)。该目录在多版本布局下同时容纳其它已装版本
与 versions.json,整根删除会连带清空正被其它实例绑定的版本,破坏多
版本隔离与"安装失败保持原状态"的约束。

新增注入端口 install_version_rollback,落位阶段把已解析的版本化安装
目标随失败结果一并传给调用方;清理时按目标类型收敛范围:平铺布局
(目标为 None)保持整根清理不变,版本化布局只委托运行时扩展包删除
本次安装尝试写入的那一个版本目录、从版本元信息摘除该条目,若被摘除
的版本恰好是元信息登记的当前版本则回退到剩余版本里语义版本号最高者;
删除后插件目录不再持有任何可用版本时才连根清理,避免留下无版本可用
的空壳登记。同步与异步两条安装流程均已接入。

测试覆盖:已有版本 A、B 时安装第三个版本失败,清理只删第三个版本目录
与登记、A 与 B 及 versions.json 完好;要回滚的版本目录本就不存在时
安全跳过;被删版本是插件唯一版本时连根清理不留空壳;平铺布局下失败
清理行为保持与改动前一致,仍整根清理。
新增的版本回滚注入端口改变了内部导入边计数,重新执行
scripts/architecture/baseline.py --write-host 同步依赖基线,并同步
architecture-overview.md 与 optimization-checklist.md 中引用的内部
导入边数字(8,189 -> 8,187)。
模型类名与文件内唯一的显式 __tablename__ 是全仓唯一例外,与 Base 的
declared_attr 自动派生表名规则不一致;改名后自动派生结果仍是
plugininstance,与既有三条迁移一致,无需新迁移。组合根同时用到
ORM 模型与同名 Pydantic schema 处按惯例用 import 别名区分。
前端多实例管理界面上线后,插件卡片列表看不出实例状态;给 Plugin 投影
补三个只读字段:pinned_version(不跟随当前版本时钉住的版本号)、
is_default_target(是否为所属插件的默认调用目标)、log_level_effective
(当前有效的日志等级覆盖,无覆盖或已过期时为空)。

取数复用 catalog.py 遍历中已取到的分身实例对象,不新增查询;源插件
本体缺少同等取数入口,为此给 PluginInstanceStore 加一次性批量读取
host_instances,按插件 ID 遍历卡片时只做内存字典查找。
catalog.py 新增对 app.runtime.log 的导入,内部依赖边 8,187->8,188,
宿主模块数不变;同步 docs/architecture-overview.md 与
docs/architecture/optimization-checklist.md 里的边数快照。
@Aqr-K
Aqr-K force-pushed the feat/plugin-instance-clones branch from 80eb743 to b3059d1 Compare September 3, 2026 15:32
@Aqr-K

Aqr-K commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

两条 high 都成立,已修复;CI 的架构基线失败与代码无关,已随变基解决。

跨盘回滚

确认成立。跨设备退化为 copytree 后,中途失败会把 final_dir 部分创建,而回滚判据写的是 not final_dir.exists(),条件恒为假因此不回滚,随后 finally 又无条件删除 previous,结果旧内容丢失、新内容残缺——正好违反该函数 docstring 自己承诺的「任一步失败都保留换入前的目录内容」。

改为显式状态机:copytree 失败先清空半成品 final_dir,再把 previous 换回;只有换入完全成功才在 finally 删除旧目录。回滚本身失败时保留恢复材料并跳过清理,且用 raise error from rollback_error 把回滚异常串在原始异常之后,不吞根因。

测试覆盖:跨设备复制失败后旧内容逐字节完好且无残留、目标此前不存在时不留垃圾、原子改名成功路径不受影响、回滚自身失败时保留恢复材料且原始异常照常抛出。

版本误删

确认成立,而且这是多版本引入后新产生的风险——单版本时代整根删除是合理的。__remove_old_plugin 删的是插件根目录整棵树,多版本布局下会连同其它版本目录与 versions.json 一并删除,而那些版本可能正被其它实例绑定。

__place_staged_plugin_content 的返回值扩展为带出已解析的版本化目标,失败清理据此路由:平铺布局(目标为空)仍走整根清理,行为逐字节不变;版本化布局只删本次的版本目录并从清单摘除该条目,若被摘除的恰是 current 则回退为剩余版本中语义号最高者;清理后若插件目录已无任何版本目录且无平铺入口,才判定空壳连根清理。同步与异步两条安装流程都已接入。

测试覆盖:已有版本 A 与 B、B 被实例绑定,安装 C 失败后 A、B 与 versions.json 完好且 current 正确回退;平铺布局失败清理仍整根删除,证明既有行为未变;另有三则针对版本摘除函数本身的单测。

一处如实说明:current 的回退取剩余版本里语义号最高者,不是精确复原安装前的真实 current。因为版本注册发生在依赖校验之前,系统里没有记录「回滚前的真实 current」。若认为需要精确复原,可以把注册前的 current 穿透给失败清理路径,这属于另一处改动,本次未做。

CI 架构基线失败

失败原因是官方在本 PR 变基后又推了 738e78043,该提交自身修改了 dependency-baseline.json;CI 检查的是本分支与最新基底的合并结果,两份基线内容冲突。本地 --check-host 与干净检出复现均通过。已重新变基到 738e78043 并重新生成基线。

本次一并做的两项

插件实例模型改名为 PluginInstance 并去掉显式 __tablename__,改由 Base 自动派生。此前它是全仓唯一显式声明表名的模型,与其余模型风格不一致;派生出的表名仍是 plugininstance,与既有迁移一致,无需新迁移。

插件列表投影新增三个只读字段 pinned_versionis_default_targetlog_level_effective,让卡片列表不必逐个打开管理界面即可看出实例被钉住的版本、默认调用目标与偏离全局的日志等级。取数复用既有遍历,本体绑定记录改为一次批量查询后内存查找,未新增每卡片的数据库往返。

验证

架构门禁 218 passed,baseline.py --check-host 通过,ruff / mypy / complexity 棘轮无回退,启动性能契约通过且基线耗时样本未动,全量 8451 passed。唯一失败是本地容器文件系统 ctime 精度导致的 test_scanner_invalidates_equal_size_source_with_preserved_mtime,在干净基线上同样失败。

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR-Agent Code Review

此 PR 引入插件实例版本绑定、多版本目录布局及暂存后换入的安装流程。整体回滚设计仍有一处高风险缺口:强制安装的落位失败路径会再次删除已经恢复的旧内容,可能造成插件数据丢失。

审查提交:b3059d1

Comment thread app/adapters/system/plugin/package.py
__place_staged_plugin_content 换入失败与登记失败共用同一个返回形状,
调用方无法区分最终目录是否真的被本次安装改动过。换入子步骤失败时,
__swap_staged_plugin_content 已经把最终目录恢复为换入前状态;
force_install=True 场景下没有临时备份可还原,同步与异步安装流程原先
都会无条件调用失败清理,把刚恢复好的内容又删掉一遍——平铺布局删整个
插件目录,版本化布局删掉目标版本目录。

改用 _PluginContentPlacement 显式带出 swap_committed:换入步骤自身失败
时为 False,调用方不得清理;仅当换入已提交、之后的版本元信息登记才
失败时为 True,沿用既有的按目标类型收敛清理语义。同步与异步两条安装
流程均已按新字段路由。

测试覆盖:平铺布局与版本化布局下换入失败均保持安装前内容逐字节完好、
不触发清理;重装已存在版本目录时换入失败,该版本恢复原内容且不被误删;
换入成功但登记失败时仍按既有语义清理本次版本目录,证明该路径未被改坏;
同步与异步流程各自验证。
scripts/architecture/concurrency.py 报三处新增线程池派发,其中两处是
插件版本化安装本身需要的阻塞调用:PluginPackageManager.__install_flow_async
里的并存检查与内容落位、__async_cleanup_failed_install 里的失败版本
回滚,均为必须离开事件循环执行的同步文件 I/O,与同一文件里其余十余个
async_* 包装方法的既有写法一致,登记进基线。

第三处 init_extra 的插件版本回收不需要独占一次新派发:offload_shutdown_callback
本就是把明确阻塞的同步 owner 包装为线程池执行的异步回调,只是命名和
docstring 局限于关闭步骤;重命名为 offload_blocking_callback 后启动收尾
直接复用同一个包装闭包,不再新增线程池调用点,该文件的并发基线因此
保持不变(仅 owner 键随重命名同步更新)。

顺带修正 __install_flow_async 的 finally 块里 staging_dir 清理仍用同步
shutil.rmtree 的遗留写法,与相邻的 backup_dir 清理保持一致改为
aioshutil.rmtree,消除 async_blocking.py 报出的新增阻塞调用。
@Aqr-K

Aqr-K commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

新提的「回滚误删」成立,已修;CI 的并发所有权失败也已修复。另两条与上一轮指纹相同,代码里已修,说明在下。

回滚误删

这条成立,而且是上一轮两个修复相互作用产生的:换入函数在失败时已经把旧内容原样恢复,但安装流程只看到「落位失败」且「无备份」,仍然调用失败清理,把刚恢复好的内容又删掉。两个修复各自都对,合起来造成数据丢失。

__place_staged_plugin_content 的返回值改为带 swap_committed 的结果对象,把「换入到底提交了没有」这个事实显式带出来。落位解析失败与换入失败都是未提交,调用方直接返回失败、绝不触碰最终目标;换入成功之后的失败才走既有清理。同步与异步两条流程对称改动。

一个边界的判定:换入成功但版本登记失败归为已提交。因为此时目标目录已经真实持有本次安装的新内容,不清理会在插件目录里留下一个未登记的幽灵版本目录。这条路径继续按目标类型收敛清理,语义未变。

新增 5 个用例,其中 3 个用移除修复代码的方式验证过确实会失败,而不是空跑。真实复现场景是「重装一个已存在的版本目录」——那时目标目录换入前就有内容,修复前会把恢复好的内容连同版本登记一起删掉。

并发所有权门禁

三处新增线程池调用未登记。逐处判断后:

安装失败清理的异步包装与异步安装流程里的两处,都是真实阻塞的同步文件 I/O(AST 扫描、os.rename/copytree、JSON 写入),必须离开事件循环,属必要,已登记。顺带说明这两处其实是更早的版本化安装管线提交引入的,基线一直没跟着刷新,这次一并补齐。

启动收尾那处可以避免,没有登记。关闭步骤已有一个「把阻塞 owner 包装成线程池回调」的闭包工厂,只是命名局限在关闭语境。改名后启动收尾直接复用同一个出口,调用点仍只落在该工厂内部,因此不产生新的派发。

顺带修的一处

async_blocking.py 时发现异步安装流程的 finally 里,备份目录清理用了 await aioshutil.rmtree,紧邻的暂存目录清理却是同步 shutil.rmtree,在异步函数里直接阻塞事件循环。该项在本次改动前就是红的,属同批历史提交遗留,已改为与相邻代码一致。

关于重复报出的两条

跨盘回滚(指纹 7dda3754)与 版本误删(指纹 2439ca14)在上一轮已修复,本轮逐行复核确认:换入失败路径现在会先清空半成品目标再换回旧内容,回滚自身失败时保留恢复材料并把回滚异常串在原始异常之后;失败清理已按版本目录收敛。这两条是同指纹重复报出,无需再改。

验证

这次把 CI architecture job 的全部命令逐条跑了一遍(上一轮正是漏跑 concurrency.py 导致红):事件语义门禁、宿主快照、受治理类型、复杂度、并发所有权、async 阻塞、后台任务所有权、进程级服务定位、ruff 与 mypy 棘轮、启动性能契约,全部通过。架构测试 218 passed,全量 8456 passed,唯一失败是本地容器 ctime 精度导致的 test_scanner_invalidates_equal_size_source_with_preserved_mtime,干净基线同样失败。

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR-Agent Code Review

该 PR 将插件安装改为暂存后换入,并引入实例级版本绑定、版本回收及日志上下文。当前仍有一条失败回滚路径会改变安装前的当前版本,可能使跟随实例在安装失败后运行错误版本,建议修正后合入。

审查提交:145aede

Comment thread app/adapters/system/plugin/package.py
版本登记函数在写入版本目录后会乐观地把当前版本置为新版本,早于依赖安装
校验完成;此前失败回滚只拿到要摘除的版本号,无从得知登记前的当前版本,
退化为在剩余版本里挑语义号最高者,导致强制安装失败后当前版本漂移到并非
安装前生效的版本。

现在登记函数在写入前读出当前版本并作为返回值之一穿透给注入的登记端口,
安装流程把它随落位结果一并交给失败清理,回滚端口据此把当前版本精确复原
为登记前的值:登记前为空则复原后同样为空;复原目标在回滚时已不在剩余
版本清单中(理论上不该发生)则置空,不去猜一个可能已与磁盘状态脱节的
版本号。同步与异步两条安装流程共用同一落位与清理实现,语义一致。
@Aqr-K

Aqr-K commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

「当前版本漂移」成立,已修复。

根因是版本登记在写入目录后就把 current 乐观置为新版本,早于依赖安装校验;而回滚端口只收到「要摘除的版本号」,不知道登记前的真实 current,于是在剩余版本里挑语义号最高者——这个值与登记前的 current 在语义上无关,只是某些场景下恰好重合。后果正如你指出的:接口返回失败,但跟随当前版本的实例改跑了另一个版本。

修法是把「登记前的 current」沿调用链穿透到失败清理:登记函数返回 (版本目录名, 登记前的当前版本),落位结果对象带上它,回滚端口多收一个参数,同步与异步两条流程的两个失败分支都传入。回滚时按传入值精确复原,不再猜测。

三种边界的行为:登记前 current 为空时回滚后也为空,不塞入剩余版本;复原目标在剩余清单中缺失时置空而非猜测,docstring 已写明这一兜底;插件此前无任何版本、首个版本安装失败时,删掉唯一版本目录后触发既有的空壳判据,整个插件目录连同刚写的清单一并清理。

新增的两个端到端用例刻意让 current 既非最高也非最低版本,确保验证的是精确复原而不是巧合。自检方式是把源码改动单独抽成补丁反向应用后重跑:其中两个用例给出的是纯语义断言失败(assert '2.0.0' == '1.0.0'assert '4.0.0' == '2.0.0'),与旧的「挑剩余最高版本」行为完全吻合,证明测试真正覆盖了缺陷。

验证:CI architecture job 的全部命令逐条通过,架构测试 218 passed,全量 8461 passed。

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR-Agent Code Review

该 PR 引入插件实例级版本绑定、多版本目录加载、安装暂存回滚,以及实例日志级别和默认目标控制。当前静态兼容性守卫既会误拦可安全运行的插件,也会漏过部分共享数据库基类用法,可能导致升级被无故拒绝或第二版本加载失败。

审查提交:a3f80e9

Comment thread app/runtime/compat/readiness.py
Comment thread app/runtime/compat/readiness.py
from app.db import base as db_base 这类"从包导入子模块"写法此前不进入
模块别名集合,配合 db_base.Base 继承会漏过共享基类阻断;if TYPE_CHECKING:
(含 typing.TYPE_CHECKING 及其别名) body 内运行期不执行的自引用/跨插件绝对
import,此前会被静态扫描误判为阻断项。
@Aqr-K

Aqr-K commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

两条都成立,已修复。这两处一个是假阴性、一个是假阳性,对多版本守卫来说两个方向的错判都会削弱它存在的意义。

共享基类漏检

_collect_base_bindings 原先只识别「导入类本身」(from app.db import Base)与「导入模块」(import app.db.base),漏掉了「从包导入子模块」这一类。判定改为对每个 aliasf"{node.module}.{alias.name}" 与共享基类模块集合比对,因此 from app.db import base as db_basefrom app.db import base 都能记入模块别名。属性链匹配逻辑无需调整,新记入的别名直接被现有前缀匹配消费。

仅类型检查导入误拦

新增仅类型检查分支的识别:收集 TYPE_CHECKING 的本地别名(覆盖 from typing import TYPE_CHECKING as Ximport typing as t 两种),判定 if 条件是否为该判据,收集匹配分支 body 内的节点 id,扫描时跳过。else 分支不在排除范围内,因为那是运行期会执行的。

共享基类判据刻意不接入这个排除,语义保持不变。自引用与跨插件依赖共用同一段处理,因此跨插件依赖在类型检查分支下的同类误拦一并修正。

测试与自检

新增 4 个用例,并用「把源码改动抽成补丁反向应用后重跑」的方式自检:3 个新用例在修复前失败,17 个既有用例仍全部通过,证明既有判定没有被放宽。

顺带做的扫描器全面自查

确认无问题:相对导入已被 node.level == 0 正确排除,在版本目录下本就是安全写法。

已识别但本次未做,列出供参考:多级别名穿透(from app import db as d 后写 d.base.Base)需要把别名结构从扁平集合改成路径映射并重写属性链解析;动态基类表达式(class M(get_base()))是静态分析对动态求值的固有局限;if not TYPE_CHECKING: 取反写法的分支排除;importlib.import_module 对纯字面量 f-string 的常量折叠,该函数被资源扫描子系统共用,改动会牵动两处。这四项都超出本次两条缺陷的边界,且对应写法在插件代码中罕见。

验证

CI architecture job 全部命令逐条通过,架构测试 218 passed,全量 8464 passed。

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR-Agent Code Review

该 PR 为插件引入按实例绑定版本、多版本目录及并行运行能力,并为安装暂存、原子换入、失败回滚和兼容性扫描补充了广泛测试。就当前可见差异而言,未发现可确认的具体行为缺陷,因此没有审查意见。

审查提交:c6dd865

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant