Files
HomeAgent/internal/plugins/timer/plugin.go
JianFeeeee 6e6035141a fix(resident/plugin): 二进制级压测暴露的三个真问题(DataDir 漏接线 / inputch 未登记 / create 不开工)+ 通道双向登记贯穿全部内建插件
在真实内核二进制(私有 netns + mock LLM + CLI unix socket)上做压力测试时,
下面三个问题**只有跑真二进制才暴露** —— 单元测试里都显式传了参数、没走插件加载,
所以全绿也照样漏。

## ① 根 agent 的 DataDir 没接线 ⇒ 驻留子永远建不出来

现象:模型调用 `resident_agents` 成功,但结果是
`创建驻留子需要 data_dir 或显式 temp_path`。
根因:`cmd/homed/main.go` 构造 AgentConfig 时没有 `DataDir`,
而驻留子的 temp 图库需要 `<data>/residents/<id>/graph.db` 这个锚点。
(单测里 `AgentConfig{DataDir: dir}` 显式给了,所以测不出来。)

修:main.go 接线 `DataDir: cfg.Daemon.DataDir`;并在工具层加**兜底 + 告警** ——
data_dir 为空时从主图库路径反推(`<data>/memory/graph.db` ⇒ `<data>`),
失败才报错。静默失败会让线上表现成"工具能调但永远建不出来"。

## ② 插件通道没登记为 inputch ⇒ "划入 inputch"必然失败

现象:`划入 inputch cli: inputch 未注册`。
根因:`cli` 插件只调 `RegisterOutputChannel("cli", ...)`,却用同一个名字
`InjectTextSync("cli", ...)` 注入输入 —— 内核 inputch 登记表里根本没有它。
(实测审计:内建 6 个插件里只有 0 个登记过入站通道;SDK 示例里只有 qq/weather 是对的。)

修两处:
- **全部内建插件显式登记入站通道**:`cli`/`agentcli`/`timer`/`webui`(+`http`, NoMemory)/
  `clawhubadapter`(每个 OC 通道声明处)/`remotedevice`(`device/<id>` 懒登记,幂等)。
- `registry.go` 把隐式兜底改成**留痕的兼容网**:只有当该名字还没登记为 inputch 时
  才兜底登记,并打日志说明"建议显式 RegisterInputChannel"。
  实测:改完内建插件后,启动日志里兜底告警 **0 次**。

## ③ create 之后子不开工 ⇒ rounds 恒为 0

现象:`[agent] r1 started, waiting for IO interrupts` 之后什么都没有,登记表里 rounds=0。
根因:`TaskPrompt` 只进了子的**系统提示词**,从没作为输入投给子。
修:create 即开工 —— 把任务提示词作为**第一条排队输入**投给子(排队而非中断:
创建是"安排工作",不是"打断它正在做的事")。

## 测试

- `TestResident_InputchTableAutoAndProactive` / `TestLightKernel_TraditionalContextNoTrimming`
  随行为更新:create 会多跑一轮(任务提示词那轮也会写处理表),
  断言改为"以创建时的表长为基线 + 等待新的一轮"。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(agent/plugin/plugins)干净。

## 真实二进制压力测试结果(修复后)

私有 netns 里跑 mock LLM + 内核,用 CLI socket 驱动多并发连接:
- 密集:16 连接×12 输入 + 4 线程×20 次 L4 中断 → **274 任务 executed=274 / rejected=0 / errors=0**,
  峰值排队 15、峰值待处理中断 76;
- 稀疏(中断每 3s 一次,压在排队任务的流式段上)→ **suspended=27 / resumed=27 / preempted=27**;
- 驻留子全链路:父建子(inputchs=["cli"])→ 子开工 → 子 `notify_parent` → 父侧收到
  `interrupt from r1/child/r1`(L3,且父被抢占 suspended/resumed=1);
- 优雅退出:SIGTERM 后驻留子 temp 目录被清除、无残留进程。
2026-09-13 11:38:03 +08:00

138 lines
3.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package timer
import (
"fmt"
"log"
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.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() 之前执行。
s.RegisterStopHandler(func() { close(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"},
},
}, 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
}