Files
weichen c713834954 fix(bizdev): commit squad ledger as the document author
Non-author triggers were denied by dutyAuthorizer because AutoCommitUserID was the triggering user. Record that user on OnBehalfOf and surface the duty mismatch in the task error.
2026-09-28 01:24:25 +00:00

331 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package bizdev
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
"chat-agent-system/internal/projectworkflow"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/workflowdef"
)
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
}
// squadCommitActor 让受控账本提交通过编制人职责门。
// 触发者不是编制人时,以文档编制人作为 AutoCommitUserID,并把触发者写入 OnBehalfOf。
// 触发者或编制人缺失时保持原触发者,避免把 0 写成代理身份。
func squadCommitActor(triggerUserID int64, docRec *docgov.DocumentRecord) (int64, *int64) {
if docRec == nil || docRec.AuthorID <= 0 || triggerUserID <= 0 || docRec.AuthorID == triggerUserID {
return triggerUserID, nil
}
onBehalf := triggerUserID
return docRec.AuthorID, &onBehalf
}
func qualityFailedErrorReason(report *squad.QualityReport) string {
if report == nil {
return "质量门禁未通过"
}
if len(report.Violations) > 0 {
return "质量门禁未通过:" + strings.Join(report.Violations, ";")
}
return fmt.Sprintf("质量门禁未通过(评分 %.0f/100)", report.Score)
}
// finalizeSquadDeliverable 处理小队返回后的受控文档投影边界。
// 质量失败必须在 SetDocumentWorkDir / Materialize / MarkDocumentContentUpdated / 完成活动之前短路。
func (s *Service) finalizeSquadDeliverable(
ctx context.Context,
req TaskRunRequest,
res *squad.SquadRunResult,
docID string,
docRec *docgov.DocumentRecord,
appliedProposalID string,
upstreamSRDoc *docgov.DocumentRecord,
) (*TaskRunResult, error) {
if res == nil {
return &TaskRunResult{TaskKey: req.TaskKey, Success: false, ErrorReason: "小队无返回结果"}, nil
}
// 质量门禁短路:保留隔离工作区与可见失败摘要,不改正式 doc work_dir/head/hash,不激活账本版本。
if qualityBlocksControlledProjection(res.QualityReport) {
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: qualityFailedErrorReason(res.QualityReport),
QualityReport: res.QualityReport,
Manifest: res.Manifest,
AdvisorBrief: res.AdvisorBrief,
DocRecord: docRec,
}, nil
}
// RecordActivity for "完成交付物" must NOT run here: Materialize may still fail or
// leave needs_confirmation. Activity is recorded only after a committed projection
// (or a pending-awaiting note that is explicitly not completion).
if res.Success && s.docGov != nil && strings.TrimSpace(docID) != "" && docRec != nil {
_ = s.docGov.SetDocumentWorkDir(ctx, docID, res.WorkDir)
dataRoot := "."
if s.cfg != nil && s.cfg.DataDir != "" {
dataRoot = s.cfg.DataDir
}
// 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}
commitUserID, onBehalfOf := squadCommitActor(req.UserID, docRec)
mat, matErr := s.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,
OnBehalfOf: onBehalfOf,
AutoCommitUserID: commitUserID,
})
if matErr != nil {
reason := fmt.Sprintf("文档版本账本/变更报告落盘失败: %v", matErr)
if errors.Is(matErr, uow.ErrPermissionDenied) {
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
}
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,
)
s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle,
fmt.Sprintf("交付物【%s】候选待编制人确认", res.ArtifactTitle),
map[string]any{
"task_key": res.TaskKey,
"work_dir": res.WorkDir,
"doc_id": docID,
"pending_agent_candidate_id": pendCand,
"pending_change_report_id": pendRep,
"pending_risk_level": risk,
"awaiting_author_confirmation": true,
}, true)
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 != "" {
if err := s.docGov.MarkProposalApplied(ctx, appliedProposalID); 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
}
}
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
docRec = latest
}
s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle,
fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle),
map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir, "doc_id": docID}, true)
} 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 != "" {
if err := s.docGov.MarkProposalApplied(ctx, appliedProposalID); 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
}
}
if latest, err := s.docGov.GetDocument(ctx, docID); err == nil {
docRec = latest
}
s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle,
fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle),
map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir, "doc_id": docID}, true)
}
} else if res.Success {
// Uncontrolled / no DocGov path: squad success is the completion boundary.
s.recordSquadDeliveryActivity(ctx, req, res.ArtifactTitle,
fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle),
map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir}, true)
}
if res.Success && workflowdef.SameTaskKey(req.TaskKey, "test_manager") && upstreamSRDoc != nil && s.projectWorkflow != nil && req.ProjectID > 0 {
_ = s.projectWorkflow.RecordLineage(ctx, &projectworkflow.DocumentLineage{
ProjectID: req.ProjectID,
DownstreamDocID: docID,
DownstreamVersion: lineageVersion(docRec),
UpstreamDocID: upstreamSRDoc.DocID,
UpstreamVersion: lineageVersion(upstreamSRDoc),
UpstreamSHA256: upstreamSRDoc.ContentSHA256,
IsStale: false,
})
}
// Stale propagation only after fact source committed (not while pending confirmation).
if res.Success && workflowdef.SameTaskKey(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, "上游系统需求规格书已更新,下游验证方案需复核")
}
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: res.Success,
ErrorReason: res.ErrorReason,
QualityReport: res.QualityReport,
Manifest: res.Manifest,
AdvisorBrief: res.AdvisorBrief,
DocRecord: docRec,
}, nil
}
// recordSquadDeliveryActivity writes project activity only for explicit outcomes.
// Callers must not invoke this for Materialize/projection failures.
func (s *Service) recordSquadDeliveryActivity(ctx context.Context, req TaskRunRequest, artifactTitle, title string, detail map[string]any, milestone bool) {
if s == nil || s.ctxMgr == nil || req.ProjectID <= 0 || strings.TrimSpace(title) == "" {
return
}
if detail == nil {
detail = map[string]any{}
}
if _, ok := detail["task_key"]; !ok && req.TaskKey != "" {
detail["task_key"] = req.TaskKey
}
if artifactTitle != "" {
detail["artifact_title"] = artifactTitle
}
_, _ = s.ctxMgr.RecordActivity(ctx, req.ProjectID, req.UserID, "系统智能体小队", "operation",
title, detail, 1.0, milestone)
}
// ExecuteStage 执行指定成熟度阶段