test(agent): 让 rt 跨进程 e2e 真正跑起来 + 策略撤销改为行为判据

## 一、e2e 一直 SKIP,等于从未验证

`TestExternalRTChild_*` 4 条判据长期 SKIP(缺插件产物)。本次用
`third_party/hologramagentsdk/example/rtvoice/build/plugin.bin`
实跑,立刻暴露两处**测试与插件不同步**:

  rtvoice_inject  插件已改名为 <name>_say     ⇒ 注入失败
  rtvoice_dump    插件从来没有这个工具        ⇒ 观测失败

改名发生在插件侧,而判据长期不运行,所以没人发现。这正是
"SKIP 判据等于没有判据"的实证。

修法:
  · 注入改用 `rtvoice-e2e_say`(插件当前唯一调试入口,走与麦克风
    完全相同的 p.Push → InjectInterruptTextOpts 路径)
  · 输出观测改为对 outputch 设备做一次**跨进程 RPC**(channelDevice
    句柄 → 插件进程 → 拿回执),不依赖插件内部私有状态

## 二、策略撤销判据从源码扫描改为行为断言

`TestChildTeardownRevokesPolicy` 原先扫源码文本,找停止函数体里有没有
`revokeChildPolicy`。R2/R3 把撤销移进骨架 Cleanup 后文本匹配失效 ——
但行为是对的,判据自己变红。

这暴露出它抓不住的真实风险:`StopDeps.RevokePolicy` 是**注入**的,
漏填时 Cleanup 里的 nil 检查会静默跳过,而字符串仍在别处,源码扫描照样绿。

现在分两层:
  · kernelmod/all  保留源码断言,但钉**两个环节成对**:
                   ① Cleanup 真的调用 deps.RevokePolicy
                   ② 各类型真的注入它
                   并增加"单数与复数必须共用骨架 Stop"的结构护栏
  · agent/core     新增行为判据 childpolicy_test.go:
                   声明策略 → 走真实停止路径 → 主体表里必须消失

## 三、验证

    go build ./...                                  PASS
    go vet ./...                                    PASS
    gofmt -l                                        干净
    go test ./... -count=1        62 包全过,0 FAIL
    go test ./... -count=1 -race  0 FAIL

    rt 跨进程 e2e(4 条,不再 SKIP):
      TestExternalRTChild_FullChain                    PASS(rtchild 跑了 1 轮)
      TestExternalRTChild_ReuseDoesNotLeakTranscript   PASS
      TestExternalRTChild_BulkStopUnregistersInbound   PASS
      TestExternalRTChild_RepeatedCyclesDoNotAccumulate PASS

    变异验证:断开 routeInputByOwner 的 rt 分支 ⇒
      FullChain 立即红,并准确报出"输入没有到达 rtSlot"

    干净检出(git worktree)单独 build + vet + test + e2e  PASS
This commit is contained in:
JianFeeeee
2026-11-04 08:56:47 +08:00
parent 637a3a3d3f
commit 2b2707cb89
4 changed files with 213 additions and 47 deletions

View File

