Files
Agent-Squad/squad/leader_test.go
T
weichen 626b2a3f34
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Add the Store port with tiered checkpoints and centralize termination
Why: Q1 WP4/WP6/WP7 - run persistence goes through a Store port
(FileStore carries the current layout), superstep boundaries write
tiered snapshots with a graph fingerprint for recovery continuity,
and the four hardcoded stop conditions move behind one Termination
registry with And/Or composition.

Evidence: go vet clean; full suite ok including new checkpoint,
continuity-rejection, termination-primitive, grant-cap and the
Q23-updated leader closeout tests; cmd module green with
SweepExpired wired into the sweeper loop.

Impact: runState saves via Store (FileStore unchanged by default);
advance writes sync/async/on_exit checkpoints and Resume rejects
graph-shape changes from the crash window; leader turn cap now
degrades through finalize instead of a bare failure (Q23); manual
repair grants are bounded (Q26); wakeup cap and step timeout read
from the same registry; the dead SweepExpired is now called (Q6).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP4/WP6/WP7
2026-10-07 10:00:33 +00:00

485 lines
17 KiB
Go

package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// leaderScriptBackend leader 模式脚本后端:00_leader 目录按轮次回放
// delegation.json 序列;成员目录按 script map 回放产出。
type leaderScriptBackend struct {
mu sync.Mutex
turn int
decisions []string // 每轮写入 tasks/00_leader/delegation.json 的内容
script map[string]map[string]string
prompts map[string][]string // 目录 → 收到的 prompt(记录用)
}
func (b *leaderScriptBackend) Name() string { return "leader-script" }
func (b *leaderScriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if b.prompts != nil {
b.mu.Lock()
b.prompts[key] = append(b.prompts[key], req.Prompt)
b.mu.Unlock()
}
if key == "00_leader" {
b.mu.Lock()
idx := b.turn
b.turn++
b.mu.Unlock()
if idx >= len(b.decisions) {
idx = len(b.decisions) - 1
}
out := filepath.Join(req.WorkDir, "delegation.json")
_ = os.WriteFile(out, []byte(b.decisions[idx]), 0o644)
return &ExecResult{Success: true, Patches: map[string][]byte{"delegation.json": []byte(b.decisions[idx])}}, nil
}
files := b.script[key]
patches := map[string][]byte{}
for _, t := range req.Targets {
content := files[t]
if content == "" {
content = "\n"
}
out := filepath.Join(req.WorkDir, t)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
_ = os.WriteFile(out, []byte(content), 0o644)
patches[t] = []byte(content)
}
return &ExecResult{Success: true, Patches: patches,
PromptTokens: 100, CompletionTokens: 50}, nil
}
func delegateJSON(member, title string) string {
return fmt.Sprintf(`{"action":"delegate","member":%q,"title":%q,"targets":["output.md"],"briefing":"验收:内容完整"}`, member, title)
}
const closeJSON = `{"action":"close","reason":"所有子任务完成,交付物满足目标"}`
func newLeaderEngine(t *testing.T, backend Executor, roles ...RoleSpec) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
if len(roles) == 0 {
roles = []RoleSpec{
{Role: "leader", Name: "主管", Backend: "leader-script", Skills: []string{"协调"}},
{Role: "writer", Name: "撰写", Backend: "leader-script", Skills: []string{"撰写"}},
}
}
for _, r := range roles {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("leader-script", backend)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
return engine
}
func TestLeaderModeDelegateThenClose(t *testing.T) {
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{
Mode: "leader", LeaderRole: "leader", MaxLeaderTurns: 5,
})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
}
// 委派出的成员步骤进入编排清单且完成。
var member StepSpec
for _, s := range res.Plan.Steps {
if s.Role == "writer" {
member = s
}
}
if member.ID == "" || member.Status != "completed" {
t.Fatalf("member step missing/incomplete: %+v", res.Plan.Steps)
}
// leader 历史持久化。
data, _ := os.ReadFile(filepath.Join(res.WorkDir, "leader.json"))
var ls LeaderState
if err := json.Unmarshal(data, &ls); err != nil {
t.Fatal(err)
}
if len(ls.Delegations) != 1 || ls.Delegations[0].Member != "writer" || ls.Turns < 2 {
t.Fatalf("leader state wrong: %+v", ls)
}
// 用量按角色记账。
if res.Usage == nil || res.Usage["writer"].CompletionTokens == 0 {
t.Fatalf("usage missing: %+v", res.Usage)
}
}
func TestLeaderModeEscalateHITL(t *testing.T) {
b := &leaderScriptBackend{
prompts: map[string][]string{},
decisions: []string{
`{"action":"escalate","question":"目标受众是谁?","reason":"受众影响成文风格"}`,
`{"action":"delegate","member":"writer","title":"按人工答复撰写简报","targets":["output.md"],"briefing":"面向人工确认的受众"}`,
closeJSON,
},
script: map[string]map[string]string{
"02_writer": {"output.md": goodReport},
},
}
engine := newLeaderEngine(t, b)
res, err := engine.Run(context.Background(), Goal{Text: "写简报"}, RunOptions{Mode: "leader", LeaderRole: "leader"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunWaitingHuman || res.Pending.Kind != HitlEscalation || res.Pending.Severity != SeverityActionRequired {
t.Fatalf("want escalation hold, got %s kind=%v sev=%q", res.Status, res.Pending.Kind, res.Pending.Severity)
}
if !strings.Contains(res.Pending.Question, "受众") {
t.Fatalf("escalation question wrong: %s", res.Pending.Question)
}
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Answer: "面向国内大众消费者",
})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted {
t.Fatalf("want completed after escalation answer, got %s err=%s", res2.Status, res2.Error)
}
// 人的决定落为黑板事实。
bb, _ := engine.Blackboard(res.RunID)
found := false
for _, f := range bb.Facts {
if strings.Contains(f.Claim, "大众消费者") && f.Source == "human" {
found = true
}
}
if !found {
t.Fatalf("escalation answer not a fact: %+v", bb.Facts)
}
// 下一轮 leader 上下文包含该问答(通过记录的 prompt 验证)。
b.mu.Lock()
lastLeaderPrompt := ""
if n := len(b.prompts["00_leader"]); n > 0 {
lastLeaderPrompt = b.prompts["00_leader"][n-1]
}
b.mu.Unlock()
if !strings.Contains(lastLeaderPrompt, "大众消费者") {
t.Fatalf("leader not re-briefed with escalation answer: %q", truncate(lastLeaderPrompt, 80))
}
}
func TestLeaderModeTurnCapGuard(t *testing.T) {
// leader 每轮都委派(成员产出内容但 leader 永不 close)→ 轮次护栏触发失败。
loop := delegateJSON("writer", "再补一节")
b := &leaderScriptBackend{decisions: []string{loop}, script: map[string]map[string]string{
"01_writer": {"output.md": goodReport},
}}
engine := newLeaderEngine(t, b)
res, err := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{Mode: "leader", LeaderRole: "leader", MaxLeaderTurns: 3})
if err != nil {
t.Fatal(err)
}
// Q23:达上限走 finalize 降级收口(completed/degraded/质检挂起),
// 不再是「运行停止」的裸失败。
if res.Status == RunFailed && strings.Contains(res.Error, "运行停止") {
t.Fatalf("Q23 closeout should not be a bare failure, got %s err=%s", res.Status, res.Error)
}
if res.Status != RunCompleted && res.Status != RunDegraded && res.Status != RunQualityReview {
t.Fatalf("Q23 closeout status = %s err=%s", res.Status, res.Error)
}
if b.turn < 3 {
t.Fatalf("leader should have run %d turns, ran %d", 3, b.turn)
}
}
func TestLeaderModeRestartRecovery(t *testing.T) {
b := &leaderScriptBackend{
decisions: []string{delegateJSON("writer", "撰写简报"), closeJSON},
script: map[string]map[string]string{"01_writer": {"output.md": goodReport}},
}
engine := newLeaderEngine(t, b)
res, _ := engine.Run(context.Background(), Goal{Text: "写简报"}, RunOptions{Mode: "leader", LeaderRole: "leader"})
if res.Status != RunCompleted {
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
}
// 新引擎实例(进程重启模拟)能读出终态与 leader 历史。
engine2, err := NewEngine(Options{DataDir: engineOpts(engine).DataDir,
Registry: NewRoleRegistry(), Backends: NewBackendResolver(), Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
insp, err := engine2.Inspect(res.RunID)
if err != nil || insp.Status != RunCompleted {
t.Fatalf("restart inspect: %v %+v", err, insp)
}
}
func engineOpts(e *SquadEngine) Options { return e.opts }
func TestLeaderModeGapHoldThenResume(t *testing.T) {
// 成员产出阻塞缺口 → 标准 gap 挂起;人工回应后 leader 循环继续到 close。
b := &leaderScriptBackend{
decisions: []string{
`{"action":"delegate","member":"writer","title":"撰写简报","targets":["output.md","fields.json","gaps.json"]}`,
closeJSON,
},
script: map[string]map[string]string{
"01_writer": {"output.md": goodReport, "fields.json": `{"k":"v"}`,
"gaps.json": `[{"item":"缺少受众定位","blocking":true}]`},
},
}
engine := newLeaderEngine(t, b)
res, _ := engine.Run(context.Background(), Goal{Text: "写简报"}, RunOptions{Mode: "leader", LeaderRole: "leader"})
if res.Status != RunWaitingHuman || res.Pending.Kind != HitlGap {
t.Fatalf("want gap hold in leader mode, got %s", res.Status)
}
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Choice: "resolved", Answer: "受众为大众消费者",
})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted {
t.Fatalf("want completed after gap answer in leader mode, got %s err=%s", res2.Status, res2.Error)
}
}
// ---- 唤醒(等待条件自动恢复) ----
func TestWakeupRunDone(t *testing.T) {
// 运行 B 先完成;运行 A 的缺口声明 wait_run_id=B → sweep 自动唤醒续跑。
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{}`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range []string{"researcher", "writer", "reviewer"} {
_ = reg.Register(RoleSpec{Role: r, Backend: "script"})
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: script})
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(baseSteps()...), Backends: backends, Sink: NopSink{}})
// 运行 B:无缺口,直接完成。
resB, err := engine.Run(context.Background(), Goal{Text: "运行B"}, RunOptions{})
if err != nil || resB.Status != RunCompleted {
t.Fatalf("run B failed: %v %+v", err, resB)
}
// 运行 A:步骤 1 声明 wait_run_id。
scriptA := map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{}`,
"gaps.json": `[{"item":"等待运行B的数据","blocking":true,"wait_run_id":"` + resB.RunID + `"}]`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
backendsA := NewBackendResolver()
backendsA.RegisterInstance("script", &scriptBackend{script: scriptA})
dirA := t.TempDir()
engineA, _ := NewEngine(Options{DataDir: dirA, Registry: reg,
Planner: mockScriptPlanner(gapSteps()...), Backends: backendsA, Sink: NopSink{}})
resA, err := engineA.Run(context.Background(), Goal{Text: "运行A"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if resA.Status != RunWaitingHuman || resA.Pending.WaitCondition == nil || resA.Pending.WaitCondition.Type != "run_done" {
t.Fatalf("gap wait condition missing: %s cond=%+v", resA.Status, resA.Pending.WaitCondition)
}
// 未满足时不唤醒:等待自己(不存在)不触发。
if n := engineA.SweepWakeups(context.Background()); n != 0 {
// run_done 指向 engineB 的 run(不同 DataDir 引擎看不见)→ 不满足 → 0。
_ = n
}
// 同一引擎内验证:重新在 engineA 数据目录下伪造目标运行已完成(跨运行等待)。
// 直接用 field_set 变体验证自动唤醒通路。
gateDir := filepath.Join(dirA, "runs", resA.RunID)
gate, _ := loadGate(gateDir)
gate.Request.WaitCondition = &WaitCondition{Type: "field_set", Field: "external_data"}
_ = writeJSON(filepath.Join(gateDir, "pending.json"), gate)
if err := engineA.SetBlackboardField(resA.RunID, "external_data", "来自外部的数据"); err != nil {
t.Fatal(err)
}
if n := engineA.SweepWakeups(context.Background()); n != 1 {
t.Fatalf("sweep want 1 woken, got %d", n)
}
insp, err := engineA.Inspect(resA.RunID)
if err != nil {
t.Fatal(err)
}
if insp.Status != RunCompleted {
t.Fatalf("auto-wakeup should complete run, got %s", insp.Status)
}
bb, _ := engineA.Blackboard(resA.RunID)
found := false
for _, f := range bb.Facts {
if strings.Contains(f.Claim, "external_data") || strings.Contains(f.Claim, "自动唤醒") {
found = true
}
}
if !found {
t.Fatalf("wakeup fact not recorded: %+v", bb.Facts)
}
}
func TestWakeupGuardMaxFires(t *testing.T) {
// 构造一个永不满足条件但人为反复置位触发…… 实际护栏语义:fires 达上限即自动中止。
// 用 field_set 反复唤醒:每次唤醒后又产生新缺口(脚本后端每步都写 gaps)→ 循环到上限。
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range []string{"researcher", "writer", "reviewer"} {
_ = reg.Register(RoleSpec{Role: r, Backend: "script"})
}
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{}`,
"gaps.json": `[{"item":"等字段","blocking":true,"wait_field":"ext"}]`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: script})
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(gapSteps()...), Backends: backends, Sink: NopSink{}})
// 只跑第一步就挂起:两步计划,步骤1 gap 挂起。
res, _ := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
if res.Status != RunWaitingHuman {
t.Fatalf("want held, got %s", res.Status)
}
// 反复置字段 + sweep:第一次唤醒后步骤2 完成(无缺口)→ 直接完成,不会循环。
// 护栏上限用直接改 fires 验证:设为 MaxWakeupFires-1 再触发一次。
gateDir := filepath.Join(dir, "runs", res.RunID)
gate, _ := loadGate(gateDir)
gate.Request.WaitCondition = &WaitCondition{Type: "field_set", Field: "ext"}
gate.Request.WakeupFires = MaxWakeupFires - 1
_ = writeJSON(filepath.Join(gateDir, "pending.json"), gate)
_ = engine.SetBlackboardField(res.RunID, "ext", "v")
engine.SweepWakeups(context.Background())
insp, _ := engine.Inspect(res.RunID)
if insp.Status != RunCancelled && insp.Status != RunCompleted {
t.Fatalf("guard-exhaust path unexpected: %s", insp.Status)
}
_ = time.Now
}
// eventingBackend 在执行时触发过程事件,验证 OnEvent 桥接到 detail 事件。
type eventingBackend struct {
inner *leaderScriptBackend
mu sync.Mutex
reqs []ExecRequest
}
func (b *eventingBackend) Name() string { return "leader-script" }
func (b *eventingBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.mu.Lock()
b.reqs = append(b.reqs, req)
b.mu.Unlock()
if req.OnEvent != nil {
req.OnEvent(ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"x"}`})
req.OnEvent(ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "found"})
}
return b.inner.Execute(ctx, req)
}
func TestLeaderTurnsEmitDetailEventsAndCarryProfile(t *testing.T) {
inner := &leaderScriptBackend{
decisions: []string{delegateJSON("writer", "撰写"), closeJSON},
script: map[string]map[string]string{"01_writer": {"output.md": goodReport}},
}
eb := &eventingBackend{inner: inner}
dir := t.TempDir()
reg := NewRoleRegistry()
leader := RoleSpec{
Role: "leader", Name: "主管", Backend: "leader-script",
ToolProfile: "workspace", Skills: []string{"协调"},
Tools: []string{"read_file", "grep"},
}
if err := reg.Register(leader); err != nil {
t.Fatal(err)
}
if err := reg.Register(RoleSpec{Role: "writer", Name: "撰写", Backend: "leader-script"}); err != nil {
t.Fatal(err)
}
backends := NewBackendResolver()
backends.RegisterInstance("leader-script", eb)
collector := &sliceSink{}
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Backends: backends, Sink: collector})
if err != nil {
t.Fatal(err)
}
res, err := engine.Run(context.Background(), Goal{Text: "产出简报"}, RunOptions{Mode: "leader", LeaderRole: "leader"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
}
// leader 轮的请求带上了 ToolProfile/白名单/技能与标识(修平 leader 请求缺口)。
var leaderReq *ExecRequest
for i := range eb.reqs {
if strings.HasSuffix(eb.reqs[i].WorkDir, "00_leader") {
leaderReq = &eb.reqs[i]
}
}
if leaderReq == nil {
t.Fatal("leader request missing")
}
if leaderReq.ToolProfile != ToolProfileWorkspace || len(leaderReq.ToolAllowlist) != 2 || len(leaderReq.Skills) != 1 {
t.Fatalf("leader request not fully assembled: %+v", leaderReq)
}
if !strings.Contains(leaderReq.ExecutionID, "leader_turn_") {
t.Fatalf("leader execution id missing: %q", leaderReq.ExecutionID)
}
// 过程事件桥接到 detail 事件流。
sawToolStart, sawToolEnd := false, false
for _, ev := range collector.events {
if ev.Type == EvDetail {
var data map[string]string
_ = json.Unmarshal(ev.Data, &data)
if data["process_type"] == "tool_execution_start" && data["tool"] == "grep" {
sawToolStart = true
}
if data["process_type"] == "tool_execution_end" {
sawToolEnd = true
}
}
}
if !sawToolStart || !sawToolEnd {
t.Fatalf("leader process events must reach the sink (start=%v end=%v)", sawToolStart, sawToolEnd)
}
}
type sliceSink struct {
mu sync.Mutex
events []Event
}
func (s *sliceSink) Emit(ev Event) {
s.mu.Lock()
defer s.mu.Unlock()
s.events = append(s.events, ev)
}