Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,33 @@ jobs:
steps:
- uses: actions/checkout@v7

- name: Tune Windows runner for test I/O
shell: pwsh
run: |
# Defender real-time scanning taxes the many short-lived SQLite/tempdir
# files these tests create; hosted runners are elevated, so turn it off
# and exclude the hot paths as a fallback.
Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction SilentlyContinue
$exclusions = @(
$env:GITHUB_WORKSPACE,
$env:RUNNER_TEMP,
[System.IO.Path]::GetTempPath(),
$env:CARGO_HOME,
$env:RUSTUP_HOME,
"$env:USERPROFILE\.cargo",
"$env:USERPROFILE\.rustup"
) | Where-Object { $_ } | Select-Object -Unique
foreach ($path in $exclusions) {
Add-MpPreference -ExclusionPath $path -ErrorAction SilentlyContinue
}
# Default user TEMP is on the slow C: drive; RUNNER_TEMP shares the
# faster workspace drive. Point TMP/TEMP there so tempfile::TempDir in
# the nextest test processes follows.
$fastTemp = Join-Path $env:RUNNER_TEMP "tmp"
New-Item -ItemType Directory -Path $fastTemp -Force | Out-Null
"TMP=$fastTemp" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"TEMP=$fastTemp" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append

- uses: dtolnay/rust-toolchain@stable

- name: Use lld-link linker
Expand Down Expand Up @@ -142,6 +169,8 @@ jobs:
cache-on-failure: true

- name: Run Windows tests
env:
TRACEDECAY_SQLITE_UNSAFE_FAST: "1"
run: cargo nextest run --workspace --profile ci --locked --partition slice:${{ matrix.partition }}/10 --test-threads num-cpus --status-level slow

- name: Clean abandoned Windows test children
Expand Down
15 changes: 15 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,18 @@ criterion = { version = "0.5", features = ["async_tokio", "html_reports"] }
[[bench]]
name = "large_repos"
harness = false

# Compile the SQLite stack with optimizations even in dev/test builds. The
# bundled SQLite C sources in libsql-ffi are otherwise built at -O0 (cc honors
# 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 "*".
[profile.dev.package.libsql-ffi]
opt-level = 2

[profile.dev.package.libsql-sys]
opt-level = 2

[profile.dev.package.libsql-rusqlite]
opt-level = 2
87 changes: 85 additions & 2 deletions src/db/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,37 @@ pub(crate) fn platform_safe_mmap_size(mmap: u64) -> u64 {
}
}

/// Env var that, when set to `1`, switches every `TraceDecay` `SQLite`
/// connection to `journal_mode=MEMORY` + `synchronous=OFF` on all platforms.
///
/// **For tests/CI only — must never be set in production.** It trades away
/// crash durability entirely: a process or OS crash mid-transaction can
/// corrupt the database. CI test runs don't care (every DB is a throwaway
/// fixture), and on Windows this avoids the per-transaction rollback-journal
/// file create/write/fsync/delete cost of the `DELETE`+`FULL` pairing. An
/// in-memory journal also never enters WAL mode, so it sidesteps the Windows
/// WAL close-time teardown crash the same way `DELETE` does.
pub const SQLITE_UNSAFE_FAST_ENV: &str = "TRACEDECAY_SQLITE_UNSAFE_FAST";

fn sqlite_unsafe_fast_enabled() -> bool {
std::env::var(SQLITE_UNSAFE_FAST_ENV).as_deref() == Ok("1")
}

