新增: 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:
@@ -0,0 +1,162 @@
|
||||
//! D2a 工具结果过期外置 + D2d 90 天归档:治 devflow.db 膨胀(193.7MB,审计大结果为主要头)。
|
||||
//!
|
||||
//! 设计(对齐 u-claw 成熟实践「大内容外置+摘要留指针」):
|
||||
//! - 超阈值(8KB)的审计 result 正文落盘至 {app_data}/tool-results/{yyyy-MM}/{id}.txt,
|
||||
//! DB 只留句柄 JSON(offloaded 标记 + 路径 + 头部摘要 + 大小 + 工具名/时间)。
|
||||
//! - 句柄可读:句柄含 path,用户/agent 需要细节时按路径找回(工具结果在 messages 侧
|
||||
//! 已有 namespace 外置,本模块治审计表侧——两处同构,互不影响)。
|
||||
//! - D2d 归档:cleanup_expired(90 天)删除过期外置文件(句柄留 DB,文件没了读时
|
||||
//! 降级提示「已过期清理」);调用点挂 unattended 引擎每日一轮。
|
||||
//! - 历史数据默认保留禁清理(全局规则)——cleanup 只删「本模块自己写的外置文件」
|
||||
//! (目录专有,含 marker 头校验),不碰任何 DB 行,原始库零风险。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 外置根目录(进程级一次注入;lib.rs setup 调 init_base)。未注入时 offload_result 直通。
|
||||
static BASE: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// 注入外置根目录(app data dir)。幂等,首次生效。
|
||||
pub fn init_base(base: PathBuf) {
|
||||
let _ = BASE.set(base);
|
||||
}
|
||||
|
||||
/// 无参包装:build_audit_record 等审计写路径统一走此入口。
|
||||
/// base 未注入或失败降级时原样返回(不外置不丢数据)。
|
||||
pub fn offload_result(id: &str, tool_name: &str, result: &str) -> String {
|
||||
match BASE.get() {
|
||||
Some(b) => offload_if_large(b, id, tool_name, result),
|
||||
None => result.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 外置阈值:审计 result 超 8KB 落盘(DB 只留句柄)。namespace 侧阈值 2048B 更激进
|
||||
/// (上下文敏感),审计侧存证要求低,8KB 平衡 IO 频率与瘦身收益。
|
||||
pub const OFFLOAD_THRESHOLD_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// D2d 归档阈值(定案 2026-10-06):外置文件 90 天后可清理。
|
||||
pub const ARCHIVE_AFTER_DAYS: u64 = 90;
|
||||
|
||||
/// 句柄 JSON 字段名(读取降级判据)。
|
||||
const HANDLE_MARK: &str = "__offloaded__";
|
||||
|
||||
/// 计算外置目录({base}/tool-results/{yyyy-MM}/)。base 由调用方传入(app data dir),
|
||||
/// 本模块不做 Tauri 依赖(纯函数可测)。
|
||||
fn month_dir(base: &PathBuf, id: &str) -> PathBuf {
|
||||
// id 前缀含时间成分则用之,否则统一放 unsorted/;目录按月仅为人读友好
|
||||
let month = id.get(0..7).filter(|s| s.len() == 7 && s.as_bytes()[4] == b'-').unwrap_or("unsorted");
|
||||
base.join("tool-results").join(month)
|
||||
}
|
||||
|
||||
/// 落盘并生成句柄。失败(磁盘/权限)时返回原内容(降级:不外置也不丢数据)。
|
||||
pub fn offload_if_large(base: &PathBuf, id: &str, tool_name: &str, result: &str) -> String {
|
||||
if result.len() <= OFFLOAD_THRESHOLD_BYTES {
|
||||
return result.to_string();
|
||||
}
|
||||
let dir = month_dir(base, id);
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return result.to_string();
|
||||
}
|
||||
let file = dir.join(format!("{id}.txt"));
|
||||
if std::fs::write(&file, result).is_err() {
|
||||
return result.to_string();
|
||||
}
|
||||
let head: String = result.chars().take(200).collect();
|
||||
// meta 旁路文件:cleanup_expired 的认领判据(防误删同目录非本模块产物)
|
||||
let meta = serde_json::json!({ HANDLE_MARK: true, "id": id, "tool": tool_name });
|
||||
let _ = std::fs::write(file.with_extension("meta.json"), meta.to_string());
|
||||
let handle = serde_json::json!({
|
||||
HANDLE_MARK: true,
|
||||
"path": file.to_string_lossy(),
|
||||
"size_bytes": result.len(),
|
||||
"tool": tool_name,
|
||||
"head": head,
|
||||
"hint": "结果过大已外置落盘,需要细节时按 path 读取原文"
|
||||
});
|
||||
handle.to_string()
|
||||
}
|
||||
|
||||
/// 读外置文件(前端/查询侧展开句柄用)。过期清理后降级提示。
|
||||
pub fn read_offloaded(path: &str) -> Result<String, String> {
|
||||
std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败(可能已过 90 天归档清理): {e}"))
|
||||
}
|
||||
|
||||
/// D2d:清理超期外置文件(仅本模块目录内带 marker 头的文件;失败静默记日志)。
|
||||
/// 返回 (删除数, 释放字节)。全局规则「历史数据默认保留」的对齐说明:本函数只删
|
||||
/// offload 自产的派生文件(原文曾入 DB 后被替换为句柄),非用户原始数据。
|
||||
pub fn cleanup_expired(base: &PathBuf) -> (usize, u64) {
|
||||
let root = base.join("tool-results");
|
||||
let Ok(entries) = std::fs::read_dir(&root) else { return (0, 0) };
|
||||
let cutoff = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
.saturating_sub(ARCHIVE_AFTER_DAYS * 86400);
|
||||
let mut removed = 0usize;
|
||||
let mut freed = 0u64;
|
||||
for month in entries.flatten() {
|
||||
let Ok(files) = std::fs::read_dir(month.path()) else { continue };
|
||||
for f in files.flatten() {
|
||||
let p = f.path();
|
||||
if p.extension().and_then(|e| e.to_str()) != Some("txt") {
|
||||
continue;
|
||||
}
|
||||
// marker 头校验:句柄 JSON 写入文件首行注释不行(txt 是原文)——改判据:
|
||||
// 同名 .meta.json 存在且 offloaded=true 才认领。写入侧同步产出 meta。
|
||||
let meta = p.with_extension("meta.json");
|
||||
let Ok(meta_str) = std::fs::read_to_string(&meta) else { continue };
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&meta_str) else { continue };
|
||||
if !v.get(HANDLE_MARK).and_then(|b| b.as_bool()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let Ok(md) = f.metadata() else { continue };
|
||||
let Ok(modified) = md.modified() else { continue };
|
||||
let mtime = modified
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
if mtime < cutoff {
|
||||
let size = md.len();
|
||||
if std::fs::remove_file(&p).is_ok() {
|
||||
let _ = std::fs::remove_file(&meta);
|
||||
removed += 1;
|
||||
freed += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(removed, freed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn small_result_not_offloaded() {
|
||||
let base = std::env::temp_dir().join(format!("d2a-test-{}", std::process::id()));
|
||||
let out = offload_if_large(&base, "2026-10-07-x", "read_file", "short");
|
||||
assert_eq!(out, "short");
|
||||
let _ = std::fs::remove_dir_all(base.join("tool-results"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_result_offloaded_with_handle() {
|
||||
let base = std::env::temp_dir().join(format!("d2a-test-{}", std::process::id()));
|
||||
let big = "x".repeat(10 * 1024);
|
||||
let out = offload_if_large(&base, "2026-10-07-y", "run_command", &big);
|
||||
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
|
||||
assert_eq!(v.get(HANDLE_MARK).and_then(|b| b.as_bool()), Some(true));
|
||||
let path = v.get("path").and_then(|p| p.as_str()).unwrap();
|
||||
// 原文可找回
|
||||
assert_eq!(read_offloaded(path).unwrap(), big);
|
||||
let _ = std::fs::remove_dir_all(base.join("tool-results"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_dir_parses_id_prefix() {
|
||||
let base = PathBuf::from("/tmp");
|
||||
assert!(month_dir(&base, "2026-10-07-abcd").ends_with("tool-results/2026-10"));
|
||||
assert!(month_dir(&base, "weird").ends_with("tool-results/unsorted"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user