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

- 待发箱地基: 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
+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())
);
}
}