Files
DevFlow/docs/02-架构设计/专项设计/generating状态机加固-2026-06-15.md
绝尘 c011f864fd 重构: aichat 双轨状态机收口 + AiCompressed 事件拆分
- generating bool + CONV_STATE_ENABLED 开关双轨退役,ConvState enum 单一真相源
- can_accept_request 接入 chat 域入口(覆盖 Stopping 竞态,严谨于 is_active)
- AiCompressed 拆 AiManualCompressed/AiAutoCompressed(治自动压缩误触桌面toast+刷新)
- convStates/getConvState 下沉 aiShared.ts 破循环依赖
2026-06-25 03:20:42 +08:00

317 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<AiSession>` 内 | 是否有 agentic loop 活跃 |
| `pending_approvals` | `HashMap` | `Mutex<AiSession>` 内 | 待审批工具调用 |
| `stop_flag` | `Arc<AtomicBool>` | 跨锁共享 | 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 guardDrop 兜底复位),对应审查项 ①
- **读侧收敛** = `session.state()` enum 视图方法(只读,不改字段),对应审查项 ⑤
- **stop_flag 保留** `Arc<AtomicBool>`(跨锁信号,状态机管不了)
### 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<AtomicBool>` 独立——这是状态机 enum 字段(锁内)无法替代的并发要求。
---
## 4. 改造设计
### 4.1 ① RAII guard — 写侧收敛P0 根治)
新增 guard struct`run_agentic_loop` 入口创建,函数退出(含 panic/abort/正常 return时 Drop 兜底复位。
```rust
struct GeneratingGuard {
session: Arc<Mutex<AiSession>>,
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:518readonly 放行)形成一致心智——「新建=放弃当前生成 / 切换=只读接管」。
需加 `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; // 对话已切走,丢弃本轮,不 pushguard ① 兜底复位)
}
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/abortstop_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 | **不做** | 跨锁信号必须 AtomicBoolenum 锁内无法替代 |
| panic 100% 兜底 | **不可能** | OS 级 abortOOM killguard 抓不到,②软复位兜底 |
| 命令黑名单/资源限制 | **不做**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 实现源)