新增: 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
@@ -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"));
}
}