fix(bizdev): record delivery activity only after commit; surface proposal mark errors
Move RecordActivity off the pre-Materialize path: pending writes an explicit awaiting note, failures skip completion, committed records 完成交付物. MarkProposalApplied failures now return retryable governance/projection errors. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -62,7 +62,7 @@ go test -short ./...
|
||||
cd web && npm test && npm run build
|
||||
```
|
||||
|
||||
本轮门禁(2026-09-13):`go vet ./...` PASS;`go test -short ./...` PASS(含 Materialize 首/二轮 change_report_id、PATCH 顺序、Materialize 失败透出、Assert 先职责后租约、SaveDocumentContent 审计、pending 事实源不越权、同步/异步待确认文案);`web npm test` 197+24 PASS;`npm run build` PASS。
|
||||
本轮门禁(2026-09-13):`go vet ./...` PASS;`go test -short ./...` PASS(含 Materialize 首/二轮 change_report_id、PATCH 顺序、Materialize 失败透出、Assert 先职责后租约、SaveDocumentContent 审计、pending 事实源不越权、同步/异步待确认文案、完成活动仅 committed、pending 活动标明待确认、MarkProposalApplied 失败显式可重试);`web npm test` 197+24 PASS;`npm run build` PASS。
|
||||
|
||||
复核补强(同日):
|
||||
- A:`MaterializeAgentOutput` 错误写入 `TaskRunResult.Success=false` + `ErrorReason`,不再空白忽略
|
||||
@@ -72,6 +72,7 @@ cd web && npm test && npm run build
|
||||
- E:revision-state 暴露 `pending_candidate_id` / `pending_change_report_id` / `pending_risk_level` + `can_decide_pending`;工具条「待确认报告」确认/拒绝调 decision API(不绕过确认)
|
||||
- F:**高风险事实源门禁**:Materialize 后仅 committed 才 MarkDocumentContentUpdated / MarkProposalApplied;`needs_confirmation` 立即 `ProjectOutputMarkdown` 恢复 ledger head,候选仅留 immutable blob;decision=commit 投影后再 Mark;decision=reject 保持旧 head;投影失败显式错误
|
||||
- G:**对话可见状态**:`TaskRunResult` 带 `pending_agent_candidate_id` / report / risk;`RequiresAuthorApproval` 可复用但用 `PendingProposal` vs `PendingAgentCandidateID` 区分 DocGov 提案;terminal=`pending_author_confirmation`;同步 coordinator 与异步 `CompletionSummary` 显示「文档候选已生成,待编制人确认」+ artifact 元数据;确认并投影后才「已完成」
|
||||
- H:**活动与提案收口**:`RecordActivity(完成交付物)` 仅在 committed 投影成功后;pending 记「候选待编制人确认」;落盘/投影失败不记完成。`MarkProposalApplied` 失败返回显式错误(bizdev / `DOCUMENT_GOVERNANCE_FAILED`)可重试
|
||||
|
||||
### 用户 UI 实战验收
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package bizdev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
)
|
||||
|
||||
func activityTitles(t *testing.T, svc *Service, projectID int64) []string {
|
||||
t.Helper()
|
||||
pCtx, err := svc.ContextManager().GetProjectContext(context.Background(), projectID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProjectContext: %v", err)
|
||||
}
|
||||
var titles []string
|
||||
for _, a := range pCtx.AllActivities {
|
||||
titles = append(titles, a.Title)
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
func TestExecuteTask_MaterializeFailure_DoesNotRecordCompletedActivity(t *testing.T) {
|
||||
svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})
|
||||
ctx := context.Background()
|
||||
svc.DocRevision().AgentCommit = false
|
||||
projectID := int64(5601)
|
||||
|
||||
res, err := svc.ExecuteTask(ctx, TaskRunRequest{
|
||||
TaskKey: "research", ProjectID: projectID, ProjectName: "P",
|
||||
CustomerName: "C", DeviceType: "医疗器械", Backend: "stub",
|
||||
Timeout: 10 * time.Second, DocID: "doc-act-fail", UserID: 1,
|
||||
})
|
||||
if err == nil || res == nil || res.Success {
|
||||
t.Fatalf("want materialize failure, err=%v res=%+v", err, res)
|
||||
}
|
||||
for _, title := range activityTitles(t, svc, projectID) {
|
||||
if strings.Contains(title, "完成交付物") {
|
||||
t.Fatalf("must not record completion activity on materialize failure: %q", title)
|
||||
}
|
||||
if strings.Contains(title, "待编制人确认") {
|
||||
t.Fatalf("must not record pending activity on materialize failure: %q", title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTask_PendingConfirmation_RecordsAwaitingNotCompleted(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
svc := MustNewService(t, &config.Config{DataDir: dataDir, AgentBackend: "stub"})
|
||||
ctx := context.Background()
|
||||
projectID := int64(5602)
|
||||
docID := "doc_act_pending"
|
||||
workDir := filepath.Join(dataDir, "ws-act-pending")
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := "# base\n\n法规引用 GB9706\n"
|
||||
if _, err := svc.DocRevision().OpenTemplateVersion(ctx, docID, base, docrevision.ActorSnapshot{DisplayName: "t"}, projectID, workDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.DocGov().CreateOrGetDocument(ctx, docID, projectID, "research", "调研报告", "V0.5", workDir, 1, "a", 2, "r", 3, "p"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Stub output omits 法规 → deterministic high risk → needs_confirmation.
|
||||
res, err := svc.ExecuteTask(ctx, TaskRunRequest{
|
||||
TaskKey: "research", ProjectID: projectID, ProjectName: "P",
|
||||
CustomerName: "C", DeviceType: "医疗器械", Backend: "stub",
|
||||
Timeout: 10 * time.Second, DocID: docID, UserID: 1, WorkDir: workDir,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteTask: %v", err)
|
||||
}
|
||||
if res == nil || !res.AwaitsAgentConfirmation() {
|
||||
t.Fatalf("want pending confirmation, got success=%v pending=%d reason=%s",
|
||||
res != nil && res.Success, res.PendingAgentCandidateID, res.ErrorReason)
|
||||
}
|
||||
|
||||
titles := activityTitles(t, svc, projectID)
|
||||
foundPending, foundCompleted := false, false
|
||||
for _, title := range titles {
|
||||
if strings.Contains(title, "待编制人确认") {
|
||||
foundPending = true
|
||||
}
|
||||
if strings.Contains(title, "完成交付物") {
|
||||
foundCompleted = true
|
||||
}
|
||||
}
|
||||
if !foundPending {
|
||||
t.Fatalf("expected pending activity, titles=%v", titles)
|
||||
}
|
||||
if foundCompleted {
|
||||
t.Fatalf("pending must not record 完成交付物, titles=%v", titles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTask_Committed_RecordsCompletedActivity(t *testing.T) {
|
||||
svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})
|
||||
ctx := context.Background()
|
||||
projectID := int64(5603)
|
||||
res, err := svc.ExecuteTask(ctx, TaskRunRequest{
|
||||
TaskKey: "intake", ProjectID: projectID, ProjectName: "P",
|
||||
CustomerName: "C", DeviceType: "医疗器械", Backend: "stub",
|
||||
Timeout: 10 * time.Second, DocID: "doc_act_ok", UserID: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteTask: %v reason=%v", err, res)
|
||||
}
|
||||
if res.AwaitsAgentConfirmation() {
|
||||
t.Fatal("first-gen intake should auto-commit, not pending")
|
||||
}
|
||||
found := false
|
||||
for _, title := range activityTitles(t, svc, projectID) {
|
||||
if strings.Contains(title, "完成交付物") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected 完成交付物 after committed materialize")
|
||||
}
|
||||
}
|
||||
@@ -724,12 +724,9 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.Success && s.ctxMgr != nil && req.ProjectID > 0 {
|
||||
_, _ = s.ctxMgr.RecordActivity(ctx, req.ProjectID, req.UserID, "系统智能体小队", "operation",
|
||||
fmt.Sprintf("完成交付物【%s】", res.ArtifactTitle),
|
||||
map[string]any{"task_key": res.TaskKey, "work_dir": res.WorkDir},
|
||||
1.0, true)
|
||||
}
|
||||
// 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)
|
||||
@@ -810,6 +807,17 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
"高风险变更(%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,
|
||||
@@ -850,23 +858,48 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
}, err
|
||||
}
|
||||
if appliedProposalID != "" {
|
||||
_ = s.docGov.MarkProposalApplied(ctx, 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 != "" {
|
||||
_ = s.docGov.MarkProposalApplied(ctx, 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 && req.TaskKey == "test_manager" && upstreamSRDoc != nil && s.projectWorkflow != nil && req.ProjectID > 0 {
|
||||
@@ -909,6 +942,25 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
|
||||
}, 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 执行指定成熟度阶段
|
||||
func (s *Service) ExecuteStage(ctx context.Context, req StageRunRequest) (*StageRunResult, error) {
|
||||
return s.executeStageInternal(ctx, req)
|
||||
|
||||
@@ -249,3 +249,23 @@ func TestDocGov_FullApprovalLifecycleAndRejectRollback(t *testing.T) {
|
||||
t.Fatalf("expected at least 5 audit logs, got %d", len(logs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkProposalApplied_MissingOrNotApproved(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
engine := docgov.NewGovernanceEngine(nil)
|
||||
if err := engine.MarkProposalApplied(ctx, "prop_missing"); err != docgov.ErrProposalNotFound {
|
||||
t.Fatalf("missing want ErrProposalNotFound got %v", err)
|
||||
}
|
||||
docID := "doc_mark_prop"
|
||||
_, err := engine.CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "", 1, "a", 2, "r", 3, "p")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prop, err := engine.ProposeModification(ctx, docID, "agent", "改", "影响", "风险")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := engine.MarkProposalApplied(ctx, prop.ProposalID); err == nil {
|
||||
t.Fatal("unapproved proposal must not MarkProposalApplied")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,9 +280,15 @@ func (e *GovernanceEngine) MarkProposalApplied(ctx context.Context, proposalID s
|
||||
}
|
||||
p.Status = ProposalApplied
|
||||
if e.pool != nil {
|
||||
_, _ = e.pool.Exec(ctx, `
|
||||
tag, err := e.pool.Exec(ctx, `
|
||||
UPDATE document_change_proposals SET status = $1 WHERE proposal_id = $2
|
||||
`, string(ProposalApplied), proposalID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("docgov: mark proposal applied: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("docgov: mark proposal applied: no rows updated for %s", proposalID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -186,3 +186,58 @@ func TestDocRevision_PendingCandidateExposedAndDecisionRequiresAuthor(t *testing
|
||||
t.Fatalf("confirmed commit want 200 got %d %s", wOK.Code, wOK.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocRevision_CommitWithMissingProposalReturnsGovernanceError(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
docID := "doc_proj1_gov_prop"
|
||||
if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", "", 1, "admin", 2, "r", 3, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# base\n\n法规 GB9706\n", docrevision.ActorSnapshot{}, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cand, _, err := srv.svc.DocRevision().CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
|
||||
DocID: docID, RunID: "gov-miss-1", ProposalID: "prop_does_not_exist",
|
||||
InputSnapshotID: st.HeadSnapshotID,
|
||||
OutputMarkdown: "# base\n\n", ActorKind: docrevision.ActorAgentSquad,
|
||||
})
|
||||
if err != nil && cand == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cand.Status != docrevision.CandidateNeedsConfirmation {
|
||||
t.Fatalf("want needs_confirmation got %s", cand.Status)
|
||||
}
|
||||
|
||||
token := loginTestAdmin(t, srv)
|
||||
decOK := httptest.NewRequest(http.MethodPost,
|
||||
"/api/v1/documents/"+docID+"/agent-candidates/"+strconv.FormatInt(cand.ID, 10)+"/decision",
|
||||
bytes.NewReader([]byte(`{"decision":"commit","confirmed":true,"expected_input_sha":"`+st.HeadSHA256+`"}`)))
|
||||
withBearerAuth(decOK, token)
|
||||
wOK := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(wOK, decOK)
|
||||
if wOK.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("missing proposal mark want 500 got %d %s", wOK.Code, wOK.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(wOK.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["code"] != "DOCUMENT_GOVERNANCE_FAILED" {
|
||||
t.Fatalf("code=%v body=%#v", body["code"], body)
|
||||
}
|
||||
// Retryable: ledger already committed — second call should still surface governance error
|
||||
// until proposal exists (or mark becomes no-op when already applied).
|
||||
wRetry := httptest.NewRecorder()
|
||||
decRetry := httptest.NewRequest(http.MethodPost,
|
||||
"/api/v1/documents/"+docID+"/agent-candidates/"+strconv.FormatInt(cand.ID, 10)+"/decision",
|
||||
bytes.NewReader([]byte(`{"decision":"commit","confirmed":true,"expected_input_sha":"`+st.HeadSHA256+`"}`)))
|
||||
withBearerAuth(decRetry, token)
|
||||
srv.Routes().ServeHTTP(wRetry, decRetry)
|
||||
// Candidate already committed → decision may be conflict/not pending; either governance
|
||||
// retry path or explicit conflict is acceptable as long as we did not silently 200.
|
||||
if wRetry.Code == http.StatusOK {
|
||||
t.Fatalf("retry after governance failure must not silently succeed: %s", wRetry.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +365,15 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
if body.Decision == "commit" && cand != nil && strings.TrimSpace(cand.ProposalID) != "" {
|
||||
_ = s.svc.DocGov().MarkProposalApplied(r.Context(), cand.ProposalID)
|
||||
if err := s.svc.DocGov().MarkProposalApplied(r.Context(), cand.ProposalID); err != nil {
|
||||
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
||||
"code": "DOCUMENT_GOVERNANCE_FAILED",
|
||||
"error": err.Error(),
|
||||
"hint": "候选已提交且事实源已投影,但变更提案标记为已应用失败;请用同一决策重试以完成治理收口。",
|
||||
"state": st,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSONResponse(w, http.StatusOK, st)
|
||||
|
||||
Reference in New Issue
Block a user