修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复
This commit is contained in:
@@ -27,11 +27,11 @@ use crate::task_state_machine::{can_transition, is_regression, is_valid_state, A
|
||||
/// 入参:
|
||||
/// - `repo`:df-storage TaskRepo
|
||||
/// - `id`:任务 ID
|
||||
/// - `target_status`:目标状态(7 态之一,snake_case)
|
||||
/// - `target_status`:目标状态(8 态之一,snake_case)
|
||||
///
|
||||
/// 流程:
|
||||
/// 1. 任务存在性:找不到 → NotFound 错误(前端可据此提示)
|
||||
/// 2. target_status 合法性:非 7 态 → Validation 错误(防脏数据直入推进链)
|
||||
/// 2. target_status 合法性:非 8 态 → Validation 错误(防脏数据直入推进链)
|
||||
/// 3. 状态机(三类拒绝,错误区分供前端分辨):
|
||||
/// - 同态拒绝(from==to):Validation「相同状态,无需推进」(非状态机违例,是空操作)
|
||||
/// - 非法转换(跳态/终态后继等):InvalidState「非法状态转换 X→Y」(含 from/to 上下文)
|
||||
@@ -46,7 +46,7 @@ pub async fn advance_task_atomic(
|
||||
id: &str,
|
||||
target_status: &str,
|
||||
) -> df_types::error::Result<TaskRecord> {
|
||||
// 1. target 合法性(7 态之一)。先于读库校验:即便任务不存在,也先拒绝非法状态值。
|
||||
// 1. target 合法性(8 态之一)。先于读库校验:即便任务不存在,也先拒绝非法状态值。
|
||||
if !is_valid_state(target_status) {
|
||||
return Err(df_types::error::Error::Validation(format!(
|
||||
"非法 target_status {:?},合法值: {}",
|
||||
@@ -66,24 +66,36 @@ pub async fn advance_task_atomic(
|
||||
// - 非法转换(跳态/终态无后继等):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| -> String {
|
||||
let legal_hint = |from: &str, to_hint: Option<&str>| -> String {
|
||||
let legal = crate::task_state_machine::legal_targets(from);
|
||||
if legal.is_empty() {
|
||||
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)
|
||||
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}(非法状态转换), {}", legal_hint(from)),
|
||||
current: format!("{from}→{target_status}(非法状态转换), {hint}"),
|
||||
expected: target_status.to_string(),
|
||||
});
|
||||
}
|
||||
@@ -102,24 +114,56 @@ pub async fn advance_task_atomic(
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 父任务聚合 — 父任务 status 重算 + 推进联动(知识图谱 Phase 1 V29,设计 §2.1)
|
||||
// 父任务聚合 — 父任务 status 重算 + 推进联动(容器模型,设计 §2.1)
|
||||
// ============================================================
|
||||
|
||||
/// 父任务 status 重算(容器模型,不走状态机)。
|
||||
/// 父状态聚合纯函数(全状态集完备划分,与 recompute_parent_status 解耦以便测试)。
|
||||
///
|
||||
/// 聚合规则(设计 §2.1 父聚合规则,优先级从高到低):
|
||||
/// 1. 任一子 blocked → 父 blocked(阻塞优先,避免掩盖卡点)
|
||||
/// 2. 任一子 in_progress → 父 in_progress(执行中)
|
||||
/// 3. 全子 done/cancelled → 父 done(全部完成/取消)
|
||||
/// 4. 全子 todo → 父 todo(尚未开始)
|
||||
/// 5. 其他混合态(如 todo+done)→ 父 in_progress(进行中,有进展未全完)
|
||||
/// `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。
|
||||
/// 写入 `repo.set_status_for_aggregation`(父任务 status 唯一非状态机写入路径,
|
||||
/// 状态相同则不写,避免无谓 updated_at 抖动)。返回重算后的最新 status。
|
||||
pub async fn recompute_parent_status(
|
||||
repo: &TaskRepo,
|
||||
parent_id: &str,
|
||||
@@ -139,27 +183,7 @@ pub async fn recompute_parent_status(
|
||||
// 转 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()
|
||||
};
|
||||
let new_status = aggregate_parent_status(&map, total);
|
||||
|
||||
// 读当前父 status,相同则不写(避免无谓 updated_at 抖动)
|
||||
let current = repo
|
||||
@@ -256,7 +280,7 @@ impl Node for TaskAdvanceNode {
|
||||
"task_id": { "type": "string" },
|
||||
"target_status": {
|
||||
"type": "string",
|
||||
"enum": ["todo", "in_progress", "in_review", "testing", "done", "blocked", "cancelled"]
|
||||
"enum": ["todo", "in_progress", "in_review", "testing", "done", "blocked", "cancelled", "deferred"]
|
||||
}
|
||||
},
|
||||
"required": ["task_id", "target_status"]
|
||||
@@ -288,6 +312,69 @@ mod tests {
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user