merge: candidate backlog-triage-p10-2026-09-25 into dev
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# P10 backlog 价值评估与择优实施 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
总控 P10:两份 backlog(p6-backlog + rdev-phase2)逐项评估定做/不做。评估结论:B5(报价校验复核)与 A5(小队非关键降级)本批实施;A6/D5/R1/R4–R7 登记不做(依据见 [任务文](../ops/short-term/2026-09-25-p10-backlog-triage.md))。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
1. B5:`quote` 输入校验 fail-closed(空名/空表/负值/NaN/Inf);复核不变式(行合计=总额、buffer/margin/tax 费率自洽)。
|
||||
2. A5:非关键步骤失败转 `degraded` 继续;关键步骤失败行为不变。
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- OS:Linux;Go 1.27.1;worktree `.worktrees/backlog-triage-p10-2026-09-25`(base dev `4bdbb0da`)。
|
||||
- 层级:工程层单测(真实 WorkspaceEngine/PrepareWorkspace + fail-first backend)。
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
| # | 用例 | 预期 |
|
||||
|---|---|---|
|
||||
| T1 | `TestValidateInput`(7 组畸形输入) | 全部拒绝;合法输入通过 |
|
||||
| T2 | `TestCalculateAndFillRejectsBadInput` | NaN hours 返回 error、无文件产出 |
|
||||
| T3 | `TestReviewSummaryRateInvariants` | 自洽数据通过;buffer 失衡被拒 |
|
||||
| T4 | `TestNonCriticalStepDegradesAndContinues` | Success=true + DegradedSteps=[step_01] + wf=degraded + step_02 completed |
|
||||
| T5 | `TestCriticalStepFailureStillAborts` | err 非 nil + wf=failed + 后续步骤不跑 |
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
- T1:PASS(7/7 畸形拒绝 + 合法通过)
|
||||
- T2:PASS
|
||||
- T3:PASS(初版测试数据 TotalPrice 与行合计不一致被正确拦截——复核器按预期工作;修正测试夹具后通过)
|
||||
- T4:PASS(backend.calls=2 证明降级后续跑;`step_degraded` 治理审计落 `gov_audit`)
|
||||
- T5:PASS(backend.calls=1,默认关键语义零变更)
|
||||
- 全量:`go test -short ./...` 无 FAIL;`arch-check --mode block` production gate passed
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
| 套件 | 结果 |
|
||||
|---|---|
|
||||
| `go test ./internal/quote/` | PASS(4 用例含既有 TestCDOQuoteGeneration) |
|
||||
| `go test ./internal/squad/` | PASS(含 2 新用例 + 既有全量) |
|
||||
| `go test -short ./...`(全仓) | PASS(0 FAIL) |
|
||||
| `arch-check --mode block` | PASS |
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
- B5/A5 达到交付终点;A6(已修复)、D5(维持共享)、R1(另立任务)、R4/R5/R6/R7(前置未满足/已落地/低优)评估结论入档。
|
||||
- 后续:R1 N/A 生成模式若启动需跨 manifest/bizdev/squad/前端四点联动独立认领;squad 并行组降级(本批只覆盖串行路径)可作 A5 二期。
|
||||
@@ -25,7 +25,76 @@ const (
|
||||
hoursPerPM = 160.0
|
||||
)
|
||||
|
||||
// ValidateInput B5 输入范围校验:畸形输入 fail-closed,不产出报价文件。
|
||||
func ValidateInput(in Input) error {
|
||||
if in.CustomerName == "" || in.ProductName == "" {
|
||||
return fmt.Errorf("quote: customer_name and product_name are required")
|
||||
}
|
||||
if len(in.Hours) == 0 {
|
||||
return fmt.Errorf("quote: hours must not be empty")
|
||||
}
|
||||
for role, h := range in.Hours {
|
||||
if isInvalidFloat(h) || h < 0 {
|
||||
return fmt.Errorf("quote: hours[%s]=%v must be a non-negative finite number", role, h)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isInvalidFloat(v float64) bool {
|
||||
return v != v || v > 1.7976931348623157e+307*1.0000000000000002 || v < -1.7976931348623157e+307*1.0000000000000002
|
||||
}
|
||||
|
||||
// reviewSummary B5 轻量复核:行合计与费率组合自洽性不变式,失衡返回错误。
|
||||
func reviewSummary(summary *Summary) error {
|
||||
var sumPY, sumCost, sumBuf, sumMar, sumTax, sumPrice float64
|
||||
for _, l := range summary.Lines {
|
||||
if l.Cost+bufferFudge(l.Cost) < 0 { // 防御:非有限值已被 ValidateInput 拦截,此处冗余校验
|
||||
return fmt.Errorf("quote: line %s cost invalid", l.Code)
|
||||
}
|
||||
sumPY += l.PersonYear
|
||||
sumCost += l.Cost
|
||||
sumBuf += l.Buffer
|
||||
sumMar += l.Margin
|
||||
sumTax += l.Tax
|
||||
sumPrice += l.Price
|
||||
}
|
||||
if !approxEqual(sumPrice, summary.TotalPrice) {
|
||||
return fmt.Errorf("quote: line price sum %.2f != total %.2f", sumPrice, summary.TotalPrice)
|
||||
}
|
||||
if !approxEqual(sumBuf, sumCost*bufferRate) {
|
||||
return fmt.Errorf("quote: buffer sum %.2f != cost*%.0f%%", sumBuf, bufferRate*100)
|
||||
}
|
||||
if !approxEqual(sumMar, (sumCost+sumBuf)*marginRate) {
|
||||
return fmt.Errorf("quote: margin sum %.2f != (cost+buffer)*%.0f%%", sumMar, marginRate*100)
|
||||
}
|
||||
if !approxEqual(sumTax, (sumCost+sumBuf+sumMar)*taxRate) {
|
||||
return fmt.Errorf("quote: tax sum %.2f != (cost+buffer+margin)*%.0f%%", sumTax, taxRate*100)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bufferFudge(v float64) float64 { return 0 }
|
||||
|
||||
func approxEqual(a, b float64) bool {
|
||||
diff := a - b
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
scale := b
|
||||
if scale < 0 {
|
||||
scale = -scale
|
||||
}
|
||||
if scale < 1 {
|
||||
scale = 1
|
||||
}
|
||||
return diff <= scale*1e-6
|
||||
}
|
||||
|
||||
func CalculateAndFill(in Input, templateXlsx string, outPath string) (*Summary, error) {
|
||||
if err := ValidateInput(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := os.Stat(templateXlsx); err != nil {
|
||||
wd, _ := os.Getwd()
|
||||
dir := wd
|
||||
@@ -74,6 +143,11 @@ func CalculateAndFill(in Input, templateXlsx string, outPath string) (*Summary,
|
||||
},
|
||||
}
|
||||
|
||||
// B5 轻量复核:不变式失衡即失败,不落盘 Excel。
|
||||
if err := reviewSummary(summary); err != nil {
|
||||
return nil, fmt.Errorf("quote review failed: %w", err)
|
||||
}
|
||||
|
||||
sheet := "报价模型"
|
||||
if idx, _ := f.GetSheetIndex(sheet); idx != -1 {
|
||||
_ = f.SetCellValue(sheet, "B3", in.CustomerName)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package quote
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validInput() Input {
|
||||
return Input{
|
||||
CustomerName: "验收医院",
|
||||
ProductName: "监护仪 X1",
|
||||
Hours: map[string]float64{
|
||||
"结构工程师": 320,
|
||||
"软件工程师": 480,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// B5:输入范围校验 fail-closed。
|
||||
func TestValidateInput(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(in Input) Input
|
||||
}{
|
||||
{"empty customer", func(in Input) Input { in.CustomerName = ""; return in }},
|
||||
{"empty product", func(in Input) Input { in.ProductName = ""; return in }},
|
||||
{"empty hours", func(in Input) Input { in.Hours = map[string]float64{}; return in }},
|
||||
{"nil hours", func(in Input) Input { in.Hours = nil; return in }},
|
||||
{"negative hours", func(in Input) Input { in.Hours["软件工程师"] = -8; return in }},
|
||||
{"nan hours", func(in Input) Input { in.Hours["软件工程师"] = math.NaN(); return in }},
|
||||
{"inf hours", func(in Input) Input { in.Hours["软件工程师"] = math.Inf(1); return in }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if err := ValidateInput(tc.mut(validInput())); err == nil {
|
||||
t.Errorf("%s: expected error", tc.name)
|
||||
}
|
||||
}
|
||||
if err := ValidateInput(validInput()); err != nil {
|
||||
t.Fatalf("valid input rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// B5:畸形输入不产出文件。
|
||||
func TestCalculateAndFillRejectsBadInput(t *testing.T) {
|
||||
in := validInput()
|
||||
in.Hours["项目经理"] = math.NaN()
|
||||
out := filepath.Join(t.TempDir(), "out.xlsx")
|
||||
if _, err := CalculateAndFill(in, "../../templates/xlsx/cdo_quote.xlsx", out); err == nil {
|
||||
t.Fatal("NaN hours must fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
// B5:复核不变式——费率组合自洽。
|
||||
func TestReviewSummaryRateInvariants(t *testing.T) {
|
||||
summary := &Summary{
|
||||
TotalPrice: 179.76,
|
||||
Lines: []SummaryLine{
|
||||
{Code: "A", Cost: 100, Buffer: 20, Margin: 48, Tax: 11.76, Price: 179.76},
|
||||
},
|
||||
}
|
||||
// buffer 20% of cost=100 → 20 ✓;margin 40% of (100+20)=48 ✓;tax 7% of (168)=11.76 ✓;price=179.76 ✓
|
||||
if err := reviewSummary(summary); err != nil {
|
||||
t.Fatalf("consistent summary rejected: %v", err)
|
||||
}
|
||||
summary.Lines[0].Buffer = 25 // 打破 buffer 不变式
|
||||
if err := reviewSummary(summary); err == nil {
|
||||
t.Fatal("broken buffer invariant must fail review")
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,16 @@ type StepSpec struct {
|
||||
Frozen bool `json:"frozen,omitempty"` // 归档锁定:completed 且已生成步骤级 manifest 后置 true
|
||||
ArchivedAt *time.Time `json:"archived_at,omitempty"` // 归档时间
|
||||
ReopenCount int `json:"reopen_count,omitempty"` // 显式重开次数(审计)
|
||||
|
||||
// ---- A5 非关键步骤降级 ----
|
||||
// nil(默认)= 关键步骤:失败即终止整个工作流;
|
||||
// false = 非关键步骤:失败转 degraded 继续,收口时 wf.Status=degraded。
|
||||
Critical *bool `json:"critical,omitempty"`
|
||||
}
|
||||
|
||||
// IsCritical 报告步骤是否关键(nil 视为关键,保持既有行为)。
|
||||
func (s StepSpec) IsCritical() bool {
|
||||
return s.Critical == nil || *s.Critical
|
||||
}
|
||||
|
||||
// WorkflowPlan 动态任务编排清单
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package squad_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/agent"
|
||||
"chat-agent-system/internal/squad"
|
||||
)
|
||||
|
||||
// failFirstBackend 第一步失败、其余成功。
|
||||
type failFirstBackend struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (b *failFirstBackend) Name() string { return "fail-first" }
|
||||
|
||||
func (b *failFirstBackend) Execute(ctx context.Context, req agent.Request) (*agent.Result, error) {
|
||||
b.calls++
|
||||
if b.calls == 1 {
|
||||
return &agent.Result{Success: false, ErrorReason: "boom"}, nil
|
||||
}
|
||||
body := []byte("# core deliverable\n")
|
||||
_ = os.WriteFile(filepath.Join(req.WorkDir, "output.md"), body, 0o644)
|
||||
return &agent.Result{Success: true, Patches: map[string][]byte{"output.md": body}}, nil
|
||||
}
|
||||
|
||||
func prepareDegradeWS(t *testing.T) (string, *squad.WorkflowDriver) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
engine := squad.NewWorkspaceEngine()
|
||||
ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{
|
||||
WorkDir: tmpDir,
|
||||
TaskKey: "degrade_test",
|
||||
StageTitle: "降级验收",
|
||||
ProjectName: "degrade-audit",
|
||||
Backend: "fail-first",
|
||||
WorkflowSteps: []squad.StepSpec{
|
||||
{ID: "step_01_optional", Role: "writer", Title: "可选分析", SubWorkspace: "tasks/01_optional", Mode: "execute"},
|
||||
{ID: "step_02_core", Role: "writer", Title: "核心成文", SubWorkspace: "tasks/02_core", Mode: "execute"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareWorkspace: %v", err)
|
||||
}
|
||||
return ws.WorkDir, squad.NewWorkflowDriver(engine)
|
||||
}
|
||||
|
||||
// A5:非关键步骤失败 → degraded 记录、后续步骤继续、整体 degraded + DegradedSteps。
|
||||
func TestNonCriticalStepDegradesAndContinues(t *testing.T) {
|
||||
wsDir, driver := prepareDegradeWS(t)
|
||||
wf, err := driver.LoadWorkflow(wsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonCritical := false
|
||||
for i := range wf.Steps {
|
||||
if wf.Steps[i].ID == "step_01_optional" {
|
||||
wf.Steps[i].Critical = &nonCritical
|
||||
}
|
||||
}
|
||||
_ = driver.SaveWorkflow(wsDir, wf)
|
||||
|
||||
backend := &failFirstBackend{}
|
||||
res, err := driver.Execute(context.Background(), wsDir, backend, squad.ExecuteOptions{Timeout: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("degraded run must not hard-fail: %v", err)
|
||||
}
|
||||
if !res.Success {
|
||||
t.Fatalf("degraded run Success must be true, reason=%s", res.ErrorReason)
|
||||
}
|
||||
if len(res.DegradedSteps) != 1 || res.DegradedSteps[0] != "step_01_optional" {
|
||||
t.Fatalf("DegradedSteps = %v", res.DegradedSteps)
|
||||
}
|
||||
after, _ := driver.LoadWorkflow(wsDir)
|
||||
if after.Status != "degraded" {
|
||||
t.Fatalf("wf.Status = %s, want degraded", after.Status)
|
||||
}
|
||||
found := false
|
||||
for _, s := range after.Steps {
|
||||
if s.ID == "step_01_optional" {
|
||||
found = true
|
||||
if s.Status != "degraded" || !strings.Contains(s.Error, "boom") {
|
||||
t.Fatalf("step_01 = %s err=%s", s.Status, s.Error)
|
||||
}
|
||||
}
|
||||
if s.ID == "step_02_core" && s.Status != "completed" {
|
||||
t.Fatalf("step_02 must complete, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("step_01_optional missing")
|
||||
}
|
||||
if backend.calls != 2 {
|
||||
t.Fatalf("second step must run, calls=%d", backend.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A5:关键步骤(默认 nil)失败行为不变——立即终止、不跑后续。
|
||||
func TestCriticalStepFailureStillAborts(t *testing.T) {
|
||||
wsDir, driver := prepareDegradeWS(t)
|
||||
backend := &failFirstBackend{}
|
||||
res, err := driver.Execute(context.Background(), wsDir, backend, squad.ExecuteOptions{Timeout: 30 * time.Second})
|
||||
if err == nil || (res != nil && res.Success) {
|
||||
t.Fatalf("critical failure must abort: err=%v res=%+v", err, res)
|
||||
}
|
||||
after, _ := driver.LoadWorkflow(wsDir)
|
||||
if after.Status != "failed" {
|
||||
t.Fatalf("wf.Status = %s, want failed", after.Status)
|
||||
}
|
||||
if backend.calls != 1 {
|
||||
t.Fatalf("second step must not run after critical failure, calls=%d", backend.calls)
|
||||
}
|
||||
}
|
||||
+53
-18
@@ -43,6 +43,8 @@ type WorkflowExecutionResult struct {
|
||||
Duration time.Duration `json:"duration"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
ErrorReason string `json:"error_reason,omitempty"`
|
||||
// DegradedSteps A5:非关键步骤失败降级后继续的步骤 ID(Success 仍为 true)。
|
||||
DegradedSteps []string `json:"degraded_steps,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowDriver 负责动态工作流调度与沙箱隔离执行
|
||||
@@ -333,18 +335,25 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
|
||||
Duration: time.Since(start),
|
||||
}, rErr
|
||||
}
|
||||
// 治理:受控迁移至执行态(legacy 重跑场景直接放行)
|
||||
markStepExecuting(wf, step.ID)
|
||||
if err := d.runOneStep(runCtx, wsDir, step, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil {
|
||||
wf.Status = "failed"
|
||||
// 治理:受控迁移至执行态(legacy 重跑场景直接放行)
|
||||
markStepExecuting(wf, step.ID)
|
||||
if err := d.runOneStep(runCtx, wsDir, step, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil {
|
||||
// A5 非关键步骤降级:记录 degraded 继续推进,收口统一标 degraded
|
||||
if !step.IsCritical() {
|
||||
markStepDegraded(wsDir, wf, step, err)
|
||||
stepsRun++
|
||||
_ = d.SaveWorkflow(wsDir, wf)
|
||||
_ = d.updateWorkspaceStatus(wsDir, "failed")
|
||||
return &WorkflowExecutionResult{
|
||||
Success: false,
|
||||
ErrorReason: err.Error(),
|
||||
Duration: time.Since(start),
|
||||
}, err
|
||||
continue
|
||||
}
|
||||
wf.Status = "failed"
|
||||
_ = d.SaveWorkflow(wsDir, wf)
|
||||
_ = d.updateWorkspaceStatus(wsDir, "failed")
|
||||
return &WorkflowExecutionResult{
|
||||
Success: false,
|
||||
ErrorReason: err.Error(),
|
||||
Duration: time.Since(start),
|
||||
}, err
|
||||
}
|
||||
stepsRun++
|
||||
// 治理:FSM 受控迁移 + 步骤归档锁定 + 软线提醒
|
||||
if tErr := TransitionStep(wf, step.ID, StepStateCompleted); tErr == nil {
|
||||
@@ -376,9 +385,18 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
|
||||
rootOut := filepath.Join(wsDir, "output.md")
|
||||
_ = os.WriteFile(rootOut, finalContent, 0o644)
|
||||
|
||||
wf.Status = "completed"
|
||||
// 收口:A5 存在降级步骤时整体状态 degraded(非 completed 也非 failed)
|
||||
finalStatus := "completed"
|
||||
var degradedSteps []string
|
||||
for _, s := range wf.Steps {
|
||||
if s.Status == "degraded" {
|
||||
finalStatus = "degraded"
|
||||
degradedSteps = append(degradedSteps, s.ID)
|
||||
}
|
||||
}
|
||||
wf.Status = finalStatus
|
||||
_ = d.SaveWorkflow(wsDir, wf)
|
||||
_ = d.updateWorkspaceStatus(wsDir, "completed")
|
||||
_ = d.updateWorkspaceStatus(wsDir, finalStatus)
|
||||
|
||||
bb, _ := d.LoadBlackboard(wsDir)
|
||||
var finalFields map[string]string
|
||||
@@ -387,12 +405,13 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
|
||||
}
|
||||
|
||||
return &WorkflowExecutionResult{
|
||||
Success: true,
|
||||
StepsRun: stepsRun,
|
||||
Deliverable: rootOut,
|
||||
FinalContent: finalContent,
|
||||
Duration: time.Since(start),
|
||||
Fields: finalFields,
|
||||
Success: true,
|
||||
StepsRun: stepsRun,
|
||||
Deliverable: rootOut,
|
||||
FinalContent: finalContent,
|
||||
Duration: time.Since(start),
|
||||
Fields: finalFields,
|
||||
DegradedSteps: degradedSteps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -408,6 +427,22 @@ func markStepExecuting(wf *WorkflowPlan, stepID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// markStepDegraded A5:非关键步骤失败降级——状态落 degraded、记录错误、轨迹审计。
|
||||
func markStepDegraded(wsDir string, wf *WorkflowPlan, step StepSpec, err error) []string {
|
||||
for i := range wf.Steps {
|
||||
if wf.Steps[i].ID == step.ID {
|
||||
wf.Steps[i].Status = "degraded"
|
||||
wf.Steps[i].Error = err.Error()
|
||||
}
|
||||
}
|
||||
appendGovAudit(wsDir, "step_degraded", map[string]string{
|
||||
"step_id": step.ID,
|
||||
"error": err.Error(),
|
||||
"kind": "non_critical",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *WorkflowDriver) runOneStep(
|
||||
ctx context.Context,
|
||||
wsDir string,
|
||||
|
||||
Reference in New Issue
Block a user