From a60bba6fbf2d0cb2f6e6e3ddf2502b0f86d4ee7d Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 05:03:08 +0000 Subject: [PATCH] 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,