Files
Agent-Squad/squad/termination_test.go
T
weichen 90ac98ea65
ci / vet-and-test (pull_request) Successful in 23s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Converge run persistence on SQLite through the Store port
Why: Q1 WP5 - platform's SQLite becomes an implementation of the
squad Store port so run state and orchestration state live in one
database, and platform's in-process execution goes through the
Executor port instead of bypassing it (X6).

Evidence: all five modules go vet clean and go test ok, including
engine-on-SQLite (state in DB, no run-level JSON files, second
engine recovers from the same DB), SQLiteStore round trip, and the
full squad compatibility net.

Impact: runState/hitlGate exported so the port is implementable;
Options.Store wires into engineDeps; leader persistence and
checkpoints route through the port; PrepareWorkspace no longer
writes run-level JSON (st.save owns it); platform RunInProcess
takes squad.Executor (callers updated); serve mounts the engine on
platform's RunStore - single orchestration.db for both.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md Q1 WP5
2026-10-07 10:29:47 +00:00

83 lines
2.8 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"
"strings"
"testing"
"time"
)
// TestTerminationAndOr:And 全中才成立、Or 任一中即成立(WP6 原语)。
func TestTerminationAndOr(t *testing.T) {
c := Counters{LeaderTurns: 8, RepairSpent: 2, WakeupFires: 0}
or := Or(MaxTurnsC(8), WakeupsC(1))
if _, hit := or(c, time.Now()); !hit {
t.Fatal("or should hit on turns")
}
and := And(MaxTurnsC(8), WakeupsC(1))
if _, hit := and(c, time.Now()); hit {
t.Fatal("and should not hit with zero wakeups")
}
and2 := And(MaxTurnsC(8), RepairBudgetC(1))
if _, hit := and2(c, time.Now()); !hit {
t.Fatal("and should hit when all met")
}
dl := DeadlineC()
past := Counters{Deadline: time.Now().Add(-time.Minute)}
if _, hit := dl(past, time.Now()); !hit {
t.Fatal("deadline should hit when past")
}
}
// TestRepairGrantCap:人工 retry 累计追加有上限(Q26)。
func TestRepairGrantCap(t *testing.T) {
engine, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}, NopSink{})
res, err := engine.Run(context.Background(), Goal{Text: "先跑完再验", HitlMode: HitlAuto}, RunOptions{})
if err != nil || res.Status != RunCompleted {
t.Fatalf("setup run: %v %+v", err, res)
}
// 预置:修复授予已达上限 + 一个真实的质检挂起门(与 finalize 的形态一致)。
st, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
st.RepairBudgetGranted = engine.terminationOf(st).MaxRepairGranted
st.Meta.CompletedAt = nil
st.Meta.Status = string(RunQualityReview)
req := &HumanRequest{
ID: newRequestID(), RunID: st.Meta.ID, Kind: HitlQualityReview, Severity: SeverityActionRequired,
Question: "交付物质检未通过且自动修复预算已用尽,请人工裁决:",
Options: []HumanOption{
{Value: "approve", Label: "人工放行(记录人工通过)"},
{Value: "retry", Label: "再修复一次(重跑交付步骤)"},
{Value: "abort", Label: "判定失败"},
},
CreatedAt: time.Now(), Status: "pending",
}
if err := st.saveGate(&HitlGate{Request: *req, PrevStatus: RunExecuting}); err != nil {
t.Fatal(err)
}
if err := st.save(); err != nil {
t.Fatal(err)
}
// 人工 retry 应被拒绝而不是再追加。
out, err := engine.Respond(context.Background(), res.RunID, RespondDecision{Choice: "retry"})
if err != nil {
t.Fatal(err)
}
if out == nil || !strings.Contains(out.Error, "grants exhausted") {
t.Fatalf("expected grant-cap failure, got %+v err=%v", out, err)
}
st2, err := loadRunState(res.WorkDir)
if err != nil {
t.Fatal(err)
}
if st2.RepairBudgetGranted != engine.terminationOf(st2).MaxRepairGranted {
t.Fatalf("granted changed after cap: %d", st2.RepairBudgetGranted)
}
}