diff --git a/.doc/ops/short-term/2026-09-23-task-bubble-bind.md b/.doc/ops/short-term/2026-09-23-task-bubble-bind.md new file mode 100644 index 00000000..7fff59d2 --- /dev/null +++ b/.doc/ops/short-term/2026-09-23-task-bubble-bind.md @@ -0,0 +1,43 @@ +--- +title: "任务气泡 serverId 绑定与失败卡按钮修复" +task_id: "task-bubble-bind-2026-09-23" +status: done +created: 2026-09-23 +updated: 2026-09-23 +owner: Cursor +task_type: implementation +task_tier: small +policy_revision: "agent-governance-2026-09-21-r1 @ 15fdb28aa18af456c33fe06f0dce93813d112cc8" +branch: feature/task-bubble-bind-2026-09-23 +worktree: .worktrees/task-bubble-bind-2026-09-23 +base_dev_sha: "15fdb28aa18af456c33fe06f0dce93813d112cc8" +head_sha: "b9b192a8430a47aebb1ab064f6c71370e8ef516d" +write_scope: ["web/src", "internal/server", ".doc"] +allowed_environments: ["test"] +mutex: "web-task-bubble+task-run-api" +review_state: pending +integration_state: pending +deployment: test_app_overlay_from_worktree +cleanup: pending +cleanup_owner: "独立维护者" +cleanup_trigger: "合入 local_dev 且 tip 为祖先后" +remote_sync: not_requested +--- + +# 任务气泡 serverId 绑定与失败卡按钮修复 + +## 用户目标 + +test 环境任务卡双气泡/排序错乱/刷新退化;继续任务按钮无反应;同时确认 test Runner 已部署响应体积修复。 + +## 步骤 + +1. [x] createTaskRun 响应返回 assistant_msg_id;前端绑定 serverId + clientTurnId +2. [x] 失败卡:不可 resume 时隐藏 continue;restart 新开 turn/气泡 +3. [x] test 重建核验 + 场景/LiveDualTrack 证据 +4. [x] web 回归 + 浏览器验证;报告落盘 + +## 相关 + +- 报告:[2026-09-23-task-bubble-bind-test-report](../../test-reports/2026-09-23-task-bubble-bind-test-report.md) +- Plan: test 环境验证 + 前端任务卡修复 diff --git a/.doc/test-reports/2026-09-23-task-bubble-bind-test-report.md b/.doc/test-reports/2026-09-23-task-bubble-bind-test-report.md new file mode 100644 index 00000000..d92a251d --- /dev/null +++ b/.doc/test-reports/2026-09-23-task-bubble-bind-test-report.md @@ -0,0 +1,46 @@ +# 测试报告:任务气泡 serverId 绑定与 test 环境核验 + +- **日期**: 2026-09-23 +- **任务**: [2026-09-23-task-bubble-bind](../ops/short-term/2026-09-23-task-bubble-bind.md) +- **被测 SHA**: `b9b192a8`(`feature/task-bubble-bind-2026-09-23`) +- **环境**: test(`:8081` / Runner `:18866` MiniMax-M3);app 镜像由本 worktree 源码构建并 `--force-recreate` +- **判定**: PASS(范围内) + +## 范围 + +1. test Runner `capPatches` 与 LiveDualTrack(typed reject 体积路径) +2. `POST /api/v1/tasks/runs` 返回 `assistant_msg_id`;前端绑定 serverId / clientTurnId;失败卡不展示不可 resume 的「继续任务」 +3. `web` `npm test` / `npm run build`;浏览器登录后发任务话术并刷新 + +## 命令与结果 + +| 检查 | 结果 | 证据 | +|---|---|---| +| arch-check --mode block | PASS | [arch-check.txt](./assets/2026-09-23-task-bubble-bind/arch-check.txt) | +| web npm test | PASS(491 + 34) | [node-test.txt](./assets/2026-09-23-task-bubble-bind/node-test.txt) | +| web npm run build | PASS | 本地构建日志 | +| go test -short ./internal/server | PASS(含 TypedReject→fatal) | 本会话执行 | +| test `/health` + Runner health | ok / MiniMax-M3 | [app](./assets/2026-09-23-task-bubble-bind/task-bubble-test-app-health.json) · [runner](./assets/2026-09-23-task-bubble-bind/task-bubble-test-runner-health.json) | +| createTaskRun `assistant_msg_id` | 202 + id=242 | [create-task-run.json](./assets/2026-09-23-task-bubble-bind/create-task-run.json) · [scenario-verdict.json](./assets/2026-09-23-task-bubble-bind/scenario-verdict.json) | +| LiveDualTrack @ test Runner | PASS 4.36s | [live-dualtrack.txt](./assets/2026-09-23-task-bubble-bind/live-dualtrack.txt) | +| 浏览器发任务 + 刷新 | 同 turn 无双「任务执行中」占位 | [browser-verify.json](./assets/2026-09-23-task-bubble-bind/browser-verify.json) · screenshots | + +## 预期 vs 实际 + +| 预期 | 实际 | +|---|---| +| Runner 含 `capPatches` | 容器内 `result-v1.mjs` 可见 | +| 不再以 typed reject 伪装可继续 | LiveDualTrack PASS;classify fatal 文案在 app binary | +| create 响应带 `assistant_msg_id` | 有 | +| 终端 failed 不提供 continue | legacy map + handleErrorAction 守卫;单测覆盖 | +| 刷新后无同 turn 双气泡 | 浏览器刷新后每 turn 一张助手卡 | + +## 缺项 / 风险 + +- 完整「已激活工作流」长 E2E intake 本轮因默认 plan 保存校验未走通,未跑通真实文档生成;typed reject 体积路径以 LiveDualTrack + Runner 制品核验为准。 +- test app 镜像由 worktree 构建覆盖,尚未合入 local_dev;合入前勿假定 main 构建上下文含本提交。 +- `integration_state=pending`;`cleanup=pending`。 + +## 结论 + +工作流 A(test Runner/classify 制品)与工作流 B(气泡绑定 + 按钮守卫)在授权 test 环境内验收通过;待合入 local_dev。 diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/activate-405.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/activate-405.json new file mode 100644 index 00000000..c5771e73 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/activate-405.json @@ -0,0 +1,3 @@ +{ + "raw": "Method Not Allowed\n" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/activation-preview.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/activation-preview.json new file mode 100644 index 00000000..b279b2d5 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/activation-preview.json @@ -0,0 +1,6 @@ +{ + "code": "VALIDATION", + "message": "请求参数无效", + "request_id": "req_b8cd81eead320278", + "error": "{\"error\":\"ERR_INVALID_PLAN\",\"message\":\"invalid workflow plan: no saved plan\"}" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/arch-check.txt b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/arch-check.txt new file mode 100644 index 00000000..f3f3eada --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/arch-check.txt @@ -0,0 +1,5 @@ +arch-check mode=block +boundaries=/private/tmp/Agent-System/.worktrees/task-bubble-bind-2026-09-23/.doc/system-design/module-boundaries.yaml +ok chat-agent-system/internal/archtest 0.978s +ok chat-agent-system/internal/archtest 0.486s +arch-check block: production gate passed diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/browser-after-refresh.png b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/browser-after-refresh.png new file mode 100644 index 00000000..7b0702b1 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/browser-after-refresh.png differ diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/browser-chat-settled.png b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/browser-chat-settled.png new file mode 100644 index 00000000..a7364119 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/browser-chat-settled.png differ diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/browser-chat.png b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/browser-chat.png new file mode 100644 index 00000000..b6e76336 Binary files /dev/null and b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/browser-chat.png differ diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/browser-verify.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/browser-verify.json new file mode 100644 index 00000000..cbba6ee6 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/browser-verify.json @@ -0,0 +1,15 @@ +{ + "env": "test", + "base_url": "http://127.0.0.1:8081", + "observations": [ + "login as admin succeeded", + "send「根据我上传的文档,编写用户初始需求收集表」showed single live task card (执行中) for the new turn", + "refresh kept one assistant card per turn (blocked_pending_governance / 待激活工作流); no duplicated 任务执行中 placeholder for the same turn", + "deployed web bundle contains assistant_msg_id and 正在重新开始任务 markers" + ], + "screenshots": [ + "browser-chat.png", + "browser-chat-settled.png", + "browser-after-refresh.png" + ] +} diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/create-task-run.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/create-task-run.json new file mode 100644 index 00000000..6fff0193 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/create-task-run.json @@ -0,0 +1,9 @@ +{ + "http_status": 202, + "run_id": "run_1790133483248059802", + "status": "blocked_pending_governance", + "assistant_msg_id": 242, + "workspace_id": null, + "error_code": null, + "message": "" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/default-plan-meta.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/default-plan-meta.json new file mode 100644 index 00000000..8224cae0 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/default-plan-meta.json @@ -0,0 +1,15 @@ +{ + "status": 200, + "keys": [ + "workflow_definition_key", + "workflow_version", + "workflow_version_id", + "stages" + ], + "plan_keys": [ + "workflow_definition_key", + "workflow_version", + "workflow_version_id", + "stages" + ] +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/deploy-markers.txt b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/deploy-markers.txt new file mode 100644 index 00000000..15535742 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/deploy-markers.txt @@ -0,0 +1,3 @@ +assistant_msg_id present in /app/server +capPatches present in runner result-v1.mjs +classify fatal copy present diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/live-dualtrack.txt b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/live-dualtrack.txt new file mode 100644 index 00000000..f543c118 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/live-dualtrack.txt @@ -0,0 +1,12 @@ +=== RUN TestPiAgentTypedResult_LiveDualTrack +=== RUN TestPiAgentTypedResult_LiveDualTrack/failure_empty_prompt +=== RUN TestPiAgentTypedResult_LiveDualTrack/timeout +=== RUN TestPiAgentTypedResult_LiveDualTrack/cancel +=== RUN TestPiAgentTypedResult_LiveDualTrack/success +--- PASS: TestPiAgentTypedResult_LiveDualTrack (4.36s) + --- PASS: TestPiAgentTypedResult_LiveDualTrack/failure_empty_prompt (0.02s) + --- PASS: TestPiAgentTypedResult_LiveDualTrack/timeout (0.01s) + --- PASS: TestPiAgentTypedResult_LiveDualTrack/cancel (0.30s) + --- PASS: TestPiAgentTypedResult_LiveDualTrack/success (4.02s) +PASS +ok chat-agent-system/internal/agent 5.431s diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/login-status.txt b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/login-status.txt new file mode 100644 index 00000000..b049161f --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/login-status.txt @@ -0,0 +1 @@ +status=200 user_id=4 diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/node-test.txt b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/node-test.txt new file mode 100644 index 00000000..e550eba0 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/node-test.txt @@ -0,0 +1,20 @@ + ✔ 2. 切回项目时相同正文不应因 temp/server id 差异重复展示 (0.070333ms) +✔ 工作流切换与消息展示时序测试套件 (0.924292ms) +✔ DeliverableItem 必须包含 projectId 与 isTemplateOnly 属性 (0.409333ms) +ℹ tests 491 +ℹ suites 38 +ℹ pass 491 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 3110.608583 + + RUN v5.0.0 /private/tmp/Agent-System/.worktrees/task-bubble-bind-2026-09-23/web + + + Test Files 3 passed (3) + Tests 34 passed (34) + Start at 11:09:56 + Duration 292ms (transform 63%, import 29%, tests 6%, worker 2%) + diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/project-create.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/project-create.json new file mode 100644 index 00000000..483a4dad --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/project-create.json @@ -0,0 +1,19 @@ +{ + "id": 6011, + "code": "task-bubble--0056305d", + "name": "task-bubble-bind-1790133432", + "customer_name": "", + "device_type": "肌骨超声", + "intended_use": "", + "target_market": "", + "current_stage": "V0.1_triage", + "environment": "dev", + "status": "active", + "creator_user_id": 4, + "lifecycle_status": "planning", + "active_workflow_key": "", + "workflow_selection_version": 1, + "workflow_plan_revision": 0, + "created_at": "2026-09-23T03:17:12.134425334Z", + "updated_at": "2026-09-23T03:17:12.134425334Z" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/projects-shape.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/projects-shape.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/projects-shape.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/projects-status.txt b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/projects-status.txt new file mode 100644 index 00000000..b4a99424 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/projects-status.txt @@ -0,0 +1 @@ +status=200 type=list diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/save-plan.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/save-plan.json new file mode 100644 index 00000000..e1879687 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/save-plan.json @@ -0,0 +1,41 @@ +{ + "error": "VALIDATION_FAILED", + "issues": [ + { + "code": "SEPARATION_OF_DUTY_VIOLATION", + "message": "作者、审核人、批准人必须分离", + "field": "bd_intake" + }, + { + "code": "SEPARATION_OF_DUTY_VIOLATION", + "message": "作者、审核人、批准人必须分离", + "field": "bd_research" + }, + { + "code": "SEPARATION_OF_DUTY_VIOLATION", + "message": "作者、审核人、批准人必须分离", + "field": "bd_feasibility_dev" + }, + { + "code": "SEPARATION_OF_DUTY_VIOLATION", + "message": "作者、审核人、批准人必须分离", + "field": "bd_feasibility_reg" + }, + { + "code": "SEPARATION_OF_DUTY_VIOLATION", + "message": "作者、审核人、批准人必须分离", + "field": "bd_user_requirements" + }, + { + "code": "SEPARATION_OF_DUTY_VIOLATION", + "message": "作者、审核人、批准人必须分离", + "field": "bd_prelim_design" + }, + { + "code": "SEPARATION_OF_DUTY_VIOLATION", + "message": "作者、审核人、批准人必须分离", + "field": "bd_cdo_quote" + } + ], + "message": "工作流配置校验未通过" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/scenario-verdict.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/scenario-verdict.json new file mode 100644 index 00000000..808ba1aa --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/scenario-verdict.json @@ -0,0 +1,8 @@ +{ + "assistant_msg_id_in_create": true, + "final_status": "blocked_pending_governance", + "typed_result_rejected": false, + "has_fatal_validate_copy": false, + "resumable": false, + "error_snippet": "" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/session-upsert.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/session-upsert.json new file mode 100644 index 00000000..5380962a --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/session-upsert.json @@ -0,0 +1,9 @@ +{ + "session_id": "sess-p6011-u4-bubble", + "title": "bubble bind probe", + "user_id": 4, + "project_id": 6011, + "project_name": "task-bubble-bind-1790133432", + "message_count": 0, + "updated_at": "2026-09-23T03:18:03.233795Z" +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/task-bubble-test-app-health.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/task-bubble-test-app-health.json new file mode 100644 index 00000000..b3935e53 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/task-bubble-test-app-health.json @@ -0,0 +1 @@ +{"status":"ok","env":"test","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":65,"schema_ready":true,"task_store_ready":true} diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/task-bubble-test-runner-health.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/task-bubble-test-runner-health.json new file mode 100644 index 00000000..cd156b28 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/task-bubble-test-runner-health.json @@ -0,0 +1 @@ +{"ok":true,"service":"shuimu-pi-agent-runner","endpoint_url":"https://api.minimaxi.com/v1","model_id":"MiniMax-M3","api":"openai-compatible","has_token":true,"tavily":{"active":true,"keys_count":2},"data_dir":"/app/data/workspaces","uptime":18.608525759} diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/task-run-final.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/task-run-final.json new file mode 100644 index 00000000..9a320dd6 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/task-run-final.json @@ -0,0 +1,9 @@ +{ + "poll": 1, + "http_status": 200, + "status": "blocked_pending_governance", + "error_message": "", + "workspace_id": null, + "resumable": false, + "assistant_message_id": 242 +} \ No newline at end of file diff --git a/.doc/test-reports/assets/2026-09-23-task-bubble-bind/workflow-plan-get.json b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/workflow-plan-get.json new file mode 100644 index 00000000..babe99dc --- /dev/null +++ b/.doc/test-reports/assets/2026-09-23-task-bubble-bind/workflow-plan-get.json @@ -0,0 +1,3 @@ +{ + "raw": "404 page not found\n" +} \ No newline at end of file diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index d0edbdb3..cf4d90b1 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -31,9 +31,10 @@ type createTaskRunRequest struct { } type createTaskRunResponse struct { - RunID string `json:"run_id"` - WorkspaceID string `json:"workspace_id,omitempty"` - Status string `json:"status"` + RunID string `json:"run_id"` + WorkspaceID string `json:"workspace_id,omitempty"` + Status string `json:"status"` + AssistantMsgID int64 `json:"assistant_msg_id,omitempty"` } type taskRunResponse struct { @@ -97,9 +98,10 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { return } writeJSONResponse(w, http.StatusAccepted, createTaskRunResponse{ - RunID: existing.RunID, - WorkspaceID: existing.WorkspaceID, - Status: existing.Status, + RunID: existing.RunID, + WorkspaceID: existing.WorkspaceID, + Status: existing.Status, + AssistantMsgID: existing.AssistantMsgID, }) return } @@ -169,8 +171,10 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { } if !created { writeJSONResponse(w, http.StatusAccepted, createTaskRunResponse{ - RunID: claimed.RunID, - Status: claimed.Status, + RunID: claimed.RunID, + WorkspaceID: claimed.WorkspaceID, + Status: claimed.Status, + AssistantMsgID: claimed.AssistantMsgID, }) return } @@ -224,8 +228,10 @@ func (s *Server) handleCreateTaskRun(w http.ResponseWriter, r *http.Request) { } writeJSONResponse(w, http.StatusAccepted, createTaskRunResponse{ - RunID: createdRun.RunID, - Status: createdRun.Status, + RunID: createdRun.RunID, + WorkspaceID: createdRun.WorkspaceID, + Status: createdRun.Status, + AssistantMsgID: assistantMsgID, }) } @@ -257,11 +263,14 @@ func (s *Server) createBlockedGovernanceRun(w http.ResponseWriter, r *http.Reque } if !created { writeJSONResponse(w, http.StatusAccepted, createTaskRunResponse{ - RunID: claimed.RunID, - Status: claimed.Status, + RunID: claimed.RunID, + WorkspaceID: claimed.WorkspaceID, + Status: claimed.Status, + AssistantMsgID: claimed.AssistantMsgID, }) return } + var assistantMsgID int64 ctxMgr := s.ctxMgr if ctxMgr != nil && req.UserMessage != "" { userMeta := map[string]any{ @@ -277,14 +286,30 @@ func (s *Server) createBlockedGovernanceRun(w http.ResponseWriter, r *http.Reque "client_turn_id": req.ClientTurnID, "task_key": req.TaskKey, "task_status": "blocked_pending_governance", + "task_request": 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, + }, } projectinstance.ApplyScopeToMetadata(asstMeta, taskScope) - assistantMsgID, _ := ctxMgr.RecordUserMessageWithID(r.Context(), req.SessionID, 0, "assistant", projectinstance.GovernanceBlockedHint, "TRIGGER_TASK", "", asstMeta) + assistantMsgID, _ = ctxMgr.RecordUserMessageWithID(r.Context(), req.SessionID, 0, "assistant", projectinstance.GovernanceBlockedHint, "TRIGGER_TASK", "", asstMeta) _ = s.taskRuns.BindAssistantMsgID(r.Context(), claimed.RunID, assistantMsgID) } writeJSONResponse(w, http.StatusAccepted, createTaskRunResponse{ - RunID: claimed.RunID, - Status: claimed.Status, + RunID: claimed.RunID, + WorkspaceID: claimed.WorkspaceID, + Status: claimed.Status, + AssistantMsgID: assistantMsgID, }) } @@ -686,9 +711,10 @@ func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { } writeJSONResponse(w, http.StatusAccepted, createTaskRunResponse{ - RunID: resumed.RunID, - WorkspaceID: resumed.WorkspaceID, - Status: resumed.Status, + RunID: resumed.RunID, + WorkspaceID: resumed.WorkspaceID, + Status: resumed.Status, + AssistantMsgID: resumed.AssistantMsgID, }) } diff --git a/internal/server/taskstream_test.go b/internal/server/taskstream_test.go index e38a433b..19d320c6 100644 --- a/internal/server/taskstream_test.go +++ b/internal/server/taskstream_test.go @@ -120,11 +120,9 @@ func TestTaskStream_TransientInjection_DeadPiAgent(t *testing.T) { } } -// TestTaskStream_SoftFailure_ClassifiedResumable 软失败(pi-agent 上游业务失败而非网络/超时): -// squad 层返回携带 WorkDir 的 result → class=resumable、actions=[continue restart], -// workspace_id 供前端「继续任务」续跑 -func TestTaskStream_SoftFailure_ClassifiedResumable(t *testing.T) { - // 伪造 pi-agent runner:HTTP 500 + 非 JSON body → decode 失败 → 业务软失败(非 dial/timeout) +// TestTaskStream_TypedReject_ClassifiedFatal runner 契约失败(typed result rejected): +// 即使携带 WorkDir,也必须 class=fatal、仅 restart,不得伪装成可 continue 的中断。 +func TestTaskStream_TypedReject_ClassifiedFatal(t *testing.T) { brokenRunner := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "simulated upstream business failure", http.StatusInternalServerError) })) @@ -171,14 +169,17 @@ func TestTaskStream_SoftFailure_ClassifiedResumable(t *testing.T) { if errEv.ErrorInfo == nil { t.Fatal("error_info is nil") } - if errEv.ErrorInfo.Class != stream.ErrorClassResumable { - t.Fatalf("class=%q want resumable (upstream=%q)", errEv.ErrorInfo.Class, errEv.ErrorInfo.Upstream) + if errEv.ErrorInfo.Class != stream.ErrorClassFatal { + t.Fatalf("class=%q want fatal (upstream=%q)", errEv.ErrorInfo.Class, errEv.ErrorInfo.Upstream) } - if errEv.ErrorInfo.WorkspaceID == "" { - t.Fatal("workspace_id is empty (expected squad soft-failure to carry work_dir)") + if errEv.ErrorInfo.Code != "RUNNER_RESULT_REJECTED" { + t.Fatalf("code=%q want RUNNER_RESULT_REJECTED", errEv.ErrorInfo.Code) } - if !hasAction(errEv.ErrorInfo.Actions, "continue") || !hasAction(errEv.ErrorInfo.Actions, "restart") { - t.Fatalf("actions=%v want [continue restart]", errEv.ErrorInfo.Actions) + if hasAction(errEv.ErrorInfo.Actions, "continue") { + t.Fatalf("actions=%v must not include continue for typed reject", errEv.ErrorInfo.Actions) + } + if !hasAction(errEv.ErrorInfo.Actions, "restart") { + t.Fatalf("actions=%v want restart", errEv.ErrorInfo.Actions) } } diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index d862fb34..2c52a8be 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -210,22 +210,23 @@ export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage { r.role === 'assistant' && (meta.task_status === 'failed' || meta.task_status === 'interrupted' || meta.task_status === 'finalizing_failed') && !legacyBusyError - ? { - userMessage: - meta.task_status === 'interrupted' || meta.workspace_id + ? (() => { + const interrupted = meta.task_status === 'interrupted'; + const workspaceId = typeof meta.workspace_id === 'string' ? meta.workspace_id : ''; + const resumable = interrupted && workspaceId.trim() !== ''; + return { + userMessage: interrupted ? '任务执行中断,可继续或重新开始' : '任务执行失败,可按原请求重新开始', - code: meta.task_status === 'interrupted' || meta.workspace_id ? 'TASK_INTERRUPTED' : 'TASK_FAILED', - class: (meta.task_status === 'interrupted' || meta.workspace_id ? 'resumable' : 'fatal') as - | 'resumable' - | 'fatal', - upstream: r.content, - workspaceId: typeof meta.workspace_id === 'string' ? meta.workspace_id : undefined, - actions: - meta.task_status === 'interrupted' || meta.workspace_id + code: interrupted ? 'TASK_INTERRUPTED' : 'TASK_FAILED', + class: (resumable ? 'resumable' : 'fatal') as 'resumable' | 'fatal', + upstream: r.content, + workspaceId: resumable ? workspaceId : undefined, + actions: resumable ? (['continue', 'restart'] as Array<'continue' | 'restart'>) : (['restart'] as Array<'restart'>), - } + }; + })() : undefined; const msgId = meta.client_id || (r.id ? `msg-${r.id}` : `msg-tmp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`); const phaseSnapshot = parseTaskPhaseSnapshot(meta.task_phase_snapshot); diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index a470af5d..81cec907 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -15,6 +15,7 @@ export type CreateTaskRunResponse = { run_id: string; status: string; workspace_id?: string; + assistant_msg_id?: number; }; export async function createTaskRun(body: CreateTaskRunBody): Promise { diff --git a/web/src/components/chat/chatWindowArtifact.test.ts b/web/src/components/chat/chatWindowArtifact.test.ts index 2edd9d49..08fcc028 100644 --- a/web/src/components/chat/chatWindowArtifact.test.ts +++ b/web/src/components/chat/chatWindowArtifact.test.ts @@ -164,4 +164,28 @@ describe('mapRecordToMessage artifact projection', () => { expect(msg.errorInfo?.code).toBe('DOC_BUSY'); expect(msg.errorInfo?.actions).toEqual([]); }); + + it('failed legacy rows with workspace_id do not offer continue', () => { + const msg = mapRecordToMessage({ + id: 101, + session_id: 'sess-p143-u9', + user_id: 0, + day_key: '2026-09-13', + role: 'assistant', + content: '助手执行结果校验失败,请重新开始任务', + metadata: { + project_id: 143, + task_key: 'intake', + task_status: 'failed', + workspace_id: '/work/failed-run', + run_id: 'run-101', + }, + created_at: '2026-09-13T03:01:00Z', + }); + + expect(msg.errorInfo?.code).toBe('TASK_FAILED'); + expect(msg.errorInfo?.class).toBe('fatal'); + expect(msg.errorInfo?.actions).toEqual(['restart']); + expect(msg.errorInfo?.workspaceId).toBeUndefined(); + }); }); diff --git a/web/src/hooks/useChatWindow.ts b/web/src/hooks/useChatWindow.ts index 6cde1e79..47dd6302 100644 --- a/web/src/hooks/useChatWindow.ts +++ b/web/src/hooks/useChatWindow.ts @@ -597,6 +597,9 @@ export function useChatWindow({ } if (idxA !== undefined) return 1; if (idxB !== undefined) return -1; + if (a.clientTurnId && b.clientTurnId && a.clientTurnId === b.clientTurnId && a.role !== b.role) { + return a.role === 'user' ? -1 : 1; + } return (a.serverId || 0) - (b.serverId || 0); }); diff --git a/web/src/hooks/useSendMessage.ts b/web/src/hooks/useSendMessage.ts index 2cabdcd1..a05a8d67 100644 --- a/web/src/hooks/useSendMessage.ts +++ b/web/src/hooks/useSendMessage.ts @@ -396,6 +396,8 @@ export function useSendMessage(input: SendMessageInput) { }; const placeholder: ChatMessage = { id: asstId, + clientTurnId, + clientMessageId: `${clientTurnId}:assistant`, sessionId: activeSessionId, role: 'assistant', authorName: '项目助手', diff --git a/web/src/hooks/useTaskRunAttempts.test.ts b/web/src/hooks/useTaskRunAttempts.test.ts index 3d0f1068..3d901a21 100644 --- a/web/src/hooks/useTaskRunAttempts.test.ts +++ b/web/src/hooks/useTaskRunAttempts.test.ts @@ -26,6 +26,11 @@ test('chat and task attempts live in one hook', () => { assert.equal(hook.includes('buildCancelledTaskErrorInfo'), true); assert.equal(hook.includes('isTaskStageDegradeCode'), true); assert.equal(hook.includes('改由对话确认引导'), true); + assert.equal(hook.includes('assistant_msg_id'), true); + assert.equal(hook.includes('replaceMessageServerId(asstId, assistantMsgId)'), true); + assert.equal(hook.includes('run not resumable'), true); + assert.equal(hook.includes('_restart'), true); + assert.equal(hook.includes('正在重新开始任务'), true); }); test('stage unresolved and not-in-active-stage degrade to chat once', () => { diff --git a/web/src/hooks/useTaskRunAttempts.ts b/web/src/hooks/useTaskRunAttempts.ts index 7e4d912d..c0147edf 100644 --- a/web/src/hooks/useTaskRunAttempts.ts +++ b/web/src/hooks/useTaskRunAttempts.ts @@ -677,7 +677,16 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { }); retainedRunId = created.run_id; taskRunStream.setRunId(sessionId, created.run_id); - patchAsst({ taskRunId: created.run_id }); + const assistantMsgId = + typeof created.assistant_msg_id === 'number' && created.assistant_msg_id > 0 + ? created.assistant_msg_id + : 0; + if (assistantMsgId > 0) { + chatWindow.replaceMessageServerId(asstId, assistantMsgId); + patchAsst({ taskRunId: created.run_id, serverId: assistantMsgId }); + } else { + patchAsst({ taskRunId: created.run_id }); + } if (isBlockedGovernanceStatus(created.status)) { settled = true; patchAsst({ @@ -874,6 +883,28 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { }); return; } + const canResume = + target.errorInfo?.class === 'resumable' && + !!(target.errorInfo?.workspaceId || target.taskWorkspaceId); + if (!canResume) { + console.warn('continue ignored: run not resumable', messageId, target.errorInfo?.code); + chatWindow.updateLiveMessage(messageId, { + livePhaseLabel: '该任务无法继续,请重新开始', + errorInfo: target.errorInfo + ? { + ...target.errorInfo, + class: 'fatal', + actions: (target.errorInfo.actions || []).filter((a) => a !== 'continue') as ChatErrorInfo['actions'], + } + : { + userMessage: '该任务无法继续,请重新开始', + code: 'ERR_NOT_RESUMABLE', + class: 'fatal', + actions: ['restart'], + }, + }); + return; + } runTaskAttempt(target.taskRequest, messageId, 'resume', 0, { phaseEvents: target.phaseEvents, workDir: target.errorInfo?.workspaceId || target.taskWorkspaceId, @@ -881,7 +912,41 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) { }); } else { if (!window.confirm('将放弃当前工作区并按原意图重新开始任务,确认?')) return; - runTaskAttempt(target.taskRequest, messageId, 'restart', 0); + const restartTurnId = `turn_${Date.now()}_restart`; + const restartAsstId = `ast-${Date.now()}-restart`; + const restartTask: TaskRunContext = { + ...target.taskRequest, + clientTurnId: restartTurnId, + }; + // 旧失败卡仅保留「已重开」提示,进度走新 live 气泡,避免幂等键撞车与历史行污染。 + chatWindow.updateLiveMessage(messageId, { + livePhaseLabel: undefined, + errorInfo: target.errorInfo + ? { + ...target.errorInfo, + class: 'fatal', + actions: [], + userMessage: `${target.errorInfo.userMessage || '任务已失败'}(已重新开始)`, + } + : undefined, + }); + setMessages((prev) => [ + ...prev, + { + id: restartAsstId, + clientTurnId: restartTurnId, + clientMessageId: `${restartTurnId}:assistant`, + sessionId: restartTask.sessionId || activeSessionId, + role: 'assistant', + authorName: '项目助手', + content: '', + timestamp: new Date().toTimeString().substring(0, 5), + status: 'pending', + livePhaseLabel: '正在重新开始任务…', + taskRequest: restartTask, + }, + ]); + runTaskAttempt(restartTask, restartAsstId, 'run', 0); } return; }