修复: 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
+136 -2
View File
@@ -224,6 +224,20 @@ pub const STALL_BREAKER_THRESHOLD: u32 = 3;
/// 确认无误杀场景用。
pub const STALL_BREAKER_WARN_FIRST: bool = true;
/// 目标验证续轮开关(默认 false,灰度放开)。
///
/// 治"过早宣称":末轮无 tool_calls 时,若 pinned_goals 非空且无 applied.matched_target
/// 证据 → 不收敛,注入 nudge 消息强制续轮验证。机制非 prompt 说教(实证无效)。
/// false(默认,回退):整块跳过,converged 判定逐字节等价改动前(只看末轮有无 tool_calls)。
/// 主入口保障:默认关,自测+历史会话回放验证不误伤后再默认开。
pub const GOAL_VERIFY_ENABLED: bool = false;
/// 目标验证续轮上限(默认 2)。
///
/// 每强制续轮 +1,达上限无条件强制收敛 + AiCompleted 标注 goal_unverified。
/// 保证 loop 必然终止,绝不死循环。LLM 续轮内调了工具(无论成败)计数器重置(说明在行动)。
pub const GOAL_VERIFY_MAX_ROUNDS: u32 = 2;
/// G2 警示是否回顾目标(默认 true,需 G1 goal 字段)。
///
/// true(默认):警示文本引用 pinned_goal(若存在)提示「回顾目标: {goal}」,精准;
@@ -1353,6 +1367,11 @@ pub(crate) async fn run_agentic_loop(
let mut stall_count: u32 = 0;
let mut stall_warned: bool = false;
// 目标验证续轮计数(GOAL_VERIFY_ENABLED 门控):末轮无 tool_calls 但 pinned_goals 未达成
// 且无 applied.matched_target 证据时强制续轮,每续 +1。达 GOAL_VERIFY_MAX_ROUNDS 强制收敛
// + AiCompleted 标注 goal_unverified。续轮内调了工具则重置(说明 LLM 在行动非空转)。
let mut goal_verify_rounds: u32 = 0;
// DeepSeek thinking 模式推理内容跨轮透传
let mut last_reasoning_content: Option<String> = None;
@@ -1969,11 +1988,47 @@ pub(crate) async fn run_agentic_loop(
return;
}
// 无工具调用 → 最终文本响应正常收敛退出
// 无工具调用 → 最终文本响应正常收敛退出,或目标验证续轮(GOAL_VERIFY_ENABLED)。
#[allow(unused_assignments)]
if !has_tool_calls { converged = true; break; }
if !has_tool_calls {
// 目标验证续轮:开关开 + 有钉扎目标 + 本轮无 applied.matched_target 证据 + 未达上限
// → 不收敛,注入 nudge 强制续轮(治过早宣称,机制非 prompt 说教)。
// 任一条件不满足 → 正常收敛(逐字节等价改动前)。
let needs_verify = GOAL_VERIFY_ENABLED
&& !pinned_goals_snapshot.is_empty()
&& goal_verify_rounds < GOAL_VERIFY_MAX_ROUNDS
&& !last_round_has_match_evidence(&session_arc, &conv_id).await;
if needs_verify {
goal_verify_rounds += 1;
insert_goal_verify_nudge(&session_arc, &conv_id, iteration, goal_verify_rounds).await;
// 不 break,续轮。下轮 LLM 见 nudge → 调工具验证(计数重置)或诚实收敛。
// 续轮走 loop 顶部正常流程(build_for_request + stream),无特殊路径。
} else {
// 达上限强制收敛(GOAL_VERIFY_ENABLED && goal_verify_rounds >= MAX):
// LLM 经 N 轮 nudge 仍空手停,目标未验证。标注 warn 供事后核查主入口行为。
// (未达上限/开关关/无目标/有证据 → 正常收敛,无额外日志。)
if GOAL_VERIFY_ENABLED
&& !pinned_goals_snapshot.is_empty()
&& goal_verify_rounds >= GOAL_VERIFY_MAX_ROUNDS
{
tracing::warn!(
conv_id = %conv_id, iteration, goal_verify_rounds,
"[GOAL-VERIFY] 达续轮上限强制收敛,目标未验证(LLM 经 nudge 仍空手停)"
);
}
converged = true;
break;
}
}
let _ = converged;
// 续轮内 LLM 调了工具(无论成败)→ 目标验证计数重置(说明在行动非空转,非卡死)。
// 紧跟在 converged 判定之后、process_tool_calls 之前:本轮 has_tool_calls=true 到这里,
// 说明 LLM 对 nudge 作出了工具响应,清零给后续验证留额度。
if GOAL_VERIFY_ENABLED && goal_verify_rounds > 0 && has_tool_calls {
goal_verify_rounds = 0;
}
// AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。
let round_tool_call_count = tool_calls_acc.len();
// 处理工具调用(Low 自动执行 / Medium+High 待审批)
@@ -2282,6 +2337,85 @@ fn fnv1a_32(bytes: &[u8]) -> u32 {
hash
}
// ============================================================
// 目标验证续轮(GOAL_VERIFY_ENABLED 门控)——治"过早宣称"的机制化兜底
// ============================================================
//
// 根因:loop `converged` 判定只看末轮有无 tool_calls(形态),不看目标是否达成(事实)。
// LLM 调完 advance_task 就停 → 被判 converged → 过早宣称。本机制在收敛闸门接事实维度:
// 末轮无 tool_calls 时,若 pinned_goals 非空且本轮无 applied.matched_target 证据 → 不收敛,
// 注入 nudge 强制续轮。复用 G2 insert_stall_warning 范式 + count_recent_failures 读尾消息模式。
//
// 主入口保障:三态兜底(applied 字段缺失/解析异常 → 视为无证据,不误伤也不阻断)、
// 上限熔断(goal_verify_rounds 达 MAX 强制收敛)、调工具重置计数(LLM 在行动非空转)。
/// 检查本轮(末尾连续 Tool 消息)是否有 applied.matched_target=true 的验证证据。
///
/// 读尾部 BREAKER_TAIL_N 条消息,取末尾连续 Tool 消息,解析 content JSON 的
/// `applied.matched_target` 字段。任一为 true 即返 true(本轮有达成证据)。
/// 三态兜底:字段缺失/非 JSON/解析异常 → 返 false(无证据),不阻断也不误判。
async fn last_round_has_match_evidence(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
) -> bool {
let messages = {
let session = session_arc.lock().await;
match session.conv_read(conv_id) {
Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N),
None => Vec::new(),
}
};
for m in messages.iter().rev() {
if !matches!(m.role, MessageRole::Tool) {
break;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(&m.content) else {
continue;
};
if v.get("applied")
.and_then(|a| a.get("matched_target"))
.and_then(|x| x.as_bool())
.unwrap_or(false)
{
return true;
}
}
false
}
/// 注入目标验证续轮 nudge(强制续轮,非软提示)。
///
/// 文案随 goal_verify_rounds 递进:第 1 次请验证,第 2 次(将达上限)明确最后机会。
/// 复用 insert_stall_warning 的 insert_at(0, system) 范式 + fetch_goal_summary 拼目标。
async fn insert_goal_verify_nudge(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
iteration: usize,
verify_rounds: u32,
) {
let goals = fetch_goal_summary(session_arc, conv_id).await;
let nudge_text = if verify_rounds + 1 >= GOAL_VERIFY_MAX_ROUNDS {
format!(
"⚠ 这是最后一次验证机会。目标{}仍未给出完成证据。要么立即用工具验证目标已达成,要么诚实说明哪些未完成、为何未完成,不得再无证据宣称完成。",
goals
)
} else {
format!(
"⚠ 你停下了工具调用,但目标{}尚未给出完成证据。在宣称完成前,必须用工具(读取/核对/查询)验证结果与目标一致;若实际未完成,诚实说明现状,不得宣称完成。",
goals
)
};
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.insert_at(0, ChatMessage::system(&nudge_text));
tracing::info!(
conv_id = %conv_id, iteration, verify_rounds,
"[GOAL-VERIFY] 目标未验证,注入续轮 nudge(强制续轮)"
);
}
}
// ── is_failure_content: 纯结构化判定(只读字段,绝不解析内容文本) ──
// 原理:工具成败是执行层的结构化事实(exit_code / status),断路器只读字段。内容文本(无论含
// error/失败/任何词)绝不参与判定 —— 这样读含 error 字样的代码、搜"失败"的结果等成功工具内容
+23 -10
View File
@@ -163,7 +163,7 @@ pub(super) async fn insert_pending_approval(
// High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。
// 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。
// 兜底:flag 关或无审计记录 → retry_count=0,等价原行为。
let retry_count = detect_retry_count(audit_repo, &draft.id).await;
let retry_count = detect_retry_count(audit_repo, conv_id, &draft.name, &draft.args).await;
if retry_count >= 1 {
let skip_msg = format!(
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
@@ -219,28 +219,41 @@ pub(super) async fn insert_pending_approval(
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
}
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 (会话, 工具, 参数) 重试计数。
///
/// 判重语义:同会话内同工具同参数的重试。区别于旧实现按裸 tool_call_id 判重——
/// 弱模型 provider(如 sensenova)的 tool_call_id 每轮从 0 重排(gen_stream_0/1/2...),
/// 跨轮不同会话的合法调用会复用同一 id,裸 id 判重会把合法新操作误判为「同 id 死循环重试」
/// 静默跳过(实证 2026-08-11:推进任务被跳过 4 次,AI 却宣称成功)。
///
/// 返回语义:
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。
/// - ≥1:审计表已有该 tc_id 的落定记录(executed/failed/rejected/skipped_retry),即该
/// tc_id 此前已被审批执行过一次,LLM 又用同 id 重试 → 调用方据 ≥1 跳过执行 + emit Completed
/// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。
/// - 0:审计表无该 (conv, tool, args) 落定记录(或仅 pending),属首次审批执行,正常挂起。
/// - ≥1:审计表已有该 (conv, tool, args) 的落定记录(executed/failed/rejected/skipped_retry),
/// 即该调用此前已被审批执行过一次,LLM 又重试同参数 → 调用方据 ≥1 跳过执行 + emit Completed
/// 断「超时/权限错→LLM 死循环重试→重新挂起→用户被迫二次授权」循环。
///
/// 实现:查 `find_by_tool_call_id`status 为 pending 视为"尚未落定"(返 0,首次挂起审批的
/// 实现:查 `find_by_conv_tool_args`status 为 pending 视为"尚未落定"(返 0,首次挂起审批的
/// 正常态);其余落定状态返 1。retry_count 当前仅取 0/1(断路器语义:第二次即跳过),
/// 字段类型 u32 留给未来"允许多次重试"扩展(配置上限阈值)。
///
/// 兜底/回退:flag 关(文档标记)或审计查询失败 → 返 0,等价原行为(单次审批执行,无重试防护)。
pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u32 {
pub(super) async fn detect_retry_count(
audit_repo: &AiToolExecutionRepo,
conv_id: &str,
tool_name: &str,
args: &str,
) -> u32 {
// 审计查询失败不阻断主流程(DB 故障等降级为无重试防护,返回 0 走原审批流程)
let rec = match audit_repo.find_by_tool_call_id(tc_id).await {
let rec = match audit_repo.find_by_conv_tool_args(conv_id, tool_name, args).await {
Ok(opt) => match opt {
Some(r) => r,
None => return 0, // 无记录 = 首次
},
Err(e) => {
tracing::warn!("[approval-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
tracing::warn!(
"[approval-retry] 查审计表 (conv={}, tool={}) 失败(降级无重试防护): {}",
conv_id, tool_name, e
);
return 0;
}
};
+1 -1
View File
@@ -376,7 +376,7 @@ pub(crate) async fn process_tool_calls(
.unwrap_or_default();
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
// 锁外:(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
let retry_count = detect_retry_count(&audit_repo, conv_id, &draft.name, &draft.args).await;
if retry_count >= 1 {
let skip_msg = format!(
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
-1
View File
@@ -126,7 +126,6 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
- \n\
- 使\n\
- \n\
- **** grep//\n\
- **** read_file/search_files\n\
## \n\
- \n\
+79 -2
View File
@@ -495,7 +495,7 @@ pub(crate) async fn stream_llm(
if full_text.is_empty() && tool_calls_acc.is_empty() {
flush_delta(app_handle, conv_id, &mut pending_delta);
return StreamResult::InitFailed {
retryable: classify_status_or_class(&status_or_class),
retryable: classify_status_with_body(&status_or_class, &raw),
error: fmt_diag(
provider.name(),
DiagKind::MidStream,
@@ -631,6 +631,34 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
|| lower.contains("broken pipe")
}
/// 429 额度/配额耗尽特征检测:区分「额度耗尽型 429」(确定性,重试必失败)与
/// 「普通瞬时限流 429」(可重试)。实证:anthropic/GLM 周月额度耗尽返回
/// `[1310] 您已达到每周/每月使用上限,限额将在 X 重置`(code 1310)。
/// 特征命中 → Fatal 不重试;未命中 → 交 is_status_retryable 正常判定。
/// 入参 combined:状态码串 + 错误体原文(如 "HTTP 429 [1310] 已达到上限...")。
fn is_quota_exhaustion(combined: &str) -> bool {
let lower = combined.to_lowercase();
let quota_markers = [
"额度", "限额", "达到上限", "使用上限", "重置",
"quota", "limit exceeded", "usage limit", "quota exhausted",
"insufficient_quota",
// 注意:不含 rate_limit_error——它是标准 429 错误类型(普通瞬时限流也带),
// 误配会把可恢复的限流当额度耗尽降 Fatal。额度语义用上面明确词。
];
// 仅当文本同时含「429」与额度特征,才判定额度耗尽(避免误伤普通限流)。
lower.contains("429")
&& quota_markers.iter().any(|m| lower.contains(m))
}
/// 额度耗尽型 429 → Fatal(确定性,不重试);否则交 classify_status_or_class 正常判定。
/// 入参 body:错误体原文(provider bail 文本 / error 帧 message)。
fn classify_status_with_body(status_or_class: &str, body: &str) -> bool {
if is_quota_exhaustion(&format!("{status_or_class} {body}")) {
return false;
}
classify_status_or_class(status_or_class)
}
/// 据 provider 流式 error 帧的 message 文本分类是否可重试(A2-B12 / G4.2)。
///
/// openai/anthropic helper 仅将 error 帧的 `message` 字段透传进 chunk.error
@@ -641,8 +669,10 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
/// - 其余 → retryable=true(保守,防误判瞬态为 Fatal)
fn classify_error_frame_retryable(msg: &str, status_or_class: &str) -> bool {
// 已有状态码/传输类 → 走既有单一分类源(显式 4xx Fatal,5xx/429/timeout/connect 可重试)
// 额度耗尽型 429(如 [1310] 已达周/月上限)确定性失败,经 body 感知识别降 Fatal,
// 不再被普通 429 路径误判可重试空耗。
if status_or_class != "unknown" {
return classify_status_or_class(status_or_class);
return classify_status_with_body(status_or_class, msg);
}
// 无状态码:按错误类型关键词明确非重试签名降级 Fatal
let lower = msg.to_lowercase();
@@ -779,6 +809,53 @@ mod tests {
assert!(classify_error_frame_retryable("stream error", &s2));
}
// ---- is_quota_exhaustion / classify_status_with_body:额度耗尽型 429 不重试 ----
/// 额度耗尽型 429anthropic/GLM 周月上限,含 [1310] 与「使用上限/重置」特征)→ 判定额度耗尽
#[test]
fn quota_exhaustion_429_detected() {
let combined = "HTTP 429 [1310] 您已达到每周/每月使用上限,限额将在 2026-08-14 重置";
assert!(is_quota_exhaustion(combined));
assert!(!classify_status_with_body("HTTP 429", combined));
}
/// 普通瞬时限流 429(无额度特征)→ 不判定额度耗尽,仍可重试
#[test]
fn transient_429_not_quota() {
let combined = "HTTP 429 请求过于频繁,请稍后重试";
assert!(!is_quota_exhaustion(combined));
assert!(classify_status_with_body("HTTP 429", combined));
}
/// 额度关键词但无 429 状态码 → 不判定额度耗尽(防误伤普通 5xx)
#[test]
fn quota_marker_without_429_not_detected() {
assert!(!is_quota_exhaustion("HTTP 500 达到上限"));
}
/// classify_status_with_body 非 429 路径行为不变(5xx 仍可重试)
#[test]
fn classify_status_with_body_5xx_retryable() {
assert!(classify_status_with_body("HTTP 500", "Internal Server Error"));
}
/// 普通瞬时限流 429 的标准 type=rate_limit_error 不应误判额度耗尽(仍可重试,防误伤)
#[test]
fn standard_rate_limit_error_not_quota() {
let combined = r#"HTTP 429 {"type":"rate_limit_error","message":"请求过于频繁,请稍后重试"}"#;
assert!(!is_quota_exhaustion(combined));
assert!(classify_status_with_body("HTTP 429", combined));
}
/// error 帧 message 含额度耗尽型 429 → 经 classify_error_frame_retryable 降 Fatal 不重试
#[test]
fn error_frame_quota_429_fatal() {
let msg = "HTTP 429 [1310] 您已达到每周/每月使用上限,限额将在 2026-08-14 重置";
let (s, _) = extract_error_diag_from_str(msg);
assert_eq!(s, "HTTP 429");
assert!(!classify_error_frame_retryable(msg, &s));
}
// ---- extract_error_diag:业务层 bailprovider 串已含状态码)----
/// provider 在 non-2xx bail 的典型串:抠出 401(鉴权失败/Key 错)
+25 -7
View File
@@ -38,7 +38,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry,
db: Arc<Database>,
"list_tasks",
"列出任务,可按 project_id/status 筛选(status: todo/in_progress/in_review/testing/blocked/done/cancelled),支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
"列出任务,可按 project_id/status 筛选(status: todo/in_progress/in_review/testing/blocked/done/cancelled/deferred),支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
RiskLevel::Low,
schema: object_schema(vec![("project_id", "string", false), ("status", "string", false), ("offset", "integer", false), ("limit", "integer", false)]),
args => {
@@ -48,7 +48,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
} else {
repo.list_all().await?
};
// 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled
// 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled/deferred
if let Some(status) = args.get("status").and_then(|v| v.as_str()) {
tasks.retain(|t| t.status.as_str() == status);
}
@@ -127,7 +127,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry,
db: Arc<Database>,
"update_task",
"更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,7 态 todo/in_progress/in_review/testing/blocked/done/cancelled,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)",
"更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,8 态 todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
args => {
@@ -139,7 +139,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// 对齐 Agent B df-storage tasks 白名单移 status——双重防御(schema 拒绝 + 白名单拒绝)。
if field == "status" {
anyhow::bail!(
"status 改动须走 advance_task 工具(状态机推进,7 态不可跳跃),本工具不接受 status 字段"
"status 改动须走 advance_task 工具(状态机推进,8 态不可跳跃),本工具不接受 status 字段"
);
}
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
@@ -166,7 +166,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry,
db: Arc<Database>,
"advance_task",
"推进任务状态,必须走状态机(7 态: todo/in_progress/in_review/testing/blocked/done/cancelled),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord",
"推进任务状态,必须走状态机(8 态: todo/in_progress/in_review/testing/blocked/done/cancelled/deferred),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true), ("target_status", "string", true)]),
args => {
@@ -177,11 +177,29 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// 与 commands::task::advance_task IPC 同源,避免双轨。设计 D3:子任务推进后
// 自动触发父 status 聚合(advance_task_with_parent,父聚合失败仅 warn 不阻断)。
let repo = df_storage::crud::TaskRepo::new(&db);
// 推进前读 from(供 applied 断言对比,推进链内部也读,多一次轻量读可接受)。
// 读失败不阻断推进 — from 缺失时 applied.from 留 null(向后兼容)。
let from_status = repo.get_by_id(id).await
.ok().flatten().map(|t| t.status.as_str().to_string());
let updated = df_nodes::task_advance_node::advance_task_with_parent(
&repo, id, target_status,
).await?;
// 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。
Ok(serde_json::to_value(&updated)?)
// 返回推进后的 TaskRecord + applied 结构化断言。matched_target = 实际 to 是否
// 等于调用方传的 target(CAS 冲突后状态可能≠预期,matched_target=false 暴露真相)。
// loop 收敛判定(阶段1-2)据此判目标达成,而非只看"LLM 停止调工具"。
let to_status = updated.status.as_str();
let matched = to_status == target_status;
// TaskRecord 字段平铺在顶层(前端 formatAdvanceTask 直接读 r.title/r.status 等,
// 零破坏);applied 作为附加嵌套字段供 loop 收敛判定读取(阶段1-2)。
let mut value = serde_json::to_value(&updated)?;
if let Some(obj) = value.as_object_mut() {
obj.insert("applied".to_string(), serde_json::json!({
"from": from_status,
"to": to_status,
"matched_target": matched,
}));
}
Ok(value)
}
);
+19 -33
View File
@@ -14,7 +14,7 @@ use crate::state::AppState;
use super::{err_str, now_millis};
// ============================================================
// 知识图谱 Phase 2:事件流埋点辅助(best-effort,对标设计 §2.4 hook/after + §10.1)
// 事件流埋点辅助(best-effort)
// ============================================================
/// 追加一条项目事件到 project_events(best-effort)。
@@ -75,17 +75,17 @@ pub struct CreateTaskInput {
/// 空字符串视为 None(向后兼容)。
#[serde(default)]
pub module_id: Option<String>,
/// 管理维度池(知识图谱 Phase 1 V29,对标设计 §2.1)。默认 "todo"(待办池)。
/// 管理维度池。默认 "todo"(待办池)。
/// 合法值:backlog / todo / decision / active / done。非法值在 IPC 层兜底校验。
/// 空字符串视为默认 todo(向后兼容,与 idea_id 一致处理)。
#[serde(default = "default_queue")]
pub queue: String,
/// 父任务 ID(知识图谱 Phase 1 V29)。默认 None = 叶子任务。
/// 父任务 ID。默认 None = 叶子任务。
/// 非空 = 子任务(限制 1 级嵌套,无孙任务:父任务自身不能有 parent_id,由 IPC 层校验)。
/// 空字符串视为 None(向后兼容)。
#[serde(default)]
pub parent_id: Option<String>,
/// 结构化需求规格 JSON 字符串(知识图谱 Phase 1 V29)
/// 结构化需求规格 JSON 字符串。
/// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。
/// None = 无结构化规格(纯文本 description)。
#[serde(default)]
@@ -102,7 +102,7 @@ fn default_queue() -> String {
}
// ============================================================
// 知识图谱 Phase 1:queue 白名单 + queue/status 一致性约束(IPC 层校验,对标设计 §2.1)
// queue 白名单 + queue/status 一致性约束(IPC 层校验)
// ============================================================
/// queue 合法值白名单(对标设计 §2.1 queue 字段语义)。
@@ -235,7 +235,7 @@ pub async fn get_task_by_id(
/// 创建任务,返回完整记录
///
/// 知识图谱 Phase 1 V29(对标设计 §2.1):扩展 queue/parent_id/content_json 可选参数(向后兼容,
/// 扩展 queue/parent_id/content_json 可选参数(向后兼容,
/// 旧调用方不传等价改造前行为)。三个新参数的 IPC 层校验:
/// - `queue`:白名单校验(validate_queue)+ queue/status 一致性(create_task 时 status 恒 todo,
/// 仅 backlog/todo/decision 合法;active/done 需经 move_task_queue 或 advance_task 流转)。
@@ -325,7 +325,7 @@ pub async fn create_task(
idea_id: input.idea_id.filter(|s| !s.is_empty()),
// 多工程:可选关联到具体工程(module)
module_id: input.module_id.filter(|s| !s.is_empty()),
// 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json
// 经上方校验的 queue / parent_id / content_json
queue,
parent_id,
content_json,
@@ -337,7 +337,7 @@ pub async fn create_task(
.insert(record.clone())
.await
.map_err(err_str)?;
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_created 事件。best-effort 不阻断。
// task_created 事件。best-effort 不阻断。
emit_event(
&state,
&record.project_id,
@@ -591,34 +591,21 @@ pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
///
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加 + 父聚合)
/// 落 df-nodes::task_advance_node,本命令只做参数转发与错误串化
/// 落 df-nodes::task_advance_node,本命令只做参数转发与结果包装
///
/// 流程:读当前态 → can_transition 校验 → 下沉 SQL `WHERE id AND status=expected`
/// 防 TOCTOU → 退回转换一并 review_rounds+=1。失败均返回 Err(状态机/TOCTOU/任务不存在)。
///
/// **知识图谱 Phase 1 V29 父聚合(设计 §2.1)**:推进走 `advance_task_with_parent`,推进完成后
/// 若任务有 parent_id,自动触发父任务 status 重算(recompute_parent_status,df-nodes 共享层
/// D3)。父任务=容器模型,status 不走状态机,由子任务聚合计算。聚合失败不阻断推进(best-effort,
/// 设计 §十一「事件流写入失败不阻断主操作」同类宽容语义)。
///
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
/// 返回: `Result<TaskRecord, String>` — 成功返更新后的任务;失败 Err(状态机拒绝/CAS 冲突/
/// 库错误)走 Tauri reject,前端 `.catch` 捕获(与 list/update/create 单轨范式一致)。
#[tauri::command]
pub async fn advance_task(
state: State<'_, AppState>,
id: String,
target_status: String,
) -> Result<TaskRecord, String> {
// 知识图谱 Phase 2:推进前读当前态,作 task_advanced 事件 from_state(仅一次轻量读,
// advance 低频无压力)。失败(任务不存在)不阻断——后续 atomic 会用 NotFound 拒绝,from 留空。
let from_state = state
.tasks
.get_by_id(&id)
.await
.map_err(err_str)?
.map(|t| t.status);
let from_state = match state.tasks.get_by_id(&id).await {
Ok(Some(t)) => Some(t.status),
_ => None,
};
// 推进 + 父聚合(df-nodes 共享层 D3,与 AI 工具/MCP 同源,消除双轨):
// 子任务推进成功后自动触发父 status 聚合(容器模型,聚合失败仅 warn 不阻断)。
let updated = df_nodes::task_advance_node::advance_task_with_parent(
&state.tasks,
&id,
@@ -627,7 +614,6 @@ pub async fn advance_task(
.await
.map_err(err_str)?;
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):task_advanced 事件。best-effort 不阻断。
emit_event(
&state,
&updated.project_id,
@@ -643,7 +629,7 @@ pub async fn advance_task(
}
// ============================================================
// 知识图谱 Phase 1:task_link CRUD IPC(对标设计 §2.2 + §五 AI 工具表)
// task_link CRUD IPC
// ============================================================
/// 创建任务横向关联(对标设计 §2.2,AI 拓扑排序编排调度的基础)。
@@ -737,7 +723,7 @@ pub async fn list_task_links(
}
// ============================================================
// 知识图谱 Phase 1:move_task_queue(跨池移动 + 一致性约束,对标设计 §2.1 + §五)
// move_task_queue(跨池移动 + 一致性约束)
// ============================================================
/// 跨池移动任务(对标设计 §2.1 queue 字段语义 + §五 move_task_queue)。
@@ -785,7 +771,7 @@ pub async fn move_task_queue(
.map_err(err_str)?
.ok_or_else(|| format!("任务 {id} 不存在"))?;
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。
// queue 变化事件。best-effort 不阻断。
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。
//
// M17:用独立 event_type "task_queue_moved"(非复用 "task_advanced"),与 status 推进事件
@@ -826,7 +812,7 @@ pub async fn move_task_queue(
}
// ============================================================
// 知识图谱 Phase 1:get_task_tree(父子任务树,对标设计 §2.1 + §五)
// get_task_tree(父子任务树)
// ============================================================
/// 任务树节点(父 + 子任务列表,对标设计 §2.1 限 1 级嵌套)。