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` 独立失败态且不同步激活
|
- C:`TerminalStatus` 优先 quality;Materialize 仅 `Passed`;FE `quality_failed` 独立失败态且不同步激活
|
||||||
- D:现场 L204 / repair budget / TerminalStatus / FE 映射单测
|
- D:现场 L204 / repair budget / TerminalStatus / FE 映射单测
|
||||||
- **E(投影边界)**:`finalizeSquadDeliverable` 在任何 `SetDocumentWorkDir` / `Materialize` / `MarkDocumentContentUpdated` / 完成活动之前按 `QualityReport.Passed=false` 短路;保留隔离工作区与失败摘要;正式 `work_dir`/head/hash/候选不变。单测:`TestFinalizeSquadDeliverable_QualityFailed_NoDocProjectionSideEffects`(敌意 `Success=true`)
|
- **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 真实问题单测 + 门禁回归
|
8. [x] D 真实问题单测 + 门禁回归
|
||||||
9. [x] E 质量失败在 SetDocumentWorkDir/Materialize 前短路(无正式投影副作用)
|
9. [x] E 质量失败在 SetDocumentWorkDir/Materialize 前短路(无正式投影副作用)
|
||||||
10. [x] 豁免收窄为同行 meta+否定紧邻/引号;handlers 阶段请求字段边界单测锁定
|
10. [x] 豁免收窄为同行 meta+否定紧邻/引号;handlers 阶段请求字段边界单测锁定
|
||||||
|
11. [x] ExecuteStage 质量失败停链:不吸收事实、不追加 PriorDoc
|
||||||
|
|
||||||
## 验收标准
|
## 验收标准
|
||||||
|
|
||||||
|
|||||||
+20
-38
@@ -43,6 +43,8 @@ type Service struct {
|
|||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
schemaVersion int64
|
schemaVersion int64
|
||||||
stageGate StageAccessGate
|
stageGate StageAccessGate
|
||||||
|
// stageTaskHook 仅测试注入:覆盖 ExecuteStage 子任务执行,验证链中止边界。
|
||||||
|
stageTaskHook func(context.Context, TaskRunRequest) (*TaskRunResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。
|
// NewService 构造业务服务;生产/test 环境数据库不可用且未显式 ALLOW_IN_MEMORY 时返回 error。
|
||||||
@@ -1131,7 +1133,7 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest)
|
|||||||
ForceDirectEdit: req.ForceDirectEdit,
|
ForceDirectEdit: req.ForceDirectEdit,
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := s.ExecuteTask(ctx, tReq)
|
res, err := s.executeStageTask(ctx, tReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &StageRunResult{
|
return &StageRunResult{
|
||||||
Stage: req.Stage,
|
Stage: req.Stage,
|
||||||
@@ -1144,48 +1146,28 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest)
|
|||||||
}, err
|
}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DocGov claimed-doc proposal intercept — stop stage; do not absorb facts.
|
if halt := classifyStageTaskHalt(res, tKey); halt != nil {
|
||||||
if res != nil && res.IsProposalIntercept() {
|
out := &StageRunResult{
|
||||||
return &StageRunResult{
|
Stage: req.Stage,
|
||||||
Stage: req.Stage,
|
StageTitle: targetMeta.Title,
|
||||||
StageTitle: targetMeta.Title,
|
Success: false,
|
||||||
Success: false,
|
WorkDir: workDir,
|
||||||
WorkDir: workDir,
|
Duration: time.Since(start),
|
||||||
Duration: time.Since(start),
|
ErrorReason: halt.reason,
|
||||||
ErrorReason: fmt.Sprintf("任务【%s】对应文档已被编制负责人认领,系统已拦截直接修改并生成变更提案,需编制人拍板授权", tKey),
|
PrimaryArtifact: res,
|
||||||
SubArtifacts: append(executedArtifacts, res),
|
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(
|
if halt.pending {
|
||||||
"任务【%s】已生成高风险文档候选(风险:%s,候选 #%d),待编制人确认。本阶段已暂停,未将候选正文或提取事实注入后续任务;请在页面打开「待确认报告」确认或拒绝后,再重新发起本阶段。",
|
out.PendingAgentCandidateID = res.PendingAgentCandidateID
|
||||||
tKey, risk, res.PendingAgentCandidateID,
|
out.PendingChangeReportID = res.PendingChangeReportID
|
||||||
)
|
out.PendingRiskLevel = res.PendingRiskLevel
|
||||||
return &StageRunResult{
|
}
|
||||||
Stage: req.Stage,
|
return out, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
executedArtifacts = append(executedArtifacts, res)
|
executedArtifacts = append(executedArtifacts, res)
|
||||||
|
|
||||||
// 吸收回填事实(仅已提交 / 非 pending 产物)
|
// 吸收回填事实(仅已提交且过门禁的产物)
|
||||||
if res.ExtractedData != nil {
|
if res.ExtractedData != nil {
|
||||||
for k, v := range res.ExtractedData {
|
for k, v := range res.ExtractedData {
|
||||||
if v != "" {
|
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