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
83 lines
2.8 KiB
Go
83 lines
2.8 KiB
Go
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)
|
||
}
|
||
}
|