Why: Q2 acceptance 2 requires the squad review topology to be equivalent to today's backend runReview flow (parallel finders, per-finding verification, drop unverified); handoff and group_chat complete 11 of 12 builtin topologies. Evidence: wp3_conformance_test.go mirrors backend review_test.go assertions (true finder concurrency via release gate, verify calls = 5 unique after dedupe, only PASS kept, dropped-count line, severity order); handoff records ownership transfer, target receives filtered history, finalize takes chain-tail output, unknown target role fails closed; group-chat rotate / close-marker / moderator conformance with budget stop and fail-closed unknown nominee. Full suite green with -race. Impact: review/group_chat drivers lit in the topology registry (task_board remains fail-closed until WP4); RunState gains Handoffs (persisted); RunOptions gains GroupChat*/Review* knobs; runOneStep validates handoff targets and injects handoff context; finalize prefers chain-tail deliverable. Parse/dedupe/verdict/severity/report formats ported verbatim from backend internal/runtime/review.go for byte-level report equivalence. Refs: Q2 plan WP3 (2026-10-07-agent-stack-q2q3-plans.md); task card .task/feature-agent-stack-q2.md
367 lines
11 KiB
Go
367 lines
11 KiB
Go
package squad
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// review.go — review 拓扑驱动(Q2 WP3,验收二)。
|
||
// 与 backend internal/runtime/review.go 的 runReview 等价:
|
||
// 并行 N finder(semaphore 3)→ 宽松 JSON 解析(首 `[` 末 `]`,丢空 issue)
|
||
// → 按 (location,issue) 去重、上限 10 → 每条独立 verifier(尾行
|
||
// VERDICT: PASS/FAIL/PARTIAL)→ 只留 PASS(fail-closed)→ severity 稳定排序
|
||
// → 报告恒带「已丢弃 N 条未验证」。解析/去重/判定/排序/文案与 backend 同一
|
||
// 格式串(计划 §一.5:以报告字节形态为等价判据)。
|
||
//
|
||
// squad 侧接线:finder = 计划步骤(各自的 findings.json 为目标产物,经
|
||
// runOneStep 走完整工作区/事件/HITL 机制);verifier = 驱动内按 verify 角色
|
||
// 直接 Execute 的核实调用(对应 backend 的 spawn(verifyProfileID,…)),
|
||
// 每次核实落盘 reviews/verify_NN/ 留痕。
|
||
|
||
func init() {
|
||
_ = RegisterTopologyDriver(TopologyReview)
|
||
}
|
||
|
||
// reviewFinding 与 backend reviewFinding 同构(JSON 键一致)。
|
||
type reviewFinding struct {
|
||
Location string `json:"location"`
|
||
Severity string `json:"severity"`
|
||
Issue string `json:"issue"`
|
||
Evidence string `json:"evidence"`
|
||
}
|
||
|
||
// reviewVerbatimTask 是核实调用的任务文案(与 backend runReview 同串)。
|
||
const reviewVerbatimTask = "独立核实这条问题是否成立。材料如下:\n%s\n\n问题:%s\n只在结尾给出一行 VERDICT: PASS、FAIL 或 PARTIAL。"
|
||
|
||
// parseFindings 宽松解析:首 `[` 末 `]`,丢空 issue(backend 同款)。
|
||
func parseFindings(text string) []reviewFinding {
|
||
start := strings.Index(text, "[")
|
||
end := strings.LastIndex(text, "]")
|
||
if start < 0 || end <= start {
|
||
return nil
|
||
}
|
||
var out []reviewFinding
|
||
if err := json.Unmarshal([]byte(text[start:end+1]), &out); err != nil {
|
||
return nil
|
||
}
|
||
var kept []reviewFinding
|
||
for _, f := range out {
|
||
if strings.TrimSpace(f.Issue) == "" {
|
||
continue
|
||
}
|
||
kept = append(kept, f)
|
||
}
|
||
return kept
|
||
}
|
||
|
||
// dedupeFindings 按 (location,issue) 去重,上限 limit(backend 同款)。
|
||
func dedupeFindings(groups [][]reviewFinding, limit int) []reviewFinding {
|
||
seen := map[string]bool{}
|
||
var out []reviewFinding
|
||
for _, group := range groups {
|
||
for _, f := range group {
|
||
key := strings.TrimSpace(f.Location) + "\n" + strings.TrimSpace(f.Issue)
|
||
if seen[key] {
|
||
continue
|
||
}
|
||
seen[key] = true
|
||
out = append(out, f)
|
||
if len(out) >= limit {
|
||
return out
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// reviewVerdict 尾行 VERDICT 判定(backend 同款:PASS/FAIL/PARTIAL,
|
||
// 解析不出 = 空串 = 不通过)。
|
||
func reviewVerdict(text string) string {
|
||
lines := strings.Split(text, "\n")
|
||
for i := len(lines) - 1; i >= 0; i-- {
|
||
line := strings.TrimSpace(lines[i])
|
||
if line == "" || !strings.HasPrefix(strings.ToUpper(line), "VERDICT:") {
|
||
continue
|
||
}
|
||
v := strings.TrimSpace(line[len("VERDICT:"):])
|
||
v = strings.ToUpper(v)
|
||
switch {
|
||
case strings.HasPrefix(v, "PASS"):
|
||
return "PASS"
|
||
case strings.HasPrefix(v, "FAIL"):
|
||
return "FAIL"
|
||
case strings.HasPrefix(v, "PARTIAL"):
|
||
return "PARTIAL"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// severityRank 稳定排序键(backend 同款)。
|
||
func severityRank(s string) int {
|
||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||
case "critical":
|
||
return 0
|
||
case "high":
|
||
return 1
|
||
case "medium":
|
||
return 2
|
||
case "low":
|
||
return 3
|
||
default:
|
||
return 4
|
||
}
|
||
}
|
||
|
||
// reviewParallel semaphore-3 并行(backend parallel 同款)。
|
||
func reviewParallel(n int, fn func(i int)) {
|
||
if n <= 0 {
|
||
return
|
||
}
|
||
sem := make(chan struct{}, 3)
|
||
var wg sync.WaitGroup
|
||
for i := 0; i < n; i++ {
|
||
wg.Add(1)
|
||
go func(i int) {
|
||
defer wg.Done()
|
||
sem <- struct{}{}
|
||
defer func() { <-sem }()
|
||
fn(i)
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
}
|
||
|
||
// runReviewDriver review 拓扑入口。finder 阶段复用计划步骤(可挂起可恢复:
|
||
// 重入时已完成步骤不重跑,findings.json 从各自子工作区读取)。
|
||
func (e *SquadEngine) runReviewDriver(ctx context.Context, st *RunState) (*RunResult, error) {
|
||
drv := &flowDriver{opts: e.deps}
|
||
steps := st.Plan.Workflow.Steps
|
||
|
||
// verify 角色先行解析(fail-fast:缺角色不浪费 finder 轮次)。
|
||
verifyRoleName := orDefault(st.Meta.Metadata["review_verify_role"], "verifier")
|
||
verifyRole, ok := findAgent(st.Plan.Squad.Agents, verifyRoleName)
|
||
if !ok {
|
||
if r, exists := e.deps.Registry.Get(verifyRoleName); exists {
|
||
verifyRole, ok = r, true
|
||
}
|
||
}
|
||
if !ok {
|
||
return e.driverFail(st, fmt.Sprintf("review: verify 角色 %q 不在小队内(fail-closed)", verifyRoleName)), nil
|
||
}
|
||
verifyBackend, _, rerr := e.deps.Backends.Resolve(verifyRole, st.Meta.Backend)
|
||
if rerr != nil {
|
||
return e.driverFail(st, "review: verify 后端解析失败: "+rerr.Error()), nil
|
||
}
|
||
|
||
// 1) finder 阶段:先全组过门(任一要挂起则整组不执行,与 advance 并行组
|
||
// 同款语义),再 semaphore-3 并行执行。
|
||
var finderIdx []int
|
||
for i := range steps {
|
||
if steps[i].Status == "pending" || steps[i].Status == "running" {
|
||
finderIdx = append(finderIdx, i)
|
||
}
|
||
}
|
||
for _, idx := range finderIdx {
|
||
out, gate, err := drv.gateBeforeRun(ctx, st, idx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if gate != nil {
|
||
if out == nil {
|
||
out = heldResult(st, gate)
|
||
}
|
||
return out, nil
|
||
}
|
||
if out != nil {
|
||
return out, nil
|
||
}
|
||
}
|
||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("review:%d 个 finder 并行找问题", len(finderIdx))})
|
||
var holdGate *HitlGate
|
||
var holdOut *RunResult
|
||
var fatal *RunResult
|
||
finderFailed := 0
|
||
var fmu sync.Mutex
|
||
reviewParallel(len(finderIdx), func(k int) {
|
||
idx := finderIdx[k]
|
||
out, gate, err := drv.runOneStep(ctx, st, idx)
|
||
fmu.Lock()
|
||
defer fmu.Unlock()
|
||
if err != nil {
|
||
if fatal == nil {
|
||
fatal = &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||
Error: err.Error(), WorkDir: st.Meta.WorkDir}
|
||
}
|
||
return
|
||
}
|
||
if gate != nil && holdGate == nil {
|
||
holdGate = gate
|
||
holdOut = out
|
||
return
|
||
}
|
||
if out != nil && out.Status == RunFailed && fatal == nil {
|
||
fatal = out // 关键 finder 失败:fail-closed 传播
|
||
return
|
||
}
|
||
if st.Plan.Workflow.Steps[idx].Status == "failed" {
|
||
finderFailed++ // 非关键 finder 失败:宽容(backend spawn err 同款),收口降级
|
||
}
|
||
})
|
||
if fatal != nil {
|
||
return fatal, nil
|
||
}
|
||
if holdGate != nil {
|
||
if holdOut == nil {
|
||
holdOut = heldResult(st, holdGate)
|
||
}
|
||
return holdOut, nil
|
||
}
|
||
|
||
// 2) 收集各 finder 的 findings.json(宽松解析;缺文件/坏 JSON = 该 finder
|
||
// 零发现,与 backend 同款)。
|
||
steps = st.Plan.Workflow.Steps
|
||
groups := make([][]reviewFinding, 0, len(steps))
|
||
for i := range steps {
|
||
if steps[i].Status != "completed" {
|
||
continue
|
||
}
|
||
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(steps[i].SubWorkspace))
|
||
data, err := os.ReadFile(filepath.Join(subDir, "findings.json"))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
groups = append(groups, parseFindings(string(data)))
|
||
}
|
||
unique := dedupeFindings(groups, 10)
|
||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("review:去重后 %d 条,逐条独立核实", len(unique))})
|
||
|
||
// 3) 逐条 verify(semaphore-3 并行;fail-closed:核实调用失败/无判定 = 丢弃)。
|
||
target := reviewTargetMaterial(st)
|
||
kept := make([]reviewFinding, len(unique))
|
||
pass := make([]bool, len(unique))
|
||
reviewParallel(len(unique), func(i int) {
|
||
f := unique[i]
|
||
body, _ := json.Marshal(f)
|
||
task := fmt.Sprintf(reviewVerbatimTask, target, body)
|
||
vDir := filepath.Join(st.Meta.WorkDir, "reviews", fmt.Sprintf("verify_%02d", i+1))
|
||
if err := os.MkdirAll(vDir, 0o755); err != nil {
|
||
return
|
||
}
|
||
instructions := ""
|
||
if e.deps.Workspaces != nil {
|
||
instructions = e.deps.Workspaces.ResolveInstructions(verifyRole)
|
||
}
|
||
res, err := verifyBackend.Execute(ctx, ExecRequest{
|
||
WorkDir: vDir,
|
||
Prompt: task,
|
||
Instructions: instructions,
|
||
Targets: []string{"verdict.md"},
|
||
Timeout: e.deps.StepTimeout,
|
||
SessionScope: st.Meta.ID + "/verify_" + fmt.Sprintf("%02d", i+1),
|
||
})
|
||
if err != nil || res == nil {
|
||
return
|
||
}
|
||
text := string(res.Patches["verdict.md"]) + "\n" + res.Logs
|
||
if data, rerr2 := os.ReadFile(filepath.Join(vDir, "verdict.md")); rerr2 == nil {
|
||
text = string(data) + "\n" + text
|
||
}
|
||
if reviewVerdict(text) == "PASS" {
|
||
kept[i] = f
|
||
pass[i] = true
|
||
}
|
||
})
|
||
var verified []reviewFinding
|
||
for i, ok := range pass {
|
||
if ok {
|
||
verified = append(verified, kept[i])
|
||
}
|
||
}
|
||
|
||
// 4) severity 稳定排序 + 等价报告(backend 同格式串)。
|
||
sort.SliceStable(verified, func(i, j int) bool {
|
||
return severityRank(verified[i].Severity) < severityRank(verified[j].Severity)
|
||
})
|
||
dropped := len(unique) - len(verified)
|
||
var b strings.Builder
|
||
if len(verified) == 0 {
|
||
b.WriteString("没有验证通过的问题。\n")
|
||
}
|
||
for _, f := range verified {
|
||
fmt.Fprintf(&b, "- [%s] %s:%s\n 证据:%s\n",
|
||
orDefault(f.Severity, "medium"), orDefault(f.Location, "未定位"), f.Issue, f.Evidence)
|
||
}
|
||
fmt.Fprintf(&b, "已丢弃 %d 条未验证\n", dropped)
|
||
report := b.String()
|
||
|
||
// 5) 收口:报告落盘(reviews/report.md + output.md 交付物约定),
|
||
// finder 有失败则降级(诚实信号),否则完成。
|
||
reviewsDir := filepath.Join(st.Meta.WorkDir, "reviews")
|
||
_ = os.MkdirAll(reviewsDir, 0o755)
|
||
_ = os.WriteFile(filepath.Join(reviewsDir, "report.md"), []byte(report), 0o644)
|
||
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, "output.md"), []byte(report), 0o644)
|
||
st.mu.Lock()
|
||
if st.BB.Fields == nil {
|
||
st.BB.Fields = map[string]string{}
|
||
}
|
||
st.BB.Fields["review_verified"] = fmt.Sprintf("%d", len(verified))
|
||
st.BB.Fields["review_dropped"] = fmt.Sprintf("%d", dropped)
|
||
wf := st.Workflow()
|
||
status := RunCompleted
|
||
if finderFailed > 0 {
|
||
status = RunDegraded
|
||
}
|
||
wf.Status = string(status)
|
||
st.setWorkflow(wf)
|
||
st.Meta.Status = string(status)
|
||
now := time.Now()
|
||
st.Meta.CompletedAt = &now
|
||
_ = st.save()
|
||
st.mu.Unlock()
|
||
summary := fmt.Sprintf("review:保留 %d 条 / 丢弃 %d 条未验证", len(verified), dropped)
|
||
if finderFailed > 0 {
|
||
summary += fmt.Sprintf("(%d 个 finder 失败,降级)", finderFailed)
|
||
}
|
||
drv.emit(st, Event{Type: EvPhase, Text: summary})
|
||
return &RunResult{RunID: st.Meta.ID, Status: status, Plan: planPtr(st),
|
||
Deliverable: "output.md", Summary: summary, WorkDir: st.Meta.WorkDir}, nil
|
||
}
|
||
|
||
// reviewTargetMaterial 取被评审材料:元数据 review_target 优先,
|
||
// 其次运行目标(context/goal.md)。
|
||
func reviewTargetMaterial(st *RunState) string {
|
||
if v := strings.TrimSpace(st.Meta.Metadata["review_target"]); v != "" {
|
||
return v
|
||
}
|
||
if data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "context", "goal.md")); err == nil {
|
||
return string(data)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// driverFail 驱动级 fail-closed 收口(与 leaderFail 同款语义)。
|
||
func (e *SquadEngine) driverFail(st *RunState, reason string) *RunResult {
|
||
now := time.Now()
|
||
st.mu.Lock()
|
||
st.Meta.Status = string(RunFailed)
|
||
st.Meta.CompletedAt = &now
|
||
wf := st.Workflow()
|
||
wf.Status = "failed"
|
||
st.setWorkflow(wf)
|
||
_ = st.save()
|
||
st.mu.Unlock()
|
||
(&flowDriver{opts: e.deps}).emit(st, Event{Type: EvPhase, Text: reason})
|
||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||
Error: reason, WorkDir: st.Meta.WorkDir}
|
||
}
|