fix(cabi): output_send 等待真实发送结果,消除假成功(plan 11.1 / Part 0.1)

根因:CORE_REGISTER_OUTPUT_CH handler 无条件返回 {status:queued}+err=nil,
模型永远收到「已发送」,实际失败(如 meta 缺 user_id)只写日志,模型无法感知不会重试。
现网近 7 天成功 44 次、失败 2 次全部谎报成功。

改动:
- loader.go: 新增 awaitOutputResult(+可注入版 awaitOutputResultWith)+ outputSendTimeout=10s
  goroutine 执行 cgo 发送 + 带超时 channel 等结果 → sent / error / unconfirmed 三态
  handler 由 executeOutputSendTool 从 Go 侧调起,非 cgo 栈,不构成 cgo 嵌套
- output.go: executeOutputSendTool 识别 unconfirmed|queued,回报「发送结果未确认」而非「已发送」
- output_test.go: Success/Failure/Timeout 三用例

验证: go build exit 0; go test ./internal/plugin/... ./internal/agent/... 全绿
接口冻结: git diff third_party/homeagent-sdk/sdk/ 为空
This commit is contained in:
dev
2026-08-31 12:16:39 +08:00
parent 8a1a7df406
commit b74ee15321
5 changed files with 139 additions and 23 deletions

View File

@ -31,23 +31,31 @@
> 依据plan.md §11.1/11.3/11.6。不依赖任何新架构,独立交付,现网直接受益。 > 依据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 ```go
resCh := make(chan error, 1) resCh := make(chan error, 1)
go func() { resCh <- pluginInvokeOutput(pid, chName, argsJSON) }() go func() { resCh <- invoke(pid, channel, argsJSON) }()
select { select {
case err := <-resCh: case err := <-resCh:
if err != nil { return nil, err } if err != nil { return nil, err } // 真实失败上报
return map[string]interface{}{"status": "sent"}, nil return map[string]interface{}{"status": "sent"}, nil
case <-time.After(10 * time.Second): case <-time.After(timeout):
return map[string]interface{}{"status": "queued", "note": "发送超时未确认"}, nil return map[string]interface{}{"status": "unconfirmed", "note": "..."}, nil
} }
``` ```
关键:`dev.Execute` 由 `executeOutputSendTool` 从 Go 侧调起(不在 cgo 栈内goroutine 内的 `pluginInvokeOutput` 才是 cgo**不构成嵌套**。 关键:`dev.Execute` 由 `executeOutputSendTool` 从 Go 侧调起(不在 cgo 栈内goroutine 内的 `pluginInvokeOutput` 才是 cgo**不构成嵌套**。
- 【R】确认无 cgo 嵌套;审「超时未确认」措辞不误导(区别于 11.2 的"已取消"谎言) - 【M】✅ `internal/agent/core/output.go` `executeOutputSendTool`:识别 `status=unconfirmed|queued` → 返回「发送结果未确认:<note>」而非「已发送」,把未确认状态透传给模型
- 【V】构造 meta 缺 `user_id` 的失败场景 → 模型收到错误而非"已发送";正常场景收到 "sent"。 - 【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 ### 0.2 stage lost update 补丁11.3

View File

@ -67,6 +67,17 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
if err != nil { if err != nil {
return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err) 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) return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
} }

View File

@ -52,11 +52,17 @@ import (
"log" "log"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"unsafe" "unsafe"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk" sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
) )
// outputSendTimeout 是 output_send 等待通道真实发送确认的超时。
// 超过该时间仍未收到插件确认,返回 unconfirmed结果未知而非谎报成功。
// plan.md 11.1
const outputSendTimeout = 10 * time.Second
var ( var (
pluginMap sync.Map // int32 pluginID → *pluginState pluginMap sync.Map // int32 pluginID → *pluginState
nextID int32 nextID int32
@ -255,6 +261,49 @@ func pluginInvokeTool(pluginID int32, name, argsJSON string) (string, error) {
return C.GoString(result), nil 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 { func pluginInvokeOutput(pluginID int32, channel, payload string) error {
v, ok := pluginMap.Load(pluginID) v, ok := pluginMap.Load(pluginID)
if !ok { 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) { s.RegisterOutputChannel(chName, n1, a2, chDef, func(args map[string]interface{}) (interface{}, error) {
// Output is async: return immediately, send in background // 发送在 goroutine 内进行cgo 调用不能嵌套在 cgo 栈上,否则可能崩溃),
// to avoid nested cgo calls (cgo within cgo can crash) // 但调用方必须拿到真实结果:本 handler 由 executeOutputSendTool 从 Go 侧
go func() { // 调起,不在 cgo 栈内,因此这里等待 goroutine 的结果不构成 cgo 嵌套。
argsJSON, _ := json.Marshal(args) // plan.md 11.1
log.Printf("[dispatch] async output %s/%s args=%s", ps.name, chName, string(argsJSON)) argsJSON, _ := json.Marshal(args)
if err := pluginInvokeOutput(pid, chName, string(argsJSON)); err != nil { log.Printf("[dispatch] output %s/%s args=%s", ps.name, chName, string(argsJSON))
log.Printf("[dispatch] async output %s/%s failed: %v", ps.name, chName, err) return awaitOutputResult(pid, chName, string(argsJSON))
} else {
log.Printf("[dispatch] async output %s/%s OK", ps.name, chName)
}
}()
return map[string]interface{}{"status": "queued"}, nil
}) })
return 0 return 0

View File

@ -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 不应返回 errorgot %v", err)
}
m, _ := res.(map[string]interface{})
if m["status"] != "unconfirmed" {
t.Fatalf("expected status=unconfirmed, got %v", m["status"])
}
}

10
plan.md
View File

@ -674,9 +674,13 @@ case <-time.After(10 * time.Second):
`dev.Execute` `executeOutputSendTool` Go 侧调起不在 cgo 栈内 `dev.Execute` `executeOutputSendTool` Go 侧调起不在 cgo 栈内
goroutine 里的 `pluginInvokeOutput` 才是 cgo 调用**不构成嵌套**。 goroutine 里的 `pluginInvokeOutput` 才是 cgo 调用**不构成嵌套**。
- [ ] 实现修复 - [x] 实现修复 —— `loader.go` 新增 `awaitOutputResult`/`awaitOutputResultWith` + `outputSendTimeout=10s`
- [ ] **实测验证不触发 cgo 嵌套崩溃**此判断为推理必须实测 `CORE_REGISTER_OUTPUT_CH` handler 改为等真实结果sent / error / unconfirmed 三态
- [ ] 构造 meta `user_id` 的失败场景确认模型收到错误而非"已发送" - [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 次 ### 11.2 紧急cgo 工具超时不可中断,线性泄漏 ⚠️ 现网已发生 26 次