diff --git a/.doc/ops/short-term/2026-09-13-document-revision-runtime.md b/.doc/ops/short-term/2026-09-13-document-revision-runtime.md index 364982cb..c72c9edd 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-runtime.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-runtime.md @@ -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 实战验收 diff --git a/internal/bizdev/activity_outcome_test.go b/internal/bizdev/activity_outcome_test.go new file mode 100644 index 00000000..1619f65a --- /dev/null +++ b/internal/bizdev/activity_outcome_test.go @@ -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") + } +} diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 59f7ae00..c00646c1 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -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) diff --git a/internal/docgov/engine_test.go b/internal/docgov/engine_test.go index 9fffb2dd..5008db44 100644 --- a/internal/docgov/engine_test.go +++ b/internal/docgov/engine_test.go @@ -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") + } +} diff --git a/internal/docgov/lease.go b/internal/docgov/lease.go index aff1163d..490aa43e 100644 --- a/internal/docgov/lease.go +++ b/internal/docgov/lease.go @@ -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 } diff --git a/internal/server/document_revision_authz_test.go b/internal/server/document_revision_authz_test.go index 4e197c09..39b18ef1 100644 --- a/internal/server/document_revision_authz_test.go +++ b/internal/server/document_revision_authz_test.go @@ -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()) + } +} diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go index 0b5caec4..20843eac 100644 --- a/internal/server/document_revision_handlers.go +++ b/internal/server/document_revision_handlers.go @@ -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)