From 7f2e33d54fc1fd51062159e6c4db72139bfdcff2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 1 Jul 2026 23:14:55 +0000 Subject: [PATCH 1/2] perf: trim remaining Windows CI test hotspots Follow-up to #174 targeting the remaining slow Windows tests, which are subprocess-spawn dominated: - Consolidate sequential python3 invocations in hermes plugin/bridge tests and cache generated read-only artifacts per process. - Tighten fake codex app-server and LSP fixture timeouts/polling; skip the taskkill spawn for already-exited children on Windows. - Replace git subprocess spawns with in-process gix equivalents for current-branch, branch-exists, rev distance, remote URL, worktree root, and common-dir lookups, with a cheap .git-ancestor pre-flight before any remaining git fallback. - Compile tiktoken-rs/regex/base64 at opt-level 2 in dev builds and split the BPE vocabulary unit test so each process pays one model load. - Reuse the cached empty-schema store template in dashboard LCM fixes and memory eval fixtures. --- Cargo.toml | 24 ++- src/branch.rs | 92 ++++++++--- src/dashboard/token_count.rs | 10 ++ src/sessions/codex_app_server.rs | 6 + src/tracedecay.rs | 13 ++ src/worktree.rs | 33 ++++ tests/agent_test.rs | 105 +++++++++---- tests/automation_backend_test.rs | 60 +++++--- tests/dashboard_lcm_fixes_test.rs | 8 +- tests/hermes_lcm_bridge_test.rs | 237 ++++++++++++++--------------- tests/lsp_code_diagnostics_test.rs | 13 +- tests/memory_eval_test.rs | 8 +- 12 files changed, 400 insertions(+), 209 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5c378442..41efc792 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -163,7 +163,7 @@ harness = false # cargo's OPT_LEVEL), and nearly every test creates databases and runs many # queries — unoptimized SQLite dominates test runtime, worst on Windows CI. # The `test` profile inherits these dev package overrides. Deliberately -# scoped to the libsql layers only; do not blanket-override "*". +# scoped to the libsql and tokenizer layers only; do not blanket-override "*". [profile.dev.package.libsql-ffi] opt-level = 2 @@ -172,3 +172,25 @@ opt-level = 2 [profile.dev.package.libsql-rusqlite] opt-level = 2 + +# The first token count pays a one-time BPE model load (embedded-vocabulary +# base64 decode + BPE regex compile) that costs seconds in unoptimized +# builds; the dashboard token-count warm task and its tests hit it on every +# test-process start. +[profile.dev.package.tiktoken-rs] +opt-level = 2 + +[profile.dev.package.base64] +opt-level = 2 + +[profile.dev.package.fancy-regex] +opt-level = 2 + +[profile.dev.package.regex] +opt-level = 2 + +[profile.dev.package.regex-automata] +opt-level = 2 + +[profile.dev.package.regex-syntax] +opt-level = 2 diff --git a/src/branch.rs b/src/branch.rs index f2c866e5..8cbce89f 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -10,10 +10,18 @@ use crate::branch_meta::BranchMeta; /// /// Returns `None` for detached HEAD or if the repository cannot be opened. pub fn current_branch(project_root: &Path) -> Option { - if let Some(branch) = current_branch_gix(project_root) { - return Some(branch); + match current_branch_gix(project_root) { + GixHead::Branch(branch) => Some(branch), + // A readable repo answered with a detached HEAD; `git symbolic-ref` + // would fail the same way, so don't spawn it. + GixHead::Detached => None, + GixHead::Unavailable => { + if !crate::worktree::git_may_resolve_repo(project_root) { + return None; + } + current_branch_git(project_root) + } } - current_branch_git(project_root) } /// Returns true if `branch` exists as a local `refs/heads/*` branch. @@ -21,32 +29,53 @@ pub fn local_branch_exists(project_root: &Path, branch: &str) -> bool { if branch.is_empty() { return false; } + let refname = format!("refs/heads/{branch}"); if let Ok(repo) = gix::open(project_root) { - let refname = format!("refs/heads/{branch}"); - if repo.find_reference(&refname).is_ok() { - return true; - } + // gix reads loose and packed refs, the same sources `git show-ref` + // consults; trust its answer instead of paying a subprocess spawn + // (~100-300ms on Windows) to re-ask git. + return repo.find_reference(&refname).is_ok(); + } + if !crate::worktree::git_may_resolve_repo(project_root) { + return false; } std::process::Command::new("git") - .args([ - "show-ref", - "--verify", - "--quiet", - &format!("refs/heads/{branch}"), - ]) + .args(["show-ref", "--verify", "--quiet", &refname]) .current_dir(project_root) .status() .is_ok_and(|status| status.success()) } -fn current_branch_gix(project_root: &Path) -> Option { - let repo = gix::open(project_root).ok()?; - let head = repo.head().ok()?; - let name = head.name().as_bstr(); - let name_str = std::str::from_utf8(name).ok()?; - name_str - .strip_prefix("refs/heads/") - .map(std::string::ToString::to_string) +/// What gix could learn about HEAD without spawning `git`. +enum GixHead { + /// HEAD points at a local branch. + Branch(String), + /// A readable repo whose HEAD is detached (or on a non-branch ref). + Detached, + /// No repo could be opened at this path or its HEAD was unreadable; + /// the `git` subprocess fallback should decide. + Unavailable, +} + +fn current_branch_gix(project_root: &Path) -> GixHead { + let Ok(repo) = gix::open(project_root) else { + return GixHead::Unavailable; + }; + let Ok(head) = repo.head() else { + return GixHead::Unavailable; + }; + // `Head::name()` is always the literal "HEAD"; the branch HEAD points + // to (if any) is the referent. + let Some(name) = head.referent_name() else { + return GixHead::Detached; + }; + let Ok(name_str) = std::str::from_utf8(name.as_bstr()) else { + return GixHead::Unavailable; + }; + match name_str.strip_prefix("refs/heads/") { + Some(branch) => GixHead::Branch(branch.to_string()), + None => GixHead::Detached, + } } fn current_branch_git(project_root: &Path) -> Option { @@ -80,6 +109,23 @@ fn git_rev_list_count(project_root: &Path, from_ref: &str, to_ref: &str) -> Opti .ok() } +/// In-process equivalent of `git rev-list --count hidden..tip`: commits +/// reachable from `tip` but not from `hidden`. Saves a `git` subprocess +/// spawn (~100-300ms on Windows) on every branch-add parent ranking. +fn gix_rev_distance( + repo: &gix::Repository, + tip: gix::ObjectId, + hidden: gix::ObjectId, +) -> Option { + let walk = repo.rev_walk([tip]).with_hidden([hidden]).all().ok()?; + let mut count = 0_usize; + for info in walk { + info.ok()?; + count += 1; + } + Some(count) +} + /// Auto-detects the default branch (main or master). /// /// Strategy: @@ -259,7 +305,9 @@ pub fn find_nearest_tracked_ancestor( // branch. Rank them by commit distance so a direct parent wins even // when multiple merge-bases land in the same timestamp second. if base_id == tracked_commit.id { - if let Some(distance) = git_rev_list_count(project_root, &tracked_ref, &branch_ref) { + let distance = gix_rev_distance(&repo, branch_commit.id, tracked_commit.id) + .or_else(|| git_rev_list_count(project_root, &tracked_ref, &branch_ref)); + if let Some(distance) = distance { let replace = best_ancestor .as_ref() .is_none_or(|(_, best_distance, best_time)| { diff --git a/src/dashboard/token_count.rs b/src/dashboard/token_count.rs index acda5373..faf06f4d 100644 --- a/src/dashboard/token_count.rs +++ b/src/dashboard/token_count.rs @@ -504,6 +504,9 @@ mod tests { assert!(!encoder_for_model("opus-large").exact); } + // The two vocabulary tests are split so each test process pays only one + // BPE model load (the dominant cost, especially on Windows) and nextest + // can run them in parallel. #[cfg(feature = "token-counting")] #[test] fn bpe_counts_diverge_from_chars4() { @@ -513,8 +516,15 @@ mod tests { // Code-heavy text tokenizes denser than chars/4 predicts; the exact // value is vocabulary-dependent, so only sanity-bound it. assert!(bpe <= text.len() as i64); + } + + #[cfg(feature = "token-counting")] + #[test] + fn bpe_counts_use_cl100k_for_legacy_models() { + let text = "fn main() { println!(\"hello tokenizer world\"); }"; let cl = count_text_tokens(text, "gpt-4"); assert!(cl > 0); + assert!(cl <= text.len() as i64); } #[tokio::test] diff --git a/src/sessions/codex_app_server.rs b/src/sessions/codex_app_server.rs index fa954006..a30a78ee 100644 --- a/src/sessions/codex_app_server.rs +++ b/src/sessions/codex_app_server.rs @@ -261,6 +261,12 @@ impl Drop for ChildGuard { #[cfg(windows)] fn kill_child_process_tree(child: &mut Child) { + // `cmd /C` shims wait for their grandchildren, so a child that already + // exited has no live process tree left; skip the taskkill spawn + // (~100-300ms) that would fail anyway. + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } let _ = Command::new("taskkill") .arg("/PID") .arg(child.id().to_string()) diff --git a/src/tracedecay.rs b/src/tracedecay.rs index 0523a06f..d04c0ee2 100644 --- a/src/tracedecay.rs +++ b/src/tracedecay.rs @@ -1246,6 +1246,19 @@ fn profile_store_id(project_id: &str) -> String { } fn git_remote_url(project_root: &Path) -> Option { + // gix reads the same config `git config --get` would (repo-local + + // global) without a subprocess spawn (~100-300ms on Windows). + if let Ok(repo) = gix::discover(project_root) { + let url = repo + .config_snapshot() + .string("remote.origin.url")? + .to_string(); + let url = url.trim(); + return (!url.is_empty()).then(|| url.to_string()); + } + if !crate::worktree::git_may_resolve_repo(project_root) { + return None; + } git_output(project_root, &["config", "--get", "remote.origin.url"]) } diff --git a/src/worktree.rs b/src/worktree.rs index ae149c16..2015196c 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -39,6 +39,15 @@ pub struct WorktreeIndexMismatch { /// checkout and each linked worktree report their own distinct directory, /// which is exactly the distinction this module relies on. pub fn git_worktree_root(dir: &Path) -> Option { + // gix discovery walks up the same way `git rev-parse` does but without + // a subprocess spawn (~100-300ms on Windows). A discovered bare repo + // (no workdir) matches `--show-toplevel` failing. + if let Ok(repo) = gix::discover(dir) { + return realpath(repo.workdir()?); + } + if !git_may_resolve_repo(dir) { + return None; + } let trimmed = git_output(dir, &["rev-parse", "--show-toplevel"])?; realpath(Path::new(&trimmed)) } @@ -48,6 +57,18 @@ pub fn git_worktree_root(dir: &Path) -> Option { /// For a linked worktree this is the main checkout's `.git` directory, which is /// the stable local identity all linked worktrees share. pub fn git_common_dir(dir: &Path) -> Option { + if let Ok(repo) = gix::discover(dir) { + let common_dir = repo.common_dir().to_path_buf(); + let resolved = if common_dir.is_absolute() { + common_dir + } else { + dir.join(common_dir) + }; + return Some(resolved.canonicalize().unwrap_or(resolved)); + } + if !git_may_resolve_repo(dir) { + return None; + } let raw = git_output(dir, &["rev-parse", "--git-common-dir"])?; let common_dir = PathBuf::from(raw); let resolved = if common_dir.is_absolute() { @@ -58,6 +79,18 @@ pub fn git_common_dir(dir: &Path) -> Option { Some(resolved.canonicalize().unwrap_or(resolved)) } +/// Cheap pre-flight for the `git` subprocess fallbacks in this crate: `git` +/// can only resolve a repository for `dir` when a `.git` entry exists +/// somewhere in its ancestor chain or the caller overrides discovery via +/// `GIT_DIR`. Spawning `git` costs ~100-300ms on Windows, so callers skip +/// the spawn when it is guaranteed to fail anyway. +pub(crate) fn git_may_resolve_repo(dir: &Path) -> bool { + if std::env::var_os("GIT_DIR").is_some() { + return true; + } + dir.ancestors().any(|p| p.join(".git").exists()) +} + /// Detect when `start_path` lives in one git working tree but the resolved /// tracedecay index (`index_root`) belongs to a *different* working tree. /// diff --git a/tests/agent_test.rs b/tests/agent_test.rs index a02ec314..ec2a073f 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -3,7 +3,7 @@ mod common; use std::path::Path; use std::process::Command; -use common::{pyyaml_shim_pythonpath, EnvVarGuard}; +use common::EnvVarGuard; use tempfile::TempDir; use tracedecay::agents::*; use tracedecay::automation::managed_skills::{ @@ -214,6 +214,51 @@ fn expected_tracedecay_bin() -> String { path_match.unwrap_or_else(|| env!("CARGO_BIN_EXE_tracedecay").replace('\\', "/")) } +/// 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 +/// Windows CI every python launch goes through a .cmd shim and costs ~1s, so +/// folding the compile check into the check script halves those tests' +/// interpreter launches while keeping compile failures attributable. +/// `plugin_dir_expr` is a Python expression evaluating to the plugin dir. +fn python_compile_check(plugin_dir_expr: &str) -> String { + format!( + r#" +import pathlib as _compile_pathlib +import py_compile as _py_compile +import sys as _compile_sys + +for _name in ("tools.py", "schemas.py", "__init__.py"): + try: + _py_compile.compile(str(({plugin_dir_expr}) / _name), doraise=True) + except _py_compile.PyCompileError as _exc: + print(f"generated Python should compile: {{_name}}: {{_exc}}", file=_compile_sys.stderr) + _compile_sys.exit(1) +"# + ) +} + +/// Falls back to the bundled PyYAML shim (argv[2]) only when the interpreter +/// has no importable `yaml`, replacing the separate `python3 -c "import +/// yaml"` probe process that `pyyaml_shim_pythonpath` spawns. Appending to +/// sys.path keeps the precedence identical: a real PyYAML always wins. +const PYYAML_FALLBACK_PRELUDE: &str = r#" +import importlib.util as _yaml_probe_util +import sys as _yaml_probe_sys + +if _yaml_probe_util.find_spec("yaml") is None: + _yaml_probe_sys.path.append(_yaml_probe_sys.argv[2]) +"#; + +/// Writes the PyYAML test shim next to the test home and returns its +/// directory, for scripts using [`PYYAML_FALLBACK_PRELUDE`]. +fn write_pyyaml_shim(scratch: &Path) -> std::path::PathBuf { + let shim_dir = scratch.join("pyyaml-shim"); + std::fs::create_dir_all(&shim_dir).unwrap(); + std::fs::write(shim_dir.join("yaml.py"), common::PYYAML_SHIM).unwrap(); + shim_dir +} + fn assert_python_compiles(paths: &[&Path]) { let output = Command::new("python3") .arg("-m") @@ -1109,16 +1154,13 @@ fn test_hermes_generated_python_handles_quoted_unicode_tracedecay_path() { HermesIntegration.install(&ctx).unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_tools.py"); std::fs::write( &script, - r#" + format!( + "{}\n{}", + python_compile_check("_compile_pathlib.Path(_compile_sys.argv[1]).parent"), + r#" import importlib.util import json import os @@ -1157,7 +1199,8 @@ assert payload["stdout"].startswith("stdout-") assert payload["stderr"].startswith("stderr-") assert payload["stdout"].endswith("...") assert payload["stderr"].endswith("...") -"#, +"# + ), ) .unwrap(); @@ -1183,16 +1226,13 @@ fn test_hermes_generated_python_registers_memory_provider() { .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_memory_provider.py"); std::fs::write( &script, - r#" + format!( + "{}\n{}", + python_compile_check("_compile_pathlib.Path(_compile_sys.argv[1])"), + r#" import importlib import importlib.machinery import importlib.util @@ -1390,7 +1430,8 @@ collector = ProviderCollector() plugin.register(collector) assert collector.provider is not None assert collector.provider.name == "tracedecay" -"#, +"# + ), ) .unwrap(); @@ -1419,7 +1460,9 @@ fn test_hermes_generated_memory_provider_is_discovered_from_active_home() { let script = plugin_dir.join("check_hermes_discovery.py"); std::fs::write( &script, - r#" + format!( + "{PYYAML_FALLBACK_PRELUDE}\n{}", + r#" import abc import importlib.machinery import importlib.util @@ -1548,15 +1591,16 @@ provider.initialize("doctor-session", hermes_home=str(hermes_home), platform="cl assert provider.hermes_home == str(hermes_home) assert "fact_store" in [schema["name"] for schema in provider.get_tool_schemas()] assert "memory_status" in [schema["name"] for schema in provider.get_tool_schemas()] -"#, +"# + ), ) .unwrap(); let mut check = Command::new("python3"); - check.arg(&script).arg(hermes_home); - if let Some(shim_dir) = pyyaml_shim_pythonpath(home.path()) { - check.env("PYTHONPATH", shim_dir); - } + check + .arg(&script) + .arg(hermes_home) + .arg(write_pyyaml_shim(home.path())); let output = check .output() .expect("python3 should run Hermes memory provider discovery check"); @@ -5749,7 +5793,9 @@ fn test_hermes_generated_python_reads_plugins_tracedecay_config_block() { let script = plugin_dir.join("check_config_block.py"); std::fs::write( &script, - r#" + format!( + "{PYYAML_FALLBACK_PRELUDE}\n{}", + r#" import importlib.machinery import importlib.util import json @@ -5840,15 +5886,16 @@ assert plugin._configured_int(attr_engine.config, "fresh_tail_count") == 16 # Engines bound to a different profile home do not inherit this block. other_engine = plugin.TraceDecayContextEngine(hermes_home="/tmp/definitely-missing-hermes-home") assert other_engine.project_root is None -"#, +"# + ), ) .unwrap(); let mut check = Command::new("python3"); - check.arg(&script).arg(&plugin_dir); - if let Some(shim_dir) = pyyaml_shim_pythonpath(home.path()) { - check.env("PYTHONPATH", shim_dir); - } + check + .arg(&script) + .arg(&plugin_dir) + .arg(write_pyyaml_shim(home.path())); let output = check .output() .expect("python3 should run generated Hermes config block check"); diff --git a/tests/automation_backend_test.rs b/tests/automation_backend_test.rs index a64fc1f9..a243e582 100644 --- a/tests/automation_backend_test.rs +++ b/tests/automation_backend_test.rs @@ -1,6 +1,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::Mutex; +#[cfg(target_os = "linux")] use std::thread; use std::time::Duration; @@ -355,17 +356,21 @@ fn codex_app_server_backend_falls_back_to_configured_model_when_server_omits_mod #[test] fn codex_app_server_backend_from_automation_config_forwards_runtime_limits() { - let _env_lock = ENV_LOCK.lock().unwrap(); let fake = FakeCodexAppServer::new_with_behavior("json"); - let _codex_bin = EnvVarGuard::set("TRACEDECAY_CODEX_BIN", &fake.bin); - let backend = CodexAppServerBackend::from_automation_config(&AutomationConfig { - backend: AutomationBackend::CodexAppServer, - model: Some("automation-model".to_string()), - timeout_secs: fake_codex_response_timeout_secs(), - max_tokens: Some(1024), - temperature: Some(0.4), - ..AutomationConfig::default() - }); + // Env vars are only read while the backend is constructed, so hold the + // env lock just for that window instead of across the subprocess run. + let backend = { + let _env_lock = ENV_LOCK.lock().unwrap(); + let _codex_bin = EnvVarGuard::set("TRACEDECAY_CODEX_BIN", &fake.bin); + CodexAppServerBackend::from_automation_config(&AutomationConfig { + backend: AutomationBackend::CodexAppServer, + model: Some("automation-model".to_string()), + timeout_secs: fake_codex_response_timeout_secs(), + max_tokens: Some(1024), + temperature: Some(0.4), + ..AutomationConfig::default() + }) + }; let request = AgentTaskRequest::new( "run_runtime_options".to_string(), AgentTaskKind::SessionReflector, @@ -392,19 +397,23 @@ fn codex_app_server_backend_from_automation_config_forwards_runtime_limits() { #[test] fn codex_app_server_backend_uses_env_runtime_limits_when_config_omits_them() { - let _env_lock = ENV_LOCK.lock().unwrap(); let fake = FakeCodexAppServer::new_with_behavior("json"); - let _codex_bin = EnvVarGuard::set("TRACEDECAY_CODEX_BIN", &fake.bin); - let _max_tokens = EnvVarGuard::set("TRACEDECAY_CODEX_SUMMARY_MAX_TOKENS", "2048"); - let _temperature = EnvVarGuard::set("TRACEDECAY_CODEX_SUMMARY_TEMPERATURE", "0.25"); - let backend = CodexAppServerBackend::from_automation_config(&AutomationConfig { - backend: AutomationBackend::CodexAppServer, - model: Some("automation-model".to_string()), - timeout_secs: fake_codex_response_timeout_secs(), - max_tokens: None, - temperature: None, - ..AutomationConfig::default() - }); + // Env vars are only read while the backend is constructed, so hold the + // env lock just for that window instead of across the subprocess run. + let backend = { + let _env_lock = ENV_LOCK.lock().unwrap(); + let _codex_bin = EnvVarGuard::set("TRACEDECAY_CODEX_BIN", &fake.bin); + let _max_tokens = EnvVarGuard::set("TRACEDECAY_CODEX_SUMMARY_MAX_TOKENS", "2048"); + let _temperature = EnvVarGuard::set("TRACEDECAY_CODEX_SUMMARY_TEMPERATURE", "0.25"); + CodexAppServerBackend::from_automation_config(&AutomationConfig { + backend: AutomationBackend::CodexAppServer, + model: Some("automation-model".to_string()), + timeout_secs: fake_codex_response_timeout_secs(), + max_tokens: None, + temperature: None, + ..AutomationConfig::default() + }) + }; let request = AgentTaskRequest::new( "run_env_runtime_options".to_string(), AgentTaskKind::SkillWriter, @@ -429,7 +438,9 @@ fn codex_app_server_backend_uses_env_runtime_limits_when_config_omits_them() { #[test] fn codex_app_server_backend_propagates_timeout_errors_and_reaps_child() { - let (err, pid) = backend_error_for_behavior("timeout", Duration::from_millis(500)); + // Short but not tight: the fake must have time to start and write its pid + // file on Linux before the client gives up and reaps it. + let (err, pid) = backend_error_for_behavior("timeout", Duration::from_millis(300)); assert!( err.contains("timed out waiting for codex app-server"), @@ -549,7 +560,7 @@ fn fake_codex_app_server_times_out_and_reaps_child() { let config = CodexAppServerSummaryConfig { codex_bin: fake.bin.display().to_string(), model: None, - timeout: Duration::from_millis(500), + timeout: Duration::from_millis(300), max_tokens: None, temperature: None, }; @@ -629,7 +640,6 @@ impl FakeCodexAppServer { let script = fake_codex_script(&log, &pid, behavior); fs::write(&script_path, script).unwrap(); install_fake_codex_launcher(&script_path, &bin); - thread::sleep(Duration::from_millis(10)); Self { _temp: temp, bin, diff --git a/tests/dashboard_lcm_fixes_test.rs b/tests/dashboard_lcm_fixes_test.rs index 03524991..5727d1af 100644 --- a/tests/dashboard_lcm_fixes_test.rs +++ b/tests/dashboard_lcm_fixes_test.rs @@ -9,7 +9,8 @@ use std::path::Path; use common::{ create_runtime, get_json, http_agent, message_record_at, pick_free_port, tempdir_or_panic, - wait_for_dashboard, EnvVarGuard, GLOBAL_DB_ENV, GLOBAL_DB_ENV_LOCK, + wait_for_dashboard, write_empty_global_db_schema, EnvVarGuard, GLOBAL_DB_ENV, + GLOBAL_DB_ENV_LOCK, }; use serde_json::Value; use tempfile::TempDir; @@ -322,9 +323,14 @@ async fn start_fixture(external_payload: bool) -> DashboardFixture { let project_root = tmp.path().join("project"); let global_db_path = tmp.path().join("global").join("global.db"); let env_guard = EnvVarGuard::set(GLOBAL_DB_ENV, &global_db_path); + // Pre-create both GlobalDb-schema stores from the cached empty template + // so seeding and dashboard startup open existing DBs instead of paying a + // full schema creation each (slow on Windows). + write_empty_global_db_schema(&global_db_path).await; let cg = setup_project(&project_root).await; let session_db_path = project_session_db_path(&project_root); + write_empty_global_db_schema(&session_db_path).await; let global_db = match GlobalDb::open_at(&session_db_path).await { Some(db) => db, diff --git a/tests/hermes_lcm_bridge_test.rs b/tests/hermes_lcm_bridge_test.rs index c1d415b7..4c2fca57 100644 --- a/tests/hermes_lcm_bridge_test.rs +++ b/tests/hermes_lcm_bridge_test.rs @@ -3,11 +3,53 @@ mod common; use std::path::Path; use std::process::Command; -use common::pyyaml_shim_pythonpath; +use common::PYYAML_SHIM; use tempfile::TempDir; use tracedecay::agents::{AgentIntegration, HermesIntegration, InstallContext}; use tracedecay::sessions::lcm::{LcmCompressionRequest, LcmSummarizerMode}; +// Compiles the generated plugin sources inside the same interpreter that runs +// the per-test check script (argv[1] is the plugin dir). This replaces the +// separate `python3 -m py_compile` process each test used to spawn: on +// Windows CI every python launch goes through a .cmd shim and costs ~1s, so +// one interpreter per test instead of two roughly halves these tests' wall +// time while keeping compile failures attributable. +const PYTHON_COMPILE_CHECK: &str = r#" +import pathlib as _compile_pathlib +import py_compile as _py_compile +import sys as _compile_sys + +for _name in ("tools.py", "schemas.py", "__init__.py"): + try: + _py_compile.compile( + str(_compile_pathlib.Path(_compile_sys.argv[1]) / _name), doraise=True + ) + except _py_compile.PyCompileError as _exc: + print(f"generated Python should compile: {_name}: {_exc}", file=_compile_sys.stderr) + _compile_sys.exit(1) +"#; + +// Falls back to the bundled PyYAML shim (argv[2]) only when the interpreter +// has no importable `yaml`, replacing the separate `python3 -c "import yaml"` +// probe process that `pyyaml_shim_pythonpath` spawns. Appending to sys.path +// keeps the precedence identical: a real PyYAML always wins. +const PYYAML_FALLBACK_PRELUDE: &str = r#" +import importlib.util as _yaml_probe_util +import sys as _yaml_probe_sys + +if _yaml_probe_util.find_spec("yaml") is None: + _yaml_probe_sys.path.append(_yaml_probe_sys.argv[2]) +"#; + +/// Writes the PyYAML test shim next to the test home and returns its +/// directory, for scripts using [`PYYAML_FALLBACK_PRELUDE`]. +fn write_pyyaml_shim(scratch: &Path) -> std::path::PathBuf { + let shim_dir = scratch.join("pyyaml-shim"); + std::fs::create_dir_all(&shim_dir).unwrap(); + std::fs::write(shim_dir.join("yaml.py"), PYYAML_SHIM).unwrap(); + shim_dir +} + const PLUGIN_LOAD_PRELUDE: &str = r#" import importlib.machinery import importlib.util @@ -48,21 +90,6 @@ fn make_install_ctx(home: &Path) -> InstallContext { } } -fn assert_python_compiles(paths: &[&Path]) { - let output = Command::new("python3") - .arg("-m") - .arg("py_compile") - .args(paths) - .output() - .expect("python3 should be available for Hermes generated Python syntax checks"); - assert!( - output.status.success(), - "generated Python should compile\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); -} - fn run_generated_plugin_script(script_name: &str, script: &str, failure_message: &str) { let home = TempDir::new().unwrap(); HermesIntegration @@ -70,14 +97,8 @@ fn run_generated_plugin_script(script_name: &str, script: &str, failure_message: .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script_path = plugin_dir.join(script_name); - let script = format!("{PLUGIN_LOAD_PRELUDE}\n{script}"); + let script = format!("{PYTHON_COMPILE_CHECK}\n{PLUGIN_LOAD_PRELUDE}\n{script}"); std::fs::write(&script_path, script).unwrap(); let mut command = Command::new("python3"); @@ -932,16 +953,12 @@ fn generated_context_engine_registers_when_supported() { .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_context_engine.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{}", + r#" import importlib.machinery import importlib.util import os @@ -1086,7 +1103,8 @@ class LegacyCtx: legacy = LegacyCtx() plugin.register(legacy) -"#, +"# + ), ) .unwrap(); @@ -1114,16 +1132,12 @@ fn context_engine_preflight_uses_tracedecay_tool_json_args() { .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_preflight_bridge.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{}", + r#" import importlib.machinery import importlib.util import json @@ -1211,7 +1225,8 @@ assert args == { "messages": [{"role": "user", "content": "hello"}], "current_tokens": 987, } -"#, +"# + ), ) .unwrap(); @@ -1239,16 +1254,12 @@ fn context_engine_session_start_reports_compression_boundary() { .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_session_boundary_bridge.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{}", + r#" import importlib.machinery import importlib.util import json @@ -1323,7 +1334,8 @@ assert engine.active_session_id == "session-b" # A non-compression boundary must not call the tool. engine.on_session_start(session_id="session-d", old_session_id="session-b", boundary_reason="manual") assert len(calls) == 1 -"#, +"# + ), ) .unwrap(); @@ -1351,16 +1363,12 @@ fn context_engine_compress_uses_tracedecay_tool_json_args() { .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_compress_bridge.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{}", + r#" import importlib.machinery import importlib.util import json @@ -1461,7 +1469,8 @@ assert args == { "focus_topic": "handoff", "summarizer": {"mode": "hermes_auxiliary"}, } -"#, +"# + ), ) .unwrap(); @@ -1489,16 +1498,12 @@ fn context_engine_projects_config_defaults_into_preflight_and_compress_args() { .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_context_engine_config_defaults.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{}", + r#" import importlib.machinery import importlib.util import json @@ -1586,7 +1591,8 @@ assert preflight_args["session_id"] == "session-1" assert preflight_args["current_tokens"] == 800 assert preflight_args["messages"] == [{"role": "user", "content": "hello"}] assert compress_args["summarizer"] == {"mode": "hermes_auxiliary"} -"#, +"# + ), ) .unwrap(); @@ -1611,16 +1617,12 @@ fn context_engine_expand_query_and_profile_storage_project_flags() { .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_project_flag_bridge.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{}", + r#" import importlib.machinery import importlib.util import json @@ -1705,7 +1707,8 @@ explicit = plugin.tools.call_tracedecay_tool( assert json.loads(explicit)["content"] explicit_argv = calls.pop() assert explicit_argv[1:6] == ["tool", "--project", "/tmp/project", "tracedecay_lcm_status", "--json"] -"#, +"# + ), ) .unwrap(); @@ -1733,16 +1736,12 @@ fn auxiliary_summary_strips_reasoning_tags() { .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_auxiliary_summary.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{}", + r#" import importlib.machinery import importlib.util import pathlib @@ -1798,7 +1797,8 @@ assert agent.auxiliary_client.calls[0]["messages"] == [ {"role": "user", "content": "Summarize"}, ] assert agent.auxiliary_client.calls[0]["temperature"] == 0.3 -"#, +"# + ), ) .unwrap(); @@ -1823,16 +1823,12 @@ fn context_engine_expand_query_synthesizes_and_degrades() { .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_expand_query_synthesis.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{}", + r#" import importlib.machinery import importlib.util import json @@ -1951,7 +1947,8 @@ assert failed_payload["degraded"] is True assert "schema bug" in failed_payload["error"] assert failed_payload["needs_synthesis"] is False assert failed_payload["matches"], "retrieval must survive synthesis failures" -"#, +"# + ), ) .unwrap(); @@ -1976,16 +1973,12 @@ fn context_engine_compress_provides_auxiliary_summary_after_needs_summary() { .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_auxiliary_compress_flow.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{}", + r#" import importlib.machinery import importlib.util import json @@ -2101,7 +2094,8 @@ assert f"[USER]: {old_one}" in prompt assert f"[ASSISTANT]: {old_two}" in prompt assert agent.auxiliary_client.calls[0]["temperature"] == 0.3 assert agent.auxiliary_client.calls[0]["max_tokens"] == 4000 -"#, +"# + ), ) .unwrap(); @@ -2449,16 +2443,12 @@ fn context_engine_compress_rejects_oversized_auxiliary_summary() { .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_auxiliary_oversized_fallback.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{}", + r#" import importlib.machinery import importlib.util import json @@ -2566,7 +2556,8 @@ assert l2_prompt.startswith("Compress this into bullet points. Maximum 1000 toke assert "Keep only: decisions made, files changed, errors hit, current state." in l2_prompt assert agent.auxiliary_client.calls[0]["max_tokens"] == 4000 assert agent.auxiliary_client.calls[1]["max_tokens"] == 2000 -"#, +"# + ), ) .unwrap(); @@ -2883,16 +2874,12 @@ fn auxiliary_summary_falls_back_and_tracks_route_cooldown() { .unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_auxiliary_fallbacks.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{}", + r#" import importlib.machinery import importlib.util import pathlib @@ -2990,7 +2977,8 @@ assert tuned_engine._route_failures["default"] == 1 assert tuned_engine._cooldown_until["default"] > 0 del os.environ["LCM_SUMMARY_CIRCUIT_BREAKER_FAILURE_THRESHOLD"] del os.environ["LCM_SUMMARY_CIRCUIT_BREAKER_COOLDOWN_SECONDS"] -"#, +"# + ), ) .unwrap(); @@ -4464,7 +4452,9 @@ fn generated_context_engine_satisfies_abstract_update_from_response() { let script = plugin_dir.join("check_update_from_response.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{}", + r#" import abc import importlib.machinery import importlib.util @@ -4535,7 +4525,8 @@ engine.update_from_response(None) assert engine.last_prompt_tokens == 0 assert engine.last_completion_tokens == 0 assert engine.last_total_tokens == 0 -"#, +"# + ), ) .unwrap(); @@ -4638,16 +4629,12 @@ fn generated_plugin_honors_install_time_project_root_pin() { HermesIntegration.install(&ctx).unwrap(); let plugin_dir = home.path().join(".hermes/plugins/tracedecay"); - assert_python_compiles(&[ - &plugin_dir.join("tools.py"), - &plugin_dir.join("schemas.py"), - &plugin_dir.join("__init__.py"), - ]); - let script = plugin_dir.join("check_project_root_pin.py"); std::fs::write( &script, - r#" + format!( + "{PYTHON_COMPILE_CHECK}\n{PYYAML_FALLBACK_PRELUDE}\n{}", + r#" import importlib.machinery import importlib.util import json @@ -4734,7 +4721,8 @@ assert configured.project_root == "/from/config", configured.project_root explicit = plugin.TraceDecayContextEngine(config={}) explicit.on_session_start(session_id="s2", project_root="/explicit/root") assert explicit.project_root == "/explicit/root", explicit.project_root -"#, +"# + ), ) .unwrap(); @@ -4742,14 +4730,13 @@ assert explicit.project_root == "/explicit/root", explicit.project_root check .arg(&script) .arg(&plugin_dir) + // Reading the config-block pin requires a yaml module; the script + // falls back to this shim only when the real PyYAML is missing. + .arg(write_pyyaml_shim(home.path())) // expanduser reads HOME on POSIX and USERPROFILE on Windows. .env("HOME", home.path()) .env("USERPROFILE", home.path()) .env_remove("HERMES_HOME"); - // Reading the config-block pin requires a yaml module. - if let Some(shim_dir) = pyyaml_shim_pythonpath(home.path()) { - check.env("PYTHONPATH", shim_dir); - } let output = check .output() .expect("python3 should run generated Hermes plugin check"); diff --git a/tests/lsp_code_diagnostics_test.rs b/tests/lsp_code_diagnostics_test.rs index ad33f60a..3733ccfb 100644 --- a/tests/lsp_code_diagnostics_test.rs +++ b/tests/lsp_code_diagnostics_test.rs @@ -4,6 +4,11 @@ use tracedecay::diagnostics::lsp; const FAKE_LANGUAGE: &str = "fake"; const FAKE_PATH: &str = "src/lib.fake"; +// The stdio client intentionally keeps listening until this deadline expires +// (late publishes are part of the LSP contract), so every successful +// collection pays the FULL timeout as wall time. Keep it small: it only has +// to cover a didOpen -> publishDiagnostics round trip against an +// already-initialized fake server. const FAKE_LSP_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(150); #[test] @@ -106,7 +111,7 @@ async fn stdio_client_collects_publish_diagnostics() { &[script_path.display().to_string()], temp.path(), vec![fake_document(FAKE_LANGUAGE, FAKE_PATH, "let nope")], - std::time::Duration::from_secs(3), + FAKE_LSP_TIMEOUT, ) .await .unwrap(); @@ -132,7 +137,7 @@ async fn stdio_client_keeps_listening_after_initial_empty_publish() { &[script_path.display().to_string()], temp.path(), vec![fake_document(FAKE_LANGUAGE, FAKE_PATH, "let nope")], - std::time::Duration::from_millis(500), + std::time::Duration::from_millis(250), ) .await .unwrap(); @@ -412,9 +417,7 @@ async fn broker_ignores_refresh_completion_after_language_is_disabled() { .expect("enabled language should prepare a refresh"); broker.set_language_enabled(FAKE_LANGUAGE, false); - let completed = prepared - .collect_diagnostics(std::time::Duration::from_secs(3)) - .await; + let completed = prepared.collect_diagnostics(FAKE_LSP_TIMEOUT).await; broker.finish_refresh(completed).unwrap(); let snapshot = broker.snapshot(); diff --git a/tests/memory_eval_test.rs b/tests/memory_eval_test.rs index af6b99fd..a51b6118 100644 --- a/tests/memory_eval_test.rs +++ b/tests/memory_eval_test.rs @@ -326,7 +326,9 @@ fn run_with_timeout(mut command: Command, timeout: Duration) -> Output { "tracedecay hung after {:?}", started.elapsed() ); - std::thread::sleep(Duration::from_millis(50)); + // Keep the poll tight: every scenario step pays the tail of this + // sleep after the CLI exits, and steps run in sequence. + std::thread::sleep(Duration::from_millis(5)); } } @@ -498,6 +500,10 @@ fn initialize_fixture_project(fixture: &Fixture) { global_db_path: Some(profile_root.join("global.db")), }; runtime().block_on(async { + // Pre-create the global DB from the cached empty-schema template so + // init and every scenario-step CLI invocation open an existing store + // instead of paying full schema creation (slow on Windows). + common::write_empty_global_db_schema(&profile_root.join("global.db")).await; let cg = TraceDecay::init_with_options(&fixture.project_path, open_options) .await .unwrap_or_else(|e| panic!("initialize fixture project: {e}")); From e5daee51660457e5cb3184df3dc6b8d5cf4e99e3 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 1 Jul 2026 23:25:26 +0000 Subject: [PATCH 2/2] refactor: dedupe pyyaml shim helpers and trim comments Consolidate the PYYAML_FALLBACK_PRELUDE/write_pyyaml_shim duplicates from agent_test and hermes_lcm_bridge_test into tests/common, drop the now-unused pyyaml_shim_pythonpath/python3_has_real_yaml probe helpers, and trim repeated subprocess-cost annotations to the canonical git_may_resolve_repo doc. --- src/branch.rs | 4 ++-- src/sessions/codex_app_server.rs | 4 ++-- src/tracedecay.rs | 2 +- src/worktree.rs | 4 ++-- tests/agent_test.rs | 23 +------------------- tests/common/mod.rs | 36 +++++++++++++++----------------- tests/hermes_lcm_bridge_test.rs | 23 +------------------- 7 files changed, 26 insertions(+), 70 deletions(-) diff --git a/src/branch.rs b/src/branch.rs index 8cbce89f..dc2cb7d3 100644 --- a/src/branch.rs +++ b/src/branch.rs @@ -33,7 +33,7 @@ pub fn local_branch_exists(project_root: &Path, branch: &str) -> bool { if let Ok(repo) = gix::open(project_root) { // gix reads loose and packed refs, the same sources `git show-ref` // consults; trust its answer instead of paying a subprocess spawn - // (~100-300ms on Windows) to re-ask git. + // to re-ask git. return repo.find_reference(&refname).is_ok(); } if !crate::worktree::git_may_resolve_repo(project_root) { @@ -111,7 +111,7 @@ fn git_rev_list_count(project_root: &Path, from_ref: &str, to_ref: &str) -> Opti /// In-process equivalent of `git rev-list --count hidden..tip`: commits /// reachable from `tip` but not from `hidden`. Saves a `git` subprocess -/// spawn (~100-300ms on Windows) on every branch-add parent ranking. +/// spawn on every branch-add parent ranking. fn gix_rev_distance( repo: &gix::Repository, tip: gix::ObjectId, diff --git a/src/sessions/codex_app_server.rs b/src/sessions/codex_app_server.rs index a30a78ee..a19ca8c8 100644 --- a/src/sessions/codex_app_server.rs +++ b/src/sessions/codex_app_server.rs @@ -262,8 +262,8 @@ impl Drop for ChildGuard { #[cfg(windows)] fn kill_child_process_tree(child: &mut Child) { // `cmd /C` shims wait for their grandchildren, so a child that already - // exited has no live process tree left; skip the taskkill spawn - // (~100-300ms) that would fail anyway. + // exited has no live process tree left; skip the taskkill spawn that + // would fail anyway. if matches!(child.try_wait(), Ok(Some(_))) { return; } diff --git a/src/tracedecay.rs b/src/tracedecay.rs index d04c0ee2..4fa45669 100644 --- a/src/tracedecay.rs +++ b/src/tracedecay.rs @@ -1247,7 +1247,7 @@ fn profile_store_id(project_id: &str) -> String { fn git_remote_url(project_root: &Path) -> Option { // gix reads the same config `git config --get` would (repo-local + - // global) without a subprocess spawn (~100-300ms on Windows). + // global) without a subprocess spawn. if let Ok(repo) = gix::discover(project_root) { let url = repo .config_snapshot() diff --git a/src/worktree.rs b/src/worktree.rs index 2015196c..3802b7c9 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -40,8 +40,8 @@ pub struct WorktreeIndexMismatch { /// which is exactly the distinction this module relies on. pub fn git_worktree_root(dir: &Path) -> Option { // gix discovery walks up the same way `git rev-parse` does but without - // a subprocess spawn (~100-300ms on Windows). A discovered bare repo - // (no workdir) matches `--show-toplevel` failing. + // a subprocess spawn. A discovered bare repo (no workdir) matches + // `--show-toplevel` failing. if let Ok(repo) = gix::discover(dir) { return realpath(repo.workdir()?); } diff --git a/tests/agent_test.rs b/tests/agent_test.rs index ec2a073f..3acde602 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -3,7 +3,7 @@ mod common; use std::path::Path; use std::process::Command; -use common::EnvVarGuard; +use common::{write_pyyaml_shim, EnvVarGuard, PYYAML_FALLBACK_PRELUDE}; use tempfile::TempDir; use tracedecay::agents::*; use tracedecay::automation::managed_skills::{ @@ -238,27 +238,6 @@ for _name in ("tools.py", "schemas.py", "__init__.py"): ) } -/// Falls back to the bundled PyYAML shim (argv[2]) only when the interpreter -/// has no importable `yaml`, replacing the separate `python3 -c "import -/// yaml"` probe process that `pyyaml_shim_pythonpath` spawns. Appending to -/// sys.path keeps the precedence identical: a real PyYAML always wins. -const PYYAML_FALLBACK_PRELUDE: &str = r#" -import importlib.util as _yaml_probe_util -import sys as _yaml_probe_sys - -if _yaml_probe_util.find_spec("yaml") is None: - _yaml_probe_sys.path.append(_yaml_probe_sys.argv[2]) -"#; - -/// Writes the PyYAML test shim next to the test home and returns its -/// directory, for scripts using [`PYYAML_FALLBACK_PRELUDE`]. -fn write_pyyaml_shim(scratch: &Path) -> std::path::PathBuf { - let shim_dir = scratch.join("pyyaml-shim"); - std::fs::create_dir_all(&shim_dir).unwrap(); - std::fs::write(shim_dir.join("yaml.py"), common::PYYAML_SHIM).unwrap(); - shim_dir -} - fn assert_python_compiles(paths: &[&Path]) { let output = Command::new("python3") .arg("-m") diff --git a/tests/common/mod.rs b/tests/common/mod.rs index bbe5cb6d..d3ece00b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -803,26 +803,24 @@ def dump(data, stream=None, default_flow_style=False, **kwargs): return None "##; -pub fn python3_has_real_yaml() -> bool { - static HAS_YAML: std::sync::OnceLock = std::sync::OnceLock::new(); - *HAS_YAML.get_or_init(|| { - std::process::Command::new("python3") - .args(["-c", "import yaml"]) - .output() - .map(|out| out.status.success()) - .unwrap_or(false) - }) -} - -/// Returns a PYTHONPATH entry providing the `yaml` shim when the system -/// python3 has no real PyYAML, so config.yaml-dependent checks run on every -/// OS instead of failing on bare CI runners. -pub fn pyyaml_shim_pythonpath(scratch: &std::path::Path) -> Option { - if python3_has_real_yaml() { - return None; - } +/// Python prelude that falls back to the bundled PyYAML shim (argv[2]) only +/// when the interpreter has no importable `yaml`, so config.yaml-dependent +/// checks run on bare CI runners without a separate `python3 -c "import +/// yaml"` probe process. Appending to sys.path keeps the precedence +/// identical: a real PyYAML always wins. +pub const PYYAML_FALLBACK_PRELUDE: &str = r#" +import importlib.util as _yaml_probe_util +import sys as _yaml_probe_sys + +if _yaml_probe_util.find_spec("yaml") is None: + _yaml_probe_sys.path.append(_yaml_probe_sys.argv[2]) +"#; + +/// Writes the PyYAML test shim next to the test home and returns its +/// directory, for scripts using [`PYYAML_FALLBACK_PRELUDE`]. +pub fn write_pyyaml_shim(scratch: &Path) -> PathBuf { let shim_dir = scratch.join("pyyaml-shim"); std::fs::create_dir_all(&shim_dir).unwrap(); std::fs::write(shim_dir.join("yaml.py"), PYYAML_SHIM).unwrap(); - Some(shim_dir) + shim_dir } diff --git a/tests/hermes_lcm_bridge_test.rs b/tests/hermes_lcm_bridge_test.rs index 4c2fca57..5ad7a2b6 100644 --- a/tests/hermes_lcm_bridge_test.rs +++ b/tests/hermes_lcm_bridge_test.rs @@ -3,7 +3,7 @@ mod common; use std::path::Path; use std::process::Command; -use common::PYYAML_SHIM; +use common::{write_pyyaml_shim, PYYAML_FALLBACK_PRELUDE}; use tempfile::TempDir; use tracedecay::agents::{AgentIntegration, HermesIntegration, InstallContext}; use tracedecay::sessions::lcm::{LcmCompressionRequest, LcmSummarizerMode}; @@ -29,27 +29,6 @@ for _name in ("tools.py", "schemas.py", "__init__.py"): _compile_sys.exit(1) "#; -// Falls back to the bundled PyYAML shim (argv[2]) only when the interpreter -// has no importable `yaml`, replacing the separate `python3 -c "import yaml"` -// probe process that `pyyaml_shim_pythonpath` spawns. Appending to sys.path -// keeps the precedence identical: a real PyYAML always wins. -const PYYAML_FALLBACK_PRELUDE: &str = r#" -import importlib.util as _yaml_probe_util -import sys as _yaml_probe_sys - -if _yaml_probe_util.find_spec("yaml") is None: - _yaml_probe_sys.path.append(_yaml_probe_sys.argv[2]) -"#; - -/// Writes the PyYAML test shim next to the test home and returns its -/// directory, for scripts using [`PYYAML_FALLBACK_PRELUDE`]. -fn write_pyyaml_shim(scratch: &Path) -> std::path::PathBuf { - let shim_dir = scratch.join("pyyaml-shim"); - std::fs::create_dir_all(&shim_dir).unwrap(); - std::fs::write(shim_dir.join("yaml.py"), PYYAML_SHIM).unwrap(); - shim_dir -} - const PLUGIN_LOAD_PRELUDE: &str = r#" import importlib.machinery import importlib.util