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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 05:03:08 +00:00
co-authored by Cursor
parent 8d22fa3827
commit a60bba6fbf
4 changed files with 98 additions and 5 deletions
@@ -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()
+47
View File
@@ -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) {
+4 -4
View File
@@ -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);
});
+2 -1
View File
@@ -32,6 +32,7 @@ const fatalDenialCopy: Record<string, string> = {
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,