# AI Chat 各模块技术债审查报告

> 日期:2026-06-21 | 方法:workflow 串行 10 单元,每单元独立 grep 核验(不信文档/注释) | 基线:src-tauri cargo check 0 error(L2 拆分后)
> 产物:0 P0 / 7 P1 / 25 P2 / 40 P3 | 已登记 8 / 新债 64
> 关联:[[code-review-anti-contamination]] [[devflow-todo-docs-split]] | 审查 workflow wf_92b61c35-4d2

## 模块健康度

| 模块 | 设计点 | 健康 | 债数 |
|---|---|---|---|
| 1-agentic-loop | 8 | {'healthy': 8} | 7 |
| 2-stream-recv | 7 | {'healthy': 7} | 6 |
| DevFlow AI Chat — 3-compress | 8 | {'healthy': 6, 'smell': 2} | 8 |
| AI Chat · tool_registry.rs | 8 | {'healthy': 8} | 8 |
| src-tauri/src/commands/ai/au | 12 | {'healthy': 12} | 7 |
| 6-session-state | 7 | {'healthy': 6, 'smell': 1} | 7 |
| 7-ipc-approval | 8 | {'healthy': 6, 'smell': 1, 'risk': 1} | 6 |
| AI Chat【8-provider-pool】 | 7 | {'healthy': 7} | 6 |
| 9-fe-events-cards | 9 | {'healthy': 9} | 9 |
| 10-fe-conversation | 9 | {'healthy': 9} | 8 |

## P1(7 项 · 已修 2 / 归路线 5)

### [P1] watchdog 单计时器 + onStreamTimeout 全清式收尾,与 F-09 多会话并发冲突:整流超时(可能误报)会 clear 所有 generatingConvs + 清空 queue(含用户已排队未发的输入)+ 清 currentText,误伤其他正在生成的会话与静默丢弃排队用户输入
- **位置**:src/composables/ai/useAiStream.ts:30-32
- **根因**:_streamWatchdog 是模块级单计时器(useAiStream.ts:23)无 per-conv 隔离;onStreamTimeout 注释自陈「无法确定具体 conv,保守全清」。F-09 多会话并发下,A 会话超时会连带杀掉 B 会话的 generating 态;且 state.queue(useAiSend.ts:304 push 用户排队输入)被一刀切,用户排队消息静默丢失(注释称「防丢失」但 clear 本身即丢失)
- **修法**:watchdog 改 per-conv Map<convId, timer>,onStreamTimeout 携带 convId 仅清该 conv 态;queue clear 改为仅清当前 conv 关联项或保留队列(超时不应删用户未发输入)
- **归因**:并发/数据丢失 | 已登记:no

### [P1] patch_file 读-改-写在 FILE_LOCKS 之外,CAS 与 new_content 计算基于陈旧读,锁只序列化最终写不防 lost-update
- **位置**:src-tauri/src/commands/ai/tool_registry.rs:1247
- **根因**:L3 文件指纹校验(1176) + old_text CAS 比对(1209) + new_content 计算(1197-1240)全在「阶段一:无锁纯读」段;L1 tokio::Mutex 仅在「阶段二」保护 backup→tmp→rename 序列(1242-1269)。两个并发 patch_file 同文件:A/B 各自读旧内容→A 获锁写入释放→B 获锁用基于旧内容算的 new_content 覆盖 A 的改动(lost update)。FILE_LOCKS 注释明示「防同文件并发读写冲突」(270)但作用域界定错误,锁只防写写交错不防读写交错。
- **修法**:将读文件+校验+new_content 计算整体移入 FILE_LOCKS 锁临界区(同一 abs_path 的 lock entry),或在锁内重读 content 再做 CAS;改 per-path 独立 Mutex(每个 abs_path 一把锁,非全局 HashMap 占位)让读改写串行。注意 write_file(Medium,trust_hits 并行)不取 FILE_LOCKS,patch_file+write_file 同文件并发也未被保护(同根因)。
- **归因**:并发 | 已登记:no