/// Returns the `journal_mode` safe for the current platform.
///
/// Windows libsql/SQLite local databases can intermittently fault while closing
/// WAL-mode databases under nextest's per-test process isolation. Disabling
/// mmap removed one unsafe teardown path, but master CI still aborts in
/// unrelated tests as different short-lived databases close. Use rollback
/// journaling on Windows and keep WAL everywhere else.
///
/// When [`SQLITE_UNSAFE_FAST_ENV`] is `1` (tests/CI only — never set it in
/// production) this returns `MEMORY` on every platform, skipping journal file
/// I/O entirely at the cost of crash durability.
pub(crate) fn platform_safe_journal_mode() -> &'static str {
if cfg!(windows) {
if sqlite_unsafe_fast_enabled() {
"MEMORY"
} else if cfg!(windows) {
"DELETE"
} else {
"WAL"
Expand All @@ -70,8 +92,14 @@ pub(crate) fn platform_safe_journal_mode() -> &'static str {
/// final fsync, but rollback journals need `FULL` to avoid corruption after an
/// OS crash or power loss. Keep the faster WAL+NORMAL pairing on non-Windows
/// and use DELETE+FULL on Windows.
///
/// When [`SQLITE_UNSAFE_FAST_ENV`] is `1` (tests/CI only — never set it in
/// production) this returns `OFF` on every platform, skipping fsyncs entirely
/// at the cost of crash durability.
pub(crate) fn platform_safe_synchronous_mode() -> &'static str {
if cfg!(windows) {
if sqlite_unsafe_fast_enabled() {
"OFF"
} else if cfg!(windows) {
"FULL"
} else {
"NORMAL"
Expand Down Expand Up @@ -413,6 +441,39 @@ mod tests {
const KB: u64 = 1024;
const MB: u64 = 1024 * 1024;

/// Serializes tests that mutate [`SQLITE_UNSAFE_FAST_ENV`]; process env is
/// shared across threads under plain `cargo test`.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

struct EnvVarGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}

impl EnvVarGuard {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}

fn unset(key: &'static str) -> Self {
let previous = std::env::var_os(key);
std::env::remove_var(key);
Self { key, previous }
}
}

impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(previous) = self.previous.take() {
std::env::set_var(self.key, previous);
} else {
std::env::remove_var(self.key);
}
}
}

#[test]
fn adaptive_new_db_gets_minimum() {
let (cache_kb, mmap) = adaptive_cache_sizes(0);
Expand Down Expand Up @@ -470,6 +531,10 @@ mod tests {

#[test]
fn journal_mode_uses_wal_except_on_windows() {
let _lock = ENV_LOCK.lock().unwrap();
// Pin the CI-only escape hatch off: Windows CI exports it for the
// whole test run, and this test asserts the durable defaults.
let _env = EnvVarGuard::unset(SQLITE_UNSAFE_FAST_ENV);
if cfg!(windows) {
assert_eq!(platform_safe_journal_mode(), "DELETE");
} else {
Expand All @@ -479,10 +544,28 @@ mod tests {

#[test]
fn synchronous_mode_matches_platform_journal_mode() {
let _lock = ENV_LOCK.lock().unwrap();
let _env = EnvVarGuard::unset(SQLITE_UNSAFE_FAST_ENV);
if cfg!(windows) {
assert_eq!(platform_safe_synchronous_mode(), "FULL");
} else {
assert_eq!(platform_safe_synchronous_mode(), "NORMAL");
}
}

#[test]
fn unsafe_fast_env_overrides_journal_and_synchronous_modes() {
let _lock = ENV_LOCK.lock().unwrap();
let _env = EnvVarGuard::set(SQLITE_UNSAFE_FAST_ENV, "1");
assert_eq!(platform_safe_journal_mode(), "MEMORY");
assert_eq!(platform_safe_synchronous_mode(), "OFF");
}

#[test]
fn unsafe_fast_env_requires_exact_value_one() {
let _lock = ENV_LOCK.lock().unwrap();
let _env = EnvVarGuard::set(SQLITE_UNSAFE_FAST_ENV, "true");
assert_ne!(platform_safe_journal_mode(), "MEMORY");
assert_ne!(platform_safe_synchronous_mode(), "OFF");
}
}
2 changes: 1 addition & 1 deletion src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ mod stats;
mod tx;
mod unresolved;

pub use connection::Database;
pub(crate) use connection::{
platform_safe_journal_mode, platform_safe_mmap_size, platform_safe_synchronous_mode,
};
pub use connection::{Database, SQLITE_UNSAFE_FAST_ENV};
pub use fingerprints::StoredFingerprint;
pub use search::DependencyImportUse;
12 changes: 12 additions & 0 deletions tests/automation_runner_support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,18 @@ impl Drop for EnvVarGuard {
}
}

/// Pins `TRACEDECAY_GLOBAL_DB` at the test project's already-created session
/// store for the guard's lifetime. Skill-writer evidence building calls
/// `GlobalDb::open()`, which would otherwise create (or contend on) the
/// shared per-user global DB — a full schema creation that dominates these
/// fixtures on Windows CI, where many test processes share one home. The
/// session store uses the same schema and these tests never rely on
/// pre-existing global-DB contents, so reusing it keeps the open cheap and
/// fully isolated. Callers must hold [`ENV_LOCK`] while the guard is alive.
pub(crate) fn isolate_global_db(cg: &TraceDecay) -> EnvVarGuard {
EnvVarGuard::set("TRACEDECAY_GLOBAL_DB", &cg.store_layout().sessions_db_path)
}

impl FailingBackend {
pub(crate) fn new(task: AgentTaskKind) -> Self {
Self {
Expand Down
80 changes: 6 additions & 74 deletions tests/automation_runner_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,16 @@ async fn scheduler_memory_curator_respects_interval_gate() {
);
}

// The scheduler gate tests below deliberately skip session-evidence seeding:
// the runners evaluate the scheduler gate before opening any session store,
// and each test pins the exact gate skip reason, so a regression that
// reordered gating behind evidence gathering would fail with a different
// error. Skipping the seed avoids paying a full session-DB schema creation
// per test, which dominates these fixtures on Windows.
#[tokio::test]
async fn scheduler_session_reflector_respects_interval_gate() {
let temp = tempdir().unwrap();
let cg = init_project(temp.path()).await;
seed_session_evidence(&cg).await;
let config = scheduler_config(Some(3600), None);
append_run_record(
&cg.store_layout().dashboard_root,
Expand Down Expand Up @@ -125,7 +130,6 @@ async fn scheduler_session_reflector_respects_interval_gate() {
async fn scheduler_skill_writer_respects_interval_gate() {
let temp = tempdir().unwrap();
let cg = init_project(temp.path()).await;
seed_session_evidence(&cg).await;
let config = scheduler_config(Some(3600), None);
append_run_record(
&cg.store_layout().dashboard_root,
Expand Down Expand Up @@ -164,7 +168,6 @@ async fn scheduler_skill_writer_respects_interval_gate() {
async fn scheduler_skill_writer_respects_idle_window_after_manual_run() {
let temp = tempdir().unwrap();
let cg = init_project(temp.path()).await;
seed_session_evidence(&cg).await;
let mut config = scheduler_config(Some(1), None);
config.tasks.skill_writer.min_idle_secs = Some(3600);
let mut record = scheduler_record_for(
Expand Down Expand Up @@ -357,77 +360,6 @@ async fn init_project(project_root: &Path) -> TraceDecay {
TraceDecay::init(project_root).await.unwrap()
}

async fn seed_session_evidence(cg: &TraceDecay) {
let db = GlobalDb::open_at(&cg.store_layout().sessions_db_path)
.await
.expect("session db open");
seed_session_message_in_db(
&db,
cg.project_root(),
SeedSessionMessage {
provider: "cursor",
session_id: "session-reflect-1",
message_id: "session-reflect-1-message-001",
role: "user",
timestamp: 1_715_000_001,
text: "Remember durable session reflection facts must remain approval gated for automation workflows.",
source: None,
},
)
.await;
}

struct SeedSessionMessage<'a> {
provider: &'a str,
session_id: &'a str,
message_id: &'a str,
role: &'a str,
timestamp: i64,
text: &'a str,
source: Option<&'a str>,
}

async fn seed_session_message_in_db(
db: &GlobalDb,
project_root: &Path,
seed: SeedSessionMessage<'_>,
) {
let session = SessionRecord {
provider: seed.provider.to_string(),
session_id: seed.session_id.to_string(),
project_key: project_root.display().to_string(),
project_path: project_root.display().to_string(),
title: Some("Session reflection fixture".to_string()),
started_at: Some(seed.timestamp.saturating_sub(1)),
ended_at: None,
transcript_path: None,
metadata_json: None,
parent_session_id: None,
is_subagent: false,
agent_id: None,
parent_tool_use_id: None,
};
assert!(db.upsert_session(&session).await);
let message = SessionMessageRecord {
provider: seed.provider.to_string(),
message_id: seed.message_id.to_string(),
session_id: seed.session_id.to_string(),
role: seed.role.to_string(),
timestamp: Some(seed.timestamp),
ordinal: 1,
text: seed.text.to_string(),
kind: Some("message".to_string()),
model: None,
tool_names: None,
source_path: None,
source_offset: None,
metadata_json: seed
.source
.map(|source| json!({ "source": source }).to_string()),
};
assert!(db.upsert_session_message(&message).await);
}

async fn seed_duplicate_facts(cg: &TraceDecay) {
let conn = cg.db().conn();
let vec_a = HolographicEncoder::serialize(&[0.20, 0.35, 0.50]).unwrap();
Expand Down
5 changes: 4 additions & 1 deletion tests/automation_session_reflector_runner_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,10 @@ async fn session_fact_proposals_dedupe_repeated_pending_facts_across_runs() {
async fn session_reflector_runner_reads_hermes_profile_lcm_with_filters() {
let temp = tempdir().unwrap();
let cg = init_project(temp.path()).await;
seed_session_evidence(&cg).await;
// No project session store is seeded (or created) on purpose: the
// hermes_profile storage scope must read only the hermes profile DB, so
// a regression that consulted the project store would find no store at
// all and skip with lcm_not_ingested instead of succeeding.

let hermes_home = tempdir().unwrap();
let profile_db_path = resolve_hermes_profile_session_db_path(hermes_home.path()).unwrap();
Expand Down
Loading
Loading