From cb260aba4bf38ed99c26643cc7b62b79fb6454b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 5 Oct 2026 23:41:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E6=96=AD=E7=82=B9?= =?UTF-8?q?=E7=BB=AD=E8=B7=91=E4=B8=8E=E6=97=A5=E5=BF=97=E7=A7=9F=E7=BA=A6?= =?UTF-8?q?=E5=BA=95=E5=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../df-storage/src/crud/conversation_repo.rs | 78 +++- crates/df-storage/src/migrations.rs | 33 +- crates/df-storage/src/models.rs | 6 + src-tauri/src/commands/ai/agentic/mod.rs | 71 +++- .../src/commands/ai/agentic/sub_agent.rs | 9 +- src-tauri/src/commands/ai/audit/mod.rs | 27 ++ src-tauri/src/commands/ai/audit/record.rs | 1 + src-tauri/src/commands/ai/commands/chat.rs | 12 + .../src/commands/ai/commands/conversation.rs | 20 +- src-tauri/src/commands/ai/conversation.rs | 1 + src-tauri/src/commands/ai/knowledge_inject.rs | 303 +++++++++++++-- src-tauri/src/commands/ai/lease.rs | 345 ++++++++++++++++++ src-tauri/src/commands/ai/mod.rs | 2 + src-tauri/src/commands/ai/prompt.rs | 43 ++- src-tauri/src/commands/ai/tool_registry.rs | 11 +- src-tauri/src/commands/ai/tools/file.rs | 19 +- .../src/commands/ai/tools/spawn_agent.rs | 6 +- src-tauri/src/lib.rs | 41 +-- src-tauri/src/logging.rs | 179 +++++++++ src-tauri/src/main.rs | 35 +- src-tauri/src/state.rs | 11 + src/components/ai/TaskParkedCard.vue | 15 +- 22 files changed, 1132 insertions(+), 136 deletions(-) create mode 100644 src-tauri/src/commands/ai/lease.rs create mode 100644 src-tauri/src/logging.rs diff --git a/crates/df-storage/src/crud/conversation_repo.rs b/crates/df-storage/src/crud/conversation_repo.rs index bd67ad1..5cfe2f9 100644 --- a/crates/df-storage/src/crud/conversation_repo.rs +++ b/crates/df-storage/src/crud/conversation_repo.rs @@ -110,6 +110,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> 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) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", + "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)", 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.boundary_json, rec.loop_state ], ) }, update => |conn, rec| { conn.execute( - "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14", + "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13, loop_state = ?14 WHERE id = ?15", params![ 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.updated_at, rec.boundary_json, rec.id + rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.loop_state, rec.id ], ) } @@ -809,6 +811,72 @@ impl AiConversationRepo { .map_err(storage_err)? } + /// loop 生命周期标记写入(V49 loop_state,断点续跑底座)。 + /// + /// state: Some("running")=任务 loop spawn 时置起;Some("parked")=重启扫描改写; + /// None=正常收敛/用户停止时清理。纯元数据标记不进 update_full(防 save_conversation + /// 高频全量写覆盖 loop 生命周期语义),对齐 set_boundary_json/set_archived 模式。 + /// 不动 updated_at(纯标记)。对话未落库(懒创建)时影响 0 行,调用方忽略。 + pub async fn set_loop_state(&self, id: &str, state: Option<&str>) -> Result { + let conn = self.conn.clone(); + let id = id.to_owned(); + let value = state.map(|s| s.to_owned()); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "UPDATE ai_conversations SET loop_state = ?1 WHERE id = ?2", + params![value, id], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + + /// 重启扫描:把所有 loop_state='running' 残留改写为 'parked'。 + /// + /// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死 + /// (内存态不复存在),残留 running 是假活。改写 parked 后侧栏读 list_recoverable + /// 展示「任务中断可恢复」。返回改写条数(0=无中断任务,静默)。 + pub async fn mark_running_as_parked(&self) -> Result { + let conn = self.conn.clone(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "UPDATE ai_conversations SET loop_state = 'parked' WHERE loop_state = 'running'", + [], + ) + .map_err(storage_err)?; + Ok(affected as u64) + }) + .await + .map_err(storage_err)? + } + + /// 列出可恢复会话(loop_state='parked',重启中断待一键恢复)。 + pub async fn list_parked(&self) -> Result> { + let conn = self.conn.clone(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let mut stmt = guard + .prepare( + "SELECT * FROM ai_conversations WHERE loop_state = 'parked' ORDER BY updated_at DESC", + ) + .map_err(storage_err)?; + let rows = stmt + .query_map([], ai_conversation_from_row) + .map_err(storage_err)? + .collect::, _>>() + .map_err(storage_err)?; + Ok(rows) + }) + .await + .map_err(storage_err)? + } + /// G1.3: 删除对话 + 其全部 ai_messages 子行(单事务原子)。 /// /// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 ai_messages 表无外键级联 diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index eabed6d..5fd16d7 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -57,7 +57,9 @@ pub fn run(conn: &Connection) -> Result<()> { // V46/V47 = 任务/灵感归档(archived INTEGER NOT NULL DEFAULT 0,归档功能 P1, // 对标 ai_conversations.archived V4;归档与软删正交:回收站=删除缓冲,归档=低频可见保检索)。 // V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。 - let steps: [(i32, fn(&Connection) -> Result<()>); 48] = [ + // V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座: + // running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。 + let steps: [(i32, fn(&Connection) -> Result<()>); 49] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -109,6 +111,8 @@ pub fn run(conn: &Connection) -> Result<()> { (47, migrate_v47), // V48 = 项目归档(projects.archived 列,归档功能 P2,对标 V46/V47 同款)。 (48, migrate_v48), + // V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。 + (49, migrate_v49), ]; for (version, migrate_fn) in steps { @@ -1427,6 +1431,25 @@ fn migrate_v48(conn: &Connection) -> Result<()> { Ok(()) } +/// V49: 幂等补 ai_conversations.loop_state 列(断点续跑底座) +/// +/// loop_state TEXT NULL:None=非任务会话/已收敛;"running"=任务 loop 在跑(spawn 时置起); +/// "parked"=重启扫描时 running 残留改写(侧栏标「任务中断可恢复」+ 一键恢复)。 +/// 对齐无人值守长任务设计 §4.5 拍板 A(不自动续跑,标记+一键恢复)。 +/// - **幂等**:column_exists 守卫;**可逆**:`ALTER TABLE ai_conversations DROP COLUMN loop_state`。 +fn migrate_v49(conn: &Connection) -> Result<()> { + if !column_exists(conn, "ai_conversations", "loop_state") { + conn.execute( + "ALTER TABLE ai_conversations ADD COLUMN loop_state TEXT", + [], + )?; + tracing::info!("v49: 补建 ai_conversations.loop_state 列(断点续跑标记)"); + } + conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [49])?; + tracing::info!("迁移 v49 完成: ai_conversations 加 loop_state 列"); + Ok(()) +} + /// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 @@ -2455,8 +2478,8 @@ mod tests { ) .expect("查 schema_version 应成功"); assert_eq!( - max_version, 48, - "全量迁移后 schema_version 应为 48(实际 {}),说明某条 migrate_vN 链路断在中间", + max_version, 49, + "全量迁移后 schema_version 应为 49(实际 {}),说明某条 migrate_vN 链路断在中间", max_version ); @@ -2533,7 +2556,7 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(max_v, 48, "首轮应推进到 48"); + assert_eq!(max_v, 49, "首轮应推进到 49"); // 清空版本表强制全链第二遍(每步 execute 第二次) conn.execute("DELETE FROM schema_version", []).unwrap(); @@ -2545,7 +2568,7 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(max_v2, 48, "重跑后应重新推进到 48"); + assert_eq!(max_v2, 49, "重跑后应重新推进到 49"); } // ============================================================ diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index 7ca93ac..7e752d1 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -390,6 +390,12 @@ pub struct AiConversationRecord { /// 无人值守任务边界快照(V45):TaskBoundary JSON;None=未声明(现状逐次审批)。 /// 生命周期=会话存续(发起时一次声明,随会话同亡),save 随行写/switch 读还原。 pub boundary_json: Option, + /// loop 生命周期标记(V49,断点续跑底座):None=非任务会话/已收敛清理; + /// Some("running")=任务 loop 在跑(spawn 时置起);Some("parked")=进程重启时检测到 + /// running 残留 → 启动扫描改写,侧栏标「任务中断可恢复」+ 一键恢复。 + /// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。 + #[serde(default)] + pub loop_state: Option, } /// AI 工具执行审计记录 diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 5ecfffd..39dce1c 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -181,6 +181,22 @@ pub const SKILL_LIST_MAX_ITEMS: usize = 120; /// 清单只做提示,截断防长描述(部分技能 description 数百字)撑爆 prompt,截断后接省略号。 pub const SKILL_DESC_MAX_CHARS: usize = 80; +/// 派工纪律母本(N1 通用能力内置沉淀):主 agent 派工纪律 8 行,措辞可微调不可删项(用户拍板)。 +/// +/// 为什么内置而非放知识库:知识库是分发面数据——新实例/新机器/知识库为空时纪律即失传(分发裸奔); +/// 编译进二进制随代码分发,零配置生效,且位于 system 稳定段(跨轮缓存命中,只付一次 token 成本)。 +/// 双路径同源:build_runtime_prompt(主会话,派工方)+ sub_agent_system_prompt(子代理,被派方) +/// 均拼接本母本,两侧共享同一套纪律文本,禁在别处另抄副本(改纪律只改这里)。 +pub(crate) const DISPATCH_DISCIPLINE: &str = r#"## 派工纪律 +1. 派工先估颗粒:单任务一轮内自证完成,装不下就拆批;独立子任务并行派。 +2. 任务书三要素:目标文件边界(热点文件单点编辑权)+预期时限+标准验证命令。 +3. 验收认证据:子代理须交"命令+结果",不收"我做完了";主代理独立抽查裁决。 +4. 子代理报告限长(约4千字),超长落盘只给指针;主代理消化后紧凑透出,禁全量转贴。 +5. 超时处置:到点发收敛令→无响应试探存活→确认死则主代理砍维度亲自收尾,勿重派同颗粒。 +6. 改代码禁全量验证:单文件快检,阶段收官统一验证一次。 +7. 派工前查重在途执行体,同类故障只委托一头。 +8. 结果透出结论先行,证据随附,过程不铺陈。"#; + /// G4 目标感知降级:话题标记 insert 当 pinned_goals 存在时跳过(默认 true)。 /// /// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的 @@ -457,6 +473,17 @@ pub(crate) async fn run_agentic_loop( return; } + // V49 断点续跑底座:loop 启动置 loop_state='running'(fire-and-forget,不阻塞启动路径; + // 正常收敛出口 emit_ai_completed_once 清理;进程重启时残留 running 由启动扫描改写 parked)。 + { + let db2 = db.clone(); + let cid = conv_id.clone(); + tokio::spawn(async move { + let repo = df_storage::crud::AiConversationRepo::new(&db2); + let _ = repo.set_loop_state(&cid, Some("running")).await; + }); + } + // 治本:LLM 生成间隙向前端发 AiHeartbeat,防前端 watchdog 误断流。 // 后端 emit AiHeartbeat → useAiEvents.ts resetStreamWatchdog(convId) → per-conv timer 重置。 // 解决:LLM 重试循环(~60s)期间无事件到达前端 → watchdog 45s/90s 到期误杀。 @@ -840,8 +867,8 @@ pub(crate) async fn run_agentic_loop( // 推理结果存入 pinned_goals: Vec,含 text+status 状态追踪。 // 新目标加入时自动标记之前的 active 为 completed。 // - // 入口 system_prompt 五段拼接(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / - // T4 工作流 DAG)收敛至 build_runtime_prompt helper:一次锁读 per_conv 三字段 + // 入口 system_prompt 六段拼接(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 派工纪律 / + // 技能清单 / T4 工作流 DAG)收敛至 build_runtime_prompt helper:一次锁读 per_conv 三字段 // (pinned_goals / working_context / workflow_dag_summary)消除原 G1/T5/T4 三处独立 lock, // 且返回 pinned_goals 复用作 AiCompleted 快照(消除原 L1157 二次读)。拼接顺序+内容逐字节等价。 let (system_prompt, pinned_goals_snapshot) = build_runtime_prompt( @@ -1078,7 +1105,7 @@ pub(crate) async fn run_agentic_loop( // ── 进度可观测段(设计 §4.3):每 N 轮 emit AiTaskProgress ── // round 1 起算对齐 AiAgentRound 口径;max_rounds=0=不限轮次(前端按无上限处理)。 // owner 守卫:被接管的旧 loop 不发(防串会话进度)。事件即逝不落库,终局不补发。 - if iteration % PROGRESS_EVERY_N_ROUNDS == 0 + if (iteration % PROGRESS_EVERY_N_ROUNDS == 0 || iteration == start_iteration) && loop_epoch_arc.load(Ordering::SeqCst) == my_epoch { emit_ai_chat_event(&app_handle, AiChatEvent::AiTaskProgress { @@ -2570,6 +2597,17 @@ pub(crate) async fn emit_ai_completed_once( super::commands::chat::finalize_pending_placeholders(&mut *session_guard, conv_id, "生成完成"); session_guard.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(conv_id)); } + // V49:正常收敛出口清理 loop_state(fire-and-forget 独立 spawn,不与上面的锁段耦合; + // 进程内 loop 死亡的残留 running 由启动扫描兜底)。 + { + let app_state = app_handle.state::(); + let db2 = app_state.db.clone(); + let cid = conv_id.to_string(); + tokio::spawn(async move { + let repo = df_storage::crud::AiConversationRepo::new(&db2); + let _ = repo.set_loop_state(&cid, None).await; + }); + } // emit 端(前端通道):incomplete 用 emit_incomplete(MidStream 传 Some(true))。 // token 分项(2026-08-02):cache_hit/cache_miss/reasoning 透传前端分计费展示。 // publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为); @@ -2578,6 +2616,9 @@ pub(crate) async fn emit_ai_completed_once( let ev = completed_event(usage, is_estimated, emit_incomplete, incomplete_reason, conv_id, pinned_goals); if do_publish && emit_incomplete == publish_incomplete { emit_ai_chat_event(app_handle, ev); + // P0 修复(2026-10-05):正常完成恒走此早退,完成通知桥必须在此投递—— + // 否则等待方(编排节点/飞书渠道)永远收不到完成通知(实证:等待方 30 分钟超时)。 + crate::commands::ai::commands::chat::deliver_completion_report_if_waited(app_handle, conv_id).await; return; } // 有意不走 emit_ai_chat_event(emit/publish 两端 incomplete 标志分叉,见上注释)—— @@ -2602,18 +2643,8 @@ pub(crate) async fn emit_ai_completed_once( // 本函数是全部正常出口(5 类 teardown_round 出口 + 正常完成直调)的统一单点, // 到达者必为 owner loop(epoch 守卫在各调用方:teardown_round 入口 / 正常完成双检), // force_send 接管后的旧 loop 不触发。无等待方(普通用户会话)has 判空短路,零会话锁开销。 - { - let app_state = app_handle.state::(); - if app_state.has_agent_completion_waiter(conv_id) { - let report = last_active_assistant_text(&app_state.ai_session, conv_id).await; - let delivered = app_state.notify_agent_completion(conv_id, report); - tracing::info!( - conv_id = %conv_id, - delivered, - "[ai] 编排会话收敛,完成通知桥投递最终报告" - ); - } - } + // review ④:内联块收敛到 deliver_completion_report_if_waited 单实现(与上方早退分支同源)。 + crate::commands::ai::commands::chat::deliver_completion_report_if_waited(app_handle, conv_id).await; } /// 取会话末条 active assistant 消息文本(编排会话最终报告投递用)。 @@ -2621,7 +2652,7 @@ pub(crate) async fn emit_ai_completed_once( /// conv 不存在 / 无 assistant 消息返空串,原样投递,空文本由节点侧判「无输出」失败 /// (用户 stop 在任何输出前的场景)。读内存 per_conv 非常规出口:正常完成路径 save 落库 /// 在后台 spawn 里,时序上可能晚于本函数,内存消息是唯一已确定就绪的真相源。 -async fn last_active_assistant_text( +pub(crate) async fn last_active_assistant_text( session_arc: &Arc>, conv_id: &str, ) -> String { @@ -2663,9 +2694,10 @@ fn completed_event( } } -// ── build_runtime_prompt: run_agentic_loop 入口 system_prompt 五段拼接 ── +// ── build_runtime_prompt: run_agentic_loop 入口 system_prompt 六段拼接 ── // // 抽自原 L1024-1152(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / T4 工作流 DAG)。 +// N1 增量:新增派工纪律段(DISPATCH_DISCIPLINE 母本内置,拼在 behavior 后),非原五段内容。 // **行为逐字节等价**:拼接顺序与内容不变,开关(GOAL_PIN_ENABLED / SKILL_LIST_INJECT_ENABLED / // GOAL_INJECT_BANNER)与截断(GOAL_MAX_CHARS / SKILL_LIST_MAX_ITEMS / SKILL_DESC_MAX_CHARS)常量原样引用。 // @@ -2757,6 +2789,11 @@ async fn build_runtime_prompt( ); system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt); + // N1 派工纪律内置注入:母本编译进二进制(知识库为分发面数据,新实例知识库为空即裸奔, + // 内置零配置生效)。与 sub_agent_system_prompt 同源引用 DISPATCH_DISCIPLINE, + // 主 agent(派工方)受约束,子代理(被派方)同样知晓纪律。 + system_prompt = format!("{}\n\n{}", system_prompt, DISPATCH_DISCIPLINE); + // 技能清单注入:skills_cached() 进程内缓存(懒扫描一次,后续零开销),无锁需求。 if SKILL_LIST_INJECT_ENABLED { let skills = crate::commands::ai::skills::skills_cached().await; diff --git a/src-tauri/src/commands/ai/agentic/sub_agent.rs b/src-tauri/src/commands/ai/agentic/sub_agent.rs index 0485fbe..c70ab06 100644 --- a/src-tauri/src/commands/ai/agentic/sub_agent.rs +++ b/src-tauri/src/commands/ai/agentic/sub_agent.rs @@ -86,7 +86,9 @@ const SUB_AGENT_ORIGIN: &str = "sub_agent"; // ============================================================ /// 子代理默认最大工具循环轮数(spawn 参数 max_rounds 缺省值,透传可调)。 -pub(crate) const SUB_AGENT_DEFAULT_MAX_ROUNDS: u32 = 6; +/// 12 的依据:狗粮实证(2026-10-04,~810 次工具调用)父侧试探性把缺省 6 提到 10/20, +/// 6 轮对探索检索类任务偏小(一次死磕即耗尽);20 顶格留给重型任务显式传参。 +pub(crate) const SUB_AGENT_DEFAULT_MAX_ROUNDS: u32 = 12; /// max_rounds 入口钳制上限(防 spawn 参数放大预算失控)。 pub(crate) const SUB_AGENT_MAX_ROUNDS_CAP: u32 = 20; @@ -460,6 +462,7 @@ pub(crate) fn append_rejected_section(summary: String, rejected: &[(String, u32) /// 替换「默认最多 6 轮」类硬编码文案——父可传 1..20,子代理必须按真实预算规划探索节奏, /// 预算将尽时主动收尾交卷,而非被截断后拿末轮过程性独白当报告。 /// 工具纪律段经 prompt::sub_agent_tool_discipline_section 同源拼接(P1-1)。 +/// 派工纪律段经 super::DISPATCH_DISCIPLINE 母本同源拼接(N1),与主会话共享同一文本。 pub(crate) fn sub_agent_system_prompt(max_rounds: u32) -> String { let mut p = format!( "你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\ @@ -483,6 +486,10 @@ pub(crate) fn sub_agent_system_prompt(max_rounds: u32) -> String { // 纪律/禁臆造 URL/查配置用 get_app_config);obscura 安装引导等交互条款已在共享函数 // 内裁剪,不适用于子代理。 p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section("zh")); + // 派工纪律同源注入(N1):引用父模块母本常量,与主会话 build_runtime_prompt 共享同一文本 + // (报告限长/结论先行/证据随附直接约束子代理输出形态,若再派工亦同规约束); + // 内置于二进制而非知识库,防新实例知识库为空时纪律失传。 + p.push_str(&format!("\n\n{}", super::DISPATCH_DISCIPLINE)); p } diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 47e815e..91be415 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -253,6 +253,33 @@ pub(crate) async fn execute_with_heartbeat( use std::sync::atomic::{AtomicBool, Ordering}; use tokio::time::Duration; + // ── W-h1 感知租约:写类文件工具执行前置校验(跨会话互斥,治互踩三连) ── + // 写类(write_file/patch_file/append_file/delete_file/rename_file 等:非 readonly + // 且有 path_args)执行前 acquire;任一路径被**其他会话**占用 → 立即返错(拒绝文案 + // 指路占用方),不执行——LLM 拿到可行动错误自行等/换文件。获取成功 → LeaseGuard + // RAII 随本函数任意退出路径(Ok/Err/panic unwind/超时 drop)自动释放。 + // 同会话重复 acquire 放行(同会话工具串行,连续编辑同一文件是正常模式)。 + // agent_kind 判定:子代理调用链经 spawn_agent task-local CALLER_CONV 标记,此处 + // conv_id 即 caller_conv;主/子区分用执行上下文(子代理 process_tool_calls 传 + // SubAgent policy 但本函数无该参,故用名字启发不值得——统一按"main"登记不影响 + // 互斥正确性,agent_kind 仅指路文案用)。 + let _lease_guard = if super::lease::is_write_file_tool(name) { + match super::lease::acquire_leases(conv_id, "main", name, tool_call_id, &args) { + Ok(g) => Some(g), + Err(reason) => { + tracing::warn!( + conv_id = %conv_id, + tool = %name, + tool_call_id = %tool_call_id, + "[lease] 写类工具被文件租约拒绝: {reason}" + ); + return Err(anyhow::anyhow!("文件占用冲突: {reason}")); + } + } + } else { + None + }; + // RAII guard:drop 时 stop+abort 心跳 task,确保 execute panic(unwind)也不泄漏心跳 task。 struct HeartbeatGuard { stop: Arc, diff --git a/src-tauri/src/commands/ai/audit/record.rs b/src-tauri/src/commands/ai/audit/record.rs index 2cd5035..c6c377d 100644 --- a/src-tauri/src/commands/ai/audit/record.rs +++ b/src-tauri/src/commands/ai/audit/record.rs @@ -600,6 +600,7 @@ mod tests { updated_at: "1".to_string(), origin: origin.map(|s| s.to_string()), boundary_json: None, + loop_state: None, }) .await .expect("测试会话插入应成功"); diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 2a724f2..870cc5c 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -913,6 +913,18 @@ pub(crate) async fn spawn_agentic_session( /// 注册先于 spawn:完成通知最早发生在 loop 收敛出口(秒级之后),不存在注册前触发竞态; /// 顺序对调则有「先收敛后注册」丢通知窗口。返回的接收端由节点 select! 等待;发送端挂在 /// 通知桥,agentic loop 收敛统一出口(emit_ai_completed_once)投递最终 assistant 报告。 +/// 完成通知桥投递(统一单点):有等待方才从会话内存取最终报告并投递,无等待方零开销短路。 +/// 调用方:agent loop 全部完成出口(含正常完成早退分支)——修复前早退分支不投递, +/// 编排/飞书等待方永远收不到完成通知(P0,2026-10-05 飞书渠道 30 分钟超时实证)。 +pub(crate) async fn deliver_completion_report_if_waited(app_handle: &AppHandle, conv_id: &str) { + let app_state = app_handle.state::(); + if !app_state.has_agent_completion_waiter(conv_id) { + return; + } + let report = crate::commands::ai::agentic::last_active_assistant_text(&app_state.ai_session, conv_id).await; + app_state.notify_agent_completion(conv_id, report); +} + pub(crate) fn orchestration_session_spawner() -> df_nodes::ai_agent_node::SpawnFn { std::sync::Arc::new(|task: String| -> df_nodes::ai_agent_node::SpawnFuture { Box::pin(async move { diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index 9f79c4c..77bf587 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -218,6 +218,9 @@ pub async fn ai_conversation_create( /// 列出对话(仅摘要,不含 messages 全文) /// /// limit 默认 50 防数据膨胀;include_archived 默认 false(归档对话默认隐藏)。 +/// +/// 截断按组独立:活跃/归档各自取 limit,归档项不挤占活跃名额(侧栏归档分组数据源依赖 +/// include_archived=true,见 useAiConversations.loadConversations)。 #[tauri::command] pub async fn ai_conversation_list( state: State<'_, AppState>, @@ -231,7 +234,6 @@ pub async fn ai_conversation_list( // origin 非空(子代理轨迹,V44)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。 let summaries: Vec = records.iter() .filter(|r| (include_archived || !r.archived) && r.origin.is_none()) - .take(limit) .map(|r| { // 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组 let models: Vec = r.models.as_deref() @@ -257,7 +259,20 @@ pub async fn ai_conversation_list( "updated_at": r.updated_at, }) }).collect(); - Ok(summaries) + // 分组截断:活跃/归档各自独立取 limit,防归档项挤占活跃名额(前端侧栏活跃列表被截空)。 + // 原单 take(limit) 在 include_archived=true 时归档项混排会占满 50 名额。 + let mut active = Vec::with_capacity(limit); + let mut archived = Vec::with_capacity(limit); + for s in summaries { + if s.get("archived").and_then(|v| v.as_bool()) == Some(true) { + if archived.len() < limit { archived.push(s); } + } else if active.len() < limit { + active.push(s); + } + } + // 活跃在前(侧栏主区),归档在后(归档分组);各自保持 created_at DESC 原序。 + active.extend(archived); + Ok(active) } /// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端) @@ -1469,6 +1484,7 @@ mod tests { updated_at: created_at.to_string(), origin: origin.map(|s| s.to_string()), boundary_json: None, + loop_state: None, }) .await .expect("测试会话插入应成功"); diff --git a/src-tauri/src/commands/ai/conversation.rs b/src-tauri/src/commands/ai/conversation.rs index 44032c0..dee94b3 100644 --- a/src-tauri/src/commands/ai/conversation.rs +++ b/src-tauri/src/commands/ai/conversation.rs @@ -531,6 +531,7 @@ async fn save_conversation_inner( boundary_json: conv_boundary.as_ref() .map(|b| serde_json::to_string(b).unwrap_or_default()) .filter(|s| !s.is_empty()), + loop_state: None, }; if let Err(e) = conv_repo.insert(rec).await { tracing::warn!("落库对话失败 {conv_id}: {e}"); diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index 6a7e8d2..c02a817 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -1,6 +1,7 @@ //! 知识库集成 — 注入 + 提炼(嵌入生成 / 混合检索 / 上下文构建 / 对话提炼) use std::collections::HashMap; +use std::panic::AssertUnwindSafe; use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use tokio::sync::Mutex; @@ -13,7 +14,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; use df_ai::router::{ is_likely_image_model, select_model_id, Modality, TaskRequirements, }; -use df_storage::crud::{AiConversationRepo, AiMessageRepo, AiProviderRepo, KnowledgeRepo}; +use df_storage::crud::{AiConversationRepo, AiMessageRepo, AiProviderRepo, KnowledgeRepo, ProjectRepo}; use df_storage::db::Database; use df_storage::models::{AiProviderRecord, KnowledgeRecord}; @@ -21,6 +22,8 @@ use df_types::types::new_id; use crate::state::{AppState, ExtractTrigger, LlmConcurrency, log_slow_lock}; +use super::boundary::TaskBoundary; + use crate::commands::ai::audit::truncate_chars_ellipsis; use crate::commands::err_str; @@ -411,16 +414,21 @@ pub async fn retry_failed_embeddings(state: &AppState) -> anyhow::Result /// vector_enabled 关闭或 embed 失败时纯 LIKE(零外部依赖降级)。 /// LIKE 路 query 先经 [`extract_query_keywords`] 分词(整句 LIKE 命中率≈0); /// 向量路 query 同步改用关键词拼接(虚词稀释 embedding 语义),分词为空时回退原句。 +/// +/// `project_id`(跨会话召回 D2):Some 时 LIKE 路走项目软排序(同项目知识排前, +/// limit 截断时优先保留);None 与旧路径逐字一致。向量路不做项目过滤(sqlite-vec +/// 无列过滤,软排序在 merge 阶段同样以 keyword 路排序为准)。 async fn hybrid_search( state: &AppState, query: &str, limit: usize, config: &crate::state::KnowledgeConfig, + project_id: Option<&str>, ) -> Vec { let keywords = extract_query_keywords(query); let keyword_results = match state .knowledge - .search_keywords(&keywords, config.include_candidates, limit) + .search_keywords_scoped(&keywords, config.include_candidates, limit, project_id) .await { Ok(v) => v, @@ -507,7 +515,10 @@ pub(crate) async fn build_knowledge_context( if !config.auto_inject { return String::new(); } - let results = hybrid_search(state, query, 3, config).await; + // 跨会话召回(D2 软排序):当前会话归属项目 → 注入侧同项目知识优先。 + // 弱推断失败(None)=全局会话,不过滤,与旧行为逐字一致(回归门)。 + let current_project = resolve_source_project(&state.db, conv_id, &[]).await; + let results = hybrid_search(state, query, 3, config, current_project.as_deref()).await; if results.is_empty() { return String::new(); } @@ -713,7 +724,8 @@ pub(crate) async fn maybe_spawn_extraction( let conv_id = conv_id.to_string(); let provider_config = provider_config.clone(); let llm_concurrency = llm_concurrency.clone(); - tauri::async_runtime::spawn(async move { + // 占位兜底同手动路径:panic 出口清 InFlight,防自动提炼永久卡死(见 spawn_with_inflight_guard)。 + spawn_with_inflight_guard(conv_id.clone(), async move { match extract_knowledge_from_conversation(&db, &conv_id, &provider_config, &llm_concurrency).await { Ok(inserted) => { if inserted > 0 { @@ -781,44 +793,96 @@ pub(crate) async fn maybe_spawn_extraction( /// /// P1 修复(提炼重复触发):手动路径强制清 knowledge_extracted 去重标志, /// 允许用户手动重提炼(语义=手动覆盖自动去重)。 +/// +/// panic 兜底包装(治走查🟡):占位(ExtractWatermark::InFlight)置位后,若后续任务体 +/// panic(tokio spawn 默认吞 panic 不清状态),水位会永久卡 InFlight,自动提炼从此全跳过 +/// 且手动路径也被并发占位拒——无补救通道。AssertUnwindSafe + catch_unwind 保证任何 +/// panic 出口都记 Done 清占位(与 Err 出口同语义,保节奏),next 提炼可恢复。提取流水线 +/// 持 Arc/Arc 等跨 unwind 引用,故用 AssertUnwindSafe(非 UnwindSafe)。 +fn spawn_with_inflight_guard(conv_id: String, fut: F) +where + F: std::future::Future + Send + 'static, +{ + tauri::async_runtime::spawn(async move { + let cid = conv_id.clone(); + let result = futures::future::FutureExt::catch_unwind(AssertUnwindSafe(fut)).await; + if result.is_err() { + tracing::error!(conv_id = %cid, "[knowledge] 提炼任务 panic,清 InFlight 占位恢复水位"); + EXTRACT_WATERMARKS + .lock() + .expect("提炼水位互斥锁中毒") + .insert(cid, ExtractWatermark::Done(0)); + } + }); +} + pub async fn trigger_extraction_now(state: &AppState) -> Result { let conv_id = { let mut session = state.ai_session.lock().await; let conv_id = session.active_conversation_id.clone(); // 强制清哨兵 + 频次水位:手动提炼是用户显式动作,绕过自动路径的间隔限制(语义=手动覆盖)。 + // 注意只清 Done(自动节奏水位),InFlight 并发占位保留——清掉会让下方占位检查永假, + // 连点按钮即并发重复提炼(占位语义优先于手动覆盖)。 if let Some(cid) = conv_id.as_deref() { session.conv(cid).knowledge_extracted = false; - EXTRACT_WATERMARKS - .lock() - .expect("提炼水位互斥锁中毒") - .remove(cid); + let mut wms = EXTRACT_WATERMARKS.lock().expect("提炼水位互斥锁中毒"); + if matches!(wms.get(cid), Some(ExtractWatermark::Done(_))) { + wms.remove(cid); + } } conv_id }; let conv_id = conv_id.ok_or_else(|| "当前无活跃对话".to_string())?; + // 并发占位(治走查🟡):连点按钮/双入口(ManualOnly+按钮)此前无占位会并发跑多个提炼任务 + // 重复刷 candidate。已 InFlight → 有提炼在跑,返回 false(前端仅刷新列表,无副作用)。 + { + let mut wms = EXTRACT_WATERMARKS.lock().expect("提炼水位互斥锁中毒"); + if matches!(wms.get(&conv_id), Some(ExtractWatermark::InFlight)) { + tracing::info!(conv_id = %conv_id, "[knowledge] 手动提炼跳过:已有提炼任务进行中"); + return Ok(false); + } + wms.insert(conv_id.clone(), ExtractWatermark::InFlight); + } let provider_config = super::prompt::get_active_provider(state).await.map_err(err_str)?; let db = state.db.clone(); let session_arc = state.ai_session.clone(); let llm_concurrency = state.llm_concurrency.clone(); - tauri::async_runtime::spawn(async move { + spawn_with_inflight_guard(conv_id.clone(), async move { match extract_knowledge_from_conversation(&db, &conv_id, &provider_config, &llm_concurrency).await { Ok(inserted) => { - // 手动提炼成功(≥1 条)后与自动路径同语义收口:置哨兵 + 记水位, - // 避免手动提炼后再触发自动提炼重复。用户再次手动按按钮会再次清位,行为自洽。 + // 手动提炼后与自动路径同语义收口:≥1 条置哨兵;所有出口记 Done 水位 + // (占位已置,任何出口不清位会永久卡死后续自动提炼)。 + let mut session = session_arc.lock().await; + let rounds = session + .conv_read(&conv_id) + .map(|c| count_user_rounds(c.messages.iter())) + .unwrap_or(0); if inserted > 0 { - let mut session = session_arc.lock().await; session.conv(&conv_id).knowledge_extracted = true; - let rounds = session - .conv_read(&conv_id) - .map(|c| count_user_rounds(c.messages.iter())) - .unwrap_or(0); - EXTRACT_WATERMARKS - .lock() - .expect("提炼水位互斥锁中毒") - .insert(conv_id.clone(), ExtractWatermark::Done(rounds)); } + EXTRACT_WATERMARKS + .lock() + .expect("提炼水位互斥锁中毒") + .insert(conv_id.clone(), ExtractWatermark::Done(rounds)); + tracing::info!( + conv_id = %conv_id, inserted, rounds, + "[knowledge] 手动提炼完成,水位记为当前进度,新增用户消息达阈值后才再自动提炼" + ); + } + Err(e) => { + tracing::warn!("手动提炼失败(非阻断): {}", e); + // 失败出口同样记 Done 清占位(防 InFlight 永久卡死),保节奏与自动路径一致。 + // (只读 conv_read + 写水位表,无需 mut session——原 Err 分支因清哨兵才要 mut) + let session = session_arc.lock().await; + let rounds = session + .conv_read(&conv_id) + .map(|c| count_user_rounds(c.messages.iter())) + .unwrap_or(0); + EXTRACT_WATERMARKS + .lock() + .expect("提炼水位互斥锁中毒") + .insert(conv_id.clone(), ExtractWatermark::Done(rounds)); } - Err(e) => tracing::warn!("手动提炼失败(非阻断): {}", e), } }); Ok(true) @@ -838,6 +902,97 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话 4. 输出纯 JSON,无任何额外文字\n\ 5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)"; +/// 项目归属弱推断(跨会话召回最小闭环,设计 D1:a→b 两级,皆无 → None=全局知识)。 +/// +/// 级 a(高置信):读 `ai_conversations.boundary_json`(V45 无人值守边界)的 `dir_allowlist` +/// 首条目录,反查 projects 表 path 前缀匹配(canonicalize 归一口径对齐 `dir_covered`)。 +/// boundary 是显式任务边界声明,视为用户主动绑定项目上下文。 +/// 级 b(中置信,B1 拍板:mention 唯一才启用):反向扫描最近消息找 `[项目:名称]` enrichment +/// 标记形态(chat.rs extract_pinned_goal 同款字面量),命中集合恰好唯一时按名称匹配 +/// projects.name;零命中或多项目不猜,置 None 防张冠李戴。 +/// +/// 两级皆不命中 → None(全局知识,注入侧不过滤,行为与存量一致)。 +/// DB 查询失败一律降级 None(warn 留痕,非阻断:归属推断失败不该杀死提炼主流程)。 +async fn resolve_source_project(db: &Arc, conv_id: &str, messages: &[ChatMessage]) -> Option { + let project_repo = ProjectRepo::new(db); + let conv_repo = AiConversationRepo::new(db); + + // 级 a:boundary_json.dir_allowlist 首条 → projects.path 前缀匹配 + if let Ok(Some(conv)) = conv_repo.get_by_id(conv_id).await { + if let Some(bj) = conv.boundary_json.as_deref().filter(|s| !s.is_empty()) { + match serde_json::from_str::(bj) { + Ok(boundary) => { + if let Some(dir) = boundary.dir_allowlist.first().filter(|d| !d.is_empty()) { + match project_repo.list_active().await { + Ok(projects) => { + let dir_norm = normalize_dir_for_match(dir); + // path 前缀匹配:项目根目录 ∈ boundary 白名单目录即归属 + // (白名单常填项目根本身或其子目录;取首条命中,白名单多目录 + // 跨项目时本就不该猜,取首条是确定性选择) + if let Some(p) = projects.iter().find(|p| { + p.path.as_deref() + .map(|pp| normalize_dir_for_match(pp).starts_with(&dir_norm) + || dir_norm.starts_with(&normalize_dir_for_match(pp))) + .unwrap_or(false) + }) { + return Some(p.id.clone()); + } + } + Err(e) => tracing::warn!(error = %e, conv_id, "[KNOWLEDGE-EXTRACT] 项目列表查询失败,级 a 归属跳过"), + } + } + } + Err(e) => tracing::warn!(error = %e, conv_id, "[KNOWLEDGE-EXTRACT] boundary_json 解析失败,级 a 归属跳过"), + } + } + } + + // 级 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]; + 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()); + } + } + } + + None +} + +/// 目录形态归一(归属匹配专用,轻量词法版):斜杠统一 + 去尾分隔符 + 小写(Windows 盘符 +/// 大小写差异)。不做 canonicalize(提炼路径是后台 best-effort 推断,不付 IO 成本; +/// boundary.dir_covered 已在审批判定侧做过严格归一,此处宽松形态匹配足够)。 +fn normalize_dir_for_match(dir: &str) -> String { + let mut s = dir.replace('\\', "/"); + while s.len() > 1 && s.ends_with('/') { + s.pop(); + } + s.to_lowercase() +} + /// 从对话中提炼知识,产出 candidate 写入知识库 /// /// 流程: 读对话消息 → 过滤 user/assistant 取最近 [`KNOWLEDGE_EXTRACT_WINDOW`] 条 → @@ -1011,6 +1166,11 @@ async fn extract_knowledge_from_conversation( let knowledge_repo = KnowledgeRepo::new(db); let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(db); + // 跨会话召回(D1 弱推断):本轮产出知识归属项目。None=全局知识(行为与存量一致)。 + let source_project = resolve_source_project(db, conv_id, &messages).await; + if let Some(pid) = &source_project { + tracing::info!(conv_id, project_id = %pid, "[KNOWLEDGE-EXTRACT] 本轮产出知识归属项目"); + } let mut inserted = 0; for item in &items { let kind = match item.get("kind").and_then(|v| v.as_str()) { @@ -1038,7 +1198,8 @@ async fn extract_knowledge_from_conversation( confidence, reuse_count: 0, verified: false, - source_project: None, + // 跨会话召回(D1):归属项目弱推断结果(级 a boundary/级 b 唯一 mention),None=全局 + source_project: source_project.clone(), // F-260619-04 P1 消息级溯源:conv_msg:{assistant_msg_id} 精确定位本轮 AI 产出。 // last_assistant_msg_id 为 None(老数据无 id)→ 降级 conv:{conv_id} 对话级, // 展示侧双格式解析兼容。 @@ -1393,4 +1554,102 @@ mod salvage_tests { assert!(salvage_truncated_json_array(r#"{"kind":"pitfall""#).is_none(), "无完整闭合元素"); assert!(salvage_truncated_json_array("[").is_none()); } + + // ---------- 项目归属弱推断(D1 跨会话召回) ---------- + + use crate::commands::ai::boundary::TaskBoundary as BoundaryForTest; + use df_storage::models::AiConversationRecord; + + /// 精简 ChatMessage 构造(user 消息,走 df-ai-core 正典构造器) + fn um(content: &str) -> ChatMessage { + ChatMessage::user(content) + } + + /// 插入测试项目 + async fn ins_project(db: &Arc, id: &str, name: &str, path: Option<&str>) { + df_storage::crud::ProjectRepo::new(db) + .insert(df_storage::models::ProjectRecord { + id: id.into(), + name: name.into(), + description: String::new(), + status: df_types::types::ProjectStatus::Planning, + idea_id: None, + path: path.map(|s| s.into()), + stack: None, + archived: false, + created_at: "2026-01-01".into(), + updated_at: "2026-01-01".into(), + }) + .await + .unwrap(); + } + + /// 插入测试会话(带可选 boundary_json) + async fn ins_conv(db: &Arc, id: &str, boundary: Option<&BoundaryForTest>) { + df_storage::crud::AiConversationRepo::new(db) + .insert(AiConversationRecord { + id: id.into(), + title: Some("t".into()), + messages: "[]".into(), + provider_id: None, + model: None, + models: None, + archived: false, + pinned: false, + prompt_tokens: None, + completion_tokens: None, + pinned_goals: None, + pending_approvals: None, + created_at: "2026-01-01".into(), + updated_at: "2026-01-01".into(), + origin: None, + boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()), + loop_state: None, + }) + .await + .unwrap(); + } + + /// 目录归一:反斜杠→斜杠 + 去尾分隔 + 小写 + #[test] + fn normalize_dir_slash_and_case() { + assert_eq!(normalize_dir_for_match("E:\\wk-lab\\DevFlow\\"), "e:/wk-lab/devflow"); + assert_eq!(normalize_dir_for_match("e:/wk-lab/devflow"), "e:/wk-lab/devflow"); + assert_eq!(normalize_dir_for_match("/"), "/"); + assert_eq!(normalize_dir_for_match(""), ""); + } + + /// 级 a:boundary_json.dir_allowlist 指向项目根本身 → 归属该项目 + #[tokio::test] + async fn resolve_tier_a_boundary_dir_hits_project_root() { + let db = Arc::new(Database::open_in_memory().await.unwrap()); + ins_project(&db, "p-boundary", "边界项目", Some("E:/wk-lab/boundary-proj")).await; + let boundary = BoundaryForTest { + risk_ceiling: df_ai::ai_tools::RiskLevel::Medium, + dir_allowlist: vec!["e:\\wk-lab\\boundary-proj\\".into()], + forbid_destructive: true, + budget: None, + }; + ins_conv(&db, "conv-a", Some(&boundary)).await; + + let got = resolve_source_project(&db, "conv-a", &[]).await; + assert_eq!(got.as_deref(), Some("p-boundary"), "级 a:boundary 白名单目录应归属项目"); + } + + /// 级 b:唯一 `[项目:名称]` mention 才归属;多项目/零 mention 不猜(B1 拍板) + #[tokio::test] + async fn resolve_tier_b_unique_mention_only() { + let db = Arc::new(Database::open_in_memory().await.unwrap()); + ins_project(&db, "p-1", "项目甲", None).await; + ins_project(&db, "p-2", "项目乙", None).await; + + // 唯一 mention → 命中 + let msgs = vec![um("修一下 [项目: 项目甲] 的列表页")]; + assert_eq!(resolve_source_project(&db, "conv-ghost", &msgs).await.as_deref(), Some("p-1")); + // 多项目 mention → 不猜(None) + let msgs = vec![um("[项目:项目甲] 和 [项目:项目乙] 谁对")]; + assert_eq!(resolve_source_project(&db, "conv-ghost", &msgs).await, None); + // 零 mention → None + assert_eq!(resolve_source_project(&db, "conv-ghost", &[um("普通消息")]).await, None); + } } diff --git a/src-tauri/src/commands/ai/lease.rs b/src-tauri/src/commands/ai/lease.rs new file mode 100644 index 0000000..21a3604 --- /dev/null +++ b/src-tauri/src/commands/ai/lease.rs @@ -0,0 +1,345 @@ +//! 文件租约注册表(W-h1 感知租约,设计文档《并行化与异步唤醒-2026-10-04》§10.1) +//! +//! 治「互踩三连」(双头编排/子代理同文件/主客两线叠改):写类文件工具执行前登记 +//! path→(会话,工具,调用id) 到进程级注册表,执行完成(RAII)释放;同路径第二写者 +//! 前置校验命中 → 拒绝 + 指路「该文件正被会话 B 编辑」。同会话同文件重复写 +//! (同 conv 续写多段)放行——租约语义是**跨会话/跨代理互斥**,同会话内串行执行 +//! 天然无竞态(单 loop 顺序执行工具)。 +//! +//! 与 W-a 二维分组器互补:分组器管同轮内保序,registry 管跨轮跨会话互斥。 +//! +//! ## TTL 兜底 +//! 租约带 10min TTL(取 execute_with_heartbeat 最长 outer_secs=600s 上限同源), +//! 崩溃/超时路径 guard drop 泄漏时由 try_acquire 惰性过期清理,无需后台任务。 +//! +//! ## 键归一 +//! path 按平台 case 折叠(Windows 不敏感/Unix 敏感)+ 前后斜杠归一,防同一文件 +//! 大小写/斜杠变体绕过互斥。语义化路径(如 a/../b)不做 resolve——工具层传参 +//! 惯例为规范绝对路径,过度归一反而引入 surprise。 +//! +//! ## 单测 +//! 见文末 tests 模块:互斥/同会话放行/释放/TTL 过期/键归一五组。 + +use std::collections::HashMap; +use std::sync::LazyLock; +use std::sync::RwLock; +use std::time::{Duration, Instant}; + +/// 租约 TTL:与 execute_with_heartbeat outer_secs 上限(600s)同源加缓冲。 +/// guard 正常 drop 走显式释放,TTL 仅兜底异常泄漏(进程内 panic unwind 被 +/// catch 层接住后 guard 仍会 drop;此处防「未来新增执行路径忘记 RAII」)。 +pub(crate) const LEASE_TTL: Duration = Duration::from_secs(660); + +/// 租约持有者信息(指路文案的数据源) +#[derive(Debug, Clone)] +pub(crate) struct LeaseHolder { + pub conv_id: String, + /// 会话内身份:main=主会话,sub=子代理(父会话下发的子任务) + pub agent_kind: &'static str, + pub tool_name: String, + /// 持有该租约的 tool_call_id(诊断/未来按调用粒度让位用;query 输出未含,防噪声) + #[allow(dead_code)] + pub tool_call_id: String, + pub acquired_at: Instant, +} + +impl LeaseHolder { + fn expired(&self, now: Instant) -> bool { + now.duration_since(self.acquired_at) > LEASE_TTL + } + + /// 指路文案:占用方是谁+多久了+建议动作 + fn describe(&self) -> String { + let elapsed = self.acquired_at.elapsed().as_secs(); + let who = if self.agent_kind == "sub" { "子代理" } else { "主会话" }; + format!( + "该文件正被另一会话的{who}占用中(工具 {tool},已持 {elapsed}s)。为避免并发写互踩,本次调用被拒绝;请等占用方完成后再重试,或先编辑其他文件。", + who = who, + tool = self.tool_name, + elapsed = elapsed + ) + } +} + +/// 进程级租约表。写类文件工具是唯一的 acquire/release 入口,query_leases 工具只读。 +static REGISTRY: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +/// path 归一键:Windows 不区分大小写,Unix 区分;斜杠统一。 +fn lease_key(path: &str) -> String { + let norm = path.trim().replace('\\', "/"); + if cfg!(windows) { + norm.to_lowercase() + } else { + norm + } +} + +/// 写类文件工具判定:非只读 ∧ 声明了 path 参数键 = 会改文件的写工具。 +/// 覆盖 write_file/patch_file/append_file/delete_file/rename_file(from,to 双键 +/// 全部登记,移动目标也锁)。run_command 不在此列——命令副作用面不可静态判定, +/// 属 W-a 分组器职责,registry 不越界。 +pub(crate) fn is_write_file_tool(tool_name: &str) -> bool { + let meta = df_ai::ai_tools::tool_meta(tool_name); + match meta { + Some(m) => !m.readonly && !m.path_args.is_empty(), + None => false, + } +} + +/// 从 args 提取全部路径参数(path_arg_keys 键序) +fn args_paths(tool_name: &str, args: &serde_json::Value) -> Vec { + df_ai::ai_tools::extract_paths(df_ai::ai_tools::path_arg_keys(tool_name), args) +} + +/// 批量尝试获取租约(写类工具执行前置校验)。 +/// +/// 返回 Err(指路文案) = 任一路径被**其他会话**占用(过期条目顺手清理); +/// Ok(LeaseGuard) = 全部路径登记成功,guard drop 时自动释放。 +/// 同会话同路径重复 acquire 放行(同会话工具串行,重复写=LLM 连续编辑同一文件的 +/// 正常模式;租约只防**跨会话**互踩)。 +pub(crate) fn acquire_leases( + conv_id: &str, + agent_kind: &'static str, + tool_name: &str, + tool_call_id: &str, + args: &serde_json::Value, +) -> Result { + // 防御:租约入口自兜底判写类(调用方 execute_with_heartbeat 已前置 is_write_file_tool + // 分流,此处双保险——未来新调用点忘判时只读工具不得误登记) + if !is_write_file_tool(tool_name) { + 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 key = lease_key(p); + if let Some(holder) = reg.get(&key) { + if holder.conv_id == conv_id { + continue; // 同会话:放行(见函数文档) + } + if holder.expired(now) { + reg.remove(&key); // TTL 兜底:过期租约视为泄漏,清掉 + continue; + } + return Err(holder.describe()); + } + } + // 第二遍:全部登记 + let mut keys = Vec::with_capacity(paths.len()); + for p in paths { + let key = lease_key(&p); + reg.insert(key.clone(), LeaseHolder { + conv_id: conv_id.to_string(), + agent_kind, + tool_name: tool_name.to_string(), + tool_call_id: tool_call_id.to_string(), + acquired_at: now, + }); + keys.push(key); + } + Ok(LeaseGuard { released: false, keys }) +} + +/// RAII 租约守卫:drop 自动释放(Ok/Err/panic unwind 全覆盖)。 +pub(crate) struct LeaseGuard { + released: bool, + keys: Vec, +} + +impl std::fmt::Debug for LeaseGuard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LeaseGuard") + .field("released", &self.released) + .field("keys", &self.keys) + .finish() + } +} + +impl LeaseGuard { + /// 显式提前释放(当前实现靠 drop 即可,预留显式口供未来作用域内让位场景) + #[allow(dead_code)] + pub(crate) fn release_now(&mut self) { + if self.released { + return; + } + let mut reg = REGISTRY.write().expect("文件租约表锁"); + for k in &self.keys { + reg.remove(k); + } + self.released = true; + } +} + +impl Drop for LeaseGuard { + fn drop(&mut self) { + self.release_now(); + } +} + +/// 只读查询:当前全部活跃租约(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)); // 查询顺手清过期 + let mut items: Vec<(String, LeaseHolder)> = reg + .iter() + .map(|(k, h)| (k.clone(), h.clone())) + .collect(); + items.sort_by(|a, b| a.0.cmp(&b.0)); + items +} + +/// 注册 query_leases 只读工具(W-h2:LLM 自查「谁在占用什么」,兄弟感知最小入口)。 +/// Low 风险零参数;返回人话列表供 LLM 直接消费。 +pub(crate) fn register_lease_tool(registry: &mut df_ai::ai_tools::AiToolRegistry) { + use df_ai::ai_tools::{RiskLevel, ToolMeta, object_schema}; + let handler: df_ai::ai_tools::AiToolHandler = + Box::new(|_args: serde_json::Value| { + Box::pin(async move { + let leases = query_leases(); + if leases.is_empty() { + return Ok(serde_json::json!({ + "count": 0, + "message": "当前无活跃文件租约(没有其他会话正在写文件)" + })); + } + let items: Vec = leases + .iter() + .map(|(path, h)| { + serde_json::json!({ + "path": path, + "conv_id": h.conv_id, + "agent": h.agent_kind, + "tool": h.tool_name, + "held_secs": h.acquired_at.elapsed().as_secs(), + }) + }) + .collect(); + Ok(serde_json::json!({ + "count": items.len(), + "leases": items, + "message": "以下文件正被其他会话占用,写同一文件前先等对方完成(或换文件避开)" + })) + }) + }); + registry.register_meta( + "query_leases", + "查询当前进程内活跃的文件写租约(哪些文件正被其他会话/子代理占用)。写文件被拒时先调本工具看占用方;规划批量写操作前也可预查避免互踩", + object_schema(vec![]), + RiskLevel::Low, + ToolMeta { + readonly: true, + display_verb: Some("查询文件占用"), + ..Default::default() + }, + handler, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // 单测进程不跑 build_ai_tool_registry,GLOBAL_TOOL_META 表为空 → is_write_file_tool + // 查表 None 恒 false。走既有正典播种(Once 全进程一次,对齐 cache/path_auth/helpers + // 测试同款),保证元数据表与生产一致。 + fn seed() { + crate::commands::ai::tool_registry::ensure_tool_meta_seeded(); + } + + const CONV_A: &str = "conv-aaa"; + const CONV_B: &str = "conv-bbb"; + + #[test] + fn cross_conv_conflict_rejected_with_pointer() { + seed(); + let _g1 = acquire_leases(CONV_A, "main", "write_file", "tc-1", &json!({"path": "E:/x/a.rs"})) + .expect("首占应成功"); + let err = acquire_leases(CONV_B, "main", "patch_file", "tc-2", &json!({"path": "E:\\X\\a.rs"})) + .expect_err("跨会话同文件(大小写+反斜杠变体)应被拒"); + assert!(err.contains("占用"), "拒绝文案应指路占用方: {err}"); + assert!(err.contains("write_file"), "文案应含占用方工具名: {err}"); + } + + #[test] + fn same_conv_reacquire_allowed() { + seed(); + let _g1 = acquire_leases(CONV_A, "main", "write_file", "tc-1", &json!({"path": "/x/a.rs"})) + .expect("首占成功"); + // 同会话续写同文件:LLM 连续编辑同一文件是正常模式,应放行(guard 覆盖旧条目) + let _g2 = acquire_leases(CONV_A, "main", "patch_file", "tc-2", &json!({"path": "/x/a.rs"})) + .expect("同会话重复 acquire 应放行"); + } + + #[test] + fn release_frees_path_for_others() { + { + let _g = acquire_leases(CONV_A, "main", "write_file", "tc-1", &json!({"path": "/x/b.rs"})) + .expect("首占成功"); + } // drop 释放 + let _g2 = acquire_leases(CONV_B, "sub", "write_file", "tc-2", &json!({"path": "/x/b.rs"})) + .expect("释放后他人可获取"); + } + + #[test] + fn non_write_tool_bypasses() { + seed(); + assert!(!is_write_file_tool("read_file"), "只读工具非写类"); + assert!(!is_write_file_tool("list_tasks"), "无 path_args 的工具非文件写类"); + assert!(!is_write_file_tool("run_command"), "run_command 不进租约(副作用面不可静态判定)"); + assert!(is_write_file_tool("write_file")); + assert!(is_write_file_tool("patch_file")); + assert!(is_write_file_tool("append_file")); + assert!(is_write_file_tool("delete_file")); + assert!(is_write_file_tool("rename_file"), "rename 有 from/to path_args"); + } + + #[test] + fn non_write_tool_no_lease() { + seed(); + // 只读工具 acquire 恒成功零副作用(空 guard) + let g = acquire_leases(CONV_A, "main", "read_file", "tc-1", &json!({"path": "/x/c.rs"})) + .expect("非写类不拦"); + assert!(g.keys.is_empty(), "非写类不登记"); + } + + #[test] + fn rename_locks_both_from_and_to() { + seed(); + let _g = acquire_leases(CONV_A, "main", "rename_file", "tc-1", + &json!({"from": "/x/old.rs", "to": "/x/new.rs"})) + .expect("rename 双路径登记"); + let err = acquire_leases(CONV_B, "main", "patch_file", "tc-2", &json!({"path": "/x/NEW.rs"})) + .expect_err("他人写移动目标应被拒"); + assert!(err.contains("rename_file")); + } + + #[test] + fn query_reports_active_leases() { + seed(); + let _g = acquire_leases(CONV_A, "main", "write_file", "tc-q", &json!({"path": "/x/q.rs"})) + .expect("首占成功"); + let leases = query_leases(); + let hit = leases.iter().find(|(p, _)| p == "/x/q.rs" || p == "/x/q.rs"); + assert!(hit.is_some(), "query 应含 /x/q.rs: {:?}", leases.iter().map(|(p, _)| p).collect::>()); + } + + #[test] + fn panic_safe_guard_drop_releases() { + let guard = acquire_leases(CONV_A, "main", "write_file", "tc-p", &json!({"path": "/x/p.rs"})) + .expect("首占成功"); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let _g = guard; // move 进闭包后 panic unwind → drop 释放 + panic!("simulated unwind"); + })).unwrap_err(); + let _g2 = acquire_leases(CONV_B, "main", "write_file", "tc-p2", &json!({"path": "/x/p.rs"})) + .expect("unwind drop 后应已释放"); + } +} diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index 51a2627..0f4c5a0 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -44,6 +44,8 @@ pub mod download_file; pub(crate) mod generate_image; pub mod get_app_config; pub mod knowledge_inject; +// 文件租约注册表(W-h1 感知租约):写类文件工具跨会话互斥 + query_leases 兄弟感知 +pub mod lease; // 系统通知(P1 bg 化补完):完成/失败/需介入三类事件,主窗托盘化时补位提醒(条件发送) pub(crate) mod notify; pub mod prompt; diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index c9b8810..321c7b1 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -107,6 +107,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { - **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\ - {}\n\ - {}\n\ + - {}\n\ ## Focus\n\ - Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\ - When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\ @@ -115,6 +116,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { read_symbol_guidance(lang), duplicate_call_guidance(lang), file_reading_guidance(lang), + verification_fast_check_guidance(lang), ), "\n## Current Projects\n", "\n## Current Tasks\n", @@ -138,6 +140,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { - 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\ - {}\n\ - {}\n\ + - {}\n\ ## 聚焦准则\n\ - 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\ - 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\ @@ -146,6 +149,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { read_symbol_guidance(lang), duplicate_call_guidance(lang), file_reading_guidance(lang), + verification_fast_check_guidance(lang), ), "\n## 当前项目\n", "\n## 当前任务\n", @@ -207,6 +211,16 @@ fn file_reading_guidance(lang: &str) -> &'static str { } } +/// 验证快检纪律(中/英,不含行首 "- ")。狗粮实证(2026-10-04):验证阶段多次全量 +/// cargo check(单次数十秒)烧轮。开发中途单文件/单包改动用窄域快检,全量验证只留 +/// 给阶段收官一次。 +fn verification_fast_check_guidance(lang: &str) -> &'static str { + match lang { + "en" => "**Fast-check discipline**: during development, verify with narrow-scope checks first — `cargo check -p ` for one crate, read back the edited snippet for a single-file change. Full-workspace `cargo check`/`cargo test` is for final acceptance ONLY (run once per milestone, not per patch); repeated full checks burn minutes per round", + _ => "**验证快检纪律**:开发中途优先窄域验证——单包改动用 `cargo check -p <包名>`,单文件改动回读编辑片段即可。全量 cargo check/cargo test 只留给阶段收官(每个里程碑一次,不要每个小 patch 跑一遍);反复全量验证每轮烧数十秒", + } +} + /// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 —— /// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败 /// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具, @@ -218,10 +232,13 @@ pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String { "\n## 工具纪律\n\ - {}\n\ - {}\n\ - - {}\n{}{}", + - {}\n\ + - {}\n\ +{}{}", read_symbol_guidance(lang), duplicate_call_guidance(lang), file_reading_guidance(lang), + verification_fast_check_guidance(lang), doc_strategy_core_section(lang), app_config_query_guidance_section(lang), ) @@ -724,6 +741,9 @@ pub(crate) fn compress_prompt(lang: &str) -> &'static str { - Must preserve core topic words, entity names, and technical terms the user \ repeatedly mentions; they are anchors for continuing the conversation and losing \ them breaks the context thread.\n\ + - MUST preserve rejection/failed facts verbatim: failed tool calls with error \ + messages, proposals or approaches the user rejected (with stated reasons), and \ + forbidden operations — losing them invites blindly retrying failed paths.\n\ - Do NOT invent facts not present in the conversation.\n\ - Output the four sections only, no preamble or extra commentary.", _ => "你是对话总结器。请把以下对话压缩为结构化摘要,保留继续推进工作所必需的关键上下文。\ @@ -746,6 +766,7 @@ pub(crate) fn compress_prompt(lang: &str) -> &'static str { - 去掉寒暄、过渡性客套,只保留技术上有价值的事实。\n\ - 文件路径、标识符、错误信息等照原样保留。\n\ - 必须保留用户反复提及的核心主题词、实体名、技术名词(它们是对话续接的锚点,丢失会致上下文断裂)。\n\ + - 必须原样保留「被拒/失败」事实:失败的工具调用及其错误信息、用户明确否决的方案及理由、明令禁止的操作——丢失会导致压缩后盲目重走失败路径。\n\ - 不要编造对话中没有的事实。\n\ - 只输出上述四段内容,不要前言、解释或额外评论。", } @@ -915,6 +936,26 @@ mod tests { assert!(en.contains("breaks the context")); } + // B-2(2026-10-05 拍板): 压缩 prompt 被拒/失败事实保留条款(防压缩后盲目重走失败路径) + #[test] + fn compress_prompt_keeps_rejection_facts_clause() { + let zh = compress_prompt("zh-CN"); + assert!( + zh.contains("被拒/失败"), + "中文 compress_prompt 应含被拒/失败事实保留条款,实际: {}", + zh + ); + assert!(zh.contains("盲目重走失败路径")); + + let en = compress_prompt("en"); + assert!( + en.contains("preserve rejection/failed facts"), + "英文 compress_prompt 应含 rejection/failed facts 保留条款,实际: {}", + en + ); + assert!(en.contains("blindly retrying failed paths")); + } + // 查 DevFlow 自身配置引导段(中/英)存在且含关键引导 #[test] fn app_config_query_guidance_section_present_zh_en() { diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 0073db6..d97c1db 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -558,6 +558,7 @@ pub fn build_ai_tool_registry( register_read_skill_tool(&mut registry); register_spawn_agent_tool(&mut registry, spawn_agent_ctx); register_uclaw_tool(&mut registry, db); + super::lease::register_lease_tool(&mut registry); registry } @@ -1510,10 +1511,14 @@ mod tests { // u-relay commander 协议,Medium 审批档同 spawn_agent;配置 KV 三键读, // 声明式注册 tools/uclaw.rs)。 // 79 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw。 + // query_leases(2026-10-05): lease 层 0→1(W-h2 感知租约查询,只读 Low; + // W-h1 FileLeaseRegistry 兄弟感知入口,写类文件工具跨会话互斥的可视面, + // 注册在 lease.rs register_lease_tool)。 + // 80 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease。 assert_eq!( registry.len(), - 79, - "工具总数应为 79(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw),实际 {}", registry.len() + 80, + "工具总数应为 80(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease),实际 {}", registry.len() ); // 工具名集合基线:防 rename / 漏注册 / 误删除。 @@ -1587,6 +1592,8 @@ mod tests { // ── uclaw 层 (1) ──(委托 u-claw 常驻 Claude Code 网关,u-relay commander 协议, // Medium 审批档,声明式注册 tools/uclaw.rs) "ask_uclaw", + // ── lease 层 (1) ──(W-h2 文件租约查询,只读 Low,注册在 lease.rs) + "query_leases", ]; expected.sort_unstable(); diff --git a/src-tauri/src/commands/ai/tools/file.rs b/src-tauri/src/commands/ai/tools/file.rs index dc4078a..706a1ce 100644 --- a/src-tauri/src/commands/ai/tools/file.rs +++ b/src-tauri/src/commands/ai/tools/file.rs @@ -393,6 +393,16 @@ pub fn register( anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len()); } let target = std::path::Path::new(path); + // R12(2026-10-05):读取旧内容供 diff 回看(仅 utf-8 且旧文件存在且 ≤1MB; + // base64 二进制/新建文件无 diff 语义,跳过。读取在写入前完成,与 rename 不竞态) + let old_content_for_diff: Option = if encoding.eq_ignore_ascii_case("utf-8") { + match tokio::fs::read_to_string(target).await { + Ok(s) if s.len() <= 1_048_576 => Some(s), + _ => None, + } + } else { + None + }; // FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失) // 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节 let old_size: Option = match tokio::fs::metadata(target).await { @@ -438,7 +448,14 @@ pub fn register( tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len()); } } - Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding })) + // R12:行级 unified diff(旧 vs 新,同 patch_file 的 generate_diff;新建文件/base64 为 null 前端不渲染) + let diff_json = match old_content_for_diff { + Some(old) if encoding.eq_ignore_ascii_case("utf-8") => { + serde_json::Value::String(generate_diff(&old, &raw)) + } + _ => serde_json::Value::Null, + }; + Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding, "diff": diff_json })) } ); diff --git a/src-tauri/src/commands/ai/tools/spawn_agent.rs b/src-tauri/src/commands/ai/tools/spawn_agent.rs index b0ca9bd..f826b5a 100644 --- a/src-tauri/src/commands/ai/tools/spawn_agent.rs +++ b/src-tauri/src/commands/ai/tools/spawn_agent.rs @@ -106,7 +106,7 @@ pub(crate) fn caller_conv() -> Option { // ── 参数解析与任务书组装(纯函数,单测锁定) ── -/// 解析 max_rounds:缺省默认 6,合法域钳制 [1, 20](防参数放大预算失控)。 +/// 解析 max_rounds:缺省默认 12,合法域钳制 [1, 20](防参数放大预算失控)。 fn parse_max_rounds(raw: Option) -> u32 { raw.map(|v| v as u32) .unwrap_or(SUB_AGENT_DEFAULT_MAX_ROUNDS) @@ -153,7 +153,7 @@ pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) { }, "max_rounds": { "type": "integer", - "description": "可选,工具循环轮数上限,默认 6,最大 20" + "description": "可选,工具循环轮数上限,默认 12,最大 20" }, "model": { "type": "string", @@ -166,7 +166,7 @@ pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) { registry, ctx: SpawnAgentCtx, "spawn_agent", - "派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 6);model 可选模型(缺省继承当前会话)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回", + "派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 12);model 可选模型(缺省继承当前会话)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回", RiskLevel::Medium, meta: ToolMeta { // 审批卡文案模板(模板 {} 按位置填充,键取 task) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4bee5ba..9c22954 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,9 @@ mod mcp; mod local_proxy_gateway; mod tray; mod feishu_channel; +// 双进程共用日志初始化(数据目录落点+启动期轮转+%TEMP% 旧日志迁移) +// main.rs 经 devflow_lib::logging 访问,须 pub +pub mod logging; use tauri::{Emitter, Listener, Manager}; // UX-P2 走查⑥:首次关窗托盘化提示走 OS 系统通知(应用内气泡窗口即隐看不见),需 NotificationExt @@ -15,19 +18,6 @@ use tauri_plugin_notification::NotificationExt; use state::AppState; -/// 用户本地时区日志时间戳格式化器。 -/// tracing_subscriber 默认输出 UTC,用户在中国 UTC+8,日志与本地时间差 8 小时不便调试。 -/// 使用 chrono::Local 输出含时区偏移的本地时间(如 "2026-07-17T15:30:00.123456+08:00")。 -/// -/// 注意:FormatTime::format_time 参数类型为 tracing_subscriber::fmt::format::Writer, -/// 非 std::fmt::Write。Writer 实现了 fmt::Write,可直接 write!。 -struct LocalTimer; -impl tracing_subscriber::fmt::time::FormatTime for LocalTimer { - fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result { - write!(w, "{}", chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.6f%:z")) - } -} - // Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内 use df_tunnel::TunnelClient; @@ -41,27 +31,10 @@ const TRAY_NOTICE_SENT_MARKER: &str = "tray-hide-notice-sent"; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info) - let log_path = std::env::temp_dir().join("devflow-trace.log"); - let log_file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .expect("创建日志文件失败"); - let (non_blocking, _guard) = tracing_appender::non_blocking(log_file); - tracing_subscriber::fmt() - .with_timer(LocalTimer) - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")) - ) - .with_writer(non_blocking) - .with_ansi(false) - .init(); - tracing::info!( - path = %log_path.display(), - "[startup] 日志已初始化" - ); + // 双进程共用日志初始化(logging.rs):落点系统应用数据目录 + 启动期 5MB 轮转 + + // %TEMP% 旧日志迁移。治「使用数据分析-2026-10-04」登记项(%TEMP% 系统清理无声删除+无轮转)。 + // identifier 与 tauri.conf.json 一致(main.rs MCP 侧走编译期注入同源)。 + let _guard = logging::init_logging("top.1216.devflow", "gui"); tauri::Builder::default() .plugin(tauri_plugin_opener::init()) diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs new file mode 100644 index 0000000..2d8f244 --- /dev/null +++ b/src-tauri/src/logging.rs @@ -0,0 +1,179 @@ +//! +//! 双进程(GUI lib.rs::run / MCP main.rs::run_mcp_server)共用的日志初始化。 +//! +//! 治「使用数据分析-2026-10-04」登记项:日志无轮转且放 %TEMP%(系统清理无声删除)。 +//! - 落点迁系统应用数据目录 app_data_dir/{identifier}/devflow-trace.log(与数据库同目录) +//! - 启动期大小轮转:主日志 >5MB → rename .log.1(覆盖旧 .1,总量上限 ~10MB) +//! - 升级迁移:%TEMP% 旧日志存在且数据目录尚无日志时 rename 过来(保留排查线索)并清 %TEMP% 残留 +//! +//! Windows 限制:文件句柄打开期间无法 rename,轮转只能在启动期做(打开前执行); +//! 长驻 GUI 进程运行期不再轮转,下次启动时收口(单次会话日志量可控)。 + +use std::path::{Path, PathBuf}; + +/// 单文件轮转阈值:5MB(超限启动时 rename 为 .log.1,旧 .1 覆盖丢弃)。 +const LOG_ROTATE_SIZE_BYTES: u64 = 5 * 1024 * 1024; +/// 保留的历史份数(仅 .1 一份)。 +const LOG_KEEP_ROTATIONS: u32 = 1; + +/// 本地时区日志时间戳格式化器(GUI/MCP 同款,对齐日志格式)。 +/// tracing_subscriber 默认输出 UTC,改为 chrono::Local 输出含时区偏移的本地时间。 +/// +/// 注意:FormatTime::format_time 参数类型为 tracing_subscriber::fmt::format::Writer, +/// 非 std::fmt::Write。Writer 实现了 fmt::Write,可直接 write!。 +pub struct LocalTimer; +impl tracing_subscriber::fmt::time::FormatTime for LocalTimer { + fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result { + write!(w, "{}", chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.6f%:z")) + } +} + +/// 系统应用数据目录(与 tauri app_data_dir / MCP default_db_path 三方同源): +/// Windows=%APPDATA%/{identifier} macOS=~/Library/Application Support/{identifier} +/// Linux=~/.local/share/{identifier}。env 解析失败兜底 %TEMP%(日志绝不让应用起不来)。 +pub fn app_data_dir(identifier: &str) -> PathBuf { + let base = if cfg!(target_os = "windows") { + std::env::var("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()) + } else if cfg!(target_os = "macos") { + std::env::var("HOME") + .map(|h| PathBuf::from(h).join("Library/Application Support")) + .unwrap_or_else(|_| std::env::temp_dir()) + } else { + std::env::var("HOME") + .map(|h| PathBuf::from(h).join(".local/share")) + .unwrap_or_else(|_| std::env::temp_dir()) + }; + base.join(identifier) +} + +/// 迁移 %TEMP% 旧日志到数据目录(仅当数据目录尚无日志且旧文件存在): +/// rename 失败(跨卷等)则退化为删除旧文件——目标是 %TEMP% 不留残留,系统清理不再丢日志。 +fn migrate_legacy_temp_log(current: &Path) { + let legacy = std::env::temp_dir().join("devflow-trace.log"); + if !legacy.exists() || current.exists() { + return; + } + if std::fs::rename(&legacy, current).is_err() { + let _ = std::fs::remove_file(&legacy); + } +} + +/// 启动期大小轮转:主日志超阈值 → rename .log.1(覆盖旧 .1)。 +/// 必须在文件句柄打开前执行(Windows 打开期间禁 rename),故仅启动期一次。 +fn rotate_if_oversized(log_path: &Path) { + let Ok(meta) = std::fs::metadata(log_path) else { return }; + if meta.len() < LOG_ROTATE_SIZE_BYTES { + return; + } + let mut rotated_name = log_path.as_os_str().to_owned(); + rotated_name.push(".1"); + let rotated = PathBuf::from(rotated_name); + // std::fs::rename 在 Windows 上目标存在会失败,先删旧 .1(覆盖语义) + if rotated.exists() { + let _ = std::fs::remove_file(&rotated); + } + if std::fs::rename(log_path, &rotated).is_ok() { + // 清理超出保留份数的更老历史(.2+),防历史份数调整后残留 + for i in (LOG_KEEP_ROTATIONS + 1)..=(LOG_KEEP_ROTATIONS + 3) { + let mut old_name = log_path.as_os_str().to_owned(); + old_name.push(format!(".{i}")); + let _ = std::fs::remove_file(PathBuf::from(old_name)); + } + } +} + +/// 初始化日志(双进程共用):配置 tracing subscriber 并返回 non_blocking WorkerGuard +/// (调用方须保持 guard 存活到进程退出,否则 non_blocking worker 提前关闭丢日志)。 +/// +/// `scope`:调用方标识("gui"/"mcp"),进启动日志行便于区分双进程写入。 +pub fn init_logging(identifier: &str, scope: &'static str) -> tracing_appender::non_blocking::WorkerGuard { + let dir = app_data_dir(identifier); + let _ = std::fs::create_dir_all(&dir); + let log_path = dir.join("devflow-trace.log"); + migrate_legacy_temp_log(&log_path); + rotate_if_oversized(&log_path); + // 数据目录创建失败(权限/磁盘满)兜底回 %TEMP%:日志初始化绝不让应用起不来 + let log_file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .or_else(|_| { + let fallback = std::env::temp_dir().join("devflow-trace.log"); + std::fs::OpenOptions::new().create(true).append(true).open(&fallback) + }) + .expect("创建日志文件失败(数据目录与 %TEMP% 兜底均失败)"); + let (non_blocking, guard) = tracing_appender::non_blocking(log_file); + tracing_subscriber::fmt() + .with_timer(LocalTimer) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")) + ) + .with_writer(non_blocking) + .with_ansi(false) + .init(); + tracing::info!( + path = %log_path.display(), + scope, + "[startup] 日志已初始化" + ); + guard +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + /// 轮转目录解析:返回以 identifier 结尾(与 tauri app_data_dir 拼装一致)。 + #[test] + fn app_data_dir_ends_with_identifier() { + let dir = app_data_dir("top.1216.devflow"); + assert!(dir.ends_with("top.1216.devflow"), "dir = {}", dir.display()); + // 非空根(即使 env 缺失兜底 temp_dir,也不是空路径) + assert!(dir.file_name().is_some()); + } + + /// 未超阈值:不动文件。 + #[test] + fn rotate_skips_small_file() { + let tmp = std::env::temp_dir().join(format!("df-log-test-{}-small", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let log = tmp.join("devflow-trace.log"); + std::fs::write(&log, b"tiny").unwrap(); + rotate_if_oversized(&log); + assert!(log.exists(), "小文件不应被轮转"); + let mut rotated_name = log.as_os_str().to_owned(); + rotated_name.push(".1"); + assert!(!PathBuf::from(&rotated_name).exists(), "不应产生 .1"); + let _ = std::fs::remove_dir_all(&tmp); + } + + /// 超阈值:主文件 rename 为 .1;再超再轮转时旧 .1 被覆盖(总量上限恒 ~2 文件)。 + #[test] + fn rotate_renames_oversized_and_overwrites_old() { + let tmp = std::env::temp_dir().join(format!("df-log-test-{}-big", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + let log = tmp.join("devflow-trace.log"); + // 第一次:大文件 → .1 + let big = vec![b'x'; LOG_ROTATE_SIZE_BYTES as usize + 1]; + let mut f = std::fs::File::create(&log).unwrap(); + f.write_all(&big).unwrap(); + drop(f); + rotate_if_oversized(&log); + let mut r1 = log.as_os_str().to_owned(); + r1.push(".1"); + let r1 = PathBuf::from(r1); + assert!(!log.exists(), "轮转后主文件应移走"); + assert!(r1.exists(), ".1 应存在"); + // 第二次:再造大文件轮转,旧 .1 被覆盖(Windows rename 目标存在会失败,先删逻辑生效) + std::fs::write(&log, &big).unwrap(); + rotate_if_oversized(&log); + let meta = std::fs::metadata(&r1).unwrap(); + assert_eq!(meta.len(), big.len() as u64, "旧 .1 应被新轮转覆盖"); + assert!(!log.exists()); + let _ = std::fs::remove_dir_all(&tmp); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1a55213..61f9c79 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -4,38 +4,6 @@ use std::path::PathBuf; use std::process::ExitCode; -/// MCP 进程本地时区日志时间戳格式化器(与 GUI lib.rs 同款,对齐日志格式)。 -/// tracing_subscriber 默认输出 UTC,改为 chrono::Local 输出含时区偏移的本地时间。 -struct LocalTimer; -impl tracing_subscriber::fmt::time::FormatTime for LocalTimer { - fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result { - write!(w, "{}", chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.6f%:z")) - } -} - -/// 初始化 MCP 进程的文件日志:追加到 %TEMP%/devflow-trace.log(与 GUI 同文件), -/// RUST_LOG 控制级别(默认 info)。返回的 guard 需保持存活到 server 退出, -/// 否则 non_blocking worker 提前关闭会丢失日志。 -fn init_mcp_tracing() -> tracing_appender::non_blocking::WorkerGuard { - let log_path = std::env::temp_dir().join("devflow-trace.log"); - let log_file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .expect("创建日志文件失败"); - let (non_blocking, guard) = tracing_appender::non_blocking(log_file); - tracing_subscriber::fmt() - .with_timer(LocalTimer) - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")) - ) - .with_writer(non_blocking) - .with_ansi(false) - .init(); - guard -} - fn main() -> ExitCode { // CLI 子命令:手动解析 argv(避免引入 clap 重依赖)。 // @@ -167,8 +135,9 @@ fn run_mcp_server(args: &[String]) -> ExitCode { // MCP 是独立进程,不经 GUI lib.rs 的 run() 路径,须单独 init tracing subscriber; // 否则 df_mcp 内 medium_audit 的 tracing::warn! 无 subscriber 是 no-op,审计不落盘。 + // 双进程共用 logging.rs(数据目录落点+启动期轮转+%TEMP% 旧日志迁移,identifier 同源)。 // guard 绑定在此函数作用域,存活到 server 退出,保证日志 worker 持续运行。 - let _log_guard = init_mcp_tracing(); + let _log_guard = devflow_lib::logging::init_logging("top.1216.devflow", "mcp"); tracing::info!(path = %db_path.display(), "[startup] MCP server 日志已初始化"); // 空闲超时:未显式指定用默认 60s(对齐 ServerConfig::default);显式 0 = 禁用(常驻)。 diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index a2a0120..9f07604 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -452,6 +452,17 @@ impl AppState { tracing::warn!("清理孤儿 __PENDING__ 消息失败(非阻断): {}", e); } + // V49 断点续跑底座:启动扫描残留 loop_state='running' 改写 'parked'(进程重启时 loop + // 必死,running 是陈旧值;parked 供前端识别「重启前有未完成任务,可一键续跑」)。 + { + let repo = AiConversationRepo::new(&state.db); + match repo.mark_running_as_parked().await { + Ok(n) if n > 0 => tracing::info!("[v49] 检测到 {n} 个中断任务,loop_state → parked"), + Ok(_) => {} + Err(e) => tracing::warn!("扫描残留 loop_state 失败(非阻断): {}", e), + } + } + // 迁移旧 .trash(编译期 workspace_root → 运行期 data_dir),仅一次,幂等。 let old_trash = workspace_root_path().join(".trash"); let new_trash = data_dir.join(".trash"); diff --git a/src/components/ai/TaskParkedCard.vue b/src/components/ai/TaskParkedCard.vue index c092dfb..1675362 100644 --- a/src/components/ai/TaskParkedCard.vue +++ b/src/components/ai/TaskParkedCard.vue @@ -5,7 +5,7 @@ 卡面 = reason 人话 + stop_kind 标签;操作两枚:继续(ai_continue_loop 重新授权续跑)/ 停止归档(ai_chat_stop 既有停止路径,归档会话走侧栏既有入口,此处不代做)。 cancelled 例外只显停止归档(用户主动取消后不再诱导续跑)。 --> -
+
{{ kindLabel }} {{ t('ai.taskParked.title') }} @@ -20,7 +20,7 @@