Files
DevFlow/crates/df-nodes/src/task_state_machine.rs
T

550 lines
22 KiB
Rust

//! 任务推进状态机 — 8 态合法转换定义。
//!
//! 独立模块,非挂在 df-types::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落
//! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层
//! (原子写 SQL 在 task_advance_node.rs 完成)。
//!
//! 8 态(与 df-types::TaskStatus / 前端对齐,D-260616-01):
//! todo / in_progress / in_review / testing / done / blocked / cancelled / deferred
//!
//! 闸门链主路径: todo → in_progress → in_review → testing → done
//!
//! 合法转换矩阵(以报告 docs/05-代码审查/任务执行与推进能力分析-2026-06-16.md §8
//! 与决策 docs/02-架构设计/专项设计/任务推进链实施路径-2026-06-16.md F-01 为准):
//! - todo → in_progress(开始), cancelled(取消)
//! - in_progress → in_review(提交审查), blocked(阻塞), cancelled
//! - in_review → testing(审查通过进测试), in_progress(退回修改, review_rounds+1),
//! blocked, cancelled
//! - testing → done(测试通过), in_review(退回重审, review_rounds+1),
//! blocked, cancelled
//! - done → 终态(原则上不可变;若需重开走 cancelled 或新任务)
//! - blocked → in_progress(解除阻塞继续), cancelled
//! - cancelled → todo(取消后恢复,非终态;仅 done 为终态)
// ============================================================
// 状态字符串常量 — 从 df-types::TaskStatus::as_str 派生(单一真相源)
// ============================================================
//
// 任务 #17 合并:字符串常量不再独立定义,直接从 `TaskStatus::as_str()` 派生。
// 消除 df-nodes 字符串常量与 df-types enum 之间的双源问题—— 任一处修改 enum 的
// as_str 输出,本模块常量自动同步,编译期即可发现柡移。
use df_types::types::TaskStatus;
/// 待开始
pub const TODO: &str = TaskStatus::Todo.as_str();
/// 进行中
pub const IN_PROGRESS: &str = TaskStatus::InProgress.as_str();
/// 代码审查中
pub const IN_REVIEW: &str = TaskStatus::InReview.as_str();
/// 测试中
pub const TESTING: &str = TaskStatus::Testing.as_str();
/// 已完成(终态)
pub const DONE: &str = TaskStatus::Done.as_str();
/// 已阻塞
pub const BLOCKED: &str = TaskStatus::Blocked.as_str();
/// 已取消(可恢复为待开始)
pub const CANCELLED: &str = TaskStatus::Cancelled.as_str();
/// 已暂缓
pub const DEFERRED: &str = TaskStatus::Deferred.as_str();
/// 全部合法状态值(供输入校验与错误提示复用)
pub const ALL_STATES: &[&str] = &[
TODO,
IN_PROGRESS,
IN_REVIEW,
TESTING,
DONE,
BLOCKED,
CANCELLED,
DEFERRED,
];
/// 字符串是否为合法状态值
pub fn is_valid_state(s: &str) -> bool {
ALL_STATES.contains(&s)
}
/// 判定从 `from` 到 `to` 的状态转换是否合法(状态机核心)。
///
/// done 为唯一终态,无任何合法后继(cancelled 可恢复为 todo,非终态);非法或未知状态入参一律返回 false
/// (调用方 advance_task 在前置校验已拦截非法 status,此处保守拒绝防漏)。
///
/// 注意:仅判「是否合法」,不判「是否是退回」——退回(导致 review_rounds+1)
/// 的识别见 [`is_regression`],advance_task 据此决定是否一并 SET review_rounds+=1。
pub fn can_transition(from: &str, to: &str) -> bool {
use std::sync::OnceLock;
// 转换表静态构造一次(运行时常量,无锁开销分摊)。行=from,列=to。
// 表条目为 true 即合法转换。未列入的 (from, to) 一律 false。
static TABLE: OnceLock<std::collections::HashMap<(&'static str, &'static str), bool>> =
OnceLock::new();
let table = TABLE.get_or_init(|| {
let mut m = std::collections::HashMap::new();
let allowed: &[(&str, &str)] = &[
// todo → in_progress(开始), cancelled(取消)
(TODO, IN_PROGRESS),
(TODO, CANCELLED),
// in_progress → in_review(提交审查), blocked(阻塞), cancelled
(IN_PROGRESS, IN_REVIEW),
(IN_PROGRESS, BLOCKED),
(IN_PROGRESS, CANCELLED),
// in_review → testing(审查通过进测试), in_progress(退回修改),
// blocked, cancelled
(IN_REVIEW, TESTING),
(IN_REVIEW, IN_PROGRESS),
(IN_REVIEW, BLOCKED),
(IN_REVIEW, CANCELLED),
// testing → done(测试通过), in_review(退回重审), blocked, cancelled
(TESTING, DONE),
(TESTING, IN_REVIEW),
(TESTING, BLOCKED),
(TESTING, CANCELLED),
// done → 终态,无后继
// blocked → in_progress(解除阻塞继续), cancelled
(BLOCKED, IN_PROGRESS),
(BLOCKED, CANCELLED),
// cancelled → todo(取消后恢复)
(CANCELLED, TODO),
// deferred → todo(恢复), cancelled(取消)
(DEFERRED, TODO),
(DEFERRED, CANCELLED),
// todo → deferred(延后)
(TODO, DEFERRED),
// in_progress → deferred
(IN_PROGRESS, DEFERRED),
// in_review → deferred
(IN_REVIEW, DEFERRED),
// testing → deferred
(TESTING, DEFERRED),
// blocked → deferred
(BLOCKED, DEFERRED),
];
for (f, t) in allowed {
m.insert((*f, *t), true);
}
m
});
table.get(&(from, to)).copied().unwrap_or(false)
}
/// 返回从 `from` 出发的所有合法目标状态列表。
///
/// 供 advance_task 错误提示复用:状态机拒绝非法转换时,把「当前状态 + 合法目标」附进
/// 错误信息,让 LLM 下次直接选对目标态,避免靠猜反复触发状态机拒绝。
/// 遍历 ALL_STATES 过滤 can_transition,与状态机矩阵单一真相源对齐(矩阵改动自动同步)。
/// done 为唯一终态无合法后继 → 返回空列表,调用方据此提示「终态无后继」。
pub fn legal_targets(from: &str) -> Vec<&'static str> {
ALL_STATES
.iter()
.copied()
.filter(|to| can_transition(from, to))
.collect()
}
/// 基于 BFS 计算从 `from` 到 `to` 的最短合法转换路径(含首尾)。
///
/// 当 `can_transition(from, to)` 为 false 时,调用方可将此路径附进错误提示,
/// 让 LLM 或用户知道「一步跳不到,但可以这样走」。
///
/// 返回路径列表,第一项为 `from`, 最后一项为 `to`。
/// 如果 `from == to`(同态)或不存在可达路径,返回空切片。
/// 注意:done 是唯一终态,从非终态到 done 只有主路径一条(跳闸门仍不可达);
/// 从 done 到任何态均不可达,返回空。
pub fn shortest_path(from: &str, to: &str) -> Vec<&'static str> {
use std::collections::VecDeque;
if from == to {
return vec![]; // 同态,无路径
}
if !is_valid_state(from) || !is_valid_state(to) {
return vec![]; // 未知态
}
// BFS:队列存(当前节点,路径);visited 跳过已探节点。
let mut visited = std::collections::HashSet::<&'static str>::new();
let mut queue: VecDeque<Vec<&'static str>> = VecDeque::new();
// from 已知是合法状态值,unwrap 安全
let from_static = ALL_STATES.iter().find(|s| **s == from).copied().unwrap();
visited.insert(from_static);
queue.push_back(vec![from_static]);
while let Some(path) = queue.pop_front() {
let current = path.last().copied().unwrap();
for candidate in ALL_STATES {
if !can_transition(current, candidate) {
continue;
}
if *candidate == to {
let mut full = path.clone();
full.push(candidate);
return full;
}
if visited.insert(candidate) {
let mut next = path.clone();
next.push(candidate);
queue.push_back(next);
}
}
}
vec![] // 不可达
}
/// 格式化为人类可读路径字符串,如 "deferred → todo → in_progress → blocked"。
pub fn format_path(path: &[&'static str]) -> String {
path.join(" → ")
}
/// 判定一次转换是否为「退回」(review_rounds 应 +1)。
///
/// 退回语义:任务从前向推进阶段回退到更早的推进阶段,意味着上一轮产出未过闸门、
/// 需重做。具体两类:
/// - in_review → in_progress(审查退回修改)
/// - testing → in_review(测试退回重审)
///
/// 其余合法转换(前向推进 / 进出 blocked / 进 cancelled)均不累加 review_rounds。
pub fn is_regression(from: &str, to: &str) -> bool {
matches!((from, to), (IN_REVIEW, IN_PROGRESS) | (TESTING, IN_REVIEW))
}
/// 工作流联动任务「失败退一步」的目标态映射。
///
/// 工作流失败时,任务不应停留在失败前向目标态,需回退到上一闸门重做。映射表:
/// - testing → in_review(测试失败退回重审)
/// - in_review → in_progress(审查失败退回修改)
/// - in_progress → None(状态机禁止 in_progress→todo,见 [`can_transition`] 的
/// backward_to_todo_rejected;失败退回 todo 会被 advance_task_atomic
/// 的 InvalidState 拦截,任务原地保留 in_progress 无信号。改为 None
/// 则回调跳过推进,留 in_progress 等人介入 — 决策 CR-13-O1-b)
/// - 其他(done/blocked/cancelled/todo 等非推进链前向目标) → None(无退回映射)
///
/// 设计归属:此映射属状态机业务契约(失败退一步的目标态),居 df-nodes 与
/// [`can_transition`] / [`is_regression`] 同位,统一管理推进链的状态语义。
/// 此前该函数在 workflow.rs(app crate 私有) 与 task_advance_node.rs(测试镜像)
/// 各存一份(DRY 漂移),现收敛为单一可信源,调用方均从此导入。
pub fn regression_target(target: &str) -> Option<&'static str> {
match target {
TESTING => Some(IN_REVIEW),
IN_REVIEW => Some(IN_PROGRESS),
// in_progress 失败不自动退回(状态机不允许→todo),留 in_progress 等人介入
IN_PROGRESS => None,
_ => None,
}
}
// ============================================================
// 单元测试 — 转换矩阵 + 边界
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
// ---------- can_transition 闸门主路径 ----------
#[test]
fn main_path_todo_to_done_all_forward() {
assert!(can_transition(TODO, IN_PROGRESS));
assert!(can_transition(IN_PROGRESS, IN_REVIEW));
assert!(can_transition(IN_REVIEW, TESTING));
assert!(can_transition(TESTING, DONE));
}
#[test]
fn cancel_from_any_non_terminal() {
for s in [TODO, IN_PROGRESS, IN_REVIEW, TESTING, BLOCKED] {
assert!(can_transition(s, CANCELLED), "应允许 {s} → cancelled");
}
}
#[test]
fn blocked_round_trip() {
assert!(can_transition(IN_PROGRESS, BLOCKED));
assert!(can_transition(BLOCKED, IN_PROGRESS));
assert!(can_transition(IN_REVIEW, BLOCKED));
assert!(can_transition(TESTING, BLOCKED));
}
#[test]
fn regression_paths_allowed() {
assert!(can_transition(IN_REVIEW, IN_PROGRESS));
assert!(can_transition(TESTING, IN_REVIEW));
}
// ---------- 非法转换被拒 ----------
#[test]
fn illegal_forward_skips_rejected() {
// 不允许跳过闸门(直奔 done)
assert!(!can_transition(TODO, DONE));
assert!(!can_transition(TODO, TESTING));
assert!(!can_transition(TODO, IN_REVIEW));
assert!(!can_transition(IN_PROGRESS, DONE));
assert!(!can_transition(IN_PROGRESS, TESTING));
assert!(!can_transition(IN_REVIEW, DONE));
}
#[test]
fn terminal_states_have_no_successors() {
// done 是唯一终态,任何后继都拒绝(cancelled 已支持取消后恢复,非终态)
for to in ALL_STATES {
assert!(!can_transition(DONE, to), "终态 {DONE} 不应有后继 → {to}");
}
}
#[test]
fn backward_to_todo_rejected() {
// 不允许回退到 todo(开始即不可撤回)
for s in [IN_PROGRESS, IN_REVIEW, TESTING, BLOCKED] {
assert!(!can_transition(s, TODO), "不应允许 {s} → todo");
}
}
#[test]
fn blocked_only_to_in_progress_or_cancelled() {
assert!(can_transition(BLOCKED, IN_PROGRESS));
assert!(can_transition(BLOCKED, CANCELLED));
// 解除阻塞不能直接跳到 in_review/testing/done
assert!(!can_transition(BLOCKED, IN_REVIEW));
assert!(!can_transition(BLOCKED, TESTING));
assert!(!can_transition(BLOCKED, DONE));
}
// ---------- legal_targets(错误提示用合法目标列表) ----------
#[test]
fn legal_targets_matches_transition_matrix() {
// 逐态锁定合法目标列表(顺序 = ALL_STATES 遍历序),与 can_transition 矩阵一一对应
assert_eq!(legal_targets(TODO), vec![IN_PROGRESS, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(IN_PROGRESS), vec![IN_REVIEW, BLOCKED, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(IN_REVIEW), vec![IN_PROGRESS, TESTING, BLOCKED, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(TESTING), vec![IN_REVIEW, DONE, BLOCKED, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(BLOCKED), vec![IN_PROGRESS, CANCELLED, DEFERRED]);
assert_eq!(legal_targets(CANCELLED), vec![TODO]);
assert_eq!(legal_targets(DEFERRED), vec![TODO, CANCELLED]);
// done 是唯一终态,无合法后继
assert!(legal_targets(DONE).is_empty());
}
#[test]
fn legal_targets_all_returned_values_are_valid_and_allowed() {
// 性质断言:任何合法目标都必须是合法状态值且 can_transition 放行(防列表与矩阵漂移)
for from in ALL_STATES {
for to in legal_targets(from) {
assert!(is_valid_state(to), "legal_targets({from}) 返回 {to:?} 不是合法状态值");
assert!(can_transition(from, to), "legal_targets({from}) 含 {to:?} 但 can_transition 拒绝");
}
}
}
#[test]
fn legal_targets_unknown_state_empty() {
// 未知态防御性 → 空列表(不 panic,调用方提示无合法后继)
assert!(legal_targets("merged").is_empty());
assert!(legal_targets("").is_empty());
}
// ---------- 未知状态 ----------
#[test]
fn unknown_states_rejected() {
assert!(!can_transition("unknown", TODO));
assert!(!can_transition(TODO, "unknown"));
assert!(!can_transition("", ""));
}
// ---------- is_regression ----------
#[test]
fn regression_only_on_review_back_edges() {
assert!(is_regression(IN_REVIEW, IN_PROGRESS));
assert!(is_regression(TESTING, IN_REVIEW));
// 前向推进不累加
assert!(!is_regression(TODO, IN_PROGRESS));
assert!(!is_regression(IN_PROGRESS, IN_REVIEW));
assert!(!is_regression(IN_REVIEW, TESTING));
assert!(!is_regression(TESTING, DONE));
// 进出 blocked 不累加
assert!(!is_regression(IN_PROGRESS, BLOCKED));
assert!(!is_regression(BLOCKED, IN_PROGRESS));
// 进 cancelled 不累加
assert!(!is_regression(IN_REVIEW, CANCELLED));
// 不合法的"退回"(实际上不存在的转换)也不应判定为 regression
assert!(!is_regression(TESTING, IN_PROGRESS));
}
// ---------- regression_target(失败退一步映射) ----------
#[test]
fn regression_target_mapping_matches_contract() {
// 锁定 ②-4 失败退回映射表(逐项)
assert_eq!(regression_target(TESTING), Some(IN_REVIEW));
assert_eq!(regression_target(IN_REVIEW), Some(IN_PROGRESS));
// CR-13-O1-b: in_progress 失败不退回(状态机禁止→todo),留 in_progress 等人介入
assert_eq!(regression_target(IN_PROGRESS), None);
// done 是终态前向目标,失败无可退态 → None
assert_eq!(regression_target(DONE), None);
// todo 是起点态无可退;blocked/cancelled 非推进链目标 → None
assert_eq!(regression_target(TODO), None);
assert_eq!(regression_target(BLOCKED), None);
assert_eq!(regression_target(CANCELLED), None);
// 未知态防御性 → None
assert_eq!(regression_target("merged"), None);
assert_eq!(regression_target(""), None);
}
#[test]
fn regression_target_returned_state_is_valid_constant() {
// 防漂移的真正增量(上例已逐项锁值,本例锁性质):
// 任何非 None 返回值都必须是 ALL_STATES 内的合法状态常量。
// 一旦 regression_target 误返回非状态字符串(拼写漂移 / 拼了历史态如 "merged"),
// 该字符串过不了 is_valid_state,本测试立即失败定位。
// 覆盖全部 8 态入参,不依赖上例已锁定的具体期望值。
for target in ALL_STATES {
match regression_target(target) {
Some(ret) => assert!(
is_valid_state(ret),
"regression_target({target:?}) 返回 {ret:?} 不是合法状态常量(ALL_STATES 漂移)"
),
None => {}
}
}
}
// ---------- is_valid_state ----------
#[test]
fn is_valid_state_accepts_all_known() {
for s in ALL_STATES {
assert!(is_valid_state(s));
}
}
#[test]
fn is_valid_state_rejects_unknown() {
assert!(!is_valid_state(""));
assert!(!is_valid_state("unknown"));
assert!(!is_valid_state("TODO")); // 大写
assert!(!is_valid_state("merged")); // 历史 5 态残留
assert!(!is_valid_state("abandoned"));
}
#[test]
fn all_states_has_eight_entries() {
assert_eq!(ALL_STATES.len(), 8);
}
// ---------- 双源一致性校验 ----------
//
// task_state_machine 维护一份独立的 8 态字符串常量集(本模块 ALL_STATES / TODO / ...),
// 与 df-types::TaskStatus::as_str / valid_values() 同语义但不复用枚举(独立模块定位,
// 推进链判定不耦合存储枚举)。两源无编译期绑定,若任一处改拼写或增删状态值,
// 状态机判定会与数据库 status 列存值静默脱节(按常量判合法但落库值对不上)。
//
// 此处锁定「值必须严格一致」契约:ALL_STATES 必须与 df-types::TaskStatus::valid_values()
// 逐项相等(顺序与值)。df-nodes 单向依赖 df-types,无环依赖风险。一旦未来某方改动
// 导致两源漂移,本测试立即失败并精确定位,避免运行时静默脱节。
#[test]
fn all_states_matches_df_types_valid_values() {
use df_types::types::TaskStatus;
let expected: &[&str] = TaskStatus::valid_values();
// 逐项比对(顺序敏感):位置 i 的值必须与 df-types 一致,失败时报具体索引便于定位
assert_eq!(
ALL_STATES.len(),
expected.len(),
"ALL_STATES 与 TaskStatus::valid_values 长度不一致(状态值数量漂移)"
);
for (i, (got, want)) in ALL_STATES.iter().zip(expected.iter()).enumerate() {
assert_eq!(
got, want,
"ALL_STATES[{i}] = {got:?} 与 TaskStatus::valid_values()[{i}] = {want:?} 不一致(状态值漂移)"
);
}
}
#[test]
fn each_state_constant_matches_task_status_as_str() {
// 逐变体锁定:常量值必须与对应 TaskStatus 变体的 as_str 产出严格相等。
// 比上面整表比对更精细,单点漂移能直接指向具体常量行,加速定位。
use df_types::types::TaskStatus;
assert_eq!(TODO, TaskStatus::Todo.as_str());
assert_eq!(IN_PROGRESS, TaskStatus::InProgress.as_str());
assert_eq!(IN_REVIEW, TaskStatus::InReview.as_str());
assert_eq!(TESTING, TaskStatus::Testing.as_str());
assert_eq!(DONE, TaskStatus::Done.as_str());
assert_eq!(BLOCKED, TaskStatus::Blocked.as_str());
assert_eq!(CANCELLED, TaskStatus::Cancelled.as_str());
assert_eq!(DEFERRED, TaskStatus::Deferred.as_str());
}
// ---------- shortest_path(最短路径推导) ----------
#[test]
fn shortest_path_deferred_to_blocked() {
// deferred→todo→in_progress→blocked:最短 3 跳
let path = shortest_path(DEFERRED, BLOCKED);
assert_eq!(path, vec![DEFERRED, TODO, IN_PROGRESS, BLOCKED]);
}
#[test]
fn shortest_path_direct_transition() {
// 一步可达:todo→in_progress
let path = shortest_path(TODO, IN_PROGRESS);
assert_eq!(path, vec![TODO, IN_PROGRESS]);
}
#[test]
fn shortest_path_same_state_returns_empty() {
// 同态返回空
assert!(shortest_path(TODO, TODO).is_empty());
assert!(shortest_path(IN_PROGRESS, IN_PROGRESS).is_empty());
}
#[test]
fn shortest_path_from_terminal_returns_empty() {
// done 是终态,无任何后继
assert!(shortest_path(DONE, TODO).is_empty());
assert!(shortest_path(DONE, IN_PROGRESS).is_empty());
}
#[test]
fn shortest_path_unknown_state_returns_empty() {
assert!(shortest_path("merged", TODO).is_empty());
assert!(shortest_path(TODO, "merged").is_empty());
assert!(shortest_path("", "").is_empty());
}
#[test]
fn shortest_path_multi_hop_consistency() {
// 多跳路径:第一条边必须是 legal_targets(from) 的子集
let path = shortest_path(DEFERRED, BLOCKED);
assert_eq!(path.len(), 4, "deferred→blocked 最短是 4 节点,实际: {:?}", path);
// 首条边 deferred→todo 必须合法
assert!(can_transition(path[0], path[1]));
// 中间每条边都合法
for i in 0..path.len() - 1 {
assert!(
can_transition(path[i], path[i + 1]),
"路径边 {i}: {}→{} 不合法",
path[i],
path[i + 1]
);
}
// 最后一段必须是 blocked
assert_eq!(path.last().copied().unwrap(), BLOCKED);
}
#[test]
fn format_path_joins_with_arrow() {
assert_eq!(
format_path(&[DEFERRED, TODO, IN_PROGRESS, BLOCKED]),
"deferred → todo → in_progress → blocked"
);
assert_eq!(format_path(&[TODO, IN_PROGRESS]), "todo → in_progress");
assert_eq!(format_path(&[]), "");
}
}