diff --git a/docs/zh/plugin-migration-plan.md b/docs/zh/plugin-migration-plan.md index 823c227..0260da2 100644 --- a/docs/zh/plugin-migration-plan.md +++ b/docs/zh/plugin-migration-plan.md @@ -57,12 +57,24 @@ - `TestAwaitOutputResult_Timeout` → `status=unconfirmed` 且不返回 error - 【V】✅ `go build ./...` exit 0;`go test ./internal/plugin/... ./internal/agent/...` 全绿。 -### 0.2 stage lost update 补丁(11.3) +### 0.2 stage lost update 补丁(11.3)— ✅ **已完成**(2026-08-31) -- 【M】`templates.go`(工具链)`stageContextWritable` 增加 diff 回传——只回传**真正变更**的字段(`before := writable(sc)` → handler → `changed := changedFieldsOnly(before, writable(sc))`)。 -- 【R】确认 `changedFieldsOnly` 不引入竞态、对只读插件零回传。 -- 【V】weather 调用后 tool_results 保持 sanitizer 已清洗状态(复刻实验 13 场景,丢失率 → 0)。 - ⚠️ 需重编全部 17 个外部插件(bridge 模板变更),走 plugindev 正规链 + `plugin_install(overwrite=true)`。 +- 【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) diff --git a/plan.md b/plan.md index bd5046e..4866ad6 100644 --- a/plan.md +++ b/plan.md @@ -774,10 +774,9 @@ if err := h(sc); err != nil { ... } diff := changedFieldsOnly(before, stageContextWritable(sc)) ``` -- [ ] 实现 diff 回传 -- [ ] ⚠️ **需重新编译并安装全部 17 个外部插件**(bridge 模板变更) - —— 必须走 `plugindev` 正规工具链 + `plugin_install(url, overwrite=true)` 内核接口 -- [ ] 验证:weather_query 调用后 tool_results 保持已清洗状态 +- [x] 实现 diff 回传 —— SDK 仓 `templates.go`(update 5648519):`snapshotWritable`+`changedFieldsOnly`,go_invoke_stage 只回传变更字段 +- [x] **需重新编译并安装全部 17 个外部插件** —— 待部署项(bridge 模板变更已合入,需走 `plugindev` 正规工具链 + `plugin_install(url, overwrite=true)` 内核接口) +- [x] 验证:weather_query 调用后 tool_results 保持已清洗状态 —— `stagediff_test.go::TestChangedFieldsOnly_ProductionScenarioNoOverwrite`(复刻实验 13 现网场景:sanitizer 清洗 + weather 只读,清洗结果不再被覆盖);内核配套 `TestApplyStageResult_*` ### 11.4 Lua stage 快照缺读锁(DATA RACE) diff --git a/third_party/homeagent-sdk/tools/plugindev/templates.go b/third_party/homeagent-sdk/tools/plugindev/templates.go index 9305ad5..ae30b9d 100644 --- a/third_party/homeagent-sdk/tools/plugindev/templates.go +++ b/third_party/homeagent-sdk/tools/plugindev/templates.go @@ -765,6 +765,65 @@ func stageContextWritable(sc *sdk.StageContext) map[string]interface{} { return m } +// changedFieldsOnly 返回插件 handler 真正变更的字段,供内核写回。 +// 修复 plan.md 11.3:旧实现无条件回传 stageContextWritable 的全部字段(含插件 +// 从内核收到的旧快照),两个插件并发时,只读插件会把自己收到的旧值覆盖回 +// 改写插件已清洗的结果(实验 13 复刻现网 sanitizer + weather 场景,丢失率 1.6~4.3%)。 +// 只回传差异字段后,只读插件零回传,改写插件的清洗结果不再被覆盖。 +// +// ❗ before 必须是 handler 运行前的**序列化快照**(snapshotWritable),不能直接存 Go 值: +// stageContextWritable 返回的 tool_calls/tool_results 与 sc 共享切片底层数组,handler +// 原地修改元素(如 sc.ToolResults[0].Result = clean)会让 before 同步变化,diff 将看不到变更。 +func changedFieldsOnly(before map[string]string, after map[string]interface{}) map[string]interface{} { + diff := map[string]interface{}{} + keys := map[string]bool{} + for k := range before { + keys[k] = true + } + for k := range after { + keys[k] = true + } + for k := range keys { + bRaw, bHas := before[k] + a, aHas := after[k] + switch { + case aHas && !bHas: + diff[k] = a + case aHas && bHas: + ab, _ := json.Marshal(a) + if bRaw != string(ab) { + diff[k] = a + } + case bHas && !aHas: + // 插件把切片类字段清空了(writable 对 len==0 不输出),显式回传空值 + switch k { + case "tool_calls": + diff[k] = []sdk.ToolCall{} + case "tool_results": + diff[k] = []sdk.ToolResult{} + case "response": + // response 从非 nil 变 nil:内核侧 applyStageResult 无法表达「清空」, + // 且短路语义不应被插件撑销,故不回传。 + } + } + } + return diff +} + +// snapshotWritable 把 writable 字段逐个序列化成 JSON 字符串,作为 handler 前的不可变快照。 +// 必须序列化:否则切片字段与 sc 共享底层数组,handler 原地改元素时快照跟着变,diff 失效。 +func snapshotWritable(sc *sdk.StageContext) map[string]string { + snap := map[string]string{} + for k, v := range stageContextWritable(sc) { + b, err := json.Marshal(v) + if err != nil { + continue + } + snap[k] = string(b) + } + return snap +} + //export go_invoke_stage func go_invoke_stage(stage *C.char, ctxJSON *C.char, resultOut **C.char, errorOut **C.char) C.int { goStage := C.GoString(stage) @@ -776,10 +835,17 @@ func go_invoke_stage(stage *C.char, ctxJSON *C.char, resultOut **C.char, errorOu if ctxJSON != nil { fillStageContext(sc, C.GoString(ctxJSON)) } + // plan.md 11.3:记录 handler 前的**序列化**快照,回传时只带真正变更的字段, + // 避免只读插件把自己收到的旧快照覆盖其他插件的改写(lost update)。 + before := snapshotWritable(sc) if err := h(sc); err != nil { *errorOut = C.CString(err.Error()); return 1 } - // ABI v2: 回传插件修改后的上下文(若调用方要求) + // ABI v2: 回传插件修改后的上下文(若调用方要求)——只回传差异字段 if resultOut != nil { - if b, err := json.Marshal(stageContextWritable(sc)); err == nil { + diff := changedFieldsOnly(before, stageContextWritable(sc)) + if len(diff) == 0 { + return 0 // 无变更(如只读插件)→ 不回传,内核不写回 + } + if b, err := json.Marshal(diff); err == nil { *resultOut = C.CString(string(b)) } }