重构: AI流式断线保文StreamResult三分支+重试对齐决策a1,推进链落df-nodes

This commit is contained in:
2026-06-16 19:11:19 +08:00
parent 7d5cd4c89a
commit ba7f35552b
30 changed files with 1325 additions and 283 deletions

View File

@@ -6,7 +6,10 @@ pub mod context;
pub mod coordinator;
pub mod openai_compat;
pub mod provider;
mod retry;
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
pub mod retry;
use provider::LlmProvider;

View File

@@ -37,8 +37,10 @@ const MAX_TOTAL_BUDGET: Duration = Duration::from_secs(30);
const JITTER_RATIO: f64 = 0.2;
/// 一次尝试的分类结果 —— 在 anyhow 不透明化前决定是否值得重试。
///
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(Init 失败分类 Fatal/Retryable 决定是否重试)。
#[derive(Debug)]
pub(super) enum AttemptOutcome<T> {
pub enum AttemptOutcome<T> {
/// 成功,携带结果。
Ok(T),
/// 可重试错误(timeout / connect / 5xx / 429)。携带人类可读错误描述。
@@ -50,12 +52,14 @@ pub(super) enum AttemptOutcome<T> {
/// 判定 reqwest::Error 是否可重试(仅 timeout / connect 类)。
///
/// body 解析错(reqwest::Error::is_decode)与请求构造错(is_builder)属 Fatal不重试。
pub(super) fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool {
pub fn is_reqwest_error_retryable(e: &reqwest::Error) -> bool {
e.is_timeout() || e.is_connect() || (e.is_request() && !e.is_builder())
}
/// 判定 HTTP 状态码是否可重试: 5xx 与 429 重试,其余(含 4xx)不重试。
pub(super) fn is_status_retryable(status: u16) -> bool {
///
/// CR-30-1: 暴露给 agentic.rs 流前重试复用(4xx Fatal 立即放弃,5xx/429 重试)。
pub fn is_status_retryable(status: u16) -> bool {
status == 429 || (500..600).contains(&status)
}
@@ -64,7 +68,10 @@ pub(super) fn is_status_retryable(status: u16) -> bool {
///
/// jitter 用 SystemTime 纳秒取模生成(无依赖),避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
fn backoff_delay(attempt: u32) -> Duration {
///
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
pub fn backoff_delay(attempt: u32) -> Duration {
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
// 纳秒 → [0, 2000) 区间,再映射到 [-1.0, +1.0) 比例
let nanos = SystemTime::now()
@@ -85,7 +92,11 @@ fn backoff_delay(attempt: u32) -> Duration {
///
/// 调用方约定: `attempt_fn` 内部应保留完整请求重发能力(每次重建 RequestBuilder)
/// 因为 reqwest::RequestBuilder 一次 `.send()` 后不可复用。
pub(super) async fn retry_with_backoff<T, F, Fut>(
///
/// CR-30-1: 仍 pub(crate)(complete() 内部用),流前重试 stream_recv/agentic 不走此函数
/// (流式 request 不可整体 retry_with_backoff 包裹,需在 agentic loop 内手写循环复用
/// backoff_delay + is_status_retryable)。
pub(crate) async fn retry_with_backoff<T, F, Fut>(
label: &str,
attempt_fn: F,
) -> anyhow::Result<T>

View File

@@ -379,10 +379,15 @@ mod tests {
// 唯一未覆盖的是「闭包组装 + tracing」胶水代码(非业务逻辑)。
// ============================================================
/// 镜像 workflow.rs::regression_target(workflow.rs L44-51 私有函数)的失败退回映射。
/// 镜像 workflow.rs::regression_target(workflow.rs L44-54 私有函数)的失败退回映射。
///
/// 此处是契约镜像(非导入):若 workflow.rs 改映射而忘同步,这里会红,提醒两处一致。
/// 映射:testing→in_review / in_review→in_progress / in_progress→todo / 其他→None。
/// 映射:testing→in_review / in_review→in_progress / in_progress→None / 其他→None。
///
/// 为什么 in_progress → None? 状态机禁止 in_progress→todo
/// (task_state_machine.rs backward_to_todo_rejected),失败退回 todo 会被
/// advance_task_atomic 的 InvalidState 拦截;改为 None 则回调跳过推进,
/// 留 in_progress 等人介入(决策 CR-13-O1-b)。
///
/// 为什么不 pub regression_target 复用? 它是 workflow.rs(app crate)的私有函数,
/// df-nodes 不依赖 app crate(单向依赖:app → df-nodes)。把退回映射当 df-nodes 的
@@ -391,7 +396,8 @@ mod tests {
match target {
"testing" => Some("in_review"),
"in_review" => Some("in_progress"),
"in_progress" => Some("todo"),
// in_progress 失败不自动退回(状态机不允许→todo),留 in_progress 等人介入
"in_progress" => None,
_ => None,
}
}
@@ -414,7 +420,8 @@ mod tests {
// 锁定 workflow.rs ②-4 失败退回映射表(逐项)。
assert_eq!(regression_target("testing"), Some("in_review"));
assert_eq!(regression_target("in_review"), Some("in_progress"));
assert_eq!(regression_target("in_progress"), Some("todo"));
// CR-13-O1-b: in_progress 失败不退回(状态机禁止→todo),留 in_progress 等人介入
assert_eq!(regression_target("in_progress"), None);
// done 是终态前向目标,失败无可退态(workflow.rs 注释:done→None)
assert_eq!(regression_target("done"), None);
// todo 是起点态无可退;blocked/cancelled 非推进链目标 → None
@@ -454,10 +461,8 @@ mod tests {
callback_advance_target("failed", "in_review").as_deref(),
Some("in_progress")
);
assert_eq!(
callback_advance_target("failed", "in_progress").as_deref(),
Some("todo")
);
// 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);
}
@@ -526,21 +531,15 @@ mod tests {
assert_eq!(r.status, "in_progress");
assert_eq!(r.review_rounds, 1);
// in_progress 模板失败:任务当前 in_progress → 退回 todo(rounds 不累加,
// 因 todo→in_progress 是前向,in_progress→todo 是非法转换 —— 见状态机)
// 关键:regression_target("in_progress")=todo,但状态机不允许 in_progress→todo,
// 故回调 advance_task_atomic 会报 InvalidState。验证这一防御性行为:
// in_progress 模板失败:regression_target("in_progress")=None(CR-13-O1-b),
// 回调返回 None → 跳过推进,任务保留 in_progress 等人介入。
// 验证:callback 返回 None,不调 advance_task_atomic。
repo.insert(rec("f3", "in_progress")).await.unwrap();
let to = callback_advance_target("failed", "in_progress").unwrap();
assert_eq!(to, "todo");
let err = advance_task_atomic(&repo, "f3", &to).await.unwrap_err();
assert!(
matches!(err, df_core::error::Error::InvalidState { .. }),
"in_progress→todo 是非法转换,回调应被状态机拦截(InvalidState),实际: {err:?}"
);
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, "in_progress", "非法退回不应改动 status");
assert_eq!(still.status, "in_progress", "None 退回不应改动 status");
}
/// ②-4 回调 failed+done 无退回映射验证:callback_advance_target 返回 None。