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/ 为空
This commit is contained in:
dev
2026-09-02 10:41:18 +08:00
parent fe2fdc9692
commit 610e9d0bbb
12 changed files with 1919 additions and 8 deletions

View File

@ -0,0 +1,514 @@
package proc
import (
"encoding/json"
"fmt"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// StageContext 的跨进程编解码§3.3 数据面 / §3.4 SDK 封装全部复杂度)。
//
// 设计要点:
//
// 1. **插件作者永远不接触 Slice{off,len}**。插件进程内保留原生
// *pubsdk.StageContexthandler 照常读写字段stage 入口从共享段
// 反序列化成本地对象handler 返回时把**脏字段**写回共享段。
//
// 2. **字段级描述符消除 lost update**。只改 FinalText 的插件不触碰
// ToolResults 的描述符,故不存在"只读插件把旧快照写回覆盖他人改写"
// (今日副本模型实测 35.8~36.8% 丢失§8.4)。
//
// 3. **全部 16 个字段可见**。今日经 C ABI 只下发 10 个字段,外部插件永远
// 看不到 ContextMsgs/ReasoningContent/TokenUsage/Memory/Extra/Errors§8.3
// 共享内存下全部可见可改——接口形式不变,能力变强。
//
// 4. **Extra 的 4 个键提升为具名字段**§3.3 已核实全部使用点仅这 4 个):
// media_blocks / media_type / input_source / output_channel。
// 它们都是内核写、插件读,无并发改写需求;真正需要多插件并发改的
// LLMText/FinalText/ToolCalls/Errors全是强类型字段。
// extra 中被提升为具名字段的键。
const (
ExtraKeyMediaBlocks = "media_blocks"
ExtraKeyMediaType = "media_type"
ExtraKeyInputSource = "input_source"
ExtraKeyOutputChannel = "output_channel"
)
// WriteAll 把整个 StageContext 写入共享段(内核侧在 stage 开始前调用一次)。
// 调用方须持有写锁。
func (s *Segment) WriteAll(sc *pubsdk.StageContext) error {
sc.RLock()
snap := captureLocal(sc)
sc.RUnlock()
return s.writeLocal(snap)
}
// localCtx 是 StageContext 的值快照,用于在不持有 sc 锁的情况下做编解码。
type localCtx struct {
RawMessage string
UserID string
GroupID string
LLMText string
ReasoningContent string
FinalText string
Response *string
Phase string
NoMemory bool
ContextMsgs []map[string]interface{}
ToolCalls []pubsdk.ToolCall
ToolResults []pubsdk.ToolResult
Memory []pubsdk.MemItem
TokenUsage map[string]int
Errors []string
ExtraMediaBlocks interface{}
ExtraMediaType interface{}
ExtraInputSource interface{}
ExtraOutputChan interface{}
}
func captureLocal(sc *pubsdk.StageContext) *localCtx {
l := &localCtx{
RawMessage: sc.RawMessage,
UserID: sc.UserID,
GroupID: sc.GroupID,
LLMText: sc.LLMText,
ReasoningContent: sc.ReasoningContent,
FinalText: sc.FinalText,
Response: sc.Response,
Phase: string(sc.Phase),
NoMemory: sc.NoMemory,
ContextMsgs: sc.ContextMsgs,
ToolCalls: sc.ToolCalls,
ToolResults: sc.ToolResults,
Memory: sc.Memory,
TokenUsage: sc.TokenUsage,
Errors: sc.Errors,
}
if sc.Extra != nil {
l.ExtraMediaBlocks = sc.Extra[ExtraKeyMediaBlocks]
l.ExtraMediaType = sc.Extra[ExtraKeyMediaType]
l.ExtraInputSource = sc.Extra[ExtraKeyInputSource]
l.ExtraOutputChan = sc.Extra[ExtraKeyOutputChannel]
}
return l
}
func (s *Segment) writeLocal(l *localCtx) error {
putStr := func(f stageField, v string) error {
sl, err := s.write([]byte(v))
if err != nil {
return fmt.Errorf("写入字段 %s: %w", f, err)
}
s.setDesc(f, sl)
return nil
}
putJSON := func(f stageField, v interface{}) error {
if v == nil {
s.setDesc(f, Slice{})
return nil
}
b, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("序列化字段 %s: %w", f, err)
}
sl, err := s.write(b)
if err != nil {
return fmt.Errorf("写入字段 %s: %w", f, err)
}
s.setDesc(f, sl)
return nil
}
for _, step := range []struct {
f stageField
v string
}{
{fRawMessage, l.RawMessage},
{fUserID, l.UserID},
{fGroupID, l.GroupID},
{fLLMText, l.LLMText},
{fReasoningContent, l.ReasoningContent},
{fFinalText, l.FinalText},
{fPhase, l.Phase},
} {
if err := putStr(step.f, step.v); err != nil {
return err
}
}
// Response 是 *string用标志位表达 nil避免 "" 与 nil 混淆
if l.Response != nil {
if err := putStr(fResponse, *l.Response); err != nil {
return err
}
s.setFlag(flagResponseSet, true)
} else {
s.setDesc(fResponse, Slice{})
s.setFlag(flagResponseSet, false)
}
s.setFlag(flagNoMemory, l.NoMemory)
// 切片/映射字段nil 与空切片都写成"未设置",避免插件收到 [] 后误以为
// 内核显式清空过(与今日 writable 的 len>0 才下发语义一致)。
jsonFields := []struct {
f stageField
v interface{}
}{
{fContextMsgs, sliceOrNil(len(l.ContextMsgs), l.ContextMsgs)},
{fToolCalls, sliceOrNil(len(l.ToolCalls), l.ToolCalls)},
{fToolResults, sliceOrNil(len(l.ToolResults), l.ToolResults)},
{fMemory, sliceOrNil(len(l.Memory), l.Memory)},
{fTokenUsage, sliceOrNil(len(l.TokenUsage), l.TokenUsage)},
{fErrors, sliceOrNil(len(l.Errors), l.Errors)},
{fExtraMediaBlocks, l.ExtraMediaBlocks},
{fExtraMediaType, l.ExtraMediaType},
{fExtraInputSource, l.ExtraInputSource},
{fExtraOutputChannel, l.ExtraOutputChan},
}
for _, step := range jsonFields {
if err := putJSON(step.f, step.v); err != nil {
return err
}
}
s.bumpSeq()
return nil
}
// sliceOrNil 让长度为 0 的容器写成 nil未设置非零则原样返回。
func sliceOrNil(n int, v interface{}) interface{} {
if n == 0 {
return nil
}
return v
}
// ReadInto 从共享段读出全部字段填充到 sc插件进程侧 stage 入口调用)。
// 调用方须持有读锁或写锁。
func (s *Segment) ReadInto(sc *pubsdk.StageContext) error {
getStr := func(f stageField) (string, error) {
b, err := s.read(s.getDesc(f))
if err != nil {
return "", fmt.Errorf("读取字段 %s: %w", f, err)
}
return string(b), nil
}
getJSON := func(f stageField, out interface{}) error {
b, err := s.read(s.getDesc(f))
if err != nil {
return fmt.Errorf("读取字段 %s: %w", f, err)
}
if len(b) == 0 {
return nil
}
if err := json.Unmarshal(b, out); err != nil {
return fmt.Errorf("反序列化字段 %s: %w", f, err)
}
return nil
}
raw, err := getStr(fRawMessage)
if err != nil {
return err
}
uid, err := getStr(fUserID)
if err != nil {
return err
}
gid, err := getStr(fGroupID)
if err != nil {
return err
}
llm, err := getStr(fLLMText)
if err != nil {
return err
}
reason, err := getStr(fReasoningContent)
if err != nil {
return err
}
final, err := getStr(fFinalText)
if err != nil {
return err
}
phase, err := getStr(fPhase)
if err != nil {
return err
}
var ctxMsgs []map[string]interface{}
var toolCalls []pubsdk.ToolCall
var toolResults []pubsdk.ToolResult
var mem []pubsdk.MemItem
var usage map[string]int
var errs []string
if err := getJSON(fContextMsgs, &ctxMsgs); err != nil {
return err
}
if err := getJSON(fToolCalls, &toolCalls); err != nil {
return err
}
if err := getJSON(fToolResults, &toolResults); err != nil {
return err
}
if err := getJSON(fMemory, &mem); err != nil {
return err
}
if err := getJSON(fTokenUsage, &usage); err != nil {
return err
}
if err := getJSON(fErrors, &errs); err != nil {
return err
}
extra := map[string]interface{}{}
for _, pair := range []struct {
f stageField
key string
}{
{fExtraMediaBlocks, ExtraKeyMediaBlocks},
{fExtraMediaType, ExtraKeyMediaType},
{fExtraInputSource, ExtraKeyInputSource},
{fExtraOutputChannel, ExtraKeyOutputChannel},
} {
var v interface{}
if err := getJSON(pair.f, &v); err != nil {
return err
}
if v != nil {
extra[pair.key] = v
}
}
var respPtr *string
if s.getFlag(flagResponseSet) {
r, err := getStr(fResponse)
if err != nil {
return err
}
respPtr = &r
}
sc.Lock()
defer sc.Unlock()
sc.RawMessage = raw
sc.UserID = uid
sc.GroupID = gid
sc.LLMText = llm
sc.ReasoningContent = reason
sc.FinalText = final
sc.Phase = pubsdk.Stage(phase)
sc.NoMemory = s.getFlag(flagNoMemory)
sc.Response = respPtr
sc.ContextMsgs = ctxMsgs
sc.ToolCalls = toolCalls
sc.ToolResults = toolResults
sc.Memory = mem
sc.TokenUsage = usage
sc.Errors = errs
if len(extra) > 0 {
sc.Extra = extra
} else {
sc.Extra = nil
}
return nil
}
// WriteDirty 只把与 base 快照不同的字段写回共享段(插件进程侧 handler 返回后调用)。
//
// **这是消除 lost update 的关键**:只读插件的 dirty 集为空 → 零写入 →
// 不可能覆盖其他插件的改写。对比今日副本模型无条件回传 10 个字段的行为
// §8.4 实测 35.8~36.8% 丢失,现网量级百分之几的脏数据进 LLM
//
// 返回实际写回的字段数,便于诊断与测试断言。
func (s *Segment) WriteDirty(sc *pubsdk.StageContext, base *Snapshot) (int, error) {
sc.RLock()
cur := captureLocal(sc)
sc.RUnlock()
now := newSnapshotFromLocal(cur)
changed := 0
putStr := func(f stageField, v string) error {
sl, err := s.write([]byte(v))
if err != nil {
return fmt.Errorf("写回字段 %s: %w", f, err)
}
s.setDesc(f, sl)
changed++
return nil
}
putRaw := func(f stageField, raw string) error {
if raw == "" {
s.setDesc(f, Slice{})
changed++
return nil
}
sl, err := s.write([]byte(raw))
if err != nil {
return fmt.Errorf("写回字段 %s: %w", f, err)
}
s.setDesc(f, sl)
changed++
return nil
}
for _, step := range []struct {
f stageField
v string
}{
{fRawMessage, cur.RawMessage},
{fUserID, cur.UserID},
{fGroupID, cur.GroupID},
{fLLMText, cur.LLMText},
{fReasoningContent, cur.ReasoningContent},
{fFinalText, cur.FinalText},
{fPhase, cur.Phase},
} {
if base.strs[step.f] != now.strs[step.f] {
if err := putStr(step.f, step.v); err != nil {
return changed, err
}
}
}
// JSON 字段:比较序列化结果
for f := range now.jsons {
if base.jsons[f] != now.jsons[f] {
if err := putRaw(f, now.jsons[f]); err != nil {
return changed, err
}
}
}
// Response 的 nil 语义变化也算脏
if base.responseSet != now.responseSet || base.response != now.response {
if now.responseSet {
if err := putStr(fResponse, now.response); err != nil {
return changed, err
}
s.setFlag(flagResponseSet, true)
} else {
// 插件把 Response 置回 nil短路语义不应被撑销故不清空内核已设的值。
// 与 C ABI 路径 applyStageResult 的行为保持一致。
s.setFlag(flagResponseSet, s.getFlag(flagResponseSet))
}
}
if base.noMemory != now.noMemory {
s.setFlag(flagNoMemory, now.noMemory)
changed++
}
if changed > 0 {
s.bumpSeq()
}
return changed, nil
}
// Snapshot 是 handler 运行前的字段快照,用于计算脏字段。
//
// ❗ 必须存**序列化后的字符串**而非 Go 值StageContext 的切片字段与
// 调用方共享底层数组handler 原地改元素sc.ToolResults[0].Result = x
// 时直接持有的 Go 值快照会跟着变,脏字段计算失效——这个坑在 C ABI 侧
// 修 11.3 时已经踩过一次(见 SDK 仓 stagediff_test.go 的注释)。
type Snapshot struct {
strs map[stageField]string
jsons map[stageField]string
response string
responseSet bool
noMemory bool
}
// Snapshot 抓取当前 StageContext 的快照(插件进程侧 handler 前调用)。
func TakeSnapshot(sc *pubsdk.StageContext) *Snapshot {
sc.RLock()
l := captureLocal(sc)
sc.RUnlock()
return newSnapshotFromLocal(l)
}
func newSnapshotFromLocal(l *localCtx) *Snapshot {
sn := &Snapshot{
strs: map[stageField]string{},
jsons: map[stageField]string{},
}
sn.strs[fRawMessage] = l.RawMessage
sn.strs[fUserID] = l.UserID
sn.strs[fGroupID] = l.GroupID
sn.strs[fLLMText] = l.LLMText
sn.strs[fReasoningContent] = l.ReasoningContent
sn.strs[fFinalText] = l.FinalText
sn.strs[fPhase] = l.Phase
marshal := func(v interface{}) string {
if v == nil {
return ""
}
b, err := json.Marshal(v)
if err != nil {
return ""
}
return string(b)
}
sn.jsons[fContextMsgs] = marshal(sliceOrNil(len(l.ContextMsgs), l.ContextMsgs))
sn.jsons[fToolCalls] = marshal(sliceOrNil(len(l.ToolCalls), l.ToolCalls))
sn.jsons[fToolResults] = marshal(sliceOrNil(len(l.ToolResults), l.ToolResults))
sn.jsons[fMemory] = marshal(sliceOrNil(len(l.Memory), l.Memory))
sn.jsons[fTokenUsage] = marshal(sliceOrNil(len(l.TokenUsage), l.TokenUsage))
sn.jsons[fErrors] = marshal(sliceOrNil(len(l.Errors), l.Errors))
sn.jsons[fExtraMediaBlocks] = marshal(l.ExtraMediaBlocks)
sn.jsons[fExtraMediaType] = marshal(l.ExtraMediaType)
sn.jsons[fExtraInputSource] = marshal(l.ExtraInputSource)
sn.jsons[fExtraOutputChannel] = marshal(l.ExtraOutputChan)
if l.Response != nil {
sn.response = *l.Response
sn.responseSet = true
}
sn.noMemory = l.NoMemory
return sn
}
// String 让字段枚举在错误信息里可读。
func (f stageField) String() string {
switch f {
case fRawMessage:
return "raw_message"
case fUserID:
return "user_id"
case fGroupID:
return "group_id"
case fLLMText:
return "llm_text"
case fReasoningContent:
return "reasoning_content"
case fFinalText:
return "final_text"
case fResponse:
return "response"
case fPhase:
return "phase"
case fContextMsgs:
return "context_msgs"
case fToolCalls:
return "tool_calls"
case fToolResults:
return "tool_results"
case fMemory:
return "memory"
case fTokenUsage:
return "token_usage"
case fErrors:
return "errors"
case fExtraMediaBlocks:
return "extra." + ExtraKeyMediaBlocks
case fExtraMediaType:
return "extra." + ExtraKeyMediaType
case fExtraInputSource:
return "extra." + ExtraKeyInputSource
case fExtraOutputChannel:
return "extra." + ExtraKeyOutputChannel
}
return fmt.Sprintf("field(%d)", int(f))
}