新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏

- 待发箱地基: V51 ai_outbox 表 + 手写 OutboxRepo(claim CAS/崩溃残留复位/瞬时失败保留) + outbox.rs 调度器(KV 默认关/Notify 唤醒/会话忙跳过不丢)
- Wake 异步唤醒原语: 三条件(delay/file-appear/process-exit)+纯判定+注入走正典 spawn_agentic_session,默认关
- 上下文预算随模型窗口: model_context.rs 三级优先(池显式>预设表>回退 128K);上界取 1M 而非 128K——max_tokens 仅裁剪预算,卡 128K 等于不治根因
- 文件租约补纯函数与单测(未另造双轨:LEASE 早随 cb260ab 落地) / 跨会话召回补纯函数单测(主体早已落地)
- 子代理: 完整卡已消化 + 子会话压缩层 + P3 批量
- 隧道/中继: 指令离线不丢 + Hello proto 版本 + 心跳与 runbook
- df-mcp: 写工具后落 KV 变更标记(供 GUI 轮询兜底,补独立 stdio 进程无感知)
- 迁移链顶点改单一真值常量 LATEST_SCHEMA_VERSION(修 2 处因 V51 失效的断言)
- 治理: i18n 特殊字符提交门禁(含反引号覆盖 + 10 自测) / 根目录卫生门禁 / 看板归档器 / 单一 lockfile(bun) / swap-install 落 temp/ 且兼容无 Get-FileHash
- 场景护栏: 流式正文可见性 e2e(playwright 免真机事件回放) + 列表续写/外置句柄 node 测试
This commit is contained in:
lxy
2026-10-10 08:09:17 +08:00
parent 9c68f98d89
commit b37279f3a2
32 changed files with 4175 additions and 152 deletions
+73 -25
View File
@@ -902,6 +902,38 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
4. 输出纯 JSON,无任何额外文字\n\
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
/// 弱推断会话唯一提及的项目名(级 b,**纯函数**:仅解析消息文本,不碰 DB)。
///
/// 反向扫最近 20 条 user 消息,收集全部 `[项目:名称]` 标记(chat.rs `extract_pinned_goal`
/// 与 df-ai `strip_mention_tags` 同款字面量);**去重后恰好唯一**才返回该名,零命中或
/// 多项目返回 None(B1 拍板:多项目 mention 不猜,防张冠李戴)。
///
/// `dedup(); len()==1` 等价于「所有提及名相同」:任意两处不同名必留下相邻不重复元素
/// 使 len≥2,故 A/B/A 这类非相邻重复也被正确判为两个项目(→ None)。
fn unique_mentioned_project(messages: &[ChatMessage]) -> Option<&str> {
let mut mentioned: Vec<&str> = Vec::new();
for m in messages.iter().rev().take(20) {
if !matches!(m.role, MessageRole::User) {
continue;
}
let mut rest = m.content.as_str();
while let Some(start) = rest.find("[项目:") {
let after = &rest[start + "[项目:".len()..];
if let Some(end) = after.find(']') {
let name = after[..end].trim();
if !name.is_empty() {
mentioned.push(name);
}
rest = &after[end + 1..];
} else {
break; // 未闭合(可能截断),停扫本条
}
}
}
mentioned.dedup();
(mentioned.len() == 1).then(|| mentioned[0])
}
/// 项目归属弱推断(跨会话召回最小闭环,设计 D1:a→b 两级,皆无 → None=全局知识)。
///
/// 级 a(高置信):读 `ai_conversations.boundary_json`(V45 无人值守边界)的 `dir_allowlist`
@@ -947,31 +979,9 @@ async fn resolve_source_project(db: &Arc<Database>, conv_id: &str, messages: &[C
}
}
// 级 b(B1:mention 唯一才启用):最近消息扫 `[项目:名称]` 标记,唯一命中才归属
let mut mentioned: Vec<&str> = Vec::new();
for m in messages.iter().rev().take(20) {
if !matches!(m.role, MessageRole::User) {
continue;
}
let mut rest = m.content.as_str();
// 找全部 `[项目:xxx]` / `[项目:xxx]` 标记(半/全角冒号),收名称
while let Some(start) = rest.find("[项目:") {
let after = &rest[start + "[项目:".len()..];
if let Some(end) = after.find(']') {
let name = after[..end].trim();
if !name.is_empty() {
mentioned.push(name);
}
rest = &after[end + 1..];
} else {
break; // 未闭合(可能截断),停扫本条
}
}
}
// 去重后恰好唯一才猜(B1 拍板:多项目 mention 不猜)
mentioned.dedup();
if mentioned.len() == 1 {
let name = mentioned[0];
// 级 b(B1:mention 唯一才启用):唯一命中才归属,多项目/零 mention 不猜
// (判定收口至纯函数 unique_mentioned_project,便于单测覆盖命中/未命中/去重)
if let Some(name) = unique_mentioned_project(messages) {
if let Ok(projects) = project_repo.list_active().await {
if let Some(p) = projects.iter().find(|p| p.name == name) {
return Some(p.id.clone());
@@ -1605,6 +1615,7 @@ mod salvage_tests {
origin: None,
boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()),
loop_state: None,
status: "active".into(),
})
.await
.unwrap();
@@ -1652,4 +1663,41 @@ mod salvage_tests {
// 零 mention → None
assert_eq!(resolve_source_project(&db, "conv-ghost", &[um("普通消息")]).await, None);
}
// ---------- 唯一提及项目判定(纯函数 unique_mentioned_project:命中/未命中/去重) ----------
/// 命中:单条唯一提及 → 去空白后返回名称
#[test]
fn unique_mention_hit_and_trim() {
let msgs = vec![um("修一下 [项目: 项目甲] 的列表页")];
assert_eq!(unique_mentioned_project(&msgs), Some("项目甲"));
}
/// 去重:同名跨消息重复 → 去重后唯一 → 仍命中
#[test]
fn unique_mention_dedup_same_name_repeated() {
let msgs = vec![
um("[项目:项目甲] 先看这个"),
um("还是 [项目:项目甲] 的老问题"),
];
assert_eq!(unique_mentioned_project(&msgs), Some("项目甲"));
}
/// 未命中:零提及 / 多项目 / 非相邻重复 / 非 user 消息
#[test]
fn unique_mention_miss_cases() {
let none_mention = vec![um("普通消息,无标记")];
assert_eq!(unique_mentioned_project(&none_mention), None);
let multi = vec![um("[项目:项目甲] 和 [项目:项目乙] 谁对")];
assert_eq!(unique_mentioned_project(&multi), None);
// 非相邻重复(A/B/A)视为两个项目 → 不猜(去重判据的关键边界)
let interleaved = vec![um("[项目:项目甲] [项目:项目乙] [项目:项目甲]")];
assert_eq!(unique_mentioned_project(&interleaved), None);
// 非 user 消息里的标记不参与(仅扫 user)
let assistant_only = vec![ChatMessage::assistant("[项目:项目甲]")];
assert_eq!(unique_mentioned_project(&assistant_only), None);
}
}
+84 -13
View File
@@ -18,7 +18,9 @@
//! 惯例为规范绝对路径,过度归一反而引入 surprise。
//!
//! ## 单测
//! 见文末 tests 模块:互斥/同会话放行/释放/TTL 过期/键归一五组。
//! 见文末 tests 模块:跨会话互斥/同会话放行/释放/TTL 过期/多会话并存等。冲突检测与
//! 过期清理抽为 `try_register`/`purge_expired` 纯函数——单测注入独立表与时间点,
//! 既不真等 TTL,也不触碰全局 REGISTRY(免并发测试互相污染)。
use std::collections::HashMap;
use std::sync::LazyLock;
@@ -111,17 +113,31 @@ pub(crate) fn acquire_leases(
return Ok(LeaseGuard { released: true, keys: Vec::new() });
}
let paths = args_paths(tool_name, args);
if paths.is_empty() {
return Ok(LeaseGuard { released: true, keys: Vec::new() });
}
let now = Instant::now();
let mut reg = REGISTRY.write().expect("文件租约表锁");
// 第一遍:冲突检测(不做部分登记,全过才统一登记)
for p in &paths {
let keys = try_register(&mut reg, conv_id, agent_kind, tool_name, tool_call_id, &paths, Instant::now())?;
Ok(LeaseGuard { released: false, keys })
}
/// 冲突检测 + 登记(纯逻辑,不含锁):对 `reg` 做「全过才登记」,便于单测注入独立表
/// 与时间点,不必真等 TTL,也不污染全局 REGISTRY(并发测试隔离)。
///
/// 先扫一遍 `paths`:任一路径被**其他会话**活跃占用即返回 Err(指路文案),不做部分
/// 登记;顺路把过期条目当泄漏清掉。全过后统一登记,返回登记的键集合。同会话同路径
/// 放行(见 acquire_leases 文档)。
fn try_register(
reg: &mut HashMap<String, LeaseHolder>,
conv_id: &str,
agent_kind: &'static str,
tool_name: &str,
tool_call_id: &str,
paths: &[String],
now: Instant,
) -> Result<Vec<String>, String> {
for p in paths {
let key = lease_key(p);
if let Some(holder) = reg.get(&key) {
if holder.conv_id == conv_id {
continue; // 同会话:放行(见函数文档)
continue; // 同会话:放行(见 acquire_leases 文档)
}
if holder.expired(now) {
reg.remove(&key); // TTL 兜底:过期租约视为泄漏,清掉
@@ -130,10 +146,9 @@ pub(crate) fn acquire_leases(
return Err(holder.describe());
}
}
// 第二遍:全部登记
let mut keys = Vec::with_capacity(paths.len());
for p in paths {
let key = lease_key(&p);
let key = lease_key(p);
reg.insert(key.clone(), LeaseHolder {
conv_id: conv_id.to_string(),
agent_kind,
@@ -143,7 +158,7 @@ pub(crate) fn acquire_leases(
});
keys.push(key);
}
Ok(LeaseGuard { released: false, keys })
Ok(keys)
}
/// RAII 租约守卫:drop 自动释放(Ok/Err/panic unwind 全覆盖)。
@@ -184,9 +199,8 @@ impl Drop for LeaseGuard {
/// 只读查询:当前全部活跃租约(query_leases 工具数据源,W-h2 兄弟感知)。
pub(crate) fn query_leases() -> Vec<(String, LeaseHolder)> {
let now = Instant::now();
let mut reg = REGISTRY.write().expect("文件租约表锁");
reg.retain(|_, h| !h.expired(now)); // 查询顺手清过期
purge_expired(&mut reg, Instant::now()); // 查询顺手清过期
let mut items: Vec<(String, LeaseHolder)> = reg
.iter()
.map(|(k, h)| (k.clone(), h.clone()))
@@ -195,6 +209,13 @@ pub(crate) fn query_leases() -> Vec<(String, LeaseHolder)> {
items
}
/// 过期清理(纯逻辑):移除所有过期条目,返回移除条数(query 只读面顺手清扫泄漏)。
fn purge_expired(reg: &mut HashMap<String, LeaseHolder>, now: Instant) -> usize {
let before = reg.len();
reg.retain(|_, h| !h.expired(now));
before - reg.len()
}
/// 注册 query_leases 只读工具(W-h2:LLM 自查「谁在占用什么」,兄弟感知最小入口)。
/// Low 风险零参数;返回人话列表供 LLM 直接消费。
pub(crate) fn register_lease_tool(registry: &mut df_ai::ai_tools::AiToolRegistry) {
@@ -342,4 +363,54 @@ mod tests {
let _g2 = acquire_leases(CONV_B, "main", "write_file", "tc-p2", &json!({"path": "/x/p.rs"}))
.expect("unwind drop 后应已释放");
}
#[test]
fn expired_lease_reclaimed_on_conflict_check() {
// 纯逻辑单测:注入独立表 + 时间点(不碰全局 REGISTRY,免并发测试互相污染)。
let mut reg: HashMap<String, LeaseHolder> = HashMap::new();
let t0 = Instant::now();
let a = try_register(&mut reg, CONV_A, "main", "write_file", "tc-e1", &["/x/e.rs".to_string()], t0)
.expect("t0 时 A 登记成功");
assert_eq!(a, vec!["/x/e.rs".to_string()]);
// TTL 边界内:他会话写同路径 → 冲突拒绝
let within = t0 + LEASE_TTL - Duration::from_secs(1);
assert!(
try_register(&mut reg, CONV_B, "sub", "patch_file", "tc-e2", &["/x/e.rs".to_string()], within).is_err(),
"TTL 内应互斥"
);
// 越过 TTL:过期条目被约束检查惰性清理 → B 获取成功,表中只剩 B
let t_later = t0 + LEASE_TTL + Duration::from_secs(1);
let b = try_register(&mut reg, CONV_B, "sub", "patch_file", "tc-e3", &["/x/e.rs".to_string()], t_later)
.expect("过期残留清掉后 B 可获取");
assert_eq!(b, vec!["/x/e.rs".to_string()]);
assert_eq!(reg.len(), 1, "过期的 A 条目应已被清理");
assert_eq!(reg.get("/x/e.rs").unwrap().conv_id, CONV_B);
}
#[test]
fn purge_expired_removes_stale_entries() {
let mut reg: HashMap<String, LeaseHolder> = HashMap::new();
let t0 = Instant::now();
try_register(&mut reg, CONV_A, "main", "write_file", "tc-p1", &["/x/purge-a.rs".to_string()], t0).unwrap();
try_register(&mut reg, CONV_B, "main", "write_file", "tc-p2", &["/x/purge-b.rs".to_string()], t0).unwrap();
// 未到 TTL:不清理
assert_eq!(purge_expired(&mut reg, t0 + LEASE_TTL - Duration::from_secs(1)), 0);
assert_eq!(reg.len(), 2);
// 越过 TTL:两条都清
assert_eq!(purge_expired(&mut reg, t0 + LEASE_TTL + Duration::from_secs(1)), 2);
assert!(reg.is_empty());
}
#[test]
fn multi_conv_leases_coexist_on_distinct_paths() {
// 多会话并存:异路径互不冲突,同表共存(纯逻辑,独立表)。
let mut reg: HashMap<String, LeaseHolder> = HashMap::new();
let now = Instant::now();
try_register(&mut reg, CONV_A, "main", "write_file", "tc-ma", &["/x/a-multi.rs".to_string()], now).unwrap();
try_register(&mut reg, CONV_B, "sub", "write_file", "tc-mb", &["/x/b-multi.rs".to_string()], now)
.expect("异路径不冲突");
assert_eq!(reg.len(), 2);
assert_eq!(reg.get("/x/a-multi.rs").unwrap().conv_id, CONV_A);
assert_eq!(reg.get("/x/b-multi.rs").unwrap().conv_id, CONV_B);
}
}
+149
View File
@@ -0,0 +1,149 @@
//! C2 GUI 侧接点:轮询 df-mcp 落库的变更标记,把外部写库转成 `df-data-changed`。
//!
//! 背景:stdio 形态的 MCP 是独立进程,无 AppHandle,写库后 GUI 无从经事件感知,
//! 造成双端数据断层(P0-2)。df-mcp 每次成功写工具后向 [`MCP_DATA_CHANGED_KEY`]
//! 覆盖写入 `{"at": <毫秒>, "tool": "<工具名>"}`(跨进程落库信令)。
//!
//! 本模块周期读该 KV,`at` 推进即视为外部改动过库,再按 `tool` 复用
//! [`emit_data_changed`] 的 entity 映射补发 `df-data-changed` 刷新前端列表。
//! 桌面内嵌 HTTP 形态走 on_tool_call 直接 emit,不经此路(标记仅 stdio 写)。
//!
//! 接线:lib.rs setup 调 [`start`] 挂载;接线落地前本模块无外部调用方,
//! 先 allow(dead_code) 保留入口(零调用方≠垃圾)。
#![allow(dead_code)]
use std::time::Duration;
use tauri::{AppHandle, Manager};
use super::audit::emit_data_changed;
use crate::state::AppState;
use df_mcp::MCP_DATA_CHANGED_KEY;
/// 轮询周期(秒)。变更标记是低频兜底信令(非实时事件队列),10s 内感知即够。
const POLL_SECS: u64 = 10;
/// 变更标记体(KV 值反序列化目标)。
#[derive(serde::Deserialize)]
struct Marker {
/// 写入时刻(毫秒时间戳),用于判定是否为新的一次外部改动。
at: i64,
/// 触发写入的工具名,用于复用 entity 映射。
tool: String,
}
/// 解析 KV 原值为标记;缺失/坏值返 None(调用方保持基线,不下发也不推进)。
fn parse_marker(raw: Option<&str>) -> Option<Marker> {
serde_json::from_str(raw?).ok()
}
/// 变更标记观察器:维护上次 `at` 基线,判定是否需补发。
///
/// 纯逻辑(只持 `last_at`,无 IO),便于单测;IO/事件下发留在 [`start`] 循环。
struct MarkerWatcher {
last_at: Option<i64>,
}
impl MarkerWatcher {
fn new() -> Self {
Self { last_at: None }
}
/// 观察一次 KV 原值:返回需补发 `df-data-changed` 的工具名,无则 None。
///
/// - 首次读到有效标记只建基线不补发(避免每次 GUI 启动空刷一次列表)
/// - `at` 未变 → None;`at` 推进 → 更新基线并返回工具名
/// - 缺失/坏值 → None(基线不动,下轮有效值仍能判定)
fn observe(&mut self, raw: Option<&str>) -> Option<String> {
let marker = parse_marker(raw)?;
match self.last_at {
Some(prev) if prev == marker.at => None,
Some(_) => {
self.last_at = Some(marker.at);
Some(marker.tool)
}
None => {
self.last_at = Some(marker.at);
None
}
}
}
}
/// GUI 侧接点挂载入口(lib.rs setup 调用一次)。
///
/// 常驻轮询循环;读库失败静默跳过(下轮再试),不 panic 拖垮后台任务。
pub fn start(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut watcher = MarkerWatcher::new();
let mut interval = tokio::time::interval(Duration::from_secs(POLL_SECS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
let raw = {
let state = app.state::<AppState>();
state.settings.get(MCP_DATA_CHANGED_KEY).await.ok().flatten()
};
if let Some(tool) = watcher.observe(raw.as_deref()) {
tracing::debug!(tool = %tool, "mcp 变更标记推进,补发 df-data-changed");
emit_data_changed(&app, &tool);
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_rejects_missing_or_bad() {
assert!(parse_marker(None).is_none());
assert!(parse_marker(Some("not json")).is_none());
assert!(parse_marker(Some(r#"{"at":1}"#)).is_none()); // 缺 tool
}
#[test]
fn parse_ok() {
let m = parse_marker(Some(r#"{"at":123,"tool":"create_task"}"#)).unwrap();
assert_eq!(m.at, 123);
assert_eq!(m.tool, "create_task");
}
#[test]
fn first_read_baselines_without_emit() {
let mut w = MarkerWatcher::new();
assert_eq!(w.observe(Some(r#"{"at":100,"tool":"create_task"}"#)), None);
}
#[test]
fn advanced_at_emits_tool() {
let mut w = MarkerWatcher::new();
w.observe(Some(r#"{"at":100,"tool":"create_task"}"#));
assert_eq!(
w.observe(Some(r#"{"at":200,"tool":"update_project"}"#)),
Some("update_project".to_string())
);
}
#[test]
fn unchanged_at_no_emit() {
let mut w = MarkerWatcher::new();
w.observe(Some(r#"{"at":100,"tool":"create_task"}"#));
assert_eq!(w.observe(Some(r#"{"at":100,"tool":"create_task"}"#)), None);
assert_eq!(w.observe(Some(r#"{"at":100,"tool":"other"}"#)), None);
}
#[test]
fn missing_or_bad_keeps_baseline() {
let mut w = MarkerWatcher::new();
w.observe(Some(r#"{"at":100,"tool":"create_task"}"#));
assert_eq!(w.observe(None), None);
assert_eq!(w.observe(Some("garbage")), None);
// 基线未动:随后真推进仍能补发
assert_eq!(
w.observe(Some(r#"{"at":101,"tool":"create_task"}"#)),
Some("create_task".to_string())
);
}
}
+28 -1
View File
@@ -46,6 +46,10 @@ pub mod get_app_config;
pub mod knowledge_inject;
// 文件租约注册表(W-h1 感知租约):写类文件工具跨会话互斥 + query_leases 兄弟感知
pub mod lease;
// C2 GUI 侧接点:轮询 df-mcp 落库变更标记,stdio MCP 独立进程写库后补发 df-data-changed
pub mod mcp_watch;
// 939 ★③:按目标模型上下文窗口构造上下文预算配置(收口会话构造点)
pub mod model_context;
// 系统通知(P1 bg 化补完):完成/失败/需介入三类事件,主窗托盘化时补位提醒(条件发送)
pub(crate) mod notify;
pub(crate) mod feishu_notify;
@@ -66,6 +70,10 @@ pub mod tool_result_offload;
pub mod tools;
// B5 无人值守续作引擎:看板轮询自动领单 + parked 自动恢复(收敛回读钩子在 agentic 出口)
pub mod unattended;
// 待发箱调度器(定时消息与待发箱持久化 P0 地基):到点且会话空闲即经正典 spawn_agentic_session 派发
pub mod outbox;
// W-b 异步唤醒原语:内存 registry 三条件(delay/file-appear/process-exit)满足即注入续跑
pub mod wake;
use agentic::conv_state::{ConvState, ConvStateStore};
@@ -1223,7 +1231,8 @@ impl PerConvState {
/// 新建默认会话级状态。
///
/// 各字段初值**逐字对齐 [`AiSession::new`]** —— 批2 迁移调用方时行为不变。
/// - messages: ContextManager::new(ContextConfig::default())
/// - messages: 先 `ContextConfig::default()`(128K),发送入口再经 [`Self::apply_model_window`]
/// 按目标模型实际窗口重设(939 ★③;未重设时等价旧 128K 行为)
/// - generating: false
/// - stop_flag: Arc::new(AtomicBool::new(false))
/// - notify: Arc::new(tokio::sync::Notify::new())
@@ -1261,6 +1270,19 @@ impl PerConvState {
readonly_cache_floor: 0,
}
}
/// 按目标模型上下文窗口重设消息预算(939 ★③,收口见 [`model_context`])。
///
/// **仅在会话尚无消息时生效**:已有消息时重建 `ContextManager` 会丢弃
/// `pending_topic_marker`/`needs_full_rewrite` 等内存态、且可能错位 `persisted_msg_count`
/// 持久化基线,风险大于收益。故只对「构造点语义」的空会话重建;已存在的会话沿用其
/// 创建时(同样走本方法)设定的窗口——模型切换不回溯改预算(切换后新会话生效)。
pub fn apply_model_window(&mut self, model_id: &str, model_configs: &[df_ai::df_ai_core::model::ModelConfig]) {
if !self.messages.is_empty() {
return;
}
self.messages = ContextManager::new(model_context::context_config_for_model(model_id, model_configs));
}
}
impl Default for PerConvState {
@@ -1444,6 +1466,11 @@ pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &s
if let Err(e) = repo.set_loop_state(&cid, Some("parked")).await {
tracing::warn!(conv_id = %cid, error = %e, "[ai] 停靠写 loop_state=parked 失败");
}
// V50 终局态:停靠=未完成终局(断流/审批超时/重试预算耗尽等),标 incomplete 供未完成率统计。
// 用户恢复(ai_continue_loop)时由 recover_parked_inner 复位 active,避免续作后误标。
if let Err(e) = repo.set_status(&cid, "incomplete").await {
tracing::warn!(conv_id = %cid, error = %e, "[ai] 停靠写 status=incomplete 失败");
}
});
}
// ② 发停靠事件(前端渲染停靠卡+继续按钮)。
+121
View File
@@ -0,0 +1,121 @@
//! 按目标模型的上下文窗口构造 [`ContextConfig`](df_ai::context::ContextConfig)。
//!
//! 背景(939 ★③):主会话 `PerConvState::new` 原固定 `ContextConfig::default()`(预算恒 128K),
//! 不随模型窗口变化 —— 1M 窗口模型(deepseek-v4-flash / gemini-1.5-pro)被按 128K 裁(能力降级),
//! 小窗口模型(如 deepseek-chat 64K)预算高于实际窗口(有过发风险)。本模块在会话构造点
//! 按 `resolved_model` 的实际窗口重设预算,收口到单一入口。
//!
//! 窗口来源优先级(高 → 低),沿用仓库既有探测链风格:
//! 1. provider 池 `model_configs` 中该模型的显式配置 —— 但仅当 `probe_source` 为
//! 预设表/用户手填(窗口可信)时采信。`context_window` 未配置时是默认哨兵 8192,
//! `probe_source` 为 None/Heuristic,不能当真实窗口(否则未配置模型被误降到 8K)。
//! 2. 内置预设表 `model_probe::probe` 精确/前缀匹配命中(PresetTable)。
//! 3. 均未命中 → 保守回退 128K(等价旧行为,零回归)。
//!
//! 取舍:
//! - 仅按窗口**下调/持平**预算,绝不放大到超出来源置信范围;未知窗口回退 128K 不降级。
//! - 上限钳制到 [`MAX_CONTEXT_TOKENS`](1M)而非需求示例的 128K:128K 会让「大窗口被当
//! 128K 裁」的根因不治;`max_tokens` 只是裁剪预算(历史不足则不裁,不会撑大实际发送),
//! 放宽上限只减少无谓裁剪——1M 作保守上界挡住预设表脏值即可。
use df_ai::context::ContextConfig;
use df_ai::df_ai_core::model::{ModelConfig, ProbeSource};
use df_ai::model_probe;
/// 预算上限安全顶:挡住脏预设值,不挡真实大窗口(deepseek-v4-flash 恰为 1M)。
const MAX_CONTEXT_TOKENS: u32 = 1_048_576;
/// 未知窗口的保守回退(等价旧行为 128K,零回归)。
const FALLBACK_CONTEXT_TOKENS: u32 = 128_000;
/// 按模型窗口构造上下文预算配置(主会话/子代理统一口径)。
pub fn context_config_for_model(model_id: &str, model_configs: &[ModelConfig]) -> ContextConfig {
let max_tokens = match window_for_model(model_id, model_configs) {
Some(w) => (w as u32).min(MAX_CONTEXT_TOKENS),
None => {
// 未识别模型:保守回退 128K(todo 要求列出未识别模型,故留 debug 日志)
tracing::debug!(model = %model_id, "[ai] 未见模型上下文窗口配置,预算回退 128K");
FALLBACK_CONTEXT_TOKENS
}
};
// 其余维度(输出预留/安全系数)沿用 ContextConfig 默认,与应用既有裁剪口径一致。
ContextConfig { max_tokens, ..ContextConfig::default() }
}
/// 解析模型上下文窗口(tokens)。未命中可信来源返 `None`。
fn window_for_model(model_id: &str, model_configs: &[ModelConfig]) -> Option<usize> {
// ① provider 池显式配置:仅预设表/用户手填来源可信(其余为默认哨兵 8192,非真实窗口)
if let Some(cfg) = model_configs.iter().find(|m| m.model_id == model_id) {
if matches!(
cfg.probe_source,
Some(ProbeSource::PresetTable) | Some(ProbeSource::UserSet)
) {
return Some(cfg.context_window);
}
}
// ② 预设表命中(精确 + 前缀匹配,与路由/能力探测同款来源)
let probed = model_probe::probe(model_id);
if probed.probe_source == Some(ProbeSource::PresetTable) {
return Some(probed.context_window);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(model_id: &str, window: usize, source: Option<ProbeSource>) -> ModelConfig {
ModelConfig { context_window: window, probe_source: source, ..ModelConfig::with_defaults(model_id) }
}
#[test]
fn preset_model_uses_preset_window() {
// glm-4 预设 128K → max_tokens=128K(持平旧行为)
let c = context_config_for_model("glm-4", &[]);
assert_eq!(c.max_tokens, 128_000);
}
#[test]
fn small_window_model_reduced() {
// deepseek-chat 预设 64K < 128K → 预算下调,不再按 128K 过度发送
let c = context_config_for_model("deepseek-chat", &[]);
assert_eq!(c.max_tokens, 64_000);
}
#[test]
fn large_window_model_not_capped_at_128k() {
// deepseek-v4-flash 预设 1M → 预算随窗口放大(★③ 根因:不再被当 128K 裁)
let c = context_config_for_model("deepseek-v4-flash", &[]);
assert_eq!(c.max_tokens, 1_048_576);
}
#[test]
fn unknown_model_falls_back_128k() {
// 未知模型无预设/配置 → 保守回退 128K(零回归)
let c = context_config_for_model("acme-unknown-model", &[]);
assert_eq!(c.max_tokens, 128_000);
}
#[test]
fn configured_pool_window_takes_priority() {
// provider 池用户手填窗口(32K)优先于预设,并被采信
let pool = vec![cfg("glm-4", 32_000, Some(ProbeSource::UserSet))];
let c = context_config_for_model("glm-4", &pool);
assert_eq!(c.max_tokens, 32_000);
}
#[test]
fn pool_default_sentinel_not_trusted() {
// 池内未配置(哨兵 8192 + 非可信来源)→ 不采信,回落到预设(glm-4=128K)
let pool = vec![cfg("glm-4", 8192, None)];
let c = context_config_for_model("glm-4", &pool);
assert_eq!(c.max_tokens, 128_000);
}
#[test]
fn prefix_match_inherits_window() {
// 前缀匹配(glm-4v-flash → glm-4v 预设)继承窗口
let c = context_config_for_model("glm-4v-flash", &[]);
assert_eq!(c.max_tokens, 128_000);
}
}
+371
View File
@@ -0,0 +1,371 @@
//! 待发箱调度器(定时消息与待发箱持久化 P0 地基)。
//!
//! 抄 `unattended.rs` 形态:tokio 常驻循环 + KV 开关热读 + 到点派发走既有正典
//! `spawn_agentic_session`。设计见
//! docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.3。
//!
//! 组成:
//! - 常驻循环:周期兜底 + `Notify` 即时唤醒(会话空闲/变更后立即续发,不强实时)
//! - 到点取件:list_due(status='pending' 且 due_at 到点)→ 会话空闲 → claim CAS → 派发
//! - 单会话单轮一条:同会话同轮只派一条(串行保序),会话忙则本轮跳过(不丢,下轮再取)
//! - 开关默认关:未配置 `df-outbox-enabled` 时循环只读配置空转,零行为
//!
//! ⚠️ P0 地基:调度器与派发链已就位,但 **未接线**(lib.rs setup 挂载 + agentic 收敛出口钩子),
//! 故本模块暂整体 allow(dead_code)。接线清单见交付说明,不在此文件内改 lib.rs。
//!
//! 配置面(Settings KV,通用 settings_get/settings_set IPC 可读写):
//! - `df-outbox-enabled` bool("true"/"1" 为开),默认 **false**(地基默认关,可观测空转)
//! - `df-outbox-poll-secs` u64,默认 10(下限钳 5,防误配热循环)
//! - `df-outbox-max-attempts` i64,默认 3(受理瞬时失败累计达上限转 failed)
#![allow(dead_code)]
use std::collections::HashSet;
use std::sync::OnceLock;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use df_ai::provider::ContentPart;
use df_storage::crud::OutboxRepo;
use df_types::augmentation::MentionSpanDto;
use crate::commands::ai::commands::chat::spawn_agentic_session;
use crate::commands::ai::notify::{notify_session_event, NotifyKind};
use crate::state::AppState;
// ============================================================
// 配置
// ============================================================
const KV_ENABLED: &str = "df-outbox-enabled";
const KV_POLL_SECS: &str = "df-outbox-poll-secs";
const KV_MAX_ATTEMPTS: &str = "df-outbox-max-attempts";
const DEFAULT_POLL_SECS: u64 = 10;
const MIN_POLL_SECS: u64 = 5;
const DEFAULT_MAX_ATTEMPTS: i64 = 3;
/// 单轮取件上限(防一轮全量派发突发)。
const DUE_BATCH_LIMIT: i64 = 32;
/// 调度器运行期配置(每轮从 KV 热读,改配置无需重启)。
#[derive(Debug, Clone, PartialEq, Eq)]
struct OutboxConfig {
enabled: bool,
poll_secs: u64,
max_attempts: i64,
}
impl Default for OutboxConfig {
fn default() -> Self {
// 默认关:未配置时调度器空转,零行为(地基默认关,便于先观测后放量)。
OutboxConfig {
enabled: false,
poll_secs: DEFAULT_POLL_SECS,
max_attempts: DEFAULT_MAX_ATTEMPTS,
}
}
}
impl OutboxConfig {
fn from_kv(get: &dyn Fn(&str) -> Option<String>) -> Self {
let mut cfg = OutboxConfig::default();
if let Some(v) = get(KV_ENABLED) {
let v = v.trim().trim_matches('"').to_lowercase();
cfg.enabled = v == "true" || v == "1";
}
if let Some(v) = get(KV_POLL_SECS) {
if let Ok(secs) = v.trim().trim_matches('"').parse::<u64>() {
cfg.poll_secs = secs.max(MIN_POLL_SECS);
}
}
if let Some(v) = get(KV_MAX_ATTEMPTS) {
if let Ok(n) = v.trim().trim_matches('"').parse::<i64>() {
cfg.max_attempts = n.max(1);
}
}
cfg
}
}
async fn read_config(app: &AppHandle) -> OutboxConfig {
let state = app.state::<AppState>();
let kv = [
(KV_ENABLED, state.settings.get(KV_ENABLED).await.ok().flatten()),
(KV_POLL_SECS, state.settings.get(KV_POLL_SECS).await.ok().flatten()),
(KV_MAX_ATTEMPTS, state.settings.get(KV_MAX_ATTEMPTS).await.ok().flatten()),
];
OutboxConfig::from_kv(&|k| {
kv.iter().find(|(key, _)| *key == k).and_then(|(_, v)| v.clone())
})
}
/// 从 `AppState.db` 现构 OutboxRepo(仅 clone 内部 conn 句柄,廉价)。
/// P0 地基不经 AppState 持字段(最小改动);接线期若需 IPC 复用可再落 `state.outbox`。
fn outbox_repo(app: &AppHandle) -> OutboxRepo {
let state = app.state::<AppState>();
OutboxRepo::new(&state.db)
}
// ============================================================
// 唤醒原语
// ============================================================
/// 调度器唤醒:周期 tick 兜底,`Notify` 供会话空闲/变更后即时唤醒(不强实时)。
static NOTIFY: OnceLock<tokio::sync::Notify> = OnceLock::new();
fn notify() -> &'static tokio::sync::Notify {
NOTIFY.get_or_init(tokio::sync::Notify::new)
}
/// 会话转空闲钩子:agentic 收敛出口(emit_ai_completed_once)在 `notify_batch_done` 旁并列调用。
/// 会话一空闲即唤醒调度器续发同会话待发项,替代前端 `ai-drain-queue` 自驱。
///
/// 参数 `_conv_id` 保留为将来「仅唤醒相关会话」的扩展位;当前全量唤醒(按 due 重新扫描,幂等安全)。
pub fn notify_conv_idle(_conv_id: &str) {
notify().notify_one();
}
// ============================================================
// 派发循环
// ============================================================
/// 调度器挂载入口(lib.rs setup 调用一次)。开关关时每轮只读配置空转,零副作用。
pub fn start(app: AppHandle) {
tauri::async_runtime::spawn(async move {
// 启动复位崩溃半途残留的 sending(进程重启时必为陈旧值),使重启后待发不卡。
// 幂等安全动作,先于使能判定执行(即便开关关,复位也无害)。
match outbox_repo(&app).reset_stale_sending().await {
Ok(n) if n > 0 => tracing::info!("[outbox] 启动复位 {n} 条发送中残留 → 待发"),
Ok(_) => {}
Err(e) => tracing::warn!("[outbox] 启动复位 sending 残留失败(非阻断): {e}"),
}
let cfg = read_config(&app).await;
let mut interval = tokio::time::interval(Duration::from_secs(cfg.poll_secs));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut cur_poll_secs = cfg.poll_secs;
loop {
tokio::select! {
_ = interval.tick() => {}
_ = notify().notified() => {}
}
let cfg = read_config(&app).await;
if cfg.poll_secs != cur_poll_secs {
cur_poll_secs = cfg.poll_secs;
interval = tokio::time::interval(Duration::from_secs(cur_poll_secs));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
}
if !cfg.enabled {
continue; // 开关关:零行为(默认关)
}
run_tick(&app, &cfg).await;
}
});
}
/// 单轮:取到点待发项 → 会话空闲 → claim CAS → 派发正典链;受理失败按永久/瞬时分流。
async fn run_tick(app: &AppHandle, cfg: &OutboxConfig) {
let repo = outbox_repo(app);
let now = df_types::now_millis();
let items = match repo.list_due(now, DUE_BATCH_LIMIT).await {
Ok(v) => v,
Err(e) => {
tracing::warn!("[outbox] 取待发项失败: {e}");
return;
}
};
// 同会话同轮只派一条(串行保序):派发后该会话本轮不再派,余项下轮再取。
let mut dispatched: HashSet<String> = HashSet::new();
for item in items {
let conv = item.conversation_id.clone();
if dispatched.contains(&conv) {
continue;
}
let state = app.state::<AppState>();
// 会话忙(生成中)→ 本轮跳过,不 claim、不丢,下轮 due 仍在会再取(顺延到空闲)。
if !state.conv_states.can_accept_request(&conv) {
continue;
}
// CAS 占取:pending → sending,防同条被并发双取。
match repo.claim(&item.id).await {
Ok(true) => {}
Ok(false) => continue, // 他轮已取,跳过
Err(e) => {
tracing::warn!("[outbox] claim {} 失败: {e}", item.id);
continue;
}
}
let parts = parse_parts(item.parts_json.as_deref());
let spans = parse_spans(item.spans_json.as_deref());
let result = spawn_agentic_session(
app,
&state,
item.text.clone(),
None, // language 沿用默认
item.skill.clone(), // /技能名
item.model_override.clone(),
Some(conv.clone()), // 目标会话(定时/队列不误发到活跃会话)
parts,
spans,
false, // pin_active=false:不劫持用户活跃面板
)
.await;
match result {
Ok(_conv_id) => {
// Ok=受理成功即终态:轮次内失败(流断/工具错)由既有 loop 自愈,不回流 outbox。
let _ = repo.mark_sent(&item.id).await;
dispatched.insert(conv.clone());
emit(app, "ai-outbox-sent", &item.id, &conv, None, None);
emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
tracing::info!("[outbox] 派发 {} → 会话 {conv}", item.id);
}
Err(err) => {
if is_permanent_spawn_error(&err) {
let _ = repo.mark_failed(&item.id, &err).await;
emit(app, "ai-outbox-failed", &item.id, &conv, Some(&err), Some(true));
notify_session_event(
app,
NotifyKind::Failed,
&conv,
"定时/待发消息发送失败",
&format!("受理失败(不可重试): {err}"),
);
tracing::error!("[outbox] 受理永久失败 {}: {err}", item.id);
} else {
// 瞬时失败:回退 pending 保留下轮再试(网络超时/会话忙竞态天然重试)。
let attempts = repo.revert_pending(&item.id, Some(&err)).await.unwrap_or(0);
if attempts >= cfg.max_attempts {
let _ = repo.mark_failed(&item.id, &err).await;
emit(app, "ai-outbox-failed", &item.id, &conv, Some(&err), Some(false));
notify_session_event(
app,
NotifyKind::Failed,
&conv,
"定时/待发消息发送失败",
&format!("连续 {attempts} 次受理失败: {err}"),
);
tracing::error!("[outbox] 重试达上限({attempts})转失败 {}: {err}", item.id);
} else {
emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
tracing::warn!(
"[outbox] 受理瞬时失败 {} (第 {attempts} 次),下轮重试: {err}",
item.id
);
}
}
}
}
}
}
/// 判定受理失败是否永久(不重试)。仅覆盖明确不会因重试而改变的签名;
/// 其余一律视为瞬时(保守,永不静默丢——宁可多试不误判丢消息)。
///
/// 判据来源:spawn_agentic_session 的前置失败串(provider 未配 / 模型不支持该内容);
/// 与前端 `classifySendError` 同源,此处收敛为 Rust 侧最小集合。
fn is_permanent_spawn_error(err: &str) -> bool {
const PERMANENT_SIGNATURES: &[&str] = &[
"不支持图片", // 纯图片 + 模型无 vision:同内容重试恒失败
"未配置", // provider 未配置:需先配置,自动重试无意义
"无可用", // 无可用 provider
"not configured",
];
PERMANENT_SIGNATURES.iter().any(|s| err.contains(s))
}
/// parts_json / spans_json 反序列化(空/None → None,纯文本)。
fn parse_parts(json: Option<&str>) -> Option<Vec<ContentPart>> {
let s = json?;
if s.trim().is_empty() {
return None;
}
serde_json::from_str(s).ok()
}
fn parse_spans(json: Option<&str>) -> Option<Vec<MentionSpanDto>> {
let s = json?;
if s.trim().is_empty() {
return None;
}
serde_json::from_str(s).ok()
}
/// 前端事件双路 emit(变更/已发/失败)。payload 与设计 §3.3 一致,前端 `ai-outbox-*` 订阅刷新。
fn emit(
app: &AppHandle,
event: &str,
id: &str,
conv_id: &str,
error: Option<&str>,
permanent: Option<bool>,
) {
let mut payload = serde_json::json!({ "id": id, "conversation_id": conv_id });
if let (Some(e), Some(p)) = (error, permanent) {
payload["error"] = serde_json::json!(e);
payload["permanent"] = serde_json::json!(p);
}
let _ = app.emit(event, payload);
}
// ============================================================
// 单测(纯函数:配置解析/永久失败判据)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
/// 开关默认关:未配置 KV 时 enabled=false(调度器空转零行为),且 poll/max_attempts 取默认。
#[test]
fn config_defaults_disabled_so_switch_off_is_zero_behavior() {
let cfg = OutboxConfig::from_kv(&|_| None);
assert!(!cfg.enabled, "默认关:未配置时调度器空转,零行为");
assert_eq!(cfg.poll_secs, DEFAULT_POLL_SECS);
assert_eq!(cfg.max_attempts, DEFAULT_MAX_ATTEMPTS);
}
/// KV 解析:开关 + 周期下限钳 + 上限;非 true/1 视为关。
#[test]
fn config_parses_kv_with_clamps() {
let cfg = OutboxConfig::from_kv(&|k| match k {
"df-outbox-enabled" => Some("\"true\"".to_string()),
"df-outbox-poll-secs" => Some("1".to_string()),
"df-outbox-max-attempts" => Some("5".to_string()),
_ => None,
});
assert!(cfg.enabled);
assert_eq!(cfg.poll_secs, MIN_POLL_SECS, "低于下限钳到 5s 防热循环");
assert_eq!(cfg.max_attempts, 5);
// 显式 false → 关
let cfg = OutboxConfig::from_kv(&|k| {
(k == "df-outbox-enabled").then(|| "false".to_string())
});
assert!(!cfg.enabled);
}
/// 永久失败判据只命中明确签名;会话忙等瞬时错一律不判永久(保守不误丢)。
#[test]
fn permanent_spawn_error_classification() {
assert!(is_permanent_spawn_error("当前模型不支持图片,无法发送纯图片消息"));
assert!(is_permanent_spawn_error("未配置 AI 提供商"));
assert!(!is_permanent_spawn_error("AI 正在生成中,请等待完成"));
assert!(!is_permanent_spawn_error("network timeout"));
}
/// parts/spans 空串 → None;非法 JSON → None 降级纯文本(不阻断派发)。
#[test]
fn parse_parts_and_spans_degrade_safely() {
assert!(parse_parts(None).is_none());
assert!(parse_parts(Some("")).is_none());
assert!(parse_parts(Some("not-json")).is_none());
assert!(parse_spans(None).is_none());
assert!(parse_spans(Some("[\"broken\"")).is_none());
}
/// 开关关时零行为的语义锚点:STATUS_PENDING 常量经 crud 单一来源(防裸串漂移)。
#[test]
fn status_constant_single_source() {
assert_eq!(df_storage::crud::STATUS_PENDING, "pending");
}
}
+28 -11
View File
@@ -158,6 +158,10 @@ pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) {
"model": {
"type": "string",
"description": "可选,子代理用模型 id;缺省继承当前会话模型"
},
"temperature": {
"type": "number",
"description": "可选,子代理采样温度(0~2);缺省沿用 provider 配置/默认 0.7,不影响主会话"
}
},
"required": ["task"]
@@ -166,7 +170,7 @@ pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) {
registry,
ctx: SpawnAgentCtx,
"spawn_agent",
"派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 12);model 可选模型(缺省继承当前会话)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回",
"派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 12);model 可选模型(缺省继承当前会话);temperature 可选采样温度(缺省沿用 provider 配置)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回",
RiskLevel::Medium,
meta: ToolMeta {
// 审批卡文案模板(模板 {} 按位置填充,键取 task)
@@ -210,6 +214,8 @@ async fn spawn_agent_handler(
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
// 采样温度独立覆盖(缺省 None=沿用 provider 配置/默认 0.7,不影响主会话)
let temperature = args.get("temperature").and_then(|v| v.as_f64()).map(|t| t as f32);
let requested: Vec<String> = args
.get("tools")
.and_then(|v| v.as_array())
@@ -241,21 +247,27 @@ async fn spawn_agent_handler(
return Err(anyhow::anyhow!("spawn_agent 无法定位父会话:只能在会话内调用"));
}
// model 不传 → 继承父会话模型 override(与父对话同模型语义)
let child_model = match model {
Some(m) => Some(m),
None => ctx
.session
.lock()
.await
.conv_read(&parent_conv_id)
.and_then(|c| c.model_override.clone()),
// model 不传 → 继承父会话模型 override(与父对话同模型语义);
// lang 恒沿用父会话语言(agent_language,缺省中文),子代理主体与工具纪律段随之同语,
// 否则英文会话派生的子代理仍用中文提示词(报告语言/术语断层)。一次锁内取两者。
let (child_model, parent_lang) = {
let session = ctx.session.lock().await;
let parent = session.conv_read(&parent_conv_id);
let model = match model {
Some(m) => Some(m),
None => parent.and_then(|c| c.model_override.clone()),
};
let lang = parent
.and_then(|c| c.agent_language.clone())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "zh-CN".to_string());
(model, lang)
};
let child_id = next_child_id(&parent_conv_id);
let task_prompt = build_task_prompt(&task, &allowed, &trimmed);
// P2-3:系统提示词注入实际生效的预算值(父可传 1..20,子代理按真实预算规划)
let system_prompt = sub_agent_system_prompt(max_rounds);
let system_prompt = sub_agent_system_prompt(max_rounds, &parent_lang);
// P3-5:spawn 时刻快照父会话 save 元数据(active provider/created_at)。子代理后台
// 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据;
@@ -286,6 +298,8 @@ async fn spawn_agent_handler(
tools = allowed.len(),
max_rounds,
model = ?child_model,
lang = %parent_lang,
temperature = ?temperature,
"[sub-agent] 派发子代理"
);
@@ -303,6 +317,7 @@ async fn spawn_agent_handler(
allowed,
max_rounds,
child_model,
temperature,
ctx.llm_concurrency.clone(),
parent_save_meta,
)
@@ -316,6 +331,8 @@ async fn spawn_agent_handler(
"agent_id": child_id,
"rounds": outcome.rounds,
"tokens": outcome.tokens,
// 实际生效模型(回退/候选切换后的最终值):父会话据此知晓子代理真用了哪个模型
"model": outcome.model,
"report": outcome.summary,
}))
}
File diff suppressed because it is too large Load Diff
+367
View File
@@ -0,0 +1,367 @@
//! 异步唤醒原语(W-b 核心,设计文档《并行化与异步唤醒-2026-10-04》§二)
//!
//! 治「挂起即等人」:挂起任务登记唤醒条件,条件满足自动唤醒注入续跑。首期进程内存
//! registry(随 app 存亡,不落库——唤醒条件本就依附 app 存续,接力棒场景由进程外脚本
//! 兜底)。条件三型:delay(到点)/ file-appear(路径出现)/ process-exit(进程退出)。
//!
//! ## 不新增机制(设计 §2.2)
//! 唤醒动作复用既有注入通路:向目标会话注入一条 user 角色消息并续跑。既有无人在场
//! 领单走正典 `spawn_agentic_session`(会话生成态守卫 + epoch 语义俱在),本模块沿用
//! 同一路径,不造第二套注入。守卫语义照设计——目标会话正在生成时注入被拒,条目保留
//! 待下轮重试(对齐「轮界排队,不打断流」)。
//!
//! ## 防重(设计 §2.1)
//! once 语义(触发成功即移除)+ expires 过期清理(默认 24h)+ registry 上限 64。
//!
//! ## 判定纯函数化
//! 条件判定([`condition_met`]/[`decide`])为纯函数,IO(时间/文件存在/进程存活)由
//! [`observe`] 采集后喂入,便于单测三型条件与过期/触发优先级。
//!
//! ## 开关
//! 默认关(KV `df-wake-enabled`,关时调度器每轮只读配置空转,零行为变更)。
//!
//! ## 接线
//! 调度器入口 [`start`] 需在 lib.rs setup 挂载一次(与 unattended::start 同处),本批未越界。
//! 挂载前本模块整体不可达,`allow(dead_code)` 即为此待接线态;接线后应移除该属性恢复死码检查。
#![allow(dead_code)]
use std::sync::{LazyLock, RwLock};
use std::time::Duration;
use tauri::{AppHandle, Manager};
use crate::commands::ai::commands::chat::spawn_agentic_session;
use crate::state::AppState;
/// 开关 KV:默认 false。关时调度器空转,且无条目注册 → 全链路零行为变更。
const KV_ENABLED: &str = "df-wake-enabled";
/// 调度器轮询周期(秒)。delay 型精度上界 + file/process 型发现延迟上界。
const POLL_SECS: u64 = 30;
/// registry 条数上限(设计 §2.1「宁少勿预留」)。
const MAX_ENTRIES: usize = 64;
/// 默认条目存活期(24h,设计 §2.1)。到点未触发即过期清理。
const DEFAULT_TTL_MILLIS: i64 = 24 * 60 * 60 * 1000;
/// 唤醒条件(首期三型,设计 §2.1)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WakeCondition {
/// 延时到点(绝对毫秒时间戳,对齐 [`df_types::types::now_millis`] 口径)。
Delay { due_at_millis: i64 },
/// 文件出现(路径存在即满足;接力棒 result.json 场景)。
FileAppear { path: String },
/// 进程退出(pid 不再存活即满足;自换装场景)。
ProcessExit { pid: u32 },
}
/// registry 条目。
#[derive(Debug, Clone)]
pub struct WakeEntry {
pub id: String,
/// 唤醒注入的目标会话。
pub conv_id: String,
pub condition: WakeCondition,
/// 唤醒上下文人话(注入消息正文;条件判据由消息尾缀追加,不混入 payload)。
pub payload: String,
pub created_at_millis: i64,
pub expires_at_millis: i64,
}
/// 单条目条件判定的观测快照。IO 由 [`observe`] 采集,判定纯函数化便于单测。
/// 非对应型条件的字段无意义(判定只读所属型字段),采集侧可填占位值。
#[derive(Debug, Clone, Copy)]
pub struct WakeObservation {
pub now_millis: i64,
/// file-appear 目标当前是否存在。
pub file_exists: bool,
/// process-exit 目标进程当前是否仍存活。
pub process_alive: bool,
}
/// 本轮对单条目的处置:触发 / 过期 / 继续等待。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WakeDecision {
Fire,
Expired,
Wait,
}
/// 条件判定(纯函数,设计三型核心):条件是否已满足。
pub fn condition_met(cond: &WakeCondition, obs: &WakeObservation) -> bool {
match cond {
WakeCondition::Delay { due_at_millis } => obs.now_millis >= *due_at_millis,
WakeCondition::FileAppear { .. } => obs.file_exists,
WakeCondition::ProcessExit { .. } => !obs.process_alive,
}
}
/// 单条目本轮终判(纯函数):过期优先于条件满足——过期即弃,不注入陈旧唤醒。
pub fn decide(cond: &WakeCondition, expires_at_millis: i64, obs: &WakeObservation) -> WakeDecision {
if obs.now_millis >= expires_at_millis {
return WakeDecision::Expired;
}
if condition_met(cond, obs) {
WakeDecision::Fire
} else {
WakeDecision::Wait
}
}
/// 条件人话(注入消息尾缀,让 LLM 知道因何被唤醒)。
fn condition_desc(cond: &WakeCondition) -> String {
match cond {
WakeCondition::Delay { due_at_millis } => format!("延时到点({due_at_millis})"),
WakeCondition::FileAppear { path } => format!("文件出现:{path}"),
WakeCondition::ProcessExit { pid } => format!("进程退出:pid {pid}"),
}
}
/// 唤醒注入消息(纯函数):payload 为唤醒上下文,尾缀标明触发条件。
pub fn wake_message(entry: &WakeEntry) -> String {
format!(
"[异步唤醒] {}\n\n唤醒条件已满足({}),请基于当前会话状态续跑后续工作。",
entry.payload,
condition_desc(&entry.condition)
)
}
/// 进程级 registry(条目少,Vec 有序 + retain 清理足够,不强求 HashMap)。
static REGISTRY: LazyLock<RwLock<Vec<WakeEntry>>> = LazyLock::new(|| RwLock::new(Vec::new()));
/// 登记一条唤醒条件。
/// - `ttl_millis` None → 默认 24h。
/// - 返回 Err = registry 已满(上限 64),登记方据此放弃或改期。
pub fn register(
conv_id: &str,
condition: WakeCondition,
payload: &str,
ttl_millis: Option<i64>,
) -> Result<String, String> {
let now = df_types::types::now_millis();
let mut reg = REGISTRY.write().expect("唤醒 registry 锁");
reg.retain(|e| e.expires_at_millis > now); // 顺手清过期,腾槽位
if reg.len() >= MAX_ENTRIES {
return Err(format!("唤醒 registry 已满(上限 {MAX_ENTRIES})"));
}
let id = df_types::types::new_id();
reg.push(WakeEntry {
id: id.clone(),
conv_id: conv_id.to_string(),
condition,
payload: payload.to_string(),
created_at_millis: now,
expires_at_millis: now + ttl_millis.unwrap_or(DEFAULT_TTL_MILLIS),
});
Ok(id)
}
/// 当前活跃条目快照(只读;顺手清过期)。
pub fn list() -> Vec<WakeEntry> {
let now = df_types::types::now_millis();
let mut reg = REGISTRY.write().expect("唤醒 registry 锁");
reg.retain(|e| e.expires_at_millis > now);
reg.clone()
}
/// 取消一条(按 id)。返回值 = 是否命中(供调用方区分「已触发/已过期」与「取消了在等条目」)。
pub fn cancel(id: &str) -> bool {
let mut reg = REGISTRY.write().expect("唤醒 registry 锁");
let before = reg.len();
reg.retain(|e| e.id != id);
reg.len() != before
}
fn remove_entry(id: &str) {
let mut reg = REGISTRY.write().expect("唤醒 registry 锁");
reg.retain(|e| e.id != id);
}
/// 采集单条目条件观测(IO 层:时间/文件存在/进程存活)。
fn observe(cond: &WakeCondition, now_millis: i64) -> WakeObservation {
WakeObservation {
now_millis,
file_exists: match cond {
WakeCondition::FileAppear { path } => std::path::Path::new(path).exists(),
_ => false,
},
process_alive: match cond {
WakeCondition::ProcessExit { pid } => process_alive(*pid),
_ => true,
},
}
}
/// 进程存活查询。std 无跨平台接口,按平台最小实现:
/// - Unix: /proc/<pid> 存在。
/// - Windows: tasklist 按 PID 过滤(低频轮询 + registry ≤64,开销可接受)。
/// 查询失败保守判「存活」(不触发)——避免不确定时抛出误唤醒注入。
#[cfg(unix)]
fn process_alive(pid: u32) -> bool {
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(windows)]
fn process_alive(pid: u32) -> bool {
let out = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout).contains(&format!("\"{pid}\"")),
Err(_) => true, // 查询失败:保守判存活,不误触发
}
}
#[cfg(not(any(unix, windows)))]
fn process_alive(_pid: u32) -> bool {
true
}
/// 唤醒注入:复用正典注入路径向目标会话投递 user 消息并续跑。
/// 目标会话生成中 → spawn 被 generating 守卫拒绝,返回 false(条目保留,下轮重试)。
async fn fire(app: &AppHandle, entry: &WakeEntry) -> bool {
let state = app.state::<AppState>();
let message = wake_message(entry);
match spawn_agentic_session(
app,
&state,
message,
None, // language 沿用默认
None, // skill 不挂
None, // model_override 沿用路由
Some(entry.conv_id.clone()),
None,
None,
false, // pin_active=false:唤醒不劫持用户活跃面板
)
.await
{
Ok(_) => {
tracing::info!(conv_id = %entry.conv_id, "[wake] 唤醒条件满足,已注入续跑");
true
}
Err(err) => {
// 生成中/暂不可接受:保留条目下轮重试(对齐「轮界排队,不打断流」)
tracing::warn!(conv_id = %entry.conv_id, "[wake] 注入暂不可用,条目保留下轮重试: {}", err);
false
}
}
}
/// 单轮:扫描 registry,过期的清、条件满足的注入。开关关时调用方已短路。
async fn run_tick(app: &AppHandle) {
let now = df_types::types::now_millis();
// 先快照后处理:注入是 async,不得持 registry 锁跨 await。
let entries = list();
for entry in entries {
let obs = observe(&entry.condition, now);
match decide(&entry.condition, entry.expires_at_millis, &obs) {
WakeDecision::Wait => {}
WakeDecision::Expired => {
remove_entry(&entry.id);
tracing::info!(conv_id = %entry.conv_id, "[wake] 条目过期清理");
}
WakeDecision::Fire => {
// once:注入成功即移除;失败保留(生成态/瞬时错误下轮重试)。
if fire(app, &entry).await {
remove_entry(&entry.id);
}
}
}
}
}
/// 调度器挂载入口(仿 `unattended::start`,lib.rs setup 调用一次)。
/// 开关关时每轮只读配置空转,零副作用。
pub fn start(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(POLL_SECS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
let state = app.state::<AppState>();
let enabled = matches!(
state.settings.get(KV_ENABLED).await.ok().flatten(),
Some(v) if {
let v = v.trim().trim_matches('"').to_lowercase();
v == "true" || v == "1"
}
);
if !enabled {
continue;
}
run_tick(&app).await;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
const CONV: &str = "conv-wake";
/// 单测注册表复用进程级 static,串行化避免相互清理干扰。
static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn obs(now: i64, file: bool, alive: bool) -> WakeObservation {
WakeObservation { now_millis: now, file_exists: file, process_alive: alive }
}
#[test]
fn delay_fires_at_or_after_due() {
let cond = WakeCondition::Delay { due_at_millis: 1000 };
assert_eq!(decide(&cond, 9999, &obs(999, false, true)), WakeDecision::Wait);
assert_eq!(decide(&cond, 9999, &obs(1000, false, true)), WakeDecision::Fire);
assert_eq!(decide(&cond, 9999, &obs(1500, false, true)), WakeDecision::Fire);
}
#[test]
fn file_appear_fires_only_when_exists() {
let cond = WakeCondition::FileAppear { path: "E:/x/result.json".into() };
assert_eq!(decide(&cond, 9999, &obs(0, false, true)), WakeDecision::Wait);
assert_eq!(decide(&cond, 9999, &obs(0, true, true)), WakeDecision::Fire);
}
#[test]
fn process_exit_fires_only_when_not_alive() {
let cond = WakeCondition::ProcessExit { pid: 4242 };
assert_eq!(decide(&cond, 9999, &obs(0, false, true)), WakeDecision::Wait);
assert_eq!(decide(&cond, 9999, &obs(0, false, false)), WakeDecision::Fire);
}
#[test]
fn expired_wins_over_met_condition() {
// 过期优先:即便条件已满足(延时已到),过期即弃不注入陈旧唤醒。
let cond = WakeCondition::Delay { due_at_millis: 100 };
assert_eq!(decide(&cond, 500, &obs(1000, false, true)), WakeDecision::Expired);
}
#[test]
fn wake_message_carries_payload_and_condition() {
let entry = WakeEntry {
id: "w1".into(),
conv_id: CONV.into(),
condition: WakeCondition::FileAppear { path: "E:/x/result.json".into() },
payload: "接力棒已完成,请读取结果并继续".into(),
created_at_millis: 0,
expires_at_millis: 1,
};
let msg = wake_message(&entry);
assert!(msg.contains("接力棒已完成,请读取结果并继续"), "应含 payload: {msg}");
assert!(msg.contains("E:/x/result.json"), "应含触发条件人话: {msg}");
}
#[test]
fn registry_register_cancel_and_cap() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
// 清场:上轮遗留条目
for e in list() {
remove_entry(&e.id);
}
let id = register(CONV, WakeCondition::Delay { due_at_millis: 10 }, "p", None)
.expect("登记应成功");
assert_eq!(list().len(), 1);
assert!(cancel(&id), "取消在等条目应命中");
assert!(!cancel(&id), "重复取消不应再命中");
assert!(list().is_empty(), "取消后 registry 应空");
}
}