mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
fix(rtchild): 同名复用串线 + 批量停止漏注销
三处缺陷,全部在「回收/复用」这条线上 —— 单实例 e2e 天然测不到:
1. RTTranscript 读序(主线)
原实现先查留档、命中即返回。留档以 id 为键而 id 由模型给,于是
回收后同名重建,rt_transcript 返回的永远是上一通电话的原文,
且完全不报错。改为「在跑实例优先,无活实例才落回留档」。
2. StopRTChildren 漏注销入站登记
单数路径会 Unregister(child/rt-<id>),复数路径没有。登记表共享、
按 name 全局唯一,残留随 create/destroy 单调累积,而外部没有任何
工具能单独注销一条 inputch。
3. 撤回一个错误的修法
一度在 SpawnRTChild 里 delete 留档。变异测试证明删掉它判据仍绿
(治不了串线),且有害:留档的意义正是「回收后父按需取原文」,
提前删掉会造成「推了信号却拉不到正文」。现仅在 CloseRTChild 写
同名键时自然覆写。
判据(均通过变异验证,去掉修复必红):
- internal/agent/core/rtchild_reuse_test.go(进程内,5 条)
- internal/plugins/rtchild_reuse_e2e_test.go(跨进程真插件,3 条)
★ 写判据时踩到两个坑,一并记在注释里:
- 前两版判据假绿(先塞留档被 Spawn 清理掉 / 新实例不 push 导致
「读到旧内容」与「读到空」都是 len==0),靠变异才发现没测到真东西
- 等 Rounds 不等于历史已入账(rtRunOnce 先 round++ 后 rtAppendHistory),
与 dff745c 同一个坑;已改为等 history 条数
This commit is contained in:
271
internal/plugins/rtchild_reuse_e2e_test.go
Normal file
271
internal/plugins/rtchild_reuse_e2e_test.go
Normal file
@ -0,0 +1,271 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package plugins
|
||||
|
||||
// 端到端判据:已发布的 rtchild 修复在**真实外部插件 + 真 RPC** 下成立。
|
||||
//
|
||||
// ## 为什么这一组必须单独存在(与 FullChain 的分工)
|
||||
//
|
||||
// `TestExternalRTChild_FullChain` 证明"全链路能跑通"——登记→划拨→注入→
|
||||
// 输出回写→回收悬空。但它**一次都不测同名复用**,也不测批量停止:
|
||||
// 它用的是全新的 id、且只在末尾 Cleanup 里 StopRTChildren。
|
||||
//
|
||||
// 而 2026-10-30 修的三处恰好全在"复用/清理"这条线上:
|
||||
//
|
||||
// ① RTTranscript 的读序(留档遮蔽在跑实例 ⇒ 两通电话串线)
|
||||
// ② StopRTChildren 漏注销入站登记(child/rt-<id> 单调累积)
|
||||
// ③ 留档跨代覆写语义
|
||||
//
|
||||
// 三者都是**跨通话生命周期**的缺陷 —— 单实例 e2e 天然测不到。这一组用
|
||||
// 同一个插件产物、同一个通道对,反复"拨号—挂断",把清理路径跑成主路径。
|
||||
//
|
||||
// ## 前置(与 FullChain 同口径)
|
||||
//
|
||||
// RTCHILD_E2E_PLUGIN_BIN=/path/to/plugin.bin.linux.amd64 \
|
||||
// go test ./internal/plugins/ -run TestExternalRTChild_Reuse -v
|
||||
//
|
||||
// 未提供产物时 **skip** 而非 fail(CI 不强制先构建插件)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core"
|
||||
)
|
||||
|
||||
// rtE2EHost 装配一个"插件面 + 内核 Agent"的现场,供复用类判据共用。
|
||||
//
|
||||
// 与 FullChain 内联装配的唯一差别是抽出来复用:三条判据各要一份干净现场,
|
||||
// 内联复制会让"为什么这三条要各自 spawn 一套"变得不可读。
|
||||
func rtE2EHost(t *testing.T, bin string) (*testPluginEnv, *agentCore.Agent, string, string) {
|
||||
t.Helper()
|
||||
env := setupIntegration(t)
|
||||
|
||||
plgDir := env.tmpDir + "/plugins"
|
||||
installRTPlugin(t, plgDir, "rtvoice-e2e", bin)
|
||||
if err := env.pluginReg.Load(plgDir); err != nil {
|
||||
t.Fatalf("加载外部插件: %v", err)
|
||||
}
|
||||
if env.pluginReg.Get("rtvoice-e2e") == nil {
|
||||
t.Fatal("rtvoice-e2e 未加载")
|
||||
}
|
||||
|
||||
host := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "reuse-host",
|
||||
IO: env.iom,
|
||||
Provider: &e2eEchoProvider{},
|
||||
})
|
||||
return env, host, "rtvoice-e2e-in", "rtvoice-e2e"
|
||||
}
|
||||
|
||||
// TestExternalRTChild_ReuseDoesNotLeakTranscript 钉住①在**真实链路**下成立:
|
||||
// 同名 rtchild 回收后重建,新建实例的 transcript 不得含上一通的内容;
|
||||
// 同时上一通的留档(在覆写之前)必须仍然可取 —— 那是交接契约。
|
||||
//
|
||||
// 变异方式:RTTranscript 改回 archive-first ⇒ 第二条断言必红(进程内判据
|
||||
// 已实测;本判据保证同样的错误在跨进程装配下也拦得住)。
|
||||
func TestExternalRTChild_ReuseDoesNotLeakTranscript(t *testing.T) {
|
||||
bin := rtE2EPluginBin(t)
|
||||
env, host, inCh, outCh := rtE2EHost(t, bin)
|
||||
defer env.cleanup()
|
||||
defer host.StopRTChildren()
|
||||
|
||||
const id = "e2e-reuse"
|
||||
|
||||
// ── 第一通:注入 → 跑一轮 → 回收 ──
|
||||
if _, err := host.SpawnRTChild(agentCore.RTChildOptions{ID: id, Input: inCh, Output: outCh}); err != nil {
|
||||
t.Fatalf("第一通创建失败: %v", err)
|
||||
}
|
||||
if _, err := env.stageHost.ExecuteTool("rtvoice_inject",
|
||||
map[string]interface{}{"text": "第一通说的话"}); err != nil {
|
||||
t.Fatalf("第一通注入失败: %v", err)
|
||||
}
|
||||
waitHistory(t, host, id, 2, "第一通(user+assistant)")
|
||||
|
||||
firstMsgs, ok := host.RTTranscript(id)
|
||||
if !ok || len(firstMsgs) == 0 {
|
||||
t.Fatalf("第一通 transcript 为空(ok=%v len=%d)", ok, len(firstMsgs))
|
||||
}
|
||||
firstText := agentCore.FormatRTTranscript(firstMsgs)
|
||||
if n, err := host.CloseRTChild(id); err != nil || n == 0 {
|
||||
t.Fatalf("第一通回收失败(n=%d err=%v)", n, err)
|
||||
}
|
||||
|
||||
// ★ 回收后留档必须仍可取 —— 这是「推信号 + 拉状态」的另一半。
|
||||
// 若这条红,说明有人为了"修串线"把留档删了(我本人曾写错过这一版)。
|
||||
archived, ok := host.RTTranscript(id)
|
||||
if !ok || len(archived) == 0 {
|
||||
t.Fatalf("★ 回收后取不到上一通原文 —— 交接契约断链(推了信号却拉不到正文)")
|
||||
}
|
||||
if !strings.Contains(agentCore.FormatRTTranscript(archived), "第一通说的话") {
|
||||
t.Fatalf("留档里没有第一通的内容:%s",
|
||||
truncateE2E(agentCore.FormatRTTranscript(archived)))
|
||||
}
|
||||
|
||||
// ── 第二通:同名重建,必须"忘掉"上一通 ──
|
||||
if _, err := host.SpawnRTChild(agentCore.RTChildOptions{ID: id, Input: inCh, Output: outCh}); err != nil {
|
||||
t.Fatalf("同名重建失败: %v", err)
|
||||
}
|
||||
if _, err := env.stageHost.ExecuteTool("rtvoice_inject",
|
||||
map[string]interface{}{"text": "第二通说的话"}); err != nil {
|
||||
t.Fatalf("第二通注入失败: %v", err)
|
||||
}
|
||||
waitHistory(t, host, id, 2, "第二通(user+assistant)")
|
||||
|
||||
second, ok := host.RTTranscript(id)
|
||||
if !ok {
|
||||
t.Fatal("第二通取不到 transcript")
|
||||
}
|
||||
secondText := agentCore.FormatRTTranscript(second)
|
||||
if strings.Contains(secondText, "第一通说的话") {
|
||||
t.Fatalf("★ 同名重建后 transcript 含**第一通**的内容 —— 两通电话串线:%s",
|
||||
truncateE2E(secondText))
|
||||
}
|
||||
if !strings.Contains(secondText, "第二通说的话") {
|
||||
t.Fatalf("第二通 transcript 里没有它自己的输入:%s", truncateE2E(secondText))
|
||||
}
|
||||
_ = firstText
|
||||
}
|
||||
|
||||
// TestExternalRTChild_BulkStopUnregistersInbound 钉住②在真实链路下成立:
|
||||
// 批量停止必须把 child/rt-<id> 从共享登记表里摘掉。
|
||||
//
|
||||
// 为什么要在跨进程层测:入站登记是**内核自己建的**,但登记表由插件
|
||||
// 装配共用。进程内判据用的是裸 IOManager;这里用的是真实插件装好的那张表
|
||||
// ——"在真实装配下也摘得干净"才算数。
|
||||
func TestExternalRTChild_BulkStopUnregistersInbound(t *testing.T) {
|
||||
bin := rtE2EPluginBin(t)
|
||||
env, host, inCh, outCh := rtE2EHost(t, bin)
|
||||
defer env.cleanup()
|
||||
|
||||
reg := env.iom.ChannelRegistry()
|
||||
|
||||
const id = "e2e-bulk"
|
||||
if _, err := host.SpawnRTChild(agentCore.RTChildOptions{ID: id, Input: inCh, Output: outCh}); err != nil {
|
||||
t.Fatalf("创建失败: %v", err)
|
||||
}
|
||||
|
||||
// 入站登记的名字由内核约定:child/rt-<id>。
|
||||
// 这里不直接调用未导出的 inboundChannelName,而是按登记表现实探测:
|
||||
// 批量停止前后必须有**恰好一条** child/rt- 前缀的登记消失。
|
||||
const inboundPrefix = "child/rt/"
|
||||
countInbound := func() int {
|
||||
n := 0
|
||||
for _, ch := range reg.List() {
|
||||
if strings.HasPrefix(ch.Name, inboundPrefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
before := countInbound()
|
||||
if before == 0 {
|
||||
t.Fatalf("创建后没有任何 %q 前缀的入站登记 —— 判据前提不成立", inboundPrefix)
|
||||
}
|
||||
|
||||
if n := host.StopRTChildren(); n != 1 {
|
||||
t.Fatalf("StopRTChildren 返回 %d,期望 1", n)
|
||||
}
|
||||
after := countInbound()
|
||||
if after != before-1 {
|
||||
t.Fatalf("★ 批量停止后入站登记 %d → %d(期望减少 1)—— "+
|
||||
"登记会随 create/destroy 次数单调累积,而外部无法单独注销",
|
||||
before, after)
|
||||
}
|
||||
|
||||
// 插件声明的 rt 通道必须**悬空**而非消失(用户裁定:主 agent 无 rt 能力)。
|
||||
for _, n := range []string{inCh, outCh} {
|
||||
ch, ok := reg.Lookup(n)
|
||||
if !ok {
|
||||
t.Fatalf("批量停止把插件声明的 rt 通道 %q 也删了 —— 应保持悬空", n)
|
||||
}
|
||||
if ch.Owner == "" {
|
||||
t.Fatalf("rt 通道 %q 被归还为未分配(应悬空指向已销毁的 %q)", n, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExternalRTChild_RepeatedCyclesDoNotAccumulate 是"长跑"形态的护栏:
|
||||
// 连续多轮建—停,登记表与 rtChildren 都不得累积。
|
||||
//
|
||||
// 这条对应生产里的真实场景:模型被反复要求"打个电话问一句"。
|
||||
// 单次回收正确、但多次之后残留堆积,是典型的"每次少清一点"型缺陷
|
||||
// (②正是这种:单数路径对、复数路径漏)。
|
||||
func TestExternalRTChild_RepeatedCyclesDoNotAccumulate(t *testing.T) {
|
||||
bin := rtE2EPluginBin(t)
|
||||
env, host, inCh, outCh := rtE2EHost(t, bin)
|
||||
defer env.cleanup()
|
||||
|
||||
reg := env.iom.ChannelRegistry()
|
||||
countInbound := func() int {
|
||||
n := 0
|
||||
for _, ch := range reg.List() {
|
||||
if strings.HasPrefix(ch.Name, "child/rt/") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
const cycles = 3
|
||||
base := countInbound()
|
||||
for i := 0; i < cycles; i++ {
|
||||
id := fmt.Sprintf("cycle-%d", i)
|
||||
if _, err := host.SpawnRTChild(agentCore.RTChildOptions{
|
||||
ID: id, Input: inCh, Output: outCh,
|
||||
}); err != nil {
|
||||
t.Fatalf("第 %d 轮创建失败: %v", i, err)
|
||||
}
|
||||
if n := host.StopRTChildren(); n != 1 {
|
||||
t.Fatalf("第 %d 轮 StopRTChildren 返回 %d,期望 1", i, n)
|
||||
}
|
||||
}
|
||||
|
||||
if got := countInbound(); got != base {
|
||||
t.Fatalf("★ %d 轮建—停后入站登记从 %d 涨到 %d —— 每轮都有残留", cycles, base, got)
|
||||
}
|
||||
if ids := host.RTChildren(); len(ids) != 0 {
|
||||
t.Fatalf("多轮建—停后 rtChildren 仍有 %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 共用小工具 ──
|
||||
|
||||
// waitHistory 等到 rtchild 的 history 里出现 want 条消息(超时即失败)。
|
||||
//
|
||||
// ## 为什么等 history 而不等 Rounds(本仓已有专门教训)
|
||||
//
|
||||
// `rtRunOnce` 先 `rc.round++`(Rounds 立刻 +1),**之后**才流式生成、
|
||||
// 最后才 `rtAppendHistory`。所以"Rounds 到了"不等于"话说完了、历史入账了"——
|
||||
// 此时 RTTranscript 可能仍是空的。
|
||||
//
|
||||
// 这正是 dff745c「rt_children stop 判据改为等历史入账,不等 Rounds」的
|
||||
// 同一个坑,我第一版又踩了一遍(首轮判据报"transcript 为空(len=0)")。
|
||||
// 等 history 才是可观测的正确条件。
|
||||
func waitHistory(t *testing.T, host *agentCore.Agent, id string, want int, what string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
var last int
|
||||
for time.Now().Before(deadline) {
|
||||
if msgs, ok := host.RTTranscript(id); ok {
|
||||
if len(msgs) >= want {
|
||||
return
|
||||
}
|
||||
last = len(msgs)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
st, ok := host.RTChildStats(id)
|
||||
t.Fatalf("%s:history 只到 %d 条(期望 ≥%d,ok=%v stats=%+v)—— "+
|
||||
"输入可能没到达 rtSlot,或话说完了但历史没入账", what, last, want, ok, st)
|
||||
}
|
||||
|
||||
func truncateE2E(s string) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= 240 {
|
||||
return s
|
||||
}
|
||||
return string(r[:240]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user