refactor(timer): 从内核内置插件迁为外部插件,移除编译期注册

老大要求「把 timer 移到外部插件,移除内核的内置 timer」。本次只做内核侧
的**摘除**,插件本体在外部工程(plugins/timer → .hmap 安装)。

timer 原先由 internal/plugins/timer 的 init() 注册进编译期工厂表。
后果是改一行定时器逻辑要重编整个 homed(87MB)并重启内核——而它恰是
最该独立演进的部件:有自己的持久化状态、触发语义、并发模型,却被绑死
在主进程的发版节奏上。

搬出来后:换实现只需重装插件 + 重启单个插件进程;插件崩了自己被拉起,
不影响 agent 主循环。

1. 删除 internal/plugins/timer(plugin.go + plugin_test.go)
2. internal/plugins/all.go 去掉该 import,并写明**不要加回去**的原因
3. internal/plugins/integration_test.go 的 expectedTools 去掉 timer_set

TestIntegrationAllPluginsRegister 的语义是「编译期内置插件是否都注册了工具」,
它走的是本地装配(setupIntegration 只加载 internal/plugins 下的内置包)。
timer 搬走后不再参与这个进程的装配,留在列表里只会造出永远绿不了的假失败。

真正不该省的是验证:外部插件的工具是否真注册成功,靠装完 .hmap 后实调
timer_set / timer_list / timer_cancel 来确认(见交付报告)。

- 配置表:外部插件同用 config_<name> ⇒ config_timer,老值 max_duration /
  pending_timers 直接读回,老大已设的定时器无需迁移
- 工具名:timer_set / timer_list / timer_cancel 不变,模型侧零感知
- 注入源与通道:source/channel 仍是 "timer"
- 注入级别:内置插件可声明 L4,外部插件被夹到 L3;本插件本就要 L3,
  故行为等价

StageHost.RegisterTool 对同名工具返回 `already registered`,而插件注册是
批量失败(新实例的工具一个都注不上)。所以必须**先装新内核**(不含内置
timer)再装外部 timer 插件,反之会撞名。

(cherry picked from commit f584776f23c3bfd8fb2892155e6018a1ec57eac0)
This commit is contained in:
JianFeeeee
2026-10-19 14:11:20 +08:00
committed by JianFeeeee
parent c53d1da2a6
commit 6b64d69b10
3 changed files with 32 additions and 144 deletions

View File

