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:
weichen
2026-09-13 09:55:42 +00:00
co-authored by Cursor
parent d07d201728
commit c990bcdb89
7 changed files with 280 additions and 11 deletions
@@ -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 实战验收
+127
View File
@@ -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")
}
}
+60 -8
View File
@@ -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)
+20
View File
@@ -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")
}
}
+7 -1
View File
@@ -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)