新增: 断点续跑与日志租约底座

This commit is contained in:
lxy
2026-10-05 23:41:18 +08:00
parent 8233ba095e
commit cb260aba4b
22 changed files with 1132 additions and 136 deletions
@@ -110,6 +110,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
origin: row.get("origin").unwrap_or(None),
// boundary_json 列老库经 v45 迁移补建。unwrap_or(None) 兜底(NULL=未声明任务边界)。
boundary_json: row.get("boundary_json").unwrap_or(None),
// loop_state 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。
loop_state: row.get("loop_state").unwrap_or(None),
})
}
@@ -185,25 +187,25 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json)
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<bool> {
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<u64> {
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<Vec<AiConversationRecord>> {
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::<std::result::Result<Vec<_>, _>>()
.map_err(storage_err)?;
Ok(rows)
})
.await
.map_err(storage_err)?
}
/// G1.3: 删除对话 + 其全部 ai_messages 子行(单事务原子)。
///
/// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 ai_messages 表无外键级联
+28 -5
View File
@@ -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");
}
// ============================================================
+6
View File
@@ -390,6 +390,12 @@ pub struct AiConversationRecord {
/// 无人值守任务边界快照(V45):TaskBoundary JSON;None=未声明(现状逐次审批)。
/// 生命周期=会话存续(发起时一次声明,随会话同亡),save 随行写/switch 读还原。
pub boundary_json: Option<String>,
/// loop 生命周期标记(V49,断点续跑底座):None=非任务会话/已收敛清理;
/// Some("running")=任务 loop 在跑(spawn 时置起);Some("parked")=进程重启时检测到
/// running 残留 → 启动扫描改写,侧栏标「任务中断可恢复」+ 一键恢复。
/// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。
#[serde(default)]
pub loop_state: Option<String>,
}
/// AI 工具执行审计记录
+54 -17
View File
@@ -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<GoalEntry>,含 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::<AppState>();
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::<AppState>();
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<Mutex<AiSession>>,
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;
@@ -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
}
+27
View File
@@ -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<AtomicBool>,
@@ -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("测试会话插入应成功");
@@ -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::<AppState>();
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 {
@@ -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<serde_json::Value> = records.iter()
.filter(|r| (include_archived || !r.archived) && r.origin.is_none())
.take(limit)
.map(|r| {
// 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组
let models: Vec<String> = 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("测试会话插入应成功");
@@ -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}");
+274 -15
View File
@@ -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<usize>
/// 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<df_storage::models::KnowledgeRecord> {
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,33 +793,87 @@ 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<Database>/Arc<Mutex> 等跨 unwind 引用,故用 AssertUnwindSafe(非 UnwindSafe)。
fn spawn_with_inflight_guard<F>(conv_id: String, fut: F)
where
F: std::future::Future<Output = ()> + 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<bool, String> {
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 条)后与自动路径同语义收口:置哨兵 + 记水位,
// 避免手动提炼后再触发自动提炼重复。用户再次手动按按钮会再次清位,行为自洽。
if inserted > 0 {
// 手动提炼后与自动路径同语义收口:≥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 {
session.conv(&conv_id).knowledge_extracted = true;
}
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()))
@@ -818,8 +884,6 @@ pub async fn trigger_extraction_now(state: &AppState) -> Result<bool, String> {
.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<Database>, conv_id: &str, messages: &[ChatMessage]) -> Option<String> {
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::<TaskBoundary>(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<Database>, 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<Database>, 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);
}
}
+345
View File
@@ -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<RwLock<HashMap<String, LeaseHolder>>> =
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<String> {
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<LeaseGuard, String> {
// 防御:租约入口自兜底判写类(调用方 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<String>,
}
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<serde_json::Value> = 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::<Vec<_>>());
}
#[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 后应已释放");
}
}
+2
View File
@@ -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;
+42 -1
View File
@@ -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 <package>` 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() {
+9 -2
View File
@@ -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();
+18 -1
View File
@@ -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<String> = 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<u64> = 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 }))
}
);
@@ -106,7 +106,7 @@ pub(crate) fn caller_conv() -> Option<String> {
// ── 参数解析与任务书组装(纯函数,单测锁定) ──
/// 解析 max_rounds:缺省默认 6,合法域钳制 [1, 20](防参数放大预算失控)。
/// 解析 max_rounds:缺省默认 12,合法域钳制 [1, 20](防参数放大预算失控)。
fn parse_max_rounds(raw: Option<u64>) -> 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)
+7 -34
View File
@@ -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())
+179
View File
@@ -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);
}
}
+2 -33
View File
@@ -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 = 禁用(常驻)。
+11
View File
@@ -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");
+5 -10
View File
@@ -5,7 +5,7 @@
卡面 = reason 人话 + stop_kind 标签;操作两枚:继续(ai_continue_loop 重新授权续跑)/
停止归档(ai_chat_stop 既有停止路径,归档会话走侧栏既有入口,此处不代做)。
cancelled 例外只显停止归档(用户主动取消后不再诱导续跑)。 -->
<div v-if="parkedActive" ref="cardRef" class="ai-task-parked">
<div v-if="parkedActive" class="ai-task-parked">
<div class="ai-task-parked-head">
<span class="ai-task-parked-tag" :class="kindClass">{{ kindLabel }}</span>
<span class="ai-task-parked-title">{{ t('ai.taskParked.title') }}</span>
@@ -20,7 +20,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api'
@@ -52,14 +52,9 @@ const kindLabel = computed(() => {
// fatal 走 danger 红色(连续失败重入预算耗尽,警示级最高;语义上仍有「继续」路),其余 warning 琥珀
const kindClass = computed(() => (parkedActive.value?.stopKind === 'fatal' ? 'ai-task-parked-tag--fatal' : ''))
// 卡片根 ref。停靠常发生在多轮迭代后消息流已长,卡显时 scrollIntoView 让操作按钮立即可见
const cardRef = ref<HTMLElement | null>(null)
watch(parkedActive, async (p) => {
if (!p) return
await nextTick()
cardRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
// A5(2026-10-05):删 scrollIntoView——卡挂输入区上方(.ai-input-area 内,常驻可见),
// 非消息流;scrollIntoView 找不到有意义的滚动容器≈no-op,且原注释「消息流已长时让按钮
// 可见」与实际挂载点不符。卡片显示态本身由 parkedActive watch 驱动,无需额外滚动。
// 操作进行中防重复点击(继续/停止都是 IPC 往返,连点会重复触发续跑/停止)
const busy = ref(false)