### [P1] expected_hash schema 契约破裂:描述称「read_file 返回的 file_hash」,但 read_file 从不返回该字段;LLM 无从获取合法 hash,指纹防并发形同虚设
- **位置**:src-tauri/src/commands/ai/tool_registry.rs:1112
- **根因**:grep 全文件 file_hash 仅此一处(1112 schema 描述),read_file handler(892-976)返回 {path,content,size,lines,offset,returned_lines,has_more} 无 file_hash 字段。expected_hash 实际由 patch_file 内部 format!("{}_{}", modified_secs, len)(1180)即造即比,LLM 拿不到前置值只能瞎传或省略→校验常 mismatch bail 或被跳过。L3 防并发设计目标(防行号漂移)在实际 LLM 调用链上落空。
- **修法**:二选一:(a) read_file 返回 file_hash(同 modified_secs_len 格式)让 LLM 闭环回传;(b) 去掉 schema 描述的来源声明,改文档约定由前端审批卡透传(但当前路径无前端介入)。优先 (a)。
- **归因**:契约失配 | 已登记:no

### [P1] 审批状态字符串双轨:ai_approve/ai_authorize_dir 写 "executed",process_tool_calls/find_cached fallback 写 "completed",DTO 文档仅列 completed
- **位置**:src-tauri/src/commands/ai/commands/chat.rs:466,668 (写 "executed") vs src-tauri/src/commands/ai/audit/mod.rs:490,545 + audit/cache.rs:113 (写 "completed")；DTO 文档列 completed 在 df-storage/src/models.rs:221
- **根因**:无 newtype/enum 约束 status 字段(对齐 SMELL-P1-6 String 滥用替 enum),两条写入路径各用不同字面量。审计表混存 executed/completed 两种『成功』态
- **修法**:抽 status newtype 或 enum(对齐 SMELL-P1-6),统一成功态字面量(process 全改 executed 或 chat 全改 completed),消除双轨。行为变更需评估 AuditLog.vue 显示与 find_cached 透传影响
- **归因**:类型/一致性 | 已登记:SMELL-P1-6(String 类型滥用,覆盖此根因,未单独登记 executed/completed 双轨)

### [P1] ai_approve 缺 60s 执行超时,与 ai_authorize_dir 不对称,卡死致 generating 永真+前端看门狗静默吞消息
- **位置**:src-tauri/src/commands/ai/commands/chat.rs:461
- **根因**:ai_authorize_dir 为 F-260620 加了 tokio::time::timeout(60s)(chat.rs:658),但同样执行用户审批工具的 ai_approve 路径(state.ai_tools.execute(chat.rs:461) / execute_run_workflow_for_tool(chat.rs:459))无超时。run_command 等慢命令或 list_directory 大目录在 ai_approve 执行路径卡住 → 到不了 audit_finalize/emit/try_continue → IPC 永挂 → per_conv.generating 永真 → 前端 130s 看门狗静默吞消息(F-260620 描述的同类故障在 ai_approve 重现)
- **修法**:抽 helper execute_with_timeout(tool, args),ai_approve 与 ai_authorize_dir 共用同一 60s 超时包裹(含 run_workflow 分支统一),超时走 failed 分支
- **归因**:并发/卡死 | 已登记:no

### [P1] switchConversation 不复位 state.streaming,真并发下切换非生成会话残留 stop 按钮 + 错停会话
- **位置**:src/composables/ai/useAiConversations.ts:91-206(switchConversation 全程不触碰 state.streaming;对比 newConversation:73 显式置 false)
- **根因**:F-09 决策e 真并发上线后,streaming 仍是全局单 boolean(state.ts:63)。switchConversation 加载历史时不复位 streaming,而 ChatInput.vue:88 v-if="store.state.streaming" 读此单值显示停止按钮。会话 A 生成中切到非生成的会话 B → streaming 仍 true → B 显示 stop 按钮 → 点击 store.stopChat 传 B 的 activeConversationId(:427)发出错会话的 stop IPC。AiChat.vue:204 showAgenticProgress(streaming && agentRound>0)同样残留。
- **修法**:streaming 改 per-conv 态(归入 F-260616-09 B 路线 per-conv state 改造);或 switchConversation 入口按 isGenerating(targetConvId) 重算 streaming(active 在生成则 true 否则 false),与 newConversation:73 对齐。后者轻量可在 A 路线内补。
- **归因**:竞态/状态残留 | 已登记:no

