From f8730791b9a0a9d4ac67586f622acb6afaf43f10 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 12:34:32 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(coordinator):=20=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E9=A1=B9=E7=9B=AE=E7=BB=91=E5=AE=9A=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=8F=97=E6=8E=A7=E6=8A=95=E5=BD=B1=E8=A2=AB?= =?UTF-8?q?=E8=B7=B3=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聊天入口只把项目 ID 放在授权字段,协调器仍读空的 UserCtx.Project.ID,受控任务因此以 project_id=0 跳过账本投影,回复却称正文已保存。 --- .doc/ops/CURRENT.md | 1 + internal/bizdev/doc_id_test.go | 17 +++--- internal/bizdev/quality_projection_test.go | 2 +- internal/bizdev/service_task_run.go | 29 ++++++++- internal/bizdev/service_test.go | 3 + internal/bizdev/task_result_status.go | 3 + internal/bizdev/task_result_status_test.go | 12 ++++ internal/bizdev/testutil.go | 10 +++ internal/bizdev/types_test.go | 4 ++ internal/coordinator/engine_task.go | 16 ++++- .../pending_confirmation_reply_test.go | 61 ++++++++++++++++++- internal/server/e2e_test.go | 1 + internal/server/project_binding_test.go | 41 +++++++++++++ internal/server/stage_access_helpers.go | 13 ++++ internal/server/task_run_observer_test.go | 4 +- internal/server/taskstream_test.go | 3 + 16 files changed, 202 insertions(+), 18 deletions(-) create mode 100644 internal/server/project_binding_test.go diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index dbd77238..51ac36ed 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| Cursor | controlled-projection-project-binding-2026-09-28 | fix/controlled-projection-project-binding-2026-09-28 | .worktrees/controlled-projection-project-binding-2026-09-28 | controlled-projection-binding | in_progress | | Cursor | docgov-shared-map-race-2026-09-28-r2 | fix/docgov-shared-map-race-2026-09-28 | .worktrees/docgov-shared-map-race-2026-09-28 | docgov-shared-map-race | in_progress | | Cursor | promote-rc-beta6-closeout-2026-09-28 | feature/promote-rc-beta6-closeout-2026-09-28 | .worktrees/promote-rc-beta6-closeout-2026-09-28 | git:test+env:test | done | | Cursor | quality-gate-heading-mismatch-2026-09-28-r3 | fix/quality-gate-heading-mismatch-2026-09-28 | .worktrees/quality-gate-heading-mismatch-2026-09-28 | quality-gate-heading | in_progress | diff --git a/internal/bizdev/doc_id_test.go b/internal/bizdev/doc_id_test.go index 9dc2565c..64554d90 100644 --- a/internal/bizdev/doc_id_test.go +++ b/internal/bizdev/doc_id_test.go @@ -239,7 +239,7 @@ func TestExecuteTask_CreateDocumentFailureIsNotSuccess(t *testing.T) { } } -func TestExecuteTask_EmptyDocIDSkipsDocRevision(t *testing.T) { +func TestExecuteTask_ControlledKeyWithoutProjectRejected(t *testing.T) { svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) ctx := context.Background() @@ -253,17 +253,14 @@ func TestExecuteTask_EmptyDocIDSkipsDocRevision(t *testing.T) { Backend: "stub", Timeout: 10 * time.Second, }) - if err != nil { - t.Fatalf("empty docID ExecuteTask panicked or failed: %v", err) + if !errors.Is(err, ErrUnboundControlledProject) { + t.Fatalf("controlled task without project must fail closed, err=%v", err) } - if res == nil || !res.Success { - t.Fatalf("CLI-like stage path should succeed without doc ledger, got %#v", res) + if res == nil || res.Success || res.DocRecord != nil { + t.Fatalf("unbound controlled task must not create a ledger or report success: %#v", res) } - if res.DocRecord != nil { - t.Fatalf("empty docID must not create a document record, got %#v", res.DocRecord) - } - if _, stErr := svc.docRev.GetRevisionState(ctx, ""); !strings.Contains(stErr.Error(), "invalid doc_id") { - t.Fatalf("empty GetRevisionState should be invalid, got %v", stErr) + if !strings.Contains(res.ErrorReason, "会话未绑定项目") { + t.Fatalf("ErrorReason=%q", res.ErrorReason) } } diff --git a/internal/bizdev/quality_projection_test.go b/internal/bizdev/quality_projection_test.go index 8495a95a..f9b01d84 100644 --- a/internal/bizdev/quality_projection_test.go +++ b/internal/bizdev/quality_projection_test.go @@ -161,7 +161,7 @@ func TestExecuteTask_Uncontrolled_DoesNotForceAutoRepair(t *testing.T) { dataDir := t.TempDir() svc := MustNewService(t, &config.Config{DataDir: dataDir, AgentBackend: "stub"}) res, err := svc.ExecuteTask(context.Background(), TaskRunRequest{ - TaskKey: "intake", + TaskKey: "adhoc_notes", ProjectID: 0, DocID: "", AutoRepair: false, diff --git a/internal/bizdev/service_task_run.go b/internal/bizdev/service_task_run.go index 0a19f0a6..21bbc563 100644 --- a/internal/bizdev/service_task_run.go +++ b/internal/bizdev/service_task_run.go @@ -14,10 +14,10 @@ import ( "chat-agent-system/internal/contracts/vocabulary" "chat-agent-system/internal/docduty" "chat-agent-system/internal/docgov" - "chat-agent-system/internal/intent" - "chat-agent-system/internal/notification" "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/intent" "chat-agent-system/internal/kb" + "chat-agent-system/internal/notification" "chat-agent-system/internal/projectcontext" "chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/squad" @@ -119,7 +119,32 @@ func (s *Service) hasCompletedMainInstance(ctx context.Context, projectID int64) return ok, err } +// ErrUnboundControlledProject 表示受控目录任务没有项目绑定,不能静默落到隔离工作区。 +var ErrUnboundControlledProject = errors.New("会话未绑定项目,受控交付物无法入账") + +// controlledDeliverableRequiresProject 覆盖发布目录里的受控任务(旧键与新键)和研发设计控制 rd_*。 +// 未知自由任务两边都原样返回,不在此列。 +func controlledDeliverableRequiresProject(taskKey string) bool { + key := strings.TrimSpace(taskKey) + if key == "" { + return false + } + if strings.HasPrefix(key, "rd_") { + return true + } + return workflowdef.CanonicalTaskKey(key) != key || workflowdef.ImplementationTaskKey(key) != key +} + func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRunResult, error) { + if controlledDeliverableRequiresProject(req.TaskKey) && req.ProjectID <= 0 { + return &TaskRunResult{ + TaskKey: req.TaskKey, + ArtifactTitle: squad.TaskArtifactTitle(req.TaskKey), + Success: false, + ErrorReason: ErrUnboundControlledProject.Error(), + }, ErrUnboundControlledProject + } + if err := s.ensureTaskStageAccess(ctx, req.ProjectID, req.UserID, req.TaskKey); err != nil { return &TaskRunResult{ TaskKey: req.TaskKey, diff --git a/internal/bizdev/service_test.go b/internal/bizdev/service_test.go index 9e0a4254..0279a9c2 100644 --- a/internal/bizdev/service_test.go +++ b/internal/bizdev/service_test.go @@ -30,6 +30,7 @@ func TestExecuteTaskIndependent(t *testing.T) { // 测试 1: 单独执行 intake (不依赖任何前序) intakeRes, err := svc.ExecuteTask(context.Background(), bizdev.TaskRunRequest{ TaskKey: "intake", + ProjectID: 1, ProjectName: "微创吻合器研发", CustomerName: "北京天工", DeviceType: "腔镜微创器械", @@ -58,6 +59,7 @@ func TestExecuteTaskIndependent(t *testing.T) { // 测试 2: 单独执行 cdo_quote (直接输出 Excel 附件) quoteRes, err := svc.ExecuteTask(context.Background(), bizdev.TaskRunRequest{ TaskKey: "cdo_quote", + ProjectID: 1, ProjectName: "微创吻合器研发", CustomerName: "北京天工", }) @@ -118,6 +120,7 @@ func TestExecuteTaskWritesMaterials(t *testing.T) { materialContent := []byte("客户提供的补充需求说明") res, err := svc.ExecuteTask(context.Background(), bizdev.TaskRunRequest{ TaskKey: "intake", + ProjectID: 1, ProjectName: "测试项目", CustomerName: "测试客户", WorkDir: workDir, diff --git a/internal/bizdev/task_result_status.go b/internal/bizdev/task_result_status.go index f98b7527..12755e90 100644 --- a/internal/bizdev/task_result_status.go +++ b/internal/bizdev/task_result_status.go @@ -91,6 +91,9 @@ func (r *TaskRunResult) CompletionSummary() string { if r.QualityReport != nil && r.QualityReport.Passed { fmt.Fprintf(&b, "\n质量评分:%.0f/100", r.QualityReport.Score) } + if r.DocRecord == nil && controlledDeliverableRequiresProject(r.TaskKey) { + b.WriteString("\n交付物已生成于工作区,尚未写入受控文档账本。") + } default: fmt.Fprintf(&b, "《%s》未成功落盘。\n\n", title) if r.ErrorReason != "" { diff --git a/internal/bizdev/task_result_status_test.go b/internal/bizdev/task_result_status_test.go index e9094544..e3e6151a 100644 --- a/internal/bizdev/task_result_status_test.go +++ b/internal/bizdev/task_result_status_test.go @@ -64,6 +64,18 @@ func TestTaskRunResult_ProposalIntercept_DistinctFromPendingAgent(t *testing.T) } } +func TestTaskRunResult_CompletedWithoutLedger_SaysNotProjected(t *testing.T) { + res := &bizdev.TaskRunResult{ + Success: true, + ArtifactTitle: "调研报告", + TaskKey: "research", + } + sum := res.CompletionSummary() + if !strings.Contains(sum, "尚未写入受控文档账本") { + t.Fatalf("summary=%s", sum) + } +} + func TestTaskRunResult_CompletedOnlyWhenCommitted(t *testing.T) { res := &bizdev.TaskRunResult{ Success: true, diff --git a/internal/bizdev/testutil.go b/internal/bizdev/testutil.go index 8f7ec883..14340676 100644 --- a/internal/bizdev/testutil.go +++ b/internal/bizdev/testutil.go @@ -29,3 +29,13 @@ func MustNewService(t testing.TB, cfg *config.Config) *Service { } return svc } + +// ClearDocumentGovernanceForTest 去掉账本,让阶段编排测试只覆盖小队产出。 +// 受控任务仍须带 ProjectID,否则 ExecuteTask 会拒绝未绑定项目。 +func ClearDocumentGovernanceForTest(s *Service) { + if s == nil { + return + } + s.docGov = nil + s.docRev = nil +} diff --git a/internal/bizdev/types_test.go b/internal/bizdev/types_test.go index 850d91e1..ff513965 100644 --- a/internal/bizdev/types_test.go +++ b/internal/bizdev/types_test.go @@ -87,10 +87,12 @@ func TestExecuteStageV01AndV05(t *testing.T) { } svc := bizdev.MustNewService(t, cfg) + bizdev.ClearDocumentGovernanceForTest(svc) // 1. 运行 Stage 1 (V0.1 Triage) stage1Res, err := svc.ExecuteStage(context.Background(), bizdev.StageRunRequest{ Stage: bizdev.StageV01_Triage, + ProjectID: 1, ProjectName: "内窥镜手术动力系统", CustomerName: "北京天衡医疗", DeviceType: "有源手术动力装置", @@ -128,6 +130,7 @@ func TestExecuteStageV01AndV05(t *testing.T) { stage2Res, err := svc.ExecuteStage(context.Background(), bizdev.StageRunRequest{ Stage: bizdev.StageV05_Feasibility, + ProjectID: 1, ProjectName: "内窥镜手术动力系统", CustomerName: "北京天衡医疗", DeviceType: "有源手术动力装置", @@ -163,6 +166,7 @@ func TestExecuteStageV01AndV05(t *testing.T) { stage3Res, err := svc.ExecuteStage(context.Background(), bizdev.StageRunRequest{ Stage: bizdev.StageV10_Quoting, + ProjectID: 1, ProjectName: "内窥镜手术动力系统", CustomerName: "北京天衡医疗", DeviceType: "有源手术动力装置", diff --git a/internal/coordinator/engine_task.go b/internal/coordinator/engine_task.go index 4e710240..3c744c43 100644 --- a/internal/coordinator/engine_task.go +++ b/internal/coordinator/engine_task.go @@ -97,6 +97,7 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent. IntendedUse: userCtx.Project.IntendedUse, TargetMarket: userCtx.Project.TargetMarket, Fields: userCtx.BlackboardFacts, + SessionID: userCtx.ChatSessionID, ProjectID: projectID, UserID: userID, // 会话触发与特设任务一致:质量门禁失败时给修复预算。 @@ -128,6 +129,16 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent. UpdatedContext: userCtx, }, nil } + if taskRes != nil && strings.Contains(taskRes.ErrorReason, "会话未绑定项目") { + return &CoordinationResult{ + Status: StatusRejected, + Intent: cand.Intent, + Branch: cand.Branch, + RejectReason: taskRes.ErrorReason, + NaturalReply: taskRes.ErrorReason, + UpdatedContext: userCtx, + }, nil + } if errors.Is(err, projectworkflow.ErrPrerequisiteNotMet) { msg := userFacingPrerequisite(err) if taskRes != nil && strings.Contains(taskRes.ErrorReason, "请先完成商务对接流程") { @@ -197,8 +208,10 @@ func formatTriggerTaskReply(taskRes *stageflow.TaskRunResult, outPath string) (r sb.WriteString(fmt.Sprintf("已为您自适应规划并完成生成【%s】!\n", taskRes.ArtifactTitle)) if taskRes.DocRecord != nil && taskRes.DocRecord.DocID != "" { sb.WriteString(fmt.Sprintf("交付物已写入受控文档:%s\n", taskRes.DocRecord.DocID)) + sb.WriteString("正文已保存。\n") + } else { + sb.WriteString("交付物已生成于工作区,尚未写入受控文档账本。\n") } - sb.WriteString("正文已保存。\n") sb.WriteString(fmt.Sprintf("耗时: %v\n", taskRes.Duration)) if taskRes.AdvisorBrief != nil && len(taskRes.AdvisorBrief.BaselineAssumptions) > 0 { sb.WriteString("\n📌 预设基准假设(已融入方案,您可随时修正):\n") @@ -278,6 +291,7 @@ func (e *CoordinatorEngine) handleTriggerAdhocTask(ctx context.Context, cand *in Fields: userCtx.BlackboardFacts, Materials: []stageflow.MaterialInput{briefMaterial}, AutoRepair: true, + SessionID: userCtx.ChatSessionID, ProjectID: projectID, UserID: userID, }) diff --git a/internal/coordinator/pending_confirmation_reply_test.go b/internal/coordinator/pending_confirmation_reply_test.go index 6c342de4..825b2a25 100644 --- a/internal/coordinator/pending_confirmation_reply_test.go +++ b/internal/coordinator/pending_confirmation_reply_test.go @@ -149,8 +149,9 @@ func TestSyncTriggerTask_EnablesAutoRepair(t *testing.T) { }} eng := NewCoordinatorEngine(stub) userCtx := &UserProjectContext{ - User: UserInfo{ID: "1", ProjectRole: "author"}, - Project: ProjectInfo{ID: "9", Name: "吞咽电刺激", CurrentStage: maturity.StageV01_Triage}, + ChatSessionID: "sess-research", + User: UserInfo{ID: "1", ProjectRole: "author"}, + Project: ProjectInfo{ID: "9", Name: "吞咽电刺激", CurrentStage: maturity.StageV01_Triage}, } cand := &intent.CandidateIntent{ Intent: intent.IntentTriggerTask, @@ -169,6 +170,29 @@ func TestSyncTriggerTask_EnablesAutoRepair(t *testing.T) { if stub.req.TaskKey != "research" { t.Fatalf("task key=%q", stub.req.TaskKey) } + if stub.req.ProjectID != 9 || stub.req.SessionID != "sess-research" { + t.Fatalf("project/session=%d %q", stub.req.ProjectID, stub.req.SessionID) + } +} + +func TestFormatTriggerTaskReply_WithoutDocRecordDoesNotClaimLedger(t *testing.T) { + ok := &stageflow.TaskRunResult{ + Success: true, + TaskKey: "research", + ArtifactTitle: "调研报告", + WorkDir: t.TempDir(), + Duration: time.Millisecond, + } + reply, status := formatTriggerTaskReply(ok, ok.WorkDir+"/output.md") + if status != stageflow.TaskStatusCompleted { + t.Fatalf("status=%q", status) + } + if strings.Contains(reply, "正文已保存") || strings.Contains(reply, "已写入受控文档") { + t.Fatalf("reply=%s", reply) + } + if !strings.Contains(reply, "尚未写入受控文档账本") { + t.Fatalf("reply=%s", reply) + } } func TestFormatTriggerTaskReply_PendingUsesCompletionSummary(t *testing.T) { @@ -216,6 +240,39 @@ func TestFormatTriggerTaskReply_QualityFailedOmitsHostPath(t *testing.T) { } } +func TestSyncTriggerTask_UnboundProjectIsUserVisible(t *testing.T) { + reason := "会话未绑定项目,受控交付物无法入账" + eng := NewCoordinatorEngine(&errReplyBizdev{ + res: &stageflow.TaskRunResult{Success: false, TaskKey: "research", ErrorReason: reason}, + err: fmt.Errorf("%s", reason), + }) + userCtx := &UserProjectContext{ + User: UserInfo{ID: "4", ProjectRole: "author"}, + Project: ProjectInfo{Name: "吞咽电刺激", CurrentStage: maturity.StageV05_Feasibility}, + } + cand := &intent.CandidateIntent{ + Intent: intent.IntentTriggerTask, + FilledSlots: map[string]string{intent.SlotTargetTask: "research"}, + } + res, err := eng.ProcessIntent(context.Background(), &intent.IntentRecognitionResult{ + Branch: intent.BranchNewIntentPreempt, + TopIntent: cand, + RawInput: "编写调研报告", + }, userCtx) + if err != nil { + t.Fatal(err) + } + if res.Status != StatusRejected { + t.Fatalf("status=%s", res.Status) + } + if res.NaturalReply != reason { + t.Fatalf("reply=%s", res.NaturalReply) + } + if strings.Contains(res.NaturalReply, "正文已保存") || strings.Contains(res.NaturalReply, "生成完成") { + t.Fatalf("reply=%s", res.NaturalReply) + } +} + func TestSyncTriggerTask_PrerequisiteIsUserVisible(t *testing.T) { preErr := fmt.Errorf("%w: %s", projectworkflow.ErrPrerequisiteNotMet, "请先完成商务对接流程(CDO 报价批准)后再启动研发设计控制流程") eng := NewCoordinatorEngine(&errReplyBizdev{ diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index eb21633a..d324f9da 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -134,6 +134,7 @@ func TestE2E_TasksRunStreamPipeline(t *testing.T) { reqBody := TaskRunRequestDTO{ TaskKey: "intake", + ProjectID: 1, ProjectName: "超声内镜系统", CustomerName: "北京水木东方", DeviceType: "超声主机", diff --git a/internal/server/project_binding_test.go b/internal/server/project_binding_test.go new file mode 100644 index 00000000..d6f1703f --- /dev/null +++ b/internal/server/project_binding_test.go @@ -0,0 +1,41 @@ +package server + +import ( + "testing" + + "chat-agent-system/internal/chat" + "chat-agent-system/internal/coordinator" +) + +func TestBindSessionProjectContext_FillsEmptyProjectID(t *testing.T) { + sess := chat.NewSession("sess-p6024-u4", &coordinator.UserProjectContext{ + Project: coordinator.ProjectInfo{Name: "吞咽电刺激"}, + }, nil, nil) + sess.SetProjectID(6024) + bindSessionProjectContext(sess) + if sess.UserCtx.Project.ID != "6024" { + t.Fatalf("project id=%q", sess.UserCtx.Project.ID) + } +} + +func TestBindSessionProjectContext_KeepsExistingProjectID(t *testing.T) { + sess := chat.NewSession("sess-keep", &coordinator.UserProjectContext{ + Project: coordinator.ProjectInfo{ID: "9", Name: "已绑定"}, + }, nil, nil) + sess.SetProjectID(6024) + bindSessionProjectContext(sess) + if sess.UserCtx.Project.ID != "9" { + t.Fatalf("project id=%q", sess.UserCtx.Project.ID) + } +} + +func TestBindSessionProjectContext_SkipsUnboundSession(t *testing.T) { + sess := chat.NewSession("sess-none", &coordinator.UserProjectContext{ + Project: coordinator.ProjectInfo{Name: "未选项目"}, + }, nil, nil) + sess.SetProjectID(0) + bindSessionProjectContext(sess) + if sess.UserCtx.Project.ID != "" { + t.Fatalf("project id=%q", sess.UserCtx.Project.ID) + } +} diff --git a/internal/server/stage_access_helpers.go b/internal/server/stage_access_helpers.go index 48de0f6c..6bcb0d37 100644 --- a/internal/server/stage_access_helpers.go +++ b/internal/server/stage_access_helpers.go @@ -155,10 +155,23 @@ func (s *Server) allowActiveStageChatAccess(ctx context.Context, projectID, user return access.StageKeyAllowed(access.ActiveStageKey) } +// bindSessionProjectContext 把已授权的会话项目写回协调器上下文。 +// 聊天入口只调用 SetProjectID 做访问校验;协调器读取的是 UserCtx.Project.ID。 +// 只填空值,不覆盖已经明确的项目绑定。 +func bindSessionProjectContext(sess *chat.Session) { + if sess == nil || sess.UserCtx == nil || sess.Project() <= 0 { + return + } + if strings.TrimSpace(sess.UserCtx.Project.ID) == "" { + sess.UserCtx.Project.ID = strconv.FormatInt(sess.Project(), 10) + } +} + func (s *Server) enrichSessionWorkflowStage(ctx context.Context, sess *chat.Session, userID int64) { if sess == nil { return } + bindSessionProjectContext(sess) if sess.Project() > 0 && s.instSvc() != nil && s.instSvc().Available() { settings, err := s.instSvc().GetSettings(ctx, sess.Project(), userID, false) if err == nil && settings != nil { diff --git a/internal/server/task_run_observer_test.go b/internal/server/task_run_observer_test.go index 55b288c8..969c2cdf 100644 --- a/internal/server/task_run_observer_test.go +++ b/internal/server/task_run_observer_test.go @@ -29,8 +29,8 @@ func TestTaskRun_DisconnectDoesNotCancelWorker(t *testing.T) { defer ts.Close() runID := "run_disconnect_test" - record := &taskrun.RunRecord{RunID: runID, TaskKey: "intake", Status: "running"} - taskReq := bizdev.TaskRunRequest{TaskKey: "intake", ProjectName: "disconnect-test"} + record := &taskrun.RunRecord{RunID: runID, TaskKey: "intake", ProjectID: 1, Status: "running"} + taskReq := bizdev.TaskRunRequest{TaskKey: "intake", ProjectID: 1, ProjectName: "disconnect-test"} _, err := srv.taskRuns().CreateAndStart(context.Background(), runID, record, taskReq, bizdev.StageRunRequest{}, "") if err != nil { t.Fatalf("CreateAndStart: %v", err) diff --git a/internal/server/taskstream_test.go b/internal/server/taskstream_test.go index 19d320c6..ccc2c1c6 100644 --- a/internal/server/taskstream_test.go +++ b/internal/server/taskstream_test.go @@ -81,6 +81,7 @@ func TestTaskStream_TransientInjection_DeadPiAgent(t *testing.T) { body, _ := json.Marshal(map[string]any{ "task_key": "intake", + "project_id": 1, "project_name": "超声内镜系统", "customer": "北京水木东方", "backend": "pi-agent", @@ -148,6 +149,7 @@ func TestTaskStream_TypedReject_ClassifiedFatal(t *testing.T) { body, _ := json.Marshal(map[string]any{ "task_key": "intake", + "project_id": 1, "project_name": "超声内镜系统", "customer": "北京水木东方", "backend": "pi-agent", @@ -207,6 +209,7 @@ func TestTaskStream_MaterialsInjection(t *testing.T) { body, _ := json.Marshal(map[string]any{ "task_key": "intake", + "project_id": 1, "project_name": "超声材料注入测试", "customer_name": "北京水木东方", "device_type": "超声诊断设备", From 88e0311a1fc4b2e15967d3efc8d5c308493dc006 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 28 Sep 2026 12:35:44 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(test):=20=E8=AE=B0=E5=BD=95=E5=8F=97?= =?UTF-8?q?=E6=8E=A7=E6=8A=95=E5=BD=B1=E9=A1=B9=E7=9B=AE=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E7=9A=84=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 短测、vet 与架构边界已通过。dev 浏览器链路尚未重载本提交,报告记为未运行。 --- ...8-controlled-projection-project-binding.md | 79 ++++++++++++++ ...-projection-project-binding-test-report.md | 103 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 .doc/ops/short-term/2026-09-28-controlled-projection-project-binding.md create mode 100644 .doc/test-reports/2026-09-28-controlled-projection-project-binding-test-report.md diff --git a/.doc/ops/short-term/2026-09-28-controlled-projection-project-binding.md b/.doc/ops/short-term/2026-09-28-controlled-projection-project-binding.md new file mode 100644 index 00000000..b67bb289 --- /dev/null +++ b/.doc/ops/short-term/2026-09-28-controlled-projection-project-binding.md @@ -0,0 +1,79 @@ +--- +title: "修复调研报告受控投影断链" +task_id: controlled-projection-project-binding-2026-09-28 +status: in_progress +created: 2026-09-28 +updated: 2026-09-28 +owner: Cursor +task_type: fix +policy_revision: "AGENTS.md + agent-worktree-workflow @ 2026-09-21" +branch: fix/controlled-projection-project-binding-2026-09-28 +worktree: .worktrees/controlled-projection-project-binding-2026-09-28 +base_dev_sha: "5d762fb2061113285ee21219471baaf11cb2d770" +write_scope: + - internal/server + - internal/coordinator + - internal/bizdev + - .doc/ops/CURRENT.md + - .doc/ops/short-term/2026-09-28-controlled-projection-project-binding.md + - .doc/test-reports/2026-09-28-controlled-projection-project-binding-test-report.md +mutex: controlled-projection-binding +review_state: pending +integration_state: branch_only +deployment: not_loaded +cleanup: pending +remote_sync: not_requested +--- + +# 修复调研报告受控投影断链 + +## 用户目标与授权 + +用户在项目会话里让助手编制调研报告。助手回复已生成并保存(含耗时与基准假设),刷新文档页仍是模板骨架。授权范围是修复受控投影断链:回填项目绑定、会话可追溯、无绑定时失败关闭、回复与账本状态一致。不改前端骨架回退,不删除工作区,不晋升环境,不 push。 + +## 检索 + +- 问题台账未另开新条;本任务记录根因与修复。catalog overlap 探测为 none。登记时未带 mutex/intent,避免与持有全仓范围 `.` 的夜间推进任务发生 same_scope 拒绝。 +- 经验闭环:本轮采纳「回复口径必须跟受控账本,而不是跟小队成功」。 + +## 根因 + +聊天入口只 `SetProjectID` 做访问校验,`UserCtx.Project.ID` 一直为空。协调器 `handleTriggerTask` 用后者解析 projectID,得到 0。`canonicalDocID` 因此返回空串,bizdev 整段跳过 docgov/docrevision 投影。`output.md` 只落在隔离工作区,回复仍写「正文已保存」。 + +证据工作区:`research_1790576535990100562`(project_id=0、session_id 空、completed)。`doc_proj6024_bd_research` 在 07:06 带绑定重跑前只有模板快照。随后 `research_1790584772615062606`(失败)和 `research_1790585141260333084`(completed)仍是 project_id=0。 + +## 修复 + +提交 `f8730791`: + +- `bindSessionProjectContext`:`enrichSessionWorkflowStage` 在项目已授权且上下文项目 ID 为空时回填。已有绑定不覆盖。 +- `handleTriggerTask` / `handleTriggerAdhocTask` 把 `ChatSessionID` 写入 `TaskRunRequest.SessionID`。 +- 受控目录任务(目录键与 `rd_*`)在 `ProjectID <= 0` 时返回「会话未绑定项目,受控交付物无法入账」,不建孤儿工作区。未知自由任务不在此列。 +- 完成回复只在 `DocRecord` 有文档 ID 时称已写入受控文档;否则说明交付物在工作区、尚未入账。`CompletionSummary` 对受控键同样区分。 + +## 数据 + +`doc_proj6024_bd_research` 已由 07:06 带绑定重跑入账,不做数据修复。 + +孤儿工作区只登记、不删除: + +| 路径 | cleanup | 原因 | +|---|---|---| +| `data/dev/workspaces/bizdev/research_1790576535990100562` | pending | project_id=0 的已完成调研,正文未入账 | +| `data/dev/workspaces/bizdev/research_1790585141260333084` | pending | 修复前再次以 project_id=0 完成 | + +cleanup_owner:本任务。复查触发:合入 local dev 并确认新会话不再产生 project_id=0 的受控工作区之后,由独立维护者决定是否归档。 + +## 验证 + +见 [测试报告](../../test-reports/2026-09-28-controlled-projection-project-binding-test-report.md)。 + +## 五态 + +| 字段 | 值 | +|---|---| +| review_state | pending | +| integration_state | branch_only | +| deployment | not_loaded(运行中的 dev 二进制早于本提交) | +| cleanup | pending | +| remote_sync | not_requested | diff --git a/.doc/test-reports/2026-09-28-controlled-projection-project-binding-test-report.md b/.doc/test-reports/2026-09-28-controlled-projection-project-binding-test-report.md new file mode 100644 index 00000000..78fd0c45 --- /dev/null +++ b/.doc/test-reports/2026-09-28-controlled-projection-project-binding-test-report.md @@ -0,0 +1,103 @@ +# 调研报告受控投影项目绑定 测试报告 + +## 一、 测试背景 (Background) + +项目会话里触发调研报告后,助手称正文已保存,刷新文档页仍显示模板骨架。根因是会话授权项目 ID 没有回填到协调器读取的 `UserCtx.Project.ID`,受控投影被跳过。任务记录见 [短文](../ops/short-term/2026-09-28-controlled-projection-project-binding.md)。 + +## 二、 测试目标 (Test Objectives) + +- 已授权会话在派发前把项目 ID 写入协调器上下文,且不覆盖已有绑定。 +- 协调器触发任务时 `TaskRunRequest` 带上项目 ID 与 `SessionID`。 +- 受控任务键在 `ProjectID <= 0` 时失败关闭,不建工作区。 +- 完成回复在没有受控账本记录时不称「正文已保存」。 +- 既有阶段编排、异步 run 与流式任务在带上项目 ID 后仍能完成。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux。工作树:`.worktrees/controlled-projection-project-binding-2026-09-28`。 +- 基线 `5d762fb2061113285ee21219471baaf11cb2d770`。被测提交 `f8730791b9a0a9d4ac67586f622acb6afaf43f10`。 +- 层级:Go 接口短测、`go vet ./...`、架构边界 `arch-check --mode block`。 +- 产品 PI 浏览器链路依赖已加载本提交的 dev 进程。本报告记录短测时,运行中的 `bin/agent-server` 仍是合入前的主目录制品。 + +## 四、 预期结果 (Expected Results) + +- `bindSessionProjectContext` 把空项目 ID 填成会话项目;已有值保持不变;未授权项目不回填。 +- 同步触发 research 时请求的 `ProjectID` 与 `SessionID` 与上下文一致。 +- `intake` / `research` 在项目 ID 为 0 时返回 `ErrUnboundControlledProject`,`DocRecord` 为空。 +- 无 `DocRecord` 的完成回复包含「尚未写入受控文档账本」,且不含「正文已保存」。 +- `go vet ./...` 无输出;三包 `go test -short` 通过;架构边界通过。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:项目绑定回填 + +- 输入:`go test -count=1 -short -timeout 180s ./internal/server/ -run TestBindSessionProjectContext`(含在用例 4 的整包短测中)。 +- 实际输出:`internal/server` 整包 `ok`,20.400s。用例覆盖回填 6024、保留已有 `"9"`、`SetProjectID(0)` 时不写入。 +- 判定:PASS +- 耗时:见整包短测 + +### 用例 2:触发请求携带项目与会话 + +- 输入:`go test -count=1 -short ./internal/coordinator/ -run 'TestSyncTriggerTask|TestFormatTriggerTaskReply'` +- 实际输出:`internal/coordinator` 整包 `ok`,0.015s。`TestSyncTriggerTask_EnablesAutoRepair` 断言 `ProjectID==9` 且 `SessionID==sess-research`。 +- 判定:PASS + +### 用例 3:无项目绑定失败关闭,回复不声称入账 + +- 输入:`go test -count=1 -short ./internal/bizdev/ -run 'TestExecuteTask|TestTaskRunResult_'` +- 实际输出:`internal/bizdev` 整包 `ok`,0.180s。受控键无项目时 `errors.Is` 为 `ErrUnboundControlledProject`。无账本的完成摘要包含「尚未写入受控文档账本」。未绑定项目的同步触发回复为该错误原文,状态 `StatusRejected`。 +- 判定:PASS + +### 用例 4:三包短测、vet、架构边界 + +- 输入: + +```text +gofmt -w internal/server/task_run_observer_test.go +go test -count=1 -short -timeout 180s ./internal/bizdev/ ./internal/coordinator/ ./internal/server/ +go vet ./... +./scripts/arch-check.sh --mode block +``` + +- 实际输出: + +```text +ok chat-agent-system/internal/bizdev 0.180s +ok chat-agent-system/internal/coordinator 0.015s +ok chat-agent-system/internal/server 20.400s +VET:0 +arch-check mode=block +ok chat-agent-system/internal/archtest 0.194s +ok chat-agent-system/internal/archtest 0.191s +arch-check block: production gate passed +ARCH:0 +``` + +- 判定:PASS +- 耗时:三包短测约 23s;vet 与架构检查约 2s。工作目录为任务 worktree。 + +说明:`TestTaskRun_DisconnectDoesNotCancelWorker` 曾因 worker 用 `RunRecord.ProjectID` 覆盖请求而失败。测试记录改为 `ProjectID: 1` 后通过。这与线上异步 run 以记录中的项目 ID 为准一致。 + +### 用例 5:dev 真实 PI 浏览器链路 + +- 输入:在已加载 `f8730791` 的项目会话中对话触发调研报告,核对 `workspace.json` 的 `project_id`、`document_versions` 的 agent_squad 提交,以及刷新后的文档正文。 +- 实际输出:未运行。当前 dev 的 `bin/agent-server` 早于本提交,主目录 `dev` 尚未合入该提交。 +- 判定:未运行 +- 风险:短测证明绑定与失败关闭,不能代替刷新后文档页看到正文。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 检查 | 结果 | +|---|---| +| `go test -short` `internal/bizdev` `internal/coordinator` `internal/server` | PASS | +| `go vet ./...` | PASS | +| `./scripts/arch-check.sh --mode block` | PASS | +| 产品 PI 浏览器 | 未运行 | + +未跑全仓 `go test -short ./...`。本改动的包与架构门禁已覆盖计划中的回归范围。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +授权内的代码与短测达到任务分支可审终点。合入 local dev 并重载 `agent-server` 之后,仍须用真实 PI 在项目会话触发调研报告,确认工作区 `project_id` 非 0、账本出现 agent_squad 版本,刷新文档页不再只剩模板骨架。 + +孤儿工作区 `research_1790576535990100562`、`research_1790585141260333084` 保持 cleanup=pending,不删除。`doc_proj6024_bd_research` 已有带绑定重跑入账,无额外数据修复。