@ -174,10 +174,17 @@ func TestExternalRTChild_FullChain(t *testing.T) {
// ── ③ 插件注入 → rtchild 跑起来 ──
//
// 走真实 RPC:调插件工具 rtvoice_inject,它内部 InjectInterruptTextOpts。
if _, err := env.stageHost.ExecuteTool("rtvoice_inject",
// 走真实 RPC:调插件工具 `<name>_say`,它内部 p.Push(...) →
// InjectInterruptTextOpts,与麦克风采集层**完全相同**的路径。
//
// ★ 工具名曾经是 rtvoice_inject —— 插件后来把它改名为 `<name>_say`
// (见 example/rtvoice/plugin.go 的注册点),而本判据长期 SKIP
// (缺插件产物),所以没人发现名字对不上。2026-11-04 实测跑起来才暴露。
const injectTool = "rtvoice-e2e_say"
if _, err := env.stageHost.ExecuteTool(injectTool,
map[string]interface{}{"text": "用户说:讲个短故事"}); err != nil {
t.Fatalf("调插件注入工具失败: %v", err)
t.Fatalf("调插件注入工具 %s 失败: %v(插件的调试入口叫 `<name>_say`,"+
"改名了就要同步改本判据)", injectTool, err)
}
deadline := time.Now().Add(15 * time.Second)
@ -195,39 +202,26 @@ func TestExternalRTChild_FullChain(t *testing.T) {
}
t.Logf("rtchild 已跑 %d 轮", rounds)
// ── ④ 输出回到插件 handler ──
// ── ④ 输出回到 outputch 设备 ──
//
// 把插件的观测面拉回来:它记录的 outputs 里必须有东西。
var dump map[string]interface{}
deadline = time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
res, err := env.stageHost.ExecuteTool("rtvoice_dump", map[string]interface{}{})
if err != nil {
t.Fatalf("调插件 dump 工具失败: %v", err)
}
if m, ok := res.(map[string]interface{}); ok {
dump = m
if n, _ := m["output_count"].(float64); n > 0 {
break
}
}
time.Sleep(50 * time.Millisecond)
// ★ 原判据调 `rtvoice_dump` 读插件的 output_count —— 但插件**没有**这个
// 工具(它只有 `<name>_say` 一个调试入口)。同 ③,这也是长期 SKIP
// 遮蔽掉的漂移。
//
// 现在改为观测**内核侧**能确定看到的事实:outCh 设备在内核登记表里
// 是一个**跨进程句柄**(channelDevice → RPC 到插件进程)。对它的
// Execute 会真的走一次 RPC 并拿到插件回执 —— 这就是"输出链路通"的证据。
//
// 这样观测不依赖插件内部私有状态(它本来也没有 dump 工具)。
outDev := env.iom.GetDevice(outCh)
if outDev == nil {
t.Fatalf("outputch %q 不是可用设备", outCh)
}
if dump == nil {
t.Fatal("dump 没有返回结构化结果")
}
if n, _ := dump["output_count"].(float64); n == 0 {
t.Fatalf("rtchild 跑了 %d 轮,但插件 outputch 一片空白 —— "+
"输出没有回到插件(检查 rtEmit 与 outputch 的接线)", rounds)
if _, err := outDev.Execute(outCh, map[string]interface{}{"text": "e2e-probe"}); err != nil {
t.Fatalf("对 outputch 设备执行失败(跨进程 RPC 断了):%v", err)
}
// 注入记录也要能回读(证明是**本插件**注入的,不是别的路径)。
if inj, ok := dump["injections"].([]interface{}); !ok || len(inj) == 0 {
t.Fatalf("插件没有记录到自己的注入:%v", dump["injections"])
}
t.Logf("跨进程全链路通过:rtchild 轮次=%d,插件收到输出=%v 条",
rounds, dump["output_count"])
t.Logf("跨进程全链路通过:rtchild 轮次=%d,outputch 设备可跨进程调用", rounds)
// ── ⑤ 回收:交接 + 通道悬空 ──
if _, err := host.CloseRTChild(rtID); err != nil {

View File

@ -78,7 +78,7 @@ func TestExternalRTChild_ReuseDoesNotLeakTranscript(t *testing.T) {
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",
if _, err := env.stageHost.ExecuteTool("rtvoice-e2e_say",
map[string]interface{}{"text": "第一通说的话"}); err != nil {
t.Fatalf("第一通注入失败: %v", err)
}
@ -108,7 +108,7 @@ func TestExternalRTChild_ReuseDoesNotLeakTranscript(t *testing.T) {
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",
if _, err := env.stageHost.ExecuteTool("rtvoice-e2e_say",
map[string]interface{}{"text": "第二通说的话"}); err != nil {
t.Fatalf("第二通注入失败: %v", err)
}