新增: 父子任务支持(数据→后端→前端全链路,会话前基线收尾)

- df-nodes task_advance_node(父聚合推进)+ task.rs 命令(create parent_id 支持/delete 级联软删子任务)+ task_graph 工具

- 前端 Tasks 树形列表(折叠箭头/子进度徽章/缩进)+ 新建弹窗父任务下拉 + TaskDetail 父面包屑/子任务面板

- 设计文档: 父子任务支持设计-2026-08-04
This commit is contained in:
lxy
2026-08-05 22:15:01 +08:00
parent 71fdaac1b4
commit 28de5d6143
13 changed files with 1097 additions and 307 deletions
+4 -3
View File
@@ -203,10 +203,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let target_status = args["target_status"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
// 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds),
// 与 commands::task::advance_task IPC 同源,避免双轨。
// 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds + 父聚合),
// 与 commands::task::advance_task IPC 同源,避免双轨。设计 D3:子任务推进后
// 自动触发父 status 聚合(advance_task_with_parent,父聚合失败仅 warn 不阻断)。
let repo = df_storage::crud::TaskRepo::new(&db);
let updated = df_nodes::task_advance_node::advance_task_atomic(
let updated = df_nodes::task_advance_node::advance_task_with_parent(
&repo, id, target_status,
).await?;
// 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。
+6 -42
View File
@@ -138,11 +138,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// ── move_task_queue (Medium,设计 §2.1 + §五) ──
// 跨池移动任务(backlog/todo/decision/active/done),按一致性约束联动 status。
// 对齐 commands::task::move_task_queue 语义:
// 全部逻辑收口到 TaskRepo::move_task_queue 单事务方法(读当前 → 联动 status →
// 写 queue+status 于同一 transaction),与 commands::task::move_task_queue 共用,防漂移。
// - done → status 强制 done / backlog → status 强制 todo
// - active → status 若不在执行中三态则强制 in_progress / todo → status 强制 todo
// - decision → status 不变(待决策池保留执行态)
// status 写入走专用 set_status_for_aggregation(绕过 status 收口,move_task_queue 是合法非状态机路径)。
declare_tool!(
registry,
db: Arc<Database>,
@@ -156,50 +156,14 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
.ok_or_else(|| anyhow::anyhow!("缺少 new_queue"))?
.trim()
.to_string();
// queue 白名单校验(对标 commands::task::validate_queue)
const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
if !TASK_QUEUE_VALUES.contains(&new_queue.as_str()) {
anyhow::bail!("非法 new_queue 值 {:?},合法值: {:?}", new_queue, TASK_QUEUE_VALUES);
}
// 单事务原子移动:读当前 → 一致性联动 status → 写 queue+status 全在
// TaskRepo::move_task_queue 同一 transaction 内完成(queue 白名单/联动规则单点)。
let repo = df_storage::crud::TaskRepo::new(&db);
let current = repo
.get_by_id(id)
let updated = repo
.move_task_queue(id, &new_queue)
.await?
.ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在"))?;
// 一致性约束联动:根据 new_queue 决定 status 是否需调整
// (对标 commands::task::move_task_queue 同源逻辑,单一真相源)
const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"];
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()
}
}
"todo" => "todo".to_string(),
"decision" => current.status.as_str().to_string(),
_ => unreachable!("queue 白名单已收口"),
};
// 写 queue(queue 已在 tasks 白名单);与 current 不同才写(避免无谓 updated_at 抖动)
if current.queue != new_queue {
repo.update_field(id, "queue", &new_queue).await?;
}
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径)
if current.status.as_str() != new_status {
repo.set_status_for_aggregation(id, &new_status).await?;
}
// 回读最新记录返回
let updated = repo
.get_by_id(id)
.await?
.ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在(移动后回读失败)"))?;
Ok(serde_json::to_value(&updated)?)
}
);
+53 -145
View File
@@ -440,13 +440,39 @@ pub async fn update_task(
Ok(true)
}
/// 删除任务结果(级联删子任务后返回)
#[derive(Debug, Serialize)]
pub struct TaskDeleteResult {
/// 是否删除成功
pub ok: bool,
/// 级联软删的子任务数
pub cascaded: i32,
}
/// 删除任务(软删 → 回收站,可恢复)。对标 delete_project(SET deleted_at=now)。
///
/// **级联软删子任务(设计 D4,容器语义)**:删除父任务即删整个工作单元。先 `get_children(id)`
/// 取未删子任务列表,逐个 `soft_delete` 计数(cascaded),再 `soft_delete(id)` 父任务。
/// 1 级嵌套(设计 D2),仅一层子任务,无需递归。
///
/// 埋点 task_deleted(问题3 项目最近活跃排序):删除是业务事件,推动项目活跃时间。
/// best-effort 不阻断。读 project_id 一次轻量读(soft_delete 返 bool 不带 project_id)。
#[tauri::command]
pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
pub async fn delete_task(
state: State<'_, AppState>,
id: String,
) -> Result<TaskDeleteResult, String> {
let rec = state.tasks.get_by_id(&id).await.map_err(err_str)?;
// 级联软删子任务(容器语义 D4):逐个 soft_delete 并计数
let children = state.tasks.get_children(&id).await.map_err(err_str)?;
let mut cascaded = 0;
for child in &children {
let child_ok = state.tasks.soft_delete(&child.id).await.map_err(err_str)?;
if child_ok {
cascaded += 1;
}
}
// 软删父任务本身
let ok = state.tasks.soft_delete(&id).await.map_err(err_str)?;
if ok {
if let Some(r) = rec {
@@ -462,7 +488,7 @@ pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool,
.await;
}
}
Ok(ok)
Ok(TaskDeleteResult { ok, cascaded })
}
/// 恢复任务(从回收站还原,清 deleted_at)。对标 restore_project。
@@ -491,16 +517,16 @@ pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
///
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)
/// 落 df-nodes::task_advance_node::advance_task_atomic,本命令只做参数转发与错误串化。
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加 + 父聚合)
/// 落 df-nodes::task_advance_node,本命令只做参数转发与错误串化。
///
/// 流程:读当前态 → can_transition 校验 → 下沉 SQL `WHERE id AND status=expected`
/// 防 TOCTOU → 退回转换一并 review_rounds+=1。失败均返回 Err(状态机/TOCTOU/任务不存在)。
///
/// **知识图谱 Phase 1 V29 父聚合(对标设计 §2.1)**:推进完成后,若推进的任务有 parent_id,
/// 触发父任务 status 重算(recompute_parent_status)。父任务=容器模型,status 不走状态机,
/// 由子任务聚合计算(聚合规则见 recompute_parent_status)。聚合失败不阻断推进(best-effort,
/// 对标设计 §十一「事件流写入失败不阻断主操作」同类宽容语义)。
/// **知识图谱 Phase 1 V29 父聚合(设计 §2.1)**:推进走 `advance_task_with_parent`,推进完成后
/// 若任务有 parent_id,自动触发父任务 status 重算(recompute_parent_status,df-nodes 共享层
/// D3)。父任务=容器模型,status 不走状态机,由子任务聚合计算。聚合失败不阻断推进(best-effort,
/// 设计 §十一「事件流写入失败不阻断主操作」同类宽容语义)。
///
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
#[tauri::command]
@@ -518,7 +544,9 @@ pub async fn advance_task(
.map_err(err_str)?
.map(|t| t.status);
let updated = df_nodes::task_advance_node::advance_task_atomic(
// 推进 + 父聚合(df-nodes 共享层 D3,与 AI 工具/MCP 同源,消除双轨):
// 子任务推进成功后自动触发父 status 聚合(容器模型,聚合失败仅 warn 不阻断)。
let updated = df_nodes::task_advance_node::advance_task_with_parent(
&state.tasks,
&id,
&target_status,
@@ -526,21 +554,6 @@ pub async fn advance_task(
.await
.map_err(err_str)?;
// 父聚合(知识图谱 Phase 1 V29):推进的子任务有 parent_id → 重算父 status。
// best-effort:聚合失败不阻断推进(子任务已成功推进是主结果,父 status 漂移可后续修正),
// 仅 warn 日志记录。父任务 status 不走状态机,经专用方法 set_status_for_aggregation
// 直接写(绕过 D-260616-04 status 收口:父任务=容器模型,聚合规则是唯一非状态机写入路径)。
if let Some(pid) = &updated.parent_id {
if let Err(e) = recompute_parent_status(&state, pid).await {
tracing::warn!(
task_id = %id,
parent_id = %pid,
error = %e,
"[父聚合] 重算父任务 status 失败(不阻断子任务推进)"
);
}
}
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_advanced 事件。best-effort 不阻断。
emit_event(
&state,
@@ -556,84 +569,6 @@ pub async fn advance_task(
Ok(updated)
}
// ============================================================
// 知识图谱 Phase 1:父任务聚合(对标设计 §2.1 父聚合规则)
// ============================================================
/// 父任务 status 重算(对标设计 §2.1 聚合规则,D3 父任务=容器模型)。
///
/// 聚合规则(设计 §2.1「父任务推导 status」表,优先级从高到低):
/// 1. 任一子 blocked → 父 blocked(阻塞优先,避免掩盖卡点)
/// 2. 任一子 in_progress → 父 in_progress(执行中)
/// 3. 全子 done/cancelled → 父 done(全部完成/取消)
/// 4. 全子 todo → 父 todo(尚未开始)
/// 5. 其他混合态(如 todo+done) → 父 in_progress(进行中,有进展未全完)
///
/// 触发时机:advance_task 子任务推进成功后,若子任务有 parent_id 则调本函数。
/// 数据源:count_children_by_status(一次 GROUP BY 查询,数据量小无压力)。
/// 写入:set_status_for_aggregation(父任务 status 唯一非状态机写入路径)。
///
/// 返回:重算后的父任务最新 status(若与当前相同则不写,返当前值)。
async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -> Result<String, String> {
let counts = state
.tasks
.count_children_by_status(parent_id)
.await
.map_err(err_str)?;
// 无子任务(parent_id 悬空,理论上不该发生):不重算,返当前 status
if counts.is_empty() {
return state
.tasks
.get_by_id(parent_id)
.await
.map_err(err_str)?
.map(|t| t.status.as_str().to_string())
.ok_or_else(|| format!("父任务 {parent_id} 不存在"));
}
// 转 HashMap<status, count> 便于按规则判定
let map: std::collections::HashMap<String, i64> = counts.into_iter().collect();
let total: i64 = map.values().sum();
let blocked = map.get("blocked").copied().unwrap_or(0);
let in_progress = map.get("in_progress").copied().unwrap_or(0);
let todo = map.get("todo").copied().unwrap_or(0);
let done = map.get("done").copied().unwrap_or(0);
let cancelled = map.get("cancelled").copied().unwrap_or(0);
// 聚合规则判定(优先级从高到低,首个命中即定)
let new_status = if blocked > 0 {
"blocked".to_string()
} else if in_progress > 0 {
"in_progress".to_string()
} else if (done + cancelled) == total {
// 全 done/cancelled → done(终端态聚合为 done)
"done".to_string()
} else if todo == total {
// 全 todo → todo(尚未开始)
"todo".to_string()
} else {
// 其他混合态(如 todo+done, in_review+done 等)→ in_progress(进行中)
"in_progress".to_string()
};
// 读当前父 status,相同则不写(避免无谓 updated_at 抖动)
let current = state
.tasks
.get_by_id(parent_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("父任务 {parent_id} 不存在"))?;
if current.status.as_str() == new_status {
return Ok(new_status);
}
state
.tasks
.set_status_for_aggregation(parent_id, &new_status)
.await
.map_err(err_str)?;
Ok(new_status)
}
// ============================================================
// 知识图谱 Phase 1:task_link CRUD IPC(对标设计 §2.2 + §五 AI 工具表)
// ============================================================
@@ -745,8 +680,11 @@ pub async fn list_task_links(
/// 父任务(容器模型)也可 move_task_queue(其 status 由聚合规则管,本命令仅联动改 status
/// 以满足一致性约束,聚合规则在子任务推进时仍会重算)。
///
/// queue/status 均走白名单校验(status 写入用专用 set_status_for_aggregation 绕过 status 收口,
/// 因 move_task_queue 是合法的非状态机 status 联动路径,非 advance_task 状态机路径)。
/// 一致性联动 + 写 queue/status 收口到 `TaskRepo::move_task_queue` **单事务原子方法**
/// (G1.4:读当前 → 联动 status → 写 queue+status 于同一 transaction,防两段式独立写的
/// 非原子中间态),与 AI 工具共用防漂移。status 联动绕过状态机收口(move_task_queue 是
/// 合法的非状态机 status 联动路径,非 advance_task 状态机路径),soft 删回收站任务不可
/// move(repo 方法返回 None)。
#[tauri::command]
pub async fn move_task_queue(
state: State<'_, AppState>,
@@ -756,7 +694,7 @@ pub async fn move_task_queue(
let new_queue = new_queue.trim().to_string();
validate_queue(&new_queue)?;
// 读当前任务(取当前 status 做一致性联动决策)
// 读当前(取旧 queue 做事件埋点决策;一致性联动 + 原子写全在 repo 单事务内完成)。
let current = state
.tasks
.get_by_id(&id)
@@ -764,39 +702,15 @@ pub async fn move_task_queue(
.map_err(err_str)?
.ok_or_else(|| format!("任务 {id} 不存在"))?;
// 一致性约束联动:根据 new_queue 决定 status 是否需调整
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(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态)
"decision" => current.status.as_str().to_string(), // 待决策池保留当前 status(暂停推进不重置执行态)
_ => unreachable!("validate_queue 已收口"),
};
// 写 queue:走通用 update_field(queue 已在 tasks 白名单登记,知识图谱 Phase 1 V29 新增)。
// 与 current.queue 不同才写(避免无谓 updated_at 抖动)。
if current.queue != new_queue {
state
.tasks
.update_field(&id, "queue", &new_queue)
.await
.map_err(err_str)?;
}
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径)
if current.status.as_str() != new_status {
state
.tasks
.set_status_for_aggregation(&id, &new_status)
.await
.map_err(err_str)?;
}
// 单事务原子移动(读当前 → 联动 status → 写 queue+status,见 TaskRepo::move_task_queue)。
// G1.4:此前两段式(update_field queue + set_status_for_aggregation)两次独立写非原子,
// 可被并发读/写破坏;现收口为单一 repo 方法,commands 与 AI 工具共用防漂移。
let updated = state
.tasks
.move_task_queue(&id, &new_queue)
.await
.map_err(err_str)?
.ok_or_else(|| format!("任务 {id} 不存在"))?;
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。
@@ -821,13 +735,7 @@ pub async fn move_task_queue(
.await;
}
// 回读最新记录返回
state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("任务 {id} 不存在(移动后回读失败)"))
Ok(updated)
}
// ============================================================