修复: 上下文管理第二批(E5注入产物/E13超时收敛/E2降级透出)

- E5:[补充指令]与[系统恢复]同治三处——载入滤出/恢复回放剔除/
  首条任务消息排除(434c8c40占位实证),is_guide_artifact统一判据
- E13:同工具超时连败≥3跨签名聚合,执行前软收敛警告(治换档位调参
  逃逸,434c8c40 run_command 8连败实证)
- E2:压缩失败走关键词摘要兜底时新发AiContextDegraded,前端toast
  「上下文已降级」不再静默失忆(76730ce8 seq166实证)
- vite watch忽略target/(cargo编译写.o触发EBUSY杀dev进程)
- provider-list模板根div缺闭合补齐(拆分半成品断链)

验收:tool_failure 5/keyword_fallback 3/recovery 6 passed;vue-tsc EXIT 0
This commit is contained in:
lxy
2026-10-19 00:25:51 +08:00
parent 25ec9cf69a
commit eec5978fa7
13 changed files with 171 additions and 22 deletions
@@ -270,7 +270,7 @@ pub(super) async fn maybe_auto_compress(
); );
// 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补: // 复位 is_compressing 收口进 keyword_fallback_commit 单锁(909①-补:
// 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。 // 原先此处单独取锁复位,与提交之间留并发二次压缩窗口)。
keyword_fallback_commit( let degraded = keyword_fallback_commit(
&session_arc, &session_arc,
&conv_id, &conv_id,
protect_start, protect_start,
@@ -281,6 +281,16 @@ pub(super) async fn maybe_auto_compress(
recall_on.then_some(data_dir.as_path()), recall_on.then_some(data_dir.as_path()),
) )
.await; .await;
// E2:降级不再静默(实证 76730ce8 seq166 用户被迫原句重发)
if degraded {
emit_ai_chat_event(
&app_handle,
AiChatEvent::AiContextDegraded {
conversation_id: Some(conv_id.to_string()),
reason: "压缩 provider 重建失败,已降级为关键词摘要".into(),
},
);
}
return; return;
} }
}; };
@@ -384,7 +394,7 @@ pub(super) async fn maybe_auto_compress(
"[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})", "[ai] 自动压缩失败(后台),降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
KEYWORD_FALLBACK_ENABLED, KEYWORD_FALLBACK_ENABLED,
); );
keyword_fallback_commit( let degraded = keyword_fallback_commit(
&session_arc, &session_arc,
&conv_id, &conv_id,
protect_start, protect_start,
@@ -395,6 +405,17 @@ pub(super) async fn maybe_auto_compress(
recall_on.then_some(data_dir.as_path()), recall_on.then_some(data_dir.as_path()),
) )
.await; .await;
// E2:降级不再静默——AiError 语义是生成结束,此处 loop 仍在跑,
// 用专用事件让前端透出降级状态条/提示(不打断流)
if degraded {
emit_ai_chat_event(
&app_handle,
AiChatEvent::AiContextDegraded {
conversation_id: Some(conv_id.to_string()),
reason: "LLM 压缩失败,已降级为关键词摘要".into(),
},
);
}
} }
} }
}) // 闭 catch_unwind 内 async move }) // 闭 catch_unwind 内 async move
@@ -444,11 +465,11 @@ async fn keyword_fallback_commit(
log_head: &str, log_head: &str,
head_id: &Option<String>, head_id: &Option<String>,
recall: Option<&Path>, recall: Option<&Path>,
) { ) -> bool {
let inserted = { let inserted = {
let mut session = session_arc.lock().await; let mut session = session_arc.lock().await;
if !session.per_conv.contains_key(conv_id) { if !session.per_conv.contains_key(conv_id) {
return; return false;
} }
let conv = session.conv(conv_id); let conv = session.conv(conv_id);
// 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。 // 939⑪ 内容版本守卫:上下文已被整体替换 → 丢弃兜底提交(标志亦不复位)。
@@ -458,7 +479,7 @@ async fn keyword_fallback_commit(
"[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃", "[ai] {}兜底提交前上下文版本已变(疑似 clear 重建),丢弃",
log_head log_head
); );
return; return false;
} }
// 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。 // 复位与提交同锁原子(909①-补):消「锁外复位→再取锁提交」之间的并发二次压缩窗口。
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
@@ -495,6 +516,7 @@ async fn keyword_fallback_commit(
log_head log_head
); );
} }
inserted
} }
#[cfg(test)] #[cfg(test)]
+1
View File
@@ -538,6 +538,7 @@ pub(crate) async fn run_agentic_loop(
.find(|m| { .find(|m| {
matches!(m.role, df_ai::provider::MessageRole::User) matches!(m.role, df_ai::provider::MessageRole::User)
&& !m.content.starts_with("[系统恢复]") && !m.content.starts_with("[系统恢复]")
&& !m.content.starts_with("[补充指令]")
&& m.content.chars().count() >= 4 && m.content.chars().count() >= 4
}) })
.map(|m| { .map(|m| {
@@ -419,6 +419,18 @@ fn is_recovery_artifact(text: &str) -> bool {
text.starts_with(RECOVERY_ARTIFACT_PREFIX) text.starts_with(RECOVERY_ARTIFACT_PREFIX)
} }
/// 注入产物判定(E5):[补充指令] 是运行中用户插话的通道标记,非任务意图本身
/// (实证 434c8c40 active goal=「[补充指令] 123 hi」占位)。与恢复产物同治:
/// 载入侧滤出内存,下轮 save 重写即自愈落库。
fn is_injection_artifact(text: &str) -> bool {
text.starts_with("[补充指令]")
}
/// 引导产物总判定(恢复+注入),双侧(载入/回放)统一用。
fn is_guide_artifact(text: &str) -> bool {
is_recovery_artifact(text) || is_injection_artifact(text)
}
/// 目标快照:按最终有效意图生成 parked 恢复引导消息。 /// 目标快照:按最终有效意图生成 parked 恢复引导消息。
/// ///
/// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的 /// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的
@@ -435,7 +447,7 @@ pub(crate) fn build_recovery_message(
let mut active: Vec<&str> = Vec::new(); let mut active: Vec<&str> = Vec::new();
for g in &working_context.goals { for g in &working_context.goals {
if matches!(g.status, GoalStatus::Active) if matches!(g.status, GoalStatus::Active)
&& !is_recovery_artifact(&g.text) && !is_guide_artifact(&g.text)
&& seen.insert(g.text.clone()) && seen.insert(g.text.clone())
{ {
active.push(&g.text); active.push(&g.text);
@@ -443,7 +455,7 @@ pub(crate) fn build_recovery_message(
} }
for g in pinned_goals { for g in pinned_goals {
if matches!(g.status, crate::commands::ai::GoalStatus::Active) if matches!(g.status, crate::commands::ai::GoalStatus::Active)
&& !is_recovery_artifact(&g.text) && !is_guide_artifact(&g.text)
&& seen.insert(g.text.clone()) && seen.insert(g.text.clone())
{ {
active.push(&g.text); active.push(&g.text);
@@ -504,7 +516,7 @@ pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::
Some(crate::commands::ai::GoalEntry::new(text)) Some(crate::commands::ai::GoalEntry::new(text))
} }
}) })
.filter(|e| !is_recovery_artifact(&e.text)) .filter(|e| !is_guide_artifact(&e.text))
.collect(), .collect(),
_ => vec![], _ => vec![],
} }
@@ -1961,4 +1973,18 @@ mod tests {
let cleaned = serde_json::to_string(&parsed).unwrap(); let cleaned = serde_json::to_string(&parsed).unwrap();
assert_eq!(parse_pinned_goals(Some(&cleaned)).len(), 1); assert_eq!(parse_pinned_goals(Some(&cleaned)).len(), 1);
} }
#[test]
fn parse_pinned_goals_drops_injection_artifacts() {
// E5:[补充指令] 是通道标记非任务意图(434c8c40「[补充指令] 123 hi」占位实证),
// 与恢复产物同治——载入侧剔除,下轮 save 重写自愈。
let polluted = serde_json::to_string(&vec![
crate::commands::ai::GoalEntry::new("[补充指令] 123 hi".to_string()),
crate::commands::ai::GoalEntry::new("升级工具链到最新版".to_string()),
])
.unwrap();
let parsed = parse_pinned_goals(Some(&polluted));
assert_eq!(parsed.len(), 1, "注入产物条目须剔除: {parsed:?}");
assert_eq!(parsed[0].text, "升级工具链到最新版");
}
} }
+5
View File
@@ -317,6 +317,11 @@ pub enum AiChatEvent {
/// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。 /// 摘要 system 消息已由后端 insert_at,后续 stream 自然带出,无需整会话刷新。
/// miniapp 仍插摘要气泡(对端发生压缩告知用户)。 /// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
AiAutoCompressed { conversation_id: Option<String>, summary: String }, AiAutoCompressed { conversation_id: Option<String>, summary: String },
/// E2 压缩失败降级透出(2026-10-18):LLM 压缩失败走关键词摘要兜底时,loop 仍在继续
/// (不发 AiError——其 case 无条件 setStreaming(false) 会误判生成结束),但用户须知晓
/// 上下文已降级(实证 76730ce8 seq166:静默降级→AI 失忆→用户被迫原句重发)。
/// 前端弹一次性状态提示,不中断流。
AiContextDegraded { conversation_id: Option<String>, reason: String },
/// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21): /// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
/// ///
/// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI /// agent 断路器(§2.2)熔断 / 自省(识别"我反复失败")时发,区别于 prompt 教 AI
@@ -25,6 +25,25 @@ struct FailureEntry {
last_error: String, last_error: String,
} }
/// 工具级超时连败计数(E13):治「同工具跨签名调参重试」逃逸——run_command 换 timeout
/// 档位即换 key,签名级拦截不触发,8 连败照烧轮次(434c8c40 实证)。此处按工具聚合:
/// 连续超时(错误特征匹配)≥3 次 → 该工具下次执行前注入软收敛警告(不硬拦,防误杀
/// 同工具的轻量调用),信息给足引导 LLM 换方案/问用户。该工具任一次成功即清零。
static TOOL_TIMEOUT_STREAKS: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
/// 超时连败软收敛阈值(跨签名聚合口径)。
pub const TIMEOUT_STREAK_WARN_AFTER: u32 = 3;
fn timeout_streaks() -> &'static Mutex<HashMap<String, u32>> {
TOOL_TIMEOUT_STREAKS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 错误摘要是否属超时族(中英特征,大小写不敏感)。
fn is_timeout_error(error: &str) -> bool {
let lower = error.to_lowercase();
lower.contains("超时") || lower.contains("timeout") || lower.contains("timed out")
}
/// 进程级失败记忆(全 conv 共享单锁;锁内零 await,微秒级)。 /// 进程级失败记忆(全 conv 共享单锁;锁内零 await,微秒级)。
static FAILURE_MEMORY: OnceLock<Mutex<HashMap<String, FailureEntry>>> = OnceLock::new(); static FAILURE_MEMORY: OnceLock<Mutex<HashMap<String, FailureEntry>>> = OnceLock::new();
@@ -70,34 +89,50 @@ fn sort_json(v: &serde_json::Value) -> serde_json::Value {
} }
} }
/// 执行前检查:同 key 连败达标 → 拦截,返回结构化失败提示(LLM 可读换路引导)。 /// 执行前检查:同 key 连败达标 → 拦截;工具级超时连败达标 → 软收敛警告(不拦执行)。
/// 未达标/无记忆 → None 放行。 /// 两者都未达标/无记忆 → None 放行。
pub fn precheck(tool: &str, args: &serde_json::Value) -> Option<String> { pub fn precheck(tool: &str, args: &serde_json::Value) -> Option<String> {
let key = memory_key(tool, args); let key = memory_key(tool, args);
let mem = memory().lock().unwrap_or_else(|p| p.into_inner()); let mem = memory().lock().unwrap_or_else(|p| p.into_inner());
let Some(entry) = mem.get(&key) else { return None }; if let Some(entry) = mem.get(&key) {
if entry.consecutive >= ABORT_AFTER_CONSECUTIVE_FAILURES { if entry.consecutive >= ABORT_AFTER_CONSECUTIVE_FAILURES {
Some(format!( return Some(format!(
"{{\"status\":\"blocked_by_failure_memory\",\"tool\":\"{tool}\",\"consecutive_failures\":{},\"last_error\":\"{}\",\"hint\":\"同一调用已连败 {} 次,已自动拦截。请勿原样重试:①检查参数(路径/SQL/命令拼写) ②换工具或换路径 ③若确认无法绕过,向用户报告放弃原因。\"}}", "{{\"status\":\"blocked_by_failure_memory\",\"tool\":\"{tool}\",\"consecutive_failures\":{},\"last_error\":\"{}\",\"hint\":\"同一调用已连败 {} 次,已自动拦截。请勿原样重试:①检查参数(路径/SQL/命令拼写) ②换工具或换路径 ③若确认无法绕过,向用户报告放弃原因。\"}}",
entry.consecutive, entry.consecutive,
entry.last_error.replace('\"', "'"), entry.last_error.replace('\"', "'"),
entry.consecutive entry.consecutive
)) ));
} else { }
}
// E13 工具级超时连败(跨签名聚合):换参数/换档位逃不出工具维度。
let streaks = timeout_streaks().lock().unwrap_or_else(|p| p.into_inner());
if let Some(streak) = streaks.get(tool) {
if *streak >= TIMEOUT_STREAK_WARN_AFTER {
return Some(format!(
"{{\"status\":\"timeout_streak_warning\",\"tool\":\"{tool}\",\"timeout_streak\":{},\"hint\":\"该工具已连续超时 {} 次(不同参数/档位都超)。继续原路大概率仍超时:①把任务拆小分步执行 ②换更轻量的方式(降低数据量/改后台执行) ③若确实需要长时间运行,向用户说明并确认后再继续。\"}}",
streak, streak
));
}
}
None None
} }
}
/// 执行后回写:成功清零删除,失败递增并记最近错误。 /// 执行后回写:成功清零删除(签名级+工具级超时计数),失败递增并记最近错误;
/// 失败属超时族时工具级超时连败 +1。
pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option<String>) { pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option<String>) {
let key = memory_key(tool, args); let key = memory_key(tool, args);
let mut mem = memory().lock().unwrap_or_else(|p| p.into_inner()); let mut mem = memory().lock().unwrap_or_else(|p| p.into_inner());
let mut streaks = timeout_streaks().lock().unwrap_or_else(|p| p.into_inner());
if ok { if ok {
mem.remove(&key); mem.remove(&key);
streaks.remove(tool);
} else { } else {
let e = mem.entry(key).or_insert(FailureEntry { consecutive: 0, last_error: String::new() }); let e = mem.entry(key).or_insert(FailureEntry { consecutive: 0, last_error: String::new() });
e.consecutive += 1; e.consecutive += 1;
e.last_error = error.unwrap_or_default().chars().take(200).collect(); e.last_error = error.unwrap_or_default().chars().take(200).collect();
if is_timeout_error(&e.last_error) {
*streaks.entry(tool.to_string()).or_insert(0) += 1;
}
} }
} }
@@ -145,4 +180,38 @@ mod tests {
let a = json!({"command": "cargo test", "working_dir": "E:/x"}); let a = json!({"command": "cargo test", "working_dir": "E:/x"});
assert!(memory_key("run_command", &a).contains("cmd=cargo test")); assert!(memory_key("run_command", &a).contains("cmd=cargo test"));
} }
#[test]
fn timeout_streak_aggregates_across_signatures_and_soft_warns() {
clear();
timeout_streaks().lock().unwrap().clear();
// 换档位=换签名(E13 逃逸路径:超时档拼在命令里即不同 key):签名级各 1 次均不拦;
// 工具级 streak 在第 3 次超时发生后达 3,同工具再调用(含当次迭代)即软警告
for (i, timeout) in ["60", "300", "600"].iter().enumerate() {
let args = json!({"command": format!("cargo build --timeout {timeout}")});
record("run_command", &args, false, Some(format!("命令执行超时({timeout}s)")));
if i < 2 {
assert!(precheck("run_command", &args).is_none(), "签名级 1 次且 streak<3 不拦");
}
}
// 新签名(第 4 档):工具级超时连败 ≥3 → 软收敛警告(放行执行但注入换路引导)
let args4 = json!({"command": "cargo build --timeout 900"});
let warn = precheck("run_command", &args4).unwrap();
assert!(warn.contains("timeout_streak_warning"));
assert!(warn.contains("\"timeout_streak\":3"));
// 同工具任一成功 → 工具级清零
record("run_command", &json!({"command": "ls"}), true, None);
assert!(precheck("run_command", &args4).is_none());
}
#[test]
fn non_timeout_failures_do_not_aggregate_to_timeout_streak() {
clear();
timeout_streaks().lock().unwrap().clear();
for path in ["/a.rs", "/b.rs", "/c.rs"] {
record("read_file", &json!({"path": path}), false, Some("文件不存在".into()));
}
// 3 连败但非超时族 → 无工具级警告
assert!(precheck("read_file", &json!({"path": "/d.rs"})).is_none());
}
} }
+3
View File
@@ -539,6 +539,9 @@ export type AiChatEvent = ({
} | { } | {
// 上下文已清空:前端刷新消息列表 + toast 提示 // 上下文已清空:前端刷新消息列表 + toast 提示
type: 'AiContextCleared' type: 'AiContextCleared'
} | {
// E2 压缩失败降级(关键词摘要兜底,loop 仍在跑):前端 toast 提示不中断流
type: 'AiContextDegraded'; reason: string
}) & { }) & {
conversation_id?: ConvId conversation_id?: ConvId
} }
+4
View File
@@ -698,6 +698,10 @@ async function initContextEventListeners(): Promise<void> {
onError: (_convId, message) => { onError: (_convId, message) => {
showToast(t('aiChat.compressError', { msg: message }), 'error') showToast(t('aiChat.compressError', { msg: message }), 'error')
}, },
// E2 压缩失败降级:仅提示不刷新不打断流(loop 仍在跑,实证静默降级致用户原句重发)
onContextDegraded: (_convId, reason) => {
showToast(t('aiChat.contextDegraded', { reason }), 'warning')
},
}) })
} }
</script> </script>
@@ -50,6 +50,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
+6
View File
@@ -46,6 +46,7 @@ async function initContextListener(callbacks: {
onCleared?: (conversationId: string) => void onCleared?: (conversationId: string) => void
onCompressed?: (conversationId: string, summary: string) => void onCompressed?: (conversationId: string, summary: string) => void
onError?: (conversationId: string, message: string) => void onError?: (conversationId: string, message: string) => void
onContextDegraded?: (conversationId: string, reason: string) => void
}): Promise<void> { }): Promise<void> {
setContextCallbacks({ setContextCallbacks({
onCompressing: () => { isCompressing.value = true }, onCompressing: () => { isCompressing.value = true },
@@ -62,6 +63,11 @@ async function initContextListener(callbacks: {
autoCompressedFlashTimer = setTimeout(() => { autoCompressedFlash.value = false }, 4000) autoCompressedFlashTimer = setTimeout(() => { autoCompressedFlash.value = false }, 4000)
}, },
onCleared: (convId) => callbacks.onCleared?.(convId), onCleared: (convId) => callbacks.onCleared?.(convId),
onContextDegraded: (convId, reason) => {
// E2:压缩失败降级为关键词摘要兜底——复位 loading + 上抛 toast(不中断 LLM 流)
isCompressing.value = false
callbacks.onContextDegraded?.(convId, reason)
},
onError: (convId, message) => { onError: (convId, message) => {
// AiError 也用于流式生成失败(useAiEvents 已处理);此处仅在压缩中时复位 + toast, // AiError 也用于流式生成失败(useAiEvents 已处理);此处仅在压缩中时复位 + toast,
// 避免双重处理流式错误。 // 避免双重处理流式错误。
@@ -30,6 +30,8 @@ export interface ContextCallbacks {
onCleared?: (conversationId: string) => void onCleared?: (conversationId: string) => void
/** 错误(AiError,含压缩失败):仅在 loading 时置 false + toast */ /** 错误(AiError,含压缩失败):仅在 loading 时置 false + toast */
onError?: (conversationId: string, message: string) => void onError?: (conversationId: string, message: string) => void
/** E2 压缩失败降级(AiContextDegraded):loop 仍在跑,仅提示上下文已降级不中断流 */
onContextDegraded?: (conversationId: string, reason: string) => void
} }
let _contextCallbacks: ContextCallbacks = {} let _contextCallbacks: ContextCallbacks = {}
@@ -344,6 +346,11 @@ export async function handleLifecycleEvent(event: AiChatEvent): Promise<boolean>
_contextCallbacks.onCleared?.(event.conversation_id || '') _contextCallbacks.onCleared?.(event.conversation_id || '')
return true return true
case 'AiContextDegraded':
// E2:压缩失败已走关键词摘要兜底,loop 继续——透出降级提示而非静默失忆
_contextCallbacks.onContextDegraded?.(event.conversation_id || '', event.reason || '')
return true
default: default:
return false return false
} }
+1
View File
@@ -275,6 +275,7 @@ export default {
// B4: one-shot auto-compress notice (loop auto-compress no longer fully silent) // B4: one-shot auto-compress notice (loop auto-compress no longer fully silent)
autoCompressed: 'Context auto-compressed (earlier messages folded into summary)', autoCompressed: 'Context auto-compressed (earlier messages folded into summary)',
compressError: 'Compression failed: {msg}', compressError: 'Compression failed: {msg}',
contextDegraded: 'Context degraded: {reason} — the AI may be missing some earlier details',
clearError: 'Clear failed: {msg}', clearError: 'Clear failed: {msg}',
initError: 'AI panel init failed: {msg} (reopen panel to retry)', initError: 'AI panel init failed: {msg} (reopen panel to retry)',
initDegraded: 'List load failed, continuing in degraded mode: {msg}', initDegraded: 'List load failed, continuing in degraded mode: {msg}',
+2
View File
@@ -276,6 +276,8 @@ export default {
// B4: 自动压缩完成一次性提示(loop 自动压缩不再全程静默) // B4: 自动压缩完成一次性提示(loop 自动压缩不再全程静默)
autoCompressed: '上下文已自动压缩(较早消息已折叠为摘要)', autoCompressed: '上下文已自动压缩(较早消息已折叠为摘要)',
compressError: '压缩失败:{msg}', compressError: '压缩失败:{msg}',
// E2:压缩失败走关键词摘要兜底,loop 继续——提示降级而非静默失忆
contextDegraded: '上下文已降级:{reason},AI 可能缺失部分早前细节',
clearError: '清空失败:{msg}', clearError: '清空失败:{msg}',
initError: 'AI 面板初始化失败:{msg}(重开面板可重试)', initError: 'AI 面板初始化失败:{msg}(重开面板可重试)',
initDegraded: '列表加载失败,面板降级继续:{msg}', initDegraded: '列表加载失败,面板降级继续:{msg}',
+3 -1
View File
@@ -23,7 +23,9 @@ export default defineConfig(async () => ({
} }
: undefined, : undefined,
watch: { watch: {
ignored: ["**/src-tauri/**"], // target/ 同理须忽略:cargo 编译写 .o/.rlib 时 watcher 触发 EBUSY(文件被写锁),
// chokidar 直接抛异常杀掉 dev 进程(2026-10-18 实证:watch sqlite3.o 崩溃)
ignored: ["**/src-tauri/**", "**/target/**"],
}, },
}, },
})); }));