diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 316118136..7761e6c05 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -349,6 +349,39 @@ pub(crate) fn append_direct_project_user_message_at( append_direct_project_history_item_at_with_user_policy(root, item, true) } +/// 争用重试的退避表:锁本身已经等满一个完整窗口(≈10s),这里只补一次短退避。 +/// 再等多一轮能成功的概率已经很低,而每多一轮都会让"收尾失败"晚 10s 才告诉用户。 +const DIRECT_PROJECT_HISTORY_CONTENTION_RETRY_BACKOFF_MS: &[u64] = &[250]; + +/// 追加写失败里"再等一会就可能成功"的那一类:跨进程锁 / 项目锁争用。 +/// +/// 判据只认两个常量,不各自复制中文:格式类失败(行形状、解析、注入超限)不重试——同一份 +/// 历史每次读都是同一个结论,重试只是白等。 +pub(crate) fn is_direct_project_history_contention_failure(error: &str) -> bool { + error.contains(crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER) + || error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) +} + +/// 测试注入:让接下来 N 次单次尝试都返回"争用类失败",用来确定性地验证"重试"与"不重试"。 +#[cfg(test)] +fn take_direct_project_history_contention_injection_at(root: &Path) -> Option { + let path = root.join(".agent/runtime/test-fail-next-direct-project-history-append"); + let remaining = fs::read_to_string(&path).ok()?.trim().parse::().ok()?; + if remaining == 0 { + let _ = fs::remove_file(&path); + return None; + } + if remaining == 1 { + let _ = fs::remove_file(&path); + } else { + let _ = fs::write(&path, (remaining - 1).to_string()); + } + Some(format!( + "获取DirectProject 历史追加写{}:注入的争用失败(测试)", + crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER + )) +} + fn append_direct_project_history_item_at_with_user_policy( root: &Path, item: &Value, @@ -361,6 +394,30 @@ fn append_direct_project_history_item_at_with_user_policy( if !allow_user_item && is_direct_project_codex_user_item(item) { return Ok(()); } + // `retryable = true` 只有配上"真的自动重试"才算数:争用这一类在这里做有界退避重试, + // 而不是把同一轮原样丢回给用户再点一次;格式类失败不重试(见判据函数)。 + let mut last_contention: Option = None; + for backoff_ms in std::iter::once(0u64).chain( + DIRECT_PROJECT_HISTORY_CONTENTION_RETRY_BACKOFF_MS + .iter() + .copied(), + ) { + if backoff_ms > 0 { + std::thread::sleep(std::time::Duration::from_millis(backoff_ms)); + } + match append_direct_project_history_item_once(root, item) { + Ok(()) => return Ok(()), + Err(error) if is_direct_project_history_contention_failure(&error) => { + last_contention = Some(error); + } + Err(error) => return Err(error), + } + } + Err(last_contention.unwrap_or_else(|| "DirectProject 历史追加写未产生结果".to_string())) +} + +/// 追加写的单次尝试:锁外回扫 → 取项目锁与 append 锁 → 状态过期才在锁内重扫 → 追加。 +fn append_direct_project_history_item_once(root: &Path, item: &Value) -> Result<(), String> { let path = history_path(root); let history_exists = prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?; @@ -385,6 +442,10 @@ fn append_direct_project_history_item_at_with_user_policy( None }; fire_direct_project_history_scan_probe(DIRECT_PROJECT_HISTORY_SCAN_PROBE_FINISHED); + #[cfg(test)] + if let Some(error) = take_direct_project_history_contention_injection_at(root) { + return Err(error); + } let line = record(item)?; let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?; let lock = project_append_lock_for(&path)?; @@ -569,6 +630,117 @@ mod tests { const LEGACY_ASSISTANT_ROW: &str = r#"{"schemaVersion":"game-creator-conversation.v1","role":"assistant","content":"已完成 第一行\n第二行 ","agentId":null,"updatedAt":1757000001}"#; const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; + /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 + /// + /// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次 + /// 之后第二次必须成功,且标记被删(证明重试真的跑过,而不是根本没走进重试分支)。 + #[test] + fn contention_failure_is_retried_with_bounded_backoff() { + let root = init_history_project("contention-retry"); + write_history_lines(root.path(), &[LEGACY_USER_ROW]); + let marker = root + .path() + .join(".agent/runtime/test-fail-next-direct-project-history-append"); + std::fs::create_dir_all(marker.parent().expect("marker parent")).expect("marker dir"); + std::fs::write(&marker, "1").expect("write injection marker"); + + let item = json!({ + "type": "message", + "role": "assistant", + "id": "retry-item", + "content": [{"type": "output_text", "text": "争用后重试成功"}] + }); + append_direct_project_history_item_at(root.path(), &item).expect("append after retry"); + + assert!( + !marker.exists(), + "注入的争用失败必须被有界重试消耗掉(证明重试真的跑过)" + ); + let items = read_direct_project_history_items_at(root.path()).expect("read history"); + assert_eq!( + items.iter().filter(|existing| *existing == &item).count(), + 1, + "重试结束后条目恰好落一行" + ); + } + + /// 判据:重试是**有界**的。注入超过退避预算的争用失败时必须失败关闭, + /// 剩下的注入次数原样留着(不是无限重试)。 + #[test] + fn contention_failure_beyond_the_backoff_budget_fails_closed() { + let root = init_history_project("contention-bounded"); + write_history_lines(root.path(), &[LEGACY_USER_ROW]); + let marker = root + .path() + .join(".agent/runtime/test-fail-next-direct-project-history-append"); + std::fs::create_dir_all(marker.parent().expect("marker parent")).expect("marker dir"); + std::fs::write(&marker, "5").expect("write injection marker"); + + let error = append_direct_project_history_item_at( + root.path(), + &json!({ + "type": "message", + "role": "assistant", + "id": "bounded-item", + "content": [{"type": "output_text", "text": "超出重试预算"}] + }), + ) + .expect_err("contention beyond the budget must fail closed"); + assert!( + error.contains(crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER), + "{error}" + ); + assert_eq!( + std::fs::read_to_string(&marker) + .expect("read injection marker") + .trim() + .parse::() + .expect("marker count"), + 3, + "退避表只有一次重试:注入 5 次必须还剩 3 次" + ); + } + + /// 判据:格式类失败**不**触发重试。同一份历史每次读都是同一个结论,重试只是白等 + /// (而且会把"收尾失败"再拖一轮)。注入次数因此必须原样留着。 + /// + /// fixture 形状说明:回扫只对"后面还有换行的完整行"做严格解析,文件里第一段(以及末尾 + /// 没有换行的那一段)按可修复尾行放宽。所以损坏行必须夹在两条完整行中间才会失败关闭。 + #[test] + fn shape_failure_is_not_retried() { + let root = init_history_project("shape-no-retry"); + write_history_lines( + root.path(), + &[ + LEGACY_USER_ROW, + r#"{"schemaVersion":"game-creator"#, + RESPONSE_ITEM_ROW, + ], + ); + let marker = root + .path() + .join(".agent/runtime/test-fail-next-direct-project-history-append"); + std::fs::create_dir_all(marker.parent().expect("marker parent")).expect("marker dir"); + std::fs::write(&marker, "5").expect("write injection marker"); + + let error = append_direct_project_history_item_at( + root.path(), + &json!({ + "type": "message", + "role": "assistant", + "id": "shape-item", + "content": [{"type": "output_text", "text": "格式类失败"}] + }), + ) + .expect_err("shape failure must fail closed"); + assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}"); + assert_eq!( + std::fs::read_to_string(&marker).expect("read injection marker"), + "5", + "格式类失败不重试:注入次数不能被消耗" + ); + } + /// 判据:幂等回扫发生时,append 锁**不能**在本进程手里。 /// /// 变异验证(把回扫挪回锁内)会把这条用例打成红的:探针在回扫时点用与生产同一套 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 6cffa8d63..8252b6c6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -1792,6 +1792,9 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st if direct_project_history_shape_failure(error) { return "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求"; } + if direct_project_history_contention_failure(error) { + return "另一个客户端进程正在读写该项目的历史或项目锁,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求"; + } match stage { DirectCodexFailureStage::ArtPreparation => { "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" @@ -1877,6 +1880,17 @@ fn direct_project_history_shape_failure(error: &str) -> bool { .any(|marker| error.contains(marker)) } +/// 跨进程锁 / 项目锁争用。 +/// +/// 与"行形状"类相反:它不是同一份历史的同一个结论,而是别的进程此刻正拿着锁——锁本身 +/// 没有残留(所有权是句柄,进程退出即释放),所以"稍后重试"是真能生效的动作。提示因此 +/// 指向现象与动作,而不是原来 CodeGeneration 阶段那句"请检查运行时配置后重试"。 +/// 判据只认两个常量,不各自复制中文。 +fn direct_project_history_contention_failure(error: &str) -> bool { + error.contains(crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER) + || error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) +} + fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool { let normalized = error.to_ascii_lowercase(); error.contains("泥点余额不足") diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index 0bce5df4a..911f74d4b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -3537,6 +3537,16 @@ pub(crate) struct ProjectAppendGuard<'a> { _os_lock: File, } +/// append 锁的等待口径与项目写锁对齐:`agent/runtime_actions/project_gates.rs:1821-1823` +/// 是 5ms × 2000(≈10s 完整窗口)/ 200(≈1s 短窗口),这里曾经是 10ms × 100 ≈ 1s。 +const PROJECT_APPEND_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(5); +const PROJECT_APPEND_LOCK_WAIT_ATTEMPTS: usize = 2_000; +const PROJECT_APPEND_LOCK_SHORT_WAIT_ATTEMPTS: usize = 200; + +/// 锁超时文案里的稳定片段:控制流(DirectProject 失败提示、收尾重试判据)只认这个常量, +/// 不各自复制一份中文(改一次文案等于改一次重试语义)。 +pub(crate) const PROJECT_APPEND_LOCK_TIMEOUT_MARKER: &str = "跨进程锁超时"; + impl ProjectAppendLock { fn lock_process(&self, error_label: &str) -> Result, String> { self.process_lock @@ -3544,12 +3554,30 @@ impl ProjectAppendLock { .map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏")) } + /// 追加写入口,走完整等待窗口(≈10s)。这条等待的失败会直接变成用户可见的 + /// "写历史失败",而持锁方可能正在做一件 O(文件大小) 的事(整份读历史)甚至等 UAC, + /// 1s 的窗口在这种对手面前必然打满。 pub(crate) fn lock(&self, error_label: &str) -> Result, String> { + self.lock_with_attempts(error_label, PROJECT_APPEND_LOCK_WAIT_ATTEMPTS) + } + + /// 只读入口,沿用短窗口(≈1s):调用方本来就会重跑(面板刷新、下一轮 prompt 组装), + /// 长时间阻塞只会把它一起拖住。 + pub(crate) fn lock_short(&self, error_label: &str) -> Result, String> { + self.lock_with_attempts(error_label, PROJECT_APPEND_LOCK_SHORT_WAIT_ATTEMPTS) + } + + fn lock_with_attempts( + &self, + error_label: &str, + max_attempts: usize, + ) -> Result, String> { let process_guard = self .process_lock .lock() .map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏"))?; - let os_lock = acquire_project_append_os_lock(&self.os_lock_path, error_label)?; + let os_lock = + acquire_project_append_os_lock(&self.os_lock_path, error_label, max_attempts)?; Ok(ProjectAppendGuard { _process_guard: process_guard, _os_lock: os_lock, @@ -3606,21 +3634,91 @@ fn project_append_os_lock_path(path: &Path) -> Result { .join(format!("{}.lock", &fingerprint[..32]))) } -fn acquire_project_append_os_lock(path: &Path, error_label: &str) -> Result { +fn acquire_project_append_os_lock( + path: &Path, + error_label: &str, + max_attempts: usize, +) -> Result { if let Some(parent) = path.parent() { ensure_game_creator_private_directory_tree(parent, error_label)?; prepare_game_creator_private_path_for_read(parent, true, error_label)?; } prepare_game_creator_private_path_for_read(path, false, error_label)?; - for attempt in 0..100 { - if let Some(file) = try_open_project_append_os_lock(path, error_label)? { + let max_attempts = max_attempts.max(1); + for attempt in 0..max_attempts { + if let Some(mut file) = try_open_project_append_os_lock(path, error_label)? { + refresh_project_append_lock_diagnostic(&mut file, error_label); return Ok(file); } - if attempt < 99 { - thread::sleep(Duration::from_millis(10)); + if attempt + 1 < max_attempts { + thread::sleep(PROJECT_APPEND_LOCK_RETRY_INTERVAL); } } - Err(format!("获取{error_label}跨进程锁超时:{}", path.display())) + Err(format!( + "获取{error_label}{PROJECT_APPEND_LOCK_TIMEOUT_MARKER}:{}({})", + path.display(), + project_append_lock_holder_diagnostic(path) + )) +} + +/// 超时时报出"谁在持锁",只读锁文件里的诊断元数据。 +/// +/// 它**只用于报错文案**:不判活、不回收、不抢占。读不到就明说读不到——现场最怕的是 +/// 一句"检查运行时配置",那既不是现象也不是动作。 +fn project_append_lock_holder_diagnostic(path: &Path) -> String { + let Ok(content) = fs::read_to_string(path) else { + return "持锁方身份不可读:锁文件正被独占持有或已不可读".to_string(); + }; + let Ok(record) = serde_json::from_str::(&content) else { + return "持锁方身份不可读:锁文件里没有可解析的诊断元数据".to_string(); + }; + let pid = record.get("pid").and_then(serde_json::Value::as_u64); + let label = record.get("label").and_then(serde_json::Value::as_str); + let started_at = record + .get("processStartedAt") + .and_then(serde_json::Value::as_u64); + match (pid, label, started_at) { + (Some(pid), Some(label), Some(started_at)) => { + format!("持锁方 pid={pid}(进程启动于 {started_at},取锁用途 {label})") + } + (Some(pid), Some(label), None) => format!("持锁方 pid={pid}(取锁用途 {label})"), + (Some(pid), None, _) => format!("持锁方 pid={pid}"), + (None, ..) => "持锁方身份不可读:锁文件里没有 pid".to_string(), + } +} + +/// 把"谁在持这把锁"写进锁文件,供事后排障;同一进程重复取同一把锁时不重复写。 +/// +/// 所有权是句柄本身,不靠文件内容成立:这里写失败绝不影响取锁结果。诊断元数据也不参与 +/// 任何判活/回收/抢占判断(既有口径见 docs/project-memory/shared-memory/decision-log.md +/// 2026-09-09「项目写锁残留回收与启动诊断」)。 +fn refresh_project_append_lock_diagnostic(file: &mut File, error_label: &str) { + let pid = std::process::id(); + let mut current = String::new(); + if file.seek(SeekFrom::Start(0)).is_ok() + && std::io::Read::read_to_string(file, &mut current).is_ok() + && serde_json::from_str::(¤t) + .ok() + .and_then(|value| value.get("pid").and_then(serde_json::Value::as_u64)) + == Some(u64::from(pid)) + { + return; + } + let Ok(serialized) = serde_json::to_string(&serde_json::json!({ + "acquiredAt": unix_timestamp(), + "label": error_label, + "pid": pid, + "processStartedAt": crate::project::project_write_lock_process_start_time_seconds( + u64::from(pid) + ), + })) else { + return; + }; + if file.set_len(0).is_err() || file.seek(SeekFrom::Start(0)).is_err() { + return; + } + let _ = file.write_all(serialized.as_bytes()); + let _ = file.flush(); } /// 测试专用探针:判断某个追加写目标此刻是否被别人持有 OS 锁。 @@ -3758,9 +3856,8 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result= std::time::Duration::from_secs(10), + "append 锁完整窗口必须 >= 项目写锁的 10s" + ); + assert!( + PROJECT_APPEND_LOCK_RETRY_INTERVAL * 200 <= std::time::Duration::from_secs(1), + "只读短窗口必须留在 1s 级,避免把面板读路径一起拖住" + ); +} + +fn append_lock_test_target(test_name: &str) -> (PathBuf, PathBuf) { + let root = unique_agent_db_test_root(test_name); + let target = root.join(".agent/conversations/project.jsonl"); + fs::create_dir_all(target.parent().expect("history parent")).expect("history dir"); + (root, target) +} + +#[cfg(windows)] +fn hold_lock_file_exclusively_for_test(path: &Path) -> File { + use std::os::windows::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + .write(true) + .share_mode(0) + .open(path) + .expect("hold lock file exclusively") +} + +#[cfg(unix)] +fn hold_lock_file_exclusively_for_test(path: &Path) -> File { + use std::os::fd::AsRawFd; + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .expect("open lock file"); + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + assert_eq!(result, 0, "flock lock file"); + file +} + +/// 锁文件必须留下"谁在持锁"的诊断元数据:零字节锁文件让现场无法回答是谁在持锁, +/// 而"可操作提示"正是靠它才能成立。这里也钉住"只诊断、不抢占"——元数据只被读来报错。 +#[test] +fn append_lock_file_records_the_holder_for_postmortem() { + let (root, target) = append_lock_test_target("append-lock-diagnostic"); + let lock = project_append_lock_for(&target).expect("resolve lock"); + drop( + lock.lock("DirectProject 历史追加写") + .expect("acquire append lock"), + ); + + let lock_path = project_append_os_lock_path(&target).expect("lock path"); + let raw = fs::read_to_string(&lock_path).expect("read lock file"); + let record: serde_json::Value = + serde_json::from_str(&raw).expect("lock file carries diagnostic json"); + assert_eq!( + record.get("pid").and_then(serde_json::Value::as_u64), + Some(u64::from(std::process::id())), + "{raw}" + ); + assert_eq!( + record.get("label").and_then(serde_json::Value::as_str), + Some("DirectProject 历史追加写"), + "{raw}" + ); + assert!(record.get("processStartedAt").is_some(), "{raw}"); + assert!(record.get("acquiredAt").is_some(), "{raw}"); + + fs::remove_dir_all(&root).ok(); +} + +#[test] +fn append_lock_holder_diagnostic_reports_the_recorded_pid() { + let (root, target) = append_lock_test_target("append-lock-holder-diagnostic"); + let lock_path = project_append_os_lock_path(&target).expect("lock path"); + fs::create_dir_all(lock_path.parent().expect("lock parent")).expect("lock dir"); + fs::write( + &lock_path, + "{\"acquiredAt\":1,\"label\":\"测试用途\",\"pid\":4242,\"processStartedAt\":7}", + ) + .expect("seed lock file"); + + let diagnostic = project_append_lock_holder_diagnostic(&lock_path); + assert!(diagnostic.contains("pid=4242"), "{diagnostic}"); + assert!(diagnostic.contains('7'), "{diagnostic}"); + assert!(diagnostic.contains("测试用途"), "{diagnostic}"); + + fs::remove_file(&lock_path).ok(); + let missing = project_append_lock_holder_diagnostic(&lock_path); + assert!(missing.contains("身份不可读"), "{missing}"); + + fs::remove_dir_all(&root).ok(); +} + +/// 真的被别人独占持有时:超时文案必须报出锁路径与持锁方线索,而且**不留残留**—— +/// 释放后立刻要能重新取到(这把锁的所有权是句柄,所以本来就不该有 stale 回收)。 +#[test] +fn append_lock_short_wait_times_out_with_the_holder_and_leaks_nothing() { + let (root, target) = append_lock_test_target("append-lock-timeout"); + let lock = project_append_lock_for(&target).expect("resolve lock"); + // 先让生产代码创建并收紧锁文件(本地 harden 只在首次创建时发生)。 + drop( + lock.lock_short("DirectProject 历史追加写") + .expect("first acquire"), + ); + let lock_path = project_append_os_lock_path(&target).expect("lock path"); + let held = hold_lock_file_exclusively_for_test(&lock_path); + + let error = match lock.lock_short("DirectProject 历史追加写") { + Ok(_guard) => panic!("exclusively held lock must not be acquired"), + Err(error) => error, + }; + assert!( + error.contains(PROJECT_APPEND_LOCK_TIMEOUT_MARKER), + "{error}" + ); + assert!( + error.contains("持锁方"), + "超时文案必须回答是谁在持锁:{error}" + ); + + drop(held); + drop( + lock.lock_short("DirectProject 历史追加写") + .expect("reacquire after release"), + ); + fs::remove_dir_all(&root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs index 3e0e26294..2d98aa1b4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs @@ -1063,7 +1063,9 @@ pub(crate) fn read_local_conversation_for_session_at( } }; let append_lock = project_append_lock_for(&path)?; - let _append_guard = append_lock.lock("对话记录追加写")?; + // 只读路径用短窗口:整份读只要一份当前快照,调用方(面板刷新、下一轮 prompt 组装) + // 自己会重跑,长时间阻塞只会把它一起拖住;写路径继续用完整窗口。 + let _append_guard = append_lock.lock_short("对话记录追加写")?; let records = read_persisted_local_conversation_records_unlocked(&path)?; Ok(local_conversation_result_from_persisted_records( &path, @@ -1113,7 +1115,7 @@ fn read_local_conversation_message_by_id_for_session_internal_at( let (path, normalized_agent_id, normalized_session_id) = conversation_file_path_for_session(root, agent_id, session_id)?; let append_lock = project_append_lock_for(&path)?; - let _append_guard = append_lock.lock("对话记录追加写")?; + let _append_guard = append_lock.lock_short("对话记录追加写")?; let records = read_persisted_local_conversation_records_unlocked(&path)?; let matched_index = persisted_local_conversation_message_index_by_id( &records,