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

@ -195,6 +195,23 @@ internal/
## 项目状态
**v1.3.x 线**v1.3.1v1.3.12,最新已发布)—— **驻留式子 agent** + **输入调度器重做**
- **驻留式子 agent**:内核可派驻轻量内核的子 agent自己的调度器、自己的 temp 图记忆、
共享通道登记表)。父经 `resident_agents`list/create/send/inspect/compress/reclaim/destroy
派活与收活inputch 可划给子,输入**在进内核之前**就已路由到子。
- **输出通道可寻址到具体 agent**`AllowedOutputs` 授权集合(三处过滤点一致),
父/子之间可互相投递;设备能力也 outputch 化(每设备一个 `device/<id>` 通道)。
- **输入调度器**:排队/中断两类别 + 四级中断L1~L4+ 抢占/挂起/恢复/中断栈;
同级不抢占、有饥饿防护与抢占冷却L4 只归内核与内核级插件WebUI 终止按钮)。
- **轻量内核 profile**:子的记忆面收窄为「传统上下文 + 图记忆」(窄接口,
主库以 query_only 受限句柄打开,写走自己的 temp 实例)。
- **积压及时反馈**(后续线):主 agent 长时间忙时,内核把排队输入交给临时**分诊助手** ——
简单的直接处理并回复,需要主 agent 的立刻回「忙碌中,请稍候」,用户不再干等十几分钟。
- 修掉一批真实缺陷:销毁驻留子时入站 inputch`child/<id>`)注册残留、
子的轮次永远显示 0`info()` 根本没填)、子侧 childIO 空壳(未继承父的输出通道)、
设备心跳 pong 忘了 Flush每 60 秒掉线、Lua 插件桥与 SDK 1.3.0 对齐。
**v1.2.0** — 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存。
- **模型中立的统一向量空间**:内核不再适配任何具体模型,只提供公共 provider SPI

View File

@ -184,6 +184,31 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
## Project Status
**v1.3.x line** (v1.3.1v1.3.12, latest released) — **resident sub-agents** + **input scheduler rework**.
- **Resident sub-agents**: the kernel can station lightweight-kernel child agents (their own
scheduler, their own temp graph memory, sharing the channel registry). The parent dispatches
and collects work via `resident_agents` (list/create/send/inspect/compress/reclaim/destroy).
An inputch can be assigned to a child, so input is routed to it **before entering the kernel**.
- **Output channels addressable to a specific agent**: the `AllowedOutputs` grant set
(consistent across all three filter points) lets parent/child deliver to each other;
device capabilities became output channels too (one `device/<id>` per device).
- **Input scheduler**: two task classes (queued/interrupt) + four interrupt levels (L1L4)
+ preempt/suspend/resume/interrupt-stack; same level never preempts same level, with a
starvation guard and preemption cooldown. L4 belongs only to the kernel and kernel-level
plugins (e.g. the WebUI stop button).
- **Lightweight kernel profile**: a child's memory surface narrows to "conventional context
+ graph memory" (narrow interface; the main graph opens as a query_only handle, writes go
to its own temp instance).
- **Backlog timely feedback** (later in the line): when the main agent is busy for a long time,
the kernel hands queued input to a temporary **triage assistant** — simple items are handled
directly, items needing the main agent get an immediate "busy, please wait", so users no
longer wait 10+ minutes in silence.
- Fixed a batch of real defects: inbound inputch (`child/<id>`) registration leak on resident
destruction, a child's round count always showing 0 (`info()` never filled it), an empty
child-side childIO (output channels not inherited), device heartbeat pong missing Flush
(dropping every 60s), and Lua plugin bridge alignment with SDK 1.3.0.
**v1.2.0** — unified multimodal vector space, media promoted to first-class graph memory, and the whole data plane moved into shared memory.
- **Model-neutral unified embedding space**: the kernel no longer adapts to any specific model.

View File

