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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 09:32:08 +00:00
co-authored by Cursor
parent 4a91105e99
commit 645c4b67b0
4 changed files with 108 additions and 6 deletions
+30
View File
@@ -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)
}
}
+21 -2
View File
@@ -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
}
}
}
+42
View File
@@ -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)
}
}
+15 -4
View File
@@ -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