From 9e50bc0543a8305eae750f94f52a7c0af49bf1ff Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 15:17:40 +0000 Subject: [PATCH] fix(docgov): stop ExecuteStage on quality_failed without absorbing facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阶段链在 ExecuteTask err=nil 后同时检查质量门禁与 Success;失败立即停链并保留 artifact,不注入 ExtractedData/PriorDoc,不宣称阶段成功。 Co-authored-by: Cursor --- ...nt-revision-quality-gate-self-pollution.md | 1 + ...09-13-document-revision-sse-idempotency.md | 1 + internal/bizdev/service.go | 58 +++---- internal/bizdev/stage_halt.go | 70 ++++++++ internal/bizdev/stage_quality_stop_test.go | 152 ++++++++++++++++++ 5 files changed, 244 insertions(+), 38 deletions(-) create mode 100644 internal/bizdev/stage_halt.go create mode 100644 internal/bizdev/stage_quality_stop_test.go diff --git a/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md index da927bc2..1049bffd 100644 --- a/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md +++ b/.doc/audit/2026-09-13-document-revision-quality-gate-self-pollution.md @@ -35,6 +35,7 @@ severity: high - C:`TerminalStatus` 优先 quality;Materialize 仅 `Passed`;FE `quality_failed` 独立失败态且不同步激活 - D:现场 L204 / repair budget / TerminalStatus / FE 映射单测 - **E(投影边界)**:`finalizeSquadDeliverable` 在任何 `SetDocumentWorkDir` / `Materialize` / `MarkDocumentContentUpdated` / 完成活动之前按 `QualityReport.Passed=false` 短路;保留隔离工作区与失败摘要;正式 `work_dir`/head/hash/候选不变。单测:`TestFinalizeSquadDeliverable_QualityFailed_NoDocProjectionSideEffects`(敌意 `Success=true`) +- **F(阶段链)**:`ExecuteStage` 在 `err=nil` 后经 `classifyStageTaskHalt` 检查质量/`Success`;失败立即停链,保留失败 artifact,不吸收 ExtractedData、不追加 PriorDoc、不写 completed workspace / 阶段跃迁。单测:`TestExecuteStage_QualityFailed_StopsChainWithoutAbsorbingFactsOrPriorDoc` ## 证据 diff --git a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md index dd39f0d5..8c7e2ca1 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-sse-idempotency.md @@ -38,6 +38,7 @@ mutex: docgov 8. [x] D 真实问题单测 + 门禁回归 9. [x] E 质量失败在 SetDocumentWorkDir/Materialize 前短路(无正式投影副作用) 10. [x] 豁免收窄为同行 meta+否定紧邻/引号;handlers 阶段请求字段边界单测锁定 +11. [x] ExecuteStage 质量失败停链:不吸收事实、不追加 PriorDoc ## 验收标准 diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index eb335e82..66fdd254 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -43,6 +43,8 @@ type Service struct { pool *pgxpool.Pool schemaVersion int64 stageGate StageAccessGate + // stageTaskHook 仅测试注入:覆盖 ExecuteStage 子任务执行,验证链中止边界。 + stageTaskHook func(context.Context, TaskRunRequest) (*TaskRunResult, error) } // NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。 @@ -1131,7 +1133,7 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest) ForceDirectEdit: req.ForceDirectEdit, } - res, err := s.ExecuteTask(ctx, tReq) + res, err := s.executeStageTask(ctx, tReq) if err != nil { return &StageRunResult{ Stage: req.Stage, @@ -1144,48 +1146,28 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest) }, err } - // DocGov claimed-doc proposal intercept — stop stage; do not absorb facts. - if res != nil && res.IsProposalIntercept() { - return &StageRunResult{ - Stage: req.Stage, - StageTitle: targetMeta.Title, - Success: false, - WorkDir: workDir, - Duration: time.Since(start), - ErrorReason: fmt.Sprintf("任务【%s】对应文档已被编制负责人认领,系统已拦截直接修改并生成变更提案,需编制人拍板授权", tKey), - SubArtifacts: append(executedArtifacts, res), - }, nil - } - - // High-risk revision awaiting author confirmation — stop stage chain. - // Do NOT absorb ExtractedData or MarkdownBody as PriorDoc (would indirectly apply unconfirmed content). - if res != nil && res.AwaitsAgentConfirmation() { - risk := res.PendingRiskLevel - if risk == "" { - risk = "high" + if halt := classifyStageTaskHalt(res, tKey); halt != nil { + out := &StageRunResult{ + Stage: req.Stage, + StageTitle: targetMeta.Title, + Success: false, + WorkDir: workDir, + Duration: time.Since(start), + ErrorReason: halt.reason, + PrimaryArtifact: res, + SubArtifacts: append(executedArtifacts, res), } - reason := fmt.Sprintf( - "任务【%s】已生成高风险文档候选(风险:%s,候选 #%d),待编制人确认。本阶段已暂停,未将候选正文或提取事实注入后续任务;请在页面打开「待确认报告」确认或拒绝后,再重新发起本阶段。", - tKey, risk, res.PendingAgentCandidateID, - ) - return &StageRunResult{ - Stage: req.Stage, - StageTitle: targetMeta.Title, - Success: false, - WorkDir: workDir, - Duration: time.Since(start), - ErrorReason: reason, - PrimaryArtifact: res, - SubArtifacts: append(executedArtifacts, res), - PendingAgentCandidateID: res.PendingAgentCandidateID, - PendingChangeReportID: res.PendingChangeReportID, - PendingRiskLevel: res.PendingRiskLevel, - }, nil + if halt.pending { + out.PendingAgentCandidateID = res.PendingAgentCandidateID + out.PendingChangeReportID = res.PendingChangeReportID + out.PendingRiskLevel = res.PendingRiskLevel + } + return out, nil } executedArtifacts = append(executedArtifacts, res) - // 吸收回填事实(仅已提交 / 非 pending 产物) + // 吸收回填事实(仅已提交且过门禁的产物) if res.ExtractedData != nil { for k, v := range res.ExtractedData { if v != "" { diff --git a/internal/bizdev/stage_halt.go b/internal/bizdev/stage_halt.go new file mode 100644 index 00000000..27f99bda --- /dev/null +++ b/internal/bizdev/stage_halt.go @@ -0,0 +1,70 @@ +package bizdev + +import ( + "context" + "fmt" + "strings" +) + +type stageTaskHalt struct { + reason string + pending bool + quality bool +} + +func (s *Service) executeStageTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) { + if s != nil && s.stageTaskHook != nil { + return s.stageTaskHook(ctx, req) + } + return s.ExecuteTask(ctx, req) +} + +// classifyStageTaskHalt 阶段链在 ExecuteTask err=nil 后的中止判定。 +// 质量失败(含仅 Success=false + QualityReport.Passed=false)必须停链: +// 不吸收 ExtractedData、不追加 PriorDoc、不宣称阶段成功。 +func classifyStageTaskHalt(res *TaskRunResult, tKey string) *stageTaskHalt { + if res == nil { + return &stageTaskHalt{reason: fmt.Sprintf("任务【%s】无返回结果", tKey)} + } + if res.IsProposalIntercept() { + return &stageTaskHalt{ + reason: fmt.Sprintf("任务【%s】对应文档已被编制负责人认领,系统已拦截直接修改并生成变更提案,需编制人拍板授权", tKey), + } + } + if res.AwaitsAgentConfirmation() { + risk := res.PendingRiskLevel + if risk == "" { + risk = "high" + } + return &stageTaskHalt{ + pending: true, + reason: fmt.Sprintf( + "任务【%s】已生成高风险文档候选(风险:%s,候选 #%d),待编制人确认。本阶段已暂停,未将候选正文或提取事实注入后续任务;请在页面打开「待确认报告」确认或拒绝后,再重新发起本阶段。", + tKey, risk, res.PendingAgentCandidateID, + ), + } + } + if qualityBlocksControlledProjection(res.QualityReport) || res.TerminalStatus() == TaskStatusQualityFailed { + detail := qualityFailedErrorReason(res.QualityReport) + if res.ErrorReason != "" && !strings.Contains(detail, res.ErrorReason) { + detail = res.ErrorReason + } + return &stageTaskHalt{ + quality: true, + reason: fmt.Sprintf( + "任务【%s】质量门禁未通过。%s。本阶段已停止,未吸收提取事实、未将未过门禁正文作为后续 PriorDoc。", + tKey, strings.TrimPrefix(detail, "质量门禁未通过:"), + ), + } + } + if !res.Success { + reason := res.ErrorReason + if strings.TrimSpace(reason) == "" { + reason = "任务未成功" + } + return &stageTaskHalt{ + reason: fmt.Sprintf("任务【%s】未成功:%s。本阶段已停止,未吸收提取事实、未追加 PriorDoc。", tKey, reason), + } + } + return nil +} diff --git a/internal/bizdev/stage_quality_stop_test.go b/internal/bizdev/stage_quality_stop_test.go new file mode 100644 index 00000000..80bce120 --- /dev/null +++ b/internal/bizdev/stage_quality_stop_test.go @@ -0,0 +1,152 @@ +package bizdev + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "chat-agent-system/internal/config" + "chat-agent-system/internal/squad" +) + +func TestClassifyStageTaskHalt_QualityFailedStopsWithoutAbsorbing(t *testing.T) { + res := &TaskRunResult{ + Success: false, + TaskKey: "research", + ArtifactTitle: "调研报告", + MarkdownBody: "此处篇幅有限,不做展开。", + ExtractedData: map[string]string{"device_type": "poison_from_failed_draft"}, + QualityReport: &squad.QualityReport{Passed: false, Score: 40, Violations: []string{"省略/敷衍写法"}}, + ErrorReason: "质量门禁未通过:省略/敷衍写法", + } + halt := classifyStageTaskHalt(res, "research") + if halt == nil || !halt.quality { + t.Fatalf("quality fail must halt stage, got %+v", halt) + } + if !strings.Contains(halt.reason, "质量门禁未通过") { + t.Fatalf("reason=%q", halt.reason) + } + if !strings.Contains(halt.reason, "未吸收") || !strings.Contains(halt.reason, "PriorDoc") { + t.Fatalf("reason must state no absorb / no PriorDoc: %q", halt.reason) + } +} + +func TestClassifyStageTaskHalt_SuccessFalseWithoutQualityAlsoStops(t *testing.T) { + halt := classifyStageTaskHalt(&TaskRunResult{Success: false, ErrorReason: "落盘失败"}, "intake") + if halt == nil || halt.quality || halt.pending { + t.Fatalf("generic failure must halt (not as quality/pending): %+v", halt) + } +} + +func TestClassifyStageTaskHalt_PassedSuccessContinues(t *testing.T) { + if halt := classifyStageTaskHalt(&TaskRunResult{ + Success: true, + QualityReport: &squad.QualityReport{Passed: true, Score: 90}, + }, "intake"); halt != nil { + t.Fatalf("passed task must continue: %+v", halt) + } +} + +func TestExecuteStage_QualityFailed_StopsChainWithoutAbsorbingFactsOrPriorDoc(t *testing.T) { + dataDir := t.TempDir() + svc := MustNewService(t, &config.Config{DataDir: dataDir, AgentBackend: "stub"}) + ctx := context.Background() + projectID := int64(6099) + stageWork := filepath.Join(dataDir, "ws-stage-quality") + if err := os.MkdirAll(stageWork, 0o755); err != nil { + t.Fatal(err) + } + + sentinelKey := "quality_must_not_overwrite" + sentinelVal := "pre_stage_sentinel" + var seenKeys []string + svc.stageTaskHook = func(_ context.Context, req TaskRunRequest) (*TaskRunResult, error) { + seenKeys = append(seenKeys, req.TaskKey) + if req.TaskKey != "research" { + t.Fatalf("quality fail must stop before subsequent task %q; seen=%v priors=%d", + req.TaskKey, seenKeys, len(req.PriorDocs)) + } + for _, p := range req.PriorDocs { + if p.DocKey == "research" || strings.Contains(p.Content, "未过门禁") { + t.Fatalf("must not feed failed markdown as PriorDoc: %+v", p) + } + } + return &TaskRunResult{ + TaskKey: "research", + ArtifactTitle: "调研报告", + MarkdownBody: "# 未过门禁草稿\n此处篇幅有限,不做展开。", + ExtractedData: map[string]string{ + sentinelKey: "poison_from_quality_fail", + "device_type": "should_not_land_on_blackboard", + }, + WorkDir: filepath.Join(stageWork, "research"), + Success: false, + ErrorReason: "质量门禁未通过:省略/敷衍写法", + QualityReport: &squad.QualityReport{ + Passed: false, + Score: 40, + Violations: []string{"省略/敷衍写法"}, + }, + }, nil + } + + stageRes, err := svc.ExecuteStage(ctx, StageRunRequest{ + Stage: StageV05_Feasibility, + ProjectName: "阶段链质量失败测试", + CustomerName: "C", + DeviceType: "医疗器械", + IntendedUse: "诊断", + TargetMarket: "国内", + Fields: map[string]string{sentinelKey: sentinelVal}, + Backend: "stub", + Timeout: 15 * time.Second, + WorkDir: stageWork, + ProjectID: projectID, + UserID: 1, + }) + if err != nil { + t.Fatalf("ExecuteStage unexpected hard error: %v", err) + } + if stageRes == nil || stageRes.Success { + t.Fatalf("stage must fail on quality gate, got %+v", stageRes) + } + if !strings.Contains(stageRes.ErrorReason, "质量门禁未通过") { + t.Fatalf("ErrorReason=%q", stageRes.ErrorReason) + } + if stageRes.PrimaryArtifact == nil || stageRes.PrimaryArtifact.MarkdownBody == "" { + t.Fatal("must retain failed artifact body") + } + if stageRes.PrimaryArtifact.TerminalStatus() != TaskStatusQualityFailed { + t.Fatalf("primary status=%q", stageRes.PrimaryArtifact.TerminalStatus()) + } + if len(stageRes.SubArtifacts) != 1 || seenKeys[0] != "research" || len(seenKeys) != 1 { + t.Fatalf("must stop after first task; sub=%d seen=%v", len(stageRes.SubArtifacts), seenKeys) + } + for _, next := range []string{"feasibility_dev", "feasibility_reg", "user_requirements", "prelim_design"} { + if _, err := os.Stat(filepath.Join(stageWork, next, "output.md")); err == nil { + t.Fatalf("subsequent task %s must not run", next) + } + } + if _, err := os.Stat(filepath.Join(stageWork, "workspace.json")); err == nil { + t.Fatal("quality stop must not write completed stage workspace.json") + } + + pCtx, err := svc.ContextManager().GetProjectContext(ctx, projectID) + if err != nil { + t.Fatalf("GetProjectContext: %v", err) + } + if got := pCtx.BlackboardFacts[sentinelKey]; got != "" && got != sentinelVal { + t.Fatalf("must not absorb failed ExtractedData onto blackboard: %q", got) + } + if pCtx.BlackboardFacts["device_type"] == "should_not_land_on_blackboard" { + t.Fatal("poison extracted field landed on project blackboard") + } + for _, act := range pCtx.AllActivities { + if strings.Contains(act.Title, "阶段跃迁") { + t.Fatalf("must not record stage_advance: %q", act.Title) + } + } +}