新增: 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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user