diff --git a/internal/agent/core/child/test/registry_contract_test.go b/internal/agent/core/child/test/registry_contract_test.go new file mode 100644 index 00000000..0d4d796e --- /dev/null +++ b/internal/agent/core/child/test/registry_contract_test.go @@ -0,0 +1,272 @@ +package test + +// child.Registry 的契约判据:**输入 → 输出**,不碰内部字段。 +// +// ## 为什么这个包必须有判据(2026-11-04) +// +// `internal/agent/core/child` 是本轮重构的核心产物(把 residents / rtChildren +// 统一成一张登记表),419 行,但 CI 覆盖率实测 **0.0%** —— 一个判据都没有。 +// +// 而这个包的注释里明确写着它要消灭的缺陷形态: +// +// StopRTChild(单数)注销入站登记,StopRTChildren(复数)漏了, +// 残留随 create/destroy 单调累积且只能重启清。 +// +// 也就是说:它存在的**理由**就是"单数与复数共用同一条清理路径"。 +// 那条不变量没有判据,等于这次重构的核心承诺无人守。 +// +// ## 判据放在哪里 +// +// `child/test/`(黑箱)。理由见 engineering-lessons §5.3.1: +// Go 按目录划分包实例,子目录里写 `package child` 也访问不到未导出符号, +// 所以这个位置**物理上强制**只能调导出接口。 + +import ( + "context" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/agent/core/child" +) + +// fakeEntry 是最小的 Entry 实现,只记录自己被怎么处理过。 +// +// 它属于**测试替身**,不是绕过生产路径:生产里 Entry 的实现是 rtChild / +// residentChild,它们对骨架的用法就是"Base 返回公共部分 + Dispose 收尾"。 +type fakeEntry struct { + base *child.Child + kind child.Kind + disposed int + disposeMsg string +} + +func newFake(id string, kind child.Kind) *fakeEntry { + ctx, cancel := context.WithCancel(context.Background()) + return &fakeEntry{ + base: &child.Child{ID: id, Ctx: ctx, Cancel: cancel}, + kind: kind, + } +} + +func (f *fakeEntry) Base() *child.Child { return f.base } +func (f *fakeEntry) Kind() child.Kind { return f.kind } +func (f *fakeEntry) Dispose() string { + f.disposed++ + return f.disposeMsg +} + +// TestAddRejectsDuplicateID 钉住:同名登记必须被拒(不静默覆盖)。 +// +// 静默覆盖会让上一代的残留(通道 Owner、主体策略、留档)被当成这一代的 —— +// 这正是 registry.go 里 Add 的注释写明要避免的。 +// +// 变异:把 `if _, dup := r.byID[b.ID]; dup { return ErrChildExists }` +// 改成覆盖 ⇒ 本例红。 +func TestAddRejectsDuplicateID(t *testing.T) { + var r child.Registry + if err := r.Add(newFake("a", "rt")); err != nil { + t.Fatalf("首次登记应成功: %v", err) + } + err := r.Add(newFake("a", "rt")) + if err == nil { + t.Fatal("★ 同名重复登记没有被拒 —— 上一代残留会被当成这一代的") + } + if r.Len() != 1 { + t.Fatalf("重复登记后 Len()=%d,期望 1(不许被覆盖或追加)", r.Len()) + } +} + +// TestAddRejectsEmptyOrNil 钉住输入校验三条。 +// +// 变异:删掉 Add 开头的守卫 ⇒ 本例红(会登记一个 id 为空的子, +// 之后 Get("") 能查到它,清理路径也会拿空 id 去撤销策略)。 +func TestAddRejectsEmptyOrNil(t *testing.T) { + var r child.Registry + if err := r.Add(nil); err == nil { + t.Fatal("★ nil Entry 被接受了") + } + e := newFake("x", "rt") + e.base = nil + if err := r.Add(e); err == nil { + t.Fatal("★ Base() 为 nil 的 Entry 被接受了") + } + if err := r.Add(newFake("", "rt")); err == nil { + t.Fatal("★ ID 为空的 Entry 被接受了") + } + if r.Len() != 0 { + t.Fatalf("三次非法输入后 Len()=%d,期望 0", r.Len()) + } +} + +// TestAddWritesKindAndCreatedAt 钉住输出:Add 会把 Kind 与 CreatedAt 写回 Base。 +// +// 这是 Base 的**对外可观测状态**(状态面/排序都读它),属于契约。 +func TestAddWritesKindAndCreatedAt(t *testing.T) { + var r child.Registry + e := newFake("a", "resident") + if err := r.Add(e); err != nil { + t.Fatal(err) + } + if e.base.Kind != "resident" { + t.Fatalf("Base.Kind = %q,期望 resident(Add 应写入 Entry.Kind())", e.base.Kind) + } + if e.base.CreatedAt == 0 { + t.Fatal("★ Base.CreatedAt 未被写入 —— 状态面排序会失去依据") + } +} + +// TestStopOneAndStopAllShareCleanup 钉住本包存在的**理由**: +// 单数与复数走同一条清理路径,四步副作用一步不少。 +// +// 变异:让 StopAll 复制一份循环体、漏掉 UnregisterInbound ⇒ 本例红。 +func TestStopOneAndStopAllShareCleanup(t *testing.T) { + type trace struct { + disposed int + revoked []string + unreg []string + onDispose int + } + + run := func(stop func(*child.Registry, child.StopDeps), useAll bool) trace { + var r child.Registry + var tr trace + a := newFake("a", "rt") + b := newFake("b", "rt") + a.base.InboundCh = "child/a" + b.base.InboundCh = "child/b" + _ = r.Add(a) + _ = r.Add(b) + + deps := child.StopDeps{ + RevokePolicy: func(id string) { tr.revoked = append(tr.revoked, id) }, + UnregisterInbound: func(ch string) { tr.unreg = append(tr.unreg, ch) }, + OnDispose: func(child.Entry) { tr.onDispose++ }, + } + if useAll { + if n := r.StopAll("", deps); n != 2 { + t.Fatalf("StopAll 停了 %d 个,期望 2", n) + } + } else { + for _, id := range []string{"a", "b"} { + if err := r.StopOne(id, deps); err != nil { + t.Fatalf("StopOne(%s): %v", id, err) + } + } + } + tr.disposed = a.disposed + b.disposed + return tr + } + + one := run(nil, false) + all := run(nil, true) + + for _, c := range []struct { + name string + one, all trace + }{{"stopOne vs stopAll", one, all}} { + if c.one.disposed != 2 || c.all.disposed != 2 { + t.Fatalf("[%s] Dispose 次数 %d/%d,各期望 2", c.name, c.one.disposed, c.all.disposed) + } + if len(c.one.revoked) != 2 || len(c.all.revoked) != 2 { + t.Fatalf("[%s] RevokePolicy 次数 %d/%d,各期望 2 ——\n"+ + "主体策略不撤销会让同名重建继承旧策略(engineering-lessons §3.2)", + c.name, len(c.one.revoked), len(c.all.revoked)) + } + if len(c.one.unreg) != 2 || len(c.all.unreg) != 2 { + t.Fatalf("[%s] UnregisterInbound 次数 %d/%d,各期望 2 ——\n"+ + "这就是历史上「单数注销、复数漏了」的缺陷形态", + c.name, len(c.one.unreg), len(c.all.unreg)) + } + if c.one.onDispose != 2 || c.all.onDispose != 2 { + t.Fatalf("[%s] OnDispose 次数 %d/%d,各期望 2", c.name, c.one.onDispose, c.all.onDispose) + } + } +} + +// TestStopOneUnknownReturnsError 钉住单数语义:不存在时返回错误。 +// +// 与 StopAll 的"返回停了多少个"形成对比 —— 两者语义不同是有意的。 +func TestStopOneUnknownReturnsError(t *testing.T) { + var r child.Registry + if err := r.StopOne("nope", child.StopDeps{}); err == nil { + t.Fatal("★ StopOne 对不存在的 id 返回了 nil") + } +} + +// TestStopAllFiltersByKind 钉住 StopAll 的过滤语义。 +// +// 变异:忽略 kind 参数(总是停全部)⇒ 本例红。 +func TestStopAllFiltersByKind(t *testing.T) { + var r child.Registry + rt := newFake("rt1", "rt") + res := newFake("res1", "resident") + _ = r.Add(rt) + _ = r.Add(res) + + n := r.StopAll("rt", child.StopDeps{}) + if n != 1 { + t.Fatalf("StopAll(rt) 停了 %d 个,期望 1", n) + } + if rt.disposed != 1 { + t.Fatal("★ rt 子没有被清理") + } + if res.disposed != 0 { + t.Fatal("★ 驻留子被误清理了 —— StopAll 没按 kind 过滤") + } + if r.Len() != 1 { + t.Fatalf("剩余 %d 个,期望 1(驻留子应还在)", r.Len()) + } +} + +// TestRemoveReturnsEntryOnce 钉住:Remove 摘出并返回,第二次得 nil。 +// +// 变异:Remove 不删除(只查)⇒ 本例红。 +func TestRemoveReturnsEntryOnce(t *testing.T) { + var r child.Registry + e := newFake("a", "rt") + _ = r.Add(e) + if got := r.Remove("a"); got == nil { + t.Fatal("★ 首次 Remove 返回 nil") + } + if got := r.Remove("a"); got != nil { + t.Fatal("★ 第二次 Remove 仍返回了条目 —— 没真正删除") + } + if r.Len() != 0 { + t.Fatalf("Len()=%d,期望 0", r.Len()) + } +} + +// TestByKindAndIDs 钉住查询视图。 +func TestByKindAndIDs(t *testing.T) { + var r child.Registry + _ = r.Add(newFake("a", "rt")) + _ = r.Add(newFake("b", "resident")) + _ = r.Add(newFake("c", "rt")) + + if n := len(r.ByKind("rt")); n != 2 { + t.Fatalf("ByKind(rt) 返回 %d 个,期望 2", n) + } + ids := r.IDs() + if len(ids) != 3 { + t.Fatalf("IDs() 返回 %d 个,期望 3", len(ids)) + } + for i := 1; i < len(ids); i++ { + if ids[i-1] > ids[i] { + t.Fatalf("IDs() 未排序: %v(调用方据此做稳定输出)", ids) + } + } +} + +// TestChildStopIsIdempotent 钉住 Base().Stop() 幂等且会 cancel ctx。 +// +// 变异:Stop 不 cancel ⇒ 本例红(正在跑的轮次不会在安全点退出)。 +func TestChildStopIsIdempotent(t *testing.T) { + e := newFake("a", "rt") + e.base.Stop() + e.base.Stop() // 第二次不得 panic + if !e.base.Stopped() { + t.Fatal("Stop 后 Stopped() 为 false") + } + if e.base.Ctx.Err() == nil { + t.Fatal("★ Stop 没有 cancel ctx —— 正在跑的轮次不会退出") + } +} diff --git a/internal/agent/io/toolblocks_test.go b/internal/agent/io/toolblocks_test.go index 6086e913..5356c2af 100644 --- a/internal/agent/io/toolblocks_test.go +++ b/internal/agent/io/toolblocks_test.go @@ -1,6 +1,7 @@ package io import ( + "strconv" "sync" "testing" ) @@ -84,11 +85,23 @@ func TestConsumeToolBlocksFor_ConcurrentNoCrossTalk(t *testing.T) { m := NewIOManager() const n = 32 + // ★ 每个 goroutine 必须用**唯一**的 id(2026-11-04 修)。 + // + // 原来写的是 `id := string(rune('A' + i%26))` —— n=32 而字母只有 26 个, + // 于是 i=0 与 i=26 都得到 'A',6 个 id 各被两个 goroutine 共用。 + // 两者共享同一个 key:后 Set 的覆盖先 Set 的、先 Consume 的拿到空或 + // 别人的值 ⇒ 判据约 1/8 概率假红。 + // + // 实测该失败在**本次改动之前**的基线上同样复现(12 次内中 1 次), + // 报错 `call E 期望 [block-4-E],实际 []`。这是**测试自身的缺陷**, + // 不是 SetToolBlocksFor / ConsumeToolBlocksFor 的并发缺陷 —— + // 两个 goroutine 抢同一个 key 本就该串话(那是覆盖语义,不是串话)。 var wg sync.WaitGroup errs := make(chan string, n) for i := 0; i < n; i++ { - id := string(rune('A' + i%26)) - want := "block-" + string(rune('0'+i%10)) + "-" + id + // id 唯一 ⇒ 每个调用有自己独立的 key,断言才有意义。 + id := "call-" + strconv.Itoa(i) + want := "block-" + strconv.Itoa(i%10) + "-" + id wg.Add(1) go func() { defer wg.Done() @@ -106,6 +119,50 @@ func TestConsumeToolBlocksFor_ConcurrentNoCrossTalk(t *testing.T) { } } +// TestConsumeToolBlocksFor_KeysAreIsolated 钉住"不同 key 互不干扰"这条契约。 +// +// ## 为什么单开一条(2026-11-04) +// +// 上面那条并发判据原先用 `i%26` 生成 id,n=32 ⇒ 6 个 id 重复,两个 goroutine +// 抢同一个 key ⇒ 约 1/8 概率假红。它红了半年没人管,因为"偶发"通常被当成 +// 环境问题。 +// +// 根因是判据**自己违反了它想验证的前提**:想测"不同 key 不串话", +// 却让多个 goroutine 共用 key。 +// +// 这条判据把前提本身钉住:一次性写入 N 个**互不相同**的 key, +// 逐个读回必须各得其所。它不涉及并发,所以失败一定是真缺陷。 +func TestConsumeToolBlocksFor_KeysAreIsolated(t *testing.T) { + m := NewIOManager() + const n = 64 + want := make([]string, n) + for i := 0; i < n; i++ { + id := "call-" + strconv.Itoa(i) + want[i] = "block-" + strconv.Itoa(i) + m.SetToolBlocksFor(id, []interface{}{want[i]}) + } + for i := 0; i < n; i++ { + id := "call-" + strconv.Itoa(i) + got := m.ConsumeToolBlocksFor(id) + if len(got) != 1 || got[0] != want[i] { + t.Fatalf("★ key %s 期望 [%s],实际 %s ——\n"+ + "不同 key 之间串话了(或写入被覆盖)", id, want[i], toStr(got)) + } + } +} + +// TestConsumeToolBlocksFor_ConsumeIsOneShot 钉住消费语义:读一次后清空。 +func TestConsumeToolBlocksFor_ConsumeIsOneShot(t *testing.T) { + m := NewIOManager() + m.SetToolBlocksFor("c", []interface{}{"b"}) + if got := m.ConsumeToolBlocksFor("c"); len(got) != 1 { + t.Fatalf("首次消费应得 1 条,实际 %d", len(got)) + } + if got := m.ConsumeToolBlocksFor("c"); len(got) != 0 { + t.Fatalf("★ 二次消费应得空(block 是一次性的),实际 %s", toStr(got)) + } +} + func toStr(b []interface{}) string { s := "[" for i, v := range b {