Files
Agent-Squad/squad/topology_conformance_test.go
T
weichen acbe744142
ci / vet-and-test (pull_request) Successful in 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Add task-board driver with claim/lease/complete and 12-topology aggregate (Q2 WP4)
Why: Q2 acceptance 1 requires a scripted-backend conformance for every one of
the 12 builtin topologies; task_board was the last unlit driver.

Evidence: wp4_conformance_test.go: diamond-dependency board respects the
dependency gate in claim order; orphan lease (claimed + expired) re-arms on
driver re-entry and re-claims with Attempts=2 via Resume; non-critical task
failure cascades dependents to skipped and finishes degraded; fail-closed
when tasks remain unclosable. TestTopologyConformance_allTwelve runs all 12
names (10 runnable to completion, handoff/mailbox rejected as primitives).
Full suite green with -race.

Impact: task_board lit in the topology registry (all 12 topologies now
covered); RunState gains Board (persisted in runstate.json); RunOptions gains
TaskBoardLeaseSeconds; leader topology now maps to mode before plan
confirmation so Topology=leader runs no longer hit the confirmation gate.

Refs: Q2 plan WP4 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q2.md
2026-10-08 01:29:03 +00:00

436 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// topology_conformance_test.go — Q2 验收一的机器证据:每种拓扑一个
// 脚本化后端的 conformance(零模型、零网络)。
// ---- 脚本化夹具 ----
// collectSink 收集事件流供断言。
type collectSink struct {
mu sync.Mutex
events []Event
}
func (c *collectSink) Emit(ev Event) {
c.mu.Lock()
defer c.mu.Unlock()
c.events = append(c.events, ev)
}
func (c *collectSink) snapshot() []Event {
c.mu.Lock()
defer c.mu.Unlock()
return append([]Event(nil), c.events...)
}
func (c *collectSink) countText(sub string) int {
n := 0
for _, ev := range c.snapshot() {
if strings.Contains(ev.Text, sub) {
n++
}
}
return n
}
// iterBackend 按「步骤子目录 × 第几次执行」回放内容(循环/评估拓扑需要
// 逐轮不同的 fields.json)。
type iterBackend struct {
mu sync.Mutex
calls map[string]int
content func(stepKey string, iter int, target string) []byte
delay time.Duration
}
func (b *iterBackend) Name() string { return "iter" }
func (b *iterBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if b.delay > 0 {
time.Sleep(b.delay)
}
b.mu.Lock()
b.calls[key]++
iter := b.calls[key]
b.mu.Unlock()
patches := map[string][]byte{}
for _, t := range req.Targets {
var data []byte
if b.content != nil {
data = b.content(key, iter, t)
}
if data == nil {
data = []byte(goodReport)
}
out := filepath.Join(req.WorkDir, t)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
if err := os.WriteFile(out, data, 0o644); err != nil {
return nil, err
}
patches[t] = data
}
return &ExecResult{Success: true, Patches: patches}, nil
}
func (b *iterBackend) totalCalls() int {
b.mu.Lock()
defer b.mu.Unlock()
n := 0
for _, v := range b.calls {
n += v
}
return n
}
func newTopologyEngine(t *testing.T, planner Planner, be Executor, sink ProgressSink) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, spec := range []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "iter"},
{Role: "writer", Name: "撰写", Backend: "iter"},
{Role: "reviewer", Name: "审核", Backend: "iter"},
} {
if err := reg.Register(spec); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("iter", be)
if sink == nil {
sink = NopSink{}
}
engine, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: planner, Backends: backends, Sink: sink,
})
if err != nil {
t.Fatal(err)
}
return engine
}
func fieldsJSON(kv map[string]string) []byte {
b, _ := json.Marshal(kv)
return b
}
// ---- sequential / parallel:既有图语义 + 拓扑名受理 ----
func TestTopologyConformance_sequential(t *testing.T) {
be := &iterBackend{calls: map[string]int{}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"notes.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "二", Targets: []string{"output.md"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "顺序任务"}, RunOptions{Topology: TopologySequential})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
if be.totalCalls() != 2 {
t.Fatalf("执行次数 = %d", be.totalCalls())
}
if sink.countText("拓扑 sequential") != 1 {
t.Fatal("缺拓扑受理事件")
}
}
func TestTopologyConformance_parallel(t *testing.T) {
be := &iterBackend{calls: map[string]int{}}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "并行甲", Parallel: true, Targets: []string{"a.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "并行乙", Parallel: true, Targets: []string{"b.md"}},
StepSpec{ID: "s3", Role: "reviewer", Title: "汇合", Targets: []string{"output.md"}},
), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "并行任务"}, RunOptions{Topology: TopologyParallel})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted || be.totalCalls() != 3 {
t.Fatalf("status = %s calls = %d err = %v", res.Status, be.totalCalls(), res.Error)
}
}
// ---- loop:条件停机 + 预算触顶 ----
func TestTopologyConformance_loopUntilField(t *testing.T) {
// 第 1-2 轮 done=no,第 3 轮 done=yes → 恰执行 3 次。
be := &iterBackend{calls: map[string]int{}, content: func(_ string, iter int, target string) []byte {
if target == "fields.json" {
if iter >= 3 {
return fieldsJSON(map[string]string{"done": "yes"})
}
return fieldsJSON(map[string]string{"done": "no"})
}
return nil
}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "循环体", Targets: []string{"output.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 5, UntilField: "done", UntilEquals: "yes"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "循环任务"}, RunOptions{Topology: TopologyLoop})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
if be.totalCalls() != 3 {
t.Fatalf("应在条件满足时停机(3 次),实际 %d", be.totalCalls())
}
if sink.countText("再武装") != 2 {
t.Fatalf("再武装事件应 2 次:%d", sink.countText("再武装"))
}
// 计划里的 LoopRuns 持久化为 2。
if len(res.Plan.Steps) != 1 || res.Plan.Steps[0].LoopRuns != 2 {
t.Fatalf("LoopRuns = %+v", res.Plan.Steps)
}
}
func TestTopologyConformance_loopBudgetExhausted(t *testing.T) {
// 条件永不满足:恰执行 MaxIterations 次后触顶(非失败)。
be := &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"done": "no"})
}
return nil
}}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "循环体", Targets: []string{"output.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 3, UntilField: "done", UntilEquals: "yes"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "循环任务"}, RunOptions{Topology: TopologyLoop})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("触顶应是完成而非失败:%s %v", res.Status, res.Error)
}
if be.totalCalls() != 3 {
t.Fatalf("恰 3 次,实际 %d", be.totalCalls())
}
if sink.countText("loop_exhausted") != 1 {
t.Fatal("缺 loop_exhausted 事件")
}
}
// ---- router:分支选中/落选跳过/无命中 fail-closed ----
func routerSteps() []StepSpec {
return []StepSpec{
{ID: "r1", Role: "researcher", Title: "路由", Targets: []string{"output.md", "fields.json"},
Branches: []Branch{
{When: BranchCond{Field: "route", Equals: "fast"}, Then: []string{"fast"}},
{When: BranchCond{Field: "route", NotEquals: "fast"}, Then: []string{"slow"}},
}},
{ID: "fast", Role: "writer", Title: "快线", Needs: []string{"r1"}, Targets: []string{"output.md"}},
{ID: "slow", Role: "writer", Title: "慢线", Needs: []string{"r1"}, Targets: []string{"output.md"}},
}
}
func TestTopologyConformance_routerSelectsBranch(t *testing.T) {
be := &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"route": "fast"})
}
return nil
}}
engine := newTopologyEngine(t, mockScriptPlanner(routerSteps()...), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "路由任务"}, RunOptions{Topology: TopologyRouter})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("分支落选是预期路径,不得降级/失败:%s %v", res.Status, res.Error)
}
byID := map[string]StepSpec{}
for _, s := range res.Plan.Steps {
byID[s.ID] = s
}
if byID["fast"].Status != "completed" {
t.Fatalf("fast = %s", byID["fast"].Status)
}
if byID["slow"].Status != "skipped" || byID["slow"].Error != routerSkipReason {
t.Fatalf("slow = %s %q", byID["slow"].Status, byID["slow"].Error)
}
}
func TestTopologyConformance_routerNoMatchFailsClosed(t *testing.T) {
be := &iterBackend{calls: map[string]int{}, content: func(_ string, _ int, target string) []byte {
if target == "fields.json" {
return fieldsJSON(map[string]string{"route": ""}) // 两条分支都不命中
}
return nil
}}
steps := routerSteps()
// 去掉 NotEquals 兜底分支,制造无命中。
steps[0].Branches = []Branch{{When: BranchCond{Field: "route", Equals: "fast"}, Then: []string{"fast"}}}
engine := newTopologyEngine(t, mockScriptPlanner(steps...), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "路由任务"}, RunOptions{Topology: TopologyRouter})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "无分支命中") {
t.Fatalf("无命中必须 fail-closed:%s %v", res.Status, res.Error)
}
}
// ---- evaluator-optimizer:draft→eval 循环复合,收敛即停 ----
func TestTopologyConformance_evaluatorOptimizer(t *testing.T) {
// eval 第 1-2 轮 verdict=fail,第 3 轮 pass;draft 随 body 再武装。
be := &iterBackend{calls: map[string]int{}, content: func(key string, iter int, target string) []byte {
if target == "fields.json" {
if iter >= 3 {
return fieldsJSON(map[string]string{"verdict": "pass"})
}
return fieldsJSON(map[string]string{"verdict": "fail"})
}
if target == "verdict.md" {
return []byte(fmt.Sprintf("评审第 %d 轮\n\n%s", iter, goodReport))
}
return nil
}}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "draft", Role: "writer", Title: "起草", Targets: []string{"output.md"}},
StepSpec{ID: "eval", Role: "reviewer", Title: "评审", Targets: []string{"verdict.md", "fields.json"},
Loop: &LoopSpec{MaxIterations: 4, UntilField: "verdict", UntilEquals: "pass", Body: []string{"draft"}}},
), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "起草并评审"}, RunOptions{Topology: TopologyEvaluatorOptimizer})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("status = %s err = %v", res.Status, res.Error)
}
byID := map[string]StepSpec{}
for _, s := range res.Plan.Steps {
byID[s.ID] = s
}
if byID["eval"].LoopRuns != 2 {
t.Fatalf("eval 应再武装 2 轮:%+v", byID["eval"])
}
// draft 3 次 + eval 3 次。
if be.totalCalls() != 6 {
t.Fatalf("总执行次数 = %d,期望 6", be.totalCalls())
}
}
// ---- long-running:截止时刻 → 未完步骤 skipped、降级收口 ----
func TestTopologyConformance_longRunningDeadline(t *testing.T) {
// 时序鲁棒设计:每步 300ms、截止 +500ms——装配耗时不影响断言核心:
// s1 必完成(首个 superstep 在截止前开跑),s3 必被跳过(第三个
// superstep 边界必超期),收口必为 degraded。s2 完成与否取决于装配
// 耗时,不做断言。
be := &iterBackend{calls: map[string]int{}, delay: 300 * time.Millisecond}
sink := &collectSink{}
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"notes.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "二", Targets: []string{"output.md"}},
StepSpec{ID: "s3", Role: "reviewer", Title: "三", Targets: []string{"output.md"}},
), be, sink)
res, err := engine.Run(context.Background(), Goal{Text: "长任务"}, RunOptions{
Topology: TopologyLongRunning,
Deadline: time.Now().Add(500 * time.Millisecond),
})
if err != nil {
t.Fatal(err)
}
if res.Status != RunDegraded {
t.Fatalf("截止后应降级收口:%s %v", res.Status, res.Error)
}
byID := map[string]StepSpec{}
for _, s := range res.Plan.Steps {
byID[s.ID] = s
}
if byID["s1"].Status != "completed" {
t.Fatalf("s1 = %s", byID["s1"].Status)
}
if byID["s3"].Status != "skipped" || byID["s3"].Error != "deadline exceeded" {
t.Fatalf("s3 = %s %q", byID["s3"].Status, byID["s3"].Error)
}
if sink.countText("截止时刻") != 1 {
t.Fatal("缺截止事件")
}
}
// ---- 拓扑校验 fail-closed ----
func TestTopologyValidationFailsClosed(t *testing.T) {
be := &iterBackend{calls: map[string]int{}}
cases := []struct {
name string
topo string
wantErr string
}{
{"未注册名", "bogus_topology", "未知拓扑"},
{"primitive 单跑", TopologyMailbox, "跨拓扑原语"},
{"primitive 单跑 handoff", TopologyHandoff, "跨拓扑原语"},
// review/group_chat(WP3)与 task_board(WP4)驱动均已点亮;
// 12/12 受理面由 TestTopologyConformance_allTwelve 汇总钉住。
}
for _, tc := range cases {
engine := newTopologyEngine(t, mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
), be, nil)
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{Topology: tc.topo})
if err != nil {
t.Fatalf("%s:err = %v", tc.name, err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, tc.wantErr) {
t.Fatalf("%s:%s %q(期望含 %q)", tc.name, res.Status, res.Error, tc.wantErr)
}
}
}
// leader 拓扑名映射到既有 Mode 委派循环(本测只钉「受理且映射」,
// leader 行为 conformance 在 leader_test.go / WP2 增强)。
func TestTopologyLeaderMapsToMode(t *testing.T) {
spec, ok := LookupTopology(TopologyLeader)
if !ok || spec.Kind != TopologyMode {
t.Fatalf("leader 注册形态:%+v %v", spec, ok)
}
if _, err := checkTopology(TopologyLeader); err != nil {
t.Fatalf("leader 拓扑应可受理:%v", err)
}
}
// 12 种内置拓扑全部注册且 Kind 合法(清单完整性)。
func TestBuiltinTopologiesComplete(t *testing.T) {
want := map[string]TopologyKind{
TopologySequential: TopologyGraph, TopologyParallel: TopologyGraph,
TopologyLoop: TopologyGraph, TopologyRouter: TopologyGraph,
TopologyLeader: TopologyMode, TopologyHandoff: TopologyPrimitive,
TopologyGroupChat: TopologyDriver, TopologyReview: TopologyDriver,
TopologyEvaluatorOptimizer: TopologyGraph, TopologyLongRunning: TopologyGraph,
TopologyTaskBoard: TopologyDriver, TopologyMailbox: TopologyPrimitive,
}
got := BuiltinTopologies()
if len(got) != len(want) {
t.Fatalf("注册数 = %d,期望 %d", len(got), len(want))
}
for _, spec := range got {
if k, ok := want[spec.Name]; !ok || k != spec.Kind {
t.Fatalf("拓扑 %s kind = %s(期望 %s)", spec.Name, spec.Kind, k)
}
}
}