优化: 边界加固(AI loop竞态根治+数据/审批/反馈/并发/错误分类)

AI loop 竞态(P0):per-conv epoch/owner token + 存活心跳治 force_send 双loop + stop 3s兜底误判;旧loop stale 全跳过(guard/emit/save)

agentic 收尾(A2-B8):Fatal 退出落库user消息(镜像Exhausted)+ 入口早退补save + usage is_estimated 打标 + emit_ai_completed_once 单点收敛清审批残留

聊天清理(A2-B9):clearChat 先停loop→DB单事务→内存清(clear_conversation_atomic)+ 前端错误气泡

循环并发(A2-B11):三态 ProviderAcquire(NotConfigured/Acquired/Exhausted)+ 候选循环非阻塞+防抖3次饱和降级+单测

错误分类(A2-B12):stream error帧接入 classify_status_or_class + 关键词保守降级 + 7单测

数据(G1.2/G1.4):purge_with_descendants 级联补全(11表单事务+存在性守卫)+ move_task_queue 单事务收口(两调用方共用)

git只读(G3.1):run_git_status/diff/log success判定(exit_code差异语义,失败结构化{success:false,error})

安全(G5.2/G5.6):create_project 目录Err+name校验 + module.rs 路径遍历DRY(分段匹配修a..b.rs误伤)

幂等(V2/V32):裸ALTER全守卫化 + v1..v40全链重跑幂等测试(16过)

