diff --git a/docs/02-架构设计/F-09-多会话并发架构设计-2026-06-19.md b/docs/02-架构设计/F-09-多会话并发架构设计-2026-06-19.md new file mode 100644 index 0000000..848e0fa --- /dev/null +++ b/docs/02-架构设计/F-09-多会话并发架构设计-2026-06-19.md @@ -0,0 +1,605 @@ +# F-260616-09 AiSession 多会话并发架构 — B 阶段设计(草案) + +> **状态**:设计草案(2026-06-19) | **范围**:只读分析 + 设计文档,**不改源码** +> **关联**:[F-09B-多会话并发设计-2026-06-16.md](./F-09B-多会话并发设计-2026-06-16.md)(旧版,行号已过期,本文为 06-19 更新核验版) +> **决策基线**(2026-06-16 已决·待归档): +> - **a**:A 隔离修复已做(06-19 核验:A 路线补漏已落地) + B 立项 🔥 高优先尽快实施 +> - **b**:形态 — 仅拆 `generating`/`stop_flag` per-conv,messages/pending 按 conv reload(⚠️ 06-19 核验修正:messages 必须同 per-conv,见 §2.1.1) +> - **c**:并发上限 — 复用 `llm_concurrency.global` 改「并发会话数」语义 +> - **d**:UI — d1 单面板侧栏切换 + d2 独立 Tauri 窗口都做(已决先 d1 后 d2) +> - **e**:旧 loop 切换处理 — 不退出各自跑完(真并发,⚠️ 依赖 b 修正) +> +> **审查防污染铁律**:本文 file:line 均为 2026-06-19 独立 grep/read 核验源码当前形态,**不沿用 06-16 旧文档结论**。 + +--- + +## 0. 与 06-16 旧文档的差异(为什么重写) + +06-16 旧文档结构完整但行号已全部过期(代码改动较多)。06-19 重新核验关键差异: + +| 项目 | 06-16 文档说法 | 06-19 核验当前形态 | +|---|---|---| +| `AiSession` 字段 | mod.rs:166-216(10 字段) | mod.rs:282-350(12 字段,新增 `notify`、`model_override`、`session_trust`) | +| 单例锚点 | state.rs:165 | state.rs:227(`pub ai_session: Arc>`) | +| init | state.rs:216 | state.rs:278 `AiSession::new()` | +| ai_chat_send 互斥 | commands.rs:144-152 | commands.rs:78-88(签名已改,conv_id 是参数) | +| ai_conversation_create 漏清 | 旧文标"未补" | **已补** stop_flag(line 1404)+ agent_language(line 1405)+ model_override(line 1407)+ session_trust(line 1409)(F-260616-09 A 路线已落地) | +| 前端 newConversation 漏清 | 旧文标"未补" | **已补** queue/generatingConvId/agentRound/searchQuery(useAiConversations.ts:80-83) | +| B-260615-11 退出点 | agentic.rs:197-211 等 | agentic.rs:486-500(每轮)/ 786-793(MidStream)/ 837-844(push 前) | +| `notify` 字段 | 旧文未独立列出 | mod.rs:309-319 独立字段(B-260615-14 即时停止唤醒,stream_recv select! 监听) | +| `iteration_used` 语义 | "累计计数" | mod.rs:320-331 双语义(审批续跑累计 / 达 max 续跑重计 / 新消息 0),已预留"per-conv"注释 | +| 前端 events 路由 | useAiEvents.ts:125-140 | useAiEvents.ts:125-144(同款,isCurrent 路由就绪) | + +**核心结论**:06-16 设计骨架(b/c/d/e 决策)依然成立,本文更新行号 + 补 06-16 后的演进(`notify`/`model_override`/`session_trust` 字段、SW-260618-02 finalize_pending_placeholders、A 路线补漏已落),并明确 ⚠️ 待裁决点。 + +--- + +## 1. 现状梳理(独立核验) + +### 1.1 单例结构与字段 + +**单例锚点**: + +```rust +// src/state.rs:227 +pub ai_session: Arc>, +// src/state.rs:278(init) +ai_session: Arc::new(Mutex::new(AiSession::new())), +``` + +整个应用一份 `AiSession`,所有 IPC 命令竞争同一把 `tokio::Mutex`。 + +**`AiSession` 字段**(src/commands/ai/mod.rs:282-350,共 12 字段): + +| 字段 | 行 | 类型 | 多会话语义 | B 阶段处理 | +|---|---|---|---|---| +| `messages` | 284 | `ContextManager` | **会话级**(每对话一份历史) | ⚠️ 必须拆 per-conv(见 §2.1.1) | +| `active_provider_id` | 286 | `Option` | 应用级(provider 全局共享) | 保持单例 | +| `active_conversation_id` | 288 | `Option` | 应用级路由键(当前展示的 conv) | 保持单例(切走即变,不影响后台 loop) | +| `active_conv_created_at` | 290 | `Option` | 会话级(懒创建时间戳) | 入 PerConvState | +| `pending_approvals` | 302 | `HashMap` | 已带 `conversation_id`(mod.rs:412) | 保持单层 + retain 过滤 | +| `generating` | 304 | `bool` | 会话级(单例=全局互斥) | **核心拆点** 入 PerConvState | +| `agent_language` | 306 | `Option` | 会话级 | 入 PerConvState | +| `stop_flag` | 308 | `Arc` | 会话级 | **核心拆点** 入 PerConvState | +| `notify` | 319 | `Arc` | 会话级(B-260615-14 即时唤醒) | 入 PerConvState(随 stop_flag 配对) | +| `iteration_used` | 331 | `usize` | 会话级(mod.rs:320-331 注释已标"F-09 B 改 per-conv") | 入 PerConvState | +| `model_override` | 344 | `Option` | 会话级(F-01 阶段6 主对话指定模型) | 入 PerConvState | +| `session_trust` | 349 | `HashSet` | 会话级(AE-2025-04,不跨对话) | 入 PerConvState | + +**关键观察**:`mod.rs:320-331` 注释自身已写「单例一份:当前 AiSession 是全局单例(F-09 B 多会话架构落地时改 per-conv)」——本设计是早已预留的债。 + +### 1.2 切换软隔离(readonly)实现 + +**`ai_conversation_switch`**(src/commands/ai/commands.rs:1454-1522): + +- 生成中(`session.generating` 为 true,commands.rs:1474)→ 直接 `return Ok(...{ readonly: true })`(commands.rs:1475-1481):**只返回目标对话的 messages 给前端展示,不改 session 状态**。 +- 非生成中(commands.rs:1482-):正常切 `active_conversation_id` + `messages.restore_from_messages` + retain pending_approvals + 清 model_override/session_trust。 + +**这是单例下的不得已妥协**:active_conversation_id 是 loop 写消息的路由键(B-260615-11 校验点),切走即会让旧 loop 退出;故生成中只能 readonly,无法真切换。 + +**B 阶段改造**:删除 readonly 分支,切走直接改 `active_conversation_id`(应用级路由键),旧 conv 的 per-conv state 保留,后台 loop 继续跑(决策 e)。 + +### 1.3 B-260615-11 旧 loop 退出逻辑(单例下防污染) + +**三处 push 前一致性校验**(全部读单例 `active_conversation_id`): + +| 文件:行 | 触发点 | 当前行为 | +|---|---|---| +| `agentic.rs:486-500` | 每轮 loop 开始 | `if session.active_conversation_id != conv_id { return }` + `iteration_used = iteration + 1` | +| `agentic.rs:786-793` | MidStream 保文 push 前 | 同款校验 + return | +| `agentic.rs:837-844` | stream 后正常 push 前 | 同款校验 + return | + +**当前语义**:loop 持快照 `conv_id`(入参),每轮查单例 `active_conversation_id`,不一致即 return 退出 loop。 + +**决策 e 真并发下**:旧 loop 跑的是自己的 conv,若 messages/stop_flag 已 per-conv,旧 loop 写自己的 conv 不污染他人,该退出校验**反成阻碍**——B 阶段须改为「conv 是否仍存在」判据(被删则退出)。 + +### 1.4 `llm_concurrency` per_conv 当前语义(已退化为单对话内并发) + +**`state.rs:84-193` LlmConcurrency 结构核验**: + +```rust +// state.rs:106-113 +pub struct LlmConcurrency { + global: Arc>>, // state.rs:108 + per_conv: Arc>>, // state.rs:109 — 应用级单信号量(非 HashMap) + per_provider: Arc>>>, // state.rs:112(F-260614-04c) +} +``` + +**state.rs:94-98 注释自陈**(关键证据): + +> per_conv 当前是应用级单一信号量(非 per-conv map)。因 AiSession 为单例 + generating 互斥,同一时刻仅一个对话的 loop 在跑,per_conv 退化为"单对话内并发"(主循环 stream_llm + 标题生成 + 知识提炼)。**未来若支持多对话并发,需改为 `HashMap`。** + +**初始化**:`state.rs:283` `LlmConcurrency::new(3, 2)` —— global=3 / per_conv=2。 + +**acquire 路径**(state.rs:125-134):`acquire_global()` 和 `acquire_per_conv()` 均是 lock 内层 Arc + clone + acquire_owned。 + +**loop 内 permit 持有**(agentic.rs:659-660): + +```rust +let _global_permit = llm_concurrency.acquire_global().await; +let _per_conv_permit = llm_concurrency.acquire_per_conv().await; +``` + +重试期间(agentic.rs:207-303 `stream_one_provider` 内)持有两 permit 不释放,stream 后(agentic.rs:824-825)显式 drop。**F-260616-12 依赖项**:重试期持 permit 阻塞其他会话入槽。 + +**决策 c 改造方向**:global 语义从「LLM 调用并发上限」改为「并发会话数上限」(每 loop 入口 acquire 1 整 loop 持有);per_conv 改 `HashMap`。 + +### 1.5 残留 bug 核验(A 阶段补漏状态) + +**后端 `ai_conversation_create` 漏清字段**(src/commands/ai/commands.rs:1347-1412): + +- 06-16 文档标"漏清 agent_language/stop_flag" —— **06-19 核验已补**: + - line 1404 `session.stop_flag.store(false, Ordering::SeqCst)` + - line 1405 `session.agent_language = None` + - line 1407 `session.model_override = None` + - line 1409 `session.session_trust.clear()` +- A 路线补漏注释 line 1401-1409 明确:「F-260616-09(A 路线):补漏清字段维持单例软隔离」 +- **后端 A 路线补漏已完成,B 阶段此处仅剩「删生成中强制结束旧 loop 块」(commands.rs:1360-1379)与决策 e 的冲突**(见 §5.2) + +**前端 `useAiConversations.ts:newConversation` 漏清字段**(src/composables/ai/useAiConversations.ts:69-86): + +- 06-16 文档标"漏清 queue/generatingConvId/agentRound/searchQuery" —— **06-19 核验已补**: + - line 80 `state.queue = []` + - line 81 `state.generatingConvId = null` + - line 82 `state.agentRound = 0` + - line 83 `state.searchQuery = ''` +- 注释 line 77-79 明确:「F-260616-09(A 路线):补漏清字段维持单例软隔离」 +- **前端 A 路线补漏已完成** + +**结论**:A 路线补漏全部落地,**阶段 1(A 隔离残留 bug 修复)实际已无残留 bug**,B 阶段可直接进入阶段 2(多会话并发核心改造)。 + +### 1.6 前端事件路由(已就绪) + +**`useAiEvents.ts:handleEvent`**(src/composables/ai/useAiEvents.ts:124-366): + +```typescript +// line 125 +const convId = event.conversation_id +// line 133 +const isCurrent = !convId || convId === state.activeConversationId +// line 134-140 +if (!isCurrent) { + if (event.type === 'AiCompleted' || event.type === 'AiError') { + state.generatingConvId = null + void loadConversations() + } + return // 非当前会话事件不污染当前视图 +} +``` + +**事件按 conversation_id 路由就绪**:非当前会话事件只刷侧栏不污染视图。**这是 B 阶段 d1(侧栏切换 + 后台并行)的前置保障——零改动可用**。 + +**AiChatEvent 11 变体 conversation_id 全覆盖**(mod.rs:92-157):每个变体都有 `conversation_id: Option` 字段,后端 emit 全部传 `Some(conv_id)`(agentic.rs 多处 emit 均核验:`Some(conv_id.clone())`)。 + +### 1.7 前端 ai_is_generating 单值耦合 + +**`commands.rs:ai_is_generating`**(src/commands/ai/commands.rs:156-159): + +```rust +pub async fn ai_is_generating(state: State<'_, AppState>) -> Result { + let session = state.ai_session.lock().await; + Ok(session.generating) // 返回全局单 bool +} +``` + +**消费点**: +- 前端 `useAiSend.ts:314-321` sendMessage 发送前查后端真值(`backendGenerating = await invoke('ai_is_generating')`) +- 前端 `useAiWindow.ts:121` `restoreGeneratingState` 核对后端真值(分离窗口接管生成态) + +**B 阶段必须打破**:改 `ai_is_generating(conv_id) -> bool`,前端两处调用传 conv_id。否则多会话下,查 A 的生成态会读到 B 的 generating 单值,误判阻塞。 + +### 1.8 detached 窗口基础(useAiWindow.ts) + +**`WebviewWindow('ai-detached', ...)`**(src/composables/ai/useAiWindow.ts:43-52):单 label 单窗口。 + +- `WebviewWindow.getByLabel('ai-detached')`(useAiWindow.ts:23):复用已存在窗口 +- detach 时快照 `df-ai-gen` / `df-ai-text` 到 localStorage(useAiWindow.ts:30-31) +- `resumeInDetached`(useAiWindow.ts:151-156):核验 `ai_is_generating` 后恢复 +- 注释 useAiWindow.ts:33-34:「主窗口 state 与分离窗口 state 独立(各自 webview 独立 JS context)」 + +**d2 多窗口扩展点**:label 改 `ai-detached-${convId}` 支持每会话独立窗口。 + +--- + +## 2. 设计选项 + 推荐 + +### 2.1 b 形态:HashMap 整体 vs 拆字段 per-conv + +#### 2.1.1 ⚠️ 决策点 b-1:messages 是否 per-conv(偏离决策 b 原文,须用户裁决) + +**决策 b 原文**:「仅拆 `generating`/`stop_flag` per-conv,messages/pending 按 conv reload」。 + +**06-19 核验结论**:**messages 必须同 per-conv**,否则决策 e「不退出各自跑完」落空。 + +**证据链**: +1. 单例 `messages` 下,旧 loop(已切走的 conv A)继续跑会 `session.messages.push(...)`(agentic.rs:858/799/863)——push 到**单例 messages**(此时已被新 active conv B 的 `restore_from_messages` 覆盖过)。 +2. 这正是 §1.3 B-260615-11 三处退出校验防的污染:loop 写自己的 conv 但单例 messages 被新 conv 覆盖了 → 写错地方。 +3. 若 messages 保持单例,旧 loop 退出校验**必须保留**(否则污染);但决策 e 要求切换不退出,矛盾。 +4. 若 messages per-conv(`session.conv(&conv_a).messages.push(...)`),旧 loop 写自己的 conv 不污染 B,退出校验可改为「conv 存在性」判据,决策 e 成立。 + +**侵入面增量评估**:极小。`messages` 本就是 `ContextManager` 自包含结构(mod.rs:284),挪进 HashMap 即可;`restore_from_messages` 路径(switch 时从 DB reload)在 per-conv 下变为「建/已存在则跳过」(决策 e 下后台 conv 的 messages 不应被 reload 覆盖)。 + +**推荐**:**修正决策 b 为「拆 `generating`/`stop_flag`/`notify`/`iteration_used`/`agent_language`/`model_override`/`session_trust`/`messages` 全 per-conv,仅 `pending_approvals` 保持单层 HashMap(已带 conversation_id 符合决策 b 原意)」**。**此偏离决策 b 原文,列入待决策.md 须用户拍板**。 + +#### 2.1.2 b 形态数据结构推荐 + +```rust +// src/commands/ai/mod.rs 新增 +pub struct PerConvState { + pub messages: ContextManager, + pub generating: bool, + pub stop_flag: Arc, + pub notify: Arc, + pub iteration_used: usize, + pub agent_language: Option, + pub model_override: Option, + pub session_trust: HashSet, + pub created_at: Option, // 懒创建时间戳(随 active_conv 走) +} + +pub struct AiSession { + // ── 应用级(保持单例)── + pub active_provider_id: Option, + pub active_conversation_id: Option, // 路由键:当前展示的 conv + // ── 会话级(per-conv)── + pub pending_approvals: HashMap, // 单层(已带 conversation_id) + pub per_conv: HashMap, +} +``` + +**访问器收敛(避免 41 处 lock 各自写 HashMap 索引)**: + +```rust +impl AiSession { + /// 取某 conv 的 PerConvState(不存在则惰性创建) + pub fn conv(&mut self, conv_id: &str) -> &mut PerConvState { ... } + /// 只读快照(供 IPC 查询,不创建) + pub fn conv_read(&self, conv_id: &str) -> Option<&PerConvState> { ... } +} +``` + +**锁粒度推荐**:维持单一 `Arc>`(不拆 per-conv 细锁): +- loop 内锁持有时间极短(push 一条 / 查 stop_flag 均 O(1)),真实竞争窗口小 +- stream_llm 期间不持锁(agentic.rs:631-637 build messages 后释放锁,stream 内无锁) +- per-conv 细锁理论并发更好,但死锁风险高(锁顺序难保证 + pending_approvals 单层 HashMap 锁域交叉) +- 后续若 profiling 显示锁竞争,再细化到 per-conv Mutex(渐进路径) + +### 2.2 c 并发上限:复用 `llm_concurrency.global` + +**当前 global**:permits=3(state.rs:283 `LlmConcurrency::new(3, 2)`),限**全局 LLM 调用并发**。 + +**B 阶段新语义**:permits = **并发会话数上限**(默认 3)。每会话 loop 入口 `acquire_global()` 拿 1 permit,持有整个 loop 生命周期(含工具执行/审批等待/重试)。 + +- 含义:同一时刻最多 3 个对话并发跑 loop,第 4 个排队(acquire_global await 阻塞)。 +- 收益:token 暴增护栏(决策 c 原意)。 +- 实现:`run_agentic_loop` 入口 `let _conv_permit = llm_concurrency.acquire_global().await;`,permit 绑 guard Drop 释放。 + +**per_conv Semaphore 改 HashMap**: + +```rust +per_conv: Arc>>>, +``` + +每对话内限流不变(permits=2,主循环 + 标题 + 提炼),按 conv_id 各自一份。 + +**`acquire_per_conv(conv_id)`**:lock HashMap → 若无则建(permits=2)→ clone Arc → 释放 lock → acquire_owned。 + +**conv 退出清理**:loop 结束 + 无 pending 审批时,remove 该 conv 的 Semaphore 条目(防 HashMap 无限增长)。 + +**⚠️ 决策点 c-1**:global permits=3 是否合理?多会话并发下 3 个会话同时跑 LLM,token 成本是单会话的 ~3 倍。是否需调低默认(如 2)或暴露 Settings 配置?**建议保持默认 3,Settings 加「并发会话数上限」数字配置项**,列入待决策.md。 + +### 2.3 d UI:d1 侧栏 + d2 独立窗口(已决先 d1 后 d2) + +**d1 单面板侧栏切换 + 后台并行**(先做): +- 现状基础已就绪:useAiEvents.ts:133-140 事件按 conversation_id 路由不污染当前视图 +- 改动小:侧栏会话项显示生成态指示器 + state 加 `generatingConvs: Set` 替代单值 generatingConvId +- 不需要多窗口、状态同步、localStorage 跨窗口 + +**d2 每会话独立 Tauri 窗口**(后做): +- 现状基础:useAiWindow.ts `WebviewWindow('ai-detached')` 单 label + getByLabel 复用 +- 改动:label 改 `ai-detached-${convId}` + detachPanel 加 conv_id 参数 + localStorage 多 key + 窗口间状态同步 + +**实施顺序推荐**:d1 先(纯前端 + 后端 §2-§5 改造的最小 UI 呈现层)→ 验证后台并行成立 → d2 后(独立窗口扩展,风险更高:多窗口内存、窗口生命周期管理)。 + +**理由**:d1 先做可让后端 per-conv 改造有最小验证界面(双会话并发验收),d2 风险点多(多 webview 内存 + 跨窗口同步)放后面。 + +### 2.4 e 旧 loop 切换处理:不退出各自跑完(真并发) + +**决策 e 原文**:切换不退出旧 loop,各自跑完(真并发)。 + +**前置依赖**:必须 b-1 决策(messages per-conv)成立,否则单例 messages 下旧 loop 写 push 必污染新 active conv(见 §2.1.1)。 + +**改造点**(见 §4.1):agentic.rs:486-500/786-793/837-844 三处退出校验改为「conv 是否仍存在」判据(被删则退出),而非 `active_conversation_id != conv_id`。 + +**⚠️ 决策点 e-1**:旧 loop 跑完后的 save_conversation 是否仍走原路径?原路径(agentic.rs:474/807/874/928/958)save 传 conv_id,与 active 无关,**零改动可用**(save_conversation 签名接 conv_id,见 conversation.rs)。**推荐保持原路径**,列入待决策.md 备案。 + +--- + +## 3. 实施路径(分阶段,每阶段文件 + 风险) + +### 阶段 1:A 隔离残留 bug 修复 — **实际已完成,无工作** + +**核验结论**(§1.5):A 路线补漏全部落地: +- 后端 ai_conversation_create 漏清 stop_flag/agent_language/model_override/session_trust — **已补**(commands.rs:1404-1409) +- 前端 newConversation 漏清 queue/generatingConvId/agentRound/searchQuery — **已补**(useAiConversations.ts:80-83) + +**阶段 1 实际无残留 bug**,B 阶段直接进入阶段 2。**列为待决策.md 备案项**:阶段 1 完成确认。 + +### 阶段 2:B 多会话并发核心 — **B 阶段主体** + +**主改文件**: +- `src/commands/ai/mod.rs`:新增 `PerConvState` struct + AiSession 字段迁移 + `conv()`/`conv_read()` 访问器 +- `src/commands/ai/commands.rs`:41+ 处 `session.lock()` 调用点迁移到 `conv(&conv_id)` 索引(分小批 commit) +- `src/commands/ai/agentic.rs`:GeneratingGuard 加 conv_id 字段 + stop_flag/notify/messages 取用改 per-conv + §4 三处退出校验改 conv 存在性 + try_continue_agent_loop 改 per-conv 续跑 +- `src/commands/ai/audit.rs`:restore_pending_approvals 启动重建路径适配 +- `src/commands/ai/conversation.rs`:save_conversation 签名接 conv_id(已是,零改动) +- `src/state.rs`:`LlmConcurrency` global 语义改会话级 + per_conv 改 HashMap + +**关键改造**(详 §4): + +1. PerConvState 数据结构 + 访问器(批 1,纯重构,无行为变化) +2. 41 处 lock 调用点迁移到 conv() 索引(批 2,高风险批,单会话全功能回归) +3. run_agentic_loop per-conv 改造(批 3,GeneratingGuard + 三处退出校验 + try_continue) +4. commands.rs 切换/新建/停止/发送改 per-conv(批 4,IPC 签名变更) +5. llm_concurrency 改造 + F-260616-12 处理(批 5) + +**风险**: +- R-1(b-1 决策偏离,messages 必须同 per-conv)——须用户确认 +- R-2(单 Mutex 多 conv 抢锁,loop 内锁持有短,可控) +- R-3(事件路由已就绪,新增 emit 须核对 conv_id) +- R-5(回归面广,批 2/4 高风险,逐命令人工验收) +- R-6(F-260616-12 retry 持 permit,global 语义切换后核验) +- R-8(iteration_used 跨审批续跑在多会话下正确性,挪入 PerConvState) +- R-9(PendingApproval.conversation_id 一致性,None 视为无主审批归类) + +**验收**:双会话并发(开 A 跑 → 切 B 发 → A 后台跑完不污染 B)+ 单会话全功能回归。 + +### 阶段 3:UI d1 侧栏切换 + 后台并行 + +**主改文件**: +- `src/stores/ai.ts`:新增 `generatingConvs: Set` 替代单值 generatingConvId(line 66) +- `src/composables/ai/useAiEvents.ts`:line 142-144 写入逻辑改 Set + 各清零点(line 136/273/317/342)全改 +- `src/composables/ai/useAiConversations.ts`:switch/newConversation 切走不清零他 conv 的生成态 +- 前端侧栏组件(Sidebar.vue / 会话列表项):显示生成态指示器(读 `generatingConvs.has(conv.id)`) +- `src/composables/ai/useAiSend.ts`:line 314-321 `ai_is_generating` 改传 conv_id + +**风险**: +- R-4(前端 generatingConvId 单值 → Set,漏改清零点致残留) + +**验收**:双会话并发 UI(侧栏见两 conv 流式指示器,切走后台继续)。 + +### 阶段 4:UI d2 独立 Tauri 窗口 + +**主改文件**: +- `src/composables/ai/useAiWindow.ts`: + - `detachPanel(convId)` 加 conv_id 参数 + - WebviewWindow label 改 `ai-detached-${convId}`(line 43) + - getByLabel 查重改按 convId 查(line 23) + - localStorage 多 key(`df-ai-gen-${convId}` / `df-ai-text-${convId}`) + - 窗口间状态同步(Tauri `emit` 自定义事件 + 各窗口 listen) +- 侧栏组件:每会话加「在新窗口打开」按钮(右键菜单或图标) +- `src/composables/ai/useAiSend.ts` / `useAiConversations.ts`:窗口内操作路由到正确 conv_id + +**风险**: +- 多窗口 + 多 webview 内存占用(每窗口一份 Vue app + store) +- 窗口生命周期管理(关窗是否停 loop?决策 e 下不停,但须确认 UI 提示) +- 跨窗口 provider 变更同步(自定义事件桥接) + +**验收**:每会话独立窗口 + 多窗口并发 + 窗口间状态同步。 + +--- + +## 4. 关键改造点(代码级,实施时参照) + +### 4.1 B-260615-11 陈旧 loop 退出逻辑改 conv 存在性 + +**改造前**(agentic.rs:486-500): + +```rust +let mut session = session_arc.lock().await; +if session.active_conversation_id.as_deref() != Some(conv_id.as_str()) { + tracing::warn!("[ai] 对话已切换,旧 loop 退出(B-260615-11)避免污染新对话"); + return; +} +session.iteration_used = iteration + 1; +``` + +**改造后**(决策 e 真并发): + +```rust +{ + let mut session = session_arc.lock().await; + if !session.per_conv.contains_key(&conv_id) { + tracing::warn!(conv_id = %conv_id, "[ai] conv 已删除,旧 loop 退出"); + return; + } + session.conv(&conv_id).iteration_used = iteration + 1; +} +``` + +同样改造 agentic.rs:786-793(MidStream 保文后 push)/837-844(stream 后 push):三处 push 前校验**全部改为 conv 存在性**而非 active 一致性。 + +### 4.2 stop_flag/notify/messages 取用改 per-conv 索引 + +**改造前**(agentic.rs:446-449): + +```rust +let (stop_flag, notify) = { + let session = session_arc.lock().await; + (session.stop_flag.clone(), session.notify.clone()) +}; +``` + +**改造后**: + +```rust +let (stop_flag, notify) = { + let session = session_arc.lock().await; + let conv = session.per_conv.get(&conv_id).expect("loop 启动前 conv 已建"); + (conv.stop_flag.clone(), conv.notify.clone()) +}; +``` + +**build_for_request 改 per-conv messages**(agentic.rs:631-637): + +```rust +let messages = { + let session = session_arc.lock().await; + let conv = session.per_conv.get(&conv_id).expect("..."); + let (history_msgs, _trimmed) = conv.messages.build_for_request(sys_tokens); + // ... +}; +``` + +### 4.3 GeneratingGuard 改 per-conv 复位 + +**改造前**(agentic.rs:73-108):guard 持 `Arc>`,Drop 时 `session.lock().await.generating = false`(全局)。 + +**改造后**:guard 加 `conv_id: String` 字段,`reset()` / Drop 时 `session.conv(&conv_id).generating = false`(只复位该 conv)。 + +### 4.4 try_continue_agent_loop 改 per-conv 续跑 + +**改造前**(agentic.rs:992-1112):读全局 generating + pending 决定续跑,conv_id 取自 `active_conversation_id` 或 pending_approvals。 + +**改造后**:加 `conv_id: &str` 参数,读 `conv_read(conv_id).generating` + `pending_approvals.values().any(|a| a.conversation_id == Some(conv_id))` 决定续跑,spawn run_agentic_loop 传同一 conv_id。 + +**调用点**:ai_approve(commands.rs 330/411)调用处传 `approval.conversation_id`(已有,见 commands.rs:484/548 校验)。 + +### 4.5 ai_conversation_switch 删 readonly 分支 + +**改造前**(commands.rs:1474-1481):生成中 readonly 切换。 + +**改造后**: + +```rust +let mut session = state.ai_session.lock().await; +session.active_conversation_id = Some(conversation_id.clone()); // 直接切,不动 per-conv +// 旧 conv 的 per-conv state 保留(后台 loop 继续跑) +// 新 conv 从 DB reload 到其 per-conv(已存在则跳过,防覆盖后台 conv 的内存 messages) +let conv_state = session.per_conv.entry(conversation_id.clone()) + .or_insert_with(|| PerConvState::from_messages(messages)); // 仅惰性建 +session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id)); +// 删 readonly 分支 +``` + +### 4.6 ai_conversation_create 删强制结束旧 loop 块 + +**改造前**(commands.rs:1360-1379):生成中 `generating=false` + `stop_flag=true` 杀旧 loop + emit AiCompleted。 + +**改造后**: + +```rust +let mut session = state.ai_session.lock().await; +// B 阶段:新建会话不杀旧 loop(决策 e)。新会话建独立 per-conv state。 +let id = new_id(); +session.active_conversation_id = Some(id.clone()); +session.per_conv.insert(id.clone(), PerConvState::new()); // 全新 state +session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&id)); +// 删 if session.generating { 强制结束 } 块 +``` + +**注意**:旧块前还有 SW-260618-02 `finalize_pending_placeholders`(commands.rs:1365/1398)——保留其语义但改为针对**旧 conv** 的占位终态化(若旧 conv 的 messages 即将被新建覆盖,占位须终态化;per-conv 后旧 conv 的 messages 保留,占位无须终态化,**可直接删**)。 + +### 4.7 ai_chat_send / ai_is_generating / ai_chat_stop / force / continue / stop_loop 改 per-conv + +所有命令**加 conv_id 参数**(前端传 activeConversationId 或目标 conv),操作仅针对该 conv 的 per-conv state: + +- `ai_is_generating(conv_id)` → `conv_read(conv_id).map(|c| c.generating).unwrap_or(false)` +- `ai_chat_send(conv_id, ...)` → 互斥仅查该 conv 的 generating +- `ai_chat_stop(conv_id)` → 置该 conv 的 stop_flag + notify +- `ai_chat_force_send(conv_id, ...)` → 复位该 conv 的 generating + 重发 +- `ai_continue_loop(conv_id)` / `ai_stop_loop(conv_id)` → 操作该 conv + +**IPC 签名变更**:更新 `#[tauri::command]` 签名 + lib.rs invoke_handler + 前端 api/ai.ts wrapper + useAiSend.ts/useAiConversations.ts 调用处。 + +--- + +## 5. 风险点 + 回归测试点 + +### 5.1 风险清单 + +| 编号 | 风险 | 评估 | 缓解 | +|---|---|---|---| +| R-1 | ⚠️ 决策 b 偏离:messages 必须同 per-conv | 高(若不偏离决策 e 落空) | **列入待决策.md,须用户拍板** | +| R-2 | 单 Mutex 多 conv 抢锁 | 中(loop 内锁持有短) | profiling 后再细化 per-conv Mutex | +| R-3 | 事件路由新增 emit 漏 conv_id | 低(11 变体已全覆盖) | 批 3/4 改造时 grep emit checklist | +| R-4 | 前端 generatingConvId 单值 → Set 漏改清零点 | 中(4 处清零点) | 批 6 集中改 + 双会话 UI 验收 | +| R-5 | 回归面广(批 2/4 高风险) | 高 | 批间 cargo + vue-tsc + 逐命令人工验收 | +| R-6 | F-260616-12 retry 持 permit | 低(global 改会话级后语义自洽) | 批 5 验证 global 新语义下行为符合预期即关闭 | +| R-7 | 启动恢复 + L0 握手多 conv 适配 | 中(restore_pending_approvals + lib.rs L0) | 批 8 集中处理 | +| R-8 | iteration_used 跨审批续跑在多会话下正确性 | 中(挪入 PerConvState) | 批 2 checklist 含 ai_approve 两处读(commands.rs:330/411) | +| R-9 | PendingApproval.conversation_id 一致性 | 低(insert 点均带 Some(conv_id)) | 批 2 核验所有 insert 点,None 视为无主审批归类 | + +### 5.2 回归测试点 + +**单会话全功能回归**(批 2/4 后必做,逐命令): +- send / regenerate / edit_last / approve / stop / continue_loop / stop_loop / switch / create / delete / rename / archive / set_pinned + +**双会话并发验收**(批 3 后): +- 开 conv A 跑长任务(工具调用) → 切 conv B 发消息 → A 后台跑完不污染 B 的 messages/pending +- A 跑审批等待 → 切 B 发 → A 审批仍可处理(approve 后 A 续跑) +- A 跑达 max_iterations → 切 B 发 → A 仍可 continue_loop / stop_loop + +**3 会话并发验收**(批 5 后): +- 同时跑 3 个会话(第 4 个排队)+ 重试期不阻塞他对话(F-260616-12) +- token 暴增护栏:3 会话并发 token ≈ 3 倍单会话 + +**d1 UI 验收**(批 6): +- 侧栏见两 conv 流式指示器(generatingConvs.has) +- 切走后台继续,A 完成后指示器消失 + +**d2 UI 验收**(批 7): +- 每会话独立窗口(`ai-detached-${convId}`)+ 多窗口并发 + 窗口间 provider 变更同步 + +**启动恢复验收**(批 8): +- 重启恢复多 conv pending 审批(各归各的 per_conv) +- HMR 清多 conv 残留 generating(L0 握手遍历 per_conv) + +**关键回归点**: +- **切换不丢上下文**:conv A 跑一半切走再切回,messages 完整(per-conv 保留) +- **并发不串话**:conv A 的 AiTextDelta 不写入 conv B 的视图(事件 conversation_id 路由) +- **事件路由 conversation_id**:所有 emit 带 conv_id(useAiEvents.ts:125 已路由) +- **token 暴增护栏**:global permits=3 限并发会话数 + +--- + +## 6. 与已决架构债的关系 + +- **memory `aichat-arch-extensibility`**:「AiSession 单例未动」是登记的架构债,本设计正式清偿。 +- **mod.rs:320-331 注释**:「当前 AiSession 是全局单例(F-09 B 多会话架构落地时改 per-conv)」——预留债标记,本设计落地。 +- **state.rs:94-98 注释**:per_conv「未来若支持多对话并发,需改为 HashMap」——本设计 §2.2 落地。 +- **不冲突**:本设计与 F-260616-11(iteration 累计,已 per-conv 化预留)、F-260616-07(流式重试,F-260616-12 依赖项 §2.2 处理)、generating 状态机加固(RAII guard,§4.3 改 per-conv)、B-260615-14(notify 即时唤醒,随 stop_flag 入 PerConvState)均兼容。 + +--- + +## 7. 待裁决决策点清单(供主代裁决 / 待决策.md) + +| 编号 | 决策点 | 推荐 | 影响范围 | +|---|---|---|---| +| ⚠️ b-1 | messages 是否 per-conv(偏离决策 b 原文) | **必须 per-conv**(否则决策 e 落空) | 阶段 2 批 1-3 | +| ⚠️ c-1 | global permits=3 默认值是否合理 / 暴露 Settings | 保持 3 + Settings 加「并发会话数上限」配置项 | 阶段 2 批 5 | +| ⚠️ e-1 | 旧 loop 跑完 save_conversation 是否走原路径 | 保持原路径(save 接 conv_id 零改动) | 阶段 2 批 3 备案 | +| (备案) | 阶段 1(A 隔离残留 bug)实际无残留,确认跳过 | 确认跳过 | 阶段 1 | +| (备案) | d2 关窗是否停 loop(决策 e 下不停) | 不停,UI 提示「窗口关闭后后台继续」 | 阶段 4 | +| (备案) | d2 多窗口内存上限(是否限制最多 N 个独立窗口) | 建议限 3-5 个,超限提示 | 阶段 4 | + +--- + +## 8. 验收清单(每批完成后) + +- [ ] 阶段 1:(无工作,A 路线补漏已落地,确认即可) +- [ ] 阶段 2 批 1:cargo build + PerConvState 访问器单测(惰性建/已存在/conv 删除) +- [ ] 阶段 2 批 2:cargo + vue-tsc 0err + 单会话全功能回归(逐命令) +- [ ] 阶段 2 批 3:双会话并发(开 A 跑 → 切 B 发 → A 后台跑完不污染 B)+ 单会话回归 +- [ ] 阶段 2 批 4:双会话并发(切走不杀旧 loop,各自跑完)+ 全 IPC 签名前端调用核对 +- [ ] 阶段 2 批 5:3 会话并发(第 4 排队)+ 重试期不阻塞他对话(F-260616-12) +- [ ] 阶段 2 批 8:重启恢复多 conv pending 审批 + HMR 清多 conv 残留 +- [ ] 阶段 3:双会话并发 UI(侧栏两 conv 流式指示器,切走后台继续) +- [ ] 阶段 4:每会话独立窗口 + 多窗口并发 + 窗口间状态同步 + +--- + +**设计完。本文件为设计草案,不含代码改动。实施时按阶段 2(批 1→8)→ 阶段 3 → 阶段 4 顺序,每批独立可验证。** + +**核心待裁决**:⚠️ b-1(messages per-conv 偏离决策 b 原文)是 B 阶段能否落地的关键,须用户拍板后方可进入阶段 2 批 1。 diff --git a/docs/todo.md b/docs/todo.md index 8f305b3..329cf7c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -112,7 +112,7 @@ > 功能需求,已走查定位链路 + 方案记录,待用户确认后实施。 -- [ ] F-260616-09 [P1/架构·**A路线✅(batch60·2026-06-16·workflow whae812z5+主代核查),B已决高优先级·待立项实施(待决策归档)**] — **AiSession 单例 → 多会话并发架构(多窗口来回切 + 并行执行)**。用户需求(3 条消息强化):「应用级支持同时具备多会话」+「同时具备多会话的能力」+「多个会话窗口来回切,**并行执行**」。现状(Explore 走查确认):`AiSession` **应用级单例**(`state.rs:164` `ai_session: Arc>`,init:205),messages/generating/stop_flag/pending_approvals/agent_language 全一份。设计「单例 + 软隔离」——generating 互斥(同一时刻**只一个对话能跑**) + 切换旧 loop 退出(`agentic.rs:177-190` B-260615-11) + 切换时 generating→readonly(`commands.rs:873-881`)。`llm_concurrency` per_conv 因此退化为单对话内并发(`state.rs:93-96` 注释已标注预留改 HashMap)。**memory 已标记「AiSession 单例未动」架构债**(aichat-arch-extensibility)。**残留 bug**(表象=新建会话上下文残留):前端 `newConversation`(`useAiConversations.ts:31-41`) 漏清 queue/generatingConvId/agentRound/searchQuery;后端 `ai_conversation_create`(`commands.rs:784-821`) 漏清 agent_language/stop_flag(设 true 停旧 loop 没复位 false)。**用户「并行执行」明确=B 方向**(A 只解切换残留不满足并行)。**分档**:**A 短期隔离修复**(补漏清字段,维持单例软隔离,解残留,低风险立即做,**B 前置**) + **B 多会话并发架构**(单例 → `HashMap`,每会话独立 messages/generating/stop_flag/pending_approvals + llm_concurrency per_conv 改真 per-conv HashMap + loop 内校验改 conv_id 索引各自 session + 切换不退出旧 loop 各自跑完真并发 + 事件路由核对 conversation_id 全覆盖)。**决策点(待定)**:(a) A 先做解燃眉 vs 直接 B——倾向 **A 先**(残留真 bug 立即解,B 架构另立项) (b) B 形态——AiSession 整体 HashMap vs 拆字段(messages/pending 已可按 conv reload,只拆 generating/stop_flag per-conv)——倾向**后者侵入小** (c) B 并发上限——复用 llm_concurrency.global 限并发会话数防 token 暴增——倾向是 (d) **多窗口 UI 形态**——(d1) 单面板侧栏会话列表切换+后台并行(改动小) vs (d2) 每会话独立 Tauri 窗口(`useAiWindow.ts` detached 已有基础可扩,改动大但「窗口来回切」语义最贴)——**待用户定**,倾向先 d1 后 d2 (e) 旧 loop 切换处理——不退出各自跑完(真并发,倾向) vs 仍退出。— state.rs(AiSession 去单例化) + commands.rs(create/switch 重构 + 漏清补) + agentic.rs(:177-190 校验改 conv_id 索引) + context.rs(ContextManager per-conv) + useAiConversations.ts(漏清补) + stores/ai.ts(per-conv state) + llm_concurrency per_conv + useAiWindow.ts(若 d2 多窗口)。**B 是大改架构升级,需单独设计文档 + 立项**。**✅ 已决(2026-06-16·待决策归档):a — A 隔离修复 + B 立项(🔥 B 阶段高优先级尽快实施),UI d1+d2 都做(侧栏切换 + 独立 Tauri 窗口)**。 +- [ ] F-260616-09 [P1/架构·**A路线✅(batch60 + 06-19核验补漏已落地:commands.rs:1404/useAiConversations.ts:80 → 阶段1跳过),B 设计完成 2026-06-19([F-09设计文档](./02-架构设计/F-09-多会话并发架构设计-2026-06-19.md))·阶段2 批1-8 待拍板 b-1(messages per-conv)启动(见待决策.md)**] — **AiSession 单例 → 多会话并发架构(多窗口来回切 + 并行执行)**。用户需求(3 条消息强化):「应用级支持同时具备多会话」+「同时具备多会话的能力」+「多个会话窗口来回切,**并行执行**」。现状(Explore 走查确认):`AiSession` **应用级单例**(`state.rs:164` `ai_session: Arc>`,init:205),messages/generating/stop_flag/pending_approvals/agent_language 全一份。设计「单例 + 软隔离」——generating 互斥(同一时刻**只一个对话能跑**) + 切换旧 loop 退出(`agentic.rs:177-190` B-260615-11) + 切换时 generating→readonly(`commands.rs:873-881`)。`llm_concurrency` per_conv 因此退化为单对话内并发(`state.rs:93-96` 注释已标注预留改 HashMap)。**memory 已标记「AiSession 单例未动」架构债**(aichat-arch-extensibility)。**残留 bug**(表象=新建会话上下文残留):前端 `newConversation`(`useAiConversations.ts:31-41`) 漏清 queue/generatingConvId/agentRound/searchQuery;后端 `ai_conversation_create`(`commands.rs:784-821`) 漏清 agent_language/stop_flag(设 true 停旧 loop 没复位 false)。**用户「并行执行」明确=B 方向**(A 只解切换残留不满足并行)。**分档**:**A 短期隔离修复**(补漏清字段,维持单例软隔离,解残留,低风险立即做,**B 前置**) + **B 多会话并发架构**(单例 → `HashMap`,每会话独立 messages/generating/stop_flag/pending_approvals + llm_concurrency per_conv 改真 per-conv HashMap + loop 内校验改 conv_id 索引各自 session + 切换不退出旧 loop 各自跑完真并发 + 事件路由核对 conversation_id 全覆盖)。**决策点(待定)**:(a) A 先做解燃眉 vs 直接 B——倾向 **A 先**(残留真 bug 立即解,B 架构另立项) (b) B 形态——AiSession 整体 HashMap vs 拆字段(messages/pending 已可按 conv reload,只拆 generating/stop_flag per-conv)——倾向**后者侵入小** (c) B 并发上限——复用 llm_concurrency.global 限并发会话数防 token 暴增——倾向是 (d) **多窗口 UI 形态**——(d1) 单面板侧栏会话列表切换+后台并行(改动小) vs (d2) 每会话独立 Tauri 窗口(`useAiWindow.ts` detached 已有基础可扩,改动大但「窗口来回切」语义最贴)——**待用户定**,倾向先 d1 后 d2 (e) 旧 loop 切换处理——不退出各自跑完(真并发,倾向) vs 仍退出。— state.rs(AiSession 去单例化) + commands.rs(create/switch 重构 + 漏清补) + agentic.rs(:177-190 校验改 conv_id 索引) + context.rs(ContextManager per-conv) + useAiConversations.ts(漏清补) + stores/ai.ts(per-conv state) + llm_concurrency per_conv + useAiWindow.ts(若 d2 多窗口)。**B 是大改架构升级,需单独设计文档 + 立项**。**✅ 已决(2026-06-16·待决策归档):a — A 隔离修复 + B 立项(🔥 B 阶段高优先级尽快实施),UI d1+d2 都做(侧栏切换 + 独立 Tauri 窗口)**。 - [ ] F-260616-12 [P2/依赖F-09] — **retry 持 permit 不释放(多会话隐患)**。F-260616-07 落地的流式重试循环(`agentic.rs:238-239`)重试期间持有 global+per_conv permit 不释放(注释「防新请求挤占」)。当前 AiSession 单例 + 主 loop 串行无影响,但 **F-260616-09 多会话并发后**,重试期间阻塞其他对话 LLM 调用。**方向**:多会话落地时核对——主 loop 串行下重试持 permit 防自己挤占无意义,倾向重试不持 permit 或仅持 per_conv。— agentic.rs:238 + F-260616-09 多会话架构。**依赖 F-260616-09 立项后一并处理**。 diff --git a/docs/待决策.md b/docs/待决策.md index 0fd3a55..651b227 100644 --- a/docs/待决策.md +++ b/docs/待决策.md @@ -143,6 +143,16 @@ - **关联**:批次7 transition_status 已删 / CR-22 删 risk_level / CR-23 标 allow 5 处 - **状态**:✅ 已实施 b(2026-06-18·主代自主决策·risk_level 删 + 预留/diff 标 allow·cargo 0 warning) +#### F-260616-09-B 多会话并发架构 B 阶段实施决策(设计完成 2026-06-19·待拍板) +- **背景**:F-09 B 阶段设计文档完成 [F-09-多会话并发架构设计-2026-06-19.md](./02-架构设计/F-09-多会话并发架构设计-2026-06-19.md)。核验发现 **A 路线补漏已全部落地**(commands.rs:1404-1409 + useAiConversations.ts:80-83)→ **阶段1 跳过**。AiSession 12 字段。阶段2(B 主体,批1-8)待启动。 +- **决策点**: + - **⚠️ b-1(关键·须拍板)**:messages 是否 per-conv。原决策 b「messages 按 conv reload(不 per-conv)」与决策 e「切换不退出各自跑完」**矛盾**(单例 messages 下旧 loop push 污染新 conv,B-260615-11 退出校验必须保留→与 e 冲突)。设计推荐**修正 b 为 messages per-conv**(e 的技术必然必要条件,侵入面增量极小:ContextManager 挪 HashMap)。 + - **⚠️ c-1**:global permits=3 默认值 + Settings 加「并发会话数上限」配置项。 + - **e-1(主代已定✅)**:旧 loop save_conversation 保持原路径(save 接 conv_id 零改动)。低风险技术细节,主代裁决采纳。 +- **推荐**:**✅ b-1 采纳(messages per-conv,技术必然)+ c-1 保持 3 + Settings 配置 + e-1 原路径**。拍板后启动阶段2 批1(PerConvState 数据结构 + 访问器)。 +- **关联**:todo F-260616-09 / 设计文档 / memory aichat-arch-extensibility +- **状态**:🟡 待用户拍板(b-1/c-1) + --- ## 已决归档 diff --git a/docs/待审查.md b/docs/待审查.md index 0054cc4..7fc9da5 100644 --- a/docs/待审查.md +++ b/docs/待审查.md @@ -661,7 +661,7 @@ - **⚪ WATCH-1**: run_workflow 审批 reason 走轻量「触发工作流」动词(不含 task 名/target_status),UX-14 范围外;若后续需 run_workflow reason 显任务名,需同时从 tool_display_hint :1577 移除 run_workflow(让 display_hint_for_tool :1550 模板生效)+ build_approval_reason task_id key 特化走 resolve_task_label。当前接受动词级。 - **待修项回流 todo**: **无** 🔴/🟡 项 -### CR-260619-02 UX-260618-15 第一批 流式重试N+1气泡根治·方案A(agent ux15-batch1 实施 + 主代兜底核验 + 注释修正) — 🟡 待审 +### CR-260619-02 UX-260618-15 第一批 流式重试N+1气泡根治·方案A(agent ux15-batch1 实施 + 主代兜底核验 + 注释修正) — ✅ 已审(PASS·0) - **范围**(3 文件 + 主代注释修正): - **stream_recv.rs**:`InitFailed` 变体加 `error: String` 字段(:125);5 处重试路径 AiError emit 全移除(idle timeout :203 / stream-error :262 / chunk Err :303 / 流尽无 finished :362 / provider.stream() Err :402),retryable=true 重试路径 emit 权交 agentic,retryable=false Fatal 也交 agentic 统一 emit。**主代修正注释残留** :251/:299(旧"emit AiError"→"返回 InitFailed")。 @@ -671,6 +671,29 @@ - **审查要点(供审查 agent)**:① N+1 根治——重试过程不再每轮 push AiError,改 AiStreamRetry 单气泡(首次创建+更新),耗尽/Fatal emit 1 最终 AiError(N+1→1重试+1最终);② stream_recv 5 处移除判定(retryable=true 重试路径移除 / Fatal 交 agentic);③ agentic Fatal/耗尽 emit 单气泡(Network 分类);④ **riskNote error_type 统一 Network**——401 鉴权 Fatal 原本 error_type=None(前端 canOpenSettings 只认 {auth,provider_config} 本不显「去设置」),现 Network 也不显,**不恶化既有行为**(记 low WATCH:理想 401 显 auth 按钮需单独做);⑤ InitFailed 枚举加 error 字段 pub(crate) 内部无破坏。 - **关联**:todo UX-260618-15 第一批销账;第二批(方案C 聚合气泡显示错误原因)可选增强待定。 +**复审结论(CR-260619-02)**: verdict **PASS**(0 high / 0 med / 0 low)。独立 grep/read 核验 3 文件当前形态,N+1 气泡根治落地完整,emit 权移交无 Fatal swallow,单气泡聚合无重复 emit,保留 emit 未误删,注释与代码一致。 + +**逐项核验表**(源码 file:line 佐证): + +| 审查点 | 核验结论 | 佐证 | +|---|---|---| +| N+1 根治:重试不再每轮 push AiError | ✅ PASS | stream_recv.rs 全文 `emit` 仅 AiTextDelta(:229)/AiHeartbeat(:329);5 处 InitFailed 路径(:206/:265/:306/:364/:407)均 `return StreamResult::InitFailed{...}` 不 emit | +| 5 处移除完整无遗漏 InitFailed emit | ✅ PASS | grep `AiError` 在 stream_recv.rs 仅命中注释/Doc,无运行时 emit 调用 | +| retryable=true 重试路径移除判定正确 | ✅ PASS | agentic.rs:243-300 解构 `InitFailed{retryable,error}`,retryable=true 走重试 emit AiStreamRetry(:293);retryable=false Fatal 返回 `StreamOutcome::Fatal{error}`(:254) | +| retryable=false Fatal 正确交 agentic(无 swallow) | ✅ PASS | 外层 agentic.rs:735-744 `StreamOutcome::Fatal` 分支 guard.reset + emit AiError(Network) + return,终态单 emit | +| Fatal/耗尽单气泡聚合无重复 emit | ✅ PASS | Fatal 分支 :739 单 emit;全 candidate 耗尽 :761 单 emit;`last_exhausted_error` 累积 Exhaused 文本(:688 init / :732 write),Exhausted 分支 :724 注释「不在此 emit」防残留 | +| 保留 emit 未误删 | ✅ PASS | ensure_resolved_key Auth :401 / 压缩失败 Unknown :618 / try_continue ProviderConfig :1058 三处 emit 完整 | +| useAiEvents AiStreamRetry 首次创建边界 | ✅ PASS | useAiEvents.ts:185 `if (lastMsg && lastMsg.isError) 更新 content else push 新错误气泡`;重试过程不再先 emit AiError 故首条到达末条非 isError,正确创建;后续 isError 更新 | +| 401 鉴权走 ensure_resolved_key(Auth)非 stream_llm Fatal(Network) | ✅ PASS | 401 key 缺失/损坏早失败在 agentic.rs:390 `ensure_resolved_key` Err → :401 emit Auth;stream_llm Fatal(Network) 仅覆盖 key 已过 ensure 但被 provider 拒等场景 | +| 注释 :251/:299 修正一致 | ✅ PASS | stream_recv.rs :203-204/:262-263/:303-305/:362-363/:402-404 均修正为「不再 emit AiError/返回 InitFailed」,与代码行为一致 | + +**对抗印证**:① stream_recv.rs 无运行时 AiError emit(grep 仅命中注释/Doc);② agentic.rs AiStreamRetry 单点 emit(:293)位于 retryable 重试分支,Fatal/Exhaused 分支均不重复;③ InitFailed 枚举字段 `error:String`(:125)pub(crate) 内部,跨模块解构(agentic.rs:243)正确。 + +**WATCH**: +- **⚪ WATCH-1(沿用 riskNote ④,low)**:stream_llm Fatal 路径 error_type 统一 Network,401 经此路径时不显「去设置」按钮(前端 canOpenSettings 仅认 {auth,provider_config})。401 主路径已由 ensure_resolved_key Auth 兜底,不恶化既有行为;理想 401 显 auth 按钮需单独做。 + +**待修项回流 todo**: **无** 🔴/🟡 项 + --- ## 已审归档