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 目录被清除、无残留进程。
This commit is contained in:
JianFeeeee
2026-09-13 11:38:03 +08:00
parent aba1770701
commit 6e6035141a
11 changed files with 89 additions and 13 deletions

View File

@ -174,9 +174,23 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
a.residentMu.Unlock()
child.Start()
// ⑥ create 即开工:把任务提示词作为**第一条排队输入**投给子。
//
// 为什么必须在这里投TaskPrompt 只进子的系统提示词("你是谁、要做什么"
// 而**不会**让子跑起来 —— 实测现象是子启动后 rounds=0、永远待机
// (日志 `[agent] r1 started, waiting for IO interrupts` 之后无事发生)。
// 走排队输入(非中断):创建是"安排工作",不是"打断它正在做的事"。
if strings.TrimSpace(opts.TaskPrompt) != "" {
child.io.InjectInputTo(a.residentParentSource(), parentInCh, "text",
map[string]interface{}{"content": opts.TaskPrompt})
}
return rc.info(), nil
}
// residentParentSource 是"父给子投递"的输入来源名(子的视角里能看出是谁发的)。
func (a *Agent) residentParentSource() string { return "parent/" + string(a.id) }
// residentInboundChannel 是"父接收某个子的消息"的 inputch 名(登记进登记表可见)。
func (a *Agent) residentInboundChannel(childID string) string {
ch := "child/" + childID

View File

@ -201,18 +201,30 @@ func TestResident_InputchTableAutoAndProactive(t *testing.T) {
spawnTestResident(t, parent, dir, "child-1")
child := parent.residents["child-1"].agent
// 说明create 会把任务提示词作为**第一条输入**投给子("create 即开工"
// 所以这里先等那一轮写完 —— 表里每多一轮就多一条,正是"每轮必有记录"。
waitFor(t, "任务提示词那一轮写入", func() bool {
table, err := parent.ResidentTable("child-1")
return err == nil && len(table) >= 1
})
base, err := parent.ResidentTable("child-1")
if err != nil {
t.Fatal(err)
}
n := len(base)
// ① 子不主动写 ⇒ 系统自动写(每一轮必有记录)。
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "干活"})
waitFor(t, "自动写处理表", func() bool {
table, err := parent.ResidentTable("child-1")
return err == nil && len(table) == 1 && !table[0].Proactive
return err == nil && len(table) == n+1 && !table[n].Proactive
})
table, err := parent.ResidentTable("child-1")
if err != nil {
t.Fatal(err)
}
if table[0].InputCh != "sub/in" {
t.Fatalf("处理表应记本轮 inputch实际 %q", table[0].InputCh)
if table[n].InputCh != "sub/in" {
t.Fatalf("处理表应记本轮 inputch实际 %q", table[n].InputCh)
}
// ② 子主动写 ⇒ 本轮不再自动写。
@ -222,7 +234,7 @@ func TestResident_InputchTableAutoAndProactive(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(table) != 2 || !table[1].Proactive || !strings.Contains(table[1].Text, "第一阶段") {
if len(table) != n+2 || !table[n+1].Proactive || !strings.Contains(table[n+1].Text, "第一阶段") {
t.Fatalf("主动写优先的语义不成立:%+v", table)
}
}
@ -428,6 +440,12 @@ func TestLightKernel_TraditionalContextNoTrimming(t *testing.T) {
t.Fatal("驻留子必须被识别为轻量内核")
}
// 先等"create 即开工"那一轮(任务提示词)跑完,否则下面抓到的是它的请求,
// 而不是我们注入了大段上下文之后的那一轮。
waitFor(t, "任务提示词那一轮结束", func() bool {
table, err := parent.ResidentTable("child-1")
return err == nil && len(table) >= 1
})
// 前提:动态上下文的份额 < 窗口(否则测不出区别)。
b := ComputeTokenBudget(child.provider, child.systemPrompt)
if b.MaxContext <= b.ContextTokens {
@ -445,9 +463,10 @@ func TestLightKernel_TraditionalContextNoTrimming(t *testing.T) {
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "本轮输入"})
waitFor(t, "子发出 LLM 请求", func() bool {
// 等**新的一轮**请求(首轮可能已经发过,必须严格等到注入之后那次)。
waitFor(t, "子发出新一轮 LLM 请求", func() bool {
n, _ := provider.chatText()
return n >= 1
return n >= 2
})
_, got := provider.chatText()
if !strings.Contains(got, "最早的事件标记EARLY") {

View File

@ -7,6 +7,7 @@ package core
import (
"fmt"
"log"
"path/filepath"
"strings"
@ -57,10 +58,20 @@ func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string {
id := strArg(tc, "id")
tempPath := strArg(tc, "temp_path")
if tempPath == "" {
if a.dataDir == "" {
return "创建驻留子需要 data_dir 或显式 temp_path"
anchor := a.dataDir
if anchor == "" {
// 兜底:从**主图库路径**推导(<data>/memory/graph.db ⇒ <data>)。
// 为什么不静默失败:这条路径只在"配置漏接线"时走到,
// 静默报错会让线上表现为"工具能调但永远建不出来"(实测就是这样)。
if a.memory != nil && a.memory.Path() != "" {
anchor = filepath.Dir(filepath.Dir(a.memory.Path()))
log.Printf("[resident] data_dir 未接线,回退到主图库目录: %s", anchor)
}
}
tempPath = filepath.Join(a.dataDir, "residents", id, "graph.db")
if anchor == "" {
return "创建驻留子需要 data_dir 或显式 temp_path内核未接线 DataDir"
}
tempPath = filepath.Join(anchor, "residents", id, "graph.db")
}
info, err := a.SpawnResident(ResidentOptions{
ID: id,