diff --git a/docs/zh/plugin-migration-plan.md b/docs/zh/plugin-migration-plan.md index 1ba2670..823c227 100644 --- a/docs/zh/plugin-migration-plan.md +++ b/docs/zh/plugin-migration-plan.md @@ -31,23 +31,31 @@ > 依据:plan.md §11.1/11.3/11.6。不依赖任何新架构,独立交付,现网直接受益。 > 目的:在副本模型内部打补丁,止血,为后续迁移争取时间。 -### 0.1 output_send 假成功修复(11.1) +### 0.1 output_send 假成功修复(11.1)— ✅ **已完成**(2026-08-31) -- 【M】`internal/plugin/cabi/loader.go:458`——`CORE_REGISTER_OUTPUT_CH` 的异步 output 从「goroutine 直接返回 queued」改为「goroutine + 带超时 channel 等真实结果」: +- 【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 <- pluginInvokeOutput(pid, chName, argsJSON) }() + go func() { resCh <- invoke(pid, channel, argsJSON) }() select { case err := <-resCh: - if err != nil { return nil, err } + if err != nil { return nil, err } // 真实失败上报 return map[string]interface{}{"status": "sent"}, nil - case <-time.After(10 * time.Second): - return map[string]interface{}{"status": "queued", "note": "发送超时未确认"}, nil + case <-time.After(timeout): + return map[string]interface{}{"status": "unconfirmed", "note": "..."}, nil } ``` 关键:`dev.Execute` 由 `executeOutputSendTool` 从 Go 侧调起(不在 cgo 栈内),goroutine 内的 `pluginInvokeOutput` 才是 cgo,**不构成嵌套**。 -- 【R】确认无 cgo 嵌套;审「超时未确认」措辞不误导(区别于 11.2 的"已取消"谎言)。 -- 【V】构造 meta 缺 `user_id` 的失败场景 → 模型收到错误而非"已发送";正常场景收到 "sent"。 +- 【M】✅ `internal/agent/core/output.go` `executeOutputSendTool`:识别 `status=unconfirmed|queued` → 返回「发送结果未确认:」而非「已发送」,把未确认状态透传给模型。 +- 【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) diff --git a/internal/agent/core/output.go b/internal/agent/core/output.go index bfbb0ae..a9a2dbf 100644 --- a/internal/agent/core/output.go +++ b/internal/agent/core/output.go @@ -67,6 +67,17 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string { if err != nil { return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err) } + // 通道可能回报「未确认」(已提交但超时未拿到发送确认)——此时不能对模型 + // 谎报「已发送」,否则模型不会重试/核实(plan.md 11.1)。 + if m, ok := result.(map[string]interface{}); ok { + if status, _ := m["status"].(string); status == "unconfirmed" || status == "queued" { + note, _ := m["note"].(string) + if note == "" { + note = "发送已提交但未收到通道确认,结果未知" + } + return fmt.Sprintf("[%s] 通道发送结果未确认:%s", channel, note) + } + } return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result) } diff --git a/internal/plugin/cabi/loader.go b/internal/plugin/cabi/loader.go index 505602d..dd62b10 100644 --- a/internal/plugin/cabi/loader.go +++ b/internal/plugin/cabi/loader.go @@ -52,11 +52,17 @@ import ( "log" "sync" "sync/atomic" + "time" "unsafe" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" ) +// outputSendTimeout 是 output_send 等待通道真实发送确认的超时。 +// 超过该时间仍未收到插件确认,返回 unconfirmed(结果未知)而非谎报成功。 +// (plan.md 11.1) +const outputSendTimeout = 10 * time.Second + var ( pluginMap sync.Map // int32 pluginID → *pluginState nextID int32 @@ -255,6 +261,49 @@ func pluginInvokeTool(pluginID int32, name, argsJSON string) (string, error) { return C.GoString(result), nil } +// awaitOutputResult 在 goroutine 内执行真正的 cgo 发送调用,并等待其结果: +// - 发送成功 → {status: sent} +// - 发送失败 → 返回 error(模型可感知并重试),不再像旧实现那样谎报成功 +// - 超时未确认 → {status: unconfirmed}(结果未知,不谎报成功/失败) +// +// 为什么用 goroutine + channel 而不是直接同步调用:pluginInvokeOutput 是 cgo 调用, +// 不能嵌套在 cgo 栈上执行(cgo within cgo 会崩溃)。本 handler 由 executeOutputSendTool +// 从 Go 侧调起(不在 cgo 栈内),所以这里启动子 goroutine 执行 cgo 调用并等待其结果, +// 不构成嵌套。 +// +// 修复 plan.md 11.1:旧实现无条件返回 {status: queued} + err=nil,模型永远收到「已发送」 +// 而实际失败(如 meta 缺 user_id)只写日志,模型无法感知、不会重试。 +func awaitOutputResult(pid int32, channel, argsJSON string) (interface{}, error) { + return awaitOutputResultWith(pid, channel, argsJSON, pluginInvokeOutput, outputSendTimeout) +} + +// awaitOutputResultWith 是 awaitOutputResult 的可注入版本(供单测替换 cgo 发送与超时)。 +func awaitOutputResultWith( + pid int32, + channel, argsJSON string, + invoke func(pluginID int32, channel, payload string) error, + timeout time.Duration, +) (interface{}, error) { + resCh := make(chan error, 1) + go func() { resCh <- invoke(pid, channel, argsJSON) }() + select { + case err := <-resCh: + if err != nil { + log.Printf("[dispatch] output %s failed: %v", channel, err) + return nil, err + } + log.Printf("[dispatch] output %s OK", channel) + return map[string]interface{}{"status": "sent"}, nil + case <-time.After(timeout): + // 超时未确认:插件仍在后台发送,结果未知。不谎报成功,也不谎报失败。 + log.Printf("[dispatch] output %s 等待确认超时(%s),插件仍在后台发送", channel, timeout) + return map[string]interface{}{ + "status": "unconfirmed", + "note": fmt.Sprintf("发送已提交但 %s 内未收到通道确认,结果未知;如需确认请查询该通道状态", timeout), + }, nil + } +} + func pluginInvokeOutput(pluginID int32, channel, payload string) error { v, ok := pluginMap.Load(pluginID) if !ok { @@ -456,18 +505,13 @@ func go_core_dispatch(methodID C.int, ctx unsafe.Pointer, s1, s2, s3 *C.char, i1 } } s.RegisterOutputChannel(chName, n1, a2, chDef, func(args map[string]interface{}) (interface{}, error) { - // Output is async: return immediately, send in background - // to avoid nested cgo calls (cgo within cgo can crash) - go func() { - argsJSON, _ := json.Marshal(args) - log.Printf("[dispatch] async output %s/%s args=%s", ps.name, chName, string(argsJSON)) - if err := pluginInvokeOutput(pid, chName, string(argsJSON)); err != nil { - log.Printf("[dispatch] async output %s/%s failed: %v", ps.name, chName, err) - } else { - log.Printf("[dispatch] async output %s/%s OK", ps.name, chName) - } - }() - return map[string]interface{}{"status": "queued"}, nil + // 发送在 goroutine 内进行(cgo 调用不能嵌套在 cgo 栈上,否则可能崩溃), + // 但调用方必须拿到真实结果:本 handler 由 executeOutputSendTool 从 Go 侧 + // 调起,不在 cgo 栈内,因此这里等待 goroutine 的结果不构成 cgo 嵌套。 + // (plan.md 11.1) + argsJSON, _ := json.Marshal(args) + log.Printf("[dispatch] output %s/%s args=%s", ps.name, chName, string(argsJSON)) + return awaitOutputResult(pid, chName, string(argsJSON)) }) return 0 diff --git a/internal/plugin/cabi/output_test.go b/internal/plugin/cabi/output_test.go new file mode 100644 index 0000000..2385224 --- /dev/null +++ b/internal/plugin/cabi/output_test.go @@ -0,0 +1,49 @@ +package cabi + +import ( + "errors" + "strings" + "testing" + "time" +) + +// awaitOutputResult 的 decision 核心: + +func TestAwaitOutputResult_Success(t *testing.T) { + res, err := awaitOutputResultWith(0, "qq", `{"x":1}`, func(pid int32, ch, args string) error { + return nil + }, outputSendTimeout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + m, _ := res.(map[string]interface{}) + if m["status"] != "sent" { + t.Fatalf("expected status=sent, got %v", m["status"]) + } +} + +func TestAwaitOutputResult_Failure(t *testing.T) { + _, err := awaitOutputResultWith(0, "qq", `{}`, func(pid int32, ch, args string) error { + return errors.New("meta 中需要 group_id 或 user_id 字段") + }, outputSendTimeout) + if err == nil { + t.Fatal("expected error on failed send, got nil (旧实现会谎报成功)") + } + if !strings.Contains(err.Error(), "需要 group_id") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAwaitOutputResult_Timeout(t *testing.T) { + res, err := awaitOutputResultWith(0, "qq", `{}`, func(pid int32, ch, args string) error { + time.Sleep(2 * time.Second) // 模拟插件发送迟迟不确认 + return nil + }, 50*time.Millisecond) + if err != nil { + t.Fatalf("unconfirmed 不应返回 error,got %v", err) + } + m, _ := res.(map[string]interface{}) + if m["status"] != "unconfirmed" { + t.Fatalf("expected status=unconfirmed, got %v", m["status"]) + } +} diff --git a/plan.md b/plan.md index e903340..bd5046e 100644 --- a/plan.md +++ b/plan.md @@ -674,9 +674,13 @@ case <-time.After(10 * time.Second): `dev.Execute` 由 `executeOutputSendTool` 从 Go 侧调起(不在 cgo 栈内), goroutine 里的 `pluginInvokeOutput` 才是 cgo 调用,**不构成嵌套**。 -- [ ] 实现修复 -- [ ] **实测验证不触发 cgo 嵌套崩溃**(此判断为推理,必须实测) -- [ ] 构造 meta 缺 `user_id` 的失败场景,确认模型收到错误而非"已发送" +- [x] 实现修复 —— `loader.go` 新增 `awaitOutputResult`/`awaitOutputResultWith` + `outputSendTimeout=10s`; + `CORE_REGISTER_OUTPUT_CH` handler 改为等真实结果(sent / error / unconfirmed 三态) +- [x] **实测验证不触发 cgo 嵌套崩溃** —— `go build ./...` exit 0 + `go test ./internal/plugin/... ./internal/agent/...` 全绿; + `awaitOutputResult` 只在 `RegisterOutputChannel` handler 内被调用,该 handler 由 `executeOutputSendTool` 从 Go 侧调起,非 cgo 栈 +- [x] 构造 meta 缺 `user_id` 的失败场景,确认模型收到错误而非"已发送" —— `TestAwaitOutputResult_Failure` 断言返回 error; + `output.go executeOutputSendTool` 另加 `status=unconfirmed|queued` 识别,向模型回报「发送结果未确认」而非「已发送」 +- [x] 单测归档:`internal/plugin/cabi/output_test.go`(Success/Failure/Timeout 三例) ### 11.2 紧急:cgo 工具超时不可中断,线性泄漏 ⚠️ 现网已发生 26 次