|
|
5608abdf5a
|
feat(shm): Cleaner 迁移到 SharedRef(§13.4)
- CleanerInvokeParams/Result 的 Text 改为 TextRef SharedRef
- cleanerProxy: 写 text 到 arena,传 SharedRef,读结果 SharedRef
- 插件侧 cleaner.invoke: 从 SharedRef 读 input,清洗后写回 arena 返回 TextRef
- NewHost(): 分配空间含 arena(256KB)
|
2026-09-10 15:30:00 +08:00 |
|
|
|
cf098a1d4f
|
feat(shm): arena allocator for unified region (§13.2)
- SuperBlock 新增 arenaOff/arenaCap/arenaUsed 字段
- arenaAlloc: append-only bump 分配,offset 0 保留给空语义
- arenaWrite: 写入并返回 SharedRef(含 generation)
- arenaRead: 按 SharedRef 切片读取
- arenaReset: 压实后重置游标
|
2026-09-10 14:33:44 +08:00 |
|
|
|
a8e3633174
|
feat(shm): ToolCall ring buffer(§13.3) — 栈帧模型 ring,push/pop 帧状态机
- ToolCallRing: 定长帧 ring buffer (64 frames × 112B),状态机 FREE→CALLING→READING→READY→FREE
- Reserve: 环形扫描找 FREE 帧,全忙返回 ErrToolCallRingFull 背压
- SharedRef: pack/unpack 辅助函数
- unified.go: arena 分配器(arenaAlloc/arenaWrite/arenaRead)
- 5 个单测覆盖 init/reserve/背压/find/reuse
|
2026-09-10 12:33:58 +08:00 |
|
|
|
ad016e4410
|
feat(shm): 统一共享内存区域(§13.1) — 单 memfd 容纳 SuperBlock+StageContext+EvtRing
- unified.go: SuperBlock 布局 + SharedRef 类型
- NewHost(): 两段 memfd 合并为单一 memfd,fd 3 = 统一区域,fd 4 = eventfd
- 子进程侧: testdata 插件从 SuperBlock 解析 ctxOff/evtOff
- streaming 测试: EvtConsumer 协程在 host.Close 前 Stop+Wait 防 SIGSEGV
|
2026-09-10 11:42:22 +08:00 |
|
|
|
7a328679da
|
feat(proc): Cleaner 跨进程修复(tool/input/output) + 共享内存安全模式 + plan §13
- Cleaner 协议统一为 cleaner.invoke(scope,name,text),覆盖工具/输入/输出三类
- 新增 ShmSecurityMode (safe/debug/full),Linux 审计探针,Windows crypto nonce
- plan.md 追加 §13 步骤分组
|
2026-09-10 10:21:50 +08:00 |
|
|
|
687e5655bc
|
feat(sdk): 多模态贯通插件边界——公开接口、内核桥接与统一输入主干
记忆系统在 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放:
用户在 qq 发图能落进 CAS、能被记忆引用,而插件调 Commit / DocMemory().Insert
交进来的媒体一律无处安放。原因是三层都断着,且**每一层都不报错**。
## 一、公开 SDK:补上媒体的表达能力(全部新增,无签名变更)
- `Triple` += `SentenceText`、`MediaDigests`
- `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment`
- `TextEvent` += `Attachments`
- `DocMemoryAPI` += `InsertWithMedia`
- `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia`
- `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有)
`MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节
去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索
可能命中几十份媒体,全塞回去会把跨进程消息撑爆。
媒体注入不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,且媒体
要等下一条 tool message 才到模型手上。插件主动发起一轮带媒体的对话、以及中断
注入,需要自己的签名,且媒体在**本轮**就送到模型。
## 二、内核桥接层:原先在静默裁字段
`internal/sdk/memory_impl.go` 此前只搬自己认识的几个字段,其余丢弃且返回 nil:
- 图记忆丢 `Confidence`/`SubjectType`/`ObjectType`/`SentenceText`,又走 `Commit`
而非 `CommitWithMedia`(不回 sentenceIDs)→ 媒体绑定链 `SentenceText → sentences
→ sentence_id → media_refs` 一步都走不通,插件即便按格式写好标记也永远挂不上;
- 知识库 `Query` 只回 ID/Title/Content,`Insert` 只写这三个;`Remove` 不解引用,
于是那些媒体永久处于「被引用」状态,GC 收不掉、磁盘只增不减
(内核的归档路径 `releaseDocMedia` 做了这一步,插件路径漏了同一步)。
规则改为:**内部结构有的字段一律透传**。标记格式处理作为包级私有辅助留在桥接
层自己手里,但必须与内核 `mediaSummaryForEvent` 字节兼容——两边要能互读对方
写下的标记。
标记插入必须在 `ds.Insert` **之前**(向量索引取 `Summary + " " + Content`,
之后补的标记检索不到),引用绑定必须在**之后**(owner_id 是 Insert 生成的 ID)。
## 三、跨进程链路:不接线就是全体外部插件编译失败
`go test` 直接把这一层拍出来了——`procIO does not implement sdk.IOInjector`。
公开接口加方法后,生成模板不跟上,**每个外部插件都编不过**,是硬失败不是软降级。
六处接线:`protocol.go` 四个 method 常量、`capability.go` 能力归属、
`corehandler.go` 四个分派分支、`proc_core.go` 委托、`proc_main.go.tmpl` 模板侧
实现、以及三个测试替身。
## 四、统一输入主干:把模态从「函数选择」降级为「字段」
`processTextInput` / `processMediaInput` 合并为 `processInput`。这个分叉是历史
产物而非设计:`processTextInput` 本来就处理媒体(`bindEventMedia` +
`mediaSummaryForEvent`,与媒体路径尾部完全相同),`process()` 只看
`stageCtx.Extra["media_blocks"]`、根本不认识 `evt.Type`。模态是输入的**属性**,
不是输入的**种类**。
媒体路径由此获得它一直缺的六项:去重、`no_memory`、通道 `Cleaner`、中断语义、
`_consolidation_` 路由、正确的 `EventRawInput`。
最后一项是个真 bug:媒体路径发布 `"content": evt.Payload`(一个 map),而
`webui/handler.go` 断言 `.(string)` → 断言失败、`content == ""`、提前返回。
**用户发的图从来没出现在 WebUI 聊天记录里。**
`media_blocks` 同时接受 `[]agentAPI.ContentBlock` 与 `[]pubsdk.ContentBlock`:
字段一致但 Go 不自动转换,只认一种的后果是另一种被静默丢弃。
## 五、模型可调用的三个工具
`memory_commit` 的 `sentence_text` **从未暴露给模型**,而它是绑定链上的必经环节;
连同 `media_digests` 一起补进 JSON schema 与工具文档。`doc_commit` 加
`media_digests`。`doc_query` 把关联媒体单独一行附在结果末尾(正文按 2000 字截断,
标记通常就在尾部)。
标记由**内核**生成而非插件/模型拼装:要求调用方知道格式,等于让一个拼写错误
静默切断引用绑定,而全链路无人报错。
## 六、WebUI 上传走真实媒体链路
图片/音频读回字节拼 data URL 注入 `media_blocks`(8MB 上限,超限退回按路径处理)。
此前只注入一句「文件已保存到 <路径>」,指望模型自己调 `files_read`——但那返回
文本,图片字节对模型永远不可见。附件类型识别扩展到 audio 并在缺 Content-Type
时按扩展名兜底(判错不只是卡片样式问题,图片被当普通文件就进不了视觉链路)。
## 测试
- `internal/sdk/memory_impl_test.go`(12 例,此前该包**没有任何测试文件**)
- `internal/agent/core/inputunify_test.go`(统一主干 + 双静态类型 + 三工具媒体)
- `third_party/homeagent-sdk/sdk/stress_test.go`(13 例并发压测)
压测抓到两处**真**竞态(不是理论风险):`PluginSDK` 的 API 字段与 `autoRestart`
无锁,而写方(内核注入 API、插件 `SetAutoRestart`)与读方(插件后台 goroutine
注入、内核 registry 读 `AutoRestart`)天然跨 goroutine。加 `apiMu` 修掉;约定
只在持锁期间取字段值,取完即释放再调用——持锁调用会把 `InjectInputSync` 这类
阻塞到 agent 回复(可达数分钟)的方法与 `SetIOInjector` 串起来,让插件重载卡死。
测试还抓出两个自身缺陷:`bindDocMedia` 把同一份媒体数两次(`AddRef` 幂等所以表
是对的,但日志说「绑定 2 个」而实际 1 条——误导后续排查),以及用单字符实体名
时 `validEntityName` 静默跳过、`Commit` 返回 nil 却什么都没写。
存量插件不需要改一行也不需要重编:新增方法由插件调用、内核实现,不调就不受影响。
17 个 example 插件源码零改动通过类型检查。
|
2026-09-06 09:51:31 +08:00 |
|
|
|
e272c686d3
|
fix(proc): stage 协调器双重解锁——内核本体 fatal 崩溃的真因
## 现象
2026-09-04 06:56:18 生产 homed 主进程直接死亡,退出码 2,
带走全部 27 个子进程插件。
fatal error: sync: unlock of unlocked mutex
proc.(*Host).endStage(...) host.go:189
proc.(*coreHandler).runStage.func1() stage.go:94
core.(*StageHost).RunStage.func1() stages.go:190
stage.go:94 与 stages.go:190 各有一层 recover,专为「插件出错不拖垮内核」
而设,却全部失效:**sync.Mutex 的双重解锁走 runtime fatal,不是 panic,
recover 结构上就拦不住**。这就是本次「插件崩溃被隔离」的设计没能生效、
内核本体整体死亡的原因。
## 根因
endStage 把 coord.leave()(递减 inflight、判定「我是最后离开者」)放在
coordMu 临界区**之外**,而摘除 h.coord 在临界区**之内**,留出窗口:
A.endStage: leave() → inflight 1→0, last=true,尚未摘除 h.coord
B.beginStage: 看到 h.coord != nil,以「后到者」身份 enter,inflight 0→1
(后到者按设计不取 stageMu)
A.endStage: h.coord = nil;stageMu.Unlock() ← 第 1 次
B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥
B 从未持有 stageMu,却因挂进一个正在收尾的协调器而被判成「最后离开者」,
对同一把锁解了两次。崩溃前一行日志是 config_list_keys 的结果——那一刻
正好有 stage 扇出,与竞态窗口重合。
## 修复
把「递减 inflight → 判定最后离开者 → 摘除 h.coord」收进同一个 coordMu
临界区,后到者再不可能挂进已收尾的协调器。为此把 leave() 拆成:
- depart():纯计数,由 endStage 在 coordMu 内调用
- finish():共享段回读 + arena 压实,在 coordMu 外、但仍在
stageMu.Unlock() 之前(先放锁会让下一轮 stage 在回读未完时改写共享段)
leave() 保留给单测。
同一函数的第二个隐患一并修掉:首进者的 enter()(含 WriteAll 写共享段)
原先在 coordMu 之外,后到者可能拿到 coord 就去读**写了一半**的段。
现在 enter() 在锁内完成。
beginStage 错误路径的 stageMu.Unlock() 必须保留并已加注释说明:
runStage 的 defer endStage(coord) 是在 beginStage 返回 err 的检查**之后**
才注册的,这条路径上没有任何人会替它解锁,漏掉就是整个 stage 通道永久卡死。
锁序 stageMu → coordMu;endStage 只解锁 stageMu 不获取,无环。
## 验证
反向验证:把 host.go stash 回旧版跑新测试 → fatal error: sync: unlock of
unlocked mutex;恢复修复 → 通过。测试抓的确实是这个缺陷。
5 个回归用例(host_stage_test.go):
- 后到者不复用已收尾的协调器(直接构造那个时序,不靠调度巧合)
- 8 worker × 40 轮并发进出(旧实现下整个测试二进制 fatal 而非 FAIL)
- 同阶段多插件扇出共用一个协调器、仅最后离开者解锁
- 50 轮串行不泄漏(少解锁会在第二轮卡死)
- 四阶段序列 pre_action→chat→after_toolcall→post_action
internal/plugin/... 全量 -race -count=2 通过。
## 同类缺陷审计(本 commit 未改动其他文件,仅记录结论)
针对「recover 拦不住的 runtime fatal」这一整类做了全仓审计:
1. 跨函数持锁(本缺陷的形状,脚本枚举 Lock/Unlock 不配对的函数)
- proc/lock.go 的 Release/ForceRelease 同样「只 Unlock 不 Lock」,
但两者都在 ownerMu 下先检查 held/owner 再解锁,非持有者直接返回,
不存在双解锁路径。
- 其余 22 处 Lock/Unlock 计数不等的函数逐一复核:全部是多分支早退各自
解锁(waiter 的 goto nextMessage、sidecar.call 的五个错误分支、
lua adapterPool 的 cond.Wait 池模式等),配对正确。
2. 并发 map 读写(同样是 runtime fatal)
- 16 处「无锁访问 map」全部复核为安全:Locked 后缀约定(orderedLocked、
defsLockedRegisterSource)、调用方持锁(document 的 addSummary/
removeDoc/loadAll、registry 的 runStopHandlers/runOnRemoveHandlers)、
或启动期单线程(knowledge.scanAll、static_embedder 构造后只读)。
3. close of closed channel
- 全仓仅 sidecar.go 有同名变量的两处 close(ch),但作用于不同集合成员,
且 Close() 前有 readerWg.Wait() 与 stopped 标志,reader 侧已 delete
出 pending,不会双关。
- 各插件 stopCh 的 close:healthcheck 用 select 守卫、clawhubadapter 用
stopOnce、evtring 用 running 标志、timer 交给 StopHandler 单次调用。
agentcli.Stop() 是裸 close(p.stopCh) 无幂等守卫,但 Registry 的六处
Stop 调用点都在同一把 r.mu 下先 delete(r.plugins)+摘 r.instances 再
Stop,不存在二次调用路径——记录为「依赖调用方约定」而非当前缺陷。
4. WaitGroup 误用:未发现 Add 出现在 goroutine 体内的形状。
5. 全仓 go test ./... -race:零 DATA RACE、零 FAIL。
|
2026-09-04 18:43:05 +08:00 |
|
|
|
02cc74ce11
|
fix(proc): 子进程崩溃自愈 + 集中台账 + 注册面摘除
根因:子进程插件被 kill 后,内核只发了一个无人订阅的事件,
工具/stage handler/IO 通道全留在注册表里指向死进程,
模型继续调用只吃 ErrProcessExited,没有任何路径把插件拉回来。
## 四层修复
### 1. 专职 waitLoop(进程收割)
- 每个子进程配一根 waitLoop goroutine,是 cmd.Wait() 的唯一调用点
- 不再依赖 stdout EOF 判定死亡(孙子进程继承 stdout 时 EOF 永不到来)
- 手工 os.Pipe 替代 cmd.StdinPipe/StdoutPipe,避免 waitLoop 与
os/exec 的内部关闭竞争
- host.go: Host.Supervisor(),Host.Close() 先 StopAll 再拆段
### 2. 集中台账 Supervisor
- proc/supervisor.go: 插件 Spawn 握手成功即 track,进程退出即 untrack
- StopAll: 并发发 plugin.stop 走优雅路径,到期仍在的一律 Kill
- 关停后才完成握手的进程被立即结束,不会活过内核
- 消除「孤儿进程持共享段映射 → SIGBUS」的隐患
### 3. 注册面摘除(detachPlugin)
- 新增 StageHost.UnregisterPluginStages:摘除指定插件的全部 stage handler
- 新增 Registry.pluginChannels 台账:记录每个插件注册的 IO 通道
- 三条路径统一走 detachPlugin:Disable / ReloadOne / RemovePlugin
- StopAndUnload 漏了 IO 通道也一并补上
### 4. 自动重启
- onProcCrash 从「只发事件」改为「摘注册面 → 从注册表移除 → 异步排重启」
- scheduleProcRestart: 窗口 5 分钟内最多 3 次,线性退避 1s/2s/3s
- 超限停手留日志;重启前复核是否已被 Disable 或被其他路径加载
- 崩溃计数窗口过期自动归零
### 5. 主动停止 vs 崩溃的区分
- proc.Plugin 新增 stopping 标志:Stop()/Close() 里 Set(true)
- handleExit 读 stopping 标志,主动停止不上报 onCrash
- 防止重载/禁用/卸载被误判为崩溃触发多余重启
### 6. Linux Pdeathsig 兜底
- procattr_linux.go: SysProcAttr.Pdeathsig = SIGKILL
- 兜 homed 自身被 SIGKILL/OOM 时子进程变孤儿的场景
- macOS/Windows 无等价物,空实现
### 7. pluginmgr 升级
- PluginManager 接口新增 PluginRuntime / ListPluginRuntimes
- plugin_list 输出运行态:loaded / alive / pid / crash_count / channel
- 新增 plugin_status: 全量运行期快照 + dead/unhealthy 汇总
- 新增 plugin_restart: 无条件重启单个插件(plgreload 不动未改二进制的插件)
### 测试
- process_test.go: 3 例(grandchild stdout 感知 / Supervisor track-untrack /
StopAll 无孤儿)
- crash_recovery_test.go: 8 例(detach 三项齐全 / 通道重注册 / 崩溃不阻塞 /
退避阈值 / 窗口过期 / 关停中跳过 / PluginRuntime 通道识别)
- stages_plugin_test.go: 4 例(stage 按插件摘除 / 空 stage 清理 / 空名 no-op /
工具+stage 双摘后可重新注册同名)
|
2026-09-03 12:37:58 +08:00 |
|
|
|
2572688c51
|
proc: 性能基准 + 流式压测(Part 6.6 验收项)
此前只做了功能冒烟与内存快照,延迟与压测都没测。这两项是计划里
明确列出的验收条件,补上。
## 基准结果(AMD Ryzen 7 7840HS)
| 项目 | 实测 | 基线 |
|---|---|---|
| 工具调用 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,并在注释里写明不可对照的理由——
否则日后有人拿 0.76µs 去比 19.4µs 会得出「优化了 25 倍」的错误结论。
**stage 往返 132µs 的成本构成**:共享段编解码只占 3.7µs,其余是
一次 stage 要走 3 次进程间往返(stage.invoke + 插件侧反向的
stage.lock / stage.unlock)。相对 LLM 往返 2-8 秒可忽略;要优化的方向是
把 lock/unlock 合入 stage.invoke 的请求/应答,省掉两次往返。
## 流式压测:§4.3 标记「风险高」的那一项通过
原文担忧:「Bus.Publish 路径禁用任何锁/阻塞——流式输出逐 token 发布,
任何等待都会卡顿」。事件环是 Part 5 新加在这条路径上的,必须验。
```
5000 次 Publish + 每条睡 20µs 的慢消费者
实测 2.29ms,均摊 457 ns/token
同步语义理论下限 100ms
订阅者 1 个:1.547ms(515 ns/次)
订阅者 8 个:1.518ms(506 ns/次) ← 无线性恶化
环溢出(无消费者写 30000 次,cap=8192):均摊 35 ns/次 ← 仍 O(1)
```
2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token),
post-and-forget 在实现中成立。
第三项的意义:消费者完全停摆时写端覆盖最旧 slot,这条路径仍是 O(1),
故「消费者卡住」不会连带拖慢内核主循环。
Ref: docs/zh/plugin-migration-plan.md Part 6.6、docs/zh/架构迁移评估.md §4.3
|
2026-09-02 21:42:18 +08:00 |
|
|
|
2ebdb9a5b7
|
plugin: 权限梯度显式化(Part 6.4)
迁移前,「外部插件拿不到 Selftest/Supervisor/Tracker」是 C ABI 表达能力的
**意外产物**——C 结构体不好传函数指针,这些能力自然到不了插件侧。那是运气
不是策略:任何人给 dispatch 加个 case 就能捅穿。
现在变成显式声明并强制,分三道闸:
1. **类型层**(proc_core.go,Part 6.2 已落地):procCore 用命名字段持有
内核 SDK 而非嵌入,未在收窄面写出的方法编译期就不存在。
2. **能力集**(新增 capability.go):54 个 plugin→kernel method 划入 11 个
capability 组,manifest 未声明的组被拒。
3. **RPC 边界**(corehandler.Handle 入口):被拒时返回**明确错误**而非
静默忽略。
第 3 条针对一类真实故障:C ABI 时代 case 23/24(事件订阅)是空实现,
返回成功但永远收不到事件(§1.3 的「给不了」而非「不给」),插件作者无从得知。
错误消息含四要素:哪个插件、哪个调用、缺什么能力、在哪声明。
## 能力划分的两个判断
**粒度按能力域而非单 method**。逐 method 授权看似更精细,但插件作者要在
manifest 里列 60 个名字,且内核每加 method 所有 manifest 都得改。
**空声明 = 不受限,而非「只有 core」**。17 个存量插件的 plugin.json 都没有
capabilities 字段。若空声明当作最小权限,它们会全部失去 IO 注入、记忆读写
而**静默降级**——违反「外部插件零改动」的硬约束。收紧的路径是让插件显式
声明,而不是默默拒绝老插件。
## core 与受限能力的边界
core(无需声明,始终可用):注册自身工具/阶段/通道/API、读写**自己的**配置、
共享段锁仲裁、握手、autoRestart 自述、setToolBlocks。没有这些插件无法工作。
受限(需声明):io / memory / doc_memory / knowledge / text_memory / llm /
social / events / plugin_mgr / settings_cross。
settings 刻意拆成两级:读写自己的配置属 core(正常工作所需),读写**其他插件**
配置或**内核核心**配置属 settings_cross(能改别人/内核的行为)。
## withheldCapabilities:让「不给」可见
10 项刻意不提供的内核内部机制列在表里并附理由。它们没有对应 method 常量——
不是忘了加,是决定不加。列表存在本身就是「这是策略而非疏漏」的证据,
读代码的人能看到边界在哪,而不是从「protocol.go 里没有」这个负面事实去推断。
## 测试
proc 包 10 项:
- AllMethodsClassified:**最重要的一项**。漏登记的 method 会按 CapCore 放行,
等于绕过整套检查。新增 method 忘登记时当场报出。
- EmptyDeclarationIsUnrestricted / DeclaredSetRestrictsOthers / CoreAlwaysAllowed
- SettingsScopeSeparation:自身配置 vs 跨插件配置的归属
- DeniedErrorIsActionable:错误消息四要素
- HandleEnforcesAtRPCBoundary:被拒的调用不进 switch
- WithheldListIsDocumented:每项都有理由,且不被任何 method 暴露
- UnknownMethodFallsThrough:未知 method 报「未知」而非「权限被拒」,
否则作者会以为是漏声明能力
写这个测试时踩到自己的坑:第一版用子串匹配查 withheld 泄漏,"Tool" 匹配到
tool.register 和 io.setToolBlocks 误报——那两个是合法开放的(注册自己的工具)。
改成前缀 + unregister 关键字匹配,withheld 项也改名带 API 后缀以示区分。
internal/plugins 2 项接线验证:
- RestrictedPluginStillLoads:只声明 io 的 weather 仍能加载并注册工具
(它在 Start 里读 Settings,属 core)
- LegacyManifestUnrestricted:无 capabilities 字段的存量插件正常加载
真实 homed 实测:
[plugin] weather-capped 声明能力: [io]
[plugin] weather-capped: 经 proc 通道加载(子进程)
registering tool: weather-capped_current / _forecast / _set_location
验证:go build ./... 通过;go test ./... 全仓无失败;
go test -race ./internal/plugin/... 全绿;go vet 干净。
Ref: docs/zh/架构迁移评估.md §3.8、docs/zh/plugin-migration-plan.md Part 6.4
|
2026-09-02 21:27:13 +08:00 |
|
|
|
d027c964e2
|
proc: Windows 共享内存 + 事件通知适配(Part 6.2 内核侧)
补齐内核侧的 Windows 创建端,与 6.1 的插件侧打开端配对。三平台
(linux/darwin/windows)现在都能构建 internal/plugin/proc。
## Windows 走命名内核对象(无 fd 继承语义)
os/exec 的 ExtraFiles 在 Windows 实现里不被支持,故:
- shmalloc_windows.go:CreateFileMappingW(INVALID_HANDLE_VALUE + 命名 →
系统页文件支撑的匿名段,不落盘)+ MapViewOfFile
- evtfd_windows.go:CreateEventW 命名 Event 对象 + SetEvent 通知
- shmpass_windows.go:把段名/对象名经环境变量注入子进程
(HOMEAGENT_SHM_STAGE / HOMEAGENT_SHM_EVTRING / HOMEAGENT_EVT_EVENT)
名字带 PID + 递增序号:多个 homed 实例并存时不能撞名。
Event 与 eventfd 的语义差异:Event 是二元信号,多次 SetEvent 只对应一次
唤醒,不累积。不影响正确性——消费者被唤醒后按 readSeq 追 writeSeq 批量
drain,丢的是"唤醒次数"不是"事件";事件环本身就允许溢出丢弃并让消费者
知道丢了(dropped 计数),通知面从来不是可靠投递语义。
## 传递机制抽象为 shmpass_*.go
Plugin.Start 不再直接构造 ExtraFiles 列表,改为问 Host 要:
Env: p.host.procEnvForShm() // Windows 返回段名,Unix 返回 nil
ExtraFiles: p.host.procExtraFilesForShm() // Unix 返回 fd 列表,Windows 返回 nil
平台差异被收敛到这一对函数,Plugin/coreHandler/stage 全部平台无关。
## macOS pipe 生命周期修正
原实现只返回读端 fd,写端 *os.File 无人持有 → 可能被 GC 回收 →
读端收到 EOF 而非阻塞 → 消费循环变忙转。改为 pipePair 表同时持有两端,
evtfdClose 一并关闭。
## E2E 测试跟进模板拆分
模板从单文件拆成三个(主体 + unix/windows 挂载),测试需要一并落盘,
否则编译报 attachStageShm undefined。procRuntimeTemplates 表必须与
SDK 仓 proc_runtime.go 的 procRuntimeFiles 一致。
验证:三平台 go build ./internal/plugin/... 通过(gojieba 的 cgo 依赖
导致 internal/memory 在非 linux 失败,与本次无关);
go test -race ./internal/plugin/... 全绿,含 2 项真实模板 E2E。
Ref: docs/zh/架构迁移评估.md §9.2、docs/zh/plugin-migration-plan.md Part 6
|
2026-09-02 19:07:14 +08:00 |
|
|
|
5bbfcc02fb
|
plugin: 事件环内核侧实现(§3.6 Part 5 核心)
事件环(EvtRing)是子进程首次获得事件订阅能力的基础设施。
此前 case 23/24 明确返回未实现,现在经事件环真正可用。
核心设计(§3.6,实验 4 已验证 post-and-forget 加速比 2218x):
- 事件环放**独立共享段**(不与 StageContext 混放):stage compact 会清 arena,
事件要独立于 stage 生命周期。Host 持有两块 memfd:fd 3 = StageContext,
fd 4 = 事件环段,fd 5 = eventfd。
- 无锁数据结构:内核 WritePush 追加写 slot,子进程 EvtConsumer 消费。
writeSeq 原子递增(Bus.Publish 并发调用),readSeq 每订阅者独立。
- eventfd 通知:Linux 用 unix.Eventfd(计数合并,1000 token 只唤醒几次),
macOS 用 os.Pipe(阻塞模式走 netpoller,只 park goroutine,实验 1 验证
200 等待者仅 +1 OS 线程)。两者行为一致:Read 阻塞直到有新事件。
- 溢出语义:落后超 cap 时跳到最新,丢弃计数记入 dropped(消费者知道丢了)。
不静默覆盖最旧(写端直接覆盖 slot,读端靠 seq 判断跳过)。
- 事件类型编码:pubsdk.EventType 字符串 ↔ uint32 位索引(编译时映射表),
typeMask 位掩码过滤(1<<idx)。
Host 改动:
- NewHost 同时创建事件环段和 eventfd(惰创建,一次分配)。
- Host 持有 evtSubscriber 接口(EvtRingSubscriber),由 Registry 注入
EventRing 实现——proc 包不依赖 internal/plugin(避免循环依赖)。
corehandler 改动:
- events.subscribe(原 case 23):子进程传事件类型列表,coreHandler
通过 evtRing 接口调用 EvtRingSubscribe,注册到 Bus 上。
事件经 EventRing 写入环后由子进程 mmap 读取。
- events.unsubscribe(原 case 24):当前由内核统一清理(子进程 Stop 时)。
Registry 改动:
- ensureProcHost 在创建 Host 后同时创建 EventRing(Bus → EvtRing → eventfd),
并通过 Host.SetEvtSubscriber 注入给 coreHandler。
测试 3 项:
- BasicWriteAndConsume:Host 创建 → EventRing 写入 → 消费者读到
- OverflowStillDelivers:写入超过 cap 后消费者仍能读到最新事件
- TypeMaskFiltering:typeMask 只订阅 tool_call,agent_output 被过滤
验证:go build ./... 通过;go test -race ./internal/plugin/... 全绿;
既有事件环测试 3/3 通过;proc 包测试未受影响。
Ref: docs/zh/架构迁移评估.md §3.6、docs/zh/plugin-migration-plan.md Part 5
|
2026-09-02 16:48:27 +08:00 |
|
|
|
11c1bbcebb
|
plugin: 子进程通道接通 registry(proc 通道端到端可运行)
Part 3 收尾。tryLoadProc 从桩位变成真实加载路径,plugin.bin 插件现在
经 registry 完整跑起来:spawn → 握手(共享段 fd 3)→ init/start →
反向注册 → 工具调用 → stage 共享内存读改写。
registry 侧:
- Registry 持有 procHost(惰性创建,**全部 .bin 插件共用一块段**)。
每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成
副本模型,lost update 原样复现(§8.4 实测 35.8~36.8%)。
- tryDynamic 分派到 Registry.loadProc;tryLoadProc 退为纯静态校验
(构造需要 Host,只有 Registry 有)。
- StopAll 在锁外释放共享段:插件还持有映射时拆段,它们下一次访问就是
SIGBUS;且持锁调用会与 onProcCrash 回调产生锁序风险。
- onProcCrash 把子进程退出转成 EventSystem 事件,不在回调里直接重载
(重载需 registry 锁,而回调可能来自持锁路径的 goroutine)。
proc_core.go —— 权限梯度的类型系统落点(§3.8):
- procCore 用**命名字段**持有 *isdk.PluginSDK,不是嵌入。嵌入会提升全部
方法,外部插件就能经类型断言拿到 Supervisor/Tracker/Adapter/Indexer/
Status/Selftest。命名字段下只有显式写出的方法存在——权限梯度从
「C ABI 表达能力的意外产物」变成显式声明并强制的策略。
- 能力访问器把内部超集接口收窄到公开面(isdk.KnowledgeAPI 内嵌
pubsdk.KnowledgeAPI 再加 Stats/Remove,isdk.MemoryAPI 加 GraphData,
isdk.LLMAPI 加 Chat/ReloadFromConfig);nil 保护避免类型化 nil 让
corehandler 的判空失效。
- procPluginAdapter 转接 Start(*isdk.PluginSDK) → Start(proc.CoreSDK),
Close 对 closeDynamic 可见故重载能真 kill 子进程(对比 dlclose 对
Go c-shared 是 no-op,§1.1)。
共享段分配按平台拆分(原先 host.go 直接调 unix.MemfdCreate,darwin/windows
交叉编译失败):Linux memfd;macOS 立即 unlink 的临时文件(无 memfd_create,
但语义一致:无残留、fd 可经 ExtraFiles 传递、子进程 mmap 同一 inode);
其余平台明确报错而非静默降级成「无共享段」——那会让 stage 静默失去数据面。
测试 +13 项:
- e2e_template_test.go 用**真实 plugindev 模板**(而非 testdata 手写假插件)
编译插件跑全链路,验证「模板 ↔ 内核」协议/布局真的对齐,不只是内核自己
跟自己对齐。含 lifecycle.autoRestart 上报、工具调用、stage 读改写、
FinalText 回传(C ABI 下 after_toolcall 看不到此字段,§8.3 10→16)、
只读插件不覆盖改写插件。
- proc_load_test.go 验证 Host 唯一性/惰性、chmod +x 错误提示、
Close 可见性,以及 procCore 不暴露内核内部机制的断言。
验证:go build ./... 通过;go test -race ./internal/plugin/... 全绿;
全仓 go test 无新增失败;git diff third_party/homeagent-sdk/sdk/ 为空。
既有告警 cabi/loader.go:156 unsafe.Pointer 非本次引入。
Ref: docs/zh/架构迁移评估.md §3.3/§3.4/§3.8、docs/zh/plugin-migration-plan.md Part 3
|
2026-09-02 13:03:57 +08:00 |
|
|
|
82dcc86173
|
feat(proc): Plugin 加载器 + 51 method handler + RunStage 接共享段(Part 2 完成 / Part 4 闭环)
corehandler.go —— cabi/loader.go 51 个 case 体的整块平移(§3.2):
- 参数从「s1/s2/s3 + i1/i2 五个固定槽」改为结构化 JSON,语义不变
- CoreSDK 接口刻意只含外部插件应得能力:无 Selftest/Supervisor/Tracker/
Status/Adapter/Config/Tool/Indexer/OutputChan/Publish
→ 权限梯度从「C ABI 表达能力的意外产物」变成「显式声明并强制的策略」(§3.8)
- 事件订阅(case 23/24)与 SetToolBlocks 明确返回未实现,不再像 C ABI 那样静默成功
(静默成功后收不到事件比报错更难排查)
- ToolDef.Cleaner / ChannelDef.Cleaner 是函数,跨进程置 nil(§3.5 回调型资源)
host.go —— 共享段所有权中心:
- ❗ 全部子进程插件共享**同一块 memfd**。若每插件一段,
「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型,
最后回读者覆盖前者,§8.4 的 35.8~36.8% lost update 原样复现
- stageMu 串行化整次 stage 对段的独占(内核可能并发触发 RunStage)
- 首个进入者写入段,最后离开者回读 + Compact(此时无插件持锁,满足 §3.3 前提)
stage.go —— RunStage 接线(风险 3.4 落点):
- 并发扇出保留(§0.2 第 1 条:并发扇出是原始设计,不是缺陷)
- 插件失败时 ForceReleaseLock,避免后续插件死锁(实验 9,无需 robust mutex)
plugin.go —— registry 可加载的插件实体:
- Start: spawn(fd 3 传共享段)→ 握手 → plugin.init → plugin.start
- Close: **真 kill + wait**,对比 cabi 的 Close 只做 dlclose 而后者是 no-op(§1.1)
- invokeOutput **同步等真实结果**,失败上报 error —— §9.4 根治
验证(34 项测试全绿,含 -race,全部用真实子进程):
- 单插件 stage 读改写经共享段回到内核 StageContext
- sanitizer(改写) + weather(只读) 并发:清洗结果不被覆盖(现网场景)
- **5 个独立进程并发 append 同一 FinalText:5 个标记全部保留,零丢失零撕裂**
(实验 8 在真实 RPC + 真实 RunStage 下的复刻)
- 工具注册可调用 / 输出通道真实失败上报 / start 期间反向调用
- 未知 method 与未实现能力被拒绝 / stage 外加锁被拒绝
接口冻结: git diff third_party/homeagent-sdk/sdk/ 为空
|
2026-09-02 11:34:33 +08:00 |
|
|
|
d62430a71b
|
feat(proc): 子进程通道 —— RPC 协议 + 进程管理(Part 2 核心)
协议面(protocol.go,§3.2 method id 平移为 method 名):
- NDJSON 帧,双向复用同一对 stdio;ID>0 需应答,ID==0 为通知(post-and-forget)
- 51 个 C ABI method id 全部平移为可读 method 名并标注原编号对照
编号本身扔掉——加能力不用改两边常量表,不再有 47 夹在 7 和 8 之间的痕迹
- case 25(CORE_FREE_STRING) 无对应 method:进程模型下各自 GC,概念消失
- case 23/24(事件订阅) 与 io.setToolBlocks 今日均为空实现「给不了」,
子进程下首次真正可给(§3.8 能力对齐)
- 新增 stage.lock/stage.unlock(C ABI 下不存在跨进程锁概念)
- StageInvokeParams 不含 StageContext 数据本身——数据在共享段,只带 stage 名 + seq
进程面(process.go,§2.3 保留现有生命周期机制):
- Spawn: 启动 + 握手(协议版本不匹配显式拒绝,不半兼容运行)
- readLoop: NDJSON 分派应答/插件反向请求,1MB 单帧上限(大 payload 走 arena)
- CallContext: ctx 取消时立即返回**且清理 pending 条目**
对比 cgo:超时只让调用方返回,goroutine 永久卡在 C 调用里(现网泄漏 26 次)
- Notify: ID=0 不占 pending 表,满足约束 B(流式逐 token 发布不得等待消费者)
- markExited: EOF/退出 → 唤醒全部在途调用 → onExit 回调
这是「把 panic 捕获换成进程退出检测」的落点,plugin_health 逻辑完全复用
- Stop: plugin.stop → 宽限期 → 超时 Kill;Kill 后 OS 回收全部资源,零泄漏
- serveRequest 带 panic 隔离:内核 handler panic 不带崩 readLoop
验证(10 项,真实子进程而非 mock,含 -race):
- 握手/工具调用/错误上报(插件失败调用方收到 error,非假成功)
- 插件反向调用内核(tool.register + settings.get 双向往返)
- **崩溃隔离**:插件 panic → 子进程 exit 2,内核存活、收到 onExit、在途调用不挂死
- 优雅停止 / **Kill 卡死插件**(ctx 超时返回 + pending 清零 + 资源回收)
- 通知不等应答(100 条 < 1s)/ 50 并发调用应答不串 / 协议版本不匹配拒绝
接口冻结: git diff third_party/homeagent-sdk/sdk/ 为空
|
2026-09-02 10:59:59 +08:00 |
|
|
|
610e9d0bbb
|
feat(plugin): entry 双通道分派 + 共享内存 stage 数据面(Part 1 + Part 4 核心)
Part 1 加载分派骨架(迁移可逐插件推进、随时回退的前提):
- dynamic.go: 新增 binEntry/skillEntry 常量 + entryKind 枚举 + classifyEntry/detectEntryKind
manifest entry 优先级最高(改回 plugin.so 即回退 cabi);无 manifest 时按目录探测,.bin 优先
- registry.go: tryDynamic 按 entry 分派 proc/cabi 双通道;
entry 声明 .bin 但二进制缺失时报明确错误,不静默回退(否则'已迁移插件跑回旧通道'极难排查)
- registry.go: pluginEntryHash 候选顺序与 detectEntryKind 对齐(.bin 优先),
否则增量重载会用错文件算 hash
- dynamic_proc_{unix,windows}.go: tryLoadProc 桩位(权限/类型校验已实现,进程管理属 Part 2)
Part 4 共享内存数据面(迁移评估 §3.3/§3.4/§3.7,最关键一环):
- proc/shm.go: 段布局(Header + ShmStageCtx 描述符数组 + append-only arena)
相对偏移设计——各进程 mmap 到不同虚拟地址仍能正确解引用
arena 用尽显式报错而非静默截断(§4.4 风险登记);Compact() 回收 append-only 垃圾
- proc/shmcodec.go: StageContext 16 字段跨进程编解码
字段级描述符消除 lost update:只改 FinalText 的插件不触碰 ToolResults 描述符
WriteDirty 只写脏字段——只读插件零写入,不可能覆盖他人改写
Snapshot 存序列化字符串(切片共享底层数组的坑,C ABI 侧修 11.3 时已踩过)
Extra 4 键提升为具名字段;Response 用标志位表达 nil vs 空串
- proc/lock.go: 锁仲裁回归内核(§3.7 已裁定,零 cgo)
ForceRelease 实现实验 9 的崩溃自愈——排除 robust pthread_mutex 必要性
重复加锁显式拒绝(否则死锁 30s);等待超时有补偿 goroutine 防锁泄漏
验证:
- proc 包 16 项测试全绿(含 -race):全字段往返/只读零写回/原地改切片识别/
现网 sanitizer+weather 场景/5插件×40轮并发零丢失/arena 耗尽报错/压实不破坏字段/
锁互斥·串扰拒绝·崩溃自愈·临界区串行化
- entry 分派 9 项测试全绿;go build ./... exit 0;接口冻结 git diff sdk/ 为空
|
2026-09-02 10:41:18 +08:00 |
|