//! 任务推进状态机 — 7 态合法转换定义(F-260616-01) //! //! 独立模块,非挂在 df-types::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落 //! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层 //! (原子写 SQL 在 task_advance_node.rs 完成,见 F-260616-02)。 //! //! 7 态(与 df-types::TaskStatus / 前端对齐,D-260616-01): //! todo / in_progress / in_review / testing / done / blocked / cancelled //! //! 闸门链主路径: 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 → 终态 // ============================================================ // 状态字符串常量 — 与 df-types::TaskStatus::as_str 一一对应 // ============================================================ // // 不复用 df-types::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型), // 但字符串值严格对齐(df-types::TaskStatus::as_str 产出的小写 snake_case), // 保证状态机判定的 from/to 与数据库 status 列存值语义一致。 /// 待开始 pub const TODO: &str = "todo"; /// 进行中 pub const IN_PROGRESS: &str = "in_progress"; /// 代码审查中 pub const IN_REVIEW: &str = "in_review"; /// 测试中 pub const TESTING: &str = "testing"; /// 已完成(终态) pub const DONE: &str = "done"; /// 已阻塞 pub const BLOCKED: &str = "blocked"; /// 已取消(终态) pub const CANCELLED: &str = "cancelled"; /// 全部合法状态值(供输入校验与错误提示复用) pub const ALL_STATES: &[&str] = &[ TODO, IN_PROGRESS, IN_REVIEW, TESTING, DONE, BLOCKED, CANCELLED, ]; /// 字符串是否为合法状态值 pub fn is_valid_state(s: &str) -> bool { ALL_STATES.contains(&s) } /// 判定从 `from` 到 `to` 的状态转换是否合法(状态机核心)。 /// /// 终态(done/cancelled)无任何合法后继;非法或未知状态入参一律返回 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> = 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 → 终态,无后继 ]; for (f, t) in allowed { m.insert((*f, *t), true); } m }); table.get(&(from, to)).copied().unwrap_or(false) } /// 判定一次转换是否为「退回」(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)) } /// 工作流联动任务「失败退一步」的目标态映射(F-260616-06 ②-4)。 /// /// 工作流失败时,任务不应停留在失败前向目标态,需回退到上一闸门重做。映射表: /// - 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 term in [DONE, CANCELLED] { for to in ALL_STATES { assert!(!can_transition(term, to), "终态 {term} 不应有后继 → {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)); } // ---------- 未知状态 ---------- #[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,本测试立即失败定位。 // 覆盖全部 7 态入参,不依赖上例已锁定的具体期望值。 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_7_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_seven_entries() { assert_eq!(ALL_STATES.len(), 7); } // ---------- 双源一致性校验 ---------- // // task_state_machine 维护一份独立的 7 态字符串常量集(本模块 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()); } }