Files
Agent-Squad/squad/recovery_test.go
T
weichen 23f161bfb3 Honor executor terminals in retries and interrupted recovery
Why: F05 - files-presence could promote failures to success, recovery
marked crashed steps completed on files alone or reran them blindly,
and retries minted a fresh ExecutionID each attempt.
Evidence: squad tests pass incl. executor result contract and recovery
tables (verify-u3b.json); (nil,nil), canceled and side-effect-possible
semantics handled; interrupted steps without a trusted terminal now
hold for reconciliation instead of auto-advancing.
Impact: retries reuse the step ExecutionID and shared deadline; no
auto-replay of unverified external actions.
Refs: feature/agent-stack-audit-remediation-b1 U3B
2026-10-21 17:20:40 +00:00

420 lines
13 KiB
Go

package squad
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func approvalScript() map[string]map[string]string {
return map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{}`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
}
func TestStepApprovalApproveExecutes(t *testing.T) {
steps := baseSteps()
steps[0].RequiresApproval = true
eng, _ := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
res, err := eng.Run(context.Background(), Goal{Text: "需要审批"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunAwaitingConfirmation || res.Pending == nil || res.Pending.Reason != "step_approval" {
t.Fatalf("want step approval hold, got %s %+v", res.Status, res.Pending)
}
res, err = eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "approve"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("approve should execute, got %s %s", res.Status, res.Error)
}
}
func TestStepApprovalSkipDoesNotCancel(t *testing.T) {
steps := baseSteps()
steps[0].RequiresApproval = true
eng, _ := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
res, err := eng.Run(context.Background(), Goal{Text: "跳过一步"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
res, err = eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "skip"})
if err != nil {
t.Fatal(err)
}
if res.Status == RunCancelled || res.Status == RunFailed {
t.Fatalf("skip must not cancel the run, got %s %s", res.Status, res.Error)
}
if res.Plan == nil || len(res.Plan.Steps) == 0 || res.Plan.Steps[0].Status != "skipped" {
t.Fatalf("first step should be skipped: %+v", res.Plan)
}
}
func TestApprovalSurvivesRestart(t *testing.T) {
steps := baseSteps()
steps[0].RequiresApproval = true
eng, dir := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
res, err := eng.Run(context.Background(), Goal{Text: "授权要留下"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if _, err := eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "approve"}); err != nil {
t.Fatal(err)
}
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
if !st.Approved["step:s1"] {
t.Fatalf("approval not stored: %+v", st.Approved)
}
st.Meta.Status = string(RunExecuting)
st.Meta.CompletedAt = nil
wf := st.Workflow()
wf.Status = "running"
wf.Steps[0].Status = "pending"
st.setWorkflow(wf)
if err := st.save(); err != nil {
t.Fatal(err)
}
eng2, err := NewEngine(Options{
DataDir: dir, Registry: eng.Registry(), Planner: mockScriptPlanner(steps...),
Backends: eng.opts.Backends, Sink: NopSink{},
})
if err != nil {
t.Fatal(err)
}
res, err = eng2.Resume(context.Background(), res.RunID)
if err != nil {
t.Fatal(err)
}
if res.Status == RunAwaitingConfirmation {
t.Fatal("restart asked for approval again")
}
if res.Status != RunCompleted {
t.Fatalf("resume after restart: %s %s", res.Status, res.Error)
}
}
type countBackend struct {
mu sync.Mutex
n int
}
func (b *countBackend) Name() string { return "script" }
func (b *countBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.mu.Lock()
b.n++
b.mu.Unlock()
return (&scriptBackend{script: approvalScript()}).Execute(ctx, req)
}
func (b *countBackend) calls() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.n
}
func TestInterruptedRunningStepStaysHeld(t *testing.T) {
backend := &countBackend{}
dir := t.TempDir()
reg := NewRoleRegistry()
for _, role := range []string{"researcher", "writer", "reviewer"} {
if err := reg.Register(RoleSpec{Role: role, Backend: "script"}); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("script", backend)
eng, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(baseSteps()...),
Backends: backends, Sink: NopSink{},
})
if err != nil {
t.Fatal(err)
}
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再假装中断"}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
before := backend.calls()
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
st.Meta.Status = string(RunExecuting)
st.Meta.CompletedAt = nil
wf := st.Workflow()
wf.Status = "running"
wf.Steps[0].Status = "running"
wf.Steps[0].Attempts = 1
st.setWorkflow(wf)
if err := st.save(); err != nil {
t.Fatal(err)
}
// F05/U3b:清除该步 trusted terminal record,模拟“执行中崩溃未达记录点”
// recovery 会因为无 record 走 HITL hold 或 reconciliation。
terminals := loadStepTerminalsForWorkDir(res.WorkDir)
if terminals != nil {
stepID := st.Plan.Workflow.Steps[0].ID
if _, ok := terminals[stepID]; ok {
delete(terminals, stepID)
data, _ := json.Marshal(terminals)
_ = os.WriteFile(filepath.Join(res.WorkDir, "step_terminals.json"), data, 0o644)
}
}
eng2, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: eng.opts.Planner, Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
res, err = eng2.Resume(context.Background(), res.RunID)
if err != nil {
t.Fatal(err)
}
if res.Status != RunAwaitingConfirmation || res.Pending == nil || res.Pending.Reason != "interrupted" {
t.Fatalf("want interrupted hold, got %s %+v", res.Status, res.Pending)
}
if backend.calls() != before {
t.Fatalf("interrupted step executed again: calls %d -> %d", before, backend.calls())
}
}
type blockBackend struct {
started chan struct{}
release chan struct{}
once sync.Once
}
func (b *blockBackend) Name() string { return "script" }
func (b *blockBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
b.once.Do(func() { close(b.started) })
select {
case <-b.release:
case <-ctx.Done():
return nil, ctx.Err()
}
return (&scriptBackend{script: approvalScript()}).Execute(ctx, req)
}
func TestTwoEnginesOneRunIsBusy(t *testing.T) {
backend := &blockBackend{started: make(chan struct{}), release: make(chan struct{})}
dir := t.TempDir()
reg := NewRoleRegistry()
for _, role := range []string{"researcher", "writer", "reviewer"} {
_ = reg.Register(RoleSpec{Role: role, Backend: "script"})
}
backends := NewBackendResolver()
backends.RegisterInstance("script", backend)
mk := func() *SquadEngine {
t.Helper()
eng, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(baseSteps()...),
Backends: backends, Sink: NopSink{},
})
if err != nil {
t.Fatal(err)
}
return eng
}
engA := mk()
done := make(chan struct{})
go func() {
defer close(done)
_, _ = engA.Run(context.Background(), Goal{Text: "占住锁"}, RunOptions{})
}()
select {
case <-backend.started:
case <-time.After(3 * time.Second):
t.Fatal("run did not start")
}
runs, err := engA.ListRuns()
if err != nil || len(runs) != 1 {
t.Fatalf("runs: %v %+v", err, runs)
}
engB := mk()
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
_, err = engB.Resume(ctx, runs[0].ID)
if !errors.Is(err, ErrRunBusy) {
t.Fatalf("want busy, got %v", err)
}
close(backend.release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("holder did not finish")
}
}
func TestWakeupDoesNotApprovePermission(t *testing.T) {
var kinds []string
sink := ProgressFunc(func(ev Event) { kinds = append(kinds, ev.Type) })
steps := gapSteps()
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},
}
eng, dir := newTestEngine(t, mockScriptPlanner(steps...), script, sink)
res, err := eng.Run(context.Background(), Goal{Text: "权限不能被唤醒放行"}, RunOptions{})
if err != nil || res.Status != RunWaitingHuman {
t.Fatalf("setup: %v %+v", err, res)
}
gate, err := loadGate(filepath.Join(dir, "runs", res.RunID))
if err != nil || gate == nil {
t.Fatal(err)
}
gate.Request.Kind = HitlPermission
gate.Request.WaitCondition = &WaitCondition{Type: "field_set", Field: "ext"}
if err := writeJSON(filepath.Join(dir, "runs", res.RunID, "pending.json"), gate); err != nil {
t.Fatal(err)
}
if err := eng.SetBlackboardField(res.RunID, "ext", "ready"); err != nil {
t.Fatal(err)
}
if n := eng.SweepWakeups(context.Background()); n != 0 {
t.Fatalf("permission wakeup must not count as resolved, got %d", n)
}
insp, err := eng.Inspect(res.RunID)
if err != nil {
t.Fatal(err)
}
if insp.Status != RunWaitingHuman || insp.Pending == nil || insp.Pending.Kind != HitlPermission {
t.Fatalf("permission hold changed: %s %+v", insp.Status, insp.Pending)
}
saw := false
for _, k := range kinds {
if k == "wakeup_held" {
saw = true
}
}
if !saw {
t.Fatalf("expected wakeup_held event, got %v", kinds)
}
}
func TestParallelGroupSecondStepApprovalHolds(t *testing.T) {
steps := baseSteps()
steps[0].Parallel = true
steps[1].Parallel = true
steps[1].RequiresApproval = true
eng, _ := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
res, err := eng.Run(context.Background(), Goal{Text: "并行组第二步要审批"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunAwaitingConfirmation || res.Pending == nil || res.Pending.StepID != "s2" {
t.Fatalf("group should hold on the second step, got %s %+v", res.Status, res.Pending)
}
if _, err := os.Stat(filepath.Join(res.WorkDir, "tasks", "01_researcher", "notes.md")); !os.IsNotExist(err) {
t.Fatalf("parallel group ran before approval: %v", err)
}
}
func TestParallelGroupSecondStepPermissionHolds(t *testing.T) {
dir := t.TempDir()
reg := NewRoleRegistry()
for _, spec := range []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "script"},
{Role: "writer", Name: "撰写", Backend: "script", BashPermission: BashPermissionAsk},
{Role: "reviewer", Name: "审核", Backend: "script"},
} {
if err := reg.Register(spec); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: approvalScript()})
steps := baseSteps()
steps[0].Parallel = true
steps[1].Parallel = true
eng, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(steps...), Backends: backends, Sink: NopSink{},
})
if err != nil {
t.Fatal(err)
}
var res *RunResult
res, err = eng.Run(context.Background(), Goal{Text: "并行组第二步要授权"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunWaitingHuman || res.Pending == nil || res.Pending.Kind != HitlPermission || res.Pending.StepID != "s2" {
t.Fatalf("group should hold on writer permission, got %s %+v", res.Status, res.Pending)
}
if _, err := os.Stat(filepath.Join(res.WorkDir, "tasks", "01_researcher", "notes.md")); !os.IsNotExist(err) {
t.Fatalf("parallel group ran before permission: %v", err)
}
}
func TestFreeTextKeepsChoiceHold(t *testing.T) {
for _, kind := range []HumanRequestKind{HitlConfirmation, HitlPermission, HitlQualityReview} {
t.Run(string(kind), func(t *testing.T) {
steps := baseSteps()
steps[0].RequiresApproval = true
eng, dir := newTestEngine(t, mockScriptPlanner(steps...), approvalScript(), nil)
res, err := eng.Run(context.Background(), Goal{Text: "自由文本不能取消"}, RunOptions{})
if err != nil || res.Pending == nil {
t.Fatalf("setup: %v %+v", err, res)
}
work := filepath.Join(dir, "runs", res.RunID)
gate, err := loadGate(work)
if err != nil || gate == nil {
t.Fatal(err)
}
id := gate.Request.ID
gate.Request.Kind = kind
if err := writeJSON(filepath.Join(work, "pending.json"), gate); err != nil {
t.Fatal(err)
}
res, err = eng.Respond(context.Background(), res.RunID, RespondDecision{Answer: "补充一句"})
if err != nil {
t.Fatal(err)
}
if res.Pending == nil || res.Pending.ID != id || res.Pending.Status != "pending" {
t.Fatalf("hold was cleared: %+v", res.Pending)
}
data, err := os.ReadFile(filepath.Join(work, "steering.json"))
if err != nil || !strings.Contains(string(data), "补充一句") {
t.Fatalf("steering not recorded: %v %s", err, data)
}
})
}
}
func TestFailedDecisionRestoresPending(t *testing.T) {
steps := gapSteps()
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},
}
eng, _ := newTestEngine(t, mockScriptPlanner(steps...), script, nil)
res, err := eng.Run(context.Background(), Goal{Text: "错误选择后仍可回应"}, RunOptions{})
if err != nil || res.Pending == nil {
t.Fatalf("setup: %v %+v", err, res)
}
if _, err := eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "nope"}); err == nil {
t.Fatal("unknown choice should fail")
}
res, err = eng.Respond(context.Background(), res.RunID, RespondDecision{Choice: "resolved"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("restored hold should still be answerable, got %s %s", res.Status, res.Error)
}
}