From 645c4b67b0e01e3d8908978ea7ba991ff1b044c0 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 09:32:08 +0000 Subject: [PATCH] fix(bizdev): surface Materialize failures and block invalid auto-commit TaskRunResult now fails visibly when ledger/report materialization errors; invalid or unassessed reports are never auto-committed. Co-authored-by: Cursor --- internal/bizdev/doc_id_test.go | 30 +++++++++++++++++ internal/bizdev/service.go | 23 +++++++++++-- internal/docrevision/materialize_test.go | 42 ++++++++++++++++++++++++ internal/docrevision/service.go | 19 ++++++++--- 4 files changed, 108 insertions(+), 6 deletions(-) diff --git a/internal/bizdev/doc_id_test.go b/internal/bizdev/doc_id_test.go index 0c45faa8..5c30940e 100644 --- a/internal/bizdev/doc_id_test.go +++ b/internal/bizdev/doc_id_test.go @@ -200,3 +200,33 @@ func TestExecuteTask_EmptyDocIDSkipsDocRevision(t *testing.T) { t.Fatalf("empty GetRevisionState should be invalid, got %v", stErr) } } + +func TestExecuteTask_MaterializeFailureSurfacesInTaskRunResult(t *testing.T) { + svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) + ctx := context.Background() + svc.DocRevision().AgentCommit = false // force MaterializeAgentOutput to fail after squad success + + res, err := svc.ExecuteTask(ctx, TaskRunRequest{ + TaskKey: "research", + ProjectID: 56, + ProjectName: "智能体演示项目", + CustomerName: "北京水木东方", + DeviceType: "医疗器械", + Backend: "stub", + Timeout: 10 * time.Second, + DocID: "doc-p56-research", + UserID: 1, + }) + if err == nil { + t.Fatal("expected Materialize error to be returned") + } + if res == nil { + t.Fatal("TaskRunResult must be non-nil with visible failure") + } + if res.Success { + t.Fatal("TaskRunResult.Success must be false when ledger/report materialize fails") + } + if !strings.Contains(res.ErrorReason, "文档版本账本/变更报告落盘失败") { + t.Fatalf("ErrorReason must be visible to callers, got %q", res.ErrorReason) + } +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index a9627dc8..16949750 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -744,15 +744,34 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun } // 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. if s.docRev != nil && docRec != nil && strings.TrimSpace(docRec.DocID) != "" { actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed} - _, _ = s.docRev.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ + if _, 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 { + reason := fmt.Sprintf("文档版本账本/变更报告落盘失败: %v", matErr) + 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: false, + ErrorReason: reason, + QualityReport: res.QualityReport, + Manifest: res.Manifest, + AdvisorBrief: res.AdvisorBrief, + DocRecord: docRec, + }, matErr + } } } diff --git a/internal/docrevision/materialize_test.go b/internal/docrevision/materialize_test.go index c30d2b4b..48b8acb6 100644 --- a/internal/docrevision/materialize_test.go +++ b/internal/docrevision/materialize_test.go @@ -2,6 +2,7 @@ package docrevision_test import ( "context" + "errors" "strings" "testing" @@ -111,3 +112,44 @@ func TestMaterializeAgentOutput_EmptyOutputRejected(t *testing.T) { t.Fatalf("want ErrNeedsContent got %v", err) } } + +func TestMaterializeAgentOutput_InvalidReportDoesNotAutoCommit(t *testing.T) { + svc := docrevision.NewService(nil) + ctx := context.Background() + docID := "doc_invalid_report" + out := "# V\n\nbody after agent\n" + + res, err := svc.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ + DocID: docID, RunID: "run-bad-report", OutputMarkdown: out, + ReportJSON: `{"schema_version":99,"summary":["x"]}`, + ProjectID: 1, + ActorKind: docrevision.ActorAgentSquad, + AutoCommitUserID: 7, + }) + if err == nil { + t.Fatal("expected error for invalid report") + } + if !errors.Is(err, docrevision.ErrReportInvalid) { + t.Fatalf("want ErrReportInvalid, got %v", err) + } + if res == nil || res.Candidate == nil { + t.Fatalf("candidate should still be returned for visibility, got %+v", res) + } + if res.Candidate.Status == docrevision.CandidateCommitted { + t.Fatal("invalid/unassessed report must not auto-commit") + } + if res.Candidate.Status != docrevision.CandidateNeedsConfirmation { + t.Fatalf("status want needs_confirmation got %q", res.Candidate.Status) + } + if res.Report != nil && res.Report.AnalysisStatus != docrevision.AnalysisUnassessed { + t.Fatalf("analysis want unassessed got %q", res.Report.AnalysisStatus) + } + st, stErr := svc.GetRevisionState(ctx, docID) + if stErr != nil { + t.Fatal(stErr) + } + // Still on template/open baseline version — no agent version sealed in. + if st.CurrentVersionNo != 1 || st.HeadRevisionNo != 0 { + t.Fatalf("head must remain pre-commit baseline, got %+v", st) + } +} diff --git a/internal/docrevision/service.go b/internal/docrevision/service.go index 784ff935..e38d0a97 100644 --- a/internal/docrevision/service.go +++ b/internal/docrevision/service.go @@ -330,6 +330,7 @@ type AgentMaterializeRequest struct { ProposalID string OutputMarkdown string PriorMarkdown string // optional; when absent/equal to output, EmptyGenerationBaseline is used for first gen + ReportJSON string // optional; empty → deterministic fallback report (never invented PI prose) ProjectID int64 WorkDir string ActorKind string @@ -349,6 +350,7 @@ type AgentMaterializeResult struct { // MaterializeAgentOutput ensures an open ledger, creates an agent candidate with a deterministic // change report (analysis_status=fallback when no PI narrative JSON), and optionally auto-commits // when risk does not require confirmation. First generation always yields a visible report. +// Invalid or unassessed reports are never auto-committed even if a candidate row exists. func (s *Service) MaterializeAgentOutput(ctx context.Context, req AgentMaterializeRequest) (*AgentMaterializeResult, error) { if err := requireDocID(req.DocID); err != nil { return nil, err @@ -386,13 +388,20 @@ func (s *Service) MaterializeAgentOutput(ctx context.Context, req AgentMateriali cand, rep, err := s.CreateAgentCandidate(ctx, AgentCandidateRequest{ DocID: req.DocID, RunID: req.RunID, ProposalID: req.ProposalID, - InputSnapshotID: st.HeadSnapshotID, OutputMarkdown: req.OutputMarkdown, ReportJSON: "", + InputSnapshotID: st.HeadSnapshotID, OutputMarkdown: req.OutputMarkdown, ReportJSON: req.ReportJSON, ActorKind: req.ActorKind, ActorSnapshot: req.ActorSnapshot, OnBehalfOf: req.OnBehalfOf, }) if cand == nil { return nil, err } out := &AgentMaterializeResult{Candidate: cand, Report: rep, FirstGeneration: first, State: st} + // Never auto-commit when report validation failed / unassessed, even if cand != nil. + if err != nil { + return out, err + } + if rep != nil && rep.AnalysisStatus == AnalysisUnassessed { + return out, fmt.Errorf("%w: unassessed report cannot auto-commit", ErrReportInvalid) + } if cand.Status == CandidateStale { return out, ErrCandidateStale } @@ -409,7 +418,6 @@ func (s *Service) MaterializeAgentOutput(ctx context.Context, req AgentMateriali return out, cErr } out.State = st2 - // Refresh candidate after commit if c2, gErr := s.GetCandidate(ctx, req.DocID, cand.ID); gErr == nil { out.Candidate = c2 } @@ -464,8 +472,11 @@ func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRe rep, err := ValidateChangeReportJSON(reportJSON, inSnap.ContentSHA256, outSHA, ruleRisk, impacts, diff.Hunks) status := CandidateReady if err != nil { - status = CandidateReady - rep.AnalysisStatus = AnalysisUnassessed + // Invalid reports stay visible as candidates but must not look "ready" for auto-commit. + status = CandidateNeedsConfirmation + if rep != nil { + rep.AnalysisStatus = AnalysisUnassessed + } } if strings.TrimSpace(req.ReportJSON) == "" && rep != nil && rep.AnalysisStatus == AnalysisComplete { rep.AnalysisStatus = AnalysisFallback