新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏
- 待发箱地基: 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:
@@ -112,6 +112,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
||||
boundary_json: row.get("boundary_json").unwrap_or(None),
|
||||
// loop_state 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。
|
||||
loop_state: row.get("loop_state").unwrap_or(None),
|
||||
// status 列老库经 v50 迁移补建(NOT NULL DEFAULT 'active')。兜底迁移未跑/极端缺列时按 active。
|
||||
status: row.get("status").unwrap_or_else(|_| "active".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,6 +135,8 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
|
||||
requested_at: row.get("requested_at")?,
|
||||
executed_at: row.get("executed_at")?,
|
||||
decided_by: row.get("decided_by")?,
|
||||
// duration_ms 列老库经 v50 迁移补建。unwrap_or(None) 兜底(NULL=老数据/未测路径)。
|
||||
duration_ms: row.get("duration_ms").unwrap_or(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,22 +191,22 @@ impl_repo!(
|
||||
from_row => |row| ai_conversation_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
params![
|
||||
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
|
||||
rec.boundary_json, rec.loop_state
|
||||
rec.boundary_json, rec.loop_state, rec.status
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
// loop_state 有意不在 SET 列(V49 生命周期标记独立更新,对齐 boundary 早期模式):
|
||||
// loop_state/status 有意不在 SET 列(V49/V50 生命周期标记独立更新,对齐 boundary 早期模式):
|
||||
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
|
||||
// 读改写窗口内 loop 生命周期可变更(spawn 置 running/收敛清 None)——若 SET 含
|
||||
// loop_state,旧值会覆盖新值(lost-update)。生命周期只经 set_loop_state 单点写。
|
||||
// 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
|
||||
// 生命周期只经 set_loop_state / set_status 单点写。
|
||||
conn.execute(
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14",
|
||||
params![
|
||||
@@ -249,22 +253,22 @@ impl_repo!(
|
||||
from_row => |row| ai_tool_execution_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by, duration_ms)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||
rec.requested_at, rec.executed_at, rec.decided_by
|
||||
rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
conn.execute(
|
||||
"UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11 WHERE id = ?12",
|
||||
"UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11, duration_ms = ?12 WHERE id = ?13",
|
||||
params![
|
||||
rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||
rec.requested_at, rec.executed_at, rec.decided_by, rec.id
|
||||
rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -298,15 +302,15 @@ impl AiToolExecutionRepo {
|
||||
let tx = guard.transaction().map_err(storage_err)?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by, duration_ms)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
for rec in &records {
|
||||
stmt.execute(params![
|
||||
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||
rec.requested_at, rec.executed_at, rec.decided_by
|
||||
rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms
|
||||
])
|
||||
.map_err(storage_err)?;
|
||||
}
|
||||
@@ -839,6 +843,29 @@ impl AiConversationRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 会话终局态写入(V50 status)。
|
||||
///
|
||||
/// status: "active"(默认/进行中/正常完成)、"incomplete"(停靠/断流等未完成终局)、
|
||||
/// "error"(循环错误终局)。纯标记不进 update_full(防 save_conversation 高频全量写覆盖),
|
||||
/// 对齐 set_loop_state 模式;不动 updated_at。对话未落库(懒创建)时影响 0 行,调用方忽略。
|
||||
pub async fn set_status(&self, id: &str, status: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let status = status.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_conversations SET status = ?1 WHERE id = ?2",
|
||||
params![status, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 重启扫描:把所有 loop_state='running' 残留改写为 'parked'。
|
||||
///
|
||||
/// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死
|
||||
@@ -1020,6 +1047,7 @@ mod tests {
|
||||
requested_at: "0".into(),
|
||||
executed_at: None,
|
||||
decided_by: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1068,6 +1096,43 @@ mod tests {
|
||||
assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录");
|
||||
}
|
||||
|
||||
/// D2a 结果外置的读写兼容契约:result 列由外置层(audit 侧)决定写内联还是写句柄,
|
||||
/// 存储层既不解释也不改写——老行(外置落地前写入)是内联全文,读回必须原样;新行的
|
||||
/// 外置句柄 JSON 同样原样存取。锁死「旧行仍读表内全文」:存储层若对 result 做截断/
|
||||
/// 外置/解析等 mutation,会同时破坏两侧兼容(旧行读残缺 + 句柄被误当正文)。
|
||||
#[tokio::test]
|
||||
async fn tool_execution_result_stored_verbatim_old_and_new() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiToolExecutionRepo::new(&db);
|
||||
|
||||
// 旧数据:内联全文。超大值也要能原样读回(D2a 落地前的历史大结果就长这样,存储层不得追改)
|
||||
let legacy_big = "A".repeat(10 * 1024);
|
||||
let mut old_big = audit_rec("old_big", "conv1", "call_old_big", "read_file", "{}", "completed");
|
||||
old_big.result = Some(legacy_big.clone());
|
||||
repo.insert(old_big).await.expect("insert old big");
|
||||
let mut old_small = audit_rec("old_small", "conv2", "call_old_small", "write_file", "{}", "completed");
|
||||
old_small.result = Some("{\"ok\":true}".to_string());
|
||||
repo.insert(old_small).await.expect("insert old small");
|
||||
|
||||
// 新数据:外置句柄 JSON(存储层不解析,当普通 TEXT 存取)
|
||||
let handle = r#"{"__offloaded__":true,"path":"/tmp/x.txt","size_bytes":20480}"#;
|
||||
let mut new_off = audit_rec("new_off", "conv3", "call_new", "run_command", "{}", "completed");
|
||||
new_off.result = Some(handle.to_string());
|
||||
repo.insert(new_off).await.expect("insert new offloaded");
|
||||
|
||||
// 读回:三条各自原样(旧行全文;句柄不被误解析/清空)
|
||||
let got_old_big = repo.get_by_id("old_big").await.expect("get").expect("row");
|
||||
assert_eq!(got_old_big.result.as_deref(), Some(legacy_big.as_str()), "老行超大内联 result 须原样读回");
|
||||
let got_old_small = repo.get_by_id("old_small").await.expect("get").expect("row");
|
||||
assert_eq!(got_old_small.result.as_deref(), Some("{\"ok\":true}"));
|
||||
let got_new = repo.get_by_id("new_off").await.expect("get").expect("row");
|
||||
assert_eq!(got_new.result.as_deref(), Some(handle), "外置句柄 JSON 须原样存取");
|
||||
|
||||
// 专用查询路径(按 tool_call_id 定位)同样原样,与通用 get_by_id 口径一致
|
||||
let by_tc = repo.find_by_tool_call_id("call_old_big").await.expect("query").expect("row");
|
||||
assert_eq!(by_tc.result.as_deref(), Some(legacy_big.as_str()));
|
||||
}
|
||||
|
||||
/// insert_checkpoint 冲突语义:同 id 二次写入 Ok 不报错(幂等跳过,affected=0 走 warn),
|
||||
/// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。
|
||||
#[tokio::test]
|
||||
@@ -1120,6 +1185,7 @@ mod tests {
|
||||
origin: None,
|
||||
boundary_json: None,
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.await
|
||||
.expect("insert");
|
||||
@@ -1133,4 +1199,41 @@ mod tests {
|
||||
let cleared = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||
assert_eq!(cleared.loop_state, None, "清 loop_state 应可读回 None");
|
||||
}
|
||||
|
||||
/// V50 契约:set_status 写终局态后可读回,且 insert 默认 active。
|
||||
/// 锁死「会话终局态」建模能力,防 from_row 映射或 insert 列回归致默认值丢失。
|
||||
#[tokio::test]
|
||||
async fn set_status_roundtrip_and_default_active() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiConversationRepo::new(&db);
|
||||
repo.insert(AiConversationRecord {
|
||||
id: "c1".into(),
|
||||
title: None,
|
||||
messages: "[]".into(),
|
||||
provider_id: None,
|
||||
model: None,
|
||||
models: None,
|
||||
archived: false,
|
||||
pinned: false,
|
||||
prompt_tokens: Some(0),
|
||||
completion_tokens: Some(0),
|
||||
pinned_goals: None,
|
||||
pending_approvals: None,
|
||||
created_at: "0".into(),
|
||||
updated_at: "0".into(),
|
||||
origin: None,
|
||||
boundary_json: None,
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let got = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||
assert_eq!(got.status, "active", "默认终局态应为 active");
|
||||
|
||||
assert!(repo.set_status("c1", "incomplete").await.expect("set"));
|
||||
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||
assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user