优化: 边界加固(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
+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");
}
}