From 53d1be5349f999edfa4a0289a3812024d7238479 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 25 Sep 2026 17:10:35 +0000 Subject: [PATCH] feat(quote,squad): B5 input validation + review invariants; A5 non-critical step degradation P10 backlog triage (implement B5+A5; A6 already fixed in-tree, D5 keep shared KB, R1 separate task, R4/R5/R6/R7 no-go with evidence): - quote: ValidateInput (empty names/hours, negative/NaN/Inf fail-closed); reviewSummary invariants (line sums = total; buffer 20%/margin 40%/tax 7% rate self-consistency) enforced before Excel write - squad: StepSpec.Critical *bool (nil=critical default); non-critical step failure marks step degraded + gov audit, workflow continues, final status degraded with DegradedSteps list; critical failure semantics unchanged - tests: 5 new cases (quote 3 + squad 2), full short suite green, arch-check pass --- ...26-09-25-p10-backlog-triage-test-report.md | 48 +++++++ internal/quote/quote.go | 74 +++++++++++ internal/quote/validation_review_test.go | 70 +++++++++++ internal/squad/models.go | 10 ++ internal/squad/noncritical_degrade_test.go | 118 ++++++++++++++++++ internal/squad/workflow.go | 71 ++++++++--- 6 files changed, 373 insertions(+), 18 deletions(-) create mode 100644 .doc/test-reports/2026-09-25-p10-backlog-triage-test-report.md create mode 100644 internal/quote/validation_review_test.go create mode 100644 internal/squad/noncritical_degrade_test.go diff --git a/.doc/test-reports/2026-09-25-p10-backlog-triage-test-report.md b/.doc/test-reports/2026-09-25-p10-backlog-triage-test-report.md new file mode 100644 index 00000000..343b2021 --- /dev/null +++ b/.doc/test-reports/2026-09-25-p10-backlog-triage-test-report.md @@ -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 二期。 diff --git a/internal/quote/quote.go b/internal/quote/quote.go index 60711131..51b32be3 100644 --- a/internal/quote/quote.go +++ b/internal/quote/quote.go @@ -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) diff --git a/internal/quote/validation_review_test.go b/internal/quote/validation_review_test.go new file mode 100644 index 00000000..af3ff322 --- /dev/null +++ b/internal/quote/validation_review_test.go @@ -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") + } +} diff --git a/internal/squad/models.go b/internal/squad/models.go index 38fa588c..42fa39a6 100644 --- a/internal/squad/models.go +++ b/internal/squad/models.go @@ -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 动态任务编排清单 diff --git a/internal/squad/noncritical_degrade_test.go b/internal/squad/noncritical_degrade_test.go new file mode 100644 index 00000000..71b03cf9 --- /dev/null +++ b/internal/squad/noncritical_degrade_test.go @@ -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) + } +} diff --git a/internal/squad/workflow.go b/internal/squad/workflow.go index a3eb0eb8..d8ca8a33 100644 --- a/internal/squad/workflow.go +++ b/internal/squad/workflow.go @@ -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,