docs: 全面按当前源码更新文档 + 删除已过时文档

## 删除(内容已落地/已被替换,保留只会误导)
- demo.md ................... failback 与 recoverydiag 均已实现,0 引用
- docs/defect-qq-output-send-loop.md .. 已修复(本身也标了「已修复」),0 引用
- docs/embedding-comparison.md ....... 一次性选型报告,仅被 agent 产物引用
- docs/zh/plan.md ............ 描述的旧 nav 布局已重写、死配置已清,全部完成
- docs/zh/plugin-migration-plan.md ... 迁移已上生产,纯过程稿(Part 0~6 全完成)

## 更新(按当前源码核对)
- assets/docs/{zh,en}/ARCHITECTURE.md(README 指向的用户文档,最重要):
  把只讲 cancel/intercept 的旧「中断机制」章节重写为「输入调度器与中断机制」——
  补上两类别 + 四级中断(L1~L4,默认 L1、外部插件 L4 夹到 L3)+ 抢占/挂起/中断栈
  + 饥饿防护(PreemptCount 提升,封顶 L4)+ 抢占冷却(2s)+ 停止语义(cancelBudget)
  + 驻留子/分诊助手/残余任务;新增「上下文预算」章节(窗口 ≠ 工作面,600K 封顶,
  预算是上限非填充目标)。中英章节数现已对齐(各 13 节)。
- assets/docs/{zh,en}/PLUGIN_DEV.md:插件示例表补 6 个缺失项
  (acp/deepsearch/plugindev/recoverydiag/vanblog/vikunja);qq 工具数 17 → 20(实测)。
- README.md / README_EN.md:补 v1.3.x 线(此前只到 v1.2.0,而 1.3.x 已发布 12 个 patch)——
  驻留式子 agent、输出通道寻址、输入调度器、轻量内核 profile、积压及时反馈。
- plan.md:开头两个「⚠️ 紧急/正在持续污染」是过期告警(实测  残留 = 0),
  改为「已解决」并加文档定位说明;§13 仍是活跃路线图故保留。
- docs/zh/plugin-interface-matrix.md + 两处源码注释:清理指向已删文档的断链。

全仓 md 断链检查:仅剩 1 处,位于 third_party 的 oh_modules(第三方依赖,非本项目)。
This commit is contained in:
JianFeeeee
2026-09-19 19:14:55 +08:00
parent 8acd3ce1a8
commit 7213edd181
15 changed files with 238 additions and 1679 deletions

View File

@ -1,248 +0,0 @@
# QQ `output_send` 回声/无限循环(核心侧缺陷) QQ `output_send` 回声/无限循环(核心侧缺陷,非插件)
> 状态:**已修复**(核心已具备轮次上限:`core.agent.max_tool_turns`,默认 10
> 在 `internal/agent/core/task.go` 到达上限即强制收尾;回归测试
> `TestMaxToolTurns_CapsRunawayLoop`。本文保留为缺陷定位过程记录。)
>
> 原始状态(修复前):**待修复**
> 影响Agent 单轮内每 ~10 秒调用一次 `output_send__qq`,持续数十分钟不结束(实测单轮 `1780624ms`80+ 次工具调用)
> 定位结论:**问题在核心(富回执 + 每轮重复追加同一条“继续”占位 + 无轮次上限QQ 插件侧已是最小回执,改插件无效**
---
## 1. 现象
生产日志(`/home/newqqagent`homed 运行期):
```
18:31:39 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
18:31:47 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
18:31:58 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
18:32:06 ...
18:32:14 ...
(每 8~12 秒一条payload 长度各异387/237/246/203/252/270/254/231/258/227/188/227/200/209/155/284/212/173/198/242/218/191…
18:31:19 eventloop.go:426: [agent] text from qq → response (1780624ms, tools=[... 80+ 项 ...])
```
- 每条内容**都不同**,所以“相同参数才拦”的插件保险不会触发。
- 不是 webhook 回声15 分钟内只有 1 条真实入站中断)。
- 是模型每轮都收到“发送成功的富回执”,把它当成“继续下一步”的信号。
---
## 2. 根因链(核心侧,三层)
### 2.1 QQ 插件已经返回最小回执 —— 但被核心丢弃
`third_party/homeagent-sdk/example/qq/plugin.go``handleChannelOutput` 尾部):
```go
if sendErr != nil {
return nil, sendErr
}
// 成功:返回极简标记。不再回传 NapCat 原始响应(含 message_id 等)给模型,
// 避免模型把"发送成功"当成"上一步完成,继续下一步"的信号驱动循环。
return "ok", nil
```
插件返回的是字符串 `"ok"`
### 2.2 核心 proc 桥丢弃它并伪造 `status:sent`
`internal/plugin/proc/plugin.go:336``(*Plugin).invokeOutput`
```go
raw, err := p.proc.Call(MethodOutputInvoke, OutputInvokeParams{Channel: channel, Args: args})
if err != nil { return nil, err }
if len(raw) == 0 {
return map[string]interface{}{"status": "sent"}, nil
}
var res map[string]interface{}
if err := json.Unmarshal(raw, &res); err != nil {
return map[string]interface{}{"status": "sent"}, nil // ← "ok" 不是 JSON object落到这里
}
if _, ok := res["status"]; !ok {
res["status"] = "sent" // ← 再兜底
}
return res, nil
```
插件返回 `"ok"``json.Unmarshal``map[string]interface{}` 失败 → 核心合成 `{status: sent}`
**插件的返回值在这里被完全覆盖,所以只改插件永远修不掉回声。**
### 2.3 核心把这个富回执喂给模型
`internal/agent/core/output.go:81`HEAD / 部署中的 homed 行为):
```go
return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
// → "已通过 [qq] 通道发送: map[status:sent]"
```
模型看到“发送成功 + 详情”后继续调用 `output_send__qq`,形成闭环。
### 2.4 核心没有工具轮次硬上限(放大器)
`core.agent.max_tool_turns` 只在配置层定义,**agent 循环里没有任何读取点**
```
internal/config/registry.go:551 set("core.agent.max_tool_turns", "10")
internal/config/registry.go:669 reg(ConfigDef{Key: "core.agent.max_tool_turns", ...})
$ grep -rn 'max_tool_turns\|MaxToolTurns' internal/agent/ → 无结果
```
`internal/agent/core/process.go:242` 的唯一终止条件是:
```go
if len(resp.ToolCalls) == 0 {
return resp.Content, toolsUsed, toolResults, nil
}
```
即:**模型不主动停,循环就永不结束**。`core.agent.max_tool_turns`(本机 DB 现为 `1000`)形同虚设。
### 2.5 每轮重复追加同一条 user 占位(“反复喂相同消息”的直接来源)
`internal/agent/core/process.go:77-82`,位置在 `for turn := 0; ; turn++` 循环的**顶部**
```go
for turn := 0; ; turn++ {
for _, interrupt := range a.drainInterrupts() { ... }
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
msgs = append(msgs, agentAPI.Message{ // ← process.go:79
Role: "user",
Content: "请根据以上工具结果继续。",
})
}
...
}
```
`msgs``process.go:32` 在循环**外**创建,循环内只增不减:
- 每轮工具调用结束后,`msgs` 尾部必然是 `tool` 消息;
- 下一轮顶部判断成立,于是**再追加一条完全相同的** `请根据以上工具结果继续。`
- 不做替换、不做去重、不做裁剪(`ContextPolicy: prune` 只裁剪 `a.context`,不裁剪 `msgs`)。
跑 N 轮,模型收到的 prompt 里就叠了 N 条一模一样的“继续”指令。这才是“核心把前面相同消息反复喂给模型”的直接机制,也是把模型持续推向 `output_send` 的持续推力。
**预期行为**占位消息应当a仅在没有尾部 user 消息时补一条b补之前先移除上一条同类占位保持至多一条绝不能线性累积。
---
## 3. 现有未完成/未部署的修复
| 文件 | 状态 | 内容 |
| --- | --- | --- |
| `internal/agent/core/output.go:81` | **已改,未提交** (`M`) | `return fmt.Sprintf("已通过 [%s] 通道发送: %v", ...)``return "ok"`(含解释回声的注释) |
| `internal/plugin/proc/plugin.go:336` | **已改,未提交** (`M`) | 仍是伪造 `status:sent` 的版本,未处理非 map 返回值 |
| `third_party/homeagent-sdk/example/qq/plugin.go` | **已改,未提交** (`M`) | `handleChannelOutput` 返回 `"ok"` |
运行中的 `homed`**Sep 6 11:39** 构建的二进制,不含 `output.go` 的极简回执改动 → 仍回显富回执。
另外该二进制用旧 SDK 协议(`shmMagic` 直连,无 `unifiedMagic`),而 `third_party/homeagent-sdk` 仓库 HEAD 已升级到统一区域协议(`fc23612` 起)。**重建并部署 homed 时二者必须对齐**(见 §5
---
## 4. 建议修复
### 4.1 (必须)让模型只看到最小回执
**方案 A最小改动已在工作区**`internal/agent/core/output.go`
```go
// internal/agent/core/output.go:81
// 成功回执:只返回极简标记,不回传完整插件响应。
// 「已通过 [qq] 通道发送: map[status:sent message_id:xxx]」这类富回执
// 会驱动模型继续调用 output_send回声效应是 output loop 的根源之一。
return "ok"
```
**方案 B同时修掉 proc 桥的伪造)**`internal/plugin/proc/plugin.go:336`
不要对非 map 结果伪造 `status:sent`,保留插件真实返回;例如:
```go
if len(raw) == 0 {
return map[string]interface{}{"status": "ok"}, nil
}
var res map[string]interface{}
if err := json.Unmarshal(raw, &res); err != nil {
// 插件返回的是标量(如 "ok")——原样透传,不要伪造 status
var scalar interface{}
if err2 := json.Unmarshal(raw, &scalar); err2 == nil {
return scalar, nil
}
return map[string]interface{}{"status": "ok"}, nil
}
```
注意:`output.go` 仍需要 `status == "unconfirmed"/"queued"` 的判定,改成标量透传时该判定自然跳过(非 map语义正确。
### 4.2 (必须)工具循环硬上限
`internal/agent/core/process.go` 的工具循环里读取并强制 `core.agent.max_tool_turns`
- 位置:`for turn := 0; ; turn++ {` 循环内,执行工具前/每轮结束后检查。
- 语义:达到上限时追加一条系统消息(如 `[系统] 已达到最大工具轮次 N请立即总结并停止调用工具`),并终止循环返回当前内容,而不是继续下一轮。
- 至少要在 `turn > maxTurns` 时强制 `break`,避免模型不停调用。
### 4.3 建议QQ 插件侧保持最小回执
`third_party/homeagent-sdk/example/qq/plugin.go``return "ok", nil` 是正确的,保留即可。
**不要**再依赖插件侧修这个回声——见 §2.2。
### 4.4 (必须)修掉每轮重复追加的 user 占位
`internal/agent/core/process.go:77-82`。改为“至多保留一条”,例如:
```go
// 只在尾部是工具轮产物时补位;先移除上一条同类占位,避免线性累积。
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
// 若尾部之上已经存在一条我们自己的占位,就不要重复追加。
if !isContinuationPlaceholder(msgs[len(msgs)-1]) {
msgs = append(msgs, agentAPI.Message{
Role: "user",
Content: continuationPlaceholder,
})
}
}
```
更稳妥的写法:在追加前从 `msgs` 尾部回扫,删除所有此前由本机制插入的占位,再追加一条。判定不要只靠字符串相等,建议给占位加一个可识别标记(例如 `internal:continuation`)或单独的 `NoMemory/Role` 约定,避免误删真实用户消息。
同时建议给 `msgs` 加长度/ token 上限(或定期裁剪历史),防止长任务把上下文堆爆(这正是 §2.4 无轮次上限的伴生问题)。
---
## 5. 部署前提与步骤
> ⚠️ 部署 homed 前必须先对齐 SDK 协议,否则所有子进程插件握手失败(`统一区域魔数不匹配`)。
1. **确认工具链协议与要部署的 homed 一致**
- 现状:`/usr/local/bin/homed` = 旧协议;`/usr/local/bin/plugindev` 已替换为旧协议版本(备份 `/usr/local/bin/plugindev.bak-20260910-174547`)。
- 若决定升级到统一区域协议,则需同时:升级 homed 二进制 + 用新 SDK`third_party/homeagent-sdk` HEAD重建全部插件。
- 若维持旧协议:用 `/usr/local/bin/plugindev`(旧)重建插件即可,不要用仓库 HEAD 的 `tools/plugindev` 直接 `go run`
2. **构建 homed**`go build ./...` 已验证通过;产出替换 `/usr/local/bin/homed`(按项目部署纪律:备份 → 原子替换)。
3. **重启**`systemctl restart homeagent.service`
4. **重建受影响的子进程插件**(协议一致时):至少 `qq`
---
## 6. 验收标准
修复后,发一条会触发回复的 QQ 消息,应满足:
1. 日志中 `output_send__qq` 的 tool result **不再包含** `已通过 [qq] 通道发送: map[status:sent]`
2. 单轮只发送 1 条(或模型明确决定的多条**不同**消息),**不出现每 ~10 秒一次的持续调用**
3. 当模型异常地持续调用工具时,日志出现达到 `core.agent.max_tool_turns` 的终止记录,且该轮在有限步内结束;
4. `eventloop.go:426``text from qq → response` 耗时应回落到正常量级(秒级~分钟级),不再是 30 分钟;
5. 抓取发往上游的请求(或用调试钩子 dump `req.Messages`),确认 `请根据以上工具结果继续。` 在整轮 prompt 中**至多出现一次**;修复前应为 N 条N=轮数),这正是 §2.5 的判据。
---
## 7. 相关背景(避免误修)
- QQ 插件的 `beforeToolcall` 循环保险只拦“参数完全相同的重复调用”(`max_duplicate_qq_send`**拦不住内容各异的循环**;本次循环每条内容都不同,所以保险未触发。这是设计使然,不是 bug。
- 15 分钟内仅 1 条真实 QQ 入站中断,说明**不是** webhook 把出站消息当入站回灌,**不是**插件回声。
- `internal/plugin/proc/plugin.go:122``invokeCleaner` 签名不匹配(此前导致 `go build` 失败)**已被修复**,当前 `go build ./...` 通过。

View File

@ -1,124 +0,0 @@
# 检索方案对比报告2026-09-09
## 测试数据
- 文档库492 篇生产文档(过滤 108 条健康检查测试文档)
- 媒体库3 张生产图片(验证码、新闻截图、深色模式备忘录)
- 文本查询10 组(精确匹配、语义、跨语言、模糊表达)
- 媒体查询6 组(中文/英文查图片3 张图片各 2 条)
---
## 一、文本检索对比(文档库)
| 方案 | Hit@1 | Hit@5 | MRR | 平均延迟 |
|------|-------|-------|-----|----------|
| TF-IDF | 3/10 | 7/10 | 0.457 | 0.3ms |
| fastText200k 中文+378k 英文) | 5/10 | 5/10 | 0.530 | 8.3ms |
| TF-IDF + fastText RRF | 4/10 | 7/10 | 0.552 | 12.3ms |
| **Jina v5-omni-nano** | **8/10** | **10/10** | **0.900** | **39.9ms** |
### 关键发现
1. **Jina 的优势来自"短语语义"能力**
- "邮件代理是否已经成功接入" → TF-IDF rank 5Jina rank 1
- "升级安装 QQ 插件包" → fastText rank 169Jina rank 1margin +0.30
- "我所在城市的天气预报" → fastText rank 44Jina rank 1
- "聊天输入区域文字多了会不会自动增高" → TF-IDF rank 1Jina rank 1margin +0.33
2. **TF-IDF 在精确匹配上不可替代**
- "长期文档记忆功能是否健康" → TF-IDF rank 3Jina rank 1
- "重新加载全部扩展组件" → TF-IDF rank 0完全未命中Jina rank 2
- TF-IDF 的 Hit@5 70% 证明精确关键词召回仍有价值
3. **RRF 融合反而变差**
- TF-IDF+fastText RRF MRR=0.552,低于 Jina 单路 0.900
- 原因两种稀疏向量的排序在语义查询上高度重叠RRF 无法弥补各自短板
---
## 二、图片检索对比(同 3 张图片6 条查询)
| 方案 | Hit@1 | MRR | 平均 margin |
|------|-------|-----|-------------|
| CLIP ViT-B/32 | 4/6 | 0.806 | -0.008(负值!) |
| Jina v5-omni-nano | 4/6 | 0.833 | +0.024 |
### 逐条对比
| 查询 | CLIP rank | CLIP margin | Jina rank | Jina margin |
|------|-----------|-------------|-----------|-------------|
| 验证码图片(中) | 1 | +0.027 | 1 | +0.036 |
| 验证码图片(英) | 1 | +0.063 | 1 | +0.077 |
| 新闻截图(中) | 6 | -0.091 | 2 | -0.064 |
| 新闻截图(英) | 1 | +0.008 | 2 | -0.028 |
| 备忘录截图(中) | 3 | -0.045 | 1 | +0.045 |
| 备忘录截图(英) | 1 | +0.051 | 1 | +0.079 |
### 关键发现
1. **中文文本→图片**Jina 明显优于 CLIPMRR 0.833 vs 0.611
- CLIP 中文查询余弦可低至 -0.076(完全反直觉)
- Jina 最差也是 +0.045,正样本始终高于负样本
2. **新闻截图是共同弱点**
- CLIP 和 Jina 都被"深色模式备忘录"抢走新闻截图的排序
- 原因:新闻截图的文字描述含"深色"、"备忘录"等词,与备忘录图片的视觉特征重叠
- 这是描述质量 vs 视觉特征的竞争,不是模型问题
3. **margin 的实际意义**
- CLIP 的平均 margin = -0.008(负值意味着正样本平均不如负样本)
- Jina 的平均 margin = +0.024(正样本始终略高于负样本)
- 但两者的 margin 都很小(< 0.1生产环境仍需阈值校准
---
## 三、延迟与资源
| 方案 | 单次查询延迟 | 索引构建 | 内存 |
|------|-------------|----------|------|
| TF-IDF | 0.3ms | <1s | ~50MB |
| fastText | 8.3ms | <1s | ~200MB |
| CLIP ONNX | 26ms | N/A | ~600MB |
| Jina v5-omni CPU | 39.9ms | 78s492篇 | ~4GB |
---
## 四、结论与建议
### 核心判断
| 维度 | TF-IDF/fastText | CLIP | Jina v5-omni |
|------|-----------------|------|--------------|
| 文本精确匹配 | ★★★★★ | N/A | ★★★★ |
| 文本语义检索 | ★★ | N/A | ★★★★★ |
| 中文文本图片 | 无能力 | | ★★★★ |
| 英文文本图片 | 无能力 | ★★★ | ★★★★ |
| 图片图片 | 无能力 | ★★★ | ★★★★ |
| 多语言统一空间 | 无能力 | 有限 | ★★★★★ |
| 延迟 | ★★★★★ | ★★★ | ★★ |
### 架构建议
1. **保留 TF-IDF 作为精确召回的一级通道**
- 0.3ms 延迟不可替代
- Hit@5 70% 证明在关键词匹配场景仍有价值
- 特别是"插件安装"、"设备查询"这类精确操作指令
2. **用 Jina 替换 fastText + CLIP 的稠密通道**
- Jina 单路 MRR=0.90,超过 fastText+CLIP 融合
- 统一空间消除三条通道的维护成本
- 中文文本图片从"无法检索"提升到"可检索"
3. **两路融合TF-IDF + Jina RRF**而非 TF-IDF + fastText RRF
- TF-IDF 精确匹配 + Jina 语义覆盖
- RRF 避免跨空间分数归一化问题
- 预期 MRR > 0.90(精确匹配补 Jina 的语义盲区)
4. **图片检索仍需阈值校准**
- Jina 的 margin 平均 +0.024,生产环境需设置合理阈值
- 建议:用真实正负样本对重新标定,而非沿用 CLIP 的 0.20 阈值
### 下一步
- 实现 TF-IDF + Jina RRF 融合,验证 MRR 是否能突破 0.90
- 用更多生产图片标定 Jina 的图片检索阈值
- 测试 fastText 词嵌入是否可以完全被 Jina 文本编码替代L0 相关性计算)