@ -1,5 +1,26 @@
package plugins
// 编译期内置插件清单。
//
// ★ 顺序无关(各插件在 init() 里自注册),但**排序保持一致**:
// 这样 diff 里能一眼看出增删,而不是被 import 重排掩盖。
//
// ## timer 已移出本清单(2026-10-19)
//
// timer 原先是这里的编译器内置插件(internal/plugins/timer + RegisterFactory),
// 后果是改一行定时器逻辑要重编整个 homed(87MB)并重启内核 ——
// 而它是**最该独立演进**的部件之一:有自己的持久化状态、自己的触发语义、
// 自己的并发模型,却被绑死在主进程的发版节奏上。
//
// 现在它作为**外部插件**(plugins/timer,.hmap 安装)运行:
// - 换实现只需重装插件 + 重启单个插件进程,不动主进程;
// - 插件崩了自己被拉起,不影响 agent 主循环;
// - 工具名 / 配置表(config_timer)/ 注入通道(timer)保持不变,
// 模型侧与已设的定时器都无需迁移。
//
// ⚠️ 不要再把 timer 加回这里:父子两份同时存在会导致同名工具注册冲突,
// 而「谁赢」取决于加载顺序 —— 那是最难排查的一类故障。
import (
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/agentcli"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/ai_image"
@ -17,6 +38,5 @@ import (
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/seemedia"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/seq"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/skillmgr"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/timer"
_ "github.com/JianFeeeee/HomeAgent/internal/plugins/webui"
)

View File

@ -165,7 +165,17 @@ func TestIntegrationAllPluginsRegister(t *testing.T) {
"terminal_create", "terminal_write", "terminal_read",
"terminal_resize", "terminal_close", "terminal_list",
"healthcheck", "healthcheck_plugins", "healthcheck_tools", "healthcheck_memory", "healthcheck_perf",
"timer_set",
// ⚠️ timer_set **不在**这里(2026-10-19 起)。
//
// 本用例的语义是「编译期内置插件是否都注册了工具」,set 走的是本地
// 装配(setupIntegration 只加载 internal/plugins 下的内置包)。
// timer 已搬为**外部插件**(plugins/timer,.hmap),不参与本进程装配,
// 所以它出现在这个列表里只会造出「永远绿不了」的假失败。
//
// 外部插件的工具是否真的注册成功,由另一层负责:装完 .hmap 后
// 用 timer_list / timer_set 实调一次(见交付报告里的现网验证)。
// 别为了「让它绿」把 timer 加回 all.go —— 那会让内置与外部两份
// 同时存在,工具重名而胜者取决于加载顺序。
}
defs := env.stageHost.GetToolDefs()

View File

@ -1,142 +0,0 @@
package timer
import (
"fmt"
"log"
"sync"
"time"
"github.com/JianFeeeee/HomeAgent/internal/plugin"
safeclose "github.com/JianFeeeee/HomeAgent/internal/safeclose"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
func init() {
plugin.RegisterPluginMeta("timer", "定时任务", "Timer")
plugin.RegisterFactory("timer", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
}
type Plugin struct {
name string
mu sync.Mutex
wg sync.WaitGroup
stopCh chan struct{}
maxDur time.Duration
}
type timerTask struct {
id int
dur time.Duration
message string
doneAt time.Time
s *sdk.PluginSDK
}
func New(name string) *Plugin {
return &Plugin{name: name, stopCh: make(chan struct{})}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.maxDur = 24 * time.Hour
// 停止清理(取消倒计时)交由 stop handler:内核在调用 Stop() 之前执行。
// 幂等(本仓 close 纪律):stop handler 由内核调用;若将来多一条
// 停机路径(重载/热更新)就会二次 close ⇒ panic。
s.RegisterStopHandler(safeclose.SafeFunc(p.stopCh))
// 删除清理:移除插件自身配置(删除专用回调,重载不触发)。
s.RegisterOnRemoveHandler(func() {
if err := s.Settings().Remove("max_duration"); err != nil {
log.Printf("[timer] onRemove cleanup: %v", err)
}
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "max_duration", Type: "string", DisplayName: "最大定时时长",
Description: "允许设置的最大定时时长,例如 24h, 7d, 1h(默认 24h)",
Default: "24h",
})
if v, _ := s.Settings().Get("max_duration"); v != nil {
if s, ok := v.(string); ok && s != "" {
if d, err := time.ParseDuration(s); err == nil && d > 0 {
p.maxDur = d
}
}
}
// timer 通道:定时器到点经它注入 agent(见本文件 InjectInterruptTextOpts 调用)。
_ = s.RegisterInputChannel("timer", sdk.ChannelDef{})
s.RegisterTool("timer_set", sdk.ToolDef{
Name: "timer_set",
Description: "设置一个定时提醒。倒计时结束后通过中断通道通知 agent。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"duration": map[string]interface{}{
"type": "string",
"description": "持续时间,例如 5s, 2m, 1h",
},
"message": map[string]interface{}{
"type": "string",
"description": "提醒内容",
},
},
"required": []string{"duration", "message"},
},
// 写定时器:改 p.timers(持 p.mu),按序更可预期
Serial: true,
}, func(args map[string]interface{}) (interface{}, error) {
durStr, _ := args["duration"].(string)
message, _ := args["message"].(string)
if durStr == "" {
return map[string]interface{}{"error": "duration is required"}, nil
}
if message == "" {
return map[string]interface{}{"error": "message is required"}, nil
}
dur, err := time.ParseDuration(durStr)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("invalid duration %q: %v", durStr, err)}, nil
}
if dur > p.maxDur {
return map[string]interface{}{"error": fmt.Sprintf("duration %v exceeds max %v", dur, p.maxDur)}, nil
}
p.mu.Lock()
p.wg.Add(1)
p.mu.Unlock()
go func() {
defer p.wg.Done()
select {
case <-time.After(dur):
log.Printf("[timer] firing: %s (%s later)", message, dur)
// NoMemory:定时提醒是系统通知,不是记忆内容。
// PriorityL3:定时器是“时钟那种实时工作”——到点就该处理,
// 比 QQ 那类可无限等待的异步消息高(L3 vs L1)。
s.InjectInterruptTextOpts("timer", "timer", fmt.Sprintf("timer: %s", message),
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL3})
case <-p.stopCh:
log.Printf("[timer] cancelled: %s", message)
}
}()
doneAt := time.Now().Add(dur)
return map[string]interface{}{
"status": "timer_set",
"duration": durStr,
"message": message,
"done_at": doneAt.Format(time.RFC3339),
}, nil
})
return nil
}
func (p *Plugin) Stop() error {
p.wg.Wait()
return nil
}