diff --git a/src/config.rs b/src/config.rs index 79b4670c..58ef99c8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -810,6 +810,66 @@ pub fn is_excluded(file_path: &str, config: &TraceDecayConfig) -> bool { false } +/// Serializes lib unit tests that mutate process-wide storage env vars +/// (`TRACEDECAY_DATA_DIR` and related HOME/profile pins). Parallel tests +/// otherwise race on profile resolution and hook analytics paths. +#[cfg(test)] +pub static USER_DATA_DIR_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Acquires [`USER_DATA_DIR_TEST_LOCK`], recovering even when poisoned. +#[cfg(test)] +pub fn lock_user_data_dir_test_env() -> std::sync::MutexGuard<'static, ()> { + USER_DATA_DIR_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Pins [`USER_DATA_DIR_ENV`] to an isolated temp profile while holding +/// [`USER_DATA_DIR_TEST_LOCK`], so parallel lib tests cannot race profile +/// resolution during `TraceDecay::init` / indexing. +#[cfg(test)] +pub struct PinnedUserDataDir { + _lock: std::sync::MutexGuard<'static, ()>, + _root: tempfile::TempDir, + previous: Option, +} + +#[cfg(test)] +impl PinnedUserDataDir { + pub fn new() -> Self { + let lock = lock_user_data_dir_test_env(); + let root = tempfile::TempDir::new() + .unwrap_or_else(|err| panic!("failed to create temp profile dir: {err}")); + let profile = root.path().join(TRACEDECAY_DIR); + fs::create_dir_all(&profile) + .unwrap_or_else(|err| panic!("failed to create isolated profile root: {err}")); + let previous = std::env::var_os(USER_DATA_DIR_ENV); + std::env::set_var(USER_DATA_DIR_ENV, &profile); + Self { + _lock: lock, + _root: root, + previous, + } + } +} + +#[cfg(test)] +impl Default for PinnedUserDataDir { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +impl Drop for PinnedUserDataDir { + fn drop(&mut self) { + match self.previous.take() { + Some(previous) => std::env::set_var(USER_DATA_DIR_ENV, previous), + None => std::env::remove_var(USER_DATA_DIR_ENV), + } + } +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests; diff --git a/src/config/tests.rs b/src/config/tests.rs index 44d79408..ba04bcc9 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -1,15 +1,13 @@ use super::{ db_filename, get_project_db_path, get_tracedecay_dir, is_excluded, is_excluded_dir, - is_ignored_by_explicit_global_excludes, is_ignored_by_git, is_included, user_data_dir, - TraceDecayConfig, USER_DATA_DIR_ENV, + is_ignored_by_explicit_global_excludes, is_ignored_by_git, is_included, + lock_user_data_dir_test_env, user_data_dir, TraceDecayConfig, USER_DATA_DIR_ENV, }; use std::ffi::OsString; use std::fs; use std::process::Command; use tempfile::TempDir; -static USER_DATA_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - struct EnvRestore { key: &'static str, previous: Option, @@ -58,7 +56,7 @@ fn test_data_dir_uses_tracedecay_when_present() { #[cfg(unix)] #[test] fn user_data_dir_canonicalizes_symlinked_existing_parent() { - let _lock = USER_DATA_DIR_ENV_LOCK.lock().unwrap(); + let _lock = lock_user_data_dir_test_env(); let root = TempDir::new().unwrap(); let real_home = root.path().join("real-home"); let linked_home = root.path().join("linked-home"); @@ -294,7 +292,7 @@ fn partial_sync_table_fills_missing_fields_with_defaults() { #[test] fn sync_config_env_overrides_bool_and_int() { - let _lock = USER_DATA_DIR_ENV_LOCK.lock().unwrap(); + let _lock = lock_user_data_dir_test_env(); let _watch = EnvRestore::set("TRACEDECAY_SYNC_AUTO_WATCH", "false"); let _debounce = EnvRestore::set("TRACEDECAY_SYNC_WATCH_DEBOUNCE_MS", "5000"); // Unparsable ints/bools are ignored (field keeps its base value). diff --git a/src/daemon/service.rs b/src/daemon/service.rs index 1cfba1f3..3a939908 100644 --- a/src/daemon/service.rs +++ b/src/daemon/service.rs @@ -869,7 +869,6 @@ fn launchctl_stderr_is_not_loaded(stderr: &str) -> bool { mod tests { use std::ffi::{OsStr, OsString}; use std::path::PathBuf; - use std::sync::Mutex; #[cfg(target_os = "linux")] use std::os::unix::fs::PermissionsExt; @@ -877,8 +876,7 @@ mod tests { use tempfile::TempDir; use super::{DaemonServiceSpec, LaunchctlFailureMode, LaunchdCommand}; - - static ENV_LOCK: Mutex<()> = Mutex::new(()); + use crate::config::lock_user_data_dir_test_env; struct EnvVarGuard { key: &'static str, @@ -938,7 +936,7 @@ mod tests { #[cfg(target_os = "macos")] #[test] fn service_status_includes_launchd_debug_commands() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); + let _env_lock = lock_user_data_dir_test_env(); let profile = tempfile::TempDir::new().expect("profile temp dir"); let _data_dir_guard = EnvVarGuard::set(crate::config::USER_DATA_DIR_ENV, profile.path()); @@ -1022,7 +1020,7 @@ mod tests { #[cfg(unix)] #[test] fn render_launchd_plist_includes_program_arguments_socket_logs_and_label() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); + let _env_lock = lock_user_data_dir_test_env(); let profile = tempfile::TempDir::new().expect("profile temp dir"); let home = tempfile::TempDir::new().expect("home temp dir"); let _home_guard = EnvVarGuard::set("HOME", home.path()); @@ -1061,7 +1059,7 @@ mod tests { #[cfg(unix)] #[test] fn render_launchd_plist_escapes_xml_and_parser_unescapes_socket_path() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); + let _env_lock = lock_user_data_dir_test_env(); let profile = tempfile::TempDir::new().expect("profile temp dir"); let home = tempfile::TempDir::new().expect("home temp dir"); let _home_guard = EnvVarGuard::set("HOME", home.path()); @@ -1117,7 +1115,7 @@ mod tests { #[cfg(unix)] #[test] fn launchd_plist_env_value_round_trips_data_dir_override() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); + let _env_lock = lock_user_data_dir_test_env(); let profile = tempfile::TempDir::new().expect("profile temp dir"); let home = tempfile::TempDir::new().expect("home temp dir"); let _home_guard = EnvVarGuard::set("HOME", home.path()); @@ -1139,7 +1137,7 @@ mod tests { #[cfg(unix)] #[test] fn launchd_plist_env_value_ignores_plist_without_override() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); + let _env_lock = lock_user_data_dir_test_env(); let profile = tempfile::TempDir::new().expect("profile temp dir"); let home = tempfile::TempDir::new().expect("home temp dir"); let _home_guard = EnvVarGuard::set("HOME", home.path()); @@ -1227,7 +1225,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn refresh_service_rewrites_unit_and_restarts_daemon() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); + let _env_lock = lock_user_data_dir_test_env(); let dir = TempDir::new().expect("temp dir"); let config_home = dir.path().join("config"); let fake_bin = dir.path().join("bin"); @@ -1276,7 +1274,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn refresh_installed_service_skips_missing_unit() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); + let _env_lock = lock_user_data_dir_test_env(); let dir = TempDir::new().expect("temp dir"); let config_home = dir.path().join("config"); let fake_bin = dir.path().join("bin"); @@ -1305,7 +1303,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn refresh_installed_service_preserves_existing_socket_path() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); + let _env_lock = lock_user_data_dir_test_env(); let dir = TempDir::new().expect("temp dir"); let config_home = dir.path().join("config"); let fake_bin = dir.path().join("bin"); @@ -1365,7 +1363,7 @@ mod tests { #[test] fn default_socket_path_is_profile_scoped_not_project_scoped() { - let _env_lock = ENV_LOCK.lock().expect("env lock"); + let _env_lock = lock_user_data_dir_test_env(); let profile = tempfile::TempDir::new().expect("profile temp dir"); let project_a = tempfile::TempDir::new().expect("project a temp dir"); let project_b = tempfile::TempDir::new().expect("project b temp dir"); diff --git a/src/hooks/codex.rs b/src/hooks/codex.rs index 249a812d..0a711d16 100644 --- a/src/hooks/codex.rs +++ b/src/hooks/codex.rs @@ -644,7 +644,7 @@ mod tests { #[test] fn codex_prompt_hints_dedupe_by_session_and_category() { - let _lock = crate::hooks::test_env_lock().lock().unwrap(); + let _lock = crate::hooks::lock_test_env(); let project = tempfile::tempdir().unwrap(); let profile = tempfile::tempdir().unwrap(); let project_root = project.path().canonicalize().unwrap(); diff --git a/src/hooks/cursor.rs b/src/hooks/cursor.rs index e15b48ca..a151b5db 100644 --- a/src/hooks/cursor.rs +++ b/src/hooks/cursor.rs @@ -788,7 +788,7 @@ mod tests { /// so a prompt-shaped trigger steers identically on both agents. #[test] fn cursor_prompt_hint_runs_decide_hint_and_dedupes_per_session() { - let _lock = crate::hooks::test_env_lock().lock().unwrap(); + let _lock = crate::hooks::lock_test_env(); let project = tempfile::tempdir().unwrap(); let profile = tempfile::tempdir().unwrap(); let project_root = project.path().canonicalize().unwrap(); diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 35662976..1f510556 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -6,8 +6,6 @@ use std::io::Read; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; -#[cfg(test)] -use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use serde_json::Value; @@ -157,9 +155,8 @@ fn now_unix_secs() -> i64 { } #[cfg(test)] -pub(crate) fn test_env_lock() -> &'static Mutex<()> { - static ENV_LOCK: OnceLock> = OnceLock::new(); - ENV_LOCK.get_or_init(|| Mutex::new(())) +pub(crate) fn lock_test_env() -> std::sync::MutexGuard<'static, ()> { + crate::config::lock_user_data_dir_test_env() } /// Mints a unique id for one hint candidate so its `hint_candidate` row and its @@ -661,7 +658,7 @@ mod hint_analytics_tests { #[test] fn record_hint_emitted_missing_session_is_single_terminal() { - let _lock = super::test_env_lock().lock().unwrap(); + let _lock = super::lock_test_env(); let project = tempfile::tempdir().unwrap(); let profile = tempfile::tempdir().unwrap(); let project_root = project.path().canonicalize().unwrap(); @@ -689,7 +686,7 @@ mod hint_analytics_tests { /// known. #[test] fn every_hint_branch_yields_exactly_one_terminal_with_hint_id() { - let _lock = super::test_env_lock().lock().unwrap(); + let _lock = super::lock_test_env(); let project = tempfile::tempdir().unwrap(); let profile = tempfile::tempdir().unwrap(); let project_root = project.path().canonicalize().unwrap(); @@ -784,7 +781,7 @@ mod hint_analytics_tests { /// terminal, and no hint is returned to the caller. #[test] fn budget_exhaustion_records_suppressed_budget_terminal() { - let _lock = super::test_env_lock().lock().unwrap(); + let _lock = super::lock_test_env(); let project = tempfile::tempdir().unwrap(); let profile = tempfile::tempdir().unwrap(); let project_root = project.path().canonicalize().unwrap(); @@ -848,7 +845,7 @@ mod hint_analytics_tests { /// stronger re-hint recorded as `hint_escalated`, with the escalation prefix. #[test] fn repeated_usage_records_hint_escalated_terminal() { - let _lock = super::test_env_lock().lock().unwrap(); + let _lock = super::lock_test_env(); let project = tempfile::tempdir().unwrap(); let profile = tempfile::tempdir().unwrap(); let project_root = project.path().canonicalize().unwrap(); diff --git a/src/mcp/response_handles.rs b/src/mcp/response_handles.rs index 2c3f1b80..3a32f18f 100644 --- a/src/mcp/response_handles.rs +++ b/src/mcp/response_handles.rs @@ -659,3 +659,11 @@ fn clipped_handle_for_log(handle: &str) -> String { clipped } } + +/// Serializes lib unit tests that store response handles under the +/// process-global profile root (`TRACEDECAY_DATA_DIR`). Uses the shared +/// user-data-dir test lock so env mutation cannot race profile resolution. +#[cfg(test)] +pub(crate) fn lock_response_handle_store() -> std::sync::MutexGuard<'static, ()> { + crate::config::lock_user_data_dir_test_env() +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs index bc3e063c..a186d6e3 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -1372,7 +1372,7 @@ impl McpServer { return; } - self.spawn_read_refresh_task(Arc::clone(cg), self.sync_config.full_sync_escalation_files); + self.spawn_read_refresh_task(cg, self.sync_config.full_sync_escalation_files); } /// Spawns the detached D4 refresh task. The task owns cheap `Arc` clones @@ -1383,7 +1383,7 @@ impl McpServer { /// /// The caller MUST have already set `background_refresh_running` to /// `true`; this task clears it on completion. - fn spawn_read_refresh_task(&self, cg: Arc, escalation: usize) { + fn spawn_read_refresh_task(&self, cg: &Arc, escalation: usize) { let running = Arc::clone(&self.background_refresh_running); let done_at = Arc::clone(&self.last_background_refresh_done_at); let token_map = Arc::clone(&self.file_token_map); @@ -1461,7 +1461,7 @@ impl McpServer { } self.last_background_refresh_at .store(now, Ordering::Release); - self.spawn_read_refresh_task(cg, self.sync_config.full_sync_escalation_files); + self.spawn_read_refresh_task(&cg, self.sync_config.full_sync_escalation_files); } /// Returns a compact one-line notice when automation runs have staged diff --git a/src/mcp/server/freshness_tests.rs b/src/mcp/server/freshness_tests.rs index 6b212eec..e15a6590 100644 --- a/src/mcp/server/freshness_tests.rs +++ b/src/mcp/server/freshness_tests.rs @@ -1,4 +1,5 @@ use super::{format_index_age_phrase, staleness_banner, McpServer, StalenessBannerInputs}; +use crate::config::PinnedUserDataDir; use crate::tracedecay::TraceDecay; use std::sync::atomic::Ordering; use std::time::Duration; @@ -15,7 +16,8 @@ fn git(root: &std::path::Path, args: &[&str]) { assert!(ok, "git {args:?} failed"); } -async fn init_indexed_repo() -> (TraceDecay, TempDir) { +async fn init_indexed_repo() -> (TraceDecay, TempDir, PinnedUserDataDir) { + let pin = PinnedUserDataDir::new(); let dir = TempDir::new().unwrap(); let root = dir.path(); git(root, &["init", "-q", "-b", "main"]); @@ -28,7 +30,7 @@ async fn init_indexed_repo() -> (TraceDecay, TempDir) { git(root, &["commit", "-q", "-m", "initial"]); let cg = TraceDecay::init(root).await.expect("init"); cg.index_all().await.expect("index"); - (cg, dir) + (cg, dir, pin) } // ---- D7 pure-logic banner tests (test c) -------------------------- @@ -113,7 +115,7 @@ fn banner_instructs_manual_sync_when_auto_sync_disabled() { #[tokio::test] async fn startup_catch_up_spawned_once_per_server() { - let (cg, _dir) = init_indexed_repo().await; + let (cg, _dir, _pin) = init_indexed_repo().await; let server = McpServer::new(cg, None).await; // The D1 spawn should have claimed the one-shot flag. assert!( @@ -144,7 +146,7 @@ async fn startup_catch_up_spawned_once_per_server() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn read_refresh_is_non_blocking_and_single_flighted() { - let (cg, dir) = init_indexed_repo().await; + let (cg, dir, _pin) = init_indexed_repo().await; let root = dir.path().to_path_buf(); let mut config = crate::config::load_config(&root).expect("load config"); config.sync.session_start_sync = false; diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index e1e5ab17..5b275546 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -516,6 +516,7 @@ pub async fn handle_profile_scoped_lcm_tool_call( #[allow( clippy::unwrap_used, clippy::expect_used, + clippy::await_holding_lock, clippy::redundant_closure_for_method_calls, clippy::uninlined_format_args )] @@ -528,13 +529,10 @@ mod tests { use serde_json::json; use tempfile::TempDir; - use tokio::sync::Mutex; use super::super::get_tool_definitions; use super::*; - use crate::config::USER_DATA_DIR_ENV; - - static SELECTOR_ENV_LOCK: Mutex<()> = Mutex::const_new(()); + use crate::config::{lock_user_data_dir_test_env, USER_DATA_DIR_ENV}; struct EnvVarGuard { key: &'static str, @@ -726,7 +724,7 @@ mod tests { #[tokio::test] async fn graph_reader_selector_dispatch_targets_registered_project() { - let _env_lock = SELECTOR_ENV_LOCK.lock().await; + let _env_lock = lock_user_data_dir_test_env(); let dir = TempDir::new().unwrap(); let _env = SelectorEnv::new(dir.path()); let active_project = dir.path().join("active"); @@ -788,7 +786,7 @@ mod tests { #[tokio::test] async fn graph_reader_selector_dispatch_accepts_unique_project_basename() { - let _env_lock = SELECTOR_ENV_LOCK.lock().await; + let _env_lock = lock_user_data_dir_test_env(); let dir = TempDir::new().unwrap(); let _env = SelectorEnv::new(dir.path()); let active_project = dir.path().join("active"); @@ -837,7 +835,7 @@ mod tests { #[tokio::test] async fn graph_reader_selector_rejects_ambiguous_project_basename() { - let _env_lock = SELECTOR_ENV_LOCK.lock().await; + let _env_lock = lock_user_data_dir_test_env(); let dir = TempDir::new().unwrap(); let _env = SelectorEnv::new(dir.path()); let active_project = dir.path().join("active"); @@ -881,7 +879,7 @@ mod tests { #[tokio::test] async fn unsupported_selector_tool_rejects_explicit_project_selector() { - let _env_lock = SELECTOR_ENV_LOCK.lock().await; + let _env_lock = lock_user_data_dir_test_env(); let dir = TempDir::new().unwrap(); let _env = SelectorEnv::new(dir.path()); let project = dir.path().join("active"); @@ -916,7 +914,7 @@ mod tests { const LARGE_RESPONSE_MARKER_COUNT: usize = 120; const LAST_LARGE_RESPONSE_MARKER: usize = LARGE_RESPONSE_MARKER_COUNT - 1; - let _env_lock = SELECTOR_ENV_LOCK.lock().await; + let _env_lock = lock_user_data_dir_test_env(); let dir = TempDir::new().unwrap(); let _env = SelectorEnv::new(dir.path()); let active_project = dir.path().join("active"); diff --git a/src/mcp/tools/handlers/session/tests.rs b/src/mcp/tools/handlers/session/tests.rs index cc0412fb..30693edc 100644 --- a/src/mcp/tools/handlers/session/tests.rs +++ b/src/mcp/tools/handlers/session/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::mcp::response_handles::lock_response_handle_store; fn sample_message_search_payload() -> Value { json!({ @@ -109,6 +110,7 @@ fn message_text_snippet_plain_text_is_collapsed() { #[test] fn lcm_preflight_markdown_truncation_stores_retrieval_handle() { + let _store_guard = lock_response_handle_store(); // Regression: the markdown-default preflight path must thread the // project root so an oversized payload truncates *with* a recoverable // handle rather than an irreversible clip. @@ -190,6 +192,7 @@ fn oversized_needs_synthesis_expand_query_payload() -> Value { #[test] fn lcm_expand_query_needs_synthesis_floor_is_bounded_valid_json() { + let _store_guard = lock_response_handle_store(); // Regression (S3): a needs_synthesis payload that is still over budget // after Minimal compaction must NOT be emitted unbounded. The floor // must stay within MAX_RESPONSE_CHARS, remain valid JSON, and keep the diff --git a/src/mcp/tools/render/tests.rs b/src/mcp/tools/render/tests.rs index b3a3a564..9ee05e50 100644 --- a/src/mcp/tools/render/tests.rs +++ b/src/mcp/tools/render/tests.rs @@ -1,5 +1,7 @@ use super::*; -use crate::mcp::response_handles::{retrieve_response_handle_from_root, ResponseHandleLookup}; +use crate::mcp::response_handles::{ + lock_response_handle_store, retrieve_response_handle_from_root, ResponseHandleLookup, +}; use crate::tracedecay::current_timestamp; use serde_json::json; @@ -58,6 +60,7 @@ fn truncate_long_response() { #[test] fn truncated_json_envelope_includes_handle() { + let _store_guard = lock_response_handle_store(); let dir = tempfile::TempDir::new().unwrap(); let long = format!( "{{\"items\":[{}]}}", @@ -93,6 +96,7 @@ fn truncated_json_envelope_includes_handle() { #[test] fn truncated_markdown_includes_readable_handle_guidance() { + let _store_guard = lock_response_handle_store(); let dir = tempfile::TempDir::new().unwrap(); let long = format!("# Scan\n\n{}", "- repeated finding\n".repeat(3_000)); @@ -138,6 +142,7 @@ fn truncate_text_with_handle_returns_short_text_unchanged() { #[test] fn truncate_text_with_handle_stores_reversible_envelope() { + let _store_guard = lock_response_handle_store(); let dir = tempfile::TempDir::new().unwrap(); let long = "- indexed file entry\n".repeat(3_000); @@ -159,6 +164,7 @@ fn truncate_text_with_handle_stores_reversible_envelope() { #[test] fn truncated_json_envelope_reports_store_failure() { + let _store_guard = lock_response_handle_store(); let dir = tempfile::TempDir::new().unwrap(); std::fs::create_dir_all(dir.path().join(".tracedecay")).unwrap(); std::fs::write( diff --git a/src/tracedecay/indexing/freshness_tests.rs b/src/tracedecay/indexing/freshness_tests.rs index f257b9e6..e814c5be 100644 --- a/src/tracedecay/indexing/freshness_tests.rs +++ b/src/tracedecay/indexing/freshness_tests.rs @@ -1,3 +1,4 @@ +use crate::config::PinnedUserDataDir; use crate::tracedecay::TraceDecay; use std::path::Path; use std::process::Command; @@ -28,7 +29,8 @@ fn head_oid(dir: &Path) -> String { String::from_utf8(out.stdout).unwrap().trim().to_string() } -async fn init_repo_with_commit() -> (TraceDecay, TempDir) { +async fn init_repo_with_commit() -> (TraceDecay, TempDir, PinnedUserDataDir) { + let pin = PinnedUserDataDir::new(); let dir = TempDir::new().unwrap(); let root = dir.path(); git(root, &["init", "-q", "-b", "main"]); @@ -39,19 +41,19 @@ async fn init_repo_with_commit() -> (TraceDecay, TempDir) { let cg = TraceDecay::init(root).await.expect("init"); cg.index_all().await.expect("index"); - (cg, dir) + (cg, dir, pin) } #[tokio::test] async fn last_synced_commit_stamped_after_index() { - let (cg, dir) = init_repo_with_commit().await; + let (cg, dir, _pin) = init_repo_with_commit().await; let stamped = cg.last_synced_commit().await; assert_eq!(stamped.as_deref(), Some(head_oid(dir.path()).as_str())); } #[tokio::test] async fn stale_files_since_commit_reports_changed_file() { - let (cg, dir) = init_repo_with_commit().await; + let (cg, dir, _pin) = init_repo_with_commit().await; let root = dir.path(); let base = head_oid(root); @@ -70,7 +72,7 @@ async fn stale_files_since_commit_reports_changed_file() { #[tokio::test] async fn stale_files_since_commit_none_when_base_missing() { - let (cg, _dir) = init_repo_with_commit().await; + let (cg, _dir, _pin) = init_repo_with_commit().await; // A syntactically valid but unreachable commit id. let bogus = "0".repeat(40); assert!(cg.stale_files_since_commit(&bogus, 500).is_none()); @@ -78,7 +80,7 @@ async fn stale_files_since_commit_none_when_base_missing() { #[tokio::test] async fn stale_files_since_commit_none_when_over_escalation_limit() { - let (cg, dir) = init_repo_with_commit().await; + let (cg, dir, _pin) = init_repo_with_commit().await; let root = dir.path(); let base = head_oid(root); diff --git a/src/user_config.rs b/src/user_config.rs index e5161556..2b57026c 100644 --- a/src/user_config.rs +++ b/src/user_config.rs @@ -251,13 +251,11 @@ pub fn parse_duration(s: &str) -> Option { )] mod tests { use super::*; - use crate::config::USER_DATA_DIR_ENV; + use crate::config::{lock_user_data_dir_test_env, USER_DATA_DIR_ENV}; use std::ffi::OsString; use std::time::Duration; use tempfile::TempDir; - static USER_DATA_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - struct EnvRestore { key: &'static str, previous: Option, @@ -307,7 +305,7 @@ mod tests { #[test] fn save_preserves_existing_corrupt_config_file() { - let _lock = USER_DATA_DIR_ENV_LOCK.lock().unwrap(); + let _lock = lock_user_data_dir_test_env(); let temp = TempDir::new().unwrap(); let _env = EnvRestore::set(USER_DATA_DIR_ENV, temp.path()); let path = config_path().expect("config path should resolve"); @@ -327,7 +325,7 @@ mod tests { #[test] fn save_preserves_unknown_config_keys() { - let _lock = USER_DATA_DIR_ENV_LOCK.lock().unwrap(); + let _lock = lock_user_data_dir_test_env(); let temp = TempDir::new().unwrap(); let _env = EnvRestore::set(USER_DATA_DIR_ENV, temp.path()); let path = config_path().expect("config path should resolve"); diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index 2240a42f..d54320e0 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -281,6 +281,47 @@ fn expected_tracedecay_bin() -> String { .replace('\\', "/") } +fn expected_tracedecay_bin_variants() -> Vec { + let raw = PathBuf::from(env!("CARGO_BIN_EXE_tracedecay")); + let canonical = std::fs::canonicalize(&raw).unwrap_or_else(|_| raw.clone()); + let mut variants = Vec::new(); + for path in [raw, canonical] { + let native = path.to_string_lossy().to_string(); + let slash = native.replace('\\', "/"); + if !variants.contains(&native) { + variants.push(native); + } + if !variants.contains(&slash) { + variants.push(slash); + } + } + variants +} + +fn contains_expected_tracedecay_bin(body: &str) -> bool { + let slash_body = body.replace('\\', "/"); + expected_tracedecay_bin_variants().iter().any(|expected| { + body.contains(expected) || slash_body.contains(&expected.replace('\\', "/")) + }) +} + +fn comparable_command_path(command: &str) -> String { + command + .strip_prefix("//?/") + .unwrap_or(command) + .replace('\\', "/") +} + +fn assert_command_eq(actual: &serde_json::Value, expected: &str) { + let actual = actual + .as_str() + .unwrap_or_else(|| panic!("command should be a string: {actual}")); + assert_eq!( + comparable_command_path(actual), + comparable_command_path(expected) + ); +} + /// Python snippet that py_compiles the generated plugin sources inside the /// same interpreter that runs a test's check script, instead of the separate /// `python3 -m py_compile` process `assert_python_compiles` spawns. On @@ -408,7 +449,7 @@ fn assert_codex_plugin_bundle( let mcp = read_json(&plugin_dir.join(".mcp.json")); let server = &mcp["mcpServers"]["tracedecay"]; assert_eq!(server["type"], "stdio"); - assert_eq!(server["command"], expected_command); + assert_command_eq(&server["command"], expected_command); assert_eq!(server["args"], expected_args); if expected_global_bundle { assert_eq!(server["env"]["TRACEDECAY_ENABLE_GLOBAL_DB"], "1"); @@ -523,7 +564,7 @@ fn assert_cursor_plugin_bundle(plugin_dir: &Path, expected_command: &str, expect let mcp = read_json(&plugin_dir.join("mcp.json")); let server = &mcp["mcpServers"]["tracedecay"]; assert_eq!(server["type"], "stdio"); - assert_eq!(server["command"], expected_command); + assert_command_eq(&server["command"], expected_command); assert_eq!( server["args"], serde_json::json!(["serve", "--path", "${workspaceFolder}"]) @@ -561,7 +602,8 @@ fn assert_cursor_plugin_bundle(plugin_dir: &Path, expected_command: &str, expect assert!( hook["command"] .as_str() - .is_some_and(|command| command.contains(expected_command)), + .is_some_and(|command| comparable_command_path(command) + .contains(&comparable_command_path(expected_command))), "plugin hook commands should use the installed tracedecay binary" ); assert!( @@ -795,6 +837,7 @@ fn assert_hermes_config_enables_tracedecay_memory(config_path: &Path) -> String #[test] fn test_cursor_install_installs_local_plugin_without_global_mcp() { let home = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(&home); let ctx = make_install_ctx(home.path()); CursorIntegration.install(&ctx).unwrap(); @@ -817,6 +860,7 @@ fn test_cursor_install_installs_local_plugin_without_global_mcp() { #[test] fn test_cursor_plugin_hooks_quote_binary_paths_with_spaces() { let home = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(&home); let tracedecay_bin = home.path().join("bin with spaces/tracedecay"); let ctx = InstallContext { home: home.path().to_path_buf(), @@ -999,6 +1043,7 @@ fn test_local_install_cursor_preserves_existing_permissions_file() { #[test] fn test_cursor_healthcheck_ignores_foreign_project_cursor_files() { let home = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(&home); let project = TempDir::new().unwrap(); CursorIntegration .install(&make_install_ctx(home.path())) @@ -1089,7 +1134,7 @@ fn test_hermes_local_install_writes_profile_plugin() { })); let tools_py = std::fs::read_to_string(plugin_dir.join("tools.py")).unwrap(); - assert!(tools_py.contains(&expected_tracedecay_bin())); + assert!(contains_expected_tracedecay_bin(&tools_py)); assert!(tools_py.contains("subprocess.run")); assert!(tools_py.contains("tracedecay tool")); assert!(tools_py.contains("TRACEDECAY_TIMEOUT_SECONDS = 120")); @@ -1307,6 +1352,7 @@ fn test_hermes_generated_python_registers_lcm_context_engine() { #[test] fn test_hermes_generated_python_handles_quoted_unicode_tracedecay_path() { let home = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(&home); let tracedecay_bin = home.path().join("bin with spaces").join("token\"save-π"); let ctx = InstallContext { home: home.path().to_path_buf(), @@ -2002,6 +2048,7 @@ assert fallback.name == "tracedecay" #[test] fn test_hermes_global_install_and_uninstall_plugin() { let home = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(&home); let ctx = make_install_ctx(home.path()); HermesIntegration.install(&ctx).unwrap(); @@ -2035,6 +2082,7 @@ fn test_hermes_global_install_and_uninstall_plugin() { #[test] fn test_hermes_profile_install_targets_named_profile() { let home = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(&home); let ctx = InstallContext { home: home.path().to_path_buf(), tracedecay_bin: "/usr/local/bin/tracedecay".to_string(), @@ -2218,6 +2266,7 @@ fn test_hermes_local_install_with_profile_targets_named_profile() { #[test] fn test_hermes_install_rejects_invalid_profile_names() { let home = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(&home); let ctx = InstallContext { home: home.path().to_path_buf(), tracedecay_bin: "/usr/local/bin/tracedecay".to_string(), @@ -2586,6 +2635,7 @@ fn test_hermes_install_replaces_default_compressor_context_engine() { #[test] fn test_hermes_install_preserves_user_keys_in_tracedecay_config_block() { let home = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(&home); let hermes_dir = home.path().join(".hermes"); std::fs::create_dir_all(&hermes_dir).unwrap(); std::fs::write( @@ -2707,6 +2757,7 @@ fn test_hermes_install_rejects_inline_plugins_config_without_rewrite() { #[test] fn test_hermes_uninstall_preserves_other_profile_plugins_and_config() { let home = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(&home); let profile = home.path().join(".hermes/profiles/work"); let plugin_dir = profile.join("plugins/tracedecay"); let other_plugin = profile.join("plugins/other"); @@ -2957,9 +3008,8 @@ fn assert_local_install_writes_project_paths(agent: &str, paths: &[&str]) { && (*relative == ".agents/plugins/marketplace.json" || relative.ends_with(".codex-plugin/plugin.json")); if !is_instruction_file && !is_codex_metadata { - let expected = expected_tracedecay_bin(); assert!( - body.contains(&expected), + contains_expected_tracedecay_bin(&body), "{agent} local config {} should use the resolved absolute tracedecay executable", path.display() ); @@ -3101,6 +3151,7 @@ fn test_local_install_rejects_cline_without_project_mutation() { fn test_claude_install_creates_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); @@ -3187,6 +3238,7 @@ fn test_claude_install_creates_config() { fn test_gemini_install_creates_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); GeminiIntegration.install(&ctx).unwrap(); @@ -3220,6 +3272,7 @@ fn test_gemini_install_creates_config() { fn test_codex_install_creates_plugin_bundle_and_marketplace() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); CodexIntegration.install(&ctx).unwrap(); @@ -3331,6 +3384,7 @@ async fn test_codex_shareable_plugin_artifact_exports_bundle_and_managed_skills( fn test_codex_install_refreshes_existing_cache_and_keeps_bootstrap_source_listable() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); let stale_plugin_dir = codex_stale_cached_plugin_install_dir(home); write_codex_plugin_manifest(&stale_plugin_dir, "0.0.0"); @@ -3375,6 +3429,7 @@ fn test_codex_install_refreshes_existing_cache_and_keeps_bootstrap_source_listab fn test_codex_install_refreshes_existing_cache_and_prunes_stale_skills() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); let cached_plugin_dir = codex_cached_plugin_install_dir(home); let bootstrap_dir = codex_plugin_install_dir(home); @@ -3414,6 +3469,7 @@ fn test_codex_install_refreshes_existing_cache_and_prunes_stale_skills() { fn test_codex_install_sweeps_legacy_global_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let codex_dir = home.join(".codex"); std::fs::create_dir_all(&codex_dir).unwrap(); std::fs::write( @@ -3624,6 +3680,7 @@ fn assert_codex_hooks_registered(hooks: &serde_json::Value) { fn test_codex_global_install_bundles_hooks_in_plugin() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); CodexIntegration.install(&ctx).unwrap(); @@ -3682,7 +3739,7 @@ fn assert_command_contains_expected_bin( }) .expect("handler command should exist"); assert!( - command.contains(expected), + comparable_command_path(command).contains(&comparable_command_path(expected)), "Codex hook command must use the resolved absolute tracedecay executable, got {command}" ); } @@ -3691,6 +3748,7 @@ fn assert_command_contains_expected_bin( fn test_codex_install_reconciles_hooks_idempotently() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); CodexIntegration.install(&ctx).unwrap(); @@ -3723,6 +3781,7 @@ fn test_codex_install_reconciles_hooks_idempotently() { fn test_codex_uninstall_removes_plugin_hooks() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); CodexIntegration.install(&ctx).unwrap(); @@ -3741,6 +3800,7 @@ fn test_codex_uninstall_removes_plugin_hooks() { fn test_kimi_install_creates_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); KimiIntegration.install(&ctx).unwrap(); @@ -3767,6 +3827,7 @@ fn test_kimi_install_creates_config() { fn test_kimi_install_then_uninstall() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); KimiIntegration.install(&ctx).unwrap(); @@ -3794,6 +3855,7 @@ fn test_kimi_install_then_uninstall() { fn test_kimi_is_detected_and_has_tracedecay() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); assert!(!KimiIntegration.is_detected(home)); assert!(!KimiIntegration.has_tracedecay(home)); @@ -3809,6 +3871,7 @@ fn test_kimi_is_detected_and_has_tracedecay() { fn test_cursor_install_creates_plugin() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); CursorIntegration.install(&ctx).unwrap(); @@ -3924,6 +3987,7 @@ async fn test_prompt_integrations_export_active_managed_skill_indexes() { fn test_opencode_install_creates_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // OpenCode uses ~/.config/opencode/opencode.json // Create the parent dir so install can discover it let ctx = make_install_ctx(home); @@ -3943,6 +4007,7 @@ fn test_opencode_install_creates_config() { fn test_zed_install_creates_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ZedIntegration.install(&ctx).unwrap(); @@ -3966,6 +4031,7 @@ fn test_zed_install_creates_config() { fn test_cline_install_creates_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ClineIntegration.install(&ctx).unwrap(); @@ -3996,6 +4062,7 @@ fn test_cline_install_creates_config() { fn test_roo_code_install_creates_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); RooCodeIntegration.install(&ctx).unwrap(); @@ -4021,6 +4088,7 @@ fn test_roo_code_install_creates_config() { fn test_copilot_install_creates_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); CopilotIntegration.install(&ctx).unwrap(); @@ -4061,6 +4129,7 @@ fn test_copilot_install_creates_config() { fn test_vibe_install_creates_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); VibeIntegration.install(&ctx).unwrap(); @@ -4105,6 +4174,7 @@ fn test_vibe_install_creates_config() { fn test_claude_install_then_uninstall() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); let marketplace_manifest = @@ -4184,6 +4254,7 @@ fn test_claude_uninstall_unrecords_memory_digest_target() { fn test_gemini_install_then_uninstall() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); GeminiIntegration.install(&ctx).unwrap(); @@ -4221,6 +4292,7 @@ fn test_gemini_install_then_uninstall() { fn test_codex_install_then_uninstall() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); CodexIntegration.install(&ctx).unwrap(); @@ -4317,6 +4389,7 @@ fn test_codex_install_preserves_existing_config() { // empty table. let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); std::fs::create_dir_all(home.join(".codex")).unwrap(); let config_path = home.join(".codex/config.toml"); let original = "\ @@ -4355,6 +4428,7 @@ fn test_codex_install_leaves_unparseable_config_untouched() { // user config must be left byte-identical while the plugin bundle installs. let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); std::fs::create_dir_all(home.join(".codex")).unwrap(); let config_path = home.join(".codex/config.toml"); let original = "this is not valid TOML {{{{"; @@ -4391,6 +4465,8 @@ fn assert_install_backs_up_and_preserves( original: &str, marker: &str, ) { + // Serializes env-mutating installs; callers must not hold AgentEnvLock. + let _agent_env = crate::common::AgentEnvLock::pin(home); let config_path = agent .primary_config_path(home) .unwrap_or_else(|| panic!("{} must implement primary_config_path", agent.name())); @@ -4432,6 +4508,7 @@ fn test_claude_install_preserves_existing_config() { // foreign settings key and a foreign registered marketplace must survive. let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let claude_dir = home.join(".claude"); let plugins_dir = claude_dir.join("plugins"); std::fs::create_dir_all(&plugins_dir).unwrap(); @@ -4494,6 +4571,7 @@ fn test_gemini_install_preserves_existing_config() { #[test] fn test_cursor_install_preserves_existing_legacy_mcp_config() { let dir = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(dir.path()); let path = dir.path().join(".cursor/mcp.json"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); let original = r#"{ @@ -4570,6 +4648,7 @@ fn test_cursor_uninstall_backs_up_config_with_other_content() { // before rewriting, so a botched rewrite is recoverable. let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); let path = home.join(".cursor/mcp.json"); @@ -4636,6 +4715,7 @@ fn test_antigravity_install_preserves_existing_config() { fn test_antigravity_install_writes_cli_plugin() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let bin = "/usr/local/bin/tracedecay"; let ctx = InstallContext { home: home.to_path_buf(), @@ -4688,6 +4768,7 @@ fn test_antigravity_install_writes_cli_plugin() { fn test_antigravity_uninstall_removes_both_locations() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let bin = "/usr/local/bin/tracedecay"; let ctx = InstallContext { home: home.to_path_buf(), @@ -4777,6 +4858,7 @@ fn test_every_tested_agent_advertises_primary_config_path() { fn test_cursor_install_then_uninstall() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); CursorIntegration.install(&ctx).unwrap(); @@ -4812,6 +4894,7 @@ fn test_cursor_install_then_uninstall() { fn test_copilot_install_then_uninstall() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); CopilotIntegration.install(&ctx).unwrap(); @@ -4834,6 +4917,7 @@ fn test_copilot_install_then_uninstall() { fn test_vibe_install_then_uninstall() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); VibeIntegration.install(&ctx).unwrap(); @@ -4888,6 +4972,7 @@ fn make_install_ctx_with_real_bin(home: &Path) -> InstallContext { fn test_healthcheck_claude_clean_install() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx_with_real_bin(home); ClaudeIntegration.install(&ctx).unwrap(); @@ -4904,6 +4989,7 @@ fn test_healthcheck_claude_clean_install() { fn test_healthcheck_gemini_clean_install() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); GeminiIntegration.install(&ctx).unwrap(); @@ -4920,6 +5006,7 @@ fn test_healthcheck_gemini_clean_install() { fn test_healthcheck_codex_after_install() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); CodexIntegration.install(&ctx).unwrap(); @@ -4975,6 +5062,7 @@ fn test_healthcheck_codex_local_install_warns_when_repo_marketplace_missing() { #[test] fn test_healthcheck_codex_ignores_unrelated_project_agents_md() { let home = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(&home); let project = TempDir::new().unwrap(); std::fs::write( project.path().join("AGENTS.md"), @@ -5000,6 +5088,7 @@ fn test_healthcheck_codex_ignores_unrelated_project_agents_md() { fn test_healthcheck_cursor_clean_install() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); CursorIntegration.install(&ctx).unwrap(); @@ -5087,6 +5176,7 @@ async fn test_cursor_healthcheck_warns_on_literal_workspace_folder_transcript_pa #[test] fn test_healthcheck_hermes_profile_install_checks_named_profiles() { let home = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(&home); let project = TempDir::new().unwrap(); let ctx = InstallContext { home: home.path().to_path_buf(), @@ -5134,6 +5224,7 @@ fn test_healthcheck_hermes_local_install_checks_project_hermes_home() { fn test_healthcheck_opencode_clean_install() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); @@ -5502,6 +5593,7 @@ fn test_parse_jsonc() { fn test_is_detected_claude() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); assert!(!ClaudeIntegration.is_detected(home)); std::fs::create_dir_all(home.join(".claude")).unwrap(); assert!(ClaudeIntegration.is_detected(home)); @@ -5569,6 +5661,7 @@ fn test_is_detected_copilot() { fn test_has_tracedecay_claude() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // No config => false assert!(!ClaudeIntegration.has_tracedecay(home)); @@ -5586,6 +5679,7 @@ fn test_has_tracedecay_claude() { fn test_has_tracedecay_gemini() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); assert!(!GeminiIntegration.has_tracedecay(home)); let ctx = make_install_ctx(home); @@ -5597,6 +5691,7 @@ fn test_has_tracedecay_gemini() { fn test_has_tracedecay_codex() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); assert!(!CodexIntegration.has_tracedecay(home)); let ctx = make_install_ctx(home); @@ -5614,6 +5709,7 @@ fn test_has_tracedecay_codex() { fn test_has_tracedecay_cursor() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); assert!(!CursorIntegration.has_tracedecay(home)); let ctx = make_install_ctx(home); @@ -5625,6 +5721,7 @@ fn test_has_tracedecay_cursor() { fn test_has_tracedecay_opencode() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); assert!(!OpenCodeIntegration.has_tracedecay(home)); let ctx = make_install_ctx(home); @@ -5636,6 +5733,7 @@ fn test_has_tracedecay_opencode() { fn test_has_tracedecay_copilot() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); assert!(!CopilotIntegration.has_tracedecay(home)); let ctx = make_install_ctx(home); @@ -5651,6 +5749,7 @@ fn test_has_tracedecay_copilot() { fn test_claude_install_idempotent() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); // Install twice should not fail @@ -5677,6 +5776,7 @@ fn test_claude_install_idempotent() { fn test_gemini_install_idempotent() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); GeminiIntegration.install(&ctx).unwrap(); @@ -5692,6 +5792,7 @@ fn test_gemini_install_idempotent() { fn test_uninstall_without_install_does_not_crash() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); // Uninstalling when nothing is installed should not panic or error @@ -5716,6 +5817,7 @@ fn test_uninstall_without_install_does_not_crash() { fn test_claude_install_preserves_existing_claude_json() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Pre-populate .claude.json with a foreign MCP server and a custom key. // The plugin model no longer writes tracedecay into ~/.claude.json, and the @@ -5747,6 +5849,7 @@ fn test_claude_install_preserves_existing_claude_json() { fn test_gemini_install_preserves_existing_settings() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let settings_path = home.join(".gemini/settings.json"); std::fs::create_dir_all(home.join(".gemini")).unwrap(); @@ -5965,6 +6068,7 @@ fn test_migrate_installed_agents_skips_when_already_populated() { fn test_migrate_installed_agents_detects_installed_agents() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Install copilot so it can be detected let ctx = make_install_ctx(home); @@ -6157,6 +6261,7 @@ fn test_backup_and_safe_write_round_trip() { #[test] fn test_hermes_install_writes_and_preserves_project_root_pin() { let home = TempDir::new().unwrap(); + let _agent_env = crate::common::AgentEnvLock::pin(&home); let pinned = InstallContext { home: home.path().to_path_buf(), tracedecay_bin: "/usr/local/bin/tracedecay".to_string(), diff --git a/tests/agent_suite/claude_agent_test.rs b/tests/agent_suite/claude_agent_test.rs index 929408de..92593a53 100644 --- a/tests/agent_suite/claude_agent_test.rs +++ b/tests/agent_suite/claude_agent_test.rs @@ -77,6 +77,7 @@ fn permission_allowlist(settings: &serde_json::Value) -> Vec<&str> { fn test_install_deploys_plugin_mcp_server() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); @@ -104,6 +105,7 @@ fn test_install_deploys_plugin_mcp_server() { fn test_install_deploys_plugin_hooks() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); @@ -168,6 +170,7 @@ fn test_install_deploys_plugin_hooks() { fn test_install_creates_settings_with_permissions() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); @@ -279,6 +282,7 @@ fn test_install_claude_md_has_moment_triggers() { fn test_install_creates_claude_md_with_rules() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); @@ -309,6 +313,7 @@ fn test_install_creates_claude_md_with_rules() { fn test_claude_md_contains_explore_agent_paragraph() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Pre-populate CLAUDE.md with existing content let claude_dir = home.join(".claude"); @@ -337,6 +342,7 @@ fn test_claude_md_contains_explore_agent_paragraph() { fn test_uninstall_removes_explore_agent_paragraph() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Pre-populate CLAUDE.md with existing content let claude_dir = home.join(".claude"); @@ -372,6 +378,7 @@ fn test_uninstall_removes_explore_agent_paragraph() { fn test_install_idempotent_claude_md() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); @@ -390,6 +397,7 @@ fn test_install_idempotent_claude_md() { fn test_install_preserves_existing_claude_json() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Pre-populate .claude.json with an extra key std::fs::write(home.join(".claude.json"), r#"{"foo": "bar"}"#).unwrap(); @@ -417,6 +425,7 @@ fn test_install_preserves_existing_claude_json() { fn test_install_preserves_existing_settings() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Pre-populate settings.json with an existing hook let claude_dir = home.join(".claude"); @@ -476,6 +485,7 @@ fn test_install_preserves_existing_settings() { fn test_install_migrates_off_config_managed_integration() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let claude_dir = home.join(".claude"); std::fs::create_dir_all(&claude_dir).unwrap(); @@ -569,6 +579,7 @@ fn test_install_migrates_off_config_managed_integration() { fn test_uninstall_removes_mcp_from_claude_json() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); ClaudeIntegration.uninstall(&ctx).unwrap(); @@ -592,6 +603,7 @@ fn test_uninstall_removes_mcp_from_claude_json() { fn test_uninstall_removes_deployed_bundle_and_lone_marketplace_file() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); // Install deploys the plugin bundle and registers the marketplace. @@ -642,6 +654,7 @@ fn test_uninstall_removes_deployed_bundle_and_lone_marketplace_file() { fn test_uninstall_removes_hook_from_settings() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); ClaudeIntegration.uninstall(&ctx).unwrap(); @@ -678,6 +691,7 @@ fn test_uninstall_removes_hook_from_settings() { fn test_uninstall_removes_permissions_from_settings() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); ClaudeIntegration.uninstall(&ctx).unwrap(); @@ -705,6 +719,7 @@ fn test_uninstall_removes_permissions_from_settings() { fn test_uninstall_preserves_other_permissions() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Install first so all files are set up let ctx = make_install_ctx(home); @@ -739,6 +754,7 @@ fn test_uninstall_preserves_other_permissions() { fn test_uninstall_removes_claude_md_rules() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); @@ -761,6 +777,7 @@ fn test_uninstall_removes_claude_md_rules() { fn test_uninstall_preserves_other_claude_md_content() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Create CLAUDE.md with pre-existing content let claude_dir = home.join(".claude"); @@ -892,6 +909,7 @@ fn test_healthcheck_detects_missing_permissions() { fn test_healthcheck_detects_stale_permissions() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx_with_real_bin(home); ClaudeIntegration.install(&ctx).unwrap(); @@ -919,6 +937,7 @@ fn test_healthcheck_detects_stale_permissions() { fn test_healthcheck_detects_missing_claude_md() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx_with_real_bin(home); ClaudeIntegration.install(&ctx).unwrap(); @@ -941,6 +960,7 @@ fn test_healthcheck_detects_missing_claude_md() { fn test_healthcheck_clean_local_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let project = dir.path().join("myproject"); std::fs::create_dir_all(&project).unwrap(); @@ -974,6 +994,7 @@ fn test_healthcheck_clean_local_config() { fn test_healthcheck_local_settings_cleanup() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let project = dir.path().join("myproject"); let local_claude = project.join(".claude"); std::fs::create_dir_all(&local_claude).unwrap(); @@ -1021,6 +1042,7 @@ fn test_healthcheck_local_settings_cleanup() { fn test_has_tracedecay_after_install() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); ClaudeIntegration.install(&ctx).unwrap(); diff --git a/tests/agent_suite/copilot_agent_test.rs b/tests/agent_suite/copilot_agent_test.rs index fc291cd9..c25e6d4d 100644 --- a/tests/agent_suite/copilot_agent_test.rs +++ b/tests/agent_suite/copilot_agent_test.rs @@ -58,6 +58,7 @@ fn cli_config_path(home: &Path) -> std::path::PathBuf { fn test_install_creates_vscode_settings_with_mcp_server() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); CopilotIntegration.install(&ctx).unwrap(); @@ -94,6 +95,7 @@ fn test_install_creates_vscode_settings_with_mcp_server() { fn test_install_creates_cli_config_with_mcp_server() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); CopilotIntegration.install(&ctx).unwrap(); @@ -125,6 +127,7 @@ fn test_install_creates_cli_config_with_mcp_server() { fn test_install_preserves_existing_vscode_settings() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Pre-populate VS Code settings with other content let settings_path = vscode_settings_path(home); @@ -153,6 +156,7 @@ fn test_install_preserves_existing_vscode_settings() { fn test_install_preserves_existing_cli_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Pre-populate CLI config with another MCP server let cli_path = cli_config_path(home); @@ -181,6 +185,7 @@ fn test_install_preserves_existing_cli_config() { fn test_install_idempotent_vscode() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); CopilotIntegration.install(&ctx).unwrap(); @@ -201,6 +206,7 @@ fn test_install_idempotent_vscode() { fn test_install_idempotent_cli() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); CopilotIntegration.install(&ctx).unwrap(); @@ -223,6 +229,7 @@ fn test_install_idempotent_cli() { fn test_uninstall_removes_vscode_mcp_entry() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); CopilotIntegration.install(&ctx).unwrap(); @@ -250,6 +257,7 @@ fn test_uninstall_removes_vscode_mcp_entry() { fn test_uninstall_cleans_empty_mcp_objects() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); CopilotIntegration.install(&ctx).unwrap(); @@ -269,6 +277,7 @@ fn test_uninstall_cleans_empty_mcp_objects() { fn test_uninstall_removes_cli_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); CopilotIntegration.install(&ctx).unwrap(); @@ -293,6 +302,7 @@ fn test_uninstall_removes_cli_config() { fn test_uninstall_preserves_other_cli_servers() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Pre-populate CLI config with another server let cli_path = cli_config_path(home); @@ -327,6 +337,7 @@ fn test_uninstall_preserves_other_cli_servers() { fn test_uninstall_preserves_other_vscode_settings() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Pre-populate VS Code settings let settings_path = vscode_settings_path(home); @@ -348,6 +359,7 @@ fn test_uninstall_preserves_other_vscode_settings() { fn test_uninstall_without_install_does_not_crash() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); // Should not panic or error CopilotIntegration.uninstall(&ctx).unwrap(); @@ -357,6 +369,7 @@ fn test_uninstall_without_install_does_not_crash() { fn test_uninstall_cli_with_no_tracedecay_is_noop() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Create a CLI config without tracedecay let cli_path = cli_config_path(home); @@ -383,6 +396,7 @@ fn test_uninstall_cli_with_no_tracedecay_is_noop() { fn test_healthcheck_clean_install_no_issues() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); CopilotIntegration.install(&ctx).unwrap(); @@ -595,6 +609,7 @@ fn test_has_tracedecay_before_install() { fn test_has_tracedecay_after_install() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); CopilotIntegration.install(&ctx).unwrap(); assert!( @@ -607,6 +622,7 @@ fn test_has_tracedecay_after_install() { fn test_has_tracedecay_after_uninstall() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); CopilotIntegration.install(&ctx).unwrap(); CopilotIntegration.uninstall(&ctx).unwrap(); diff --git a/tests/agent_suite/kiro_agent_test.rs b/tests/agent_suite/kiro_agent_test.rs index 4673ac81..a867bcd2 100644 --- a/tests/agent_suite/kiro_agent_test.rs +++ b/tests/agent_suite/kiro_agent_test.rs @@ -102,6 +102,7 @@ fn assert_hook( fn test_install_creates_global_mcp_steering_agent_and_default() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); KiroIntegration.install(&ctx).unwrap(); @@ -293,6 +294,7 @@ async fn test_install_preserves_user_managed_agent_while_updating_skill_index() fn test_install_preserves_existing_mcp_config_and_writes_backup() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let mcp_path = home.join(".kiro/settings/mcp.json"); std::fs::create_dir_all(mcp_path.parent().unwrap()).unwrap(); std::fs::write( @@ -318,6 +320,7 @@ fn test_install_preserves_existing_mcp_config_and_writes_backup() { fn test_install_and_uninstall_preserve_existing_steering_content() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); let user_steering_path = home.join(".kiro/steering/team.md"); @@ -352,6 +355,7 @@ fn test_install_and_uninstall_preserve_existing_steering_content() { fn test_uninstall_preserves_user_steering_after_tracedecay_block() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); KiroIntegration.install(&ctx).unwrap(); @@ -381,6 +385,7 @@ fn test_uninstall_preserves_user_steering_after_tracedecay_block() { fn test_install_replaces_legacy_marker_block_and_uninstall_removes_it() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); let steering_path = home.join(".kiro/steering/tracedecay.md"); @@ -428,6 +433,7 @@ fn test_install_replaces_legacy_marker_block_and_uninstall_removes_it() { fn test_uninstall_removes_legacy_marker_block_preserving_user_content() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); let steering_path = home.join(".kiro/steering/tracedecay.md"); @@ -454,6 +460,7 @@ fn test_uninstall_removes_legacy_marker_block_preserving_user_content() { fn test_uninstall_removes_tracedecay_and_preserves_other_mcp_servers() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); let mcp_path = home.join(".kiro/settings/mcp.json"); @@ -487,6 +494,7 @@ fn test_uninstall_removes_tracedecay_and_preserves_other_mcp_servers() { fn test_install_and_uninstall_preserve_user_managed_custom_agent() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); let agent_path = home.join(".kiro/agents/tracedecay.json"); @@ -525,6 +533,7 @@ fn test_install_and_uninstall_preserve_user_managed_custom_agent() { fn test_install_preserves_existing_custom_default_agent_choice() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); let cli_path = home.join(".kiro/settings/cli.json"); @@ -547,6 +556,7 @@ fn test_install_preserves_existing_custom_default_agent_choice() { fn test_install_replaces_builtin_default_agent_choice() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); let cli_path = home.join(".kiro/settings/cli.json"); @@ -563,6 +573,7 @@ fn test_install_replaces_builtin_default_agent_choice() { fn test_has_tracedecay_tracks_global_mcp_entry() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); assert!(!KiroIntegration.has_tracedecay(home)); @@ -578,6 +589,7 @@ fn test_has_tracedecay_tracks_global_mcp_entry() { fn test_healthcheck_clean_install_has_no_issues_or_warnings() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); KiroIntegration.install(&ctx).unwrap(); @@ -597,6 +609,7 @@ fn test_healthcheck_clean_install_has_no_issues_or_warnings() { fn test_healthcheck_fails_when_steering_lacks_owned_end_marker() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); KiroIntegration.install(&ctx).unwrap(); @@ -625,6 +638,7 @@ fn test_healthcheck_fails_when_steering_lacks_owned_end_marker() { fn test_healthcheck_warns_when_agent_tool_policy_is_not_permissive() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); KiroIntegration.install(&ctx).unwrap(); @@ -657,6 +671,7 @@ fn test_healthcheck_fails_when_workspace_mcp_disables_tracedecay() { let home_dir = TempDir::new().unwrap(); let project_dir = TempDir::new().unwrap(); let home = home_dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let project = project_dir.path(); let ctx = make_ctx(home); @@ -688,6 +703,7 @@ fn test_healthcheck_fails_when_workspace_mcp_shadows_global_command() { let home_dir = TempDir::new().unwrap(); let project_dir = TempDir::new().unwrap(); let home = home_dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let project = project_dir.path(); let ctx = make_ctx(home); diff --git a/tests/agent_suite/opencode_agent_test.rs b/tests/agent_suite/opencode_agent_test.rs index ea45b763..16c1813f 100644 --- a/tests/agent_suite/opencode_agent_test.rs +++ b/tests/agent_suite/opencode_agent_test.rs @@ -47,6 +47,7 @@ fn opencode_prompt_path(home: &Path) -> std::path::PathBuf { fn test_install_creates_opencode_json() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); @@ -79,6 +80,7 @@ fn test_install_creates_opencode_json() { fn test_install_creates_opencode_md_with_rules() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); @@ -100,6 +102,7 @@ fn test_install_creates_opencode_md_with_rules() { fn test_install_preserves_existing_opencode_json() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Pre-populate opencode.json with existing content let config_path = opencode_config_path(home); @@ -133,6 +136,7 @@ fn test_install_preserves_existing_opencode_json() { fn test_install_idempotent_opencode_json() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); @@ -148,6 +152,7 @@ fn test_install_idempotent_opencode_json() { fn test_install_idempotent_opencode_md() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); @@ -167,6 +172,7 @@ fn test_install_idempotent_opencode_md() { fn test_install_preserves_existing_opencode_md_content() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Create AGENTS.md with pre-existing content let config_dir = home.join(".config/opencode"); @@ -199,6 +205,7 @@ fn test_install_preserves_existing_opencode_md_content() { fn test_uninstall_removes_mcp_from_config() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); @@ -220,6 +227,7 @@ fn test_uninstall_removes_mcp_from_config() { fn test_uninstall_removes_empty_config_file() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); @@ -237,6 +245,7 @@ fn test_uninstall_removes_empty_config_file() { fn test_uninstall_preserves_other_mcp_servers() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Pre-populate with another server let config_path = opencode_config_path(home); @@ -271,6 +280,7 @@ fn test_uninstall_preserves_other_mcp_servers() { fn test_uninstall_removes_opencode_md_rules() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); @@ -293,6 +303,7 @@ fn test_uninstall_removes_opencode_md_rules() { fn test_uninstall_preserves_other_opencode_md_content() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Create AGENTS.md with pre-existing content let config_dir = home.join(".config/opencode"); @@ -324,6 +335,7 @@ fn test_uninstall_preserves_other_opencode_md_content() { fn test_uninstall_without_install_does_not_crash() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); // Should not panic or error OpenCodeIntegration.uninstall(&ctx).unwrap(); @@ -333,6 +345,7 @@ fn test_uninstall_without_install_does_not_crash() { fn test_uninstall_config_with_no_tracedecay_is_noop() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); // Create opencode.json without tracedecay let config_path = opencode_config_path(home); @@ -359,6 +372,7 @@ fn test_uninstall_config_with_no_tracedecay_is_noop() { fn test_healthcheck_clean_install_no_issues() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); @@ -445,6 +459,7 @@ fn test_healthcheck_detects_missing_serve_arg() { fn test_healthcheck_detects_missing_opencode_md() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); @@ -468,6 +483,7 @@ fn test_healthcheck_detects_missing_opencode_md() { fn test_healthcheck_detects_missing_tracedecay_rules_in_opencode_md() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); @@ -530,6 +546,7 @@ fn test_has_tracedecay_before_install() { fn test_has_tracedecay_after_install() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); assert!( @@ -542,6 +559,7 @@ fn test_has_tracedecay_after_install() { fn test_has_tracedecay_after_uninstall() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_ctx(home); OpenCodeIntegration.install(&ctx).unwrap(); OpenCodeIntegration.uninstall(&ctx).unwrap(); diff --git a/tests/agent_suite/plugin_skill_contract_test.rs b/tests/agent_suite/plugin_skill_contract_test.rs index 33ae4de2..cf811799 100644 --- a/tests/agent_suite/plugin_skill_contract_test.rs +++ b/tests/agent_suite/plugin_skill_contract_test.rs @@ -5,14 +5,12 @@ use std::path::Path; -use crate::common::{EnvVarGuard, PROCESS_ENV_LOCK}; use crate::plugin_validation_support::{ is_kebab_case_skill_name, load_skill_docs, load_skill_docs_from, relative_files_under, repo_path, SkillDoc, }; use tempfile::TempDir; use tracedecay::agents::{expected_tool_perms, get_integration, InstallContext}; -use tracedecay::config::USER_DATA_DIR_ENV; const CODEX_SKILL_ROOT: &str = "plugin/skills"; const MAX_BUNDLED_SKILL_METADATA_CHARS: usize = 6_000; @@ -45,9 +43,8 @@ fn codex_plugin_skills_match_codex_skill_creator_quick_validate_rules() { #[test] fn generated_codex_plugin_skills_are_byte_copies_of_the_source_bundle() { - let _env_lock = install_env_lock(); let home = TempDir::new().expect("temp home"); - let _data_dir_guard = pinned_profile_storage(home.path()); + let _agent_env = crate::common::AgentEnvLock::pin(home.path()); let codex = get_integration("codex").expect("codex integration"); codex .install(&install_ctx(home.path())) @@ -82,9 +79,8 @@ fn cursor_plugin_skills_match_cursor_skill_contract() { #[test] fn generated_cursor_plugin_skills_are_byte_copies_of_the_source_bundle() { - let _env_lock = install_env_lock(); let home = TempDir::new().expect("temp home"); - let _data_dir_guard = pinned_profile_storage(home.path()); + let _agent_env = crate::common::AgentEnvLock::pin(home.path()); let cursor = get_integration("cursor").expect("cursor integration"); cursor .install(&install_ctx(home.path())) @@ -118,17 +114,6 @@ fn produced_plugin_skills_meet_the_metadata_budget_and_openai_contract() { } } -fn install_env_lock() -> tokio::sync::MutexGuard<'static, ()> { - PROCESS_ENV_LOCK.blocking_lock() -} - -/// Pins TraceDecay profile storage to the temp home so an ambient -/// `TRACEDECAY_DATA_DIR` with active managed skills cannot leak an -/// `agent-managed` overlay into the generated bundle. -fn pinned_profile_storage(home: &Path) -> EnvVarGuard { - EnvVarGuard::set(USER_DATA_DIR_ENV, home.join(".tracedecay")) -} - fn install_ctx(home: &Path) -> InstallContext { InstallContext { home: home.to_path_buf(), diff --git a/tests/agent_suite/update_plugin_test.rs b/tests/agent_suite/update_plugin_test.rs index 78f52ca2..2485c848 100644 --- a/tests/agent_suite/update_plugin_test.rs +++ b/tests/agent_suite/update_plugin_test.rs @@ -14,18 +14,12 @@ use serde_json::json; use tempfile::TempDir; use tracedecay::agents::{get_integration, InstallContext, UpdatePluginOutcome}; -use crate::common::{EnvVarGuard, PROCESS_ENV_LOCK}; +use crate::common::{AgentEnvLock, EnvVarGuard}; use crate::plugin_validation_support::{assert_schema_valid, compile_schema, relative_files_under}; const OLD_BIN: &str = "/old/bin/tracedecay"; const NEW_BIN: &str = "/new/bin/tracedecay"; -fn hermes_env_guard() -> (tokio::sync::MutexGuard<'static, ()>, EnvVarGuard) { - let lock = PROCESS_ENV_LOCK.blocking_lock(); - let guard = EnvVarGuard::unset("HERMES_HOME"); - (lock, guard) -} - fn ctx(home: &Path, tracedecay_bin: &str) -> InstallContext { InstallContext { home: home.to_path_buf(), @@ -160,8 +154,9 @@ fn write_retired_codex_skill(plugin_dir: &Path, name: &str) { #[test] fn hermes_update_plugin_refreshes_all_profiles_without_touching_config() { - let (_env_lock, _hermes_home) = hermes_env_guard(); let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(home.path()); + let _hermes_home = EnvVarGuard::unset("HERMES_HOME"); let project = TempDir::new().unwrap(); let hermes = get_integration("hermes").unwrap(); @@ -227,8 +222,9 @@ fn hermes_update_plugin_refreshes_all_profiles_without_touching_config() { #[test] fn hermes_update_plugin_succeeds_where_a_config_rewrite_would_refuse() { - let (_env_lock, _hermes_home) = hermes_env_guard(); let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(home.path()); + let _hermes_home = EnvVarGuard::unset("HERMES_HOME"); let hermes = get_integration("hermes").unwrap(); hermes.install(&ctx(home.path(), OLD_BIN)).unwrap(); @@ -252,8 +248,9 @@ fn hermes_update_plugin_succeeds_where_a_config_rewrite_would_refuse() { #[test] fn hermes_update_plugin_reports_not_installed_when_nothing_is_detected() { - let (_env_lock, _hermes_home) = hermes_env_guard(); let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(home.path()); + let _hermes_home = EnvVarGuard::unset("HERMES_HOME"); // A Hermes home without a generated plugin must not be installed into. std::fs::create_dir_all(home.path().join(".hermes")).unwrap(); let hermes = get_integration("hermes").unwrap(); @@ -270,6 +267,7 @@ fn hermes_update_plugin_reports_not_installed_when_nothing_is_detected() { #[test] fn cursor_update_plugin_refreshes_bundle_and_preserves_user_config() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let cursor = get_integration("cursor").unwrap(); // User-owned Cursor config that update-plugin must never write. @@ -336,6 +334,7 @@ fn cursor_update_plugin_refreshes_bundle_and_preserves_user_config() { #[test] fn cursor_update_plugin_reports_not_installed_without_a_bundle() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); std::fs::create_dir_all(home.path().join(".cursor")).unwrap(); let cursor = get_integration("cursor").unwrap(); let outcome = cursor.update_plugin(&ctx(home.path(), NEW_BIN)).unwrap(); @@ -346,6 +345,7 @@ fn cursor_update_plugin_reports_not_installed_without_a_bundle() { #[test] fn claude_update_plugin_refreshes_bundle_and_preserves_user_config() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let claude = get_integration("claude").unwrap(); // User-owned Claude config that update-plugin must never destroy. @@ -452,6 +452,7 @@ fn claude_update_plugin_writes_plugin_permissions_and_refreshes_claude_md() { #[test] fn claude_update_plugin_reports_not_installed_without_a_bundle() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); std::fs::create_dir_all(home.path().join(".claude")).unwrap(); let claude = get_integration("claude").unwrap(); let outcome = claude.update_plugin(&ctx(home.path(), NEW_BIN)).unwrap(); @@ -469,6 +470,7 @@ fn claude_update_plugin_reports_not_installed_without_a_bundle() { #[test] fn codex_update_plugin_refreshes_bundle_without_touching_config() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let project_root = home.path().join("workspace"); let codex = get_integration("codex").unwrap(); codex.install(&ctx(home.path(), OLD_BIN)).unwrap(); @@ -522,6 +524,7 @@ fn codex_update_plugin_refreshes_bundle_without_touching_config() { #[test] fn codex_update_plugin_refreshes_cache_and_keeps_bootstrap_source_listable() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let project_root = home.path().join("workspace"); let stale_plugin_dir = home .path() @@ -567,6 +570,7 @@ fn codex_update_plugin_refreshes_cache_and_keeps_bootstrap_source_listable() { #[test] fn codex_update_plugin_recreates_bootstrap_source_from_cache_only_state() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let project_root = home.path().join("workspace"); let cached_plugin_dir = codex_cached_plugin_dir(home.path()); let bootstrap_dir = codex_bootstrap_dir(home.path()); @@ -601,6 +605,7 @@ fn codex_update_plugin_recreates_bootstrap_source_from_cache_only_state() { #[test] fn codex_update_plugin_sweeps_legacy_config_when_cache_exists() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let project_root = home.path().join("workspace"); let cached_plugin_dir = codex_cached_plugin_dir(home.path()); let legacy_config = write_codex_legacy_config(home.path()); @@ -626,6 +631,7 @@ fn codex_update_plugin_sweeps_legacy_config_when_cache_exists() { #[test] fn codex_update_plugin_refreshes_global_cache_and_repo_local_bundle() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let project = TempDir::new().unwrap(); let cached_plugin_dir = codex_cached_plugin_dir(home.path()); write_codex_plugin_manifest(&cached_plugin_dir, "0.0.0"); @@ -663,6 +669,7 @@ fn codex_update_plugin_refreshes_global_cache_and_repo_local_bundle() { #[test] fn codex_update_plugin_repairs_personal_marketplace_for_bootstrap_bundle() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let project_root = home.path().join("workspace"); let plugin_dir = codex_bootstrap_dir(home.path()); write_codex_plugin_manifest(&plugin_dir, "0.0.0"); @@ -683,6 +690,7 @@ fn codex_update_plugin_repairs_personal_marketplace_for_bootstrap_bundle() { #[test] fn codex_update_plugin_refreshes_repo_local_bundle_from_project_root() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let project = TempDir::new().unwrap(); let codex = get_integration("codex").unwrap(); codex @@ -707,6 +715,7 @@ fn codex_update_plugin_refreshes_repo_local_bundle_from_project_root() { #[test] fn codex_uninstall_removes_repo_local_bundle_from_project_root() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let project = TempDir::new().unwrap(); let codex = get_integration("codex").unwrap(); let install_ctx = ctx_with_project(home.path(), OLD_BIN, project.path()); @@ -725,6 +734,7 @@ fn codex_uninstall_removes_repo_local_bundle_from_project_root() { #[test] fn codex_update_plugin_migrates_legacy_config_only_install_to_plugin() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let project_root = home.path().join("workspace"); let legacy_config = write_codex_legacy_config(home.path()); let codex = get_integration("codex").unwrap(); @@ -750,6 +760,7 @@ fn codex_update_plugin_migrates_legacy_config_only_install_to_plugin() { #[test] fn codex_update_plugin_migrates_legacy_config_even_when_repo_bundle_refreshes() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let project = TempDir::new().unwrap(); let codex = get_integration("codex").unwrap(); // A repo-local bundle exists (so the refresh list is non-empty) alongside @@ -788,6 +799,7 @@ fn codex_update_plugin_migrates_legacy_config_even_when_repo_bundle_refreshes() #[test] fn codex_update_plugin_reports_not_installed_without_bundle_or_legacy_config() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let project_root = home.path().join("workspace"); std::fs::create_dir_all(home.path().join(".codex")).unwrap(); let codex = get_integration("codex").unwrap(); @@ -805,6 +817,7 @@ fn codex_update_plugin_reports_not_installed_without_bundle_or_legacy_config() { #[test] fn kiro_update_plugin_rebakes_managed_agent_and_preserves_configs() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let kiro = get_integration("kiro").unwrap(); kiro.install(&ctx(home.path(), OLD_BIN)).unwrap(); @@ -840,6 +853,7 @@ fn kiro_update_plugin_rebakes_managed_agent_and_preserves_configs() { #[test] fn kiro_update_plugin_leaves_user_managed_agent_files_alone() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let kiro = get_integration("kiro").unwrap(); let agent_file = home.path().join(".kiro/agents/tracedecay.json"); @@ -1216,6 +1230,7 @@ fn assert_codex_rendered_bundle_valid(plugin_dir: &Path, bin: &str, scope: Codex #[test] fn cursor_install_renders_structurally_valid_bundle() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let cursor = get_integration("cursor").unwrap(); cursor.install(&ctx(home.path(), NEW_BIN)).unwrap(); assert_cursor_rendered_bundle_valid( @@ -1227,6 +1242,7 @@ fn cursor_install_renders_structurally_valid_bundle() { #[test] fn cursor_update_plugin_rerenders_structurally_valid_bundle() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let cursor = get_integration("cursor").unwrap(); cursor.install(&ctx(home.path(), OLD_BIN)).unwrap(); @@ -1241,6 +1257,7 @@ fn cursor_update_plugin_rerenders_structurally_valid_bundle() { #[test] fn codex_install_renders_structurally_valid_bundle() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let codex = get_integration("codex").unwrap(); codex.install(&ctx(home.path(), NEW_BIN)).unwrap(); @@ -1260,6 +1277,7 @@ fn codex_install_renders_structurally_valid_bundle() { #[test] fn codex_local_install_renders_project_scoped_mcp() { let home = TempDir::new().unwrap(); + let _agent_env = AgentEnvLock::pin(&home); let project = TempDir::new().unwrap(); let codex = get_integration("codex").unwrap(); codex diff --git a/tests/agent_suite/upgrade_refresh_test.rs b/tests/agent_suite/upgrade_refresh_test.rs index f5b72ef8..1b6553f6 100644 --- a/tests/agent_suite/upgrade_refresh_test.rs +++ b/tests/agent_suite/upgrade_refresh_test.rs @@ -24,6 +24,7 @@ fn make_install_ctx(home: &Path) -> InstallContext { fn claude_json_upgrade_refresh_is_idempotent_and_preserves_unknown_keys() { let dir = TempDir::new().unwrap(); let home = dir.path(); + let _agent_env = crate::common::AgentEnvLock::pin(home); let ctx = make_install_ctx(home); let claude_json = home.join(".claude.json"); diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8a1995f5..8d3a9de5 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -67,6 +67,16 @@ pub const GLOBAL_DB_ENV: &str = "TRACEDECAY_GLOBAL_DB"; /// a test needs finer-grained control over which env vars it swaps. pub static GLOBAL_DB_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +/// Acquires `mutex`, recovering the guard even when a prior holder panicked. +pub fn lock_recovering_poison(mutex: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|err| err.into_inner()) +} + +/// Serializes tests that pin [`GLOBAL_DB_ENV`], tolerating a poisoned lock. +pub fn lock_global_db_env() -> std::sync::MutexGuard<'static, ()> { + lock_recovering_poison(&GLOBAL_DB_ENV_LOCK) +} + /// Serializes [`IsolatedEnv`] users within one test binary: storage isolation /// swaps process-wide env vars (`HOME`, `TRACEDECAY_DATA_DIR`, ...), so tests /// must not overlap. @@ -193,6 +203,30 @@ pub fn isolated_tracedecay_storage(tmp: &TempDir) -> TraceDecayStorageEnvGuard { TraceDecayStorageEnvGuard::for_tempdir(tmp) } +/// Serializes in-process agent install/uninstall tests and pins +/// [`USER_DATA_DIR_ENV`] to that test's home. +/// +/// Without this, concurrent `cargo test` cases can point each other at the same +/// managed-skill target file and race during atomic rewrites. Field order keeps +/// the env pin alive until just before the lock is released. +pub struct AgentEnvLock { + _pin: EnvVarGuard, + _lock: tokio::sync::MutexGuard<'static, ()>, +} + +impl AgentEnvLock { + /// Pins [`USER_DATA_DIR_ENV`] to `/.tracedecay` while holding + /// [`PROCESS_ENV_LOCK`]. + pub fn pin(home: impl AsRef) -> Self { + let lock = PROCESS_ENV_LOCK.blocking_lock(); + let pin = EnvVarGuard::set(USER_DATA_DIR_ENV, home.as_ref().join(".tracedecay")); + Self { + _pin: pin, + _lock: lock, + } + } +} + fn canonicalize_test_dir(path: &Path) -> PathBuf { fs::create_dir_all(path).unwrap_or_else(|err| { panic!( diff --git a/tests/hooks_lsp_suite/hooks_test.rs b/tests/hooks_lsp_suite/hooks_test.rs index cef032c1..88dc2f24 100644 --- a/tests/hooks_lsp_suite/hooks_test.rs +++ b/tests/hooks_lsp_suite/hooks_test.rs @@ -1,4 +1,4 @@ -use crate::common::{EnvVarGuard, GLOBAL_DB_ENV, GLOBAL_DB_ENV_LOCK}; +use crate::common::{lock_global_db_env, lock_recovering_poison, EnvVarGuard, GLOBAL_DB_ENV}; use std::path::Path; use tracedecay::config::USER_DATA_DIR_ENV; use tracedecay::hooks::{ @@ -392,9 +392,7 @@ fn test_cursor_post_tool_use_hints_for_single_file_read() { #[test] fn test_cursor_post_tool_use_dedupes_hints_per_session() { let dir = tempfile::tempdir().unwrap(); - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|err| err.into_inner()); + let _env_lock = lock_global_db_env(); let project_root = dir.path().canonicalize().unwrap(); let profile_root = project_root.join("profile"); let _env_guards = hook_profile_env(&project_root, &profile_root); @@ -451,9 +449,7 @@ fn test_cursor_post_tool_use_dedupes_hints_per_session() { #[test] fn test_cursor_post_tool_use_records_hint_analytics_for_emitted_duplicate_and_missing_session() { let dir = tempfile::tempdir().unwrap(); - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|err| err.into_inner()); + let _env_lock = lock_global_db_env(); let project_root = dir.path().canonicalize().unwrap(); let profile_root = project_root.join("profile"); let _env_guards = hook_profile_env(&project_root, &profile_root); @@ -534,9 +530,7 @@ fn test_cursor_post_tool_use_decision_silent_without_index() { #[test] fn test_cursor_post_tool_use_records_uninitialized_suppression() { let dir = tempfile::tempdir().unwrap(); - let _env_lock = GLOBAL_DB_ENV_LOCK - .lock() - .unwrap_or_else(|err| err.into_inner()); + let _env_lock = lock_global_db_env(); let project_root = dir.path().canonicalize().unwrap(); let profile_root = project_root.join("profile"); let _env_guards = hook_profile_env(&project_root, &profile_root); @@ -959,7 +953,7 @@ async fn test_codex_user_prompt_submit_generic_workspace_suppresses_code_hints() // hook context generation resolves profile storage and records analytics. #[allow(clippy::await_holding_lock)] async fn test_codex_user_prompt_submit_records_workspace_status_and_missing_session_hint() { - let _lock = GLOBAL_DB_ENV_LOCK.lock().unwrap(); + let _lock = lock_global_db_env(); let project = tempfile::tempdir().unwrap(); let generic = tempfile::tempdir().unwrap(); let profile = tempfile::tempdir().unwrap(); @@ -1040,7 +1034,7 @@ fn test_codex_workspace_status_distinguishes_generic_and_project_like_dirs() { #[test] fn test_codex_workspace_status_detects_initialized_trace_decay_project() { - let _lock = GLOBAL_DB_ENV_LOCK.lock().unwrap(); + let _lock = lock_global_db_env(); let project = tempfile::tempdir().unwrap(); let profile = tempfile::tempdir().unwrap(); let project_root = project.path().canonicalize().unwrap(); @@ -1346,7 +1340,7 @@ fn test_codex_subagent_start_injects_context_for_new_no_history_agent() { #[test] fn test_codex_subagent_start_dedupes_context_per_session() { - let _lock = GLOBAL_DB_ENV_LOCK.lock().unwrap(); + let _lock = lock_global_db_env(); let project = tempfile::tempdir().unwrap(); let profile = tempfile::tempdir().unwrap(); let project_root = project.path().canonicalize().unwrap(); @@ -1374,7 +1368,7 @@ fn test_codex_subagent_start_dedupes_context_per_session() { #[test] fn test_codex_subagent_start_no_history_does_not_suppress_later_research_context() { - let _lock = GLOBAL_DB_ENV_LOCK.lock().unwrap(); + let _lock = lock_global_db_env(); let project = tempfile::tempdir().unwrap(); let profile = tempfile::tempdir().unwrap(); let project_root = project.path().canonicalize().unwrap(); @@ -1416,7 +1410,7 @@ fn test_codex_subagent_start_no_history_does_not_suppress_later_research_context #[test] fn test_codex_subagent_start_counts_and_formats_log_line() { - let _lock = GLOBAL_DB_ENV_LOCK.lock().unwrap(); + let _lock = lock_global_db_env(); let project = tempfile::tempdir().unwrap(); let profile = tempfile::tempdir().unwrap(); let project_root = project.path().canonicalize().unwrap(); @@ -1521,3 +1515,28 @@ fn test_codex_project_root_uses_cwd() { Some(dir.path().to_path_buf()) ); } + +/// Regression guard for tolerant recovery from a poisoned env lock. +#[test] +fn poisoned_env_lock_is_recovered_by_tolerant_acquire() { + use std::sync::Mutex; + + static LOCK: Mutex<()> = Mutex::new(()); + + let poisoned = std::panic::catch_unwind(|| { + let _guard = LOCK.lock().unwrap(); + panic!("simulated panic while holding the env lock"); + }); + assert!(poisoned.is_err(), "the injected closure must have panicked"); + assert!( + LOCK.is_poisoned(), + "a panic while holding the guard must poison the mutex" + ); + + assert!( + LOCK.lock().is_err(), + "poisoned lock must surface Err to a plain lock()/unwrap() caller" + ); + + let _recovered = lock_recovering_poison(&LOCK); +} diff --git a/tests/mcp_suite/git_correlation_test.rs b/tests/mcp_suite/git_correlation_test.rs index 4c6eeb76..8ce8621a 100644 --- a/tests/mcp_suite/git_correlation_test.rs +++ b/tests/mcp_suite/git_correlation_test.rs @@ -146,6 +146,9 @@ async fn sessions_for_and_scoped_search_end_to_end() { let profile_root = base.join("profile"); std::fs::create_dir_all(&profile_root).unwrap_or_else(|e| panic!("create profile root: {e}")); + let profile_root = profile_root + .canonicalize() + .unwrap_or_else(|e| panic!("canonicalize profile root: {e}")); let cg = TraceDecay::init_with_options( &project_root, TraceDecayOpenOptions { diff --git a/tests/storage_suite/corruption_test.rs b/tests/storage_suite/corruption_test.rs index b7272053..0a268c47 100644 --- a/tests/storage_suite/corruption_test.rs +++ b/tests/storage_suite/corruption_test.rs @@ -206,7 +206,7 @@ async fn bulk_load_preserves_platform_synchronous_mode() { // the *durable* platform synchronous mode, which // TRACEDECAY_SQLITE_UNSAFE_FAST=1 (exported for the whole Windows CI test // run) would relax to OFF. - let _env_lock = common::GLOBAL_DB_ENV_LOCK.lock().unwrap(); + let _env_lock = common::lock_global_db_env(); let _unsafe_fast_off = common::EnvVarGuard::unset(tracedecay::db::SQLITE_UNSAFE_FAST_ENV); let (db, _dir, _path) = setup_db().await;