fix(docgov): stop ExecuteStage on quality_failed without absorbing facts

阶段链在 ExecuteTask err=nil 后同时检查质量门禁与 Success;失败立即停链并保留 artifact,不注入 ExtractedData/PriorDoc,不宣称阶段成功。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 15:17:40 +00:00
co-authored by Cursor
parent 107f898362
commit 9e50bc0543
5 changed files with 244 additions and 38 deletions
@@ -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`
## 证据
@@ -38,6 +38,7 @@ mutex: docgov
8. [x] D 真实问题单测 + 门禁回归
9. [x] E 质量失败在 SetDocumentWorkDir/Materialize 前短路(无正式投影副作用)
10. [x] 豁免收窄为同行 meta+否定紧邻/引号;handlers 阶段请求字段边界单测锁定
11. [x] ExecuteStage 质量失败停链:不吸收事实、不追加 PriorDoc
## 验收标准
+20 -38
View File
@@ -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 != "" {
+70
View File
@@ -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
}
+152
View File
@@ -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)
}
}
}