fix(fs): Windows-safe atomic replace everywhere — model switch now sticks
Root cause of 'model+effort switch works on Mac, not on Windows': the switch APPLIES in-session (the dispatch/apply chain is platform-identical, verified adversarially) but its persistence never sticks on Windows. Every tmp+rename atomic write except auth/storage.rs committed with a bare fs::rename, and Windows MoveFileExW(REPLACE_EXISTING) fails with a sharing violation whenever AV/search-indexer/cloud-sync transiently holds the destination open. Consequences: [models].default never persisted (next launch = original model), the session summary's current model never persisted (resume = original model), and the models cache went silently stale (all its write errors were swallowed). - New kigi_shell_base::util::fs::replace_file — THE commit step for tmp+rename: plain rename on Unix; on Windows delete-first + two short backoffs (the pattern auth/storage.rs shipped first), tmp cleaned on failure, error always returned. Windows branch type-checked against x86_64-pc-windows-msvc. - Adopted at every replace site: config.toml (save_config / atomic_write_string / mcp saves), models cache (plus unique tmp suffixes and tracing::warn on failure — writes were fully silent), session storage (summary/current-model, jsonl, plan/signals/ announcement/goal/graph state), auth.json, active-sessions registry, prompt history, claude/kimi import, campaigns state, goal artifacts. Directory-move renames (worktree pool, corrupt-file backups) keep plain rename — their destinations don't pre-exist. Verified: kigi-shell + kigi-shell-base 5318 tests green, clippy clean, msvc-target check of the new cfg(windows) code clean.
This commit is contained in:
@@ -1147,7 +1147,7 @@ fn persist_chat_history_jsonl_sync(session_info: &SessionInfo, conversation: &[C
|
||||
buf.push(b'\n');
|
||||
}
|
||||
std::fs::File::create(&tmp_path)?.write_all(&buf)?;
|
||||
std::fs::rename(&tmp_path, &final_path)?;
|
||||
crate::util::fs::replace_file(&tmp_path, &final_path)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
|
||||
@@ -594,10 +594,10 @@ async fn write_patch_file_atomic(path: &Path, body: &str) -> std::io::Result<()>
|
||||
.unwrap_or("goal-classifier.patch");
|
||||
let tmp = dir.join(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7()));
|
||||
tokio::fs::write(&tmp, body).await?;
|
||||
if let Err(err) = tokio::fs::rename(&tmp, path).await {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
return Err(err);
|
||||
}
|
||||
let dest = path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -895,7 +895,8 @@ impl GoalTracker {
|
||||
};
|
||||
let dest = goal_dir.join(name);
|
||||
let _ = std::fs::create_dir_all(&goal_dir);
|
||||
if std::fs::rename(&src, &dest).is_ok() || copy_no_follow(&src, &dest).is_ok() {
|
||||
if crate::util::fs::replace_file(&src, &dest).is_ok() || copy_no_follow(&src, &dest).is_ok()
|
||||
{
|
||||
append_skeptic_reports(&scratch_root, &dest);
|
||||
o.last_classifier_details_path = Some(dest.to_string_lossy().into_owned());
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ pub fn project(dir: &Path, state: &GraphOrchestration) -> std::io::Result<()> {
|
||||
f.write_all(&buf)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&tmp, &target)
|
||||
crate::util::fs::replace_file(&tmp, &target)
|
||||
}
|
||||
|
||||
/// Load the projected graph, `Ok(None)` when absent. Malformed content
|
||||
|
||||
@@ -98,7 +98,7 @@ pub fn truncate_if_needed(cwd: &str) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::rename(temp_path, path)?;
|
||||
crate::util::fs::replace_file(&temp_path, &path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,16 @@ use std::fs::OpenOptions;
|
||||
use std::io::{self, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// Commit `tmp` over `target` with the shared Windows-safe replace
|
||||
/// (`util::fs::replace_file`): a bare async rename silently lost session
|
||||
/// state — including the switched model — on Windows whenever AV/indexer
|
||||
/// held the destination open.
|
||||
async fn replace_file_async(tmp: PathBuf, target: PathBuf) -> io::Result<()> {
|
||||
tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &target))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(io::Error::other(e)))
|
||||
}
|
||||
/// How the adapter resolves the session directory on disk.
|
||||
///
|
||||
/// - `FromRoot` (default): computes `{root}/sessions/{urlencoded(cwd)}/{session_id}/`
|
||||
@@ -289,7 +299,7 @@ impl JsonlStorageAdapter {
|
||||
}
|
||||
let tmp = path.with_extension("jsonl.tmp");
|
||||
tokio::fs::write(&tmp, &content).await?;
|
||||
tokio::fs::rename(&tmp, &path).await
|
||||
replace_file_async(tmp, path).await
|
||||
}
|
||||
fn read_jsonl<T: serde::de::DeserializeOwned>(&self, path: PathBuf) -> io::Result<Vec<T>> {
|
||||
if !path.exists() {
|
||||
@@ -378,7 +388,7 @@ impl JsonlStorageAdapter {
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
let tmp = summary_path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, &bytes)?;
|
||||
std::fs::rename(&tmp, &summary_path)
|
||||
crate::util::fs::replace_file(&tmp, &summary_path)
|
||||
}
|
||||
fn read_summary_sync(&self, info: &Info) -> io::Result<Summary> {
|
||||
let path = self.summary_file(info);
|
||||
@@ -1057,7 +1067,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
let target = self.plan_mode_state_file(info);
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
replace_file_async(tmp, target).await
|
||||
}
|
||||
async fn write_signals(
|
||||
&self,
|
||||
@@ -1069,7 +1079,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
let target = self.signals_file(info);
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, signals_json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
replace_file_async(tmp, target).await
|
||||
}
|
||||
async fn write_announcement_state(
|
||||
&self,
|
||||
@@ -1081,7 +1091,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
let target = self.announcement_state_file(info);
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
replace_file_async(tmp, target).await
|
||||
}
|
||||
async fn write_goal_mode_state(
|
||||
&self,
|
||||
@@ -1096,7 +1106,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
}
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
replace_file_async(tmp, target).await
|
||||
}
|
||||
async fn write_graph_mode_state(
|
||||
&self,
|
||||
@@ -1119,7 +1129,7 @@ impl StorageAdapter for JsonlStorageAdapter {
|
||||
}
|
||||
let tmp = target.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp, json).await?;
|
||||
tokio::fs::rename(&tmp, &target).await
|
||||
replace_file_async(tmp, target).await
|
||||
}
|
||||
async fn load_session(&self, info: &Info) -> io::Result<PersistedData> {
|
||||
let summary = self.read_summary_sync(info)?;
|
||||
|
||||
@@ -230,7 +230,10 @@ fn write_summary_atomic(summary_path: &Path, summary: &Summary) -> io::Result<()
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
let tmp = summary_path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, &bytes)?;
|
||||
std::fs::rename(&tmp, summary_path)
|
||||
// Windows-safe replace: this is the write that persists a session's
|
||||
// CURRENT MODEL — a bare rename made a switched model silently revert
|
||||
// on resume whenever AV/indexer held summary.json open on Windows.
|
||||
crate::util::fs::replace_file(&tmp, summary_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user