From da327ac3e121a4fe6d8d5dfd5fd2c556454e0dbe Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 16 Aug 2026 18:22:13 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E5=AF=BC=E5=87=BA=E5=8D=95?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E9=87=8D=E8=BD=BD=E5=88=B0=20SDK=20(CORE=5FP?= =?UTF-8?q?LUGIN=5FRELOAD=5FONE/48=20+=20LIST/49=20+=20IS=5FDISABLED/50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PluginManager 接口新增 ReloadOne(name) error - cabi dispatch: case 48 单插件重载, 49 列已加载, 50 查询禁用 - 外部 SDK 新增 PluginMgrAPI(ReloadOne/ListLoadedPlugins/IsPluginDisabled) + bridge dispatchPluginMgr 注入 --- internal/meta/meta.go | 3 +++ internal/plugin/cabi/loader.go | 34 ++++++++++++++++++++++++++++++++++ internal/sdk/plugin.go | 2 ++ 3 files changed, 39 insertions(+) diff --git a/internal/meta/meta.go b/internal/meta/meta.go index 4ed993a..73ed48d 100644 --- a/internal/meta/meta.go +++ b/internal/meta/meta.go @@ -101,4 +101,7 @@ const ( CoreSettingsPlugins = 45 CoreRegisterInputCh = 46 CoreInjectInputSync = 47 + CorePluginReloadOne = 48 + CorePluginListLoaded = 49 + CorePluginIsDisabled = 50 ) diff --git a/internal/plugin/cabi/loader.go b/internal/plugin/cabi/loader.go index 3703465..f42b16a 100644 --- a/internal/plugin/cabi/loader.go +++ b/internal/plugin/cabi/loader.go @@ -887,6 +887,40 @@ func go_core_dispatch(methodID C.int, ctx unsafe.Pointer, s1, s2, s3 *C.char, i1 } s.RegisterInputChannel(a1, chDef) return 0 + + case 48: // CORE_PLUGIN_RELOAD_ONE + if s.PluginMgr() == nil { + setErr(errorOut, fmt.Errorf("plugin manager not available")) + return 1 + } + if err := s.PluginMgr().ReloadOne(a1); err != nil { + setErr(errorOut, err) + return 1 + } + setResult(result, "reloaded: "+a1) + return 0 + + case 49: // CORE_PLUGIN_LIST_LOADED + if s.PluginMgr() == nil { + setErr(errorOut, fmt.Errorf("plugin manager not available")) + return 1 + } + if b, err := json.Marshal(s.PluginMgr().ListLoadedPlugins()); err == nil { + setResult(result, string(b)) + } + return 0 + + case 50: // CORE_PLUGIN_IS_DISABLED + if s.PluginMgr() == nil { + setErr(errorOut, fmt.Errorf("plugin manager not available")) + return 1 + } + if s.PluginMgr().IsPluginDisabled(a1) { + setResult(result, "1") + } else { + setResult(result, "0") + } + return 0 } return 0 } diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index 899b313..2a2e7d8 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -74,6 +74,8 @@ type PluginManager interface { // onRemove 回调(RegisterOnRemoveHandler),最后从注册表移除。目录删除由调用方负责。 RemovePlugin(name string) error ReloadPlugins() (string, error) + // ReloadOne 重载单个插件(停止后重新加载,处理 dlclose/dynamic 句柄)。 + ReloadOne(name string) error PluginMetas() map[string]PluginMeta PluginDir() string } From eb577afed28ef3d95e1316e6b7c57bd2f31a238b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 16 Aug 2026 18:23:55 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20healthcheck=20auto-check=20=E5=B9=82?= =?UTF-8?q?=E7=AD=89=E9=87=8D=E5=90=AF(stopCh=20=E5=8F=AF=E9=87=8D?= =?UTF-8?q?=E5=BB=BA,=20=E4=BF=AE=E5=A4=8D=20plgreload=20=E5=90=8E?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E5=99=A8=E5=A4=B1=E6=95=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/plugins/healthcheck/plugin.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/plugins/healthcheck/plugin.go b/internal/plugins/healthcheck/plugin.go index ea4428e..ff59e41 100644 --- a/internal/plugins/healthcheck/plugin.go +++ b/internal/plugins/healthcheck/plugin.go @@ -49,8 +49,7 @@ type Plugin struct { selfToolNames map[string]bool checkMu sync.Mutex - stopCh chan struct{} - stopOnce sync.Once + stopCh chan struct{} perfData PerfData autoInterval time.Duration @@ -83,6 +82,15 @@ func New(name string) *Plugin { func (p *Plugin) Name() string { return p.name } func (p *Plugin) Start(s *sdk.PluginSDK) error { + // 幂等重启 auto-check:若实例被 Stop 过(stopCh 已关闭)后再次 Start + // (如 plgreload 复用实例),重建 stopCh 使 startAutoCheck 能重新调度 ticker。 + p.mu.Lock() + select { + case <-p.stopCh: + p.stopCh = make(chan struct{}) + default: + } + p.mu.Unlock() s.SetAutoRestart(true) p.autoInterval = 30 * time.Minute p.llmTimeout = 120 * time.Second @@ -280,9 +288,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { } func (p *Plugin) Stop() error { - p.stopOnce.Do(func() { + // 幂等关闭:仅当 stopCh 未被关闭时 close。 + p.mu.Lock() + select { + case <-p.stopCh: + default: close(p.stopCh) - }) + } + p.mu.Unlock() return nil } From 2089d41c0f309ea176ec8cc17ca449d294b47f4e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 16 Aug 2026 18:44:28 +0800 Subject: [PATCH 3/5] =?UTF-8?q?sync:=20=E5=90=8C=E6=AD=A5=20SDK=20PluginMg?= =?UTF-8?q?r=20API=20=E4=B8=8E=20dispatch=2048/49/50=20(vendored=20?= =?UTF-8?q?=E5=89=AF=E6=9C=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- third_party/homeagent-sdk/meta/meta.go | 5 +++++ third_party/homeagent-sdk/sdk/plugin.go | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/third_party/homeagent-sdk/meta/meta.go b/third_party/homeagent-sdk/meta/meta.go index dfa7d67..6b69bb8 100644 --- a/third_party/homeagent-sdk/meta/meta.go +++ b/third_party/homeagent-sdk/meta/meta.go @@ -99,4 +99,9 @@ const ( CoreSettingsDefs = 43 CoreSettingsDump = 44 CoreSettingsPlugins = 45 + CoreRegisterInputCh = 46 + CoreInjectInputSync = 47 + CorePluginReloadOne = 48 + CorePluginListLoaded = 49 + CorePluginIsDisabled = 50 ) diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go index 1378eaf..f3cb0b1 100644 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -147,6 +147,17 @@ type EventSubscriber interface { Subscribe(eventType EventType, handler EventHandler) func() } +// PluginMgrAPI 提供插件管理能力(外部插件可调用)。 +// 由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。 +type PluginMgrAPI interface { + // ReloadOne 重载单个插件(停止后重新加载)。 + ReloadOne(name string) error + // ListLoadedPlugins 列出已加载插件。 + ListLoadedPlugins() []string + // IsPluginDisabled 查询插件是否被禁用。 + IsPluginDisabled(name string) bool +} + // StageScope controls which events a stage handler receives. type StageScope int @@ -200,6 +211,7 @@ type PluginSDK struct { sett SettingsAPI social SocialAPI events EventSubscriber + plgMgr PluginMgrAPI autoRestart bool @@ -347,6 +359,13 @@ func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm } func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social } func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es } +// SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup). +func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI) { s.plgMgr = pm } + +// PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list). +// May be nil if the host did not wire it. +func (s *PluginSDK) PluginMgr() PluginMgrAPI { return s.plgMgr } + // ---- IO Convenience Methods ---- // InjectInterruptText injects a text interrupt that can preempt current LLM processing. From e43b7aa216362e34e806ac74b41a9d6bb544d040 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 16 Aug 2026 18:52:56 +0800 Subject: [PATCH 4/5] =?UTF-8?q?test:=20PluginManager=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=90=AB=20ReloadOne=20=E7=9A=84=E7=BC=96=E8=AF=91=E6=9C=9F?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/plugin/plugin_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index 87b1744..f6d1a73 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -2,6 +2,8 @@ package plugin import ( "testing" + + sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) func TestExtractDescription(t *testing.T) { @@ -195,3 +197,8 @@ A tool with no parameters` t.Errorf("expected no params, got %d", len(props)) } } + +func TestPluginManagerInterfaceReloadOne(t *testing.T) { + // 编译期契约:Registry 必须实现 PluginManager(含 ReloadOne 单插件重载)。 + var _ sdk.PluginManager = (*Registry)(nil) +} From 2338f3f4f3d4a2e646b799c75a299f06f4a93cf3 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 17 Aug 2026 08:53:39 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E6=8F=92=E4=BB=B6=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=E9=87=8D=E8=BD=BD=20+=20clawhubadapter=20ipcGoroutine=20?= =?UTF-8?q?=E4=BC=98=E9=9B=85=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Registry.Reload 改为增量: 对比插件入口文件(plugin.so/main.lua) SHA256, 仅重载有变更的插件, 未变更保持运行, 消除 plgreload 触发全量 StopAll+Load 导致的重复加载与内置插件状态错乱 - Registry 新增 pluginHashes 记录 + pluginEntryHash 辅助 - clawhubadapter.ipcGoroutine 无退出条件导致重载后旧 goroutine 永久残留(线程累积): 增加 stopCh/stopOnce, Stop() 关闭, ipcGoroutine 用 select 监听退出 - 新增 TestRegistryIncrementalReload 验证无变更跳过/变更重载 --- internal/plugin/plugin_test.go | 56 +++++++++++++++++ internal/plugin/registry.go | 75 ++++++++++++++++++++++- internal/plugins/clawhubadapter/plugin.go | 21 +++++-- 3 files changed, 145 insertions(+), 7 deletions(-) diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go index f6d1a73..684f9a6 100644 --- a/internal/plugin/plugin_test.go +++ b/internal/plugin/plugin_test.go @@ -1,8 +1,12 @@ package plugin import ( + "os" + "path/filepath" + "strings" "testing" + internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) @@ -202,3 +206,55 @@ func TestPluginManagerInterfaceReloadOne(t *testing.T) { // 编译期契约:Registry 必须实现 PluginManager(含 ReloadOne 单插件重载)。 var _ sdk.PluginManager = (*Registry)(nil) } + +func TestRegistryIncrementalReload(t *testing.T) { + dir := t.TempDir() + plgDir := filepath.Join(dir, "plugins") + os.MkdirAll(plgDir, 0755) + + // 一个 Lua 插件 + luaDir := filepath.Join(plgDir, "reloaddemo") + os.MkdirAll(luaDir, 0755) + os.WriteFile(filepath.Join(luaDir, "plugin.json"), []byte(`{"name":"reloaddemo","entry":"main.lua"}`), 0644) + writeLua := func(body string) { + os.WriteFile(filepath.Join(luaDir, "main.lua"), []byte(`local plugin = { name = "reloaddemo" } +function plugin.start(sdk) sdk.log("info", "`+body+`") end +function plugin.stop() end +return plugin +`), 0644) + } + writeLua("v1") + + reg := NewRegistry() + reg.SetPluginDir(plgDir) + reg.SetConfigRegistry(internalConfig.NewConfigRegistry("")) + + + // 首次 Reload:应加载 1 个 + msg, err := reg.Reload(plgDir) + if err != nil { + t.Fatalf("first reload: %v", err) + } + if len(reg.List()) != 1 { + t.Fatalf("first reload loaded=%d, want 1 (%s)", len(reg.List()), msg) + } + + // 无变更再 Reload:不应重载(0 changed, 1 unchanged) + msg, err = reg.Reload(plgDir) + if err != nil { + t.Fatalf("second reload: %v", err) + } + if !strings.Contains(msg, "0 plugins") || !strings.Contains(msg, "1 unchanged") { + t.Errorf("unchanged reload should skip: %q", msg) + } + + // 修改 main.lua → 应重载该插件 + writeLua("v2") + msg, err = reg.Reload(plgDir) + if err != nil { + t.Fatalf("changed reload: %v", err) + } + if !strings.Contains(msg, "1 plugins") { + t.Errorf("changed reload should reload 1: %q", msg) + } +} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 2b8cf47..f3a36e6 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -1,6 +1,8 @@ package plugin import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "log" @@ -97,6 +99,10 @@ type Registry struct { knownDisabled map[string]bool allowlist map[string]bool + + // pluginHashes 记录各插件二进制(plugin.so/main.lua)的 SHA256, + // 供增量重载(Reload)对比:仅重载有变更的插件,避免全量 StopAll+Load 导致重复加载。 + pluginHashes map[string]string } func NewRegistry() *Registry { @@ -106,6 +112,7 @@ func NewRegistry() *Registry { pluginAutoRestart: make(map[string]bool), sdkRefs: make(map[string]*sdk.PluginSDK), knownDisabled: make(map[string]bool), + pluginHashes: make(map[string]string), } } @@ -345,6 +352,19 @@ func (r *Registry) isDisabled(name string) bool { return r.cfgReg.IsPluginDisabled(name) } +// pluginEntryHash 计算插件入口文件(plugin.so 或 main.lua)的 SHA256,用于增量重载对比。 +// 无入口文件(内置纯工厂插件)返回空字符串(始终视为已加载)。 +func pluginEntryHash(plgDir string) string { + for _, candidate := range []string{"plugin.so", "plugin.dll", "main.lua", "SKILL.md"} { + path := filepath.Join(plgDir, candidate) + if data, err := os.ReadFile(path); err == nil && len(data) > 0 { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) + } + } + return "" +} + func (r *Registry) loadOne(plgDir, name string) bool { if r.isDisabled(name) { log.Printf("[plugin] %s is disabled, skipping", name) @@ -410,6 +430,11 @@ func (r *Registry) loadOne(plgDir, name string) bool { r.pluginAutoRestart[name] = plgSDK.AutoRestart() r.sdkRefs[name] = plgSDK r.instances = append(r.instances, plg) + if h := pluginEntryHash(plgDir); h != "" { + r.pluginHashes[name] = h + } else { + delete(r.pluginHashes, name) + } r.mu.Unlock() log.Printf("[plugin] loaded: %s", name) return true @@ -445,11 +470,55 @@ func (r *Registry) StopAll() { } func (r *Registry) Reload(dir string) (string, error) { - r.StopAll() - if err := r.Load(dir); err != nil { + if dir == "" { + dir = r.plgDir + } + // 增量重载:扫描插件目录,对比入口文件 hash,仅 Stop+重载有变更的插件。 + // 未变更插件保持运行,避免 plgreload 触发全量 StopAll+Load 导致所有插件重复加载 + // 及内置插件(如 healthcheck)状态机错乱。 + entries, err := os.ReadDir(dir) + if err != nil { return "", err } - return fmt.Sprintf("loaded %d plugins", len(r.instances)), nil + changed := 0 + remaining := 0 + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + if !r.allowlistAllows(name) { + continue + } + plgDir := filepath.Join(dir, name) + h := pluginEntryHash(plgDir) + r.mu.RLock() + old := r.pluginHashes[name] + loaded := r.plugins[name] != nil + r.mu.RUnlock() + // 无入口文件(纯内置工厂插件)始终视为已加载; + // 有变更或首次出现且未加载 → 需要重载。 + if !loaded { + if r.loadOne(plgDir, name) { + changed++ + } + continue + } + if h == "" { + remaining++ + continue + } + if old != h { + if err := r.ReloadOne(name); err != nil { + log.Printf("[plugin] reload %s: %v", name, err) + } else { + changed++ + } + } else { + remaining++ + } + } + return fmt.Sprintf("reloaded %d plugins, %d unchanged", changed, remaining), nil } func (r *Registry) ReloadOne(name string) error { diff --git a/internal/plugins/clawhubadapter/plugin.go b/internal/plugins/clawhubadapter/plugin.go index 8b97431..ac6718e 100644 --- a/internal/plugins/clawhubadapter/plugin.go +++ b/internal/plugins/clawhubadapter/plugin.go @@ -53,6 +53,9 @@ type Plugin struct { sdk *sdk.PluginSDK dispatcher *RegistryDispatcher httpClient *http.Client + + stopCh chan struct{} + stopOnce sync.Once } // pluginSingleton 内核单例引用(Start 时设置),供 SendToChannel/ChannelSender 使用 @@ -65,6 +68,7 @@ func New(name, skillsDir string) *Plugin { } return &Plugin{ name: name, + stopCh: make(chan struct{}), skillsDir: skillsDir, simulatorDir: sd, dispatcher: NewDispatcher(), @@ -300,10 +304,14 @@ func (p *Plugin) ipcGoroutine(s *sdk.PluginSDK) { ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() - for range ticker.C { - p.mu.Lock() - simDir := p.simulatorDir - p.mu.Unlock() + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + p.mu.Lock() + simDir := p.simulatorDir + p.mu.Unlock() if simDir == "" { continue } @@ -339,6 +347,7 @@ func (p *Plugin) ipcGoroutine(s *sdk.PluginSDK) { } os.Remove(reloadPath) } + } } } @@ -1247,6 +1256,10 @@ func (p *Plugin) loadSidecar(s *sdk.PluginSDK, dir, name string) error { func (p *Plugin) Stop() error { p.mu.Lock() defer p.mu.Unlock() + // 关停 ipcGoroutine(stopCh),避免重载后旧 goroutine 残留导致线程累积 + p.stopOnce.Do(func() { + close(p.stopCh) + }) for _, sp := range p.sidecars { sp.Close() }