Files
HomeAgent/internal/plugin/proc/protocol.go
JianFeeeee f5a1366770 fix(proc): arena 扩容块泄漏 + 取消成为一等状态
## 1. arena 泄漏(线上 arena 被打满的根因)

插件经 arena.alloc 申请的扩容块,块头 owner 记的是**插件的** ownerID,
而内核三处归还全写着 Free(OwnerHost, ref)。归属校验必然失败,错误又被
`_ =` 吞掉 ⇒ 每返回一次大结果就永久泄漏一块。

线上表现(2026-10-25):重任务跑几轮后 4MB arena 满,之后连 64KB 调用帧
都分不出来,所有插件工具一起报「arena: 空间不足(请求 65779 字节)」。

修法:三处改用插件自己的 ownerID,且失败必须留日志(吞掉错误等于把
下一次「空间不足」的原因提前销毁)。

## 2. 取消成为一等状态

旧实现里「调用在进行中」只表达为 pending 表里有这个键:取消时直接
delete,状态的痕迹全部消失,插件随后回的应答只能走「未知 id,丢弃」。
那是副作用的正确,不是规则的正确。

改成每次调用带显式状态机(callstate.go):

    pending ──应答──→ done
       ├──取消/超时──→ canceled ──迟到应答──→ done
       └──进程退出──→ failed

迁移表集中在 legalTransition,非法组合一律拒绝(CAS 只校验 from,
不校验 to——实测曾把 done 改回 failed)。

## 3. 取消跨进程送达

内核超时到点后只停止「等」,插件侧的 handler 还在跑,占着连接/子进程/
文件句柄,结果却已被丢弃。现在 CallContext 取消时向插件发 call.cancel
通知(fire-and-forget),插件运行时按 **RPC id** 定位并 cancel 对应调用的 ctx。

不按工具名定位:同一工具可并发多次,按名字会误伤其它实例。

插件作者零改动:handler 签名不变,取消由模板运行时接住。

## 4. 取消超时判定插件不稳定

严格区分两类超时(用户裁定):

    业务超时 = 任务跑太久     → 正常,不作为不稳定依据
    取消超时 = 喊停之后还不停 → 异常,是插件失去响应的直接证据

取消宽限(cancelGrace=5s)到期仍无应答 ⇒ ① 强制回收该调用名下的扩容块
(插件已静默退出,永远不会归还)② 计数 ③ 连续两次触发既有崩溃重启链。
插件一旦响应取消即清零;重启后也清零(判据是"连续"不是"累计")。

判据:8 条新增单测 + 全部变异验证(不回收/不上报/不清零/取消不跨进程
任一处改坏,对应用例立刻变红)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-25 10:10:21 +08:00

