diff --git a/internal/plugins/pluginmgr/plugin.go b/internal/plugins/pluginmgr/plugin.go index 802002df..7308d541 100644 --- a/internal/plugins/pluginmgr/plugin.go +++ b/internal/plugins/pluginmgr/plugin.go @@ -733,6 +733,19 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro } if existing && overwrite { + // ★ 先记住升级前是否在运行:StopAndUnload 会把它停掉,而升级不该 + // 顺手改变运行状态。生产实测(2026-10-20):重装 qq 后它静默停着, + // agent 发现工具全没了,只能 get_plugin_tools×N → plugin_start 摸回来。 + wasLoaded := false + if p.sdk != nil && p.sdk.PluginMgr() != nil { + for _, rt := range p.sdk.PluginMgr().ListPluginRuntimes() { + if rt.Name == pkg.Name && rt.Loaded { + wasLoaded = true + break + } + } + } + // 原地更新:停旧实例(保留配置表),备份旧目录,解压新包,失败回滚。 if p.sdk != nil && p.sdk.PluginMgr() != nil { if err := p.sdk.PluginMgr().StopAndUnload(pkg.Name); err != nil { @@ -771,6 +784,17 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro // 失败不阻断 —— skill 登记是附带收益,插件本身已装好。 skillN, skillFailed := p.handOutPluginSkills(target, pkg.Name) + // 升级前在跑的插件,升级后自动拉回来(运行状态不该被"换个包"改变)。 + // 失败不阻断安装:返回里如实写 running_restored=false,调用方可见。 + runningRestored := false + if wasLoaded && p.sdk != nil && p.sdk.PluginMgr() != nil { + if err := p.sdk.PluginMgr().StartPlugin(pkg.Name); err != nil { + log.Printf("[pluginmgr] 升级后恢复运行 %s 失败: %v(插件已安装,可手动 plugin_start)", pkg.Name, err) + } else { + runningRestored = true + } + } + checksum := fmt.Sprintf("%x", sha256.Sum256(data)) action := "upgraded" if cmpVersion(pkg.Version, oldVersion) < 0 { @@ -788,6 +812,8 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro "action": action, "reload_required": true, "config_kept": true, + "was_running": wasLoaded, + "running_restored": runningRestored, "skills_registered": skillN, "skills_failed": skillFailed, }, nil diff --git a/internal/plugins/pluginmgr/upgrade_test.go b/internal/plugins/pluginmgr/upgrade_test.go index e1590414..c7f93cd6 100644 --- a/internal/plugins/pluginmgr/upgrade_test.go +++ b/internal/plugins/pluginmgr/upgrade_test.go @@ -68,6 +68,8 @@ func (m *pmSettings) IsSecret(string) bool { return false } type fakePluginMgr struct { mu sync.Mutex stopAndUnloads []string + loaded map[string]bool // 升级前"在跑"的插件,供 ListPluginRuntimes 返回 + starts []string } func (f *fakePluginMgr) ListLoadedPlugins() []string { return nil } @@ -77,7 +79,12 @@ func (f *fakePluginMgr) IsBuiltinPlugin(string) bool { return func (f *fakePluginMgr) DisablePlugin(string, string) error { return nil } func (f *fakePluginMgr) EnablePlugin(string) error { return nil } func (f *fakePluginMgr) StopPlugin(string) error { return nil } -func (f *fakePluginMgr) StartPlugin(string) error { return nil } +func (f *fakePluginMgr) StartPlugin(name string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.starts = append(f.starts, name) + return nil +} func (f *fakePluginMgr) IsPluginRuntimeStopped(string) bool { return false } func (f *fakePluginMgr) RemovePlugin(string) error { return nil } func (f *fakePluginMgr) StopAndUnload(name string) error { @@ -95,7 +102,15 @@ func (f *fakePluginMgr) PluginDir() string { return "" } func (f *fakePluginMgr) PluginRuntime(string) (sdk.PluginRuntimeInfo, bool) { return sdk.PluginRuntimeInfo{}, false } -func (f *fakePluginMgr) ListPluginRuntimes() []sdk.PluginRuntimeInfo { return nil } +func (f *fakePluginMgr) ListPluginRuntimes() []sdk.PluginRuntimeInfo { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]sdk.PluginRuntimeInfo, 0, len(f.loaded)) + for name, loaded := range f.loaded { + out = append(out, sdk.PluginRuntimeInfo{Name: name, Loaded: loaded}) + } + return out +} // buildHmap 构造一个最小 .hmap 包。 func buildHmap(t *testing.T, name, version string) []byte { @@ -234,3 +249,65 @@ func TestExtractFailureRollsBack(t *testing.T) { t.Fatalf("old version not restored: %v / %v", mfest, err) } } + +// ★ 升级不该顺手改变运行状态(2026-10-20 生产实证)。 +// +// 现场:重装 qq 插件后它静默停着(StopAndUnload 只停不启),agent 发现工具 +// 全没了,只能 get_plugin_tools ×N → plugin_start 摸回来。安装流程的语义应当是 +// "换个包",不是"把正在用的插件关掉"。 +func TestUpgradeRestoresRunningState(t *testing.T) { + dir := t.TempDir() + fm := &fakePluginMgr{loaded: map[string]bool{}} + bus := events.NewBus() + sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{ + Settings: &pmSettings{}, + EventBus: bus, + PluginMgr: fm, + }) + p := &Plugin{pluginDir: dir, sdk: sdkInst} + + if r, _ := p.installFromData(buildHmap(t, "demo", "1.0.0"), false); r.(map[string]interface{})["status"] != "installed" { + t.Fatal("install failed") + } + + // 升级前它是在跑的 + fm.mu.Lock() + fm.loaded["demo"] = true + fm.mu.Unlock() + + r, _ := p.installFromData(buildHmap(t, "demo", "2.0.0"), true) + m := r.(map[string]interface{}) + if m["was_running"] != true { + t.Fatalf("was_running 应为 true: %v", m) + } + if m["running_restored"] != true { + t.Fatalf("升级后应自动拉回运行状态: %v", m) + } + fm.mu.Lock() + starts := append([]string{}, fm.starts...) + fm.mu.Unlock() + if len(starts) != 1 || starts[0] != "demo" { + t.Fatalf("StartPlugin 应被调用一次: %v", starts) + } +} + +// 升级前没在跑的插件,安装后也不该被强行拉起。 +func TestUpgradeDoesNotStartStoppedPlugin(t *testing.T) { + dir := t.TempDir() + fm := &fakePluginMgr{loaded: map[string]bool{}} + bus := events.NewBus() + sdkInst := sdk.New("pluginmgr", sdk.SDKConfig{Settings: &pmSettings{}, EventBus: bus, PluginMgr: fm}) + p := &Plugin{pluginDir: dir, sdk: sdkInst} + + if r, _ := p.installFromData(buildHmap(t, "demo", "1.0.0"), false); r.(map[string]interface{})["status"] != "installed" { + t.Fatal("install failed") + } + r, _ := p.installFromData(buildHmap(t, "demo", "2.0.0"), true) + m := r.(map[string]interface{}) + if m["was_running"] != false || m["running_restored"] != false { + t.Fatalf("未运行的插件不应被拉起: %v", m) + } + if len(fm.starts) != 0 { + t.Fatalf("StartPlugin 不应被调用: %v", fm.starts) + } +}