修复: gen_stream判重+重试分类+状态机最短路径+既有测试修复
This commit is contained in:
@@ -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 字样的代码、搜"失败"的结果等成功工具内容
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 死循环重试同卡死工具)",
|
||||
|
||||
@@ -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\
|
||||
|
||||
@@ -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 不重试 ----
|
||||
|
||||
/// 额度耗尽型 429(anthropic/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:业务层 bail(provider 串已含状态码)----
|
||||
|
||||
/// provider 在 non-2xx bail 的典型串:抠出 401(鉴权失败/Key 错)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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 级嵌套)。
|
||||
|
||||
Reference in New Issue
Block a user