@ -455,7 +455,74 @@ Extended fields:
- Relation extension: Confidence
## Interrupt Mechanism
## Input Scheduler & Interrupt Mechanism
Inputs do not go straight to the LLM — they first enter the **input scheduler**
(`internal/agent/core/scheduler.go`). Full design:
[`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md).
### Two task classes
| Class | Level | Meaning |
|-------|-------|---------|
| `TaskQueued` | none (always 0) | Pending work. Any interrupt (≥ L1) preempts it |
| `TaskInterrupt` | L1L4 | "How urgent is this", declared by the source via `InjectOptions.Priority` |
### Four interrupt levels
| Level | Meaning | Typical source |
|-------|---------|----------------|
| L1 Background | Fully deferrable | QQ/WeChat messages, bulk notifications |
| L2 Message | General notice | Plugin hints that should be seen soon but aren't urgent |
| L3 Interactive | Needs timely handling | Timer expiry, terminal output, resident-agent reports |
| L4 Critical | **Kernel-exclusive** | panic, kernel events, kernel-level plugin stop button |
When no level is declared it defaults to **L1** — "explicit is a privilege", so a new
plugin never gets preemption rights by accident. L4 declared by an external plugin is
**clamped to L3** (`clampPluginLevel`).
### Preemption and suspension
- **Same level never preempts same level** (`canPreempt` requires strictly greater) —
this is why messages normally wait for the running task to finish.
- A preempted task is pushed onto the **interrupt stack** (LIFO) with its frame saved,
and resumed later; the stack is never re-sorted by priority.
- **Starvation guard**: preemption count raises the effective level
(`effectiveLevel = Level + min(PreemptCount, 2)`, capped at L4).
- **Preemption cooldown**: a just-preempted task cannot be preempted again for
`preemptCooldown` (2s), so a high-priority stream cannot interrupt the same task forever.
- The interrupt stack depth is structurally bounded (chain = queued ← L1 ← L2 ← L3 ← L4).
### Stop (user presses stop / `/stop`)
Stop is not an empty interrupt. It does two things: ① cancel the current LLM inference;
② short-circuit the x messages **already queued at the moment of stop** during their
pre-action phase (`cancelBudget` snapshot), instead of running them as new input.
Inputs arriving **after** the stop are unaffected.
`PendingInputs()` must include the segment still sitting in `io.inputCh` (not yet moved
into the queue by `pumpInbox`) — during a stop the scheduler is usually busy running a
task, and counting only `sched.queue` yields 0.
### Resident sub-agents and timely feedback
Design: [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md).
- A **resident** is an independent lightweight-kernel agent: its own scheduler, its own
temp graph memory, sharing the channel registry.
- Parent→child control plane: `resident_agents`
(list / create / send / inspect / compress / reclaim / destroy).
- **Backlog feedback**: when the main agent is busy for a long time (default > 5m,
configurable), the kernel hands queued inputs to a temporary **triage assistant**
(`offload_*` config): simple ones are handled directly, ones needing the main agent
get an immediate "busy, please wait". Users no longer wait 10+ minutes in silence.
- The triage assistant gets **no inputch** (it receives no plugin user input) and
**all output channels** (results must reach the original channel).
- On reclaim/destroy, its **residual tasks are decided explicitly by the parent**:
`residual=keep` (returned to the parent queue, default) or `drop` (explicitly
discarded with a per-item log entry).
### Legacy three-path view (still present, now a layer beneath the scheduler)
```
interceptLoop (goroutine)
@ -465,15 +532,28 @@ interceptLoop (goroutine)
└── (c) InjectInput() → Trigger new processing when idle
```
Three delivery paths:
Code: `internal/agent/core/scheduler.go` (scheduler), `eventloop.go` (intercept loop).
| Path | Effect | Timing |
|------|--------|--------|
| cancelLLM | Cancel current HTTP request | On context.Canceled |
| interceptCh | Insert `[interrupt message]` in process() | Before each LLM call |
| InjectInput | Trigger new processing when eventLoop is idle | No ongoing request |
## Context Budget
Code: `internal/agent/core/eventloop.go``interceptLoop` / `drainInterrupts`
`internal/agent/core/tokenbudget.go``ComputeTokenBudget`:
```
maxCtx = provider.MaxContextTokens() // declared window (per-source context_window wins)
targetUsage = min(maxCtx × 0.8, 600000) // working band, capped at 600K
├── memory recall budget = (targetUsage - fixed) / 3
└── context events budget = remaining 2/3
```
**Window ≠ working band**: a source's real window may reach 1M, but near-full windows
lose attention and cost/latency rise linearly, so `maxTargetTokens=600000` caps the
working band separately. If the model name (e.g. `AUTO`) yields no window,
`ModelContextWindow` **logs a warning** and falls back conservatively; operators should
declare `core.llm.sources.<name>.context_window` explicitly.
**Budgets are ceilings, not fill targets**: memory is recall-ranked (it stops when nothing
is relevant) and the timeline is taken newest-first within budget. Measured: with a 400K
budget, actual injection was still a few hundred characters.
## Configuration System

View File

@ -804,7 +804,7 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
|---------|------|----------|
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 17 tools, full input/output channel wiring |
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 20 tools, full input/output channel wiring |
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | Memo management, PreAction injection + timed interrupt dual reminder |
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | File system operations, 4 write modes, sandbox isolation |
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | Web search + HTTP fetch (SSRF) + Chromium render (merged from web/webfetch) |
@ -817,6 +817,12 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS subscriptions |
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI image generation |
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | Music playback |
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja task management (projects/tasks/labels CRUD) |
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog publishing and management |
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | Multi-round deep search (progressive focus + cited summary) |
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol (external editors/IDEs drive this agent) |
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | Five-part fault diagnosis (triage / sqlite check / log signatures / diff / ranked conclusions); core plugin of failback mode |
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | Plugin scaffolding: generate, build, install — lets the agent develop plugins itself |
### Built-in Plugins

View File

@ -444,7 +444,64 @@ type Plugin interface {
- Relation 扩展Confidence
## 中断机制
## 输入调度器与中断机制
输入不直接进 LLM —— 它们先进**输入调度器**`internal/agent/core/scheduler.go`)。
设计全文见 [`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md)。
### 两类任务
| 类别 | 级别 | 语义 |
|------|------|------|
| `TaskQueued` 排队输入 | 无级别(恒 0 | 待办工作。任何中断(≥ L1都能抢它 |
| `TaskInterrupt` 中断 | L1~L4 | "这件事有多不能等",由来源在 `InjectOptions.Priority` 声明 |
### 四级中断
| 级别 | 含义 | 典型来源 |
|------|------|----------|
| L1 背景 | 完全可等 | QQ/微信消息、批量通知 |
| L2 消息 | 一般提醒 | 插件希望尽快看到但不紧急的提示 |
| L3 交互 | 需及时处理 | 定时器到达、终端输出、子 agent 汇报 |
| L4 关键 | **内核独占** | panic、内核事件、内核级插件的终止按钮 |
未声明级别时取 **L1**`DefaultLevel`)——「显式才是特权」,新插件不会默认拿到抢占权。
外部插件声明 L4 会被**夹到 L3**`clampPluginLevel`)。
### 抢占与挂起
- **同级不能抢占同级**`canPreempt` 要求严格大于)——这是日常"消息排队等前面跑完"的成因。
- 被抢占的任务压入**中断栈**LIFO`suspendStack` 保存现场,稍后恢复;
栈内不做优先级重排("后被打断的先恢复"才是栈语义)。
- **饥饿防护**:被抢占次数会提升有效级别(`effectiveLevel = Level + min(PreemptCount, 2)`
封顶 L4确保低级别流不会被困。
- **抢占冷却**:刚被抢占过的任务在 `preemptCooldown`2s内不再被抢
避免高优先级流把同一个任务反复打断到永不完结。
- 中断栈帧数有**结构上界**(链条 = 排队 ← L1 ← L2 ← L3 ← L4最多挂起 4 帧)。
### 停止(用户按停止按钮 / `/stop`
停止 ≠ 空中断。它做两件事:① 立即结束当前 LLM 推理;② 对**停止那一刻已排队**
的 x 条消息依次在 pre-action 阶段短路(`cancelBudget` 快照配额),而不是把它们
当新输入再跑一遍。停止之后**新到**的输入不受影响。
`PendingInputs()` 必须把"还停在 `io.inputCh`、没被 `pumpInbox` 搬进队列"的那一段
算进来 —— 停止时调度器多半正忙于当前任务,只数 `sched.queue` 会得到 0。
### 驻留式子 agent 与及时反馈
设计见 [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md)。
- **驻留子**是轻量内核的独立 agent自己的调度器、自己的 temp 图记忆、共享的通道登记表。
- 父对子的控制面:`resident_agents`list / create / send / inspect / compress / reclaim / destroy
- **积压及时反馈**:主 agent 长时间忙时(默认 > 5m可配内核把排队输入交给
一个临时**分诊助手**`offload_*` 配置):简单的直接处理并回复,需要主 agent 的
立刻回「忙碌中,请稍候」。这样用户不会干等十几分钟。
- 分诊助手**不配 inputch**(不接收插件用户输入)、**持有全部输出通道**(结果要能发回原通道)。
- 回收/销毁时它手头的**残余任务由父显式决定**`residual=keep`(转回父队列,默认)
`drop`(明确丢弃,逐条记日志)。
### 旧版三路径(仍存在,但已是调度器之下的一层)
```
interceptLoop (goroutine)
@ -454,15 +511,26 @@ interceptLoop (goroutine)
└── (c) InjectInput() → 空闲时触发新处理
```
三种投递路径:
代码:`internal/agent/core/scheduler.go`(调度器)、`eventloop.go`(拦截循环)。
| 路径 | 效果 | 时机 |
|------|------|------|
| cancelLLM | 取消当前 HTTP 请求 | 收到 context.Canceled |
| interceptCh | process() 中插入 `[打断消息]` | 每个 LLM call 前 |
| InjectInput | eventLoop 空闲时触发新处理 | 无进行中请求 |
## 上下文预算
代码:`internal/agent/core/eventloop.go``interceptLoop` / `drainInterrupts`
`internal/agent/core/tokenbudget.go``ComputeTokenBudget`
```
maxCtx = provider.MaxContextTokens() // 声明窗口per-source context_window 优先)
targetUsage = min(maxCtx × 0.8, 600000) // 工作面:封顶 600K
├── 记忆召回预算 = (targetUsage - 固定开销) / 3
└── 上下文事件预算 = 其余 2/3
```
**窗口 ≠ 工作面**:源的真实窗口可能到 1M但接近满窗口时注意力涣散、
成本与延迟线性上升,因此 `maxTargetTokens=600000` 把工作面单独封顶。
若模型名(如 `AUTO`)推断不出窗口,`ModelContextWindow` 会**打日志提醒**并回退保守值,
部署方应用 `core.llm.sources.<name>.context_window` 显式声明。
**预算都是上限而非填充目标**:记忆按相关度召回(没相关就停),时间线按预算从新到旧取。
实测:预算 400K 时实际注入仍只有几百字符。
## 配置系统

View File

@ -797,7 +797,7 @@ pmgr.ReloadPlugins() // 重载所有插件
|------|------|------|
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | 天气查询wttr.in演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,17 个工具,输入/输出通道完整对接 |
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,20 个工具,输入/输出通道完整对接 |
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | 备忘管理PreAction 注入 + 定时打断双提醒 |
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | 文件系统操作4 种写入模式,沙箱隔离 |
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | 网络搜索、网页抓取SSRF、浏览器渲染合并自 web/webfetch |
@ -810,6 +810,12 @@ pmgr.ReloadPlugins() // 重载所有插件
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS 订阅 |
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI 图片生成 |
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | 音乐播放 |
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja 任务管理对接(项目/任务/标签 CRUD |
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog 博客发布与管理 |
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | 多轮深度检索(逐层聚焦 + 引用汇总) |
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol 对接(外部编辑器/IDE 驱动本 agent |
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | 故障诊断五件套(分诊/sqlite 校验/日志签名/diff/结论排序failback 模式的核心插件 |
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | 插件脚手架:生成工程、构建、安装,供 agent 自助开发插件 |
### 内置插件

364
demo.md
View File

@ -1,364 +0,0 @@
# HomeAgent 自愈 / Failback 架构设计与讨论全程记录 (demo.md)
> 本文档按讨论演进顺序记录"守护 / 保活 / 文件追踪 / 崩溃自愈 / failback"整个设计过程,
> 含代码勘查结论、现实日志记录模式分析,以及最终定稿的架构与尚未落地的接口清单。
---
## 0. 背景与目标
框架目标:**内核零 IO、插件承载所有 IO**`homed 内核 ← PluginSDK → 插件`),三层记忆 + 常用块。
**Failback 的定位所有错误的一层兜底Safe-Mode 式),而非针对单一场景。**
- 主 agent全量 LLM agent是一切骚操作的执行者可能把自己搞到无法自愈的任意状态
LLM 源改坏 / 系统网络proxy、DNS、host破坏 / 配置文件损坏 / OOM / panic / 崩溃循环……
这些错误无法在**同一个被污染环境内**用自身操作自救。
- 故需要一层**脱离主 agent 坏环境的、最小厚度且独立可控的恢复层**——
类似 Windows **安全模式 / 启动修复**:只带最小驱动集合 + 干净 LLM 源(锚定 IP
单一职责:**让主 agent 回到可用状态;若不可行,则做最后的系统级兜底(回滚快照 / 重启)。**
- 它不替代主 agent 的功能,只在主 agent 无法自愈时作为最后一道防线出现。能省则省、能判定就不推理、有底即主张。
---
## 1. 现状盘点(代码勘查结论)
### 1.1 守护进程(`internal/supervisor/daemon.go`
- 主 agent in-process 常驻,`agent.Start()``cmd/homed/main.go:468` 直接启动,**无独立进程边界**。
- `healthLoop``checkAgent` 判 LLM 是否可达,判定**仅依赖 `network.Monitor``AggregateResult().LLMAPIReachable`**`daemon.go:132`)。
- `failCount >= MaxRetries`(默认 3`handleFailure`
- 有 tracker → `trk.Rollback()`;失败才降级 `restartAgent`
- `restartAgent``daemon.go:166`**只改内存状态再重新 Register不真重启任何进程**,几乎空转。
关键问题:
- 探测是系统级HTTP/DNS/TCP回滚只作用于 `<data>/agentfs` overlay 的 upper**二者对象错位**。
- `AgregateResult` 在无 LLM endpoint 时恒 healthy机制形同虚设。
- `lastHB` 每轮都置 `time.Now()``Uptime` 无意义。
- `RollbackPolicy``HealthThreshold/CooldownPeriod/AutoRollback` 都是死字段,只用 `MaxRetries`
### 1.2 文件追踪(`internal/tracker`
- overlayfs 三层:`lower/upper/work → merged``tracker.go:156`)。
- `captureFSState(upperDir)` 递归遍历 upper 并 sha256`changeset.go:56``PreAction/PostAction` 前后 diff`toolcall.go:86-94`)。
- **lower 恒空**`Init``MkdirAll`,从不填充)→ 无 canonical 基线可回滚。
- `Rollback()` = `RemoveAll(upper)` 清空全部 changesets`FileChange.Content`(本应存回滚原文)**从未回填**。
结论overlay/tracker 对"LLM 可达性"这主场景**错位**,只能作数据兜底。
### 1.3 通信插件真相(`third_party/homeagent-sdk/example/qq/plugin.go`
- agent 对外通信全部由**插件设置**驱动,存于 **ConfigRegistry / SQLite config.db**
`qq.napcat_url``qq.listen``qq.files_dir``qq.remote_dir``dm/group_policy``plugin.go:120-130`)。
- 插件在 `Start()``getSetting(...)` 读设置(`plugin.go:134-143`)→ **改动配置需重载插件才生效**
- `cfgmgr` 提供 `config_set / config_batch_set` 可运行时改任意 core/插件配置(`cfgmgr/plugin.go:54,103`)。
### 1.4 LLM 源与"恢复即生效"
`internal/sdk/llm_impl.go:103 ReloadFromConfig()` **已存在**
- `cfg := cfgReg.ToConfig()` 从 config.db 重建(含 `core.llm.sources.*`,见 `registry.go:590`
- `mgr.Reset()` → 逐源 `NewLuaAdaptedProvider` → 重设默认。
即:**LLM 源的"恢复即生效"钩子已经具备**,缺的是"快照 + 探测 + 触发"三件事。
---
## 2. 现实环境:日志记录模式内参
> 看真实 systemd 托管的 HomeAgent`/home/newqqagent`)日志,**目的是弄清现有的日志模型**
> (写哪、什么格式、工具调用打在哪),为 failback / recoveryDiag 的 `diag_log_scan` 提供准确的解析依据。
### 2.1 systemd 托管现状(样例)
```
homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10
llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3
```
- 实测数据区:`/home/newqqagent/` 下有 `log/``config.db``agentfs/`overlay merged`snapshots/``changesets/``knowledge/``memory/``plugins/`
`cli.sock``adapters/``homed.log``memos.json` 等——**日志以独立子目录 `log/` 存放,与配置/快照/knowledge 分置**。
- 启动段确认:`[files] started, sandbox: /`**files 沙箱=全主机 `/` 实锤**`main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8`LLM 走本地 mock
### 2.2 日志目录与格式(核心)
- **目录配置**`core.log.path`,默认 `<dataDir>/log``internal/config/registry.go:415,508`)。
- **单次运行文件**:每次启动新建 `homed_<YYYY-MM-DD_HH-MM-SS>.log``cmd/homed/main.go:75`
写入 `logDir` 下;`log.SetOutput(io.MultiWriter(os.Stderr, logFile))``main.go:80`)——
**同时进 stderrsystemd 捕获到 journald/`journalctl -u`)与文件**
- **格式**:标准 Go `log.Printf`,即 `YYYY/MM/DD HH:MM:SS file.go:line: [module] message`
用户可看文件,也可用 `journalctl -u homeagent.service` 看同一来源(同一行)。
- **层级压缩 + 保留**`internal/log/manager.go:26-28` + `compressor.go`
- 周度压缩 → `week_<year>-W<ww>.tar.gz`;月度 → `month_<yyyy-mm>.tar.gz`;年度 `year_*.tar.gz`;
raw 文件正则 `^homed_(\d{4}-\d{2}-\d{2})_\d{2}-\d{2}-\d{2}\.log$``compressor.go:16`)。
- 保留策略:`core.log.retention`default forever`core.log.retention_months`default 3
`applyRetention` 只留当前周 + 近 N 月(`retention.go`)。
实测:`log/` 下即为 `homed_2026-08-03_08-03-38.log` + `month_2026-*.tar.gz` + `week_2026-W31.tar.gz`,与代码一致。
### 2.3 工具调用日志打在哪儿(进程主循环 `internal/agent/core/process.go`
| 位置 | 日志行内容 | 备注 |
|---|---|---|
| `process.go:36` | `[agent] tool call loop start, max_ctx=… target=… fixed=… mem=… ctx=… N tools, M events, personality=X, docs=K` | 每轮循环开头上下文统计 |
| `process.go:196` | `[agent] executing tool: <name> (plugin=<p>, id=<id>)` | **只记工具名/插件/id不记 args** |
| `process.go:226` | `[agent] tool <name> result: <截断100字符>` | 结果截断到 100 字符(`truncateStr`|
| `process.go:218` | `[agent] skip tool <name>: plugin <name> unhealthy` | 插件崩溃态跳过 |
| `process.go:89/109/121/125` | LLM fallback`trying provider %q (#%d)` / `switched active provider` / `provider %q marked unavailable (HTTP %d)` / `provider %q failed` | 主循环内 LLM 商可观测 |
| `toolcall.go:20` | `[agent] tool %s panic: %v` + `debug.Stack()` | 工具 panic + 完整栈 |
| `toolcall.go:41` | `[agent] tool %s timed out after 60s` | 60s 超时 |
| `toolcall.go:92` | `[agent] tool %s changed %d files (changeset: %s)` | overlay changeset 摘要 |
| 插件侧 | Lua 插件 `sdk.log``print("[lua-plugin] <level>: <msg>")` | 模板见 `cmd_debug.go:74`/`templates.go` |
- 完整的工具**入参/结果**在 EventBus 事件 `EventToolCall``{tool, plugin, args, result, status}``process.go:184/205`)而非文件日志——**文件日志只是执行/结果的摘要指针**(结果被截断)。
- 重要观察:日志里未见 shell/cmd 之类的操作系统执行类调用摘要落盘(`[cmd]` 只在工具结果里),
需要的话由 `diag_log_scan``executing tool: cmd_*` 前缀做签名匹配即可。
### 2.4 崩溃 / 重启观察
- `NRestarts=0`MainPID 自 08-03 起稳定 3330844。曾出现**真实重复 panic**pid 3310036
`[stage] handler panic: runtime error: invalid memory address or nil pointer dereference`03:23 / 05:23 / 07:23约每 2h
`stages.go:139``RunStage` recover 吞掉 → **进程未真崩**systemd 未见重启。
- 08:03:38 有过一次干净 `[homed] stopped` → systemd `Started` → pid 3310036 → 3330844。
- 对 failback 的意义:现有崩溃防护全赖 **in-process recover**,真实进程级崩溃从未被监督;
且当前 `Restart=always` 由 systemd **直绑 worker 且无 StartLimit**——一旦真崩并陷入循环,
systemd 每 10s 反复拉起,没有独立 failback/取证层。→ guard 取代点在此。
---
## 3. 设计演进(讨论全过程)
### 3.0 起点:`internal/supervisor` + `internal/tracker` 我是"保活 + 文件追踪"
- 保活 = 网络健康感知 + 内存态重置;追踪 = overlayfs 变更集 + `Rollback` 清空。
- 意图LLM 不可达 → 回滚 agent 文件改动 → 自愈。
### 3.1 第一次纠正:回滚对象错位
- 回滚只作用于 overlayfs upper而真正能改坏网络的路径files 默认 `/`,可写 `/etc/resolv.conf``/etc/hosts`、代理配置)与 LLM 配置config.db、adapters 目录)**都不在 overlay 内**。
- → 检测命中但回滚删错对象,闭环在"回滚"这一环断掉。
### 3.2 第二次纠正:授权不能靠外部插件主动配合
- 阶段管道是被动通知(`RunStage` 收集 error不主动拒绝拒绝依赖各插件 handler。
- 插件是外部不可控对象 → "安全 = 插件主动授权放行"不成立。
- → 安全应做**默认拒绝**,由内核在 `executeToolCall` 分发点按 ToolDef 的 capability 裁决;配置快照 + replug 只兜底"可文件化"改动。
- 中途又修正:真正的祸首不是 IO 组件,而是 LLM 源/系统网络配置IO 组件崩溃是正交偶发轴,不并进来。
### 3.3 第三次纠正LLM 源才是核心config.db + ReloadFromConfig 已具钩子)
- `llm_set_source` 只切内存默认;真正改坏 LLM 源靠 `cfgmgr.config_set` 写 config.db `core.llm.sources.*`
- `ReloadFromConfig()` 已能"恢复即生效" → 只需补:**LLM 配置快照 + 真实 liveness 探测QuickChat+ 在 handleFailure 里触发 RFO 复检**。
- 但用户进一步点明:**重点是 agent 改了系统网络配置文件proxy/DNS** —— 这类连救援 LLM 都连不上。
### 3.4 定稿架构一lastFailback独立进程第一道防线
- 独立进程 + agent 碰不到的 root:0600 配置 + 最小插件集(文件读写 + cmd+ 单一任务。
- 用**锚定 IP/干净 DNS** 的 LLM 源绕开坏掉的 DNS/proxy。
- 若 failback 也失败 → agentfs 回滚最近快照 + 重启计算机(应用快照前系统配置)。
- 兼顾 systemd 托管冲突(主 agent 崩溃循环被 systemd 反复拉起 → failback 无限增殖)。
### 3.5 定稿架构二:去掉强 systemd 依赖 → 独立 guard 守护进程
- systemd 只绑定**静态、极小、永驻的 guard**guard 作为父进程拉起 worker`homed` 全量 agent
- guard 通过 IPCunix socket / 匿名 pipe收 worker 心跳;
父进程 `waitpid` 天然 reap 崩溃/OOM/信号退出。
- worker 可崩可换systemd 完全看不见 → **failback 唯一性 + 无风暴天然成立**
- guard 抗 OOM`oom_score_adj` 拉低,优先杀 worker 而非 guard。
### 3.6 定稿架构三:更纯粹 —— 同一二进制的两个 role非独立二进制
- **不新建二进制**。`homed` 拆两个入口:
- `homed --role=guard`:父守护进程,先起,负责拉起/监测/探活/裁决/恢复。
- `homed --role=agent`:主 agent工作进程guard 的子进程)。
- `homed --role=agent --boot=failback`:恢复用 agent受限 bootstrap
- guard 复用现有 homeagent 基础设置;检测到崩溃时拉起 failback agent只加载
**webfetch + 文件读写 + cmd** 三个插件,外加 **恢复知识库插件****常见错误检测插件**
复用 agent 核心以 `trigger_prompt` 初始化,要求其"尝试恢复主 agent"。
- guard 配置**独立 YAML**,不复用 config.db逃生舱知识必须脱离 agent 可达区)。
### 3.7 recoveryDiag崩溃取证 / 根因定位插件(省 token 关键)
- 铁律:**工具返回结论,不返回原文**(签名式/统计式/确定性排序)。
- 工具集:
| 工具 | 作用 |
|---|---|
| `diag_triage` | exit code/信号+uptime+头部嫌疑,快速粗分"进程死亡 vs 配置类不可达" |
| `diag_db` | config.db integrity_check + LLM 源解析校验,逐项 ok/fail |
| `diag_log_scan` | 时间窗内命中已知错误签名panic/provider failed/unreachable/sql/OOM|
| `diag_delta` | 崩溃前 config/agentfs 与 last-good 快照 diff"改了什么"|
| `diag_loc` | 综合正交,输出按因果强度排序的定位结论 + 推荐动作 |
- 崩溃类别 → 恢复分支决策表:
| 结论类 | 走分支 |
|---|---|
| 配置损坏类 | 还原 config 快照 + ReloadFromConfig + 拉活主 agent无需 agent 推理)|
| 系统网络类 | 还原 DNS/proxy → 重载主 agent第一步小修命中即停|
| 进程失稳类OOM/panic| 不还原配置,检查内存/泄漏 → 重建 worker |
| 未知/混合 | 放开 webfetch/知识库,用 rescue 源 + diag_loc 摘要最小推理 |
- 只有"未知/混合"消耗 token前几类近乎 0 token。
- 结论落盘 `recovery_kb/diag_<ts>.json`,回流知识库,同类崩溃下次直接命中,越用越省。
### 3.8 三层防御总览(最终)
```
L0 平时:核心只读探活 + 写前快照config_set 写 core.llm.* 前、files 写 /etc 前自动留档)
L1 failbackguard 拉起Safe-Mode 式兜底):按诊断分支逐类恢复——
还原 DNS/proxy → 还原 config 快照 + ReloadFromConfig → QuickChat 复检 → 拉起主 agent
L2 最后手段agentfs 回滚最近快照 + 重启(应用快照前系统配置)
```
- failback 是**所有错误LLM 不可达 / 网络 / 配置损坏 / OOM / panic / 崩溃循环)的统一兜底层**
并非只针对某一条;`diag_*` 决定它走哪条恢复路径。
---
## 4. 最终架构(定稿)
### 4.1 进程拓扑(同一二进制,两个 role
```
systemd ──▶ homed --role=guard # 父守护进程,永驻、静态、极小
├─ exec ──▶ homed --role=agent # 主 agent可崩
└─ exec ──▶ homed --role=agent --boot=failback # 恢复用 agent
```
- guard先起持有恢复知识锚定源 / DNS/proxy 还原 / 配置快照 / failback 逻辑)。
- workerguard 子进程,心跳经 IPC崩溃由 guard reap + 判型。
- failback agent = 受限启动webfetch+files+cmd + 恢复知识库 + recoveryDiag单一任务"恢复主 agent"N 轮有界。
### 4.2 guard 独立 YAML 示例
> 现状实现§5 已完成):`guard.yaml` 已落地为 `max_restarts / heartbeat_timeout / heartbeat_interval / llm_snapshot / failback_enabled / last_resort / restart_command / reboot_grace` 子集(`cmd/homed/guard.go`),恢复梯子=重试→LLM 基线恢复→failback 受限启动→last_resort。下表的 rescue 源 / trigger_prompt / N 轮 failback 推理是目标态,未实现。
```yaml
role: guard
llm:
sources:
- name: rescue
base_url: http://1.2.3.4:8080 # 锚定 IP 直连,绕开被破坏的 DNS/代理
api_key: ${GUARD_RESCUE_KEY}
adapter: ... # 锚定/SNI 型适配器
recovery:
max_attempts: 4 # 可配置尝试轮次
attempt_timeout: 120s
knowledge_base: /opt/homeagent/recovery/
trigger_prompt: "你是恢复 agent唯一任务让主 agent 恢复运行。优先还原 DNS/代理,再重载 LLM 源…"
plugins: [webfetch, files, cmd]
last_resort:
action: reboot # restart_app | reboot
snapshot_before: true
```
### 4.3 guard 恢复状态机N 轮有界)
```
guard 检测( exit≠0 | OOM | 心跳超时 | guard 锚定源探活失败 )
1. 固化追溯exit/信号、panic、journal、OOM 上下文 → 永久区
2. 拉起 failback agent受限插件 + rescue 源 + trigger_prompt + 知识库 + recoveryDiag
for attempt in 1..N:
(可选先 diag_triage/diag_loc 判型)
failback 尝试恢复
guard 每轮复检主 agent 是否可达/存活
├─ 成功 → 结束,交回主 agent
└─ 超时/失败 → kill 重建,进入下一轮
3. N 轮未成 → 取消 failback agent
→ agentfs 回滚崩溃前最近快照
→ 依 yaml 执行最后手段restart_app 或 reboot
```
### 4.4 systemd 绑定(极简,杜绝风暴)
```
[Unit] # guard
OnFailure=... # 备用通常不触发guard 稳定)
[Service] # guard
Restart=always # guard 静态稳定 → 几乎不重启
ExecStart=/usr/local/bin/homed --role=guard ...
# No StartLimit needed for loop 情况guard 不崩
```
- 主 agent 崩 → 只触发 guard 内部 failbacksystemd 仅看 guard看不到 worker 崩溃循环。
- failback 唯一性 + 无启动风暴:由"guard 永驻、唯一裁决"天然保证。
- guard 抗 OOM`oom_score_adj` 拉低。
---
## 5. 尚未落地的接口 / 下一步
**已完成**
`recoverydiag` 快速检查插件(`third_party/homeagent-sdk/example/recoverydiag/`,外部插件)。
- 五件套全实现:`diag_triage`(退出码/信号/存活粗分)、`diag_db`config.db integrity_check + LLM 源字段校验sqlite3 CLI 优先、缺失回退内核 Settings`diag_log_scan`(日志签名按类计数)、`diag_delta`baseline vs 现状 diff`diag_loc`(四项结论正交排序 + 推荐恢复动作)。
- 全部确定性、返回结论非原文、`NoMemory`;工具实际名带插件前缀 `recoverydiag_diag_*`
- 已通过 go vet + 6 个单测(对真实 config.db/日志跑通3 个 LLM 源全 ok、日志命中 228 行主导 provider/fatal并用**仓库内重建的 plugindev** 打出 `dist/recovery_diagnostics_linux_amd64.hmap`,装进运行实例(`/home/newqqagent/plugins/recoverydiag/`)加载成功、注册 5 工具。
- **结论落盘 + 知识库回流**`diag_loc``persist`(缺省 true→ 写 `<data_dir>/recovery_kb/diag_<ts>.json`(可配 `recovery_kb_dir`),并经 `sdk.Knowledge().Add``diag:<cause>:<ts>` 回流知识库(同类崩溃下次直接命中,越用越省);失败不阻塞工具。新增 `TestDiagLocPersist`
- 顺带修复:仓库内 `plugindev` 需重编译(`/usr/local/bin/plugindev` 是旧版、桥模板缺 `InjectInputSync`);重编译见 `third_party/homeagent-sdk/tools/plugindev``go build -o ... .`
- 注意:本环境 `snapshots/``changesets/` 均为空direct 模式无基线)→ `diag_delta` 需显式传入 baseline_dir未来接 guard 时由快照解包目录提供。
`ConfigRegistry` 快照钩子(`internal/config/registry.go`)。
- `SnapshotCoreLLM()`:抓全部 `core.llm.*` 键值快照;`RestoreCoreLLM(snap)`:精确还原(快照内键回写、快照外当前键删除)。
- `SetLLMSnapshotFile(path)`:写前自动留档——此后任意写 `core.llm.*` 键先把当前 LLM 配置整体快照到该文件guard 恢复的外部基线homed 启动即挂 `<data>/llm_snapshot.json`
- 文件持久化对:`SaveLLMSnapshot/LoadLLMSnapshot`。新增 `TestSnapshotRestoreCoreLLM``TestLLMSnapshotFile``TestSetLLMSnapshotFile`
`homed --role{guard,agent}` 入口拆分 + `--boot=failback` 受限插件集(`cmd/homed/`)。
- `--role=guard` 父守护(永驻):读独立 `<data>/guard.yaml`(避开被改坏的 config.db拉起 worker`--role=agent`)、心跳探活 + waitpid 收割、信号转发停机。
- `--role=agent` 工作进程:默认启动全插件;`--boot=failback` 走插件白名单(`core.agent.failback_plugins`,缺省 `webui,pluginmgr,recoverydiag`),内核 webfetch/files/cmd 仍内置可用。
- guard 恢复梯子(已端到端实测):连续 `max_restarts` 次 normal 崩溃 → `restoreLLMBaseline`(从 llm_snapshot.json 恢复 core.llm.*)→ failback 受限启动 → failback 也崩 → `last_resort`restart_app / reboot
- 心跳worker 每 5s 触碰 `<data>/heartbeat`agent 角色 goroutineguard 以 mtime 判定卡死(超 `heartbeat_timeout` 即 SIGKILL 计入崩溃)。
- 插件注册表加 `SetLoadAllowlist(names)`白名单外插件含已注册工厂一律跳过failback 40 工具 → 4 工具实测通过。
现状 bug 修复supervisor/network/tracker
- **monitor 无 endpoint 恒 healthy**`internal/network/monitor.go` + `pkg/types``NetworkCheckResult``EndpointsConfigured`;无探活端点时不再谎报 `LLMAPIReachable=true`(置 false + Error`NewMonitor` 初始化空切片消除启动竞态daemon 仅在配置了端点时才据此判定降级。探活端点新增 `core.defaults.llm_endpoints`(逗号分隔,留空自动取 LLM 源 base_url生产从此健康检查有真实目标。
- **lastHB 恒置 now**`internal/supervisor/daemon.go``checkAgent` 接入真实存活源 `SetHeartbeatSource`homed 注册为 agent core `GetKernelStatus`),只在确认 agent 存活时更新 `lastHB`;无源置 `HealthUnknown`,存活源丢失置 `HealthDown` 且不再刷新 lastHB。
- **restartAgent 只改内存空转**:增 `SetRestartHandler`homed 注册为"清理后以 `exitRestartRequested=42` 退出"不再假装成功guard 把 42 识别为"请求重建"`workerRestartRequested`,不计失败轮次直接重建),无 guard 时 systemd `Restart=always` 兜底。无 handler 时仅内存复位并打日志。
- **tracker 三缺陷**`internal/tracker/``captureFSStateWithContent` 为 before 基线捕获原文(上限 8MB`diffStates` 对 modified/deleted 回填 `FileChange.Content`(回滚用原文);新增 `RollbackLatest()` 定向撤销最近一条 changeset`Rollback()` 改为按时间逆序逐条逆应用(还原被改/被删文件原文、删除新增),无 changeset 时才退回整目录重置。新增 6 个测试覆盖。
剩余:
- guard ↔ agent 心跳 IPC 升级为带自诊断上报的 `PING/ACK`(当前为文件心跳 + 退出码)。
- supervisor 适配成 guard 的探测/裁决逻辑;`ReloadFromConfig()` 复用为"恢复即生效"。
- 生产实例迁移:编译新 homed、改 systemd 只托管 guard`--role=guard`),确认 failback 插件recoverydiag就位。
---
## 附录真实日志节选systemd 托管示例,`/home/newqqagent`
```
# systemd unit
homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10
llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3
# 日志文件与格式log.Printf 标准格式)
2026/08/03 08:03:38 main.go:80: [homed] logging to /home/newqqagent/log/homed_2026-08-03_08-03-38.log
2026/08/03 08:03:38 daemon.go:61: [homed] daemon started successfully
2026/08/03 08:03:39 tracker.go:65: [tracker] initialized (work=/home/newqqagent/agentfs)
# 工具调用摘要process.go
2026/08/03 10:03:52 process.go:36: [agent] tool call loop start, max_ctx=32768 target=26214 fixed=1306 mem=8302 ctx=16606 176 tools, 31 events, personality=true, docs=5589
... process.go:196: [agent] executing tool: <name> (plugin=<p>, id=<id>)
... process.go:226: [agent] tool <name> result: <截断100字符>
# 启动 & 沙箱
homed[3330844]: [files] started, sandbox: /
homed[3330844]: main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8
# 重复 in-process panic被 RunStage recover 吞掉,未进程级崩溃)
homed[3310036]: [stage] handler panic: runtime error: invalid memory address or nil pointer dereference # 03:23 / 05:23 / 07:23
# 一次性干净重启systemd 手动/触发 Startedpid 3310036 → 3330844
homed[3310036]: [homed] stopped
systemd[1]: Stopped homeagent.service - HomeAgent - 24/7 AI Butler.
systemd[1]: Started homeagent.service - HomeAgent - 24/7 AI Butler.
# 运行状态
systemctl show homeagent.service -p NRestarts → 0
systemctl show homeagent.service -p MainPID → 3330844自 08-03 起稳定)
# 归档
/home/newqqagent/log/: homed_2026-08-03_08-03-38.log + week_2026-W31.tar.gz + month_2026-*.tar.gz
```

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 要同步维护那张整数表。

View File

@ -41,7 +41,7 @@ func tryLoadProc(dir, name string, config map[string]interface{}) (sdk.Plugin, e
// 子进程插件加载plugin.bin——外部插件多进程化的加载入口。
//
// 设计依据docs/zh/架构迁移评估.md §3stdio JSON-RPC 控制面 + shm 数据面 + eventfd 通知面)
// 实施计划:docs/zh/plugin-migration-plan.md Part 2/3
// 实施记录见 docs/zh/架构迁移评估.md子进程化论证与实验数据
// validateProcBinary 校验 plugin.bin 是否存在且可执行。
// 返回 ("", nil) 表示该目录不是 proc 插件。

View File

@ -7,7 +7,7 @@ import (
"testing"
)
// entry 分派docs/zh/plugin-migration-plan.md Part 1/6)。
// entry 分派(docs/zh/架构迁移评估.md 的入口双通道章节)。
//
// C ABI 通道(.so/.dll/.dylib已整体退场外部插件统一走子进程 + stdio RPC。
// 这些测试守住的是「旧产物给明确错误」而非「静默跳过」——后者会让

22
plan.md
View File

@ -1,5 +1,11 @@
# HomeAgent 生产问题修复计划
> **文档定位2026-09-19 核)**:本文是**历史工单 + 仍活跃路线图**的混合档。
> 开头 §0.1/§0.2 的两个「紧急」项与 §1~§11 的历史条目**均已解决**(见各节勾选),
> 下方 §13 是**仍活跃**的推进路线图。想知道当前架构请读
> `assets/docs/zh/ARCHITECTURE.md`;想知道当前调度/驻留子设计请读
> `docs/zh/input-scheduler-design.md` 与 `docs/zh/resident-subagent-design.md`。
## 设计意图备忘(核心架构原则)
本框架的两大核心设计意图,贯穿所有插件/记忆/工具设计,**所有改动必须符合**
@ -14,9 +20,13 @@
---
## 0.1 紧急healthcheck 健康检查污染真实存储 ⚠️ 正在持续污染
## 0.1 ~~紧急~~ 已解决healthcheck 健康检查污染真实存储
**现象**2026-08-11 22:02 起,每 30 分钟一次):日志反复出现
> **状态:已修复2026-09-19 实测确认)**。本文保留为定位过程记录。
> 验证方式:`ls /home/newqqagent/knowledge/ | grep -c _hc_` = 0
> `memory/graph.db` 内 `_hc_` 表/行 = 0用 sqlite3 查 sqlite_master
**当时的现象**2026-08-11 22:02 起,每 30 分钟一次):日志反复出现
`[knowledge] added: _hc_knowledge_test_<ts>`,且知识库出现 `gotest``luatest``_hc_knowledge_test_*` 等测试残留。
**根因**`internal/plugins/healthcheck/plugin.go` 的三个"写入通道"自检**全部在真实生产存储上写入再删除**
@ -41,7 +51,7 @@
---
## 0.2 紧急QQ 消息被无视agentcli 幽灵终端自喂送风暴)⚠️ 优先处理
## 0.2 ~~紧急~~ 已解决QQ 消息被无视agentcli 幽灵终端自喂送风暴)
**现象**2026-08-11 20:4x用户发 QQ 私聊消息agent 不回应。日志显示 agent 被 `agentcli` 终端 echo 洪水完全阻塞。
@ -923,7 +933,8 @@ context 累积导致的内存增长。
> 三项合入门禁**已全部通过**`make test` 零失败、`go vet ./...` 无告警、
> `git diff main -- third_party/homeagent-sdk/sdk/` 为空(接口冻结不变量)。
>
> 详细执行记录见 `docs/zh/plugin-migration-plan.md`Part 0~6 全部标记完成)。
> 迁移的执行记录Part 0~6 过程稿)已随迁移完成而删除;论证与实验数据仍见
> `docs/zh/架构迁移评估.md`。
### 12.1 ✅ 已完成:合并到 main + 发布分支2026-09-03 ~ 09-06
@ -956,8 +967,7 @@ SDK 仓 `v1.0.0` / `v1.1.0`。main 的版本路牌现为 `1.2.0`(尚无 tag
### 12.2 验收清单里两项**未达成**的目标
这两项在 `docs/zh/plugin-migration-plan.md` 的最终验收清单里如实标了 ⚠️,
不是遗漏而是明确的未兑现承诺。
这两项在迁移过程稿的最终验收清单里如实标了 ⚠️,不是遗漏而是明确的未兑现承诺。
#### 12.2.1 `SetToolBlocks` 仍是未实现(承诺未兑现)