附:remote_bridge await 临时引用修(E0716)+ agentic emit 收敛 E0716 app_state 绑定修
This commit is contained in:
lxy
2026-08-05 22:10:32 +08:00
parent ec9f0bf1ea
commit 5667da6cf4
16 changed files with 1696 additions and 215 deletions
+141 -25
View File
@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
let steps: [(i32, fn(&Connection) -> Result<()>); 39] = [
let steps: [(i32, fn(&Connection) -> Result<()>); 40] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -85,6 +85,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(37, migrate_v37),
(38, migrate_v38),
(39, migrate_v39),
(40, migrate_v40),
];
for (version, migrate_fn) in steps {
@@ -105,8 +106,21 @@ fn migrate_v1(conn: &Connection) -> Result<()> {
}
/// V2: 补齐关联字段 + branches 表
///
/// 幂等化(2026-08-05):V2_SQL 原含 7 个裸 ALTER,全链重跑 duplicate column。
/// 拆分:CREATE TABLE branches(IF NOT EXISTS 幂等,execute_batch 跑);
/// 7 个 ALTER 改逐列 column_exists 守卫(对齐 v4+ 幂等模式)。
fn migrate_v2(conn: &Connection) -> Result<()> {
// branches 等 CREATE TABLE IF NOT EXISTS 幂等,execute_batch 跑
conn.execute_batch(V2_SQL)?;
// ideas: 晋升关联 + AI 分析 + 多维评分
for (table, col) in [("ideas", "promoted_to"), ("ideas", "ai_analysis"), ("ideas", "scores"),
("tasks", "workflow_def_id"), ("tasks", "base_branch"),
("workflow_executions", "project_id"), ("workflow_executions", "task_id")] {
if !column_exists(conn, table, col) {
conn.execute(&format!("ALTER TABLE {table} ADD COLUMN {col} TEXT"), [])?;
}
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [2])?;
tracing::info!("迁移 v2 完成");
Ok(())
@@ -917,10 +931,16 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
/// 对话目标由 PerConvState.pinned_goals(Vec<GoalEntry>)管理,原先仅在内存态存在,
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
fn migrate_v32(conn: &Connection) -> Result<()> {
conn.execute_batch(
"ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';"
)?;
tracing::info!("v32: ai_conversations 加 pinned_goals 列");
// G5.5: 幂等守卫(column_exists 探测,同 v4/v20/v33 模式)——列已存在跳过 ALTER。
// 防存量库崩溃重跑/版本号回退后重跑 migrate_v32 报 duplicate column name。
if !column_exists(conn, "ai_conversations", "pinned_goals") {
conn.execute_batch(
"ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';"
)?;
tracing::info!("v32: ai_conversations 加 pinned_goals 列");
} else {
tracing::info!("v32: pinned_goals 列已存在,跳过");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [32])?;
tracing::info!("迁移 v32 完成");
Ok(())
@@ -974,6 +994,9 @@ fn migrate_v33(conn: &Connection) -> Result<()> {
/// 单仓库项目退化:项目下只有一个工程(path = 绑定目录)。
///
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
///
/// 注:description / status 列由 V40 追加(老库 V34 时无),新库由 V40 ALTER
/// 补建(因 V34 首次建表已注册版本号,新库只跑 V34 一次)。两边列定义须一致。
fn migrate_v34(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS project_modules (
@@ -1184,6 +1207,29 @@ fn migrate_v39(conn: &Connection) -> Result<()> {
Ok(())
}
/// V40: project_modules 表加 description / status 列(工程实体化,补全描述身份)
///
/// 解「工程仅有目录地址、无描述身份」:Monorepo 下前端工程/后端服务/基础设施
/// 各有职责与状态,原表只存 name/path/git_url/stack,失去真实场景表达。
/// - description:工程职责描述(如"前端 web 工程""后端 API 服务"),nullable 老工程兼容。
/// - status:工程状态(active/archived),nullable,默认 active(老工程回读时 None 由
/// 应用层归一为 active——见 ProjectModuleRecord 注释)。
///
/// 列可空,向前兼容:V34 前无此列的老库 ALTER 后旧行 NULL,前端/应用层视为未填写。
fn migrate_v40(conn: &Connection) -> Result<()> {
if !column_exists(conn, "project_modules", "description") {
conn.execute("ALTER TABLE project_modules ADD COLUMN description TEXT", [])?;
tracing::info!("v40: project_modules 加 description 列");
}
if !column_exists(conn, "project_modules", "status") {
conn.execute("ALTER TABLE project_modules ADD COLUMN status TEXT", [])?;
tracing::info!("v40: project_modules 加 status 列");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [40])?;
tracing::info!("迁移 v40 完成: project_modules 加 description/status 列(工程实体化)");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -1329,21 +1375,9 @@ CREATE INDEX IF NOT EXISTS idx_node_executions_workflow_id ON node_executions(wo
/// V2 迁移 SQL — 补齐数据层断裂字段
///
/// 注意: SQLite 的 ALTER TABLE ADD COLUMN 一条语句只能加一列
/// 幂等化(2026-08-05):原含 7 个裸 ALTER(ideas/tasks/workflow 加列),全链重跑 duplicate column
/// ALTER 已移至 migrate_v2 逐列 column_exists 守卫,此处仅保留 branches CREATE + 索引(IF NOT EXISTS 幂等)。
const V2_SQL: &str = "
-- 想法表: 晋升关联 + AI 分析 + 多维评分
ALTER TABLE ideas ADD COLUMN promoted_to TEXT;
ALTER TABLE ideas ADD COLUMN ai_analysis TEXT;
ALTER TABLE ideas ADD COLUMN scores TEXT;
-- 任务表: 工作流定义关联 + 基础分支
ALTER TABLE tasks ADD COLUMN workflow_def_id TEXT;
ALTER TABLE tasks ADD COLUMN base_branch TEXT;
-- 工作流执行表: 项目 / 任务关联
ALTER TABLE workflow_executions ADD COLUMN project_id TEXT;
ALTER TABLE workflow_executions ADD COLUMN task_id TEXT;
-- 分支表 — 任务与 Git 分支绑定(核心功能)
CREATE TABLE IF NOT EXISTS branches (
id TEXT PRIMARY KEY,
@@ -1881,7 +1915,7 @@ mod tests {
}
// ============================================================
// 全量迁移测试 — 新库从零跑完整 V1-V37 路径
// 全量迁移测试 — 新库从零跑完整 V1-V40 路径
// ------------------------------------------------------------
// 目的:某 migrate_vN 的 SQL 手滑写错(列名/类型/缺索引/缺表)只能等运行时暴露,
// 此测试一次性覆盖全部迁移路径。任何一条迁移 SQL 写错、列名拼错、缺建表
@@ -1905,12 +1939,12 @@ mod tests {
);
}
/// 全量迁移:新库从零跑完 V1-V37,验证关键表齐全 + 列数 > 0 + 关键列存在。
/// 全量迁移:新库从零跑完 V1-V40,验证关键表齐全 + 列数 > 0 + 关键列存在。
///
/// 覆盖至少:task / ai_conversations / ai_messages / ai_tool_executions /
/// conversation_checkpoints / ai_providers / projects / ideas。
/// 抽查关键列:ai_providers.enabled/weight、conversation_checkpoints.snapshot、
/// tasks.idea_id(这些列由不同 vN 加,任一漏加此处失败)。
/// tasks.idea_id、project_modules.description/status(这些列由不同 vN 加,任一漏加此处失败)。
#[tokio::test]
async fn test_full_migration_on_fresh_db() {
// 用 Database::open_in_memory 打开新库,内部自动跑 migrations::run() 全量迁移
@@ -1956,7 +1990,7 @@ mod tests {
"tasks.idea_id 列缺失(V1 建表已带)"
);
// 3. schema_version 应推进到 37(全量迁移成功落版本号)
// 3. schema_version 应推进到 40(全量迁移成功落版本号)
let max_version: i64 = conn
.query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
@@ -1965,9 +1999,91 @@ mod tests {
)
.expect("查 schema_version 应成功");
assert_eq!(
max_version, 37,
"全量迁移后 schema_version 应为 37(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version, 40,
"全量迁移后 schema_version 应为 40(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version
);
// 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑)
assert!(
column_exists(&conn, "project_modules", "description"),
"project_modules.description 列缺失(V40 加)"
);
assert!(
column_exists(&conn, "project_modules", "status"),
"project_modules.status 列缺失(V40 加)"
);
}
// ============================================================
// G5.5: v32 幂等守卫 + 全链幂等不变量
// ------------------------------------------------------------
// 背景:migrate_v32 原为裸 ALTER(唯一漏网),崩溃重跑/版本号回退后重跑会报
// duplicate column name。修后应幂等;再加全链重跑不变量防止未来新 vN 引入裸 ALTER。
// ============================================================
/// G5.5: v32 幂等守卫——pinned_goals 列已存在时重跑不报 duplicate column。
/// 构造最小 ai_conversations 表(无 pinned_goals 列)模拟老库,验证补列 + 重跑短路。
#[test]
fn v32_idempotent_column_guard() {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch(
"CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
CREATE TABLE ai_conversations (id TEXT PRIMARY KEY);",
)
.expect("create tables");
assert!(
!column_exists(&conn, "ai_conversations", "pinned_goals"),
"迁移前应无 pinned_goals 列"
);
migrate_v32(&conn).expect("首次迁移应补 pinned_goals 列");
assert!(column_exists(&conn, "ai_conversations", "pinned_goals"));
// 回退版本号模拟「列已存在但版本号缺失」崩溃重跑场景 → ALTER 应被守卫短路,
// 仅补版本号,不报 duplicate column name。
conn.execute("DELETE FROM schema_version WHERE version = 32", [])
.unwrap();
migrate_v32(&conn).expect("列已存在时重跑应幂等不报错");
let v_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM schema_version WHERE version = 32",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(v_count, 1, "版本号 32 应只写一次");
}
/// G5.5: 全链幂等不变量——V1-V40 每步执行两遍不抛错。
///
/// 首轮 run() 建全 schema;清空 schema_version 强制下一轮从 V1 重跑每步
/// (模拟存量库 + 崩溃重跑/版本号回退)。任何 migrate_vN 的裸 ALTER(无 column_exists
/// 守卫,如 v32 修前形态)都会在第二遍报 duplicate column 被此测试捕获。
#[test]
fn v1_to_v40_full_chain_rerun_idempotent() {
let conn = Connection::open_in_memory().expect("open in-memory db");
run(&conn).expect("首轮全量迁移应成功");
let max_v: i64 = conn
.query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(max_v, 40, "首轮应推进到 40");
// 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap();
run(&conn).expect("全链第二遍不抛错(幂等不变量)");
let max_v2: i64 = conn
.query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(max_v2, 40, "重跑后应重新推进到 40");
}
}