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

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), origin: row.get("origin").unwrap_or(None),
// boundary_json 列老库经 v45 迁移补建。unwrap_or(None) 兜底(NULL=未声明任务边界)。 // boundary_json 列老库经 v45 迁移补建。unwrap_or(None) 兜底(NULL=未声明任务边界)。
boundary_json: row.get("boundary_json").unwrap_or(None), boundary_json: row.get("boundary_json").unwrap_or(None),
// loop_state 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。
loop_state: row.get("loop_state").unwrap_or(None),
}) })
} }
@@ -185,25 +187,25 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row), from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
params![ params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin, rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
rec.boundary_json rec.boundary_json, rec.loop_state
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
conn.execute( conn.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14", "UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13, loop_state = ?14 WHERE id = ?15",
params![ params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 }, if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens, rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.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)? .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 子行(单事务原子)。 /// G1.3: 删除对话 + 其全部 ai_messages 子行(单事务原子)。
/// ///
/// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 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, // V46/V47 = 任务/灵感归档(archived INTEGER NOT NULL DEFAULT 0,归档功能 P1,
// 对标 ai_conversations.archived V4;归档与软删正交:回收站=删除缓冲,归档=低频可见保检索)。 // 对标 ai_conversations.archived V4;归档与软删正交:回收站=删除缓冲,归档=低频可见保检索)。
// V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。 // 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), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -109,6 +111,8 @@ pub fn run(conn: &Connection) -> Result<()> {
(47, migrate_v47), (47, migrate_v47),
// V48 = 项目归档(projects.archived 列,归档功能 P2,对标 V46/V47 同款)。 // V48 = 项目归档(projects.archived 列,归档功能 P2,对标 V46/V47 同款)。
(48, migrate_v48), (48, migrate_v48),
// V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。
(49, migrate_v49),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -1427,6 +1431,25 @@ fn migrate_v48(conn: &Connection) -> Result<()> {
Ok(()) 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 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -2455,8 +2478,8 @@ mod tests {
) )
.expect("查 schema_version 应成功"); .expect("查 schema_version 应成功");
assert_eq!( assert_eq!(
max_version, 48, max_version, 49,
"全量迁移后 schema_version 应为 48(实际 {}),说明某条 migrate_vN 链路断在中间", "全量迁移后 schema_version 应为 49(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version max_version
); );
@@ -2533,7 +2556,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(max_v, 48, "首轮应推进到 48"); assert_eq!(max_v, 49, "首轮应推进到 49");
// 清空版本表强制全链第二遍(每步 execute 第二次) // 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap(); conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2545,7 +2568,7 @@ mod tests {
|r| r.get(0), |r| r.get(0),
) )
.unwrap(); .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=未声明(现状逐次审批)。 /// 无人值守任务边界快照(V45):TaskBoundary JSON;None=未声明(现状逐次审批)。
/// 生命周期=会话存续(发起时一次声明,随会话同亡),save 随行写/switch 读还原。 /// 生命周期=会话存续(发起时一次声明,随会话同亡),save 随行写/switch 读还原。
pub boundary_json: Option<String>, 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 工具执行审计记录 /// AI 工具执行审计记录
+54 -17
View File
@@ -181,6 +181,22 @@ pub const SKILL_LIST_MAX_ITEMS: usize = 120;
/// 清单只做提示,截断防长描述(部分技能 description 数百字)撑爆 prompt,截断后接省略号。 /// 清单只做提示,截断防长描述(部分技能 description 数百字)撑爆 prompt,截断后接省略号。
pub const SKILL_DESC_MAX_CHARS: usize = 80; 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)。 /// G4 目标感知降级:话题标记 insert 当 pinned_goals 存在时跳过(默认 true)。
/// ///
/// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的 /// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的
@@ -457,6 +473,17 @@ pub(crate) async fn run_agentic_loop(
return; 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 误断流。 // 治本:LLM 生成间隙向前端发 AiHeartbeat,防前端 watchdog 误断流。
// 后端 emit AiHeartbeat → useAiEvents.ts resetStreamWatchdog(convId) → per-conv timer 重置。 // 后端 emit AiHeartbeat → useAiEvents.ts resetStreamWatchdog(convId) → per-conv timer 重置。
// 解决:LLM 重试循环(~60s)期间无事件到达前端 → watchdog 45s/90s 到期误杀。 // 解决:LLM 重试循环(~60s)期间无事件到达前端 → watchdog 45s/90s 到期误杀。
@@ -840,8 +867,8 @@ pub(crate) async fn run_agentic_loop(
// 推理结果存入 pinned_goals: Vec<GoalEntry>,含 text+status 状态追踪。 // 推理结果存入 pinned_goals: Vec<GoalEntry>,含 text+status 状态追踪。
// 新目标加入时自动标记之前的 active 为 completed。 // 新目标加入时自动标记之前的 active 为 completed。
// //
// 入口 system_prompt 五段拼接(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / // 入口 system_prompt 六段拼接(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 派工纪律 /
// T4 工作流 DAG)收敛至 build_runtime_prompt helper:一次锁读 per_conv 三字段 // 技能清单 / T4 工作流 DAG)收敛至 build_runtime_prompt helper:一次锁读 per_conv 三字段
// (pinned_goals / working_context / workflow_dag_summary)消除原 G1/T5/T4 三处独立 lock, // (pinned_goals / working_context / workflow_dag_summary)消除原 G1/T5/T4 三处独立 lock,
// 且返回 pinned_goals 复用作 AiCompleted 快照(消除原 L1157 二次读)。拼接顺序+内容逐字节等价。 // 且返回 pinned_goals 复用作 AiCompleted 快照(消除原 L1157 二次读)。拼接顺序+内容逐字节等价。
let (system_prompt, pinned_goals_snapshot) = build_runtime_prompt( 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 ── // ── 进度可观测段(设计 §4.3):每 N 轮 emit AiTaskProgress ──
// round 1 起算对齐 AiAgentRound 口径;max_rounds=0=不限轮次(前端按无上限处理)。 // round 1 起算对齐 AiAgentRound 口径;max_rounds=0=不限轮次(前端按无上限处理)。
// owner 守卫:被接管的旧 loop 不发(防串会话进度)。事件即逝不落库,终局不补发。 // 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 && loop_epoch_arc.load(Ordering::SeqCst) == my_epoch
{ {
emit_ai_chat_event(&app_handle, AiChatEvent::AiTaskProgress { 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, "生成完成"); 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)); 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))。 // emit 端(前端通道):incomplete 用 emit_incomplete(MidStream 传 Some(true))。
// token 分项(2026-08-02):cache_hit/cache_miss/reasoning 透传前端分计费展示。 // token 分项(2026-08-02):cache_hit/cache_miss/reasoning 透传前端分计费展示。
// publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为); // 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); let ev = completed_event(usage, is_estimated, emit_incomplete, incomplete_reason, conv_id, pinned_goals);
if do_publish && emit_incomplete == publish_incomplete { if do_publish && emit_incomplete == publish_incomplete {
emit_ai_chat_event(app_handle, ev); 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; return;
} }
// 有意不走 emit_ai_chat_event(emit/publish 两端 incomplete 标志分叉,见上注释)—— // 有意不走 emit_ai_chat_event(emit/publish 两端 incomplete 标志分叉,见上注释)——
@@ -2602,18 +2643,8 @@ pub(crate) async fn emit_ai_completed_once(
// 本函数是全部正常出口(5 类 teardown_round 出口 + 正常完成直调)的统一单点, // 本函数是全部正常出口(5 类 teardown_round 出口 + 正常完成直调)的统一单点,
// 到达者必为 owner loop(epoch 守卫在各调用方:teardown_round 入口 / 正常完成双检), // 到达者必为 owner loop(epoch 守卫在各调用方:teardown_round 入口 / 正常完成双检),
// force_send 接管后的旧 loop 不触发。无等待方(普通用户会话)has 判空短路,零会话锁开销。 // force_send 接管后的旧 loop 不触发。无等待方(普通用户会话)has 判空短路,零会话锁开销。
{ // review ④:内联块收敛到 deliver_completion_report_if_waited 单实现(与上方早退分支同源)。
let app_state = app_handle.state::<AppState>(); crate::commands::ai::commands::chat::deliver_completion_report_if_waited(app_handle, conv_id).await;
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] 编排会话收敛,完成通知桥投递最终报告"
);
}
}
} }
/// 取会话末条 active assistant 消息文本(编排会话最终报告投递用)。 /// 取会话末条 active assistant 消息文本(编排会话最终报告投递用)。
@@ -2621,7 +2652,7 @@ pub(crate) async fn emit_ai_completed_once(
/// conv 不存在 / 无 assistant 消息返空串,原样投递,空文本由节点侧判「无输出」失败 /// conv 不存在 / 无 assistant 消息返空串,原样投递,空文本由节点侧判「无输出」失败
/// (用户 stop 在任何输出前的场景)。读内存 per_conv 非常规出口:正常完成路径 save 落库 /// (用户 stop 在任何输出前的场景)。读内存 per_conv 非常规出口:正常完成路径 save 落库
/// 在后台 spawn 里,时序上可能晚于本函数,内存消息是唯一已确定就绪的真相源。 /// 在后台 spawn 里,时序上可能晚于本函数,内存消息是唯一已确定就绪的真相源。
async fn last_active_assistant_text( pub(crate) async fn last_active_assistant_text(
session_arc: &Arc<Mutex<AiSession>>, session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str, conv_id: &str,
) -> String { ) -> 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)。 // 抽自原 L1024-1152(G1 目标钉扎 / T5 工作上下文 / Env+behavior / 技能清单 / T4 工作流 DAG)。
// N1 增量:新增派工纪律段(DISPATCH_DISCIPLINE 母本内置,拼在 behavior 后),非原五段内容。
// **行为逐字节等价**:拼接顺序与内容不变,开关(GOAL_PIN_ENABLED / SKILL_LIST_INJECT_ENABLED / // **行为逐字节等价**:拼接顺序与内容不变,开关(GOAL_PIN_ENABLED / SKILL_LIST_INJECT_ENABLED /
// GOAL_INJECT_BANNER)与截断(GOAL_MAX_CHARS / SKILL_LIST_MAX_ITEMS / SKILL_DESC_MAX_CHARS)常量原样引用。 // 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); 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() 进程内缓存(懒扫描一次,后续零开销),无锁需求。 // 技能清单注入:skills_cached() 进程内缓存(懒扫描一次,后续零开销),无锁需求。
if SKILL_LIST_INJECT_ENABLED { if SKILL_LIST_INJECT_ENABLED {
let skills = crate::commands::ai::skills::skills_cached().await; let skills = crate::commands::ai::skills::skills_cached().await;
@@ -86,7 +86,9 @@ const SUB_AGENT_ORIGIN: &str = "sub_agent";
// ============================================================ // ============================================================
/// 子代理默认最大工具循环轮数(spawn 参数 max_rounds 缺省值,透传可调)。 /// 子代理默认最大工具循环轮数(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 参数放大预算失控)。 /// max_rounds 入口钳制上限(防 spawn 参数放大预算失控)。
pub(crate) const SUB_AGENT_MAX_ROUNDS_CAP: u32 = 20; 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,子代理必须按真实预算规划探索节奏, /// 替换「默认最多 6 轮」类硬编码文案——父可传 1..20,子代理必须按真实预算规划探索节奏,
/// 预算将尽时主动收尾交卷,而非被截断后拿末轮过程性独白当报告。 /// 预算将尽时主动收尾交卷,而非被截断后拿末轮过程性独白当报告。
/// 工具纪律段经 prompt::sub_agent_tool_discipline_section 同源拼接(P1-1)。 /// 工具纪律段经 prompt::sub_agent_tool_discipline_section 同源拼接(P1-1)。
/// 派工纪律段经 super::DISPATCH_DISCIPLINE 母本同源拼接(N1),与主会话共享同一文本。
pub(crate) fn sub_agent_system_prompt(max_rounds: u32) -> String { pub(crate) fn sub_agent_system_prompt(max_rounds: u32) -> String {
let mut p = format!( let mut p = format!(
"你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\ "你是 DevFlow 主会话派生的子代理,在隔离上下文中独立执行父任务交办的任务书。\n\
@@ -483,6 +486,10 @@ pub(crate) fn sub_agent_system_prompt(max_rounds: u32) -> String {
// 纪律/禁臆造 URL/查配置用 get_app_config);obscura 安装引导等交互条款已在共享函数 // 纪律/禁臆造 URL/查配置用 get_app_config);obscura 安装引导等交互条款已在共享函数
// 内裁剪,不适用于子代理。 // 内裁剪,不适用于子代理。
p.push_str(&crate::commands::ai::prompt::sub_agent_tool_discipline_section("zh")); 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 p
} }
+27
View File
@@ -253,6 +253,33 @@ pub(crate) async fn execute_with_heartbeat(
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use tokio::time::Duration; 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。 // RAII guard:drop 时 stop+abort 心跳 task,确保 execute panic(unwind)也不泄漏心跳 task。
struct HeartbeatGuard { struct HeartbeatGuard {
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
@@ -600,6 +600,7 @@ mod tests {
updated_at: "1".to_string(), updated_at: "1".to_string(),
origin: origin.map(|s| s.to_string()), origin: origin.map(|s| s.to_string()),
boundary_json: None, boundary_json: None,
loop_state: None,
}) })
.await .await
.expect("测试会话插入应成功"); .expect("测试会话插入应成功");
@@ -913,6 +913,18 @@ pub(crate) async fn spawn_agentic_session(
/// 注册先于 spawn:完成通知最早发生在 loop 收敛出口(秒级之后),不存在注册前触发竞态; /// 注册先于 spawn:完成通知最早发生在 loop 收敛出口(秒级之后),不存在注册前触发竞态;
/// 顺序对调则有「先收敛后注册」丢通知窗口。返回的接收端由节点 select! 等待;发送端挂在 /// 顺序对调则有「先收敛后注册」丢通知窗口。返回的接收端由节点 select! 等待;发送端挂在
/// 通知桥,agentic loop 收敛统一出口(emit_ai_completed_once)投递最终 assistant 报告。 /// 通知桥,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 { pub(crate) fn orchestration_session_spawner() -> df_nodes::ai_agent_node::SpawnFn {
std::sync::Arc::new(|task: String| -> df_nodes::ai_agent_node::SpawnFuture { std::sync::Arc::new(|task: String| -> df_nodes::ai_agent_node::SpawnFuture {
Box::pin(async move { Box::pin(async move {
@@ -218,6 +218,9 @@ pub async fn ai_conversation_create(
/// 列出对话(仅摘要,不含 messages 全文) /// 列出对话(仅摘要,不含 messages 全文)
/// ///
/// limit 默认 50 防数据膨胀;include_archived 默认 false(归档对话默认隐藏)。 /// limit 默认 50 防数据膨胀;include_archived 默认 false(归档对话默认隐藏)。
///
/// 截断按组独立:活跃/归档各自取 limit,归档项不挤占活跃名额(侧栏归档分组数据源依赖
/// include_archived=true,见 useAiConversations.loadConversations)。
#[tauri::command] #[tauri::command]
pub async fn ai_conversation_list( pub async fn ai_conversation_list(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -231,7 +234,6 @@ pub async fn ai_conversation_list(
// origin 非空(子代理轨迹,V44)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。 // origin 非空(子代理轨迹,V44)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。
let summaries: Vec<serde_json::Value> = records.iter() let summaries: Vec<serde_json::Value> = records.iter()
.filter(|r| (include_archived || !r.archived) && r.origin.is_none()) .filter(|r| (include_archived || !r.archived) && r.origin.is_none())
.take(limit)
.map(|r| { .map(|r| {
// 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组 // 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组
let models: Vec<String> = r.models.as_deref() let models: Vec<String> = r.models.as_deref()
@@ -257,7 +259,20 @@ pub async fn ai_conversation_list(
"updated_at": r.updated_at, "updated_at": r.updated_at,
}) })
}).collect(); }).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 给前端) /// 切换到指定对话(从 DB 加载 messages 到内存 + 返回 messages 给前端)
@@ -1469,6 +1484,7 @@ mod tests {
updated_at: created_at.to_string(), updated_at: created_at.to_string(),
origin: origin.map(|s| s.to_string()), origin: origin.map(|s| s.to_string()),
boundary_json: None, boundary_json: None,
loop_state: None,
}) })
.await .await
.expect("测试会话插入应成功"); .expect("测试会话插入应成功");
@@ -531,6 +531,7 @@ async fn save_conversation_inner(
boundary_json: conv_boundary.as_ref() boundary_json: conv_boundary.as_ref()
.map(|b| serde_json::to_string(b).unwrap_or_default()) .map(|b| serde_json::to_string(b).unwrap_or_default())
.filter(|s| !s.is_empty()), .filter(|s| !s.is_empty()),
loop_state: None,
}; };
if let Err(e) = conv_repo.insert(rec).await { if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}"); tracing::warn!("落库对话失败 {conv_id}: {e}");
+274 -15
View File
@@ -1,6 +1,7 @@
//! 知识库集成 — 注入 + 提炼(嵌入生成 / 混合检索 / 上下文构建 / 对话提炼) //! 知识库集成 — 注入 + 提炼(嵌入生成 / 混合检索 / 上下文构建 / 对话提炼)
use std::collections::HashMap; use std::collections::HashMap;
use std::panic::AssertUnwindSafe;
use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -13,7 +14,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
use df_ai::router::{ use df_ai::router::{
is_likely_image_model, select_model_id, Modality, TaskRequirements, 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::db::Database;
use df_storage::models::{AiProviderRecord, KnowledgeRecord}; 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 crate::state::{AppState, ExtractTrigger, LlmConcurrency, log_slow_lock};
use super::boundary::TaskBoundary;
use crate::commands::ai::audit::truncate_chars_ellipsis; use crate::commands::ai::audit::truncate_chars_ellipsis;
use crate::commands::err_str; use crate::commands::err_str;
@@ -411,16 +414,21 @@ pub async fn retry_failed_embeddings(state: &AppState) -> anyhow::Result<usize>
/// vector_enabled 关闭或 embed 失败时纯 LIKE(零外部依赖降级)。 /// vector_enabled 关闭或 embed 失败时纯 LIKE(零外部依赖降级)。
/// LIKE 路 query 先经 [`extract_query_keywords`] 分词(整句 LIKE 命中率≈0); /// LIKE 路 query 先经 [`extract_query_keywords`] 分词(整句 LIKE 命中率≈0);
/// 向量路 query 同步改用关键词拼接(虚词稀释 embedding 语义),分词为空时回退原句。 /// 向量路 query 同步改用关键词拼接(虚词稀释 embedding 语义),分词为空时回退原句。
///
/// `project_id`(跨会话召回 D2):Some 时 LIKE 路走项目软排序(同项目知识排前,
/// limit 截断时优先保留);None 与旧路径逐字一致。向量路不做项目过滤(sqlite-vec
/// 无列过滤,软排序在 merge 阶段同样以 keyword 路排序为准)。
async fn hybrid_search( async fn hybrid_search(
state: &AppState, state: &AppState,
query: &str, query: &str,
limit: usize, limit: usize,
config: &crate::state::KnowledgeConfig, config: &crate::state::KnowledgeConfig,
project_id: Option<&str>,
) -> Vec<df_storage::models::KnowledgeRecord> { ) -> Vec<df_storage::models::KnowledgeRecord> {
let keywords = extract_query_keywords(query); let keywords = extract_query_keywords(query);
let keyword_results = match state let keyword_results = match state
.knowledge .knowledge
.search_keywords(&keywords, config.include_candidates, limit) .search_keywords_scoped(&keywords, config.include_candidates, limit, project_id)
.await .await
{ {
Ok(v) => v, Ok(v) => v,
@@ -507,7 +515,10 @@ pub(crate) async fn build_knowledge_context(
if !config.auto_inject { if !config.auto_inject {
return String::new(); 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() { if results.is_empty() {
return String::new(); return String::new();
} }
@@ -713,7 +724,8 @@ pub(crate) async fn maybe_spawn_extraction(
let conv_id = conv_id.to_string(); let conv_id = conv_id.to_string();
let provider_config = provider_config.clone(); let provider_config = provider_config.clone();
let llm_concurrency = llm_concurrency.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 { match extract_knowledge_from_conversation(&db, &conv_id, &provider_config, &llm_concurrency).await {
Ok(inserted) => { Ok(inserted) => {
if inserted > 0 { if inserted > 0 {
@@ -781,33 +793,87 @@ pub(crate) async fn maybe_spawn_extraction(
/// ///
/// P1 修复(提炼重复触发):手动路径强制清 knowledge_extracted 去重标志, /// 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> { pub async fn trigger_extraction_now(state: &AppState) -> Result<bool, String> {
let conv_id = { let conv_id = {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;
let conv_id = session.active_conversation_id.clone(); let conv_id = session.active_conversation_id.clone();
// 强制清哨兵 + 频次水位:手动提炼是用户显式动作,绕过自动路径的间隔限制(语义=手动覆盖)。 // 强制清哨兵 + 频次水位:手动提炼是用户显式动作,绕过自动路径的间隔限制(语义=手动覆盖)。
// 注意只清 Done(自动节奏水位),InFlight 并发占位保留——清掉会让下方占位检查永假,
// 连点按钮即并发重复提炼(占位语义优先于手动覆盖)。
if let Some(cid) = conv_id.as_deref() { if let Some(cid) = conv_id.as_deref() {
session.conv(cid).knowledge_extracted = false; session.conv(cid).knowledge_extracted = false;
EXTRACT_WATERMARKS let mut wms = EXTRACT_WATERMARKS.lock().expect("提炼水位互斥锁中毒");
.lock() if matches!(wms.get(cid), Some(ExtractWatermark::Done(_))) {
.expect("提炼水位互斥锁中毒") wms.remove(cid);
.remove(cid); }
} }
conv_id conv_id
}; };
let conv_id = conv_id.ok_or_else(|| "当前无活跃对话".to_string())?; 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 provider_config = super::prompt::get_active_provider(state).await.map_err(err_str)?;
let db = state.db.clone(); let db = state.db.clone();
let session_arc = state.ai_session.clone(); let session_arc = state.ai_session.clone();
let llm_concurrency = state.llm_concurrency.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 { match extract_knowledge_from_conversation(&db, &conv_id, &provider_config, &llm_concurrency).await {
Ok(inserted) => { Ok(inserted) => {
// 手动提炼成功(≥1 条)后与自动路径同语义收口:置哨兵 + 记水位, // 手动提炼后与自动路径同语义收口:≥1 条置哨兵;所有出口记 Done 水位
// 避免手动提炼后再触发自动提炼重复。用户再次手动按按钮会再次清位,行为自洽。 // (占位已置,任何出口不清位会永久卡死后续自动提炼)。
if inserted > 0 {
let mut session = session_arc.lock().await; 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; 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 let rounds = session
.conv_read(&conv_id) .conv_read(&conv_id)
.map(|c| count_user_rounds(c.messages.iter())) .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)); .insert(conv_id.clone(), ExtractWatermark::Done(rounds));
} }
} }
Err(e) => tracing::warn!("手动提炼失败(非阻断): {}", e),
}
}); });
Ok(true) Ok(true)
} }
@@ -838,6 +902,97 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
4. 输出纯 JSON,无任何额外文字\n\ 4. 输出纯 JSON,无任何额外文字\n\
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)"; 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 写入知识库 /// 从对话中提炼知识,产出 candidate 写入知识库
/// ///
/// 流程: 读对话消息 → 过滤 user/assistant 取最近 [`KNOWLEDGE_EXTRACT_WINDOW`] 条 → /// 流程: 读对话消息 → 过滤 user/assistant 取最近 [`KNOWLEDGE_EXTRACT_WINDOW`] 条 →
@@ -1011,6 +1166,11 @@ async fn extract_knowledge_from_conversation(
let knowledge_repo = KnowledgeRepo::new(db); let knowledge_repo = KnowledgeRepo::new(db);
let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::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; let mut inserted = 0;
for item in &items { for item in &items {
let kind = match item.get("kind").and_then(|v| v.as_str()) { let kind = match item.get("kind").and_then(|v| v.as_str()) {
@@ -1038,7 +1198,8 @@ async fn extract_knowledge_from_conversation(
confidence, confidence,
reuse_count: 0, reuse_count: 0,
verified: false, 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 产出。 // F-260619-04 P1 消息级溯源:conv_msg:{assistant_msg_id} 精确定位本轮 AI 产出。
// last_assistant_msg_id 为 None(老数据无 id)→ 降级 conv:{conv_id} 对话级, // 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(r#"{"kind":"pitfall""#).is_none(), "无完整闭合元素");
assert!(salvage_truncated_json_array("[").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(crate) mod generate_image;
pub mod get_app_config; pub mod get_app_config;
pub mod knowledge_inject; pub mod knowledge_inject;
// 文件租约注册表(W-h1 感知租约):写类文件工具跨会话互斥 + query_leases 兄弟感知
pub mod lease;
// 系统通知(P1 bg 化补完):完成/失败/需介入三类事件,主窗托盘化时补位提醒(条件发送) // 系统通知(P1 bg 化补完):完成/失败/需介入三类事件,主窗托盘化时补位提醒(条件发送)
pub(crate) mod notify; pub(crate) mod notify;
pub mod prompt; 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\ - **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\ - {}\n\
- {}\n\
## Focus\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\ - 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\ - 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), read_symbol_guidance(lang),
duplicate_call_guidance(lang), duplicate_call_guidance(lang),
file_reading_guidance(lang), file_reading_guidance(lang),
verification_fast_check_guidance(lang),
), ),
"\n## Current Projects\n", "\n## Current Projects\n",
"\n## Current Tasks\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\
## 聚焦准则\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), read_symbol_guidance(lang),
duplicate_call_guidance(lang), duplicate_call_guidance(lang),
file_reading_guidance(lang), file_reading_guidance(lang),
verification_fast_check_guidance(lang),
), ),
"\n## 当前项目\n", "\n## 当前项目\n",
"\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 同源拼接的工具使用纪律 —— /// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 ——
/// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败 /// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败
/// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具, /// 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\ - {}\n\
- {}\n{}{}", - {}\n\
- {}\n\
{}{}",
read_symbol_guidance(lang), read_symbol_guidance(lang),
duplicate_call_guidance(lang), duplicate_call_guidance(lang),
file_reading_guidance(lang), file_reading_guidance(lang),
verification_fast_check_guidance(lang),
doc_strategy_core_section(lang), doc_strategy_core_section(lang),
app_config_query_guidance_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 \ - Must preserve core topic words, entity names, and technical terms the user \
repeatedly mentions; they are anchors for continuing the conversation and losing \ repeatedly mentions; they are anchors for continuing the conversation and losing \
them breaks the context thread.\n\ 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\ - Do NOT invent facts not present in the conversation.\n\
- Output the four sections only, no preamble or extra commentary.", - 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\ - 必须保留用户反复提及的核心主题词、实体名、技术名词(它们是对话续接的锚点,丢失会致上下文断裂)。\n\
- 必须原样保留「被拒/失败」事实:失败的工具调用及其错误信息、用户明确否决的方案及理由、明令禁止的操作——丢失会导致压缩后盲目重走失败路径。\n\
- 不要编造对话中没有的事实。\n\ - 不要编造对话中没有的事实。\n\
- 只输出上述四段内容,不要前言、解释或额外评论。", - 只输出上述四段内容,不要前言、解释或额外评论。",
} }
@@ -915,6 +936,26 @@ mod tests {
assert!(en.contains("breaks the context")); 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 自身配置引导段(中/英)存在且含关键引导 // 查 DevFlow 自身配置引导段(中/英)存在且含关键引导
#[test] #[test]
fn app_config_query_guidance_section_present_zh_en() { 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_read_skill_tool(&mut registry);
register_spawn_agent_tool(&mut registry, spawn_agent_ctx); register_spawn_agent_tool(&mut registry, spawn_agent_ctx);
register_uclaw_tool(&mut registry, db); register_uclaw_tool(&mut registry, db);
super::lease::register_lease_tool(&mut registry);
registry registry
} }
@@ -1510,10 +1511,14 @@ mod tests {
// u-relay commander 协议,Medium 审批档同 spawn_agent;配置 KV 三键读, // u-relay commander 协议,Medium 审批档同 spawn_agent;配置 KV 三键读,
// 声明式注册 tools/uclaw.rs)。 // 声明式注册 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。 // 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!( assert_eq!(
registry.len(), registry.len(),
79, 80,
"工具总数应为 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(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 / 漏注册 / 误删除。 // 工具名集合基线:防 rename / 漏注册 / 误删除。
@@ -1587,6 +1592,8 @@ mod tests {
// ── uclaw 层 (1) ──(委托 u-claw 常驻 Claude Code 网关,u-relay commander 协议, // ── uclaw 层 (1) ──(委托 u-claw 常驻 Claude Code 网关,u-relay commander 协议,
// Medium 审批档,声明式注册 tools/uclaw.rs) // Medium 审批档,声明式注册 tools/uclaw.rs)
"ask_uclaw", "ask_uclaw",
// ── lease 层 (1) ──(W-h2 文件租约查询,只读 Low,注册在 lease.rs)
"query_leases",
]; ];
expected.sort_unstable(); expected.sort_unstable();
+18 -1
View File
@@ -393,6 +393,16 @@ pub fn register(
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len()); anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len());
} }
let target = std::path::Path::new(path); 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 致数据彻底丢失) // FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
// 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节 // 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节
let old_size: Option<u64> = match tokio::fs::metadata(target).await { 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()); 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 { fn parse_max_rounds(raw: Option<u64>) -> u32 {
raw.map(|v| v as u32) raw.map(|v| v as u32)
.unwrap_or(SUB_AGENT_DEFAULT_MAX_ROUNDS) .unwrap_or(SUB_AGENT_DEFAULT_MAX_ROUNDS)
@@ -153,7 +153,7 @@ pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) {
}, },
"max_rounds": { "max_rounds": {
"type": "integer", "type": "integer",
"description": "可选,工具循环轮数上限,默认 6,最大 20" "description": "可选,工具循环轮数上限,默认 12,最大 20"
}, },
"model": { "model": {
"type": "string", "type": "string",
@@ -166,7 +166,7 @@ pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) {
registry, registry,
ctx: SpawnAgentCtx, ctx: SpawnAgentCtx,
"spawn_agent", "spawn_agent",
"派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 6);model 可选模型(缺省继承当前会话)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回", "派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 12);model 可选模型(缺省继承当前会话)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回",
RiskLevel::Medium, RiskLevel::Medium,
meta: ToolMeta { meta: ToolMeta {
// 审批卡文案模板(模板 {} 按位置填充,键取 task) // 审批卡文案模板(模板 {} 按位置填充,键取 task)
+7 -34
View File
@@ -8,6 +8,9 @@ mod mcp;
mod local_proxy_gateway; mod local_proxy_gateway;
mod tray; mod tray;
mod feishu_channel; mod feishu_channel;
// 双进程共用日志初始化(数据目录落点+启动期轮转+%TEMP% 旧日志迁移)
// main.rs 经 devflow_lib::logging 访问,须 pub
pub mod logging;
use tauri::{Emitter, Listener, Manager}; use tauri::{Emitter, Listener, Manager};
// UX-P2 走查⑥:首次关窗托盘化提示走 OS 系统通知(应用内气泡窗口即隐看不见),需 NotificationExt // UX-P2 走查⑥:首次关窗托盘化提示走 OS 系统通知(应用内气泡窗口即隐看不见),需 NotificationExt
@@ -15,19 +18,6 @@ use tauri_plugin_notification::NotificationExt;
use state::AppState; 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 方法)需在作用域内 // Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内
use df_tunnel::TunnelClient; 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
// 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info) // 双进程共用日志初始化(logging.rs):落点系统应用数据目录 + 启动期 5MB 轮转 +
let log_path = std::env::temp_dir().join("devflow-trace.log"); // %TEMP% 旧日志迁移。治「使用数据分析-2026-10-04」登记项(%TEMP% 系统清理无声删除+无轮转)。
let log_file = std::fs::OpenOptions::new() // identifier 与 tauri.conf.json 一致(main.rs MCP 侧走编译期注入同源)。
.create(true) let _guard = logging::init_logging("top.1216.devflow", "gui");
.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] 日志已初始化"
);
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .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::path::PathBuf;
use std::process::ExitCode; 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 { fn main() -> ExitCode {
// CLI 子命令:手动解析 argv(避免引入 clap 重依赖)。 // CLI 子命令:手动解析 argv(避免引入 clap 重依赖)。
// //
@@ -167,8 +135,9 @@ fn run_mcp_server(args: &[String]) -> ExitCode {
// MCP 是独立进程,不经 GUI lib.rs 的 run() 路径,须单独 init tracing subscriber; // MCP 是独立进程,不经 GUI lib.rs 的 run() 路径,须单独 init tracing subscriber;
// 否则 df_mcp 内 medium_audit 的 tracing::warn! 无 subscriber 是 no-op,审计不落盘。 // 否则 df_mcp 内 medium_audit 的 tracing::warn! 无 subscriber 是 no-op,审计不落盘。
// 双进程共用 logging.rs(数据目录落点+启动期轮转+%TEMP% 旧日志迁移,identifier 同源)。
// guard 绑定在此函数作用域,存活到 server 退出,保证日志 worker 持续运行。 // 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 日志已初始化"); tracing::info!(path = %db_path.display(), "[startup] MCP server 日志已初始化");
// 空闲超时:未显式指定用默认 60s(对齐 ServerConfig::default);显式 0 = 禁用(常驻)。 // 空闲超时:未显式指定用默认 60s(对齐 ServerConfig::default);显式 0 = 禁用(常驻)。
+11
View File
@@ -452,6 +452,17 @@ impl AppState {
tracing::warn!("清理孤儿 __PENDING__ 消息失败(非阻断): {}", e); 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),仅一次,幂等。 // 迁移旧 .trash(编译期 workspace_root → 运行期 data_dir),仅一次,幂等。
let old_trash = workspace_root_path().join(".trash"); let old_trash = workspace_root_path().join(".trash");
let new_trash = data_dir.join(".trash"); let new_trash = data_dir.join(".trash");
+5 -10
View File
@@ -5,7 +5,7 @@
卡面 = reason 人话 + stop_kind 标签;操作两枚:继续(ai_continue_loop 重新授权续跑)/ 卡面 = reason 人话 + stop_kind 标签;操作两枚:继续(ai_continue_loop 重新授权续跑)/
停止归档(ai_chat_stop 既有停止路径,归档会话走侧栏既有入口,此处不代做)。 停止归档(ai_chat_stop 既有停止路径,归档会话走侧栏既有入口,此处不代做)。
cancelled 例外只显停止归档(用户主动取消后不再诱导续跑)。 --> 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"> <div class="ai-task-parked-head">
<span class="ai-task-parked-tag" :class="kindClass">{{ kindLabel }}</span> <span class="ai-task-parked-tag" :class="kindClass">{{ kindLabel }}</span>
<span class="ai-task-parked-title">{{ t('ai.taskParked.title') }}</span> <span class="ai-task-parked-title">{{ t('ai.taskParked.title') }}</span>
@@ -20,7 +20,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai' import { useAiStore } from '../../stores/ai'
import { aiApi } from '../../api' import { aiApi } from '../../api'
@@ -52,14 +52,9 @@ const kindLabel = computed(() => {
// fatal 走 danger 红色(连续失败重入预算耗尽,警示级最高;语义上仍有「继续」路),其余 warning 琥珀 // fatal 走 danger 红色(连续失败重入预算耗尽,警示级最高;语义上仍有「继续」路),其余 warning 琥珀
const kindClass = computed(() => (parkedActive.value?.stopKind === 'fatal' ? 'ai-task-parked-tag--fatal' : '')) const kindClass = computed(() => (parkedActive.value?.stopKind === 'fatal' ? 'ai-task-parked-tag--fatal' : ''))
// 卡片根 ref。停靠常发生在多轮迭代后消息流已长,卡显时 scrollIntoView 让操作按钮立即可见 // A5(2026-10-05):删 scrollIntoView——卡挂输入区上方(.ai-input-area 内,常驻可见),
const cardRef = ref<HTMLElement | null>(null) // 非消息流;scrollIntoView 找不到有意义的滚动容器≈no-op,且原注释「消息流已长时让按钮
watch(parkedActive, async (p) => { // 可见」与实际挂载点不符。卡片显示态本身由 parkedActive watch 驱动,无需额外滚动。
if (!p) return
await nextTick()
cardRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
// 操作进行中防重复点击(继续/停止都是 IPC 往返,连点会重复触发续跑/停止) // 操作进行中防重复点击(继续/停止都是 IPC 往返,连点会重复触发续跑/停止)
const busy = ref(false) const busy = ref(false)