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
356 lines
14 KiB
Go
356 lines
14 KiB
Go
package squad
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// wp4_conformance_test.go — Q2 WP4 验收:task_board 拓扑(claim/lease/complete
|
||
// 原语 + 依赖门 + 孤儿租约再武装)与全 12 拓扑 conformance 汇总跑。
|
||
|
||
// ---- task_board ----
|
||
|
||
// 菱形依赖 a→(b,c)→d:认领序尊重依赖门,全部 done,标准 finalize 收口。
|
||
func TestTopologyConformance_taskBoard(t *testing.T) {
|
||
be := &iterBackend{calls: map[string]int{}}
|
||
sink := &collectSink{}
|
||
engine := newTopologyEngine(t, mockScriptPlanner(
|
||
StepSpec{ID: "a", Role: "researcher", Title: "任务a", Targets: []string{"a.md"}},
|
||
StepSpec{ID: "b", Role: "writer", Title: "任务b", Needs: []string{"a"}, Targets: []string{"b.md"}},
|
||
StepSpec{ID: "c", Role: "reviewer", Title: "任务c", Needs: []string{"a"}, Targets: []string{"c.md"}},
|
||
StepSpec{ID: "d", Role: "writer", Title: "任务d", Needs: []string{"b", "c"}, Targets: []string{"output.md"}},
|
||
), be, sink)
|
||
res, err := engine.Run(context.Background(), Goal{Text: "任务板"}, RunOptions{Topology: TopologyTaskBoard})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Status != RunCompleted {
|
||
t.Fatalf("status = %s err = %v", res.Status, res.Error)
|
||
}
|
||
// 认领序尊重依赖门:a 先于 b/c,d 最后(板上按步骤序认领,确定性)。
|
||
var order []string
|
||
for _, ev := range sink.snapshot() {
|
||
if strings.Contains(ev.Text, "认领") {
|
||
order = append(order, ev.StepID)
|
||
}
|
||
}
|
||
want := []string{"a", "b", "c", "d"}
|
||
if strings.Join(order, ",") != strings.Join(want, ",") {
|
||
t.Fatalf("认领序 = %v,期望 %v", order, want)
|
||
}
|
||
st, _ := engine.loadRun(engine.runDir(res.RunID))
|
||
for _, task := range st.Board {
|
||
if task.Status != "done" {
|
||
t.Fatalf("任务 %s = %s,期望 done", task.ID, task.Status)
|
||
}
|
||
}
|
||
if sink.countText("task_board:收口(done 4/4)") != 1 {
|
||
t.Fatal("缺收口事件")
|
||
}
|
||
}
|
||
|
||
// 孤儿租约再武装:崩溃(ctx 取消)留下 claimed+过期租约 → Resume 重入
|
||
// 再武装回 open、重新认领(Attempts=2)并跑完。
|
||
func TestTaskBoardOrphanLeaseRearms(t *testing.T) {
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
crashed := false
|
||
be := &iterBackend{calls: map[string]int{}, content: func(key string, _ int, _ string) []byte {
|
||
if key == "02_writer" && !crashed {
|
||
crashed = true
|
||
cancel() // 模拟进程死亡:runOneStep 在 ctx 检查处退出,步骤回到 pending
|
||
}
|
||
return nil
|
||
}}
|
||
sink := &collectSink{}
|
||
engine := newTopologyEngine(t, mockScriptPlanner(
|
||
StepSpec{ID: "t1", Role: "researcher", Title: "一", Targets: []string{"a.md"}},
|
||
StepSpec{ID: "t2", Role: "writer", Title: "二", Needs: []string{"t1"}, Targets: []string{"output.md"}},
|
||
), be, sink)
|
||
res0, err := engine.Run(ctx, Goal{Text: "任务板"}, RunOptions{Topology: TopologyTaskBoard})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res0.Status != RunCancelled {
|
||
t.Fatalf("取消后应为 RunCancelled:%s", res0.Status)
|
||
}
|
||
// 崩溃现场:t2 claimed、租约未过期;步骤在盘上是 running(setStep 只改
|
||
// 内存,取消路径的 pending 未落盘)——进程死亡即此形态。测试把租约回拨
|
||
// 成过期(孤儿)并把步骤置回 pending(模拟重启前的一致化),再走 Resume。
|
||
st, err := engine.loadRun(engine.runDir(res0.RunID))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
found := false
|
||
for i := range st.Board {
|
||
if st.Board[i].ID == "t2" {
|
||
found = true
|
||
if st.Board[i].Status != "claimed" || st.Board[i].Attempts != 1 {
|
||
t.Fatalf("崩溃现场 t2 = %+v", st.Board[i])
|
||
}
|
||
st.Board[i].LeaseExpires = time.Now().Add(-time.Hour) // 租约过期 = 孤儿
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatal("板上无 t2")
|
||
}
|
||
for i := range st.Plan.Workflow.Steps {
|
||
if st.Plan.Workflow.Steps[i].ID == "t2" && st.Plan.Workflow.Steps[i].Status == "running" {
|
||
st.Plan.Workflow.Steps[i].Status = "pending"
|
||
}
|
||
}
|
||
if err := st.save(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 重入:孤儿租约再武装 → 重新认领 → 跑完收口。
|
||
res, err := engine.Resume(context.Background(), st.Meta.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Status != RunCompleted {
|
||
t.Fatalf("恢复后应完成:%s %v", res.Status, res.Error)
|
||
}
|
||
if sink.countText("孤儿租约再武装 t2") != 1 {
|
||
t.Fatal("缺孤儿租约再武装事件")
|
||
}
|
||
st2, _ := engine.loadRun(engine.runDir(res.RunID))
|
||
for _, task := range st2.Board {
|
||
if task.ID == "t2" && task.Attempts != 2 {
|
||
t.Fatalf("再武装后 Attempts = %d,期望 2", task.Attempts)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 依赖门级联:非关键任务失败 → 下游 open 任务级联 skipped → 降级收口。
|
||
func TestTaskBoardDependencyGateCascade(t *testing.T) {
|
||
be := &iterBackend{calls: map[string]int{}, content: func(key string, _ int, _ string) []byte {
|
||
return nil
|
||
}}
|
||
failBe := &failOnceBackend{inner: be, failKey: "02_writer"}
|
||
crit := false
|
||
sink := &collectSink{}
|
||
engine := newTopologyEngine(t, mockScriptPlanner(
|
||
StepSpec{ID: "t0", Role: "researcher", Title: "零", Targets: []string{"output.md"}},
|
||
StepSpec{ID: "t1", Role: "writer", Title: "一", Needs: []string{"t0"}, Targets: []string{"b.md"}, Critical: &crit},
|
||
StepSpec{ID: "t2", Role: "reviewer", Title: "二", Needs: []string{"t1"}, Targets: []string{"c.md"}},
|
||
), failBe, sink)
|
||
res, err := engine.Run(context.Background(), Goal{Text: "任务板"}, RunOptions{Topology: TopologyTaskBoard})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Status != RunDegraded {
|
||
t.Fatalf("非关键失败+级联跳过应降级收口:%s %v", res.Status, res.Error)
|
||
}
|
||
st, _ := engine.loadRun(engine.runDir(res.RunID))
|
||
byID := map[string]BoardTask{}
|
||
for _, task := range st.Board {
|
||
byID[task.ID] = task
|
||
}
|
||
if byID["t0"].Status != "done" || byID["t1"].Status != "failed" || byID["t2"].Status != "skipped" {
|
||
t.Fatalf("板状态 = %+v", byID)
|
||
}
|
||
for _, s := range st.Plan.Workflow.Steps {
|
||
if s.ID == "t2" && (s.Status != "skipped" || s.Error != boardSkipReason) {
|
||
t.Fatalf("t2 = %s %q", s.Status, s.Error)
|
||
}
|
||
}
|
||
}
|
||
|
||
// failOnceBackend 对指定目录恒定失败(非瞬态),其余委托 inner。
|
||
type failOnceBackend struct {
|
||
inner Executor
|
||
failKey string
|
||
}
|
||
|
||
func (b *failOnceBackend) Name() string { return b.inner.Name() }
|
||
|
||
func (b *failOnceBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||
if key == b.failKey {
|
||
return &ExecResult{Success: false, ErrorReason: "fatal: 拒绝执行"}, nil
|
||
}
|
||
return b.inner.Execute(ctx, req)
|
||
}
|
||
|
||
// ---- 12/12 汇总 ----
|
||
|
||
// TestTopologyConformance_allTwelve 全 12 内置拓扑汇总跑:每个拓扑名一次
|
||
// 脚本化最小运行(零模型零网络)。graph/mode/driver 十种断言完成收口;
|
||
// handoff/mailbox 两个 primitive 断言 fail-closed 拒绝单跑(其原语能力分别
|
||
// 由 TestTopologyConformance_handoff 与 WP2 mailbox 测试钉住)。
|
||
func TestTopologyConformance_allTwelve(t *testing.T) {
|
||
specs := BuiltinTopologies()
|
||
if len(specs) != 12 {
|
||
t.Fatalf("内置拓扑 = %d,期望 12", len(specs))
|
||
}
|
||
for _, spec := range specs {
|
||
spec := spec
|
||
t.Run(spec.Name, func(t *testing.T) {
|
||
switch spec.Name {
|
||
case TopologySequential:
|
||
runAgg(t, spec.Name, mockScriptPlanner(
|
||
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
|
||
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunCompleted)
|
||
case TopologyParallel:
|
||
runAgg(t, spec.Name, mockScriptPlanner(
|
||
StepSpec{ID: "s1", Role: "researcher", Title: "甲", Parallel: true, Targets: []string{"a.md"}},
|
||
StepSpec{ID: "s2", Role: "writer", Title: "乙", Parallel: true, Targets: []string{"output.md"}},
|
||
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunCompleted)
|
||
case TopologyLoop:
|
||
runAgg(t, spec.Name, mockScriptPlanner(
|
||
StepSpec{ID: "s1", Role: "researcher", Title: "循环体", Targets: []string{"output.md", "fields.json"},
|
||
Loop: &LoopSpec{MaxIterations: 2, UntilField: "done", UntilEquals: "yes"}},
|
||
), &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
|
||
}}, RunOptions{}, RunCompleted)
|
||
case TopologyRouter:
|
||
runAgg(t, spec.Name, mockScriptPlanner(routerSteps()...),
|
||
&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
|
||
}}, RunOptions{}, RunCompleted)
|
||
case TopologyEvaluatorOptimizer:
|
||
runAgg(t, spec.Name, 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"}}},
|
||
), &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{"verdict": "pass"})
|
||
}
|
||
return fieldsJSON(map[string]string{"verdict": "fail"})
|
||
}
|
||
return nil
|
||
}}, RunOptions{}, RunCompleted)
|
||
case TopologyLongRunning:
|
||
runAgg(t, spec.Name, mockScriptPlanner(
|
||
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
|
||
), &iterBackend{calls: map[string]int{}},
|
||
RunOptions{Deadline: time.Now().Add(5 * time.Second)}, RunCompleted)
|
||
case TopologyLeader:
|
||
b := &leaderScriptBackend{
|
||
decisions: []string{delegateJSON("writer", "撰写简报"), closeJSON},
|
||
script: map[string]map[string]string{"01_writer": {"output.md": goodReport}},
|
||
}
|
||
engine := newLeaderEngine(t, b)
|
||
res, err := engine.Run(context.Background(), Goal{Text: "汇总"}, RunOptions{Topology: TopologyLeader})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Status != RunCompleted {
|
||
t.Fatalf("status = %s err = %v", res.Status, res.Error)
|
||
}
|
||
case TopologyReview:
|
||
planner := mockScriptPlanner(
|
||
StepSpec{ID: "f1", Role: "researcher", Title: "评审", Targets: []string{"findings.json"}},
|
||
)
|
||
planner.Agents = []RoleSpec{
|
||
{Role: "researcher", Name: "评审", Backend: "agg-review"},
|
||
{Role: "verifier", Name: "核实", Backend: "agg-review"},
|
||
}
|
||
engine := newAggEngine(t, planner, &aggReviewBackend{}, []RoleSpec{
|
||
{Role: "researcher", Name: "评审", Backend: "agg-review"},
|
||
{Role: "verifier", Name: "核实", Backend: "agg-review"},
|
||
})
|
||
res, err := engine.Run(context.Background(), Goal{Text: "评审"},
|
||
RunOptions{Topology: TopologyReview, ReviewTarget: "材料"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Status != RunCompleted {
|
||
t.Fatalf("status = %s err = %v", res.Status, res.Error)
|
||
}
|
||
data, _ := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
|
||
if !strings.Contains(string(data), "已丢弃 0 条未验证") || !strings.Contains(string(data), "样例问题") {
|
||
t.Fatalf("报告 = %s", data)
|
||
}
|
||
case TopologyGroupChat:
|
||
engine := newAggEngine(t, mockScriptPlanner(
|
||
StepSpec{ID: "p1", Role: "researcher", Title: "甲"},
|
||
StepSpec{ID: "p2", Role: "writer", Title: "乙"},
|
||
), &gcScriptBackend{}, []RoleSpec{
|
||
{Role: "researcher", Name: "甲", Backend: "gc-script"},
|
||
{Role: "writer", Name: "乙", Backend: "gc-script"},
|
||
})
|
||
res, err := engine.Run(context.Background(), Goal{Text: "群聊"},
|
||
RunOptions{Topology: TopologyGroupChat, GroupChatMaxTurns: 2})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Status != RunCompleted {
|
||
t.Fatalf("status = %s err = %v", res.Status, res.Error)
|
||
}
|
||
case TopologyTaskBoard:
|
||
runAgg(t, spec.Name, mockScriptPlanner(
|
||
StepSpec{ID: "t1", Role: "researcher", Title: "一", Targets: []string{"a.md"}},
|
||
StepSpec{ID: "t2", Role: "writer", Title: "二", Needs: []string{"t1"}, Targets: []string{"output.md"}},
|
||
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunCompleted)
|
||
case TopologyHandoff, TopologyMailbox:
|
||
// primitive:不可单独作为运行拓扑(fail-closed);原语能力由
|
||
// 专项 conformance 钉住(handoff/mailbox 测试)。
|
||
runAgg(t, spec.Name, mockScriptPlanner(
|
||
StepSpec{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
|
||
), &iterBackend{calls: map[string]int{}}, RunOptions{}, RunFailed)
|
||
default:
|
||
t.Fatalf("未覆盖的拓扑 %q", spec.Name)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// runAgg 汇总跑的最小引擎:graph 类拓扑共用 iter 三角色装配。
|
||
func runAgg(t *testing.T, topo string, planner Planner, be Executor, opts RunOptions, want RunStatus) {
|
||
t.Helper()
|
||
opts.Topology = topo
|
||
engine := newTopologyEngine(t, planner, be, nil)
|
||
res, err := engine.Run(context.Background(), Goal{Text: "汇总跑 " + topo}, opts)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if res.Status != want {
|
||
t.Fatalf("status = %s err = %q,期望 %s", res.Status, res.Error, want)
|
||
}
|
||
}
|
||
|
||
// aggReviewBackend 汇总跑用的最小 review 后端:finder 出一条发现,verify 判 PASS。
|
||
type aggReviewBackend struct{}
|
||
|
||
func (b *aggReviewBackend) Name() string { return "agg-review" }
|
||
|
||
func (b *aggReviewBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
|
||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||
if strings.HasPrefix(key, "verify_") {
|
||
return writeOne(req.WorkDir, "verdict.md", "核对成立。\nVERDICT: PASS"), nil
|
||
}
|
||
return writeOne(req.WorkDir, "findings.json",
|
||
`[{"location":"第1段","severity":"high","issue":"样例问题","evidence":"样例证据"}]`), nil
|
||
}
|
||
|
||
// newAggEngine 自定义角色表的汇总引擎(backend 实例名 = be.Name())。
|
||
func newAggEngine(t *testing.T, planner Planner, be Executor, roles []RoleSpec) *SquadEngine {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
reg := NewRoleRegistry()
|
||
for _, r := range roles {
|
||
if err := reg.Register(r); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
backends := NewBackendResolver()
|
||
backends.RegisterInstance(be.Name(), be)
|
||
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: planner, Backends: backends, Sink: NopSink{}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return engine
|
||
}
|