# AI 生成状态机加固(generating 生命周期) > 创建: 2026-06-15 | 来源: /review generating 状态机专项审查 | 关联 bug: 用户报障"创建不了新对话" --- ## 1. 背景 用户报障:AI 面板"创建不了新对话了",前端报 `生成中无法新建对话,请先停止或等待完成`,但前端实际无内容生成。 排查定位:后端 `AiSession.generating` 标志卡在 `true`,与实际无生成状态不符。`ai_conversation_create`(commands.rs:451)硬拦 `generating=true` → 死锁,用户永远新建不了对话,只能重启 app。 `/review` 对 generating 状态机(commands.rs 对话/审批/stop + agentic.rs loop/try_continue + stream_recv.rs stream_llm + mod.rs AiSession)做专项审查,产出本文档。 --- ## 2. 现状:三标志组合表达隐式状态机 ### 2.1 状态标志 | 标志 | 类型 | 位置 | 作用 | |------|------|------|------| | `generating` | `bool` | `Mutex` 内 | 是否有 agentic loop 活跃 | | `pending_approvals` | `HashMap` | `Mutex` 内 | 待审批工具调用 | | `stop_flag` | `Arc` | 跨锁共享 | ai_chat_stop 置位,loop/stream 读取 | ### 2.2 隐式表达的 4 态 | 状态 | 标志组合 | 语义 | |------|---------|------| | **Idle** | `generating=false` | 空闲,可接新请求 | | **Streaming** | `generating=true && pending=空` | agentic loop 流式生成中 | | **AwaitingApproval** | `generating=true && pending非空` | loop 暂停等用户审批 | | **Stopping** | `stop_flag=true`(瞬态) | 用户点了停止,loop 检查点退出中 | ### 2.3 两个散布问题(根因) **写侧散布(复位)**:`generating=false` 手动散落在 6 个 return 点(agentic.rs:53/94/144/190/264/318)。任一新增 return 漏写、或 spawn task panic/abort → 所有复位点跳过 → `generating` 永久 true。 **读侧散布(判别)**:状态判别靠标志组合反推: - `try_continue`(agentic.rs:283):`should_continue = generating && !pending` - `ai_chat_stop`(commands.rs:250):`pending非空` 分流流式/审批态 - `ai_conversation_create`(commands.rs:451):`generating` 硬拦 判别逻辑散落三处,无单一真相源,新增分支易漏。 --- ## 3. 状态机决策:轻量状态机,不引入框架 ### 3.1 决策结论 **不引入独立状态机框架**(enum 字段替换 bool + 转换守卫 + codegen 库)。采用**轻量状态机**: - **写侧收敛** = RAII guard(Drop 兜底复位),对应审查项 ① - **读侧收敛** = `session.state()` enum 视图方法(只读,不改字段),对应审查项 ⑤ - **stop_flag 保留** `Arc`(跨锁信号,状态机管不了) ### 3.2 多角度论证 | 角度 | 独立状态机框架 | 轻量方案(guard+视图) | 判 | |------|--------------|-------------------|-----| | **状态复杂度** | 4 态 6 边转换,简单 | 同 | 不足以 justify 框架 | | **stop_flag 约束** | enum 不能跨锁,stop_flag 仍须 AtomicBool 独存 | stop_flag 保留,enum 只读视图 | 框架无法替代跨锁信号 | | **根因对症** | 复位散布=写侧 / 判别散布=读侧 | guard 治写 + 视图治读,**精准对症** | 轻量直达根因 | | **风格契合** | enum 字段+转换函数+非法检测=过度封装 | 增量加 guard+方法,做减法 | 轻量契合"可读性>抽象性" | | **迁移成本** | 改 AiSession 字段+所有读写点+测试,大改 | 增量,①⑤已在审查清单 | 轻量零额外工作 | ### 3.3 关键洞察 ① RAII guard(写收敛)+ ⑤ enum 视图(读收敛)= 状态机读写两端都收敛,**功能上等价于状态机,但不引入 enum 字段/转换守卫/框架**。这是务实路线,符合作减法风格。 `stop_flag` 是跨锁信号(ai_chat_stop 在 IPC 线程置位,loop/stream 在 agentic task 读),必须保持 `Arc` 独立——这是状态机 enum 字段(锁内)无法替代的并发要求。 --- ## 4. 改造设计 ### 4.1 ① RAII guard — 写侧收敛(P0 根治) 新增 guard struct,`run_agentic_loop` 入口创建,函数退出(含 panic/abort/正常 return)时 Drop 兜底复位。 ```rust struct GeneratingGuard { session: Arc>, app: AppHandle, conv_id: String, } impl Drop for GeneratingGuard { fn drop(&mut self) { let app = self.app.clone(); let conv_id = self.conv_id.clone(); let session = self.session.clone(); tauri::async_runtime::spawn(async move { let mut s = session.lock().await; if s.generating { // 仅在仍 true 时复位(避免重复 emit) s.generating = false; drop(s); let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, conversation_id: Some(conv_id), }); } }); } } ``` `run_agentic_loop` 入口:`let _guard = GeneratingGuard { ... };`,函数内所有手动 `session.generating = false` 删除。 **注意**:Drop 是同步的不能再 `.await`,故 spawn 异步复位。存在极小窗口 generating 仍 true,由 ② 软复位兜底。 ### 4.2 ⑤ session.state() 视图 — 读侧收敛(P1) 只读视图方法,不改 AiSession 字段,收敛三标志组合判别: ```rust enum SessionState { Idle, Streaming, AwaitingApproval } impl AiSession { fn state(&self) -> SessionState { if !self.generating { return SessionState::Idle; } if self.pending_approvals.is_empty() { SessionState::Streaming } else { SessionState::AwaitingApproval } } } ``` 改造判别点: - `try_continue`:`should_continue = matches!(session.state(), SessionState::Streaming)` - `ai_chat_stop`:分流用 `matches!(session.state(), SessionState::AwaitingApproval)` - `ai_conversation_create`:见 4.3 ### 4.3 ② newConversation 软复位 — 死锁解除(P0) `ai_conversation_create`(commands.rs:451)硬拦改软复位。`generating=true` 时强制复位(等同 `ai_chat_stop` 审批态分支 commands.rs:252)再新建: ```rust if session.generating { session.generating = false; session.pending_approvals.clear(); session.stop_flag.store(true, Ordering::SeqCst); let conv_id = session.active_conversation_id.clone(); drop(session); let _ = app.emit("ai-chat-event", AiChatEvent::AiCompleted { ... conversation_id: conv_id }); session = state.ai_session.lock().await; } session.active_conversation_id = Some(id.clone()); // ... ``` **含 ④ 策略统一**:软复位后 newConversation 与 switchConversation:518(readonly 放行)形成一致心智——「新建=放弃当前生成 / 切换=只读接管」。 需加 `app: AppHandle` 参数(tauri 注入,前端无感)。 ### 4.4 ③ loop 对话一致性校验 — 竞态防护(P0) ② 软复位后旧 loop 退出时 `session.messages.push(旧 assistant)`(agentic.rs:171/173)污染新对话。loop 每次 push 前校验: ```rust let mut session = session_arc.lock().await; if session.active_conversation_id.as_deref() != Some(&conv_id) { return; // 对话已切走,丢弃本轮,不 push(guard ① 兜底复位) } if has_tool_calls { /* push */ } ``` 同样校验建议加在 `save_conversation` 调用前(agentic.rs:90/186/212/254),防旧 loop 写库污染。 ### 4.5 ⑥ stop 流式态兜底 — 治 loop 已死(P1) `ai_chat_stop`(commands.rs:261)流式态分支仅置 stop_flag,依赖 loop 自复位。若 loop 已 panic/abort,stop_flag 无人读,generating 永不复位。复用 ② 抽出的复位 helper 兜底。 ### 4.6 ⑦ stop_notify 即时打断(P1,可选) `stop_flag`(AtomicBool)无 async 通知能力,靠 30s 心跳 tick 间接唤醒 select!。换 `tokio::sync::Notify` 即时打断。`stop_flag` 保留作循环顶快检(冗余双保险)。 --- ## 5. 状态转换图 ``` ai_chat_send │ ▼ ┌─────────────────┐ │ Streaming │◄────────────────┐ │ generating=true │ │ │ pending=空 │ │ └────────┬────────┘ │ │ │ ┌──────────┼──────────┐ │ │ │ │ │ 有待审批 无工具调用 stop_flag ai_approve │ (收敛) (用户停) (处理完) ▼ │ │ │ ┌──────────────┐ │ ▼ │ │AwaitingApproval│ │ ┌─────────┐ │ │ generating=true │ │ │ Stopping│ │ │ pending非空 │ │ │stop=true│ │ └──────┬───────┘ │ └────┬────┘ │ │ │ │ │ ai_approve │ loop 检查点 │ (处理完) │ 退出+复位 │ │ │ │ │ └───────────┼──────────┼───────────────┘ ▼ ▼ ┌────────────────────┐ │ Idle │ │ generating=false │ └────────────────────┘ ``` **复位保障**:所有退出路径(实线)由 ① guard 的 Drop 兜底;异常路径(panic/abort,虚线)同样由 Drop 覆盖。② 软复位作为 newConversation 入口的二级防线。 --- ## 6. 实施计划 ### P0(用户 bug 根治组合,必须同批) | # | 项 | 文件 | 依赖 | |---|---|------|------| | B-260615-09 | ① RAII guard 收尾 | agentic.rs | 无(上游) | | B-260615-10 | ② newConversation 软复位(含④策略统一)| commands.rs:451 | 必须配 B-11 | | B-260615-11 | ③ loop 对话一致性校验 | agentic.rs:158 | B-10 配套 | **依赖关系**: - ②③ 强耦合(②放开 newConversation 触发③的竞态,单独做②会引入数据污染)→ **捆绑实施** - ① 是②③的上游根治(①修了仍需②③,因 panic 兜底不可能 100% 覆盖 OS 级 abort)→ **三者同批** ### P1 | # | 项 | 文件 | |---|---|------| | B-260615-12 | ⑤ session.state() enum 视图(轻量状态机读侧)| mod.rs:97 | | B-260615-13 | ⑥ stop 流式态兜底(复用②复位 helper)| commands.rs:261 | | B-260615-14 | ⑦ stop_flag 换 Notify 即时打断 | stream_recv.rs:148 | ### P2 | # | 项 | 文件 | |---|---|------| | B-260615-15 | ⑧ heartbeat interval 提到 loop 外 | stream_recv.rs:138 | | B-260615-16 | ⑨ MAX_AGENT_ITERATIONS 配置化 | agentic.rs:28 | | B-260615-17 | ⑩ key_len 复用 build_provider_for 结果(FR-S1 相关)| agentic.rs:67 | | B-260615-18 | ⑪ pending_approvals 单例+conv_id 路由加注释 | mod.rs:107 | --- ## 7. 验证 ### P0 验证(用户 bug 复现 + 根治) 1. **卡死复现**:构造 panic 场景(如临时在 run_agentic_loop 内 panic)→ 验证 generating 卡 true → newConversation 被拦 2. **① guard 兜底**:同上 panic 场景 → guard Drop 复位 generating → newConversation 正常 3. **② 软复位**:手动置 generating=true(模拟卡死)→ newConversation 强制复位成功新建 4. **③ 一致性**:② 软复位后旧 loop 退出 → 验证不 push 到新对话 messages ### 回归 - 正常收敛(无工具调用)→ break → guard 复位 ✅ - 审批等待 → pending 非空 → guard 不复位(设计)→ ai_approve → try_continue 续 ✅ - stop 流式态 → stop_flag → loop 退出 → guard 复位 ✅ - stop 审批态 → 直接清 pending + 复位 ✅ --- ## 8. 取舍 / 未做 | 项 | 决策 | 理由 | |----|------|------| | 独立状态机框架 | **不做** | 过度封装,轻量方案(guard+视图)功能等价 | | enum 字段替换 bool | **不做** | 改字段+所有读写点,大改;视图方法够用 | | stop_flag 转 enum | **不做** | 跨锁信号必须 AtomicBool,enum 锁内无法替代 | | panic 100% 兜底 | **不可能** | OS 级 abort(OOM kill)guard 抓不到,②软复位兜底 | | 命令黑名单/资源限制 | **不做**(run_command 安全边界)| 属 B/C/D 方案领域,A 方案靠人审 | --- ## 收口记录(2026-06-25) > 双轨状态机退役,enum 单一真相源。本文档 §3.2「轻量状态机,不引入框架」的轻量路线在批3 完成**升级**:原方案是「bool 保留 + enum 视图只读」,收口后 enum 成为写侧真相源,bool 与开关双轨全部删除。 ### 收口了什么 1. **双轨退役(写侧)**: - `generating: bool` 字段(顶层单例 + `PerConvState` 内)删除。 - `CONV_STATE_ENABLED` 灰度开关删除(批2 常量删,批3 全部门控分支删)。 - `ConvState` enum(`conv_state.rs`,5 态 Idle/Generating/Stopping/Error/Compressed)成为**唯一真相源**,所有状态写入经 `transition_to` 守卫(非法转换拒绝 `Err(InvalidTransition)`)。 - `GeneratingGuard`(guard.rs)的 new/reset/drop 改为无条件迁移 `ConvState` + emit `AiConvStateChanged`(原 `if CONV_STATE_ENABLED` 门控删)。 2. **AiCompressed 拆 Manual/Auto(治 BUG-260624-05)**: - 原 `AiCompressed` 单事件被自动压缩路径误用 → 桌面端每次发送误弹 toast + 误刷整会话。 - 拆 `AiManualCompressed`(手动 IPC,3 处 emit 点,前端弹 toast)+ `AiAutoCompressed`(loop 自动,桌面静默仅复位 `isCompressing` 防按钮卡死,miniapp 仍插摘要气泡)。 3. **can_accept_request 接入(读侧)**: - `ConvState::can_accept_request()`(Idle/Error 可接)接入 chat 域 4 处入口拦截:`ai_chat_send` / `ai_regenerate` / `ai_chat_edit` / `ai_is_generating`。 - 比旧 `is_active()` 更严谨:覆盖 Stopping 态(漏拦停止中接新请求的竞态)。 ### 为什么收口 - **双轨隐患**:bool 字段与 enum 开关共存期间,任一 guard 入口漏加门控即绕过 enum 守卫回退旧 bool 写,状态机形同虚设。开关是渐进过渡的临时灰度机制,收口即删才是终态,长期保留等于把「过渡态」固化成「架构债」。 - **AiCompressed 合流**:手动(用户主动压缩,要反馈)与自动(loop 内压缩,静默)是两种 UX 语义,共用事件必致前端按「最严」语义(弹 toast)兜底,自动路径每次都误触发。拆分是按语义而非按字段。 ### 残留过渡分支(非回归,待清理) 代码内保留若干 `CONV_STATE_ENABLED off 回退分支` 注释(agentic/mod.rs:1608/1722、chat.rs:1533、remote_bridge.rs:762、前端 types.ts/ChatInput.vue/MaxRoundsCard.vue/useAiEvents.ts),均标注「enum 单路径」,用于兼容老后端/前端未追踪态的兜底。开关常量已删,这些注释分支实际不可触发,属说明性遗留,后续清理时一并删。 --- ## 相关文档 - [df-ai AI 集成模块](../../03-模块文档/df-ai-AI集成模块-2026-06-12.md) - [aichat 审查报告-2026-06-14](../构想审查/aichat审查报告-2026-06-14.md) - [流式 Markdown 渲染调研-2026-06-15](../构想审查/aichat流式Markdown渲染调研-2026-06-15.md) - `src-tauri/src/commands/ai/agentic/conv_state.rs`(`ConvState` enum 实现源)