修复: 模型停用无效根因 + 上下文归位/压缩守卫
- 模型停用无效根因: get_active_provider 两条路径均不校验 enabled(显式活跃直返/默认只挑 is_default) → 收敛为启用集合解析,无启用 provider 明确报错 - 压缩/标题兜底模型不校验 enabled → 新增 resolve_fallback_model 纯函数(title 复用同一实现,单一源无双轨) - 上下文多夹层归位修复: gather_tool_responses 聚拢该头全部响应(此前第二个夹层之后的响应仍被判未闭合剥除) - 压缩后台任务加内容版本哨兵(取首条消息 id,否掉 len:压缩期间 loop 正常 append 会致守卫永不生效) + panic 兜底复位标志 - 902 超时类失败与其它 Fatal 区分停靠文案(点继续可续跑) - 子代理事件按 conv 改挂(route_scope) + 运行期事件推父会话视图 - 飞书 13a 真根因: run_stream_refresher 死代码(提前 drop)+静默钟被心跳重置 → 帧通道+仅内容事件重置 - 项目详情文件查看: F22 目录不存在误显非 Git 仓库 / F24 模块为仓库子目录时 git 徽标全失效(scope_git_path 按 show-prefix 剥前缀) - 更新徽章通道错配(Tauri 事件 vs window CustomEvent 同名不同通道,徽章此前永不触发) - ProjectCard 卡片根 keydown 顶替内嵌按钮(补 e.target 守卫) - i18n 坏键 ai.aiTool.rejectedHint → aiTool.rejectedHint(双缺会渲染裸 key) - miniapp 三处: 会话列表刷新漏网/看门狗节流致新一轮无兜底/newConversation 漏清看门狗
This commit is contained in:
@@ -46,6 +46,8 @@ use super::conversation::{save_conversation, TokenAccumulator};
|
||||
use super::knowledge_inject::{maybe_spawn_extraction};
|
||||
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
||||
use super::{AiChatEvent, AiSession, ErrorType};
|
||||
// B5a 挂钟闸分任务定级:轮间闸读会话策略(未注册落 legacy 硬停,确定性任务检查点续跑)。
|
||||
use super::unattended::{wall_clock_policy_for, WallClockAction};
|
||||
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
||||
|
||||
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
||||
@@ -102,6 +104,8 @@ pub const ROUND_REENTRY_BACKOFF_SECS: [u64; ROUND_REENTRY_MAX] = [60, 300, 900];
|
||||
/// 无人值守长任务(max_iterations=0 不限轮次)缺 token/挂钟兜底——模型死循环级输出
|
||||
/// 白烧预算无人踩闸。三闸在每轮头检查(轮间粒度,单轮内不中断):
|
||||
/// ①挂钟:超 UNATTENDED_WALL_SECS(默认 30min,对齐委托任务超时底座)停靠 wall_clock;
|
||||
/// 未注册分档的会话落此 legacy 阈值;已注册会话由 unattended::wall_clock_policy_for 的
|
||||
/// 策略决定(确定性任务放宽+检查点续跑,探索性/失控疑似硬停),见下方挂钟闸;
|
||||
/// ②累计 token:超 UNATTENDED_MAX_TOKENS(默认 5M)停靠 token_budget;
|
||||
/// ③轮次:max_iterations>0 既有 for 上界 + 达上限停靠分支,不重复设闸。
|
||||
/// 超限走既有 emit_task_parked——前端词表已预留,零新契约。
|
||||
@@ -1163,8 +1167,9 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 无注入扩轮时动态判定与原上界等价,行为零变化。扩轮总数封顶防注入洪水无限续跑。
|
||||
let mut injected_extra_rounds: usize = 0;
|
||||
|
||||
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)
|
||||
let loop_started = tokio::time::Instant::now();
|
||||
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)。
|
||||
// 可变:确定性任务到点走检查点续跑时顺延起点(见下方挂钟闸)。
|
||||
let mut loop_started = tokio::time::Instant::now();
|
||||
|
||||
// H3 外部干预感知基点:loop 启动时在被监视工作区取 git HEAD 快照(轮间粒度比对)。
|
||||
// 设计口径:loop_epoch 管「引擎内接管」,H3 管「引擎外干预」——用户/另一 AI 会话在 loop
|
||||
@@ -1219,10 +1224,30 @@ pub(crate) async fn run_agentic_loop(
|
||||
{
|
||||
let elapsed = loop_started.elapsed().as_secs();
|
||||
let total_tokens = tokens.total() as u64;
|
||||
let over = if elapsed >= UNATTENDED_WALL_SECS {
|
||||
// ── 挂钟闸(B5a):读会话策略决定阈值与到点动作 ──
|
||||
// None(未注册/分档关)→ legacy 30min 硬停,零行为变更;
|
||||
// CheckpointContinue(确定性任务)→ 到点存进度 + 顺延挂钟起点后本轮续跑,不停工;
|
||||
// HardPark(探索性/失控疑似)→ 维持原 emit_task_parked(wall_clock) + teardown。
|
||||
let wall_policy = wall_clock_policy_for(&conv_id);
|
||||
let wall_deadline = wall_policy.map_or(UNATTENDED_WALL_SECS, |p| p.deadline_secs);
|
||||
let mut wall_exceeded = elapsed >= wall_deadline;
|
||||
if wall_exceeded
|
||||
&& matches!(wall_policy.map(|p| p.action), Some(WallClockAction::CheckpointContinue))
|
||||
{
|
||||
// 检查点续跑:落库当前进度(不等 loop 出口),挂钟起点顺延,本轮继续。
|
||||
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
|
||||
save_conversation(&session_arc, &db, &conv_id, Some(&save_usage), Some(&resolved_model), true).await;
|
||||
loop_started = tokio::time::Instant::now();
|
||||
wall_exceeded = false;
|
||||
tracing::info!(
|
||||
conv_id = %conv_id, elapsed, deadline = wall_deadline,
|
||||
"[ai] 挂钟到点,检查点续跑(确定性任务)"
|
||||
);
|
||||
}
|
||||
let over = if wall_exceeded {
|
||||
Some((
|
||||
"wall_clock",
|
||||
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", UNATTENDED_WALL_SECS / 60),
|
||||
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", wall_deadline / 60),
|
||||
))
|
||||
} else if total_tokens >= UNATTENDED_MAX_TOKENS {
|
||||
Some((
|
||||
@@ -1364,9 +1389,13 @@ pub(crate) async fn run_agentic_loop(
|
||||
"[ai] 连续 {} 轮失败,重入预算耗尽,任务停靠",
|
||||
consecutive_round_failures,
|
||||
);
|
||||
// 停靠语义:超时类(上游收了请求不响应,非确定性)与其他 Fatal 走
|
||||
// 不同文案——超时突出「可继续」,由停靠卡「继续」一键续跑而非中断;
|
||||
// 其余 Fatal 维持原连续失败文案(确定性错误,续跑无益)。
|
||||
let reason = fatal_park_reason(consecutive_round_failures, &error);
|
||||
crate::commands::ai::emit_task_parked(
|
||||
&app_handle, &conv_id,
|
||||
&format!("模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}", consecutive_round_failures, error),
|
||||
&reason,
|
||||
"fatal",
|
||||
);
|
||||
return;
|
||||
@@ -2778,6 +2807,34 @@ fn classify_permanent_provider_error(error: &str) -> bool {
|
||||
model_missing || no_access || param_reject
|
||||
}
|
||||
|
||||
// ── is_timeout_error: 超时类失败判定(首字节超时/流式超时/调用超时) ──
|
||||
// 超时是「上游收了请求却不响应」的非确定性失败:重入退避后仍未恢复时,续跑可能因上游恢复
|
||||
// 而成功,故落停靠语义给一键「继续」,而非让用户只见裸错误文案后中断。与之相对,4xx/鉴权/
|
||||
// 参数类 Fatal 是确定性错误(改配置才有解,续跑无益),不套用本判定——判定只读错误文本的
|
||||
// 超时特征(引擎各超时出口文案含「超时」或 diag 包装的 timeout 分类)。
|
||||
fn is_timeout_error(error: &str) -> bool {
|
||||
error.to_lowercase().contains("timeout") || error.contains("超时")
|
||||
}
|
||||
|
||||
/// 重入预算耗尽时的停靠文案决策(纯函数,便于单测锁定「超时类 → 停靠」契约)。
|
||||
///
|
||||
/// 超时类走「超时 + 可续跑」文案(点明可一键继续);其余 Fatal 维持原「连续失败」文案。
|
||||
/// stop_kind 均用既有 "fatal"(不新增停靠族:前端 stop_kind 词表/i18n 未同步,新值会退化
|
||||
/// 为中性兜底标签;超时与其他 Fatal 的区分落在文案语义,卡片「继续」按钮两者皆已具备)。
|
||||
fn fatal_park_reason(failures: usize, error: &str) -> String {
|
||||
if is_timeout_error(error) {
|
||||
format!(
|
||||
"模型响应连续 {} 轮超时(上游收了请求未及时响应,重入退避后仍未恢复),任务安全停靠:已完成进度已保存,点「继续」可基于当前上下文续跑",
|
||||
failures
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"模型调用连续 {} 轮失败(重入退避后仍未恢复),任务安全停靠:{}",
|
||||
failures, error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ──
|
||||
// 双写经 emit_ai_chat_event。error_type: 可判定的永久性 provider 错误(模型不存在/令牌无权/
|
||||
// 参数拒绝类 400)归 ProviderConfig,其余维持 Network 现状(2026-10-01 两端统一时的口径)。
|
||||
@@ -3502,6 +3559,56 @@ mod round_reentry_tests {
|
||||
}
|
||||
}
|
||||
|
||||
// 902 契约:超时类失败在轮次重入预算耗尽时落停靠语义(而非只留裸错误文案中断)。
|
||||
// 停靠文案由纯函数 fatal_park_reason 决策,本测试锁「超时类 → 超时+可续跑文案 /
|
||||
// 非超时 Fatal → 原连续失败文案」两分支(完整 loop 依赖 Tauri 运行时,契约由纯函数锁定)。
|
||||
#[cfg(test)]
|
||||
mod fatal_park_tests {
|
||||
use super::{fatal_park_reason, is_timeout_error};
|
||||
|
||||
#[test]
|
||||
fn timeout_class_recognized() {
|
||||
// 首字节超时(stream_recv 出口文案,全角括号)
|
||||
assert!(is_timeout_error(
|
||||
"流式响应超时(10秒未收到首字节,服务无响应或请求体过大)"
|
||||
));
|
||||
// 专用线程 120s 硬超时
|
||||
assert!(is_timeout_error("LLM 调用超时(120s)"));
|
||||
// diag 包装的英文分类(timeout)
|
||||
assert!(is_timeout_error(
|
||||
"[u-switch] 流式接收错误(timeout): request timed out"
|
||||
));
|
||||
// 非超时:4xx/鉴权/模型不存在不误判
|
||||
assert!(!is_timeout_error(
|
||||
"[x] AI 调用失败(HTTP 401): unauthorized"
|
||||
));
|
||||
assert!(!is_timeout_error("model is not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_exhaustion_parks_with_continue_guidance() {
|
||||
let reason = fatal_park_reason(4, "LLM 调用超时(120s)");
|
||||
assert!(reason.contains("超时"), "超时类停靠文案须点明超时: {reason}");
|
||||
assert!(
|
||||
reason.contains("继续"),
|
||||
"超时类停靠文案须给一键「继续」指引: {reason}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_timeout_fatal_keeps_generic_reason() {
|
||||
let reason = fatal_park_reason(4, "[x] AI 调用失败(HTTP 404): model not found");
|
||||
assert!(
|
||||
reason.contains("连续 4 轮失败"),
|
||||
"非超时 Fatal 维持原连续失败文案: {reason}"
|
||||
);
|
||||
assert!(
|
||||
reason.contains("model not found"),
|
||||
"原诊断文本须保留: {reason}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 末轮注入扩轮(todo.md 28)loop 侧契约:到达原轮次终点时,注入侧顺延的余量必须被消费
|
||||
// 续跑(「末轮注入→有续轮」),无余量则收尾(与原 for 自然结束等价,零行为变化)。
|
||||
// 与注入侧判定 injection_hits_final_round(commands/ai/commands/chat.rs)分半锁同一契约。
|
||||
|
||||
Reference in New Issue
Block a user