View File

@ -1,284 +0,0 @@
# WebUI 布局与配置归位修复计划
## 一、背景
上一轮 SDK 接口化改造完成并部署后,用户指出三个问题:
1. **WebUI 窄屏布局损坏**:顶部 `<nav>` 为桌面式横排(标题 + 6 tab + 连接指示器 + 语言 + 主题),
窄屏断点仅缩小字号不换行,`body { overflow-x:hidden }` 直接把溢出的 tab 裁掉不可点击。
2. **OpenClaw skills 目录被注册为核心配置**`core.skills.path`"OpenClaw 技能存储目录")注册在核心
配置表(`internal/config/registry.go`),但全仓无任何读取方(死配置);实际生效路径是
clawhubadapter 自己的 `skills_dir` 配置(`config_clawhubadapter` 表 + `core.daemon.data_dir`/skills 兜底)。
技能目录是 clawhubadapter 适配加载的领域,不应属于核心配置。
3. **clawhubadapter 加载的微信插件成为独立配置项**:设置页出现 `channels.wechat.*`(核心表)、
`plugin.wechat.*`config_wechat 表)、`config_openclaw_weixin`(空表)等多处微信配置,
全部为历史残留——当前代码零引用OC 技能的配置实际在其自身 `~/.openclaw/openclaw.json`
设置页会把核心表全部键 + 全部插件表当作配置组展示,导致残留以"独立配置项"形态出现。
## 二、修复计划
| # | 动作 | 位置 | 风险 |
|---|------|------|------|
| A | 窄屏导航修复:<768px nav 横向滚动h1 缩写连接指示器简化body 溢出裁切改为 nav 内滚动 | `cmd/gui/renderer/style.css` | |
| B | 删除 `core.skills.path` 核心配置注册set + RegisterDef 两处 | `internal/config/registry.go` | 无读取方 |
| C | 备份后清理残留配置`channels.wechat.*` `config_wechat` / `config_openclaw_weixin` / `config_openclaw` 含微信 token先备份 | 生产库 `/home/newqqagent/config.db` | 当前代码不读 |
## 三、实施记录
### 步骤 Awebui 窄屏导航修复(已完成)
- 修复对象为 webui HTTP 服务真正前端 `internal/plugins/webui/dashboard.html``go:embed` 内嵌
登录后 `/` 返回104KBcmd/gui 是独立 electron 客户端 webui 一部分)。
- `<768px` 断点`nav { overflow-x:auto; scrollbar-width:none; flex-wrap:nowrap }` + `::-webkit-scrollbar { display:none }`
`nav a { white-space:nowrap; flex-shrink:0 }``nav h1 { font-size:0 }`保留 logo 隐藏文字弥补窄屏空间
`nav > div { flex-shrink:0 }` 右侧语言/主题/退出按钮不压缩
- 顺带在 cmd/guielectron 客户端同步了窄屏样式与消息来源徽标`app.js`/`style.css`客户端窗口缩放同样受益
客户端需另行构建 electron 应用才生效)。
- 验证部署后 `/` 返回的 dashboard `scrollbar-width:none`/`font-size:0`/`::-webkit-scrollbar` 规则
### 步骤 B删除 core.skills.path 核心配置(已完成)
- 删除 `internal/config/registry.go` 两处`set("core.skills.path", ...)`SeedDefaults
`reg(ConfigDef{Key:"core.skills.path", ...})`定义注册)。
- 理由该键全仓无读取方grep 仅命中注册处实际生效路径是 clawhubadapter `skills_dir`
config_clawhubadapter + `core.daemon.data_dir`/skills 兜底)。技能目录属 clawhubadapter 适配领域
- 验证`grep -rn "skills.path" --include="*.go"` 零命中部署后设置页无 `core.skills.path`
### 步骤 C清理生产库残留配置已完成
- 操作前 `sqlite3 .backup /tmp/opencode/config.db.pre-clean.bak`含微信 token 数据)。
- 删除`config` `channels.wechat.*` 3 + `core.skills.path` `DROP TABLE config_wechat /
config_openclaw_weixin / config_openclaw`三者均为历史残留当前代码零引用clawhubadapter 实际
使用 config_clawhubadapter 表OC 技能配置在其自身 `~/.openclaw/openclaw.json`)。
- 验证:设置页总键数 138→129无 wechat/weixin/skills.path 残留,`plugin.clawhubadapter.skills_dir /
simulator_dir` 正常;服务 healthcheck ready、clawhubadapter "OC plugin manager started"。
---
# SDK Stop 注册接口RegisterStopHandler计划
## 一、背景
2026-08-01 20:00 起生产 homeagent 进入崩溃循环(`fatal error: thread exhaustion`
systemd 重启计数 61+)。排查定位为 SDK 示例插件 `calendar`(示例源码在 SDK 仓库
`example/calendar`,生产以 plugin.so 形态加载)三个缺陷叠加:
1. **农历引擎 3 个 bug**`daysInLunarYear` 位循环 `i > 0` 应 `i > 0x8`、缺闰月天数、
`lunarToSolar` 内层重复加闰月)→ `lunarToSolar(2026,4,12)` 返回 **2062-11-16**(偏移 36 年),
农历重复事件(`lunar_yearly`)的 next 被生成到遥远错误日期。
2. **`cleanupPastEvents` 保留过时重复事件** → 每 30s ticker 对已到点的重复事件再生成一份 next
事件从 7 个爆炸到 **45612 个**15MB events.json
3. **无提醒投递保护**15018 份同时到点的事件一次性 `go sdk.InjectInterruptText(...)` 投递
→ interrupt 风暴 → goroutine/线程耗尽。
处置:修复农历引擎 3 处 + next 去重 + 清理过时重复事件,用**新版 SDK 仓库 + 新版 plugindev 工具链**
重建 `calendar_linux_amd64.hmap`,经 **webui `POST /api/v1/plugins`**(透明代理到 pluginmgr 安装接口)
重装,重启验证收敛(事件 4 个、next 正确生成 2027-05-17、0 崩溃)。
**过程中暴露的能力缺口**SDK 只有 `Plugin` 接口的 `Name/Start/Stop`**没有 stop 注册接口**
`RegisterStopHandler`/`OnStop` 均不存在SDK v0.7.2/v0.8.0/master 一致)。插件停止时只能在自己的
`Stop()` 里写清理逻辑SDK 层无法统一执行"停止时清理"回调calendar 的 `Stop() { p.saveEvents() }`
还会用陈旧内存把已清理的数据写回磁盘(曾导致删除的重复事件复活)。
## 二、计划
| # | 动作 | 位置 | 风险 |
|---|------|------|------|
| 1 | 公共 SDK `PluginSDK` 加 `RegisterStopHandler(fn func())` + `RunStopHandlers()`(幂等、后注册先执行),两处同步 | `third_party/homeagent-sdk/sdk/plugin.go`、SDK 仓库 `sdk/plugin.go` | 低(纯新增,内置 SDK 内嵌透传) |
| 2 | 内核 Registry 保存每插件 SDK 引用(`sdkRefs``StopAll`/`ReloadOne`/`DisablePlugin` 调 `Stop()` 前执行 `RunStopHandlers` | `internal/plugin/registry.go` | 中(生命周期路径,需回归 reload/disable |
| 3 | 工具链 plugindevz_bridge 模板 `bridgeState` 存 SDK`StopPlugin` 先 `RunStopHandlers()` 再 `plugin.Stop()`init 脚手架模板加演示 | SDK 仓库 `tools/plugindev/templates.go`、`templates/main.go.tmpl` | 低 |
| 4 | 内置示例插件演示(如 timerticker 停止改为 stop handler | `internal/plugins/timer/plugin.go` | 低 |
| 5 | 外部示例插件同步(`example/calendar` 的 `saveEvents` 改由 stop handler 执行,验证 z_bridge 链路;其余 example 加演示) | SDK 仓库 `example/*` | 低 |
| 6 | 文档同步SDK README 生命周期章节 + 主仓插件开发文档 | SDK 仓库 `README.md`/`README_EN.md` 等 | 无 |
## 三、实施记录
1. SDK 公共层(`RegisterStopHandler` + `RunStopHandlers`:后注册先执行、执行后清空幂等)已落地
`third_party/homeagent-sdk/sdk/plugin.go`,并同步到 SDK 仓库 `/tmp/opencode/sdk-repo/sdk/plugin.go`(两处一致)。
2. 内核 Registry`internal/plugin/registry.go`)新增 `sdkRefs map[string]*sdk.PluginSDK` + `runStopHandlers`
`loadOne` 注册、`StopAll`/`ReloadOne`/`DisablePlugin` 在 `Stop()` 前执行(共 4 处调用点)。
3. 工具链 plugindevSDK 仓库linux `tmplLinuxBridge` 的 `go_stop_plugin` 先 `RunStopHandlers()` 再 `plg.Stop()`
windows `tmplBridge` 的 `bridgeState` 加 `sdk` 字段、`StopPlugin` 同链路;`tmplPluginGo` + `main.go.tmpl`
脚手架加 `RegisterStopHandler` 演示。plugindev 重新编译通过GOPATH=/root/go
4. 内置 timer 插件演示:`close(p.stopCh)` 移入 stop handler`Stop()` 只 `wg.Wait()`。
5. 外部示例:`example/calendar` 的 `saveEvents` 改为 `s.RegisterStopHandler(p.saveEvents)`
`Stop()` 删除写盘调用(持久化交由 stop handler避免陈旧内存复活已删事件
6. 文档SDK 仓库 `README.md`/`README_EN.md` 生命周期章节补充 RegisterStopHandler 说明。
7. 构建测试:主仓 `go build ./...` + `go test ./internal/sdk/... ./internal/plugin/...` 全绿;
SDK 仓库 `go build ./...` + `go test ./sdk/...` 全绿。
8. 生产部署验证:新 plugindev--no-bundle重建 `calendar_linux_amd64.hmap`md5 084e97c0…strings 确认
`go_stop_plugin → RunStopHandlers → saveEvents` 编译进 plugin.sowebui API 删旧装新;重装新内核
homed含 sdkRefs/runStopHandlers两次重启事件稳定 3 个不复活、events.json mtime 与 stop 时刻吻合
saveEvents 经 stop handler 真实执行、0 次 thread exhaustion、服务 active。
---
# clawhubadapter OpenClaw 通道插件兼容修复计划
## 一、背景
生产 `core.llm.provider` 已是 mock LLM 源(`core.llm.sources.mocktest`base_url
`http://127.0.0.1:18080/v1`、model mock-model、adapter openaimock LLM 服务常驻运行。
借助 **mock 通道插件**`/tmp/opencode/mock-skills/mock-wechat/`,完全复刻 openclaw-weixin 的
真实注册格式 `register(api) → api.registerChannel({ plugin: ChannelPlugin })`)放入生产 skills 目录
端到端复现,得出如下结论:
**已验证可用链路**manager 加载 mock 插件 → Go 端识别 `ocplugin mock-wechat handled by manager` →
注册工具 `mock-wechat_read_mock_wechat_input`/`mock-wechat_mock_echo` → `RegisterOutputChannel("mock-wechat")`
→ mock 自推消息经 `channel_input` 通知 → `[agent] interrupt from manager/mock-wechat` →
mock LLM 正常回复195ms
**复现的核心缺陷**(真实通道插件 wechat/dingding"根本不可用"的根因):
1. **输出断链**manager `tools/call` 通道分支只认 `channelPlugin.outbound.sendText/sendMedia`
(旧格式),真实 ChannelPluginopenclaw-weixin 等)无 outbound →
`tools/call mock-wechat → error: "channel mock-wechat has no output handler"`。
2. **生命周期静止**manager mock api 从不调用 `gateway.startAccount/stopAccount`,也无
`api.runtime`/`channelRuntime` → 通道插件加载后永不启动(不登录、不轮询、不收消息)。
3. **输入依赖错位**:真实插件把消息经 `channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher`
推送manager 完全无此对象),而不是调 `api.submitInput`。
4. **stdout 污染**:插件 `console.log` 直接进 JSON-RPC 流Go 端 readLoop 跳过非 JSON 行,有丢通知风险。
**wechat 通道的心跳机制**`openclaw-weixin/dist/index.js` `pollLoop`448 行起):每账号一个常驻
`pollLoop`,循环 `POST ilink/bot/getupdates`body `{get_updates_buf}`,超时 35s——**长轮询即心跳**
服务器收到 poll 请求即知通道在线,新消息随 poll 响应 push 回来;超时视为空响应继续轮询,真错误延时
5s 重试。**与 gateway 生命周期强绑定**
- `pollLoop` 只由 `gateway.startAccount(ctx)` 启动;不被调用 → 心跳/收消息全断(服务器侧认为通道离线)。
- `startAccount` 末尾 `await new Promise(()=>{})` **永久挂起**——OC gateway 靠它配合 health-monitor
startAccount 退出 → 判定账号崩溃 → 重启账号。manager 调 `startAccount` 必须 **fire-and-forget**。
- 停靠 `gateway.stopAccount(ctx)``ctx.account.accountId` 定位),停止时经 `statusSinks` 调
`ctx.setStatus({running:false, connected:false, lastStopAt})`;启动即上报
`ctx.getStatus()/ctx.setStatus({...running:true, connected:true, lastStartAt})`——`connected` 是
gateway 判断账号存活的依据。
- `sendTyping`ilink/bot/sendtyping + typing_ticket是打字指示非心跳无需支持。
## 二、修复计划
| # | 动作 | 位置 | 风险 |
|---|------|------|------|
| A | manager 提供 **gateway 生命周期桥**channel 插件注册后自动 `gateway.startAccount(ctx)`fire-and-forget不等待挂起的 Promise构造完整 ctx `{account, cfg, channelRuntime, getStatus, setStatus}`Go 端 `channel_stop` 通知 → `stopAccount` | `internal/plugins/clawhubadapter/manager/main.js` | 中 |
| B | 实现 **channelRuntime mock**`reply.dispatchReplyWithBufferedBlockDispatcher`deliver 回调 → `channel_output` 通知送 Go 端)、`getPolls`OC 通用通道轮询输入)、`call` 透传 | 同上 | 中 |
| C | `tools/call` 通道分支改造:无 `outbound` 的 ChannelPlugin 改走 channelRuntime 事件式发送agent 输出 → deliver不再报 "no output handler" | 同上 | 低 |
| D | 状态上报透传:`setStatus` 经 `channel_status` 通知 → Go 端可查health-monitor 语义startAccount 保持挂起) | 同上 | 低 |
| E | stdout 卫生:插件 `console.log` 重定向 stderr或 JSON-RPC 流感知封装),杜绝污染 | 同上 | 低 |
| F | Go 端:`channel_output`/`channel_status` 通知接入(事件分发),通道输出 handler 保持 `sp.CallTool` | `internal/plugins/clawhubadapter/registry.go`、`plugin.go` | 中 |
| G | 端到端验证mock 通道插件 + mock LLM生产环境临时放入/移出 skills 目录)复跑全链路(登录启动→收消息→回复→出站→停止) | 生产 | 低 |
## 三、实施记录
(逐步填写)
1. **manager/main.js — 通道运行时与生命周期桥(已完成,独立运行验证)**
- `makeChannelRuntime(chName, ch)``reply.dispatchReplyWithBufferedBlockDispatcher(opts)` 提取
`dispatcherOptions.deliver`/`typingCallbacks` 按 `ctx.AccountId` 挂到 `ch.deliverers`,随后
`notify('channel_input', {channel, payload:{content: BodyForAgent||Body, from, sessionKey, accountId,
messageSid, chatType, raw}})` 入站;返回 dispatchersendNow/addToBuffer/sendBuffer/closeBuffer
`chatPolls`/`getPolls` 空转(防断连误判)、`call` 转发 `channel_output` 通知。
- `startChannels(name)`channel 插件注册后自动枚举账号(`config.listAccountIds`→`resolveAccount`
缺省 `['default']`),构造完整 ctx `{account, cfg, channelRuntime, getStatus, setStatus}`
**fire-and-forget** 调 `gateway.startAccount`(真实插件会永久挂起,绝不等待);崩溃/状态变更经
`channel_status` 通知透传health-monitor 语义startAccount 不退出=账号存活)。
- `stopChannels(name)`:逐个账号 `gateway.stopAccount`;进程 SIGTERM/SIGINT 时统一执行优雅停靠。
- `tools/call` 通道分支:保留 outbound旧格式→ 新增 **deliver 事件式发送**
`deliverItem` 按 accountId 取 deliver + typingCallbacks.onReplyStart/onCleanup 包裹)→
无 deliver 时降级 `channel_output` 通知 → 兜底报错。不再出现 "has no output handler"。
- **stdout 卫生**:全局 `console.log` 重定向 stderrJSON-RPC 流仅承载协议帧。
2. **mock 插件升级(/tmp/opencode/mock-skills/mock-wechat/index.js**:完全复刻真实 weixin 行为——
`gateway.startAccount` 永久挂起 + `setStatus` 上报 + `setInterval` 心跳轮询 + 800ms 后经
`dispatchReplyWithBufferedBlockDispatcher` 推送入站deliver 本地记录发送);`stopAccount` 停轮询+状态置否。
3. **manager 独立运行验证(通过)**`channel_status` 启动上报running=true connected=true
`tools/call mock-wechat` → `{"status":"sent","via":"channelRuntime.deliver"}`,插件 deliver 收到
`text="hello from agent"` 且 typing onReplyStart/onCleanup 正确包裹;心跳 poll #1-4 常驻;
SIGTERM → `stopAccount called` 退出码 0插件 console 输出全部走 stderr协议流零污染
4. **Go 端通知接入plugin.go translateAndRegister + registry.go 状态缓存)**`channel_status` 存
`channelStatus` map可查+ 日志;`channel_output` 降级事件日志。`go build ./...` 通过。
5. **回复闭环修复(同步注入)**`channel_input → InjectInterruptText` 的 InputEvent 不带 ResponseCh
internal/agent/io/channel.go:276agent 回复在 emitResponseeventloop.go:384被静默丢弃。
改为 `s.InjectInputSync(pluginName, channel, "text", payload)`(内部 SDK 已有 4 参版本,返回
`*OutputEvent`)同步等待回复 → 提取 `Payload["content"]` → `sp.CallTool(channel, {payload, meta})`
→ manager `tools/call` → deliver → 插件发送 → 微信送达。公共 SDK IOInjector 同步补
`InjectInputSync(source, channel, text) string`ioAdapter 实现,供外部插件一致使用)。
6. **mock LLM 恒定文本化**/opt/llm-mock/mock_server.py `decide()` 删除工具调用分支,一律回文本
"无论收到什么消息都通过微信插件发送"),保证每条入站消息回复必然走通道输出。
7. **生产微信闭环验证(通过)**:用户微信发"你好..." → pollLoop 收到 → dispatchReply →
InjectInputSync → mock LLM 回文本 → CallTool(wechat) → deliver → `POST ilink/bot/sendmessage`
→ **status=200 message_id=7489545365740590088**,微信收到"mock已收到消息长度 324 字符。"
8. **通用性审查(无硬编码)**manager/plugin.go/registry.go 均无 weixin/wechat 特判,全部按 OC 规范
字段实现gateway/config/capabilities/channelRuntime/deliver/typingCallbacks。修正规范签名参数
约定:`listAccountIds(cfg)`、`resolveAccount(cfg, accountId)` 正确传 cfg。
9. **已知边界**非硬编码架构性a) `channelRuntime.getPolls/chatPolls` 返回空 msgs——依赖
runtime 轮询输入的通用通道型插件收不到消息weixin/dingding 类自带 pollLoop 的通道不受影响);
b) `gatewayMethods` 登录流程web.login.start/QR 扫码未实现CLI 有 stub通道凭 token
配置直连c) startAccount ctx 提供 account/channelRuntime/cfg/getStatus/setStatus 核心字段。
10. **补充边界 a) getPolls/chatPolls 消息源(已完成)**manager `makeChannelRuntime` 的
`chatPolls/getPolls` 改为读 `ch.pollQueues`(按 accountId 队列poll 取走即消费);新增
`channel/send` RPCGo 端注入 → 队列 → 插件轮询取走Go 端 `SendToChannel(channel, payload)`
+ `ChannelSender()` 单例Start 时置位。验证mock-poll 插件(纯 chatPolls 轮询型)——
`channel/send` → `{"status":"queued"}` → `[mock-poll] poll got msg` → dispatchReply →
`channel_input` 入站完整content/from/sessionKey/accountId/messageSid/chatType
微信链路回归正常Polling started + channel_status running=true
11. **边界 b) 登录流程核实(已解决,无需实现)**:真实登录机制是 **SKILL 脚本旁路**——
`weixin-openclaw-login` SKILL 的 `scripts/get-login-url.js`ilink 二维码 URL+
`poll-login-status.py`(轮询扫码状态)→ agent 经 exec 执行 → 拿 bot_token 写入
`~/.openclaw-weixin/account.json`2026-07-28 17:31 创建token 有效)→ 插件启动
`resolveAccountData` 直读。不依赖 manager gatewayMethodsweb.login.start 等 OC gateway
协议 stub 不影响真实使用)。
12. **clawhubadapter 全量管理接口(已完成并验证)**:向 agent 暴露完整插件/通道管理面——
- 新工具:`clawhubadapter_plugin_info`(类型/工具/关联通道详情)、`plugin_reload`reloadPlugin
`channel_list`(全部注册通道 + 实时状态)、`channel_send`SendToChannel 投递)、
`channel_start`/`channel_stop`manager `channel/start`、`channel/stop` RPC
- manager 新增 RPC`plugins/channels`registeredChannels 摘要含 status/accounts
`channel/start`fire-and-forget startChannels 恢复账号)、`channel/stop`stopAccount
按 channel 全停或按 accountId 单停,省略 channel 则全部停止)。
- Go 侧 `channelSummary(mgr)` 合并 manager 注册信息与 `channel_status` 实时缓存(缓存优先)。
- 端到端验证生产实例LLM 为本地 mock 源):微信发"你好通道..." → agent 执行
`channel_list` → `- wechat | plugin=openclaw-weixin type=text running=true connected=true
accounts=[default]` → 回复送达;发"注入..." → 执行 `channel_send` → "消息已投递到通道
wechat" → 回复送达。standalone manager 另验证 `channel/start`mock-wechat startAccount
重新执行、心跳恢复)与 `channel/stop`stopAccount called
13. **插件删除回调 onRemove 全套(已完成并验证)**`RegisterOnRemoveHandler`(仅卸载触发、
重载/禁用不触发,与 stop handler 互补——stop 每次停止都执行。registry.RemovePlugin 流程:
stop handlers → Stop → runOnRemoveHandlers → 移除 plugins/sdkRefs/instances →
UnregisterPluginTools → **配置清理**。配置清理含两层:`ConfigRegistry.RemovePlugin`
删除 defs 中 `plugin.<name>.*` 配置项定义 + DROP `config_<name>` 插件配置表
含用户设置值ListPlugins 基于 config_% 表枚举故配置区完全消失);已用临时程序验证
before: defs=1/plugins=[timer] → after: defs=0/plugins=[]。示例盘点SDK 仓库 15 个):
calendarevents.json、memomemos.json、rss订阅数据目录、weather缓存目录已加
filesfilesDir 为用户配置的访问根目录,默认 /、bili/qq用户下载资产
ocr函数内 defer RemoveAll 自清理按语义不加plugindev 模板 main.go.tmpl + README.tmpl
含 onRemove 演示SDK README/README_EN 生命周期文档补"删除清理onRemove"小节。
14. [2026-08-03] SDK 工具链/打包/重装 + dlclose 修复:
- 工具链源码位置澄清SDK 仓完整内容位于 third_party/homeagent-sdk主仓 .gitignore 仅跟踪
sdk/meta/go.mod"两个远程仓库各取所需"/tmp/opencode/sdk-repo 为工作克隆,远程=gitcode
- 工具链支持公共 IOInjector.InjectInputSyncCORE_INJECT_INPUT_SYNC=47C 桥 dispatchIO
callString 回传回复文本);主仓 cabi loader case 47 用内部 4 参版 InjectInputSync 取
OutputEvent.Payload["content"] setResultmeta.go ID 47 + loader.go主仓 3f252ed
- plugindev 构建环境GOMODCACHE=/root/go/pkg/modyaegi 缓存所在、GOPROXY=off。
- 工具链打包 memoplg.json BOM 去除、name_en "Memo/Notes"→"Memo"toSnake 不处理斜杠,
name_en 带 / 会使 hmap 名含子路径报错bundle=true 时走全平台交叉编译(本机无 darwin
工具链),打包用 `build --no-bundle --target linux/amd64`;产物 dist/memo_linux_amd64.hmap。
- 重装pluginmgr HTTP API127.0.0.1:9876DELETE /plugins/memo 卸载(走内核 RemovePlugin
+ onRemove→ POST /plugins binary body 传 hmap返回 installed+checksum生效用
webui `POST /api/v1/plugins/reload`X-API-Key生产 admin123
- 关键 bugLinux dlopen 同路径复用旧句柄——RemovePlugin/ReloadOne 只 Stop 不 dlclose
插件二进制更新后重载仍执行旧代码(生产 memo 装新版仍注册旧 3 工具)。修复:
cabiPlugin.Close()handle.Close+ Registry.closeDynamic 在卸载/重载时调用(主仓 649e312
生产 homed-new7 验证memo 6 工具memo_todo_add/complete/list + memo_memo_create/list/delete
注册正常wechat 通道 running。
- SDK 仓推送 b6e30f9工具链 47 + 重建 bin 二进制 + memo plg.json + sdk/plugin.go 注释精简)。
15. [2026-08-03] 嵌套 git 恢复 + 工作区清理 + codegraph 索引修正:
- 嵌套 git 恢复third_party/homeagent-sdk 原本是"单仓库双提交"(目录内嵌套 .git 推 gitcode
homeagent-sdk 仓,主仓 git 跟踪 sdk/meta/go.mod 推 HomeAgent 仓),嵌套 .git 此前被误删;
已从 /tmp/opencode/sdk-repo 复制 .git 恢复remote=homeagent-sdk.gitHEAD=b6e30f9工作区干净
主仓 git 不受影响。以后 SDK 改动直接在 third_party 内 git commit+pushSDK 仓推送仍用带凭据
URL https://JianFeeeee:BCkb32xBuLxWD9P4MmU8ydZ5@gitcode.com/JianFeeeee/homeagent-sdk.git
不再经 /tmp 中转。
- /tmp 清理:删除 /tmp/opencode/sdk-repo、hasdk-fresh、plugindev-new、plugindev_new、mock-run、
lunartestSDK 中转/临时目录);保留 homed-new*生产二进制备份、mock-skills 等非 SDK 内容。
- replace 修正go.mod 第 17 行已是 `./third_party/homeagent-sdk`正确package-linux.sh
prepare_gomod 优先用 $PROJECT_ROOT/third_party/homeagent-sdk仅缺失时才 clone /tmp/homeagent-sdk
兜底(主仓 108faac
- codegraph 索引修正:根目录 codegraph.jsonPROJECT_CONFIG_FILENAME
includeIgnored+include: ["third_party/homeagent-sdk"]codegraph index 后 Files 179→233
third_party 文件 7→61tools/plugindev 与 sdk/plugin.goInjectInputSync 等)均可查询
(此前嵌套 SDK 仓被主仓 .gitignore 挡在索引外codegraph sync 不感知配置变更,需 index 全量重建。

View File

@ -420,7 +420,6 @@ data URL 本身已是 base64 文本,包进二进制传输省不了空间,还
## 八、关联文档
- `docs/zh/架构迁移评估.md` — 完整论证§3.2 method id 平移、§3.3 数据面、§3.4 SDK 封装、§3.5 回调型资源、§3.8 能力对齐)
- `docs/zh/plugin-migration-plan.md` — Part 0~6 执行计划与完成实录(含 Part 6.5 生产切换、Part 6.6 压测)
- `plan.md` §11 — 11.1~11.9 修复清单(唯一权威编号)
- `third_party/homeagent-sdk/sdk/` — 合同面 A 的代码实现(全程零 diff
- `internal/plugin/proc/protocol.go` — 合同面 B 的代码实现(`Method*` 常量,取代已删的 bridge 模板)

View File

@ -1,632 +0,0 @@
# 外部插件多进程化适配计划(修改→审查→验证三步微循环)
> 分支:`update`
> 基线:`docs/zh/plugin-interface-matrix.md`(合同面 A/B/C+ `plan.md` §11 + `docs/zh/架构迁移评估.md`
> 每部分 = 一个「修改 → 审查 → 验证」三步微循环。所有验证在 **update 分支**完成,可独立交付、可回退。
>
> **循环的铁律**(每部分适用):
> - **修改**:只动核心侧 + 工具链,`third_party/homeagent-sdk/sdk/`(合同面 A**零 diff**。
> - **审查**:接口冻结检查(`git diff` 公开 SDK 为空)+ 代码 review + `go vet`。
> - **验证**`make test` + 针对性单测 + 端到端冒烟,产物 `.bin` 端到端可用。
>
> 标 `【M】`=修改部分、`【R】`=审查部分、`【V】`=验证部分。依赖前置部分完成后才可开始。
---
## 目录
- **Part 0** 脆弱基线先行(不依赖迁移,现网可直接受益)— 0.1 ✅ / 0.2 ✅ / 0.3 ⏭️ / 0.4 ⏭️
- **Part 1** 加载分派骨架(`entry` 双通道共存)— ✅ **已完成**
- **Part 2** 子进程通道原型spawn / JSON-RPC / procPlugin— ✅ **已完成**
- **Part 3** plugindev 工具链改造(`.bin` 产物)— ✅ **已完成**
- **Part 4** 共享内存数据面StageContext 跨进程并发改写)— ✅ **已完成**(段/编解码/锁仲裁 + RunStage 接线)
- **Part 5** 通知面(事件环 + eventfd— ✅ **核心已完成**
- **Part 6** 迁移与收尾17 插件逐个 + 删 cabi + 权限显式化)
- 最终验收清单
> **进度快照2026-09-02**:分支 `feature/plugin-proc-migration`。
> 已交付:现网止血 2 项11.1/11.3、entry 双通道分派、共享内存 stage 并发、
> 子进程控制面NDJSON RPC + 51 method 名平移、plugindev `.bin` 构建、
> registry 接线、**事件环§3.6**。**外部插件已可端到端跑在子进程 + 共享内存上**
> 且首次获得事件订阅能力C ABI 下 case 23/24 一直是空实现)。
> 测试:内核 `internal/plugin/proc` 38 项 + `internal/plugin` 16 项(含 `-race`
> SDK 仓 plugindev 16 项。
> 下一步Part 6 逐插件迁移 + 删 `internal/plugin/cabi/`。
---
## Part 0脆弱基线先行阶段 0~1 人日)
> 依据plan.md §11.1/11.3/11.6。不依赖任何新架构,独立交付,现网直接受益。
> 目的:在副本模型内部打补丁,止血,为后续迁移争取时间。
### 0.1 output_send 假成功修复11.1)— ✅ **已完成**2026-08-31
- 【M】✅ `internal/plugin/cabi/loader.go`——`CORE_REGISTER_OUTPUT_CH`:454的异步 output 从「goroutine 直接返回 queued」改为「goroutine + 带超时 channel 等真实结果」。
新增 `awaitOutputResult`:276+ 可注入版 `awaitOutputResultWith`:281+ 常量 `outputSendTimeout = 10s`
```go
resCh := make(chan error, 1)
go func() { resCh <- invoke(pid, channel, argsJSON) }()
select {
case err := <-resCh:
if err != nil { return nil, err } // 真实失败上报
return map[string]interface{}{"status": "sent"}, nil
case <-time.After(timeout):
return map[string]interface{}{"status": "unconfirmed", "note": "..."}, nil
}
```
关键:`dev.Execute` 由 `executeOutputSendTool` 从 Go 侧调起(不在 cgo 栈内goroutine 内的 `pluginInvokeOutput` 才是 cgo**不构成嵌套**。
- 【M】✅ `internal/agent/core/output.go` `executeOutputSendTool`:识别 `status=unconfirmed|queued` → 返回「发送结果未确认:<note>」而非「已发送」,把未确认状态透传给模型。
- 【R】✅ 无 cgo 嵌套(`awaitOutputResult` 只在 `RegisterOutputChannel` 的 handler 内被调用,该 handler 从 Go 侧调起);
「超时未确认」措辞与 11.2 的"已取消"谎言区分——用 `unconfirmed` + 显式 note不谎报成功也不谎报失败。
- 【R】✅ 接口冻结:`git diff third_party/homeagent-sdk/sdk/` 为空。
- 【V】✅ 新增 `internal/plugin/cabi/output_test.go` 三用例全绿:
- `TestAwaitOutputResult_Success` → `status=sent`
- `TestAwaitOutputResult_Failure`(模拟 meta 缺 user_id→ **返回 error**(旧实现会谎报成功)
- `TestAwaitOutputResult_Timeout` → `status=unconfirmed` 且不返回 error
- 【V】✅ `go build ./...` exit 0`go test ./internal/plugin/... ./internal/agent/...` 全绿。
### 0.2 stage lost update 补丁11.3)— ✅ **已完成**2026-08-31
- 【M】✅ `templates.go`**SDK 仓** update 分支 `5648519``go_invoke_stage` 改为 diff 回传:
- 新增 `snapshotWritable(sc) map[string]string`——handler 前的**序列化**快照
- 新增 `changedFieldsOnly(before, after)`——只回传变更字段,无变更零回传
- ❗ **第一版踩坑并修正**`stageContextWritable` 返回的切片字段与 `sc` **共享底层数组**handler 原地改元素(`sc.ToolResults[0].Result = clean`)时 before 快照跟着变diff 看不到变更 → 修复会静默失效。故 before 必须逐字段序列化成字符串。
- 【M】✅ `internal/plugin/cabi/loader.go` `applyStageResult` 配套(本仓 `9bb9cb3``tool_calls`/`tool_results` 去掉 `len(v)>0` 拦截——改为键存在即应用,使插件「清空全部工具调用」的显式 `[]` 能被表达(旧插件仅 len>0 才带键,不会被误清空)。
- 【R】✅ `changedFieldsOnly` 无竞态(纯函数,无共享状态);只读插件零回传(单测断言)。
- 【R】✅ 接口冻结:两仓 `git diff sdk/` 均为空(只改 bridge 模版 + 内核)。
- 【R】✅ bridge 模版可编译性:抽取 `tmplLinuxBridge` + 真实 `weather/plugin.go` 做 `go build -buildmode=c-shared` → exit 0。
- 【V】✅ SDK 仓 `tools/plugindev/stagediff_test.go` 6 用例全绿:
- `_ReadOnlyPluginReturnsNothing`(只读插件零回传——修复核心)
- `_WriterReturnsOnlyChanged`(原地改切片元素仅回传 tool_results
- `_ScalarChange` / `_NewResponseIsReturned` / `_ClearedSliceIsReturnedAsEmpty`
- `_ProductionScenarioNoOverwrite`**复刻实验 13 现网场景**sanitizer 清洗 + weather 只读,清洗结果不再被覆盖)
- 【V】✅ 内核侧 `output_test.go` 新增 `TestApplyStageResult_ClearedSlicesAreApplied` / `_OnlyPresentKeysApplied` 全绿。
- 【V】✅ `go build ./...` exit 0`go test ./internal/plugin/... ./internal/agent/...` 全绿。
- ⚠️ **待部署项**:需用新 plugindev 重编全部 17 个外部插件bridge 模版变更),走 `plugin_install(overwrite=true)`。
### 0.3 reload 语义修正11.6)— ⏭️ **已跳过**2026-08-31 用户决策:直接进入进程化重构)
> 子进程模型下 `DF_1_NODELETE` 议题**整体消失**§3.1)——同路径替换 `plugin.bin` 重启进程即生效。
> 在 cabi 路径上补 ELF 检测属于「给即将删除的代码打补丁」,性价比低。
> 现网仍受 reload 假成功影响,但 Part 1 的 entry 分派已为迁移铺路,迁移完成即根治。
- 【M】`dynamic_loader_unix.go`ELF 检测 `DF_1_NODELETE` → 标记"不可热重载"。
- 【M】`registry.go` 的 `ReloadOne`:对此类插件返回"需重启 homed"。
- 【M】`pluginmgr/plugin.go` 的 `plugin_install`:返回 `restart_required` 替代 `reload_required`。
- 【R】确认 `.so` 插件重载不再"假成功"。
- 【V】单测mock ELF 头带 NODELETE vs 不带 → 正确区分。
### 0.4 超时日志措辞修正 + 附带11.2 短期项 + 11.4)— ⏭️ **已跳过**(同上)
> 11.2 的 cgo 超时不可中断在子进程模型下由 `Process.Kill()` 真正解决§9.5
> 11.4 的 Lua 路径在迁移后统一走 RPC三套 ABI 收敛),锁语义天然有边界。
- 【M】`internal/agent/core/toolcall.go:41`:日志从"已取消"改为"已放弃等待(插件仍在后台运行,其占用的线程无法回收)"。
- 【M】`internal/plugin/lua_plugin.go:726`stage 快照加 `sc.RLock()`/`RUnlock()`11.4)。
- 【R】措辞语义诚实Lua 快照持锁。
- 【V】`make test` 全绿;超时日志不再撒谎。
**Part 0 出口条件**11.1/11.3/11.6 全部落地并有针对性测试;生产可先部署(现网止血)。
---
## Part 1加载分派骨架阶段 2.4S
> 依据:迁移评估 §2.4 / 3.2plan.md 11.7。目标:让 registry 能按 entry 把插件分派到 `.so`cabi或 `.bin`proc两条通道——**双通道共存是整个计划可回退的前提**。
### 修改(核心)
- 【M】`internal/plugin/manifest.go``PluginManifest.Entry` 注释与 `IsPluginDir` 支持 `plugin.bin`。
- 【M】`internal/plugin/dynamic.go`:新增 `binEntry = "plugin.bin"` 常量;`readManifest` 读取 entry。
- 【M】`internal/plugin/registry.go` `loadOne`~:376把「无工厂 → `tryDynamic`」的分支改为按 entry 分派:
```go
switch entry {
case soEntry, dllEntry: p, err = r.tryLoadSO(...) // 现有 cabi
case binEntry: p, err = r.tryLoadProc(...) // 新增Part 2 填充)
default: p, err = r.tryOther(...) // lua / skill
}
```
先保留一个 `tryLoadProc` 桩(返回"未实现"错误),保证分派骨架先成立、可测。
- 【M】`internal/plugin/dynamic_loader_unix.go`:把 `tryLoadSO` 从 `tryDynamic` 拆出成 registry 可独立调用的函数。
### 审查
- 【R】确认内置插件`hasFactory` 分支)完全不受影响——仍走 `RegisterNative` 进程内路径。
- 【R】确认 `.so` 路径行为与今天逐字节一致(无回归)。
- 【R】接口冻结`git diff` 公开 SDK 为空。
### 验证
- 【V】单元测试mock 三种 manifestso/dll/bin/lua→ 分派到正确通道;`.bin` 桩返回明确错误而非 panic。
- 【V】既有 `.so` 插件加载 e2e 不回归(带一个真实 .so 冒烟)。
**Part 1 出口条件**:分派骨架在,`.bin` 有明确桩位,`.so` 全回归。
#### ✅ **Part 1 已完成**2026-08-31commit `610e9d0`
- 【M】✅ `dynamic.go`:新增 `binEntry`/`skillEntry` 常量 + `entryKind` 枚举 + `classifyEntry` / `detectEntryKind`
- **manifest 的 entry 优先级最高**——把 entry 改回 `plugin.so` 即回退 cabi 通道(回退路径的保证)
- 无 manifest 时按目录探测,`.bin` 优先于 `.so`(迁移期同目录两产物共存时走新通道)
- 【M】✅ `registry.go` `tryDynamic`:按 entry 分派 proc/cabientry 声明 `.bin` 但二进制缺失时**报明确错误,不静默回退**
- 【M】✅ `registry.go` `pluginEntryHash`:候选顺序与 `detectEntryKind` 对齐(`.bin` 优先),否则增量重载会用错文件算 hash
- 【M】✅ `manifest.go``Entry` 字段注释补 `plugin.bin`
- 【M】✅ `dynamic_proc_unix.go` / `dynamic_proc_windows.go``tryLoadProc` 桩位(存在性/类型/可执行权限校验已实现)
- 【R】✅ 内置插件(`hasFactory` 分支)完全未受影响——仍走进程内 `RegisterNative`
- 【R】✅ `.so` 路径行为与改动前一致(既有测试全绿,无回归)
- 【R】✅ 接口冻结:`git diff third_party/homeagent-sdk/sdk/` 为空
- 【V】✅ `entry_dispatch_test.go` 9 项全绿:
- `TestClassifyEntry`8 种 entry 分类)
- `TestDetectEntryKind_ManifestWins` / `_ManifestCanForceRollback`**回退路径验证**
- `TestDetectEntryKind_ProbeOrderPrefersBin` / `_ProbeFallbacks`4 子例)
- `TestTryLoadProc_MissingBinaryReturnsNil` / `_NonExecutableRejected`
- `TestPluginEntryHash_PrefersBin` / `_EmptyForFactoryOnlyPlugin`
- 【V】✅ `go build ./...` exit 0`go test -race ./internal/plugin/...` 全绿;全量 32 个包测试通过
---
## Part 2子进程通道原型阶段 2.1~2.3/2.5/2.9~3 周,核心风险点)
> 依据:迁移评估 §4.1 阶段 2迁移评估指明可大幅参考 `clawhubadapter/sidecar.go:54-350`(已有 stdin/stdout + pending map + notifyCh
> 目标把单个外部插件weather以 `plugin.bin` 端到端跑通,验证"接口不变"假设。
### 修改(核心)
- 【M】新建 `internal/plugin/proc/`
- `process.go`——`procPlugin` 实现 `sdk.Plugin` 接口;`spawn`/健康检查/优雅停止/`Close()`=真 kill+wait。
- **可参考** `clawhubadapter/sidecarProcess``exec.Cmd` + `stdin *bufio.Writer` + `readLoop`scanner 大 buffer 64KB+ `pending map[int]chan<- []byte` + `notifyCh chan OCNotification` + readerStop/readerWg。
- `rpc.go`——双向 JSON-RPC 编解码7 个 kernel→plugin 调用(`tool.invoke`/`stage.invoke`/`output.invoke`+ 51 个 plugin→kernel 回调(平移自合同面 B 映射表)。
- 【M】`internal/plugin/dynamic_loader_unix.go`:实现 `tryLoadProc`spawn `.bin`,回连 stdio RPC
- 【M】`internal/plugin/registry.go` `closePlugin`/卸载路径:对 proc 插件 `Close()` 真 kill。
- 【M】`internal/agent/core/plugin_health.go` 调用侧:插件**退出码/EOF** → `recordCrash`**逻辑完全复用**,仅把"panic 捕获"换成"进程退出检测",见迁移评估 §2.3)。
### 审查
- 【R】`readLoop` 鉴权:只接受来自本进程 spawn 的 stdout防注入
- 【R】JSON-RPC 帧边界处理(`bufio.Scanner` 长行截断风险——沿用 sidecar 64KB buffer
- 【R】pending map 泄漏:超时清 map、退出时清 map。
- 【R】崩溃重启`SetAutoRestart(true)` 语义保留;`plugin_health` 冷却/自愈复用。
- 【R】接口冻结公开 SDK 零 diff。
### 验证
- 【V】单测spawn→握手→工具调用往返→正常 Stop→kill 崩溃→退出码捕获。
- 【V】weather `.bin` 端到端:`RegisterTool`/`Settings`/`InjectInputSync` 全部经 stdio RPC 打通。
- 【V】与 Part 1 的 entry 分派联动:同目录 `.so` 与 `.bin` 共存互不干扰。
**Part 2 出口条件**:一个真实外部插件 `.bin` 全链路可用,崩溃隔离生效,接口零改动。
#### ✅ **Part 2 已完成**2026-09-01commit `d62430a` + `82dcc86`
- `proc/protocol.go`NDJSON 帧、**51 个 method id 平移为 method 名**(编号扔掉)、握手/stage/tool/output 参数类型。
`case 25`(CORE_FREE_STRING) 无对应 methodGC 接管);`case 23/24`(事件订阅) 与 `io.setToolBlocks`
明确返回未实现,**不静默成功**。
- `proc/process.go`Spawn/readLoop/CallContext/Notify/Stop/Kill/markExited单帧上限 1MB。
- `proc/corehandler.go`51 case 平移 + `CoreSDK` 接口(**刻意排除**内核内部机制,见 Part 6 权限梯度)。
- `proc/host.go`**全部插件共享同一 memfd**。最初写成每插件一块段,尝试后发现
那等于**副本模型换壳**(各写各段、各自回读、最后回读者覆盖前者),已改正。
- `proc/stage.go`RunStage 接线 + lockRegistry`proc/plugin.go`Plugin 实体。
- 共享段分配按平台拆分(`shmalloc_linux.go` memfd / `shmalloc_darwin.go` 立即 unlink 的临时文件 /
`shmalloc_other.go` 明确报错)——不静默降级成「无共享段」,那会让 stage 静默失去数据面。
- registry 接线commit `11c1bbc``tryDynamic` → `Registry.loadProc`Host 惰创建且全局唯一;
`StopAll` **锁外**释放共享段(插件还持有映射时拆段 → SIGBUS持锁调与 onProcCrash 有锁序风险);
`onProcCrash` 只发 EventSystem 事件,**不在回调里直接重载**(重载需 registry 锁)。
- `proc_core.go` —— 权限梯度的类型系统落点:`procCore` 用**命名字段**持有 `*isdk.PluginSDK`
不是嵌入。嵌入会提升全部方法,外部插件就能经类型断言拿到
Supervisor/Tracker/Adapter/Indexer/Status/Selftest。
- 测试 36 项含 `-race``testdata/` 8 个假插件 + `e2e_template_test.go` 用**真实 plugindev 模板**
编译插件跑全链路(验证「模板 ↔ 内核」协议/布局真的对齐,不只是内核自己跟自己对齐)。
---
## Part 3plugindev 工具链改造(阶段 2.6/2.7/2.8MSDK 仓)
> 依据:合同面 B迁移评估 §4.1。此部分在**独立 SDK 仓**维护(用户决策 sdk_repo_only
> 目标:让外部插件能用普通 `go build` 产出 `.bin`,业务代码零改动。
### 修改(工具链)
- 【M】`tools/plugindev/templates.go`:新增 `tmplProcMain`——把 bridge 从「7 个 `//export` + `-buildmode=c-shared`」改为「`main()` + stdio JSON-RPC loop」注册逻辑`buildPluginSDK` 的 registar 闭包)从 `callVoid(id,...)` 改为 `sendRPC(methodName,...)`(合同面 B 的平移)。
- 【M】`tools/plugindev/cmd_build.go`
- 新增目标 `plugin.bin``go build`(去 `-buildmode=c-shared`、`CGO_ENABLED=0`)→ `plugin.bin`。
- bundle 平台表:`{"linux/amd64","plugin.bin"}`(替代 `.so`)。
- `resolveBuild`bin 分支不再需 C 编译器。
- 【M】`tools/plugindev/cmd_build.go` `validBinaries`/打包:`.hmap` 内条目支持 `plugin.bin``plugin.json` entry 写 `plugin.bin`)。
- 【M】`plg.json` 模板(`tmplPlgJSON``entry` 默认改为 `plugin.bin`(保留 `.so` 兼容)。
### 审查
- 【R】生成的 `tmplProcMain` 与旧 bridge 的 SDK 方法一一对应(对照合同面 B 51 行映射表逐行核对)。
- 【R】业务代码**零改动**证据:同一 `plugin.go`,仅入口文件/构建命令不同。
- 【R】交叉编译简化确认`.bin` 无需 cgo 工具链,跨 GOOS 仅需目标 toolchain。
### 验证
- 【V】用新 plugindev 重编 `example/weather` → 产出 `plugin.bin`。
- 【V】`.hmap` 打包/解包校验:`plugin.bin` 条目正确登记。
- 【V】与 Part 2 集成weather.bin 被 homed proc 通道正确加载运行。
**Part 3 出口条件**plugindev 一条命令产出 `.bin` + 正确 `.hmap`,外部插件源码零改动。
#### ✅ **Part 3 已完成**2026-09-02SDK 仓 commit `09b64dc`
**模板落地方式换了**:不是计划里的 `templates.go` 新增 `tmplProcMain` raw string
而是真实 `.go` 源文件 `templates/proc_main.go.tmpl` + `//go:embed``proc_runtime.go`)。
原因900+ 行代码塞在字符串里写错只能等生成插件时才炸,作为源文件可被
`go/parser`、`gofmt`、`go vet` 直接检查。这也是 `proc_runtime_test.go` 16 项
静态检查得以存在的前提。
- `templates/proc_main.go.tmpl`1113 行51 个 method 的插件侧 RPC 实现
`procIO`/`procMemory`/`procSettings`/`procSocial`/`procLLM`/`procKnowledge`/
`procDocMemory`/`procTextMemory`/`procPluginMgr`、共享段访问fd 3与 16 字段
StageContext 编解码、`handleStageInvoke`(拿锁 → 读段 → handler → **只写脏字段** → 放锁)。
- `cmd_build.go``resolveBuild(target, proc)` 分派proc 走 `go build -trimpath` + `CGO_ENABLED=0`
**交叉编译不再需要目标平台 C 工具链**。bundle 模式各平台产物同名(进程边界即 ABI 边界,
无平台扩展名),故 zip 内加平台后缀 `plugin.bin.linux.amd64`。
- `proc_runtime.go`:生成时清理残留 `z_bridge_gen.go`/`z_entry.c`——同目录两套 main 会编译冲突,
这让 `.so` → `.bin` 切换无需人工清理。
**计划外补的一个真缺口**`lifecycle.autoRestart` 没接线。公开 SDK 的 `SetAutoRestart`
是纯 setter`s.autoRestart = enabled`,无回调 hook。C ABI 下内核在 `Start` 返回后
直接读 `plgSDK.AutoRestart()`;子进程隔着进程边界读不到,插件调它只改自己进程内的副本。
修法:模板在 `plg.Start()` 返回后显式上报一次(内核侧 `corehandler.go:145` 早已就绪)。
**没有改公开 SDK 接口**。
验证(均已实测):
```
$ plugindev build # plg.json: entry = "plugin.bin"
compiling linux/amd64 (子进程模式CGO_ENABLED=0)...
packaged weather_linux_amd64.hmap
build/plugin.bin → ELF 64-bit executable, statically linked ← 零 cgo
dist/*.hmap → plugin.json + plugin.bin
$ diff example/weather/plugin.go <构建目录>/plugin.go
✅ 逐字节一致 ← 业务代码零改动的硬证据
$ git diff third_party/homeagent-sdk/sdk/
(空) ← 接口冻结保持
```
---
## Part 4共享内存数据面阶段 3.1~3.5~3 周,最高风险)
> 依据:迁移评估 §3.3 数据面 / 3.4 SDK 封装 / 3.7 锁仲裁;合同面 C。
> 目标:多插件并发改写同一 `StageContext` 语义与今天一致(丢失率 → 0外部插件看到全部 16 字段。
### 修改
- 【M】`internal/plugin/proc/` 新增 `shm.go`
- 共享段 schema`ShmStageCtx` + `Slice{off,len}` 偏移描述符 + arenaappend-only + 压实)。
- arena 分配器:插件把 `FinalText` 从 10B 改 10KB 时分配新区域、旧区域留垃圾、stage 结束后压实。
- 4 个 `Extra` 键media_blocks/media_type/input_source/output_channel提升为具名字段迁移评估 §3.3 已核实全部使用点)。
- 段生命周期:创建/挂载/插件崩溃后清理。
- 【M】`internal/plugin/proc/shmcodec.go``StageContext` ↔ 共享段编解码偏移↔Go 值转换)。
- 【M】`internal/plugin/proc/lock.go`**锁仲裁 RPC**——插件 `Lock/RLock` → `stage.lock`/`stage.unlock` → 内核 `sync.Mutex` 排队(迁移评估 §3.7 已裁定,实验 3+9 支撑)。
- 【M】`internal/agent/core/stages.go` `RunStage`:改造为跨进程并发扇出(**保留并发语义,最难一环**)——内置插件仍进程内 `go func`,外部插件走共享段 + 锁仲裁。
- 【M】SDK 侧(插件进程内)封装全部复杂度(迁移评估 §3.4):插件保留原生 `StageContext`handler 照常读写,脏字段写回共享段。
### 审查(最高优先级 review
- 【R】**并发语义一致性**内置0% 丢失)与外置(迁移前 35.8~36.8%)在共享内存下都收敛到 0% 丢失。
- 【R】锁仲裁死锁持锁进程崩溃自愈实验 9 已证无需 robust mutex
- 【R】arena 单 stage 写入上限:大写入在 SDK 层**报错**而非静默截断(迁移评估 §4.4)。
- 【R】`Extra` 不引入通用 tagged union 成本(维持 4 键具名字段)。
- 【R】接口冻结`sdk/` 零 diff`StageContext` 结构体字段序不变。
### 验证
- 【V】复刻实验 85 子进程 × 300 轮并发改写 → **零丢失零撕裂**。
- 【V】复刻实验 13 现网场景sanitizer改 ToolResults+ weather只读并发 → 清洗结果不再被覆盖。
- 【V】改写型插件行为基线测试`sanitizer`/`multimodal` 迁移前后行为对拍(迁移评估 §4.4 风险缓解)。
**Part 4 出口条件**:跨进程并发改写零丢失,内置/外置语义一致16 字段全可见。
#### ✅ **Part 4 核心已完成**2026-08-31commit `610e9d0`)—— 段 / 编解码 / 锁仲裁三件套
> 用户明确指出「基于共享内存的 stage 并发是最为关键的」,故先于 Part 2/3 落地数据面。
> `RunStage` 的跨进程接线3.4)待 Part 2 的进程通道就绪后进行。
- 【M】✅ `proc/shm.go` 段布局与 arena 分配器§3.3
- `Header(64B) + ShmStageCtx(描述符数组 + 标志位) + append-only arena`
- **相对偏移**:各进程 mmap 到不同虚拟地址仍能正确解引用
- `NewSegment` / `AttachSegment` 带魔数 + 版本校验(版本不匹配显式报错,不静默错读)
- **arena 用尽显式报错**而非静默截断§4.4 风险登记的硬要求)
- `Compact()` 回收 append-only 垃圾,须在无插件持锁时调用
- 【M】✅ `proc/shmcodec.go` StageContext 16 字段跨进程编解码§3.4
- **字段级描述符消除 lost update**:只改 `FinalText` 的插件完全不触碰 `ToolResults` 描述符
- `WriteDirty` 只写脏字段——**只读插件零写入**,不可能覆盖他人改写
- `Snapshot` 存**序列化字符串**切片共享底层数组的坑C ABI 侧修 11.3 时已踩过一次)
- `Extra` 4 键提升为具名字段;`Response` 用标志位区分 nil 与空串(短路语义)
- **全 16 字段可见**——今日经 C ABI 只有 10 个,`ContextMsgs`/`ReasoningContent`/`TokenUsage`/`Memory`/`Extra`/`Errors` 首次对外部插件可见
- 【M】✅ `proc/lock.go` 锁仲裁回归内核§3.7 已裁定,**零 cgo**
- `ForceRelease` 实现实验 9 的崩溃自愈 → 排除 robust pthread_mutex 必要性
- 重复加锁**显式拒绝**(否则死锁 30s比挂死更难排查
- 等待超时有补偿 goroutine 防锁永久泄漏
- 【R】✅ 并发语义:`TestSegment_ConcurrentAppend_NoLostUpdate` 断言「各标记计数之和 == 最终长度 且 == 期望写入次数」,同时排除丢失与撕裂
- 【R】✅ arena 上限报错(非静默截断):`TestSegment_ArenaExhaustionReturnsError`
- 【R】✅ `Extra` 维持 4 键具名字段,未引入通用 tagged union 成本
- 【R】✅ 接口冻结:`sdk/` 零 diff`StageContext` 结构体未改
- 【R】✅ `go vet` 干净(含 copylocks 检查)
- 【V】✅ proc 包共享段部分 **16 项测试全绿(含 `-race`**(全包现 36 项,含进程/端到端):
- 段:魔数/版本校验、全 16 字段往返、Response nil vs 空串
- 脏字段:只读零写回、原地改切片被识别、压实不破坏字段
- **现网场景复刻**`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`sanitizer 清洗 + weather 只读并发,清洗结果不被覆盖)
- **并发零丢失**5 插件 × 40 轮读-改-写同一字段200 次写入全部保留
- 锁:互斥、串扰拒绝、未持锁释放拒绝、重复加锁拒绝、**崩溃自愈**、定向强制释放、临界区串行化
#### ✅ **Part 4 RunStage 接线已完成**2026-09-0109-02
- `proc/stage.go` 把内核 `RunStage` 的并发扇出接到共享段:
`Host.beginStage`(首个到达者独占段并写入 StageContext→ `stage.invoke` RPC →
插件侧 `stage.lock` → 读段 → handler → 只写脏字段 → `stage.unlock` →
`Host.endStage`(最后离开者回读 + 压实 arena
- **并发扇出保留**§0.2 第 1 条:并发扇出是原始设计,不是缺陷);
`stageMu` 串行化整次 stage 对共享段的独占(内核可能在不同路径并发触发
RunStage而段只有一份
- 端到端验证(`e2e_template_test.go`,用**真实 plugindev 模板**编译的插件,
而非 `testdata/` 手写假插件——后者只能验证内核自己跟自己对齐):
- `TestE2E_RealTemplatePluginFullLifecycle`:握手 → init/start → 反向注册 →
工具调用 → stage 读改写;同时验证 `FinalText` 回传
**C ABI 下 after_toolcall 看不到此字段**§8.3 10→16
- `TestE2E_RealTemplateReadOnlyPluginDoesNotOverwrite`:两插件共享同一 Host 并发,
只读插件不覆盖改写插件的结果(若每插件一块段,此测试必然失败)
**Part 4 已整体完成**。
---
## Part 5通知面阶段 4.1~4.5~1.5 周)
> 依据:迁移评估 §3.6 事件环 / §2.4 约束 B / §3.8。目标:外部插件首次获得事件订阅能力,且不阻塞流式输出。
### 修改
- 【M】`internal/plugin/proc/eventring.go``EvtRing` + `Subscriber` schemawrite_seq/read_seq/dropped/type_mask/last_seen溢出计数、允许丢但让消费者知道丢了。
- 【M】eventfd 通知 + Go netpoller 消费:`unix.Eventfd(EFD_NONBLOCK|EFD_CLOEXEC)` + `os.NewFile` 注册 netpoller**不占 OS 线程**——实验 1 已证 200 goroutine 仅 +1 线程)。
- 【M】`internal/events/bus.go` `Publish`:加事件环投递(**post-and-forget绝不等待消费者**,满足约束 B
- 【M】实现 `case 23/24`(今天空实现)——`Events().Subscribe` 对外部插件真正可用。
- 【M】订阅者活性检测`last_seen` 超时 → `recordCrash`。
### 审查
- 【R】`Bus.Publish` 路径**禁用任何锁/阻塞**——流式输出逐 token 发布,任何等待都会卡顿(迁移评估 §4.3 风险高)。
- 【R】溢出语义drops 计数暴露,不静默丢。
- 【R】eventfd 计数合并1000 token 事件只唤醒几次。
### 验证
- 【V】流式压测长回复下 Publish 单次耗时不随订阅者数线性恶化。
- 【V】复刻实验 4post-and-forget 解耦5s → 2.3ms 量级)。
- 【V】外部插件订阅事件端到端原空实现 case 23/24 现在可用)。
**Part 5 出口条件**:事件订阅对外可用,流式输出无卡顿。
---
## Part 6迁移与收尾阶段 5.1~5.4~2 周)— ✅ **已完成**2026-09-03
> 依据:迁移评估 §4.5 双通道共存、§5 权限梯度。
>
> ⚠️ **实际执行偏离计划的一处**:原计划「逐插件迁移,随时回退」。
> 用户决策改为**彻底舍弃 `.so` 能力,无回退通道**(不做 `--cabi` 开关),
> 本轮直接删 `internal/plugin/cabi/`,生产全量切换。代价是某插件出问题
> 只能紧急修复或 `git revert` 整批。因此下方【V】的「`.so` ↔ `.bin` 混跑」
> 不再适用——新内核根本不认 `.so`。
### 修改
- ✅【M】**6.1** 工具链 entry 语义收敛SDK 仓 `9f84412``isProcEntry` 删除Go 插件一律产出 `plugin.bin` 不看 entry 值;`templates.go` 1296→516 行。
- ✅【M】**6.3** 17 插件全量重编(`1d7f011`16 个×3 平台 + qq×1`git status example/` 无输出(业务代码零改动)。
- ✅【M】**6.5** 生产切换(`62bdfa2`):经 `pluginmgr` 的 hmap 正规通道安装17/17 成功且 `config_kept=true`。
- ✅【M】**6.6** 压测 + 版本 1.0.0 + 文档(`2572688`、`670efcd`、tag `v1.0.0`)。
- ✅【M】**6.2** 内核侧 Windows`d027c96`+ 删 C ABI`b20121f`-3198 行):删 `internal/plugin/cabi/`(1156)、`dynamic_dll_windows.go`(272)、`dynamic_loader_unix.go`(79) + bridge 模板;新增 `shmalloc_windows.go` + `evtfd_windows.go` + `shmpass_{unix,windows}.go`;顺带修 macOS pipe 写端被 GC 回收的真 bug。
- ✅【M】**6.4** 权限梯度显式化(`2ebdb9a`54 个 method 划入 11 个 capability 组;`coreHandler.Handle` 入口强制;`withheldCapabilities` 表记录 10 项刻意不提供的内核机制及理由(`SelftestAPI`/`SupervisorAPI`/`TrackerAPI`/`StatusAPI`/`AdapterAPI`/`ConfigAPI`/`ToolAPI`/`IndexerAPI`/`OutputChanRaw`/`EventPublish`)。
- ⏭【M】`lua_plugin.go`/`dynamic_lua.go` 统一走 RPC —— **留待后续**。Lua 走解释器不经 C ABI不阻塞本轮目标消除 C ABI 前提缺陷)。收敛第三套 ABI 是独立优化。
- ✅【M】文档本文与 `plugin-interface-matrix.md` 更新;切换实录见下方。
### 审查
- ✅【R】每删一个 cabi 依赖项,`go build ./...` + `go vet ./...` 干净。
- ✅【R】权限梯度被拒 API 在 RPC 边界返回**明确错误**(非忽略)。错误消息含四要素:哪个插件、哪个调用、缺什么能力、在哪声明。`TestCapability_DeniedErrorIsActionable` 守护。
- ✅【R】接口冻结`git diff third_party/homeagent-sdk/sdk/` 全程为空。
### 验证(全量回归)
- ✅【V】17 插件经 `plugin_install(overwrite=true)` 加载,工具/设置/通道/阶段 e2e。
- ⏭【V】~~`.so` ↔ `.bin` 混跑集群冒烟~~ —— 不适用(无回退通道,见上方偏离说明)。改为验证**新内核面对旧 `.so` 给可操作错误且不崩溃**,已在真实二进制上确认。
- ✅【V】`make test` 全量绿 + `go build ./...`。
- ⚠【V】内存**未达成计划目标**。15 个插件进程 RSS=88.0MB / PSS=87.9MB,远超「基线 +29MB」。根因是每插件静态链接整个 Go runtime15 个不同二进制无共同物理页可映射PSS/RSS 99.9% vs 基线 44%)。这是「每插件独立二进制」的固有代价,实际开销高于 §4.3 乐观估计。压缩方向:共享 launcher 二进制 + 各自业务模块。
- ✅【V】工具调用 RPC 延迟 24.1µs实验 11 基线 19.6µs同量级
**Part 6 出口条件**:全部外部插件 `.bin` 化 ✅cabi 删除 ✅,接口零改动 ✅,权限显式化 ✅,无回归 ✅。
---
## 最终验收清单(对照接口不变矩阵 §7 检查点)
| # | 检查点 | 通过标准 | 结果 |
|---|---|---|---|
| 1 | 公开 SDK 接口冻结 | `git diff third_party/homeagent-sdk/sdk/` **为空**(全程) | ✅ 每次审查均确认 |
| 2 | 外部插件业务代码零改动 | 17 个 `example/*/plugin.go` 与基线逐字节可比 | ✅ `git status example/` 无输出 |
| 3 | 17 插件 `.bin` 化 | 全部经 `plugin_install` 加载,工具/设置/通道/阶段 e2e | ✅ 17/17`config_kept=true` |
| 4 | cabi 删除 | `internal/plugin/cabi/` 与 bridge 模板不存在 | ✅ -3198 行(`b20121f` |
| 5 | 崩溃隔离 | 插件 kill 只退出自身homed 存活 | ✅ `TestRealPlugin_CrashDoesNotKillKernel` |
| 6 | 热重载 | 同路径换 `.bin` 即生效,无需重启 | ✅ 生产实测(`unloaded (config kept)` → 重载) |
| 7 | 并发改写 | 跨进程 stage 丢失率 0%(对照今天 35.8~36.8% | ✅ `TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate` |
| 8 | 事件订阅 | 外部插件 `Events().Subscribe` 可用 | ✅ 事件环已接线(当前零用户) |
| 9 | 多模态 | `SetToolBlocks` 非空实现 | ⚠️ method 已定义并划入 core 能力,内核侧仍返回未实现 |
| 10 | 超时取消 | 工具超时可 `Process.Kill()`,零泄漏 | ✅ 整套新架构零 cgo |
| 11 | output_send | 真实结果返回(非假成功) | ✅ 生产实测 `map[status:sent]` |
| 12 | 权限梯度 | 内部专属 API 在 RPC 边界拒绝 | ✅ 12 项测试(`2ebdb9a` |
| 13 | 内存/延迟 | 常驻 +≤29MBRPC p50 ≤20µs 量级 | ⚠️ 延迟 24.1µs 达标;内存 88MB **未达标** |
**两项未完全达标的说明**
- **#9 SetToolBlocks**`io.setToolBlocks` 已在 protocol 定义并划入 `CapCore`
但内核侧 handler 仍返回未实现。C ABI 时代它也是空实现§1.4
故**不是回归**,但也没兑现 §3.8 的承诺。当前无插件使用。
- **#13 内存**15 个进程 RSS=88.0MB,远超「基线 +29MB」。根因是每插件
静态链接整个 Go runtime15 个不同二进制无共同物理页PSS/RSS 99.9%
vs 基线 44%)。实验 5 的基线用的是 2.68MB 最小插件,而真实插件 3.1~14.8MB
绝对数字不可比。结构性指标(均摊线程 5.5 vs 4.9)同量级。
---
## 风险与回退
| 风险 | 缓解 | 回退 |
|---|---|---|
| Part 2/4 `RunStage` 并发语义漂移 | 复刻实验 8/13 + sanitizer/multimodal 对拍Part 4 review | entry 分派切回 `.so`Part 1 双通道) |
| Part 4 `Bus.Publish` 阻塞卡顿 | 专项流式压测Part 5 | 事件环投递后置,先降级进程内 |
| Part 3 工具链 `.bin` 产物问题 | 单插件 weather 先行验证 | 保留 `.so` 构建分支 |
| Part 6 17 插件回归 | 逐个迁移 + `plugin_install(overwrite)` | 任意一个失败立即回退该插件 entry |
| 接口意外漂移 | 每部分【R】强制 `git diff sdk/` 检查 | 立即 revert暴露合同面违约 |
---
*规划2026-08-31update 分支。Part 编号与其依赖的 plan.md/迁移评估阶段对应。*
---
## Part 6.5 生产切换实录2026-09-03
### 执行顺序(先换二进制,再装包)
```
1. systemctl stop homeagent
2. 换 /usr/local/bin/homed
3. 起服务 —— 15 个 .so 插件报可操作错误被跳过homed 与 16 个内置正常
4. 逐个 POST 装 17 个 hmapoverwrite=true
5. 重启核对
```
**为何不能反过来**:若先装包,旧 homed 的 `StopAndUnload` 会停掉 qq
消息通道,而它又无法加载 `.bin`,会卡在「插件全挂」的状态。
第 3 步顺带在真实二进制上验证了 Part 6.2 的可操作错误:
```
[plugin] dynamic weather: plugin weather: 检测到旧 C ABI 产物plugin.so/.dll/.dylib
外部插件已改为子进程模式,请用新版 plugindev 重编产出 plugin.bin业务代码无需修改
```
不崩溃,只跳过该插件。
### 走 hmap 正规通道,而非手工拷贝
第一版切换脚本是手工拷 `plugin.bin` + 手改 `plugin.json` 的 entry ——
那等于**重新实现了一遍 hmap 解包逻辑,且实现得更差**。漏掉的东西:
| | 手工拷贝 | hmap 正规通道 |
|---|---|---|
| `platforms` 字段 | 漏了 | 包内 manifest 本来就写对 |
| 平台二进制选择 | 硬编码 `_linux_amd64` | `platformBinary()` 按 runtime 选 |
| `overwrite` 语义 | 无 | `StopAndUnload` **保留配置表** |
| 失败回滚 | 无 | `os.Rename` 备份,解包失败自动恢复 |
| 校验 | 只查文件存在 | `validatePackage` 查 manifest + 各平台二进制齐全 |
配置保留那条尤其关键:生产 17 个插件都有配置qq 账号、weather 默认城市、
browser profile 路径)。手工脚本恰好没碰配置表所以侥幸不丢,但那是运气不是设计。
最终实现POST 到 `127.0.0.1:9876/plugins`,传 `{path, overwrite:true}`。
保留的一个设计是**先全部校验再动手**——任一插件缺 hmap 就整批中止,
因为新 homed 不认 `.so`,「一半装了一半没装」的中间态最难排查。
### 结果
```
17/17 成功,全部 config_kept=true
0 个残留 .so17 个 plugin.bin 均有执行位
17 个 manifest 的 entry 均为 plugin.bin无 .bak 残留
bundle 包正确挑了当前平台weather 目录只留 8.7MB 的 linux/amd64 那份)
```
备份:`/home/newqqagent-migration-backup-20260902-214812`
plugins 全目录 + homed.old + homeagent.service162MB
**唯一回滚路径**是恢复该目录 + 回滚 homed 二进制。
### 生产端到端验证(真实 QQ 消息)
```
input from qq → response (83293ms, tools=[qq_get_message qq_get_history
output_send__qq output_send__qq qq_mark_read])
```
逐环节:
- **输入**qq 子进程收 webhook → 经 RPC 报给内核 → agent 主循环
- **工具调用**5 次跨进程调用全部成功(内核反向调用进子进程执行)
- **stage 改写生效**(最关键的一条):
```
[sanitizer] cleanToolCallLeakage: 2 bytes removed
[sanitizer] cleaned 2 bytes (before=13590 after=13588)
[proc] sanitizer stage post_action 改写了 1 个字段
```
sanitizer 在**另一个进程里**改了 StageContext内核读到了改写结果。
13590 字节文本经共享段传递、被改写、写回,全程未拷贝整个上下文。
- **输出真的送达**`tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]`
—— 直接验证 Part 0.1 修的 output_send 假成功缺陷§9.4
- **arena 生命周期正常**:每次 stage 结束都压实回收(单次最高 15802 字节),无泄漏累积
这一次对话触发约 20 次 stage、5 次工具调用、2 次输出发送,跨越 15 个插件子进程。
旧架构下同样流程有三处会静默出问题stage 并发写丢字段§8.4 实测 35.8~36.8%
lost update、output_send 假成功、cgo 超时泄漏 goroutine。现在这些在日志里可见且正确。
---
## Part 6.6 压测与延迟实测
基准与压测在代码里(`internal/plugin/proc/bench_test.go` + `streaming_test.go`
非独立脚本——随代码演进自动跑,不会腐坏。
| 项目 | 实测 | 基线 | 判断 |
|---|---|---|---|
| 工具调用 RPC 往返 | 24.1 µs | 实验 11: 19.6 µs | 同量级 |
| 锁仲裁(内核侧) | 0.76 µs | — | 见下注 |
| 事件环写入 | 95 ns | — | 亚微秒 |
| 事件环并发写入 | 83 ns | — | 无锁竞争恶化 |
| 完整 stage 往返 | 132 µs | — | 含 3 次进程间往返 |
| 共享段编解码 | 3.7 µs | — | 占 stage 的 2.8% |
**锁仲裁 0.76µs 不可与实验 3 的 19.40µs 对照**——测的不是同一个东西:
实验 3 测插件经 RPC 请求锁的完整跨进程往返,本基准只测内核侧
`lockRegistry.acquire/release`。真实成本仍在 20µs 量级。基准原名
`BenchmarkStageLockRoundTrip` 有误导性,已改为 `BenchmarkStageLockArbitration`。
**stage 往返 132µs 的成本构成**:共享段编解码只占 3.7µs其余是
**一次 stage 要走 3 次进程间往返**`stage.invoke` + 插件侧反向的
`stage.lock` / `stage.unlock`)。相对 LLM 往返 2-8 秒可忽略;
要优化的方向是把 lock/unlock 合入 `stage.invoke` 的请求/应答。
### 流式压测§4.3 标记「风险高」的那一项)
```
5000 次 Publish + 每条睡 20µs 的慢消费者
实测 2.29ms,均摊 457 ns/token
同步语义理论下限 100ms
订阅者 1 个1.547ms515 ns/次)
订阅者 8 个1.518ms506 ns/次) ← 无线性恶化
环溢出(无消费者写 30000 次cap=8192均摊 35 ns/次 ← 仍 O(1)
```
2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token
post-and-forget 在实现中成立。第三项的意义:消费者完全停摆时写端覆盖
最旧 slot这条路径仍是 O(1),故「插件卡住」不会连带拖慢内核主循环。
---
## 版本号
v1.0.0tag 已打)。公开 SDK 接口零改动,但产物形态从 `plugin.so` 变为
`plugin.bin`0.9.x 内核不会识别——不可互操作的破坏性变化,故跃主版本号。
⚠️ **Makefile 陷阱**`VERSION ?= $(shell git describe --tags --dirty)`
意味着实际注入值来自 git tag`meta.go` 里的默认值只在不带 ldflags 时生效。
打 tag 前 `make build` 注入的是 `v0.9.1-56-g2572688-dirty`。
同时删掉 C ABI 时代的死常量(`ABIVersion`/`CABINum`/51 个 `Core<Method>`
整数 ID——随 Part 6.2 删 `internal/plugin/cabi/` 就已无使用者,
留着会让人以为 C 层协商还在生效,或以为加 method 要同步维护那张整数表。