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

16 KiB
Raw Blame History

AI 生成状态机加固generating 生命周期)

创建: 2026-06-15 | 来源: /review generating 状态机专项审查 | 关联 bug: 用户报障"创建不了新对话"


1. 背景

用户报障AI 面板"创建不了新对话了",前端报 生成中无法新建对话,请先停止或等待完成,但前端实际无内容生成。

排查定位:后端 AiSession.generating 标志卡在 true,与实际无生成状态不符。ai_conversation_createcommands.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_continueagentic.rs:283should_continue = generating && !pending
  • ai_chat_stopcommands.rs:250pending非空 分流流式/审批态
  • ai_conversation_createcommands.rs:451generating 硬拦

判别逻辑散落三处,无单一真相源,新增分支易漏。


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 structrun_agentic_loop 入口创建,函数退出(含 panic/abort/正常 return时 Drop 兜底复位。

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 字段,收敛三标志组合判别:

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_continueshould_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_createcommands.rs:451硬拦改软复位。generating=true 时强制复位(等同 ai_chat_stop 审批态分支 commands.rs:252再新建

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 前校验:

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_stopcommands.rs:261流式态分支仅置 stop_flag依赖 loop 自复位。若 loop 已 panic/abortstop_flag 无人读generating 永不复位。复用 ② 抽出的复位 helper 兜底。

4.6 ⑦ stop_notify 即时打断P1可选

stop_flagAtomicBool无 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 enumconv_state.rs5 态 Idle/Generating/Stopping/Error/Compressed成为唯一真相源,所有状态写入经 transition_to 守卫(非法转换拒绝 Err(InvalidTransition))。
    • GeneratingGuardguard.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+ AiAutoCompressedloop 自动,桌面静默仅复位 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 单路径」,用于兼容老后端/前端未追踪态的兜底。开关常量已删,这些注释分支实际不可触发,属说明性遗留,后续清理时一并删。


相关文档