### [P1] pendingDirAuth/pendingMaxRounds 全局单 ref 非 per-conv,真并发下挂起弹窗可能错显于非挂起会话
- **位置**:src/composables/ai/useAiEvents.ts:99(pendingMaxRounds=ref(false)) + :113(pendingDirAuth=ref<...|null>(null));消费方 DirAuthDialog.vue:60-62 / MaxRoundsCard.vue:54-56 仅判 isViewingGenerating 未比对 conversationId
- **根因**:F-09 决策e 允许多会话并行,但挂起态仍是模块级单实例。pendingDirAuth 携 conversationId(:111/:239)但 DirAuthDialog:61 showDirAuthCard 守卫未用(pendingDirAuth.value.conversationId === activeConversationId);pendingMaxRounds 是纯 boolean 无 conversationId 字段,根本无法区分。会话 A 达 max 挂起(pendingMaxRounds=true)时切到同样在生成的会话 B → MaxRoundsCard 在 B 错显(因 isViewingGenerating(B)=true 且全局 pendingMaxRounds=true)。原注释 useAiEvents.ts:97『后端 AiSession 单例,达 max 后续轮次前用户必先决定』是单并发假设,真并发下失效。
- **修法**:pendingMaxRounds 改 ref<string|null>(挂起 convId);pendingDirAuth 守卫加 conversationId 比对。归入 F-260616-09 B 路线 per-conv state。
- **归因**:竞态/状态残留 | 已登记:no

## P2(25 项)

