优化: 边界加固(AI loop竞态根治+数据/审批/反馈/并发/错误分类)

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 绑定修
This commit is contained in:
lxy
2026-08-05 22:10:32 +08:00
parent ec9f0bf1ea
commit 5667da6cf4
16 changed files with 1696 additions and 215 deletions
@@ -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<bool> {
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)?
}
}
// ============================================================
+70 -7
View File
@@ -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<bool> {
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)?;
+220 -1
View File
@@ -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<Option<TaskRecord>> {
// 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<TaskRecord> = {
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(&current.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<TaskRecord> = {
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");
}
}
+137 -21
View File
@@ -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<GoalEntry>)管理,原先仅在内存态存在,
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
fn migrate_v32(conn: &Connection) -> Result<()> {
// 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");
}
}
+12 -1
View File
@@ -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<String>,
/// 工程状态:active(活跃)/ archived(归档)。V40 加列,老工程 None 视为 active。
#[serde(default)]
pub status: Option<String>,
}
/// 工程依赖关系记录(V35 module_dependencies 表)。
+51 -3
View File
@@ -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<AtomicU64>,
/// 本 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<AtomicU64>,
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 {
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)"
);
}
}
}
+89 -10
View File
@@ -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"));
}
}
+282 -32
View File
@@ -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<String>,
// 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<df_ai::provider::ToolDefinition> = 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<String> = 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<String, usize> = 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<AiProviderRecord> = 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<u32, super::ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>)> = None;
@@ -1472,10 +1588,33 @@ pub(crate) async fn run_agentic_loop(
let mut last_exhausted_error: Option<String> = 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<std::sync::atomic::AtomicU64>,
) {
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<bool>,
publish_incomplete: Option<bool>,
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::<AppState>();
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<Mutex<AiSession>>,
db: &Arc<Database>,
@@ -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<bool>,
publish_incomplete: Option<bool>,
do_publish: bool,
pinned_goals: &[super::GoalEntry],
app_handle: &AppHandle,
loop_epoch_arc: &std::sync::Arc<std::sync::atomic::AtomicU64>,
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<u32> = tool_calls_acc.keys().copied().collect();
order.sort_unstable();
+182 -51
View File
@@ -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();
// ── ① 先停 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;
// 取活跃对话 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(), "会话已清除");
if let Some(ref id) = active_id {
session.conv(id).messages.clear();
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 非法(不阻断清空)"
);
}
}
// 阶段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 {
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 {
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;
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();
// B-Phase3:读侧切无锁 conv_states。
let still_gen = if target_owned.is_empty() {
false
} else {
app.state::<AppState>().conv_states.is_active(&target_owned)
if target_owned.is_empty() {
return;
}
// 读侧无锁 conv_states:loop 已自行复位(Idle)→ 正常收尾,兜底退出。
if !app.state::<AppState>().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)
};
if still_gen {
// B-Phase3:conv_state 写切 ConvStateStore 单源。
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::<AppState>().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::<AppState>().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,
+54
View File
@@ -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<bool>,
conversation_id: Option<String>,
@@ -319,6 +324,23 @@ pub enum AiChatEvent {
/// 本机技能列表(ai_list_skills 返回的进程内缓存,已按 skills>commands>plugins 优先级去重)
skills: Vec<crate::commands::ai::skills::SkillInfo>,
},
/// 跨端模型列表同步(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<ModelInfo>,
},
/// 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<String>,
}
/// Plan 层信息(AiPlanCreated 事件的载荷)
#[derive(Debug, Clone, serde::Serialize)]
pub struct PlanLayerInfo {
@@ -879,6 +915,22 @@ pub struct PerConvState {
pub stop_flag: Arc<AtomicBool>,
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
pub notify: Arc<tokio::sync::Notify>,
/// 并发 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<std::sync::atomic::AtomicU64>,
/// 存活心跳(F2 根治,2026-08-05):loop 活跃期间由 `heartbeat_loop` 每 20s 更新(ms 时间戳)。
///
/// `ai_chat_stop` 3s 兜底据此判断 loop 是否真死:超阈值(60s)无心跳 = 真死才强制复位 +
/// emit AiCompleted;loop 还在跑(长工具执行/流式/压缩,心跳持续)则豁免,不误迁 Idle、
/// 不产生「假完成 + 旧 loop 仍活」的双完成竞态。
pub last_heartbeat: Arc<std::sync::atomic::AtomicU64>,
/// 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,
+130 -11
View File
@@ -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<String>,用户自定义别名;缺失由前端回退 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<ModelInfo> = 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<String>,空字符串规整为 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<String> = 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<String> {
state.ai_session.lock().await.active_conversation_id.clone()
}
// ============================================================
// args 字段提取 helper(从 serde_json::Value 稳健取值)
// ============================================================
+118 -2
View File
@@ -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_retryableA2-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:业务层 bailprovider 串已含状态码)----
/// provider 在 non-2xx bail 的典型串:抠出 401(鉴权失败/Key 错)
+96 -34
View File
@@ -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<i32>):
/// - String: 成功时 stdout;失败(exit != 0)时 stderr 非空则 stderr,否则 stdout;
/// 启动失败/超时 → 空串。
/// - Option<i32>: 正常退出为 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<i32>) {
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<serde_json::Value>) {
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<serde_json::Value> = 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<serde_json::Value> = 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::Value>) {
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"];
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<serde_json::Value> {
///
/// 成功判定: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<serde_json::Value> = 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<serde_json::Value>
}))
} 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<Database>) {
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<Database>) {
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)
}
);
+12 -2
View File
@@ -152,6 +152,12 @@ async fn create_with_binding(
path: Option<String>,
stack: Option<String>,
) -> Result<ProjectRecord, String> {
// 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);
+4 -4
View File
@@ -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<provider_id, global_cap>,让 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 加载持久化授权目录白名单覆盖默认值。
+156 -21
View File
@@ -34,9 +34,12 @@ use tokio::sync::{Mutex, Semaphore};
///
/// ## F-260614-04: per-provider 层(可选)
/// `per_provider` 为 HashMap<provider_id, Arc<Semaphore>>。调用方(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<AtomicUsize>,
}
/// 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<tokio::sync::OwnedSemaphorePermit> {
) -> 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",
}
}
}