916 lines
41 KiB
Rust
916 lines
41 KiB
Rust
//! 任务推进节点 — advance_task 推进链触发器。
|
|
//! 任务推进节点 — advance_task 推进链触发器。
|
|
//!
|
|
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
|
|
//! 1. 读当前 TaskRecord(取 from status)
|
|
//! 2. 状态机校验 can_transition(from, to)(task_state_machine)
|
|
//! 3. 原子写:下沉 SQL `WHERE id AND status=expected` 防 TOCTOU,退回转换一并
|
|
//! review_rounds+=1(见 df_storage::crud::TaskRepo::advance_status_atomic)
|
|
//!
|
|
//! 两条调用路径复用同一推进逻辑:
|
|
//! - DAG 工作流:TaskAdvanceNode 实现 Node trait,从 NodeContext.config 读 task_id /
|
|
//! target_status,持有 Arc<Database> 构造 TaskRepo(严格单跳,advance_task_atomic)。
|
|
//! - IPC 直驱:advance_task_atomic 公开 async fn,src-tauri commands::task::advance_task
|
|
//! thin 入口直接调用(无需构造 NodeContext,避免 EventBus/StateMachine 等重依赖)。
|
|
//!
|
|
//! auto_path 层(默认开启):advance_task_with_parent(IPC/AI 工具/MCP 同源入口)走
|
|
//! advance_task_auto_path,跳态目标按 shortest_path 自动逐跳推进(每跳独立校验 +
|
|
//! review_rounds 回归计数照旧);直达单跳与严格路径行为一致,workflow.rs 回调等
|
|
//! 机器生成单跳场景仍直用 advance_task_atomic 保持严格语义。
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use df_storage::crud::TaskRepo;
|
|
use df_storage::db::Database;
|
|
use df_storage::models::TaskRecord;
|
|
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
|
|
|
use crate::task_state_machine::{can_transition, is_regression, is_valid_state, ALL_STATES};
|
|
|
|
/// 推进任务到目标状态(核心逻辑,DAG 节点与 IPC 入口共用)。
|
|
///
|
|
/// 入参:
|
|
/// - `repo`:df-storage TaskRepo
|
|
/// - `id`:任务 ID
|
|
/// - `target_status`:目标状态(8 态之一,snake_case)
|
|
///
|
|
/// 流程:
|
|
/// 1. 任务存在性:找不到 → NotFound 错误(前端可据此提示)
|
|
/// 2. target_status 合法性:非 8 态 → Validation 错误(防脏数据直入推进链)
|
|
/// 3. 状态机(三类拒绝,错误区分供前端分辨):
|
|
/// - 同态拒绝(from==to):Validation「相同状态,无需推进」(非状态机违例,是空操作)
|
|
/// - 非法转换(跳态/终态后继等):InvalidState「非法状态转换 X→Y」(含 from/to 上下文)
|
|
/// - 两类拒绝均附 legal_targets(from) 合法目标列表,供 LLM 下次选对目标态
|
|
/// - can_transition 闸门矩阵判否即此分支
|
|
/// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true
|
|
/// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功)
|
|
///
|
|
/// 返回:成功推进后的最新 TaskRecord(含新 status / 累加后的 review_rounds / 新 updated_at)。
|
|
pub async fn advance_task_atomic(
|
|
repo: &TaskRepo,
|
|
id: &str,
|
|
target_status: &str,
|
|
) -> df_types::error::Result<TaskRecord> {
|
|
// 1. target 合法性(8 态之一)。先于读库校验:即便任务不存在,也先拒绝非法状态值。
|
|
if !is_valid_state(target_status) {
|
|
return Err(df_types::error::Error::Validation(format!(
|
|
"非法 target_status {:?},合法值: {}",
|
|
target_status,
|
|
ALL_STATES.join("/")
|
|
)));
|
|
}
|
|
|
|
// 2. 读当前任务(取 from status)。
|
|
let current = repo
|
|
.get_by_id(id)
|
|
.await?
|
|
.ok_or_else(|| df_types::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
|
|
|
|
// 3. 状态机校验(三类拒绝,错误类型区分供前端分辨):
|
|
// - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例)
|
|
// - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」
|
|
// - 两类错误均附加 legal_targets(from) 合法目标列表(当前态→可去态),
|
|
// 让 LLM 下次直接选对目标态,降低状态机拒绝的往返次数(AC-5 机制降失败)。
|
|
// - 非法转换额外附最短路径(如"deferred→todo→in_progress→blocked"),比
|
|
// 纯合法目标列表更直观(SC-260811-P1-1 状态机最短路径推导)。
|
|
let from = current.status.as_str();
|
|
let legal_hint = |from: &str, to_hint: Option<&str>| -> String {
|
|
let legal = crate::task_state_machine::legal_targets(from);
|
|
let base = if legal.is_empty() {
|
|
format!("{from} 是终态, 无合法后继")
|
|
} else {
|
|
format!("{from} 的合法目标: {}", legal.join("/"))
|
|
};
|
|
// 当调用方明确知道目标态(非法转换分支),附加最短路径。
|
|
if let Some(target) = to_hint {
|
|
let path = crate::task_state_machine::shortest_path(from, target);
|
|
if path.len() > 2 {
|
|
// 路径比 2 长说明需要多跳,附路径提示。
|
|
return format!("{}, 合法路径: {}", base, crate::task_state_machine::format_path(&path));
|
|
}
|
|
}
|
|
base
|
|
};
|
|
if from == target_status {
|
|
return Err(df_types::error::Error::Validation(format!(
|
|
"相同状态 {from:?},无需推进,{}",
|
|
legal_hint(from, None)
|
|
)));
|
|
}
|
|
if !can_transition(from, target_status) {
|
|
let hint = legal_hint(from, Some(target_status));
|
|
return Err(df_types::error::Error::InvalidState {
|
|
current: format!("{from}→{target_status}(非法状态转换), {hint}"),
|
|
expected: target_status.to_string(),
|
|
});
|
|
}
|
|
|
|
// 4. 原子 CAS 写。退回转换一并 review_rounds+=1。
|
|
let bump = is_regression(from, target_status);
|
|
let updated = repo
|
|
.advance_status_atomic(id, from, target_status, bump)
|
|
.await?;
|
|
|
|
// 5. CAS 失败:并发已改动 status(或任务被删),拒绝静默成功。
|
|
updated.ok_or_else(|| df_types::error::Error::InvalidState {
|
|
current: "已变更(并发推进或旁路修改)".to_string(),
|
|
expected: from.to_string(),
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// auto_path — 跳态目标自动逐跳推进(治 LLM 直取跳态被状态机拒绝的空耗往返)
|
|
// ============================================================
|
|
|
|
/// advance_task 自动路径推进(auto_path):目标不可直达时按最短路径逐跳推进。
|
|
///
|
|
/// 背景:实测 LLM 反复请求跳态推进(todo→done / in_progress→done),错误文本虽已附
|
|
/// shortest_path 提示,LLM 仍不走多跳,空耗往返。根修:跳态目标自动按
|
|
/// [`crate::task_state_machine::shortest_path`] 逐跳推进,每跳独立走
|
|
/// [`advance_task_atomic`](状态机校验 + 原子 CAS + review_rounds 回归计数规则照旧,
|
|
/// 如路径途经 in_review→in_progress 退回边照样 +1)。
|
|
///
|
|
/// 分派:
|
|
/// - 直达/委托(路径长 0 或 2):交 [`advance_task_atomic`] 单跳完成,同态/非法
|
|
/// target/终态不可达的错误文案零变更。
|
|
/// - 多跳(路径长 > 2):逐跳推进,任一跳失败即止(任务停在中间态,错误注明第几跳;
|
|
/// 不回滚已完成跳——回滚本身也要过状态机)。并发改动致某跳 CAS 失败时,调用方
|
|
/// 重读任务现状再决策即可。
|
|
///
|
|
/// 返回 (最终 TaskRecord, 实际路径):路径含首尾([from, ..., to]),直达时长 2、
|
|
/// 多跳时长 > 2,供调用方在 result 透出实际推进路径。
|
|
pub async fn advance_task_auto_path(
|
|
repo: &TaskRepo,
|
|
id: &str,
|
|
target_status: &str,
|
|
) -> df_types::error::Result<(TaskRecord, Vec<&'static str>)> {
|
|
let current = repo
|
|
.get_by_id(id)
|
|
.await?
|
|
.ok_or_else(|| df_types::error::Error::NotFound(format!("任务 {} 不存在", id)))?;
|
|
let from = current.status.as_str();
|
|
let path = crate::task_state_machine::shortest_path(from, target_status);
|
|
|
|
let record = if path.len() > 2 {
|
|
let mut record = current;
|
|
for (idx, &to) in path.iter().enumerate().skip(1) {
|
|
record = advance_task_atomic(repo, id, to).await.map_err(|e| match e {
|
|
// 任务被删(NotFound)原样透传;其余(状态机/CAS 冲突)包上跳次上下文
|
|
nf @ df_types::error::Error::NotFound(_) => nf,
|
|
other => df_types::error::Error::InvalidState {
|
|
current: format!(
|
|
"自动路径第 {}/{} 跳({} → {})失败: {other};任务停在中间态,请重读任务现状后再决策",
|
|
idx,
|
|
path.len() - 1,
|
|
path[idx - 1],
|
|
to
|
|
),
|
|
expected: to.to_string(),
|
|
},
|
|
})?;
|
|
}
|
|
record
|
|
} else {
|
|
// 直达(含同态/非法 target/终态不可达):atomic 原样校验与报错,语义零变更
|
|
advance_task_atomic(repo, id, target_status).await?
|
|
};
|
|
|
|
// 路径承载:shortest_path 产物全为 ALL_STATES 常量(&'static);空路径(同态/
|
|
// 非法/不可达)不会走到成功返回,此处兜底空列表。
|
|
let full_path = if path.len() >= 2 { path } else { Vec::new() };
|
|
Ok((record, full_path))
|
|
}
|
|
|
|
// ============================================================
|
|
// 父任务聚合 — 父任务 status 重算 + 推进联动(容器模型,设计 §2.1)
|
|
// ============================================================
|
|
|
|
/// 父状态聚合纯函数(全状态集完备划分,与 recompute_parent_status 解耦以便测试)。
|
|
///
|
|
/// `counts`:子任务 status→数量;`total`:子任务总数(counts 各值之和,调用方已算好)。
|
|
///
|
|
/// 划分口径(不再手数固定几个桶,而是按语义类归并全部 8 态,避免加态漏改):
|
|
/// - 活跃态 active = {in_progress, in_review, testing} —— 有子任务正在推进
|
|
/// - 阻塞态 = blocked
|
|
/// - 收尾态 settled = {done, cancelled} —— 已了结(完成/取消)
|
|
/// - 静置态 = {todo, deferred} —— 尚未开始/暂缓
|
|
///
|
|
/// 判定优先级(高→低,首个命中即定):
|
|
/// 1. 任一 blocked → blocked(阻塞优先,不被其他态掩盖卡点)
|
|
/// 2. 任一 active → in_progress(有子在推进)
|
|
/// 3. 全部 settled → done(全部了结)
|
|
/// 4. 全部 todo → todo(整体尚未开始)
|
|
/// 5. 全部 deferred → deferred(整体暂缓,不再误报 in_progress)
|
|
/// 6. 其余静置/收尾混合(todo+deferred / todo+done / deferred+done 等,无 active/blocked)
|
|
/// → in_progress(有进展或部分了结,整体视为进行中)
|
|
fn aggregate_parent_status(map: &std::collections::HashMap<String, i64>, total: i64) -> String {
|
|
let c = |k: &str| map.get(k).copied().unwrap_or(0);
|
|
let blocked = c("blocked");
|
|
let active = c("in_progress") + c("in_review") + c("testing");
|
|
let settled = c("done") + c("cancelled");
|
|
let todo = c("todo");
|
|
let deferred = c("deferred");
|
|
|
|
if blocked > 0 {
|
|
"blocked".to_string()
|
|
} else if active > 0 {
|
|
"in_progress".to_string()
|
|
} else if settled == total {
|
|
"done".to_string()
|
|
} else if todo == total {
|
|
"todo".to_string()
|
|
} else if deferred == total {
|
|
"deferred".to_string()
|
|
} else {
|
|
"in_progress".to_string()
|
|
}
|
|
}
|
|
|
|
/// 父任务 status 重算(容器模型,不走状态机)。聚合规则见 [`aggregate_parent_status`]。
|
|
///
|
|
/// 无子任务(悬空)→ 不重算,返回当前 status。
|
|
/// 数据源 `repo.count_children_by_status`(一次 GROUP BY 查询,数据量小无压力);
|
|
/// 写入 `repo.set_status_for_aggregation`(父任务 status 唯一非状态机写入路径,
|
|
/// 状态相同则不写,避免无谓 updated_at 抖动)。返回重算后的最新 status。
|
|
pub async fn recompute_parent_status(
|
|
repo: &TaskRepo,
|
|
parent_id: &str,
|
|
) -> df_types::error::Result<String> {
|
|
let counts = repo
|
|
.count_children_by_status(parent_id)
|
|
.await?;
|
|
// 无子任务(parent_id 悬空,理论上不该发生):不重算,返当前 status
|
|
if counts.is_empty() {
|
|
return repo
|
|
.get_by_id(parent_id)
|
|
.await?
|
|
.map(|t| t.status.as_str().to_string())
|
|
.ok_or_else(|| df_types::error::Error::NotFound(format!("父任务 {parent_id} 不存在")));
|
|
}
|
|
|
|
// 转 HashMap<status, count> 便于按规则判定
|
|
let map: std::collections::HashMap<String, i64> = counts.into_iter().collect();
|
|
let total: i64 = map.values().sum();
|
|
let new_status = aggregate_parent_status(&map, total);
|
|
|
|
// 读当前父 status,相同则不写(避免无谓 updated_at 抖动)
|
|
let current = repo
|
|
.get_by_id(parent_id)
|
|
.await?
|
|
.ok_or_else(|| df_types::error::Error::NotFound(format!("父任务 {parent_id} 不存在")))?;
|
|
if current.status.as_str() == new_status {
|
|
return Ok(new_status);
|
|
}
|
|
repo.set_status_for_aggregation(parent_id, &new_status).await?;
|
|
Ok(new_status)
|
|
}
|
|
|
|
/// 推进任务 + 若为子任务则触发父聚合(父聚合失败仅 warn 不阻断,宽容语义)。
|
|
///
|
|
/// 推进链唯一 status 写入路径的两段式(设计 D3 统一,IPC/AI 工具/MCP 同源):
|
|
/// 1. 推进自身:调 `advance_task_auto_path`(auto_path 默认开启:跳态目标按
|
|
/// shortest_path 自动逐跳推进,治实测 LLM todo→done / in_progress→done 直取被
|
|
/// 状态机拒绝后不走多跳的空耗往返;直达单跳行为与原 advance_task_atomic 一致)。
|
|
/// 实际路径结构化返回值在 auto_path,多跳时此处 tracing 留痕。
|
|
/// 2. 父聚合:推进成功后若 `updated.parent_id` 有值,触发 `recompute_parent_status` 重算
|
|
/// 父 status(父任务=容器模型,聚合规则见 recompute_parent_status)。父聚合失败仅
|
|
/// tracing::warn 不阻断——子任务推进成功是主结果,父 status 漂移可后续修正。
|
|
///
|
|
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
|
|
pub async fn advance_task_with_parent(
|
|
repo: &TaskRepo,
|
|
id: &str,
|
|
target_status: &str,
|
|
) -> df_types::error::Result<TaskRecord> {
|
|
let (updated, path) = advance_task_auto_path(repo, id, target_status).await?;
|
|
if path.len() > 2 {
|
|
tracing::info!(
|
|
task_id = %id,
|
|
target = %target_status,
|
|
path = %crate::task_state_machine::format_path(&path),
|
|
review_rounds = updated.review_rounds,
|
|
"[advance_task] 跳态目标已按最短路径自动逐跳推进"
|
|
);
|
|
}
|
|
if let Some(pid) = &updated.parent_id {
|
|
if let Err(e) = recompute_parent_status(repo, pid).await {
|
|
tracing::warn!(
|
|
task_id = %id,
|
|
parent_id = %pid,
|
|
error = %e,
|
|
"[父聚合] 重算父任务 status 失败(不阻断子任务推进)"
|
|
);
|
|
}
|
|
}
|
|
Ok(updated)
|
|
}
|
|
|
|
// ============================================================
|
|
// DAG 工作流节点 — TaskAdvanceNode(推进链在 DAG 内的形态)
|
|
// ============================================================
|
|
|
|
/// 任务推进节点 — DAG 工作流内触发 advance_task。
|
|
///
|
|
/// 持有 Arc<Database> 在 NodeRegistry 注册时注入(state.rs build_registry),
|
|
/// Node::execute 从 NodeContext.config 读 task_id / target_status,调 advance_task_atomic。
|
|
/// 当前推进链 F-01~04 阶段为手动推进(IPC 直驱),DAG 形态为阶段 2(工作流联动)预留。
|
|
pub struct TaskAdvanceNode {
|
|
db: Arc<Database>,
|
|
}
|
|
|
|
impl TaskAdvanceNode {
|
|
/// 构造节点(注册表工厂调用,注入数据库句柄)
|
|
pub fn new(db: Arc<Database>) -> Self {
|
|
Self { db }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Node for TaskAdvanceNode {
|
|
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
|
let task_id = ctx
|
|
.config
|
|
.get("task_id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("TaskAdvanceNode 缺少必填参数: task_id"))?;
|
|
let target_status = ctx
|
|
.config
|
|
.get("target_status")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| anyhow::anyhow!("TaskAdvanceNode 缺少必填参数: target_status"))?;
|
|
|
|
let repo = TaskRepo::new(&self.db);
|
|
let updated = advance_task_atomic(&repo, task_id, target_status)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("推进任务失败: {}", e))?;
|
|
|
|
Ok(NodeOutput::from_value(serde_json::json!({
|
|
"task": updated,
|
|
"task_id": updated.id,
|
|
"status": updated.status,
|
|
"review_rounds": updated.review_rounds,
|
|
})))
|
|
}
|
|
|
|
fn schema(&self) -> NodeSchema {
|
|
NodeSchema {
|
|
params: serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"task_id": { "type": "string" },
|
|
"target_status": {
|
|
"type": "string",
|
|
"enum": ["todo", "in_progress", "in_review", "testing", "done", "blocked", "cancelled", "deferred"]
|
|
}
|
|
},
|
|
"required": ["task_id", "target_status"]
|
|
}),
|
|
output: serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"task_id": { "type": "string" },
|
|
"status": { "type": "string" },
|
|
"review_rounds": { "type": "integer" }
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn node_type(&self) -> &str {
|
|
"task_advance"
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 单元测试 — advance_task_atomic 状态机集成(内存 DB)
|
|
// ============================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use df_storage::crud::ProjectRepo;
|
|
use df_storage::models::{ProjectRecord, TaskRecord};
|
|
use df_types::types::{ProjectStatus, TaskStatus};
|
|
|
|
// ---------- aggregate_parent_status 纯函数(全状态集覆盖) ----------
|
|
|
|
/// 由 (status, count) 列表构造 map + total,喂给 aggregate_parent_status。
|
|
fn agg(pairs: &[(&str, i64)]) -> String {
|
|
let map: std::collections::HashMap<String, i64> =
|
|
pairs.iter().map(|(s, n)| (s.to_string(), *n)).collect();
|
|
let total: i64 = pairs.iter().map(|(_, n)| *n).sum();
|
|
aggregate_parent_status(&map, total)
|
|
}
|
|
|
|
#[test]
|
|
fn agg_blocked_wins_over_all() {
|
|
// 阻塞优先,不被任何其他态掩盖
|
|
assert_eq!(agg(&[("blocked", 1), ("in_progress", 3), ("done", 2)]), "blocked");
|
|
assert_eq!(agg(&[("blocked", 1), ("deferred", 5)]), "blocked");
|
|
}
|
|
|
|
#[test]
|
|
fn agg_any_active_to_in_progress() {
|
|
// 活跃三态任一在 → in_progress
|
|
assert_eq!(agg(&[("in_progress", 1), ("todo", 2)]), "in_progress");
|
|
assert_eq!(agg(&[("in_review", 1), ("done", 2)]), "in_progress");
|
|
assert_eq!(agg(&[("testing", 1), ("deferred", 2)]), "in_progress");
|
|
}
|
|
|
|
#[test]
|
|
fn agg_all_settled_to_done() {
|
|
assert_eq!(agg(&[("done", 3)]), "done");
|
|
assert_eq!(agg(&[("cancelled", 2)]), "done");
|
|
assert_eq!(agg(&[("done", 2), ("cancelled", 1)]), "done");
|
|
}
|
|
|
|
#[test]
|
|
fn agg_all_todo_to_todo() {
|
|
assert_eq!(agg(&[("todo", 4)]), "todo");
|
|
}
|
|
|
|
#[test]
|
|
fn agg_all_deferred_to_deferred() {
|
|
// P0-1 核心回归:全暂缓不再误报 in_progress
|
|
assert_eq!(agg(&[("deferred", 3)]), "deferred");
|
|
}
|
|
|
|
#[test]
|
|
fn agg_static_mixed_to_in_progress() {
|
|
// P0-1 回归:todo+deferred / deferred+done 等静置收尾混合 → in_progress
|
|
assert_eq!(agg(&[("todo", 1), ("deferred", 2)]), "in_progress");
|
|
assert_eq!(agg(&[("deferred", 1), ("done", 2)]), "in_progress");
|
|
assert_eq!(agg(&[("todo", 1), ("done", 1)]), "in_progress");
|
|
}
|
|
|
|
#[test]
|
|
fn agg_result_always_valid_state() {
|
|
// 性质:任意状态组合的聚合结果必是合法状态值(防未来加态漏改落非法串)
|
|
use crate::task_state_machine::{is_valid_state, ALL_STATES};
|
|
for a in ALL_STATES {
|
|
for b in ALL_STATES {
|
|
let out = agg(&[(a, 1), (b, 2)]);
|
|
assert!(is_valid_state(&out), "agg({a},{b}) = {out:?} 非合法状态值");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn rec(id: &str, status: TaskStatus) -> TaskRecord {
|
|
TaskRecord {
|
|
id: id.to_string(),
|
|
project_id: "p1".to_string(),
|
|
title: format!("t-{id}"),
|
|
description: "".to_string(),
|
|
status,
|
|
priority: 2,
|
|
branch_name: None,
|
|
assignee: None,
|
|
workflow_def_id: None,
|
|
base_branch: None,
|
|
review_rounds: 0,
|
|
output_json: None,
|
|
idea_id: None,
|
|
module_id: None,
|
|
queue: "todo".to_string(),
|
|
parent_id: None,
|
|
content_json: None,
|
|
archived: false,
|
|
created_at: "0".to_string(),
|
|
updated_at: "0".to_string(),
|
|
}
|
|
}
|
|
|
|
async fn setup() -> TaskRepo {
|
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
|
// 先插父项目,满足 tasks.project_id FK 约束(PRAGMA foreign_keys=ON)。
|
|
ProjectRepo::new(&db)
|
|
.insert(ProjectRecord {
|
|
id: "p1".to_string(),
|
|
name: "proj".to_string(),
|
|
description: "".to_string(),
|
|
status: ProjectStatus::Planning,
|
|
idea_id: None,
|
|
path: None,
|
|
stack: None,
|
|
archived: false,
|
|
created_at: "0".to_string(),
|
|
updated_at: "0".to_string(),
|
|
})
|
|
.await
|
|
.expect("insert project");
|
|
TaskRepo::new(&db)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn forward_path_todo_to_done() {
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
|
// 主路径逐级推进
|
|
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "in_progress");
|
|
assert_eq!(r.review_rounds, 0);
|
|
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "in_review");
|
|
assert_eq!(r.review_rounds, 0);
|
|
let r = advance_task_atomic(&repo, "t1", "testing").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "testing");
|
|
let r = advance_task_atomic(&repo, "t1", "done").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "done");
|
|
assert_eq!(r.review_rounds, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn regression_in_review_to_in_progress_bumps_rounds() {
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
|
|
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "in_progress");
|
|
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn regression_testing_to_in_review_bumps_rounds() {
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Testing)).await.unwrap();
|
|
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "in_review");
|
|
assert_eq!(r.review_rounds, 1, "退回应 +1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn multiple_regressions_accumulate() {
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::InReview)).await.unwrap();
|
|
// in_review → in_progress (+1) → in_review (前向,不动) → in_progress (+1=2)
|
|
advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
|
advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
|
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
|
assert_eq!(r.review_rounds, 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn illegal_skip_rejected() {
|
|
// 非法转换(跳态)归 InvalidState,且消息含 from→to 上下文与「非法状态转换」。
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
|
let err = advance_task_atomic(&repo, "t1", "done").await.unwrap_err();
|
|
match err {
|
|
df_types::error::Error::InvalidState { current, expected } => {
|
|
assert!(current.contains("todo→done"), "非法转换消息应含 from→to,实际: {current}");
|
|
assert!(current.contains("非法状态转换"), "消息应含「非法状态转换」,实际: {current}");
|
|
assert_eq!(expected, "done");
|
|
}
|
|
other => panic!("非法转换应是 InvalidState,实际: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn terminal_done_no_successor() {
|
|
// 终态无后继也是非法转换路径,归 InvalidState(同 illegal_skip_rejected)。
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Done)).await.unwrap();
|
|
let err = advance_task_atomic(&repo, "t1", "todo").await.unwrap_err();
|
|
match err {
|
|
df_types::error::Error::InvalidState { current, .. } => {
|
|
assert!(current.contains("done→todo"), "终态后继消息应含 from→to,实际: {current}");
|
|
}
|
|
other => panic!("终态后继应是 InvalidState,实际: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn same_status_rejected() {
|
|
// 同态拒绝(from==to)与非法转换(跳态)错误类型区分。
|
|
// 同态属空操作,归 Validation「相同状态,无需推进」;
|
|
// 非法转换归 InvalidState「非法状态转换 X→Y」(见 illegal_skip_rejected)。
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
|
|
let err = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap_err();
|
|
match err {
|
|
df_types::error::Error::Validation(msg) => {
|
|
assert!(msg.contains("相同状态"), "同态消息应含「相同状态」,实际: {msg}");
|
|
}
|
|
other => panic!("同态拒绝应是 Validation,实际: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_target_rejected() {
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
|
let err = advance_task_atomic(&repo, "t1", "merged").await.unwrap_err();
|
|
assert!(matches!(err, df_types::error::Error::Validation(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn not_found_rejected() {
|
|
let repo = setup().await;
|
|
let err = advance_task_atomic(&repo, "nope", "in_progress").await.unwrap_err();
|
|
assert!(matches!(err, df_types::error::Error::NotFound(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn advance_succeeds_after_prior_status_change() {
|
|
// 前序旁路改 status 后,推进链基于「当前」status 判定合法转换并正常写入。
|
|
// 注:这并非 CAS 并发失败场景(真 CAS 失败由 cas_returns_none_when_status_mismatch 覆盖),
|
|
// 而是验证读后改路径在 from=当前库内 status 时正常推进。
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
|
// 另一路推进把 status 改成 in_progress(模拟并发推进,走 CAS 合法路径;
|
|
// F-03 收口后 status 不在 update_field 白名单,模拟并发改态须走 advance_status_atomic)
|
|
repo.advance_status_atomic("t1", "todo", "in_progress", false)
|
|
.await
|
|
.unwrap();
|
|
// 读出来是 in_progress,推进到 in_review 合法 → 正常成功
|
|
let r = advance_task_atomic(&repo, "t1", "in_review").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "in_review");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cas_returns_none_when_status_mismatch() {
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
|
// 直接调底层:expected 传错(模拟读到 todo 但实际已被改成 in_progress)
|
|
let r = repo
|
|
.advance_status_atomic("t1", "todo", "in_review", false)
|
|
.await
|
|
.unwrap();
|
|
// 此时 status 确实是 todo,CAS 成功,返回 Some
|
|
assert!(r.is_some());
|
|
// 再用错误的 expected todo(实际已是 in_review)→ CAS 失败 None
|
|
let r = repo
|
|
.advance_status_atomic("t1", "todo", "done", false)
|
|
.await
|
|
.unwrap();
|
|
assert!(r.is_none(), "expected 与库内不符应 CAS 失败");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn blocked_round_trip_does_not_bump() {
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
|
|
let r = advance_task_atomic(&repo, "t1", "blocked").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "blocked");
|
|
assert_eq!(r.review_rounds, 0, "进 blocked 不累加");
|
|
let r = advance_task_atomic(&repo, "t1", "in_progress").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "in_progress");
|
|
assert_eq!(r.review_rounds, 0, "解除 blocked 不累加");
|
|
}
|
|
|
|
// ============================================================
|
|
// auto_path — 跳态目标自动逐跳推进(治实测 todo→done / in_progress→done 直取失败)
|
|
// ============================================================
|
|
|
|
#[tokio::test]
|
|
async fn auto_path_todo_to_done_walks_full_gates() {
|
|
// 实测失败形态 1:todo 直取 done,auto_path 走满闸门链直达目标
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
|
let (r, path) = advance_task_auto_path(&repo, "t1", "done").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "done");
|
|
assert_eq!(r.review_rounds, 0, "纯前向路径不累加 review_rounds");
|
|
assert_eq!(path, vec!["todo", "in_progress", "in_review", "testing", "done"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_path_in_progress_to_done() {
|
|
// 实测失败形态 2:in_progress 直取 done
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::InProgress)).await.unwrap();
|
|
let (r, path) = advance_task_auto_path(&repo, "t1", "done").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "done");
|
|
assert_eq!(path, vec!["in_progress", "in_review", "testing", "done"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_path_regression_hop_bumps_rounds() {
|
|
// 目标不可直达且路径途经退回边:testing→in_review→in_progress 两跳均为退回,
|
|
// 每跳独立按 is_regression 累加 → review_rounds = 2(规则照旧,逐跳生效)
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Testing)).await.unwrap();
|
|
let (r, path) = advance_task_auto_path(&repo, "t1", "in_progress").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "in_progress");
|
|
assert_eq!(path, vec!["testing", "in_review", "in_progress"]);
|
|
assert_eq!(r.review_rounds, 2, "两跳退回各 +1,应累加到 2");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_path_multihop_forward_no_bump() {
|
|
// deferred → blocked:3 跳(deferred→todo→in_progress→blocked),全前向不累加
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Deferred)).await.unwrap();
|
|
let (r, path) = advance_task_auto_path(&repo, "t1", "blocked").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "blocked");
|
|
assert_eq!(path, vec!["deferred", "todo", "in_progress", "blocked"]);
|
|
assert_eq!(r.review_rounds, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_path_direct_transition_unchanged() {
|
|
// 直达单跳:行为与旧 advance_task_atomic 完全一致(路径长 2,rounds 不动)
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
|
let (r, path) = advance_task_auto_path(&repo, "t1", "in_progress").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "in_progress");
|
|
assert_eq!(r.review_rounds, 0);
|
|
assert_eq!(path, vec!["todo", "in_progress"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_path_with_parent_multihop_lands_done() {
|
|
// LLM 入口(advance_task_with_parent)默认开启 auto_path:todo 直取 done 成功
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
|
let r = advance_task_with_parent(&repo, "t1", "done").await.unwrap();
|
|
assert_eq!(r.status.as_str(), "done");
|
|
assert_eq!(r.review_rounds, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_path_terminal_and_same_state_still_rejected() {
|
|
// auto_path 不越状态机:终态不可达 / 同态仍按原错误类型拒绝(委托 atomic 原样报错)
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Done)).await.unwrap();
|
|
let err = advance_task_with_parent(&repo, "t1", "todo").await.unwrap_err();
|
|
assert!(
|
|
matches!(err, df_types::error::Error::InvalidState { .. }),
|
|
"终态不可达应是 InvalidState,实际: {err:?}"
|
|
);
|
|
repo.insert(rec("t2", TaskStatus::InProgress)).await.unwrap();
|
|
let err = advance_task_with_parent(&repo, "t2", "in_progress").await.unwrap_err();
|
|
match err {
|
|
df_types::error::Error::Validation(msg) => {
|
|
assert!(msg.contains("相同状态"), "同态消息应含「相同状态」,实际: {msg}");
|
|
}
|
|
other => panic!("同态应是 Validation,实际: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_path_invalid_target_still_rejected() {
|
|
let repo = setup().await;
|
|
repo.insert(rec("t1", TaskStatus::Todo)).await.unwrap();
|
|
let err = advance_task_with_parent(&repo, "t1", "merged").await.unwrap_err();
|
|
assert!(matches!(err, df_types::error::Error::Validation(_)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auto_path_not_found_still_rejected() {
|
|
let repo = setup().await;
|
|
let err = advance_task_with_parent(&repo, "nope", "done").await.unwrap_err();
|
|
assert!(matches!(err, df_types::error::Error::NotFound(_)));
|
|
}
|
|
|
|
// ============================================================
|
|
// CR-06 观察项 ② 测试补强 — ②-3(completed)/②-4(failed) 回调推进覆盖
|
|
//
|
|
// workflow.rs 的 spawn 闭包回调依赖三段纯计算:
|
|
// 1. template_for(target) 选模板(已由 task_workflow_templates.rs 测试覆盖)
|
|
// 2. regression_target(target) 推失败退回态(已收敛到 task_state_machine::regression_target
|
|
// pub 契约,workflow.rs 与本测试均从此导入,单一可信源;映射表由 task_state_machine
|
|
// 测试模块锁定)
|
|
// 3. advance_task_atomic(repo, tid, to) 落库(本模块核心,下方覆盖各回调 to)
|
|
//
|
|
// workflow.rs spawn 闭包集成测试需 mock AppState/AppHandle/EventBus,df-nodes crate
|
|
// 无此基础设施 → 降级为纯函数级 + 状态机集成级(零 mock,稳)。回调三段计算全覆盖,
|
|
// 唯一未覆盖的是「闭包组装 + tracing」胶水代码(非业务逻辑)。
|
|
// ============================================================
|
|
|
|
/// 镜像 workflow.rs spawn 闭包的回调决策(completed→target / failed→regression_target)。
|
|
/// 抽出纯函数便于直接断言,与 workflow.rs 闭包内 match 逻辑一一对应。
|
|
///
|
|
/// regression_target 现已收敛为 df-nodes::task_state_machine::regression_target 的
|
|
/// pub 契约(单一可信源),此处直接复用,不再镜像副本(防 DRY 漂移)。
|
|
///
|
|
/// 返回 Option<String>(非 &str):completed 分支回传入的 target_status(非 'static),
|
|
/// failed 分支回 regression_target 的 &'static str 常量。统一 String 避免生命周期冲突。
|
|
fn callback_advance_target(status: &str, target_status: &str) -> Option<String> {
|
|
match status {
|
|
"completed" => Some(target_status.to_string()),
|
|
"failed" => crate::task_state_machine::regression_target(target_status)
|
|
.map(String::from),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn callback_completed_advances_to_target() {
|
|
// ②-3:工作流 completed → 推进到 target_status(无论 target 是哪个)
|
|
assert_eq!(
|
|
callback_advance_target("completed", "in_progress").as_deref(),
|
|
Some("in_progress")
|
|
);
|
|
assert_eq!(
|
|
callback_advance_target("completed", "testing").as_deref(),
|
|
Some("testing")
|
|
);
|
|
assert_eq!(
|
|
callback_advance_target("completed", "done").as_deref(),
|
|
Some("done")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn callback_failed_advances_to_regression_target() {
|
|
// ②-4:工作流 failed → 推进到 regression_target(target)
|
|
assert_eq!(
|
|
callback_advance_target("failed", "testing").as_deref(),
|
|
Some("in_review")
|
|
);
|
|
assert_eq!(
|
|
callback_advance_target("failed", "in_review").as_deref(),
|
|
Some("in_progress")
|
|
);
|
|
// CR-13-O1-b: in_progress 失败不退回(状态机禁止→todo),回调返回 None 跳过推进
|
|
assert_eq!(callback_advance_target("failed", "in_progress"), None);
|
|
// failed + done:无退回映射 → None(回调跳过,workflow.rs 会 warn 跳过日志)
|
|
assert_eq!(callback_advance_target("failed", "done"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn callback_non_terminal_status_skips() {
|
|
// 工作流非终态(running/cancelled 等)不应触发回调 — workflow.rs match 的 _ 分支。
|
|
// (cancelled 在 workflow.rs 实际归入 failed 路径因 status 字段取值,这里锁定
|
|
// 唯一终态语义:仅 "completed"/"failed" 两态触发推进。)
|
|
for non_terminal in ["running", "pending", ""] {
|
|
assert_eq!(
|
|
callback_advance_target(non_terminal, "in_progress"),
|
|
None,
|
|
"非终态 {non_terminal:?} 不应触发回调推进"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// ②-3 回调落库验证:completed → advance_task_atomic(target) 成功推进 + 不累加 rounds。
|
|
/// 覆盖三个 target 的前向推进(in_progress/testing/done)从各自合法 from 态触发。
|
|
#[tokio::test]
|
|
async fn callback_completed_advance_lands_in_db() {
|
|
// in_progress: todo → in_progress(②-3 in_progress 模板完成)
|
|
let repo = setup().await;
|
|
repo.insert(rec("c1", TaskStatus::Todo)).await.unwrap();
|
|
let to = callback_advance_target("completed", "in_progress").unwrap();
|
|
let r = advance_task_atomic(&repo, "c1", &to).await.unwrap();
|
|
assert_eq!(r.status.as_str(), "in_progress");
|
|
assert_eq!(r.review_rounds, 0, "②-3 前向推进不累加 review_rounds");
|
|
|
|
// testing: in_review → testing(②-3 testing 模板自审+核对通过)
|
|
repo.insert(rec("c2", TaskStatus::InReview)).await.unwrap();
|
|
let to = callback_advance_target("completed", "testing").unwrap();
|
|
let r = advance_task_atomic(&repo, "c2", &to).await.unwrap();
|
|
assert_eq!(r.status.as_str(), "testing");
|
|
assert_eq!(r.review_rounds, 0);
|
|
|
|
// done: testing → done(②-3 done 模板最终核对通过)
|
|
repo.insert(rec("c3", TaskStatus::Testing)).await.unwrap();
|
|
let to = callback_advance_target("completed", "done").unwrap();
|
|
let r = advance_task_atomic(&repo, "c3", &to).await.unwrap();
|
|
assert_eq!(r.status.as_str(), "done");
|
|
assert_eq!(r.review_rounds, 0);
|
|
}
|
|
|
|
/// ②-4 回调落库验证:failed → advance_task_atomic(regression_target) 退回 + 累加 rounds。
|
|
/// 关键差异:②-4 退回转换是 regression,review_rounds += 1(与 ②-3 前向推进 0 增量对比)。
|
|
#[tokio::test]
|
|
async fn callback_failed_regression_lands_in_db_with_rounds_bump() {
|
|
// testing 模板失败:任务当前 testing → 退回 in_review(rounds+1)
|
|
let repo = setup().await;
|
|
repo.insert(rec("f1", TaskStatus::Testing)).await.unwrap();
|
|
let to = callback_advance_target("failed", "testing").unwrap();
|
|
assert_eq!(to, "in_review", "testing 失败应退回 in_review");
|
|
let r = advance_task_atomic(&repo, "f1", &to).await.unwrap();
|
|
assert_eq!(r.status.as_str(), "in_review");
|
|
assert_eq!(r.review_rounds, 1, "②-4 退回应累加 review_rounds(+1)");
|
|
|
|
// in_review 模板失败(注:in_review 非 callback target,但映射存在性仍锁定):
|
|
// in_review → in_progress。此处验证 regression_target 对 in_review 的映射,
|
|
// 即便当前推进链 testing 模板失败也可能退到 in_progress(链式退回)。
|
|
repo.insert(rec("f2", TaskStatus::InReview)).await.unwrap();
|
|
let to = callback_advance_target("failed", "in_review").unwrap();
|
|
assert_eq!(to, "in_progress");
|
|
let r = advance_task_atomic(&repo, "f2", &to).await.unwrap();
|
|
assert_eq!(r.status.as_str(), "in_progress");
|
|
assert_eq!(r.review_rounds, 1);
|
|
|
|
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
|
|
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
|
|
// 验证:callback 返回 None,不调 advance_task_atomic。
|
|
repo.insert(rec("f3", TaskStatus::InProgress)).await.unwrap();
|
|
let to = callback_advance_target("failed", "in_progress");
|
|
assert_eq!(to, None, "in_progress 失败应跳过推进(None),实际: {to:?}");
|
|
// 任务状态未被改动(仍是 in_progress)
|
|
let still = repo.get_by_id("f3").await.unwrap().unwrap();
|
|
assert_eq!(still.status.as_str(), "in_progress", "None 退回不应改动 status");
|
|
}
|
|
|
|
/// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。
|
|
/// workflow.rs 此分支仅 tracing::warn! 跳过,不调 advance_task_atomic。
|
|
/// 锁定:done 失败不会推进任务(终态前向目标失败,无可退态)。
|
|
#[tokio::test]
|
|
async fn callback_failed_done_skips_advance() {
|
|
let to = callback_advance_target("failed", "done");
|
|
assert_eq!(to, None, "done 失败无退回映射 → 回调跳过(None)");
|
|
// 不调 advance_task_atomic(workflow.rs L294-302 的 else 分支)
|
|
}
|
|
}
|