From 6ab8b52113e5a37a6906816962609eefe0842873 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 04:43:50 +0000 Subject: [PATCH] fix(taskrun): reject busy documents before run creation --- ...09-13-conversation-intent-runtime-state.md | 2 +- ...09-13-conversation-intent-runtime-state.md | 1 + ...sation-intent-runtime-state-remediation.md | 2 +- ...sation-intent-runtime-state-test-report.md | 6 + .../server/task_run_busy_preflight_test.go | 71 +++++++++++ internal/server/task_run_handlers.go | 111 ++++++++++++++++++ web/src/App.tsx | 8 ++ web/src/api/chatRetry.test.ts | 4 + web/src/api/chatRetry.ts | 1 + web/src/api/taskRuns.test.ts | 31 +++++ web/src/api/taskRuns.ts | 13 +- 11 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 internal/server/task_run_busy_preflight_test.go create mode 100644 web/src/api/taskRuns.test.ts diff --git a/.doc/audit/2026-09-13-conversation-intent-runtime-state.md b/.doc/audit/2026-09-13-conversation-intent-runtime-state.md index 4da66ffb..0c940682 100644 --- a/.doc/audit/2026-09-13-conversation-intent-runtime-state.md +++ b/.doc/audit/2026-09-13-conversation-intent-runtime-state.md @@ -37,7 +37,7 @@ dev PostgreSQL 的 `sess-p143-u9` 在 03:17–03:32 记录了 `debug ping`、`te ### 5. 文档忙被错误分型为系统内部错误 -并发请求因有效租约拒绝时,事件被分类为 `INTERNAL_ERROR/fatal/actions=[restart]`,用户看到“系统内部错误,请重新开始会话”。实际是可预期的 `DOC_BUSY` 状态,应禁止重复启动并展示持有人、进度与预计状态,而不是建议重启会话。 +并发请求因有效租约拒绝时,事件被分类为 `INTERNAL_ERROR/fatal/actions=[restart]`,用户看到“系统内部错误,请重新开始会话”。实际是可预期的 `DOC_BUSY` 状态;除正确分型外,还必须在创建 `task_runs` 之前做服务端 busy 预检,持久化“未创建新任务”的只读状态卡,且不提供重试动作。 ### 6. dev restart 不会加载新代码,健康检查可对旧镜像误报通过 diff --git a/.doc/ops/short-term/2026-09-13-conversation-intent-runtime-state.md b/.doc/ops/short-term/2026-09-13-conversation-intent-runtime-state.md index dd2c6f62..6dce5a07 100644 --- a/.doc/ops/short-term/2026-09-13-conversation-intent-runtime-state.md +++ b/.doc/ops/short-term/2026-09-13-conversation-intent-runtime-state.md @@ -36,6 +36,7 @@ mutex: intent 5. [x] 实现命名文档的服务端鉴权只读总结(“总结”不止是不写入) 6. [ ] 执行前端、Go、PG 与真实 PI 双标签页验收 - [x] Go、前端、构建、dev 重建与真实 PI 只读总结 + - [x] 服务端 busy 预检不创建 run,并持久化无重试状态卡 - [ ] 浏览器刷新恢复与双标签页 busy 可见性 ## 验收标准 diff --git a/.doc/remediation/2026-09-13-conversation-intent-runtime-state-remediation.md b/.doc/remediation/2026-09-13-conversation-intent-runtime-state-remediation.md index 951752cb..20390a1d 100644 --- a/.doc/remediation/2026-09-13-conversation-intent-runtime-state-remediation.md +++ b/.doc/remediation/2026-09-13-conversation-intent-runtime-state-remediation.md @@ -26,7 +26,7 @@ updated: 2026-09-13 | Wave 1 | 将客户端模板匹配收紧为“动作 + 目标”;“总结”由服务端按项目/阶段权限读取正文并在隔离对话目录生成只读摘要 | 明确“编写/生成/更新”触发;总结返回正文摘要且不创建 run/租约 | | Wave 2 | 持久化/重建 `error_info`、task request、进度与恢复动作 | 刷新前后失败卡片语义和按钮一致 | | Wave 3 | 增加运行态/租约查询与统一 busy UI,消费 governance 事件 | 运行中刷新仍显示文档、持有人、阶段、开始时间和禁止编辑提示 | -| Wave 4 | 将 busy 分型为策略状态,执行真实 PI + 刷新 + 双标签页 E2E | 重复请求不会创建第二 run;状态可恢复;完成后自动解锁 | +| Wave 4 | 将 busy 分型为策略状态,并在 `task_runs`/聊天占位创建前做服务端预检;执行真实 PI + 刷新 + 双标签页 E2E | 重复请求不会创建第二 run;busy 卡刷新可恢复;完成后自动解锁 | ## 验收标准 diff --git a/.doc/test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md b/.doc/test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md index 618ee196..7b8545bb 100644 --- a/.doc/test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md +++ b/.doc/test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md @@ -87,6 +87,12 @@ - **Actual Output**: restart 后仍返回旧版 `CASUAL_CHAT`;重建后返回新版 `QUERY_DOC_STATUS/doc_summary` - **Evaluation**: 首次 FAIL、整改后 PASS;`./scripts/env-service.sh dev rebuild` 已实跑通过,集成冒烟统一改用该命令 +### 用例 J:文档 busy 服务端预检 + +- **Input**: 文档持有有效人工租约时请求 `/api/v1/tasks/runs`,携带明确写入语句 +- **Actual Output**: HTTP 409 / `DOC_BUSY`;响应不含 `run_id`;服务端持久化 user + assistant 两条消息,assistant 含 `error_info.actions=[]` 与原 `task_request` +- **Evaluation**: PASS;刷新后仍可恢复“当前请求未创建新任务”的状态卡,且不会显示重试按钮 + ## 六、 自动化测试套件汇总 (Test Suite Execution) ```text diff --git a/internal/server/task_run_busy_preflight_test.go b/internal/server/task_run_busy_preflight_test.go new file mode 100644 index 00000000..13064aa5 --- /dev/null +++ b/internal/server/task_run_busy_preflight_test.go @@ -0,0 +1,71 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "chat-agent-system/internal/docgov" +) + +func TestCreateTaskRunBusyPreflightDoesNotPersistRunOrMessages(t *testing.T) { + srv := newTestServer(t) + ctx := t.Context() + docID := "doc_proj143_intake" + if _, err := srv.svc.DocGov().CreateOrGetDocument( + ctx, docID, 143, "intake", "用户初始需求收集表", "V0.1_triage", "", + 1, "编制人", 1, "审核人", 1, "批准人", + ); err != nil { + t.Fatalf("create document: %v", err) + } + if _, err := srv.svc.DocGov().AcquireLease(ctx, docgov.LeaseAcquireRequest{ + DocID: docID, HolderType: docgov.LeaseHolderHuman, HolderID: "editor-9", TTL: time.Minute, + }); err != nil { + t.Fatalf("acquire fixture lease: %v", err) + } + defer func() { + _, _ = srv.svc.DocGov().ReleaseLease(t.Context(), docID, string(docgov.LeaseHolderHuman), "editor-9") + }() + + requestBody, _ := json.Marshal(createTaskRunRequest{ + TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 143, ProjectName: "隔离项目"}, + SessionID: "fixture-busy-preflight", ClientTurnID: "turn-busy-1", UserMessage: "开始编写用户初始需求表", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs", bytes.NewReader(requestBody)) + withBearerAuth(req, loginTestAdmin(t, srv)) + 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 response: %v", err) + } + if payload["error_code"] != "DOC_BUSY" { + t.Fatalf("payload=%+v", payload) + } + if _, exists := payload["run_id"]; exists { + t.Fatalf("busy preflight must not return run_id: %+v", payload) + } + history, err := srv.svc.ContextManager().GetChatHistory(ctx, "fixture-busy-preflight", 20, 0) + if err != nil { + t.Fatalf("get busy history: %v", err) + } + if len(history) != 2 { + t.Fatalf("busy preflight history=%d want user+assistant", len(history)) + } + if history[1].Metadata["task_status"] != "failed" { + t.Fatalf("assistant metadata=%+v", history[1].Metadata) + } + errorInfo, ok := history[1].Metadata["error_info"].(map[string]any) + if !ok || errorInfo["code"] != "DOC_BUSY" { + t.Fatalf("error_info=%+v", history[1].Metadata["error_info"]) + } + if actions, ok := errorInfo["actions"].([]string); !ok || len(actions) != 0 { + t.Fatalf("busy actions=%+v", errorInfo["actions"]) + } +} diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index f236ae32..3bbec1a0 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -13,6 +13,8 @@ import ( "chat-agent-system/internal/agent" "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/docgov" "chat-agent-system/internal/projectinstance" "chat-agent-system/internal/squad" "chat-agent-system/internal/stream" @@ -87,6 +89,19 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { }) return } + if busy, preflightErr := s.preflightTaskDocumentBusy(r.Context(), req); preflightErr != nil { + http.Error(w, preflightErr.Error(), http.StatusBadRequest) + return + } else if busy != nil { + s.recordBusyPreflightMessages(r.Context(), ctxMgr, req, taskScope, busy) + writeJSONResponse(w, http.StatusConflict, map[string]any{ + "error": "document_busy", + "error_code": "DOC_BUSY", + "message": busy.Reason, + "detail": busy, + }) + return + } runID := fmt.Sprintf("run_%d", time.Now().UnixNano()) var assistantMsgID int64 @@ -154,6 +169,102 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) recordBusyPreflightMessages( + ctx context.Context, + ctxMgr *ctxmgr.ContextManager, + req createTaskRunRequest, + taskScope *projectinstance.MessageScope, + busy *docgov.DocumentBusyError, +) { + if ctxMgr == nil || busy == nil || req.UserMessage == "" { + return + } + userMeta := map[string]any{ + "project_id": req.ProjectID, + "client_turn_id": req.ClientTurnID, + "task_key": req.TaskKey, + } + projectinstance.ApplyScopeToMetadata(userMeta, taskScope) + _, _ = 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": "DOC_BUSY", + "class": "fatal", + "user_message": busy.Reason, + "actions": []string{}, + "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, + } + projectinstance.ApplyScopeToMetadata(asstMeta, taskScope) + _, _ = ctxMgr.RecordUserMessageWithID(ctx, req.SessionID, 0, "assistant", busy.Reason, "TRIGGER_TASK", "", asstMeta) +} + +// preflightTaskDocumentBusy 在写入聊天占位与 task_runs 之前拦截已知租约占用。 +// 真正的租约获取仍由 docgov 原子执行;本检查用于避免用户重复点击产生必然失败的 run。 +func (s *Server) preflightTaskDocumentBusy(ctx context.Context, req createTaskRunRequest) (*docgov.DocumentBusyError, error) { + if req.Stage != "" || req.ProjectID <= 0 || req.TaskKey == "" || s.svc == nil || s.svc.DocGov() == nil { + return nil, nil + } + taskReq := s.buildBizdevTaskRequest(req.TaskRunRequestDTO, req.SessionID, req.ClientTurnID, "") + docID, err := s.svc.ResolveAndValidateDocID(ctx, taskReq) + if err != nil { + return nil, err + } + if docID == "" { + return nil, nil + } + doc, err := s.svc.DocGov().GetDocument(ctx, docID) + if errors.Is(err, docgov.ErrDocumentNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + if !doc.HasActiveLease(time.Now()) { + return nil, nil + } + // 已结束的小队 run 由 AcquireLease 的孤儿回收逻辑处理,不在这里误拦截。 + if doc.LeaseHolderType == docgov.LeaseHolderSquad && s.taskRuns != nil { + if run, runErr := s.taskRuns.GetRun(doc.LeaseHolderID); runErr == nil && run.Status != "running" { + return nil, nil + } + } + expiresAt := time.Time{} + if doc.LeaseExpiresAt != nil { + expiresAt = *doc.LeaseExpiresAt + } + reason := fmt.Sprintf("文档【%s】正在由 %s(%s) 编辑中,当前请求未创建新任务", doc.Title, doc.LeaseHolderType, doc.LeaseHolderID) + return &docgov.DocumentBusyError{ + DocID: doc.DocID, + HolderType: doc.LeaseHolderType, + HolderID: doc.LeaseHolderID, + WorkDir: doc.LeaseWorkDir, + ExpiresAt: expiresAt, + Reason: reason, + }, nil +} + func (s *Server) handleTaskRunEvents(w http.ResponseWriter, r *http.Request) { user, ok := s.requirePrincipal(w, r) if !ok { diff --git a/web/src/App.tsx b/web/src/App.tsx index 7cfaaeda..c3e27daa 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2139,6 +2139,14 @@ export default function App() { ): ChatErrorInfo => { const code = opts?.errorCode || ''; const detail = opts?.detail || ''; + if (code === 'DOC_BUSY' || code === 'document_busy') { + return { + userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务', + code: 'DOC_BUSY', + class: 'fatal', + actions: [], + }; + } if (code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE') { return { userMessage: detail || '该文档任务不在当前活动阶段,请先完成当前阶段工作流后再试', diff --git a/web/src/api/chatRetry.test.ts b/web/src/api/chatRetry.test.ts index bca3d7f7..7d917e69 100644 --- a/web/src/api/chatRetry.test.ts +++ b/web/src/api/chatRetry.test.ts @@ -25,6 +25,10 @@ test('shouldAutoRetryTask: transient only, max 2', () => { ); }); +test('shouldAutoRetryTask: document busy never retries', () => { + assert.equal(shouldAutoRetryTask({ class: 'transient', code: 'DOC_BUSY' }, 0), false); +}); + test('shouldAutoRetry alias delegates to task path', () => { assert.equal(shouldAutoRetry({ class: 'transient', code: 'NETWORK_ERROR' }, 0), true); }); diff --git a/web/src/api/chatRetry.ts b/web/src/api/chatRetry.ts index ebcbfe50..839875d6 100644 --- a/web/src/api/chatRetry.ts +++ b/web/src/api/chatRetry.ts @@ -16,6 +16,7 @@ export function shouldAutoRetryTask( if ( info.code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE' || info.code === 'DOC_DUTY_FORBIDDEN' || + info.code === 'DOC_BUSY' || info.code === 'ERR_FORBIDDEN' ) { return false; diff --git a/web/src/api/taskRuns.test.ts b/web/src/api/taskRuns.test.ts new file mode 100644 index 00000000..602fba6e --- /dev/null +++ b/web/src/api/taskRuns.test.ts @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createTaskRun } from './taskRuns'; + +test('createTaskRun exposes DOC_BUSY detail without a retryable run', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + error: 'document_busy', + error_code: 'DOC_BUSY', + message: '文档正在由其他用户编辑中,当前请求未创建新任务', + detail: { reason: '文档正在由其他用户编辑中' }, + }), + { status: 409, headers: { 'Content-Type': 'application/json' } }, + ); + try { + await assert.rejects( + () => createTaskRun({ session_id: 'fixture', project_id: 143 }), + (err: unknown) => { + const typed = err as { httpStatus?: number; errorCode?: string; detail?: string }; + assert.equal(typed.httpStatus, 409); + assert.equal(typed.errorCode, 'DOC_BUSY'); + assert.match(typed.detail || '', /未创建新任务/); + return true; + }, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index f04cec67..5d3bad56 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -25,9 +25,16 @@ export async function createTaskRun(body: CreateTaskRunBody): Promise