From b37279f3a28d0578108d0435125b40df4b7bbd34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 10 Oct 2026 08:09:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E5=BE=85=E5=8F=91?= =?UTF-8?q?=E7=AE=B1/=E5=94=A4=E9=86=92=E5=8E=9F=E8=AF=AD/=E9=A2=84?= =?UTF-8?q?=E7=AE=97=E9=9A=8F=E7=AA=97=E5=8F=A3=20+=20=E6=B2=BB=E7=90=86?= =?UTF-8?q?=E9=97=A8=E7=A6=81=E4=B8=8E=E5=9C=BA=E6=99=AF=E6=8A=A4=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 待发箱地基: 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 测试 --- .gitignore | 4 + crates/df-mcp/src/lib.rs | 2 +- crates/df-mcp/src/server.rs | 113 ++- crates/df-relay/src/relay.rs | 40 + .../df-storage/src/crud/conversation_repo.rs | 131 ++- crates/df-storage/src/crud/mod.rs | 3 + crates/df-storage/src/crud/outbox_repo.rs | 528 ++++++++++ crates/df-storage/src/migrations.rs | 276 ++++- crates/df-storage/src/migrations_helpers.rs | 25 + crates/df-storage/src/models.rs | 37 + crates/df-tunnel/src/events.rs | 41 + crates/df-tunnel/src/tunnel.rs | 16 +- package.json | 3 +- scripts/check-i18n.cjs | 115 ++- scripts/check-i18n.test.cjs | 101 ++ scripts/hooks/rules/root-hygiene.sh | 14 + scripts/swap-install.ps1 | 6 +- src-tauri/src/commands/ai/knowledge_inject.rs | 98 +- src-tauri/src/commands/ai/lease.rs | 97 +- src-tauri/src/commands/ai/mcp_watch.rs | 149 +++ src-tauri/src/commands/ai/mod.rs | 29 +- src-tauri/src/commands/ai/model_context.rs | 121 +++ src-tauri/src/commands/ai/outbox.rs | 371 +++++++ .../src/commands/ai/tools/spawn_agent.rs | 39 +- src-tauri/src/commands/ai/unattended.rs | 954 +++++++++++++++++- src-tauri/src/commands/ai/wake.rs | 367 +++++++ src/components/OffloadedResultBar.vue | 105 ++ src/composables/ai/useListContinuation.ts | 128 +++ src/composables/ai/useOffloadedResult.ts | 119 +++ tests/e2e/stream-body-visibility.spec.ts | 136 +++ tests/useListContinuation.test.mjs | 109 ++ tests/useOffloadedResult.test.mjs | 50 + 32 files changed, 4175 insertions(+), 152 deletions(-) create mode 100644 crates/df-storage/src/crud/outbox_repo.rs create mode 100644 scripts/check-i18n.test.cjs create mode 100644 scripts/hooks/rules/root-hygiene.sh create mode 100644 src-tauri/src/commands/ai/mcp_watch.rs create mode 100644 src-tauri/src/commands/ai/model_context.rs create mode 100644 src-tauri/src/commands/ai/outbox.rs create mode 100644 src-tauri/src/commands/ai/wake.rs create mode 100644 src/components/OffloadedResultBar.vue create mode 100644 src/composables/ai/useListContinuation.ts create mode 100644 src/composables/ai/useOffloadedResult.ts create mode 100644 tests/e2e/stream-body-visibility.spec.ts create mode 100644 tests/useListContinuation.test.mjs create mode 100644 tests/useOffloadedResult.test.mjs diff --git a/.gitignore b/.gitignore index 902a715..8c5e8ef 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,7 @@ temp/ # df-im-gateway 运行时数据(冒烟测试残留) crates/df-im-gateway/data/ + +# Playwright 运行产物 +test-results/ +playwright-report/ diff --git a/crates/df-mcp/src/lib.rs b/crates/df-mcp/src/lib.rs index 1b2630c..8c24e5a 100644 --- a/crates/df-mcp/src/lib.rs +++ b/crates/df-mcp/src/lib.rs @@ -17,4 +17,4 @@ pub mod server; pub mod server_http; pub mod tools; -pub use server::{run_server, ServerConfig}; +pub use server::{run_server, ServerConfig, MCP_DATA_CHANGED_KEY}; diff --git a/crates/df-mcp/src/server.rs b/crates/df-mcp/src/server.rs index 5ce6d2c..864e38b 100644 --- a/crates/df-mcp/src/server.rs +++ b/crates/df-mcp/src/server.rs @@ -14,6 +14,7 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; +use df_storage::crud::SettingsRepo; use df_storage::db::Database; use serde_json::{json, Value}; 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_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 运行配置。 /// /// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。 @@ -41,7 +51,8 @@ pub struct ServerConfig { pub idle_timeout: Option, /// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。 /// 桌面内嵌形态经 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>, } @@ -154,8 +165,13 @@ where if let Some(name) = tool_name { // MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、 // create_project 空名等 result.isError=true)仍假触发 df-data-changed。 - if is_success_tool_call(&resp) { - fire_write_hook(config, &name); + if is_success_tool_call(&resp) && is_executed_write(&name, config.read_only) { + // 多进程兜底: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 { } } -/// 触发写操作回调(若有)。仅当工具为写操作(risk != Low)且未被 read-only/High 拒绝时触发, -/// 与 dispatch 的执行判定一致。回调仅作通知(如 GUI 刷新),不承载返回结果。 -fn fire_write_hook(config: &ServerConfig, name: &str) { - let Some(cb) = &config.on_write_call else { return }; - let Some(spec) = tools::find(name) else { return }; - if spec.risk != RiskLevel::Low && should_execute(config.read_only, spec.risk) { - cb(name); +/// 判定工具是否属于「会执行的成功写操作」:risk != Low 且未被 read-only/High 拒绝。 +/// +/// 变更标记与进程内写回调共用此判定,保证两通道对「哪些写操作需通知 GUI」语义一致; +/// 与 dispatch 的执行判定(`should_execute`)同源,避免逻辑漂移。 +fn is_executed_write(name: &str, read_only: bool) -> bool { + match tools::find(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() ); } + + // ── 多进程兜底: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:?}"); + } } diff --git a/crates/df-relay/src/relay.rs b/crates/df-relay/src/relay.rs index dcdef98..7910532 100644 --- a/crates/df-relay/src/relay.rs +++ b/crates/df-relay/src/relay.rs @@ -75,6 +75,17 @@ fn validate_device_id(id: &str) -> bool { !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)] pub struct Hello { @@ -84,6 +95,9 @@ pub struct Hello { pub device_id: String, /// 配对 token pub token: String, + /// 协议版本(缺省按 v1,兼容旧客户端) + #[serde(default = "default_proto")] + pub proto: u32, } /// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义) @@ -298,6 +312,16 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien 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 kind: ClientKind = hello.kind.into(); 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, ?kind, device_id = %device_id, + proto = hello.proto, "连接握手通过,进入收发循环" ); @@ -545,4 +570,19 @@ mod tests { fn max_frame_is_one_mib() { 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); + } } diff --git a/crates/df-storage/src/crud/conversation_repo.rs b/crates/df-storage/src/crud/conversation_repo.rs index 399cfde..52c7b83 100644 --- a/crates/df-storage/src/crud/conversation_repo.rs +++ b/crates/df-storage/src/crud/conversation_repo.rs @@ -112,6 +112,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result) -> std::result::Result |row| ai_conversation_from_row(row), insert => |conn, rec| { conn.execute( - "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", + "INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", params![ rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, if rec.pinned { 1i32 } else { 0i32 }, rec.prompt_tokens, rec.completion_tokens, rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin, - rec.boundary_json, rec.loop_state + rec.boundary_json, rec.loop_state, rec.status ], ) }, update => |conn, rec| { - // loop_state 有意不在 SET 列(V49 生命周期标记独立更新,对齐 boundary 早期模式): + // loop_state/status 有意不在 SET 列(V49/V50 生命周期标记独立更新,对齐 boundary 早期模式): // update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回, - // 读改写窗口内 loop 生命周期可变更(spawn 置 running/收敛清 None)——若 SET 含 - // loop_state,旧值会覆盖新值(lost-update)。生命周期只经 set_loop_state 单点写。 + // 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。 + // 生命周期只经 set_loop_state / set_status 单点写。 conn.execute( "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14", params![ @@ -249,22 +253,22 @@ impl_repo!( from_row => |row| ai_tool_execution_from_row(row), insert => |conn, rec| { conn.execute( - "INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + "INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by, duration_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, rec.arguments, rec.result, rec.status, rec.risk_level, - rec.requested_at, rec.executed_at, rec.decided_by + rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms ], ) }, update => |conn, rec| { conn.execute( - "UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11 WHERE id = ?12", + "UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11, duration_ms = ?12 WHERE id = ?13", params![ rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, rec.arguments, rec.result, rec.status, rec.risk_level, - rec.requested_at, rec.executed_at, rec.decided_by, rec.id + rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms, rec.id ], ) } @@ -298,15 +302,15 @@ impl AiToolExecutionRepo { let tx = guard.transaction().map_err(storage_err)?; { let mut stmt = tx.prepare( - "INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + "INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by, duration_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", ) .map_err(storage_err)?; for rec in &records { stmt.execute(params![ rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name, rec.arguments, rec.result, rec.status, rec.risk_level, - rec.requested_at, rec.executed_at, rec.decided_by + rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms ]) .map_err(storage_err)?; } @@ -839,6 +843,29 @@ impl AiConversationRepo { .map_err(storage_err)? } + /// 会话终局态写入(V50 status)。 + /// + /// status: "active"(默认/进行中/正常完成)、"incomplete"(停靠/断流等未完成终局)、 + /// "error"(循环错误终局)。纯标记不进 update_full(防 save_conversation 高频全量写覆盖), + /// 对齐 set_loop_state 模式;不动 updated_at。对话未落库(懒创建)时影响 0 行,调用方忽略。 + pub async fn set_status(&self, id: &str, status: &str) -> Result { + let conn = self.conn.clone(); + let id = id.to_owned(); + let status = status.to_owned(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "UPDATE ai_conversations SET status = ?1 WHERE id = ?2", + params![status, id], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + /// 重启扫描:把所有 loop_state='running' 残留改写为 'parked'。 /// /// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死 @@ -1020,6 +1047,7 @@ mod tests { requested_at: "0".into(), executed_at: None, decided_by: None, + duration_ms: None, } } @@ -1068,6 +1096,43 @@ mod tests { assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录"); } + /// D2a 结果外置的读写兼容契约:result 列由外置层(audit 侧)决定写内联还是写句柄, + /// 存储层既不解释也不改写——老行(外置落地前写入)是内联全文,读回必须原样;新行的 + /// 外置句柄 JSON 同样原样存取。锁死「旧行仍读表内全文」:存储层若对 result 做截断/ + /// 外置/解析等 mutation,会同时破坏两侧兼容(旧行读残缺 + 句柄被误当正文)。 + #[tokio::test] + async fn tool_execution_result_stored_verbatim_old_and_new() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiToolExecutionRepo::new(&db); + + // 旧数据:内联全文。超大值也要能原样读回(D2a 落地前的历史大结果就长这样,存储层不得追改) + let legacy_big = "A".repeat(10 * 1024); + let mut old_big = audit_rec("old_big", "conv1", "call_old_big", "read_file", "{}", "completed"); + old_big.result = Some(legacy_big.clone()); + repo.insert(old_big).await.expect("insert old big"); + let mut old_small = audit_rec("old_small", "conv2", "call_old_small", "write_file", "{}", "completed"); + old_small.result = Some("{\"ok\":true}".to_string()); + repo.insert(old_small).await.expect("insert old small"); + + // 新数据:外置句柄 JSON(存储层不解析,当普通 TEXT 存取) + let handle = r#"{"__offloaded__":true,"path":"/tmp/x.txt","size_bytes":20480}"#; + let mut new_off = audit_rec("new_off", "conv3", "call_new", "run_command", "{}", "completed"); + new_off.result = Some(handle.to_string()); + repo.insert(new_off).await.expect("insert new offloaded"); + + // 读回:三条各自原样(旧行全文;句柄不被误解析/清空) + let got_old_big = repo.get_by_id("old_big").await.expect("get").expect("row"); + assert_eq!(got_old_big.result.as_deref(), Some(legacy_big.as_str()), "老行超大内联 result 须原样读回"); + let got_old_small = repo.get_by_id("old_small").await.expect("get").expect("row"); + assert_eq!(got_old_small.result.as_deref(), Some("{\"ok\":true}")); + let got_new = repo.get_by_id("new_off").await.expect("get").expect("row"); + assert_eq!(got_new.result.as_deref(), Some(handle), "外置句柄 JSON 须原样存取"); + + // 专用查询路径(按 tool_call_id 定位)同样原样,与通用 get_by_id 口径一致 + let by_tc = repo.find_by_tool_call_id("call_old_big").await.expect("query").expect("row"); + assert_eq!(by_tc.result.as_deref(), Some(legacy_big.as_str())); + } + /// insert_checkpoint 冲突语义:同 id 二次写入 Ok 不报错(幂等跳过,affected=0 走 warn), /// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。 #[tokio::test] @@ -1120,6 +1185,7 @@ mod tests { origin: None, boundary_json: None, loop_state: None, + status: "active".into(), }) .await .expect("insert"); @@ -1133,4 +1199,41 @@ mod tests { let cleared = repo.get_by_id("c1").await.expect("get").expect("row"); assert_eq!(cleared.loop_state, None, "清 loop_state 应可读回 None"); } + + /// V50 契约:set_status 写终局态后可读回,且 insert 默认 active。 + /// 锁死「会话终局态」建模能力,防 from_row 映射或 insert 列回归致默认值丢失。 + #[tokio::test] + async fn set_status_roundtrip_and_default_active() { + let db = Database::open_in_memory().await.expect("open_in_memory"); + let repo = AiConversationRepo::new(&db); + repo.insert(AiConversationRecord { + id: "c1".into(), + title: None, + messages: "[]".into(), + provider_id: None, + model: None, + models: None, + archived: false, + pinned: false, + prompt_tokens: Some(0), + completion_tokens: Some(0), + pinned_goals: None, + pending_approvals: None, + created_at: "0".into(), + updated_at: "0".into(), + origin: None, + boundary_json: None, + loop_state: None, + status: "active".into(), + }) + .await + .expect("insert"); + + let got = repo.get_by_id("c1").await.expect("get").expect("row"); + assert_eq!(got.status, "active", "默认终局态应为 active"); + + assert!(repo.set_status("c1", "incomplete").await.expect("set")); + let got2 = repo.get_by_id("c1").await.expect("get").expect("row"); + assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回"); + } } diff --git a/crates/df-storage/src/crud/mod.rs b/crates/df-storage/src/crud/mod.rs index ae55176..b09e536 100644 --- a/crates/df-storage/src/crud/mod.rs +++ b/crates/df-storage/src/crud/mod.rs @@ -14,6 +14,7 @@ //! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具 //! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22) //! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏) +//! - [`mod@outbox_repo`]:OutboxRepo(待发箱 ai_outbox,V51,含 claim CAS/reset_stale_sending,手写不走宏) //! //! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` / //! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。 @@ -25,6 +26,7 @@ mod conversation_repo; mod idea_eval_repo; mod idea_repo; mod message_repo; +mod outbox_repo; mod project_event_repo; mod project_module_repo; mod project_repo; @@ -38,6 +40,7 @@ pub use conversation_repo::*; pub use idea_eval_repo::*; pub use idea_repo::*; pub use message_repo::*; +pub use outbox_repo::*; pub use project_event_repo::*; pub use project_module_repo::*; pub use project_repo::*; diff --git a/crates/df-storage/src/crud/outbox_repo.rs b/crates/df-storage/src/crud/outbox_repo.rs new file mode 100644 index 0000000..c3c0907 --- /dev/null +++ b/crates/df-storage/src/crud/outbox_repo.rs @@ -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 { + 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>, +} + +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 { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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) -> Result { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 5fd16d7..d964d62 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -1,7 +1,7 @@ //! 数据库迁移 — 建表 SQL 与版本管理 //! 数据库迁移 — 建表 SQL 与版本管理 -use crate::migrations_helpers::column_exists; +use crate::migrations_helpers::{column_exists, table_exists}; use anyhow::Result; use rusqlite::Connection; @@ -59,7 +59,12 @@ pub fn run(conn: &Connection) -> Result<()> { // V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。 // V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座: // running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。 - let steps: [(i32, fn(&Connection) -> Result<()>); 49] = [ + // V50 = 使用数据分析 schema 债补全(三项,详见 migrate_v50 文档): + // ai_conversations.status 会话终局态 / ai_tool_executions.duration_ms 纯执行耗时 / + // conversation_checkpoints.token_total 语义回填。 + // V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体, + // 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。 + let steps: [(i32, fn(&Connection) -> Result<()>); 51] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -113,6 +118,10 @@ pub fn run(conn: &Connection) -> Result<()> { (48, migrate_v48), // V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。 (49, migrate_v49), + // V50 = 使用数据分析 schema 债补全(status/duration_ms/token_total 三项)。 + (50, migrate_v50), + // V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。 + (51, migrate_v51), ]; for (version, migrate_fn) in steps { @@ -1450,6 +1459,172 @@ fn migrate_v49(conn: &Connection) -> Result<()> { Ok(()) } +/// V50: 使用数据分析 schema 债补全(2026-10-04 实证三笔,归「V49 schema 迁移批」)。 +/// +/// 一次迁移承载三项相关改动,均为纯粹加列/回填,不改既有列语义: +/// +/// ① ai_conversations.status TEXT NOT NULL DEFAULT 'active' —— 会话终局态。 +/// 原仅靠侧栏内消息尾部推断「是否收尾」(17/89 会话停在 tool 结果无 assistant 收尾), +/// 无法直接统计未完成率。新增列建模终局: +/// active=进行中/默认(含正常完成,完成态精确写入待收尾单点接驳),incomplete=停靠/ +/// 断流等未完成终局,error=循环错误终局。与 loop_state(运行期 running/parked)正交: +/// loop_state 管「能否续跑」,status 管「会话如何结束」。 +/// ② ai_tool_executions.duration_ms INTEGER —— 纯执行耗时(毫秒)。 +/// 原审计只有 requested_at→executed_at 区间,该区间在审批路径混入「等待人工批准」 +/// 时长,耗时统计不可信。本列专记工具真正执行的墙钟毫秒(执行前→执行后),与审批 +/// 等待解耦;NULL=老数据/未测路径(向后兼容)。 +/// ③ conversation_checkpoints.token_total 回填 —— 原写入侧误用「消息条数」当 token 总量 +/// (p50=120 vs 真实数万级,差 3 个量级)。写入侧已修为真实 token 累加(见 save 侧); +/// 本迁移对历史行按 snapshot 内逐条 prompt_tokens+completion_tokens 重算回填,使存量 +/// 可比。仅当重算结果 > 0 才写(N 老快照无消息级 token 时保留原值不误清零)。 +/// +/// - **幂等**:①②`column_exists` 守卫,重复执行不报 duplicate column;③重算确定(同输入同输出), +/// 反复执行结果一致,且经 schema_version 版本号闸只跑一次。 +/// - **可逆**:①`ALTER TABLE ai_conversations DROP COLUMN status`; +/// ②`ALTER TABLE ai_tool_executions DROP COLUMN duration_ms`; +/// ③无回退(DROP COLUMN 不会还原被覆盖的 token_total 旧值)——如需回退重建快照表 +/// (老值本就失真无保留价值)。 +fn migrate_v50(conn: &Connection) -> Result<()> { + // ① 会话终局态列(NOT NULL 常量默认,SQLite ALTER 合法)。 + if table_exists(conn, "ai_conversations") && !column_exists(conn, "ai_conversations", "status") { + conn.execute( + "ALTER TABLE ai_conversations ADD COLUMN status TEXT NOT NULL DEFAULT 'active'", + [], + )?; + tracing::info!("v50: 补建 ai_conversations.status 列(会话终局态)"); + } + // ② 纯执行耗时列(NULL=老数据/未测路径)。 + if table_exists(conn, "ai_tool_executions") + && !column_exists(conn, "ai_tool_executions", "duration_ms") + { + conn.execute( + "ALTER TABLE ai_tool_executions ADD COLUMN duration_ms INTEGER", + [], + )?; + tracing::info!("v50: 补建 ai_tool_executions.duration_ms 列(纯执行耗时)"); + } + // ③ 存量 checkpoint token_total 按 snapshot 重算回填。 + backfill_checkpoint_token_total(conn)?; + + conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [50])?; + tracing::info!("迁移 v50 完成: status/duration_ms 加列 + checkpoint token_total 回填"); + Ok(()) +} + +/// V51: 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。 +/// +/// 对标 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.1。 +/// 队列(931)与定时消息(935)本质是同一实体、不同触发条件,统一落本表: +/// 队列项 due_at=NULL(会话空闲即发),定时项 due_at=epoch_ms(到点且空闲即发)。 +/// +/// 选独立表而非 app_settings KV:outbox 需按 status+due_at 索引扫描、按 id 逐条 CAS 更新, +/// KV 整块 JSON 读改写有并发改期/取消互相覆盖的风险且不可索引。 +/// +/// - **幂等**:table_exists 守卫建表(同 V29/V30/V31 模式),索引 CREATE INDEX IF NOT EXISTS +/// 原生幂等;版本号 INSERT OR IGNORE 防崩溃重跑 PK 冲突。全链重跑/断点续跑均安全。 +/// - **可逆**(回退方式,当前不执行):本表纯新增、不触碰旧数据,回退即 +/// `DROP TABLE ai_outbox`(同时关调度器开关 df-outbox-enabled=false),无需数据搬迁。 +/// - **状态落点**:pending|sending|sent|failed|canceled 由调度器按项逐条 CAS 写,不依赖全局 +/// 内存推算(V21 教训:全局 COUNT 幂等窗口中途崩溃致永久残缺)。崩溃残留的 sending 由 +/// 调度器启动时 reset_stale_sending 复位为 pending。 +/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败 +/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。 +pub const LATEST_SCHEMA_VERSION: i32 = 51; + +fn migrate_v51(conn: &Connection) -> Result<()> { + if !table_exists(conn, "ai_outbox") { + conn.execute( + "CREATE TABLE ai_outbox ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'queue', + text TEXT NOT NULL, + skill TEXT, + model_override TEXT, + parts_json TEXT, + spans_json TEXT, + due_at INTEGER, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + sent_at INTEGER + )", + [], + )?; + tracing::info!("v51: 建 ai_outbox 表(待发箱)"); + } + // 索引覆盖两条最高频扫描:调度器 list_due(status+due_at)/ 前端按会话取视图(conversation_id+status)。 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_ai_outbox_due ON ai_outbox(status, due_at)", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_ai_outbox_conv ON ai_outbox(conversation_id, status)", + [], + )?; + conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [51])?; + tracing::info!("迁移 v51 完成: ai_outbox 表 + 两索引(定时消息与待发箱持久化)"); + Ok(()) +} + +/// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的 +/// prompt_tokens+completion_tokens 求真实总量。 +/// +/// 幂等:重算确定,重复执行相同结果;仅当重算值 > 0 且与现值不同才 UPDATE +/// (老快照无消息级 token → 重算 0 → 保留原值,不误清零)。表/列不存在时静默跳过 +/// (极老库未跑到 V37,或手工删表)。 +fn backfill_checkpoint_token_total(conn: &Connection) -> Result<()> { + // 表存在性探测:未跑到 V37 的极老库无此表,PRAGMA 返回空即跳过。 + if !column_exists(conn, "conversation_checkpoints", "token_total") { + return Ok(()); + } + // 收集待回填行到内存再逐条 UPDATE:避免同一连接上「先 prepare 读游标、再同连接 execute 写」 + // 造成的隐式锁/游标失效,分批小表可接受。 + let rows: Vec<(String, String, i64)> = { + let mut stmt = conn.prepare( + "SELECT id, snapshot, token_total FROM conversation_checkpoints", + )?; + let mapped = stmt.query_map([], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?)) + })?; + mapped.filter_map(|r| r.ok()).collect() + }; + let mut updated = 0usize; + for (id, snapshot, old_total) in rows { + let recomputed = sum_snapshot_tokens(&snapshot); + if recomputed > 0 && recomputed != old_total { + conn.execute( + "UPDATE conversation_checkpoints SET token_total = ?1 WHERE id = ?2", + rusqlite::params![recomputed, id], + )?; + updated += 1; + } + } + if updated > 0 { + tracing::info!("v50: checkpoint token_total 回填 {updated} 行(消息条数→真实 token)"); + } + Ok(()) +} + +/// 从消息 JSON 数组求 token 总量:逐条取 prompt_tokens + completion_tokens(UInt64) +/// 求和。字段缺失/类型不符按 0 计(老消息无消息级 token → 贡献 0)。解析失败返回 0。 +/// +/// 用 `serde_json::Value` 裸取(df-storage 不依赖 df-ai-core 的 ChatMessage,同 V21 迁移模式)。 +fn sum_snapshot_tokens(snapshot: &str) -> i64 { + let Ok(msgs) = serde_json::from_str::>(snapshot) else { + return 0; + }; + msgs.iter() + .map(|m| { + let p = m.get("prompt_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + let c = m.get("completion_tokens").and_then(|v| v.as_i64()).unwrap_or(0); + p + c + }) + .sum() +} + /// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 @@ -2478,9 +2653,9 @@ mod tests { ) .expect("查 schema_version 应成功"); assert_eq!( - max_version, 49, - "全量迁移后 schema_version 应为 49(实际 {}),说明某条 migrate_vN 链路断在中间", - max_version + max_version, LATEST_SCHEMA_VERSION as i64, + "全量迁移后 schema_version 应为顶点 {}(实际 {}),说明某条 migrate_vN 链路断在中间", + LATEST_SCHEMA_VERSION, max_version ); // 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑) @@ -2556,7 +2731,7 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(max_v, 49, "首轮应推进到 49"); + assert_eq!(max_v, LATEST_SCHEMA_VERSION as i64, "首轮应推进到 {}", LATEST_SCHEMA_VERSION); // 清空版本表强制全链第二遍(每步 execute 第二次) conn.execute("DELETE FROM schema_version", []).unwrap(); @@ -2568,7 +2743,7 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(max_v2, 49, "重跑后应重新推进到 49"); + assert_eq!(max_v2, LATEST_SCHEMA_VERSION as i64, "重跑后应重新推进到 {}", LATEST_SCHEMA_VERSION); } // ============================================================ @@ -2674,4 +2849,91 @@ mod tests { .unwrap(); assert!(got.is_some(), "boundary_json 应可写可读"); } + + /// V50: 三列/回填一次承载,幂等重跑不炸。 + /// ① ai_conversations.status 补建默认 'active'; + /// ② ai_tool_executions.duration_ms 补建(可空); + /// ③ conversation_checkpoints.token_total 按 snapshot 重算(消息条数→真实 token)。 + #[test] + fn v50_adds_columns_and_backfills_token_total() { + let conn = setup_legacy_db(); + // 前置:最小库无 status/duration_ms 列(V45 之后建,老库缺) + assert!(!column_exists(&conn, "ai_conversations", "status")); + // V50 需要 checkpoint / tool_executions 表(正常由 V1/V9 建;此处手工建以隔离本迁移被测量) + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS conversation_checkpoints (\ + id TEXT PRIMARY KEY, conv_id TEXT NOT NULL, snapshot TEXT NOT NULL,\ + token_total INTEGER NOT NULL, label TEXT, created_at TEXT NOT NULL); + INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\ + VALUES ('ck1','c1',\ + '[{\"role\":\"assistant\",\"prompt_tokens\":150000,\"completion_tokens\":20000}]',\ + 1,'100'); + CREATE TABLE IF NOT EXISTS ai_tool_executions (\ + id TEXT PRIMARY KEY, tool_call_id TEXT NOT NULL, tool_name TEXT NOT NULL,\ + arguments TEXT NOT NULL, requested_at TEXT NOT NULL);", + ) + .expect("建 checkpoint/tool_executions 表 + 塞一条失真行"); + + migrate_v50(&conn).expect("v50 首次迁移应成功"); + assert!(column_exists(&conn, "ai_conversations", "status"), "应有 status 列"); + assert!(column_exists(&conn, "ai_tool_executions", "duration_ms"), "应有 duration_ms 列"); + // ① 默认值 active 生效 + conn.execute( + "INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c1','[]','0','0')", + [], + ) + .unwrap(); + let status: String = conn + .query_row("SELECT status FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(status, "active", "新列默认值应为 active"); + // ③ 失真 token_total(1)被回填为真实和(150000+20000) + let tt: i64 = conn + .query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(tt, 170_000, "应回填为 snapshot 内 token 真实和"); + + let v: i64 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(v, 50, "应写入版本号 50"); + + // 幂等:列已存在时重跑走 column_exists 短路;回填重算确定(值不变) + migrate_v50(&conn).expect("列已存在时重跑应幂等不报错"); + let tt2: i64 = conn + .query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(tt2, 170_000, "重跑回填结果应一致"); + } + + /// V50 回填不误清零:老快照无消息级 token(重算=0)时保留原值(不 UPDATE)。 + #[test] + fn v50_backfill_keeps_legacy_when_no_message_tokens() { + let conn = setup_legacy_db(); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS conversation_checkpoints (\ + id TEXT PRIMARY KEY, conv_id TEXT NOT NULL, snapshot TEXT NOT NULL,\ + token_total INTEGER NOT NULL, label TEXT, created_at TEXT NOT NULL); + INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\ + VALUES ('ck2','c2','[{\"role\":\"user\",\"content\":\"hi\"}]', 42, '100');", + ) + .expect("建表塞老快照"); + migrate_v50(&conn).expect("v50 应成功"); + let tt: i64 = conn + .query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck2'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(tt, 42, "无消息级 token 的老快照应保留原值不误清零"); + } + + /// sum_snapshot_tokens 纯函数:字段缺失/非法 JSON 按 0 计。 + #[test] + fn sum_snapshot_tokens_handles_missing_and_bad_json() { + assert_eq!(sum_snapshot_tokens("not json"), 0, "非法 JSON 应返回 0"); + assert_eq!(sum_snapshot_tokens("[]"), 0, "空数组应为 0"); + assert_eq!( + sum_snapshot_tokens(r#"[{"prompt_tokens":10},{"completion_tokens":5},{"foo":1}]"#), + 15, + "逐条相加,缺字段按 0" + ); + } } diff --git a/crates/df-storage/src/migrations_helpers.rs b/crates/df-storage/src/migrations_helpers.rs index 4c07249..5e39092 100644 --- a/crates/df-storage/src/migrations_helpers.rs +++ b/crates/df-storage/src/migrations_helpers.rs @@ -14,6 +14,19 @@ pub(crate) fn column_exists(conn: &Connection, table: &str, col: &str) -> bool { 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)] mod tests { use super::*; @@ -74,4 +87,16 @@ mod tests { assert!(!column_exists(&conn, "probe_t", "prompt")); 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"); + } } diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index 7e752d1..abc8218 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -396,6 +396,10 @@ pub struct AiConversationRecord { /// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。 #[serde(default)] pub loop_state: Option, + /// 会话终局态(V50):active=进行中/默认(含正常完成),incomplete=停靠/断流等未完成终局, + /// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交—— + /// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。 + pub status: String, } /// AI 工具执行审计记录 @@ -419,6 +423,11 @@ pub struct AiToolExecutionRecord { pub requested_at: String, pub executed_at: Option, pub decided_by: Option, // human/auto + /// 纯执行耗时(毫秒,V50):工具真正执行的墙钟时长(执行前→执行后),与审批等待解耦。 + /// None=老数据 / 未测路径(缓存命中/未真执行)。审计面板耗时统计用本列,替代 + /// requested_at→executed_at(后者在审批路径混入等待人工批准时长)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, } /// 消息记录(ai_messages 表,消息拆分存储)。 @@ -472,6 +481,34 @@ pub struct AiMessageRecord { pub is_estimated: Option, } +/// 待发箱记录(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, + pub model_override: Option, + pub parts_json: Option, + pub spans_json: Option, + pub due_at: Option, + pub status: String, + pub attempts: i64, + pub last_error: Option, + pub created_at: i64, + pub updated_at: i64, + pub sent_at: Option, +} + // ============================================================ // 知识库模型 (V7) // ============================================================ diff --git a/crates/df-tunnel/src/events.rs b/crates/df-tunnel/src/events.rs index 046d6fd..e6d17f5 100644 --- a/crates/df-tunnel/src/events.rs +++ b/crates/df-tunnel/src/events.rs @@ -11,12 +11,25 @@ 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) /// /// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。 /// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`), /// 否则 relay 反序列化失败 → 握手被拒。 /// +/// `proto` 为协议版本,供 relay 端做版本协商(缺字段按 v1,新增破坏性变更时递增)。 +/// /// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。 /// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。 /// @@ -30,6 +43,9 @@ pub struct Hello { pub channel_id: String, /// 配对 token(env U_RELAY_TOKEN 同源) pub token: String, + /// 协议版本(缺省按 v1,兼容旧客户端) + #[serde(default = "default_proto")] + pub proto: u32, } /// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致) @@ -155,3 +171,28 @@ pub enum TunnelMessage { /// 云后端转发的指令(上行,发往桌面端) 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); + } +} diff --git a/crates/df-tunnel/src/tunnel.rs b/crates/df-tunnel/src/tunnel.rs index 1fac154..4a34848 100644 --- a/crates/df-tunnel/src/tunnel.rs +++ b/crates/df-tunnel/src/tunnel.rs @@ -38,7 +38,7 @@ use tokio_tungstenite::tungstenite::protocol::CloseFrame; use tokio_tungstenite::tungstenite::Message as WsMessage; 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) /// @@ -105,9 +105,13 @@ const HANDSHAKE_PROBE: Duration = Duration::from_secs(3); /// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。 const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); -/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收) -/// u-relay 心跳走应用层 control 帧 ping→pong,relay 本地直回不转对端 -const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25); +/// 心跳间隔(WS 协议层 Ping,补 TCP keepalive,防 NAT/反代空闲回收连接)。 +/// +/// 上界约束:链路最短空闲超时在反代侧(生产 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); @@ -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(); @@ -210,6 +215,7 @@ impl TunnelClient for WsTunnelClient { kind: HelloKind::Agent, channel_id: channel_id.to_string(), token: token.to_string(), + proto: HELLO_PROTO_VERSION, }; let hello_json = serde_json::to_string(&hello)?; sink.send(WsMessage::Text(hello_json)).await.map_err(|e| { diff --git a/package.json b/package.json index e56231a..06d306e 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,12 @@ "scripts": { "dev": "vite", "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", "check:i18n": "node scripts/check-i18n.cjs", "preview": "vite preview", "test:e2e": "playwright test", + "test:scenario": "playwright test tests/e2e/stream-body-visibility.spec.ts", "tauri": "tauri", "build:dev": "tauri build --debug --no-bundle" }, diff --git a/scripts/check-i18n.cjs b/scripts/check-i18n.cjs index dc8aaa2..83478d8 100644 --- a/scripts/check-i18n.cjs +++ b/scripts/check-i18n.cjs @@ -8,59 +8,104 @@ // 修法:字面量用 vue-i18n 的插值转义,如 `{'@'}`(渲染仍显示 @)。 // // 依赖 @intlify/message-compiler(vue-i18n 的编译器);解析不到时只告警不拦截(避免门禁误伤)。 +// +// 扫描/编译逻辑抽为可导出函数(scanText / scanDirectory / compileMessage),供 +// scripts/check-i18n.test.cjs 自测;CLI 入口行为保持不变。 const fs = require('fs'); const path = require('path'); -let baseCompile; +let baseCompile = null; try { ({ baseCompile } = require('@intlify/message-compiler')); } catch { - console.warn('[i18n-check] 未找到 @intlify/message-compiler,跳过消息编译预检(需 npm 依赖)'); - process.exit(0); + baseCompile = null; } const DIRS = ['src/i18n/zh-CN', 'src/i18n/en']; -// key: 'value' / key: "value"(带转义),仅取字符串字面量值 -const KV_RE = /(?:^|[\s,{])([A-Za-z0-9_$]+)\s*:\s*(['"])((?:\\.|(?!\2).)*)\2/gm; +// key: 'value' / "value" / `value`(带转义),三种字面量都取—— +// 反引号模板字面量同样可能含未转义 @/|/裸{},原先只匹配引号 → 门禁假阴性(2026-10-10 对抗审查发现)。 +const KV_RE = /(?:^|[\s,{])([A-Za-z0-9_$]+)\s*:\s*([`'"])((?:\\.|(?!\2).)*)\2/gm; -let checked = 0; -let failed = 0; -for (const dir of DIRS) { +// 编译单个消息值,返回错误数组(@intlify 风格:{ message }) +function compileMessage(val) { + const errs = []; + try { + baseCompile(val, { onError: (e) => errs.push(e), onWarn: () => {} }); + } catch (e) { + errs.push(e); + } + 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 { - continue; + return { checked, violations }; } 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 = []; - try { - baseCompile(val, { onError: (e) => errs.push(e), onWarn: () => {} }); - } catch (e) { - errs.push(e); - } - if (errs.length) { - failed++; - console.error(`✗ ${dir}/${f} :: ${key} => ${errs[0].message}`); - console.error(` 值: ${JSON.stringify(val)}`); - console.error(` 提示: 字面 @ 须转义为 {'@'}(同理 | 与裸 {})`); - } - } + 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 }; } -if (failed > 0) { - console.error(`[i18n-check] 失败 ${failed} 处(共编译 ${checked} 个含特殊字符的值)——prod 运行时会抛 Invalid linked format`); - process.exit(1); +// 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++; + console.error(`✗ ${dir}/${v.file} :: ${v.key} => ${v.message}`); + console.error(` 值: ${JSON.stringify(v.value)}`); + 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; } -console.log(`[i18n-check] 通过(编译 ${checked} 个含特殊字符的值,0 失败)`); + +if (require.main === module) { + process.exit(run()); +} + +module.exports = { compileMessage, scanText, scanDirectory, run, KV_RE, DIRS }; diff --git a/scripts/check-i18n.test.cjs b/scripts/check-i18n.test.cjs new file mode 100644 index 0000000..cea1d11 --- /dev/null +++ b/scripts/check-i18n.test.cjs @@ -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: '',` +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 }); + } +}); diff --git a/scripts/hooks/rules/root-hygiene.sh b/scripts/hooks/rules/root-hygiene.sh new file mode 100644 index 0000000..65a70e8 --- /dev/null +++ b/scripts/hooks/rules/root-hygiene.sh @@ -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 diff --git a/scripts/swap-install.ps1 b/scripts/swap-install.ps1 index 78e91e1..fb10217 100644 --- a/scripts/swap-install.ps1 +++ b/scripts/swap-install.ps1 @@ -15,8 +15,8 @@ if (-not $installer) { } $target = 'C:\Users\23780\AppData\Local\devflow\devflow.exe' $builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe' -$log = 'E:\wk-lab\devflow\swap-install.log' -$lockFile = 'E:\wk-lab\devflow\swap-install.lock' +$log = 'E:\wk-lab\devflow\temp\swap-install.log' +$lockFile = 'E:\wk-lab\devflow\temp\swap-install.lock' # SHA256 取值:Get-FileHash 在部分环境的本机 Windows PowerShell 5.1 上不可用(实测 2026-10-10 缺失), # 兜底走系统自带的 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 exit 1 } diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index c02a817..4e041ce 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -902,6 +902,38 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话 4. 输出纯 JSON,无任何额外文字\n\ 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=全局知识)。 /// /// 级 a(高置信):读 `ai_conversations.boundary_json`(V45 无人值守边界)的 `dir_allowlist` @@ -947,31 +979,9 @@ async fn resolve_source_project(db: &Arc, conv_id: &str, messages: &[C } } - // 级 b(B1:mention 唯一才启用):最近消息扫 `[项目:名称]` 标记,唯一命中才归属 - 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(); - // 找全部 `[项目: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]; + // 级 b(B1:mention 唯一才启用):唯一命中才归属,多项目/零 mention 不猜 + // (判定收口至纯函数 unique_mentioned_project,便于单测覆盖命中/未命中/去重) + if let Some(name) = unique_mentioned_project(messages) { if let Ok(projects) = project_repo.list_active().await { if let Some(p) = projects.iter().find(|p| p.name == name) { return Some(p.id.clone()); @@ -1605,6 +1615,7 @@ mod salvage_tests { origin: None, boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()), loop_state: None, + status: "active".into(), }) .await .unwrap(); @@ -1652,4 +1663,41 @@ mod salvage_tests { // 零 mention → 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); + } } diff --git a/src-tauri/src/commands/ai/lease.rs b/src-tauri/src/commands/ai/lease.rs index 21a3604..dd8b7f5 100644 --- a/src-tauri/src/commands/ai/lease.rs +++ b/src-tauri/src/commands/ai/lease.rs @@ -18,7 +18,9 @@ //! 惯例为规范绝对路径,过度归一反而引入 surprise。 //! //! ## 单测 -//! 见文末 tests 模块:互斥/同会话放行/释放/TTL 过期/键归一五组。 +//! 见文末 tests 模块:跨会话互斥/同会话放行/释放/TTL 过期/多会话并存等。冲突检测与 +//! 过期清理抽为 `try_register`/`purge_expired` 纯函数——单测注入独立表与时间点, +//! 既不真等 TTL,也不触碰全局 REGISTRY(免并发测试互相污染)。 use std::collections::HashMap; use std::sync::LazyLock; @@ -111,17 +113,31 @@ pub(crate) fn acquire_leases( return Ok(LeaseGuard { released: true, keys: Vec::new() }); } 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("文件租约表锁"); - // 第一遍:冲突检测(不做部分登记,全过才统一登记) - for p in &paths { + let keys = try_register(&mut reg, conv_id, agent_kind, tool_name, tool_call_id, &paths, Instant::now())?; + Ok(LeaseGuard { released: false, keys }) +} + +/// 冲突检测 + 登记(纯逻辑,不含锁):对 `reg` 做「全过才登记」,便于单测注入独立表 +/// 与时间点,不必真等 TTL,也不污染全局 REGISTRY(并发测试隔离)。 +/// +/// 先扫一遍 `paths`:任一路径被**其他会话**活跃占用即返回 Err(指路文案),不做部分 +/// 登记;顺路把过期条目当泄漏清掉。全过后统一登记,返回登记的键集合。同会话同路径 +/// 放行(见 acquire_leases 文档)。 +fn try_register( + reg: &mut HashMap, + conv_id: &str, + agent_kind: &'static str, + tool_name: &str, + tool_call_id: &str, + paths: &[String], + now: Instant, +) -> Result, String> { + for p in paths { let key = lease_key(p); if let Some(holder) = reg.get(&key) { if holder.conv_id == conv_id { - continue; // 同会话:放行(见函数文档) + continue; // 同会话:放行(见 acquire_leases 文档) } if holder.expired(now) { reg.remove(&key); // TTL 兜底:过期租约视为泄漏,清掉 @@ -130,10 +146,9 @@ pub(crate) fn acquire_leases( return Err(holder.describe()); } } - // 第二遍:全部登记 let mut keys = Vec::with_capacity(paths.len()); for p in paths { - let key = lease_key(&p); + let key = lease_key(p); reg.insert(key.clone(), LeaseHolder { conv_id: conv_id.to_string(), agent_kind, @@ -143,7 +158,7 @@ pub(crate) fn acquire_leases( }); keys.push(key); } - Ok(LeaseGuard { released: false, keys }) + Ok(keys) } /// RAII 租约守卫:drop 自动释放(Ok/Err/panic unwind 全覆盖)。 @@ -184,9 +199,8 @@ impl Drop for LeaseGuard { /// 只读查询:当前全部活跃租约(query_leases 工具数据源,W-h2 兄弟感知)。 pub(crate) fn query_leases() -> Vec<(String, LeaseHolder)> { - let now = Instant::now(); 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 .iter() .map(|(k, h)| (k.clone(), h.clone())) @@ -195,6 +209,13 @@ pub(crate) fn query_leases() -> Vec<(String, LeaseHolder)> { items } +/// 过期清理(纯逻辑):移除所有过期条目,返回移除条数(query 只读面顺手清扫泄漏)。 +fn purge_expired(reg: &mut HashMap, now: Instant) -> usize { + let before = reg.len(); + reg.retain(|_, h| !h.expired(now)); + before - reg.len() +} + /// 注册 query_leases 只读工具(W-h2:LLM 自查「谁在占用什么」,兄弟感知最小入口)。 /// Low 风险零参数;返回人话列表供 LLM 直接消费。 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"})) .expect("unwind drop 后应已释放"); } + + #[test] + fn expired_lease_reclaimed_on_conflict_check() { + // 纯逻辑单测:注入独立表 + 时间点(不碰全局 REGISTRY,免并发测试互相污染)。 + let mut reg: HashMap = 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 = 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 = 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); + } } diff --git a/src-tauri/src/commands/ai/mcp_watch.rs b/src-tauri/src/commands/ai/mcp_watch.rs new file mode 100644 index 0000000..3d74c05 --- /dev/null +++ b/src-tauri/src/commands/ai/mcp_watch.rs @@ -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 { + serde_json::from_str(raw?).ok() +} + +/// 变更标记观察器:维护上次 `at` 基线,判定是否需补发。 +/// +/// 纯逻辑(只持 `last_at`,无 IO),便于单测;IO/事件下发留在 [`start`] 循环。 +struct MarkerWatcher { + last_at: Option, +} + +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 { + 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::(); + 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()) + ); + } +} diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index a7405be..2c83c41 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -46,6 +46,10 @@ pub mod get_app_config; pub mod knowledge_inject; // 文件租约注册表(W-h1 感知租约):写类文件工具跨会话互斥 + query_leases 兄弟感知 pub mod lease; +// C2 GUI 侧接点:轮询 df-mcp 落库变更标记,stdio MCP 独立进程写库后补发 df-data-changed +pub mod mcp_watch; +// 939 ★③:按目标模型上下文窗口构造上下文预算配置(收口会话构造点) +pub mod model_context; // 系统通知(P1 bg 化补完):完成/失败/需介入三类事件,主窗托盘化时补位提醒(条件发送) pub(crate) mod notify; pub(crate) mod feishu_notify; @@ -66,6 +70,10 @@ pub mod tool_result_offload; pub mod tools; // B5 无人值守续作引擎:看板轮询自动领单 + parked 自动恢复(收敛回读钩子在 agentic 出口) 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}; @@ -1223,7 +1231,8 @@ impl PerConvState { /// 新建默认会话级状态。 /// /// 各字段初值**逐字对齐 [`AiSession::new`]** —— 批2 迁移调用方时行为不变。 - /// - messages: ContextManager::new(ContextConfig::default()) + /// - messages: 先 `ContextConfig::default()`(128K),发送入口再经 [`Self::apply_model_window`] + /// 按目标模型实际窗口重设(939 ★③;未重设时等价旧 128K 行为) /// - generating: false /// - stop_flag: Arc::new(AtomicBool::new(false)) /// - notify: Arc::new(tokio::sync::Notify::new()) @@ -1261,6 +1270,19 @@ impl PerConvState { 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 { @@ -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 { 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 失败"); + } }); } // ② 发停靠事件(前端渲染停靠卡+继续按钮)。 diff --git a/src-tauri/src/commands/ai/model_context.rs b/src-tauri/src/commands/ai/model_context.rs new file mode 100644 index 0000000..4cd50e9 --- /dev/null +++ b/src-tauri/src/commands/ai/model_context.rs @@ -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 { + // ① 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) -> 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); + } +} diff --git a/src-tauri/src/commands/ai/outbox.rs b/src-tauri/src/commands/ai/outbox.rs new file mode 100644 index 0000000..508577d --- /dev/null +++ b/src-tauri/src/commands/ai/outbox.rs @@ -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) -> 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::() { + 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::() { + cfg.max_attempts = n.max(1); + } + } + cfg + } +} + +async fn read_config(app: &AppHandle) -> OutboxConfig { + let state = app.state::(); + 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::(); + OutboxRepo::new(&state.db) +} + +// ============================================================ +// 唤醒原语 +// ============================================================ + +/// 调度器唤醒:周期 tick 兜底,`Notify` 供会话空闲/变更后即时唤醒(不强实时)。 +static NOTIFY: OnceLock = 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 = HashSet::new(); + for item in items { + let conv = item.conversation_id.clone(); + if dispatched.contains(&conv) { + continue; + } + let state = app.state::(); + // 会话忙(生成中)→ 本轮跳过,不 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> { + let s = json?; + if s.trim().is_empty() { + return None; + } + serde_json::from_str(s).ok() +} + +fn parse_spans(json: Option<&str>) -> Option> { + 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, +) { + 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"); + } +} diff --git a/src-tauri/src/commands/ai/tools/spawn_agent.rs b/src-tauri/src/commands/ai/tools/spawn_agent.rs index f826b5a..8d81995 100644 --- a/src-tauri/src/commands/ai/tools/spawn_agent.rs +++ b/src-tauri/src/commands/ai/tools/spawn_agent.rs @@ -158,6 +158,10 @@ pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) { "model": { "type": "string", "description": "可选,子代理用模型 id;缺省继承当前会话模型" + }, + "temperature": { + "type": "number", + "description": "可选,子代理采样温度(0~2);缺省沿用 provider 配置/默认 0.7,不影响主会话" } }, "required": ["task"] @@ -166,7 +170,7 @@ pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) { registry, ctx: SpawnAgentCtx, "spawn_agent", - "派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 12);model 可选模型(缺省继承当前会话)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回", + "派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 12);model 可选模型(缺省继承当前会话);temperature 可选采样温度(缺省沿用 provider 配置)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回", RiskLevel::Medium, meta: ToolMeta { // 审批卡文案模板(模板 {} 按位置填充,键取 task) @@ -210,6 +214,8 @@ async fn spawn_agent_handler( .and_then(|v| v.as_str()) .map(|s| s.trim().to_string()) .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 = args .get("tools") .and_then(|v| v.as_array()) @@ -241,21 +247,27 @@ async fn spawn_agent_handler( return Err(anyhow::anyhow!("spawn_agent 无法定位父会话:只能在会话内调用")); } - // model 不传 → 继承父会话模型 override(与父对话同模型语义) - let child_model = match model { - Some(m) => Some(m), - None => ctx - .session - .lock() - .await - .conv_read(&parent_conv_id) - .and_then(|c| c.model_override.clone()), + // model 不传 → 继承父会话模型 override(与父对话同模型语义); + // 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), + None => parent.and_then(|c| c.model_override.clone()), + }; + let lang = parent + .and_then(|c| c.agent_language.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "zh-CN".to_string()); + (model, lang) }; let child_id = next_child_id(&parent_conv_id); let task_prompt = build_task_prompt(&task, &allowed, &trimmed); // 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)。子代理后台 // 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据; @@ -286,6 +298,8 @@ async fn spawn_agent_handler( tools = allowed.len(), max_rounds, model = ?child_model, + lang = %parent_lang, + temperature = ?temperature, "[sub-agent] 派发子代理" ); @@ -303,6 +317,7 @@ async fn spawn_agent_handler( allowed, max_rounds, child_model, + temperature, ctx.llm_concurrency.clone(), parent_save_meta, ) @@ -316,6 +331,8 @@ async fn spawn_agent_handler( "agent_id": child_id, "rounds": outcome.rounds, "tokens": outcome.tokens, + // 实际生效模型(回退/候选切换后的最终值):父会话据此知晓子代理真用了哪个模型 + "model": outcome.model, "report": outcome.summary, })) } diff --git a/src-tauri/src/commands/ai/unattended.rs b/src-tauri/src/commands/ai/unattended.rs index a979762..0332f04 100644 --- a/src-tauri/src/commands/ai/unattended.rs +++ b/src-tauri/src/commands/ai/unattended.rs @@ -22,7 +22,18 @@ //! - `df-unattended-poll-secs` u64,默认 300(下限钳 30,防误配热循环) //! - `df-unattended-claim-scope` "red_only"(默认)| "all_open" //! - `df-unattended-todo-path` 看板绝对路径,默认 DevFlow 仓库根 docs/todo.md +//! - `df-unattended-wallclock-tier` bool(B5a 挂钟闸分任务定级),默认 false=全部任务走 +//! legacy 30min 硬停(零行为变更);开=确定性任务放宽到 relaxed 挂钟并到点检查点续跑 +//! - `df-unattended-wallclock-relaxed-mins` u64,确定性任务放宽挂钟(分钟),默认 120(下限钳 30) +//! +//! W-d1 挂机循环骨架(设计《并行化与异步唤醒》§6.3)配置面,默认全关=零行为变更: +//! - `df-unattended-autopilot` bool,挂机循环开关,默认 false +//! - `df-unattended-autopilot-max-tasks` u32,预算闸·轮数上限(累计领单数),默认 12(下限钳 1) +//! - `df-unattended-autopilot-wall-mins` u64,预算闸·时长上限(挂机总挂钟,分钟),默认 480(下限钳 10) +//! - `df-unattended-autopilot-design-marker` str,设计先行类标记前缀,默认 `[设计]` +//! (正文以此前缀开头的条目=W-d2 自设计范围,挂机不领) +use std::collections::HashMap; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Mutex, OnceLock}; @@ -30,6 +41,9 @@ use std::time::Duration; use tauri::{AppHandle, Manager}; +use df_ai::ai_tools::RiskLevel; + +use crate::commands::ai::boundary::TaskBoundary; use crate::commands::ai::commands::chat::spawn_agentic_session; use crate::state::AppState; @@ -43,6 +57,20 @@ const KV_CLAIM_SCOPE: &str = "df-unattended-claim-scope"; const KV_TODO_PATH: &str = "df-unattended-todo-path"; /// 提交冻结 KV 标志(39 治理):Some(true)=强制冻结/Some(false)=强制解冻(覆盖看板判据) const KV_COMMIT_FROZEN: &str = "df-commit-frozen"; + +/// W-d1 挂机循环骨架 KV(默认关=零行为变更) +const KV_AUTOPILOT: &str = "df-unattended-autopilot"; +const KV_AUTOPILOT_MAX_TASKS: &str = "df-unattended-autopilot-max-tasks"; +const KV_AUTOPILOT_WALL_MINS: &str = "df-unattended-autopilot-wall-mins"; +const KV_AUTOPILOT_DESIGN_MARKER: &str = "df-unattended-autopilot-design-marker"; +const DEFAULT_AUTOPILOT_MAX_TASKS: u32 = 12; +/// 轮数下限钳 1(0 会使预算开启即耗尽,等同关;钳到 1 保证至少推进一单) +const MIN_AUTOPILOT_MAX_TASKS: u32 = 1; +const DEFAULT_AUTOPILOT_WALL_MINS: u64 = 480; +/// 时长下限钳 10 分钟(低于此无实用意义,且防误配秒级热跑) +const MIN_AUTOPILOT_WALL_MINS: u64 = 10; +/// 设计先行类默认标记前缀(挂机不领,留给 W-d2 自设计) +const DEFAULT_DESIGN_MARKER: &str = "[设计]"; /// 37 监控轮询:告警/心跳摘要推送目标(飞书 chat_id)。空=不推飞书,只落 tracing+feishu_log。 /// todo 37 验收「静默/进程消失 1 个周期内被发现并通报」的通报出口 const KV_MONITOR_CHAT: &str = "df-monitor-chat-id"; @@ -107,6 +135,10 @@ struct EngineConfig { todo_path: String, /// 37 监控轮询:告警/心跳摘要推送目标(飞书 chat_id),None=只落日志 monitor_chat: Option, + /// B5a 挂钟闸分任务定级(默认关=全部任务 legacy 硬停,零行为变更) + wallclock_tier: WallClockTierConfig, + /// W-d1 挂机循环骨架(默认关=既有行为零变更) + autopilot: AutopilotConfig, } impl Default for EngineConfig { @@ -117,6 +149,8 @@ impl Default for EngineConfig { claim_scope: ClaimScope::RedOnly, todo_path: DEFAULT_TODO_PATH.to_string(), monitor_chat: None, + wallclock_tier: WallClockTierConfig::default(), + autopilot: AutopilotConfig::default(), } } } @@ -149,6 +183,8 @@ impl EngineConfig { cfg.monitor_chat = Some(v); } } + cfg.wallclock_tier = WallClockTierConfig::from_kv(get); + cfg.autopilot = AutopilotConfig::from_kv(get); cfg } } @@ -161,12 +197,163 @@ async fn read_config(app: &AppHandle) -> EngineConfig { (KV_CLAIM_SCOPE, state.settings.get(KV_CLAIM_SCOPE).await.ok().flatten()), (KV_TODO_PATH, state.settings.get(KV_TODO_PATH).await.ok().flatten()), (KV_MONITOR_CHAT, state.settings.get(KV_MONITOR_CHAT).await.ok().flatten()), + (KV_WALLCLOCK_TIER, state.settings.get(KV_WALLCLOCK_TIER).await.ok().flatten()), + (KV_WALLCLOCK_RELAXED_MINS, state.settings.get(KV_WALLCLOCK_RELAXED_MINS).await.ok().flatten()), + (KV_AUTOPILOT, state.settings.get(KV_AUTOPILOT).await.ok().flatten()), + (KV_AUTOPILOT_MAX_TASKS, state.settings.get(KV_AUTOPILOT_MAX_TASKS).await.ok().flatten()), + (KV_AUTOPILOT_WALL_MINS, state.settings.get(KV_AUTOPILOT_WALL_MINS).await.ok().flatten()), + (KV_AUTOPILOT_DESIGN_MARKER, state.settings.get(KV_AUTOPILOT_DESIGN_MARKER).await.ok().flatten()), ]; EngineConfig::from_kv(&|k| { kv.iter().find(|(key, _)| *key == k).and_then(|(_, v)| v.clone()) }) } +// ============================================================ +// B5a 挂钟闸分任务定级(确定性任务放宽 + 到点检查点续跑) +// ============================================================ +// +// 背景:agentic loop 的挂钟闸一律 30min 硬停(agentic::UNATTENDED_WALL_SECS), +// 对「看板任务/用户拍板」这类目标与边界都明确的确定性任务过紧——它们只是跑得久, +// 并非失控,硬停等于把长任务腰斩。本模块按任务性质给闸口分档: +// - 确定性任务(看板领单/用户拍板项):放宽到 relaxed 挂钟,到点走「检查点+续跑」不停工; +// - 探索性任务(开放式/上下文不明,或失控疑似):维持 legacy 30min 硬停。 +// 分档开关默认关:关闭时全部任务落 legacy 硬停,零行为变更。闸口(agentic/mod.rs 轮间 +// 三闸)每轮调 [`wall_clock_policy_for`] 取本会话策略,未注册会话返回 None → 调用侧落 legacy。 + +/// 挂钟闸 legacy 硬停阈值。语义对齐 agentic::UNATTENDED_WALL_SECS(30min 硬停底座); +/// 此处独立定义以与并发编辑的 agentic 模块解耦,两处改动须同步。 +const WALL_LEGACY_SECS: u64 = 30 * 60; +const KV_WALLCLOCK_TIER: &str = "df-unattended-wallclock-tier"; +const KV_WALLCLOCK_RELAXED_MINS: &str = "df-unattended-wallclock-relaxed-mins"; +const DEFAULT_RELAXED_WALL_MINS: u64 = 120; +/// 放宽值下限钳到 legacy 同值:放宽档不得比 legacy 更紧(否则语义倒挂) +const MIN_RELAXED_WALL_MINS: u64 = WALL_LEGACY_SECS / 60; + +/// 任务性质分级:确定性(目标/边界明确)vs 探索性(开放式,或失控疑似) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskTier { + /// 看板任务、用户拍板项——跑得久不等于失控,放宽闸 + Deterministic, + /// 开放式/上下文不明,或已出现失控疑似——维持硬停 + Exploratory, +} + +/// 挂钟到点动作 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WallClockAction { + /// 检查点+续跑:存进度并顺延挂钟,不停工(确定性任务) + CheckpointContinue, + /// 硬停靠:走 emit_task_parked(wall_clock) 安全停靠(探索性/失控疑似/分档关闭) + HardPark, +} + +/// 单会话挂钟策略(闸口据此判定) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WallClockPolicy { + pub deadline_secs: u64, + pub action: WallClockAction, +} + +/// 分任务定级配置(Settings KV 热读) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WallClockTierConfig { + /// 分档开关,默认 false(=全部任务 legacy 硬停,零行为变更) + pub enabled: bool, + /// 确定性任务放宽后的挂钟(秒) + pub relaxed_secs: u64, +} + +impl Default for WallClockTierConfig { + fn default() -> Self { + WallClockTierConfig { + enabled: false, + relaxed_secs: DEFAULT_RELAXED_WALL_MINS * 60, + } + } +} + +impl WallClockTierConfig { + fn from_kv(get: &dyn Fn(&str) -> Option) -> Self { + let mut cfg = WallClockTierConfig::default(); + if let Some(v) = get(KV_WALLCLOCK_TIER) { + let v = v.trim().trim_matches('"').to_lowercase(); + cfg.enabled = v == "true" || v == "1"; + } + if let Some(v) = get(KV_WALLCLOCK_RELAXED_MINS) { + if let Ok(mins) = v.trim().trim_matches('"').parse::() { + cfg.relaxed_secs = mins.max(MIN_RELAXED_WALL_MINS) * 60; + } + } + cfg + } +} + +/// 任务定级判定(纯函数):失控疑似优先级最高——即便形似确定性任务,行为已异常即按 +/// 探索性硬停;否则看板任务/用户拍板项=确定性,其余=探索性。 +pub fn classify_task_tier( + board_task: bool, + user_decided: bool, + runaway_suspected: bool, +) -> TaskTier { + if runaway_suspected { + return TaskTier::Exploratory; + } + if board_task || user_decided { + TaskTier::Deterministic + } else { + TaskTier::Exploratory + } +} + +/// 挂钟策略判定(纯函数): +/// - 确定性 + 分档开:放宽挂钟 + 到点检查点续跑 +/// - 其余(探索性/失控疑似,或分档关):legacy 30min 硬停 +pub fn decide_wall_clock_policy(tier: TaskTier, cfg: &WallClockTierConfig) -> WallClockPolicy { + match tier { + TaskTier::Deterministic if cfg.enabled => WallClockPolicy { + deadline_secs: cfg.relaxed_secs, + action: WallClockAction::CheckpointContinue, + }, + _ => WallClockPolicy { + deadline_secs: WALL_LEGACY_SECS, + action: WallClockAction::HardPark, + }, + } +} + +/// 单会话挂钟策略注册表:引擎领单/恢复时按任务定级写入,闸口每轮读。 +/// 多会话并发各存各的策略,故用 Mutex 而非单值 OnceLock。 +static CONV_POLICIES: OnceLock>> = OnceLock::new(); + +fn conv_policies() -> &'static Mutex> { + CONV_POLICIES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn register_conv_policy(conv_id: &str, policy: WallClockPolicy) { + conv_policies() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(conv_id.to_string(), policy); +} + +fn forget_conv_policy(conv_id: &str) { + conv_policies() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(conv_id); +} + +/// B5a 闸口集成点:查询会话挂钟策略。agentic loop 轮间闸每轮调,未注册会话返回 None → +/// 调用侧落 legacy 30min 硬停(默认零行为变更)。 +pub fn wall_clock_policy_for(conv_id: &str) -> Option { + conv_policies() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(conv_id) + .copied() +} + // ============================================================ // 看板解析(纯函数) // ============================================================ @@ -233,23 +420,38 @@ pub fn parse_board(content: &str) -> Vec { // 领单判据与选择器(纯函数,G2 门禁代码复刻) // ============================================================ -/// 领单选择(G2 门禁复刻,不依赖会话自觉): -/// - 存在未清零 🔴(非 Done 的 🔴 条目)→ 只领首个 🔴 待办条目;🔴 在途/受阻无人可领则不领非 🔴(fail-closed) -/// - 🔴 已全部清零 → 按范围领:RedOnly 不领(None),AllOpen 领首个 `[ ]` 条目 +/// 领单核心(G2 门禁唯一真值源,不依赖会话自觉): +/// - 存在未清零 🔴(非 Done 的 🔴 条目)→ 只领首个「🔴 ∧ 待办 ∧ allow」条目; +/// 🔴 在途/受阻或候选不过 allow → 不领非 🔴(fail-closed) +/// - 🔴 已全部清零 → 按范围领:RedOnly 不领(None),AllOpen 领首个「待办 ∧ allow」条目 /// - `[→]` 即引擎锁,一律跳过;同轮只返回 1 条(单会话单在途) -pub fn select_claim<'a>(entries: &'a [BoardEntry], scope: ClaimScope) -> Option<&'a BoardEntry> { +/// +/// `allow` 为候选过滤谓词:普通领单恒真;挂机领单注入「确定性条目」过滤,门禁与过滤都在 +/// 候选层生效——设计先行类🔴挡在前时不越位改选普通条目(仍守 G2)。 +fn select_with bool>( + entries: &[BoardEntry], + scope: ClaimScope, + allow: F, +) -> Option<&BoardEntry> { let red_uncleared = entries.iter().any(|e| e.is_red && e.status != BoardStatus::Done); if red_uncleared { return entries .iter() - .find(|e| e.is_red && e.status == BoardStatus::Open); + .find(|e| e.is_red && e.status == BoardStatus::Open && allow(e)); } match scope { ClaimScope::RedOnly => None, - ClaimScope::AllOpen => entries.iter().find(|e| e.status == BoardStatus::Open), + ClaimScope::AllOpen => entries + .iter() + .find(|e| e.status == BoardStatus::Open && allow(e)), } } +/// 普通领单选择(无附加过滤) +pub fn select_claim<'a>(entries: &'a [BoardEntry], scope: ClaimScope) -> Option<&'a BoardEntry> { + select_with(entries, scope, |_| true) +} + /// 领单状态改写(纯函数):目标条目 `[ ]` → `[→](自动续作)`,其余行逐字不动(不新增编号)。 /// 条目不存在或非待办态(引擎锁已被占/已完成/受阻)返回 None 拒改。 pub fn apply_claim(content: &str, number: u64) -> Option { @@ -282,16 +484,413 @@ pub fn apply_claim(content: &str, number: u64) -> Option { Some(out) } -/// 原子写盘:临时文件(同目录同盘保证 rename 原子) + rename 覆盖,防半写 -fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> { +// ============================================================ +// W-d1 挂机循环骨架(设计《并行化与异步唤醒》§6.3) +// ============================================================ +// +// 挂机 = 空闲自领的完全体:空闲 → 领确定性条目 → 边界内执行 → 完成回写销账 → 循环。 +// 复用本引擎既有的看板解析/领单/G2 门禁/通知,**不另造引擎**;默认全关(零行为变更)。 +// 本骨架最小闭环四件: +// - 确定性条目筛选:设计先行类(正文带 design_marker 前缀)不领,留给 W-d2 自设计 +// - 预算闸:轮数(累计领单)+ 时长(挂机总挂钟)双上限,耗尽→停靠 + 挂机报告(飞书) +// - 轻量续接:状态文件(挂机目录 JSON,对齐接力棒 result.json),重启扫「挂机中」自动续 +// - 边界闸:挂机会话挂 TaskBoundary(ceiling=Medium + 仓库白名单 + 禁破坏),High 一律拒绝 +// +// 依赖序(设计 §6.3.1):续接不阻塞骨架先行——本版续接退化为「重启后看板重扫 + 销账标记 +// 去重」([→] 条目由销账纪律兜底),精确续点待 M2 continuum 升级。 + +/// 挂机配置(EngineConfig 子项,默认全关) +#[derive(Debug, Clone, PartialEq, Eq)] +struct AutopilotConfig { + enabled: bool, + /// 预算闸·轮数上限(本轮挂机累计领单数) + max_tasks: u32, + /// 预算闸·时长上限(本轮挂机总挂钟,秒) + max_wall_secs: u64, + /// 设计先行类标记前缀:正文以此开头的条目=W-d2 自设计范围,挂机不领 + design_marker: String, +} + +impl Default for AutopilotConfig { + fn default() -> Self { + AutopilotConfig { + enabled: false, + max_tasks: DEFAULT_AUTOPILOT_MAX_TASKS, + max_wall_secs: DEFAULT_AUTOPILOT_WALL_MINS * 60, + design_marker: DEFAULT_DESIGN_MARKER.to_string(), + } + } +} + +impl AutopilotConfig { + fn from_kv(get: &dyn Fn(&str) -> Option) -> Self { + let mut cfg = AutopilotConfig::default(); + if let Some(v) = get(KV_AUTOPILOT) { + let v = v.trim().trim_matches('"').to_lowercase(); + cfg.enabled = v == "true" || v == "1"; + } + if let Some(v) = get(KV_AUTOPILOT_MAX_TASKS) { + if let Ok(n) = v.trim().trim_matches('"').parse::() { + cfg.max_tasks = n.max(MIN_AUTOPILOT_MAX_TASKS); + } + } + if let Some(v) = get(KV_AUTOPILOT_WALL_MINS) { + if let Ok(mins) = v.trim().trim_matches('"').parse::() { + cfg.max_wall_secs = mins.max(MIN_AUTOPILOT_WALL_MINS) * 60; + } + } + if let Some(v) = get(KV_AUTOPILOT_DESIGN_MARKER) { + // 空串=不过滤(显式关闭确定性过滤);非空才覆盖默认标记 + let v = v.trim().trim_matches('"').to_string(); + cfg.design_marker = v; + } + cfg + } +} + +/// 预算终止原因(纯逻辑) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BudgetStop { + /// 达轮数上限(累计领单数) + Rounds, + /// 达时长上限(挂机总挂钟) + WallClock, +} + +/// 预算闸判定(纯函数):轮数优先于时长;均未达返回 None。 +pub fn budget_stop( + max_tasks: u32, + max_wall_secs: u64, + claimed: u32, + elapsed_secs: u64, +) -> Option { + if claimed >= max_tasks { + Some(BudgetStop::Rounds) + } else if elapsed_secs >= max_wall_secs { + Some(BudgetStop::WallClock) + } else { + None + } +} + +/// 空闲判定(纯函数):无在途会话且无待结算单。挂机仅在空闲时领下一单(单会话单在途)。 +pub fn is_idle(in_flight: Option<&str>, pending_settle: bool) -> bool { + in_flight.is_none() && !pending_settle +} + +/// 条目是否确定性可直推。设计先行类(正文以 design_marker 开头)属 W-d2 自设计范围,不领; +/// marker 为空视为不过滤(全部确定性)。 +pub fn is_deterministic_entry(entry: &BoardEntry, design_marker: &str) -> bool { + let m = design_marker.trim(); + if m.is_empty() { + return true; + } + !entry.text.trim_start().starts_with(m) +} + +/// 挂机领单:G2 门禁 + 确定性过滤(复用 [`select_with`],门禁与过滤都在候选层生效)。 +/// 设计先行类不领(=W-d2 自设计范围);🔴 设计项挡前时不越位改选普通条目(守 G2,返回 None 等人工)。 +pub fn select_autopilot_claim<'a>( + entries: &'a [BoardEntry], + scope: ClaimScope, + design_marker: &str, +) -> Option<&'a BoardEntry> { + select_with(entries, scope, |e| is_deterministic_entry(e, design_marker)) +} + +/// 挂机运行态(内存 + 状态文件 JSON 双载体;状态文件=轻量续接,对齐接力棒 result.json) +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +struct AutopilotState { + /// 运行态存在(状态文件据此判「重启自动续」) + active: bool, + /// 本轮挂机启动时刻(ms,时长预算基准) + started_at_ms: u64, + /// 已领单数(轮数预算基准) + claimed: u32, + /// 已完成数(收敛回读看板 [x] 判定) + completed: u32, + /// 待人工清单(收敛后非 [x] 的条目号) + manual: Vec, + /// 当前在途条目号(None=无在途) + in_flight_task: Option, + /// 在途条目已收敛(收敛钩子置位,下轮 tick 看板判定完成/待人工) + pending_settle: bool, + /// 运行终止原因(预算耗尽等);Some=已停,需人工关开挂机开关复位 + stop_reason: Option, +} + +/// 挂机运行态全局单例(单引擎单挂机,进程内) +static AUTOPILOT: OnceLock> = OnceLock::new(); + +fn autopilot() -> &'static Mutex { + AUTOPILOT.get_or_init(|| Mutex::new(AutopilotState::default())) +} + +/// 构建挂机报告(纯函数,停靠时推飞书 + 落日志) +fn build_autopilot_report(s: &AutopilotState, stop: BudgetStop, elapsed_secs: u64) -> String { + let reason = match stop { + BudgetStop::Rounds => format!("已达轮数上限,共 {} 单", s.claimed), + BudgetStop::WallClock => format!("已达时长上限,约 {} 分钟", elapsed_secs / 60), + }; + format!( + "🌙 **DevFlow 挂机报告**\n预算耗尽({reason}),本轮挂机结束。\n领单 {} · 完成 {} · 待人工 {}", + s.claimed, + s.completed, + s.manual.len() + ) +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// 挂机状态文件路径(`{app_data}/unattended/autopilot-state.json`) +fn autopilot_state_path(app: &AppHandle) -> std::path::PathBuf { + app.path() + .app_data_dir() + .unwrap_or_default() + .join("unattended") + .join("autopilot-state.json") +} + +/// 落状态文件(best-effort:失败只告警不 panic,挂机主流程不受影响) +async fn persist_state(app: &AppHandle, s: &AutopilotState) { + let path = autopilot_state_path(app); + if let Some(dir) = path.parent() { + if let Err(e) = tokio::fs::create_dir_all(dir).await { + tracing::warn!("[挂机] 状态目录创建失败: {e}"); + return; + } + } + match serde_json::to_string_pretty(s) { + Ok(json) => { + if let Err(e) = tokio::fs::write(&path, json).await { + tracing::warn!("[挂机] 状态文件写入失败: {e}"); + } + } + Err(e) => tracing::warn!("[挂机] 状态序列化失败: {e}"), + } +} + +/// 读状态文件;缺失/损坏一律回退全新态(不影响正常启动) +async fn load_state(app: &AppHandle) -> AutopilotState { + match tokio::fs::read_to_string(autopilot_state_path(app)).await { + Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| { + tracing::warn!("[挂机] 状态文件解析失败,按全新处理: {e}"); + AutopilotState::default() + }), + Err(_) => AutopilotState::default(), + } +} + +/// 关闭挂机(复位运行态 + 落盘):开关关时调用,零行为变更路径。 +async fn deactivate_autopilot(app: &AppHandle) { + let snap = { + let mut s = autopilot().lock().unwrap_or_else(|p| p.into_inner()); + if !s.active && s.stop_reason.is_none() { + return; + } + *s = AutopilotState::default(); + s.clone() + }; + persist_state(app, &snap).await; + tracing::info!("[挂机] 已关闭,运行态复位"); +} + +/// 记录一次挂机领单(claimed+1 + 绑在途条目号 + 落盘) +async fn record_autopilot_claim(app: &AppHandle, number: u64) { + let snap = { + let mut s = autopilot().lock().unwrap_or_else(|p| p.into_inner()); + if !s.active || s.stop_reason.is_some() { + return; + } + s.claimed += 1; + s.in_flight_task = Some(number); + s.pending_settle = false; + s.clone() + }; + persist_state(app, &snap).await; +} + +/// 读看板某编号条目的当前状态(结算用;读失败=未知) +async fn board_status_of(todo_path: &str, number: u64) -> Option { + let content = tokio::fs::read_to_string(todo_path).await.ok()?; + parse_board(&content) + .into_iter() + .find(|e| e.number == number) + .map(|e| e.status) +} + +/// 看板所在仓库根(docs/todo.md → 仓根)作边界白名单;退化保护:上级为盘符根时取看板目录本身。 +fn repo_root_of(todo_path: &str) -> Option { + let p = Path::new(todo_path); + let docs_dir = p.parent()?; + match docs_dir.parent() { + Some(root) if root.parent().is_some() => Some(root.to_string_lossy().to_string()), + _ => Some(docs_dir.to_string_lossy().to_string()), + } +} + +/// 为挂机会话挂任务边界(边界闸):ceiling=Medium + 看板仓库白名单 + 禁破坏性命令。 +/// High(部署/push/删除)超上限被结构化拒绝=等拍板。轮次/时长预算由挂机层自管, +/// TaskBoundary.budget 主 loop 未接线故不设(避免落无效果的误导数据)。 +async fn set_autopilot_boundary(app: &AppHandle, cfg: &EngineConfig, conv_id: &str) { + let boundary = TaskBoundary { + risk_ceiling: RiskLevel::Medium, + dir_allowlist: repo_root_of(&cfg.todo_path).into_iter().collect(), + forbid_destructive: true, + budget: None, + } + .normalized(); + let state = app.state::(); + { + let mut session = state.ai_session.lock().await; + session.conv(conv_id).boundary = Some(boundary.clone()); + } + // best-effort 落库:未落库会话影响 0 行属预期(save 随行补) + if let Ok(json) = serde_json::to_string(&boundary) { + let _ = state + .ai_conversations + .set_boundary_json(conv_id, Some(&json)) + .await; + } + tracing::info!( + "[挂机] 会话 {} 已挂任务边界(ceiling=Medium,白名单 {:?})", + conv_id, + boundary.dir_allowlist + ); +} + +/// 挂机闸(tick 内、领单前):结算上一单 → 停靠检查 → 激活/续接 → 预算闸 → 空闲闸。 +/// 返回 true=可继续领单,false=本轮不领。 +async fn autopilot_gate(app: &AppHandle, cfg: &EngineConfig) -> bool { + // 1) 结算:上一单收敛 → 看板判定 [x]=完成 / 其余=待人工 + let settle = { + let s = autopilot().lock().unwrap_or_else(|p| p.into_inner()); + if s.pending_settle { + s.in_flight_task + } else { + None + } + }; + if let Some(n) = settle { + let done = board_status_of(&cfg.todo_path, n).await == Some(BoardStatus::Done); + let snap = { + let mut s = autopilot().lock().unwrap_or_else(|p| p.into_inner()); + if done { + s.completed += 1; + } else { + s.manual.push(n); + } + s.pending_settle = false; + s.in_flight_task = None; + s.clone() + }; + persist_state(app, &snap).await; + tracing::info!( + "[挂机] 条目 #{} 结算:{}", + n, + if done { "完成" } else { "待人工(未销账)" } + ); + } + + // 2) 已停(预算耗尽等)不再领单,等人工关开挂机开关复位 + if autopilot() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .stop_reason + .is_some() + { + return false; + } + + // 3) 激活 / 重启续接:状态文件标「挂机中」即续(挂机=显式完全信任档);否则全新起 + let active = autopilot() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .active; + if !active { + let mut persisted = load_state(app).await; + let resumed = persisted.active; + if resumed { + // 重启后旧在途会话已亡:清残留,看板 [→] 由销账纪律兜底(本版续接退化态) + persisted.in_flight_task = None; + persisted.pending_settle = false; + } else { + persisted = AutopilotState { + active: true, + started_at_ms: now_ms(), + ..Default::default() + }; + } + let snap = { + let mut s = autopilot().lock().unwrap_or_else(|p| p.into_inner()); + if !s.active { + *s = persisted.clone(); + } + s.clone() + }; + persist_state(app, &snap).await; + tracing::info!( + "[挂机] {}(预算 {} 轮 / {} 分钟)", + if resumed { "重启自动续" } else { "启动" }, + cfg.autopilot.max_tasks, + cfg.autopilot.max_wall_secs / 60 + ); + } + + // 4) 预算闸:耗尽 → 置停靠原因(仅一次)+ 报告推飞书 + 落盘 + let (claimed, elapsed_secs) = { + let s = autopilot().lock().unwrap_or_else(|p| p.into_inner()); + (s.claimed, now_ms().saturating_sub(s.started_at_ms) / 1000) + }; + if let Some(stop) = budget_stop( + cfg.autopilot.max_tasks, + cfg.autopilot.max_wall_secs, + claimed, + elapsed_secs, + ) { + let snap = { + let mut s = autopilot().lock().unwrap_or_else(|p| p.into_inner()); + s.stop_reason = Some(match stop { + BudgetStop::Rounds => "轮数上限".to_string(), + BudgetStop::WallClock => "时长上限".to_string(), + }); + s.clone() + }; + persist_state(app, &snap).await; + let report = build_autopilot_report(&snap, stop, elapsed_secs); + tracing::info!("[挂机] {report}"); + crate::commands::ai::feishu_notify::push_card( + app, + cfg.monitor_chat.as_deref().unwrap_or(""), + &report, + ) + .await; + return false; + } + + // 5) 空闲闸:仍有未结算单则不领新单(单会话单在途) + let s = autopilot().lock().unwrap_or_else(|p| p.into_inner()); + is_idle(s.in_flight_task.map(|_| ""), s.pending_settle) +} + +/// 原子写盘:临时文件(同目录同盘保证 rename 原子) + rename 覆盖,防半写。 +/// 改 async 走 tokio::fs:调用点在引擎 async 循环,同步 fs 会阻塞单线程 runtime; +/// tokio::fs 内部走阻塞池,tmp→rename 的原子语义不变。 +async fn atomic_write(path: &Path, content: &str) -> std::io::Result<()> { let file_name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "todo.md".to_string()); let tmp = path.with_file_name(format!(".{}.b5-tmp", file_name)); - std::fs::write(&tmp, content)?; - // Windows 下 std::fs::rename 走 MOVEFILE_REPLACE_EXISTING,可覆盖已存在目标 - std::fs::rename(&tmp, path) + tokio::fs::write(&tmp, content).await?; + // Windows 下 rename 走 MOVEFILE_REPLACE_EXISTING,可覆盖已存在目标 + tokio::fs::rename(&tmp, path).await } // ============================================================ @@ -336,6 +935,14 @@ pub fn notify_batch_done(conv_id: &str) { matches }; if is_ours { + forget_conv_policy(conv_id); + // 挂机:在途收敛 → 置待结算位,下轮 tick 看板判定完成/待人工(不着色 fs,避免收敛同步点阻塞) + { + let mut s = autopilot().lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if s.active && s.in_flight_task.is_some() { + s.pending_settle = true; + } + } tracing::info!("[B5] 在途会话 {} 收敛,唤醒轮询器续作", conv_id); e.notify.notify_one(); } @@ -381,6 +988,10 @@ async fn run_tick(app: &AppHandle, cfg: &EngineConfig) { if e.breaker_tripped.load(Ordering::SeqCst) { return; // 熔断自停,等人工开关复位 } + // 挂机开关拉下 → 复位挂机运行态(默认关,零行为变更路径) + if !cfg.autopilot.enabled { + deactivate_autopilot(app).await; + } // 提交冻结标志同步(39 治理):每轮按看板 🔒 行 + KV 覆盖同步到执行闸, // 引擎派发会话内任何 git commit 在 spawn 前被拒(改动留工作区)。 sync_commit_freeze(app, cfg).await; @@ -395,6 +1006,7 @@ async fn run_tick(app: &AppHandle, cfg: &EngineConfig) { let state = app.state::(); if state.conv_states.can_accept_request(&conv) { tracing::warn!("[B5] 在途标志残留(会话 {} 已非生成态),自动清除", conv); + forget_conv_policy(&conv); *e.in_flight .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; @@ -405,6 +1017,7 @@ async fn run_tick(app: &AppHandle, cfg: &EngineConfig) { // parked 自动恢复优先于领单(断点续跑先于新任务) match recover_one_parked(app).await { Ok(conv) => { + register_engine_task(cfg, &conv); set_in_flight(conv); return; } @@ -417,10 +1030,17 @@ async fn run_tick(app: &AppHandle, cfg: &EngineConfig) { } } } - // 领单 - if let Err(err) = claim_and_spawn(app, cfg).await { - // 熔断已在计数处置时打 error,这里只记过程 - tracing::warn!("[B5] 本轮领单未成: {}", err); + // 领单(挂机开启时经挂机闸:结算/续接/预算/空闲;闸不过则本轮不领) + let can_claim = if cfg.autopilot.enabled { + autopilot_gate(app, cfg).await + } else { + true + }; + if can_claim { + if let Err(err) = claim_and_spawn(app, cfg).await { + // 熔断已在计数处置时打 error,这里只记过程 + tracing::warn!("[B5] 本轮领单未成: {}", err); + } } // 37 监控轮询:每轮末尾自监控快照(活性/熔断/失败计数),异常推飞书,正常落日志 run_self_monitor(app, cfg).await; @@ -514,6 +1134,12 @@ pub struct UnattendedStatus { in_flight_conv: Option, breaker_tripped: bool, poll_secs: u64, + /// W-d1 挂机开关与运行进度(设置页挂机区块回显) + autopilot: bool, + autopilot_claimed: u32, + autopilot_completed: u32, + autopilot_manual: usize, + autopilot_stop_reason: Option, } /// 查询挂机引擎当前状态(设置页挂机区块回显 / TopBar 徽章轮询) @@ -526,6 +1152,10 @@ pub async fn unattended_status(app: AppHandle) -> Result Result Result Result { + let state = app.state::(); + state + .settings + .set(KV_AUTOPILOT, if enabled { "true" } else { "false" }) + .await + .map_err(|e| e.to_string())?; + engine().notify.notify_one(); + Ok(enabled) +} + const RECOVER_NONE: &str = "无可恢复 parked 会话"; /// 提交冻结标志同步(39 治理):读 KV 覆盖标志 + 看板 🔒 行,写入执行闸静态位。 @@ -567,7 +1220,8 @@ const RECOVER_NONE: &str = "无可恢复 parked 会话"; async fn sync_commit_freeze(app: &AppHandle, cfg: &EngineConfig) { let state = app.state::(); let kv_frozen = state.settings.get(KV_COMMIT_FROZEN).await.ok().flatten(); - let board = std::fs::read_to_string(&cfg.todo_path).ok(); + // 读板改 tokio::fs:引擎每轮热路径,同步读会阻塞 runtime;读不到降级 None 语义不变。 + let board = tokio::fs::read_to_string(&cfg.todo_path).await.ok(); let frozen = resolve_frozen(board.as_deref(), &|_| kv_frozen.clone()); df_execute::commit_freeze::set_frozen(frozen); if frozen { @@ -582,6 +1236,26 @@ fn set_in_flight(conv_id: String) { .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(conv_id); } +/// 引擎领单/恢复的任务恒为确定性(看板条目/用户拍板续作),按当前配置算挂钟策略并注册。 +/// runaway_suspected 传 false:引擎自身不判失控,失控疑似由闸口侧感知(如需在闸口覆盖)。 +fn register_engine_task(cfg: &EngineConfig, conv_id: &str) { + let tier = classify_task_tier(true, false, false); + let policy = decide_wall_clock_policy(tier, &cfg.wallclock_tier); + match policy.action { + WallClockAction::CheckpointContinue => tracing::info!( + "[B5] 挂钟闸分档:会话 {} 确定性任务,放宽 {} 分钟,到点检查点续跑", + conv_id, + policy.deadline_secs / 60 + ), + WallClockAction::HardPark => tracing::debug!( + "[B5] 挂钟闸分档:会话 {} 按 {} 分钟硬停底座(分档关或探索性)", + conv_id, + policy.deadline_secs / 60 + ), + } + register_conv_policy(conv_id, policy); +} + /// spawn 失败计数:连续 3 次熔断(引擎自停 + error 告警,开关保持) fn count_spawn_failure() { let e = engine(); @@ -634,16 +1308,27 @@ async fn recover_one_parked(app: &AppHandle) -> Result { /// 看板领单:读板 → 选条目 → 原子写领单标记 → 正典链路开工;spawn 失败回滚领单标记 async fn claim_and_spawn(app: &AppHandle, cfg: &EngineConfig) -> Result<(), String> { let path = Path::new(&cfg.todo_path); - let content = std::fs::read_to_string(path) + let content = tokio::fs::read_to_string(path) + .await .map_err(|e| format!("看板读取失败 {}: {}", cfg.todo_path, e))?; let entries = parse_board(&content); - let Some(entry) = select_claim(&entries, cfg.claim_scope) else { - return Err("无可领条目".to_string()); + // 挂机开启:只领确定性条目(设计先行类留给 W-d2);关闭:保持既有 G2 领单不受限 + let selected = if cfg.autopilot.enabled { + select_autopilot_claim(&entries, cfg.claim_scope, &cfg.autopilot.design_marker) + } else { + select_claim(&entries, cfg.claim_scope) + }; + let Some(entry) = selected else { + return Err(if cfg.autopilot.enabled { + "无可领确定性条目(或首选为设计先行类,留待人工)".to_string() + } else { + "无可领条目".to_string() + }); }; let (number, text) = (entry.number, entry.text.clone()); let new_content = apply_claim(&content, number).ok_or_else(|| format!("条目 #{} 不可领", number))?; - atomic_write(path, &new_content).map_err(|e| format!("看板领单写入失败: {}", e))?; + atomic_write(path, &new_content).await.map_err(|e| format!("看板领单写入失败: {}", e))?; tracing::info!("[B5] 已领看板条目 #{}: {}", number, truncate(&text, 80)); // 领单任务书:条目原文全文 + 完成指令(改 [x] 附验收输出) + 冻结期硬约束(39 治理) let freeze_clause = if df_execute::commit_freeze::is_frozen(None) { @@ -661,28 +1346,39 @@ async fn claim_and_spawn(app: &AppHandle, cfg: &EngineConfig) -> Result<(), Stri number, text, cfg.todo_path, freeze_clause ); let state = app.state::(); + // 预先定会话 id:挂机会话先挂边界再开工(set_autopilot_boundary 用该 id), + // 消除「spawn 后设边界」的竞态——spawn 返回即已挂好,首轮工具调用必在边界内 + let conv_id = df_types::types::new_id(); + if cfg.autopilot.enabled { + set_autopilot_boundary(app, cfg, &conv_id).await; + } match spawn_agentic_session( app, &state, message, - None, // language 沿用默认 - None, // skill 不挂 - None, // model_override 沿用路由 - Some(df_types::types::new_id()), // 引擎独立后台会话,不共用用户活跃面板 + None, // language 沿用默认 + None, // skill 不挂 + None, // model_override 沿用路由 + Some(conv_id), // 引擎独立后台会话,不共用用户活跃面板 None, None, - false, // pin_active=false 不劫持活跃面板 + false, // pin_active=false 不劫持活跃面板 ) .await { Ok(conv_id) => { engine().failures.store(0, Ordering::SeqCst); + register_engine_task(cfg, &conv_id); set_in_flight(conv_id); + // 挂机:记领单(预算轮数基准 + 在途条目绑定 + 落状态文件) + if cfg.autopilot.enabled { + record_autopilot_claim(app, number).await; + } Ok(()) } Err(spawn_err) => { // 领单标记回滚(spawn 未成,板不得留假在途锁):best-effort 重写原文 - if let Err(e) = atomic_write(path, &content) { + if let Err(e) = atomic_write(path, &content).await { tracing::warn!("[B5] 条目 #{} 领单标记回滚失败(需人工核对): {}", number, e); } count_spawn_failure(); @@ -854,4 +1550,210 @@ mod tests { assert!(!resolve_frozen(Some(open_board), &|_| None)); assert!(!resolve_frozen(None, &|_| None), "无板无 KV 默认不冻结"); } + + // ── B5a 挂钟闸分任务定级 ────────────────────────────────── + + /// 确定性任务 + 分档开:放宽挂钟且到点检查点续跑(不硬停) + #[test] + fn wallclock_tier_deterministic_relaxes_when_enabled() { + let cfg = WallClockTierConfig { + enabled: true, + relaxed_secs: 120 * 60, + }; + // 看板任务与用户拍板项都算确定性 + assert_eq!(classify_task_tier(true, false, false), TaskTier::Deterministic); + assert_eq!(classify_task_tier(false, true, false), TaskTier::Deterministic); + let policy = decide_wall_clock_policy(TaskTier::Deterministic, &cfg); + assert_eq!(policy.deadline_secs, 120 * 60); + assert_eq!(policy.action, WallClockAction::CheckpointContinue); + } + + /// 探索性任务即便分档开也硬停(30min legacy) + #[test] + fn wallclock_tier_exploratory_hard_parks_even_when_enabled() { + let cfg = WallClockTierConfig { + enabled: true, + relaxed_secs: 120 * 60, + }; + assert_eq!(classify_task_tier(false, false, false), TaskTier::Exploratory); + let policy = decide_wall_clock_policy(TaskTier::Exploratory, &cfg); + assert_eq!(policy.deadline_secs, WALL_LEGACY_SECS); + assert_eq!(policy.action, WallClockAction::HardPark); + } + + /// 失控疑似优先级最高:形似确定性任务也回落探索性(硬停) + #[test] + fn wallclock_tier_runaway_suspicion_overrides_deterministic() { + assert_eq!(classify_task_tier(true, true, true), TaskTier::Exploratory); + } + + /// 默认关=零行为变更:确定性任务也走 legacy 30min 硬停 + #[test] + fn wallclock_tier_disabled_is_zero_behavior_change() { + let cfg = WallClockTierConfig::default(); + assert!(!cfg.enabled, "默认关"); + let policy = decide_wall_clock_policy(TaskTier::Deterministic, &cfg); + assert_eq!(policy.deadline_secs, WALL_LEGACY_SECS); + assert_eq!(policy.action, WallClockAction::HardPark); + } + + /// KV 解析:开关 + 放宽分钟,下限钳到 legacy 同值(放宽不得更紧) + #[test] + fn wallclock_tier_config_parses_kv_and_clamps() { + assert_eq!( + WallClockTierConfig::from_kv(&|_| None), + WallClockTierConfig::default() + ); + let cfg = WallClockTierConfig::from_kv(&|k| match k { + "df-unattended-wallclock-tier" => Some("\"true\"".to_string()), + "df-unattended-wallclock-relaxed-mins" => Some("200".to_string()), + _ => None, + }); + assert!(cfg.enabled); + assert_eq!(cfg.relaxed_secs, 200 * 60); + let cfg = WallClockTierConfig::from_kv(&|k| { + (k == "df-unattended-wallclock-relaxed-mins").then(|| "10".to_string()) + }); + assert_eq!(cfg.relaxed_secs, WALL_LEGACY_SECS, "低于下限钳到 legacy 同值"); + } + + // ── W-d1 挂机循环骨架 ───────────────────────────────────── + + /// 空闲判定:无在途且无待结算=空闲;有在途/待结算=忙(不领新单) + #[test] + fn autopilot_idle_gate() { + assert!(is_idle(None, false), "无在途无待结算=空闲"); + assert!(!is_idle(Some("conv-1"), false), "有在途=忙"); + assert!(!is_idle(None, true), "有待结算单=忙(先结算再领)"); + assert!(!is_idle(Some("conv-1"), true), "在途+待结算=忙"); + } + + /// 条目筛选:设计先行类(带标记前缀)不领;普通条目可领;空标记=不过滤 + #[test] + fn autopilot_deterministic_filter() { + let plain = entry(1, BoardStatus::Open, false, "修复 xxx 回归"); + let design = entry(2, BoardStatus::Open, false, "[设计] 重构上下文组装器"); + let embedded = entry(3, BoardStatus::Open, false, "正文提及 [设计] 但非行首"); + assert!(is_deterministic_entry(&plain, DEFAULT_DESIGN_MARKER)); + assert!(!is_deterministic_entry(&design, DEFAULT_DESIGN_MARKER)); + assert!( + is_deterministic_entry(&embedded, DEFAULT_DESIGN_MARKER), + "标记须在正文行首,正文中提及不误判" + ); + assert!(is_deterministic_entry(&design, ""), "空标记=不过滤"); + } + + /// 挂机领单:G2 门禁选择后过滤确定性;门禁首选设计先行类则拒领(不越位绕门禁) + #[test] + fn autopilot_claim_respects_gate_and_filter() { + // 🔴 设计项挡在最前:门禁只认 🔴 → 首选为设计类 → 不领(等人工),不越位选普通条目 + let board = vec![ + entry(1, BoardStatus::Open, true, "[设计] 🔴 设计先行类"), + entry(2, BoardStatus::Open, false, "普通可领条目"), + ]; + assert_eq!( + select_autopilot_claim(&board, ClaimScope::AllOpen, DEFAULT_DESIGN_MARKER), + None, + "门禁首选为设计类→拒领(不绕门禁)" + ); + // 无 🔴 时:all_open 跳过设计类领普通;red_only 不领 + let board2 = vec![ + entry(1, BoardStatus::Open, false, "[设计] 设计类"), + entry(2, BoardStatus::Open, false, "普通可领"), + ]; + assert_eq!( + select_autopilot_claim(&board2, ClaimScope::AllOpen, DEFAULT_DESIGN_MARKER).map(|e| e.number), + Some(2), + "all_open:跳过设计类领首个普通条目" + ); + assert_eq!( + select_autopilot_claim(&board2, ClaimScope::RedOnly, DEFAULT_DESIGN_MARKER), + None + ); + } + + /// 预算耗尽收敛:轮数优先、时长次之、未达不限 + #[test] + fn autopilot_budget_exhaustion() { + assert_eq!(budget_stop(3, 3600, 0, 0), None, "未达不限"); + assert_eq!(budget_stop(3, 3600, 2, 10), None, "差一单"); + assert_eq!(budget_stop(3, 3600, 3, 10), Some(BudgetStop::Rounds), "达轮数上限"); + assert_eq!(budget_stop(10, 3600, 1, 3600), Some(BudgetStop::WallClock), "达时长上限"); + assert_eq!( + budget_stop(3, 3600, 3, 9999), + Some(BudgetStop::Rounds), + "轮数与时长同达→轮数优先" + ); + } + + /// 挂机报告:含领单/完成/待人工计数与终止原因 + #[test] + fn autopilot_report_contents() { + let state = AutopilotState { + active: true, + started_at_ms: 0, + claimed: 5, + completed: 3, + manual: vec![2, 4], + in_flight_task: None, + pending_settle: false, + stop_reason: Some("轮数上限".to_string()), + }; + let report = build_autopilot_report(&state, BudgetStop::Rounds, 1200); + assert!(report.contains("领单 5"), "含领单数: {report}"); + assert!(report.contains("完成 3"), "含完成数: {report}"); + assert!(report.contains("待人工 2"), "含待人工数: {report}"); + let wall = build_autopilot_report(&state, BudgetStop::WallClock, 7200); + assert!(wall.contains("120 分钟"), "时长上限报告含耗时: {wall}"); + } + + /// 状态文件 serde roundtrip(轻量续接落盘契约) + #[test] + fn autopilot_state_roundtrip() { + let s = AutopilotState { + active: true, + started_at_ms: 1_700_000_000_000, + claimed: 4, + completed: 2, + manual: vec![7], + in_flight_task: Some(9), + pending_settle: true, + stop_reason: None, + }; + let json = serde_json::to_string(&s).unwrap(); + let back: AutopilotState = serde_json::from_str(&json).unwrap(); + assert_eq!(back, s); + // 缺省字段容错:精简 JSON 反序列化回落默认安全值 + let minimal: AutopilotState = serde_json::from_str("{}").unwrap(); + assert_eq!(minimal, AutopilotState::default()); + } + + /// 挂机配置 KV 解析:默认全关 + 域设置 + 下限钳 + #[test] + fn autopilot_config_parses_kv_and_clamps() { + assert_eq!(AutopilotConfig::from_kv(&|_| None), AutopilotConfig::default()); + assert!(!AutopilotConfig::default().enabled, "默认关=零行为变更"); + let cfg = AutopilotConfig::from_kv(&|k| match k { + "df-unattended-autopilot" => Some("\"true\"".to_string()), + "df-unattended-autopilot-max-tasks" => Some("0".to_string()), + "df-unattended-autopilot-wall-mins" => Some("1".to_string()), + "df-unattended-autopilot-design-marker" => Some("[待设计]".to_string()), + _ => None, + }); + assert!(cfg.enabled); + assert_eq!(cfg.max_tasks, MIN_AUTOPILOT_MAX_TASKS, "轮数低于下限钳到 1"); + assert_eq!(cfg.max_wall_secs, MIN_AUTOPILOT_WALL_MINS * 60, "时长低于下限钳到 10 分钟"); + assert_eq!(cfg.design_marker, "[待设计]"); + } + + /// 看板仓库根推导:docs/todo.md → 仓根;盘符根退化取看板目录本身 + #[test] + fn autopilot_repo_root_derivation() { + assert_eq!( + repo_root_of("E:\\wk-lab\\devflow\\docs\\todo.md").as_deref(), + Some("E:\\wk-lab\\devflow") + ); + // 盘符根退化:E:\docs\todo.md → 上级 E:\ 无再上级 → 取 E:\docs + assert_eq!(repo_root_of("E:\\docs\\todo.md").as_deref(), Some("E:\\docs")); + } } diff --git a/src-tauri/src/commands/ai/wake.rs b/src-tauri/src/commands/ai/wake.rs new file mode 100644 index 0000000..a217f22 --- /dev/null +++ b/src-tauri/src/commands/ai/wake.rs @@ -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>> = 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, +) -> Result { + 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 { + 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/ 存在。 +/// - 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::(); + 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::(); + 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 应空"); + } +} diff --git a/src/components/OffloadedResultBar.vue b/src/components/OffloadedResultBar.vue new file mode 100644 index 0000000..f7a7609 --- /dev/null +++ b/src/components/OffloadedResultBar.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/src/composables/ai/useListContinuation.ts b/src/composables/ai/useListContinuation.ts new file mode 100644 index 0000000..71de6a3 --- /dev/null +++ b/src/composables/ai/useListContinuation.ts @@ -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 } +} diff --git a/src/composables/ai/useOffloadedResult.ts b/src/composables/ai/useOffloadedResult.ts new file mode 100644 index 0000000..8a3ef89 --- /dev/null +++ b/src/composables/ai/useOffloadedResult.ts @@ -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 + 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)[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 { + return invoke('ai_read_offloaded_result', { path }) +} + +/** + * 外置结果展开状态机:识别句柄 + 懒加载读回原文(同一 path 只读一次,读回后缓存)。 + * getResult 惰性取值,响应式来源不在此固化(工具卡按 tc、审计按当前展开行)。 + */ +export function useOffloadedResult(getResult: () => unknown) { + const handle: ComputedRef = computed(() => parseOffloadHandle(getResult())) + const expanded = ref(false) + const loading = ref(false) + const error = ref('') + const resolved = ref(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 } +} diff --git a/tests/e2e/stream-body-visibility.spec.ts b/tests/e2e/stream-body-visibility.spec.ts new file mode 100644 index 0000000..cdf81e4 --- /dev/null +++ b/tests/e2e/stream-body-visibility.spec.ts @@ -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) { + await page.evaluate((ev) => { + const internals = (window as unknown as { + __TAURI_INTERNALS__: { callbacks: Map void> } + }).__TAURI_INTERNALS__ + // payload 结构对齐 Tauri Event: { 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 { + 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(流式期间当前轮正文可见)。 +// +// 历史回归:模板