修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复

This commit is contained in:
lxy
2026-08-12 00:29:06 +08:00
parent 9407f821b9
commit d31a27512c
36 changed files with 2614 additions and 264 deletions
+163 -25
View File
@@ -1,11 +1,11 @@
//! 任务推进状态机 — 7 态合法转换定义。
//! 任务推进状态机 — 8 态合法转换定义。
//!
//! 独立模块,非挂在 df-types::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落
//! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层
//! (原子写 SQL 在 task_advance_node.rs 完成)。
//!
//! 7 态(与 df-types::TaskStatus / 前端对齐,D-260616-01):
//! todo / in_progress / in_review / testing / done / blocked / cancelled
//! 8 态(与 df-types::TaskStatus / 前端对齐,D-260616-01):
//! todo / in_progress / in_review / testing / done / blocked / cancelled / deferred
//!
//! 闸门链主路径: todo → in_progress → in_review → testing → done
//!
@@ -19,7 +19,7 @@
//! blocked, cancelled
//! - done → 终态(原则上不可变;若需重开走 cancelled 或新任务)
//! - blocked → in_progress(解除阻塞继续), cancelled
//! - cancelled → 终态
//! - cancelled → todo(取消后恢复,非终态;仅 done 为终态)
// ============================================================
// 状态字符串常量 — 从 df-types::TaskStatus::as_str 派生(单一真相源)
@@ -43,8 +43,10 @@ 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] = &[
@@ -55,6 +57,7 @@ pub const ALL_STATES: &[&str] = &[
DONE,
BLOCKED,
CANCELLED,
DEFERRED,
];
/// 字符串是否为合法状态值
@@ -64,7 +67,7 @@ pub fn is_valid_state(s: &str) -> bool {
/// 判定从 `from` 到 `to` 的状态转换是否合法(状态机核心)。
///
/// 终态(done/cancelled)无任何合法后继;非法或未知状态入参一律返回 false
/// done 为唯一终态,无任何合法后继(cancelled 可恢复为 todo,非终态);非法或未知状态入参一律返回 false
/// (调用方 advance_task 在前置校验已拦截非法 status,此处保守拒绝防漏)。
///
/// 注意:仅判「是否合法」,不判「是否是退回」——退回(导致 review_rounds+1)
@@ -101,7 +104,21 @@ pub fn can_transition(from: &str, to: &str) -> bool {
// blocked → in_progress(解除阻塞继续), cancelled
(BLOCKED, IN_PROGRESS),
(BLOCKED, CANCELLED),
// 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);
@@ -117,7 +134,7 @@ pub fn can_transition(from: &str, to: &str) -> bool {
/// 供 advance_task 错误提示复用:状态机拒绝非法转换时,把「当前状态 + 合法目标」附进
/// 错误信息,让 LLM 下次直接选对目标态,避免靠猜反复触发状态机拒绝。
/// 遍历 ALL_STATES 过滤 can_transition,与状态机矩阵单一真相源对齐(矩阵改动自动同步)。
/// 终态(done/cancelled)无合法后继 → 返回空列表,调用方据此提示「终态无后继」。
/// done 为唯一终态无合法后继 → 返回空列表,调用方据此提示「终态无后继」。
pub fn legal_targets(from: &str) -> Vec<&'static str> {
ALL_STATES
.iter()
@@ -126,6 +143,60 @@ pub fn legal_targets(from: &str) -> Vec<&'static str> {
.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)。
///
/// 退回语义:任务从前向推进阶段回退到更早的推进阶段,意味着上一轮产出未过闸门、
@@ -217,11 +288,9 @@ mod tests {
#[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}");
}
// done 是唯一终态,任何后继都拒绝(cancelled 已支持取消后恢复,非终态)
for to in ALL_STATES {
assert!(!can_transition(DONE, to), "终态 {DONE} 不应有后继 → {to}");
}
}
@@ -248,14 +317,15 @@ mod tests {
#[test]
fn legal_targets_matches_transition_matrix() {
// 逐态锁定合法目标列表(顺序 = ALL_STATES 遍历序),与 can_transition 矩阵一一对应
assert_eq!(legal_targets(TODO), vec![IN_PROGRESS, CANCELLED]);
assert_eq!(legal_targets(IN_PROGRESS), vec![IN_REVIEW, BLOCKED, CANCELLED]);
assert_eq!(legal_targets(IN_REVIEW), vec![IN_PROGRESS, TESTING, BLOCKED, CANCELLED]);
assert_eq!(legal_targets(TESTING), vec![IN_REVIEW, DONE, BLOCKED, CANCELLED]);
assert_eq!(legal_targets(BLOCKED), vec![IN_PROGRESS, CANCELLED]);
// 终态无合法后继
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());
assert!(legal_targets(CANCELLED).is_empty());
}
#[test]
@@ -331,7 +401,7 @@ mod tests {
// 任何非 None 返回值都必须是 ALL_STATES 内的合法状态常量。
// 一旦 regression_target 误返回非状态字符串(拼写漂移 / 拼了历史态如 "merged"),
// 该字符串过不了 is_valid_state,本测试立即失败定位。
// 覆盖全部 7 态入参,不依赖上例已锁定的具体期望值。
// 覆盖全部 8 态入参,不依赖上例已锁定的具体期望值。
for target in ALL_STATES {
match regression_target(target) {
Some(ret) => assert!(
@@ -346,7 +416,7 @@ mod tests {
// ---------- is_valid_state ----------
#[test]
fn is_valid_state_accepts_7_known() {
fn is_valid_state_accepts_all_known() {
for s in ALL_STATES {
assert!(is_valid_state(s));
}
@@ -362,13 +432,13 @@ mod tests {
}
#[test]
fn all_states_has_seven_entries() {
assert_eq!(ALL_STATES.len(), 7);
fn all_states_has_eight_entries() {
assert_eq!(ALL_STATES.len(), 8);
}
// ---------- 双源一致性校验 ----------
//
// task_state_machine 维护一份独立的 7 态字符串常量集(本模块 ALL_STATES / TODO / ...),
// task_state_machine 维护一份独立的 8 态字符串常量集(本模块 ALL_STATES / TODO / ...),
// 与 df-types::TaskStatus::as_str / valid_values() 同语义但不复用枚举(独立模块定位,
// 推进链判定不耦合存储枚举)。两源无编译期绑定,若任一处改拼写或增删状态值,
// 状态机判定会与数据库 status 列存值静默脱节(按常量判合法但落库值对不上)。
@@ -407,5 +477,73 @@ mod tests {
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(&[]), "");
}
}