fix(docgov): keep squad AutoRepair/Success semantics; force repair only on controlled docs

小队修复预算只认 AutoRepair,Success 只表示执行完成;bizdev 在解析出 docRec 后强制开启修复,投影与阶段停链仍以 QualityReport 为准。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 15:19:20 +00:00
co-authored by Cursor
parent 9e50bc0543
commit 2d86d330e5
8 changed files with 55 additions and 39 deletions
@@ -30,7 +30,7 @@ severity: high
## 整改(同分支 `fix/document-revision-sse-idempotency`)
- A:有 `TaskKey` 的受控小队任务服务端强制 `DefaultMaxRepairRounds`;handlers 产品契约 `AutoRepair: true`
- A:小队 `repairBudget` 只认 `AutoRepair`;bizdev 在解析出 `docID`+`docRec` 后强制开启;受控 HTTP `task_run_handlers` 仍设 `true`。小队 `Success` 仅表示执行完成,投影/阶段停链看 `QualityReport`
- B:prompt 去原词;豁免仅限同一自检/规则行且同时具备 meta marker + 否定紧邻或引号引用(禁止 48 字窗口遇「无」即放过)
- C:`TerminalStatus` 优先 quality;Materialize 仅 `Passed`;FE `quality_failed` 独立失败态且不同步激活
- D:现场 L204 / repair budget / TerminalStatus / FE 映射单测
@@ -39,6 +39,7 @@ mutex: docgov
9. [x] E 质量失败在 SetDocumentWorkDir/Materialize 前短路(无正式投影副作用)
10. [x] 豁免收窄为同行 meta+否定紧邻/引号;handlers 阶段请求字段边界单测锁定
11. [x] ExecuteStage 质量失败停链:不吸收事实、不追加 PriorDoc
12. [x] 小队 AutoRepair/Success 语义恢复;受控修复仅在 bizdev docRec 与 HTTP 边界强制
## 验收标准
@@ -9,10 +9,23 @@ import (
"time"
"chat-agent-system/internal/config"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/squad"
)
func TestAutoRepairForControlledSquad(t *testing.T) {
if autoRepairForControlledSquad(false, "", nil) {
t.Fatal("uncontrolled must respect AutoRepair=false")
}
if !autoRepairForControlledSquad(true, "", nil) {
t.Fatal("uncontrolled AutoRepair=true must stay true")
}
if !autoRepairForControlledSquad(false, "doc_proj1_intake", &docgov.DocumentRecord{DocID: "doc_proj1_intake"}) {
t.Fatal("resolved controlled doc must force AutoRepair")
}
}
func TestQualityBlocksControlledProjection(t *testing.T) {
if qualityBlocksControlledProjection(nil) {
t.Fatal("nil report must not block")
+9 -1
View File
@@ -698,7 +698,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
Timeout: req.Timeout,
WorkDir: req.WorkDir,
CustomSteps: req.CustomSteps,
AutoRepair: req.AutoRepair,
AutoRepair: autoRepairForControlledSquad(req.AutoRepair, docID, docRec),
KnowledgeHits: hits,
PriorDocs: priorDocs,
UserContext: userCtxStr,
@@ -735,6 +735,14 @@ func qualityBlocksControlledProjection(report *squad.QualityReport) bool {
return report != nil && !report.Passed
}
// autoRepairForControlledSquad 已解析出正式受控文档后强制开启修复;非受控调用仍尊重请求值。
func autoRepairForControlledSquad(reqAutoRepair bool, docID string, docRec *docgov.DocumentRecord) bool {
if strings.TrimSpace(docID) != "" && docRec != nil {
return true
}
return reqAutoRepair
}
func qualityFailedErrorReason(report *squad.QualityReport) string {
if report == nil {
return "质量门禁未通过"
+1 -1
View File
@@ -20,7 +20,7 @@ func (s *Service) executeStageTask(ctx context.Context, req TaskRunRequest) (*Ta
}
// classifyStageTaskHalt 阶段链在 ExecuteTask err=nil 后的中止判定。
// 质量失败(含仅 Success=false + QualityReport.Passed=false)必须停链:
// 质量失败以 QualityReport 为准(小队 Success 仍可表示执行完成),必须停链:
// 不吸收 ExtractedData、不追加 PriorDoc、不宣称阶段成功。
func classifyStageTaskHalt(res *TaskRunResult, tKey string) *stageTaskHalt {
if res == nil {
+3 -2
View File
@@ -82,8 +82,9 @@ func TestExecuteStage_QualityFailed_StopsChainWithoutAbsorbingFactsOrPriorDoc(t
sentinelKey: "poison_from_quality_fail",
"device_type": "should_not_land_on_blackboard",
},
WorkDir: filepath.Join(stageWork, "research"),
Success: false,
WorkDir: filepath.Join(stageWork, "research"),
// 小队执行完成可为 true;阶段停链看 QualityReport,不依赖 Success=false
Success: true,
ErrorReason: "质量门禁未通过:省略/敷衍写法",
QualityReport: &squad.QualityReport{
Passed: false,
+7 -11
View File
@@ -2,24 +2,20 @@ package squad
import "testing"
func TestRepairBudgetForSquadTask_ProductContract(t *testing.T) {
// 受控文档小队任务:即使客户端省略 auto_repair=false,也必须给修复预算
func TestRepairBudgetForSquadTask_RespectsAutoRepairOnly(t *testing.T) {
got := repairBudgetForSquadTask(SquadRunRequest{TaskKey: "intake", AutoRepair: false})
if got != DefaultMaxRepairRounds {
t.Fatalf("intake AutoRepair=false budget=%d want %d", got, DefaultMaxRepairRounds)
if got != 0 {
t.Fatalf("TaskKey alone must not enable repair: budget=%d", got)
}
if DefaultMaxRepairRounds < 1 {
t.Fatalf("DefaultMaxRepairRounds must be >=1, got %d", DefaultMaxRepairRounds)
}
// 无 TaskKey 且未开 AutoRepair:保持 0(非受控路径)
got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "", AutoRepair: false})
if got != 0 {
t.Fatalf("empty TaskKey without AutoRepair budget=%d want 0", got)
}
got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "", AutoRepair: true})
got = repairBudgetForSquadTask(SquadRunRequest{TaskKey: "intake", AutoRepair: true})
if got != DefaultMaxRepairRounds {
t.Fatalf("AutoRepair=true budget=%d want %d", got, DefaultMaxRepairRounds)
}
if DefaultMaxRepairRounds < 1 {
t.Fatalf("DefaultMaxRepairRounds must be >=1, got %d", DefaultMaxRepairRounds)
}
}
+20 -23
View File
@@ -32,22 +32,22 @@ type Attachment struct {
// SquadRunRequest 运行请求
type SquadRunRequest struct {
TaskKey string `json:"task_key"`
Title string `json:"title,omitempty"` // 自由任务/非标任务的人类可读标题(缺省回落 TaskArtifactTitle)
ProjectName string `json:"project_name"`
CustomerName string `json:"customer_name"`
DeviceType string `json:"device_type"`
IntendedUse string `json:"intended_use"`
TargetMarket string `json:"target_market"`
Fields map[string]string `json:"fields"`
Materials []MaterialItem `json:"materials"`
Backend string `json:"backend"`
Timeout time.Duration `json:"timeout"`
WorkDir string `json:"work_dir"`
CustomSteps []StepSpec `json:"custom_steps"`
AutoRepair bool `json:"auto_repair"`
KnowledgeHits []KnowledgeHit `json:"knowledge_hits"`
PriorDocs []PriorDocItem `json:"prior_docs"`
TaskKey string `json:"task_key"`
Title string `json:"title,omitempty"` // 自由任务/非标任务的人类可读标题(缺省回落 TaskArtifactTitle)
ProjectName string `json:"project_name"`
CustomerName string `json:"customer_name"`
DeviceType string `json:"device_type"`
IntendedUse string `json:"intended_use"`
TargetMarket string `json:"target_market"`
Fields map[string]string `json:"fields"`
Materials []MaterialItem `json:"materials"`
Backend string `json:"backend"`
Timeout time.Duration `json:"timeout"`
WorkDir string `json:"work_dir"`
CustomSteps []StepSpec `json:"custom_steps"`
AutoRepair bool `json:"auto_repair"`
KnowledgeHits []KnowledgeHit `json:"knowledge_hits"`
PriorDocs []PriorDocItem `json:"prior_docs"`
UserContext string `json:"user_context,omitempty"`
MemoryBankFiles map[string]string `json:"memory_bank_files,omitempty"`
SessionID string `json:"session_id,omitempty"`
@@ -295,7 +295,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR
if backendName == "stub" {
finalBytes, _ = os.ReadFile(filepath.Join(workDir, "template.md"))
_ = os.WriteFile(finalOutPath, finalBytes, 0o644)
// stub 终稿以 template 为准:门禁须对齐最终交付物,避免对中间草稿误伤 Success/Materialize
// stub 终稿以 template 为准:QualityReport 对齐最终交付物(Success 仍只表示执行完成)
if reReport, reErr := s.gate.Validate(workDir, req.TaskKey, string(finalBytes)); reErr == nil {
report = reReport
manifest, _ = s.gate.GenerateManifest(workDir, req.TaskKey, *report)
@@ -356,19 +356,16 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR
WorkDir: workDir,
BackendUsed: backendName,
Duration: time.Since(start),
Success: execRes.Success && (report == nil || report.Passed),
Success: execRes.Success,
QualityReport: report,
Manifest: manifest,
AdvisorBrief: advisorBrief,
}, nil
}
// repairBudgetForSquadTask 产品契约:有 TaskKey 的受控文档小队任务默认启用修复轮预算,
// 不依赖客户端 auto_repair(浏览器可省略且默认为 false)。
// repairBudgetForSquadTask 仅尊重请求方 AutoRepair,不因 TaskKey 非空而改写 CLI/桩/非受控语义。
// 受控文档任务的修复预算由 bizdev/HTTP 在调用前强制 AutoRepair=true。
func repairBudgetForSquadTask(req SquadRunRequest) int {
if strings.TrimSpace(req.TaskKey) != "" {
return DefaultMaxRepairRounds
}
if req.AutoRepair {
return DefaultMaxRepairRounds
}