feat(plugin): 插件实例绑定版本,多实例并行运行不同版本 - #6566
Conversation
There was a problem hiding this comment.
PR-Agent Code Review
本 PR 将插件安装改为暂存后换入,并加入版本目标解析、版本登记及多版本实例支持。当前回滚逻辑在跨文件系统复制失败和强制安装失败时可能删除已有插件版本,存在高风险的数据丢失问题。
审查提交:80eb743
新增 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 里的边数快照。
80eb743 to
b3059d1
Compare
|
两条 high 都成立,已修复;CI 的架构基线失败与代码无关,已随变基解决。 跨盘回滚确认成立。跨设备退化为 改为显式状态机: 测试覆盖:跨设备复制失败后旧内容逐字节完好且无残留、目标此前不存在时不留垃圾、原子改名成功路径不受影响、回滚自身失败时保留恢复材料且原始异常照常抛出。 版本误删确认成立,而且这是多版本引入后新产生的风险——单版本时代整根删除是合理的。
测试覆盖:已有版本 A 与 B、B 被实例绑定,安装 C 失败后 A、B 与 一处如实说明: CI 架构基线失败失败原因是官方在本 PR 变基后又推了 本次一并做的两项插件实例模型改名为 插件列表投影新增三个只读字段 验证架构门禁 218 passed, |
There was a problem hiding this comment.
PR-Agent Code Review
此 PR 引入插件实例版本绑定、多版本目录布局及暂存后换入的安装流程。整体回滚设计仍有一处高风险缺口:强制安装的落位失败路径会再次删除已经恢复的旧内容,可能造成插件数据丢失。
审查提交:b3059d1
__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 报出的新增阻塞调用。
|
新提的「回滚误删」成立,已修;CI 的并发所有权失败也已修复。另两条与上一轮指纹相同,代码里已修,说明在下。 回滚误删这条成立,而且是上一轮两个修复相互作用产生的:换入函数在失败时已经把旧内容原样恢复,但安装流程只看到「落位失败」且「无备份」,仍然调用失败清理,把刚恢复好的内容又删掉。两个修复各自都对,合起来造成数据丢失。
一个边界的判定:换入成功但版本登记失败归为已提交。因为此时目标目录已经真实持有本次安装的新内容,不清理会在插件目录里留下一个未登记的幽灵版本目录。这条路径继续按目标类型收敛清理,语义未变。 新增 5 个用例,其中 3 个用移除修复代码的方式验证过确实会失败,而不是空跑。真实复现场景是「重装一个已存在的版本目录」——那时目标目录换入前就有内容,修复前会把恢复好的内容连同版本登记一起删掉。 并发所有权门禁三处新增线程池调用未登记。逐处判断后: 安装失败清理的异步包装与异步安装流程里的两处,都是真实阻塞的同步文件 I/O(AST 扫描、 启动收尾那处可以避免,没有登记。关闭步骤已有一个「把阻塞 owner 包装成线程池回调」的闭包工厂,只是命名局限在关闭语境。改名后启动收尾直接复用同一个出口,调用点仍只落在该工厂内部,因此不产生新的派发。 顺带修的一处跑 关于重复报出的两条
验证这次把 CI architecture job 的全部命令逐条跑了一遍(上一轮正是漏跑 |
There was a problem hiding this comment.
PR-Agent Code Review
该 PR 将插件安装改为暂存后换入,并引入实例级版本绑定、版本回收及日志上下文。当前仍有一条失败回滚路径会改变安装前的当前版本,可能使跟随实例在安装失败后运行错误版本,建议修正后合入。
审查提交:145aede
版本登记函数在写入版本目录后会乐观地把当前版本置为新版本,早于依赖安装 校验完成;此前失败回滚只拿到要摘除的版本号,无从得知登记前的当前版本, 退化为在剩余版本里挑语义号最高者,导致强制安装失败后当前版本漂移到并非 安装前生效的版本。 现在登记函数在写入前读出当前版本并作为返回值之一穿透给注入的登记端口, 安装流程把它随落位结果一并交给失败清理,回滚端口据此把当前版本精确复原 为登记前的值:登记前为空则复原后同样为空;复原目标在回滚时已不在剩余 版本清单中(理论上不该发生)则置空,不去猜一个可能已与磁盘状态脱节的 版本号。同步与异步两条安装流程共用同一落位与清理实现,语义一致。
|
「当前版本漂移」成立,已修复。 根因是版本登记在写入目录后就把 修法是把「登记前的 三种边界的行为:登记前 新增的两个端到端用例刻意让 验证:CI architecture job 的全部命令逐条通过,架构测试 218 passed,全量 8461 passed。 |
There was a problem hiding this comment.
PR-Agent Code Review
该 PR 引入插件实例级版本绑定、多版本目录加载、安装暂存回滚,以及实例日志级别和默认目标控制。当前静态兼容性守卫既会误拦可安全运行的插件,也会漏过部分共享数据库基类用法,可能导致升级被无故拒绝或第二版本加载失败。
审查提交:a3f80e9
from app.db import base as db_base 这类"从包导入子模块"写法此前不进入 模块别名集合,配合 db_base.Base 继承会漏过共享基类阻断;if TYPE_CHECKING: (含 typing.TYPE_CHECKING 及其别名) body 内运行期不执行的自引用/跨插件绝对 import,此前会被静态扫描误判为阻断项。
|
两条都成立,已修复。这两处一个是假阴性、一个是假阳性,对多版本守卫来说两个方向的错判都会削弱它存在的意义。 共享基类漏检
仅类型检查导入误拦新增仅类型检查分支的识别:收集 共享基类判据刻意不接入这个排除,语义保持不变。自引用与跨插件依赖共用同一段处理,因此跨插件依赖在类型检查分支下的同类误拦一并修正。 测试与自检新增 4 个用例,并用「把源码改动抽成补丁反向应用后重跑」的方式自检:3 个新用例在修复前失败,17 个既有用例仍全部通过,证明既有判定没有被放宽。 顺带做的扫描器全面自查确认无问题:相对导入已被 已识别但本次未做,列出供参考:多级别名穿透( 验证CI architecture job 全部命令逐条通过,架构测试 218 passed,全量 8464 passed。 |
There was a problem hiding this comment.
PR-Agent Code Review
该 PR 为插件引入按实例绑定版本、多版本目录及并行运行能力,并为安装暂存、原子换入、失败回滚和兼容性扫描补充了广泛测试。就当前可见差异而言,未发现可确认的具体行为缺陷,因此没有审查意见。
审查提交:c6dd865
背景
插件分身当前只能跟随源插件的当前版本运行。
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 工具会话建立时逐实例物化)都已是具体实例,不需要裁决,未接入。新增端点
均要求超级管理员:
/plugin/versions/{plugin_id}/plugin/versions/{plugin_id}/{instance_id}/plugin/versions/{plugin_id}/recycle/plugin/loglevel/{plugin_id}/plugin/loglevel/{plugin_id}/{instance_id}/plugin/instances/{plugin_id}/{instance_id}/default_target全部落在新文件
app/api/endpoints/pluginversion.py,app/api/endpoints/plugin.py净增量为 0(它恰好卡在complexity.py --v2的 1000 行阈值上)。Agent 契约(operation specs、MCP 描述、api_mcp_schema、surface audit、SKILL 文档)均用仓库既有生成脚本重跑同步。兼容性
测试
新增约 200 个用例,覆盖:版本目录双射与清单权威性、存量布局迁移(含中断续做、跨设备放弃)、四条安装路径落盘与失败逐字节回滚、三类阻断的检出与建议生成、启动选版本三种回退、并存判定、版本回收的保留判据与三重校验与引用集合收集失败上抛、实例表迁移与兜底导入幂等、本体与分身视图隔离、日志等级的过期回落与四类入口绑定与跨实例不串扰、条件唯一索引在两种方言下的谓词编译与真实拒绝第二条默认目标、裁决的三条规则。
全量 8435 passed。架构门禁 218 passed;
baseline.py --check-host、ruff / mypy / complexity 棘轮、启动性能契约均通过。已知限制
test_db_engine_postgresql.py现状一致),真实 PG 上的 schema 与条件索引行为建议合并前验证一次。PR-Agent 摘要
🤖 Generated by PR Agent at c6dd865