- **1-agentic-loop** | try_continue_agent_loop 中 pending_conv_id 跨会话收集,导致 stop 补发 AiCompleted 的 conversation_id 错绑其他会话 | `src-tauri/src/commands/ai/agentic/mod.rs:1314-1317` | todo:no
- **1-agentic-loop** | run_agentic_loop 函数 ~950 行(L318-1265),单函数承载:provider 解析+意图收敛+主题标记+自动压缩+tool_result 摘要+fallback 重试+审批分支+收敛退出,God 函数 | `src-tauri/src/commands/ai/agentic/mod.rs:318` | todo:no
- **2-stream-recv** | MidStream 保文路径 chunk.error / chunk.Err 返回 Partial 时未把 err_msg 透出,用户看不到中断原因(仅后端 warn! 日志),前端只补通用「响应不完整」系统提示,丢失具体错误诊断(如 GLM 1214 messages 非法) | `src-tauri/src/commands/ai/stream_recv.rs:275-281` | todo:no
- **2-stream-recv** | onStreamTimeout 反向遍历 messages 改 toolCall.status = 'rejected',在多会话并发下会误回滚其他会话的 running 工具卡(与 P1 全清问题同源,单遍历无 conv 过滤) | `src/composables/ai/useAiStream.ts:48-60` | todo:no
- **DevFlow AI Chat — 3-** | is_compressing 标志无 panic/cancel 兜底 — 任务被 cancel(runtime drop/进程关闭)或 compress_via_llm 内部 panic 时 set_compressing(false) 被跳过,标志永久滞留 true,该会话后续所有自动/手动压缩被 !prev_compressing(705)/is_compressing()(chat.rs:850) 永久拒绝 | `src-tauri/src/commands/ai/agentic/mod.rs:735 (set_compressin` | todo:no
- **DevFlow AI Chat — 3-** | 自动压缩(agentic loop)成功后未即时 save_conversation — 仅依赖 loop 退出 :1223 落库。若 loop 在压缩后、退出前被 cancel/panic/审批等待态 return,则 status=compressed + 摘要 system 仅存内存,会话恢复(restore_from_messages 从 DB 重建)时压缩成果丢失,history_tokens 重新虚高 | `src-tauri/src/commands/ai/agentic/mod.rs:778-794 (成功路径无 save` | todo:no
- **DevFlow AI Chat — 3-** | context.rs 1332+ 行 God 文件 — F-15/压缩/UX-09编辑/sanitize/主题检测多职责堆叠,F-15 链路仍频繁改动拆分窗口未到 | `crates/df-ai/src/context.rs:1-1552 (整文件,ContextManager impl ` | todo:REFACTOR-260619-09
- **AI Chat · tool_regis** | FILE_LOCKS HashMap 条目永不清理,每 patch 过的路径永久占槽位(内存无界增长) | `src-tauri/src/commands/ai/tool_registry.rs:1248` | todo:no
- **AI Chat · tool_regis** | expected_hash 指纹粒度仅秒级(mtime_as_secs + len),1 秒内连续外部修改检测不到,弱指纹 | `src-tauri/src/commands/ai/tool_registry.rs:1180` | todo:no
- **AI Chat · tool_regis** | patch_file 大小校验用独立 tokio::fs::metadata 后再 File::open,存在 TOCTOU(metadata→open 间文件被换/膨胀),与 read_file 单次 open 取 metadata 的 FR-S2 收口不一致 | `src-tauri/src/commands/ai/tool_registry.rs:1156` | todo:no
- **AI Chat · tool_regis** | bind_directory handler 注释承诺 reload 兜底但代码空头(tool_registry.rs:545-549),AI 绑定目录后白名单不刷新 | `src-tauri/src/commands/ai/tool_registry.rs:546` | todo:MED-1 已登记
- **AI Chat · tool_regis** | REFACTOR-260619-08 tool_registry.rs(2210 行)仍待按功能分组注册函数拆,register_* 部分超 200 行(register_file_tools ~760 行) | `src-tauri/src/commands/ai/tool_registry.rs:881` | todo:REFACTOR-260619-08
- **src-tauri/src/comman** | ai_approve 缺执行超时包装(ai_authorize_dir 有 60s 防卡死,ai_approve 裸 execute) | `src-tauri/src/commands/ai/commands/chat.rs:461 (ai_approve 裸` | todo:no
- **src-tauri/src/comman** | find_cached_high_risk_result 持 session &mut borrow 期间串行 await DB 查询,High risk 工具多时锁持有线性增长 | `src-tauri/src/commands/ai/audit/cache.rs:21-23 (性能注记自承) + ca` | todo:no(性能注记自承,CR-260618-11#5 衍生,未独立登 todo 条目)
- **src-tauri/src/comman** | audit_tool_call 插入用 let _ = 吞 insert 错误,审计记录写失败无任何日志/告警 | `src-tauri/src/commands/ai/audit/finalize.rs:35 (let _ = repo` | todo:no
- **6-session-state** | PerConvState.created_at 全死字段(0 写入) | `src/commands/ai/mod.rs:520` | todo:no
- **6-session-state** | PendingApproval.diff 结构体字段写而不读(dead payload) | `src/commands/ai/mod.rs:570` | todo:no
- **6-session-state** | process_tool_calls 持 session 锁做阻塞 fs canonicalize(粗粒度锁内同步 IO) | `src/commands/ai/audit/mod.rs:264` | todo:no
- **7-ipc-approval** | switch/delete 路径 retain 清 pending 未配套 finalize_pending_placeholders,占位 tool_result 残留 messages | `src-tauri/src/commands/ai/commands/conversation.rs:317` | todo:no
- **7-ipc-approval** | authz_debug 硬编码绝对路径 E:/wk-lab/devflow/authz-debug.log + 生产残留诊断代码(eprintln + 文件日志) | `src-tauri/src/commands/ai/commands/chat.rs:548` | todo:no
- **AI Chat【8-provider-p** | agentic/mod.rs run_agentic_loop 单函数超长(83390 字节/约 1231 行),fallback 循环 + 工具执行 + 审批 + 保文路径全在一函数 | `src-tauri/src/commands/ai/agentic/mod.rs:318 run_agentic_loo` | todo:ARC-260619-05(REFACTOR-260619-05 已部分完成,D 段待 F-09 批4)
- **9-fe-events-cards** | AiMessage.status 为 string cast(AiMessageWithStatus),消息级 status 无联合类型约束,archived_segment/compressed/truncated 全靠注释枚举 | `src/components/ai/MessageList.vue:686-688 (interface AiMessa` | todo:no
- **9-fe-events-cards** | onStreamTimeout 全清 generatingConvs(多会话并发下误杀其他正常生成会话) | `src/composables/ai/useAiStream.ts:30 (state.generatingConvs.` | todo:no
- **10-fe-conversation** | switchConversation 不复位 agentRound,切到已完成会话残留旧轮次(进度条门控依赖 streaming 可部分遮蔽) | `src/composables/ai/useAiConversations.ts:91-206(switchConver` | todo:no
- **10-fe-conversation** | loadConversations 失败时把错误气泡 push 进 state.messages,污染当前对话历史(而非用 toast) | `src/composables/ai/useAiConversations.ts:51-61 + switchConve` | todo:no

## P3(40 项 · 摘要)

- **1-agentic-loop**(5项): guard.rs 文档注释声称"双写顶层 session.generating=; guard.rs Drop 异步 spawn 复位与正常路径 reset().a; iteration 顶部 stop 检查(L581)的 save 用 None ; 候选链 candidate_chain 提前 clone primary(L97...
- **2-stream-recv**(3项): classify_status_or_class 与 extract_error; STREAM_TIMEOUT_MS=130s vs 后端 STREAM_IDLE; scheduleStreamParse 的 rAF 回调内 streamingB
- **DevFlow AI Chat — 3-comp**(5项): should_compress 检查与 set_compressing(true; messages_mut() 被迫用于只读读取 — 读取保护区外 active ; compress.rs #[allow(dead_code)] 过期残留 — c; 双监听器同通道('ai-chat-event')人工协调防双重处理 — useA...
- **AI Chat · tool_registry.**(1项): run_command 越界防线弱:working_dir 仅走 validat
- **src-tauri/src/commands/a**(3项): Low risk 失败工具硬编码中文错误文案『工具 {} 执行失败: {}』直送; build_approval_reason 三层 fallback(tool_d; path_auth_pending 挂起占位无去重保护(与 High risk 
- **6-session-state**(4项): session_state() 读视图 0 消费者(P0 终态化未承接); guard.rs 注释声称双写顶层 session.generating 但代码; best_effort_canonicalize 文件不存在时连查父目录,失败回; releases/node_executions Repo 持久化就位但 IPC
- **7-ipc-approval**(3项): ai_authorize_dir 60s 超时与 generating 状态:超; ai_chat_clear 无 conversation_id 参数,只能清 a; ai_approve 拒绝路径 _recovered 读后未用,审批拒绝无 se
- **AI Chat【8-provider-pool】**(5项): release_conv 仅 ai_conversation_delete 调用; reload_provider_caps 给每 provider 设 cap=g; set_per_conv / set_global 缺内部 permits=0 ; set_per_conv 清空 HashMap 软收敛期间,同 conv 的在跑...
- **9-fe-events-cards**(7项): 双监听器同通道(useAiEvents + useAiContext 各 lis; AiStreamRetry 每次重试重置看门狗(不在 NO_RESET_WATC; _blockCache(300 条 Map)跨会话永不清理,长期会话累积陈旧条目; combineAndTruncateLines/formatCommandRes...
- **10-fe-conversation**(4项): switchConversation pendingApprovals 恢复修改; switchConversation 内局部变量 t 覆盖模块级 t impor; doSend/regenerate/editMessage 三处 streami; modelOverride 切换对话不同步清(注释自述需同步清但代码未做)