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:
@@ -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
|
||||
|
||||
## 验收标准
|
||||
|
||||
|
||||
+14
-32
@@ -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{
|
||||
if halt := classifyStageTaskHalt(res, tKey); halt != nil {
|
||||
out := &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"
|
||||
}
|
||||
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,
|
||||
ErrorReason: halt.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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user