From 5667da6cf4d256492a77a1357e571ac90445f0ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 5 Aug 2026 22:10:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA(AI=20loop=E7=AB=9E=E6=80=81=E6=A0=B9?= =?UTF-8?q?=E6=B2=BB+=E6=95=B0=E6=8D=AE/=E5=AE=A1=E6=89=B9/=E5=8F=8D?= =?UTF-8?q?=E9=A6=88/=E5=B9=B6=E5=8F=91/=E9=94=99=E8=AF=AF=E5=88=86?= =?UTF-8?q?=E7=B1=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI loop 竞态(P0):per-conv epoch/owner token + 存活心跳治 force_send 双loop + stop 3s兜底误判;旧loop stale 全跳过(guard/emit/save) agentic 收尾(A2-B8):Fatal 退出落库user消息(镜像Exhausted)+ 入口早退补save + usage is_estimated 打标 + emit_ai_completed_once 单点收敛清审批残留 聊天清理(A2-B9):clearChat 先停loop→DB单事务→内存清(clear_conversation_atomic)+ 前端错误气泡 循环并发(A2-B11):三态 ProviderAcquire(NotConfigured/Acquired/Exhausted)+ 候选循环非阻塞+防抖3次饱和降级+单测 错误分类(A2-B12):stream error帧接入 classify_status_or_class + 关键词保守降级 + 7单测 数据(G1.2/G1.4):purge_with_descendants 级联补全(11表单事务+存在性守卫)+ move_task_queue 单事务收口(两调用方共用) git只读(G3.1):run_git_status/diff/log success判定(exit_code差异语义,失败结构化{success:false,error}) 安全(G5.2/G5.6):create_project 目录Err+name校验 + module.rs 路径遍历DRY(分段匹配修a..b.rs误伤) 幂等(V2/V32):裸ALTER全守卫化 + v1..v40全链重跑幂等测试(16过) 附:remote_bridge await 临时引用修(E0716)+ agentic emit 收敛 E0716 app_state 绑定修 --- .../df-storage/src/crud/conversation_repo.rs | 68 ++++ crates/df-storage/src/crud/project_repo.rs | 77 ++++- crates/df-storage/src/crud/task_repo.rs | 221 +++++++++++- crates/df-storage/src/migrations.rs | 166 +++++++-- crates/df-storage/src/models.rs | 13 +- src-tauri/src/commands/ai/agentic/guard.rs | 56 +++- src-tauri/src/commands/ai/agentic/helpers.rs | 99 +++++- src-tauri/src/commands/ai/agentic/mod.rs | 314 ++++++++++++++++-- src-tauri/src/commands/ai/commands/chat.rs | 251 ++++++++++---- src-tauri/src/commands/ai/mod.rs | 54 +++ src-tauri/src/commands/ai/remote_bridge.rs | 141 +++++++- src-tauri/src/commands/ai/stream_recv.rs | 120 ++++++- src-tauri/src/commands/ai/tools/git.rs | 132 ++++++-- src-tauri/src/commands/project.rs | 14 +- src-tauri/src/state.rs | 8 +- src-tauri/src/state/llm_concurrency.rs | 177 ++++++++-- 16 files changed, 1696 insertions(+), 215 deletions(-) diff --git a/crates/df-storage/src/crud/conversation_repo.rs b/crates/df-storage/src/crud/conversation_repo.rs index 732d3a6..9938ef1 100644 --- a/crates/df-storage/src/crud/conversation_repo.rs +++ b/crates/df-storage/src/crud/conversation_repo.rs @@ -524,6 +524,43 @@ impl AiConversationRepo { .map_err(storage_err)? } + /// 清空对话消息内容(单事务原子:ai_conversations.messages 置 '[]' + ai_messages 表全删)。 + /// + /// A2-B9(G3.2 clearChat 裁决):原 `clear_messages` + `delete_range` 两条独立 DB 写非原子, + /// DB 失败会致 messages JSON 列与 ai_messages 表不一致(如仅一条成功)。本方法一次 transaction + /// 覆盖两条写(① UPDATE ai_conversations 置空消息 + 清零 token;② DELETE ai_messages 该 conv + /// 全部行),成功全成功 / 失败回滚全失败。供 `ai_chat_clear` 先停 loop 再单事务清空。 + /// + /// 对话壳保留(侧栏仍可见,可继续在该对话内聊);返回 Ok(())——调用方只关心成功与否 + /// (对齐 replace_conversation 语义,不返回受影响行数)。 + pub async fn clear_conversation_atomic(&self, id: &str) -> Result<()> { + let conn = self.conn.clone(); + let id = id.to_owned(); + let now = now_millis_str(); + tokio::task::spawn_blocking(move || -> Result<()> { + let mut guard = conn.blocking_lock(); + let tx = guard.transaction().map_err(storage_err)?; + { + // ① ai_conversations.messages 置空 + token 清零(对话壳保留) + tx.execute( + "UPDATE ai_conversations SET messages = '[]', prompt_tokens = 0, completion_tokens = 0, updated_at = ?1 WHERE id = ?2", + params![now, id], + ) + .map_err(storage_err)?; + // ② ai_messages 表全删(等价 delete_range min_seq=0 max=None:seq 恒 >= 0) + tx.execute( + "DELETE FROM ai_messages WHERE conversation_id = ?1", + params![id], + ) + .map_err(storage_err)?; + } + tx.commit().map_err(storage_err)?; + Ok(()) + }) + .await + .map_err(storage_err)? + } + /// 设置归档标记(仅改 archived,不动 updated_at) /// /// 区别于 update_field(后者强制 SET updated_at=now,会把归档/取消归档误判为内容更新, @@ -586,6 +623,37 @@ impl AiConversationRepo { .await .map_err(storage_err)? } + + /// G1.3: 删除对话 + 其全部 ai_messages 子行(单事务原子)。 + /// + /// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 ai_messages 表无外键级联 + /// (conversation_id 仅普通索引),子行孤儿累积。本方法在同一事务内**先删子行 + /// (ai_messages)再删主行(ai_conversations)**,要么全删要么全不删。 + /// + /// 顺序注意:先删数据再摘内存(命令层 per_conv.remove 在其后),防后台在途 + /// save_conversation 在删主行后把孤儿消息写回复活。与 save_conversation 共享同一 + /// conn(Mutex),事务原子性保证删除期间无中间态(半删半留)。 + pub async fn delete_with_messages(&self, id: &str) -> Result { + let conn = self.conn.clone(); + let id = id.to_owned(); + tokio::task::spawn_blocking(move || { + let mut guard = conn.blocking_lock(); + let tx = guard.transaction().map_err(storage_err)?; + // 先删子行(ai_messages)再删主行(ai_conversations),单事务原子 + tx.execute( + "DELETE FROM ai_messages WHERE conversation_id = ?1", + params![id], + ) + .map_err(storage_err)?; + let conv_affected = tx + .execute("DELETE FROM ai_conversations WHERE id = ?1", params![id]) + .map_err(storage_err)?; + tx.commit().map_err(storage_err)?; + Ok(conv_affected > 0) + }) + .await + .map_err(storage_err)? + } } // ============================================================ diff --git a/crates/df-storage/src/crud/project_repo.rs b/crates/df-storage/src/crud/project_repo.rs index 7dd3e9c..79d7a4b 100644 --- a/crates/df-storage/src/crud/project_repo.rs +++ b/crates/df-storage/src/crud/project_repo.rs @@ -414,22 +414,85 @@ impl ProjectRepo { .map_err(storage_err)? } - /// 彻底删除:事务级联删 branches→releases→tasks→projects(不可恢复) + /// 彻底删除:事务级联删全部关联子表→projects(不可恢复) /// /// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束, /// 故应用层级联:子表先于父表删,单事务保证一致性。 + /// + /// **级联范围**(G1.2 补全):V1-V35 全部 `REFERENCES projects(id)` 表—— + /// branches / releases / tasks / workflow_executions(V2) + 知识图谱 V29-V35 新增 + /// task_links / project_events / project_services / project_modules / module_dependencies, + /// 以及经它们间接到项目的 node_executions(REFERENCES workflow_executions)/ + /// module_dependencies(REFERENCES project_modules)。此前只删四表,带 modules/事件/服务的 + /// 工程 purge 在 foreign_keys=ON 下必 FK 违例回滚(latent bug)。 + /// + /// **删除顺序 = FK 拓扑 最深子表→父**(先删被引用方,防 foreign_keys=ON 下 FK 违例): + /// node_executions → task_links → branches → module_dependencies → workflow_executions + /// → tasks → project_events → project_services → project_modules → releases → projects。 + /// + /// 表存在性守卫:逐表先查 `sqlite_master` 存在才 DELETE(防老库缺 V29-V35 新表时 + /// `no such table` 中断整个事务回滚)。 pub async fn purge_with_descendants(&self, id: &str) -> Result { let conn = self.conn.clone(); let id = id.to_owned(); tokio::task::spawn_blocking(move || { let mut guard = conn.blocking_lock(); let tx = guard.transaction().map_err(storage_err)?; - tx.execute("DELETE FROM branches WHERE project_id = ?1", params![id]) - .map_err(storage_err)?; - tx.execute("DELETE FROM releases WHERE project_id = ?1", params![id]) - .map_err(storage_err)?; - tx.execute("DELETE FROM tasks WHERE project_id = ?1", params![id]) - .map_err(storage_err)?; + + // (表名, 删除 SQL)。表名/SQL 均为编译期常量,无注入风险。 + // node_executions / task_links 无 project_id 列,经其父表子查询收敛到本项目; + // workflow_executions 兼删 task_id 命中(project_id 可空,补齐 task_id 关联路径)。 + const CASCADE: &[(&str, &str)] = &[ + // node_executions REFERENCES workflow_executions(id):先于 workflow_executions 删 + ( + "node_executions", + "DELETE FROM node_executions WHERE workflow_id IN \ + (SELECT id FROM workflow_executions WHERE project_id = ?1 \ + OR task_id IN (SELECT id FROM tasks WHERE project_id = ?1))", + ), + // task_links REFERENCES tasks(id):先于 tasks 删(source/target 任一端属本项目) + ( + "task_links", + "DELETE FROM task_links WHERE source_id IN \ + (SELECT id FROM tasks WHERE project_id = ?1) OR target_id IN \ + (SELECT id FROM tasks WHERE project_id = ?1)", + ), + // branches REFERENCES tasks(id) + projects(id):先于 tasks 删 + ("branches", "DELETE FROM branches WHERE project_id = ?1"), + // module_dependencies REFERENCES project_modules(id) + projects(id):先于 project_modules 删 + ("module_dependencies", "DELETE FROM module_dependencies WHERE project_id = ?1"), + // workflow_executions REFERENCES projects(id)(可空,兼删 task_id 关联) + ( + "workflow_executions", + "DELETE FROM workflow_executions WHERE project_id = ?1 \ + OR task_id IN (SELECT id FROM tasks WHERE project_id = ?1)", + ), + // tasks REFERENCES projects(id)(自引用 parent_id 同语句删,单语句末校验不违例) + ("tasks", "DELETE FROM tasks WHERE project_id = ?1"), + // 以下直接 REFERENCES projects(id),无子表依赖,顺序任意 + ("project_events", "DELETE FROM project_events WHERE project_id = ?1"), + ("project_services", "DELETE FROM project_services WHERE project_id = ?1"), + ("project_modules", "DELETE FROM project_modules WHERE project_id = ?1"), + ("releases", "DELETE FROM releases WHERE project_id = ?1"), + ]; + + for (table, sql) in CASCADE { + // 表存在性守卫:老库可能缺 V29-V35 新表,缺表时 DELETE 报 no such table + // 中断事务回滚;探测存在才删,防老库 purge 失败。 + let exists: bool = tx + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + params![*table], + |_| Ok(true), + ) + .optional() + .map_err(storage_err)? + .unwrap_or(false); + if exists { + tx.execute(sql, params![id]).map_err(storage_err)?; + } + } + let affected = tx .execute("DELETE FROM projects WHERE id = ?1", params![id]) .map_err(storage_err)?; diff --git a/crates/df-storage/src/crud/task_repo.rs b/crates/df-storage/src/crud/task_repo.rs index cd2230e..fdd7af1 100644 --- a/crates/df-storage/src/crud/task_repo.rs +++ b/crates/df-storage/src/crud/task_repo.rs @@ -567,7 +567,8 @@ impl TaskRepo { /// /// 防护: /// - 方法名 `set_status_for_aggregation` 显式表明语义,非通用 setter,防误用。 - /// - 调用方(commands::task::recompute_parent_status)负责聚合规则计算,本方法只落库。 + /// - 调用方(df-nodes task_advance_node::recompute_parent_status,2026-08-04 下沉共享层)负责 + /// 聚合规则计算,本方法只落库。 /// - 不动 review_rounds(父任务不执行工作流,无 review 退回语义)。 /// /// 返回是否命中(父任务不存在/已删 → false)。 @@ -591,6 +592,120 @@ impl TaskRepo { .map_err(storage_err)? } + /// 跨池移动任务(单事务原子:读当前 → 一致性联动 status → 写 queue + status)。 + /// + /// 整合原 IPC 层(src-tauri commands::task::move_task_queue)与 AI 工具 + /// (src-tauri commands::ai::tools::task_graph)各自的两段式 + /// (`update_field` queue + `set_status_for_aggregation` status)为**单一 repo 方法**: + /// 一次 `blocking_lock` 内 get + update queue + update status 于**同一 transaction**, + /// 杜绝「queue 已改、status 未改」的非原子中间态(G1.4)。两调用方共用本方法防漂移。 + /// + /// **不能 naive 在 tx 内复用既有 repo 方法**(各自内部 `blocking_lock`,std Mutex + /// 非重入必死锁),故本方法用裸 SQL 在事务内完成全部读写。 + /// + /// 一致性约束联动(设计 §2.1): + /// - queue=done → status 强制=done(池完成即任务完成) + /// - queue=backlog → status 强制=todo(需求池任务尚未开始) + /// - queue=active → status 若不在 {in_progress,in_review,testing} 则强制=in_progress + /// - queue=todo → status 非 todo 则强制=todo(待办池任务尚未开始) + /// - queue=decision → status 不变(待决策池保留执行态,暂停推进不重置) + /// + /// 父任务(容器模型)也可 move_task_queue(其 status 由聚合规则 recompute_parent_status + /// 在子任务推进时重算,本方法仅满足一致性约束联动,不动 review_rounds)。 + /// + /// - queue 白名单校验(bad_queue 防进 `_ => unreachable!` match)收口在方法内; + /// - `deleted_at IS NULL` 收口:软删回收站任务不可 move(与 set_status_for_aggregation + /// 语义一致,返回 None); + /// - 返回:更新后的 TaskRecord(Some);任务不存在/已软删 → None(调用方据此报「任务不存在」)。 + pub async fn move_task_queue( + &self, + id: &str, + new_queue: &str, + ) -> Result> { + // queue 白名单校验(对标 commands::task::validate_queue,防非法值进 match unreachable)。 + // 常量与联动规则集中在 Repo 层,commands/task.rs 与 ai/tools/task_graph.rs 两调用方 + // 共用同一方法(防漂移),不再各自实现。 + const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"]; + const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"]; + if !TASK_QUEUE_VALUES.contains(&new_queue) { + return Err(df_types::error::Error::Validation(format!( + "非法 queue 值 {:?},合法值: {:?}", + new_queue, TASK_QUEUE_VALUES + ))); + } + + let conn = self.conn.clone(); + let id = id.to_owned(); + let new_queue = new_queue.to_owned(); + let now = now_millis_str(); + // 显式列出全部 18 列(同 from_row 消费列,不 SELECT deleted_at: + // TaskRecord 不带该字段,取了 from_row 会因未知列报错)。 + const TASK_COLS: &str = "id, project_id, title, description, status, priority, branch_name, \ + assignee, workflow_def_id, base_branch, review_rounds, output_json, \ + idea_id, queue, parent_id, content_json, created_at, updated_at"; + tokio::task::spawn_blocking(move || { + let mut guard = conn.blocking_lock(); + let tx = guard.transaction().map_err(storage_err)?; + + // 1. 读当前(取 status 做一致性联动决策;deleted_at IS NULL 收口软删任务不可 move)。 + let current: Option = { + let mut stmt = tx + .prepare(&format!( + "SELECT {TASK_COLS} FROM tasks WHERE id = ?1 AND deleted_at IS NULL" + )) + .map_err(storage_err)?; + stmt.query_row(params![id], |row| task_from_row(row)) + .optional() + .map_err(storage_err)? + }; + let Some(current) = current else { + return Ok(None); + }; + + // 2. 一致性联动:根据 new_queue 决定 status 是否需调整(设计 §2.1)。 + let new_status = match new_queue.as_str() { + "done" => "done".to_string(), + "backlog" => "todo".to_string(), + "active" => { + if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) { + current.status.as_str().to_string() // 已在执行中三态,保留 + } else { + "in_progress".to_string() // 否则强制进 in_progress + } + } + "todo" => "todo".to_string(), + "decision" => current.status.as_str().to_string(), // 保留执行态 + _ => unreachable!("queue 白名单已收口"), + }; + + // 3. 同一事务内写 queue + status(与 current 不同才写,避免无谓 updated_at 抖动)。 + let queue_changed = current.queue != new_queue; + let status_changed = current.status.as_str() != new_status; + if queue_changed || status_changed { + tx.execute( + "UPDATE tasks SET queue = ?1, status = ?2, updated_at = ?3 WHERE id = ?4", + params![new_queue, new_status, now, id], + ) + .map_err(storage_err)?; + } + + // 4. 回读最新记录返回。 + let updated: Option = { + let mut stmt = tx + .prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE id = ?1")) + .map_err(storage_err)?; + stmt.query_row(params![id], |row| task_from_row(row)) + .optional() + .map_err(storage_err)? + }; + + tx.commit().map_err(storage_err)?; + Ok(updated) + }) + .await + .map_err(storage_err)? + } + /// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。 /// /// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id), @@ -866,4 +981,108 @@ mod tests { let ok = repo.set_status_for_aggregation("ghost", "done").await.unwrap(); assert!(!ok, "不存在的任务应返回 false"); } + + // ============================================================ + // move_task_queue 单事务跨池移动(G1.4:读当前 → 联动 status → 写 queue+status 原子) + // ============================================================ + + /// 移动后断言 queue/status 双落地(单事务原子,非两段式独立写)。 + #[tokio::test] + async fn move_task_queue_done_sets_queue_and_status_atomically() { + let repo = setup().await; + repo.insert(trec_full("t1", "active", None, TaskStatus::InProgress)) + .await + .unwrap(); + let updated = repo.move_task_queue("t1", "done").await.unwrap().unwrap(); + assert_eq!(updated.queue, "done", "queue 应改为 done"); + assert_eq!(updated.status.as_str(), "done", "status 应联动强制 done"); + } + + #[tokio::test] + async fn move_task_queue_backlog_forces_todo_status() { + let repo = setup().await; + repo.insert(trec_full("t1", "active", None, TaskStatus::InProgress)) + .await + .unwrap(); + let updated = repo.move_task_queue("t1", "backlog").await.unwrap().unwrap(); + assert_eq!(updated.queue, "backlog"); + assert_eq!(updated.status.as_str(), "todo", "backlog 池 status 强制 todo"); + } + + #[tokio::test] + async fn move_task_queue_active_preserves_executing_status() { + // active 池:已在执行中三态(in_progress)则保留,不重置 + let repo = setup().await; + repo.insert(trec_full("t1", "backlog", None, TaskStatus::InProgress)) + .await + .unwrap(); + let updated = repo.move_task_queue("t1", "active").await.unwrap().unwrap(); + assert_eq!(updated.queue, "active"); + assert_eq!(updated.status.as_str(), "in_progress", "active 池保留执行中三态"); + } + + #[tokio::test] + async fn move_task_queue_active_forces_in_progress_when_idle() { + // active 池:非执行中三态(todo)→ 强制 in_progress + let repo = setup().await; + repo.insert(trec_full("t1", "todo", None, TaskStatus::Todo)) + .await + .unwrap(); + let updated = repo.move_task_queue("t1", "active").await.unwrap().unwrap(); + assert_eq!(updated.queue, "active"); + assert_eq!(updated.status.as_str(), "in_progress", "非执行态进 active 强制 in_progress"); + } + + #[tokio::test] + async fn move_task_queue_decision_preserves_status() { + // decision 池保留当前执行态(不重置) + let repo = setup().await; + repo.insert(trec_full("t1", "active", None, TaskStatus::InReview)) + .await + .unwrap(); + let updated = repo.move_task_queue("t1", "decision").await.unwrap().unwrap(); + assert_eq!(updated.queue, "decision"); + assert_eq!(updated.status.as_str(), "in_review", "decision 池保留执行态"); + } + + #[tokio::test] + async fn move_task_queue_soft_deleted_returns_none() { + // 软删回收站任务不可 move(deleted_at IS NULL 收口,与 set_status_for_aggregation 一致) + let repo = setup().await; + repo.insert(trec_full("t1", "todo", None, TaskStatus::Todo)) + .await + .unwrap(); + repo.soft_delete("t1").await.unwrap(); + let res = repo.move_task_queue("t1", "done").await.unwrap(); + assert!(res.is_none(), "软删任务 move 应返回 None"); + } + + #[tokio::test] + async fn move_task_queue_nonexistent_returns_none() { + let repo = setup().await; + let res = repo.move_task_queue("ghost", "done").await.unwrap(); + assert!(res.is_none(), "不存在的任务 move 应返回 None"); + } + + #[tokio::test] + async fn move_task_queue_invalid_queue_rejected() { + let repo = setup().await; + repo.insert(trec_full("t1", "todo", None, TaskStatus::Todo)) + .await + .unwrap(); + let err = repo.move_task_queue("t1", "bogus").await.unwrap_err(); + assert!(matches!(err, df_types::error::Error::Validation(_)), "非法 queue 应拒绝"); + } + + #[tokio::test] + async fn move_task_queue_same_queue_noop() { + // 同池 no-op:queue/status 均不变,updated_at 不抖动(updated 记录仍正常返回) + let repo = setup().await; + repo.insert(trec_full("t1", "todo", None, TaskStatus::Todo)) + .await + .unwrap(); + let updated = repo.move_task_queue("t1", "todo").await.unwrap().unwrap(); + assert_eq!(updated.queue, "todo"); + assert_eq!(updated.status.as_str(), "todo"); + } } diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index ef495b5..2c36cb0 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> { // 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。 // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照, // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 - let steps: [(i32, fn(&Connection) -> Result<()>); 39] = [ + let steps: [(i32, fn(&Connection) -> Result<()>); 40] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -85,6 +85,7 @@ pub fn run(conn: &Connection) -> Result<()> { (37, migrate_v37), (38, migrate_v38), (39, migrate_v39), + (40, migrate_v40), ]; for (version, migrate_fn) in steps { @@ -105,8 +106,21 @@ fn migrate_v1(conn: &Connection) -> Result<()> { } /// V2: 补齐关联字段 + branches 表 +/// +/// 幂等化(2026-08-05):V2_SQL 原含 7 个裸 ALTER,全链重跑 duplicate column。 +/// 拆分:CREATE TABLE branches(IF NOT EXISTS 幂等,execute_batch 跑); +/// 7 个 ALTER 改逐列 column_exists 守卫(对齐 v4+ 幂等模式)。 fn migrate_v2(conn: &Connection) -> Result<()> { + // branches 等 CREATE TABLE IF NOT EXISTS 幂等,execute_batch 跑 conn.execute_batch(V2_SQL)?; + // ideas: 晋升关联 + AI 分析 + 多维评分 + for (table, col) in [("ideas", "promoted_to"), ("ideas", "ai_analysis"), ("ideas", "scores"), + ("tasks", "workflow_def_id"), ("tasks", "base_branch"), + ("workflow_executions", "project_id"), ("workflow_executions", "task_id")] { + if !column_exists(conn, table, col) { + conn.execute(&format!("ALTER TABLE {table} ADD COLUMN {col} TEXT"), [])?; + } + } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [2])?; tracing::info!("迁移 v2 完成"); Ok(()) @@ -917,10 +931,16 @@ fn migrate_v31(conn: &Connection) -> Result<()> { /// 对话目标由 PerConvState.pinned_goals(Vec)管理,原先仅在内存态存在, /// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。 fn migrate_v32(conn: &Connection) -> Result<()> { - conn.execute_batch( - "ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';" - )?; - tracing::info!("v32: ai_conversations 加 pinned_goals 列"); + // G5.5: 幂等守卫(column_exists 探测,同 v4/v20/v33 模式)——列已存在跳过 ALTER。 + // 防存量库崩溃重跑/版本号回退后重跑 migrate_v32 报 duplicate column name。 + if !column_exists(conn, "ai_conversations", "pinned_goals") { + conn.execute_batch( + "ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';" + )?; + tracing::info!("v32: ai_conversations 加 pinned_goals 列"); + } else { + tracing::info!("v32: pinned_goals 列已存在,跳过"); + } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [32])?; tracing::info!("迁移 v32 完成"); Ok(()) @@ -974,6 +994,9 @@ fn migrate_v33(conn: &Connection) -> Result<()> { /// 单仓库项目退化:项目下只有一个工程(path = 绑定目录)。 /// /// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。 +/// +/// 注:description / status 列由 V40 追加(老库 V34 时无),新库由 V40 ALTER +/// 补建(因 V34 首次建表已注册版本号,新库只跑 V34 一次)。两边列定义须一致。 fn migrate_v34(conn: &Connection) -> Result<()> { conn.execute( "CREATE TABLE IF NOT EXISTS project_modules ( @@ -1184,6 +1207,29 @@ fn migrate_v39(conn: &Connection) -> Result<()> { Ok(()) } +/// V40: project_modules 表加 description / status 列(工程实体化,补全描述身份) +/// +/// 解「工程仅有目录地址、无描述身份」:Monorepo 下前端工程/后端服务/基础设施 +/// 各有职责与状态,原表只存 name/path/git_url/stack,失去真实场景表达。 +/// - description:工程职责描述(如"前端 web 工程""后端 API 服务"),nullable 老工程兼容。 +/// - status:工程状态(active/archived),nullable,默认 active(老工程回读时 None 由 +/// 应用层归一为 active——见 ProjectModuleRecord 注释)。 +/// +/// 列可空,向前兼容:V34 前无此列的老库 ALTER 后旧行 NULL,前端/应用层视为未填写。 +fn migrate_v40(conn: &Connection) -> Result<()> { + if !column_exists(conn, "project_modules", "description") { + conn.execute("ALTER TABLE project_modules ADD COLUMN description TEXT", [])?; + tracing::info!("v40: project_modules 加 description 列"); + } + if !column_exists(conn, "project_modules", "status") { + conn.execute("ALTER TABLE project_modules ADD COLUMN status TEXT", [])?; + tracing::info!("v40: project_modules 加 status 列"); + } + conn.execute("INSERT INTO schema_version (version) VALUES (?)", [40])?; + tracing::info!("迁移 v40 完成: project_modules 加 description/status 列(工程实体化)"); + Ok(()) +} + /// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 @@ -1329,21 +1375,9 @@ CREATE INDEX IF NOT EXISTS idx_node_executions_workflow_id ON node_executions(wo /// V2 迁移 SQL — 补齐数据层断裂字段 /// -/// 注意: SQLite 的 ALTER TABLE ADD COLUMN 一条语句只能加一列。 +/// 幂等化(2026-08-05):原含 7 个裸 ALTER(ideas/tasks/workflow 加列),全链重跑 duplicate column。 +/// ALTER 已移至 migrate_v2 逐列 column_exists 守卫,此处仅保留 branches CREATE + 索引(IF NOT EXISTS 幂等)。 const V2_SQL: &str = " --- 想法表: 晋升关联 + AI 分析 + 多维评分 -ALTER TABLE ideas ADD COLUMN promoted_to TEXT; -ALTER TABLE ideas ADD COLUMN ai_analysis TEXT; -ALTER TABLE ideas ADD COLUMN scores TEXT; - --- 任务表: 工作流定义关联 + 基础分支 -ALTER TABLE tasks ADD COLUMN workflow_def_id TEXT; -ALTER TABLE tasks ADD COLUMN base_branch TEXT; - --- 工作流执行表: 项目 / 任务关联 -ALTER TABLE workflow_executions ADD COLUMN project_id TEXT; -ALTER TABLE workflow_executions ADD COLUMN task_id TEXT; - -- 分支表 — 任务与 Git 分支绑定(核心功能) CREATE TABLE IF NOT EXISTS branches ( id TEXT PRIMARY KEY, @@ -1881,7 +1915,7 @@ mod tests { } // ============================================================ - // 全量迁移测试 — 新库从零跑完整 V1-V37 路径 + // 全量迁移测试 — 新库从零跑完整 V1-V40 路径 // ------------------------------------------------------------ // 目的:某 migrate_vN 的 SQL 手滑写错(列名/类型/缺索引/缺表)只能等运行时暴露, // 此测试一次性覆盖全部迁移路径。任何一条迁移 SQL 写错、列名拼错、缺建表 @@ -1905,12 +1939,12 @@ mod tests { ); } - /// 全量迁移:新库从零跑完 V1-V37,验证关键表齐全 + 列数 > 0 + 关键列存在。 + /// 全量迁移:新库从零跑完 V1-V40,验证关键表齐全 + 列数 > 0 + 关键列存在。 /// /// 覆盖至少:task / ai_conversations / ai_messages / ai_tool_executions / /// conversation_checkpoints / ai_providers / projects / ideas。 /// 抽查关键列:ai_providers.enabled/weight、conversation_checkpoints.snapshot、 - /// tasks.idea_id(这些列由不同 vN 加,任一漏加此处失败)。 + /// tasks.idea_id、project_modules.description/status(这些列由不同 vN 加,任一漏加此处失败)。 #[tokio::test] async fn test_full_migration_on_fresh_db() { // 用 Database::open_in_memory 打开新库,内部自动跑 migrations::run() 全量迁移 @@ -1956,7 +1990,7 @@ mod tests { "tasks.idea_id 列缺失(V1 建表已带)" ); - // 3. schema_version 应推进到 37(全量迁移成功落版本号) + // 3. schema_version 应推进到 40(全量迁移成功落版本号) let max_version: i64 = conn .query_row( "SELECT COALESCE(MAX(version), 0) FROM schema_version", @@ -1965,9 +1999,91 @@ mod tests { ) .expect("查 schema_version 应成功"); assert_eq!( - max_version, 37, - "全量迁移后 schema_version 应为 37(实际 {}),说明某条 migrate_vN 链路断在中间", + max_version, 40, + "全量迁移后 schema_version 应为 40(实际 {}),说明某条 migrate_vN 链路断在中间", max_version ); + + // 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑) + assert!( + column_exists(&conn, "project_modules", "description"), + "project_modules.description 列缺失(V40 加)" + ); + assert!( + column_exists(&conn, "project_modules", "status"), + "project_modules.status 列缺失(V40 加)" + ); + } + + // ============================================================ + // G5.5: v32 幂等守卫 + 全链幂等不变量 + // ------------------------------------------------------------ + // 背景:migrate_v32 原为裸 ALTER(唯一漏网),崩溃重跑/版本号回退后重跑会报 + // duplicate column name。修后应幂等;再加全链重跑不变量防止未来新 vN 引入裸 ALTER。 + // ============================================================ + + /// G5.5: v32 幂等守卫——pinned_goals 列已存在时重跑不报 duplicate column。 + /// 构造最小 ai_conversations 表(无 pinned_goals 列)模拟老库,验证补列 + 重跑短路。 + #[test] + fn v32_idempotent_column_guard() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); + CREATE TABLE ai_conversations (id TEXT PRIMARY KEY);", + ) + .expect("create tables"); + assert!( + !column_exists(&conn, "ai_conversations", "pinned_goals"), + "迁移前应无 pinned_goals 列" + ); + + migrate_v32(&conn).expect("首次迁移应补 pinned_goals 列"); + assert!(column_exists(&conn, "ai_conversations", "pinned_goals")); + + // 回退版本号模拟「列已存在但版本号缺失」崩溃重跑场景 → ALTER 应被守卫短路, + // 仅补版本号,不报 duplicate column name。 + conn.execute("DELETE FROM schema_version WHERE version = 32", []) + .unwrap(); + migrate_v32(&conn).expect("列已存在时重跑应幂等不报错"); + + let v_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM schema_version WHERE version = 32", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(v_count, 1, "版本号 32 应只写一次"); + } + + /// G5.5: 全链幂等不变量——V1-V40 每步执行两遍不抛错。 + /// + /// 首轮 run() 建全 schema;清空 schema_version 强制下一轮从 V1 重跑每步 + /// (模拟存量库 + 崩溃重跑/版本号回退)。任何 migrate_vN 的裸 ALTER(无 column_exists + /// 守卫,如 v32 修前形态)都会在第二遍报 duplicate column 被此测试捕获。 + #[test] + fn v1_to_v40_full_chain_rerun_idempotent() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + run(&conn).expect("首轮全量迁移应成功"); + let max_v: i64 = conn + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM schema_version", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(max_v, 40, "首轮应推进到 40"); + + // 清空版本表强制全链第二遍(每步 execute 第二次) + conn.execute("DELETE FROM schema_version", []).unwrap(); + run(&conn).expect("全链第二遍不抛错(幂等不变量)"); + let max_v2: i64 = conn + .query_row( + "SELECT COALESCE(MAX(version), 0) FROM schema_version", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(max_v2, 40, "重跑后应重新推进到 40"); } } diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index 5087c04..9a28a68 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -480,8 +480,13 @@ pub struct KnowledgeEventRecord { /// 工程记录(project_modules 表,项目多工程,每个工程独立代码仓库) /// /// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。 -/// 每个工程有独立的目录(`path`)、Git 地址(`git_url`)、技术栈(`stack`)。 +/// 每个工程有独立的目录(`path`)、Git 地址(`git_url`)、技术栈(`stack`)、 +/// 职责描述(`description`)、状态(`status`)。 /// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。 +/// +/// 字段可空性说明: +/// - `description` / `status`:V40 加列,老工程(V34 前创建)为 None。 +/// 应用层/前端回读时 `status` 视 None 为 `active`(默认活跃),`description` 视空串。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectModuleRecord { pub id: String, @@ -494,6 +499,12 @@ pub struct ProjectModuleRecord { pub sort_order: i32, pub created_at: String, pub updated_at: String, + /// 工程职责描述(如"前端 web 工程""后端 API 服务")。V40 加列,老工程 None。 + #[serde(default)] + pub description: Option, + /// 工程状态:active(活跃)/ archived(归档)。V40 加列,老工程 None 视为 active。 + #[serde(default)] + pub status: Option, } /// 工程依赖关系记录(V35 module_dependencies 表)。 diff --git a/src-tauri/src/commands/ai/agentic/guard.rs b/src-tauri/src/commands/ai/agentic/guard.rs index 064ba83..471a96e 100644 --- a/src-tauri/src/commands/ai/agentic/guard.rs +++ b/src-tauri/src/commands/ai/agentic/guard.rs @@ -2,6 +2,14 @@ //! //! B-Phase2: ConvState 切无锁 ConvStateStore(不竞争 session lock,删 800ms fallback)。 //! 治卡死连环:AiCompleted 延迟 / 工具后中断 / 第二条进队列同源根因。 +//! +//! F1 并发 epoch 加固(2026-08-05):guard 持 `loop_epoch` Arc + 本 loop 捕获的 `my_epoch`, +//! `reset()` / `Drop` 只在本 loop 仍是 owner(epoch 匹配)时迁移 ConvState→Idle。 +//! 旧 loop(被 force_send/新 loop 接管,epoch 已变)的复位**跳过**——否则旧 loop 收尾会 +//! 把新 loop 的 Generating 复位成 Idle,致新 loop 状态机被破坏(同 conv 双 loop 竞态 F1)。 + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; @@ -14,17 +22,32 @@ use super::conv_state::ConvState; /// ConvState 经无锁 [`ConvStateStore`](super::conv_state::ConvStateStore) 迁移(不竞争 session /// lock)。正常退出显式 `reset()`;异常退出 `Drop` 兜底迁移 Idle。`disarm` 解除 Drop 兜底 /// (审批等待保持 Generating,续跑新 guard.new 重新迁移)。 +/// +/// epoch 语义:`loop_epoch` 是 per_conv 的并发 owner token(每 spawn 新 loop 前 fetch_add(1)); +/// `my_epoch` 是本 guard 对应 loop 捕获的 epoch。复位只认 owner(epoch 匹配), +/// 旧 loop 复位跳过(防 clobber 新 loop 状态)。 pub(super) struct GeneratingGuard { conv_id: String, done: bool, + /// per_conv.loop_epoch 引用(锁无关原子读,owner 判定用)。 + loop_epoch: Arc, + /// 本 loop 捕获的 epoch。 + my_epoch: u64, app_handle: AppHandle, } impl GeneratingGuard { - pub(super) fn new(conv_id: String, app_handle: AppHandle) -> Self { + pub(super) fn new( + conv_id: String, + loop_epoch: Arc, + my_epoch: u64, + app_handle: AppHandle, + ) -> Self { let guard = Self { conv_id, done: false, + loop_epoch, + my_epoch, app_handle: app_handle.clone(), }; guard.transition(ConvState::Generating); @@ -32,10 +55,23 @@ impl GeneratingGuard { } /// 复位 Idle(emit 前调,保证前端收事件时后端已 Idle)。幂等。 + /// + /// 只认 owner:本 loop epoch 匹配当前 loop_epoch 才迁移 Idle; + /// 不匹配(旧 loop)则仅置 done(跳过迁移,防 clobber 新 loop 的 Generating)。 pub(super) async fn reset(&mut self) { - if !self.done { - self.done = true; + if self.done { + return; + } + self.done = true; + if self.loop_epoch.load(Ordering::SeqCst) == self.my_epoch { self.transition(ConvState::Idle); + } else { + tracing::debug!( + conv_id = %self.conv_id, + my_epoch = self.my_epoch, + current_epoch = self.loop_epoch.load(Ordering::SeqCst), + "[ai] 旧 loop guard reset 跳过(已被新 loop 接管,不复位新 Generating)" + ); } } @@ -78,8 +114,20 @@ impl GeneratingGuard { impl Drop for GeneratingGuard { fn drop(&mut self) { - if !self.done { + if self.done { + return; + } + // 只认 owner:epoch 匹配才 Drop 兜底复位 Idle;旧 loop(epoch 已变)跳过, + // 避免 panic/异常退出时把新 loop 的 Generating clobber 成 Idle。 + if self.loop_epoch.load(Ordering::SeqCst) == self.my_epoch { self.transition(ConvState::Idle); + } else { + tracing::debug!( + conv_id = %self.conv_id, + my_epoch = self.my_epoch, + current_epoch = self.loop_epoch.load(Ordering::SeqCst), + "[ai] 旧 loop guard Drop 跳过(已被新 loop 接管,不复位新 Generating)" + ); } } } diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index fa36db2..1b02547 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -62,6 +62,8 @@ pub async fn try_continue_agent_loop( let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, + // 零 token 收敛信号,非估算(无真实 LLM 调用)。 + is_estimated: false, incomplete: None, conversation_id: Some(emit_conv_id), pinned_goals: snap.pinned_goals_snapshot.clone(), @@ -113,6 +115,8 @@ pub async fn try_continue_agent_loop( let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, + // 零 token 收敛信号,非估算(无真实 LLM 调用)。 + is_estimated: false, incomplete: None, conversation_id: Some(conv_id_owned.clone()), pinned_goals: snap.pinned_goals_snapshot.clone(), @@ -129,8 +133,15 @@ pub async fn try_continue_agent_loop( round: 0, conversation_id: Some(conv_id_owned.clone()), }); + // F1 并发 epoch:续跑也是新 loop 生命周期,递增 owner token(旧 loop 已 return/disarm)。 + // conv_id_owned 是 spawn 前 clone,此处仍可在 scope 内借用 state 锁。 + let loop_epoch = { + let mut session = state.ai_session.lock().await; + session.conv(&conv_id_owned).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1 + }; + tauri::async_runtime::spawn(async move { - super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await; + super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch).await; }); } @@ -320,6 +331,30 @@ pub(crate) fn is_repetitive_exploration(signatures: &[String]) -> bool { counts.values().any(|&c| c >= REPETITION_SINGLE_MAX) } +/// 纯问候判定:纯社交短文本(你好/谢谢/在吗等)→ true。机制层治弱模型把问候当指令 +/// 擅自调工具(会话 b4d6b4e0:用户"你好"→ list_project_modules + 探索源码)。 +/// 规则:去空白后 ≤8 字,且不含动作词/实体引用(@[)。"你好,看看 moyu 项目"含动作词+超长 → false。 +pub(crate) fn is_pure_greeting(msg: &str) -> bool { + // 问候/社交短文本词表(命中任一即可;长度约束兜底) + const GREETINGS: &[&str] = &[ + "你好", "hello", "hi", "哈喽", "嗨", "在吗", "谢谢", "感谢", "嗯", + "好的", "ok", "没问题", "辛苦", "拜拜", "再见", "👋", "你好呀", + ]; + // 动作/请求意图词:出现任一即非纯问候(放行工具) + const ACTION_WORDS: &[&str] = &[ + "看", "查", "帮", "创建", "新增", "修改", "更新", "删除", "分析", + "读取", "写", "执行", "运行", "测试", "构建", "推进", "检查", "搜索", + "列出", "绑定", "如何", "怎么", "为什么", "什么是", "有哪些", "怎么办", + ]; + let t = msg.trim(); + if t.is_empty() { return true; } + if t.chars().count() > 8 { return false; } + if t.contains("@[") { return false; } + let lower = t.to_lowercase(); + if !GREETINGS.iter().any(|g| lower.contains(g)) { return false; } + !ACTION_WORDS.iter().any(|a| lower.contains(a)) +} + #[cfg(test)] mod tests { use super::*; @@ -398,15 +433,15 @@ mod tests { tool_call_signature("grep", &serde_json::json!({"pattern": "MAX", "path": "src"})), tool_call_signature("grep", &serde_json::json!({"pattern": "truncate", "path": "src"})), tool_call_signature("grep", &serde_json::json!({"pattern": "fetch_url", "path": "src"})), - tool_call_signature("grep", &serde_json!({"pattern": "MessageRole", "path": "crates"})), - tool_call_signature("grep", &serde_json!({"pattern": "pub enum", "path": "src"})), + tool_call_signature("grep", &serde_json::json!({"pattern": "MessageRole", "path": "crates"})), + tool_call_signature("grep", &serde_json::json!({"pattern": "pub enum", "path": "src"})), tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50})), - tool_call_signature("read_file", &serde_json!({"path": "b.rs", "offset": 0, "limit": 50})), - tool_call_signature("read_file", &serde_json!({"path": "c.rs", "offset": 0, "limit": 50})), - tool_call_signature("read_file", &serde_json!({"path": "d.rs", "offset": 100, "limit": 50})), - tool_call_signature("list_dir", &serde_json!({"path": "src-tauri"})), - tool_call_signature("grep", &serde_json!({"pattern": "STALL", "path": "src"})), - tool_call_signature("read_file", &serde_json!({"path": "e.rs", "offset": 0, "limit": 50})), + tool_call_signature("read_file", &serde_json::json!({"path": "b.rs", "offset": 0, "limit": 50})), + tool_call_signature("read_file", &serde_json::json!({"path": "c.rs", "offset": 0, "limit": 50})), + tool_call_signature("read_file", &serde_json::json!({"path": "d.rs", "offset": 100, "limit": 50})), + tool_call_signature("list_dir", &serde_json::json!({"path": "src-tauri"})), + tool_call_signature("grep", &serde_json::json!({"pattern": "STALL", "path": "src"})), + tool_call_signature("read_file", &serde_json::json!({"path": "e.rs", "offset": 0, "limit": 50})), ]; assert_eq!(sigs.len(), 12); assert!(!is_repetitive_exploration(&sigs), "正常代码审查不应判重复"); @@ -461,7 +496,7 @@ mod tests { fn boundary_two_alternating_below_ratio() { let a = tool_call_signature("grep", &serde_json::json!({"pattern": "a", "path": "x"})); let b = tool_call_signature("grep", &serde_json::json!({"pattern": "b", "path": "x"})); - let sigs = vec![a, b.clone(), a.clone(), b.clone(), a, b]; + let sigs = vec![a.clone(), b.clone(), a.clone(), b.clone(), a, b]; assert!(is_repetitive_exploration(&sigs), "两签名交替唯一率低应判重复"); } @@ -474,4 +509,48 @@ mod tests { .collect(); assert!(!is_repetitive_exploration(&sigs)); } + + // ── is_pure_greeting 纯问候判定测试 ── + + #[test] + fn greeting_pure_hello_is_greeting() { + assert!(is_pure_greeting("你好")); + } + + #[test] + fn greeting_thanks_is_greeting() { + assert!(is_pure_greeting("谢谢")); + } + + #[test] + fn greeting_with_action_is_not_greeting() { + // 含动作词"看"+ 超 8 字 → 放行工具 + assert!(!is_pure_greeting("你好,看看 moyu 项目")); + } + + #[test] + fn greeting_view_tasks_is_not_greeting() { + assert!(!is_pure_greeting("查看任务")); + } + + #[test] + fn greeting_entity_ref_is_not_greeting() { + assert!(!is_pure_greeting("@[项目]")); + } + + #[test] + fn greeting_empty_string_is_greeting() { + assert!(is_pure_greeting("")); + assert!(is_pure_greeting(" ")); + } + + #[test] + fn greeting_help_query_is_not_greeting() { + assert!(!is_pure_greeting("你好,帮我查询项目")); + } + + #[test] + fn greeting_ok_is_greeting() { + assert!(is_pure_greeting("ok")); + } } diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index abbff30..51d4f6b 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -1,5 +1,6 @@ //! Agentic 循环 — 流式接收 → 工具执行 → 结果回传 LLM → 循环 +use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -21,7 +22,7 @@ use df_ai::context_helpers::{ // B 路线 Phase 1:plan_hint 接入主 loop——filter_tool_defs_planned 在 filter_tool_defs // 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到 // 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。 -use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, suggested_model_tier, IntentRecognizer}; +use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, suggested_model_tier, IntentRecognizer, Intent}; use df_ai::coordinator::{Coordinator, ExecutionResult}; use df_ai::persona::PersonaRegistry; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; @@ -40,7 +41,7 @@ use df_ai::router::{ use df_storage::db::Database; use df_storage::models::AiProviderRecord; -use crate::state::{AppState, LlmConcurrency}; +use crate::state::{AppState, LlmConcurrency, ProviderAcquire}; use super::audit::process_tool_calls; // compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。 @@ -79,6 +80,16 @@ pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10; /// partial_text)不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。 pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3; +/// 防瞬时抖动(G4.1,2026-08-05):主 provider per-provider 信号量连续饱和阈值。 +/// +/// 候选循环内 `try_acquire_for_provider` 返 `Exhausted` 时该 provider 连续饱和计数 +1 +/// (跨迭代累计);`Acquired` 时清零(已恢复)。链首(当前主 provider)连续 N 次饱和 +/// → 候选链构建时降级到链尾,让 fallback 先试。 +/// +/// **动机**:单次瞬时饱和(其他对话标题/压缩短暂占满)不应让主 provider 被长期跳过。 +/// N=3 对齐 DEFAULT_MAX_AGENT_RETRIES 量级,3 轮连续饱和才判持续过载,防抖动误降级。 +pub const PROVIDER_SATURATION_DEMOTE_THRESHOLD: usize = 3; + /// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs /// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。 /// @@ -176,6 +187,10 @@ pub const TOPIC_MARKER_GOAL_AWARE: bool = true; /// 单点回退)。与 CIRCUIT_BREAKER_ENABLED 独立。 pub const STALL_BREAKER_ENABLED: bool = true; +/// 纯问候工具门(默认 true):纯问候/社交短文本消息 → LLM 可见工具置空(tools=None), +/// 模型只能纯文本回复。false(回退)→ 走既有 intent 过滤(可能回全量),排障/对比用。 +pub const PURE_GREETING_TOOL_GATE: bool = true; + /// G2 连续重复阈值(默认 3)。 /// /// 对齐 CIRCUIT_BREAKER_THRESHOLD=3:连续 3 轮判定签名重复足以判漂移。新范式(签名重复)比 @@ -297,7 +312,7 @@ pub mod command_lock; pub(crate) mod helpers; pub(crate) use helpers::try_continue_agent_loop; pub(crate) use helpers::infer_goal_from_tool_calls; -pub(crate) use helpers::{tool_call_signature, is_repetitive_exploration}; +pub(crate) use helpers::{tool_call_signature, is_repetitive_exploration, is_pure_greeting}; // ============================================================ // 单 Provider 流式结果 + fallback 辅助 @@ -578,19 +593,55 @@ pub(crate) async fn run_agentic_loop( max_retries: usize, start_iteration: usize, model_override: Option, + // F1 并发 epoch(2026-08-05):调用方 spawn 前 `per_conv.loop_epoch.fetch_add(1)` 后传入, + // loop 捕获自己的 epoch。退出 / Guard 复位 / emit 只认 owner(epoch 匹配),否则是旧 loop。 + loop_epoch: u64, ) { - // generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底) - let mut guard = GeneratingGuard::new(conv_id.clone(), app_handle.clone()); + // F1 并发 epoch + F2 存活心跳:一次锁内取 per_conv 关键 Arc + 建 guard。 + // guard.new 同步(transition 经 ConvStateStore 无 await),可在此锁内调。 + // conv_read 不惰性建(与旧 stop_flag/notify 取用段一致):conv 已删(per_conv 无条目) + // → 旧 loop 直接退出(ConvStateStore 条目应已被 conversation_delete 同步清)。 + let (stop_flag, notify, loop_epoch_arc, last_heartbeat, mut guard) = { + let session = session_arc.lock().await; + let Some(conv) = session.conv_read(&conv_id) else { + tracing::warn!(stale_conv = %conv_id, "[ai] loop 入口 conv 已删,退出"); + return; + }; + ( + conv.stop_flag.clone(), + conv.notify.clone(), + conv.loop_epoch.clone(), + conv.last_heartbeat.clone(), + GeneratingGuard::new(conv_id.clone(), conv.loop_epoch.clone(), loop_epoch, app_handle.clone()), + ) + }; + let my_epoch = loop_epoch; + + // 入口即报活(loop 存活证据)+ 陈旧检查:spawn 后已被新 loop 接管(epoch 已变)→ 立即退出。 + last_heartbeat.store(df_types::now_millis() as u64, Ordering::SeqCst); + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + tracing::info!( + conv_id = %conv_id, + my_epoch, + current_epoch = loop_epoch_arc.load(Ordering::SeqCst), + "[ai] loop 入口即被新 loop 接管(epoch 不匹配),退出" + ); + guard.disarm(); + return; + } // 治本:LLM 生成间隙向前端发 AiHeartbeat,防前端 watchdog 误断流。 // 后端 emit AiHeartbeat → useAiEvents.ts resetStreamWatchdog(convId) → per-conv timer 重置。 // 解决:LLM 重试循环(~60s)期间无事件到达前端 → watchdog 45s/90s 到期误杀。 // shutdown_tx 在函数作用域结束(Drop)时通知 heartbeat 任务退出。 + // F2(2026-08-05):heartbeat_loop 同时更新 per_conv.last_heartbeat(loop 存活证据), + // 供 ai_chat_stop 兜底判断 loop 是否真死(长工具执行/流式/压缩期间心跳持续 → 豁免不误迁 Idle)。 let (_heartbeat_tx, mut heartbeat_rx) = tokio::sync::watch::channel(()); let hb_app = app_handle.clone(); let hb_conv_id = conv_id.clone(); + let hb_heartbeat = last_heartbeat.clone(); tokio::spawn(async move { - heartbeat_loop(&mut heartbeat_rx, &hb_app, &hb_conv_id).await; + heartbeat_loop(&mut heartbeat_rx, &hb_app, &hb_conv_id, &hb_heartbeat).await; }); // 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。 @@ -685,6 +736,13 @@ pub(crate) async fn run_agentic_loop( Ok(Ok((pc, cands, prov, kl))) => (pc, cands, prov, kl), Ok(Err(ProviderResolveError::EnsureKeyFailed(msg))) => { guard.reset().await; + // F1:旧 loop 不 emit(被新 loop 接管时,错误由 owner loop 负责)。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + return; + } + // G1.1(2026-08-05):入口早退也落库 user 消息(调用方已 push 内存),防重启/切会话丢末条 user。 + // resolved_model 此处尚未算出,model 传 None(仅持久化 user 消息,无 assistant 消息可标)。 + save_conversation(&session_arc, &db, &conv_id, None, None, true).await; let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { error: msg.clone(), // ensure_resolved_key 失败 = key 缺失/钥匙串损坏,归 Auth @@ -704,6 +762,12 @@ pub(crate) async fn run_agentic_loop( // L1 补丁:provider 解析 30s 超时(DB list_all / keyring resolve 卡死)。 // 走 AiError 分支复位 generating,对齐 ensure_resolved_key 失败处理口径。 guard.reset().await; + // F1:旧 loop 不 emit(被新 loop 接管时,错误由 owner loop 负责)。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + return; + } + // G1.1(2026-08-05):入口超时早退也落库 user 消息(同上,model None)。 + save_conversation(&session_arc, &db, &conv_id, None, None, true).await; tracing::error!(conv_id = %conv_id, "[ai] provider 解析超时(30s),可能 DB/keyring 卡死"); let err_msg = "Provider 解析超时(30s),请检查数据库/钥匙串状态后重试".to_string(); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { @@ -733,6 +797,13 @@ pub(crate) async fn run_agentic_loop( "[ai] 发起 LLM 请求" ); + // F1 并发 epoch:provider 解析(30s 窗口)期间被新 loop 接管 → 立即退出, + // 不执行 pre-loop 的 plan/coordinator 等冗余工作。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + guard.disarm(); + return; + } + // 主对话路由 — TaskRequirements(needs_tool_use=true)。 // 模态当前仅 Text(图像消息类型未实现,后续多模态接入时检测 Part/Image 追加 Vision)。 // select_model_id None(池空/无匹配)→ 兜底 default_model,行为与接入前一致。 @@ -790,6 +861,11 @@ pub(crate) async fn run_agentic_loop( .unwrap_or_default() }; let (intent, conf) = IntentRecognizer::recognize(&user_text); + // 纯问候护栏:纯社交短文本 → 工具全隐藏(tools=None),治弱模型把问候当指令擅自调工具 + // (会话 b4d6b4e0 实证:"你好"→list_project_modules("DevFlow")+探索源码)。 + // 机制非说教:空 tool_defs → provider tools=None → 模型物理上只能纯文本回复。 + // 仅 Chat 意图 + is_pure_greeting 双条件(含动作词/实体引用放行,如"你好,看看moyu"正常暴露工具)。 + let pure_greeting = PURE_GREETING_TOOL_GATE && matches!(intent, Intent::Chat) && is_pure_greeting(&user_text); const INTENT_CONF_THRESHOLD: f32 = 0.7; let all_defs = tools_arc.tool_definitions(); let total = all_defs.len(); // 提前记录全量数(all_defs 将 move 进 tool_defs) @@ -807,7 +883,9 @@ pub(crate) async fn run_agentic_loop( // Phase0a 就绪 true)与 PLANNING_ENABLED(planner.rs 主 loop 规划开关,本批仍是 false) // 分离:即使将来 PLANNING_ENABLED 翻 true,plan_hint 内部 PLAN_HINT_ENABLED 关闭时 // filter_tool_defs_planned 仍退扁平(双层开关,任一关闭均退旧行为)。 - let tool_defs = if conf >= INTENT_CONF_THRESHOLD { + let tool_defs: Vec = if pure_greeting { + Vec::new() // 纯问候 → 空工具集(tools=None,强制纯文本回复) + } else if conf >= INTENT_CONF_THRESHOLD { let filtered = if df_ai::planner::PLANNING_ENABLED { // Phase 1:plan_hint 编排排序。intent_label 供 plan_hint 备用(当前规则纯关键词驱动)。 filter_tool_defs_planned(&all_defs, &intent, intent.as_str(), &user_text) @@ -832,21 +910,8 @@ pub(crate) async fn run_agentic_loop( planning_enabled = df_ai::planner::PLANNING_ENABLED, "[ai] 意图收敛工具" ); - // 停止信号副本:stream_llm 与每轮迭代共享读取,避免重复加锁 - // notify 同取一份 Arc 引用:stream_llm select! 监听 notified() 即时唤醒。 - // 取 per_conv 的 stop_flag/notify。 - // conv_id 来源:run_agentic_loop 入参(loop 启动快照,与 guard 一致)。 - let (stop_flag, notify) = { - let session = session_arc.lock().await; - // panic-guard:原裸 .expect 在 conv 已删(并发删除/状态竞态)时 panic, - // guard 不 reset/终态不发/registry 不清致永久卡。改为 None 显式退出(对齐同函数其他 return 点; - // guard Drop 兜底复位 generating)。 - let Some(conv) = session.conv_read(&conv_id) else { - tracing::warn!(stale_conv = %conv_id, "[ai] loop 入口 conv 已删,退出"); - return; - }; - (conv.stop_flag.clone(), conv.notify.clone()) - }; + // 停止信号副本 / notify / loop_epoch / last_heartbeat 已在 loop 入口(L593)一次锁内取用 + // (stop_flag/notify 供 stream_llm 与每轮迭代共享,loop_epoch 供 owner 判定,last_heartbeat 供存活心跳)。 // token 累加器:loop 生命周期内各轮叠加,退出时传 save_conversation(累加模式落库) let mut tokens = TokenAccumulator::default(); @@ -873,6 +938,12 @@ pub(crate) async fn run_agentic_loop( // DeepSeek thinking 模式推理内容跨轮透传 let mut last_reasoning_content: Option = None; + // G4.3(2026-08-05):记录本轮 token 用量是否估算值(round_usage.prompt_tokens==0 → + // estimated_prompt 兜底处打标)。loop 内各轮更新,退出路径(MidStream/stop 等在 loop 内 + // 直接用 round_usage 判;max_iterations/正常完成在 loop 后用本字段)透传 AiCompleted 仅展示, + // 不参与 TokenAccumulator/save 累加。 + let mut last_round_estimated: bool = false; + // G1 目标钉扎:入口把 PerConvState.pinned_goals 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。 // // 治 R1(目标消息被压缩出局)/R5(prompt 说教无锚点):system_prompt 是 loop 不变量 + build_for_request @@ -1156,9 +1227,12 @@ pub(crate) async fn run_agentic_loop( &provider_config, &llm_concurrency, &mut guard, &coord_usage, + // Coordinator 路径无 round(未 stream),无估算语义 → false。 + false, None, None, true, &pinned_goals_snapshot, &app_handle, + &loop_epoch_arc, my_epoch, ).await; return; } @@ -1176,11 +1250,18 @@ pub(crate) async fn run_agentic_loop( // retry 同 loop 内,持 per_conv 合理。 let _conv_per_conv_permit = llm_concurrency.acquire_per_conv(&conv_id).await; + // 防瞬时抖动(G4.1):per-provider 连续饱和计数(跨迭代累计)。 + // 候选循环内 Exhausted +1 / Acquired 清零;链首(主 provider)连续 + // PROVIDER_SATURATION_DEMOTE_THRESHOLD 次饱和 → 候选链构建时降级到链尾(见链构建处)。 + // 仅 per-provider 层非阻塞降级,不影响 global/per_conv 阻塞限流。 + let mut provider_saturation: HashMap = HashMap::new(); + // 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发) let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations }; for iteration in start_iteration..effective_max { - // 用户请求停止 → 收尾退出(已生成文本已在上一轮入库) - if stop_flag.load(Ordering::SeqCst) { + // 用户请求停止 或 已被新 loop 接管(stale)→ 收尾退出(已生成文本已在上一轮入库)。 + // F1:stale 判定(epoch 不匹配)让旧 loop 在新 loop 启动后立即在此退出,不再继续跑。 + if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { let usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), @@ -1191,6 +1272,7 @@ pub(crate) async fn run_agentic_loop( }; // 入口 stop:本轮可能尚未 stream(首轮即停),不记 model——避免把未实际生成的 model 写入 models 数组 // 统一走 finish_round_exit:save(Some usage, None model) + spawn_title + emit(None,None,publish=true) + // (stale 时 finish_round_exit 内部仅 disarm 跳过 save/reset/emit,不干扰新 loop) finish_round_exit( &session_arc, &db, &conv_id, Some(&usage), None, @@ -1198,9 +1280,12 @@ pub(crate) async fn run_agentic_loop( &provider_config, &llm_concurrency, &mut guard, &usage, + // 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。 + last_round_estimated, None, None, true, &pinned_goals_snapshot, &app_handle, + &loop_epoch_arc, my_epoch, ).await; return; } @@ -1293,6 +1378,13 @@ pub(crate) async fn run_agentic_loop( // per_conv.iteration_used 唯一真相源。 let conv = session.conv(&conv_id); conv.iteration_used = iteration + 1; + // F1 并发 epoch:每轮顶部 stale 检查(防旧 loop 在 topic/compress 等长段期间被接管后继续跑)。 + // F2 存活心跳:每轮更新(与 heartbeat_loop 20s 心跳互补,报活更即时)。 + if conv.loop_epoch.load(Ordering::SeqCst) != my_epoch { + guard.disarm(); + return; + } + conv.last_heartbeat.store(df_types::now_millis() as u64, Ordering::SeqCst); } // 新一轮通知前端(第二轮起),前端需新建 assistant 消息 @@ -1463,6 +1555,30 @@ pub(crate) async fn run_agentic_loop( let mut candidate_chain: Vec = Vec::with_capacity(1 + candidates.len()); candidate_chain.push(provider_config.clone()); candidate_chain.extend(candidates.iter().cloned()); + + // 防瞬时抖动(G4.1):连续 PROVIDER_SATURATION_DEMOTE_THRESHOLD 次饱和才把主 provider + // 降级到链尾。计数器在候选循环内维护(Exhausted +1 / Acquired 清零),跨迭代累计。 + // 链首(当前主 provider)连续 N 次 Exhausted → 移到链尾让 fallback 先试; + // 恢复后 Acquired 清零 → 下轮回到链首。单次/瞬时饱和(N 次内)不降级,防跳过主 provider。 + // first():链构建总是 push 至少 1 个(provider_config),但防御性取首防空链 panic。 + if let Some(streak) = candidate_chain + .first() + .and_then(|c| provider_saturation.get(&c.id)) + .copied() + .filter(|s| *s >= PROVIDER_SATURATION_DEMOTE_THRESHOLD) + { + let saturated = candidate_chain.remove(0); + candidate_chain.push(saturated.clone()); + tracing::info!( + conv_id = %conv_id, + provider = %saturated.name, + streak = streak, + threshold = PROVIDER_SATURATION_DEMOTE_THRESHOLD, + "[ai] 主 provider 连续 {} 次 per-provider 饱和,降级到候选链尾(防抖动跳过)", + streak, + ); + } + let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content) = { // outcome 累积成功结果(Complete/Partial),InitFailed 不写入。 let mut outcome: Option<(String, std::collections::HashMap, df_ai::provider::TokenUsage, bool, Option)> = None; @@ -1472,10 +1588,33 @@ pub(crate) async fn run_agentic_loop( let mut last_exhausted_error: Option = None; 'candidate: for candidate in &candidate_chain { - // per-provider permit(可选):set_provider_caps 未配置时返回 None(单 provider 零变化); - // 配置后取额外 permit 防单 provider 被打满(限流 429)。切换 candidate 时上一 permit - // 随 _provider_permit 绑定作用域 Drop 释放。 - let _provider_permit = llm_concurrency.acquire_for_provider(&candidate.id).await; + // per-provider permit(非阻塞三态,G4.1): + // NotConfigured(未配置,无限流)→ 无 permit 直接 proceed(单 provider 零变化); + // Acquired(permit)→ 持 permit stream,切换 candidate 时随作用域 Drop 释放; + // Exhausted(占满)→ 连续饱和计数 +1,跳下一 candidate(降级,不阻塞)。 + // 旧 acquire_for_provider(阻塞 acquire_owned)在主 provider 信号量占满时整条 + // fallback 链卡死(降级失效),已由本三态非阻塞版本取代。 + let _provider_permit = match llm_concurrency.try_acquire_for_provider(&candidate.id).await { + ProviderAcquire::NotConfigured => None, + ProviderAcquire::Acquired(permit) => { + // 成功拿到 permit:该 provider 已恢复,连续饱和计数清零(跨迭代防抖)。 + provider_saturation.insert(candidate.id.clone(), 0); + Some(permit) + } + ProviderAcquire::Exhausted => { + // 占满:连续饱和计数 +1;本次跳下一 candidate(非阻塞,不等待)。 + // 跨迭代防抖:连续 N 次饱和后链首降级(见候选链构建处),防瞬时抖动跳过主 provider。 + *provider_saturation.entry(candidate.id.clone()).or_insert(0) += 1; + tracing::debug!( + conv_id = %conv_id, + provider = %candidate.name, + streak = provider_saturation.get(&candidate.id).copied().unwrap_or(0), + "[ai] 候选 {} per-provider 信号量占满,跳过(非阻塞,防单 provider 429)", + candidate.name, + ); + continue 'candidate; + } + }; let stream_outcome = stream_one_provider( candidate, @@ -1522,7 +1661,15 @@ pub(crate) async fn run_agentic_loop( // Fatal(4xx 非429/鉴权/参数错):立即放弃整轮 fallback。 // 统一 emit 最终错误气泡(单气泡聚合)。 guard.reset().await; + // F1:旧 loop(被新 loop 接管)不 emit 错误气泡(owner loop 负责呈现)。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + return; + } emit_fatal_error(&app_handle, &conv_id, &error).await; + // G1.1(2026-08-05):Fatal 退出也落库 user 消息(镜像下方 Exhausted 分支), + // 否则重启/切会话从 DB 恢复时末条 user 丢失(与 Exhausted 同根因不对称)。 + // epoch 校验通过后、return 前补 save(owner loop 才写,防 owner 已转移仍写)。 + save_conversation(&session_arc, &db, &conv_id, None, Some(&resolved_model), true).await; return; } } @@ -1538,6 +1685,9 @@ pub(crate) async fn run_agentic_loop( "[ai] 全 provider 候选流前失败重试耗尽,放弃本轮", ); guard.reset().await; + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + return; + } let err_msg = last_exhausted_error .unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string()); emit_fatal_error(&app_handle, &conv_id, &err_msg).await; @@ -1549,6 +1699,17 @@ pub(crate) async fn run_agentic_loop( } }; + // G4.3:本轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated_prompt 兜底), + // 供 loop 内/loop 后各退出路径透传 AiCompleted(is_estimated)仅作展示标注。 + last_round_estimated = round_usage.prompt_tokens == 0; + + // F1 并发 epoch:流返回后若已被新 loop 接管(force_send 等),旧 loop 不再 push 消息 / + // 保文 / 执行工具,立即退出(guard disarm 跳过复位,防 clobber 新 loop 的 Generating)。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + guard.disarm(); + return; + } + // CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收, // 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。 // 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。 @@ -1580,9 +1741,11 @@ pub(crate) async fn run_agentic_loop( ); return; } + // 与 push_assistant_message 一致:trim 归一化,纯空白保文(网络中断只有空白)不落库 + let full_text = full_text.trim(); if !full_text.is_empty() { let conv = session.conv(&conv_id); - let mut msg = ChatMessage::assistant(&full_text); + let mut msg = ChatMessage::assistant(full_text); msg.model = Some(resolved_model.clone()); // MidStream 保文也回填 reasoning_content msg.reasoning_content = round_reasoning_content.clone(); @@ -1622,9 +1785,11 @@ pub(crate) async fn run_agentic_loop( &provider_config, &llm_concurrency, &mut guard, &emit_usage, + round_usage.prompt_tokens == 0, Some(true), None, true, &pinned_goals_snapshot, &app_handle, + &loop_epoch_arc, my_epoch, ).await; return; } @@ -1689,8 +1854,9 @@ pub(crate) async fn run_agentic_loop( save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; } - // 停止信号:已生成文本入库后退出,不再执行后续工具调用 - if stop_flag.load(Ordering::SeqCst) { + // 停止信号 或 已被新 loop 接管(stale):已生成文本入库后退出,不再执行后续工具调用。 + // F1:stale 时旧 loop 在此退出,不执行工具(防重复工具执行);finish_round_exit 内部跳过 emit。 + if stop_flag.load(Ordering::SeqCst) || loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { let usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), @@ -1707,9 +1873,11 @@ pub(crate) async fn run_agentic_loop( &provider_config, &llm_concurrency, &mut guard, &usage, + round_usage.prompt_tokens == 0, None, None, true, &pinned_goals_snapshot, &app_handle, + &loop_epoch_arc, my_epoch, ).await; return; } @@ -1729,6 +1897,13 @@ pub(crate) async fn run_agentic_loop( "[AI-DIRAUTH-DIAG] agentic loop 收到 pending" ); + // F1 并发 epoch:工具执行期间被新 loop 接管(force_send)后立即退出, + // 不熔断/不停审批/不进下一轮(避免旧 loop 继续污染新 loop 会话)。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + guard.disarm(); + return; + } + // L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。 // count_recent_failures 读末尾连续 Tool 消息,失败包按结构化信号归一 key 入滚动窗口计数。 if CIRCUIT_BREAKER_ENABLED { @@ -1742,6 +1917,10 @@ pub(crate) async fn run_agentic_loop( "[ai] L1 断路器熔断:连续同类失败 {} 次,疑似死循环停止", max_count ); guard.reset().await; + // F1:旧 loop 不 emit 熔断求助卡(owner loop 负责呈现)。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + return; + } emit_circuit_breaker_tripped(&app_handle, &conv_id, max_count, &sample_key); return; } @@ -1764,6 +1943,11 @@ pub(crate) async fn run_agentic_loop( // 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复 if pending_count > 0 { + // F1:旧 loop 被接管后不停审批(force_send 已清 pending),直接退出。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + guard.disarm(); + return; + } let usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), @@ -1813,14 +1997,22 @@ pub(crate) async fn run_agentic_loop( &provider_config, &llm_concurrency, &mut guard, &usage, + last_round_estimated, Some(true), None, false, &pinned_goals_snapshot, &app_handle, + &loop_epoch_arc, my_epoch, ).await; return; } // 正常完成 + // F1 并发 epoch:正常完成前若已被新 loop 接管,不 spawn 后台 save/标题/提炼、不 reset/emit + // (owner loop 负责收尾),直接退出。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + guard.disarm(); + return; + } let usage = df_ai::provider::TokenUsage { prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), @@ -1853,6 +2045,12 @@ pub(crate) async fn run_agentic_loop( }); } + // F1 并发 epoch:emit 前 final stale 检查(防上方后台 spawn 块期间被 force_send 接管后仍 emit + // 重复 AiCompleted)。guard.reset 本身 epoch-aware(stale 跳过迁移),此处主要防 emit。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + guard.disarm(); + return; + } guard.reset().await; // generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成 // (正常完成路径 save+extract+title 已在上方 spawn 异步,此处仅 emit,故直接调 emit_ai_completed_once) @@ -1865,8 +2063,13 @@ pub(crate) async fn run_agentic_loop( prompt_cache_miss_tokens: tokens.cache_miss(), reasoning_tokens: tokens.reasoning(), }; + // F1 并发 epoch:emit 前最后一次 stale 检查(guard.reset 已幂等,此处防 emit 重复 AiCompleted)。 + if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch { + return; + } emit_ai_completed_once( &app_handle, &conv_id, &normal_usage, + last_round_estimated, None, None, true, &pinned_goals_snapshot, ).await; @@ -1874,15 +2077,19 @@ pub(crate) async fn run_agentic_loop( // ── heartbeat_loop: 后台心跳任务(emit AiHeartbeat 防前端 watchdog 误杀) ── // 退出条件:heartbeat_rx 发送端 Drop(function 作用域结束)。每 20s emit 一次。 +// F2(2026-08-05):同时更新 per_conv.last_heartbeat(loop 存活证据)——loop 全程(含流式/工具 +// 执行/压缩/provider 解析)每 20s 刷新,供 ai_chat_stop 兜底判断 loop 是否真死。 async fn heartbeat_loop( heartbeat_rx: &mut tokio::sync::watch::Receiver<()>, app: &AppHandle, conv_id: &str, + last_heartbeat: &std::sync::Arc, ) { loop { tokio::select! { _ = heartbeat_rx.changed() => break, _ = tokio::time::sleep(std::time::Duration::from_secs(20)) => { + last_heartbeat.store(df_types::now_millis() as u64, std::sync::atomic::Ordering::SeqCst); let _ = app.emit( "ai-chat-event", AiChatEvent::AiHeartbeat { conversation_id: Some(conv_id.to_string()) }, @@ -2295,16 +2502,33 @@ async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) { // usage 字段(对齐 run_agentic_loop 各退出点用法):emit 端的 token 三元组从 `usage` 取; // prompt/completion 与 usage 一致(各退出点原样从 tokens 或 round-derived usage 传入)。 // +// is_estimated(G4.3, 2026-08-05):本轮 token 用量是否估算值(round_usage.prompt_tokens==0 → +// estimated_prompt 处打标)。透传前端仅作展示标注,不参与 TokenAccumulator/save 累加。 +// +// G2.2(2026-08-05):本 helper 是 run_agentic_loop 所有正常出口的统一 emit 点—— +// AiCompleted 发出时同步清该 conv 的审批残留(终态化占位 + 移除 pending_approvals), +// 防占位 tool_result 残留 messages 下次发送喂给 LLM。仅清目标 conv(retain 保留其他 conv)。 +// // pinned_goals:前端直接读取刷新(G1 目标钉扎),原样透传快照(不 clone,借用调用方)。 async fn emit_ai_completed_once( app_handle: &AppHandle, conv_id: &str, usage: &df_ai::provider::TokenUsage, + is_estimated: bool, emit_incomplete: Option, publish_incomplete: Option, do_publish: bool, pinned_goals: &[super::GoalEntry], ) { + // G2.2:清审批残留收敛此处(所有正常出口都经本 helper 单点 emit)。锁内纯写改短 lock 段。 + { + // app_handle.state() 返回临时引用,直接 .ai_session.lock() 借用临时值 → E0716 悬垂。 + // 先绑定 state(守卫整块生命周期),再经 DerefMut 方法调用。 + let app_state = app_handle.state::(); + let mut session_guard = app_state.ai_session.lock().await; + super::commands::chat::finalize_pending_placeholders(&mut *session_guard, conv_id, "生成完成"); + session_guard.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(conv_id)); + } // emit 端(前端通道):incomplete 用 emit_incomplete(MidStream 传 Some(true))。 // token 分项(2026-08-02):cache_hit/cache_miss/reasoning 透传前端分计费展示。 let _ = app_handle.emit( @@ -2316,6 +2540,7 @@ async fn emit_ai_completed_once( prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens, prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens, reasoning_tokens: usage.reasoning_tokens, + is_estimated, incomplete: emit_incomplete, conversation_id: Some(conv_id.to_string()), pinned_goals: pinned_goals.to_vec(), @@ -2332,6 +2557,7 @@ async fn emit_ai_completed_once( prompt_cache_hit_tokens: usage.prompt_cache_hit_tokens, prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens, reasoning_tokens: usage.reasoning_tokens, + is_estimated, incomplete: publish_incomplete, conversation_id: Some(conv_id.to_string()), pinned_goals: pinned_goals.to_vec(), @@ -2359,8 +2585,11 @@ async fn emit_ai_completed_once( // 参数口径(对齐各退出点原代码): // - save_usage/save_model:透传 save_conversation;Coordinator 传 (None, None) 仍会调 save(幂等) // - emit_usage:emit AiCompleted 的 token 三元组来源(各退出点经 tokens.add/round-derived 后传入) +// - emit_estimated(G4.3, 2026-08-05):本轮 token 用量是否估算值,透传 emit_ai_completed_once 仅展示 // - spawn_title:true → spawn_ensure_title(provider_config 后台生成,失败有 extract_title 兜底) // - emit_incomplete/publish_incomplete/do_publish:透传 emit_ai_completed_once(见该 helper 文档) +// - loop_epoch_arc/my_epoch:F1 并发 epoch——旧 loop(epoch 不匹配)只 disarm 跳过 save/reset/emit, +// 不干扰新 loop 收尾(防「双完成 + 双 AiCompleted + guard 互踩」)。 async fn finish_round_exit( session_arc: &Arc>, db: &Arc, @@ -2372,12 +2601,27 @@ async fn finish_round_exit( llm_concurrency: &LlmConcurrency, guard: &mut GeneratingGuard, emit_usage: &df_ai::provider::TokenUsage, + emit_estimated: bool, emit_incomplete: Option, publish_incomplete: Option, do_publish: bool, pinned_goals: &[super::GoalEntry], app_handle: &AppHandle, + loop_epoch_arc: &std::sync::Arc, + my_epoch: u64, ) { + // F1 并发 epoch 收尾守卫:旧 loop(被 force_send/新 loop 接管,epoch 不匹配)不 save/reset/emit, + // 只 disarm guard 退出——owner loop 负责收尾,防「双完成 + 双 AiCompleted + guard 互踩」。 + if loop_epoch_arc.load(std::sync::atomic::Ordering::SeqCst) != my_epoch { + tracing::debug!( + conv_id = %conv_id, + my_epoch, + current_epoch = loop_epoch_arc.load(std::sync::atomic::Ordering::SeqCst), + "[ai] 旧 loop 收尾跳过(已被新 loop 接管,不 save/reset/emit)" + ); + guard.disarm(); + return; + } // 落库:save_conversation 幂等(每轮重复覆盖落库),Coordinator 传 (None,None) 也调用(对齐原 :1126) save_conversation(session_arc, db, conv_id, save_usage, save_model, true).await; // 标题生成后台化(不阻塞 Completed emit):Coordinator / max_iterations 不 spawn(对齐原行为) @@ -2388,7 +2632,7 @@ async fn finish_round_exit( guard.reset().await; // generating 复位后再 emit Completed(对齐原各路径顺序) emit_ai_completed_once( - app_handle, conv_id, emit_usage, + app_handle, conv_id, emit_usage, emit_estimated, emit_incomplete, publish_incomplete, do_publish, pinned_goals, ).await; @@ -2415,6 +2659,12 @@ fn push_assistant_message( cache_miss: u32, reasoning: u32, ) { + // 根治「空工具轮 assistant 消息落库」:LLM 仅返回 tool_calls 无文本时 full_text 可能为 + // 空/纯空白(assistant("")/assistant("\n") 均合法落库),前端渲染空气泡。入口统一 trim: + // - 纯空白 → content 归并为 ""(非 whitespace),前端 !content 判断可命中;有 tool_calls 仍 push(工具卡有效) + // - 纯空白且无 tool_calls → 下方 !full_text.is_empty() 为 false,跳过 push(空轮无任何信息不落库) + // - 真实文本仅去首尾空白,不影响内容 + let full_text = full_text.trim(); if has_tool_calls { let mut order: Vec = tool_calls_acc.keys().copied().collect(); order.sort_unstable(); diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index e3d7094..f3b67b6 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -309,6 +309,17 @@ pub async fn ai_regenerate( // 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖) save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await; + // 每会话独立模型(用户诉求:多会话并发各自维护 + 切换不干扰)。 + // model_override 持久于 per_conv(上方 :268 已写): + // - 本次带 override → per_conv 已更新,读回传 loop(本次生效) + // - 本次不带 override → 读 per_conv 已有值(上次 set 的模型继续生效 = 会话记住自己的模型) + // - 全新会话(per_conv 刚建) → None → 落回 select_model_id 路由 + // 天然 per-conv 隔离(per_conv keyed by conv_id),切模型只改本会话,不碰其他会话。 + let conv_model_override = { + let mut session = state.ai_session.lock().await; + session.conv(&conv_id).model_override.clone() + }; + let session_arc = state.ai_session.clone(); let tools_arc = state.ai_tools.clone(); let db = state.db.clone(); @@ -319,9 +330,15 @@ pub async fn ai_regenerate( let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); // F-260616-07: 流式失败重试次数快照 let max_retries = state.agent_max_retries.load(Ordering::SeqCst); + // F1 并发 epoch(F-260616-09 决策 e 真并发上线):新 loop 的 owner token。 + // 每 spawn 前 fetch_add(1) 递增,旧 loop 即刻失效(退出/Guard 复位/emit 只认自己 epoch)。 + let loop_epoch = { + let mut session = state.ai_session.lock().await; + session.conv(&conv_id).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1 + }; tauri::async_runtime::spawn(async move { - run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, model_override).await; + run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; }); Ok("ok".to_string()) @@ -482,6 +499,15 @@ pub async fn ai_chat_send( system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; } + // 每会话独立模型(与 regenerate 路径 :312-321 一致,持久读 per_conv)。 + // ai_chat_send 是 miniapp send_message 路由的入口,override 持久语义对跨端设定模型关键: + // send 带 override → per_conv 已写(:上方 conv.model_override = ...),读回传 loop; + // 不带 → 读 per_conv 已有值(会话记住自己的模型);新会话 None → 走 select_model_id 路由。 + let conv_model_override = { + let mut session = state.ai_session.lock().await; + session.conv(&conv_id).model_override.clone() + }; + // 在后台任务中执行流式调用 let session_arc = state.ai_session.clone(); let tools_arc = state.ai_tools.clone(); @@ -493,9 +519,15 @@ pub async fn ai_chat_send( let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); // F-260616-07: 流式失败重试次数快照 let max_retries = state.agent_max_retries.load(Ordering::SeqCst); + // F1 并发 epoch(F-260616-09 决策 e 真并发上线):新 loop 的 owner token。 + // 每 spawn 前 fetch_add(1) 递增,旧 loop 即刻失效(退出/Guard 复位/emit 只认自己 epoch)。 + let loop_epoch = { + let mut session = state.ai_session.lock().await; + session.conv(&conv_id).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1 + }; tauri::async_runtime::spawn(async move { - run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, model_override).await; + run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await; }); Ok("ok".to_string()) @@ -1183,40 +1215,65 @@ pub async fn ai_pending_tool_calls( /// 清空对话历史 #[tauri::command] pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> { + // A2-B9(G3.2 clearChat 裁决):先停 loop → DB 单事务 → 内存清。 + // 原实现先清内存再清 DB:DB 失败 → 前端 IPC reject 但内存已清(UI 显示旧消息, + // 下次刷新变空);且 clear_messages + delete_range 两条 DB 写非原子。裁决改为: + // ① 先停 loop(stop_flag + conv_states→Idle)防后台在途 save_conversation 在 + // DB 清后、内存清前复活 ai_messages; + // ② DB 单事务清空(clear_conversation_atomic); + // ③ DB 成功后才清内存(内存 clear 不失败,回滚只需保证 DB 原子)。 let __lock_t1095 = std::time::Instant::now(); - let mut session = state.ai_session.lock().await; - // 取活跃对话 id 后释放锁(避免持 session 锁调 DB) - let active_id = session.active_conversation_id.clone(); - // SW-260618-02:清 pending 前先把占位 tool_result 终态化(此处 messages 紧随即 clear, - // 调用为 no-op,但保持清理点统一口径防顺序变更时占位随 messages 残留 DB)。 - // F-260616-09 B 批4:per_conv.messages 唯一真相源,删顶层 messages.clear 双写; - // pending_approvals 仅清 active conv 的(retain 保留其他 conv 的审批)。 - finalize_pending_placeholders(&mut *session, &active_id.clone().unwrap_or_default(), "会话已清除"); + // ── ① 先停 loop 防并发 save ── + // 取活跃对话 id 后释放锁(避免持 session 锁调 DB)。stop_flag 置位 + notify 唤醒: + // 旧 loop 下一轮边界检测到 stop_flag 即走 finish_round_exit 退出;conv_states→Idle + // 让读侧 is_active 立即归零(Idle 自环幂等,guard.reset 不冲突)。 + let active_id = { + let mut session = state.ai_session.lock().await; + let active_id = session.active_conversation_id.clone(); + if let Some(ref id) = active_id { + let conv = session.conv(id); + conv.stop_flag.store(true, Ordering::SeqCst); + conv.notify.notify_one(); + } + if let Some(ref id) = active_id { + if let Err(e) = state.conv_states.transition(id, ConvState::Idle) { + tracing::warn!( + conv_id = %id, + error = %e, + "[ai] ai_chat_clear conv_states→Idle 非法(不阻断清空)" + ); + } + } + let __hold1095 = __lock_t1095.elapsed(); + if __hold1095 > std::time::Duration::from_millis(30) { + eprintln!("[LOCK-SLOW] ai_chat_clear:1095 持锁 {:?} (含 lock 等待)", __hold1095); + } + active_id + }; + // ── ② DB 单事务清空(clear_messages + delete_range 一次 transaction 原子)── + // F-260619-03 批次 B:同时清空 ai_messages 表(全删) + messages JSON 列(备份同步清空, + // 防 reload fallback 读旧脏数据),保留对话壳(AR-7 刷新不再恢复)。 + // 先停 loop 再 DB:stop_flag 已置,旧 loop 退出路径的 save 即使落在 DB 清后、内存清前 + // (窄竞态),内存清后 needs_full_rewrite=true 兜底(下次 save 全量重写空列表清 DB)。 if let Some(ref id) = active_id { - session.conv(id).messages.clear(); - } - // 阶段3a 单真相源合并:单表 retain(口径不变:清本 conv 保留其他 conv,kind 不区分)。 - session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != active_id.as_deref()); - let __hold1095 = __lock_t1095.elapsed(); - if __hold1095 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_clear:1095 持锁 {:?} (含 lock 等待)", __hold1095); - } - drop(session); - // 真删 DB:清空该对话 messages(JSON 备份列同步清空 + 清零 token,保留对话壳),刷新不再恢复(AR-7) - // F-260619-03 批次 B:同时清空 ai_messages 表(全删,delete_range min_seq=0 max=None) - // 旧 clear_messages(置 messages='[]')保留调用:备份列同步清空,防 reload fallback 读旧脏数据。 - if let Some(id) = active_id { state .ai_conversations - .clear_messages(&id) - .await - .map_err(err_str)?; - state - .ai_messages - .delete_range(&id, 0, None) + .clear_conversation_atomic(id) .await .map_err(err_str)?; } + // ── ③ 内存清(DB 成功后;内存 clear 不失败,回滚只需保证 DB 原子)── + if let Some(ref id) = active_id { + let mut session = state.ai_session.lock().await; + // SW-260618-02:清 pending 前先把占位 tool_result 终态化(此处 messages 紧随即 clear, + // 调用为 no-op,但保持清理点统一口径防顺序变更时占位随 messages 残留 DB)。 + // F-260616-09 B 批4:per_conv.messages 唯一真相源,删顶层 messages.clear 双写; + // pending_approvals 仅清 active conv 的(retain 保留其他 conv 的审批)。 + finalize_pending_placeholders(&mut *session, id, "会话已清除"); + session.conv(id).messages.clear(); + // 阶段3a 单真相源合并:单表 retain(口径不变:清本 conv 保留其他 conv,kind 不区分)。 + session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(id.as_str())); + } Ok(()) } @@ -1538,6 +1595,11 @@ pub async fn ai_chat_edit( let llm_concurrency = state.llm_concurrency.clone(); let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst); let max_retries = state.agent_max_retries.load(Ordering::SeqCst); + // F1 并发 epoch:新 loop 的 owner token(每 spawn 前 fetch_add(1),旧 loop 即刻失效)。 + let loop_epoch = { + let mut session = state.ai_session.lock().await; + session.conv(&conv_id).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1 + }; tauri::async_runtime::spawn(async move { run_agentic_loop( @@ -1554,6 +1616,7 @@ pub async fn ai_chat_edit( max_retries, 0, model_override, + loop_epoch, ) .await; }); @@ -1600,7 +1663,7 @@ pub async fn ai_chat_force_send( // F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板), // 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他 // 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。 - let (old_conv_id, conv_id, _user_message_id, old_pinned_goals) = { + let (old_conv_id, conv_id, _user_message_id, old_pinned_goals, new_epoch) = { let __lock_t1473 = std::time::Instant::now(); let mut session = state.ai_session.lock().await; // 目标 conv:入参优先 → active → 懒创建。 @@ -1630,7 +1693,11 @@ pub async fn ai_chat_force_send( finalize_pending_placeholders(&mut *session, &target, "已取消"); // 阶段3a 单真相源合并:单表 retain(仅清目标 conv,保留其他 conv,kind 不区分)。 session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str())); - { + // F1 并发 epoch(2026-08-05):锁内先让旧 loop 失效(stop_flag=true + notify 唤醒 + epoch++)。 + // **不立即置 false**——保持 stop_flag=true 直到锁外等待窗口结束,旧 loop 被 notify 唤醒后 + // 读 true 即退出,不再与新 loop 并行。epoch 是 owner 判定的唯一真相(旧 loop 即便还在跑, + // 退出路径 guard.reset/emit/save 全部因 epoch 不匹配而跳过,不动新 loop 状态)。 + let new_epoch = { let conv = session.conv(&target); // B-Phase3:conv_state 写切 ConvStateStore 单源。 if let Err(e) = state.conv_states.transition(&target, ConvState::Idle) { @@ -1641,12 +1708,14 @@ pub async fn ai_chat_force_send( ); } conv.stop_flag.store(true, Ordering::SeqCst); - } + conv.notify.notify_one(); + conv.loop_epoch.fetch_add(1, Ordering::SeqCst) + 1 + }; // ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截—— // 这是"强制"语义本身,前面已主动复位) let conv = session.conv(&target); // 批3 收口:生成态由 run_agentic_loop 入口 ConvState→Generating 迁移设置(不再手动赋值)。 - conv.stop_flag.store(false, Ordering::SeqCst); + // 注:stop_flag 保持 true(旧 loop 退出用),新 loop spawn 前由下方锁外等待窗口后置 false。 conv.agent_language = language.clone(); // F-260616-11: 强制发送 = 新对话生命周期起点,iteration 从头计数。 conv.iteration_used = 0; @@ -1674,9 +1743,20 @@ pub async fn ai_chat_force_send( if __hold1473 > std::time::Duration::from_millis(30) { eprintln!("[LOCK-SLOW] ai_chat_force_send:1473 持锁 {:?} (含 lock 等待)", __hold1473); } - (was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone()) + (was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone(), new_epoch) }; + // F1 并发 epoch:锁外等待窗口——旧 loop 被 notify 唤醒后读到 stop_flag=true 尽快退出, + // 缩小新旧 loop 重叠窗口。长工具执行中的旧 loop 不会在此窗口退出,但 epoch 已失效, + // 其退出路径(guard reset / emit / save)全部跳过,不会干扰新 loop。 + // 等待时长取 300ms(尽力而为:覆盖 stream_llm select! notify 唤醒到退出检查的调度延迟)。 + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + // 置 stop_flag=false 让新 loop 起跑(锁内:与上方占用同源,防并发 send IPC 抢占)。 + { + let mut session = state.ai_session.lock().await; + session.conv(&conv_id).stop_flag.store(false, std::sync::atomic::Ordering::SeqCst); + } + // 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit) if let Some(ref cid) = old_conv_id { let ev = AiChatEvent::AiCompleted { @@ -1686,6 +1766,8 @@ pub async fn ai_chat_force_send( prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, + // 零 token 收敛信号,非估算(无真实 LLM 调用)。 + is_estimated: false, incomplete: None, conversation_id: Some(cid.clone()), pinned_goals: old_pinned_goals.clone(), @@ -1714,6 +1796,13 @@ pub async fn ai_chat_force_send( system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await; } + // 每会话独立模型(与 ai_chat_send/regenerate 路径一致,持久读 per_conv)。 + // force_send 语义 = 强制重发,尊重 per_conv 已有模型(override 持久)。 + let conv_model_override = { + let mut session = state.ai_session.lock().await; + session.conv(&conv_id).model_override.clone() + }; + // 后台 spawn agentic loop(照 ai_chat_send:229-242 / ai_chat_edit:695-721 同款) let session_arc = state.ai_session.clone(); let tools_arc = state.ai_tools.clone(); @@ -1725,7 +1814,7 @@ pub async fn ai_chat_force_send( let max_retries = state.agent_max_retries.load(Ordering::SeqCst); tauri::async_runtime::spawn(async move { - run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, model_override).await; + run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch).await; }); Ok("ok".to_string()) @@ -1795,6 +1884,8 @@ pub async fn ai_chat_stop( let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, + // 零 token 收敛信号,非估算(无真实 LLM 调用)。 + is_estimated: false, incomplete: None, conversation_id: Some(target), pinned_goals, @@ -1821,26 +1912,66 @@ pub async fn ai_chat_stop( } drop(session); - // B-260615-13 兜底任务:loop 若 panic/异常退出漏发收尾,stop_flag 无人读, - // 用户点 stop 无反应、generating 卡 true。这里 sleep 短超时后重检目标 conv generating, - // 仍 true(loop 没复位)则强制复位 + emit AiCompleted 通知前端收尾。 - // 正常路径(loop 活自行复位)此时 generating 已 false,无操作退出。 + // B-260615-13 兜底任务(F2 加固,2026-08-05):loop 若 panic/异常退出漏发收尾,stop_flag 无人读, + // 用户点 stop 无反应、generating 卡 true。 + // 原实现 sleep 3s 后仍 active 就强制复位 + emit AiCompleted——但用户点停时 loop 可能在长工具执行 + // (>3s),原兜底误触发 → conv 假完成但旧 loop 仍活 → 双完成 + 用户可再发消息(竞态 F2)。 + // 修法:以 per_conv.last_heartbeat 为存活证据(heartbeat_loop 每 20s + loop 每轮更新): + // - 心跳新鲜(距 now ≤ STALE_MS)= loop 还在跑(流式/工具执行/压缩)→ 豁免,不误迁 Idle; + // - 心跳超阈值无更新 = loop 真死 → 强制复位 + emit AiCompleted。 + // 轮询每 1s,总预算 120s(真死时超 STALE_MS=60s 必触发;loop 活则恒豁免直至自行收尾)。 let session_arc = state.ai_session.clone(); let app_handle = app.clone(); tauri::async_runtime::spawn(async move { + // 首轮 3s(对齐原行为:正常路径 loop 被 stop_flag+notify 唤醒后毫秒级退出,无需等)。 tokio::time::sleep(std::time::Duration::from_secs(3)).await; - let __lock_t1686 = std::time::Instant::now(); - let mut session = session_arc.lock().await; - let target_owned = conv_id.clone().unwrap_or_default(); - // B-Phase3:读侧切无锁 conv_states。 - let still_gen = if target_owned.is_empty() { - false - } else { - app.state::().conv_states.is_active(&target_owned) - }; - if still_gen { - // B-Phase3:conv_state 写切 ConvStateStore 单源。 + const STALE_MS: u64 = 60_000; + const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); + const TOTAL_BUDGET: std::time::Duration = std::time::Duration::from_secs(120); + let started = std::time::Instant::now(); + loop { + let target_owned = conv_id.clone().unwrap_or_default(); + if target_owned.is_empty() { + return; + } + // 读侧无锁 conv_states:loop 已自行复位(Idle)→ 正常收尾,兜底退出。 + if !app.state::().conv_states.is_active(&target_owned) { + return; + } + // 读存活心跳(短锁,仅取 AtomicU64 值)。 + let hb = { + let session = session_arc.lock().await; + session + .conv_read(&target_owned) + .map(|c| c.last_heartbeat.load(std::sync::atomic::Ordering::SeqCst)) + .unwrap_or(0) + }; + let now = df_types::now_millis() as u64; + let alive = hb > 0 && now.saturating_sub(hb) <= STALE_MS; + if alive { + // loop 还在跑(流式/工具执行/压缩):豁免,继续轮询等它自行收尾。 + if started.elapsed() >= TOTAL_BUDGET { + return; // 预算耗尽仍活:靠 loop 自身 guard Drop / 前端 watchdog 兜底,不误杀。 + } + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } + // 真死(或从未报活):强制复位前重读一次心跳防 TOCTOU——用户恰在此时发了新 loop, + // 新 loop 已更新心跳 → 跳过,不误杀新 loop 的 Generating。 + let hb2 = { + let session = session_arc.lock().await; + session + .conv_read(&target_owned) + .map(|c| c.last_heartbeat.load(std::sync::atomic::Ordering::SeqCst)) + .unwrap_or(0) + }; + if hb2 > 0 && now.saturating_sub(hb2) <= STALE_MS { + return; + } + // 真死:强制复位 + emit AiCompleted。 + let mut session = session_arc.lock().await; { + // B-Phase3:conv_state 写切 ConvStateStore 单源。 let conv_states = app.state::().conv_states.clone(); if let Err(e) = conv_states.transition(&target_owned, ConvState::Idle) { tracing::warn!( @@ -1850,11 +1981,11 @@ pub async fn ai_chat_stop( ); } } + // G2.2(2026-08-05):兜底 emit AiCompleted 前对称清审批残留(终态化占位 + 移除 pending), + // 与 ai_chat_stop 审批分支/force_send 对齐——防占位 tool_result 残留 messages 下次发送喂给 LLM。 + finalize_pending_placeholders(&mut *session, &target_owned, "会话已停止"); + session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target_owned.as_str())); let pinned_goals = session.conv(&target_owned).pinned_goals.clone(); - let __hold1686 = __lock_t1686.elapsed(); - if __hold1686 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_stop:1686 持锁 {:?} (含 lock 等待)", __hold1686); - } drop(session); // 释放锁后再 emit,避免持锁调 runtime emit let ev = AiChatEvent::AiCompleted { total_tokens: 0, @@ -1863,18 +1994,16 @@ pub async fn ai_chat_stop( prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, + // 零 token 收敛信号,非估算(无真实 LLM 调用)。 + is_estimated: false, incomplete: None, - conversation_id: conv_id, + conversation_id: conv_id.clone(), pinned_goals, }; let _ = app_handle.emit("ai-chat-event", ev.clone()); // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。 let _ = app_handle.state::().ai_event_bus.publish_event(ev); - } else { - let __hold1686 = __lock_t1686.elapsed(); - if __hold1686 > std::time::Duration::from_millis(30) { - eprintln!("[LOCK-SLOW] ai_chat_stop:1686 持锁 {:?} (含 lock 等待)", __hold1686); - } + return; } }); @@ -1981,6 +2110,8 @@ pub async fn ai_stop_loop( prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, + // 零 token 收敛信号,非估算(无真实 LLM 调用)。 + is_estimated: false, incomplete: None, conversation_id: Some(conversation_id), pinned_goals, diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 0e2635d..d71405f 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -165,6 +165,11 @@ pub enum AiChatEvent { prompt_cache_miss_tokens: u32, /// 思考 token(deepseek-reasoner/o1 reasoning_tokens,隐藏输出)。0=非 reasoning 模型。 reasoning_tokens: u32, + /// G4.3(2026-08-05):本轮 token 用量是否估算值(agentic 运行时兜底 + /// round_usage.prompt_tokens==0 → estimated_prompt 处打标)。true=prompt 非真实 + /// (provider 未报,前端 MessageList 据此标「估算」),false=真实值。仅影响展示, + /// 不参与 TokenAccumulator/save 累加路径。 + is_estimated: bool, /// 不完整标记(可选):Some(true)=网络中断保文,None 或 Some(false)=完整回复 incomplete: Option, conversation_id: Option, @@ -319,6 +324,23 @@ pub enum AiChatEvent { /// 本机技能列表(ai_list_skills 返回的进程内缓存,已按 skills>commands>plugins 优先级去重) skills: Vec, }, + /// 跨端模型列表同步(2026-08-05):device 响应 miniapp `list_models` 命令, + /// 把活跃 provider 的 enabled 模型列表推回 miniapp 供模型选择器渲染。 + /// + /// 触发:remote_bridge `list_models` 路由 → `get_active_provider`(prompt.rs)读活跃 + /// provider → 过滤 `model_configs` 中 `enabled == true` → 映射 `ModelInfo { model_id, label }` + /// → `publish_event`(跨端透传,经 EventBus→tunnel subscriber→relay→miniapp)。 + /// + /// 职责:miniapp 模型选择器(列出可选模型 + 当前默认),对齐桌面端模型选择体验; + /// 选择结果经 `send_message` / `regenerate` 的 `model_override` 透传回 device(route 已透传)。 + AiModelList { + /// 活跃 provider id(读 session.active_provider_id,兜底 provider.id) + provider_id: String, + /// 活跃 provider 默认模型(provider.default_model,选择器高亮/兜底) + default_model: String, + /// enabled 模型列表(已过滤 model_configs 中 enabled == true) + models: Vec, + }, /// F-#95 跨端实体列表同步(Phase3,2026-06-23):device 响应 miniapp `list_entities` 命令, /// 把本地项目/任务/灵感全量列表(list_projects/list_tasks/list_ideas 全量等价路径)推回 miniapp。 /// @@ -371,6 +393,20 @@ pub enum AiChatEvent { }, } +/// 模型信息(AiModelList 事件的载荷,对齐 apps/df-miniapp 模型选择器 ModelInfo 字段)。 +/// +/// 与 `df_ai_core::model::ModelConfig` 区别:仅含选择器渲染必需字段(model_id + 可选 label), +/// 不携带 modalities/capabilities/权重等路由维度(省带宽,miniapp 仅需选模型)。label 为用户 +/// 自定义别名(可选),缺失时前端回退显示 model_id。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + /// 模型名(如 "glm-4-flash",对齐 ModelConfig.model_id) + pub model_id: String, + /// 用户自定义别名(可选;缺失时前端回退显示 model_id) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + /// Plan 层信息(AiPlanCreated 事件的载荷) #[derive(Debug, Clone, serde::Serialize)] pub struct PlanLayerInfo { @@ -879,6 +915,22 @@ pub struct PerConvState { pub stop_flag: Arc, /// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select! pub notify: Arc, + /// 并发 loop epoch/owner token(F1 根治,2026-08-05):每 spawn 新 loop 前 `fetch_add(1)`。 + /// + /// loop 入口捕获自己的 epoch(`run_agentic_loop` 的 `loop_epoch` 形参),退出 / Guard 复位 / + /// emit AiCompleted 时只认自己 epoch(owner)——`loop_epoch.load() == my_epoch` 才是 owner, + /// 否则是旧 loop(已被 force_send/新 loop 接管),不 reset 新 loop 状态、不 emit 重复完成事件。 + /// + /// 治 F1:force_send 原先锁内 `stop_flag=true→false` 微秒窗口,旧 loop 阻塞在 stream_llm + /// select!(不读 stop_flag)醒来 flag 已 false → 旧 loop 没停 → 同 conv 新旧两 loop 并行。 + /// epoch 让旧 loop 在新 loop 启动瞬间即失效(owner 判定失败),即便旧 loop 还在跑也不动新状态。 + pub loop_epoch: Arc, + /// 存活心跳(F2 根治,2026-08-05):loop 活跃期间由 `heartbeat_loop` 每 20s 更新(ms 时间戳)。 + /// + /// `ai_chat_stop` 3s 兜底据此判断 loop 是否真死:超阈值(60s)无心跳 = 真死才强制复位 + + /// emit AiCompleted;loop 还在跑(长工具执行/流式/压缩,心跳持续)则豁免,不误迁 Idle、 + /// 不产生「假完成 + 旧 loop 仍活」的双完成竞态。 + pub last_heartbeat: Arc, /// agentic loop 累计已跑 iteration 计数(语义同 AiSession.iteration_used) pub iteration_used: usize, /// 当前 agent 循环的语言设置(用于审批后恢复循环) @@ -963,6 +1015,8 @@ impl PerConvState { pinned_goals: Vec::new(), stop_flag: Arc::new(AtomicBool::new(false)), notify: Arc::new(tokio::sync::Notify::new()), + loop_epoch: Arc::new(std::sync::atomic::AtomicU64::new(0)), + last_heartbeat: Arc::new(std::sync::atomic::AtomicU64::new(0)), iteration_used: 0, agent_language: None, model_override: None, diff --git a/src-tauri/src/commands/ai/remote_bridge.rs b/src-tauri/src/commands/ai/remote_bridge.rs index 6716068..0772846 100644 --- a/src-tauri/src/commands/ai/remote_bridge.rs +++ b/src-tauri/src/commands/ai/remote_bridge.rs @@ -26,6 +26,7 @@ //! | `load_messages` | (无,直接读库+事件推送) | conversation_id | //! | `rename_conversation` | `ai_conversation_rename` | conversation_id, title | //! | `sync_pending` | (无,读 session 重发审批事件) | conversation_id | +//! | `list_models` | (无,读活跃 provider+事件推送) | — | //! | (未知 cmd) | (兜底臂:log+忽略) | — | //! //! ## R1 兜底(硬性,不可省,§3.3 + D6) @@ -65,8 +66,11 @@ use crate::commands::ai::{ ai_approve, ai_authorize_dir, ai_chat_clear_context, ai_chat_compress_context, ai_chat_edit, ai_chat_force_send, ai_chat_send, ai_chat_stop, ai_conversation_rename, ai_continue_loop, ai_list_skills, ai_regenerate, ai_stop_loop, record_to_message, AiChatEvent, ApprovalKind, - ConvSummary, PendingApproval, + ConvSummary, ModelInfo, PendingApproval, }; +// list_models 路由:get_active_provider(prompt.rs 已 pub(crate))读活跃 provider。 +// 不走 commands 模块(provider 获取是 prompt 子模块职责,对齐 chat.rs 同源调用路径)。 +use crate::commands::ai::prompt::get_active_provider; // F-#95 跨端实体联想:list_projects/list_tasks/list_ideas 是项目/任务/灵感的 #[tauri::command], // 经 commands::{project,task,idea} 模块路径引用(commands/mod.rs pub mod 声明)。 use crate::commands::{idea::list_ideas, project::list_projects, task::list_tasks}; @@ -97,7 +101,7 @@ pub struct MiniCommand { /// Tauri command 名(如 "send_message" / "stop" / "approve" ...) pub cmd: String, /// 命令参数(原始 JSON 对象,缺失时回退空 Object 便于各臂 from_value 兜底) - /// BUG-260623-05:`#[serde(default)]` 对 Value 给 Null(非 Object),须 custom default 返空 Object + /// `#[serde(default)]` 对 Value 给 Null(非 Object),须 custom default 返空 Object #[serde(default = "default_empty_object")] pub args: Value, } @@ -367,6 +371,14 @@ pub async fn handle_remote_command(payload: Value, app: AppHandle, state: State< route_list_entities(&state).await; } + // ── list_models:跨端模型列表同步(device→miniapp 推活跃 provider 的 enabled 模型) ── + // 调 get_active_provider(prompt.rs)读活跃 provider → 过滤 model_configs.enabled == true + // → publish_event(AiModelList) 跨端透传,供 miniapp 模型选择器渲染。 + // 对齐 route_list_skills 模式(非 app.emit,publish 才跨端透传)。 + "list_models" => { + route_list_models(&state).await; + } + // ── 兜底臂:未知 cmd,log+忽略(不崩溃) ── // 对齐 §1.2 风险缓解「桥接层 match 末尾加兜底臂,非法命令不崩溃只记录」。 unknown => { @@ -601,6 +613,76 @@ async fn route_list_skills(state: &State<'_, AppState>) { let _ = state.ai_event_bus.publish_event(AiChatEvent::AiSkillList { skills }); } +// ============================================================ +// 模型列表同步路由(device→miniapp 推活跃 provider 的 enabled 模型) +// ============================================================ + +/// `list_models` 路由 —— 读活跃 provider 的 enabled 模型列表,跨端推回 miniapp。 +/// +/// 流程: +/// 1. `get_active_provider(state)`(prompt.rs 已 pub(crate))读活跃 provider —— 优先级: +/// `session.active_provider_id` 指定 → `is_default` → 首个;无 → Err +/// 2. 取 `provider.model_configs` 中 `enabled == true` 的模型,映射 `ModelInfo { model_id, label }` +/// (label 为 Option,用户自定义别名;缺失由前端回退 model_id) +/// 3. 读 `session.active_provider_id`(活跃 provider id,兜底 provider.id)+ `provider.default_model` +/// 4. `state.ai_event_bus.publish_event(AiModelList)` 跨端透传 +/// (经 EventBus→tunnel subscriber→relay→miniapp,非 app.emit 仅桌面前端) +/// +/// 职责:miniapp 模型选择器渲染(列出可选模型 + 当前默认),对齐桌面端模型选择体验; +/// 选择结果经 `send_message` / `regenerate` 的 `model_override` 透传回 device(route 已透传)。 +/// +/// 错误兜底:get_active_provider 返 Err(未配置 provider / 活跃 provider 不存在) +/// → publish 空 Vec(对齐 route_list_skills 失败推空列表语义,miniapp 选择器渲染空态不崩溃)。 +async fn route_list_models(state: &State<'_, AppState>) { + // 读活跃 provider(失败 → 空列表兜底)。 + let provider = match get_active_provider(state).await { + Ok(p) => p, + Err(e) => { + tracing::warn!(error = %e, "[remote_bridge] list_models 读活跃 provider 失败,推送空列表"); + let _ = state.ai_event_bus.publish_event(AiChatEvent::AiModelList { + provider_id: String::new(), + default_model: String::new(), + models: Vec::new(), + }); + return; + } + }; + + // 活跃 provider id:读 session(轻量 lock 取单值),兜底 provider.id —— + // session.active_provider_id 可能在 get_active_provider 读 provider 后清空,兜底保字段不空。 + let provider_id = { + let session = state.ai_session.lock().await; + session + .active_provider_id + .clone() + .unwrap_or_else(|| provider.id.clone()) + }; + + // 过滤 enabled 模型 → ModelInfo { model_id, label }(label 可选)。 + let models: Vec = provider + .model_configs + .iter() + .filter(|m| m.enabled) + .map(|m| ModelInfo { + model_id: m.model_id.clone(), + label: m.label.clone(), + }) + .collect(); + + tracing::info!( + provider_id = %provider_id, + default_model = %provider.default_model, + count = models.len(), + "[remote_bridge] list_models 推送模型列表(跨端)" + ); + + let _ = state.ai_event_bus.publish_event(AiChatEvent::AiModelList { + provider_id, + default_model: provider.default_model, + models, + }); +} + // ============================================================ // F-#95:实体列表同步路由(device→miniapp 推项目/任务/灵感) // ============================================================ @@ -699,11 +781,27 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args: // skill:技能名(Option,空字符串规整为 None);mention_spans:@ 区间(失败兜底 None)。 let skill = args_get_string(&args, "skill").filter(|s| !s.is_empty()); let mention_spans = args_get_mention_spans(&args, "mention_spans"); + // 跨端新会话语义(2026-08-05):miniapp 新建会话(active=null)后发送,若仅传 conversation_id=null, + // ai_chat_send 会落到桌面端 active_conversation_id(旧会话)致串会话。miniapp 新建会话发送时 + // 显式传 new_conversation=true → 此处强制生成新 conv id(不走 active 兜底)。 + let new_conversation = args_get_bool(&args, "new_conversation").unwrap_or(false); + // 解析目标 conv_id(广播 AiUserMessage + R1 检查 + ai_chat_send 三处共用,保证一致): + // - new_conversation=true → 强制新会话(新 id,不立即落库,由 ai_chat_send 生成后 save upsert) + // conversation_id 有值 → 用之;None → active 兜底(可能 None,ai_chat_send 内部懒创建)。 + // 提前 await(if/else 表达式值计算上下文不允许 .await)。 + let fallback_active = get_active_conv_id(state).await; + let resolved_conv_id: Option = if new_conversation { + Some(df_types::types::new_id()) + } else if let Some(id) = conversation_id.clone().filter(|s| !s.is_empty()) { + Some(id) + } else { + fallback_active + }; // ── R1 前置 generating 检查(双轨口径对齐 ai_is_generating) ── - // 目标 conv:入参 conversation_id 优先 → active conv 兜底(与 ai_is_generating 同口径)。 + // 目标 conv:resolved(入参/new_conversation 新建/active),与 ai_is_generating 同口径。 // 空目标(无 conv_id 且无 active):无任何 conv 在跑,放行。 - if let Some(reject_conv_id) = check_generating_reject(state, conversation_id.as_deref()).await { + if let Some(reject_conv_id) = check_generating_reject(state, resolved_conv_id.as_deref()).await { // generating=true:拒绝,emit AiError 回 miniapp 提示。 // conv_id 透传原 conversation_id(用户当前面板的 conv),便于 miniapp 按 conv 路由展示。 tracing::info!( @@ -731,16 +829,27 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args: // 无论 R1 命中(理论上不会到这)/ 内部 guard 命中 / Provider 配置错 / 参数错, // 用户都能在 miniapp 看到 AiError 反馈(否则命令返 Err 静默,miniapp 不知失败)。 + // F-260805-02 图片输入:miniapp 选图转 base64 构造 ContentPart Image 片段,经 parts 透传 + // ai_chat_send(桌面端已支持多模态,FR-S1 核验)。args_get_parts 反序列化失败降级 None(不阻断纯文本)。 + let parts = args_get_parts(&args, "parts"); + // F-260622-02 跨端用户消息同步:调 ai_chat_send 前广播 AiUserMessage 到 ai_event_bus, // 让桌面 useAiEvents 收到并补 user 气泡(否则桌面只看到孤立的 assistant 响应气泡)。 // 用 publish_event(跨端透传,与 route_list_conversations:335 同通路),非 app.emit // (emit 仅桌面前端,publish 经 EventBus→tunnel subscriber,桌面端自身也是订阅者能收到)。 // 注:publish 走事件通道,device 端不会把此事件当入站命令回灌(命令/事件分轨)。 // 前端 handleEvent 去重防双气泡(末条已是同 content user 跳过)。 - let _ = state.ai_event_bus.publish_event(AiChatEvent::AiUserMessage { + // BUG-260805-02 双写修复:桌面端只 listen app.emit('ai-chat-event'),publish_event(ai_event_bus) + // 桌面前端收不到 → 微信 user 消息桌面不可见。补 emit 双写(桌面收),保留 publish(微信自回灌去重 + + // tunnel 透传其他 miniapp 订阅者)。 + let user_msg_ev = AiChatEvent::AiUserMessage { message: message.clone(), - conversation_id: conversation_id.clone(), - }); + // 用 resolve 后真实 conv_id(新会话是强制新建 id,旧会话是入参/active), + // 桌面端才能正确归属用户气泡到会话(此前广播原始 null 致无法归属)。 + conversation_id: resolved_conv_id.clone(), + }; + let _ = app.emit("ai-chat-event", user_msg_ev.clone()); + let _ = state.ai_event_bus.publish_event(user_msg_ev); let result = ai_chat_send( app.clone(), state.clone(), @@ -748,8 +857,8 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args: None, // language:远程默认不传,ai_chat_send 内部 fallback "zh-CN" skill, // skill:miniapp / 联想选中技能名透传(技能正文注入) model_override, // model_override:远程透传用户选择 - conversation_id.clone(),// conversation_id:远程透传目标 conv - None, // parts:多模态片段,远程 MVP 不传 + resolved_conv_id, // conversation_id:resolve 后目标 conv(新会话强制新建 id) + parts, // parts:miniapp 图片输入透传(ContentPart Image base64,None 走纯文本) mention_spans, // mention_spans:@ mention 区间透传(对齐桌面端 ai_chat_send) ) .await; @@ -815,10 +924,13 @@ async fn route_force_send(app: &AppHandle, state: &State<'_, AppState>, args: Va // 广播 AiUserMessage(同 route_send_message:force_send 追加新 user 消息, // 桌面端需据此补 user 气泡,防孤立 assistant 响应)。 - let _ = state.ai_event_bus.publish_event(AiChatEvent::AiUserMessage { + // BUG-260805-02 双写:桌面端只 listen app.emit,补 emit 让桌面收到(对齐 route_send_message)。 + let user_msg_ev = AiChatEvent::AiUserMessage { message: message.clone(), conversation_id: conversation_id.clone(), - }); + }; + let _ = app.emit("ai-chat-event", user_msg_ev.clone()); + let _ = state.ai_event_bus.publish_event(user_msg_ev); let result = ai_chat_force_send( app.clone(), state.clone(), @@ -1000,6 +1112,13 @@ async fn check_generating_reject( } } +/// 读当前 active conv_id(route_send_message resolve 目标 conv 用:conversation_id 缺失且 +/// 非 new_conversation 时兜底 active)。与 check_generating_reject 内部 active 读取同口径 +/// (轻量 lock 取单值,零额外持锁)。 +async fn get_active_conv_id(state: &State<'_, AppState>) -> Option { + state.ai_session.lock().await.active_conversation_id.clone() +} + // ============================================================ // args 字段提取 helper(从 serde_json::Value 稳健取值) // ============================================================ diff --git a/src-tauri/src/commands/ai/stream_recv.rs b/src-tauri/src/commands/ai/stream_recv.rs index bc817e5..df640ab 100644 --- a/src-tauri/src/commands/ai/stream_recv.rs +++ b/src-tauri/src/commands/ai/stream_recv.rs @@ -374,20 +374,33 @@ pub(crate) async fn stream_llm( } // provider 流式错误事件 if let Some(err_msg) = &chunk.error { + // A2-B12 / G4.2:error 帧按 message 文本分类 retryable。 + // 原硬编码 retryable:true,确定性请求错(鉴权/context_length/invalid_request) + // 被当瞬态空耗重试。现抠 HTTP 状态码/传输类走既有 classify_status_or_class + // 单一分类源;无码则按错误类型关键词保守降级(仅明确非重试降 Fatal)。 + let (status_or_class, _raw) = extract_error_diag_from_str(err_msg); + let retryable = classify_error_frame_retryable(err_msg, &status_or_class); + let diag_class = if status_or_class == "unknown" { + "stream-error" + } else { + status_or_class.as_str() + }; warn!( provider = %provider.name(), conv_id = %conv_id, error = %err_msg, + status_or_class = %status_or_class, + retryable = %retryable, text_len = full_text.len(), "[ai] provider 流式错误事件", ); if full_text.is_empty() && tool_calls_acc.is_empty() { return StreamResult::InitFailed { - retryable: true, + retryable, error: fmt_diag( provider.name(), DiagKind::MidStream, - "stream-error", + diag_class, err_msg, ), }; @@ -544,6 +557,52 @@ fn classify_status_or_class(status_or_class: &str) -> bool { || lower.contains("broken pipe") } +/// 据 provider 流式 error 帧的 message 文本分类是否可重试(A2-B12 / G4.2)。 +/// +/// openai/anthropic helper 仅将 error 帧的 `message` 字段透传进 chunk.error +/// (结构化 `type` 未上抛),故此处按 message 文本保守分类,对齐 Err 路径单一分类源: +/// - 内含显式 HTTP 状态码或传输类(timeout/connect) → 复用 classify_status_or_class +/// - 无状态码时按错误类型关键词:明确非重试签名(authentication / context_length / +/// invalid_request / 鉴权 / 参数错等) → Fatal +/// - 其余 → retryable=true(保守,防误判瞬态为 Fatal) +fn classify_error_frame_retryable(msg: &str, status_or_class: &str) -> bool { + // 已有状态码/传输类 → 走既有单一分类源(显式 4xx Fatal,5xx/429/timeout/connect 可重试) + if status_or_class != "unknown" { + return classify_status_or_class(status_or_class); + } + // 无状态码:按错误类型关键词明确非重试签名降级 Fatal + let lower = msg.to_lowercase(); + const FATAL_KEYWORDS: &[&str] = &[ + // OpenAI / Anthropic error.type + "authentication_error", + "invalid_request_error", + "context_length_exceeded", + "permission_error", + "not_found_error", + "request_too_large", + "insufficient_quota", + // OpenAI 消息文本签名 + "authentication", + "invalid_request", + "invalid request", + "context length", + "api key", + "api-key", + "api_key", + "unauthorized", + // 中文签名(BUG-260617-01:Anthropic 中文参数错误) + "鉴权", + "认证失败", + "未授权", + "密钥", + "参数非法", + "参数错误", + "上下文长度", + "输入超长", + ]; + !FATAL_KEYWORDS.iter().any(|k| lower.contains(k)) +} + // ============================================================ // 单测:诊断提取/格式化(纯函数,不发 HTTP、不依赖 app_handle) // ============================================================ @@ -589,6 +648,63 @@ mod tests { assert!(!classify_status_or_class("unknown")); } + // ---- classify_error_frame_retryable:A2-B12 / G4.2 error 帧分类 ---- + + /// error 帧 message 含显式 4xx → Fatal(接入 classify_status_or_class 单一分类源) + #[test] + fn error_frame_http_4xx_fatal() { + let (s, _) = extract_error_diag_from_str("LLM 流式 API 错误 401: Unauthorized"); + assert!(!classify_error_frame_retryable("LLM 流式 API 错误 401: Unauthorized", &s)); + } + + /// error 帧 message 含显式 5xx / 429 → retryable + #[test] + fn error_frame_http_5xx_retryable() { + let (s, _) = extract_error_diag_from_str("LLM 流式 API 错误 500: Internal Server Error"); + assert!(classify_error_frame_retryable("LLM 流式 API 错误 500: Internal Server Error", &s)); + let (s2, _) = extract_error_diag_from_str("rate limit 429"); + assert!(classify_error_frame_retryable("rate limit 429", &s2)); + } + + /// error 帧 message 含 timeout → retryable(瞬态) + #[test] + fn error_frame_timeout_retryable() { + let (s, _) = extract_error_diag_from_str("operation timed out"); + assert!(classify_error_frame_retryable("operation timed out", &s)); + } + + /// authentication / api key 签名 → Fatal + #[test] + fn error_frame_authentication_fatal() { + let (s, _) = extract_error_diag_from_str("Incorrect API key provided: sk-xxx"); + assert!(!classify_error_frame_retryable("Incorrect API key provided: sk-xxx", &s)); + let (s2, _) = extract_error_diag_from_str("invalid x-api-key"); + assert!(!classify_error_frame_retryable("invalid x-api-key", &s2)); + } + + /// context_length_exceeded / context length 签名 → Fatal + #[test] + fn error_frame_context_length_fatal() { + let (s, _) = extract_error_diag_from_str("This model's maximum context length is 128000 tokens"); + assert!(!classify_error_frame_retryable("This model's maximum context length is 128000 tokens", &s)); + } + + /// invalid_request 签名 → Fatal + #[test] + fn error_frame_invalid_request_fatal() { + let (s, _) = extract_error_diag_from_str("invalid_request_error: the model is not supported"); + assert!(!classify_error_frame_retryable("invalid_request_error: the model is not supported", &s)); + } + + /// 无明确签名(overloaded / stream error 等瞬态语义)→ 保守 retryable=true(防误判 Fatal 错杀) + #[test] + fn error_frame_unknown_conservative_retryable() { + let (s, _) = extract_error_diag_from_str("overloaded_error: Overloaded"); + assert!(classify_error_frame_retryable("overloaded_error: Overloaded", &s)); + let (s2, _) = extract_error_diag_from_str("stream error"); + assert!(classify_error_frame_retryable("stream error", &s2)); + } + // ---- extract_error_diag:业务层 bail(provider 串已含状态码)---- /// provider 在 non-2xx bail 的典型串:抠出 401(鉴权失败/Key 错) diff --git a/src-tauri/src/commands/ai/tools/git.rs b/src-tauri/src/commands/ai/tools/git.rs index ba8830c..e58ffa8 100644 --- a/src-tauri/src/commands/ai/tools/git.rs +++ b/src-tauri/src/commands/ai/tools/git.rs @@ -24,11 +24,15 @@ use df_storage::db::Database; // Git 私有 helper(原 tool_registry.rs 986-1080 行原样搬入,仅本模块使用) // ============================================================ -/// 在指定目录执行 git 命令(10s 超时,返回 (stdout, success))。 +/// 在指定目录执行 git 命令(10s 超时,返回 (stdout_or_stderr, exit_code))。 /// -/// 返回 (stdout, success): -/// - success = child 退出码为 0(status.success()); -/// - 失败时 stdout 含 stderr 内容(便于上层拼 reason);超时/启动失败 → (空串, false)。 +/// 返回 (String, Option): +/// - String: 成功时 stdout;失败(exit != 0)时 stderr 非空则 stderr,否则 stdout; +/// 启动失败/超时 → 空串。 +/// - Option: 正常退出为 Some(退出码);启动失败/超时/信号终止为 None。 +/// +/// 只读工具(status/diff/log)直接调本函数拿退出码做差异语义:git diff 退 1 = 有差异是 +/// **合法成功**、git log 退 128 = 非 git 仓是失败;单纯 bool(exit==0)无法区分。 /// /// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout /// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限, @@ -41,7 +45,7 @@ use df_storage::db::Database; /// BUG-2026-08-02: 原签名返 String,失败/超时统一吞成空串,使 create/switch/commit 三个写操作 /// 恒返成功假象(commit 失败 HEAD 不动,log -1 仍返上次 hash → committed:true)。 /// 改返 (String, bool),上层据 success 判定真实成败。 -async fn exec_git(working_dir: &str, args: &[&str]) -> (String, bool) { +async fn exec_git_exit(working_dir: &str, args: &[&str]) -> (String, Option) { let mut cmd = tokio::process::Command::new("git"); cmd.args(args) .current_dir(working_dir) @@ -56,32 +60,56 @@ async fn exec_git(working_dir: &str, args: &[&str]) -> (String, bool) { } match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await { Ok(Ok(out)) => { - let success = out.status.success(); - // 失败时把 stderr 拼进 stdout 返回(上层据 success=false 读 reason) + let code = out.status.code(); + // 失败时把 stderr 拼进 String 返回(上层据 exit_code != 0 读 reason) let stdout = String::from_utf8_lossy(&out.stdout).to_string(); - if success { - (stdout, true) + if code == Some(0) { + (stdout, code) } else { let stderr = String::from_utf8_lossy(&out.stderr).to_string(); if stderr.is_empty() { - (stdout, false) + (stdout, code) } else { - (stderr, false) + (stderr, code) } } } - Ok(Err(_)) => (String::new(), false), - Err(_elapsed) => (String::new(), false), // 超时:child 被 kill_on_drop 终止 + Ok(Err(_)) => (String::new(), None), + Err(_elapsed) => (String::new(), None), // 超时:child 被 kill_on_drop 终止 } } +/// 在指定目录执行 git 命令,返回 (stdout_or_stderr, success)。 +/// +/// success = exit_code == 0(对齐 run_command succeeded=exit_code==0 口径)。 +/// 写工具(commit/branch/merge)继续用本函数;只读工具(status/diff/log)改用 +/// exec_git_exit 拿退出码做差异语义(diff 退 1 有差异是成功、log 退 128 非 git 仓才失败)。 +async fn exec_git(working_dir: &str, args: &[&str]) -> (String, bool) { + let (out, code) = exec_git_exit(working_dir, args).await; + (out, code == Some(0)) +} + /// git status --porcelain 解析为结构化文件列表。 -/// 返回 (当前分支, 改动文件列表 [{path, status}]) -async fn run_git_status(working_dir: &str) -> (String, Vec) { - let branch = exec_git(working_dir, &["branch", "--show-current"]).await.0; - let branch = branch.trim().to_string(); - let raw = exec_git(working_dir, &["status", "--porcelain"]).await.0; - let files: Vec = raw +/// +/// 成功判定(对齐 run_command succeeded=exit_code==0 口径):`status --porcelain` +/// 退出码 0 = 命令可达 + 非 fatal,即成功——空输出(工作区干净)是**合法成功**,非失败。 +/// 仅命令不可达/超时(None)或非 git 仓/权限(Some(128) 等)才判失败。 +/// +/// 返回:成功 {branch, files, total_changes} / 失败 {success:false, error, branch}。 +async fn run_git_status(working_dir: &str) -> serde_json::Value { + // branch 解析(失败时 branch 也标注,保留当前已取到的值,可能为空/fatal 提示) + let (branch_raw, _) = exec_git_exit(working_dir, &["branch", "--show-current"]).await; + let branch = branch_raw.trim().to_string(); + // status --porcelain:exit 0 = 干净或改动均合法成功 + let (status_raw, status_code) = exec_git_exit(working_dir, &["status", "--porcelain"]).await; + if status_code != Some(0) { + return serde_json::json!({ + "success": false, + "error": status_raw.trim(), + "branch": branch, + }); + } + let files: Vec = status_raw .lines() .filter(|l| !l.is_empty()) .map(|line| { @@ -97,19 +125,37 @@ async fn run_git_status(working_dir: &str) -> (String, Vec) { serde_json::json!({ "path": path, "status": simple }) }) .collect(); - (branch, files) + serde_json::json!({ + "branch": branch, + "files": files, + "total_changes": files.len(), + }) } /// git diff 解析为结构化文件列表(每文件统计 + patch 截断)。 +/// +/// git 语义注意:diff 退出码 **0 = 无差异、1 = 有差异,二者均为合法成功** +/// (不能按 exit==0 判成功,否则有差异的正常 diff 会被误判失败)。仅命令不可达/ +/// 超时(None)或 >1(非 git 仓/权限/坏路径)才算失败。 +/// +/// 返回:成功 {stat, patch} / 失败 {success:false, error}。 async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value { let mut args = vec!["diff", "--stat"]; if staged { args.push("--cached"); } - let stat_raw = exec_git(working_dir, &args).await.0; + let (stat_raw, stat_code) = exec_git_exit(working_dir, &args).await; + // 0(无差异)/1(有差异)均成功;None(不可达/超时)或 >1(非 git 仓/权限)才失败 + let stat_ok = matches!(stat_code, Some(0) | Some(1)); + if !stat_ok { + return serde_json::json!({ + "success": false, + "error": stat_raw.trim(), + }); + } // 每文件 patch(截断防 token 爆) - let mut patch_args = vec!["diff" ]; + let mut patch_args = vec!["diff"]; if staged { patch_args.push("--cached"); } - let patch_raw = exec_git(working_dir, &patch_args).await.0; + let patch_raw = exec_git_exit(working_dir, &patch_args).await.0; // 截断到 8000 字符(防大体量 diff) let patch_truncated = if patch_raw.len() > 8000 { format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len()) @@ -124,11 +170,30 @@ async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value { } /// git log 解析为结构化提交列表。 -async fn run_git_log(working_dir: &str, limit: usize) -> Vec { +/// +/// 成功判定:exit 0 = 正常(提交列表,可为空);**空仓 git log 返 128 + +/// "does not have any commits yet" 视为合法空成功**(非失败);仅 exit != 0 且非 +/// 空仓提示(非 git 仓 128 等)才失败。 +/// +/// 返回:成功 {commits} / 失败 {success:false, error}。 +async fn run_git_log(working_dir: &str, limit: usize) -> serde_json::Value { let format = "%H|%an|%ad|%s"; let limit_str = format!("-{}", limit); - let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await.0; - raw.lines() + let (raw, code) = exec_git_exit( + working_dir, + &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"], + ).await; + // 空仓:git log 返 128 + 该提示 → 空提交列表是合法成功(对齐 git_commit 的 + // "nothing to commit" 同型已知提示判断) + let empty_repo = code == Some(128) && raw.contains("does not have any commits yet"); + if code != Some(0) && !empty_repo { + return serde_json::json!({ + "success": false, + "error": raw.trim(), + }); + } + let commits: Vec = raw + .lines() .filter(|l| !l.is_empty()) .filter_map(|line| { let parts: Vec<&str> = line.splitn(4, '|').collect(); @@ -141,7 +206,8 @@ async fn run_git_log(working_dir: &str, limit: usize) -> Vec })) } else { None } }) - .collect() + .collect(); + serde_json::json!({ "commits": commits }) } /// Git AI 工具注册(6 个:status/diff/log 只读 Low + commit/branch 写 Medium + merge High)。 @@ -169,12 +235,8 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { let repo = df_storage::crud::ProjectModuleRepo::new(&db); let module = repo.get_by_id(module_id).await? .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; - let (branch, files) = run_git_status(&module.path).await; - Ok(serde_json::json!({ - "branch": branch, - "files": files, - "total_changes": files.len(), - })) + let result = run_git_status(&module.path).await; + Ok(result) } ); @@ -219,8 +281,8 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { let repo = df_storage::crud::ProjectModuleRepo::new(&db); let module = repo.get_by_id(module_id).await? .ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?; - let commits = run_git_log(&module.path, limit).await; - Ok(serde_json::json!({ "commits": commits })) + let result = run_git_log(&module.path, limit).await; + Ok(result) } ); diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index 3c8288b..37c0863 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -152,6 +152,12 @@ async fn create_with_binding( path: Option, stack: Option, ) -> Result { + // G5.2: name trim + 拒空前置到 insert 之前(防空名/纯空白入库)。 + // create_project / import_project / import_projects_batch 均走本函数。 + let name = name.trim().to_string(); + if name.is_empty() { + return Err("项目名不能为空".to_string()); + } // 绑定目录:校验存在 + 防重复 + 自动探测技术栈 let (path, stack) = match path.as_deref().map(str::trim).filter(|p| !p.is_empty()) { Some(p) => { @@ -159,9 +165,11 @@ async fn create_with_binding( if p.contains("..") { return Err(format!("路径不得包含 '..': {}", p)); } + // G5.2: 目录不存在返 Err(不再静默 create_dir_all 假成功绑空目录)。 + // 对齐 import_project 语义——手误路径应提示确认而非静默建空目录。 + // 确需自动建目录时,由调用方先建目录再创建/绑定。 if !Path::new(p).is_dir() { - // 目录不存在则自动创建(消除建项目→建目录死锁) - std::fs::create_dir_all(p).map_err(|e| format!("目录创建失败: {}", e))?; + return Err(format!("目录不存在: {}", p)); } let normalized = normalize_path(p); if let Some(conflict) = find_binding_conflict(state, &normalized, None).await? { @@ -213,6 +221,8 @@ async fn create_with_binding( sort_order: 0, created_at: now_str.clone(), updated_at: now_str, + description: None, + status: Some("active".to_string()), }; if let Err(e) = state.project_modules.insert(module).await { tracing::warn!("创建项目时自动建工程失败(非阻断): {}", e); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 487e680..9c98e74 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -24,7 +24,7 @@ pub use allowed_dirs::{ pub use knowledge_config::{ ExtractTrigger, KnowledgeConfig, KNOWLEDGE_CONFIG_KEY, APPROVAL_TIMEOUT_KEY, }; -pub use llm_concurrency::LlmConcurrency; +pub use llm_concurrency::{LlmConcurrency, ProviderAcquire}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -311,9 +311,9 @@ impl AppState { crate::commands::ai::restore_pending_approvals(&state).await; // F-260614-04c: 启动一次性初始化 per-provider caps 表。 // 根据 DB enabled providers 建 HashMap,让 agentic loop 的 - // acquire_for_provider 从 None(无限流)切换到 Some(按配置限流)。disabled / weight=0 - // 的 provider 不入表(其被 provider_pool::select 过滤出候选,不会被 acquire)。 - // 单 provider 场景:该 provider cap=global_cap → acquire_global+acquire_for_provider + // try_acquire_for_provider 从 NotConfigured(无限流)切换到 Acquired/Exhausted(按配置限流)。 + // disabled / weight=0 的 provider 不入表(其被 provider_pool::select 过滤出候选,不会被 acquire)。 + // 单 provider 场景:该 provider cap=global_cap → acquire_global+try_acquire_for_provider // 串联,min(3,3)=3,有效上限同未配置 → 行为零变化。 state.reload_provider_caps().await; // F-260619-03 Phase A: 从 Settings KV 加载持久化授权目录白名单覆盖默认值。 diff --git a/src-tauri/src/state/llm_concurrency.rs b/src-tauri/src/state/llm_concurrency.rs index 51514dc..160153e 100644 --- a/src-tauri/src/state/llm_concurrency.rs +++ b/src-tauri/src/state/llm_concurrency.rs @@ -34,9 +34,12 @@ use tokio::sync::{Mutex, Semaphore}; /// /// ## F-260614-04: per-provider 层(可选) /// `per_provider` 为 HashMap>。调用方(agentic loop)经 -/// `acquire_for_provider(pid)` 取额外 permit,防单 provider 被打满(限流 429)。 +/// `try_acquire_for_provider(pid)` 取额外 permit,防单 provider 被打满(限流 429)。 /// **单 provider 场景**:若未调 `set_provider_caps`,HashMap 为空, -/// `acquire_for_provider` 返回 None(无限流,行为同 F-01 前)。零变化保证。 +/// `try_acquire_for_provider` 返 `NotConfigured`(无限流,行为同 F-01 前)。零变化保证。 +/// **三态语义(G4.1,2026-08-05)**:`NotConfigured`(未配置无限流) / `Acquired(permit)` +/// (拿到) / `Exhausted`(占满,非阻塞跳下一 candidate)。修复旧阻塞 `acquire_for_provider` +/// 在主 provider 信号量占满时整条 fallback 链卡死(降级失效)。 /// 全局容量 = min(sum(各 provider 上限), global_cap):由调用方在配置时约束 /// (set_provider_caps 传 min(sum, global_cap)),非运行时强约束。 /// @@ -87,6 +90,26 @@ pub struct LlmConcurrency { per_sub_permits: Arc, } +/// F-260614-04(2026-08-05 G4.1 裁决): per-provider 限流 acquire 的**显式三态**结果。 +/// +/// **None 双关陷阱(本枚举的动机)**:旧 `acquire_for_provider` 用 `Option` 返回值, +/// `None` 既表示「未配置无限流」又可能被 naive 调用方误读为「耗尽」——耗尽语义下 +/// naive 用 None 表耗尽会静默绕过限流打爆 429。三态把两种语义显式拆分: +/// +/// - `NotConfigured`:未配置(per_provider 表无该 provider → 无限流,permits 跳过不落表)。 +/// - `Acquired(permit)`:成功拿到 permit(绑 Semaphore,Drop 自动释放)。 +/// - `Exhausted`:信号量占满(非阻塞 try_acquire 失败),调用方跳下一 candidate。 +pub enum ProviderAcquire { + /// 未配置(per_provider 表无该 provider → 无限流,permits 跳过不落表)。 + /// 与耗尽语义完全区分:调用方无 permit 直接 proceed(单 provider 零变化)。 + NotConfigured, + /// 成功拿到 permit(绑 Semaphore,Drop 自动释放)。 + Acquired(tokio::sync::OwnedSemaphorePermit), + /// 该 provider 信号量占满(try_acquire_owned 失败,非阻塞)。 + /// 调用方跳下一 candidate(候选链降级),不阻塞等待。 + Exhausted, +} + impl LlmConcurrency { pub fn new(global: usize, per_conv: usize) -> Self { Self { @@ -239,33 +262,48 @@ impl LlmConcurrency { self.per_sub_flow.lock().await.clear(); } - /// F-260614-04: 取 per-provider 并发 permit(可选)。 + /// F-260614-04(2026-08-05 G4.1 裁决): per-provider 并发 permit 的**非阻塞三态**版本。 /// - /// - provider 在 `per_provider` 表中有配置 → 取其 Semaphore permit,返回 Some。 - /// - provider 无配置(单 provider 场景或未 set_provider_caps)→ 返回 None,无限流。 + /// **背景**:旧 `acquire_for_provider` 用阻塞 `acquire_owned()`,主 provider 信号量占满时 + /// 整条 fallback 候选链卡死(降级失效)。compress/title 已改 try_acquire,候选链是唯一漏网。 + /// 本方法用 `try_acquire_owned`(非阻塞),三态返回见 [`ProviderAcquire`]: /// - /// 调用方(agentic loop)用法: + /// - `NotConfigured`:per_provider 表无该 provider(未配置 → 无限流,单 provider 零变化)。 + /// - `Acquired(permit)`:成功拿到,permit 随 Drop 自动释放。 + /// - `Exhausted`:信号量占满,调用方跳下一 candidate(候选链降级),不阻塞。 + /// + /// **仅 per-provider 层非阻塞**:global/per_conv 仍由 `acquire_global`/`acquire_per_conv` + /// 阻塞限流(调用方各自持有),不受本方法影响。 + /// + /// 锁持有短(不含 try_acquire),对齐 `acquire_per_sub_flow` 模式。 + /// + /// **调用方用法**(agentic 候选循环): /// ```ignore - /// let _global_permit = llm_concurrency.acquire_global().await; - /// let _per_conv_permit = llm_concurrency.acquire_per_conv(&conv_id).await; - /// let _provider_permit = llm_concurrency.acquire_for_provider(&provider_id).await; + /// match llm_concurrency.try_acquire_for_provider(&candidate.id).await { + /// ProviderAcquire::NotConfigured => { /* 无 permit 直接 proceed */ } + /// ProviderAcquire::Acquired(permit) => { /* 持 permit stream,Drop 自动释放 */ } + /// ProviderAcquire::Exhausted => { /* 跳下一 candidate */ } + /// } /// ``` - /// 三 permit 均绑 guard Drop 自动释放。None 时无 permit 需释放(行为同 F-01 前)。 - pub async fn acquire_for_provider( + pub async fn try_acquire_for_provider( &self, provider_id: &str, - ) -> Option { + ) -> ProviderAcquire { let sema = { let map = self.per_provider.lock().await; map.get(provider_id).cloned() - }?; - // Semaphore 存在 → acquire。expect 同 acquire_global/per_conv:Semaphore 不会 close - // (无 close() 调用,仅在 set_provider_caps 时替换表内 Arc,旧 Arc permit 仍有效)。 - Some( - sema.acquire_owned() - .await - .expect("llm per_provider semaphore closed"), - ) + }; + match sema { + // 无配置:无限流(permits 跳过不落表),显式区分于耗尽。 + None => ProviderAcquire::NotConfigured, + Some(sema) => match sema.try_acquire_owned() { + // 成功拿到:permit 绑 Semaphore,Drop 自动释放。 + Ok(permit) => ProviderAcquire::Acquired(permit), + // 占满:非阻塞返 Exhausted,调用方跳下一 candidate(降级)。 + // try_acquire_owned 不会 Err 于 closed(Semaphore 无 close() 调用)。 + Err(_) => ProviderAcquire::Exhausted, + }, + } } /// F-260614-04: 批量设置 per-provider 并发上限(替换整表)。 @@ -280,10 +318,107 @@ impl LlmConcurrency { map.clear(); for (pid, permits) in caps { // permits=0 等同无配置(Semaphore::new(0) 永远 acquire 不到 → 死锁), - // 故 permits=0 跳过(不落表 → acquire_for_provider 返 None → 无限流)。 + // 故 permits=0 跳过(不落表 → try_acquire_for_provider 返 NotConfigured → 无限流)。 if permits > 0 { map.insert(pid, Arc::new(Semaphore::new(permits))); } } } } + +#[cfg(test)] +mod tests { + use super::*; + + // ============================================================ + // F-260614-04(2026-08-05 G4.1): try_acquire_for_provider 三态测试 + // NotConfigured / Acquired / Exhausted 显式区分(修 None 双关:None='未配置无限流', + // naive 用 None 表耗尽会静默绕过限流)。仅验证 per-provider 层三态语义, + // global/per_conv 仍阻塞限流(不经本方法)。 + // ============================================================ + + /// 未配置(空 per_provider 表)→ NotConfigured:无限流,单 provider 零变化。 + #[tokio::test] + async fn try_acquire_for_provider_not_configured() { + let lc = LlmConcurrency::new(3, 2); + let r = lc.try_acquire_for_provider("p1").await; + assert!(matches!(r, ProviderAcquire::NotConfigured)); + } + + /// 配置且有空闲 permit → Acquired(permit 绑 Semaphore,Drop 自动释放)。 + #[tokio::test] + async fn try_acquire_for_provider_acquired() { + let lc = LlmConcurrency::new(3, 2); + let mut caps = HashMap::new(); + caps.insert("p1".to_string(), 2usize); + lc.set_provider_caps(caps).await; + + let r = lc.try_acquire_for_provider("p1").await; + let permit = match r { + ProviderAcquire::Acquired(p) => p, + other => panic!("配置后应返回 Acquired,实际 {:?}", discriminant(&other)), + }; + // Drop 释放后仍可再拿(permit 绑旧 Semaphore Arc,set 后计数守恒)。 + drop(permit); + let r2 = lc.try_acquire_for_provider("p1").await; + assert!(matches!(r2, ProviderAcquire::Acquired(_))); + } + + /// 配置但信号量占满 → Exhausted(非阻塞,不等待)。 + #[tokio::test] + async fn try_acquire_for_provider_exhausted() { + let lc = LlmConcurrency::new(3, 2); + let mut caps = HashMap::new(); + caps.insert("p1".to_string(), 1usize); // 仅 1 permit + lc.set_provider_caps(caps).await; + + // 第一次拿到(permit 仍持于作用域内,不 Drop)。 + let first = lc.try_acquire_for_provider("p1").await; + assert!(matches!(first, ProviderAcquire::Acquired(_))); + // 第二次耗尽:信号量满,非阻塞返 Exhausted。 + let second = lc.try_acquire_for_provider("p1").await; + assert!(matches!(second, ProviderAcquire::Exhausted)); + + // Drop 首 permit 后恢复可再拿。 + drop(first); + let third = lc.try_acquire_for_provider("p1").await; + assert!(matches!(third, ProviderAcquire::Acquired(_))); + } + + /// 配置后清空(传空 map)→ 回退 NotConfigured(permits 跳过不落表)。 + #[tokio::test] + async fn try_acquire_for_provider_not_configured_after_caps_cleared() { + let lc = LlmConcurrency::new(3, 2); + let mut caps = HashMap::new(); + caps.insert("p1".to_string(), 1usize); + lc.set_provider_caps(caps).await; + lc.set_provider_caps(HashMap::new()).await; // 清空整表 + + let r = lc.try_acquire_for_provider("p1").await; + assert!(matches!(r, ProviderAcquire::NotConfigured)); + } + + /// 配置了 p1,未配置 p2 → p1 Acquired / p2 NotConfigured(按 provider 区分,不互相污染)。 + #[tokio::test] + async fn try_acquire_for_provider_distinct_providers() { + let lc = LlmConcurrency::new(3, 2); + let mut caps = HashMap::new(); + caps.insert("p1".to_string(), 1usize); + lc.set_provider_caps(caps).await; + + // p1 配置 → Acquired;p2 未配置 → NotConfigured。 + assert!(matches!(lc.try_acquire_for_provider("p1").await, ProviderAcquire::Acquired(_))); + assert!(matches!(lc.try_acquire_for_provider("p2").await, ProviderAcquire::NotConfigured)); + // p1 仅 1 permit 已满 → p2 不受影响。 + assert!(matches!(lc.try_acquire_for_provider("p2").await, ProviderAcquire::NotConfigured)); + } + + /// 辅助:仅用于 panic 信息里给枚举变体一个 Debug 标签(无需求时保持简单)。 + fn discriminant(r: &ProviderAcquire) -> &'static str { + match r { + ProviderAcquire::NotConfigured => "NotConfigured", + ProviderAcquire::Acquired(_) => "Acquired", + ProviderAcquire::Exhausted => "Exhausted", + } + } +}