新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏

- 待发箱地基: 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:
lxy
2026-10-10 08:09:17 +08:00
parent 9c68f98d89
commit b37279f3a2
32 changed files with 4175 additions and 152 deletions
+4
View File
@@ -107,3 +107,7 @@ temp/
# df-im-gateway 运行时数据(冒烟测试残留) # df-im-gateway 运行时数据(冒烟测试残留)
crates/df-im-gateway/data/ crates/df-im-gateway/data/
# Playwright 运行产物
test-results/
playwright-report/
+1 -1
View File
@@ -17,4 +17,4 @@ pub mod server;
pub mod server_http; pub mod server_http;
pub mod tools; pub mod tools;
pub use server::{run_server, ServerConfig}; pub use server::{run_server, ServerConfig, MCP_DATA_CHANGED_KEY};
+103 -10
View File
@@ -14,6 +14,7 @@ use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use df_storage::crud::SettingsRepo;
use df_storage::db::Database; use df_storage::db::Database;
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
@@ -29,6 +30,15 @@ pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
pub(crate) const SERVER_NAME: &str = "devflow-mcp"; pub(crate) const SERVER_NAME: &str = "devflow-mcp";
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
/// app_settings KV key:stdio MCP 写库后落的变更标记,供 GUI 侧轮询兜底消费。
///
/// 独立进程形态的 stdio MCP 无 AppHandle(`on_write_call=None`),写库后 GUI 无从经
/// `df-data-changed` 事件感知,造成双端数据断层(P0-2)。此标记是跨进程的落库信令:
/// 每次成功写工具覆盖写入,值为 `{"at": <毫秒时间戳>, "tool": "<工具名>"}`。
/// GUI 侧轮询读此 key,`at` 变化即知库被外部改动;再按 `tool` 复用 df-data-changed
/// 的 entity 映射刷新对应列表(entity 映射留在 GUI 侧,避免本 crate 引入 df-ai 依赖)。
pub const MCP_DATA_CHANGED_KEY: &str = "mcp.data_changed";
/// MCP server 运行配置。 /// MCP server 运行配置。
/// ///
/// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。 /// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。
@@ -41,7 +51,8 @@ pub struct ServerConfig {
pub idle_timeout: Option<Duration>, pub idle_timeout: Option<Duration>,
/// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。 /// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。
/// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI; /// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI;
/// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。 /// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。传 None 时由
/// 落库变更标记([`MCP_DATA_CHANGED_KEY`])兜底,供 GUI 侧轮询消费。
pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>, pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
} }
@@ -154,8 +165,13 @@ where
if let Some(name) = tool_name { if let Some(name) = tool_name {
// MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、 // MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。 // create_project 空名等 result.isError=true)仍假触发 df-data-changed。
if is_success_tool_call(&resp) { if is_success_tool_call(&resp) && is_executed_write(&name, config.read_only) {
fire_write_hook(config, &name); // 多进程兜底:stdio 形态无 AppHandle,先落库内变更标记,供 GUI 侧轮询消费
// (即便未注入回调也不丢变更感知);再触发进程内回调(HTTP 内嵌形态走事件)。
record_data_change(ctx, &name).await;
if let Some(cb) = &config.on_write_call {
cb(&name);
}
} }
} }
} }
@@ -293,13 +309,30 @@ fn required_names(schema: &Value) -> Vec<String> {
} }
} }
/// 触发写操作回调(若有)。仅当工具为写操作(risk != Low)且未被 read-only/High 拒绝时触发, /// 判定工具是否属于「会执行的成功写操作」:risk != Low 且未被 read-only/High 拒绝。
/// 与 dispatch 的执行判定一致。回调仅作通知(如 GUI 刷新),不承载返回结果。 ///
fn fire_write_hook(config: &ServerConfig, name: &str) { /// 变更标记与进程内写回调共用此判定,保证两通道对「哪些写操作需通知 GUI」语义一致;
let Some(cb) = &config.on_write_call else { return }; /// 与 dispatch 的执行判定(`should_execute`)同源,避免逻辑漂移。
let Some(spec) = tools::find(name) else { return }; fn is_executed_write(name: &str, read_only: bool) -> bool {
if spec.risk != RiskLevel::Low && should_execute(config.read_only, spec.risk) { match tools::find(name) {
cb(name); Some(spec) => spec.risk != RiskLevel::Low && should_execute(read_only, spec.risk),
None => false,
}
}
/// 写成功后向 [`MCP_DATA_CHANGED_KEY`] 覆盖写入一条变更标记(多进程兜底信令)。
///
/// 独立进程形态完成写库后,GUI 无法经事件感知;此标记是落库的持久信令,GUI 轮询读
/// 该 key、`at` 变化即知库被外部改动。写失败仅告警不阻断(工具调用已成功,下次成功
/// 写会再次覆盖,标记只作「有变更」的粗粒度提示,非精确事件队列)。
async fn record_data_change(ctx: &Ctx, tool_name: &str) {
let at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let value = json!({ "at": at, "tool": tool_name }).to_string();
if let Err(e) = SettingsRepo::new(&ctx.db).set(MCP_DATA_CHANGED_KEY, &value).await {
tracing::warn!(target: "df_mcp", tool = tool_name, err = %e, "写变更标记失败(不改工具调用结果)");
} }
} }
@@ -675,4 +708,64 @@ mod tests {
*calls.lock().unwrap() *calls.lock().unwrap()
); );
} }
// ── 多进程兜底:stdio 写库后落变更标记 ────────────────────────────
#[tokio::test]
async fn main_loop_records_data_change_marker_for_write_tool() {
// stdio 形态(on_write_call=None)成功写工具后应向 KV 落变更标记,
// 供 GUI 轮询消费——即便无任何回调注入也不丢变更感知(P0-2 兜底)。
let ctx = test_ctx().await;
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"MarkProj","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &test_config(false).await)
.await
.unwrap();
let v = SettingsRepo::new(&ctx.db)
.get(MCP_DATA_CHANGED_KEY)
.await
.unwrap()
.expect("写工具成功后应落变更标记");
let parsed: Value = serde_json::from_str(&v).unwrap();
assert_eq!(parsed["tool"], "create_project");
assert!(parsed["at"].as_u64().unwrap() > 0, "标记应含毫秒时间戳: {v}");
}
#[tokio::test]
async fn main_loop_no_data_change_marker_for_read_tool() {
// 只读工具无副作用,不应落变更标记(否则 GUI 无谓刷新)。
let ctx = test_ctx().await;
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &test_config(false).await)
.await
.unwrap();
let v = SettingsRepo::new(&ctx.db)
.get(MCP_DATA_CHANGED_KEY)
.await
.unwrap();
assert!(v.is_none(), "只读工具不应落变更标记,实际: {v:?}");
}
#[tokio::test]
async fn main_loop_no_data_change_marker_when_read_only_denies_write() {
// read-only 下写被拒(未真正写库),不应落变更标记。
let ctx = test_ctx().await;
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"X","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &test_config(true).await)
.await
.unwrap();
let v = SettingsRepo::new(&ctx.db)
.get(MCP_DATA_CHANGED_KEY)
.await
.unwrap();
assert!(v.is_none(), "read-only 下写被拒不应落变更标记,实际: {v:?}");
}
} }
+40
View File
@@ -75,6 +75,17 @@ fn validate_device_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control()) !id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control())
} }
/// 当前 Hello 握手协议版本。
///
/// 协商规则:旧客户端 Hello 缺 `proto` 字段 → serde default 视作 v1(向后兼容);
/// 高于本端支持版本时记录告警,为后续破坏性变更预留通知位(当前仅日志,不拒绝握手)。
pub const HELLO_PROTO_VERSION: u32 = 1;
/// `proto` 缺省值:旧客户端不带该字段时按 v1 处理(向后兼容)。
fn default_proto() -> u32 {
HELLO_PROTO_VERSION
}
/// 客户端首消息:身份宣告(简单协议) /// 客户端首消息:身份宣告(简单协议)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello { pub struct Hello {
@@ -84,6 +95,9 @@ pub struct Hello {
pub device_id: String, pub device_id: String,
/// 配对 token /// 配对 token
pub token: String, pub token: String,
/// 协议版本(缺省按 v1,兼容旧客户端)
#[serde(default = "default_proto")]
pub proto: u32,
} }
/// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义) /// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义)
@@ -298,6 +312,16 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
return; return;
} }
// 版本协商:proto 缺省按 v1(旧客户端不带字段,向后兼容)。
// 高于本端支持版本时告警 —— 为后续破坏性变更预留的通知位(当前仅日志,不拒绝握手)。
if hello.proto > HELLO_PROTO_VERSION {
tracing::warn!(
client_proto = hello.proto,
server_proto = HELLO_PROTO_VERSION,
"客户端协议版本高于本端,可能存在不兼容(预留通知位)"
);
}
let conn_id = next_conn_id(); let conn_id = next_conn_id();
let kind: ClientKind = hello.kind.into(); let kind: ClientKind = hello.kind.into();
let device_id = hello.device_id.clone(); let device_id = hello.device_id.clone();
@@ -305,6 +329,7 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
conn_id = conn_id.0, conn_id = conn_id.0,
?kind, ?kind,
device_id = %device_id, device_id = %device_id,
proto = hello.proto,
"连接握手通过,进入收发循环" "连接握手通过,进入收发循环"
); );
@@ -545,4 +570,19 @@ mod tests {
fn max_frame_is_one_mib() { fn max_frame_is_one_mib() {
assert_eq!(MAX_FRAME_BYTES, 1024 * 1024); assert_eq!(MAX_FRAME_BYTES, 1024 * 1024);
} }
#[test]
fn hello_proto_defaults_to_v1_when_absent() {
// 旧客户端不发 proto → 反序列化按 v1,保证向后兼容
let json = r#"{"kind":"device","device_id":"dev-1","token":"t"}"#;
let h: Hello = serde_json::from_str(json).expect("缺 proto 应可解析");
assert_eq!(h.proto, HELLO_PROTO_VERSION);
}
#[test]
fn hello_proto_parsed_when_present() {
let json = r#"{"kind":"device","device_id":"dev-1","token":"t","proto":2}"#;
let h: Hello = serde_json::from_str(json).unwrap();
assert_eq!(h.proto, 2);
}
} }
+117 -14
View File
@@ -112,6 +112,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
boundary_json: row.get("boundary_json").unwrap_or(None), boundary_json: row.get("boundary_json").unwrap_or(None),
// loop_state 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。 // loop_state 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。
loop_state: row.get("loop_state").unwrap_or(None), 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")?, requested_at: row.get("requested_at")?,
executed_at: row.get("executed_at")?, executed_at: row.get("executed_at")?,
decided_by: row.get("decided_by")?, 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), from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( 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) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
params![ params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin, 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| { update => |conn, rec| {
// loop_state 有意不在 SET 列(V49 生命周期标记独立更新,对齐 boundary 早期模式): // loop_state/status 有意不在 SET 列(V49/V50 生命周期标记独立更新,对齐 boundary 早期模式):
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回, // update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
// 读改写窗口内 loop 生命周期可变更(spawn 置 running/收敛清 None)——若 SET 含 // 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
// loop_state,旧值会覆盖新值(lost-update)。生命周期只经 set_loop_state 单点写。 // 生命周期只经 set_loop_state / set_status 单点写。
conn.execute( 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", "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![ params![
@@ -249,22 +253,22 @@ impl_repo!(
from_row => |row| ai_tool_execution_from_row(row), from_row => |row| ai_tool_execution_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( 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) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![ params![
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, 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.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| { update => |conn, rec| {
conn.execute( 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![ params![
rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
rec.arguments, rec.result, rec.status, rec.risk_level, 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 tx = guard.transaction().map_err(storage_err)?;
{ {
let mut stmt = tx.prepare( 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) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
) )
.map_err(storage_err)?; .map_err(storage_err)?;
for rec in &records { for rec in &records {
stmt.execute(params![ stmt.execute(params![
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, 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.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)?; .map_err(storage_err)?;
} }
@@ -839,6 +843,29 @@ impl AiConversationRepo {
.map_err(storage_err)? .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' 残留改写为 'parked'。
/// ///
/// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死 /// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死
@@ -1020,6 +1047,7 @@ mod tests {
requested_at: "0".into(), requested_at: "0".into(),
executed_at: None, executed_at: None,
decided_by: None, decided_by: None,
duration_ms: None,
} }
} }
@@ -1068,6 +1096,43 @@ mod tests {
assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录"); 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), /// insert_checkpoint 冲突语义:同 id 二次写入 Ok 不报错(幂等跳过,affected=0 走 warn),
/// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。 /// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。
#[tokio::test] #[tokio::test]
@@ -1120,6 +1185,7 @@ mod tests {
origin: None, origin: None,
boundary_json: None, boundary_json: None,
loop_state: None, loop_state: None,
status: "active".into(),
}) })
.await .await
.expect("insert"); .expect("insert");
@@ -1133,4 +1199,41 @@ mod tests {
let cleared = repo.get_by_id("c1").await.expect("get").expect("row"); let cleared = repo.get_by_id("c1").await.expect("get").expect("row");
assert_eq!(cleared.loop_state, None, "清 loop_state 应可读回 None"); 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 应可读回");
}
} }
+3
View File
@@ -14,6 +14,7 @@
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具 //! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22) //! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
//! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏) //! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏)
//! - [`mod@outbox_repo`]:OutboxRepo(待发箱 ai_outbox,V51,含 claim CAS/reset_stale_sending,手写不走宏)
//! //!
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` / //! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。 //! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
@@ -25,6 +26,7 @@ mod conversation_repo;
mod idea_eval_repo; mod idea_eval_repo;
mod idea_repo; mod idea_repo;
mod message_repo; mod message_repo;
mod outbox_repo;
mod project_event_repo; mod project_event_repo;
mod project_module_repo; mod project_module_repo;
mod project_repo; mod project_repo;
@@ -38,6 +40,7 @@ pub use conversation_repo::*;
pub use idea_eval_repo::*; pub use idea_eval_repo::*;
pub use idea_repo::*; pub use idea_repo::*;
pub use message_repo::*; pub use message_repo::*;
pub use outbox_repo::*;
pub use project_event_repo::*; pub use project_event_repo::*;
pub use project_module_repo::*; pub use project_module_repo::*;
pub use project_repo::*; pub use project_repo::*;
+528
View File
@@ -0,0 +1,528 @@
//! 待发箱 Repo:OutboxRepo(ai_outbox 表,V51)
//!
//! 队列与定时消息统一实体的持久层。因需按 id 逐条 CAS 更新(claim)与状态条件扫描,
//! 不走 `impl_repo!` 宏,手写(同 `SettingsRepo` 范式)。
//!
//! 设计见 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.2。
//!
//! 状态落点纪律(V21 教训):每项状态由调度器按项逐条 CAS 写,不依赖全局内存推算;
//! `reset_stale_sending` 在启动时把崩溃残留的 `sending` 复位为 `pending`,镜像无人值守引擎的
//! `in_flight` 泄漏处置。
use std::sync::Arc;
use rusqlite::{params, Connection, OptionalExtension, Row};
use tokio::sync::Mutex;
use df_types::error::Result;
use crate::db::Database;
use crate::models::AiOutboxRecord;
use super::storage_err;
// ============================================================
// 状态 / 类型字面量(单一真值来源,避免调度器与 Repo 各写一份裸串漂移)
// ============================================================
/// 待派发(初始态)。
pub const STATUS_PENDING: &str = "pending";
/// 已被调度器 CAS 占取、正在派发。
pub const STATUS_SENDING: &str = "sending";
/// 受理成功(已交给正典发送链)。
pub const STATUS_SENT: &str = "sent";
/// 受理失败且不再自动重试。
pub const STATUS_FAILED: &str = "failed";
/// 用户取消 / 清空。
pub const STATUS_CANCELED: &str = "canceled";
/// 尽快发(kind=queue,due_at 为空)。
pub const KIND_QUEUE: &str = "queue";
/// 定时发(kind=scheduled,due_at 为 epoch 毫秒)。
pub const KIND_SCHEDULED: &str = "scheduled";
/// 终态集合(不再被调度器扫描)。
const DONE_STATUSES: &str = "'sent','failed','canceled'";
fn outbox_from_row(row: &Row<'_>) -> std::result::Result<AiOutboxRecord, rusqlite::Error> {
Ok(AiOutboxRecord {
id: row.get("id")?,
conversation_id: row.get("conversation_id")?,
kind: row.get("kind")?,
text: row.get("text")?,
skill: row.get("skill")?,
model_override: row.get("model_override")?,
parts_json: row.get("parts_json")?,
spans_json: row.get("spans_json")?,
due_at: row.get("due_at")?,
status: row.get("status")?,
attempts: row.get("attempts")?,
last_error: row.get("last_error")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
sent_at: row.get("sent_at")?,
})
}
/// 待发箱 Repo(表 `ai_outbox`)。
pub struct OutboxRepo {
conn: Arc<Mutex<Connection>>,
}
impl OutboxRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
/// 插入一条待发项,返回 id。created_at/updated_at 由本方法取当前毫秒覆盖
/// (写入时机即落库时机,调用方传入的不可信)。
pub async fn insert(&self, record: AiOutboxRecord) -> Result<String> {
let conn = self.conn.clone();
let now = df_types::now_millis();
let mut rec = record;
rec.created_at = now;
rec.updated_at = now;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let id = rec.id.clone();
guard
.execute(
"INSERT INTO ai_outbox
(id, conversation_id, kind, text, skill, model_override, parts_json,
spans_json, due_at, status, attempts, last_error, created_at, updated_at, sent_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
rec.id, rec.conversation_id, rec.kind, rec.text, rec.skill,
rec.model_override, rec.parts_json, rec.spans_json, rec.due_at,
rec.status, rec.attempts, rec.last_error, rec.created_at, rec.updated_at,
rec.sent_at
],
)
.map_err(storage_err)?;
Ok(id)
})
.await
.map_err(storage_err)?
}
/// 按 id 取单条(IPC 回显 / 测试核验用)。
pub async fn get_by_id(&self, id: &str) -> Result<Option<AiOutboxRecord>> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let rec = guard
.query_row("SELECT * FROM ai_outbox WHERE id = ?1", params![id], outbox_from_row)
.optional()
.map_err(storage_err)?;
Ok(rec)
})
.await
.map_err(storage_err)?
}
/// 按会话取待发视图(前端 `state.queue` 的来源)。`include_done=false` 时排除终态,
/// 按 created_at 升序(先入先出,保序)。
pub async fn list_by_conv(
&self,
conversation_id: &str,
include_done: bool,
) -> Result<Vec<AiOutboxRecord>> {
let conn = self.conn.clone();
let conversation_id = conversation_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let sql = if include_done {
"SELECT * FROM ai_outbox WHERE conversation_id = ?1 ORDER BY created_at ASC"
.to_string()
} else {
format!(
"SELECT * FROM ai_outbox WHERE conversation_id = ?1 \
AND status NOT IN ({DONE_STATUSES}) ORDER BY created_at ASC"
)
};
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![conversation_id], outbox_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 取到点的待派发项(status='pending' 且 due_at 为空或已到),按 created_at 升序,至多 limit 条。
/// 命中 idx_ai_outbox_due。
pub async fn list_due(&self, now_ms: i64, limit: i64) -> Result<Vec<AiOutboxRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT * FROM ai_outbox \
WHERE status = 'pending' AND (due_at IS NULL OR due_at <= ?1) \
ORDER BY created_at ASC LIMIT ?2",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![now_ms, limit], outbox_from_row)
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// CAS 占取:仅当项仍为 pending 时置 sending。changes==1 才返回 true,
/// 防并发双取(多轮/多消费方同取同一条时只有一个成功)。
pub async fn claim(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'sending', updated_at = ?1 \
WHERE id = ?2 AND status = 'pending'",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected == 1)
})
.await
.map_err(storage_err)?
}
/// 标记受理成功(终态)。清空 last_error。
pub async fn mark_sent(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'sent', sent_at = ?1, updated_at = ?1, \
last_error = NULL WHERE id = ?2",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 标记永久失败(终态,不再自动重试)。
pub async fn mark_failed(&self, id: &str, err: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let err = err.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'failed', last_error = ?1, updated_at = ?2 \
WHERE id = ?3",
params![err, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 瞬时失败回退:attempts+1、状态回 pending、记 last_error,返回新的 attempts。
/// 保留下轮再试(网络超时/会话忙场景天然重试),不删项——对齐「错误不清队列」纪律。
pub async fn revert_pending(&self, id: &str, err: Option<&str>) -> Result<i64> {
let conn = self.conn.clone();
let id = id.to_owned();
let err = err.map(|s| s.to_owned());
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard
.execute(
"UPDATE ai_outbox SET status = 'pending', attempts = attempts + 1, \
last_error = ?1, updated_at = ?2 WHERE id = ?3",
params![err, now, id],
)
.map_err(storage_err)?;
let attempts: i64 = guard
.query_row(
"SELECT attempts FROM ai_outbox WHERE id = ?1",
params![id],
|r| r.get(0),
)
.map_err(storage_err)?;
Ok(attempts)
})
.await
.map_err(storage_err)?
}
/// 改期:更新 due_at(NULL=改为尽快)。
pub async fn reschedule(&self, id: &str, due_at: Option<i64>) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET due_at = ?1, updated_at = ?2 WHERE id = ?3",
params![due_at, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 取消单项。
pub async fn cancel(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 WHERE id = ?2",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 编辑待发文本。
pub async fn update_text(&self, id: &str, text: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let text = text.to_owned();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET text = ?1, updated_at = ?2 WHERE id = ?3",
params![text, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 清空待发(pending→canceled,不物理删——保留行便于审计)。`conv_id=None` 时清全部会话。
/// 返回被取消的行数。
pub async fn clear(&self, conv_id: Option<&str>) -> Result<usize> {
let conn = self.conn.clone();
let conv_id = conv_id.map(|s| s.to_owned());
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = match conv_id {
Some(id) => guard.execute(
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 \
WHERE status = 'pending' AND conversation_id = ?2",
params![now, id],
),
None => guard.execute(
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 \
WHERE status = 'pending'",
params![now],
),
}
.map_err(storage_err)?;
Ok(affected)
})
.await
.map_err(storage_err)?
}
/// 启动复位:把崩溃半途残留的 `sending` 全部复位为 `pending`(进程重启时前者必为陈旧值)。
/// 返回复位的行数。镜像无人值守引擎的 in_flight 泄漏处置。
pub async fn reset_stale_sending(&self) -> Result<usize> {
let conn = self.conn.clone();
let now = df_types::now_millis();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_outbox SET status = 'pending', updated_at = ?1 WHERE status = 'sending'",
params![now],
)
.map_err(storage_err)?;
Ok(affected)
})
.await
.map_err(storage_err)?
}
}
// ============================================================
// 单元测试 — 迁移可重入 / CAS 互斥 / sender 残留复位(内存 DB)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
/// 构造一条 pending 待发项(due_at=None=尽快)。
fn rec(id: &str, conv: &str) -> AiOutboxRecord {
AiOutboxRecord {
id: id.to_string(),
conversation_id: conv.to_string(),
kind: KIND_QUEUE.to_string(),
text: format!("msg-{id}"),
skill: None,
model_override: None,
parts_json: None,
spans_json: None,
due_at: None,
status: STATUS_PENDING.to_string(),
attempts: 0,
last_error: None,
created_at: 0,
updated_at: 0,
sent_at: None,
}
}
async fn setup() -> (Database, OutboxRepo) {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = OutboxRepo::new(&db);
(db, repo)
}
/// V51 迁移可重入:全链在已建表库上重跑 again 不报错,ai_outbox 表与两索引仍在。
#[tokio::test]
async fn migration_v51_is_reentrant() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let conn = db.conn();
let guard = conn.lock().await;
// 首次已由 open_in_memory 跑过;再显式重跑一遍全链(模拟版本号回退/崩溃重跑)。
crate::migrations::run(&guard).expect("重跑迁移不应失败(幂等)");
crate::migrations::run(&guard).expect("二次重跑仍应幂等");
// 表存在
let has_table: bool = guard
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='ai_outbox'",
[],
|r| r.get(0),
)
.unwrap();
assert!(has_table, "ai_outbox 表应存在");
// 两索引存在
for idx in ["idx_ai_outbox_due", "idx_ai_outbox_conv"] {
let has_idx: bool = guard
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
params![idx],
|r| r.get(0),
)
.unwrap();
assert!(has_idx, "索引 {idx} 应存在");
}
}
/// claim CAS 互斥:同一条只被占取一次,二次 claim 返回 false。
#[tokio::test]
async fn claim_is_mutually_exclusive() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
assert!(repo.claim("o1").await.unwrap(), "首次 claim 应成功");
assert!(!repo.claim("o1").await.unwrap(), "二次 claim 应失败(已非 pending)");
let got = repo.get_by_id("o1").await.unwrap().unwrap();
assert_eq!(got.status, STATUS_SENDING);
}
/// reset_stale_sending 复位崩溃残留:sending → pending,计数正确。
#[tokio::test]
async fn reset_stale_sending_recovers_crash_residue() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.insert(rec("o2", "conv-1")).await.unwrap();
// 模拟两条被占取后进程崩溃(残留 sending)
assert!(repo.claim("o1").await.unwrap());
assert!(repo.claim("o2").await.unwrap());
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_SENDING);
let recovered = repo.reset_stale_sending().await.unwrap();
assert_eq!(recovered, 2, "两条残留 sending 应被复位");
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_PENDING);
assert_eq!(repo.get_by_id("o2").await.unwrap().unwrap().status, STATUS_PENDING);
}
/// list_due:只取到点的 pending(尽快项恒命中,未到点定时项不取),按 created_at 升序。
#[tokio::test]
async fn list_due_respects_due_and_order() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
// 未到点定时项(远未来)
let mut future = rec("o2", "conv-1");
future.kind = KIND_SCHEDULED.to_string();
future.due_at = Some(9_999_999_999_999);
repo.insert(future).await.unwrap();
// 已到点定时项
let mut due = rec("o3", "conv-1");
due.kind = KIND_SCHEDULED.to_string();
due.due_at = Some(0);
repo.insert(due).await.unwrap();
let got = repo.list_due(df_types::now_millis(), 32).await.unwrap();
let ids: Vec<&str> = got.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["o1", "o3"], "仅到点的 pending,按 created_at 升序");
}
/// revert_pending:瞬时失败回退 pending + attempts 自增 + 记 last_error,项不丢。
#[tokio::test]
async fn revert_pending_keeps_item_and_bumps_attempts() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.claim("o1").await.unwrap();
let n = repo.revert_pending("o1", Some("boom")).await.unwrap();
assert_eq!(n, 1);
let got = repo.get_by_id("o1").await.unwrap().unwrap();
assert_eq!(got.status, STATUS_PENDING, "回退后应可再次被调度");
assert_eq!(got.attempts, 1);
assert_eq!(got.last_error.as_deref(), Some("boom"));
}
/// cancel/clear 只动 pending:已终态项不受影响,clear 按会话范围限定。
#[tokio::test]
async fn clear_cancels_only_pending_in_scope() {
let (_db, repo) = setup().await;
repo.insert(rec("o1", "conv-1")).await.unwrap();
repo.insert(rec("o2", "conv-2")).await.unwrap();
repo.mark_sent("o1").await.unwrap();
let n = repo.clear(Some("conv-1")).await.unwrap();
assert_eq!(n, 0, "conv-1 已无 pending(sent 不算),不误改");
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_SENT);
let n = repo.clear(Some("conv-2")).await.unwrap();
assert_eq!(n, 1);
assert_eq!(repo.get_by_id("o2").await.unwrap().unwrap().status, STATUS_CANCELED);
}
}
+269 -7
View File
@@ -1,7 +1,7 @@
//! 数据库迁移 — 建表 SQL 与版本管理 //! 数据库迁移 — 建表 SQL 与版本管理
//! 数据库迁移 — 建表 SQL 与版本管理 //! 数据库迁移 — 建表 SQL 与版本管理
use crate::migrations_helpers::column_exists; use crate::migrations_helpers::{column_exists, table_exists};
use anyhow::Result; use anyhow::Result;
use rusqlite::Connection; use rusqlite::Connection;
@@ -59,7 +59,12 @@ pub fn run(conn: &Connection) -> Result<()> {
// V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。 // V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。
// V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座: // V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座:
// running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。 // 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), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -113,6 +118,10 @@ pub fn run(conn: &Connection) -> Result<()> {
(48, migrate_v48), (48, migrate_v48),
// V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。 // V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。
(49, migrate_v49), (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 { for (version, migrate_fn) in steps {
@@ -1450,6 +1459,172 @@ fn migrate_v49(conn: &Connection) -> Result<()> {
Ok(()) 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 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -2478,9 +2653,9 @@ mod tests {
) )
.expect("查 schema_version 应成功"); .expect("查 schema_version 应成功");
assert_eq!( assert_eq!(
max_version, 49, max_version, LATEST_SCHEMA_VERSION as i64,
"全量迁移后 schema_version 应为 49(实际 {}),说明某条 migrate_vN 链路断在中间", "全量迁移后 schema_version 应为顶点 {}(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version LATEST_SCHEMA_VERSION, max_version
); );
// 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑) // 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑)
@@ -2556,7 +2731,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v, 49, "首轮应推进到 49"); assert_eq!(max_v, LATEST_SCHEMA_VERSION as i64, "首轮应推进到 {}", LATEST_SCHEMA_VERSION);
// 清空版本表强制全链第二遍(每步 execute 第二次) // 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap(); conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2568,7 +2743,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .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(); .unwrap();
assert!(got.is_some(), "boundary_json 应可写可读"); 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"
);
}
} }
@@ -14,6 +14,19 @@ pub(crate) fn column_exists(conn: &Connection, table: &str, col: &str) -> bool {
false false
} }
/// 检测表是否存在(幂等迁移辅助)。
///
/// [`column_exists`] 对不存在的表与缺列都返回 false,无法区分——迁移里「加列」需先确认
/// 表在(否则 ALTER 报 no such table)。sqlite_master 精确查名(非 LIKE,避免前缀误命中)。
pub(crate) fn table_exists(conn: &Connection, table: &str) -> bool {
conn.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|r| r.get::<_, bool>(0),
)
.unwrap_or(false)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -74,4 +87,16 @@ mod tests {
assert!(!column_exists(&conn, "probe_t", "prompt")); assert!(!column_exists(&conn, "probe_t", "prompt"));
assert!(!column_exists(&conn, "probe_t", "model_configs")); assert!(!column_exists(&conn, "probe_t", "model_configs"));
} }
/// table_exists 精确匹配表名,不误命中前缀/近似名(与 column_exists 区分表缺失与缺列)。
#[test]
fn table_exists_matches_exact_name() {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch("CREATE TABLE probe_t (id TEXT);")
.expect("create probe table");
assert!(table_exists(&conn, "probe_t"));
assert!(!table_exists(&conn, "probe"), "不误命中前缀");
assert!(!table_exists(&conn, "probe_t2"), "不误命中扩展名");
assert!(!table_exists(&conn, "ghost"), "未建表返回 false");
}
} }
+37
View File
@@ -396,6 +396,10 @@ pub struct AiConversationRecord {
/// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。 /// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。
#[serde(default)] #[serde(default)]
pub loop_state: Option<String>, pub loop_state: Option<String>,
/// 会话终局态(V50):active=进行中/默认(含正常完成),incomplete=停靠/断流等未完成终局,
/// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交——
/// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。
pub status: String,
} }
/// AI 工具执行审计记录 /// AI 工具执行审计记录
@@ -419,6 +423,11 @@ pub struct AiToolExecutionRecord {
pub requested_at: String, pub requested_at: String,
pub executed_at: Option<String>, pub executed_at: Option<String>,
pub decided_by: Option<String>, // human/auto pub decided_by: Option<String>, // human/auto
/// 纯执行耗时(毫秒,V50):工具真正执行的墙钟时长(执行前→执行后),与审批等待解耦。
/// None=老数据 / 未测路径(缓存命中/未真执行)。审计面板耗时统计用本列,替代
/// requested_at→executed_at(后者在审批路径混入等待人工批准时长)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<i64>,
} }
/// 消息记录(ai_messages 表,消息拆分存储)。 /// 消息记录(ai_messages 表,消息拆分存储)。
@@ -472,6 +481,34 @@ pub struct AiMessageRecord {
pub is_estimated: Option<bool>, pub is_estimated: Option<bool>,
} }
/// 待发箱记录(ai_outbox 表,V51)。
///
/// 队列与定时消息统一实体:队列项 due_at=NULL(会话空闲即发),定时项 due_at=epoch_ms(到点且空闲即发)。
/// 表是唯一真相源(前端内存队列退化为该会话 outbox 的只读视图)。设计见
/// docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.1。
///
/// kind:queue(尽快)| scheduled(定时);预留 task(937 对话内定时任务)。
/// status:pending|sending|sent|failed|canceled。时间戳均 epoch 毫秒(存储层不转字符串本表特例:
/// due_at 需按数值比较排序,created_at/updated_at/sent_at 同为毫秒整数便于调度器直接比较)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiOutboxRecord {
pub id: String,
pub conversation_id: String,
pub kind: String,
pub text: String,
pub skill: Option<String>,
pub model_override: Option<String>,
pub parts_json: Option<String>,
pub spans_json: Option<String>,
pub due_at: Option<i64>,
pub status: String,
pub attempts: i64,
pub last_error: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub sent_at: Option<i64>,
}
// ============================================================ // ============================================================
// 知识库模型 (V7) // 知识库模型 (V7)
// ============================================================ // ============================================================
+41
View File
@@ -11,12 +11,25 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// 当前 Hello 握手协议版本。
///
/// 协商规则:旧客户端 Hello 缺 `proto` 字段 → 视为 v1(向后兼容);
/// 高于对端支持版本时由 peer 记录告警,为后续破坏性变更预留通知位。
pub const HELLO_PROTO_VERSION: u32 = 1;
/// `proto` 缺省值:旧客户端不带该字段时按 v1 处理(向后兼容)。
fn default_proto() -> u32 {
HELLO_PROTO_VERSION
}
/// 握手身份宣告帧(对齐 u-relay::relay::Hello) /// 握手身份宣告帧(对齐 u-relay::relay::Hello)
/// ///
/// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。 /// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。
/// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`), /// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`),
/// 否则 relay 反序列化失败 → 握手被拒。 /// 否则 relay 反序列化失败 → 握手被拒。
/// ///
/// `proto` 为协议版本,供 relay 端做版本协商(缺字段按 v1,新增破坏性变更时递增)。
///
/// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。 /// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。
/// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。 /// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。
/// ///
@@ -30,6 +43,9 @@ pub struct Hello {
pub channel_id: String, pub channel_id: String,
/// 配对 token(env U_RELAY_TOKEN 同源) /// 配对 token(env U_RELAY_TOKEN 同源)
pub token: String, pub token: String,
/// 协议版本(缺省按 v1,兼容旧客户端)
#[serde(default = "default_proto")]
pub proto: u32,
} }
/// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致) /// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致)
@@ -155,3 +171,28 @@ pub enum TunnelMessage {
/// 云后端转发的指令(上行,发往桌面端) /// 云后端转发的指令(上行,发往桌面端)
Command(TunnelCommand), Command(TunnelCommand),
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hello_proto_defaults_to_v1_when_absent() {
// 旧客户端不发 proto → 反序列化按 v1,保证向后兼容
let json = r#"{"kind":"agent","channel_id":"c1","token":"t"}"#;
let h: Hello = serde_json::from_str(json).expect("缺 proto 应可解析");
assert_eq!(h.proto, HELLO_PROTO_VERSION);
}
#[test]
fn hello_serializes_proto_field() {
let h = Hello {
kind: HelloKind::Agent,
channel_id: "c1".into(),
token: "t".into(),
proto: HELLO_PROTO_VERSION,
};
let v: serde_json::Value = serde_json::to_value(&h).unwrap();
assert_eq!(v["proto"], HELLO_PROTO_VERSION);
}
}
+11 -5
View File
@@ -38,7 +38,7 @@ use tokio_tungstenite::tungstenite::protocol::CloseFrame;
use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::tungstenite::Message as WsMessage;
use crate::error::{Result, TunnelError}; use crate::error::{Result, TunnelError};
use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent}; use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent, HELLO_PROTO_VERSION};
/// 收到 commander→agent 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await) /// 收到 commander→agent 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await)
/// ///
@@ -105,9 +105,13 @@ const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。 /// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收) /// 心跳间隔(WS 协议层 Ping,补 TCP keepalive,防 NAT/反代空闲回收连接)。
/// u-relay 心跳走应用层 control 帧 ping→pong,relay 本地直回不转对端 ///
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25); /// 上界约束:链路最短空闲超时在反代侧(生产 nginx `proxy_read_timeout`,配置样例见
/// u-relay wire 协议文档 §5.2,最小 300s),间隔须显著小于它并留余量——容忍数次心跳
/// 丢失或网络抖动仍不被判定空闲断链。取 30s:满足约束,且与 commander 参考实现
/// (u-ppt 30s ping)节拍一致,避免两端看门狗口径不一导致误重连。
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
/// 重连初始退避 /// 重连初始退避
const RECONNECT_BASE: Duration = Duration::from_secs(1); const RECONNECT_BASE: Duration = Duration::from_secs(1);
@@ -201,7 +205,8 @@ impl TunnelClient for WsTunnelClient {
))); )));
} }
}; };
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手"); // 版本协商(本端宣告):Hello 携带 proto,供 relay 侧比对决定兼容性
tracing::info!(%url, proto = HELLO_PROTO_VERSION, "WS 连接已建立,开始 Hello 握手");
let (mut sink, mut stream) = ws_stream.split(); let (mut sink, mut stream) = ws_stream.split();
@@ -210,6 +215,7 @@ impl TunnelClient for WsTunnelClient {
kind: HelloKind::Agent, kind: HelloKind::Agent,
channel_id: channel_id.to_string(), channel_id: channel_id.to_string(),
token: token.to_string(), token: token.to_string(),
proto: HELLO_PROTO_VERSION,
}; };
let hello_json = serde_json::to_string(&hello)?; let hello_json = serde_json::to_string(&hello)?;
sink.send(WsMessage::Text(hello_json)).await.map_err(|e| { sink.send(WsMessage::Text(hello_json)).await.map_err(|e| {
+2 -1
View File
@@ -6,11 +6,12 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:stop": "taskkill /f /im vite.exe", "dev:stop": "taskkill /f /im vite.exe",
"dev:restart": "npm run dev:stop && npm run dev", "dev:restart": "bun run dev:stop && bun run dev",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"check:i18n": "node scripts/check-i18n.cjs", "check:i18n": "node scripts/check-i18n.cjs",
"preview": "vite preview", "preview": "vite preview",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:scenario": "playwright test tests/e2e/stream-body-visibility.spec.ts",
"tauri": "tauri", "tauri": "tauri",
"build:dev": "tauri build --debug --no-bundle" "build:dev": "tauri build --debug --no-bundle"
}, },
+77 -32
View File
@@ -8,59 +8,104 @@
// 修法:字面量用 vue-i18n 的插值转义,如 `{'@'}`(渲染仍显示 @)。 // 修法:字面量用 vue-i18n 的插值转义,如 `{'@'}`(渲染仍显示 @)。
// //
// 依赖 @intlify/message-compiler(vue-i18n 的编译器);解析不到时只告警不拦截(避免门禁误伤)。 // 依赖 @intlify/message-compiler(vue-i18n 的编译器);解析不到时只告警不拦截(避免门禁误伤)。
//
// 扫描/编译逻辑抽为可导出函数(scanText / scanDirectory / compileMessage),供
// scripts/check-i18n.test.cjs 自测;CLI 入口行为保持不变。
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
let baseCompile; let baseCompile = null;
try { try {
({ baseCompile } = require('@intlify/message-compiler')); ({ baseCompile } = require('@intlify/message-compiler'));
} catch { } catch {
console.warn('[i18n-check] 未找到 @intlify/message-compiler,跳过消息编译预检(需 npm 依赖)'); baseCompile = null;
process.exit(0);
} }
const DIRS = ['src/i18n/zh-CN', 'src/i18n/en']; const DIRS = ['src/i18n/zh-CN', 'src/i18n/en'];
// key: 'value' / key: "value"(带转义),仅取字符串字面量值 // key: 'value' / "value" / `value`(带转义),三种字面量都取——
const KV_RE = /(?:^|[\s,{])([A-Za-z0-9_$]+)\s*:\s*(['"])((?:\\.|(?!\2).)*)\2/gm; // 反引号模板字面量同样可能含未转义 @/|/裸{},原先只匹配引号 → 门禁假阴性(2026-10-10 对抗审查发现)。
const KV_RE = /(?:^|[\s,{])([A-Za-z0-9_$]+)\s*:\s*([`'"])((?:\\.|(?!\2).)*)\2/gm;
let checked = 0; // 编译单个消息值,返回错误数组(@intlify 风格:{ message })
let failed = 0; function compileMessage(val) {
for (const dir of DIRS) {
let files;
try {
files = fs.readdirSync(dir);
} catch {
continue;
}
for (const f of files) {
if (!f.endsWith('.ts')) continue;
const txt = fs.readFileSync(path.join(dir, f), 'utf8');
KV_RE.lastIndex = 0;
let m;
while ((m = KV_RE.exec(txt))) {
const key = m[1];
const val = m[3];
// 只编译含 vue-i18n 特殊字符的值(@ { |),其余不可能因语法报错
if (!/[@{|]/.test(val)) continue;
checked++;
const errs = []; const errs = [];
try { try {
baseCompile(val, { onError: (e) => errs.push(e), onWarn: () => {} }); baseCompile(val, { onError: (e) => errs.push(e), onWarn: () => {} });
} catch (e) { } catch (e) {
errs.push(e); errs.push(e);
} }
if (errs.length) { return errs;
}
// 扫描一段源码文本,返回 { checked, violations }
// - checked: 含 vue-i18n 特殊字符(@ { |)并已送编译的值个数
// - violations: 编译报错的条目 [{ key, value, message }]
function scanText(txt) {
const violations = [];
let checked = 0;
KV_RE.lastIndex = 0;
let m;
while ((m = KV_RE.exec(txt))) {
const key = m[1];
// 模板字面量里的 ${...} 插值对 vue-i18n 无意义,编译前替换为占位符避免假报错
const val = m[2] === '`' ? m[3].replace(/\$\{[^}]*\}/g, 'X') : m[3];
// 只编译含 vue-i18n 特殊字符的值(@ { |),其余不可能因语法报错
if (!/[@{|]/.test(val)) continue;
checked++;
const errs = compileMessage(val);
if (errs.length) violations.push({ key, value: val, message: errs[0].message });
}
return { checked, violations };
}
// 扫描目录下所有 .ts,返回 { checked, violations }(violations 带 file 字段)
function scanDirectory(dir) {
const violations = [];
let checked = 0;
let files;
try {
files = fs.readdirSync(dir);
} catch {
return { checked, violations };
}
for (const f of files) {
if (!f.endsWith('.ts')) continue;
const res = scanText(fs.readFileSync(path.join(dir, f), 'utf8'));
checked += res.checked;
for (const v of res.violations) violations.push({ file: f, ...v });
}
return { checked, violations };
}
// CLI 主流程:返回进程退出码(0 通过 / 1 失败)
function run() {
if (!baseCompile) {
console.warn('[i18n-check] 未找到 @intlify/message-compiler,跳过消息编译预检(需 npm 依赖)');
return 0;
}
let checked = 0;
let failed = 0;
for (const dir of DIRS) {
const res = scanDirectory(dir);
checked += res.checked;
for (const v of res.violations) {
failed++; failed++;
console.error(`✗ ${dir}/${f} :: ${key} => ${errs[0].message}`); console.error(`✗ ${dir}/${v.file} :: ${v.key} => ${v.message}`);
console.error(` 值: ${JSON.stringify(val)}`); console.error(` 值: ${JSON.stringify(v.value)}`);
console.error(` 提示: 字面 @ 须转义为 {'@'}(同理 | 与裸 {})`); console.error(` 提示: 字面 @ 须转义为 {'@'}(同理 | 与裸 {})`);
} }
} }
if (failed > 0) {
console.error(`[i18n-check] 失败 ${failed} 处(共编译 ${checked} 个含特殊字符的值)——prod 运行时会抛 Invalid linked format`);
return 1;
} }
console.log(`[i18n-check] 通过(编译 ${checked} 个含特殊字符的值,0 失败)`);
return 0;
} }
if (failed > 0) { if (require.main === module) {
console.error(`[i18n-check] 失败 ${failed} 处(共编译 ${checked} 个含特殊字符的值)——prod 运行时会抛 Invalid linked format`); process.exit(run());
process.exit(1);
} }
console.log(`[i18n-check] 通过(编译 ${checked} 个含特殊字符的值,0 失败)`);
module.exports = { compileMessage, scanText, scanDirectory, run, KV_RE, DIRS };
+101
View File
@@ -0,0 +1,101 @@
// check-i18n.cjs 的门禁自测:用 node 内置测试跑,不引第三方依赖。
// node --test scripts/check-i18n.test.cjs
//
// 覆盖(对应 2026-10-10 对抗审查发现的假阴性 + 常规门禁行为):
// ① 单引号值含裸 @ → 报错
// ② 双引号值含裸 @ → 报错
// ③ 反引号模板字面量含裸 @ → 报错(原实现只匹配引号,漏报)
// ④ 含 ${} 插值不误报(模板字面量里的插值需先占位再编译)
// ⑤ 转义写法 {'@'} → 通过
// ⑥ 含 | 与裸 {} 的用例
// 另附 scanDirectory 对临时目录样本的端到端断言。
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { scanText, scanDirectory } = require('./check-i18n.cjs');
let compilerAvailable = true;
try {
require('@intlify/message-compiler');
} catch {
compilerAvailable = false;
}
const opts = compilerAvailable ? {} : { skip: '@intlify/message-compiler 不可用,跳过编译相关用例' };
// 构造一份 TS 源样本:每行一个 `key: '<literal>',`
const src = (...lines) => `export default {\n${lines.map((l) => ` ${l},`).join('\n')}\n}\n`;
test('①单引号值含裸 @ → 报错', opts, () => {
const r = scanText(src("a: 'hello @ world'"));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
assert.equal(r.violations[0].key, 'a');
});
test('②双引号值含裸 @ → 报错', opts, () => {
const r = scanText(src('b: "hello @ world"'));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
assert.equal(r.violations[0].key, 'b');
});
test('③反引号模板字面量含裸 @ → 报错(假阴性回归)', opts, () => {
const r = scanText(src('c: `hello @ world`'));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
assert.equal(r.violations[0].key, 'c');
});
test('④-1 模板字面量含 ${} 插值(无特殊字符)不误报', opts, () => {
const r = scanText(src('d: `hello ${name}`'));
// 插值被占位后不含 @ { |,不计入 checked,也无违规
assert.equal(r.violations.length, 0);
});
test('④-2 插值位于花括号内不误报(占位替代的关键场景)', opts, () => {
// 原值 `total {${count}}` 直接编译会报 Invalid token in placeholder: '$',
// 必须先替换 ${...} 为占位符成为 `total {X}`(合法具名插值)才不误报。
const r = scanText(src('e: `total {${count}}`'));
assert.equal(r.violations.length, 0);
assert.equal(r.checked, 1);
});
test('⑤转义写法 {\'@\'} → 通过', opts, () => {
const r = scanText(src("f: \"{'@'}\""));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 0);
});
test('⑥-1 值含 | 会进入编译且正常通过', opts, () => {
const r = scanText(src("g: 'one | two'"));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 0);
});
test('⑥-2 值以 | 结尾(plural 缺省) → 报错', opts, () => {
const r = scanText(src("h: 'one |'"));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
});
test('⑥-3 裸 { 未闭合 → 报错', opts, () => {
const r = scanText(src("i: 'x { y'"));
assert.equal(r.checked, 1);
assert.equal(r.violations.length, 1);
});
test('scanDirectory 扫临时目录样本:定位到违规文件', opts, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-check-'));
try {
fs.writeFileSync(path.join(dir, 'ok.ts'), src("ok: 'plain text'"));
fs.writeFileSync(path.join(dir, 'bad.ts'), src('bad: `oops @ here`'));
const r = scanDirectory(dir);
assert.equal(r.violations.length, 1);
assert.equal(r.violations[0].file, 'bad.ts');
assert.equal(r.violations[0].key, 'bad');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# 根目录卫生门禁(2026-10-10 根治):根目录只放项目元文件,**新增**根文件不在白名单即拒。
# 背景:日志/一次性脚本/临时产物反复堆到仓库根,已两次人工清理(2026-10-10)——治本靠门禁而非再清一遍。
# 约定:日志与临时产物入 temp/(已 gitignore);脚本入 scripts/;文档入 docs/。
added=$(git diff --cached --name-only --diff-filter=A)
[ -z "$added" ] && exit 0
bad=$(printf '%s\n' "$added" | awk -F/ 'NF==1' | grep -vE '^(ARCHITECTURE\.md|CLAUDE\.md|README\.md|Cargo\.toml|Cargo\.lock|bun\.lock|index\.html|package\.json|playwright\.config\.ts|tsconfig\.json|tsconfig\.node\.json|vite\.config\.ts|\.gitignore|\.gitattributes|\.dockerignore)$')
if [ -n "$bad" ]; then
echo "✗ [根目录卫生] 新增根文件不在白名单: $(printf '%s ' $bad)"
echo " 路径约定:日志/临时产物→temp/(已 gitignore);脚本→scripts/;文档→docs/;确属项目元文件才放根"
echo " 如确需新增根文件,请同步更新本规则白名单(scripts/hooks/rules/root-hygiene.sh)"
exit 1
fi
exit 0
+3 -3
View File
@@ -15,8 +15,8 @@ if (-not $installer) {
} }
$target = 'C:\Users\23780\AppData\Local\devflow\devflow.exe' $target = 'C:\Users\23780\AppData\Local\devflow\devflow.exe'
$builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe' $builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe'
$log = 'E:\wk-lab\devflow\swap-install.log' $log = 'E:\wk-lab\devflow\temp\swap-install.log'
$lockFile = 'E:\wk-lab\devflow\swap-install.lock' $lockFile = 'E:\wk-lab\devflow\temp\swap-install.lock'
# SHA256 取值:Get-FileHash 在部分环境的本机 Windows PowerShell 5.1 上不可用(实测 2026-10-10 缺失), # SHA256 取值:Get-FileHash 在部分环境的本机 Windows PowerShell 5.1 上不可用(实测 2026-10-10 缺失),
# 兜底走系统自带的 certutil。返回大写十六进制串。 # 兜底走系统自带的 certutil。返回大写十六进制串。
@@ -189,7 +189,7 @@ try {
} }
# 仍无进程, 落标记等人工拉起 # 仍无进程, 落标记等人工拉起
Set-Content -Path 'E:\wk-lab\devflow\swap-need-start.flag' -Value (Get-Date -Format o) Set-Content -Path 'E:\wk-lab\devflow\temp\swap-need-start.flag' -Value (Get-Date -Format o)
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: need manual start" | Add-Content $log "[$(Get-Date -Format 'HH:mm:ss')] FAIL: need manual start" | Add-Content $log
exit 1 exit 1
} }
+73 -25
View File
@@ -902,6 +902,38 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
4. 输出纯 JSON,无任何额外文字\n\ 4. 输出纯 JSON,无任何额外文字\n\
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)"; 5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
/// 弱推断会话唯一提及的项目名(级 b,**纯函数**:仅解析消息文本,不碰 DB)。
///
/// 反向扫最近 20 条 user 消息,收集全部 `[项目:名称]` 标记(chat.rs `extract_pinned_goal`
/// 与 df-ai `strip_mention_tags` 同款字面量);**去重后恰好唯一**才返回该名,零命中或
/// 多项目返回 None(B1 拍板:多项目 mention 不猜,防张冠李戴)。
///
/// `dedup(); len()==1` 等价于「所有提及名相同」:任意两处不同名必留下相邻不重复元素
/// 使 len≥2,故 A/B/A 这类非相邻重复也被正确判为两个项目(→ None)。
fn unique_mentioned_project(messages: &[ChatMessage]) -> Option<&str> {
let mut mentioned: Vec<&str> = Vec::new();
for m in messages.iter().rev().take(20) {
if !matches!(m.role, MessageRole::User) {
continue;
}
let mut rest = m.content.as_str();
while let Some(start) = rest.find("[项目:") {
let after = &rest[start + "[项目:".len()..];
if let Some(end) = after.find(']') {
let name = after[..end].trim();
if !name.is_empty() {
mentioned.push(name);
}
rest = &after[end + 1..];
} else {
break; // 未闭合(可能截断),停扫本条
}
}
}
mentioned.dedup();
(mentioned.len() == 1).then(|| mentioned[0])
}
/// 项目归属弱推断(跨会话召回最小闭环,设计 D1:a→b 两级,皆无 → None=全局知识)。 /// 项目归属弱推断(跨会话召回最小闭环,设计 D1:a→b 两级,皆无 → None=全局知识)。
/// ///
/// 级 a(高置信):读 `ai_conversations.boundary_json`(V45 无人值守边界)的 `dir_allowlist` /// 级 a(高置信):读 `ai_conversations.boundary_json`(V45 无人值守边界)的 `dir_allowlist`
@@ -947,31 +979,9 @@ async fn resolve_source_project(db: &Arc<Database>, conv_id: &str, messages: &[C
} }
} }
// 级 b(B1:mention 唯一才启用):最近消息扫 `[项目:名称]` 标记,唯一命中才归属 // 级 b(B1:mention 唯一才启用):唯一命中才归属,多项目/零 mention 不猜
let mut mentioned: Vec<&str> = Vec::new(); // (判定收口至纯函数 unique_mentioned_project,便于单测覆盖命中/未命中/去重)
for m in messages.iter().rev().take(20) { if let Some(name) = unique_mentioned_project(messages) {
if !matches!(m.role, MessageRole::User) {
continue;
}
let mut rest = m.content.as_str();
// 找全部 `[项目:xxx]` / `[项目:xxx]` 标记(半/全角冒号),收名称
while let Some(start) = rest.find("[项目:") {
let after = &rest[start + "[项目:".len()..];
if let Some(end) = after.find(']') {
let name = after[..end].trim();
if !name.is_empty() {
mentioned.push(name);
}
rest = &after[end + 1..];
} else {
break; // 未闭合(可能截断),停扫本条
}
}
}
// 去重后恰好唯一才猜(B1 拍板:多项目 mention 不猜)
mentioned.dedup();
if mentioned.len() == 1 {
let name = mentioned[0];
if let Ok(projects) = project_repo.list_active().await { if let Ok(projects) = project_repo.list_active().await {
if let Some(p) = projects.iter().find(|p| p.name == name) { if let Some(p) = projects.iter().find(|p| p.name == name) {
return Some(p.id.clone()); return Some(p.id.clone());
@@ -1605,6 +1615,7 @@ mod salvage_tests {
origin: None, origin: None,
boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()), boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()),
loop_state: None, loop_state: None,
status: "active".into(),
}) })
.await .await
.unwrap(); .unwrap();
@@ -1652,4 +1663,41 @@ mod salvage_tests {
// 零 mention → None // 零 mention → None
assert_eq!(resolve_source_project(&db, "conv-ghost", &[um("普通消息")]).await, None); assert_eq!(resolve_source_project(&db, "conv-ghost", &[um("普通消息")]).await, None);
} }
// ---------- 唯一提及项目判定(纯函数 unique_mentioned_project:命中/未命中/去重) ----------
/// 命中:单条唯一提及 → 去空白后返回名称
#[test]
fn unique_mention_hit_and_trim() {
let msgs = vec![um("修一下 [项目: 项目甲] 的列表页")];
assert_eq!(unique_mentioned_project(&msgs), Some("项目甲"));
}
/// 去重:同名跨消息重复 → 去重后唯一 → 仍命中
#[test]
fn unique_mention_dedup_same_name_repeated() {
let msgs = vec![
um("[项目:项目甲] 先看这个"),
um("还是 [项目:项目甲] 的老问题"),
];
assert_eq!(unique_mentioned_project(&msgs), Some("项目甲"));
}
/// 未命中:零提及 / 多项目 / 非相邻重复 / 非 user 消息
#[test]
fn unique_mention_miss_cases() {
let none_mention = vec![um("普通消息,无标记")];
assert_eq!(unique_mentioned_project(&none_mention), None);
let multi = vec![um("[项目:项目甲] 和 [项目:项目乙] 谁对")];
assert_eq!(unique_mentioned_project(&multi), None);
// 非相邻重复(A/B/A)视为两个项目 → 不猜(去重判据的关键边界)
let interleaved = vec![um("[项目:项目甲] [项目:项目乙] [项目:项目甲]")];
assert_eq!(unique_mentioned_project(&interleaved), None);
// 非 user 消息里的标记不参与(仅扫 user)
let assistant_only = vec![ChatMessage::assistant("[项目:项目甲]")];
assert_eq!(unique_mentioned_project(&assistant_only), None);
}
} }
+84 -13
View File
@@ -18,7 +18,9 @@
//! 惯例为规范绝对路径,过度归一反而引入 surprise。 //! 惯例为规范绝对路径,过度归一反而引入 surprise。
//! //!
//! ## 单测 //! ## 单测
//! 见文末 tests 模块:互斥/同会话放行/释放/TTL 过期/键归一五组。 //! 见文末 tests 模块:跨会话互斥/同会话放行/释放/TTL 过期/多会话并存等。冲突检测与
//! 过期清理抽为 `try_register`/`purge_expired` 纯函数——单测注入独立表与时间点,
//! 既不真等 TTL,也不触碰全局 REGISTRY(免并发测试互相污染)。
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::LazyLock; use std::sync::LazyLock;
@@ -111,17 +113,31 @@ pub(crate) fn acquire_leases(
return Ok(LeaseGuard { released: true, keys: Vec::new() }); return Ok(LeaseGuard { released: true, keys: Vec::new() });
} }
let paths = args_paths(tool_name, args); let paths = args_paths(tool_name, args);
if paths.is_empty() {
return Ok(LeaseGuard { released: true, keys: Vec::new() });
}
let now = Instant::now();
let mut reg = REGISTRY.write().expect("文件租约表锁"); let mut reg = REGISTRY.write().expect("文件租约表锁");
// 第一遍:冲突检测(不做部分登记,全过才统一登记) let keys = try_register(&mut reg, conv_id, agent_kind, tool_name, tool_call_id, &paths, Instant::now())?;
for p in &paths { Ok(LeaseGuard { released: false, keys })
}
/// 冲突检测 + 登记(纯逻辑,不含锁):对 `reg` 做「全过才登记」,便于单测注入独立表
/// 与时间点,不必真等 TTL,也不污染全局 REGISTRY(并发测试隔离)。
///
/// 先扫一遍 `paths`:任一路径被**其他会话**活跃占用即返回 Err(指路文案),不做部分
/// 登记;顺路把过期条目当泄漏清掉。全过后统一登记,返回登记的键集合。同会话同路径
/// 放行(见 acquire_leases 文档)。
fn try_register(
reg: &mut HashMap<String, LeaseHolder>,
conv_id: &str,
agent_kind: &'static str,
tool_name: &str,
tool_call_id: &str,
paths: &[String],
now: Instant,
) -> Result<Vec<String>, String> {
for p in paths {
let key = lease_key(p); let key = lease_key(p);
if let Some(holder) = reg.get(&key) { if let Some(holder) = reg.get(&key) {
if holder.conv_id == conv_id { if holder.conv_id == conv_id {
continue; // 同会话:放行(见函数文档) continue; // 同会话:放行(见 acquire_leases 文档)
} }
if holder.expired(now) { if holder.expired(now) {
reg.remove(&key); // TTL 兜底:过期租约视为泄漏,清掉 reg.remove(&key); // TTL 兜底:过期租约视为泄漏,清掉
@@ -130,10 +146,9 @@ pub(crate) fn acquire_leases(
return Err(holder.describe()); return Err(holder.describe());
} }
} }
// 第二遍:全部登记
let mut keys = Vec::with_capacity(paths.len()); let mut keys = Vec::with_capacity(paths.len());
for p in paths { for p in paths {
let key = lease_key(&p); let key = lease_key(p);
reg.insert(key.clone(), LeaseHolder { reg.insert(key.clone(), LeaseHolder {
conv_id: conv_id.to_string(), conv_id: conv_id.to_string(),
agent_kind, agent_kind,
@@ -143,7 +158,7 @@ pub(crate) fn acquire_leases(
}); });
keys.push(key); keys.push(key);
} }
Ok(LeaseGuard { released: false, keys }) Ok(keys)
} }
/// RAII 租约守卫:drop 自动释放(Ok/Err/panic unwind 全覆盖)。 /// RAII 租约守卫:drop 自动释放(Ok/Err/panic unwind 全覆盖)。
@@ -184,9 +199,8 @@ impl Drop for LeaseGuard {
/// 只读查询:当前全部活跃租约(query_leases 工具数据源,W-h2 兄弟感知)。 /// 只读查询:当前全部活跃租约(query_leases 工具数据源,W-h2 兄弟感知)。
pub(crate) fn query_leases() -> Vec<(String, LeaseHolder)> { pub(crate) fn query_leases() -> Vec<(String, LeaseHolder)> {
let now = Instant::now();
let mut reg = REGISTRY.write().expect("文件租约表锁"); let mut reg = REGISTRY.write().expect("文件租约表锁");
reg.retain(|_, h| !h.expired(now)); // 查询顺手清过期 purge_expired(&mut reg, Instant::now()); // 查询顺手清过期
let mut items: Vec<(String, LeaseHolder)> = reg let mut items: Vec<(String, LeaseHolder)> = reg
.iter() .iter()
.map(|(k, h)| (k.clone(), h.clone())) .map(|(k, h)| (k.clone(), h.clone()))
@@ -195,6 +209,13 @@ pub(crate) fn query_leases() -> Vec<(String, LeaseHolder)> {
items items
} }
/// 过期清理(纯逻辑):移除所有过期条目,返回移除条数(query 只读面顺手清扫泄漏)。
fn purge_expired(reg: &mut HashMap<String, LeaseHolder>, now: Instant) -> usize {
let before = reg.len();
reg.retain(|_, h| !h.expired(now));
before - reg.len()
}
/// 注册 query_leases 只读工具(W-h2:LLM 自查「谁在占用什么」,兄弟感知最小入口)。 /// 注册 query_leases 只读工具(W-h2:LLM 自查「谁在占用什么」,兄弟感知最小入口)。
/// Low 风险零参数;返回人话列表供 LLM 直接消费。 /// Low 风险零参数;返回人话列表供 LLM 直接消费。
pub(crate) fn register_lease_tool(registry: &mut df_ai::ai_tools::AiToolRegistry) { pub(crate) fn register_lease_tool(registry: &mut df_ai::ai_tools::AiToolRegistry) {
@@ -342,4 +363,54 @@ mod tests {
let _g2 = acquire_leases(CONV_B, "main", "write_file", "tc-p2", &json!({"path": "/x/p.rs"})) let _g2 = acquire_leases(CONV_B, "main", "write_file", "tc-p2", &json!({"path": "/x/p.rs"}))
.expect("unwind drop 后应已释放"); .expect("unwind drop 后应已释放");
} }
#[test]
fn expired_lease_reclaimed_on_conflict_check() {
// 纯逻辑单测:注入独立表 + 时间点(不碰全局 REGISTRY,免并发测试互相污染)。
let mut reg: HashMap<String, LeaseHolder> = HashMap::new();
let t0 = Instant::now();
let a = try_register(&mut reg, CONV_A, "main", "write_file", "tc-e1", &["/x/e.rs".to_string()], t0)
.expect("t0 时 A 登记成功");
assert_eq!(a, vec!["/x/e.rs".to_string()]);
// TTL 边界内:他会话写同路径 → 冲突拒绝
let within = t0 + LEASE_TTL - Duration::from_secs(1);
assert!(
try_register(&mut reg, CONV_B, "sub", "patch_file", "tc-e2", &["/x/e.rs".to_string()], within).is_err(),
"TTL 内应互斥"
);
// 越过 TTL:过期条目被约束检查惰性清理 → B 获取成功,表中只剩 B
let t_later = t0 + LEASE_TTL + Duration::from_secs(1);
let b = try_register(&mut reg, CONV_B, "sub", "patch_file", "tc-e3", &["/x/e.rs".to_string()], t_later)
.expect("过期残留清掉后 B 可获取");
assert_eq!(b, vec!["/x/e.rs".to_string()]);
assert_eq!(reg.len(), 1, "过期的 A 条目应已被清理");
assert_eq!(reg.get("/x/e.rs").unwrap().conv_id, CONV_B);
}
#[test]
fn purge_expired_removes_stale_entries() {
let mut reg: HashMap<String, LeaseHolder> = HashMap::new();
let t0 = Instant::now();
try_register(&mut reg, CONV_A, "main", "write_file", "tc-p1", &["/x/purge-a.rs".to_string()], t0).unwrap();
try_register(&mut reg, CONV_B, "main", "write_file", "tc-p2", &["/x/purge-b.rs".to_string()], t0).unwrap();
// 未到 TTL:不清理
assert_eq!(purge_expired(&mut reg, t0 + LEASE_TTL - Duration::from_secs(1)), 0);
assert_eq!(reg.len(), 2);
// 越过 TTL:两条都清
assert_eq!(purge_expired(&mut reg, t0 + LEASE_TTL + Duration::from_secs(1)), 2);
assert!(reg.is_empty());
}
#[test]
fn multi_conv_leases_coexist_on_distinct_paths() {
// 多会话并存:异路径互不冲突,同表共存(纯逻辑,独立表)。
let mut reg: HashMap<String, LeaseHolder> = HashMap::new();
let now = Instant::now();
try_register(&mut reg, CONV_A, "main", "write_file", "tc-ma", &["/x/a-multi.rs".to_string()], now).unwrap();
try_register(&mut reg, CONV_B, "sub", "write_file", "tc-mb", &["/x/b-multi.rs".to_string()], now)
.expect("异路径不冲突");
assert_eq!(reg.len(), 2);
assert_eq!(reg.get("/x/a-multi.rs").unwrap().conv_id, CONV_A);
assert_eq!(reg.get("/x/b-multi.rs").unwrap().conv_id, CONV_B);
}
} }
+149
View File
@@ -0,0 +1,149 @@
//! C2 GUI 侧接点:轮询 df-mcp 落库的变更标记,把外部写库转成 `df-data-changed`。
//!
//! 背景:stdio 形态的 MCP 是独立进程,无 AppHandle,写库后 GUI 无从经事件感知,
//! 造成双端数据断层(P0-2)。df-mcp 每次成功写工具后向 [`MCP_DATA_CHANGED_KEY`]
//! 覆盖写入 `{"at": <毫秒>, "tool": "<工具名>"}`(跨进程落库信令)。
//!
//! 本模块周期读该 KV,`at` 推进即视为外部改动过库,再按 `tool` 复用
//! [`emit_data_changed`] 的 entity 映射补发 `df-data-changed` 刷新前端列表。
//! 桌面内嵌 HTTP 形态走 on_tool_call 直接 emit,不经此路(标记仅 stdio 写)。
//!
//! 接线:lib.rs setup 调 [`start`] 挂载;接线落地前本模块无外部调用方,
//! 先 allow(dead_code) 保留入口(零调用方≠垃圾)。
#![allow(dead_code)]
use std::time::Duration;
use tauri::{AppHandle, Manager};
use super::audit::emit_data_changed;
use crate::state::AppState;
use df_mcp::MCP_DATA_CHANGED_KEY;
/// 轮询周期(秒)。变更标记是低频兜底信令(非实时事件队列),10s 内感知即够。
const POLL_SECS: u64 = 10;
/// 变更标记体(KV 值反序列化目标)。
#[derive(serde::Deserialize)]
struct Marker {
/// 写入时刻(毫秒时间戳),用于判定是否为新的一次外部改动。
at: i64,
/// 触发写入的工具名,用于复用 entity 映射。
tool: String,
}
/// 解析 KV 原值为标记;缺失/坏值返 None(调用方保持基线,不下发也不推进)。
fn parse_marker(raw: Option<&str>) -> Option<Marker> {
serde_json::from_str(raw?).ok()
}
/// 变更标记观察器:维护上次 `at` 基线,判定是否需补发。
///
/// 纯逻辑(只持 `last_at`,无 IO),便于单测;IO/事件下发留在 [`start`] 循环。
struct MarkerWatcher {
last_at: Option<i64>,
}
impl MarkerWatcher {
fn new() -> Self {
Self { last_at: None }
}
/// 观察一次 KV 原值:返回需补发 `df-data-changed` 的工具名,无则 None。
///
/// - 首次读到有效标记只建基线不补发(避免每次 GUI 启动空刷一次列表)
/// - `at` 未变 → None;`at` 推进 → 更新基线并返回工具名
/// - 缺失/坏值 → None(基线不动,下轮有效值仍能判定)
fn observe(&mut self, raw: Option<&str>) -> Option<String> {
let marker = parse_marker(raw)?;
match self.last_at {
Some(prev) if prev == marker.at => None,
Some(_) => {
self.last_at = Some(marker.at);
Some(marker.tool)
}
None => {
self.last_at = Some(marker.at);
None
}
}
}
}
/// GUI 侧接点挂载入口(lib.rs setup 调用一次)。
///
/// 常驻轮询循环;读库失败静默跳过(下轮再试),不 panic 拖垮后台任务。
pub fn start(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut watcher = MarkerWatcher::new();
let mut interval = tokio::time::interval(Duration::from_secs(POLL_SECS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
let raw = {
let state = app.state::<AppState>();
state.settings.get(MCP_DATA_CHANGED_KEY).await.ok().flatten()
};
if let Some(tool) = watcher.observe(raw.as_deref()) {
tracing::debug!(tool = %tool, "mcp 变更标记推进,补发 df-data-changed");
emit_data_changed(&app, &tool);
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_rejects_missing_or_bad() {
assert!(parse_marker(None).is_none());
assert!(parse_marker(Some("not json")).is_none());
assert!(parse_marker(Some(r#"{"at":1}"#)).is_none()); // 缺 tool
}
#[test]
fn parse_ok() {
let m = parse_marker(Some(r#"{"at":123,"tool":"create_task"}"#)).unwrap();
assert_eq!(m.at, 123);
assert_eq!(m.tool, "create_task");
}
#[test]
fn first_read_baselines_without_emit() {
let mut w = MarkerWatcher::new();
assert_eq!(w.observe(Some(r#"{"at":100,"tool":"create_task"}"#)), None);
}
#[test]
fn advanced_at_emits_tool() {
let mut w = MarkerWatcher::new();
w.observe(Some(r#"{"at":100,"tool":"create_task"}"#));
assert_eq!(
w.observe(Some(r#"{"at":200,"tool":"update_project"}"#)),
Some("update_project".to_string())
);
}
#[test]
fn unchanged_at_no_emit() {
let mut w = MarkerWatcher::new();
w.observe(Some(r#"{"at":100,"tool":"create_task"}"#));
assert_eq!(w.observe(Some(r#"{"at":100,"tool":"create_task"}"#)), None);
assert_eq!(w.observe(Some(r#"{"at":100,"tool":"other"}"#)), None);
}
#[test]
fn missing_or_bad_keeps_baseline() {
let mut w = MarkerWatcher::new();
w.observe(Some(r#"{"at":100,"tool":"create_task"}"#));
assert_eq!(w.observe(None), None);
assert_eq!(w.observe(Some("garbage")), None);
// 基线未动:随后真推进仍能补发
assert_eq!(
w.observe(Some(r#"{"at":101,"tool":"create_task"}"#)),
Some("create_task".to_string())
);
}
}
+28 -1
View File
@@ -46,6 +46,10 @@ pub mod get_app_config;
pub mod knowledge_inject; pub mod knowledge_inject;
// 文件租约注册表(W-h1 感知租约):写类文件工具跨会话互斥 + query_leases 兄弟感知 // 文件租约注册表(W-h1 感知租约):写类文件工具跨会话互斥 + query_leases 兄弟感知
pub mod lease; pub mod lease;
// C2 GUI 侧接点:轮询 df-mcp 落库变更标记,stdio MCP 独立进程写库后补发 df-data-changed
pub mod mcp_watch;
// 939 ★③:按目标模型上下文窗口构造上下文预算配置(收口会话构造点)
pub mod model_context;
// 系统通知(P1 bg 化补完):完成/失败/需介入三类事件,主窗托盘化时补位提醒(条件发送) // 系统通知(P1 bg 化补完):完成/失败/需介入三类事件,主窗托盘化时补位提醒(条件发送)
pub(crate) mod notify; pub(crate) mod notify;
pub(crate) mod feishu_notify; pub(crate) mod feishu_notify;
@@ -66,6 +70,10 @@ pub mod tool_result_offload;
pub mod tools; pub mod tools;
// B5 无人值守续作引擎:看板轮询自动领单 + parked 自动恢复(收敛回读钩子在 agentic 出口) // B5 无人值守续作引擎:看板轮询自动领单 + parked 自动恢复(收敛回读钩子在 agentic 出口)
pub mod unattended; pub mod unattended;
// 待发箱调度器(定时消息与待发箱持久化 P0 地基):到点且会话空闲即经正典 spawn_agentic_session 派发
pub mod outbox;
// W-b 异步唤醒原语:内存 registry 三条件(delay/file-appear/process-exit)满足即注入续跑
pub mod wake;
use agentic::conv_state::{ConvState, ConvStateStore}; use agentic::conv_state::{ConvState, ConvStateStore};
@@ -1223,7 +1231,8 @@ impl PerConvState {
/// 新建默认会话级状态。 /// 新建默认会话级状态。
/// ///
/// 各字段初值**逐字对齐 [`AiSession::new`]** —— 批2 迁移调用方时行为不变。 /// 各字段初值**逐字对齐 [`AiSession::new`]** —— 批2 迁移调用方时行为不变。
/// - messages: ContextManager::new(ContextConfig::default()) /// - messages: 先 `ContextConfig::default()`(128K),发送入口再经 [`Self::apply_model_window`]
/// 按目标模型实际窗口重设(939 ★③;未重设时等价旧 128K 行为)
/// - generating: false /// - generating: false
/// - stop_flag: Arc::new(AtomicBool::new(false)) /// - stop_flag: Arc::new(AtomicBool::new(false))
/// - notify: Arc::new(tokio::sync::Notify::new()) /// - notify: Arc::new(tokio::sync::Notify::new())
@@ -1261,6 +1270,19 @@ impl PerConvState {
readonly_cache_floor: 0, readonly_cache_floor: 0,
} }
} }
/// 按目标模型上下文窗口重设消息预算(939 ★③,收口见 [`model_context`])。
///
/// **仅在会话尚无消息时生效**:已有消息时重建 `ContextManager` 会丢弃
/// `pending_topic_marker`/`needs_full_rewrite` 等内存态、且可能错位 `persisted_msg_count`
/// 持久化基线,风险大于收益。故只对「构造点语义」的空会话重建;已存在的会话沿用其
/// 创建时(同样走本方法)设定的窗口——模型切换不回溯改预算(切换后新会话生效)。
pub fn apply_model_window(&mut self, model_id: &str, model_configs: &[df_ai::df_ai_core::model::ModelConfig]) {
if !self.messages.is_empty() {
return;
}
self.messages = ContextManager::new(model_context::context_config_for_model(model_id, model_configs));
}
} }
impl Default for PerConvState { impl Default for PerConvState {
@@ -1444,6 +1466,11 @@ pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &s
if let Err(e) = repo.set_loop_state(&cid, Some("parked")).await { if let Err(e) = repo.set_loop_state(&cid, Some("parked")).await {
tracing::warn!(conv_id = %cid, error = %e, "[ai] 停靠写 loop_state=parked 失败"); tracing::warn!(conv_id = %cid, error = %e, "[ai] 停靠写 loop_state=parked 失败");
} }
// V50 终局态:停靠=未完成终局(断流/审批超时/重试预算耗尽等),标 incomplete 供未完成率统计。
// 用户恢复(ai_continue_loop)时由 recover_parked_inner 复位 active,避免续作后误标。
if let Err(e) = repo.set_status(&cid, "incomplete").await {
tracing::warn!(conv_id = %cid, error = %e, "[ai] 停靠写 status=incomplete 失败");
}
}); });
} }
// ② 发停靠事件(前端渲染停靠卡+继续按钮)。 // ② 发停靠事件(前端渲染停靠卡+继续按钮)。
+121
View File
@@ -0,0 +1,121 @@
//! 按目标模型的上下文窗口构造 [`ContextConfig`](df_ai::context::ContextConfig)。
//!
//! 背景(939 ★③):主会话 `PerConvState::new` 原固定 `ContextConfig::default()`(预算恒 128K),
//! 不随模型窗口变化 —— 1M 窗口模型(deepseek-v4-flash / gemini-1.5-pro)被按 128K 裁(能力降级),
//! 小窗口模型(如 deepseek-chat 64K)预算高于实际窗口(有过发风险)。本模块在会话构造点
//! 按 `resolved_model` 的实际窗口重设预算,收口到单一入口。
//!
//! 窗口来源优先级(高 → 低),沿用仓库既有探测链风格:
//! 1. provider 池 `model_configs` 中该模型的显式配置 —— 但仅当 `probe_source` 为
//! 预设表/用户手填(窗口可信)时采信。`context_window` 未配置时是默认哨兵 8192,
//! `probe_source` 为 None/Heuristic,不能当真实窗口(否则未配置模型被误降到 8K)。
//! 2. 内置预设表 `model_probe::probe` 精确/前缀匹配命中(PresetTable)。
//! 3. 均未命中 → 保守回退 128K(等价旧行为,零回归)。
//!
//! 取舍:
//! - 仅按窗口**下调/持平**预算,绝不放大到超出来源置信范围;未知窗口回退 128K 不降级。
//! - 上限钳制到 [`MAX_CONTEXT_TOKENS`](1M)而非需求示例的 128K:128K 会让「大窗口被当
//! 128K 裁」的根因不治;`max_tokens` 只是裁剪预算(历史不足则不裁,不会撑大实际发送),
//! 放宽上限只减少无谓裁剪——1M 作保守上界挡住预设表脏值即可。
use df_ai::context::ContextConfig;
use df_ai::df_ai_core::model::{ModelConfig, ProbeSource};
use df_ai::model_probe;
/// 预算上限安全顶:挡住脏预设值,不挡真实大窗口(deepseek-v4-flash 恰为 1M)。
const MAX_CONTEXT_TOKENS: u32 = 1_048_576;
/// 未知窗口的保守回退(等价旧行为 128K,零回归)。
const FALLBACK_CONTEXT_TOKENS: u32 = 128_000;
/// 按模型窗口构造上下文预算配置(主会话/子代理统一口径)。
pub fn context_config_for_model(model_id: &str, model_configs: &[ModelConfig]) -> ContextConfig {
let max_tokens = match window_for_model(model_id, model_configs) {
Some(w) => (w as u32).min(MAX_CONTEXT_TOKENS),
None => {
// 未识别模型:保守回退 128K(todo 要求列出未识别模型,故留 debug 日志)
tracing::debug!(model = %model_id, "[ai] 未见模型上下文窗口配置,预算回退 128K");
FALLBACK_CONTEXT_TOKENS
}
};
// 其余维度(输出预留/安全系数)沿用 ContextConfig 默认,与应用既有裁剪口径一致。
ContextConfig { max_tokens, ..ContextConfig::default() }
}
/// 解析模型上下文窗口(tokens)。未命中可信来源返 `None`。
fn window_for_model(model_id: &str, model_configs: &[ModelConfig]) -> Option<usize> {
// ① provider 池显式配置:仅预设表/用户手填来源可信(其余为默认哨兵 8192,非真实窗口)
if let Some(cfg) = model_configs.iter().find(|m| m.model_id == model_id) {
if matches!(
cfg.probe_source,
Some(ProbeSource::PresetTable) | Some(ProbeSource::UserSet)
) {
return Some(cfg.context_window);
}
}
// ② 预设表命中(精确 + 前缀匹配,与路由/能力探测同款来源)
let probed = model_probe::probe(model_id);
if probed.probe_source == Some(ProbeSource::PresetTable) {
return Some(probed.context_window);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(model_id: &str, window: usize, source: Option<ProbeSource>) -> ModelConfig {
ModelConfig { context_window: window, probe_source: source, ..ModelConfig::with_defaults(model_id) }
}
#[test]
fn preset_model_uses_preset_window() {
// glm-4 预设 128K → max_tokens=128K(持平旧行为)
let c = context_config_for_model("glm-4", &[]);
assert_eq!(c.max_tokens, 128_000);
}
#[test]
fn small_window_model_reduced() {
// deepseek-chat 预设 64K < 128K → 预算下调,不再按 128K 过度发送
let c = context_config_for_model("deepseek-chat", &[]);
assert_eq!(c.max_tokens, 64_000);
}
#[test]
fn large_window_model_not_capped_at_128k() {
// deepseek-v4-flash 预设 1M → 预算随窗口放大(★③ 根因:不再被当 128K 裁)
let c = context_config_for_model("deepseek-v4-flash", &[]);
assert_eq!(c.max_tokens, 1_048_576);
}
#[test]
fn unknown_model_falls_back_128k() {
// 未知模型无预设/配置 → 保守回退 128K(零回归)
let c = context_config_for_model("acme-unknown-model", &[]);
assert_eq!(c.max_tokens, 128_000);
}
#[test]
fn configured_pool_window_takes_priority() {
// provider 池用户手填窗口(32K)优先于预设,并被采信
let pool = vec![cfg("glm-4", 32_000, Some(ProbeSource::UserSet))];
let c = context_config_for_model("glm-4", &pool);
assert_eq!(c.max_tokens, 32_000);
}
#[test]
fn pool_default_sentinel_not_trusted() {
// 池内未配置(哨兵 8192 + 非可信来源)→ 不采信,回落到预设(glm-4=128K)
let pool = vec![cfg("glm-4", 8192, None)];
let c = context_config_for_model("glm-4", &pool);
assert_eq!(c.max_tokens, 128_000);
}
#[test]
fn prefix_match_inherits_window() {
// 前缀匹配(glm-4v-flash → glm-4v 预设)继承窗口
let c = context_config_for_model("glm-4v-flash", &[]);
assert_eq!(c.max_tokens, 128_000);
}
}
+371
View File
@@ -0,0 +1,371 @@
//! 待发箱调度器(定时消息与待发箱持久化 P0 地基)。
//!
//! 抄 `unattended.rs` 形态:tokio 常驻循环 + KV 开关热读 + 到点派发走既有正典
//! `spawn_agentic_session`。设计见
//! docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.3。
//!
//! 组成:
//! - 常驻循环:周期兜底 + `Notify` 即时唤醒(会话空闲/变更后立即续发,不强实时)
//! - 到点取件:list_due(status='pending' 且 due_at 到点)→ 会话空闲 → claim CAS → 派发
//! - 单会话单轮一条:同会话同轮只派一条(串行保序),会话忙则本轮跳过(不丢,下轮再取)
//! - 开关默认关:未配置 `df-outbox-enabled` 时循环只读配置空转,零行为
//!
//! ⚠️ P0 地基:调度器与派发链已就位,但 **未接线**(lib.rs setup 挂载 + agentic 收敛出口钩子),
//! 故本模块暂整体 allow(dead_code)。接线清单见交付说明,不在此文件内改 lib.rs。
//!
//! 配置面(Settings KV,通用 settings_get/settings_set IPC 可读写):
//! - `df-outbox-enabled` bool("true"/"1" 为开),默认 **false**(地基默认关,可观测空转)
//! - `df-outbox-poll-secs` u64,默认 10(下限钳 5,防误配热循环)
//! - `df-outbox-max-attempts` i64,默认 3(受理瞬时失败累计达上限转 failed)
#![allow(dead_code)]
use std::collections::HashSet;
use std::sync::OnceLock;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use df_ai::provider::ContentPart;
use df_storage::crud::OutboxRepo;
use df_types::augmentation::MentionSpanDto;
use crate::commands::ai::commands::chat::spawn_agentic_session;
use crate::commands::ai::notify::{notify_session_event, NotifyKind};
use crate::state::AppState;
// ============================================================
// 配置
// ============================================================
const KV_ENABLED: &str = "df-outbox-enabled";
const KV_POLL_SECS: &str = "df-outbox-poll-secs";
const KV_MAX_ATTEMPTS: &str = "df-outbox-max-attempts";
const DEFAULT_POLL_SECS: u64 = 10;
const MIN_POLL_SECS: u64 = 5;
const DEFAULT_MAX_ATTEMPTS: i64 = 3;
/// 单轮取件上限(防一轮全量派发突发)。
const DUE_BATCH_LIMIT: i64 = 32;
/// 调度器运行期配置(每轮从 KV 热读,改配置无需重启)。
#[derive(Debug, Clone, PartialEq, Eq)]
struct OutboxConfig {
enabled: bool,
poll_secs: u64,
max_attempts: i64,
}
impl Default for OutboxConfig {
fn default() -> Self {
// 默认关:未配置时调度器空转,零行为(地基默认关,便于先观测后放量)。
OutboxConfig {
enabled: false,
poll_secs: DEFAULT_POLL_SECS,
max_attempts: DEFAULT_MAX_ATTEMPTS,
}
}
}
impl OutboxConfig {
fn from_kv(get: &dyn Fn(&str) -> Option<String>) -> Self {
let mut cfg = OutboxConfig::default();
if let Some(v) = get(KV_ENABLED) {
let v = v.trim().trim_matches('"').to_lowercase();
cfg.enabled = v == "true" || v == "1";
}
if let Some(v) = get(KV_POLL_SECS) {
if let Ok(secs) = v.trim().trim_matches('"').parse::<u64>() {
cfg.poll_secs = secs.max(MIN_POLL_SECS);
}
}
if let Some(v) = get(KV_MAX_ATTEMPTS) {
if let Ok(n) = v.trim().trim_matches('"').parse::<i64>() {
cfg.max_attempts = n.max(1);
}
}
cfg
}
}
async fn read_config(app: &AppHandle) -> OutboxConfig {
let state = app.state::<AppState>();
let kv = [
(KV_ENABLED, state.settings.get(KV_ENABLED).await.ok().flatten()),
(KV_POLL_SECS, state.settings.get(KV_POLL_SECS).await.ok().flatten()),
(KV_MAX_ATTEMPTS, state.settings.get(KV_MAX_ATTEMPTS).await.ok().flatten()),
];
OutboxConfig::from_kv(&|k| {
kv.iter().find(|(key, _)| *key == k).and_then(|(_, v)| v.clone())
})
}
/// 从 `AppState.db` 现构 OutboxRepo(仅 clone 内部 conn 句柄,廉价)。
/// P0 地基不经 AppState 持字段(最小改动);接线期若需 IPC 复用可再落 `state.outbox`。
fn outbox_repo(app: &AppHandle) -> OutboxRepo {
let state = app.state::<AppState>();
OutboxRepo::new(&state.db)
}
// ============================================================
// 唤醒原语
// ============================================================
/// 调度器唤醒:周期 tick 兜底,`Notify` 供会话空闲/变更后即时唤醒(不强实时)。
static NOTIFY: OnceLock<tokio::sync::Notify> = OnceLock::new();
fn notify() -> &'static tokio::sync::Notify {
NOTIFY.get_or_init(tokio::sync::Notify::new)
}
/// 会话转空闲钩子:agentic 收敛出口(emit_ai_completed_once)在 `notify_batch_done` 旁并列调用。
/// 会话一空闲即唤醒调度器续发同会话待发项,替代前端 `ai-drain-queue` 自驱。
///
/// 参数 `_conv_id` 保留为将来「仅唤醒相关会话」的扩展位;当前全量唤醒(按 due 重新扫描,幂等安全)。
pub fn notify_conv_idle(_conv_id: &str) {
notify().notify_one();
}
// ============================================================
// 派发循环
// ============================================================
/// 调度器挂载入口(lib.rs setup 调用一次)。开关关时每轮只读配置空转,零副作用。
pub fn start(app: AppHandle) {
tauri::async_runtime::spawn(async move {
// 启动复位崩溃半途残留的 sending(进程重启时必为陈旧值),使重启后待发不卡。
// 幂等安全动作,先于使能判定执行(即便开关关,复位也无害)。
match outbox_repo(&app).reset_stale_sending().await {
Ok(n) if n > 0 => tracing::info!("[outbox] 启动复位 {n} 条发送中残留 → 待发"),
Ok(_) => {}
Err(e) => tracing::warn!("[outbox] 启动复位 sending 残留失败(非阻断): {e}"),
}
let cfg = read_config(&app).await;
let mut interval = tokio::time::interval(Duration::from_secs(cfg.poll_secs));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut cur_poll_secs = cfg.poll_secs;
loop {
tokio::select! {
_ = interval.tick() => {}
_ = notify().notified() => {}
}
let cfg = read_config(&app).await;
if cfg.poll_secs != cur_poll_secs {
cur_poll_secs = cfg.poll_secs;
interval = tokio::time::interval(Duration::from_secs(cur_poll_secs));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
}
if !cfg.enabled {
continue; // 开关关:零行为(默认关)
}
run_tick(&app, &cfg).await;
}
});
}
/// 单轮:取到点待发项 → 会话空闲 → claim CAS → 派发正典链;受理失败按永久/瞬时分流。
async fn run_tick(app: &AppHandle, cfg: &OutboxConfig) {
let repo = outbox_repo(app);
let now = df_types::now_millis();
let items = match repo.list_due(now, DUE_BATCH_LIMIT).await {
Ok(v) => v,
Err(e) => {
tracing::warn!("[outbox] 取待发项失败: {e}");
return;
}
};
// 同会话同轮只派一条(串行保序):派发后该会话本轮不再派,余项下轮再取。
let mut dispatched: HashSet<String> = HashSet::new();
for item in items {
let conv = item.conversation_id.clone();
if dispatched.contains(&conv) {
continue;
}
let state = app.state::<AppState>();
// 会话忙(生成中)→ 本轮跳过,不 claim、不丢,下轮 due 仍在会再取(顺延到空闲)。
if !state.conv_states.can_accept_request(&conv) {
continue;
}
// CAS 占取:pending → sending,防同条被并发双取。
match repo.claim(&item.id).await {
Ok(true) => {}
Ok(false) => continue, // 他轮已取,跳过
Err(e) => {
tracing::warn!("[outbox] claim {} 失败: {e}", item.id);
continue;
}
}
let parts = parse_parts(item.parts_json.as_deref());
let spans = parse_spans(item.spans_json.as_deref());
let result = spawn_agentic_session(
app,
&state,
item.text.clone(),
None, // language 沿用默认
item.skill.clone(), // /技能名
item.model_override.clone(),
Some(conv.clone()), // 目标会话(定时/队列不误发到活跃会话)
parts,
spans,
false, // pin_active=false:不劫持用户活跃面板
)
.await;
match result {
Ok(_conv_id) => {
// Ok=受理成功即终态:轮次内失败(流断/工具错)由既有 loop 自愈,不回流 outbox。
let _ = repo.mark_sent(&item.id).await;
dispatched.insert(conv.clone());
emit(app, "ai-outbox-sent", &item.id, &conv, None, None);
emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
tracing::info!("[outbox] 派发 {} → 会话 {conv}", item.id);
}
Err(err) => {
if is_permanent_spawn_error(&err) {
let _ = repo.mark_failed(&item.id, &err).await;
emit(app, "ai-outbox-failed", &item.id, &conv, Some(&err), Some(true));
notify_session_event(
app,
NotifyKind::Failed,
&conv,
"定时/待发消息发送失败",
&format!("受理失败(不可重试): {err}"),
);
tracing::error!("[outbox] 受理永久失败 {}: {err}", item.id);
} else {
// 瞬时失败:回退 pending 保留下轮再试(网络超时/会话忙竞态天然重试)。
let attempts = repo.revert_pending(&item.id, Some(&err)).await.unwrap_or(0);
if attempts >= cfg.max_attempts {
let _ = repo.mark_failed(&item.id, &err).await;
emit(app, "ai-outbox-failed", &item.id, &conv, Some(&err), Some(false));
notify_session_event(
app,
NotifyKind::Failed,
&conv,
"定时/待发消息发送失败",
&format!("连续 {attempts} 次受理失败: {err}"),
);
tracing::error!("[outbox] 重试达上限({attempts})转失败 {}: {err}", item.id);
} else {
emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
tracing::warn!(
"[outbox] 受理瞬时失败 {} (第 {attempts} 次),下轮重试: {err}",
item.id
);
}
}
}
}
}
}
/// 判定受理失败是否永久(不重试)。仅覆盖明确不会因重试而改变的签名;
/// 其余一律视为瞬时(保守,永不静默丢——宁可多试不误判丢消息)。
///
/// 判据来源:spawn_agentic_session 的前置失败串(provider 未配 / 模型不支持该内容);
/// 与前端 `classifySendError` 同源,此处收敛为 Rust 侧最小集合。
fn is_permanent_spawn_error(err: &str) -> bool {
const PERMANENT_SIGNATURES: &[&str] = &[
"不支持图片", // 纯图片 + 模型无 vision:同内容重试恒失败
"未配置", // provider 未配置:需先配置,自动重试无意义
"无可用", // 无可用 provider
"not configured",
];
PERMANENT_SIGNATURES.iter().any(|s| err.contains(s))
}
/// parts_json / spans_json 反序列化(空/None → None,纯文本)。
fn parse_parts(json: Option<&str>) -> Option<Vec<ContentPart>> {
let s = json?;
if s.trim().is_empty() {
return None;
}
serde_json::from_str(s).ok()
}
fn parse_spans(json: Option<&str>) -> Option<Vec<MentionSpanDto>> {
let s = json?;
if s.trim().is_empty() {
return None;
}
serde_json::from_str(s).ok()
}
/// 前端事件双路 emit(变更/已发/失败)。payload 与设计 §3.3 一致,前端 `ai-outbox-*` 订阅刷新。
fn emit(
app: &AppHandle,
event: &str,
id: &str,
conv_id: &str,
error: Option<&str>,
permanent: Option<bool>,
) {
let mut payload = serde_json::json!({ "id": id, "conversation_id": conv_id });
if let (Some(e), Some(p)) = (error, permanent) {
payload["error"] = serde_json::json!(e);
payload["permanent"] = serde_json::json!(p);
}
let _ = app.emit(event, payload);
}
// ============================================================
// 单测(纯函数:配置解析/永久失败判据)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
/// 开关默认关:未配置 KV 时 enabled=false(调度器空转零行为),且 poll/max_attempts 取默认。
#[test]
fn config_defaults_disabled_so_switch_off_is_zero_behavior() {
let cfg = OutboxConfig::from_kv(&|_| None);
assert!(!cfg.enabled, "默认关:未配置时调度器空转,零行为");
assert_eq!(cfg.poll_secs, DEFAULT_POLL_SECS);
assert_eq!(cfg.max_attempts, DEFAULT_MAX_ATTEMPTS);
}
/// KV 解析:开关 + 周期下限钳 + 上限;非 true/1 视为关。
#[test]
fn config_parses_kv_with_clamps() {
let cfg = OutboxConfig::from_kv(&|k| match k {
"df-outbox-enabled" => Some("\"true\"".to_string()),
"df-outbox-poll-secs" => Some("1".to_string()),
"df-outbox-max-attempts" => Some("5".to_string()),
_ => None,
});
assert!(cfg.enabled);
assert_eq!(cfg.poll_secs, MIN_POLL_SECS, "低于下限钳到 5s 防热循环");
assert_eq!(cfg.max_attempts, 5);
// 显式 false → 关
let cfg = OutboxConfig::from_kv(&|k| {
(k == "df-outbox-enabled").then(|| "false".to_string())
});
assert!(!cfg.enabled);
}
/// 永久失败判据只命中明确签名;会话忙等瞬时错一律不判永久(保守不误丢)。
#[test]
fn permanent_spawn_error_classification() {
assert!(is_permanent_spawn_error("当前模型不支持图片,无法发送纯图片消息"));
assert!(is_permanent_spawn_error("未配置 AI 提供商"));
assert!(!is_permanent_spawn_error("AI 正在生成中,请等待完成"));
assert!(!is_permanent_spawn_error("network timeout"));
}
/// parts/spans 空串 → None;非法 JSON → None 降级纯文本(不阻断派发)。
#[test]
fn parse_parts_and_spans_degrade_safely() {
assert!(parse_parts(None).is_none());
assert!(parse_parts(Some("")).is_none());
assert!(parse_parts(Some("not-json")).is_none());
assert!(parse_spans(None).is_none());
assert!(parse_spans(Some("[\"broken\"")).is_none());
}
/// 开关关时零行为的语义锚点:STATUS_PENDING 常量经 crud 单一来源(防裸串漂移)。
#[test]
fn status_constant_single_source() {
assert_eq!(df_storage::crud::STATUS_PENDING, "pending");
}
}
+27 -10
View File
@@ -158,6 +158,10 @@ pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) {
"model": { "model": {
"type": "string", "type": "string",
"description": "可选,子代理用模型 id;缺省继承当前会话模型" "description": "可选,子代理用模型 id;缺省继承当前会话模型"
},
"temperature": {
"type": "number",
"description": "可选,子代理采样温度(0~2);缺省沿用 provider 配置/默认 0.7,不影响主会话"
} }
}, },
"required": ["task"] "required": ["task"]
@@ -166,7 +170,7 @@ pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) {
registry, registry,
ctx: SpawnAgentCtx, ctx: SpawnAgentCtx,
"spawn_agent", "spawn_agent",
"派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 12);model 可选模型(缺省继承当前会话)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回", "派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 12);model 可选模型(缺省继承当前会话);temperature 可选采样温度(缺省沿用 provider 配置)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回",
RiskLevel::Medium, RiskLevel::Medium,
meta: ToolMeta { meta: ToolMeta {
// 审批卡文案模板(模板 {} 按位置填充,键取 task) // 审批卡文案模板(模板 {} 按位置填充,键取 task)
@@ -210,6 +214,8 @@ async fn spawn_agent_handler(
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()); .filter(|s| !s.is_empty());
// 采样温度独立覆盖(缺省 None=沿用 provider 配置/默认 0.7,不影响主会话)
let temperature = args.get("temperature").and_then(|v| v.as_f64()).map(|t| t as f32);
let requested: Vec<String> = args let requested: Vec<String> = args
.get("tools") .get("tools")
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
@@ -241,21 +247,27 @@ async fn spawn_agent_handler(
return Err(anyhow::anyhow!("spawn_agent 无法定位父会话:只能在会话内调用")); return Err(anyhow::anyhow!("spawn_agent 无法定位父会话:只能在会话内调用"));
} }
// model 不传 → 继承父会话模型 override(与父对话同模型语义) // model 不传 → 继承父会话模型 override(与父对话同模型语义);
let child_model = match model { // lang 恒沿用父会话语言(agent_language,缺省中文),子代理主体与工具纪律段随之同语,
// 否则英文会话派生的子代理仍用中文提示词(报告语言/术语断层)。一次锁内取两者。
let (child_model, parent_lang) = {
let session = ctx.session.lock().await;
let parent = session.conv_read(&parent_conv_id);
let model = match model {
Some(m) => Some(m), Some(m) => Some(m),
None => ctx None => parent.and_then(|c| c.model_override.clone()),
.session };
.lock() let lang = parent
.await .and_then(|c| c.agent_language.clone())
.conv_read(&parent_conv_id) .filter(|s| !s.is_empty())
.and_then(|c| c.model_override.clone()), .unwrap_or_else(|| "zh-CN".to_string());
(model, lang)
}; };
let child_id = next_child_id(&parent_conv_id); let child_id = next_child_id(&parent_conv_id);
let task_prompt = build_task_prompt(&task, &allowed, &trimmed); let task_prompt = build_task_prompt(&task, &allowed, &trimmed);
// P2-3:系统提示词注入实际生效的预算值(父可传 1..20,子代理按真实预算规划) // P2-3:系统提示词注入实际生效的预算值(父可传 1..20,子代理按真实预算规划)
let system_prompt = sub_agent_system_prompt(max_rounds); let system_prompt = sub_agent_system_prompt(max_rounds, &parent_lang);
// P3-5:spawn 时刻快照父会话 save 元数据(active provider/created_at)。子代理后台 // P3-5:spawn 时刻快照父会话 save 元数据(active provider/created_at)。子代理后台
// 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据; // 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据;
@@ -286,6 +298,8 @@ async fn spawn_agent_handler(
tools = allowed.len(), tools = allowed.len(),
max_rounds, max_rounds,
model = ?child_model, model = ?child_model,
lang = %parent_lang,
temperature = ?temperature,
"[sub-agent] 派发子代理" "[sub-agent] 派发子代理"
); );
@@ -303,6 +317,7 @@ async fn spawn_agent_handler(
allowed, allowed,
max_rounds, max_rounds,
child_model, child_model,
temperature,
ctx.llm_concurrency.clone(), ctx.llm_concurrency.clone(),
parent_save_meta, parent_save_meta,
) )
@@ -316,6 +331,8 @@ async fn spawn_agent_handler(
"agent_id": child_id, "agent_id": child_id,
"rounds": outcome.rounds, "rounds": outcome.rounds,
"tokens": outcome.tokens, "tokens": outcome.tokens,
// 实际生效模型(回退/候选切换后的最终值):父会话据此知晓子代理真用了哪个模型
"model": outcome.model,
"report": outcome.summary, "report": outcome.summary,
})) }))
} }
File diff suppressed because it is too large Load Diff
+367
View File
@@ -0,0 +1,367 @@
//! 异步唤醒原语(W-b 核心,设计文档《并行化与异步唤醒-2026-10-04》§二)
//!
//! 治「挂起即等人」:挂起任务登记唤醒条件,条件满足自动唤醒注入续跑。首期进程内存
//! registry(随 app 存亡,不落库——唤醒条件本就依附 app 存续,接力棒场景由进程外脚本
//! 兜底)。条件三型:delay(到点)/ file-appear(路径出现)/ process-exit(进程退出)。
//!
//! ## 不新增机制(设计 §2.2)
//! 唤醒动作复用既有注入通路:向目标会话注入一条 user 角色消息并续跑。既有无人在场
//! 领单走正典 `spawn_agentic_session`(会话生成态守卫 + epoch 语义俱在),本模块沿用
//! 同一路径,不造第二套注入。守卫语义照设计——目标会话正在生成时注入被拒,条目保留
//! 待下轮重试(对齐「轮界排队,不打断流」)。
//!
//! ## 防重(设计 §2.1)
//! once 语义(触发成功即移除)+ expires 过期清理(默认 24h)+ registry 上限 64。
//!
//! ## 判定纯函数化
//! 条件判定([`condition_met`]/[`decide`])为纯函数,IO(时间/文件存在/进程存活)由
//! [`observe`] 采集后喂入,便于单测三型条件与过期/触发优先级。
//!
//! ## 开关
//! 默认关(KV `df-wake-enabled`,关时调度器每轮只读配置空转,零行为变更)。
//!
//! ## 接线
//! 调度器入口 [`start`] 需在 lib.rs setup 挂载一次(与 unattended::start 同处),本批未越界。
//! 挂载前本模块整体不可达,`allow(dead_code)` 即为此待接线态;接线后应移除该属性恢复死码检查。
#![allow(dead_code)]
use std::sync::{LazyLock, RwLock};
use std::time::Duration;
use tauri::{AppHandle, Manager};
use crate::commands::ai::commands::chat::spawn_agentic_session;
use crate::state::AppState;
/// 开关 KV:默认 false。关时调度器空转,且无条目注册 → 全链路零行为变更。
const KV_ENABLED: &str = "df-wake-enabled";
/// 调度器轮询周期(秒)。delay 型精度上界 + file/process 型发现延迟上界。
const POLL_SECS: u64 = 30;
/// registry 条数上限(设计 §2.1「宁少勿预留」)。
const MAX_ENTRIES: usize = 64;
/// 默认条目存活期(24h,设计 §2.1)。到点未触发即过期清理。
const DEFAULT_TTL_MILLIS: i64 = 24 * 60 * 60 * 1000;
/// 唤醒条件(首期三型,设计 §2.1)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WakeCondition {
/// 延时到点(绝对毫秒时间戳,对齐 [`df_types::types::now_millis`] 口径)。
Delay { due_at_millis: i64 },
/// 文件出现(路径存在即满足;接力棒 result.json 场景)。
FileAppear { path: String },
/// 进程退出(pid 不再存活即满足;自换装场景)。
ProcessExit { pid: u32 },
}
/// registry 条目。
#[derive(Debug, Clone)]
pub struct WakeEntry {
pub id: String,
/// 唤醒注入的目标会话。
pub conv_id: String,
pub condition: WakeCondition,
/// 唤醒上下文人话(注入消息正文;条件判据由消息尾缀追加,不混入 payload)。
pub payload: String,
pub created_at_millis: i64,
pub expires_at_millis: i64,
}
/// 单条目条件判定的观测快照。IO 由 [`observe`] 采集,判定纯函数化便于单测。
/// 非对应型条件的字段无意义(判定只读所属型字段),采集侧可填占位值。
#[derive(Debug, Clone, Copy)]
pub struct WakeObservation {
pub now_millis: i64,
/// file-appear 目标当前是否存在。
pub file_exists: bool,
/// process-exit 目标进程当前是否仍存活。
pub process_alive: bool,
}
/// 本轮对单条目的处置:触发 / 过期 / 继续等待。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WakeDecision {
Fire,
Expired,
Wait,
}
/// 条件判定(纯函数,设计三型核心):条件是否已满足。
pub fn condition_met(cond: &WakeCondition, obs: &WakeObservation) -> bool {
match cond {
WakeCondition::Delay { due_at_millis } => obs.now_millis >= *due_at_millis,
WakeCondition::FileAppear { .. } => obs.file_exists,
WakeCondition::ProcessExit { .. } => !obs.process_alive,
}
}
/// 单条目本轮终判(纯函数):过期优先于条件满足——过期即弃,不注入陈旧唤醒。
pub fn decide(cond: &WakeCondition, expires_at_millis: i64, obs: &WakeObservation) -> WakeDecision {
if obs.now_millis >= expires_at_millis {
return WakeDecision::Expired;
}
if condition_met(cond, obs) {
WakeDecision::Fire
} else {
WakeDecision::Wait
}
}
/// 条件人话(注入消息尾缀,让 LLM 知道因何被唤醒)。
fn condition_desc(cond: &WakeCondition) -> String {
match cond {
WakeCondition::Delay { due_at_millis } => format!("延时到点({due_at_millis})"),
WakeCondition::FileAppear { path } => format!("文件出现:{path}"),
WakeCondition::ProcessExit { pid } => format!("进程退出:pid {pid}"),
}
}
/// 唤醒注入消息(纯函数):payload 为唤醒上下文,尾缀标明触发条件。
pub fn wake_message(entry: &WakeEntry) -> String {
format!(
"[异步唤醒] {}\n\n唤醒条件已满足({}),请基于当前会话状态续跑后续工作。",
entry.payload,
condition_desc(&entry.condition)
)
}
/// 进程级 registry(条目少,Vec 有序 + retain 清理足够,不强求 HashMap)。
static REGISTRY: LazyLock<RwLock<Vec<WakeEntry>>> = LazyLock::new(|| RwLock::new(Vec::new()));
/// 登记一条唤醒条件。
/// - `ttl_millis` None → 默认 24h。
/// - 返回 Err = registry 已满(上限 64),登记方据此放弃或改期。
pub fn register(
conv_id: &str,
condition: WakeCondition,
payload: &str,
ttl_millis: Option<i64>,
) -> Result<String, String> {
let now = df_types::types::now_millis();
let mut reg = REGISTRY.write().expect("唤醒 registry 锁");
reg.retain(|e| e.expires_at_millis > now); // 顺手清过期,腾槽位
if reg.len() >= MAX_ENTRIES {
return Err(format!("唤醒 registry 已满(上限 {MAX_ENTRIES})"));
}
let id = df_types::types::new_id();
reg.push(WakeEntry {
id: id.clone(),
conv_id: conv_id.to_string(),
condition,
payload: payload.to_string(),
created_at_millis: now,
expires_at_millis: now + ttl_millis.unwrap_or(DEFAULT_TTL_MILLIS),
});
Ok(id)
}
/// 当前活跃条目快照(只读;顺手清过期)。
pub fn list() -> Vec<WakeEntry> {
let now = df_types::types::now_millis();
let mut reg = REGISTRY.write().expect("唤醒 registry 锁");
reg.retain(|e| e.expires_at_millis > now);
reg.clone()
}
/// 取消一条(按 id)。返回值 = 是否命中(供调用方区分「已触发/已过期」与「取消了在等条目」)。
pub fn cancel(id: &str) -> bool {
let mut reg = REGISTRY.write().expect("唤醒 registry 锁");
let before = reg.len();
reg.retain(|e| e.id != id);
reg.len() != before
}
fn remove_entry(id: &str) {
let mut reg = REGISTRY.write().expect("唤醒 registry 锁");
reg.retain(|e| e.id != id);
}
/// 采集单条目条件观测(IO 层:时间/文件存在/进程存活)。
fn observe(cond: &WakeCondition, now_millis: i64) -> WakeObservation {
WakeObservation {
now_millis,
file_exists: match cond {
WakeCondition::FileAppear { path } => std::path::Path::new(path).exists(),
_ => false,
},
process_alive: match cond {
WakeCondition::ProcessExit { pid } => process_alive(*pid),
_ => true,
},
}
}
/// 进程存活查询。std 无跨平台接口,按平台最小实现:
/// - Unix: /proc/<pid> 存在。
/// - Windows: tasklist 按 PID 过滤(低频轮询 + registry ≤64,开销可接受)。
/// 查询失败保守判「存活」(不触发)——避免不确定时抛出误唤醒注入。
#[cfg(unix)]
fn process_alive(pid: u32) -> bool {
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(windows)]
fn process_alive(pid: u32) -> bool {
let out = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout).contains(&format!("\"{pid}\"")),
Err(_) => true, // 查询失败:保守判存活,不误触发
}
}
#[cfg(not(any(unix, windows)))]
fn process_alive(_pid: u32) -> bool {
true
}
/// 唤醒注入:复用正典注入路径向目标会话投递 user 消息并续跑。
/// 目标会话生成中 → spawn 被 generating 守卫拒绝,返回 false(条目保留,下轮重试)。
async fn fire(app: &AppHandle, entry: &WakeEntry) -> bool {
let state = app.state::<AppState>();
let message = wake_message(entry);
match spawn_agentic_session(
app,
&state,
message,
None, // language 沿用默认
None, // skill 不挂
None, // model_override 沿用路由
Some(entry.conv_id.clone()),
None,
None,
false, // pin_active=false:唤醒不劫持用户活跃面板
)
.await
{
Ok(_) => {
tracing::info!(conv_id = %entry.conv_id, "[wake] 唤醒条件满足,已注入续跑");
true
}
Err(err) => {
// 生成中/暂不可接受:保留条目下轮重试(对齐「轮界排队,不打断流」)
tracing::warn!(conv_id = %entry.conv_id, "[wake] 注入暂不可用,条目保留下轮重试: {}", err);
false
}
}
}
/// 单轮:扫描 registry,过期的清、条件满足的注入。开关关时调用方已短路。
async fn run_tick(app: &AppHandle) {
let now = df_types::types::now_millis();
// 先快照后处理:注入是 async,不得持 registry 锁跨 await。
let entries = list();
for entry in entries {
let obs = observe(&entry.condition, now);
match decide(&entry.condition, entry.expires_at_millis, &obs) {
WakeDecision::Wait => {}
WakeDecision::Expired => {
remove_entry(&entry.id);
tracing::info!(conv_id = %entry.conv_id, "[wake] 条目过期清理");
}
WakeDecision::Fire => {
// once:注入成功即移除;失败保留(生成态/瞬时错误下轮重试)。
if fire(app, &entry).await {
remove_entry(&entry.id);
}
}
}
}
}
/// 调度器挂载入口(仿 `unattended::start`,lib.rs setup 调用一次)。
/// 开关关时每轮只读配置空转,零副作用。
pub fn start(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(POLL_SECS));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
let state = app.state::<AppState>();
let enabled = matches!(
state.settings.get(KV_ENABLED).await.ok().flatten(),
Some(v) if {
let v = v.trim().trim_matches('"').to_lowercase();
v == "true" || v == "1"
}
);
if !enabled {
continue;
}
run_tick(&app).await;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
const CONV: &str = "conv-wake";
/// 单测注册表复用进程级 static,串行化避免相互清理干扰。
static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn obs(now: i64, file: bool, alive: bool) -> WakeObservation {
WakeObservation { now_millis: now, file_exists: file, process_alive: alive }
}
#[test]
fn delay_fires_at_or_after_due() {
let cond = WakeCondition::Delay { due_at_millis: 1000 };
assert_eq!(decide(&cond, 9999, &obs(999, false, true)), WakeDecision::Wait);
assert_eq!(decide(&cond, 9999, &obs(1000, false, true)), WakeDecision::Fire);
assert_eq!(decide(&cond, 9999, &obs(1500, false, true)), WakeDecision::Fire);
}
#[test]
fn file_appear_fires_only_when_exists() {
let cond = WakeCondition::FileAppear { path: "E:/x/result.json".into() };
assert_eq!(decide(&cond, 9999, &obs(0, false, true)), WakeDecision::Wait);
assert_eq!(decide(&cond, 9999, &obs(0, true, true)), WakeDecision::Fire);
}
#[test]
fn process_exit_fires_only_when_not_alive() {
let cond = WakeCondition::ProcessExit { pid: 4242 };
assert_eq!(decide(&cond, 9999, &obs(0, false, true)), WakeDecision::Wait);
assert_eq!(decide(&cond, 9999, &obs(0, false, false)), WakeDecision::Fire);
}
#[test]
fn expired_wins_over_met_condition() {
// 过期优先:即便条件已满足(延时已到),过期即弃不注入陈旧唤醒。
let cond = WakeCondition::Delay { due_at_millis: 100 };
assert_eq!(decide(&cond, 500, &obs(1000, false, true)), WakeDecision::Expired);
}
#[test]
fn wake_message_carries_payload_and_condition() {
let entry = WakeEntry {
id: "w1".into(),
conv_id: CONV.into(),
condition: WakeCondition::FileAppear { path: "E:/x/result.json".into() },
payload: "接力棒已完成,请读取结果并继续".into(),
created_at_millis: 0,
expires_at_millis: 1,
};
let msg = wake_message(&entry);
assert!(msg.contains("接力棒已完成,请读取结果并继续"), "应含 payload: {msg}");
assert!(msg.contains("E:/x/result.json"), "应含触发条件人话: {msg}");
}
#[test]
fn registry_register_cancel_and_cap() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
// 清场:上轮遗留条目
for e in list() {
remove_entry(&e.id);
}
let id = register(CONV, WakeCondition::Delay { due_at_millis: 10 }, "p", None)
.expect("登记应成功");
assert_eq!(list().len(), 1);
assert!(cancel(&id), "取消在等条目应命中");
assert!(!cancel(&id), "重复取消不应再命中");
assert!(list().is_empty(), "取消后 registry 应空");
}
}
+105
View File
@@ -0,0 +1,105 @@
<template>
<!-- D2a 外置结果条(工具卡 + 审计详情共用):标记 + 大小/工具 + 句柄路径 + 展开读回按钮。
纯展示,展开/读回状态由调用方经 useOffloadedResult 持有并透传。 -->
<div class="offload">
<div class="offload-bar">
<span class="offload-badge">{{ $t('aiTool.resultOffloaded') }}</span>
<span v-if="sizeText" class="offload-meta">{{ sizeText }}</span>
<span v-if="handle.tool" class="offload-meta">{{ handle.tool }}</span>
<code class="offload-path" :title="handle.path">{{ handle.path }}</code>
<button type="button" class="offload-toggle" :disabled="loading" @click="emit('toggle')">
{{ loading ? $t('aiTool.running') : (expanded ? $t('aiTool.collapse') : $t('aiTool.expand')) }}
</button>
</div>
<div v-if="error" class="offload-error"><span aria-hidden="true">⚠</span> {{ error }}</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatBytes } from '@/composables/ai/useToolCard'
import type { OffloadHandle } from '@/composables/ai/useOffloadedResult'
const props = defineProps<{
/** 已识别的外置句柄(非空;调用方判定后再渲染本组件) */
handle: OffloadHandle
/** 展开态(由调用方状态机持有) */
expanded: boolean
/** 读回中 */
loading: boolean
/** 读回失败信息(空串=无错误) */
error: string
}>()
const emit = defineEmits<{
/** 展开/收起:调用方负责切换展开态并按需触发 IPC 读回 */
toggle: []
}>()
/** 句柄大小的人类可读文本(缺 size_bytes 时隐藏) */
const sizeText = computed(() =>
typeof props.handle.sizeBytes === 'number' ? formatBytes(props.handle.sizeBytes) : '',
)
</script>
<style scoped>
.offload {
border-top: 0.5px solid var(--df-border);
}
.offload-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--df-warning-bg, rgba(240,199,94,0.08));
}
.offload-badge {
flex-shrink: 0;
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: var(--df-radius-sm);
background: var(--df-warning-bg, rgba(240,199,94,0.14));
color: var(--df-warning, #f0c75e);
}
.offload-meta {
flex-shrink: 0;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
}
.offload-path {
flex: 1;
min-width: 0;
font-family: var(--df-font-mono);
font-size: 10px;
color: var(--df-text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.offload-toggle {
flex-shrink: 0;
font-family: var(--df-font-sans);
font-size: 10px;
color: var(--df-text-dim);
background: rgba(255,255,255,0.04);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
padding: 2px 8px;
cursor: pointer;
transition: color 0.15s var(--df-ease), background 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
}
.offload-toggle:hover:not(:disabled) {
color: var(--df-text-secondary);
background: rgba(255,255,255,0.08);
border-color: var(--df-border-strong);
}
.offload-toggle:disabled { opacity: 0.6; cursor: not-allowed; }
.offload-error {
padding: 4px 10px;
font-size: 11px;
color: var(--df-danger);
background: var(--df-danger-bg);
}
</style>
+128
View File
@@ -0,0 +1,128 @@
//! 输入框列表续写 — 纯逻辑层(无 Vue/无副作用,供 ChatInput 接线 + node --test 单测)。
//!
//! 需求锚点: 输入框列表预览与自动编号设计(docs/02-架构设计/专项设计/输入框列表预览与自动编号-设计-2026-10-10.md)P1=V0。
//! 能力: 识别行首列表标记 → Shift+Enter 续写补下一序号/同型符号 → 空项 Shift+Enter / 末位退格退出列表。
//! 边界: 仅改输入文本与光标位,不碰发送链/渲染链(消息仍是含字面标记的 markdown 原文,marked 本渲染为列表);
//! ` ``` ` 围栏内不触发(避免把代码里的 `- x` 当列表)。
//!
//! 为什么拆成纯函数: textarea 无法在框内渲染真列表(圆点/悬挂缩进),故只做逻辑层,
//! textarea 渲染零改动(零视觉);纯函数也便于脱离浏览器环境单测。
export interface ListContext {
kind: 'ordered' | 'unordered'
/** 行首缩进(≤3 空格,原样保留) */
indent: string
/** 字面标记(含标记后单空格),如 "3. " / "- " */
marker: string
/** ordered 的序号 */
number?: number
/** unordered 的符号字符 */
bullet?: '-' | '*' | '+'
/** 标记后的正文(可空) */
content: string
/** 该行在 text 中的起始偏移 */
lineStart: number
/** 标记结束偏移(标记后第一个正文字符) */
markerEnd: number
}
/** 编辑结果:新文本 + 新光标位(调用方据此重设 selection 并显式平移 mention span) */
export interface ListEditResult {
text: string
caret: number
}
// 有序: ≤3 空格缩进 + 1-9 位数字 + `.`/`)` + 空白(兼容 markdown 两种有序标记)
const ORDERED_RE = /^(\s{0,3})(\d{1,9})([.)])\s(.*)$/
// 无序: ≤3 空格缩进 + `-`/`*`/`+` + 空白
const UNORDERED_RE = /^(\s{0,3})([-*+])\s(.*)$/
// 代码围栏行(``` 或 ~~~,≤3 缩进);与 useMarkdown.wrapNakedDiff 同款判定
const FENCE_RE = /^\s{0,3}(?:```|~~~)/
/** 统计 before 段内代码围栏行数;奇数 → 光标处于未闭合围栏内 */
function insideFence(before: string): boolean {
let count = 0
for (const line of before.split('\n')) {
if (FENCE_RE.test(line)) count++
}
return count % 2 === 1
}
/**
* 判定光标所在行的列表语境;非列表行 / 围栏内 / 光标越界返回 null。
* 只按 `\n` 定位光标所在行,不要求光标在行末(续写可从行中任意位置切开)。
*/
export function analyzeListContext(text: string, caret: number): ListContext | null {
if (caret < 0 || caret > text.length) return null
const lineStart = text.lastIndexOf('\n', caret - 1) + 1
// 围栏守卫只扫当前行之前,当前行是标记行本身
if (insideFence(text.slice(0, lineStart))) return null
const nl = text.indexOf('\n', caret)
const line = text.slice(lineStart, nl === -1 ? text.length : nl)
const om = ORDERED_RE.exec(line)
if (om) {
const marker = `${om[2]}${om[3]} `
return {
kind: 'ordered',
indent: om[1],
marker,
number: parseInt(om[2], 10),
content: om[4],
lineStart,
markerEnd: lineStart + om[1].length + marker.length,
}
}
const um = UNORDERED_RE.exec(line)
if (um) {
const bullet = um[2] as '-' | '*' | '+'
const marker = `${bullet} `
return {
kind: 'unordered',
indent: um[1],
marker,
bullet,
content: um[3],
lineStart,
markerEnd: lineStart + um[1].length + marker.length,
}
}
return null
}
/** 续写标记:ordered → 序号 +1(保留 `.`/`)` 原样式);unordered → 同型符号 */
export function nextMarker(ctx: ListContext): string {
if (ctx.kind === 'unordered') return ctx.marker
return ctx.marker.replace(/^\d+/, String((ctx.number ?? 0) + 1))
}
/**
* Shift+Enter 续写:非空列表项 → 换行并补下一标记;空列表项 → 退出列表(删标记留空行)。
* 返回 null 表示非列表语境(交默认换行)。
*/
export function continueList(text: string, caret: number): ListEditResult | null {
const ctx = analyzeListContext(text, caret)
if (!ctx) return null
if (ctx.content.trim() === '') {
// 空项 → 退出列表:剥掉 [lineStart, markerEnd) 的标记,光标落在删后同相对位
const cut = ctx.markerEnd - ctx.lineStart
const next = text.slice(0, ctx.lineStart) + text.slice(ctx.markerEnd)
return { text: next, caret: Math.max(ctx.lineStart, caret - cut) }
}
const insert = '\n' + ctx.indent + nextMarker(ctx)
return { text: text.slice(0, caret) + insert + text.slice(caret), caret: caret + insert.length }
}
/**
* 末位退格退出空列表项:光标须在行末且当前行为空列表项(仅标记+空白,无正文)。
* 返回 null 表示不适用(交默认退格,正常删字)。
*/
export function exitListOnBackspace(text: string, caret: number): ListEditResult | null {
if (caret <= 0) return null
const nl = text.indexOf('\n', caret)
if (caret !== (nl === -1 ? text.length : nl)) return null
const ctx = analyzeListContext(text, caret)
if (!ctx || ctx.content.trim() !== '') return null
const next = text.slice(0, ctx.lineStart) + text.slice(ctx.markerEnd)
return { text: next, caret: ctx.lineStart }
}
+119
View File
@@ -0,0 +1,119 @@
//! D2a 工具结果外置句柄的前端识别与读回。
//!
//! 后端审计写路径对超 8KB 的工具结果落盘到 {app_data}/tool-results/**,DB 只留句柄 JSON:
//! {"__offloaded__":true,"path":"...","size_bytes":N,"tool":"...","head":"...","hint":"..."}
//! 前端遇此句柄时显示「结果已外置」+ 展开;展开经 IPC ai_read_offloaded_result 按 path 读回原文。
//!
//! 单一真值来源:句柄识别/读回/IPC 参数名在此收口,工具卡与审计详情共用,不各自实现一份。
//! 两个来源形态:工具卡 tc.result 是完整句柄(JSON 合法);审计 result_brief 被后端截断到
//! 160 字符(JSON 非法、尾部省略号),故识别分完整解析与截断正则兜底两路。
import { computed, ref, type ComputedRef } from 'vue'
import { invoke } from '@tauri-apps/api/core'
/** 句柄标记字段(与后端 tool_result_offload::HANDLE_MARK 同字面量,改动须同步) */
export const OFFLOAD_MARK = '__offloaded__'
/** 外置句柄(后端 json! 落库;此处只取前端展示/读回需要的字段) */
export interface OffloadHandle {
path: string
sizeBytes?: number
tool?: string
head?: string
}
/** 从截断句柄文本里宽松提取 path(JSON 已非法,不能 JSON.parse)。 */
function extractPathTolerant(s: string): string | null {
const m = /"path"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(s)
if (!m) return null
// JSON 字符串反转义(Windows 路径含 \\ 转义;路径中不会出现 \n/\t 等序列)
return m[1].replace(/\\(.)/g, '$1')
}
/** 对象形态 → 句柄(标记须为 true 且含非空 path,否则 null)。 */
function handleFromObject(obj: unknown): OffloadHandle | null {
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null
const o = obj as Record<string, unknown>
if (o[OFFLOAD_MARK] !== true) return null
const path = o.path
if (typeof path !== 'string' || !path) return null
return {
path,
sizeBytes: typeof o.size_bytes === 'number' ? o.size_bytes : undefined,
tool: typeof o.tool === 'string' ? o.tool : undefined,
head: typeof o.head === 'string' ? o.head : undefined,
}
}
/** result 是否带外置标记(不要求 path 可解析;供列表单元格/头部摘要轻量判据)。 */
export function hasOffloadMark(result: unknown): boolean {
if (typeof result === 'string') return /"__offloaded__"\s*:\s*true/.test(result)
if (result && typeof result === 'object' && !Array.isArray(result)) {
return (result as Record<string, unknown>)[OFFLOAD_MARK] === true
}
return false
}
/**
* 识别外置句柄:非句柄 / 无 path → null(调用方按普通结果渲染)。
* 完整句柄走 JSON 解析;截断句柄(审计 result_brief)JSON 非法 → 标记 + 正则抽 path 兜底。
*/
export function parseOffloadHandle(result: unknown): OffloadHandle | null {
if (!hasOffloadMark(result)) return null
if (typeof result === 'string') {
try {
return handleFromObject(JSON.parse(result))
} catch {
const path = extractPathTolerant(result)
return path ? { path } : null
}
}
return handleFromObject(result)
}
/** 按 path 读回外置原文(后端 IPC ai_read_offloaded_result;文件缺失返带语义错误串)。 */
export function readOffloadedResult(path: string): Promise<string> {
return invoke<string>('ai_read_offloaded_result', { path })
}
/**
* 外置结果展开状态机:识别句柄 + 懒加载读回原文(同一 path 只读一次,读回后缓存)。
* getResult 惰性取值,响应式来源不在此固化(工具卡按 tc、审计按当前展开行)。
*/
export function useOffloadedResult(getResult: () => unknown) {
const handle: ComputedRef<OffloadHandle | null> = computed(() => parseOffloadHandle(getResult()))
const expanded = ref(false)
const loading = ref(false)
const error = ref('')
const resolved = ref<string | null>(null)
async function toggle() {
if (expanded.value) {
expanded.value = false
return
}
expanded.value = true
const h = handle.value
// 已读回 / 读回中 / 无句柄 → 仅切换展开态,不重复走 IPC
if (!h || resolved.value != null || loading.value) return
loading.value = true
error.value = ''
try {
resolved.value = await readOffloadedResult(h.path)
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
/** 展开对象切换(审计单行详情复用同一实例)时清态,防串上一行的读回结果/错误。 */
function reset() {
expanded.value = false
loading.value = false
error.value = ''
resolved.value = null
}
return { handle, expanded, loading, error, resolved, toggle, reset }
}
+136
View File
@@ -0,0 +1,136 @@
import { test, expect } from 'playwright/test'
// 冷启动首测含 vite 逐模块转换,可能远超默认 60s;放宽到 120s 防偶发超时(其余测试复用热服务很快)。
test.setTimeout(120_000)
//! 「流式正文可见性」场景回放护栏。
//!
//! 背景:这类 bug 反复回归(bcb0b82 修「流式正文被轮次折叠吞掉」,随后又迭「结果/过程分离」),
//! 都出现在「含工具调用的多轮回复」这一场景,且都在实现/单测层测不到 —— 必须按真实事件序列
//! 回放整条链路(事件 → store → renderItems 分桶 → DOM)。故走 playwright 免真机路径
//! (src/mocks/tauri-shim.ts 垫片 + 事件注入),不动任何真实数据。
//!
//! 目标契约(三条):
//! (a) 多轮含工具调用回复完成后,有正文的轮次内容不得丢失 —— 过程轮叙述收进轨迹卡,
//! 展开轨迹卡即可回看全文(不再「流式可见、收尾消失」);
//! (b) 流式期间当前轮正文必须可见(气泡里能读到正在流入的文本);
//! (c) 不得重复渲染同一段文本(轨迹卡展开态与最终气泡不重叠)。
//!
//! 事件注入路径参考 tests/e2e/live-group.spec.ts:垫片把 listen 回调登记进
//! __TAURI_INTERNALS__.callbacks,Playwright 逐个投递 AiChatEvent 驱动 handleEvent。
//! 发送动作走真实 UI(sendMessage → doSend),故 streaming 态、事件监听注册等运行态与线上一致。
//!
//! 会话上下文说明:浏览器垫片下 settings 无持久化,启动不自动选中会话(activeConversationId=null),
//! 此时 AiTextDelta 会被 handleEvent 的「无活跃会话」守卫丢弃。故先注入一条带 conversation_id 的
//! AiConvStateChanged 建立活跃会话并触发历史(conv-mock-1)加载,再发消息。
const CID = 'conv-mock-1'
const R1 = '过程轮叙述·护栏A1' // 过程轮(纯工具轮)的正文叙述
const FINAL = '最终答复·护栏Z9' // 末轮最终答复
/** 经垫片回调注入一条 AiChatEvent(投递给所有已注册的 handler) */
async function inject(page: import('playwright/test').Page, event: Record<string, unknown>) {
await page.evaluate((ev) => {
const internals = (window as unknown as {
__TAURI_INTERNALS__: { callbacks: Map<number, (data: unknown) => void> }
}).__TAURI_INTERNALS__
// payload 结构对齐 Tauri Event<T>: { event, id, payload }
for (const cb of internals.callbacks.values()) {
cb({ event: 'ai-chat-event', id: 0, payload: ev })
}
}, event)
}
/** 打开 AI 聊天页,建立活跃会话并等待其历史加载完成。 */
async function openReady(page: import('playwright/test').Page) {
await page.goto('/#ai-home')
await expect(page.locator('.ai-messages')).toBeVisible({ timeout: 20_000 })
await inject(page, { type: 'AiConvStateChanged', conversation_id: CID, conv_state: 'idle' })
// conv-mock-1 历史第 1 轮含工具调用 → 折叠为 1 张轨迹卡;出现即代表历史已加载
await expect(page.locator('.ai-trace-card').first()).toBeVisible({ timeout: 20_000 })
}
/** 走真实发送路径发一条用户消息(同步 push 用户气泡并置 streaming)。 */
async function sendUser(page: import('playwright/test').Page, text: string) {
const input = page.locator('.ai-input')
await expect(input).toBeVisible()
await input.fill(text)
await input.press('Enter')
await expect(page.locator('.ai-messages')).toContainText(text, { timeout: 5000 })
await inject(page, { type: 'AiConvStateChanged', conversation_id: CID, conv_state: 'generating' })
}
/** 整轮收尾(AiCompleted 回填最终文本 + 复位 idle)。 */
async function completeReply(page: import('playwright/test').Page) {
await inject(page, {
type: 'AiCompleted', conversation_id: CID,
total_tokens: 10, prompt_tokens: 6, completion_tokens: 4,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 6, reasoning_tokens: 0,
})
await inject(page, { type: 'AiConvStateChanged', conversation_id: CID, conv_state: 'idle' })
}
/** 统计某段文本在页面可见文本里出现的次数(重复渲染判据)。 */
async function countVisible(page: import('playwright/test').Page, text: string): Promise<number> {
return page.evaluate((t) => document.body.innerText.split(t).length - 1, text)
}
test('契约a+c:多轮含工具调用完成后,过程轮正文不丢(轨迹卡展开可见)且无重复', async ({ page }) => {
await openReady(page)
await sendUser(page, '请汇总进行中的任务(护栏场景)')
// 轮 1:正文叙述 + 一次工具调用
await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 1 })
await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${R1}:先调取任务数据核对口径。` })
await inject(page, { type: 'AiToolCallStarted', conversation_id: CID, id: 'tc-guard-1', name: 'list_tasks', args: { status: 'in_progress' } })
await inject(page, { type: 'AiToolCallCompleted', conversation_id: CID, id: 'tc-guard-1', result: '[{"id":"t1","status":"in_progress"}]' })
// 轮 2:最终答复
await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 2 })
await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${FINAL}:进行中 3 项,均属 DevFlow 项目。` })
await completeReply(page)
// 契约 a(前置):最终答复落在气泡里(结果/过程分离:气泡只放最终结果)
await expect(page.locator('.ai-msg-bubble--answer', { hasText: FINAL })).toHaveCount(1, { timeout: 5000 })
// 契约 a(核心):过程轮叙述不得丢失 —— 收进轨迹卡,展开即可回看全文 + 工具卡
const traces = page.locator('.ai-trace-card')
const n = await traces.count()
expect(n, `轨迹卡数应 ≥2(历史 1 + 本轮 1),实际 ${n}`).toBeGreaterThanOrEqual(2)
const ours = traces.last()
await ours.locator('.ai-trace-head').click()
await expect(ours.locator('.ai-trace-body')).toContainText(R1)
await expect(ours.locator('.ai-trace-body .ai-tool-card')).toHaveCount(1)
// 契约 c:无重复渲染 —— 每段文本在可见文本里各出现一次
expect(await countVisible(page, R1), `${R1} 重复渲染`).toBe(1)
expect(await countVisible(page, FINAL), `${FINAL} 重复渲染`).toBe(1)
})
// 契约 b(流式期间当前轮正文可见)。
//
// 历史回归:模板 <template v-for="item in renderItems" v-memo="[...]"> 的依赖数组曾漏流式文本源
// (decoratedStreamingBlocks / streamingBlocks),而流式 delta 只改 currentText、不改任何消息 content
// → 桶签名不变 → item 引用稳定 → v-memo 命中缓存跳过 patch → 流式气泡冻结在首个空帧,正文直到某次
// 其它 dep 变化(工具事件/收尾)才一次性出现。修复=对渲染流式块的 item 定向纳入 decoratedStreamingBlocks。
// 断言点刻意放在「仅一次 delta 之后」——此刻除流式文本源外无任何 dep 变化。
test('契约b:流式期间当前轮正文即刻可见', async ({ page }) => {
await openReady(page)
await sendUser(page, '解释一下这个函数(护栏场景)')
await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 1 })
await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${R1}流式正文应当立即出现。` })
await expect(page.locator('.ai-messages')).toContainText(R1, { timeout: 5000 })
})
test('单轮回复(无过程消息)零回归:正文进气泡且不新增轨迹卡', async ({ page }) => {
await openReady(page)
await sendUser(page, '解释一下这个函数(护栏场景二)')
await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 1 })
await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${FINAL}单轮:这是一段没有工具调用的回答。` })
await completeReply(page)
await expect(page.locator('.ai-msg-bubble--answer', { hasText: FINAL })).toHaveCount(1, { timeout: 5000 })
// 无过程消息 → 不凭空生成轨迹卡(仍只有历史那 1 张)
await expect(page.locator('.ai-trace-card')).toHaveCount(1)
expect(await countVisible(page, FINAL), `${FINAL} 重复渲染`).toBe(1)
})
+109
View File
@@ -0,0 +1,109 @@
// useListContinuation 纯逻辑层单测 —— 仓库无 vitest,走 node 内置测试跑(零第三方依赖):
// node --test tests/useListContinuation.test.mjs
// Node ≥22.6 原生剥离类型,可直接 import .ts(mjs 侧显式带 .ts 后缀)。
// 覆盖:识别(有序/无序/缩进/非列表)/ 围栏守卫 / 续写补号 / 空项退出 / 末位退格退出 / 边界。
import test from 'node:test'
import assert from 'node:assert/strict'
import {
analyzeListContext,
nextMarker,
continueList,
exitListOnBackspace,
} from '../src/composables/ai/useListContinuation.ts'
test('analyzeListContext: 有序标记识别(含缩进/字段)', () => {
const ctx = analyzeListContext(' 12. hello', 10)
assert.equal(ctx.kind, 'ordered')
assert.equal(ctx.indent, ' ')
assert.equal(ctx.marker, '12. ')
assert.equal(ctx.number, 12)
assert.equal(ctx.content, 'hello')
assert.equal(ctx.lineStart, 0)
assert.equal(ctx.markerEnd, 6)
})
test('analyzeListContext: 有序 `)` 标记', () => {
const ctx = analyzeListContext('3) x', 4)
assert.equal(ctx.kind, 'ordered')
assert.equal(ctx.marker, '3) ')
assert.equal(ctx.number, 3)
})
test('analyzeListContext: 无序三种符号', () => {
for (const b of ['-', '*', '+']) {
const ctx = analyzeListContext(`${b} item`, 3)
assert.equal(ctx.kind, 'unordered')
assert.equal(ctx.bullet, b)
assert.equal(ctx.content, 'item')
}
})
test('analyzeListContext: 非列表行返回 null', () => {
assert.equal(analyzeListContext('1.no-space', 2), null) // 标记后无空白
assert.equal(analyzeListContext('*bold*', 3), null) // 强调非列表
assert.equal(analyzeListContext('-', 1), null) // 裸符号
assert.equal(analyzeListContext('plain text', 5), null)
const deep = ' 1. deep' // 4 空格缩进 > 3
assert.equal(analyzeListContext(deep, deep.length), null)
})
test('analyzeListContext: 按光标所在行判定,不误判他行', () => {
const text = '1. list\nplain'
assert.equal(analyzeListContext(text, 9), null) // 光标在第二非列表行
assert.equal(analyzeListContext(text, 3)?.kind, 'ordered')
})
test('围栏守卫: ``` 内不触发(未闭合)', () => {
const text = '```\n- x'
assert.equal(analyzeListContext(text, text.length), null)
assert.equal(continueList(text, text.length), null)
})
test('围栏守卫: 闭合围栏外的列表照常触发', () => {
const text = '```\ncode\n```\n- x'
const ctx = analyzeListContext(text, text.length)
assert.equal(ctx?.kind, 'unordered')
})
test('nextMarker: 有序 +1(保留分隔符)/ 无序同型', () => {
assert.equal(nextMarker(analyzeListContext('1. a', 4)), '2. ')
assert.equal(nextMarker(analyzeListContext('9) a', 4)), '10) ')
assert.equal(nextMarker(analyzeListContext('- a', 3)), '- ')
})
test('continueList: 非空有序项 → 换行补下一序号', () => {
assert.deepEqual(continueList('1. foo', 6), { text: '1. foo\n2. ', caret: 10 })
})
test('continueList: 保留缩进', () => {
assert.deepEqual(continueList(' 1. a', 6), { text: ' 1. a\n 2. ', caret: 12 })
})
test('continueList: 非空无序项 → 换行补同型符号', () => {
assert.deepEqual(continueList('- foo', 5), { text: '- foo\n- ', caret: 8 })
})
test('continueList: 空项 → 退出列表(删标记留空行)', () => {
assert.deepEqual(continueList('1. ', 3), { text: '', caret: 0 })
assert.deepEqual(continueList('a\n2. ', 5), { text: 'a\n', caret: 2 })
})
test('continueList: 非列表语境返回 null(交默认换行)', () => {
assert.equal(continueList('plain', 5), null)
})
test('exitListOnBackspace: 末位退格退出空项', () => {
assert.deepEqual(exitListOnBackspace('1. ', 3), { text: '', caret: 0 })
assert.deepEqual(exitListOnBackspace('a\n- ', 4), { text: 'a\n', caret: 2 })
})
test('exitListOnBackspace: 非空项/非行末不触发', () => {
assert.equal(exitListOnBackspace('1. x', 4), null) // 有正文
assert.equal(exitListOnBackspace('1. ', 1), null) // 光标不在行末
assert.equal(exitListOnBackspace('plain', 5), null)
})
test('退出后文本不再被识别为列表(标记已移除)', () => {
const r = continueList('1. ', 3)
assert.equal(analyzeListContext(r.text, r.caret), null)
})
+50
View File
@@ -0,0 +1,50 @@
// D2a 外置句柄解析单测 —— 仓库无 vitest,走 node 内置测试(零第三方依赖):
// node --test tests/useOffloadedResult.test.mjs
// Node ≥22.6 原生剥离类型,可直接 import .ts(显式带 .ts 后缀)。
// 覆盖:完整句柄(对象/JSON 字符串)、截断句柄(审计 result_brief,JSON 非法)正则兜底、
// 标记真值校验、非句柄/无 path 的否定用例。
import test from 'node:test'
import assert from 'node:assert/strict'
import { parseOffloadHandle, hasOffloadMark } from '../src/composables/ai/useOffloadedResult.ts'
const PATH = 'C:\\Users\\me\\AppData\\tool-results\\2026-10\\abc.txt'
test('parseOffloadHandle: 完整句柄对象取全字段', () => {
const h = parseOffloadHandle({ __offloaded__: true, path: PATH, size_bytes: 9000, tool: 'read_file', head: 'x', hint: '...' })
assert.equal(h.path, PATH)
assert.equal(h.sizeBytes, 9000)
assert.equal(h.tool, 'read_file')
})
test('parseOffloadHandle: 完整句柄 JSON 字符串', () => {
const s = JSON.stringify({ __offloaded__: true, path: PATH, size_bytes: 12345, tool: 'grep' })
const h = parseOffloadHandle(s)
assert.equal(h.path, PATH)
assert.equal(h.sizeBytes, 12345)
})
test('parseOffloadHandle: 截断句柄(JSON 非法)正则兜底抽 path 并反转义', () => {
// 模拟后端 truncate_chars_ellipsis 截断:JSON 不闭合、尾部省略号
const brief = `{"__offloaded__":true,"path":"${PATH.replace(/\\/g, '\\\\')}","size_bytes":9000,"head":"hhh`
const h = parseOffloadHandle(brief)
assert.equal(h.path, PATH)
// 截断态仅能拿到 path,size/tool 缺失不报错
assert.equal(h.sizeBytes, undefined)
})
test('parseOffloadHandle: 标记非 true / 非句柄 / 无 path 一律 null', () => {
assert.equal(parseOffloadHandle({ __offloaded__: false, path: PATH }), null)
assert.equal(parseOffloadHandle({ __offloaded__: true }), null)
assert.equal(parseOffloadHandle('{"a":1}'), null)
assert.equal(parseOffloadHandle('plain text'), null)
assert.equal(parseOffloadHandle(null), null)
assert.equal(parseOffloadHandle([1, 2]), null)
})
test('hasOffloadMark: 字符串严格匹配标记,对象看布尔真值', () => {
assert.equal(hasOffloadMark('x "__offloaded__":true y'), true)
assert.equal(hasOffloadMark('just __offloaded__ word'), false)
assert.equal(hasOffloadMark({ __offloaded__: true }), true)
assert.equal(hasOffloadMark({ __offloaded__: false }), false)
assert.equal(hasOffloadMark(null), false)
})