# 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 guard（Drop 兜底复位），对应审查项 ①
- **读侧收敛** = `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: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 实现源）
