新增: 无人值守安全系列(H1-H3) + P5 审计日志与部署运维 + #14 异步委托

- H3 外部干预感知: run_agentic_loop 增 h3_watch_dir 参数, loop 启动取 git HEAD 基线, 每 5 轮探测, HEAD 被外部改写(切分支/reset/他话提交)即 emit_task_parked(stop_kind=external_change) + teardown 停靠; 4 调用方(发送/编辑/强制发送/审批续跑)从 allowed_dirs.persistent 排序取首目录, 修复初版 current_dir 恒空缺陷
- P5 审计日志: df-im-gateway audit.rs append-only SQLite(tunnel_audit 表, payload 4KB 截断, recent/by_request 查询), main.rs 8 打点点位(inbound/command/outbound), 写失败不阻断主链路, 17 tests passed
- P5 部署运维: publish-im-gateway.ps1(musl 交叉编译→scp .new→systemd 原子切换→9191 验证→自动回滚) + systemd unit + nginx im.1216.top 反代 + 运维手册
- #14 异步委托: 受理/完成两段式 + 桌面任务卡(后台进行中徽章/轮次/切换) + 飞书双端同构, 关联缺陷 #8-#12 速清

验收: cargo check workspace EXIT 0, cargo test workspace 全绿(711+560+33+12...), vue-tsc EXIT 0
This commit is contained in:
lxy
2026-10-06 15:59:10 +08:00
parent 7c252cff96
commit 438d47bc9f
17 changed files with 987 additions and 12 deletions
+9 -1
View File
@@ -194,8 +194,16 @@ pub async fn try_continue_agent_loop(
session.conv(&conv_id_owned).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1
};
// H3 外部干预感知:取首个持久授权目录(排序取首保证稳定)作为被监视工作区。
let h3_watch_dir = {
let dirs = state.allowed_dirs.read().await;
let mut sorted: Vec<_> = dirs.persistent.iter().cloned().collect();
sorted.sort();
sorted.first().map(|p| p.to_string_lossy().to_string())
};
tauri::async_runtime::spawn(async move {
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch).await;
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch, h3_watch_dir).await;
});
}
+96
View File
@@ -98,6 +98,16 @@ pub const PROGRESS_EVERY_N_ROUNDS: usize = 5;
pub const ROUND_REENTRY_MAX: usize = 3;
pub const ROUND_REENTRY_BACKOFF_SECS: [u64; ROUND_REENTRY_MAX] = [60, 300, 900];
/// H2 token 预算三闸(无人值守可靠性专项 B2,抄 sub_agent round_budget_stop 语义):
/// 无人值守长任务(max_iterations=0 不限轮次)缺 token/挂钟兜底——模型死循环级输出
/// 白烧预算无人踩闸。三闸在每轮头检查(轮间粒度,单轮内不中断):
/// ①挂钟:超 UNATTENDED_WALL_SECS(默认 30min,对齐委托任务超时底座)停靠 wall_clock;
/// ②累计 token:超 UNATTENDED_MAX_TOKENS(默认 5M)停靠 token_budget;
/// ③轮次:max_iterations>0 既有 for 上界 + 达上限停靠分支,不重复设闸。
/// 超限走既有 emit_task_parked——前端词表已预留,零新契约。
pub const UNATTENDED_WALL_SECS: u64 = 30 * 60;
pub const UNATTENDED_MAX_TOKENS: u64 = 5_000_000;
/// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs
/// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。
///
@@ -439,6 +449,9 @@ pub(crate) async fn run_agentic_loop(
// F1 并发 epoch(2026-08-05):调用方 spawn 前 `per_conv.loop_epoch.fetch_add(1)` 后传入,
// loop 捕获自己的 epoch。退出 / Guard 复位 / emit 只认 owner(epoch 匹配),否则是旧 loop。
loop_epoch: u64,
// H3 外部干预感知:被监视的工作区目录(调用方从 allowed_dirs.persistent 取,None=无授权
// 目录,探测静默跳过)。传目录而非 HEAD:loop 启动时自行取基线,续跑循环自动重置基线。
h3_watch_dir: Option<String>,
) {
// F1 并发 epoch + F2 存活心跳:一次锁内取 per_conv 关键 Arc + 建 guard。
// guard.new 同步(transition 经 ConvStateStore 无 await),可在此锁内调。
@@ -1102,6 +1115,22 @@ pub(crate) async fn run_agentic_loop(
// 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发)
let effective_max = if max_iterations == 0 { usize::MAX } else { max_iterations };
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)
let loop_started = tokio::time::Instant::now();
// H3 外部干预感知基点:loop 启动时在被监视工作区取 git HEAD 快照(轮间粒度比对)。
// 设计口径:loop_epoch 管「引擎内接管」,H3 管「引擎外干预」——用户/另一 AI 会话在 loop
// 运行期改写了工作区 HEAD(切分支/commit/reset),当前任务的工作前提已失效,继续跑有破坏面。
// 无监视目录/非 git 仓/命令失败=无法感知,静默跳过(探测是增益非阻断)。
let h3_baseline_head = if let Some(dir) = h3_watch_dir.as_deref() {
let (out, ok) = crate::commands::ai::tools::git::git_exec(
"rev-parse", &["HEAD"], dir,
).await;
if ok { out.trim().to_string() } else { String::new() }
} else {
String::new()
};
// panic 防护:loop 主体(for + 达上限收尾 + 正常完成收尾)整体包 catch_unwind。
// 背景:本函数由 IPC 层 tauri::async_runtime::spawn 托管,主体 panic 时任务静默死亡,
// conv 卡 Generating(无终态事件,前端无法恢复)。捕获后经 unwind_to_result 把 panic 转
@@ -1113,6 +1142,73 @@ pub(crate) async fn run_agentic_loop(
// 依赖 panic=unwind(tokio 默认),panic=abort 构建下 catch_unwind 不生效(尽力兜底)。
let outcome = unwind_to_result(async {
for iteration in start_iteration..effective_max {
// ── H2 三闸(B2):轮间粒度检查,超限安全停靠(轮内不中断,下一轮头生效) ──
{
let elapsed = loop_started.elapsed().as_secs();
let total_tokens = tokens.total() as u64;
let over = if elapsed >= UNATTENDED_WALL_SECS {
Some((
"wall_clock",
format!("任务运行超 {} 分钟,安全停靠:已完成进度已保存,点继续或重发消息可续跑", UNATTENDED_WALL_SECS / 60),
))
} else if total_tokens >= UNATTENDED_MAX_TOKENS {
Some((
"token_budget",
format!("任务累计消耗达 {} tokens 上限,安全停靠:防无人值守预算失控,点继续或重发消息可追加授权续跑", UNATTENDED_MAX_TOKENS),
))
} else {
None
};
if let Some((reason, message)) = over { tracing::warn!(conv_id = %conv_id, elapsed, total_tokens, "[ai] H2 闸触发({reason}),任务安全停靠");
crate::commands::ai::emit_task_parked(&app_handle, &conv_id, &message, reason);
// 走 teardown 正典收尾(save + guard 复位 + AiCompleted(incomplete))再退
let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = tokens_snapshot(&tokens);
teardown_round(
&session_arc, &db, &conv_id,
Some(&save_usage), Some(&resolved_model),
false, &provider_config, &llm_concurrency, &mut guard,
&emit_usage, last_round_estimated,
Some(true), None, Some(reason), true,
&pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false,
).await;
return;
}
// H3 外部干预感知(B4):每 5 轮探测一次 git HEAD(轮间粒度,非 git 仓跳过)。
// HEAD≠基线=有人动了工作区(切分支/reset/外部 commit),任务工作前提失效,
// 安全停靠交还用户决策。轻量 rev-parse ~10ms,5 轮一次可忽略。
if !h3_baseline_head.is_empty()
&& iteration % PROGRESS_EVERY_N_ROUNDS == 0
{
let head_now = crate::commands::ai::tools::git::git_exec(
"rev-parse", &["HEAD"], h3_watch_dir.as_deref().unwrap_or("."),
).await;
if head_now.1 && head_now.0.trim() != h3_baseline_head {
tracing::warn!(
conv_id = %conv_id,
baseline = %h3_baseline_head,
current = %head_now.0.trim(),
"[ai] H3 检测到 git HEAD 被外部改写,任务安全停靠"
);
crate::commands::ai::emit_task_parked(
&app_handle, &conv_id,
"检测到代码仓库 HEAD 在任务运行期间被外部修改(可能是切分支/回滚/其他会话提交),任务工作前提已变化,已安全停靠:请确认仓库状态后决定继续或重新安排任务",
"external_change",
); let save_usage = usage_delta_since(&tokens, &mut saved_token_snapshot);
let emit_usage = tokens_snapshot(&tokens);
teardown_round(
&session_arc, &db, &conv_id,
Some(&save_usage), Some(&resolved_model),
false, &provider_config, &llm_concurrency, &mut guard,
&emit_usage, last_round_estimated,
Some(true), None, Some("external_change"), true,
&pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false,
).await;
return;
}
}
}
// ── 进度可观测段(设计 §4.3):每 N 轮 emit AiTaskProgress ──
// round 1 起算对齐 AiAgentRound 口径;max_rounds=0=不限轮次(前端按无上限处理)。
// owner 守卫:被接管的旧 loop 不发(防串会话进度)。事件即逝不落库,终局不补发。
+33 -3
View File
@@ -446,9 +446,16 @@ pub async fn ai_regenerate(
let mut session = state.ai_session.lock().await;
session.conv(&conv_id).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1
};
// H3 外部干预感知:取首个持久授权目录(排序取首稳定)作被监视工作区。
let h3_watch_dir = {
let dirs = state.allowed_dirs.read().await;
let mut sorted: Vec<_> = dirs.persistent.iter().cloned().collect();
sorted.sort();
sorted.first().map(|p| p.to_string_lossy().to_string())
};
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch, h3_watch_dir).await;
});
Ok("ok".to_string())
@@ -894,9 +901,16 @@ pub(crate) async fn spawn_agentic_session(
let mut session = state.ai_session.lock().await;
session.conv(&conv_id).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1
};
// H3 外部干预感知:取首个持久授权目录(排序取首稳定)作被监视工作区。
let h3_watch_dir = {
let dirs = state.allowed_dirs.read().await;
let mut sorted: Vec<_> = dirs.persistent.iter().cloned().collect();
sorted.sort();
sorted.first().map(|p| p.to_string_lossy().to_string())
};
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch).await;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, loop_epoch, h3_watch_dir).await;
});
// 返回 conv_id(共用核心:IPC 层映射回 "ok",编排节点消费 conv_id 等待完成通知)
@@ -2055,6 +2069,13 @@ pub async fn ai_chat_edit(
let mut session = state.ai_session.lock().await;
session.conv(&conv_id).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1
};
// H3 外部干预感知:取首个持久授权目录(排序取首稳定)作被监视工作区。
let h3_watch_dir = {
let dirs = state.allowed_dirs.read().await;
let mut sorted: Vec<_> = dirs.persistent.iter().cloned().collect();
sorted.sort();
sorted.first().map(|p| p.to_string_lossy().to_string())
};
tauri::async_runtime::spawn(async move {
run_agentic_loop(
@@ -2073,6 +2094,7 @@ pub async fn ai_chat_edit(
0,
model_override,
loop_epoch,
h3_watch_dir,
)
.await;
});
@@ -2324,8 +2346,16 @@ pub async fn ai_chat_force_send(
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
// H3 外部干预感知:取首个持久授权目录(排序取首稳定)作被监视工作区。
let h3_watch_dir = {
let dirs = state.allowed_dirs.read().await;
let mut sorted: Vec<_> = dirs.persistent.iter().cloned().collect();
sorted.sort();
sorted.first().map(|p| p.to_string_lossy().to_string())
};
tauri::async_runtime::spawn(async move {
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch).await;
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, conv_model_override, new_epoch, h3_watch_dir).await;
});
Ok("ok".to_string())
@@ -302,6 +302,55 @@ pub async fn ai_list_recoverable(
.collect())
}
/// B4(无人值守可靠性专项):恢复 parked 会话(断点续跑闭环收尾动作)。
///
/// V49 闭环最后一块:B3 已提供 parked 清单(可恢复入口),本 IPC 接恢复动作——
/// 复用 spawn_agentic_session 正典链路(provider 解析/占用检查/loop_epoch/spawn),
/// 注入恢复引导消息让 LLM 自查上下文后续跑;loop 启动即置 loop_state='running'
/// (既有逻辑),parked 态自然清除。恢复前守卫:
/// ① 会话须存在且 loop_state='parked'(非 parked 拒绝,防误触跑会话);
/// ② conv_states.can_accept_request(目标 conv 真活跃则拒绝,双保险)。
#[tauri::command]
pub async fn ai_recover_parked(
app: AppHandle,
state: State<'_, AppState>,
conversation_id: String,
) -> Result<String, String> {
use super::chat::spawn_agentic_session;
let record = state
.ai_conversations
.get_by_id(&conversation_id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
if record.loop_state.as_deref() != Some("parked") {
return Err(format!(
"会话 {} 非可恢复状态(loop_state={:?}),仅 parked 会话可恢复",
conversation_id, record.loop_state
));
}
if !state.conv_states.can_accept_request(&conversation_id) {
return Err("该会话正在生成中,无需恢复".to_string());
}
// 恢复引导消息:LLM 上下文里既有对话历史完整(消息拆分存储回放),只需一句指令触发续跑。
let message = "[系统恢复] 应用重启导致上次任务中断,已完成进度已保留。请回顾上方对话上下文,继续完成未竟的任务;若上下文不足以判断进度,先向用户确认已完成的步骤再继续。"
.to_string();
spawn_agentic_session(
&app,
&state,
message,
None, // language:沿用会话既有 agent_language
None, // skill:恢复不挂技能
None, // model_override:沿用会话既有模型
Some(conversation_id.clone()),
None, // parts:纯文本
None, // mention_spans
true, // pin_active:恢复动作即用户当前意图
)
.await
}
/// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端)
#[tauri::command]
pub async fn ai_conversation_switch(