428 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package proc
import (
"encoding/json"
"strings"
)
// RPC 协议定义:控制面(§3.2 method id 平移为 method 名)。
//
// 帧格式:**换行分隔的 JSON**(NDJSON),双向复用同一对 stdio 管道。
// 内核 → 插件 stdin :请求 / 响应
// 插件 → 内核 stdout:请求 / 响应
//
// 为什么不用 length-prefixed 二进制帧:工具调用结果中位数仅 93B(§2.5),
// JSON 序列化 3-8 µs 对比 LLM 单轮 2-8 秒占 0.0001%,可读性与可调试性更值。
// 大 payload(多媒体二进制)走共享内存 arena,不进 RPC 帧(§3.3 实验 10:18-22x)。
// 协议兼容性判据:**大版本号唯一**。
//
// 不自定协议号。插件的兼容性由双方各自的版本号表态:内核侧 meta.Version、
// 插件侧 sdk_version(SDK 版本跟随内核中版本,patch 恒为 .0)。
// 版本号唯一事实源就是这两个 meta.Version(见 docs/zh/ci-cd-runbook.md)。
//
// 判据是**主版本相同**:
// - 1.x ↔ 1.y:兼容。1.0.0 之后全是纯追加,不调新面就不受影响。
// - 跨主版本(如 1.x → 2.x):拒绝建链,提示用配套 hmapdev 重编。
// 主版本跃迁正是**破坏性变更**的定义(1.0.0 从 C ABI 改子进程即属此列),
// 所以它天然是正确的分界,不需要另一个号来复述这件事。
//
// 布局一致性另由 stageFieldCount(描述符个数)把关:它是结构的派生事实,
// 也不是版本号。两者错配都在握手上显式失败,不半兼容运行。
// MajorVersion 取版本号的主版本段("1.4.0" → "1","" → "")。
func MajorVersion(v string) string {
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
if i := strings.IndexByte(v, '.'); i >= 0 {
return v[:i]
}
return v
}
// Direction 无需显式字段:靠 Method 是否为空区分请求与响应
// (与 clawhubadapter/sidecar 的成熟做法一致)。
// Request 是一次 RPC 调用。
//
// ID 语义:
// - ID > 0 :需要响应,调用方在 pending 表等待
// - ID == 0 :通知(fire-and-forget),被调方不得回响应
//
// 通知用于事件投递等不关心结果的路径(§2.4 约束 B:内核发通知绝不等待消费者)。
type Request struct {
ID uint64 `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// Response 是对 Request 的应答。Error 非空表示失败。
type Response struct {
ID uint64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// ---- kernel → plugin(内核调用插件;工具执行、Cleaner、stage、输出等)----
const (
// MethodPluginInit 传插件名与配置,插件构造实例但不启动。
MethodPluginInit = "plugin.init"
// MethodPluginStart 插件注册工具/阶段/通道(其间会反向发起大量 core.* 调用)。
MethodPluginStart = "plugin.start"
// MethodPluginStop 优雅停止:插件侧先跑 RunStopHandlers 再 Stop()。
MethodPluginStop = "plugin.stop"
// MethodToolInvoke 执行插件工具。
MethodToolInvoke = "tool.invoke"
// MethodCallCancel 通知插件**放弃**某个仍在执行的调用。
//
// ## 为什么取消必须跨进程
//
// 内核超时到点后自己不再等(toolcall.go 用 select + time.After),但
// **插件侧的 handler 还在跑**:它不知道没人要这个结果了,会继续占着
// 连接/子进程/文件句柄直到自己跑完。旧实现里内核只能把该请求从 pending
// 表里删掉——结果被丢弃,成本照付。
//
// ## 为什么不能让插件作者改签名
//
// 一个自然的想法是给 ToolHandler 加 context.Context,但那要求**所有**
// 插件跟着改(仓内 100 处 + 全部存量插件),而“让内核能停下一个不想停的
// 调用”本来就是**内核的**责任,不该把负担转给 100 个作者。
//
// 所以取消走这里:内核发一条**无 id 的通知帧**(不等于任何请求),
// 插件运行时按 CallID 找到正在跑的 handler,把它的 ctx cancel 掉。
// handler 签名不变 —— 它在下一次阻塞调用(HTTP/子进程/DB)返回 context.Canceled 时自然退出。
//
// 参数:CallID 是**被取消 đó 请求的 RPC id**(即 tool.invoke 帧的 id)。
MethodCallCancel = "call.cancel"
// MethodCleanerInvoke 在插件进程内执行工具或通道声明的 Cleaner。
// Cleaner 是函数,不能随注册请求 JSON 序列化;内核保留 RPC 回调闭包,
// 需要参与向量化/蒸馏时把原文送回插件执行真正的 Cleaner。
MethodCleanerInvoke = "cleaner.invoke"
// MethodStageInvoke 执行阶段处理器。数据经共享段传递,参数只带阶段名与段世代号。
MethodStageInvoke = "stage.invoke"
// MethodOutputInvoke 经插件输出通道发送。
MethodOutputInvoke = "output.invoke"
// MethodArenaAlloc 向内核申请一块共享内存(返回偏移与大小)。
// MethodArenaFree 通知内核回收先前申请的共享内存。
//
// 这两个是**内部传输层接口**,不由插件开发者直接使用:共享内存是
// 内核的内部实现,公开 SDK 仍是普通字符串/Map,模板运行时按
// payload 大小自动选择内联 JSON 还是共享槽。
MethodArenaAlloc = "arena.alloc"
MethodArenaFree = "arena.free"
// MethodHandshake 建链首帧:交换协议版本、SDK 版本、共享段规格。
MethodHandshake = "handshake"
)
// ---- plugin → kernel(51 个 method id 平移,§3.2)----
//
// 编号本身扔掉:不再维护"下一个可用 id 是 52",加能力不用改两边常量表,
// 也不再出现 47 夹在 7 和 8 之间的历史痕迹。
const (
// 注册面(原 case 1/2/3/4/46)
MethodToolRegister = "tool.register" // 1 CORE_REGISTER_TOOL
MethodStageRegister = "stage.register" // 2 CORE_REGISTER_STAGE
MethodOutputRegister = "output.register" // 3 CORE_REGISTER_OUTPUT_CH
MethodAPIRegister = "api.register" // 4 CORE_REGISTER_PLUGIN_API
MethodInputRegister = "input.register" // 46 CORE_REGISTER_INPUT_CH
// IO 注入(原 case 5/6/7/47)
MethodIOInjectText = "io.injectText" // 5 CORE_INJECT_TEXT
MethodIOInjectInterrupt = "io.injectInterrupt" // 6 CORE_INJECT_INTERRUPT_TEXT
MethodIOInjectTextNoMem = "io.injectTextNoMem" // 7 CORE_INJECT_TEXT_NO_MEMORY
MethodIOInjectSync = "io.injectInputSync" // 47 CORE_INJECT_INPUT_SYNC
// 带媒体的注入:blocks 随参数 JSON 一并过来,内核侧转成
// payload["media_blocks"],由 resolveInput 归一进统一输入主干。
// 与 SetToolBlocks 的区别:这三个是「主动发起一轮带图的对话」,
// 后者是「工具返回值里带图」,只能在工具调用内部用。
MethodIOInjectMedia = "io.injectMedia"
MethodIOInjectMediaSync = "io.injectMediaSync"
MethodIOInjectInterruptMedia = "io.injectInterruptMedia"
// MethodIOSetToolBlocks 多模态注入——今日 C ABI 侧是空实现(§1.4),
// 子进程下二进制落 arena、描述符回传,首次真正可用。
MethodIOSetToolBlocks = "io.setToolBlocks"
// 生命周期(原 case 8)
MethodLifecycleAutoRestart = "lifecycle.autoRestart" // 8 CORE_SET_AUTO_RESTART
// 图记忆(原 case 9/10/11/12/13)
MethodMemoryRecall = "memory.recall" // 9
MethodMemoryCommit = "memory.commit" // 10
MethodMemoryIntrospect = "memory.introspect" // 11
MethodMemoryMerge = "memory.merge" // 12
MethodMemoryPurge = "memory.purge" // 13
// 文档记忆(原 case 14/32/33/34)
MethodDocQuery = "doc.query" // 14
MethodDocInsert = "doc.insert" // 32
MethodDocRemove = "doc.remove" // 33
MethodDocStats = "doc.stats" // 34
// MethodDocInsertMedia 写入文档并关联媒体(附件带 data 则落盘去重,
// 只带 digest 则引用已有内容)。
MethodDocInsertMedia = "doc.insertWithMedia"
// 知识库(原 case 15/35/36)
MethodKnowledgeSearch = "knowledge.search" // 15
MethodKnowledgeAdd = "knowledge.add" // 35
MethodKnowledgeList = "knowledge.list" // 36
// MethodKnowledgeAddMedia 写入知识并关联媒体(与 doc.insertWithMedia
// 同形)。媒体成为一等节点参与跨模态召回;未接入多模态空间时与
// knowledge.add 等价。
MethodKnowledgeAddMedia = "knowledge.addWithMedia"
// 文本记忆(原 case 41)
MethodTextMemoryAppend = "textmemory.append" // 41
// 设置(原 case 16/17/18/26/27/28/29/30/31/42/43/44/45/51)
MethodSettingsGet = "settings.get" // 16
MethodSettingsSet = "settings.set" // 17
MethodSettingsRegisterDef = "settings.registerDef" // 18
MethodSettingsGetCore = "settings.getCore" // 26
MethodSettingsSetCore = "settings.setCore" // 27
MethodSettingsListCore = "settings.listCore" // 28
MethodSettingsGetPlugin = "settings.getPlugin" // 29
MethodSettingsSetPlugin = "settings.setPlugin" // 30
MethodSettingsListPlugin = "settings.listPlugin" // 31
MethodSettingsList = "settings.list" // 42
MethodSettingsDefs = "settings.defs" // 43
MethodSettingsDump = "settings.dump" // 44
MethodSettingsPlugins = "settings.plugins" // 45
MethodSettingsDataDir = "settings.dataDir" // 51
// 密文读写(强制加密,不看键名)—— 与 settings.set/get 的区别是
// 这两个**必然**走加密路径,用于插件用中性键名存凭据的场景。
MethodSettingsSetSecret = "settings.setSecret" // 52
MethodSettingsGetSecret = "settings.getSecret" // 53
// 变量库(全局共享,非 per-plugin;见 internal/security 与 corehandler_secrets.go)。
//
// 与 settings 的分工是本质的:settings 是**每个插件自己的**配置,
// secrets 是**全进程共享**的变量库单例(主/子/驻留子 agent 与全部插件共用)。
MethodSecretsAvailable = "secrets.available" // 32
MethodSecretsStore = "secrets.store" // 33
MethodSecretsList = "secrets.list" // 34
MethodSecretsForget = "secrets.forget" // 35
MethodSecretsFill = "secrets.fill" // 36
MethodSecretsEncrypt = "secrets.encrypt" // 46
MethodSecretsDecrypt = "secrets.decrypt" // 47
// LLM 源(原 case 19/20/37)
MethodLLMListSources = "llm.listSources" // 19
MethodLLMSetSource = "llm.setSource" // 20
MethodLLMCurrentSource = "llm.currentSource" // 37
// 社交图(只读,原 case 21/22/38/39/40)
MethodSocialGetPerson = "social.getPerson" // 21
MethodSocialGetNetwork = "social.getNetwork" // 22
MethodSocialGetTrait = "social.getTrait" // 38
MethodSocialGetRelation = "social.getRelations" // 39
MethodSocialListPersons = "social.listPersons" // 40
// 事件(原 case 23/24 —— 今日均为空实现「给不了」,
// 子进程下经事件环 + eventfd 首次真正可用,见 §3.6/§3.8)
MethodEventsSubscribe = "events.subscribe" // 23
MethodEventsUnsubscribe = "events.unsubscribe" // 24
// 插件管理(原 case 48/49/50)
MethodPluginReloadOne = "plugin.reloadOne" // 48
MethodPluginListLoaded = "plugin.listLoaded" // 49
MethodPluginIsDisabled = "plugin.isDisabled" // 50
// ---- 运行态启停(设计 §2.6.7):与 isDisabled(持久态)分开 ----
//
// ★ 为何必须桥接:子进程插件走本桥,未在此登记的方法会被
// corehandler 直接拒("未知 method")⇒ SDK 上加了方法但插件**调不通**。
// 同进程插件(.so)直接调 SDK,不受影响 —— 这个不对称容易漏。
MethodPluginMgrStop = "plugin.mgrStop" // 51
MethodPluginMgrStart = "plugin.mgrStart" // 52
MethodPluginMgrIsRuntimeStopped = "plugin.mgrIsRuntimeStopped" // 53
// ---- 内核控制(重启/关机,供 WebUI 等界面插件用)----
//
// ★ 同样是**必须桥接**的方法:子进程插件走本桥,未登记的方法会被
// corehandler 直接拒("未知 method")。WebUI 是子进程插件,
// 不走这里就点不动按钮。
MethodKernelRestart = "kernel.restart" // 54
MethodKernelShutdown = "kernel.shutdown" // 55
// 共享段锁仲裁(新增,无对应 method id —— C ABI 下不存在跨进程锁概念)
MethodStageLock = "stage.lock"
MethodStageUnlock = "stage.unlock"
)
// 原 case 25(CORE_FREE_STRING)无对应 RPC method:
// C ABI 下需要显式释放跨边界字符串,进程模型下由各自 GC 管理,概念消失。
// HandshakeParams 是内核 → 插件的建链首帧:告知内核侧规格。
type HandshakeParams struct {
CoreVersion string `json:"core_version"` // 内核版本(插件据此判主版本兼容)
PluginName string `json:"plugin_name"` // 内核分配的插件名
// FieldCount 是 StageContext 描述符个数。插件据此与自己的编译期
// 字段数比对:不匹配意味着标志位基址会错位,必须重编(而非静默错读)。
FieldCount uint32 `json:"field_count"`
ShmSize int `json:"shm_size"`
// EvtRingSize 是事件环段大小(0 表示不支持事件环)。插件据此 mmap fd 4。
EvtRingSize int `json:"evt_ring_size,omitempty"`
}
// HandshakeResult 是插件 → 内核的建链应答:上报自身信息。
type HandshakeResult struct {
// SDKVersion 是插件编译时链接的公开 SDK 版本。内核据此判主版本兼容。
SDKVersion string `json:"sdk_version"`
PluginName string `json:"plugin_name"`
PID int `json:"pid"`
}
// StageInvokeParams 是 stage.invoke 的参数。
//
// **注意:不含 StageContext 数据本身**——数据在共享段,此处只带定位信息。
// 这是共享内存数据面的意义:并发改写同一份状态,而非各持副本
// (副本模型实测 35.8~36.8% lost update,§8.4)。
type StageInvokeParams struct {
Stage string `json:"stage"`
// Seq 是内核写入共享段后的世代号,插件读到的 seq 应 >= 此值。
Seq uint64 `json:"seq"`
}
// StageInvokeResult 是插件执行 stage 后的应答。
type StageInvokeResult struct {
// DirtyFields 是插件实际写回共享段的字段数,0 表示只读插件。
// 内核据此判断是否需要重读共享段,也用于诊断"谁改了什么"。
DirtyFields int `json:"dirty_fields"`
// Seq 是插件写回后的世代号。
Seq uint64 `json:"seq"`
}
// ToolInvokeParams:工具调用(原 go_invoke_tool)。
//
// 按 **funccall 模型**,内核(caller)为每次调用标定一块内存帧交给插件
// (callee),插件在这块内存里工作:
//
// [0, ArgsLen) 参数 JSON
// [ArgsLen, Frame.Length) 结果区(内核预留的预算)
//
// 结果放得下就写在帧内;**不够用时插件才向内核申请扩容**(arena.alloc),
// 并在返回引用上打 sharedRefFlagExpand,内核据此单独归还扩容块。
//
// 参数永远在共享内存里,不存在“小 payload 走内联”的按大小分支。
//
// Args 仅剩给**直连 RPC 的调用方**(process/bench 测试不建 Host,拿不到
// 共享内存);内核的 invokeTool 始终走 Frame。
type ToolInvokeParams struct {
Name string `json:"name"`
Frame SharedRef `json:"frame,omitempty"`
ArgsLen uint32 `json:"args_len,omitempty"`
Args map[string]interface{} `json:"args,omitempty"` // 仅直连 RPC 调用方使用
}
type ToolInvokeResult struct {
ResultRef SharedRef `json:"result_ref,omitempty"`
Result interface{} `json:"result,omitempty"` // 仅直连 RPC 调用方使用
}
// CallCancelParams 是 call.cancel 通知的参数。
//
// CallID 是**要取消的那个请求的 RPC id**(tool.invoke / cleaner.invoke 帧的 id)。
// 插件运行时会按它找到仍在执行的 handler 并 cancel 其 ctx。
//
// 注意这是**通知**(ID=0),插件不应回响应:取消路径绝不能变成新的同步点,
// 否则内核超时时反而被“等插件确认取消”再拖一轮。
type CallCancelParams struct {
CallID uint64 `json:"call_id"`
Reason string `json:"reason,omitempty"`
}
// CleanerInvokeParams / CleanerInvokeResult:跨进程计算层清洗。
// Scope 取 tool / input / output,Name 是工具名或通道名。
//
// 与工具调用用**同一个 funccall 帧模型**:
//
// [0, InputLen) 输入文本
// [InputLen, Frame.Length) 结果区(内核预留的预算)
//
// 结果放不下时插件申请扩容块,并在 TextRef 上打 sharedRefFlagExpand。
type CleanerInvokeParams struct {
Scope string `json:"scope"`
Name string `json:"name"`
Frame SharedRef `json:"frame,omitempty"`
InputLen uint32 `json:"input_len,omitempty"`
}
type CleanerInvokeResult struct {
TextRef SharedRef `json:"text_ref,omitempty"`
}
// 调用帧的结果预算。
//
// 内核按“参数长度 + 预算”标定帧;结果超出预算不是失败,插件会申请扩容块。
// 预算取 64KB:覆盖绝大多数工具结果,使常态调用完全免于第二次分配。
const (
toolResultBudget = 64 * 1024
cleanerResultBudget = 64 * 1024
)
const (
CleanerScopeTool = "tool"
CleanerScopeInput = "input"
CleanerScopeOutput = "output"
)
// OutputInvokeParams:输出通道发送(原 go_invoke_output)。
//
// 与 C ABI 路径的关键差异:**可同步等待真实结果**。
// C ABI 下因 cgo 不可嵌套,只能异步 fire-and-forget,导致 output_send
// 永远返回成功(§9.4,现网 2 次消息发不出而模型以为成功)。
// 进程模型下 RPC 天然可等应答,该缺陷从根上消失。
type OutputInvokeParams struct {
Channel string `json:"channel"`
// Frame 是调用帧:[0, ArgsLen) 是参数 JSON;[ArgsLen, Frame.Length) 是结果区。
// 与 ToolInvokeParams 同一 funccall 帧模型(§13.3)。没有帧时(直连 RPC
// 测试)回退到 Args。大 payload 不再爆 stdin/stdout 管道——这正是同步
// output_send 卡住的根因之一。
Frame SharedRef `json:"frame,omitempty"`
ArgsLen uint32 `json:"args_len,omitempty"`
Args map[string]interface{} `json:"args,omitempty"` // 仅直连 RPC 调用方使用
}
// OutputInvokeResult 与 ToolInvokeResult 同形:结果优先写进帧结果区,
// 放不下才申扩容块并打 sharedRefFlagExpand。标量响应(如 "ok")直接在
// Result 字段返回,不走共享内存。
type OutputInvokeResult struct {
ResultRef SharedRef `json:"result_ref,omitempty"`
Result interface{} `json:"result,omitempty"` // 仅直连 RPC 调用方使用
}
// ArenaAllocParams / ArenaAllocResult:插件向内核申请共享内存。
//
// 内核返回的 Ref.Length 是槽容量(可写上限),插件写入后自行把 Length
// 改成实际 payload 长度再随业务 RPC 回传。
type ArenaAllocParams struct {
Size uint32 `json:"size"`
// CallID 是**本块归属于哪次调用**(tool.invoke / cleaner.invoke 的 RPC id)。
//
// ## 为什么必须带它(2026-10-25)
//
// 扩容块是插件为“把大结果回给内核”而申请的。但调用可能已被内核取消:
// 插件静默退出、内核从未看到那个 ResultRef,于是这块**永远不会被归还**——
// 内核手上没有任何句柄能指向它。这就是“取消了却瞒下了内存”的真实泄漏形态。
//
// 带上 CallID 后,内核就能在“取消超时”时把该调用名下的块全部强制回收,
// 而不需要信任对端会归还。
//
// 0 表示未归属(旧模板/直连调用)——此时退回旧行为:只能等插件自己归还。
CallID uint64 `json:"call_id,omitempty"`
}
type ArenaAllocResult struct {
Ref SharedRef `json:"ref"`
}
// ArenaFreeParams:插件通知内核回收共享内存。只带描述符,槽号在 Flags 里。
type ArenaFreeParams struct {
Ref SharedRef `json:"ref"`
}
// PluginInitParams:插件构造参数(原 case init_plugin)。
type PluginInitParams struct {
Name string `json:"name"`
Config map[string]interface{} `json:"config,omitempty"`
}