新增: B5 续作引擎完整批: 自领+停靠恢复+监控推送+提交冻结

- B5 引擎: 看板轮询自动领单(G2 门禁 fail-closed, 领单原子写), parked 自动恢复优先, 收敛回读续作, 3 次熔断自停, KV 配置默认关
- 引擎配套: 提交冻结同步(df-commit-frozen), 工具结果外载过期清理, 工具失败快速记忆, 自监控快照飞书推送(df-monitor-chat-id)
- 领单判据收紧: 正文以 🔴 开头才判高危(正文提及字样不误判)
- 28 注入末轮竞态: 补充指令落末轮时顺延轮次预算(封顶 8)
- 停靠卡继续伪失败根修: ai_continue_loop 非 active 时分流 parked 恢复内核(recover_parked_inner 单一事实源)
- 实测: 写板 70 秒自动领单, 领单→执行→销账→回读→领下一单 全链闭环, 2050 测试过
This commit is contained in:
lxy
2026-10-07 18:32:43 +08:00
parent e36b55a2f6
commit 8c06076873
14 changed files with 1580 additions and 29 deletions
+112 -5
View File
@@ -423,6 +423,33 @@ pub(crate) use helpers::{
DestructiveLoopSample, destructive_loop_sample, detect_destructive_loop,
};
/// 末轮注入扩轮单 loop 累计上限(todo.md 28):每次末轮注入顺延 1 轮,封顶防注入洪水把
/// 轮次预算无限放大(每扩轮都是真实 LLM 开销);打满上限后注入仍入消息,由下一轮或收尾轮可见。
const INJECT_EXTRA_ROUNDS_CAP: usize = 8;
/// 末轮注入扩轮的终点判定纯函数(todo.md 28):loop 迭代到达动态轮次终点时,按注入侧顺延
/// 余量决定「续跑几轮 or 收尾」。抽纯函数便于单测锁定「末轮注入→有续轮」契约(完整 loop
/// 依赖 Tauri 运行时,集成测试成本高;契约由本函数与注入侧 injection_hits_final_round
/// (commands/ai/commands/chat.rs)分半锁定)。调用方已保证「iteration 已达动态终点」。
///
/// 返回 Some(take)=消费 take 轮余量继续;None=收尾(break,等价原 for 自然结束)。
fn inject_extra_rounds_decision(
effective_max: usize,
consumed_extra: usize,
granted: usize,
) -> Option<usize> {
// 不限轮次 loop(max_iterations=0)到不了终点,判定分支不可达;防御返回收尾
if effective_max == usize::MAX {
return None;
}
// 余量封顶:单 loop 累计扩轮不超过 INJECT_EXTRA_ROUNDS_CAP
let room = INJECT_EXTRA_ROUNDS_CAP.saturating_sub(consumed_extra);
if granted == 0 || room == 0 {
return None;
}
Some(granted.min(room))
}
/// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环
///
/// 退出条件:
@@ -458,11 +485,16 @@ pub(crate) async fn run_agentic_loop(
// conv_read 不惰性建(与旧 stop_flag/notify 取用段一致):conv 已删(per_conv 无条目)
// → 旧 loop 直接退出(ConvStateStore 条目应已被 conversation_delete 同步清)。
let (stop_flag, notify, loop_epoch_arc, last_heartbeat, mut guard) = {
let session = session_arc.lock().await;
let Some(conv) = session.conv_read(&conv_id) else {
let mut session = session_arc.lock().await;
if session.conv_read(&conv_id).is_none() {
tracing::warn!(stale_conv = %conv_id, "[ai] loop 入口 conv 已删,退出");
return;
};
}
let conv = session.conv(&conv_id);
// 末轮注入扩轮基准(todo.md 28):写本 loop 的轮次预算快照(注入侧凭它判「末轮」;
// 热改 agent_max_iterations 不影响在跑 loop)+ 清零上 loop 残留的注入顺延余量。
conv.loop_max_rounds = max_iterations;
conv.round_budget_extra = 0;
(
conv.stop_flag.clone(),
conv.notify.clone(),
@@ -1114,6 +1146,9 @@ 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 };
// 末轮注入扩轮(todo.md 28):for 上界放宽 CAP,真实终点由轮顶动态判定(下方);
// 无注入扩轮时动态判定与原上界等价,行为零变化。扩轮总数封顶防注入洪水无限续跑。
let mut injected_extra_rounds: usize = 0;
// H2 token 三闸基点:loop 启动挂钟起点(轮间粒度检查,单轮不中断)
let loop_started = tokio::time::Instant::now();
@@ -1141,7 +1176,32 @@ pub(crate) async fn run_agentic_loop(
// 范围:仅 loop 主体;provider 解析/配额获取等已有独立错误分支的段落不包,避免语义重叠。
// 依赖 panic=unwind(tokio 默认),panic=abort 构建下 catch_unwind 不生效(尽力兜底)。
let outcome = unwind_to_result(async {
for iteration in start_iteration..effective_max {
for iteration in start_iteration..effective_max.saturating_add(INJECT_EXTRA_ROUNDS_CAP) {
// ── 末轮注入扩轮判定(todo.md 28):到原轮次终点时消费注入侧顺延余量,有则续跑
// (补充指令获得带工具的完整消费轮),无则 break 与原 for 自然结束等价,落下方
// 既有 max_rounds 收尾分支(收敛出口零改动)。判定收敛到纯函数,单测锁契约。
if iteration >= effective_max.saturating_add(injected_extra_rounds) {
let granted = {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(&conv_id) {
let c = session.conv(&conv_id);
std::mem::take(&mut c.round_budget_extra)
} else {
0
}
};
match inject_extra_rounds_decision(effective_max, injected_extra_rounds, granted) {
Some(take) => {
injected_extra_rounds += take;
tracing::info!(
conv_id = %conv_id,
extra_rounds = injected_extra_rounds,
"[ai] 末轮注入触发扩轮,轮次预算顺延 {} 轮", take
);
}
None => break,
}
}
// ── H2 三闸(B2):轮间粒度检查,超限安全停靠(轮内不中断,下一轮头生效) ──
{
let elapsed = loop_started.elapsed().as_secs();
@@ -1218,7 +1278,7 @@ pub(crate) async fn run_agentic_loop(
emit_ai_chat_event(&app_handle, AiChatEvent::AiTaskProgress {
conversation_id: Some(conv_id.clone()),
round: (iteration + 1) as u32,
max_rounds: max_iterations as u32,
max_rounds: (max_iterations + injected_extra_rounds) as u32,
tokens_used: tokens.total(),
});
}
@@ -2794,6 +2854,10 @@ pub(crate) async fn emit_ai_completed_once(
let _ = repo.set_loop_state(&cid, None).await;
});
}
// B5 无人值守续作引擎收敛回读钩子:引擎在途会话到达本出口即清其标志并唤醒轮询器
// 提前扫板续作(非引擎会话到达时引擎侧比对 conv 不匹配零动作;残留兜底在引擎轮内
// can_accept_request 判据)。本 helper 是全部正常出口统一单点,钩子挂此处即全覆盖。
crate::commands::ai::unattended::notify_batch_done(conv_id);
// emit 端(前端通道):incomplete 用 emit_incomplete(MidStream 传 Some(true))。
// token 分项(2026-08-02):cache_hit/cache_miss/reasoning 透传前端分计费展示。
// publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为);
@@ -3412,3 +3476,46 @@ mod round_reentry_tests {
assert_eq!(ROUND_REENTRY_BACKOFF_SECS, [60, 300, 900]);
}
}
// 末轮注入扩轮(todo.md 28)loop 侧契约:到达原轮次终点时,注入侧顺延的余量必须被消费
// 续跑(「末轮注入→有续轮」),无余量则收尾(与原 for 自然结束等价,零行为变化)。
// 与注入侧判定 injection_hits_final_round(commands/ai/commands/chat.rs)分半锁同一契约。
#[cfg(test)]
mod inject_extra_rounds_tests {
use super::{inject_extra_rounds_decision, INJECT_EXTRA_ROUNDS_CAP};
#[test]
fn final_round_with_grant_continues() {
// 末轮注入→有续轮:原终点处有余量,续 1 轮(核心契约)
assert_eq!(inject_extra_rounds_decision(10, 0, 1), Some(1));
// 多次末轮注入余量叠加,一次全消费(上限内)
assert_eq!(inject_extra_rounds_decision(10, 0, 3), Some(3));
}
#[test]
fn final_round_without_grant_ends() {
// 无注入:终点处无余量,收尾(行为与原 for 自然结束逐路径等价)
assert_eq!(inject_extra_rounds_decision(10, 0, 0), None);
}
#[test]
fn extra_rounds_capped() {
// 累计扩轮封顶 INJECT_EXTRA_ROUNDS_CAP:room 不足只消费剩余额度,打满后不再扩轮
assert_eq!(
inject_extra_rounds_decision(10, INJECT_EXTRA_ROUNDS_CAP - 1, 3),
Some(1),
"room=1 时只消费 1 轮",
);
assert_eq!(
inject_extra_rounds_decision(10, INJECT_EXTRA_ROUNDS_CAP, 2),
None,
"打满上限后不再扩轮",
);
}
#[test]
fn unlimited_loop_never_extends() {
// max_iterations=0 不限轮次:effective_max=usize::MAX,终点分支不可达,防御收尾
assert_eq!(inject_extra_rounds_decision(usize::MAX, 0, 5), None);
}
}