From 8d22fa382714a8d5ca7e1554e828e71706b18993 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:03:08 +0000 Subject: [PATCH 1/4] fix(projectinstance): resolve legacy task keys against namespaced snapshots New workflow instances store bd_* task keys, but chat still sends the old keys. Scope and document access now treat those aliases as the same task so a live instance no longer fails closed on an exact string miss. Co-authored-by: Cursor --- internal/projectinstance/document_access.go | 6 +- internal/projectinstance/message_scope.go | 4 +- .../projectinstance/message_scope_test.go | 64 +++++++++++++++++++ internal/projectinstance/stage_access.go | 2 +- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/internal/projectinstance/document_access.go b/internal/projectinstance/document_access.go index f92480ab..8d1a6639 100644 --- a/internal/projectinstance/document_access.go +++ b/internal/projectinstance/document_access.go @@ -2,6 +2,8 @@ package projectinstance import ( "context" + + "chat-agent-system/internal/workflowdef" ) const ( @@ -67,7 +69,7 @@ func (s *Service) CheckDocTaskAccess( var stageKey string for _, st := range detail.Stages { for i := range st.DocTasks { - if st.DocTasks[i].TaskKey == taskKey { + if workflowdef.SameTaskKey(st.DocTasks[i].TaskKey, taskKey) { copyDoc := st.DocTasks[i] docInst = ©Doc stageKey = st.StageKey @@ -119,7 +121,7 @@ func (s *Service) CheckDocTaskAccess( func findDocTaskPlan(plan WorkflowPlan, taskKey string) *DocTaskPlan { for _, st := range plan.Stages { for i := range st.DocTasks { - if st.DocTasks[i].TaskKey == taskKey { + if workflowdef.SameTaskKey(st.DocTasks[i].TaskKey, taskKey) { return &st.DocTasks[i] } } diff --git a/internal/projectinstance/message_scope.go b/internal/projectinstance/message_scope.go index cca29b54..17557b3a 100644 --- a/internal/projectinstance/message_scope.go +++ b/internal/projectinstance/message_scope.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + + "chat-agent-system/internal/workflowdef" ) const ( @@ -144,7 +146,7 @@ func (s *Service) ResolveMessageScopeForTask(ctx context.Context, projectID int6 var matches []MessageScope for _, st := range detail.Stages { for _, dt := range st.DocTasks { - if dt.TaskKey == taskKey { + if workflowdef.SameTaskKey(dt.TaskKey, taskKey) { matches = append(matches, MessageScope{ ScopeKind: ScopeWorkflowStage, WorkflowInstanceID: gov.ID, diff --git a/internal/projectinstance/message_scope_test.go b/internal/projectinstance/message_scope_test.go index dedfca98..b5e9731e 100644 --- a/internal/projectinstance/message_scope_test.go +++ b/internal/projectinstance/message_scope_test.go @@ -122,6 +122,70 @@ func TestMessageVisible_LegacyUnscopedOwnerWhenRestricted(t *testing.T) { } } +func TestResolveMessageScopeForTask_LegacyKeyMatchesCanonicalSnapshot(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + mustActivateTestPlan(t, svc, 42, 1) + + store.mu.Lock() + live := store.live[42] + live.ConfigSnapshot.Stages[0].DocTasks[0].TaskKey = "bd_intake" + for i := range store.stages[live.ID] { + for j := range store.stages[live.ID][i].tasks { + if store.stages[live.ID][i].tasks[j].taskKey == "intake" { + store.stages[live.ID][i].tasks[j].taskKey = "bd_intake" + } + } + } + store.mu.Unlock() + + scope, err := svc.ResolveMessageScopeForTask(ctx, 42, "intake") + if err != nil { + t.Fatalf("legacy intake must resolve against bd_intake snapshot: %v", err) + } + if scope.ScopeKind != ScopeWorkflowStage || scope.StageKey != "V0.1_triage" { + t.Fatalf("want V0.1_triage workflow_stage, got %+v", scope) + } +} + +func TestResolveMessageScopeForTask_CanonicalAliasAmbiguousAcrossStages(t *testing.T) { + ctx := context.Background() + store := newMemStore() + _ = SeedProjectMeta(store, 43, LifecyclePlanning, "", 1, 0) + svc := NewService(store, testResolver(), nil, nil) + mustActivateTestPlan(t, svc, 43, 1) + + store.mu.Lock() + live := store.live[43] + live.ConfigSnapshot.Stages = append(live.ConfigSnapshot.Stages, StagePlan{ + StageKey: "V0.5_feasibility", Enabled: true, + DocTasks: []DocTaskPlan{{TaskKey: "bd_intake", Enabled: true}}, + }) + store.stages[live.ID] = append(store.stages[live.ID], memStageRecord{ + id: live.ID + 100, stageKey: "V0.5_feasibility", position: 1, enabled: true, status: StagePending, + tasks: []memDocTaskRecord{{id: 900, taskKey: "bd_intake", enabled: true, status: "pending"}}, + }) + store.mu.Unlock() + + _, err := svc.ResolveMessageScopeForTask(ctx, 43, "intake") + if err == nil || !errors.Is(err, ErrTaskScopeNotResolved) { + t.Fatalf("same task in two stages must stay unresolved, got %v", err) + } +} + +func TestTaskKeyToStage_LegacyAlias(t *testing.T) { + plan := validPlan() + plan.Stages[0].DocTasks[0].TaskKey = "bd_user_requirements" + if got := TaskKeyToStage(plan, "user_requirements"); got != "V0.1_triage" { + t.Fatalf("TaskKeyToStage=%q", got) + } + if got := TaskKeyToStage(plan, "intake"); got != "" { + t.Fatalf("unrelated legacy key must miss, got %q", got) + } +} + func TestScopeFromMetadata_RoundTrip(t *testing.T) { meta := map[string]any{ "scope_kind": ScopeWorkflowStage, diff --git a/internal/projectinstance/stage_access.go b/internal/projectinstance/stage_access.go index 253322d7..497b0699 100644 --- a/internal/projectinstance/stage_access.go +++ b/internal/projectinstance/stage_access.go @@ -152,7 +152,7 @@ func MessageStageKey(metadata map[string]any) string { func TaskKeyToStage(plan WorkflowPlan, taskKey string) string { for _, st := range plan.Stages { for _, dt := range st.DocTasks { - if dt.TaskKey == taskKey { + if workflowdef.SameTaskKey(dt.TaskKey, taskKey) { return st.StageKey } } From a60bba6fbf2d0cb2f6e6e3ddf2502b0f86d4ee7d Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:03:08 +0000 Subject: [PATCH 2/4] fix(server): persist chat when a document task scope cannot be resolved A 409 used to return before any message was written, so a refresh showed an empty conversation. Record the user turn and a failed assistant reply before rejecting the run. Co-authored-by: Cursor --- .../server/task_run_busy_preflight_test.go | 45 ++++++++++++++++++ internal/server/task_run_handlers.go | 47 +++++++++++++++++++ web/src/hooks/useTaskRunAttempts.test.ts | 8 ++-- web/src/lib/taskRunErrorInfo.ts | 3 +- 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/internal/server/task_run_busy_preflight_test.go b/internal/server/task_run_busy_preflight_test.go index 523891f0..bfadcf83 100644 --- a/internal/server/task_run_busy_preflight_test.go +++ b/internal/server/task_run_busy_preflight_test.go @@ -126,6 +126,51 @@ func TestCreateTaskRun_PreActivationAcceptedNot409(t *testing.T) { } } +func TestCreateTaskRun_ScopeUnresolvedPersistsChat(t *testing.T) { + srv := newTestServer(t) + token := loginTestAdmin(t, srv) + srv.cfg.AllowInMemory = false + srv.instSvc = nil + + requestBody, _ := json.Marshal(createTaskRunRequest{ + TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 9102, ProjectName: "作用域失败"}, + SessionID: "sess-scope-unresolved", + ClientTurnID: "turn-scope-1", + UserMessage: "编写初始用户需求表", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs", bytes.NewReader(requestBody)) + withBearerAuth(req, token) + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var payload map[string]any + if err := json.NewDecoder(w.Body).Decode(&payload); err != nil { + t.Fatalf("decode: %v", err) + } + if payload["error_code"] != "TASK_SCOPE_UNRESOLVED" { + t.Fatalf("payload=%+v", payload) + } + history, err := srv.ctxMgr.GetChatHistory(t.Context(), "sess-scope-unresolved", 20, 0) + if err != nil { + t.Fatalf("history: %v", err) + } + if len(history) != 2 || history[0].Role != "user" || history[1].Role != "assistant" { + t.Fatalf("history=%+v", history) + } + if history[0].Content != "编写初始用户需求表" { + t.Fatalf("user content=%q", history[0].Content) + } + errorInfo, ok := history[1].Metadata["error_info"].(map[string]any) + if !ok || errorInfo["code"] != "TASK_SCOPE_UNRESOLVED" { + t.Fatalf("error_info=%+v", history[1].Metadata["error_info"]) + } + if history[1].Metadata["task_status"] != "failed" { + t.Fatalf("assistant metadata=%+v", history[1].Metadata) + } +} + func TestDocGovActions_PreActivationRejected(t *testing.T) { srv := newTestServer(t) store := projectinstance.NewMemoryStore() diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index a62eb489..5bbb4b61 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -120,6 +120,7 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { taskScope, scopeErr := s.resolveTaskMessageScope(r.Context(), req.ProjectID, req.TaskKey) if scopeErr != nil { + s.recordUnresolvedScopeMessages(r.Context(), ctxMgr, req) writeJSONResponse(w, http.StatusConflict, map[string]string{ "error_code": "TASK_SCOPE_UNRESOLVED", "message": "无法解析文档任务的工作流阶段作用域", @@ -347,6 +348,52 @@ func (s *Server) recordBusyPreflightMessages( _, _ = ctxMgr.RecordUserMessageWithID(ctx, req.SessionID, 0, "assistant", busy.Reason, "TRIGGER_TASK", "", asstMeta) } +const taskScopeUnresolvedMessage = "无法解析文档任务的工作流阶段作用域,当前请求未创建任务" + +// recordUnresolvedScopeMessages 在 409 早退前落库本轮对话,避免刷新后用户消息与失败原因一起消失。 +func (s *Server) recordUnresolvedScopeMessages(ctx context.Context, ctxMgr *ctxmgr.ContextManager, req createTaskRunRequest) { + if ctxMgr == nil || req.UserMessage == "" || req.SessionID == "" { + return + } + userMeta := map[string]any{ + "project_id": req.ProjectID, + "client_turn_id": req.ClientTurnID, + "task_key": req.TaskKey, + } + _, _ = ctxMgr.RecordUserMessageWithID(ctx, req.SessionID, req.UserID, "user", req.UserMessage, "TRIGGER_TASK", "", userMeta) + + taskRequest := map[string]any{ + "task_key": req.TaskKey, + "project_name": req.ProjectName, + "customer_name": req.CustomerName, + "device_type": req.DeviceType, + "intended_use": req.IntendedUse, + "target_market": req.TargetMarket, + "session_id": req.SessionID, + "user_id": req.UserID, + "project_id": req.ProjectID, + "client_turn_id": req.ClientTurnID, + "user_message": req.UserMessage, + "doc_id": req.DocID, + } + errorInfo := map[string]any{ + "code": "TASK_SCOPE_UNRESOLVED", + "class": "fatal", + "user_message": taskScopeUnresolvedMessage, + "actions": []string{"retry"}, + "session_id": req.SessionID, + } + asstMeta := map[string]any{ + "project_id": req.ProjectID, + "client_turn_id": req.ClientTurnID, + "task_key": req.TaskKey, + "task_status": "failed", + "task_request": taskRequest, + "error_info": errorInfo, + } + _, _ = ctxMgr.RecordUserMessageWithID(ctx, req.SessionID, 0, "assistant", taskScopeUnresolvedMessage, "TRIGGER_TASK", "", asstMeta) +} + // preflightTaskDocumentBusy 在写入聊天占位与 task_runs 之前拦截已知租约占用。 // 真正的租约获取仍由 docgov 原子执行;本检查用于避免用户重复点击产生必然失败的 run。 func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRunRequest) (*docgov.DocumentBusyError, error) { diff --git a/web/src/hooks/useTaskRunAttempts.test.ts b/web/src/hooks/useTaskRunAttempts.test.ts index 41fd3709..2013dea8 100644 --- a/web/src/hooks/useTaskRunAttempts.test.ts +++ b/web/src/hooks/useTaskRunAttempts.test.ts @@ -56,9 +56,9 @@ test('unknown 4xx business code is not reported as a network error', () => { assert.equal(unknown.userMessage, '字段不合法'); assert.equal(unknown.userMessage.includes('请检查网络'), false); - const bare = buildNetworkErrorInfo(400, { errorCode: 'TASK_SCOPE_UNRESOLVED' }); - assert.equal(bare.code, 'TASK_SCOPE_UNRESOLVED'); - assert.match(bare.userMessage, /请求失败/); - assert.equal(bare.userMessage.includes('请检查网络'), false); + const bare = buildNetworkErrorInfo(400, { errorCode: 'TASK_SCOPE_UNRESOLVED' }); + assert.equal(bare.code, 'TASK_SCOPE_UNRESOLVED'); + assert.match(bare.userMessage, /工作流阶段作用域/); + assert.equal(bare.userMessage.includes('请检查网络'), false); }); diff --git a/web/src/lib/taskRunErrorInfo.ts b/web/src/lib/taskRunErrorInfo.ts index a517cfd9..7524004f 100644 --- a/web/src/lib/taskRunErrorInfo.ts +++ b/web/src/lib/taskRunErrorInfo.ts @@ -32,6 +32,7 @@ const fatalDenialCopy: Record = { WORKFLOW_INSTANCE_CLOSED: '工作流实例已关闭,无法执行该文档任务', DOC_DUTY_NOT_MANAGED: '该文档未配置职责,无法执行', SESSION_FORBIDDEN: '无权访问该会话', + TASK_SCOPE_UNRESOLVED: '无法解析文档任务的工作流阶段作用域,当前请求未创建任务', }; export function buildNetworkErrorInfo( @@ -65,7 +66,7 @@ export function buildNetworkErrorInfo( }; } if (fatalDenialCopy[code]) { - const retryable = code === 'WORKFLOW_NOT_ACTIVATED' || code === 'DOC_DUTY_NOT_MANAGED'; + const retryable = code === 'WORKFLOW_NOT_ACTIVATED' || code === 'DOC_DUTY_NOT_MANAGED' || code === 'TASK_SCOPE_UNRESOLVED'; return { userMessage: detail || fatalDenialCopy[code], code, From f529458c28c25cdb1b1bf6c8b254376c0eaa2dc0 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:07:04 +0000 Subject: [PATCH 3/4] docs(ops): claim task-scope persist fix and the dev smoke slot Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 6 ++- .../2026-09-21-taskrun-scope-persist.md | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 .doc/ops/short-term/2026-09-21-taskrun-scope-persist.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 93a3f7f2..c87a4c82 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,8 @@ ## 焦点 -1. **任务错误透传已合入 dev**:`RunRecord` snake_case 与前端真实失败原因(见 [报告](../test-reports/2026-09-21-taskrun-error-transparency-test-report.md))。test/main 未晋升。 +1. **任务作用域 409 与对话丢失**:`fix/taskrun-scope-persist-2026-09-21` 进行中。新旧任务键(`intake` / `bd_intake`)归一化,409 早退前落库对话。见 [short-term](./short-term/2026-09-21-taskrun-scope-persist.md)。test/main 未晋升。 +2. **任务错误透传已合入 dev**:`RunRecord` snake_case 与前端真实失败原因(见 [报告](../test-reports/2026-09-21-taskrun-error-transparency-test-report.md))。test/main 未晋升。 2. **阶段回溯与影响闭环已合入 dev**:T1–T3 与 B1–B3 在 `dev`。空追踪 JSON `null` 已修复,登录后显示「已索引 0 份文档 · 条目 0」(见 [报告](../test-reports/2026-09-21-trace-panel-null-crash-test-report.md))。test/main 未晋升。不改 `production_ready`,不签 D7 对话 ADR。 3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。 @@ -13,6 +14,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | in_progress | | Cursor | 2026-09-21-taskrun-error-transparency | fix/taskrun-error-transparency-2026-09-21 | - | chat+web | done | | Cursor | 2026-09-21-trace-panel-null-crash | fix/trace-panel-null-crash-2026-09-21 | - | trace-index+web | done | | Cursor-Auto | 2026-09-20-stage-backtrack-design | feature/stage-backtrack-design | - | trace-impact-design | done | @@ -172,7 +174,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | 2026-09-21 | 任务错误透传已合入 dev 并 rebuild;`GET /api/v1/tasks/runs/{id}` 为 snake_case;`/health` schema_version=59 | - | +| Cursor | smoke | 2026-09-21 | fix/taskrun-scope-persist-2026-09-21:dev rebuild 与陈威项目 6579 验收 | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-21-taskrun-scope-persist.md b/.doc/ops/short-term/2026-09-21-taskrun-scope-persist.md new file mode 100644 index 00000000..3298537b --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-taskrun-scope-persist.md @@ -0,0 +1,48 @@ +--- +title: "任务作用域键归一化与失败对话落库" +status: in_progress +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: fix/taskrun-scope-persist-2026-09-21 +worktree: "-" +mutex: projectinstance+taskrun +--- + +# 任务作用域键归一化与失败对话落库 + +## 目标 + +新工作流实例使用 `bd_*` 任务键时,前端仍发送旧键(如 `intake`)不再返回 `TASK_SCOPE_UNRESOLVED`;若作用域仍无法解析,本轮用户消息与失败回复先落库。 + +## 范围 + +- `ResolveMessageScopeForTask`、`TaskKeyToStage`、文档任务查找使用 `workflowdef.SameTaskKey` +- `POST /api/v1/tasks/runs` 在 409 早退前写入 user + assistant 消息 +- 前端 `TASK_SCOPE_UNRESOLVED` 文案 + +## 非目标 + +- 不改快照数据、不改 schema +- 不晋升 test/main +- 不改 `production_ready` + +## 步骤 + +1. [x] 键别名归一化与回归测试 +2. [x] 409 路径落库与回归测试 +3. [ ] dev rebuild 与陈威项目 6579 实测 + +## 验收标准 + +- [ ] live 实例快照为 `bd_intake`、请求 `intake` 时任务创建成功(202) +- [ ] 无法解析时 `chat_messages` 仍有用户句与失败回复 +- [ ] 刷新后该会话不再为空 + +## 相关链接 + +- Test report: `.doc/test-reports/2026-09-21-taskrun-scope-persist-test-report.md` + +## 证据 / 备注 + +dev 库实例 366(项目 6579)快照任务键为 `bd_intake`。陈威 user_id=398,会话 `sess-p6579-u398` 修复前 0 条消息。 From 7ff0fb5cdd8813f860c4192048678140e037a397 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:09:39 +0000 Subject: [PATCH 4/4] docs(test): record task scope alias acceptance on dev Chen Wei's intake request against a bd_intake snapshot now creates a run, and the chat window still returns the turn after refresh. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 6 +- .../2026-09-21-taskrun-scope-persist.md | 10 +-- ...09-21-taskrun-scope-persist-test-report.md | 82 +++++++++++++++++++ 3 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 .doc/test-reports/2026-09-21-taskrun-scope-persist-test-report.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index c87a4c82..f7105f02 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **任务作用域 409 与对话丢失**:`fix/taskrun-scope-persist-2026-09-21` 进行中。新旧任务键(`intake` / `bd_intake`)归一化,409 早退前落库对话。见 [short-term](./short-term/2026-09-21-taskrun-scope-persist.md)。test/main 未晋升。 +1. **任务作用域 409 与对话丢失已合入准备**:旧键 `intake` 对 `bd_intake` 快照可创建任务;陈威项目 6579 实测 202,窗口可读回对话(见 [报告](../test-reports/2026-09-21-taskrun-scope-persist-test-report.md))。test/main 未晋升。 2. **任务错误透传已合入 dev**:`RunRecord` snake_case 与前端真实失败原因(见 [报告](../test-reports/2026-09-21-taskrun-error-transparency-test-report.md))。test/main 未晋升。 2. **阶段回溯与影响闭环已合入 dev**:T1–T3 与 B1–B3 在 `dev`。空追踪 JSON `null` 已修复,登录后显示「已索引 0 份文档 · 条目 0」(见 [报告](../test-reports/2026-09-21-trace-panel-null-crash-test-report.md))。test/main 未晋升。不改 `production_ready`,不签 D7 对话 ADR。 3. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`。 @@ -14,7 +14,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| -| Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | in_progress | +| Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | done | | Cursor | 2026-09-21-taskrun-error-transparency | fix/taskrun-error-transparency-2026-09-21 | - | chat+web | done | | Cursor | 2026-09-21-trace-panel-null-crash | fix/trace-panel-null-crash-2026-09-21 | - | trace-index+web | done | | Cursor-Auto | 2026-09-20-stage-backtrack-design | feature/stage-backtrack-design | - | trace-impact-design | done | @@ -174,7 +174,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| Cursor | smoke | 2026-09-21 | fix/taskrun-scope-persist-2026-09-21:dev rebuild 与陈威项目 6579 验收 | - | +| - | idle | 2026-09-21 | 任务作用域键归一化已在 dev 容器验收;`/health` schema_version=59;项目 6579 任务 `run_1789967333594919025` 为 running | - | **phase**: `idle` | `merging` | `smoke` | `e2e` diff --git a/.doc/ops/short-term/2026-09-21-taskrun-scope-persist.md b/.doc/ops/short-term/2026-09-21-taskrun-scope-persist.md index 3298537b..0e299cad 100644 --- a/.doc/ops/short-term/2026-09-21-taskrun-scope-persist.md +++ b/.doc/ops/short-term/2026-09-21-taskrun-scope-persist.md @@ -1,6 +1,6 @@ --- title: "任务作用域键归一化与失败对话落库" -status: in_progress +status: done created: 2026-09-21 updated: 2026-09-21 owner: Cursor @@ -31,13 +31,13 @@ mutex: projectinstance+taskrun 1. [x] 键别名归一化与回归测试 2. [x] 409 路径落库与回归测试 -3. [ ] dev rebuild 与陈威项目 6579 实测 +3. [x] dev rebuild 与陈威项目 6579 实测 ## 验收标准 -- [ ] live 实例快照为 `bd_intake`、请求 `intake` 时任务创建成功(202) -- [ ] 无法解析时 `chat_messages` 仍有用户句与失败回复 -- [ ] 刷新后该会话不再为空 +- [x] live 实例快照为 `bd_intake`、请求 `intake` 时任务创建成功(202) +- [x] 无法解析时 `chat_messages` 仍有用户句与失败回复 +- [x] 刷新后该会话不再为空 ## 相关链接 diff --git a/.doc/test-reports/2026-09-21-taskrun-scope-persist-test-report.md b/.doc/test-reports/2026-09-21-taskrun-scope-persist-test-report.md new file mode 100644 index 00000000..c91ee217 --- /dev/null +++ b/.doc/test-reports/2026-09-21-taskrun-scope-persist-test-report.md @@ -0,0 +1,82 @@ +# 任务作用域 409 与对话丢失 测试报告 + +## 一、 测试背景 (Background) + +dev 上以用户「陈威」在项目 6579 发送「编写初始用户需求表」时,接口返回 `TASK_SCOPE_UNRESOLVED`(409)。刷新页面后该会话没有任何消息。 + +根因:2026-09-19 任务键命名空间化之后,实例 366 的快照任务键是 `bd_intake`,前端仍提交旧键 `intake`。作用域解析做精确匹配,访问门在找不到任务时直接放行,于是请求在创建任务之前以 409 结束,用户消息也还没写入。 + +## 二、 测试目标 (Test Objectives) + +- 快照为 `bd_intake`、请求为 `intake` 时,`POST /api/v1/tasks/runs` 返回 202,而不是 `TASK_SCOPE_UNRESOLVED`。 +- 成功路径写入用户句与助手占位,刷新用的消息窗口能读回这两条。 +- 作用域仍无法解析时,409 之前先落库用户句和失败回复(单测覆盖;本次陈威场景已不再走该分支)。 +- 验收后端为真实 `pi-agent`(`MiniMax-M2.7-highspeed`),不用 stub 输出充当验收数据。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux。dev 编排 `agent-dev`,HTTP `:8080`,业务库 `:5433`。在 `fix/taskrun-scope-persist-2026-09-21` 上执行 `./scripts/env-service.sh dev rebuild`。 +- 后端:`go vet ./...`;`go test -short -count=1 -timeout 20m ./...`。 +- 端到端:为用户 398(陈威)签发一次性会话(验收后立即撤销,不改口令),对项目 6579、会话 `sess-p6579-u398` 提交 `task_key=intake`。 + +## 四、 预期结果 (Expected Results) + +- `/health` 为 `env=dev`、`agent_backend=pi-agent`、`schema_version=59`。 +- 创建任务 HTTP 202,`status=running`,`workflow_stage_key=V0.1_triage`。 +- `chat_messages` 与 `GET /api/v1/chat/messages/window` 都含用户句「编写初始用户需求表」和助手句「任务执行中…」,`scope_kind=workflow_stage`。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例 1:dev 健康与真实 pi-agent + +- **测试输入 (Input)**:`curl -sf http://127.0.0.1:8080/health`;`curl -sf http://127.0.0.1:18865/health`。 +- **实际输出 (Actual Output)**: + +```json +{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":59,"schema_ready":true,"task_store_ready":true} +``` + +pi-agent:`ok=true`,`model_id=MiniMax-M2.7-highspeed`。 + +- **评价判定**:PASS +- **性能与耗时**:健康检查 <1s + +### 用例 2:陈威编写初始用户需求表 + +- **测试输入 (Input)**:`POST /api/v1/tasks/runs`,`project_id=6579`,`session_id=sess-p6579-u398`,`task_key=intake`,`user_message=编写初始用户需求表`。 +- **实际输出 (Actual Output)**:HTTP 202 + +```json +{"run_id":"run_1789967333594919025","status":"running"} +``` + +`task_runs`:`task_key=intake`,`workflow_stage_key=V0.1_triage`,`status=running`,无 error。 + +`chat_messages`: + +```text +4362 | user | TRIGGER_TASK | workflow_stage | V0.1_triage | 编写初始用户需求表 +4363 | assistant | TRIGGER_TASK | workflow_stage | V0.1_triage | 任务执行中… +``` + +- **评价判定**:PASS。旧键已解析到 V0.1,任务已创建,对话已落库。 +- **性能与耗时**:接口约 0.3s 返回 202(生成仍在后台进行)。 + +### 用例 3:刷新窗口 + +- **测试输入 (Input)**:`GET /api/v1/chat/messages/window?session_id=sess-p6579-u398&days=7`(同一用户)。 +- **实际输出 (Actual Output)**:HTTP 200,1 个日期桶、2 条消息:用户「编写初始用户需求表」、助手「任务执行中…」,阶段键 `V0.1_triage`。 +- **评价判定**:PASS。刷新读取不再为空。 +- **性能与耗时**:约 0.3s + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +- `go vet ./...`:通过。 +- 定向:`go test -count=1 ./internal/projectinstance/ -run 'TestResolveMessageScopeForTask_LegacyKeyMatchesCanonicalSnapshot|TestResolveMessageScopeForTask_CanonicalAliasAmbiguousAcrossStages|TestTaskKeyToStage_LegacyAlias|TestResolveMessageScopeForTask_LiveStageScope'` 通过;`go test -count=1 ./internal/server/ -run 'TestCreateTaskRun_ScopeUnresolvedPersistsChat|TestCreateTaskRun_PreActivationAcceptedNot409|TestCreateTaskRunBusyPreflightDoesNotPersistRunOrMessages'` 通过。 +- `go test -short -count=1 -timeout 20m ./...`:`internal/projectinstance`、`internal/server` 通过。另有 3 个既有库集成用例失败,与本次 diff 无关,且会写入当前 dev 库:`TestDocGov_CreateOrGetDocument_OptionalDutiesNullFK`(项目外键)、`TestPG_StageAdvanceReminder24h` / `TestPG_EmitStageReadyDedupe` / `TestCAS_DatabaseIntegration`(项目名全局唯一冲突)。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +达到合入 dev 的标准:陈威场景不再 409,刷新后对话还在。`run_1789967333594919025` 在取样时仍为 `running`,正文生成由真实 pi-agent 继续。test/main 不晋升,不改 `production_ready`。 + +后续:追踪面板改为对话输出在独立任务中做,不纳入本次缺陷修复。