新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏
- 待发箱地基: 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:
@@ -1,7 +1,7 @@
|
||||
//! 数据库迁移 — 建表 SQL 与版本管理
|
||||
//! 数据库迁移 — 建表 SQL 与版本管理
|
||||
|
||||
use crate::migrations_helpers::column_exists;
|
||||
use crate::migrations_helpers::{column_exists, table_exists};
|
||||
use anyhow::Result;
|
||||
use rusqlite::Connection;
|
||||
|
||||
@@ -59,7 +59,12 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。
|
||||
// V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座:
|
||||
// running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 49] = [
|
||||
// V50 = 使用数据分析 schema 债补全(三项,详见 migrate_v50 文档):
|
||||
// ai_conversations.status 会话终局态 / ai_tool_executions.duration_ms 纯执行耗时 /
|
||||
// conversation_checkpoints.token_total 语义回填。
|
||||
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体,
|
||||
// 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 51] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -113,6 +118,10 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(48, migrate_v48),
|
||||
// V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。
|
||||
(49, migrate_v49),
|
||||
// V50 = 使用数据分析 schema 债补全(status/duration_ms/token_total 三项)。
|
||||
(50, migrate_v50),
|
||||
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。
|
||||
(51, migrate_v51),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -1450,6 +1459,172 @@ fn migrate_v49(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V50: 使用数据分析 schema 债补全(2026-10-04 实证三笔,归「V49 schema 迁移批」)。
|
||||
///
|
||||
/// 一次迁移承载三项相关改动,均为纯粹加列/回填,不改既有列语义:
|
||||
///
|
||||
/// ① ai_conversations.status TEXT NOT NULL DEFAULT 'active' —— 会话终局态。
|
||||
/// 原仅靠侧栏内消息尾部推断「是否收尾」(17/89 会话停在 tool 结果无 assistant 收尾),
|
||||
/// 无法直接统计未完成率。新增列建模终局:
|
||||
/// active=进行中/默认(含正常完成,完成态精确写入待收尾单点接驳),incomplete=停靠/
|
||||
/// 断流等未完成终局,error=循环错误终局。与 loop_state(运行期 running/parked)正交:
|
||||
/// loop_state 管「能否续跑」,status 管「会话如何结束」。
|
||||
/// ② ai_tool_executions.duration_ms INTEGER —— 纯执行耗时(毫秒)。
|
||||
/// 原审计只有 requested_at→executed_at 区间,该区间在审批路径混入「等待人工批准」
|
||||
/// 时长,耗时统计不可信。本列专记工具真正执行的墙钟毫秒(执行前→执行后),与审批
|
||||
/// 等待解耦;NULL=老数据/未测路径(向后兼容)。
|
||||
/// ③ conversation_checkpoints.token_total 回填 —— 原写入侧误用「消息条数」当 token 总量
|
||||
/// (p50=120 vs 真实数万级,差 3 个量级)。写入侧已修为真实 token 累加(见 save 侧);
|
||||
/// 本迁移对历史行按 snapshot 内逐条 prompt_tokens+completion_tokens 重算回填,使存量
|
||||
/// 可比。仅当重算结果 > 0 才写(N 老快照无消息级 token 时保留原值不误清零)。
|
||||
///
|
||||
/// - **幂等**:①②`column_exists` 守卫,重复执行不报 duplicate column;③重算确定(同输入同输出),
|
||||
/// 反复执行结果一致,且经 schema_version 版本号闸只跑一次。
|
||||
/// - **可逆**:①`ALTER TABLE ai_conversations DROP COLUMN status`;
|
||||
/// ②`ALTER TABLE ai_tool_executions DROP COLUMN duration_ms`;
|
||||
/// ③无回退(DROP COLUMN 不会还原被覆盖的 token_total 旧值)——如需回退重建快照表
|
||||
/// (老值本就失真无保留价值)。
|
||||
fn migrate_v50(conn: &Connection) -> Result<()> {
|
||||
// ① 会话终局态列(NOT NULL 常量默认,SQLite ALTER 合法)。
|
||||
if table_exists(conn, "ai_conversations") && !column_exists(conn, "ai_conversations", "status") {
|
||||
conn.execute(
|
||||
"ALTER TABLE ai_conversations ADD COLUMN status TEXT NOT NULL DEFAULT 'active'",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v50: 补建 ai_conversations.status 列(会话终局态)");
|
||||
}
|
||||
// ② 纯执行耗时列(NULL=老数据/未测路径)。
|
||||
if table_exists(conn, "ai_tool_executions")
|
||||
&& !column_exists(conn, "ai_tool_executions", "duration_ms")
|
||||
{
|
||||
conn.execute(
|
||||
"ALTER TABLE ai_tool_executions ADD COLUMN duration_ms INTEGER",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v50: 补建 ai_tool_executions.duration_ms 列(纯执行耗时)");
|
||||
}
|
||||
// ③ 存量 checkpoint token_total 按 snapshot 重算回填。
|
||||
backfill_checkpoint_token_total(conn)?;
|
||||
|
||||
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [50])?;
|
||||
tracing::info!("迁移 v50 完成: status/duration_ms 加列 + checkpoint token_total 回填");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V51: 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。
|
||||
///
|
||||
/// 对标 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.1。
|
||||
/// 队列(931)与定时消息(935)本质是同一实体、不同触发条件,统一落本表:
|
||||
/// 队列项 due_at=NULL(会话空闲即发),定时项 due_at=epoch_ms(到点且空闲即发)。
|
||||
///
|
||||
/// 选独立表而非 app_settings KV:outbox 需按 status+due_at 索引扫描、按 id 逐条 CAS 更新,
|
||||
/// KV 整块 JSON 读改写有并发改期/取消互相覆盖的风险且不可索引。
|
||||
///
|
||||
/// - **幂等**:table_exists 守卫建表(同 V29/V30/V31 模式),索引 CREATE INDEX IF NOT EXISTS
|
||||
/// 原生幂等;版本号 INSERT OR IGNORE 防崩溃重跑 PK 冲突。全链重跑/断点续跑均安全。
|
||||
/// - **可逆**(回退方式,当前不执行):本表纯新增、不触碰旧数据,回退即
|
||||
/// `DROP TABLE ai_outbox`(同时关调度器开关 df-outbox-enabled=false),无需数据搬迁。
|
||||
/// - **状态落点**:pending|sending|sent|failed|canceled 由调度器按项逐条 CAS 写,不依赖全局
|
||||
/// 内存推算(V21 教训:全局 COUNT 幂等窗口中途崩溃致永久残缺)。崩溃残留的 sending 由
|
||||
/// 调度器启动时 reset_stale_sending 复位为 pending。
|
||||
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
|
||||
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
|
||||
pub const LATEST_SCHEMA_VERSION: i32 = 51;
|
||||
|
||||
fn migrate_v51(conn: &Connection) -> Result<()> {
|
||||
if !table_exists(conn, "ai_outbox") {
|
||||
conn.execute(
|
||||
"CREATE TABLE ai_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'queue',
|
||||
text TEXT NOT NULL,
|
||||
skill TEXT,
|
||||
model_override TEXT,
|
||||
parts_json TEXT,
|
||||
spans_json TEXT,
|
||||
due_at INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
sent_at INTEGER
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v51: 建 ai_outbox 表(待发箱)");
|
||||
}
|
||||
// 索引覆盖两条最高频扫描:调度器 list_due(status+due_at)/ 前端按会话取视图(conversation_id+status)。
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_outbox_due ON ai_outbox(status, due_at)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_outbox_conv ON ai_outbox(conversation_id, status)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [51])?;
|
||||
tracing::info!("迁移 v51 完成: ai_outbox 表 + 两索引(定时消息与待发箱持久化)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的
|
||||
/// prompt_tokens+completion_tokens 求真实总量。
|
||||
///
|
||||
/// 幂等:重算确定,重复执行相同结果;仅当重算值 > 0 且与现值不同才 UPDATE
|
||||
/// (老快照无消息级 token → 重算 0 → 保留原值,不误清零)。表/列不存在时静默跳过
|
||||
/// (极老库未跑到 V37,或手工删表)。
|
||||
fn backfill_checkpoint_token_total(conn: &Connection) -> Result<()> {
|
||||
// 表存在性探测:未跑到 V37 的极老库无此表,PRAGMA 返回空即跳过。
|
||||
if !column_exists(conn, "conversation_checkpoints", "token_total") {
|
||||
return Ok(());
|
||||
}
|
||||
// 收集待回填行到内存再逐条 UPDATE:避免同一连接上「先 prepare 读游标、再同连接 execute 写」
|
||||
// 造成的隐式锁/游标失效,分批小表可接受。
|
||||
let rows: Vec<(String, String, i64)> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, snapshot, token_total FROM conversation_checkpoints",
|
||||
)?;
|
||||
let mapped = stmt.query_map([], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?))
|
||||
})?;
|
||||
mapped.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
let mut updated = 0usize;
|
||||
for (id, snapshot, old_total) in rows {
|
||||
let recomputed = sum_snapshot_tokens(&snapshot);
|
||||
if recomputed > 0 && recomputed != old_total {
|
||||
conn.execute(
|
||||
"UPDATE conversation_checkpoints SET token_total = ?1 WHERE id = ?2",
|
||||
rusqlite::params![recomputed, id],
|
||||
)?;
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
if updated > 0 {
|
||||
tracing::info!("v50: checkpoint token_total 回填 {updated} 行(消息条数→真实 token)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从消息 JSON 数组求 token 总量:逐条取 prompt_tokens + completion_tokens(UInt64)
|
||||
/// 求和。字段缺失/类型不符按 0 计(老消息无消息级 token → 贡献 0)。解析失败返回 0。
|
||||
///
|
||||
/// 用 `serde_json::Value` 裸取(df-storage 不依赖 df-ai-core 的 ChatMessage,同 V21 迁移模式)。
|
||||
fn sum_snapshot_tokens(snapshot: &str) -> i64 {
|
||||
let Ok(msgs) = serde_json::from_str::<Vec<serde_json::Value>>(snapshot) else {
|
||||
return 0;
|
||||
};
|
||||
msgs.iter()
|
||||
.map(|m| {
|
||||
let p = m.get("prompt_tokens").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let c = m.get("completion_tokens").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
p + c
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
@@ -2478,9 +2653,9 @@ mod tests {
|
||||
)
|
||||
.expect("查 schema_version 应成功");
|
||||
assert_eq!(
|
||||
max_version, 49,
|
||||
"全量迁移后 schema_version 应为 49(实际 {}),说明某条 migrate_vN 链路断在中间",
|
||||
max_version
|
||||
max_version, LATEST_SCHEMA_VERSION as i64,
|
||||
"全量迁移后 schema_version 应为顶点 {}(实际 {}),说明某条 migrate_vN 链路断在中间",
|
||||
LATEST_SCHEMA_VERSION, max_version
|
||||
);
|
||||
|
||||
// 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑)
|
||||
@@ -2556,7 +2731,7 @@ mod tests {
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(max_v, 49, "首轮应推进到 49");
|
||||
assert_eq!(max_v, LATEST_SCHEMA_VERSION as i64, "首轮应推进到 {}", LATEST_SCHEMA_VERSION);
|
||||
|
||||
// 清空版本表强制全链第二遍(每步 execute 第二次)
|
||||
conn.execute("DELETE FROM schema_version", []).unwrap();
|
||||
@@ -2568,7 +2743,7 @@ mod tests {
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(max_v2, 49, "重跑后应重新推进到 49");
|
||||
assert_eq!(max_v2, LATEST_SCHEMA_VERSION as i64, "重跑后应重新推进到 {}", LATEST_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -2674,4 +2849,91 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(got.is_some(), "boundary_json 应可写可读");
|
||||
}
|
||||
|
||||
/// V50: 三列/回填一次承载,幂等重跑不炸。
|
||||
/// ① ai_conversations.status 补建默认 'active';
|
||||
/// ② ai_tool_executions.duration_ms 补建(可空);
|
||||
/// ③ conversation_checkpoints.token_total 按 snapshot 重算(消息条数→真实 token)。
|
||||
#[test]
|
||||
fn v50_adds_columns_and_backfills_token_total() {
|
||||
let conn = setup_legacy_db();
|
||||
// 前置:最小库无 status/duration_ms 列(V45 之后建,老库缺)
|
||||
assert!(!column_exists(&conn, "ai_conversations", "status"));
|
||||
// V50 需要 checkpoint / tool_executions 表(正常由 V1/V9 建;此处手工建以隔离本迁移被测量)
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS conversation_checkpoints (\
|
||||
id TEXT PRIMARY KEY, conv_id TEXT NOT NULL, snapshot TEXT NOT NULL,\
|
||||
token_total INTEGER NOT NULL, label TEXT, created_at TEXT NOT NULL);
|
||||
INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\
|
||||
VALUES ('ck1','c1',\
|
||||
'[{\"role\":\"assistant\",\"prompt_tokens\":150000,\"completion_tokens\":20000}]',\
|
||||
1,'100');
|
||||
CREATE TABLE IF NOT EXISTS ai_tool_executions (\
|
||||
id TEXT PRIMARY KEY, tool_call_id TEXT NOT NULL, tool_name TEXT NOT NULL,\
|
||||
arguments TEXT NOT NULL, requested_at TEXT NOT NULL);",
|
||||
)
|
||||
.expect("建 checkpoint/tool_executions 表 + 塞一条失真行");
|
||||
|
||||
migrate_v50(&conn).expect("v50 首次迁移应成功");
|
||||
assert!(column_exists(&conn, "ai_conversations", "status"), "应有 status 列");
|
||||
assert!(column_exists(&conn, "ai_tool_executions", "duration_ms"), "应有 duration_ms 列");
|
||||
// ① 默认值 active 生效
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c1','[]','0','0')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let status: String = conn
|
||||
.query_row("SELECT status FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(status, "active", "新列默认值应为 active");
|
||||
// ③ 失真 token_total(1)被回填为真实和(150000+20000)
|
||||
let tt: i64 = conn
|
||||
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(tt, 170_000, "应回填为 snapshot 内 token 真实和");
|
||||
|
||||
let v: i64 = conn
|
||||
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, 50, "应写入版本号 50");
|
||||
|
||||
// 幂等:列已存在时重跑走 column_exists 短路;回填重算确定(值不变)
|
||||
migrate_v50(&conn).expect("列已存在时重跑应幂等不报错");
|
||||
let tt2: i64 = conn
|
||||
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(tt2, 170_000, "重跑回填结果应一致");
|
||||
}
|
||||
|
||||
/// V50 回填不误清零:老快照无消息级 token(重算=0)时保留原值(不 UPDATE)。
|
||||
#[test]
|
||||
fn v50_backfill_keeps_legacy_when_no_message_tokens() {
|
||||
let conn = setup_legacy_db();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS conversation_checkpoints (\
|
||||
id TEXT PRIMARY KEY, conv_id TEXT NOT NULL, snapshot TEXT NOT NULL,\
|
||||
token_total INTEGER NOT NULL, label TEXT, created_at TEXT NOT NULL);
|
||||
INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\
|
||||
VALUES ('ck2','c2','[{\"role\":\"user\",\"content\":\"hi\"}]', 42, '100');",
|
||||
)
|
||||
.expect("建表塞老快照");
|
||||
migrate_v50(&conn).expect("v50 应成功");
|
||||
let tt: i64 = conn
|
||||
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck2'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(tt, 42, "无消息级 token 的老快照应保留原值不误清零");
|
||||
}
|
||||
|
||||
/// sum_snapshot_tokens 纯函数:字段缺失/非法 JSON 按 0 计。
|
||||
#[test]
|
||||
fn sum_snapshot_tokens_handles_missing_and_bad_json() {
|
||||
assert_eq!(sum_snapshot_tokens("not json"), 0, "非法 JSON 应返回 0");
|
||||
assert_eq!(sum_snapshot_tokens("[]"), 0, "空数组应为 0");
|
||||
assert_eq!(
|
||||
sum_snapshot_tokens(r#"[{"prompt_tokens":10},{"completion_tokens":5},{"foo":1}]"#),
|
||||
15,
|
||||
"逐条相加,缺字段按 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user