优化: 存储层清债与子轨迹来源迁移

- V44 加 ai_conversations.origin 列(子代理轨迹来源标记)
- V21 断点续迁改造(逐对话进度判定,崩溃重跑补漏)
- 手写事务改 guard.transaction,panic 不残留打开事务
- 软删 CAS 补 deleted_at IS NULL(任务/项目/灵感三处)
- 父任务校验拒回收站挂子,删模块先清依赖边
- 列缺失与未知 status 降级补 warn 留痕,limit=0 返空集
This commit is contained in:
lxy
2026-10-03 15:34:55 +08:00
parent 3f4c914dbf
commit 499aa49e49
10 changed files with 796 additions and 84 deletions
+61 -3
View File
@@ -27,7 +27,16 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
description: row.get("description")?,
status: {
let s: String = row.get("status")?;
TaskStatus::from_db_str(&s).unwrap_or_default()
match TaskStatus::from_db_str(&s) {
Some(v) => v,
None => {
// 未知 status 静默降级默认值后,后续 update_full 回写会把库里的未知值
// 覆盖成默认值(读→改→写放大静默),warn 留痕让脏数据可发现。
let id: String = row.get("id")?;
tracing::warn!("tasks.id={id} 含未知 status {s:?},按默认值降级(回写会覆盖原值,请核查)");
Default::default()
}
}
},
priority: row.get("priority")?,
branch_name: row.get("branch_name")?,
@@ -219,6 +228,30 @@ impl TaskRepo {
.map_err(storage_err)?
}
/// 按 id 查未删除任务(`WHERE id AND deleted_at IS NULL`)。
///
/// 专供 [`validate_parent_id`](super::task_validation) 的父任务存在性检查:
/// 回收站父任务(soft_delete 已标 deleted_at)对"挂子任务"视角等同不存在,
/// 通用 get_by_id 不带软删过滤会让子任务挂到回收站父下(⑤走查项)。
/// 列清单与 get_children 一致(TaskRecord 不带 deleted_at,不 SELECT 该列)。
pub async fn get_by_id_active(&self, id: &str) -> Result<Option<TaskRecord>> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT 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, module_id, created_at, updated_at FROM tasks WHERE id = ?1 AND deleted_at IS NULL")
.map_err(storage_err)?;
let row = stmt
.query_row(params![id], |row| task_from_row(row))
.optional()
.map_err(storage_err)?;
Ok(row)
})
.await
.map_err(storage_err)?
}
/// 原子推进任务状态(任务推进链唯一 status 写入路径)
///
/// 下沉 SQL `WHERE id=? AND status=?expected` 做 CAS(Compare-And-Swap)防 TOCTOU:
@@ -784,7 +817,8 @@ impl TaskRepo {
/// 而非静默覆盖。对齐 [`advance_status_atomic`](同款 `WHERE ... = ?expected` 原子条件写)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_task)。`Ok(false)` = id 不存在或版本冲突。
/// 需要乐观锁版本的调用方(df-mcp update_task)。`Ok(false)` = id 不存在、已进回收站
/// (deleted_at IS NULL 守卫)或版本冲突。
pub async fn update_full_cas(
&self,
record: &TaskRecord,
@@ -797,7 +831,9 @@ impl TaskRepo {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, updated_at = ?17 WHERE id = ?18 AND updated_at = ?19",
// deleted_at IS NULL:回收站任务不可被 CAS 覆盖——MCP 拿旧 expected 也能
// 命中已软删行,造成回收站内容被改且 updated_at 抖动,调用方无从感知。
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, updated_at = ?17 WHERE id = ?18 AND updated_at = ?19 AND deleted_at IS NULL",
params![
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
@@ -1299,6 +1335,28 @@ mod tests {
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn update_full_cas_skips_soft_deleted() {
// 回收站任务不可被 CAS 命中(deleted_at IS NULL 守卫):即使 expected 与 DB
// updated_at 一致,已软删行也拒绝写入,防 MCP 对回收站任务静默改写。
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
repo.soft_delete("t1").await.unwrap();
// soft_delete 刷新了 updated_at,取当前值作 expected(排除版本不匹配干扰)
let current = repo.get_by_id("t1").await.unwrap().unwrap();
let mut rec = current.clone();
rec.title = "回收站里改标题".to_string();
rec.updated_at = "1900000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(!ok, "软删任务 CAS 应拒绝(deleted_at 守卫)");
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "task-t1", "回收站任务内容不应被改写");
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
}
// ============================================================
// LW-5 count_by_query 维度对齐 list_by_query(防 count/list 漂移翻页)
// ============================================================