merge: fix/taskrun-scope-persist-2026-09-21

This commit is contained in:
weichen
2026-09-21 05:09:39 +00:00
11 changed files with 304 additions and 11 deletions
+4 -2
View File
@@ -5,7 +5,8 @@
## 焦点
1. **任务错误透传已合入 dev**:`RunRecord` snake_case 与前端真实失败原因(见 [报告](../test-reports/2026-09-21-taskrun-error-transparency-test-report.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`。
@@ -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 | 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 |
@@ -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 | - |
| - | idle | 2026-09-21 | 任务作用域键归一化已在 dev 容器验收;`/health` schema_version=59;项目 6579 任务 `run_1789967333594919025` 为 running | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -0,0 +1,48 @@
---
title: "任务作用域键归一化与失败对话落库"
status: done
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. [x] dev rebuild 与陈威项目 6579 实测
## 验收标准
- [x] live 实例快照为 `bd_intake`、请求 `intake` 时任务创建成功(202)
- [x] 无法解析时 `chat_messages` 仍有用户句与失败回复
- [x] 刷新后该会话不再为空
## 相关链接
- 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 条消息。
@@ -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`。
后续:追踪面板改为对话输出在独立任务中做,不纳入本次缺陷修复。
+4 -2
View File
@@ -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 = &copyDoc
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]
}
}
+3 -1
View File
@@ -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,
@@ -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,
+1 -1
View File
@@ -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
}
}
@@ -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,