新增: 无人值守安全系列(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
+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 不发(防串会话进度)。事件即逝不落库,终局不补发。