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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user