Files
weichen c88100967d
ci / vet-and-test (pull_request) Successful in 34s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Add review driver, handoff primitive, and group-chat driver (Q2 WP3)
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
2026-10-08 01:11:51 +00:00

367 lines
11 KiB
Go
Raw Permalink 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"
"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}
}