From 4f5a6188a7386714556d0d011f44665eb97465f2 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 09:51:15 +0000 Subject: [PATCH] fix(docgov): defer fact-source update until high-risk commit Materialize needs_confirmation restores ledger head to output.md and document_records hash; only committed heads update DocGov. Decision handler projects after commit or reject. Stage gates DocGov proposals via PendingProposal, not bare RequiresAuthorApproval. Co-authored-by: Cursor --- internal/bizdev/service.go | 130 ++++++++++++-- internal/bizdev/task_result_status.go | 150 ++++++++++++++++ internal/bizdev/types.go | 68 ++++---- .../docrevision/pending_fact_source_test.go | 161 ++++++++++++++++++ internal/docrevision/service.go | 31 ++++ internal/server/document_revision_handlers.go | 27 +++ 6 files changed, 522 insertions(+), 45 deletions(-) create mode 100644 internal/bizdev/task_result_status.go create mode 100644 internal/docrevision/pending_fact_source_test.go diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 16949750..59f7ae00 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -733,27 +733,23 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil { _ = s.docGov.SetDocumentWorkDir(ctx, docID, res.WorkDir) - sha := sha256.Sum256([]byte(res.MarkdownBody)) - shaStr := hex.EncodeToString(sha[:]) - _ = s.docGov.MarkDocumentContentUpdated(ctx, docID, shaStr, false) - if appliedProposalID != "" { - _ = s.docGov.MarkProposalApplied(ctx, appliedProposalID) + dataRoot := "." + if s.cfg != nil && s.cfg.DataDir != "" { + dataRoot = s.cfg.DataDir } - if latest, err := s.docGov.GetDocument(ctx, docID); err == nil { - docRec = latest - } - // DOCREV: first generation uses empty prior→agent output so a deterministic change report - // is always visible; subsequent runs candidate against current head. Never static fiction. - // Materialize failure must not be swallowed as task success. + + // DOCREV: materialize first; only committed heads may update DocGov hash / proposal applied. + // Squad may have already written candidate body to workDir/output.md — pending must restore ledger head. if s.docRev != nil && docRec != nil && strings.TrimSpace(docRec.DocID) != "" { actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed} - if _, matErr := s.docRev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ + mat, matErr := s.docRev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey), ProposalID: appliedProposalID, OutputMarkdown: res.MarkdownBody, ProjectID: req.ProjectID, WorkDir: res.WorkDir, ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: actor, AutoCommitUserID: req.UserID, - }); matErr != nil { + }) + if matErr != nil { reason := fmt.Sprintf("文档版本账本/变更报告落盘失败: %v", matErr) return &TaskRunResult{ TaskKey: res.TaskKey, @@ -772,6 +768,104 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun DocRecord: docRec, }, matErr } + + if mat != nil && mat.Candidate != nil && mat.Candidate.Status == docrevision.CandidateNeedsConfirmation { + if err := s.docRev.ProjectOutputMarkdown(docID, dataRoot); err != nil { + reason := fmt.Sprintf("高风险待确认:恢复账本 head 到事实源失败: %v", err) + return &TaskRunResult{ + TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, + WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, + Success: false, ErrorReason: reason, DocRecord: docRec, + }, err + } + headSHA := "" + if mat.State != nil { + headSHA = mat.State.HeadSHA256 + } + if headSHA != "" { + if err := s.docGov.MarkDocumentContentUpdated(ctx, docID, headSHA, false); err != nil { + reason := fmt.Sprintf("高风险待确认:恢复 document_records 哈希失败: %v", err) + return &TaskRunResult{ + TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, + WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, + Success: false, ErrorReason: reason, DocRecord: docRec, + }, err + } + } + if latest, err := s.docGov.GetDocument(ctx, docID); err == nil { + docRec = latest + } + risk := "" + if mat.Report != nil { + risk = mat.Report.RiskLevel + } + var pendCand, pendRep int64 + if mat.Candidate != nil { + pendCand = mat.Candidate.ID + if mat.Candidate.ChangeReportID != nil { + pendRep = *mat.Candidate.ChangeReportID + } + } + note := fmt.Sprintf( + "高风险变更(%s)待编制人确认:候选 #%d。正式正文仍为当前账本 head,候选正文仅保留在不可变快照中;请打开「待确认报告」确认提交或拒绝。", + risk, pendCand, + ) + return &TaskRunResult{ + TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, + ExtractedData: res.ExtractedData, Attachments: res.Attachments, WorkDir: res.WorkDir, + BackendUsed: res.BackendUsed, Duration: res.Duration, Success: true, + QualityReport: res.QualityReport, Manifest: res.Manifest, AdvisorBrief: res.AdvisorBrief, + DocRecord: docRec, + // RequiresAuthorApproval signals "needs human decision"; distinguish from DocGov + // proposal intercept via PendingAgentCandidateID (no PendingProposal). + RequiresAuthorApproval: true, + PendingAgentCandidateID: pendCand, PendingChangeReportID: pendRep, + PendingRiskLevel: risk, PendingConfirmationNote: note, + }, nil + } + + // Committed (auto-commit or ready path): project head then update DocGov. + if err := s.docRev.ProjectOutputMarkdown(docID, dataRoot); err != nil { + reason := fmt.Sprintf("账本已提交但工作区投影失败: %v", err) + return &TaskRunResult{ + TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, + WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, + Success: false, ErrorReason: reason, DocRecord: docRec, + }, err + } + headSHA := "" + if mat != nil && mat.State != nil { + headSHA = mat.State.HeadSHA256 + } + if headSHA == "" { + sha := sha256.Sum256([]byte(res.MarkdownBody)) + headSHA = hex.EncodeToString(sha[:]) + } + if err := s.docGov.MarkDocumentContentUpdated(ctx, docID, headSHA, false); err != nil { + reason := fmt.Sprintf("账本已提交但 DocGov 哈希投影失败: %v", err) + return &TaskRunResult{ + TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, MarkdownBody: res.MarkdownBody, + WorkDir: res.WorkDir, BackendUsed: res.BackendUsed, Duration: res.Duration, + Success: false, ErrorReason: reason, DocRecord: docRec, + }, err + } + if appliedProposalID != "" { + _ = s.docGov.MarkProposalApplied(ctx, appliedProposalID) + } + if latest, err := s.docGov.GetDocument(ctx, docID); err == nil { + docRec = latest + } + } else { + // No docrevision: preserve prior best-effort hash update (CLI/legacy). + sha := sha256.Sum256([]byte(res.MarkdownBody)) + shaStr := hex.EncodeToString(sha[:]) + _ = s.docGov.MarkDocumentContentUpdated(ctx, docID, shaStr, false) + if appliedProposalID != "" { + _ = s.docGov.MarkProposalApplied(ctx, appliedProposalID) + } + if latest, err := s.docGov.GetDocument(ctx, docID); err == nil { + docRec = latest + } } } @@ -786,9 +880,14 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun IsStale: false, }) } - if res.Success && req.TaskKey == "system_reqs" && s.projectWorkflow != nil && req.ProjectID > 0 { + // Stale propagation only after fact source committed (not while pending confirmation). + if res.Success && req.TaskKey == "system_reqs" && s.projectWorkflow != nil && req.ProjectID > 0 && + (s.docRev == nil || (docRec != nil && docRec.ContentSHA256 != "")) { sha := sha256.Sum256([]byte(res.MarkdownBody)) shaStr := hex.EncodeToString(sha[:]) + if docRec != nil && docRec.ContentSHA256 != "" { + shaStr = docRec.ContentSHA256 + } _, _ = s.projectWorkflow.CheckAndPropagateStale(ctx, docID, shaStr, "上游系统需求规格书已更新,下游验证方案需复核") } @@ -939,7 +1038,8 @@ func (s *Service) executeStageInternal(ctx context.Context, req StageRunRequest) }, err } - if res != nil && res.RequiresAuthorApproval { + // DocGov claimed-doc proposal intercept only — not high-risk revision pending confirmation. + if res != nil && res.IsProposalIntercept() { return &StageRunResult{ Stage: req.Stage, StageTitle: targetMeta.Title, diff --git a/internal/bizdev/task_result_status.go b/internal/bizdev/task_result_status.go new file mode 100644 index 00000000..48cad723 --- /dev/null +++ b/internal/bizdev/task_result_status.go @@ -0,0 +1,150 @@ +package bizdev + +import ( + "fmt" + "strings" + + "chat-agent-system/internal/squad" +) + +// Terminal task statuses projected to chat / workflow_runs. +const ( + TaskStatusCompleted = "completed" + TaskStatusFailed = "failed" + TaskStatusQualityFailed = "quality_failed" + TaskStatusPendingAuthorConfirmation = "pending_author_confirmation" +) + +// AwaitsAgentConfirmation reports high/critical revision candidate awaiting author decision. +// Distinct from DocGov change-proposal intercept (PendingProposal). +func (r *TaskRunResult) AwaitsAgentConfirmation() bool { + return r != nil && r.PendingAgentCandidateID > 0 +} + +// IsProposalIntercept reports claimed-document DocGov change-proposal interception. +func (r *TaskRunResult) IsProposalIntercept() bool { + return r != nil && r.PendingProposal != nil && r.RequiresAuthorApproval +} + +// TerminalStatus maps TaskRunResult to a conversation/workflow terminal status. +// Pending agent confirmation is never "completed" until commit+projection. +func (r *TaskRunResult) TerminalStatus() string { + if r == nil || !r.Success { + return TaskStatusFailed + } + if r.AwaitsAgentConfirmation() { + return TaskStatusPendingAuthorConfirmation + } + if r.QualityReport != nil && !r.QualityReport.Passed { + return TaskStatusQualityFailed + } + return TaskStatusCompleted +} + +// CompletionSummary builds the assistant-visible completion text for sync and async finalizers. +func (r *TaskRunResult) CompletionSummary() string { + if r == nil { + return "任务执行失败。" + } + title := r.ArtifactTitle + if title == "" && r.TaskKey != "" { + title = squad.TaskArtifactTitle(r.TaskKey) + } + if title == "" { + title = "交付文档" + } + + status := r.TerminalStatus() + var b strings.Builder + + switch status { + case TaskStatusPendingAuthorConfirmation: + fmt.Fprintf(&b, "《%s》文档候选已生成,待编制人确认。\n\n", title) + b.WriteString("当前状态:**待编制人确认**") + if r.PendingRiskLevel != "" { + fmt.Fprintf(&b, "(风险:%s)", r.PendingRiskLevel) + } + b.WriteString("\n正式正文仍为账本当前 head;候选正文仅保留在不可变快照中。请打开文档「待确认报告」确认提交或拒绝后,正式正文才会更新。") + if r.PendingConfirmationNote != "" { + fmt.Fprintf(&b, "\n%s", r.PendingConfirmationNote) + } + fmt.Fprintf(&b, "\n候选 ID:%d", r.PendingAgentCandidateID) + if r.PendingChangeReportID > 0 { + fmt.Fprintf(&b, " · 变更报告:%d", r.PendingChangeReportID) + } + case TaskStatusQualityFailed: + fmt.Fprintf(&b, "《%s》已生成。\n\n", title) + b.WriteString("当前状态:**质量检查未通过,待整改**") + if r.QualityReport != nil { + fmt.Fprintf(&b, "\n质量评分:%.0f/100", r.QualityReport.Score) + if len(r.QualityReport.Violations) > 0 { + fmt.Fprintf(&b, "\n主要问题:%s", strings.Join(r.QualityReport.Violations, ";")) + } + } + case TaskStatusCompleted: + fmt.Fprintf(&b, "《%s》已生成。\n\n", title) + b.WriteString("当前状态:**已完成**") + if r.QualityReport != nil && r.QualityReport.Passed { + fmt.Fprintf(&b, "\n质量评分:%.0f/100", r.QualityReport.Score) + } + default: + fmt.Fprintf(&b, "《%s》未成功落盘。\n\n", title) + if r.ErrorReason != "" { + fmt.Fprintf(&b, "执行未成功:%s", r.ErrorReason) + } else { + b.WriteString("任务执行失败。") + } + } + + if r.DocRecord != nil && r.DocRecord.DocID != "" { + fmt.Fprintf(&b, "\n\n📄 受控文档:`%s`", r.DocRecord.DocID) + } + return b.String() +} + +// ArtifactMetadata returns chat metadata fields for a clickable document entry. +func (r *TaskRunResult) ArtifactMetadata() map[string]any { + if r == nil { + return nil + } + docID := "" + if r.DocRecord != nil { + docID = r.DocRecord.DocID + } + if docID == "" { + return nil + } + title := r.ArtifactTitle + if title == "" && r.TaskKey != "" { + title = squad.TaskArtifactTitle(r.TaskKey) + } + out := map[string]any{ + "doc_id": docID, + "title": title, + "task_key": r.TaskKey, + } + if r.WorkDir != "" { + out["workspace_id"] = r.WorkDir + } + if r.DocRecord != nil && r.DocRecord.ContentSHA256 != "" { + out["sha256"] = r.DocRecord.ContentSHA256 + } + if r.QualityReport != nil { + out["quality_passed"] = r.QualityReport.Passed + out["quality_score"] = r.QualityReport.Score + if len(r.QualityReport.Violations) > 0 { + out["quality_violations"] = r.QualityReport.Violations + } + } + if r.AwaitsAgentConfirmation() { + out["pending_agent_candidate_id"] = r.PendingAgentCandidateID + if r.PendingChangeReportID > 0 { + out["pending_change_report_id"] = r.PendingChangeReportID + } + if r.PendingRiskLevel != "" { + out["pending_risk_level"] = r.PendingRiskLevel + } + out["awaiting_author_confirmation"] = true + } + return out +} diff --git a/internal/bizdev/types.go b/internal/bizdev/types.go index cd0804cd..1f0aab5a 100644 --- a/internal/bizdev/types.go +++ b/internal/bizdev/types.go @@ -41,20 +41,20 @@ type Attachment = squad.Attachment type CaseSearchOptions = ctxmgr.CaseSearchOptions type TaskRunRequest struct { - TaskKey string `json:"task_key"` - Title string `json:"title,omitempty"` // 自由任务人类可读标题(非标任务自主规划使用) - 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 []MaterialInput `json:"materials"` - Backend string `json:"backend"` - Timeout time.Duration `json:"timeout"` - WorkDir string `json:"work_dir"` - CustomSteps []squad.StepSpec `json:"custom_steps,omitempty"` - AutoRepair bool `json:"auto_repair,omitempty"` + TaskKey string `json:"task_key"` + Title string `json:"title,omitempty"` // 自由任务人类可读标题(非标任务自主规划使用) + 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 []MaterialInput `json:"materials"` + Backend string `json:"backend"` + Timeout time.Duration `json:"timeout"` + WorkDir string `json:"work_dir"` + CustomSteps []squad.StepSpec `json:"custom_steps,omitempty"` + AutoRepair bool `json:"auto_repair,omitempty"` KnowledgeHits []squad.KnowledgeHit `json:"knowledge_hits,omitempty"` PriorDocs []squad.PriorDocItem `json:"prior_docs,omitempty"` SessionID string `json:"session_id,omitempty"` @@ -75,22 +75,30 @@ type TaskRunRequest struct { } type TaskRunResult struct { - TaskKey string `json:"task_key"` - ArtifactTitle string `json:"artifact_title"` - MarkdownBody string `json:"markdown_body"` - ExtractedData map[string]string `json:"extracted_data"` - Attachments []Attachment `json:"attachments"` - WorkDir string `json:"work_dir"` - BackendUsed string `json:"backend_used"` - Duration time.Duration `json:"duration"` - Success bool `json:"success"` - ErrorReason string `json:"error_reason,omitempty"` - QualityReport *squad.QualityReport `json:"quality_report,omitempty"` - Manifest *squad.Manifest `json:"manifest,omitempty"` - AdvisorBrief *AdvisorBriefing `json:"advisor_brief,omitempty"` - DocRecord *docgov.DocumentRecord `json:"doc_record,omitempty"` - PendingProposal *docgov.ChangeProposal `json:"pending_proposal,omitempty"` - RequiresAuthorApproval bool `json:"requires_author_approval,omitempty"` + TaskKey string `json:"task_key"` + ArtifactTitle string `json:"artifact_title"` + MarkdownBody string `json:"markdown_body"` + ExtractedData map[string]string `json:"extracted_data"` + Attachments []Attachment `json:"attachments"` + WorkDir string `json:"work_dir"` + BackendUsed string `json:"backend_used"` + Duration time.Duration `json:"duration"` + Success bool `json:"success"` + ErrorReason string `json:"error_reason,omitempty"` + QualityReport *squad.QualityReport `json:"quality_report,omitempty"` + Manifest *squad.Manifest `json:"manifest,omitempty"` + AdvisorBrief *AdvisorBriefing `json:"advisor_brief,omitempty"` + DocRecord *docgov.DocumentRecord `json:"doc_record,omitempty"` + PendingProposal *docgov.ChangeProposal `json:"pending_proposal,omitempty"` + // RequiresAuthorApproval: human must decide. Disambiguate: + // - PendingProposal != nil → DocGov claimed-doc change proposal + // - PendingAgentCandidateID > 0 → high-risk revision candidate (ledger not committed) + RequiresAuthorApproval bool `json:"requires_author_approval,omitempty"` + // Pending agent revision confirmation (high/critical): fact source stays on ledger head. + PendingAgentCandidateID int64 `json:"pending_agent_candidate_id,omitempty"` + PendingChangeReportID int64 `json:"pending_change_report_id,omitempty"` + PendingRiskLevel string `json:"pending_risk_level,omitempty"` + PendingConfirmationNote string `json:"pending_confirmation_note,omitempty"` } // StageRunRequest 运行指定成熟度阶段请求 diff --git a/internal/docrevision/pending_fact_source_test.go b/internal/docrevision/pending_fact_source_test.go new file mode 100644 index 00000000..79984be8 --- /dev/null +++ b/internal/docrevision/pending_fact_source_test.go @@ -0,0 +1,161 @@ +package docrevision_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/docrevision" +) + +func TestPendingHighRisk_DoesNotAdvanceFactSource_UntilConfirm(t *testing.T) { + ctx := context.Background() + dataDir := t.TempDir() + workDir := filepath.Join(dataDir, "ws-pending") + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatal(err) + } + docID := "doc_fact_pending" + oldMD := "# base\n\n法规引用 GB9706\n" + newMD := "# base\n\n" // deletes 法规 → high risk + + // Simulate squad already writing unconfirmed body to fact source. + if err := os.WriteFile(filepath.Join(workDir, "output.md"), []byte(newMD), 0o644); err != nil { + t.Fatal(err) + } + oldSHA := sha256.Sum256([]byte(docrevision.CanonicalizeMarkdown(oldMD))) + oldSHAStr := hex.EncodeToString(oldSHA[:]) + newSHA := sha256.Sum256([]byte(docrevision.CanonicalizeMarkdown(newMD))) + newSHAStr := hex.EncodeToString(newSHA[:]) + + eng := docgov.NewGovernanceEngine(nil) + if _, err := eng.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "a", 2, "r", 3, "p"); err != nil { + t.Fatal(err) + } + _ = eng.MarkDocumentContentUpdated(ctx, docID, oldSHAStr, false) + + rev := docrevision.NewService(nil) + rev.DataDir = dataDir + st, err := rev.OpenTemplateVersion(ctx, docID, oldMD, docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir) + if err != nil { + t.Fatal(err) + } + + mat, err := rev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ + DocID: docID, RunID: "run-pending-fact", OutputMarkdown: newMD, + ProjectID: 1, WorkDir: workDir, ActorKind: docrevision.ActorAgentSquad, + AutoCommitUserID: 1, + }) + if err != nil { + t.Fatalf("materialize: %v", err) + } + if mat.Candidate == nil || mat.Candidate.Status != docrevision.CandidateNeedsConfirmation { + t.Fatalf("want needs_confirmation, got %+v", mat.Candidate) + } + + // Restore fact source from ledger head (bizdev contract). + if err := rev.ProjectOutputMarkdown(docID, dataDir); err != nil { + t.Fatal(err) + } + if err := eng.MarkDocumentContentUpdated(ctx, docID, st.HeadSHA256, false); err != nil { + t.Fatal(err) + } + + gotMD, err := os.ReadFile(filepath.Join(workDir, "output.md")) + if err != nil { + t.Fatal(err) + } + if docrevision.CanonicalizeMarkdown(string(gotMD)) != docrevision.CanonicalizeMarkdown(oldMD) { + t.Fatalf("pending must restore output.md to ledger head; got %q want old", string(gotMD)) + } + doc, err := eng.GetDocument(ctx, docID) + if err != nil { + t.Fatal(err) + } + if doc.ContentSHA256 != st.HeadSHA256 { + t.Fatalf("document_records hash must stay on head while pending: got %s want %s", doc.ContentSHA256, st.HeadSHA256) + } + if doc.ContentSHA256 == newSHAStr { + t.Fatal("document_records must not carry unconfirmed candidate hash") + } + + // Confirm → project new head. + st2, err := rev.CommitAgentVersion(ctx, docID, mat.Candidate.ID, docrevision.CommitAgentDecision{ + Decision: "commit", Confirmed: true, ActorUserID: 1, ExpectedInputSHA: st.HeadSHA256, + }) + if err != nil { + t.Fatal(err) + } + if err := rev.ProjectOutputMarkdown(docID, dataDir); err != nil { + t.Fatal(err) + } + if err := eng.MarkDocumentContentUpdated(ctx, docID, st2.HeadSHA256, false); err != nil { + t.Fatal(err) + } + gotAfter, _ := os.ReadFile(filepath.Join(workDir, "output.md")) + if docrevision.CanonicalizeMarkdown(string(gotAfter)) != docrevision.CanonicalizeMarkdown(newMD) { + t.Fatalf("after confirm output.md must be candidate body, got %q", string(gotAfter)) + } + doc2, _ := eng.GetDocument(ctx, docID) + if doc2.ContentSHA256 != st2.HeadSHA256 { + t.Fatalf("after confirm hash want %s got %s", st2.HeadSHA256, doc2.ContentSHA256) + } +} + +func TestPendingHighRisk_RejectKeepsOldHead(t *testing.T) { + ctx := context.Background() + dataDir := t.TempDir() + workDir := filepath.Join(dataDir, "ws-reject") + _ = os.MkdirAll(workDir, 0o755) + docID := "doc_fact_reject" + oldMD := "# base\n\n法规引用 YY0505\n" + newMD := "# base\n\n" + + _ = os.WriteFile(filepath.Join(workDir, "output.md"), []byte(newMD), 0o644) + eng := docgov.NewGovernanceEngine(nil) + _, _ = eng.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "a", 2, "r", 3, "p") + rev := docrevision.NewService(nil) + rev.DataDir = dataDir + st, err := rev.OpenTemplateVersion(ctx, docID, oldMD, docrevision.ActorSnapshot{}, 1, workDir) + if err != nil { + t.Fatal(err) + } + mat, err := rev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ + DocID: docID, RunID: "run-rej", OutputMarkdown: newMD, WorkDir: workDir, + ActorKind: docrevision.ActorAgentSquad, AutoCommitUserID: 1, + }) + if err != nil { + t.Fatal(err) + } + if mat.Candidate.Status != docrevision.CandidateNeedsConfirmation { + t.Fatalf("want needs_confirmation got %s", mat.Candidate.Status) + } + _ = rev.ProjectOutputMarkdown(docID, dataDir) + _ = eng.MarkDocumentContentUpdated(ctx, docID, st.HeadSHA256, false) + + st2, err := rev.CommitAgentVersion(ctx, docID, mat.Candidate.ID, docrevision.CommitAgentDecision{ + Decision: "reject", Confirmed: true, ActorUserID: 1, + }) + if err != nil { + t.Fatal(err) + } + if err := rev.ProjectOutputMarkdown(docID, dataDir); err != nil { + t.Fatal(err) + } + _ = eng.MarkDocumentContentUpdated(ctx, docID, st2.HeadSHA256, false) + + got, _ := os.ReadFile(filepath.Join(workDir, "output.md")) + if docrevision.CanonicalizeMarkdown(string(got)) != docrevision.CanonicalizeMarkdown(oldMD) { + t.Fatalf("reject must keep old head on disk, got %q", string(got)) + } + if st2.CurrentVersionNo != 1 { + t.Fatalf("reject must not advance version, got V%d", st2.CurrentVersionNo) + } + if st2.PendingCandidateID != nil { + t.Fatalf("pending must clear after reject") + } +} diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go index e625ba63..fba58a8d 100644 --- a/internal/docrevision/service.go +++ b/internal/docrevision/service.go @@ -388,6 +388,16 @@ func (s *Service) MaterializeAgentOutput(ctx context.Context, req AgentMateriali return nil, err } + if strings.TrimSpace(req.WorkDir) != "" { + if err := s.SetDocWorkDir(ctx, req.DocID, req.WorkDir); err != nil { + return nil, err + } + st, err = s.GetRevisionState(ctx, req.DocID) + if err != nil { + return nil, err + } + } + cand, rep, err := s.CreateAgentCandidate(ctx, AgentCandidateRequest{ DocID: req.DocID, RunID: req.RunID, ProposalID: req.ProposalID, InputSnapshotID: st.HeadSnapshotID, OutputMarkdown: req.OutputMarkdown, ReportJSON: req.ReportJSON, @@ -844,6 +854,27 @@ func (s *Service) ImportFreeformLegacy(ctx context.Context, docID string, ownerI return s.mustStateLocked(docID), nil } +// SetDocWorkDir updates the in-memory ledger root workDir used by ProjectOutputMarkdown. +func (s *Service) SetDocWorkDir(ctx context.Context, docID, workDir string) error { + if err := requireDocID(docID); err != nil { + return err + } + if strings.TrimSpace(workDir) == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if err := s.hydrateDocLocked(ctx, docID); err != nil { + return err + } + root := s.roots[docID] + if root == nil { + return ErrNotFound + } + root.WorkDir = workDir + return nil +} + // ProjectOutputMarkdown writes DB head to workDir/output.md (compatibility projection). func (s *Service) ProjectOutputMarkdown(docID, repoRoot string) error { s.mu.Lock() diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go index a2de0ef2..0b5caec4 100644 --- a/internal/server/document_revision_handlers.go +++ b/internal/server/document_revision_handlers.go @@ -331,6 +331,7 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht writeJSONResponse(w, http.StatusBadRequest, map[string]any{"error": "invalid json"}) return } + cand, _ := rev.GetCandidate(r.Context(), docID, cid) st, err := rev.CommitAgentVersion(r.Context(), docID, cid, docrevision.CommitAgentDecision{ Decision: body.Decision, Confirmed: body.Confirmed, ExpectedInputSHA: body.ExpectedInputSHA, ExpectedOutputSHA: body.ExpectedOutputSHA, @@ -341,6 +342,32 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht writeDocRevisionError(w, err) return } + + // Always project ledger head to fact source after decision: + // commit → new head; reject → keep/restore previous head (never leave squad draft on disk). + if err := rev.ProjectOutputMarkdown(docID, s.dataRoot()); err != nil { + writeJSONResponse(w, http.StatusInternalServerError, map[string]any{ + "code": "DOCUMENT_PROJECTION_FAILED", + "error": err.Error(), + "hint": "候选决策已写入账本,但工作区 output.md 投影失败;请重试决策或人工修复。", + "state": st, + }) + return + } + if s.svc != nil && s.svc.DocGov() != nil && st != nil { + if err := s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, st.HeadSHA256, false); err != nil { + writeJSONResponse(w, http.StatusInternalServerError, map[string]any{ + "code": "DOCUMENT_PROJECTION_FAILED", + "error": err.Error(), + "hint": "候选决策已写入账本,但 document_records 哈希投影失败;请重试。", + "state": st, + }) + return + } + if body.Decision == "commit" && cand != nil && strings.TrimSpace(cand.ProposalID) != "" { + _ = s.svc.DocGov().MarkProposalApplied(r.Context(), cand.ProposalID) + } + } writeJSONResponse(w, http.StatusOK, st) }