From 55542abf410883db6cf0049abce4c47dd4facbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 28 Jun 2026 18:16:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E5=90=8E=E7=AB=AF=E5=85=9C=E5=BA=95,?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E9=87=8D=E5=90=AF=E6=81=A2=E5=A4=8D=E5=9C=BA?= =?UTF-8?q?=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端已有审批超时定时器(设置页可选 0/5/15/30/60 分钟),负责会话 内运行的审批超时检测。但应用重启后前端定时器丢失,从数据库恢复 的待审批无人计时,会永久卡死会话。 本次补充后端兜底机制: - 待审批记录创建时刻,用于超时判定 - 在审批恢复入口检测超时,超时自动取消并写入审计 - 应用启动时从设置恢复超时配置(默认 15 分钟,0 为禁用) - 新增读写超时配置的接口,设置页可改 - 与前端共用同一个配置项,保证单一真相源 同时更新待办文档,把已实现项标记为完成。 --- docs/todo.md | 19 ++-- docs/待决策.md | 77 +++++++------ .../commands/ai/agentic/approval_timeout.rs | 105 ++++++++++++++++++ src-tauri/src/commands/ai/agentic/mod.rs | 11 +- src-tauri/src/commands/ai/audit/mod.rs | 2 + src-tauri/src/commands/ai/audit/restore.rs | 4 + src-tauri/src/commands/ai/mod.rs | 4 + src-tauri/src/commands/settings.rs | 38 +++++++ src-tauri/src/lib.rs | 7 +- src-tauri/src/state.rs | 44 +++++++- todo.md | 25 +++-- 11 files changed, 279 insertions(+), 57 deletions(-) create mode 100644 src-tauri/src/commands/ai/agentic/approval_timeout.rs diff --git a/docs/todo.md b/docs/todo.md index 0cd70a3..8970045 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -159,7 +159,7 @@ graph TD **层2 待决策(政策范围)**:用户"默认直接访问"是否要求更宽语义——workspace_root 外的非黑名单路径也默认放行(翻转 `is_authorized` 为黑名单制,实质去白名单)?涉 P0 去固定根决策方向,见待决策.md。dev 自用场景层1 即满足。 **关联**:F-260619-03(todo :528 b22e9ae 回归源)/ 待决策 workspace_root 分发适配(方案b 用户项目绑定)/ memory [[devflow-project-path-binding]] -**状态**:🔴 待用户授权(层1 1 行修法可直改;层2 待政策澄清) +**状态**:✅ 层1已修(无条件插入 workspace_root,`state.rs:839`);层2政策范围待澄清 --- @@ -195,7 +195,7 @@ graph TD **🟡 P1 应该改进 (9项)**: -✅ **SMELL-P1-6** — **[P1🟡]** String 类型滥用替 newtype/enum —— execution_id/status/tool_type 等 5+ 处改强类型 +✅ **SMELL-P1-6** — **[P1🟢 已决·待推进]** String 类型滥用替 newtype/enum —— execution_id/status/tool_type 等 5+ 处改强类型(b 路线:仅 Rust 内部 newtype) **🆕 sweep 派生登记(2026-06-18·workflow w2xkw4ybh data 抽出 + wo7d6zdqm 多维度 sweep)**: @@ -218,19 +218,20 @@ graph TD - ✅ **UX-260617-28 [INFO]** — 已决策:现状够用,不出问题不升级 -### 🔧 2026-06-19 文件拆分升级(3 代理并行分析·建任务·未实施) +### 🔧 2026-06-19 文件拆分升级(核验结论:全部完成) -> 大文件统计(总 51143 行)+ 3 代理并行分析拆分方案。与已有 SMELL-P0-2(tool_registry)/SMELL-P0-3(AiChat.vue)/SMELL-P1-9(crud.rs) 合并。**通用执行原则**:可见性升级(私有 fn/struct → pub(super))/ 测试跟随被测函数 / **不改逻辑不改 await 边界不改签名** / 三段式验证(cargo check+test+clippy 分 crate,对齐 [[workflow-cargo-timeout-wrap]])/ 保守方案(热路径整块搬不拆函数体)。 +> **2026-06-28 核验**:三项 God 文件拆分已全部完成: +> - **tool_registry.rs** ✅ 1091行单函数已拆为按域注册函数 +> - **AiChat.vue** ✅ 4026→**767行**(已抽子组件) +> - **crud.rs** ✅ 2212→拆为 `crud/` 下11个文件 -**P1** +**P1 剩余(可继续推进)** -- [ ] **REFACTOR-260619-04 [P1·部分已做]** — **ToolCard.vue 拆分**。**2026-06-24 核验**:ToolCard 已从 1527→**428 行**,已抽 `ToolResultBody.vue` + `useToolCardHeader.ts` + `useToolCardRender.ts`(主体瘦身完成);但原计划的 `useToolFormat.ts`/`useToolApproval.ts`/`ToolCardHeader.vue`/`ToolApproval.vue` 未独立(当前 useToolCard/useToolCardHeader/useToolCardRender 三模块)。剩余:审批状态机独立(useToolApproval)+ 头部组件(ToolCardHeader)。风险:折叠态 shouldKeepOpen 共享 / 审批状态机断链(B-260616-08 回归)/ ToolCardList 批量审批联动。— `src/components/ToolCard.vue` + `ToolCardList.vue` +- [ ] **REFACTOR-260619-04 [P1·部分已做]** — **ToolCard.vue 拆分**。已从 1527→**428行**,剩余:审批状态机独立(useToolApproval)+ 头部组件(ToolCardHeader)。风险:折叠态 shouldKeepOpen 共享 / 审批状态机断链(B-260616-08 回归)/ ToolCardList 批量审批联动。— `src/components/ToolCard.vue` + `ToolCardList.vue` **P2 暂缓(窗口未到/收益低)** -- [ ] **REFACTOR-260619-09 [P2 暂缓]** — **context.rs(1332)等 F-15 压缩链路稳定再拆**。生产 745+测试 587。impl 跨文件方案(同 crate 多 impl 块,零字段可见性改动)。先 sanitize.rs(最大连续块 ~184)。当前 context.rs 被 F-15/压缩频繁改动,拆分窗口未到。— `crates/df-ai/src/context.rs` - -**执行顺序建议**:01-C provider_cfg(零风险试水) → 02 anthropropic → 03 audit → 04 ToolCard useToolFormat → 05 agentic_runtime → 06 ai_node → 07 AiChat.vue → 08 tool_registry。每步 cargo check+test+clippy 分 crate + 手测。 +- [ ] **REFACTOR-260619-09 [P2 暂缓]** — **context.rs(1332)等 F-15 压缩链路稳定再拆**。生产 745+测试 587。当前 context.rs 被压缩频繁改动,拆分窗口未到。— `crates/df-ai/src/context.rs` --- diff --git a/docs/待决策.md b/docs/待决策.md index fb5201c..13211ad 100644 --- a/docs/待决策.md +++ b/docs/待决策.md @@ -88,46 +88,25 @@ - **状态**:⏸️ 长期(INFO,当前够用) #### ARC-260618-01 God 文件拆分批(架构重投入·需设计) -- **背景**:2026-06-18 架构坏味道扫描出 3 个 God 文件:`tool_registry.rs` 1091行单函数(SMELL-P0-2)/`AiChat.vue` 4026行单组件(SMELL-P0-3)/`crud.rs` 2212行(SMELL-P1-9)。均属"功能能跑但维护成本高/测试难"的技术债,非功能 bug。 -- **决策点**:拆分何时做/优先级/拆分边界(每项拆分策略需专项设计,非小改;做不做是资源/可维护性取舍) -- **选项**: - - a: `tool_registry.rs` 按 CRUD/文件/工作流分组拆注册函数(每个<200行,单文件域,风险中) — **✅ 已实施(2026-06-18 workflow w2xkw4ybh data 抽出 + 主代 sweep 批4)·build_ai_tool_registry 1091→7 行·抽 register_data_tools(18 持 db 工具)+ register_file_tools(10 文件工具)·加基线测试 test_build_ai_tool_registry_baseline_tool_count(Database::open_in_memory·断言 len()==28 + tool_names() 集合)·主代核查 cargo check --workspace EXIT 0 / cargo test df-ai 119 passed / devflow 基线 1 passed / vue-tsc EXIT 0** - - b: `AiChat.vue` 拆 ConversationSidebar/MessageList/ChatInput/ApprovalPanel(巨型组件,风险高,AiChat.vue 已在频繁改动需择稳定窗口) — 🟡 待排期(并发改中缓) - - c: `crud.rs` 按表拆 project/task/conversation/idea_repo(2212行,中风险) — 🟡 待排期 -- **推荐**:**⏸️ 缓做**(当前功能优先;三项均大改需专项设计+充分测试。建议按 a→c→b 顺序,a 单文件域最独立先做**已实施**,b 待 AiChat 改动沉淀后) -- **关联**:todo SMELL-P0-2/P0-3/P1-9 -- **状态**:🟡 待排期决策(a 已实施·b/c 待排期:b AiChat 并发改中缓·c 待专项窗口) +- **背景**:~~2026-06-18 架构坏味道扫描出 3 个 God 文件~~ +- **核验结论(2026-06-28)**:三项拆分均已全部完成: + - **a `tool_registry.rs`** ✅ 1091行单函数已拆为 `register_data_tools` / `register_file_tools` 等多函数(当前文件 3839 行,单函数不再超长) + - **b `AiChat.vue`** ✅ 原 4026 行 → **767 行**(已抽 `ToolCard.vue`/`ToolCardList.vue` 等子组件到 `components/ai/`) + - **c `crud.rs`** ✅ 原 2212 行 → 已拆为 `crud/` 目录下 11 个独立 repo 文件(不复存在) +- **状态**:✅ **全部完成**,无需进一步拆分 -#### SMELL-P1-6 String→newtype 强类型(需设计) -- **背景**:execution_id/status/tool_type 等 5+ 处用裸 String,类型安全弱(混用/拼写错编译期不拦)。改 newtype(ExecutionId 等)需全栈波及(Rust struct 字段 + serde + IPC 边界 + 前端 ts 类型)。注:status 场景 df-types 已有 TaskStatus enum,部分代码用 String 而非 enum。 -- **决策点**:做不做 + newtype 边界(仅 Rust 内部 vs 跨 IPC 到前端) -- **选项**: - - a: 全栈 newtype(Rust+IPC+前端,类型安全最强,波及大) - - b: 仅 Rust 内部 newtype(IPC 边界仍 String,折中) - - c: 不做(String 够用,status 已有 enum 部分覆盖) -- **推荐**:**⏸️ 缓做**(全栈波及大 ROI 中等;倾向 b 折中或 c 现状 enum 已部分覆盖。若做需专项设计 newtype 边界) -- **关联**:todo SMELL-P1-6 -- **状态**:🟡 待设计决策 +#### SMELL-P1-6 String→newtype 强类型(纯重构) +- **背景**:execution_id/status/tool_type 等 5+ 处用裸 String,类型安全弱(混用/拼写错编译期不拦)。 +- **决策**:✅ b 路线 — 仅 Rust 内部 newtype(IPC 边界仍 String,折中,波及小) +- **5处改动**:执行状态/任务状态/工具类型等裸 String → newtype +- **状态**:🟢 已决·待推进 #### 消息级溯源 P2 切读策略(技术决策·大改需知情) - **背景**:消息级溯源 P0(地基 ai_messages 表 ✅ CR-10)+ P1(溯源字段 ✅ CR-12)完成。P2 切读是拆表实际启用(读写路径从 `ai_conversations.messages` JSON 切到 `ai_messages` 表)。 -- **决策点**(技术策略,影响读写路径改造): - - **a 三阶段渐进**(设计推荐):Phase1 脏标记 → Phase2 双写(旧列+新表)→ Phase3 切读删旧列。安全(双写期可对比回退),有性能开销+复杂。 - - **b 一次性切读**:直接切+删旧列。简单,但切读前无回退(风险)。 -- **推荐**:**a 三阶段渐进**(低风险,设计推荐)。但大改(ContextManager restore/clear/compress/replace 读写路径 + save_conversation 双写 + V22 删旧列重建表),需充分测试。 -- **关联**:消息级溯源 P0(CR-10 ✅)/ P1(CR-12 ✅)/ 消息拆分存储设计文档 -- **状态**:🟡 待用户拍板(P2 是否现在做 + 策略 a/b)。大改建议用户知情后启动。 +- **决策**:✅ **b 一次性切读**。读写路径直接切换,保留原 `ai_conversations.messages` 列数据不删(数据安全,可回退)。 +- **状态**:🟢 已决·待推进 + -#### BUG-260623-03 审批 pending 无超时兜底(aichat 卡死·架构) -- **背景**:实测会话 4ae73423 末尾 advance_task 触发审批 `__PENDING__` 后,`agentic/mod.rs:1464` loop return,恢复唯一依赖用户主动 ai_approve。全库无审批超时定时器(cache.rs:31 占位标记无时间戳)。用户离开/忘记 → generating=true 永久挂起,会话卡死,用户须手动重发(且该会话 token 已大,重发易触上限)。属 aichat 中断B,非 generating 状态机卡死(generating 已收敛 GeneratingGuard)。 -- **决策点**:审批 pending 超时兜底怎么实现? -- **选项**: - - a: 后端 per-conv 定时器(到期自动 reject + emit 超时,触及 AiSession 单例 + 定时器机制,最彻底) - - b: 前端倒计时提示(用户可见到期,后端不变,最小改动) - - c: 无超时(现状,靠用户手动) -- **推荐**:**b 前端倒计时**(最小改动不触 AiSession;用户可见主动处理)。a 后端定时器最彻底但触及单例大改,归 [[devflow-async-approval-concept]] -- **关联**:memory devflow-async-approval-concept / docs/05-代码审查/aichat-会话实测分析-2026-06-22.md(CR-260622-01 修正) -- **状态**:🟡 待决策 ### df-miniapp 全功能审查(2026-06-23 workflow wll7qabgr + 主代补审) @@ -279,6 +258,7 @@ - **状态**:✅ 已决 #### 灵感来源采集 +灵感来源采集 - **背景**:灵感捕捉时没有自动记录来源 - **选项**: - A: 对话自动采集 @@ -291,4 +271,31 @@ ## 已决归档 +### 2026-06-28 核验已决 + +#### DEC-260628-01 安全 8 项 +- **核验**:全数已修(代码核验 2026-06-28) +- **详情**:① relay.rs Token 硬编码→env 强制 ✅ / ② api_key Debug 脱敏 ✅ / ③ ScriptNode 白名单 ✅ / ④ bind_directory 路径规范化 ✅ / ⑤ state.rs 锁安全警告注释 ✅ / ⑥ eventbus 错误日志 ✅ / ⑦ probe_pwsh 异步探测 ✅ / ⑧ retry jitter 改进 ✅ +- **状态**:✅ 全部完成 + +#### DEC-260628-02 God 文件拆分 +- **核验**:三项拆分全部完成(代码核验 2026-06-28) +- **详情**:AiChat.vue 4026→767行 / tool_registry 单函数已拆 / crud.rs 拆为11文件 +- **状态**:✅ 全部完成 + +#### DEC-260628-03 审批 pending 超时兜底 +- **决策**:✅ 后端 15min 超时自动取消,超时时长在 Setting 可配置 +- **记录**:pending 超 15min→自动 cancel(status=cancelled)+LLM 收到回执 +- **状态**:✅ 已实施(2026-06-28):后端 `agentic/approval_timeout.rs` + IPC + Settings UI + +#### DEC-260628-04 消息级溯源 P2 切读 +- **决策**:✅ b 一次性切读(保留原表数据不删) +- **记录**:读写路径从 `ai_conversations.messages` 切到 `ai_messages` 表,原列保留 +- **状态**:🟢 已决·待推进 + +#### DEC-260628-05 SMELL-P1-6 String→newtype +- **决策**:✅ b 路线(仅 Rust 内部 newtype) +- **记录**:5 处裸 String 改强类型,纯重构 +- **状态**:🟢 已决·待推进 + 按月归档(随时间增长追加月份文件,防主文件膨胀): diff --git a/src-tauri/src/commands/ai/agentic/approval_timeout.rs b/src-tauri/src/commands/ai/agentic/approval_timeout.rs new file mode 100644 index 0000000..e373431 --- /dev/null +++ b/src-tauri/src/commands/ai/agentic/approval_timeout.rs @@ -0,0 +1,105 @@ +//! 审批超时取消 +//! +//! 当用户长时间未处理待审批时,自动将其转为拒绝状态,防止会话永久卡死。 +//! +//! 触发点:[`cleanup_expired_approvals`] 由 [`try_continue_agent_loop`] 入口调用, +//! 覆盖所有审批恢复路径(ai_approve / ai_authorize_dir / ai_continue_loop 等)。 +//! +//! 超时时长来自 AppState.approval_timeout_minutes(Settings KV 持久化,默认 15 分钟), +//! 0 表示禁用超时。 + +use std::time::{Duration, SystemTime}; + +use tauri::{AppHandle, Emitter}; + +use crate::state::AppState; + +use super::super::{ + audit::audit_finalize, conversation::save_conversation, AiChatEvent, +}; + +/// 清理指定会话中超时的待审批。 +/// +/// 遍历 pending_approvals 中属于 `conv_id` 的条目,若 `created_at` 距今超过 +/// `approval_timeout_minutes` 则: +/// 1. 从 pending_approvals 移除 +/// 2. 把占位 tool_result 内容替换为"审批超时已自动取消" +/// 3. emit AiApprovalResult(rejected=超时) +/// 4. audit_finalize 写入审计表(status=rejected) +/// +/// 设 0 时整体跳过(禁用超时)。无 pending 或均未超时则空操作。 +/// 失败(DB 错误等)不抛错,仅日志告警,避免影响主流程。 +pub async fn cleanup_expired_approvals(app: &AppHandle, state: &AppState, conv_id: &str) { + // 读超时配置(0 = 禁用) + let timeout_minutes = state.approval_timeout_minutes.load(std::sync::atomic::Ordering::SeqCst); + if timeout_minutes == 0 { + return; + } + let timeout_dur = Duration::from_secs(timeout_minutes * 60); + let now = SystemTime::now(); + + // 单次锁取出所有超时的审批(锁内不做 await,避免锁跨 await) + let expired: Vec = { + let session = state.ai_session.lock().await; + session + .pending_approvals + .iter() + .filter(|(_, a)| { + // 只处理本会话的审批(按 conversation_id 过滤) + a.conversation_id.as_deref() == Some(conv_id) + && a.created_at + .map(|t| now.duration_since(t).map(|d| d >= timeout_dur).unwrap_or(false)) + .unwrap_or(false) // created_at=None(异常)不超时 + }) + .map(|(_, a)| a.clone()) + .collect() + }; + + if expired.is_empty() { + return; + } + + // 逐个处理超时审批(每个独立处理,一个失败不影响其他) + for approval in expired { + let tc_id = approval.tool_call_id.clone(); + tracing::info!( + conv_id = %conv_id, + tool_call_id = %tc_id, + tool_name = %approval.tool_name, + timeout_minutes, + "[APPROVAL-TIMEOUT] 审批超时({}min)自动取消", timeout_minutes, + ); + + // 1. 从内存 pending_approvals 移除 + { + let mut session = state.ai_session.lock().await; + session.pending_approvals.remove(&tc_id); + // 2. 替换占位 tool_result 为超时取消提示(让 LLM 看到回执不再盲猜) + session + .conv(conv_id) + .messages + .replace_tool_result_content(&tc_id, &format!("审批超时({}分钟)已自动取消,请改换其他方案", timeout_minutes)); + } + + // 3. emit AiApprovalResult(让前端清除 pending UI) + let ev = AiChatEvent::AiApprovalResult { + id: tc_id.clone(), + approved: false, + conversation_id: Some(conv_id.to_string()), + }; + let _ = app.emit("ai-chat-event", ev.clone()); + let _ = state.ai_event_bus.publish_event(ev); + + // 4. audit_finalize 记入审计表 + audit_finalize( + state, + &tc_id, + "rejected", + Some(format!("审批超时({}分钟)自动取消", timeout_minutes)), + ) + .await; + } + + // 落库会话状态变更(占位消息替换后需持久化,防重启后丢失) + save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await; +} diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 6b2261d..21d60f0 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -245,6 +245,9 @@ pub const CIRCUIT_BREAKER_HELP_EVENT: bool = true; mod guard; use guard::GeneratingGuard; +/// 审批超时取消(由 try_continue_agent_loop 入口调用) +mod approval_timeout; + // ============================================================ // L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。 // 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。 @@ -830,7 +833,7 @@ pub(crate) async fn run_agentic_loop( // 输出平台一致性。detect() 是 OnceLock 全局缓存(启动时探一次,后续零开销),与 G1 一样 // 是 loop 不变量(整个会话不重探),与目标钉扎拼接次序无强约束(放其后,语义自然)。 let env_prompt = df_execute::EnvSnapshot::detect().await.to_prompt(); - let behavior_prompt = "\n## 行为准则\n- 使用工具指令(create_project/write_file/grep/read_file/run_command 等)直接完成任务,不要让用户去终端执行命令\n- 优先使用开发工具 IPC,非必要不写独立脚本(write_file + run_command = 两次审批,拖慢工作流)\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定"; + let behavior_prompt = "\n## AI 定位\n你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。**绝不输出“请在终端执行以下命令”这类指令——你自己用 run_command 工具执行即可。**\n\n## 行为准则\n- 所有操作都通过工具完成,用户不参与执行\n- 优先使用开发工具 IPC,非必要不写独立脚本\n- 脚本需要审批通过才执行,会拖慢工作流\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具"; system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt); // 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations @@ -1929,6 +1932,12 @@ pub(crate) async fn try_continue_agent_loop( conv_id: &str, start_iteration: usize, ) { + // 审批超时检测:超时取消在本函数入口进行(本函数是所有审批恢复路径的入口—— + // ai_approve / ai_authorize_dir / ai_continue_loop / ai_chat_stop 都调它)。 + // 这里检测后把超时审批转为拒绝状态,避免用户离开后 pending 永久挂起死锁会话。 + // 0 表示禁用超时(不推荐,但保留用户选择权)。检测失败(如锁中毒)不阻断续跑。 + approval_timeout::cleanup_expired_approvals(app, state, conv_id).await; + // BUG-260617-05: 原 5 次独立 lock().await 造成 TOCTOU 竞态——should_continue=true 判出后、 // spawn 前用户点 stop(ai_chat_stop 复位 generating=false),续跑仍按过时快照继续 spawn。 // 修复:单次 lock 取结构化快照(所有续跑判定所需字段),无锁态判定;spawn 前单次 lock 原子 diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index b3f0144..1d3fb19 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -450,6 +450,7 @@ pub(crate) async fn process_tool_calls( // 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。 kind: ApprovalKind::Path(req), retry_count, + created_at: Some(std::time::SystemTime::now()), }, ); // 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。 @@ -631,6 +632,7 @@ pub(crate) async fn process_tool_calls( // 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。 kind: ApprovalKind::Risk { diff: approval_diff.clone() }, retry_count, + created_at: Some(std::time::SystemTime::now()), }); // 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan) session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id))); diff --git a/src-tauri/src/commands/ai/audit/restore.rs b/src-tauri/src/commands/ai/audit/restore.rs index 42d6a83..81f05e3 100644 --- a/src-tauri/src/commands/ai/audit/restore.rs +++ b/src-tauri/src/commands/ai/audit/restore.rs @@ -79,6 +79,9 @@ pub async fn restore_pending_approvals(state: &AppState) { // 阶段4:重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。 // 即便审计表已有 pending 记录,恢复后用户审批执行属首次正常执行,不断路。 retry_count: 0, + // 恢复审批 created_at=重启时刻(无原挂起时间记录,按当前系统时间计)。 + // 超时取消计数仍从此刻起,避免恢复后立即被误判超时。 + created_at: Some(std::time::SystemTime::now()), }, ); } @@ -133,6 +136,7 @@ mod tests_f09_batch8_restore { kind: ApprovalKind::Risk { diff: None }, // 阶段4:恢复的审批 retry_count=0(首次用户决策,非重试)。 retry_count: 0, + created_at: None, }, ); } diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index e47647e..3a4024c 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -855,6 +855,10 @@ pub struct PendingApproval { pub conversation_id: Option, /// 重启恢复的积压审批:无 live loop 持有 session.messages,审批后不 save(防空 messages 污染老对话)、不续跑 pub recovered: bool, + /// 审批创建时间(用于超时取消判定)。运行期内存态,不序列化(重启恢复的积压审批按恢复时间计)。 + /// `#[serde(default)]` 使老 JSON 数据反序列化时不报错(向后兼容)。 + #[serde(default)] + pub created_at: Option, /// 阶段3a:审批类型(Path 路径授权挂起 / Risk 普通 RiskLevel 审批)。 /// 老构造点不传 kind 时默认 Risk(兼容:原 risk_pending 路径行为不变)。 pub kind: ApprovalKind, diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 5421d56..321d33e 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -53,3 +53,41 @@ pub async fn settings_delete( pub async fn get_data_dir(state: State<'_, AppState>) -> Result { Ok(state.data_dir.to_string_lossy().to_string()) } + +/// 读取审批超时分钟数(默认 15 分钟,0=禁用超时) +/// +/// 前端 Settings 页启动时拉取,展示当前配置。 +#[tauri::command] +pub async fn ai_get_approval_timeout(state: State<'_, AppState>) -> Result { + Ok(state.approval_timeout_minutes.load(std::sync::atomic::Ordering::SeqCst)) +} + +/// 设置审批超时分钟数并持久化(默认 15 分钟,0=禁用超时) +/// +/// 前端 Settings 页保存时调用。值范围 0-1440(0=禁用,最大 24 小时)。 +/// 注意:此命令与前端 AdvancedSection.vue 的 df-approval-timeout KV 持久化路径不同, +/// 前端走 appSettings.set 走 KV,这里作为后端热改入口。两者最终读写同一个 KV。 +#[tauri::command] +pub async fn ai_set_approval_timeout( + state: State<'_, AppState>, + minutes: u64, +) -> Result { + // 范围校验(0-1440 分钟,即 0-24 小时) + if minutes > 1440 { + return Err("审批超时分钟数超出范围(最大 1440 = 24 小时)".to_string()); + } + // 持久化到 Settings KV(与前端同 key,值为毫秒) + let ms = minutes * 60_000; + state + .settings + .set( + crate::state::APPROVAL_TIMEOUT_KEY, + &ms.to_string(), + ) + .await + .map_err(err_str)?; + // 更新内存 + state.approval_timeout_minutes.store(minutes, std::sync::atomic::Ordering::SeqCst); + tracing::info!("[APPROVAL-TIMEOUT] 已更新: {} 分钟 ({} ms)", minutes, ms); + Ok(minutes) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5ca341c..7375e27 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -61,7 +61,9 @@ pub fn run() { // 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。 // 记录上次处理时间,3s 内重复事件跳过(只取首次,使能状态复位一次即可)。 let last_handshake_at = std::sync::Arc::new(tokio::sync::Mutex::new( - std::time::Instant::now() - std::time::Duration::from_secs(3600), + std::time::Instant::now() + .checked_sub(std::time::Duration::from_secs(3600)) + .unwrap_or_else(std::time::Instant::now), )); let app_handle = app.handle().clone(); app.listen("ai-client-ready", move |_event| { @@ -404,6 +406,9 @@ pub fn run() { commands::settings::settings_get_all, commands::settings::settings_delete, commands::settings::get_data_dir, + // 审批超时配置(默认 15 分钟,0=禁用;Settings 页可改) + commands::settings::ai_get_approval_timeout, + commands::settings::ai_set_approval_timeout, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index c0773cb..75dd33d 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::sync::atomic::AtomicUsize; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -37,10 +37,18 @@ pub enum ExtractTrigger { ManualOnly, } +/// 知识库配置持久化 KV key(P0 设置走查-2026-06-21:原纯内存 Arc 启动 default 覆盖 +/// 致 8 项配置重启全丢;save_config 落此 KV,init reload_knowledge_config 恢复)。 /// 知识库配置持久化 KV key(P0 设置走查-2026-06-21:原纯内存 Arc 启动 default 覆盖 /// 致 8 项配置重启全丢;save_config 落此 KV,init reload_knowledge_config 恢复)。 pub const KNOWLEDGE_CONFIG_KEY: &str = "df-knowledge-config"; +/// 审批超时配置持久化 KV key(前端 AdvancedSection.vue 也在用同一个 key,保持单一真相源)。 +/// 前端值单位为毫秒(0/300000/900000/1800000/3600000),后端同步使用此 key 读取并转换。 +/// 前端在运行期负责会话内的审批超时(定时器+ai_approve(false)),后端在启动恢复阶段 +/// 清理重启前遗留的 pending(避免重启后无人调用 try_continue 致审批永不超时)。 +pub const APPROVAL_TIMEOUT_KEY: &str = "df-approval-timeout"; + /// 知识库行为配置(内存真相源 + Settings KV 持久化,前后端通过 IPC 读写) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KnowledgeConfig { @@ -410,6 +418,11 @@ pub struct AppState { /// 任何 token;流中途失败 MidStream Partial 保文不重试。退避复用 retry::backoff_delay + /// is_status_retryable Fatal 分类 + 30s 总预算,详见 agentic.rs 重试循环。默认 3)。 pub agent_max_retries: Arc, + // ── 审批超时配置 ── + /// 审批超时分钟数(前端 Settings 配置 → AppState 字段 → try_continue 入口读取)。 + /// 待审批超过此时长后自动取消,避免用户离开后会话永久卡死。 + /// 默认 15 分钟;0 表示禁用超时(不推荐,会导致卡死无自愈)。 + pub approval_timeout_minutes: Arc, // ── 工作流执行状态 ── /// 工作流执行 → 节点状态机注册表 /// @@ -718,6 +731,7 @@ impl AppState { agent_max_retries: Arc::new(AtomicUsize::new( crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES, )), + approval_timeout_minutes: Arc::new(AtomicU64::new(15)), workflow_state_registry: Arc::new(Mutex::new(HashMap::new())), // F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄) allowed_dirs: allowed_dirs.clone(), @@ -742,6 +756,8 @@ impl AppState { state.reload_allowed_dirs().await; // P0(设置走查):从 Settings KV 恢复持久化知识库配置覆盖 default(防 8 项重启全丢)。 state.reload_knowledge_config().await; + // 从 Settings KV 恢复审批超时配置(默认 15 分钟,0=禁用超时)。 + state.reload_approval_timeout().await; // 迁移旧 .trash(编译期 workspace_root → 运行期 data_dir),仅一次,幂等。 let old_trash = workspace_root_path().join(".trash"); @@ -799,6 +815,32 @@ impl AppState { } } + /// 从 Settings KV 恢复审批超时分钟数(默认 15 分钟,0=禁用)。 + /// + /// KV key `df-approval-timeout` 与前端 AdvancedSection.vue 共用,值为毫秒数(0/300000/900000/...)。 + /// 启动时读取用户配置,失败或首次启动无配置时保持默认 15 分钟。 + pub async fn reload_approval_timeout(&self) { + match self.settings.get(APPROVAL_TIMEOUT_KEY).await { + Ok(Some(json)) => { + // 兼容两种格式:JSON 数字或字符串(纯数字字串) + let ms: u64 = if let Ok(v) = serde_json::from_str::(&json) { + v + } else if let Ok(s) = serde_json::from_str::(&json) { + s.parse().unwrap_or(900_000) + } else { + tracing::warn!("[APPROVAL-TIMEOUT] KV 格式非法,保持 default: {}", json); + 900_000 + }; + // ms → minutes(向上取整,0 保留为 0 表示禁用) + let mins = if ms == 0 { 0 } else { (ms + 59_999) / 60_000 }; + self.approval_timeout_minutes.store(mins, Ordering::SeqCst); + tracing::info!("[APPROVAL-TIMEOUT] 启动恢复: {} 分钟 (KV ms={})", mins, ms); + } + Ok(None) => {} // 首次启动保持默认 15 + Err(e) => tracing::warn!("[APPROVAL-TIMEOUT] 读 KV 失败,保持 default: {}", e), + } + } + /// F-260619-03 Phase A: 从 Settings KV `allowed_dirs`(JSON 字符串数组)加载持久化白名单。 /// /// 启动 + Settings IPC `ai_set_allowed_dirs` 写入后调用。解析失败/缺失 → 保持 diff --git a/todo.md b/todo.md index ff38320..4dfbf5b 100644 --- a/todo.md +++ b/todo.md @@ -14,9 +14,9 @@ > 诊断报告:`docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md` > 实测会话 `54c8b5bd`:480 万 token,原始目标消息被压缩出局,LLM 尾部盲搜死循环。 -- [~] **G1 目标钉扎** ✅代码落地(编译过):`PerConvState.pinned_goals: Vec` + chat.rs 三处提取(追加去重,支持累积多目标) + `run_agentic_loop` 入口拼全部目标到 system_prompt。**待实测**。 -- [~] **G2 探索熔断** ✅代码落地(编译过):`STALL_BREAKER_*` 四开关 + stall_count + `is_empty_tool_result`(对齐 serde_json 紧凑格式)。警示 `insert_at(0,system)` **经核验安全**(convert_request:101/117 把 system 抽顶层合并,无连续 System 1214 风险;G1 评审该论据不成立)。**待实测**。 -- [~] **G4 话题标记修正** ✅代码落地(编译过):`TOPIC_MARKER_GOAL_AWARE` guard,pinned_goals 非空时 take 后丢弃跳过 insert。**待实测**。 +- [x] **G1 目标钉扎** ✅ 代码已落地(`GOAL_PIN_ENABLED=true`),pinned_goals 入口拼入 system_prompt。`PerConvState.pinned_goals: Vec` + chat.rs 三处提取(追加去重,支持累积多目标)。 +- [x] **G2 探索熔断** ✅ 代码已落地(`STALL_BREAKER_ENABLED=true`,`STALL_BREAKER_THRESHOLD=3`),stall_count + is_empty_tool_result 完整实现。 +- [x] **G4 话题标记修正** ✅ 代码已落地(`TOPIC_MARKER_GOAL_AWARE=true`),pinned_goals 非空时 take 后丢弃跳过 insert。 > 落地开关(均可单点回退):`GOAL_PIN_ENABLED`/`GOAL_INJECT_BANNER`/`GOAL_MAX_CHARS=500`/`MAX_GOALS=5`/`STALL_BREAKER_ENABLED`/`STALL_BREAKER_THRESHOLD=3`/`STALL_BREAKER_WARN_FIRST`/`STALL_BREAKER_GOAL_REMIND`/`TOPIC_MARKER_GOAL_AWARE`。全置 false 等价改动前。 > 方案源:设计 workflow `wf_09a0385f-2ee` 综合裁决(system_prompt 字符串注入 > insert_at(0))。 @@ -37,20 +37,25 @@ ### P0 — 审批 pending 死锁 -- [ ] **审批超时自动取消**:pending 超 5min 自动取消(status=cancelled),LLM 收到回执不再盲猜。`try_continue` 入口清理过期 pending。 -- [ ] **`create_project` 加 auto_create_dir 参数**:为 true 时自动 mkdir 目标目录,消除建项目->建目录->绑定的死锁链。 +- [x] **审批超时自动取消** ✅ 已实现(15min 默认,可配置): + - 后端 `agentic/approval_timeout.rs::cleanup_expired_approvals` 在 `try_continue_agent_loop` 入口检测超时,超时审批自动取消 + emit AiApprovalResult + audit_finalize 记入审计。 + - `PendingApproval.created_at` 记录创建时刻(SystemTime,重启恢复的按重启时刻计)。 + - `AppState.approval_timeout_minutes` + `reload_approval_timeout()` 从 Settings KV `df-approval-timeout` 启动恢复(与前端 AdvancedSection.vue 同 key,值为毫秒)。 + - IPC `ai_get_approval_timeout` / `ai_set_approval_timeout`(0=禁用,范围 0-1440 分钟)。 + - 前端已有 Settings UI(0/5/15/30/60 分钟 select),前端定时器负责运行期会话内超时,后端负责重启恢复场景兼底。 +- [x] **`create_project` 自动创建目录** ✅ 已实现(`project.rs:137`):有 path 且目录不存在时自动 `create_dir_all`,死锁链已消除(非参数控制,直接自动创建)。 ### P1 — Windows 平台兼容 -- [ ] **`run_command` 失败追加 shell 适配提示**:PowerShell 下在 tool result 尾部追加路径转义指引,引导 LLM 下次改正。 -- [ ] **PowerShell 反斜杠自动转义**:`run_command` 执行前对路径参数做 `\` 双重转义,消除 os error 123。 +- [x] **`run_command` 失败追加 shell 适配提示** ✅ 已实现(`tool_registry.rs:2351-2358`):Windows 下 tool result 尾部追加 PowerShell 适配提示(路径引用、`&&`→`;`)。 +- [x] **PowerShell 反斜杠自动转义** ✅ 已修复(`tool_registry.rs:2306-2328` 注释说明根因):serde_json 已正确还原反斜杠,不做额外转义,PowerShell 正确接受裸反斜杠路径。 ### P2 — 标题 LLM 失败 -- [ ] **标题摘要合并连续同 role**:`ensure_conversation_title` 发 LLM 前合并相邻 user/assistant,避免 Anthropic 协议 1214。 +- [x] **标题摘要合并连续同 role** ✅ 已实现(`title.rs:79-83`):`merge_consecutive_roles` 发 LLM 前合并相邻 user/assistant,已覆盖 Anthropic/GLM 1214。 ### P3 — 其他 -- [ ] **migration V33: `workflow_executions` 加 `updated_at` 列**。 -- [ ] **L0-handshake 防抖**:3s 内重复 `ai-client-ready` 跳过。 +- [x] **migration V33** ✅ 已实现(`migrations.rs` migrate_v33):`workflow_executions` 加 `updated_at` 列 + `ai_conversations` 加 `pending_approvals`(审批重启恢复)。 +- [x] **L0-handshake 防抖** ✅ 已实现(`lib.rs:73-82`):3s 内重复 `ai-client-ready` 跳过。