修复: 飞书走查六项 + 停靠状态写侧根因解 + agentic/audit 加固
飞书集成走查六项(feishu_channel.rs +420):
1. 审批卡按钮化: on_card_action + handle_card_action(解析 {approval_id,approved}
→ ai_approve) + send_approval_button_card(Card JSON 1.0 双按钮, 失败降级文本卡)
2. 首帧流式提速: AiTextDelta 首个 delta 立即刷占位卡, 不等 3s ticker
3. 断线重连通知: 按 run_loop 时长判据(≥30s 推断开 / <30s 累计 3 次推不稳定),
新增 notify_monitor()
4. 占位卡带任务原文回显(truncate 60)
5. 白名单配置引导: 后端分步自助引导 + FeishuPanel hintFeishuWhitelist + i18n
6. 审批决议后追加执行结果: spawn_approval_result_watcher 订阅 AiToolCallCompleted
(匹配 tool_call_id, 60s 超时兜底); 订阅须在 ai_approve 前完成(无订阅者跳过序列化)
停靠(parked)写侧根因解(904):
emit_task_parked(ai/mod.rs) 升级为「写 parked + 发事件 + 通知」三件套——
新增 set_loop_state(conv_id, Some("parked")) fire-and-forget; 8 个业务调用点
(H2 三闸/熔断两路/挂起取消/max_rounds/midstream/fatal + 审批超时) 全经单点。
治「停靠卡继续必报 AI 未在暂停态」(全仓原无写 parked 路径)。
agentic/audit 加固: context_lifecycle/stream_relay/mod 调整 + audit record 完善 +
tool_failure_memory/tool_result_offload/tools/task/settings 同步。
验收: cargo check -p devflow EXIT 0。
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
//!
|
||||
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
|
||||
//! ① 读 active 克隆(不改 status / 不扣 token)→ 后台喂 LLM 出摘要;
|
||||
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system);
|
||||
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ push(摘要 system 尾部追加);
|
||||
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
|
||||
//!
|
||||
//! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。
|
||||
@@ -54,7 +54,7 @@ const PROTECT_COUNT: usize = 6;
|
||||
/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。
|
||||
///
|
||||
/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token)
|
||||
/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。
|
||||
/// + push(system 关键词摘要)尾部追加,保留用户反复提及的主题词作续接锚点。
|
||||
/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。
|
||||
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
|
||||
const KEYWORD_FALLBACK_ENABLED: bool = true;
|
||||
@@ -252,7 +252,7 @@ pub(super) async fn maybe_auto_compress(
|
||||
|
||||
match compress_outcome {
|
||||
Ok(Some(summary)) => {
|
||||
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
|
||||
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 尾部追加 + set_compressing(false)。
|
||||
// conv 已删(per_conv 无条目)时丢弃压缩结果——惰性重建会污染新 conv,
|
||||
// is_compressing 标志随 conv 销毁无意义,直接 return。
|
||||
{
|
||||
@@ -262,8 +262,11 @@ pub(super) async fn maybe_auto_compress(
|
||||
}
|
||||
let conv = session.conv(&conv_id);
|
||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||
// NL 摘要插首位 + JSON 卡片回填 WorkingContext,决策随压缩重锚定
|
||||
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
|
||||
// NL 摘要尾部追加(而非 insert_at(0) 头部插入):头部插入会位移整条
|
||||
// 前缀字节,使 DeepSeek 等自动前缀缓存的 provider 全部缓存失效
|
||||
// (命中率掉到个位数)。尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定
|
||||
// 命中率最大化。JSON 卡片回填 WorkingContext,决策随压缩重锚定。
|
||||
conv.messages.push(ChatMessage::system(&summary.nl_summary));
|
||||
conv.messages.set_compressing(false);
|
||||
conv.working_context.sync_after_compress(&summary.json_card);
|
||||
}
|
||||
@@ -271,7 +274,7 @@ pub(super) async fn maybe_auto_compress(
|
||||
conv_id = %conv_id,
|
||||
iteration,
|
||||
pre_tokens = pre_compress_tokens,
|
||||
"[ai] 自动压缩成功(后台),摘要已插首位"
|
||||
"[ai] 自动压缩成功(后台),摘要已尾部追加"
|
||||
);
|
||||
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
|
||||
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
|
||||
@@ -290,13 +293,13 @@ pub(super) async fn maybe_auto_compress(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
|
||||
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要尾部追加(非裸裁剪)。
|
||||
//
|
||||
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
|
||||
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
|
||||
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
|
||||
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
|
||||
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点;
|
||||
// - keyword_fallback 非空 → push(system 关键词摘要)尾部追加作续接锚点;
|
||||
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
|
||||
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
|
||||
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
|
||||
@@ -337,9 +340,9 @@ pub(super) async fn maybe_auto_compress(
|
||||
///
|
||||
/// 抽自 [`maybe_auto_compress`] 后台 task 内的逐字双胞胎块:压缩 provider 重建失败路径
|
||||
/// 与压缩 LLM 失败路径共用「KEYWORD_FALLBACK_ENABLED 门控 → 锁内 check+mutate
|
||||
/// (标 compressed 释放 token + 关键词摘要插首位,非空才插)→ 成功插入打 info」。
|
||||
/// (标 compressed 释放 token + 关键词摘要尾部追加,非空才插)→ 成功插入打 info」。
|
||||
/// 两条失败路径只差日志正文,经 `log_head` 参数化(拼为
|
||||
/// "[ai] {log_head}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)")。
|
||||
/// "[ai] {log_head}兜底(后台):关键词摘要已尾部追加(compressed 标记已扣 token)")。
|
||||
///
|
||||
/// conv 已删(per_conv 无条目)→ 直接返回不兜底(对齐原 return 语义:防惰性重建污染新 conv)。
|
||||
async fn keyword_fallback_commit(
|
||||
@@ -363,7 +366,10 @@ async fn keyword_fallback_commit(
|
||||
if keyword_fallback.is_empty() {
|
||||
false
|
||||
} else {
|
||||
conv.messages.insert_at(0, ChatMessage::system(keyword_fallback));
|
||||
// 尾部追加(而非 insert_at(0) 头部插入):头部插入位移整条前缀字节,
|
||||
// 使 DeepSeek 等自动前缀缓存 provider 全部缓存失效(命中率掉到个位数)。
|
||||
// 尾部追加紧邻本轮内容 LLM 仍可见,前缀稳定命中率最大化。
|
||||
conv.messages.push(ChatMessage::system(keyword_fallback));
|
||||
true
|
||||
}
|
||||
};
|
||||
@@ -371,7 +377,7 @@ async fn keyword_fallback_commit(
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
pre_tokens,
|
||||
"[ai] {}兜底(后台):关键词摘要已插首位(compressed 标记已扣 token)",
|
||||
"[ai] {}兜底(后台):关键词摘要已尾部追加(compressed 标记已扣 token)",
|
||||
log_head
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,6 +341,19 @@ pub const CIRCUIT_BREAKER_ENABLED: bool = true;
|
||||
/// 本开关无意义;CIRCUIT_BREAKER_ENABLED=true 时本开关决定呈现形态。
|
||||
pub const CIRCUIT_BREAKER_HELP_EVENT: bool = true;
|
||||
|
||||
/// 熔断呈现形态开关(#904 附项,2026-10-09):熔断同轮双卡叠加治理。
|
||||
///
|
||||
/// 背景:熔断路径原同时 emit 两张卡——求助卡 AiHelpRequired(换思路/授权路径/人工接管)
|
||||
/// + 停靠卡 AiTaskParked(继续/归档),同轮叠加语义打架且文案冗长;且停靠卡「继续」
|
||||
/// 正是熔断后的正典恢复入口(ai_continue_loop → recover_parked_inner)。
|
||||
///
|
||||
/// true(默认):熔断只发停靠一张卡——AiTaskParked 为唯一呈现,reason 内联原求助卡的
|
||||
/// 关键信息(失败样本/路径),前端停靠卡即含恢复指引,零双卡叠加。
|
||||
/// false(回退):保留旧行为(求助卡 + 停靠卡双发),用于排障/对比。
|
||||
/// 注:本开关仅在 CIRCUIT_BREAKER_HELP_EVENT=true 时生效;后者=false 时求助卡本就是
|
||||
/// AiError 错误气泡,与停靠卡不同族,不构成双卡叠加,维持原样。
|
||||
pub const CIRCUIT_BREAKER_PARK_ONLY: bool = true;
|
||||
|
||||
// 占位配对完整性开关(解 400 orphan)。
|
||||
//
|
||||
// 单一真相源:`df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED`(本模块顶部已 use)。
|
||||
@@ -2261,12 +2274,16 @@ async fn process_round_tools(
|
||||
if loop_epoch_arc.load(Ordering::SeqCst) != my_epoch {
|
||||
return ToolsOutcome::Exit;
|
||||
}
|
||||
emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key);
|
||||
// #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡
|
||||
// 同轮叠加语义打架;求助卡关键信息(失败样本)内联进停靠卡 reason。开关关则退旧双卡。
|
||||
if !CIRCUIT_BREAKER_PARK_ONLY {
|
||||
emit_circuit_breaker_tripped(app_handle, conv_id, max_count, &sample_key);
|
||||
}
|
||||
// 安全停靠(设计 §四):熔断即终局,补发 AiTaskParked(原状只 emit 求助卡,
|
||||
// 托盘场景无独立事件;guard.reset 已在上方完成,现场已 save 于前一轮)。
|
||||
crate::commands::ai::emit_task_parked(
|
||||
app_handle, conv_id,
|
||||
&format!("连续同类失败 {} 次已熔断,任务安全停靠;已完成进度已保存,调整思路后重发消息可继续", max_count),
|
||||
&format!("连续同类失败 {} 次已熔断(最近错误: {}),任务安全停靠;已完成进度已保存,调整思路后重发消息可继续", max_count, sample_key),
|
||||
"circuit_breaker",
|
||||
);
|
||||
return ToolsOutcome::Exit;
|
||||
@@ -2542,15 +2559,19 @@ async fn check_stall_breaker(
|
||||
);
|
||||
let path_desc = loop_hit_path.as_deref().unwrap_or("未知路径");
|
||||
// DRY #1:双写正典(熔断求助卡跨端可见)
|
||||
emit_ai_chat_event(
|
||||
app_handle,
|
||||
AiChatEvent::AiHelpRequired {
|
||||
reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc),
|
||||
context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(),
|
||||
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
},
|
||||
);
|
||||
// #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡同轮
|
||||
// 叠加语义打架;开关关则退旧双卡行为。
|
||||
if !CIRCUIT_BREAKER_PARK_ONLY {
|
||||
emit_ai_chat_event(
|
||||
app_handle,
|
||||
AiChatEvent::AiHelpRequired {
|
||||
reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc),
|
||||
context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(),
|
||||
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
|
||||
crate::commands::ai::notify::notify_session_event(
|
||||
app_handle,
|
||||
@@ -2573,15 +2594,19 @@ async fn check_stall_breaker(
|
||||
"[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count
|
||||
);
|
||||
// DRY #1:双写正典(熔断求助卡跨端可见)
|
||||
emit_ai_chat_event(
|
||||
app_handle,
|
||||
AiChatEvent::AiHelpRequired {
|
||||
reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count),
|
||||
context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(),
|
||||
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
},
|
||||
);
|
||||
// #904 附项:默认只发停靠一张卡(CIRCUIT_BREAKER_PARK_ONLY),求助卡与停靠卡同轮
|
||||
// 叠加语义打架;开关关则退旧双卡行为。
|
||||
if !CIRCUIT_BREAKER_PARK_ONLY {
|
||||
emit_ai_chat_event(
|
||||
app_handle,
|
||||
AiChatEvent::AiHelpRequired {
|
||||
reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count),
|
||||
context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(),
|
||||
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
// P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默)
|
||||
crate::commands::ai::notify::notify_session_event(
|
||||
app_handle,
|
||||
|
||||
@@ -40,6 +40,27 @@ use super::{check_retry_exhausted, resolve_model_with_override};
|
||||
use super::{emit_fatal_error, teardown_round, tokens_snapshot, usage_delta_since};
|
||||
use super::super::event_bus::emit_ai_chat_event;
|
||||
|
||||
// ============================================================
|
||||
// MidStream 短重试/续传预算(看板 #906,打扰治理)
|
||||
// ============================================================
|
||||
//
|
||||
// 背景:网络抖动(如 u-switch 抖动期)导致流中途断,原路径(MidStreamSaved)直接
|
||||
// emit_task_parked 停靠——无 Fatal 家族的 ROUND_REENTRY_MAX 重入预算,一次抖动即弹
|
||||
// 「响应流中断/任务已停靠」卡并进行中任务终止,用户实测频繁打扰。
|
||||
//
|
||||
// 本层对齐 Fatal 重入语义,但采用「续传」而非「重发」:partial 已作为 assistant 消息
|
||||
// 入库,续传请求 = 原请求 + partial(assistant)+ 续传 user 提示,模型从断点继续;
|
||||
// 成功则本轮转为 Continue(不弹卡、不保文停靠),全部耗尽才落回原保文+停靠路径。
|
||||
// 退避比 Fatal 短(5s/15s):midstream 断多为瞬态网络抖动,且 partial 已落库风险低,
|
||||
// 快速恢复优于长等;仅 2 次预算,避免抖动期长时间静默。
|
||||
pub(crate) const MIDSTREAM_REENTRY_MAX: usize = 2;
|
||||
pub(crate) const MIDSTREAM_REENTRY_BACKOFF_SECS: [u64; MIDSTREAM_REENTRY_MAX] = [5, 15];
|
||||
|
||||
/// 续传提示(合成 user 消息,不落库):告知模型上一条回复因网络中断被截断,请从断点继续。
|
||||
/// 不要求重述已完成内容(避免重复),只补完剩余部分。
|
||||
pub(crate) const MIDSTREAM_CONTINUE_PROMPT: &str =
|
||||
"[系统] 上一条回复因网络中断被截断,请从中断处继续补全剩余内容;不要重述已输出的部分,直接接着写。";
|
||||
|
||||
// ============================================================
|
||||
// 单 Provider 流式结果 + fallback 辅助
|
||||
// ============================================================
|
||||
@@ -334,7 +355,7 @@ pub(crate) async fn stream_round(
|
||||
// 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。
|
||||
// 本轮各 candidate 共享一个 30s 预算(切换 provider 不重置预算,
|
||||
// 防多 provider 串行重试累加超过单轮总预算)。超预算直接放弃重试交最终错误/保文路径。
|
||||
let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
// #906:该预算现于 'midstream 循环内每次(重)入刷新(续传重入获全新 30s),故此处不再声明。
|
||||
|
||||
// 外层 for candidate 包裹 stream_one_provider。
|
||||
// 顺序:[primary, ...candidates](空池兜底时仅 primary)。
|
||||
@@ -375,7 +396,21 @@ pub(crate) async fn stream_round(
|
||||
);
|
||||
}
|
||||
|
||||
let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content, round_truncated) = {
|
||||
// ── MidStream 短重试/续传预算(看板 #906)──
|
||||
// 包裹 candidate 循环:流中途断(incomplete)时,若预算未耗尽则「续传」重试——
|
||||
// 把已接收 partial 作为 assistant 消息追加进请求(模型从断点继续),而非整轮重发
|
||||
// (整轮重发会与前端已渲染的 partial 重复)。全部耗尽才落回原保文+停靠路径。
|
||||
// 续传请求消息:首次 = 原请求;每次续传追加 [partial(assistant), 续传提示(user)]。
|
||||
// 注:partial 同时 push 进 session(见下方成功/耗尽两分支),保证落库与前端一致
|
||||
// (前端 flush 后 partial 占一个气泡,续传文本占下一个气泡)。
|
||||
let mut cont_messages: Vec<ChatMessage> = messages.to_vec();
|
||||
let mut midstream_attempt: usize = 0;
|
||||
|
||||
let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content, round_truncated) = 'midstream: loop {
|
||||
// 每次(重)入 midstream 循环刷新 30s 流前重试预算(对齐 Fatal 重入「每 per-permit 重取
|
||||
// 重入获全新 30s 预算」语义)。
|
||||
let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
|
||||
// outcome 累积成功结果(Complete/Partial),InitFailed 不写入。
|
||||
let mut outcome: Option<(String, std::collections::HashMap<u32, ToolCallDraft>, df_ai::provider::TokenUsage, bool, Option<String>, bool)> = None;
|
||||
// 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。
|
||||
@@ -414,7 +449,7 @@ pub(crate) async fn stream_round(
|
||||
|
||||
let stream_outcome = stream_one_provider(
|
||||
candidate,
|
||||
messages,
|
||||
&cont_messages,
|
||||
tool_defs,
|
||||
app_handle,
|
||||
stop_flag,
|
||||
@@ -471,7 +506,7 @@ pub(crate) async fn stream_round(
|
||||
}
|
||||
}
|
||||
|
||||
match outcome {
|
||||
let round_result = match outcome {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
// 全 candidate 耗尽(含单 provider 场景):沿用原单 provider 失败语义。
|
||||
@@ -492,7 +527,76 @@ pub(crate) async fn stream_round(
|
||||
save_conversation(session_arc, db, conv_id, None, Some(&*resolved_model), true).await;
|
||||
return RoundOutcome::Fatal { error: err_msg };
|
||||
}
|
||||
};
|
||||
|
||||
// ── MidStream 短重试/续传判定(看板 #906)──
|
||||
// 仅当本轮 incomplete(流中途断)且用户未点停时进入;否则直接交回主框架。
|
||||
let (text, tool_calls, usage, round_incomplete, rc, truncated) = round_result;
|
||||
if !(round_incomplete && !stop_flag.load(Ordering::SeqCst)) {
|
||||
break 'midstream (text, tool_calls, usage, round_incomplete, rc, truncated);
|
||||
}
|
||||
|
||||
// 用户停优先:网络断与用户点停并发时,停靠语义让位停止路径(原行为保留)。
|
||||
// 预算耗尽:落回原保文+停靠路径(交回主框架)。
|
||||
if midstream_attempt >= MIDSTREAM_REENTRY_MAX {
|
||||
break 'midstream (text, tool_calls, usage, round_incomplete, rc, truncated);
|
||||
}
|
||||
|
||||
// ── 续传重试 ──
|
||||
// ① 已接收 partial 落库为 assistant 消息(每段都落:续传轮的 partial 是「本次续传段」,
|
||||
// 与前端每次 flush 出的气泡一一对应,保证落库与前端视图一致)。
|
||||
let partial_trimmed = text.trim().to_string();
|
||||
if !partial_trimmed.is_empty() {
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.per_conv.contains_key(conv_id) {
|
||||
let conv = session.conv(conv_id);
|
||||
let mut msg = ChatMessage::assistant(&partial_trimmed);
|
||||
msg.model = Some(resolved_model.clone());
|
||||
msg.reasoning_content = None;
|
||||
conv.messages.push(msg);
|
||||
} else {
|
||||
tracing::warn!(stale_conv = %conv_id, "[ai] MidStream 续传前 conv 已删除,放弃续传");
|
||||
break 'midstream (text, tool_calls, usage, round_incomplete, rc, truncated);
|
||||
}
|
||||
}
|
||||
|
||||
// ② 本段 usage 累加(计费口径:每段都真实调用了 provider,不累加会少计中间段成本)。
|
||||
// 置于 conv 存在性校验之后:conv 已删时上方已 break(不累加),避免与终局保文分支重复计。
|
||||
// 用与主链同一派生点 round_usage_effective(prompt 估算兜底口径一致)。
|
||||
let (attempt_usage, _) = round_usage_effective(&usage, estimated_prompt);
|
||||
tokens.add_usage(&attempt_usage);
|
||||
|
||||
midstream_attempt += 1;
|
||||
let delay = MIDSTREAM_REENTRY_BACKOFF_SECS[midstream_attempt - 1];
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
attempt = midstream_attempt,
|
||||
max_attempts = MIDSTREAM_REENTRY_MAX,
|
||||
delay_secs = delay,
|
||||
text_len = partial_trimmed.len(),
|
||||
"[ai] 流中途断,{}s 后从断点续传({}/{})",
|
||||
delay, midstream_attempt, MIDSTREAM_REENTRY_MAX,
|
||||
);
|
||||
|
||||
// ③ 通知前端:flush 当前 partial 到独立气泡,续传文本渲染到新气泡(AiAgentRound
|
||||
// 触发前端 flushCurrentText + 新建 assistant 气泡)。round 传 0 = 仅 flush 语义
|
||||
// (前端 round>0 才更新进度/轮次,0 不跳进度条,对齐 helpers.rs 续跑 round:0 用法)。
|
||||
// 注:iteration==0 时 prepare_round_inputs 不发 AiAgentRound,故此处显式补发。
|
||||
emit_ai_chat_event(app_handle, AiChatEvent::AiAgentRound {
|
||||
round: 0,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
|
||||
// ④ 构造续传请求:原请求 + 本次 partial(assistant)+ 续传提示(user)。
|
||||
// 半截 reasoning 不回传(对齐 B-260617-16 MidStream 保文决策:残缺推理回传
|
||||
// reasoning 模型可能引发 thinking 块错配,且对续写无增益)。
|
||||
*last_reasoning_content = None;
|
||||
cont_messages.push(ChatMessage::assistant(&partial_trimmed));
|
||||
cont_messages.push(ChatMessage::user(MIDSTREAM_CONTINUE_PROMPT));
|
||||
|
||||
// ⑤ 退避后重入 candidate 循环(刷新 30s 流前重试预算;候选链沿用,饱和降级计数跨轮保留)。
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
continue 'midstream;
|
||||
};
|
||||
|
||||
// G4.3:本轮 token 用量是否估算值(provider 未报 prompt_tokens → estimated_prompt 兜底),
|
||||
@@ -512,10 +616,12 @@ pub(crate) async fn stream_round(
|
||||
|
||||
// CR-30-2 / UX-2025-04 / 决策 a1: MidStream 保文路径——partial_text 已接收,
|
||||
// 入库为正常 assistant 消息 + emit AiCompleted(incomplete=true) + 追加系统提示消息。
|
||||
// 不走 AiError(非异常中断,已有可用文本),不重试(决策 a1)。
|
||||
// 不走 AiError(非异常中断,已有可用文本)。
|
||||
// #906 变更:续传预算耗尽后才到达此处(预算内已由上方 'midstream 循环续传,成功则走
|
||||
// Continue 不落此)。续传路径已把「中间各段」partial 落库(循环内 push),此处补「最后一段」
|
||||
// + 系统提示(最后一段未在循环内 push,无重叠)。
|
||||
// 并发时用户停优先:网络断同时用户点停 → stop_flag true 时不走保文路径,
|
||||
// 落入下方 ~1508 stop_flag 检查走停止路径(用户意图优先)。
|
||||
// partial 文本仍由下方 push_assistant_message(~1485)保文不丢。
|
||||
// 落入下方 stop_flag 检查走停止路径(用户意图优先)。
|
||||
if incomplete && !stop_flag.load(Ordering::SeqCst) {
|
||||
// 本轮 usage 补全(prompt 估算兜底,含 cache/reasoning 分项)已由 round_usage_effective 单点派生
|
||||
let usage = round_usage_full;
|
||||
@@ -536,8 +642,10 @@ pub(crate) async fn stream_round(
|
||||
}
|
||||
// 与 push_assistant_message 一致:trim 归一化,纯空白保文(网络中断只有空白)不落库
|
||||
let full_text = full_text.trim();
|
||||
let conv = session.conv(conv_id);
|
||||
// #906:续传路径已把「中间各段」partial 落库(循环内 push),但「最后一段」
|
||||
// (预算耗尽时 break 的 text)未落库——故此处始终补最后一段(无重叠,不重复)。
|
||||
if !full_text.is_empty() {
|
||||
let conv = session.conv(conv_id);
|
||||
let mut msg = ChatMessage::assistant(full_text);
|
||||
msg.model = Some(resolved_model.clone());
|
||||
// B-260617-16: MidStream 保文【不回填】reasoning_content——半截推理回传
|
||||
@@ -555,11 +663,17 @@ pub(crate) async fn stream_round(
|
||||
// 消息级估算标记:本轮 prompt 是否 estimated 兜底,reload 逐条回显对齐 live 态
|
||||
msg.is_estimated = Some(round_is_estimated);
|
||||
conv.messages.push(msg);
|
||||
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)
|
||||
let mut notice = ChatMessage::system("⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。");
|
||||
notice.model = Some(resolved_model.clone());
|
||||
conv.messages.push(notice);
|
||||
}
|
||||
// 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制)。
|
||||
// #906:续传也失败时提示文案含「已自动重试」,让用户理解已尽力。
|
||||
let notice_text = if midstream_attempt > 0 {
|
||||
"⚠ 响应因网络中断不完整(已自动续传重试仍未恢复),以上为已接收的部分内容。可重新发送或点继续以获取完整回复。"
|
||||
} else {
|
||||
"⚠ 响应因网络中断不完整,以上为已接收的部分内容。可重新发送以获取完整回复。"
|
||||
};
|
||||
let mut notice = ChatMessage::system(notice_text);
|
||||
notice.model = Some(resolved_model.clone());
|
||||
conv.messages.push(notice);
|
||||
}
|
||||
|
||||
// 统一走 teardown_round 收尾(save + spawn_title + reset + emit)。
|
||||
@@ -678,3 +792,37 @@ pub(crate) fn merge_volatile_tail(messages: &mut Vec<ChatMessage>, tail: &str) {
|
||||
}
|
||||
messages.push(ChatMessage::user(tail));
|
||||
}
|
||||
|
||||
// MidStream 短重试/续传预算(看板 #906)契约:退避表长须覆盖重入上限(取值索引
|
||||
// attempt-1 不越界)且单调递增(快速恢复语义:midstream 断多为瞬态网络抖动,退避比
|
||||
// Fatal 家族短)。续传提示须非空(合成 user 消息,空提示模型无续传指令)。
|
||||
#[cfg(test)]
|
||||
mod midstream_reentry_tests {
|
||||
use super::{MIDSTREAM_CONTINUE_PROMPT, MIDSTREAM_REENTRY_BACKOFF_SECS, MIDSTREAM_REENTRY_MAX};
|
||||
|
||||
#[test]
|
||||
fn backoff_schedule_covers_max_attempts_and_ascending() {
|
||||
assert_eq!(
|
||||
MIDSTREAM_REENTRY_BACKOFF_SECS.len(),
|
||||
MIDSTREAM_REENTRY_MAX,
|
||||
"退避表长须覆盖续传上限(取值索引 attempt-1 不越界)",
|
||||
);
|
||||
for w in MIDSTREAM_REENTRY_BACKOFF_SECS.windows(2) {
|
||||
assert!(w[0] < w[1], "退避须单调递增: {:?}", w);
|
||||
}
|
||||
// 处方锚定:5s/15s(短退避,抖动期快速恢复;显著短于 Fatal 家族 60/300/900)
|
||||
assert_eq!(MIDSTREAM_REENTRY_BACKOFF_SECS, [5, 15]);
|
||||
assert!(
|
||||
MIDSTREAM_REENTRY_BACKOFF_SECS[0] < 60,
|
||||
"midstream 首退避须短于 Fatal 家族首退避(60s),体现快速恢复语义",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continue_prompt_non_empty() {
|
||||
assert!(
|
||||
!MIDSTREAM_CONTINUE_PROMPT.trim().is_empty(),
|
||||
"续传提示不可为空(空提示模型无续传指令)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use super::event_bus::emit_ai_chat_event;
|
||||
use crate::state::{SettingsNorm, log_slow_lock};
|
||||
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
|
||||
use super::tools::entity_resolve;
|
||||
use crate::commands::ai::is_destructive_command;
|
||||
|
||||
// utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。
|
||||
// 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块
|
||||
@@ -253,6 +254,23 @@ pub(crate) async fn execute_with_heartbeat(
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::time::Duration;
|
||||
|
||||
// ── B5d 工具失败记忆:执行前拦截「同 (conv,tool,args) 连败 ≥2 次」的重复调用 ──
|
||||
// 治「404 连环十几次无退避换路」:拦截时回填结构化换路提示(改参数/换工具/报告放弃),
|
||||
// 引导 LLM 换路而非原样重试。成功即清零(record 侧),防误杀。
|
||||
// 拦截直接返 Err(错误 tool_result),不执行工具——process_tool_calls 侧按既有错误路径处理。
|
||||
if let Some(blocked) = super::tool_failure_memory::precheck(name, &args) {
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
tool = %name,
|
||||
tool_call_id = %tool_call_id,
|
||||
"[failure-memory] 同调用连败达标,执行前拦截"
|
||||
);
|
||||
return Err(anyhow::anyhow!(blocked));
|
||||
}
|
||||
// record 侧需 args 计算 memory_key,但下方 tools.execute(name, args) 会 move args。
|
||||
// 此处留一份 clone 供执行后 record 使用(仅工具执行路径,非热循环,开销可接受)。
|
||||
let args_for_memory = args.clone();
|
||||
|
||||
// ── W-h1 感知租约:写类文件工具执行前置校验(跨会话互斥,治互踩三连) ──
|
||||
// 写类(write_file/patch_file/append_file/delete_file/rename_file 等:非 readonly
|
||||
// 且有 path_args)执行前 acquire;任一路径被**其他会话**占用 → 立即返错(拒绝文案
|
||||
@@ -411,7 +429,7 @@ pub(crate) async fn execute_with_heartbeat(
|
||||
// 三路 select:exec 正常完成 / outer_secs 兜底超时(既有语义,drop exec 腰斩) /
|
||||
// 监视判死提前退出(同样 drop exec,防审批被删或会话已删后空转到超时上限)。
|
||||
// exec 先完成时 select 丢弃 abort_watch,监视零泄漏(非 spawn,随 select 作用域消亡)。
|
||||
tokio::select! {
|
||||
let exec_result = tokio::select! {
|
||||
result = &mut exec_fut => result,
|
||||
_ = tokio::time::sleep(Duration::from_secs(outer_secs)) => {
|
||||
tracing::error!(
|
||||
@@ -432,7 +450,17 @@ pub(crate) async fn execute_with_heartbeat(
|
||||
);
|
||||
Err(anyhow::anyhow!("工具 {} 提前中止: {}", name, reason))
|
||||
}
|
||||
};
|
||||
|
||||
// ── B5d 工具失败记忆:执行后回写(成功清零 / 失败递增并记最近错误) ──
|
||||
// 与入口 precheck 配对:成功即删记忆(知识保鲜,防误杀);失败累加计数,
|
||||
// 达阈值后下次 precheck 拦截。error 摘要取 Err 文案(超时/提前退出/工具自身错误同源)。
|
||||
match &exec_result {
|
||||
Ok(_) => super::tool_failure_memory::record(name, &args_for_memory, true, None),
|
||||
Err(e) => super::tool_failure_memory::record(name, &args_for_memory, false, Some(e.to_string())),
|
||||
}
|
||||
|
||||
exec_result
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -1032,8 +1060,27 @@ pub(crate) async fn process_tool_calls(
|
||||
// 黑名单恒拒不在此层——Denied 分支在 path_auth 预校验先行硬拒,边界永不参与。
|
||||
if !is_sub_agent {
|
||||
if let Some(b) = &boundary {
|
||||
if crate::commands::ai::boundary::risk_allowed(b, &draft.name, &args, risk_level) {
|
||||
// all 档 = 用户显式完全接管(L3 最高授权),优先于任务边界:
|
||||
// 授权语义上"显式全放权"应覆盖"默认收权边界",否则用户开 all 反被边界拦死
|
||||
// (功能反转缺陷:越授权越跑不动)。all 档下不在此层拦截,落到下方
|
||||
// handle_approval_tool 走正常判定链(其内 all 档放行 High)。
|
||||
// 唯一保留的安全线:破坏性命令(all 档也不放行,纵深防御独立于风险等级)。
|
||||
if auto_exec_mode == "all" {
|
||||
let destructive = draft.name == "run_command"
|
||||
&& args.get("command").and_then(|v| v.as_str())
|
||||
.map(is_destructive_command).unwrap_or(false);
|
||||
if destructive {
|
||||
deny_boundary_tool(
|
||||
session_arc, conv_id, &draft, tools_arc, &audit_repo,
|
||||
"boundary-risk", "破坏性命令恒拒(all 档亦不放行,纵深防御)",
|
||||
current_message_id, app_handle,
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
// 非破坏性:all 档放行,不 continue,落到 handle_approval_tool
|
||||
} else if crate::commands::ai::boundary::risk_allowed(b, &draft.name, &args, risk_level) {
|
||||
boundary_auto.push((draft, args, risk_level));
|
||||
continue;
|
||||
} else {
|
||||
let reason = if risk_level == RiskLevel::High {
|
||||
"工具风险 High 超出任务风险上限".to_string()
|
||||
@@ -1044,8 +1091,8 @@ pub(crate) async fn process_tool_calls(
|
||||
session_arc, conv_id, &draft, tools_arc, &audit_repo,
|
||||
"boundary-risk", &reason, current_message_id, app_handle,
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
|
||||
|
||||
@@ -491,6 +491,16 @@ pub async fn tool_failure_stats(
|
||||
Ok(aggregate_tool_stats(rows))
|
||||
}
|
||||
|
||||
/// 读回外置工具结果原文(D2a 大结果外置的读取侧入口)。
|
||||
///
|
||||
/// 审计记录中 result 超 8KB 时被外置为句柄 JSON(含 path 字段),DB 只留句柄。
|
||||
/// 前端审计面板展示历史时,遇句柄需按 path 读回原文全文——本 IPC 即该入口。
|
||||
/// 过期(>90 天归档清理)后文件不在,返回带语义的错误提示(降级不 panic)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_read_offloaded_result(path: String) -> Result<String, String> {
|
||||
tool_result_offload::read_offloaded(&path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1421,20 +1421,39 @@ pub(crate) async fn execute_run_workflow_for_tool(
|
||||
// 无人值守安全停靠出口(设计 §四):五路非正常终局的统一事件+通知单点
|
||||
// ============================================================
|
||||
|
||||
/// 发停靠事件 AiTaskParked + NeedAction 系统通知(托盘场景补位,复用 notify 基建)。
|
||||
/// 无人值守安全停靠出口(设计 §四):五路非正常终局的统一「写 parked + 发事件 + 通知」三件套。
|
||||
///
|
||||
/// 调用方职责(本函数不做,保持单一只读语义):调用前完成「保存现场(落库)+
|
||||
/// #904 修:原实现只发事件+通知,漏写持久层 loop_state=parked —— 前端据此渲染停靠卡
|
||||
/// 「继续」按钮,但点继续走 ai_continue_loop 非活跃分流时查 loop_state 不匹配 → 报
|
||||
/// 「AI 未在暂停态」(全仓此前唯一写 parked 的路径是应用重启 V49 残留扫描)。现将写入
|
||||
/// 收敛进本单点,调用方无需再自行落库。
|
||||
///
|
||||
/// 调用方职责(本函数不做,保持单点语义):调用前完成「保存现场(落库)+
|
||||
/// 不保持 generating(conv_state 复位)」两步——各终局路径现状已有
|
||||
/// (teardown_round/stream_relay Fatal/ai_chat_stop),审批超时路径由本批补齐。
|
||||
/// reason 即待办清单:把停止原因与恢复指引写成人话,前端操作卡直接展示。
|
||||
pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &str, stop_kind: &str) {
|
||||
// ① 写持久层 loop_state=parked(停靠卡「继续」→ ai_continue_loop 分流 → recover_parked_inner
|
||||
// 的判据来源;fire-and-forget 不阻塞事件/通知链路,失败仅 warn)。
|
||||
{
|
||||
use tauri::Manager;
|
||||
let state = app.state::<crate::state::AppState>();
|
||||
let repo = state.ai_conversations.clone();
|
||||
let cid = conv_id.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = repo.set_loop_state(&cid, Some("parked")).await {
|
||||
tracing::warn!(conv_id = %cid, error = %e, "[ai] 停靠写 loop_state=parked 失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
// ② 发停靠事件(前端渲染停靠卡+继续按钮)。
|
||||
let ev = AiChatEvent::AiTaskParked {
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
reason: reason.to_string(),
|
||||
stop_kind: Some(stop_kind.to_string()),
|
||||
};
|
||||
event_bus::emit_ai_chat_event(app, ev);
|
||||
// 托盘场景补位:停靠=需用户回来决策(继续/归档),与审批挂起同用 NeedAction 分组
|
||||
// ③ 托盘场景补位:停靠=需用户回来决策(继续/归档),与审批挂起同用 NeedAction 分组
|
||||
// (主窗聚焦跳过 + 5s 节流,重复终局不会刷屏)。
|
||||
notify::notify_session_event(
|
||||
app,
|
||||
|
||||
@@ -101,11 +101,6 @@ pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option<Stri
|
||||
}
|
||||
}
|
||||
|
||||
/// 拦截条目数(测试用)。
|
||||
pub fn memory_size() -> usize {
|
||||
memory().lock().unwrap_or_else(|p| p.into_inner()).len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
//! DB 只留句柄 JSON(offloaded 标记 + 路径 + 头部摘要 + 大小 + 工具名/时间)。
|
||||
//! - 句柄可读:句柄含 path,用户/agent 需要细节时按路径找回(工具结果在 messages 侧
|
||||
//! 已有 namespace 外置,本模块治审计表侧——两处同构,互不影响)。
|
||||
//! - D2d 归档:cleanup_expired(90 天)删除过期外置文件(句柄留 DB,文件没了读时
|
||||
//! 降级提示「已过期清理」);调用点挂 unattended 引擎每日一轮。
|
||||
//! - 历史数据默认保留禁清理(全局规则)——cleanup 只删「本模块自己写的外置文件」
|
||||
//! (目录专有,含 marker 头校验),不碰任何 DB 行,原始库零风险。
|
||||
//! - 归档:2026-10-09 用户决策,原设计的 90 天清理已停用,外置文件全部保留不删。
|
||||
//! 用户明确「外置文件超过 90 天也不能删,暂时都保留」。cleanup_expired 本体与单测保留
|
||||
//! (未来若需恢复只改 unattended.rs 调用点),但当前无任何调用点,外置文件只增不减。
|
||||
//! read_offloaded 读回入口已接线(IPC ai_read_offloaded_result),句柄永远可读回。
|
||||
//! - 历史数据默认保留禁清理(全局规则),与本次决策一致,外置派生文件同样保留。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
@@ -34,7 +35,9 @@ pub fn offload_result(id: &str, tool_name: &str, result: &str) -> String {
|
||||
/// (上下文敏感),审计侧存证要求低,8KB 平衡 IO 频率与瘦身收益。
|
||||
pub const OFFLOAD_THRESHOLD_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// D2d 归档阈值(定案 2026-10-06):外置文件 90 天后可清理。
|
||||
/// 归档阈值(原设计 2026-10-06):外置文件 90 天后可清理。
|
||||
/// 2026-10-09 用户决策:90 天清理已停用,外置文件全部保留,常量保留供未来恢复。
|
||||
#[allow(dead_code)]
|
||||
pub const ARCHIVE_AFTER_DAYS: u64 = 90;
|
||||
|
||||
/// 句柄 JSON 字段名(读取降级判据)。
|
||||
@@ -76,14 +79,21 @@ pub fn offload_if_large(base: &PathBuf, id: &str, tool_name: &str, result: &str)
|
||||
handle.to_string()
|
||||
}
|
||||
|
||||
/// 读外置文件(前端/查询侧展开句柄用)。过期清理后降级提示。
|
||||
/// 读外置文件(前端/查询侧展开句柄用)。文件缺失时返回带语义的错误(降级不 panic)。
|
||||
///
|
||||
/// 注:90 天归档清理已停用(2026-10-09 用户决策,外置文件全部保留),故此路径
|
||||
/// 正常应恒可读;报错仅当文件被外部误删或路径失效。
|
||||
pub fn read_offloaded(path: &str) -> Result<String, String> {
|
||||
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败(可能已过 90 天归档清理): {e}"))
|
||||
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败(文件可能已被外部删除): {e}"))
|
||||
}
|
||||
|
||||
/// D2d:清理超期外置文件(仅本模块目录内带 marker 头的文件;失败静默记日志)。
|
||||
/// 清理超期外置文件(仅本模块目录内带 marker 头的文件;失败静默记日志)。
|
||||
/// 返回 (删除数, 释放字节)。全局规则「历史数据默认保留」的对齐说明:本函数只删
|
||||
/// offload 自产的派生文件(原文曾入 DB 后被替换为句柄),非用户原始数据。
|
||||
///
|
||||
/// 2026-10-09 用户决策:已停用,外置文件超过 90 天也不删,暂时全部保留。
|
||||
/// 调用点已从 unattended.rs 摘除(改为空转)。函数本体与单测保留供未来恢复。
|
||||
#[allow(dead_code)]
|
||||
pub fn cleanup_expired(base: &PathBuf) -> (usize, u64) {
|
||||
let root = base.join("tool-results");
|
||||
let Ok(entries) = std::fs::read_dir(&root) else { return (0, 0) };
|
||||
|
||||
@@ -43,15 +43,20 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
schema: object_schema(vec![("project_id", "string", false), ("status", "string", false), ("offset", "integer", false), ("limit", "integer", false)]),
|
||||
args => {
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
|
||||
repo.query("project_id", pid).await?
|
||||
} else {
|
||||
repo.list_all().await?
|
||||
// 走 list_by_query(与 IPC/MCP 同源):恒带 `deleted_at IS NULL AND archived = 0`,
|
||||
// 回收站/归档任务不进结果。
|
||||
//
|
||||
// 修复背景:此前用 repo.query("project_id", pid) / repo.list_all(),二者均无
|
||||
// 软删/归档过滤 → AI 工具能看到回收站任务,但 advance_task 的 CAS SQL 带
|
||||
// `deleted_at IS NULL AND archived = 0` 必败 → 报「已变更(并发推进或旁路修改)」
|
||||
// 误导性错误(真实原因是任务在回收站)。改用 list_by_query 后,AI 看到的任务
|
||||
// 与可推进的任务集合一致,消除「看得见点不动」。
|
||||
let q = df_storage::crud::TaskQuery {
|
||||
project_id: args.get("project_id").and_then(|v| v.as_str()).map(String::from),
|
||||
status: args.get("status").and_then(|v| v.as_str()).map(String::from),
|
||||
..Default::default()
|
||||
};
|
||||
// 按状态过滤(可选):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);
|
||||
}
|
||||
let tasks = repo.list_by_query(&q).await?;
|
||||
let total = tasks.len();
|
||||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||||
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
|
||||
@@ -253,11 +258,14 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
schema: object_schema(vec![("project_id", "string", false)]),
|
||||
args => {
|
||||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||||
let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
|
||||
repo.query("project_id", pid).await?
|
||||
} else {
|
||||
repo.list_all().await?
|
||||
// 走 list_by_query(与 list_tasks 同源):恒带 `deleted_at IS NULL AND archived = 0`,
|
||||
// 计数与可见任务集合一致(此前 list_all/query 无过滤,计数含回收站/归档任务,
|
||||
// 与 list_tasks 结果数不符,分页策略判断失真)。
|
||||
let q = df_storage::crud::TaskQuery {
|
||||
project_id: args.get("project_id").and_then(|v| v.as_str()).map(String::from),
|
||||
..Default::default()
|
||||
};
|
||||
let tasks = repo.list_by_query(&q).await?;
|
||||
Ok(serde_json::json!({ "total": tasks.len() }))
|
||||
}
|
||||
);
|
||||
|
||||
@@ -424,26 +424,34 @@ async fn run_tick(app: &AppHandle, cfg: &EngineConfig) {
|
||||
}
|
||||
// 37 监控轮询:每轮末尾自监控快照(活性/熔断/失败计数),异常推飞书,正常落日志
|
||||
run_self_monitor(app, cfg).await;
|
||||
// D2d 归档清理:每日一次(挂钟桶去重),清 90 天前外置派生文件(不碰 DB 行)
|
||||
// 归档清理已停用(2026-10-09 用户决策):外置文件超过 90 天也不删,全部保留。
|
||||
// 原行为是每日一次清 90 天前外置派生文件。现摘除调用点,cleanup_expired 本体与单测
|
||||
// 保留,未来若需恢复只改此处。
|
||||
run_daily_offload_cleanup(app).await;
|
||||
}
|
||||
|
||||
/// D2d 每日归档清理(挂钟桶去重:同一天只跑一次;失败静默,下一天重试)。
|
||||
async fn run_daily_offload_cleanup(app: &AppHandle) {
|
||||
use std::sync::atomic::AtomicU64;
|
||||
static LAST_DAY: AtomicU64 = AtomicU64::new(0);
|
||||
let day = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() / 86400)
|
||||
.unwrap_or(0);
|
||||
if LAST_DAY.swap(day, Ordering::SeqCst) == day {
|
||||
return; // 今日已跑
|
||||
}
|
||||
let data_dir = app.path().app_data_dir().unwrap_or_default();
|
||||
let (removed, freed) = crate::commands::ai::tool_result_offload::cleanup_expired(&data_dir);
|
||||
if removed > 0 {
|
||||
tracing::info!("[D2d] 归档清理:删除 {removed} 个过期外置文件,释放 {freed} 字节");
|
||||
}
|
||||
/// 每日归档清理,已停用(2026-10-09 用户决策:外置文件全部保留,不按 90 天删)。
|
||||
///
|
||||
/// 保留函数骨架作为恢复点:若未来恢复归档,取消下方注释即可,cleanup_expired 本体
|
||||
/// 与单测均未删。当前为空转,不触碰任何外置文件。
|
||||
async fn run_daily_offload_cleanup(_app: &AppHandle) {
|
||||
// 原逻辑:挂钟桶去重(同一天只跑一次),再调 cleanup_expired 删 90 天前文件。
|
||||
// 用户决策改为全部保留,故摘除调用点,函数保留为空转。
|
||||
//
|
||||
// use std::sync::atomic::AtomicU64;
|
||||
// static LAST_DAY: AtomicU64 = AtomicU64::new(0);
|
||||
// let day = std::time::SystemTime::now()
|
||||
// .duration_since(std::time::UNIX_EPOCH)
|
||||
// .map(|d| d.as_secs() / 86400)
|
||||
// .unwrap_or(0);
|
||||
// if LAST_DAY.swap(day, Ordering::SeqCst) == day {
|
||||
// return; // 今日已跑
|
||||
// }
|
||||
// let data_dir = _app.path().app_data_dir().unwrap_or_default();
|
||||
// let (removed, freed) = crate::commands::ai::tool_result_offload::cleanup_expired(&data_dir);
|
||||
// if removed > 0 {
|
||||
// tracing::info!("归档清理:删除 {removed} 个过期外置文件,释放 {freed} 字节");
|
||||
// }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user