修复: force_send单锁竞态+classify unknown不重试(撤销误判)+UX交互批

This commit is contained in:
2026-06-17 14:31:47 +08:00
parent 0633aa6614
commit 924158cff2
8 changed files with 256 additions and 31 deletions

View File

@@ -45,6 +45,8 @@
### P0 — 阻断性 bug
- [x] ✅(主代修06-17·撤销误判) **🚨 BUG-260617-01 [P0] 已修** — **`|| true` 致 400/参数错误无效重试 4 次**【已修:stream_recv.rs:433 删 `|| true` + 注释 + 单测 classify_unknown_retryable→classify_unknown_not_retryable 断言改 !;cargo test 20 passed】 — 用户实测证实(2026-06-17)。Anthropic `[1214][messages 参数非法]` 无 HTTP 前缀 → 落入 `stream_recv.rs:433` `\|\| true` → retryable=true → **空耗 4 次重试浪费 token**。前次审查误判为"有意设计"。**一行改动:删 `|| true` + 改单测断言**。— src-tauri/src/commands/ai/stream_recv.rs:433
- [ ] **🚨 BUG-260617-12 [P1]→P0 联动** — **DeepSeek reasoning_content 缺失致必 400** — 全代码库零处理 thinking/reasoning_content 字段(CompletionRequest+ChatMessage 均无)。DeepSeek thinking 模式每次请求 400被 BUG-01 放大至 **16 次无效请求**(4重试×可能多 provider)。需补字段+透传+openai_compat 映射。— crates/df-ai-core/src/provider.rs:22-39,92-99
- [x] B-260614-01 — ~~待审批持久化根治(重启恢复)未生效~~ ✅ mission:T-260614-01 已修复commands.rs:444 clear→retain 保其他对话 pendingai_approve 两处 if !recovered 守卫移除cargo check 0 err / 19 test pass(06-14)
- [x] B-260614-02 — ~~df-workflow ConditionEngine 默认 true~~ ✅ mission:T-260614-02 已修复conditions.rs:31 `Ok(true)``Ok(false)` 保守拒绝5 个原断言错误行为的测试同步改断言df-workflow 7 test pass(06-14)
- [x] B-260614-04 — ~~df-workflow NodeRegistry::default() script 工厂 unimplemented!~~ ✅ mission:T-260614-03 已修复(删除整个 Default impl——零调用方 + 违反铁律state.rs build_registry 已用 new() + 手动注册真实 ScriptNode(06-14)
@@ -1089,13 +1091,25 @@ gap: 8px;
#### 🔴 P0 — 确定性 Bug3 项·建议立即修复)
- [x] ✅(误判销账·主代核验06-17) **BUG-260617-01 [P0]****`classify_status_or_class` 尾部 `|| true`** 【误判:单测 classify_unknown_retryable(stream_recv.rs:474-478)明确断言 unknown→retryable=true,注释「保守可重试不误判 Fatal 错杀」是有意设计;400/403 走数字码 :424 is_status_retryable 返 false(classify_4xx_fatal:460-465 验证),`|| true` 仅影响纯文本 unknown 分支。按 todo 修(改 false)会破坏单测。审查报告把保守设计当 bug】 — `stream_recv.rs:433` 函数末尾硬编码 `|| true`400/403 等 Fatal 错误被误标为可重试 → agentic loop 无效重试循环浪费 token 和时间。**修法**:移除 `|| true`unknown 默认 `false`(保守不重试),与 `retry::is_status_retryable` 语义一致。**一行改动,零风险,最高 ROI**。— src-tauri/src/commands/ai/stream_recv.rs:433
- [x] ~~**BUG-260617-01**~~**已提升至 P0 阻断性 bug 区(🚨 立即修)**,见上方 L47
**用户报错证据(2026-06-17)**
① Anthropic: `[1214][messages 参数非法。请检查文档。]` → 无 HTTP 数字前缀 → 走文本分支 → `\|\| true` → retryable=true → **空耗 4 次重试**
② DeepSeek: `400 Bad Request: {"error":{"message":"The reasoning_content in the thinking mode must be passed back to the API"}}` → 同理可能命中
**根因**: 审查 agent 误认为"400/403 走数字码 :424 is_status_retryable 返 false"。但实际 provider 错误消息格式多样——Anthropic 中文错误无 "HTTP 400" 前缀,直接落入 `\|\| true` 兜底分支。
**修法**: 移除 `\|\| true`unknown 默认 `false`;同步修改单测 classify_unknown_retryable 断言为 `assert!(!classify_status_or_class("unknown"))`。— src-tauri/src/commands/ai/stream_recv.rs:433 + :474-478(单测)
- [x] ~~**BUG-260617-12**~~**已提升至 P0 阻断性 bug 区(🚨 联动 BUG-01**,见上方 L48
**根因**: 全代码库**零处理** reasoning_content/thinking 字段:
- `df-ai-core/src/provider.rs:22-39` `CompletionRequest` struct **无** reasoning_content 字段
- `df-ai-core/src/provider.rs:92-99` `ChatMessage` struct **无** reasoning_content 字段
- `crates/df-ai/` 下 anthropic_compat.rs / openai_compat.rs **零命中** reasoning|thinking
**影响**: 使用 DeepSeek thinking 模型时每次请求必 400且被 BUG-01 (`\|\| true`) 放大至 4 次无效重试(浪费 token + 时间)。
**修法方向**: ① CompletionRequest 加 `reasoning_content: Option<String>` + ChatMessage 同加 ② agentic loop 重试时从上一轮 response 提取 reasoning_content 透传 ③ openai_compat 构建请求时映射到 API 的 reasoning_content 字段。— crates/df-ai-core/src/provider.rs:22-39,92-99 + crates/df-ai/src/openai_compat.rs
- [x] **BUG-260617-02 [P0]****`file_info` 全量读大文件到内存再截断前 8KB** — `tool_registry.rs:1125-1126` `tokio::fs::read(path)` 将整个文件读入内存(注释说">2MB跳过避免全量读"但实际先全量读再取前 8192 字节做二进制检测)。>2MB 文件触发 OOM 风险。**修法**:用 `File::open` + `read_exact()` 仅读前 N 字节,或加 `.take(8192)` 截断流式读取。— src-tauri/src/commands/ai/tool_registry.rs:1125-1126
- [x] **BUG-260617-03 [P0]****路径遍历防护可被编码绕过**`tool_registry.rs:78-95` 只做字符串 `..` 检测 + 小写化URL 编码 `%2e%2e` / Unicode 同形字符可绕过。**修法**:先 `percent_decode` 再做词法 `..` 分段归一化检查;对不存在路径(new file) 也应规范化校验。— src-tauri/src/commands/ai/tool_registry.rs:78-95
#### 🟡 P1 — 高概率问题5 项·建议本轮修复)
- [ ] **BUG-260617-04 [P1]****`ai_chat_force_send``ai_chat_send` 双锁竞态窗口** — `commands.rs:763` force_send 先 lock 复位 generating→释放锁→调 ai_chat_send 再 lock。两锁之间 stop IPC 可插入打断"原子复位+发送"语义;若 ai_chat_send 因 generating=true 被 reject → 前端状态不一致。**修法**force_send 内联 spawn run_agentic_loop同 regenerate/edit 模式),不走 ai_chat_send 门控。— src-tauri/src/commands/ai/commands.rs:744-763
- [x] **BUG-260617-04 [P1]****`ai_chat_force_send``ai_chat_send` 双锁竞态窗口** — `commands.rs:763` force_send 先 lock 复位 generating→释放锁→调 ai_chat_send 再 lock。两锁之间 stop IPC 可插入打断"原子复位+发送"语义;若 ai_chat_send 因 generating=true 被 reject → 前端状态不一致。**修法**force_send 内联 spawn run_agentic_loop同 regenerate/edit 模式),不走 ai_chat_send 门控。— src-tauri/src/commands/ai/commands.rs:744-763
- [x] **BUG-260617-05 [P1]****`try_continue_agent_loop` 4 次独立 lock 非原子化** — `agentic.rs:943-1036` 至少 4 次 `state.ai_session.lock().await`,每次 release 后其他 IPC 可修改 session → 续跑判断基于过时快照(如步骤 1 判 should_continue=true → 步骤 2 间 user 点 stop → 步骤 3 仍续跑)。**修法**:单次 lock 内完成所有字段读写,或引入结构化快照一次取出。— src-tauri/src/commands/ai/agentic.rs:943-1036
- [x] **BUG-260617-06 [P1]****`accumulate_tokens` 整数溢出无 saturating 保护** — `conversation.rs:49-51` `old.unwrap_or(0) + add as i64` 长期对话累积接近 i64::MAX 后翻负。**修法**:改用 `.saturating_add()` 或类型改为 `u64`。— src-tauri/src/commands/ai/conversation.rs:49-51
- [x] **BUG-260617-07 [P1]****`generate_diff` LCS O(n*m) 内存爆炸** — `tool_registry.rs:32-75` 标准 DP diff5000 行输入 ≈ 200MB DP 表。虽有 changes>300 截断但截断前已分配计算完毕。**修法**:超长输入(均>1000行)跳过 LCS 改用 Myers diff 或直接返回截断 diff。— src-tauri/src/commands/ai/tool_registry.rs:32-75
@@ -1142,21 +1156,21 @@ gap: 8px;
- [ ] **UX-260617-13 [P1]🎯****刷新后主面板无流式恢复,用户看到"假死"** — 分离窗口(resumeInDetached)有完整恢复链(ai_is_generating→恢复streaming→推送占位气泡→localStorage currentText)**主面板完全没有等价逻辑**。刷新后 streaming=false + 无占位气泡 + 后端事件到达不进流式分支 → 文本仅 AiCompleted 时一次性显示退化为非流式体验。**修法**:主面板 onMounted/client-ready 后调 ai_is_generating IPCtrue 则对齐 resumeInDetached 恢复链。工作量较大但用户感知最强。— src/composables/ai/useAiEvents.ts:155-179 + src/composables/ai/useAiWindow.ts:64-89
- [x] **UX-260617-14 [P2]🎯****审批超时 130s 静默复位无反馈**`ToolCard.vue:507,541-54` 超时仅 `approving.value=false`,无 toast/error/日志。用户无法区分"审批完成状态没更新"和"超时复位"。**修法**:超时时 warning toast + APPROVE_LOADING_TIMEOUT_MS 改为共享常量导入。— src/components/ToolCard.vue:507,541-544
- [x] **UX-260617-15 [P2]🎯****regenerate/editMessage 丢弃图片 parts**`useAiSend.ts:148-190` regenerate/edit 均无 parts 参数。含图片消息重生成/编辑后图片消失。**修法**:扩展 regenerate/editMessage IPC 签名透传 parts或至少 UI 层对含 parts 消息禁用按钮/confirmDialog 提示。— src/composables/ai/useAiSend.ts:148-190
- [ ] **UX-260617-16 [P2]🎯****无路由离开/关闭保护,未发送输入静默丢失** — AiChat.vue 缺 beforeRouteLeave/onBeforeUnload 守卫。输入框有内容/已粘贴图片时切页面 → 内容瞬间消失无提示。**修法**beforeRouteLeave guard + detached window beforeunload 检查 inputText/pendingImages/pendingSkill 非空时 confirmDialog。— src/components/AiChat.vue (缺失守卫)
- [x] **UX-260617-16 [P2]🎯****无路由离开/关闭保护,未发送输入静默丢失** — AiChat.vue 缺 beforeRouteLeave/onBeforeUnload 守卫。输入框有内容/已粘贴图片时切页面 → 内容瞬间消失无提示。**修法**beforeRouteLeave guard + detached window beforeunload 检查 inputText/pendingImages/pendingSkill 非空时 confirmDialog。— src/components/AiChat.vue (缺失守卫)
- [x] **UX-260617-17 [P2]🎯****ToolCardList 分组名硬编码中文绕过 i18n**`ToolCardList.vue:259-274` nameMap 全硬编码中文(`'读取文件'`等)。英文 locale 下分组标题仍显示中文。**修法**value 替换 i18n key(`ai.toolGroup.readFile`)。— src/components/ToolCardList.vue:259-274
- [x] **UX-260617-18 [P2]🎯****isToolFailure() 正则过于宽泛误判合法输出为失败**`ToolCard.vue:220-224` `/执行失败|failed|error[:\s]/i` 匹配任何含这些子串的文本。search_files 结果列 error.log / run_command stderr 含 warning 均被标红。**修法**:收窄为 `/^(执行失败|Error:|Failed:)/m` 或按工具类型分别配置检测策略。— src/components/ToolCard.vue:220-224
- [x] **UX-260617-19 [P2]🎯****stopChat 本地先行 reset 与后端竞态窗口**`useAiSend.ts:479-488` 先设 streaming=false 再发 IPC stop。IPC 失败时后端继续生成但前端已停止;若后续事件也丢失则永久卡死。注释承认风险但无缓解。**修法**stop 后启动 5s 守护超时,未收到 Completed/Error 则推提示或重试 stop。— src/composables/ai/useAiSend.ts:479-488
#### ⚪ aichat 扫描新增发现LOW · 8 项)
- [ ] **UX-260617-20 [P3]🎯****base64 图片 parts 无大小预算200 条消息上限不感知单条体积** — MESSAGE_CAP=200 仅按条数 splice单条 base64 可达数 MB。极端 200条×5MB=~1GB 全挂 reactive state。**修法**:增加基于序列化大小的预算(total parts size<50MB),超出裁剪最旧消息 parts。— src/api/types.ts:228-243 + src/stores/ai.ts:78-87
- [ ] **UX-260617-21 [P3]🎯****Markdown 表格溢出气泡容器**`.ai-msg-bubble` 缺表格专属溢出处理。AI 返回≥4列表格撑破宽度溢出到侧边栏。**修法**`.ai-md table { display:block; overflow-x:auto; max-width:100% }`。— src/components/AiChat.vue CSS (~L3259)
- [ ] **UX-260617-22 [P3]🎯****空内容 AI 回复渲染不可见气泡**`AiChat.vue:457` v-if 条件使空 content 已完成消息整个 bubble 不渲染 → avatar+一片空白。**修法**:空 content 显示最小化占位`(empty)`或隐藏整条含 avatar。— src/components/AiChat.vue:457
- [ ] **UX-260617-23 [P3]🎯****搜索结果选中不清除搜索框,搜索视图不退出**`AiChat.vue:46` 点击结果 switchConversation 但不清 searchQuery。搜索文字保留+仍显示扁平列表。**修法**:点击 handler 加 `searchQuery=''` 自动退出搜索模式。— src/components/AiChat.vue:46
- [ ] **UX-260617-24 [P3]🎯****删除当前活跃对话无特殊警告**`AiChat.vue:1140-1145` confirmDelete 通用文案不区分活跃对话。对比 confirmNewConversation 有上下文感知警告。**修法**id===activeConversationId 时改醒目提示说明消息区将清空。— src/components/AiChat.vue:1140-1145
- [ ] **UX-260617-25 [P3]🎯****parseBlockNoCache 非 null 断言无运行时防护**`AiChat.vue:848` `purify!.sanitize(marked!.parse(...))` 。marked/DOMPurify 动态 import 失败时 TypeError → rAF 循环中断流式卡死。**修法**:防御性 null check + 降级 escapeFallback。— src/components/AiChat.vue:848
- [x] **UX-260617-20 [P3]🎯****base64 图片 parts 无大小预算200 条消息上限不感知单条体积** — MESSAGE_CAP=200 仅按条数 splice单条 base64 可达数 MB。极端 200条×5MB=~1GB 全挂 reactive state。**修法**:增加基于序列化大小的预算(total parts size<50MB),超出裁剪最旧消息 parts。— src/api/types.ts:228-243 + src/stores/ai.ts:78-87
- [x] **UX-260617-21 [P3]🎯****Markdown 表格溢出气泡容器**`.ai-msg-bubble` 缺表格专属溢出处理。AI 返回≥4列表格撑破宽度溢出到侧边栏。**修法**`.ai-md table { display:block; overflow-x:auto; max-width:100% }`。— src/components/AiChat.vue CSS (~L3259)
- [x] **UX-260617-22 [P3]🎯****空内容 AI 回复渲染不可见气泡**`AiChat.vue:457` v-if 条件使空 content 已完成消息整个 bubble 不渲染 → avatar+一片空白。**修法**:空 content 显示最小化占位`(empty)`或隐藏整条含 avatar。— src/components/AiChat.vue:457
- [x] **UX-260617-23 [P3]🎯****搜索结果选中不清除搜索框,搜索视图不退出**`AiChat.vue:46` 点击结果 switchConversation 但不清 searchQuery。搜索文字保留+仍显示扁平列表。**修法**:点击 handler 加 `searchQuery=''` 自动退出搜索模式。— src/components/AiChat.vue:46
- [x] **UX-260617-24 [P3]🎯****删除当前活跃对话无特殊警告**`AiChat.vue:1140-1145` confirmDelete 通用文案不区分活跃对话。对比 confirmNewConversation 有上下文感知警告。**修法**id===activeConversationId 时改醒目提示说明消息区将清空。— src/components/AiChat.vue:1140-1145
- [x] **UX-260617-25 [P3]🎯****parseBlockNoCache 非 null 断言无运行时防护**`AiChat.vue:848` `purify!.sanitize(marked!.parse(...))` 。marked/DOMPurify 动态 import 失败时 TypeError → rAF 循环中断流式卡死。**修法**:防御性 null check + 降级 escapeFallback。— src/components/AiChat.vue:848
- [x] **UX-260617-26 [P3]🎯****startListener/stopListener 竞态_startPromise 未在 stop 中清零**`useAiEvents.ts:354+378` stop 不清 _startPromise。极速 mount/unmount/mount(HMR) → 返回过期 promise。**修法**stop 中加 `_startPromise=null`。— src/composables/ai/useAiEvents.ts:378-388
- [ ] **UX-260617-27 [P3]🎯****ConfirmDialog 危险按钮默认标签"删除"语义不安全**`ConfirmDialog.vue:9-10` 默认 `$t('common.delete')`。非删除场景(高危工具审批)忘传 dangerLabel → 按钮显示"删除"。**修法**:默认值改为 `$t('common.confirm')`。— src/components/ConfirmDialog.vue:9-10
- [x] **UX-260617-27 [P3]🎯****ConfirmDialog 危险按钮默认标签"删除"语义不安全**`ConfirmDialog.vue:9-10` 默认 `$t('common.delete')`。非删除场景(高危工具审批)忘传 dangerLabel → 按钮显示"删除"。**修法**:默认值改为 `$t('common.confirm')`。— src/components/ConfirmDialog.vue:9-10
> **[销账 wf wys30viqi·2026-06-17]** 8 agent 文件域隔离核验修复(独立 grep 核验源码形态 + cargo check --workspace EXIT 0 + vue-tsc EXIT 0)。
> - **已修 15**BUG-02 file_info 流式读 .take(8192) / 03 validate_path percent_decode 词法归一化(+percent-encoding 依赖) / 05 try_continue 5lock→ContinueSnapshot 快照收敛+spawn 前重检 / 06 saturating_add(含 ContextUsage 周边) / 07 LCS_MAX_LINES=1000 双>1000 跳 DP / 09 max_depth clamp(1,10) / 11 read_file 默认 500 行+has_moreUX-08 catch toast+保留旧列表 / 09 startListener try/catch+console.error / 10 AiError clearAllApprovalTimers+清 pendingApprovals / 14 STREAM_TIMEOUT_MS 共享常量+reactive toast / 17 toolGroup i18n 14key / 18 isToolFailure 正则收窄^行首锚 / 19 stopChat try/catch+watchdog 复用 / 26 stopListener _startPromise=null。