From 850414bb1a91a3ee3ea89470a14de455332a9054 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Sat, 8 Aug 2026 19:09:09 +0200 Subject: [PATCH 01/20] fix(migrate): authorize staged databases Consolidation normalizes private session snapshots while holding the enclosing profile's exclusive maintenance lease. Map only its reserved staging directory to that profile authority, and keep append-only hook telemetry outside immutable confirmation fingerprints without dropping its bytes. --- .../consolidation-maintenance-telemetry.md | 5 ++ .../src/consolidate/files.rs | 4 ++ .../tracedecay-migrate/src/consolidate/mod.rs | 10 +++- .../src/db/access/path_layout.rs | 5 ++ .../src/db/access/tests.rs | 56 +++++++++++++++++++ src/migrate/consolidate/tests.rs | 40 +++++++++++++ 6 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 .changeset/consolidation-maintenance-telemetry.md diff --git a/.changeset/consolidation-maintenance-telemetry.md b/.changeset/consolidation-maintenance-telemetry.md new file mode 100644 index 000000000..a24b4e304 --- /dev/null +++ b/.changeset/consolidation-maintenance-telemetry.md @@ -0,0 +1,5 @@ +--- +"tracedecay": patch +--- + +Authorize migration-owned staged session databases under the exclusive maintenance scope, and omit only volatile hook analytics from confirmation fingerprints while preserving its bytes in backups and consolidated artifacts. diff --git a/crates/tracedecay-migrate/src/consolidate/files.rs b/crates/tracedecay-migrate/src/consolidate/files.rs index f80d9fc18..d2d69dbed 100644 --- a/crates/tracedecay-migrate/src/consolidate/files.rs +++ b/crates/tracedecay-migrate/src/consolidate/files.rs @@ -214,6 +214,10 @@ pub(super) fn is_sqlite_sidecar(relative: &Path) -> bool { .is_some_and(|value| value.ends_with("-shm") || value.ends_with("-wal")) } +pub(super) fn is_volatile_hook_telemetry(relative: &Path) -> bool { + relative == Path::new("hook_analytics.jsonl") +} + pub(super) fn is_sqlite_database(relative: &Path) -> bool { relative .extension() diff --git a/crates/tracedecay-migrate/src/consolidate/mod.rs b/crates/tracedecay-migrate/src/consolidate/mod.rs index 1f99e6fc2..f53efd254 100644 --- a/crates/tracedecay-migrate/src/consolidate/mod.rs +++ b/crates/tracedecay-migrate/src/consolidate/mod.rs @@ -35,7 +35,7 @@ pub use files::{ }; use files::{ excluded_source_artifact, is_coordination_lock, is_reference_artifact, is_runtime_lock, - is_sqlite_database, is_sqlite_sidecar, tree_stats, + is_sqlite_database, is_sqlite_sidecar, is_volatile_hook_telemetry, tree_stats, }; use finalize::{cut_over_markers, register_destination, verify_destination}; use preflight::{acquire_store_locks, ensure_profile_offline, preflight_disk_space}; @@ -993,7 +993,13 @@ fn fingerprint_inputs( } } for (relative, path) in files { - if is_runtime_lock(&relative) || is_sqlite_sidecar(&relative) { + if is_runtime_lock(&relative) + || is_sqlite_sidecar(&relative) + || is_volatile_hook_telemetry(&relative) + { + // Hooks may append this telemetry between dry-run and apply. It is + // omitted only from the confirmation fingerprint; backup and + // artifact merge still copy and checksum every telemetry byte. continue; } hash.update(relative.to_string_lossy().as_bytes()); diff --git a/crates/tracedecay-runtime-core/src/db/access/path_layout.rs b/crates/tracedecay-runtime-core/src/db/access/path_layout.rs index d8b632ea8..b59b63375 100644 --- a/crates/tracedecay-runtime-core/src/db/access/path_layout.rs +++ b/crates/tracedecay-runtime-core/src/db/access/path_layout.rs @@ -48,6 +48,11 @@ fn profile_project_root(database_path: &Path) -> Option<&Path> { let parent = database_path.parent()?; let data_root = if parent.file_name().is_some_and(|name| name == "branches") { parent.parent()? + } else if parent + .file_name() + .is_some_and(|name| name == ".consolidation-input") + { + parent.parent()? } else { parent }; diff --git a/crates/tracedecay-runtime-core/src/db/access/tests.rs b/crates/tracedecay-runtime-core/src/db/access/tests.rs index 37380b60c..50a16cca1 100644 --- a/crates/tracedecay-runtime-core/src/db/access/tests.rs +++ b/crates/tracedecay-runtime-core/src/db/access/tests.rs @@ -794,6 +794,62 @@ fn maintenance_scope_requires_and_inherits_exclusive_profile_lease() { drop(lifecycle); } +#[test] +fn consolidation_input_session_databases_inherit_profile_maintenance_scope() { + let _lock = SCOPE_TEST_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let expected_profile = platform_identity_key(&temp.path().canonicalize().unwrap()); + let expected_lock_parent = expected_profile.join(".tracedecay-database-locks"); + let lifecycle = crate::lifecycle_lease::acquire_exclusive_for_profile( + temp.path(), + "consolidation maintenance test", + ) + .unwrap(); + let scope = + enter_maintenance_database_scope(&lifecycle, temp.path(), "consolidation maintenance test") + .unwrap(); + + for file_name in ["source-sessions.db", "target-sessions.db"] { + let path = temp + .path() + .join("projects/p1/.consolidation-input") + .join(file_name); + let identity = DatabaseIdentity::for_path(&path).unwrap(); + assert_eq!( + identity.profile_root, + expected_profile, + "{}", + path.display() + ); + assert_eq!( + identity.access_lock_path.parent(), + Some(expected_lock_parent.as_path()), + "{}", + path.display() + ); + assert!( + !identity.allows_ambient_profile_scope, + "{} must require its exact profile authority", + path.display() + ); + let authority = + DatabaseAuthority::for_runtime(&path, "merge consolidation sessions").unwrap(); + assert_eq!(authority.role(), DatabaseAuthorityRole::Maintenance); + } + + let unrelated = temp + .path() + .join("projects/p1/other-input/source-sessions.db"); + let unrelated_identity = DatabaseIdentity::for_path(&unrelated).unwrap(); + assert_ne!(unrelated_identity.profile_root, expected_profile); + let unrelated_authority = + DatabaseAuthority::for_runtime(&unrelated, "unrelated nested database").unwrap(); + assert_eq!(unrelated_authority.role(), DatabaseAuthorityRole::Test); + + drop(scope); + drop(lifecycle); +} + #[test] fn daemon_scopes_are_isolated_by_profile() { let _lock = SCOPE_TEST_LOCK.lock().unwrap(); diff --git a/src/migrate/consolidate/tests.rs b/src/migrate/consolidate/tests.rs index 49849e430..019162460 100644 --- a/src/migrate/consolidate/tests.rs +++ b/src/migrate/consolidate/tests.rs @@ -400,6 +400,46 @@ async fn synthesized_branch_metadata_change_invalidates_confirmation_token() { assert!(!planned.ledger_path.exists()); } +#[tokio::test] +async fn hook_analytics_append_after_plan_preserves_bytes_without_invalidating_confirmation() { + let fixture = fixture().await; + let source = layout_for_id(&fixture.project, &fixture.profile, &fixture.source_id).unwrap(); + let telemetry_path = source.data_root.join("hook_analytics.jsonl"); + let before = b"{\"event\":\"before-plan\"}\n"; + let appended = b"{\"event\":\"after-plan\"}\n"; + fs::write(&telemetry_path, before).unwrap(); + let options = fixture.options(); + + let planned = plan(&options).await.unwrap(); + use std::io::Write; + fs::OpenOptions::new() + .append(true) + .open(&telemetry_path) + .unwrap() + .write_all(appended) + .unwrap(); + let expected = [before.as_slice(), appended.as_slice()].concat(); + + let applied = apply(&options, &planned.confirmation_token).await.unwrap(); + + assert_eq!(applied.confirmation_token, planned.confirmation_token); + assert_eq!( + fs::read(applied.destination_data_root.join("hook_analytics.jsonl")).unwrap(), + expected + ); + assert_eq!( + fs::read( + applied + .backup_root + .join(&fixture.source_id) + .join("hook_analytics.jsonl") + ) + .unwrap(), + expected + ); + assert_eq!(fs::read(telemetry_path).unwrap(), expected); +} + #[tokio::test] async fn corrupt_branch_metadata_fails_closed_without_mutation() { for content in [ From a556506d2eaf460fc62fb6f74603c17dc043e5fc Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Sat, 8 Aug 2026 19:16:27 +0200 Subject: [PATCH 02/20] fix(db): restrict consolidation authority Reserve profile maintenance authority for the two session snapshots created by consolidation. Other files under the staging directory keep their independent database identity. --- .../tracedecay-runtime-core/src/db/access/path_layout.rs | 3 +++ crates/tracedecay-runtime-core/src/db/access/tests.rs | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/crates/tracedecay-runtime-core/src/db/access/path_layout.rs b/crates/tracedecay-runtime-core/src/db/access/path_layout.rs index b59b63375..0f6d0b68c 100644 --- a/crates/tracedecay-runtime-core/src/db/access/path_layout.rs +++ b/crates/tracedecay-runtime-core/src/db/access/path_layout.rs @@ -51,6 +51,9 @@ fn profile_project_root(database_path: &Path) -> Option<&Path> { } else if parent .file_name() .is_some_and(|name| name == ".consolidation-input") + && database_path + .file_name() + .is_some_and(|name| name == "source-sessions.db" || name == "target-sessions.db") { parent.parent()? } else { diff --git a/crates/tracedecay-runtime-core/src/db/access/tests.rs b/crates/tracedecay-runtime-core/src/db/access/tests.rs index 50a16cca1..f5003feea 100644 --- a/crates/tracedecay-runtime-core/src/db/access/tests.rs +++ b/crates/tracedecay-runtime-core/src/db/access/tests.rs @@ -837,6 +837,15 @@ fn consolidation_input_session_databases_inherit_profile_maintenance_scope() { assert_eq!(authority.role(), DatabaseAuthorityRole::Maintenance); } + let arbitrary = temp + .path() + .join("projects/p1/.consolidation-input/arbitrary.db"); + let arbitrary_identity = DatabaseIdentity::for_path(&arbitrary).unwrap(); + assert_ne!(arbitrary_identity.profile_root, expected_profile); + let arbitrary_authority = + DatabaseAuthority::for_runtime(&arbitrary, "unowned consolidation input").unwrap(); + assert_eq!(arbitrary_authority.role(), DatabaseAuthorityRole::Test); + let unrelated = temp .path() .join("projects/p1/other-input/source-sessions.db"); From ab7be60b9164afe6b2100b51bdaabe94c1380b09 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Mon, 10 Aug 2026 23:01:17 +0200 Subject: [PATCH 03/20] perf(storage): avoid full counts on exact roots --- .changeset/exact-root-inventory-fast-path.md | 5 ++ src/tracedecay/lifecycle.rs | 56 +++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 .changeset/exact-root-inventory-fast-path.md diff --git a/.changeset/exact-root-inventory-fast-path.md b/.changeset/exact-root-inventory-fast-path.md new file mode 100644 index 000000000..89ba49180 --- /dev/null +++ b/.changeset/exact-root-inventory-fast-path.md @@ -0,0 +1,5 @@ +--- +"tracedecay": patch +--- + +Avoid full session-table row counts while resolving a healthy exact-root project store, keeping graph calls responsive when session history lives on slower storage. diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index d8d72b858..614a7a42e 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -239,8 +239,7 @@ impl TraceDecay { && !candidates.is_empty() && let Some(selected) = selected.as_ref() { - let selected_inventory = store_identity_inventory(selected).await; - if selected_inventory.is_healthy() && !selected_inventory.is_pristine() { + if store_identity_is_healthy_non_pristine(selected).await { return Ok(Some(selected.clone())); } } @@ -1435,6 +1434,59 @@ async fn store_identity_inventory(layout: &StoreLayout) -> StoreIdentityInventor } } +/// Check the common exact-root case without counting every row in large +/// session tables. Exact counts are needed only when constructing an +/// ambiguity/cutover diagnostic; this predicate only needs to distinguish a +/// healthy populated store from an empty or unhealthy one. +async fn store_identity_is_healthy_non_pristine(layout: &StoreLayout) -> bool { + let authority = DatabaseAuthority::for_runtime(&layout.graph_db_path, "store inventory"); + let Ok((db, _)) = (match authority { + Ok(authority) => Database::open_read_only(&layout.graph_db_path, &authority).await, + Err(error) => Err(error), + }) else { + return false; + }; + let Ok(stats) = db.get_stats().await else { + db.close(); + return false; + }; + let graph_is_populated = stats.node_count > 0 + || stats.file_count > 0 + || table_has_rows(db.conn(), "memory_facts").await; + db.close(); + if graph_is_populated { + return true; + } + + if let Some(db) = crate::global_db::GlobalDb::open_read_only_at(&layout.sessions_db_path).await + { + let conn = db.dashboard_connection(); + let sessions_are_populated = table_has_rows(&conn, "sessions").await + || table_has_rows(&conn, "session_messages").await + || table_has_rows(&conn, "lcm_raw_messages").await + || table_has_rows(&conn, "lcm_summary_nodes").await; + db.close(); + if sessions_are_populated { + return true; + } + } + + branch_meta::load_branch_meta(&layout.data_root).is_some_and(|meta| meta.branches.len() > 1) + || count_tree_files(&layout.dashboard_root) > 0 + || count_tree_files(&layout.lcm_payload_root) > 0 + || count_tree_files(&layout.response_handle_root) > 0 +} + +async fn table_has_rows(connection: &libsql::Connection, table: &str) -> bool { + let Ok(mut rows) = connection + .query(&format!("SELECT 1 FROM {table} LIMIT 1"), ()) + .await + else { + return false; + }; + rows.next().await.ok().flatten().is_some() +} + async fn count_rows(connection: &libsql::Connection, table: &str) -> u64 { let Ok(mut rows) = connection .query(&format!("SELECT COUNT(*) FROM {table}"), ()) From 133ca5c48bac9ecfb054f9ea59e6435feeca0e94 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Mon, 10 Aug 2026 23:17:42 +0200 Subject: [PATCH 04/20] perf(storage): trust healthy exact root among duplicates --- .changeset/exact-root-inventory-fast-path.md | 2 +- crates/tracedecay-runtime-core/src/storage.rs | 16 +++-- src/tracedecay/lifecycle.rs | 14 +++-- tests/storage_suite/storage_resolver_test.rs | 61 +++++++++++++++++++ 4 files changed, 77 insertions(+), 16 deletions(-) diff --git a/.changeset/exact-root-inventory-fast-path.md b/.changeset/exact-root-inventory-fast-path.md index 89ba49180..4ef8eb56b 100644 --- a/.changeset/exact-root-inventory-fast-path.md +++ b/.changeset/exact-root-inventory-fast-path.md @@ -2,4 +2,4 @@ "tracedecay": patch --- -Avoid full session-table row counts while resolving a healthy exact-root project store, keeping graph calls responsive when session history lives on slower storage. +Avoid full session-table row counts while resolving a healthy exact-root project store, including when preserved duplicate manifests exist, keeping graph calls responsive when session history lives on slower storage. diff --git a/crates/tracedecay-runtime-core/src/storage.rs b/crates/tracedecay-runtime-core/src/storage.rs index 23048be4d..9ed413515 100644 --- a/crates/tracedecay-runtime-core/src/storage.rs +++ b/crates/tracedecay-runtime-core/src/storage.rs @@ -503,8 +503,6 @@ where // manifest overrides the selected identity. Otherwise the shared-Git // recovery path still runs, and the caller decides whether a selected // identity naming this exact checkout outranks what it finds. - let selected_is_sole_exact_root = - selected_manifest_matches_exact_root && exact_manifests.is_empty(); let matching_manifests = if exact_manifests.is_empty() { let project_git_common_dir = (!is_detached_linked_worktree(project_root)) .then(|| match git_identity(project_root) { @@ -590,7 +588,7 @@ where } layouts.push(layout); } - Ok((layouts, selected_is_sole_exact_root)) + Ok((layouts, selected_manifest_matches_exact_root)) } pub fn retire_identity_cutover_manifest(layout: &StoreLayout) -> Result { @@ -1419,7 +1417,7 @@ mod tests { write_manifest(&profile_root, "proj_unrelated", &unrelated_root); let resolver_calls = RefCell::new(Vec::new()); - let (layouts, selected_is_sole_exact_root) = + let (layouts, selected_manifest_matches_exact_root) = matching_legacy_profile_layouts_with_git_identity_resolver( &project_root, &profile_root, @@ -1441,14 +1439,14 @@ mod tests { layouts[0].identity.project_id.as_deref(), Some("proj_exact") ); - assert!(!selected_is_sole_exact_root); + assert!(!selected_manifest_matches_exact_root); assert!( resolver_calls.borrow().is_empty(), "exact-root selection must not invoke shared-Git discovery" ); resolver_calls.borrow_mut().clear(); - let (layouts, selected_is_sole_exact_root) = + let (layouts, selected_manifest_matches_exact_root) = matching_legacy_profile_layouts_with_git_identity_resolver( &project_root, &profile_root, @@ -1471,7 +1469,7 @@ mod tests { Some("proj_unrelated") ); assert!( - selected_is_sole_exact_root, + selected_manifest_matches_exact_root, "the caller decides whether the selected exact root outranks recovery" ); assert_eq!( @@ -1550,7 +1548,7 @@ mod tests { write_manifest(&profile_root, "proj_historical", &historical_root); let resolver_calls = RefCell::new(Vec::new()); - let (layouts, selected_is_sole_exact_root) = + let (layouts, selected_manifest_matches_exact_root) = matching_legacy_profile_layouts_with_git_identity_resolver( &worktree_root, &profile_root, @@ -1569,7 +1567,7 @@ mod tests { .unwrap(); assert_eq!(layouts.len(), 1); - assert!(!selected_is_sole_exact_root); + assert!(!selected_manifest_matches_exact_root); assert_eq!( resolver_calls.borrow().as_slice(), [worktree_root, historical_root], diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index 614a7a42e..fc6541a21 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -210,13 +210,13 @@ impl TraceDecay { // stay behind the rare paths that actually compare stores. Resolving a // layout is on every open, including fail-closed clients that must not // touch the store at all. - let (candidates, selected_is_sole_exact_root) = + let (candidates, selected_manifest_matches_exact_root) = storage::matching_legacy_profile_layouts(project_root, &profile_root, selected_id)?; Self::choose_identity_layout( project_root, selected, candidates, - selected_is_sole_exact_root, + selected_manifest_matches_exact_root, allow_repair, ) .await? @@ -230,12 +230,14 @@ impl TraceDecay { project_root: &Path, selected: Option, candidates: Vec, - selected_is_sole_exact_root: bool, + selected_manifest_matches_exact_root: bool, allow_repair: bool, ) -> Result> { - // With no competing candidate the selected layout wins regardless, so - // skip the inventory rather than opening the store to confirm it. - if selected_is_sole_exact_root + // A healthy populated store selected by the repository marker or + // registry remains authoritative when its own manifest names this + // exact root. Legacy duplicates stay untouched, while an empty or + // unhealthy selected store still reaches the fail-closed diagnostics. + if selected_manifest_matches_exact_root && !candidates.is_empty() && let Some(selected) = selected.as_ref() { diff --git a/tests/storage_suite/storage_resolver_test.rs b/tests/storage_suite/storage_resolver_test.rs index 9be52b7b6..aa0ea6676 100644 --- a/tests/storage_suite/storage_resolver_test.rs +++ b/tests/storage_suite/storage_resolver_test.rs @@ -1394,6 +1394,67 @@ async fn linked_worktree_exact_manifest_overrides_healthy_shared_identity_store( assert_path_eq(&layout.data_root, &exact_root); } +#[tokio::test] +async fn registered_healthy_exact_root_ignores_duplicate_exact_manifests() { + let _guard = HOME_ENV_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + let home = test_home(&dir); + let profile_root = home.join(".tracedecay"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), "pub fn registered_root() {}\n").unwrap(); + let _home_guard = HomeGuard::set(&home); + init_repo_with_commit(&project); + + let selected = TraceDecay::init(&project).await.unwrap(); + selected.index_all().await.unwrap(); + let selected_project_id = selected.store_layout().identity.project_id.clone().unwrap(); + let selected_data_root = selected.store_layout().data_root.clone(); + selected.close(); + + for project_id in ["proj_duplicate_exact_one", "proj_duplicate_exact_two"] { + let data_root = profile_root.join(format!("projects/{project_id}")); + fs::create_dir_all(&data_root).unwrap(); + fs::write(data_root.join("tracedecay.db"), project_id).unwrap(); + fs::write(data_root.join("sessions.db"), b"sessions").unwrap(); + branch_meta::save_branch_meta(&data_root, &BranchMeta::new_for_dir(&data_root, "main")) + .unwrap(); + write_store_manifest_to_path( + &data_root.join(STORE_MANIFEST_FILENAME), + &StoreManifest { + schema_version: STORE_MANIFEST_SCHEMA_VERSION, + project_id: Some(project_id.to_string()), + store_kind: StoreKind::CodeProject, + storage_mode: StorageMode::ProfileSharded, + project_root: project.clone(), + data_root, + graph_db_relpath: "tracedecay.db".into(), + sessions_db_relpath: "sessions.db".into(), + branch_meta_relpath: "branch-meta.json".into(), + }, + ) + .unwrap(); + } + + let layout = TraceDecay::resolve_store_layout_for_identity(&project) + .await + .expect("a healthy selected exact-root shard must outrank duplicate legacy manifests"); + + assert_eq!( + layout.identity.project_id.as_deref(), + Some(selected_project_id.as_str()) + ); + assert_path_eq(&layout.data_root, &selected_data_root); + for project_id in ["proj_duplicate_exact_one", "proj_duplicate_exact_two"] { + assert_eq!( + fs::read_to_string(profile_root.join(format!("projects/{project_id}/tracedecay.db"))) + .unwrap(), + project_id, + "duplicate stores must remain untouched as recoverable history" + ); + } +} + #[tokio::test] async fn registered_exact_root_ignores_sibling_worktree_manifests() { let _guard = HOME_ENV_LOCK.lock().await; From ac0b0c91b45117069629b29adf1fb5300dfbef4f Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Mon, 10 Aug 2026 23:46:34 +0200 Subject: [PATCH 05/20] perf(storage): check the serving branch graph --- .changeset/exact-root-inventory-fast-path.md | 2 +- src/tracedecay/lifecycle.rs | 10 ++++++++-- tests/storage_suite/storage_resolver_test.rs | 19 +++++++++++++++++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.changeset/exact-root-inventory-fast-path.md b/.changeset/exact-root-inventory-fast-path.md index 4ef8eb56b..4124313c7 100644 --- a/.changeset/exact-root-inventory-fast-path.md +++ b/.changeset/exact-root-inventory-fast-path.md @@ -2,4 +2,4 @@ "tracedecay": patch --- -Avoid full session-table row counts while resolving a healthy exact-root project store, including when preserved duplicate manifests exist, keeping graph calls responsive when session history lives on slower storage. +Avoid full session-table row counts while resolving a healthy exact-root project store, including branch-scoped graphs and preserved duplicate manifests, keeping graph calls responsive when session history lives on slower storage. diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index fc6541a21..0b5c5c395 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -1441,9 +1441,15 @@ async fn store_identity_inventory(layout: &StoreLayout) -> StoreIdentityInventor /// ambiguity/cutover diagnostic; this predicate only needs to distinguish a /// healthy populated store from an empty or unhealthy one. async fn store_identity_is_healthy_non_pristine(layout: &StoreLayout) -> bool { - let authority = DatabaseAuthority::for_runtime(&layout.graph_db_path, "store inventory"); + let active_branch = branch::current_branch(&layout.project_root); + let (serving_graph_db_path, _, _) = TraceDecay::resolve_db_for_branch( + &layout.project_root, + &layout.data_root, + active_branch.as_deref(), + ); + let authority = DatabaseAuthority::for_runtime(&serving_graph_db_path, "store inventory"); let Ok((db, _)) = (match authority { - Ok(authority) => Database::open_read_only(&layout.graph_db_path, &authority).await, + Ok(authority) => Database::open_read_only(&serving_graph_db_path, &authority).await, Err(error) => Err(error), }) else { return false; diff --git a/tests/storage_suite/storage_resolver_test.rs b/tests/storage_suite/storage_resolver_test.rs index aa0ea6676..3817793ca 100644 --- a/tests/storage_suite/storage_resolver_test.rs +++ b/tests/storage_suite/storage_resolver_test.rs @@ -1395,7 +1395,7 @@ async fn linked_worktree_exact_manifest_overrides_healthy_shared_identity_store( } #[tokio::test] -async fn registered_healthy_exact_root_ignores_duplicate_exact_manifests() { +async fn registered_healthy_exact_branch_ignores_duplicate_exact_manifests() { let _guard = HOME_ENV_LOCK.lock().await; let dir = TempDir::new().unwrap(); let project = dir.path().join("repo"); @@ -1412,6 +1412,21 @@ async fn registered_healthy_exact_root_ignores_duplicate_exact_manifests() { let selected_data_root = selected.store_layout().data_root.clone(); selected.close(); + git( + &project, + &["checkout", "-b", "feature/selected-exact-branch"], + ); + let selected_branch = TraceDecay::open(&project) + .await + .expect("the selected store must create a healthy current-branch graph"); + selected_branch.close(); + remove_sqlite_family(&selected_data_root.join("tracedecay.db")); + fs::write( + selected_data_root.join("tracedecay.db"), + b"root graph is unavailable; current branch graph remains healthy", + ) + .unwrap(); + for project_id in ["proj_duplicate_exact_one", "proj_duplicate_exact_two"] { let data_root = profile_root.join(format!("projects/{project_id}")); fs::create_dir_all(&data_root).unwrap(); @@ -1438,7 +1453,7 @@ async fn registered_healthy_exact_root_ignores_duplicate_exact_manifests() { let layout = TraceDecay::resolve_store_layout_for_identity(&project) .await - .expect("a healthy selected exact-root shard must outrank duplicate legacy manifests"); + .expect("a healthy selected exact-branch shard must outrank duplicate legacy manifests"); assert_eq!( layout.identity.project_id.as_deref(), From 726ce54d4a2578efa8bf4774e898f4364d25bcdf Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Mon, 10 Aug 2026 23:58:11 +0200 Subject: [PATCH 06/20] perf(mcp): reuse active selected project --- .changeset/exact-root-inventory-fast-path.md | 2 +- src/mcp/tools/handlers/mod.rs | 77 ++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/.changeset/exact-root-inventory-fast-path.md b/.changeset/exact-root-inventory-fast-path.md index 4124313c7..27daaa62e 100644 --- a/.changeset/exact-root-inventory-fast-path.md +++ b/.changeset/exact-root-inventory-fast-path.md @@ -2,4 +2,4 @@ "tracedecay": patch --- -Avoid full session-table row counts while resolving a healthy exact-root project store, including branch-scoped graphs and preserved duplicate manifests, keeping graph calls responsive when session history lives on slower storage. +Avoid redundant same-project selector opens and full session-table row counts while resolving a healthy exact-root project store, including branch-scoped graphs and preserved duplicate manifests, keeping graph calls responsive when session history lives on slower storage. diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index 5f56b4964..8a2262585 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -114,6 +114,7 @@ fn rejected_tool_project_selector_present(tool_name: &str, args: &Value) -> bool } async fn selected_registered_project_reader( + active_cg: &TraceDecay, tool_name: &str, args: &Value, global_db: Option<&GlobalDb>, @@ -132,6 +133,9 @@ async fn selected_registered_project_reader( else { return Ok(None); }; + if selector_targets_active_project(active_cg, args, &context.project.project_id) { + return Ok(None); + } let global_db_path = global_db .map(|db| db.db_path().to_path_buf()) @@ -169,6 +173,36 @@ async fn selected_registered_project_reader( })) } +fn selector_targets_active_project( + active_cg: &TraceDecay, + args: &Value, + selected_project_id: &str, +) -> bool { + if active_cg.store_layout().identity.project_id.as_deref() != Some(selected_project_id) { + return false; + } + let selector = args.get("project_selector").and_then(Value::as_object); + let selector_path = selector + .and_then(|selector| { + selector + .get("path") + .or_else(|| selector.get("project_path")) + }) + .or_else(|| args.get("project_path")) + .or_else(|| args.get("project_root")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty()); + let Some(selector_path) = selector_path else { + return true; + }; + if !GlobalDb::is_explicit_project_path_selector(selector_path) { + return true; + } + GlobalDb::canonical_project_key(Path::new(selector_path)) + == GlobalDb::canonical_project_key(active_cg.project_root()) +} + fn handle_retrieve(cg: &TraceDecay, args: &Value) -> Result { let handle = args.get("handle") @@ -371,6 +405,7 @@ pub async fn handle_tool_call_with_registry_and_implicit_project( } } let selected_cg = selected_registered_project_reader( + cg, tool_name, &args, options.global_db, @@ -811,6 +846,48 @@ mod tests { } } + #[tokio::test] + async fn graph_reader_selector_reuses_the_active_project() { + 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"); + fs::create_dir_all(active_project.join("src")).unwrap(); + fs::write(active_project.join("src/active.rs"), "pub fn active() {}\n").unwrap(); + + let active = TraceDecay::init(&active_project).await.unwrap(); + let registry = GlobalDb::open().await.unwrap(); + let selected = selected_registered_project_reader( + &active, + "tracedecay_search", + &json!({"project_path": active_project.to_string_lossy()}), + Some(®istry), + false, + ) + .await + .unwrap(); + + assert!( + selected.is_none(), + "a selector resolving to the active project must reuse its already-open graph" + ); + let active_project_id = active + .store_layout() + .identity + .project_id + .as_deref() + .unwrap(); + assert!( + !selector_targets_active_project( + &active, + &json!({"project_path": dir.path().join("sibling-worktree")}), + active_project_id, + ), + "a different explicit worktree path must retain branch-specific dispatch" + ); + active.close(); + } + #[tokio::test] async fn graph_reader_selector_dispatch_targets_registered_project() { let _env_lock = lock_user_data_dir_test_env(); From 25b67d1f526f116398701d47753e877e85cd4c56 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Tue, 11 Aug 2026 10:30:15 +0200 Subject: [PATCH 07/20] perf(session): bound correlation presence checks --- .../session-correlation-presence-fast-path.md | 5 ++ .../src/runtime/git_correlation.rs | 66 ++++++++++++++++++- .../src/runtime/git_correlation/tests.rs | 44 +++++++++++++ src/global_db.rs | 11 ++++ src/mcp/tools/handlers/session.rs | 44 ++++++++----- tests/mcp_suite/git_correlation_test.rs | 6 ++ 6 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 .changeset/session-correlation-presence-fast-path.md diff --git a/.changeset/session-correlation-presence-fast-path.md b/.changeset/session-correlation-presence-fast-path.md new file mode 100644 index 000000000..7123a56e5 --- /dev/null +++ b/.changeset/session-correlation-presence-fast-path.md @@ -0,0 +1,5 @@ +--- +"tracedecay": patch +--- + +Keep `tracedecay_sessions_for` responsive on large session stores by using bounded row-presence probes for empty-index reporting instead of full correlation-table counts. diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation.rs b/crates/tracedecay-sessions/src/runtime/git_correlation.rs index 78d197872..efb7d1dae 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation.rs @@ -1316,9 +1316,69 @@ impl CorrelationIndexHealth { } } -/// Reads the correlation index health for a project store. Cheap: two counts -/// plus a metadata lookup. Never runs DDL, so a store predating the schema -/// reports `tables_present = false` with zero counts rather than erroring. +/// Bounded row-family presence for the session↔git correlation index. +/// +/// Unlike [`CorrelationIndexHealth`], this deliberately does not count rows or +/// scan for the newest write. Query paths only need to distinguish an empty +/// index from a populated index that had no match, so probing the first row of +/// each family keeps that decision constant-time on large session stores. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CorrelationIndexPresence { + pub tables_present: bool, + pub spans_present: bool, + pub commits_present: bool, +} + +impl CorrelationIndexPresence { + /// Whether the index lacks the row family needed by this reference kind. + pub const fn is_empty_for(&self, git_ref: &GitRefFilter) -> bool { + match git_ref { + GitRefFilter::Branch(_) | GitRefFilter::Worktree(_) => !self.spans_present, + GitRefFilter::Commit(_) => !self.commits_present, + } + } +} + +/// Reads only whether each correlation row family has at least one row. +/// `EXISTS` stops after the first match and avoids the full-table `COUNT(*)` +/// paid by diagnostics-oriented [`correlation_index_health`]. +pub async fn correlation_index_presence( + conn: &Connection, +) -> Result { + if !correlation_tables_present(conn).await? { + return Ok(CorrelationIndexPresence { + tables_present: false, + spans_present: false, + commits_present: false, + }); + } + let mut rows = conn + .query( + "SELECT EXISTS(SELECT 1 FROM session_git_spans LIMIT 1), + EXISTS(SELECT 1 FROM commit_sessions LIMIT 1)", + (), + ) + .await?; + let Some(row) = rows.next().await? else { + return Ok(CorrelationIndexPresence { + tables_present: true, + spans_present: false, + commits_present: false, + }); + }; + Ok(CorrelationIndexPresence { + tables_present: true, + spans_present: row.get::(0)? != 0, + commits_present: row.get::(1)? != 0, + }) +} + +/// Reads exact correlation index health for a project store. The two exact +/// counts and newest-write aggregate scan their row families, so latency grows +/// with large stores; query-time routing should use +/// [`correlation_index_presence`] instead. Never runs DDL, so a store predating +/// the schema reports `tables_present = false` with zero counts rather than +/// erroring. pub async fn correlation_index_health( conn: &Connection, ) -> Result { diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/tests.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/tests.rs index eaf2e7fc5..581c9dbb6 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/tests.rs @@ -1280,6 +1280,45 @@ async fn correlation_index_health_reports_empty_then_populated() { assert_eq!(with_watermark.backfill_watermark, Some(4_242)); } +#[tokio::test] +async fn correlation_index_presence_reports_row_families_without_counts() { + let conn = test_conn().await; + + let empty = correlation_index_presence(&conn).await.unwrap(); + assert!(empty.tables_present); + assert!(!empty.spans_present); + assert!(!empty.commits_present); + assert!(empty.is_empty_for(&GitRefFilter::Worktree("/repo".to_string()))); + + record_span_observation(&conn, &observation("s1", Some("main"), "/repo", 1_000), 600) + .await + .unwrap(); + let populated = correlation_index_presence(&conn).await.unwrap(); + assert!(populated.spans_present); + assert!(!populated.commits_present); + assert!(!populated.is_empty_for(&GitRefFilter::Branch("main".to_string()))); + assert!(populated.is_empty_for(&GitRefFilter::Commit("abcdef12".to_string()))); + + let commit = CommitSessionRecord { + commit_sha: "abcdef1234567890abcdef1234567890abcdef12".to_string(), + provider: "claude".to_string(), + session_id: "s1".to_string(), + branch: Some("main".to_string()), + worktree: Some("/repo".to_string()), + committed_at: 1_100, + span_overlap_kind: SpanOverlapKind::Direct, + span_id: None, + relation: CommitRelation::Produced, + evidence: CommitEvidence::ToolResult, + confidence: 100, + evidence_message_id: Some("m1".to_string()), + }; + assert!(upsert_commit_session(&conn, &commit).await.unwrap()); + let with_commit = correlation_index_presence(&conn).await.unwrap(); + assert!(with_commit.commits_present); + assert!(!with_commit.is_empty_for(&GitRefFilter::Commit("abcdef12".to_string()))); +} + #[tokio::test] async fn correlation_index_health_without_tables_is_empty() { // A store predating the correlation schema (no DDL run) must report an @@ -1292,4 +1331,9 @@ async fn correlation_index_health_without_tables_is_empty() { assert_eq!(health.span_count, 0); assert_eq!(health.commit_count, 0); assert_eq!(health.backfill_watermark, None); + + let presence = correlation_index_presence(&conn).await.unwrap(); + assert!(!presence.tables_present); + assert!(!presence.spans_present); + assert!(!presence.commits_present); } diff --git a/src/global_db.rs b/src/global_db.rs index 21cf378cc..9a0d5c4e3 100644 --- a/src/global_db.rs +++ b/src/global_db.rs @@ -4275,6 +4275,17 @@ impl GlobalDb { crate::sessions::git_correlation::correlation_index_health(&self.conn).await } + /// Reports bounded row-family presence for query-time empty-index checks. + /// See [`crate::sessions::git_correlation::correlation_index_presence`]. + pub async fn git_correlation_index_presence( + &self, + ) -> Result< + crate::sessions::git_correlation::CorrelationIndexPresence, + crate::sessions::git_correlation::GitCorrelationError, + > { + crate::sessions::git_correlation::correlation_index_presence(&self.conn).await + } + /// Reads one `git_correlation_meta` integer value (e.g. the auto-backfill /// watermark). Used by the incremental backfill to resume where it left off. pub async fn git_correlation_meta_get( diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index d694ae14c..cbb0eb851 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -2150,7 +2150,7 @@ pub(super) async fn handle_sessions_for(cg: &TraceDecay, args: Value) -> Result< // means nothing was ever recorded, which is a valid empty result (the // tool never ghost-creates an empty sessions.db). let db_path = cg.store_layout().sessions_db_path.clone(); - let (results, index_health, observed_fallback) = if db_path.is_file() { + let (results, index_presence, observed_fallback) = if db_path.is_file() { let Some(db) = GlobalDb::open_read_only_at(&db_path).await else { return Ok(tool_json( Some(cg.project_root()), @@ -2163,10 +2163,11 @@ pub(super) async fn handle_sessions_for(cg: &TraceDecay, args: Value) -> Result< }), )); }; - // Read the correlation-index health from the same open so an empty - // index (never populated) can be reported distinctly from a populated - // index that simply had no rows matching this git ref. - let health = db.git_correlation_index_health().await.ok(); + // Query paths need only distinguish an empty row family from a + // populated index with no match. Exact counts scan the entire + // correlation tables on large stores, so use the bounded presence + // probe here and leave exact health to diagnostics. + let presence = db.git_correlation_index_presence().await.ok(); let results = db .git_sessions_for_with_relation(&query, relation) .await @@ -2188,7 +2189,7 @@ pub(super) async fn handle_sessions_for(cg: &TraceDecay, args: Value) -> Result< } else { None }; - (results, health, observed_fallback) + (results, presence, observed_fallback) } else { // No store file at all: the correlation index was never created. (Vec::new(), None, None) @@ -2198,9 +2199,9 @@ pub(super) async fn handle_sessions_for(cg: &TraceDecay, args: Value) -> Result< // absent, or the row family for this ref kind is empty (spans for // branch/worktree, commit rows for commit). That must not read as a genuine // "no sessions matched" result. - let index_empty = index_health + let index_empty = index_presence .as_ref() - .is_none_or(|health| health.is_empty_for(&query.git_ref)); + .is_none_or(|presence| presence.is_empty_for(&query.git_ref)); let mut payload = json!({ "status": "ok", "git_ref": query.git_ref.kind(), @@ -2212,14 +2213,27 @@ pub(super) async fn handle_sessions_for(cg: &TraceDecay, args: Value) -> Result< "results": results, "index_empty": index_empty, }); - if let Some(health) = &index_health { - payload["index"] = json!({ - "tables_present": health.tables_present, - "span_count": health.span_count, - "commit_count": health.commit_count, - "last_span_write": health.last_span_write, - "backfill_watermark": health.backfill_watermark, + if let Some(presence) = &index_presence { + let mut index = json!({ + "tables_present": presence.tables_present, + "spans_present": presence.spans_present, + "commits_present": presence.commits_present, + "span_count": Value::Null, + "commit_count": Value::Null, + "last_span_write": Value::Null, + "backfill_watermark": Value::Null, + "count_mode": "presence_only", }); + // Preserve the exact-zero signal for consumers without inventing a + // count when the family is populated. Exact counts remain available + // through diagnostics, where their full scan is explicit. + if !presence.spans_present { + index["span_count"] = json!(0); + } + if !presence.commits_present { + index["commit_count"] = json!(0); + } + payload["index"] = index; } // When nothing matched, say *why*: an empty index self-heals via startup // auto-backfill (or a manual `tracedecay sessions git-backfill`), whereas a diff --git a/tests/mcp_suite/git_correlation_test.rs b/tests/mcp_suite/git_correlation_test.rs index c919a602d..51b41e387 100644 --- a/tests/mcp_suite/git_correlation_test.rs +++ b/tests/mcp_suite/git_correlation_test.rs @@ -567,6 +567,12 @@ async fn sessions_for_and_diagnostics_flag_empty_correlation_index() { .await; assert_eq!(no_match["count"], 0, "{no_match}"); assert_eq!(no_match["index_empty"], false, "{no_match}"); + assert_eq!(no_match["index"]["spans_present"], true, "{no_match}"); + assert_eq!(no_match["index"]["span_count"], Value::Null, "{no_match}"); + assert_eq!( + no_match["index"]["count_mode"], "presence_only", + "{no_match}" + ); assert!( no_match["message"] .as_str() From caba6eba7ae2a41bcd9c8827230a39db101331b0 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Tue, 11 Aug 2026 11:45:39 +0200 Subject: [PATCH 08/20] fix(daemon): bound maintenance shutdown --- .../bounded-daemon-maintenance-shutdown.md | 5 ++ .../src/db/connection.rs | 65 ++++++++++++++++++- src/daemon.rs | 4 +- src/daemon/scheduler.rs | 25 +++++-- src/daemon/tests.rs | 29 +++++++++ src/tracedecay/lifecycle.rs | 28 ++++---- 6 files changed, 132 insertions(+), 24 deletions(-) create mode 100644 .changeset/bounded-daemon-maintenance-shutdown.md diff --git a/.changeset/bounded-daemon-maintenance-shutdown.md b/.changeset/bounded-daemon-maintenance-shutdown.md new file mode 100644 index 000000000..916f252e5 --- /dev/null +++ b/.changeset/bounded-daemon-maintenance-shutdown.md @@ -0,0 +1,5 @@ +--- +"tracedecay": patch +--- + +Keep daemon shutdown bounded while background maintenance is active, and use lightweight read-only presence probes during exact-root store selection instead of full integrity scans. diff --git a/crates/tracedecay-runtime-core/src/db/connection.rs b/crates/tracedecay-runtime-core/src/db/connection.rs index 5d981a266..c7facdfc7 100644 --- a/crates/tracedecay-runtime-core/src/db/connection.rs +++ b/crates/tracedecay-runtime-core/src/db/connection.rs @@ -164,6 +164,30 @@ impl Database { db_path: &Path, authority: &DatabaseAuthority, ) -> Result<(Self, bool)> { + let database = Self::open_read_only_inner(db_path, authority, true).await?; + Ok((database, false)) + } + + /// Opens an existing database for a bounded read-only presence probe. + /// + /// This validates the SQLite header and applies read-only PRAGMAs, but it + /// deliberately does not run `PRAGMA quick_check`: callers must issue only + /// bounded queries such as `SELECT 1 ... LIMIT 1`. A subsequent serving + /// open still performs the normal full integrity validation and fails + /// closed before exposing data. + #[doc(hidden)] + pub async fn open_read_only_for_presence_probe( + db_path: &Path, + authority: &DatabaseAuthority, + ) -> Result { + Self::open_read_only_inner(db_path, authority, false).await + } + + async fn open_read_only_inner( + db_path: &Path, + authority: &DatabaseAuthority, + validate_integrity: bool, + ) -> Result { let authority = authority.hold_for(db_path, "open_read_only")?; integrity::validate_sqlite_header(db_path, "open_read_only", false)?; let db = Builder::new_local(db_path) @@ -182,7 +206,9 @@ impl Database { let file_size = std::fs::metadata(db_path).map_or(0, |m| m.len()); pragmas::apply_read_only(&conn, file_size).await?; - integrity::validate(&conn, "open_read_only").await?; + if validate_integrity { + integrity::validate(&conn, "open_read_only").await?; + } let inner = Arc::new(DatabaseInner { conn, @@ -191,7 +217,7 @@ impl Database { _authority: authority, _slot: None, }); - Ok((Self { inner }, false)) + Ok(Self { inner }) } /// Returns a reference to the underlying libsql connection. @@ -536,6 +562,41 @@ mod tests { assert_eq!(platform_safe_mmap_size(0), 0); } + #[tokio::test] + async fn presence_probe_open_is_read_only_without_full_integrity_validation() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("graph.db"); + let authority = DatabaseAuthority::acquire_test(&path, "presence probe").unwrap(); + let (seed, _) = Database::initialize(&path, &authority).await.unwrap(); + seed.conn() + .execute("CREATE TABLE probe_rows (id INTEGER)", ()) + .await + .unwrap(); + seed.conn() + .execute("INSERT INTO probe_rows (id) VALUES (1)", ()) + .await + .unwrap(); + drop(seed); + + let probe = Database::open_read_only_for_presence_probe(&path, &authority) + .await + .unwrap(); + let mut rows = probe + .conn() + .query("SELECT 1 FROM probe_rows LIMIT 1", ()) + .await + .unwrap(); + assert!(rows.next().await.unwrap().is_some()); + assert!( + probe + .conn() + .execute("INSERT INTO probe_rows (id) VALUES (2)", ()) + .await + .is_err(), + "presence probes must remain read-only" + ); + } + #[tokio::test] async fn repeated_authorized_opens_share_one_physical_connection() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/daemon.rs b/src/daemon.rs index 7ffb70728..c266eac0c 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2568,10 +2568,10 @@ impl DaemonEngine { async fn shutdown_background_tasks(&self) { self.shutdown_automation_schedulers().await; - self.git_watcher.shutdown().await; + let _ = timeout(DAEMON_TASK_ABORT_DEADLINE, self.git_watcher.shutdown()).await; if let Some(handle) = self.pr_autotrack_task.lock().await.take() { handle.abort(); - let _ = handle.await; + let _ = timeout(DAEMON_TASK_ABORT_DEADLINE, handle).await; } } diff --git a/src/daemon/scheduler.rs b/src/daemon/scheduler.rs index a0953ac1f..1e6e62c03 100644 --- a/src/daemon/scheduler.rs +++ b/src/daemon/scheduler.rs @@ -316,22 +316,35 @@ impl DaemonEngine { } pub(super) async fn shutdown_automation_schedulers(&self) { - let scheduler_handles: Vec> = self - .store_administration - .with_writer(|| async { + self.shutdown_automation_schedulers_with_deadline(DAEMON_TASK_ABORT_DEADLINE) + .await; + } + + pub(super) async fn shutdown_automation_schedulers_with_deadline(&self, deadline: Duration) { + let scheduler_handles: Vec> = match timeout( + deadline, + self.store_administration.with_writer(|| async { let mut schedulers = self .store_administration .automation_schedulers() .lock() .await; schedulers.drain().map(|(_, handle)| handle.task).collect() - }) - .await; + }), + ) + .await + { + Ok(handles) => handles, + Err(_) => { + log_daemon_event("daemon_shutdown", &[("outcome", "timeout".to_string())]); + return; + } + }; let _child_shutdown = crate::sessions::codex_app_server::begin_codex_app_server_shutdown(); for handle in &scheduler_handles { handle.abort(); } - let _ = timeout(DAEMON_TASK_ABORT_DEADLINE, async { + let _ = timeout(deadline, async { for handle in scheduler_handles { let _ = handle.await; } diff --git a/src/daemon/tests.rs b/src/daemon/tests.rs index 86c9fddd7..d2223e1cf 100644 --- a/src/daemon/tests.rs +++ b/src/daemon/tests.rs @@ -889,6 +889,35 @@ async fn daemon_scheduler_shutdown_aborts_and_joins_every_loop() { ); } +#[cfg(unix)] +#[tokio::test] +async fn daemon_scheduler_shutdown_is_bounded_while_writer_gate_is_busy() { + let engine = DaemonEngine::default(); + let administration = engine.store_administration.clone(); + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let blocker = tokio::spawn(async move { + administration + .with_writer(|| async move { + entered_tx.send(()).expect("announce writer gate"); + release_rx.await.expect("release writer gate"); + }) + .await; + }); + entered_rx.await.expect("writer gate acquired"); + + engine.lifecycle.begin_draining(); + tokio::time::timeout( + tokio::time::Duration::from_millis(250), + engine.shutdown_automation_schedulers_with_deadline(tokio::time::Duration::from_millis(25)), + ) + .await + .expect("scheduler shutdown must not wait indefinitely for writer administration"); + + release_tx.send(()).expect("release writer gate"); + blocker.await.expect("writer gate blocker task"); +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn project_server_cache_hit_skips_open_and_singleflights_first_miss() { diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index 0b5c5c395..ee70af62f 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -233,15 +233,17 @@ impl TraceDecay { selected_manifest_matches_exact_root: bool, allow_repair: bool, ) -> Result> { - // A healthy populated store selected by the repository marker or - // registry remains authoritative when its own manifest names this - // exact root. Legacy duplicates stay untouched, while an empty or - // unhealthy selected store still reaches the fail-closed diagnostics. + // A populated store selected by the repository marker or registry + // remains authoritative when its own manifest names this exact root. + // This resolver uses bounded presence probes only; the subsequent + // serving open performs full integrity validation and fails closed. + // Legacy duplicates stay untouched, while an empty or unreadable + // selected store still reaches the fail-closed diagnostics. if selected_manifest_matches_exact_root && !candidates.is_empty() && let Some(selected) = selected.as_ref() { - if store_identity_is_healthy_non_pristine(selected).await { + if store_identity_has_bounded_population_evidence(selected).await { return Ok(Some(selected.clone())); } } @@ -1440,7 +1442,7 @@ async fn store_identity_inventory(layout: &StoreLayout) -> StoreIdentityInventor /// session tables. Exact counts are needed only when constructing an /// ambiguity/cutover diagnostic; this predicate only needs to distinguish a /// healthy populated store from an empty or unhealthy one. -async fn store_identity_is_healthy_non_pristine(layout: &StoreLayout) -> bool { +async fn store_identity_has_bounded_population_evidence(layout: &StoreLayout) -> bool { let active_branch = branch::current_branch(&layout.project_root); let (serving_graph_db_path, _, _) = TraceDecay::resolve_db_for_branch( &layout.project_root, @@ -1448,18 +1450,16 @@ async fn store_identity_is_healthy_non_pristine(layout: &StoreLayout) -> bool { active_branch.as_deref(), ); let authority = DatabaseAuthority::for_runtime(&serving_graph_db_path, "store inventory"); - let Ok((db, _)) = (match authority { - Ok(authority) => Database::open_read_only(&serving_graph_db_path, &authority).await, + let Ok(db) = (match authority { + Ok(authority) => { + Database::open_read_only_for_presence_probe(&serving_graph_db_path, &authority).await + } Err(error) => Err(error), }) else { return false; }; - let Ok(stats) = db.get_stats().await else { - db.close(); - return false; - }; - let graph_is_populated = stats.node_count > 0 - || stats.file_count > 0 + let graph_is_populated = table_has_rows(db.conn(), "nodes").await + || table_has_rows(db.conn(), "files").await || table_has_rows(db.conn(), "memory_facts").await; db.close(); if graph_is_populated { From fa935352e75c9cdb9b22ea3f513ac247c002eaae Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Tue, 11 Aug 2026 12:04:41 +0200 Subject: [PATCH 09/20] fix(daemon): abort watchers before joins --- src/daemon.rs | 2 +- src/daemon/git_watch.rs | 21 ++++++++-- src/daemon/git_watch/tests.rs | 77 +++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index c266eac0c..ac7f2b74f 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2568,7 +2568,7 @@ impl DaemonEngine { async fn shutdown_background_tasks(&self) { self.shutdown_automation_schedulers().await; - let _ = timeout(DAEMON_TASK_ABORT_DEADLINE, self.git_watcher.shutdown()).await; + self.git_watcher.shutdown().await; if let Some(handle) = self.pr_autotrack_task.lock().await.take() { handle.abort(); let _ = timeout(DAEMON_TASK_ABORT_DEADLINE, handle).await; diff --git a/src/daemon/git_watch.rs b/src/daemon/git_watch.rs index 2e039b07a..77870c7e6 100644 --- a/src/daemon/git_watch.rs +++ b/src/daemon/git_watch.rs @@ -377,13 +377,18 @@ impl GitWatcher { /// Stops every watcher-owned task and joins it before database shutdown. pub async fn shutdown(&self) { + self.shutdown_with_deadline(super::DAEMON_TASK_ABORT_DEADLINE) + .await; + } + + async fn shutdown_with_deadline(&self, deadline: Duration) { if !self.inner.enabled || self.inner.shutting_down.swap(true, Ordering::AcqRel) { return; } + let mut handles = Vec::new(); if let Some(handle) = self.inner.backstop_task.lock().await.take() { - handle.abort(); - let _ = handle.await; + handles.push(handle); } let states: Vec> = { @@ -392,10 +397,18 @@ impl GitWatcher { }; for state in states { if let Some(handle) = state.task.lock().await.take() { - handle.abort(); - let _ = handle.await; + handles.push(handle); } } + for handle in &handles { + handle.abort(); + } + let _ = tokio::time::timeout(deadline, async { + for handle in handles { + let _ = handle.await; + } + }) + .await; } /// A doctor-facing snapshot of every registered project's watch health. diff --git a/src/daemon/git_watch/tests.rs b/src/daemon/git_watch/tests.rs index 178b3c254..217180e81 100644 --- a/src/daemon/git_watch/tests.rs +++ b/src/daemon/git_watch/tests.rs @@ -298,6 +298,83 @@ async fn shutdown_cancels_and_joins_watcher_tasks() { assert!(watcher.inner.backstop_task.lock().await.is_none()); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shutdown_deadline_aborts_project_tasks_before_waiting_for_blocked_backstop() { + struct NotifyOnDrop(Option>); + + impl Drop for NotifyOnDrop { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } + } + + let watcher = GitWatcher::new(fast_watch_config()); + let project_path = PathBuf::from("/tmp/shutdown-deadline-project"); + let (project_started_tx, project_started_rx) = oneshot::channel(); + let (project_aborted_tx, project_aborted_rx) = oneshot::channel(); + let project_task = tokio::spawn(async move { + let _notify_on_drop = NotifyOnDrop(Some(project_aborted_tx)); + project_started_tx.send(()).expect("announce project task"); + std::future::pending::<()>().await; + }); + project_started_rx.await.expect("project task started"); + let state = Arc::new(WatchState { + project_root: project_path.clone(), + dirty: Mutex::new(DirtySet::default()), + wake: Notify::new(), + health: ProjectHealth::default(), + task: Mutex::new(Some(project_task)), + entered_debounce: Notify::new(), + drained_plans: AtomicU64::new(0), + plan_drained: Notify::new(), + }); + watcher + .inner + .projects + .lock() + .await + .insert(project_path, Arc::clone(&state)); + + let (backstop_started_tx, backstop_started_rx) = oneshot::channel(); + let (release_backstop_tx, release_backstop_rx) = std::sync::mpsc::channel(); + let (backstop_finished_tx, backstop_finished_rx) = oneshot::channel(); + let backstop_task = tokio::task::spawn_blocking(move || { + backstop_started_tx + .send(()) + .expect("announce blocked backstop"); + release_backstop_rx + .recv() + .expect("release blocked backstop"); + let _ = backstop_finished_tx.send(()); + }); + backstop_started_rx.await.expect("backstop task started"); + *watcher.inner.backstop_task.lock().await = Some(backstop_task); + + tokio::time::timeout( + Duration::from_millis(250), + watcher.shutdown_with_deadline(Duration::from_millis(25)), + ) + .await + .expect("shutdown must return by its deadline"); + tokio::time::timeout(Duration::from_millis(100), project_aborted_rx) + .await + .expect("project task must be aborted before waiting on the blocked backstop") + .expect("project abort notification"); + + assert!(watcher.inner.projects.lock().await.is_empty()); + assert!(state.task.lock().await.is_none()); + assert!(watcher.inner.backstop_task.lock().await.is_none()); + + release_backstop_tx + .send(()) + .expect("release blocked backstop"); + backstop_finished_rx + .await + .expect("blocked backstop finished"); +} + /// The safety-critical property that justifies this metadata watcher over the /// removed #80 working-tree watcher: a plain source-file edit (no git /// operation) must NOT trigger any watcher sync. We drive the REAL watcher From a2994101fb83565eae98151308fabb68682ce2e3 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Tue, 11 Aug 2026 12:30:08 +0200 Subject: [PATCH 10/20] perf(storage): bound conflict inventory --- src/tracedecay/lifecycle.rs | 135 +++++++++++-------- tests/storage_suite/storage_resolver_test.rs | 23 ++++ 2 files changed, 104 insertions(+), 54 deletions(-) diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index ee70af62f..b26dc6e88 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -301,6 +301,14 @@ impl TraceDecay { "safe empty-store repair is available during a writable open", )); } + if !store_identity_integrity_is_healthy(&candidate).await { + return Err(identity_cutover_conflict( + project_root, + &selected_inventory, + &candidate_inventory, + "candidate failed full integrity validation; no repair was attempted", + )); + } let candidate_id = candidate.identity.project_id.as_deref().ok_or_else(|| { TraceDecayError::Config { message: "legacy candidate has no project id".to_string(), @@ -312,6 +320,14 @@ impl TraceDecay { } if candidate_inventory.is_pristine() && selected_inventory.is_healthy() { if allow_repair { + if !store_identity_integrity_is_healthy(&selected).await { + return Err(identity_cutover_conflict( + project_root, + &selected_inventory, + &candidate_inventory, + "selected store failed full integrity validation; no repair was attempted", + )); + } let selected_id = selected.identity.project_id.as_deref().ok_or_else(|| { TraceDecayError::Config { message: "selected store has no project id".to_string(), @@ -1361,7 +1377,7 @@ impl std::fmt::Display for StoreIdentityInventory { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( formatter, - "project_id={} path='{}' graph_health={} nodes={} files={} facts={} sessions={} messages={} lcm={} branches={} automation_files={} payload_files={} response_files={}", + "project_id={} path='{}' graph_health={} count_mode=presence_only nodes={} files={} facts={} sessions={} messages={} lcm={} branches={} automation_files={} payload_files={} response_files={}", self.project_id, self.data_root.display(), self.graph_health, @@ -1382,18 +1398,27 @@ impl std::fmt::Display for StoreIdentityInventory { async fn store_identity_inventory(layout: &StoreLayout) -> StoreIdentityInventory { let authority = DatabaseAuthority::for_runtime(&layout.graph_db_path, "store inventory"); let open_result = match authority { - Ok(authority) => Database::open_read_only(&layout.graph_db_path, &authority).await, + Ok(authority) => { + Database::open_read_only_for_presence_probe(&layout.graph_db_path, &authority).await + } Err(error) => Err(error), }; let (graph_health, nodes, files, facts) = match open_result { - Ok((db, _)) => { - if let Ok(stats) = db.get_stats().await { - let facts = count_rows(db.conn(), "memory_facts").await; - db.close(); - ("healthy", stats.node_count, stats.file_count, facts) - } else { - db.close(); - ("corrupt", 0, 0, 0) + Ok(db) => { + let presence = ( + table_presence(db.conn(), "nodes").await, + table_presence(db.conn(), "files").await, + table_presence(db.conn(), "memory_facts").await, + ); + db.close(); + match presence { + (Ok(nodes), Ok(files), Ok(facts)) => ( + "healthy", + u64::from(nodes), + u64::from(files), + u64::from(facts), + ), + _ => ("corrupt", 0, 0, 0), } } Err(_) if layout.graph_db_path.exists() => ("corrupt", 0, 0, 0), @@ -1405,10 +1430,12 @@ async fn store_identity_inventory(layout: &StoreLayout) -> StoreIdentityInventor { let conn = db.dashboard_connection(); let counts = ( - count_rows(&conn, "sessions").await, - count_rows(&conn, "session_messages").await, - count_rows(&conn, "lcm_raw_messages").await - + count_rows(&conn, "lcm_summary_nodes").await, + u64::from(table_has_rows(&conn, "sessions").await), + u64::from(table_has_rows(&conn, "session_messages").await), + u64::from( + table_has_rows(&conn, "lcm_raw_messages").await + || table_has_rows(&conn, "lcm_summary_nodes").await, + ), ); db.close(); counts @@ -1432,12 +1459,24 @@ async fn store_identity_inventory(layout: &StoreLayout) -> StoreIdentityInventor lcm_rows, branches: branch_meta::load_branch_meta(&layout.data_root) .map_or(0, |meta| meta.branches.len()), - automation_files: count_tree_files(&layout.dashboard_root), - payload_files: count_tree_files(&layout.lcm_payload_root), - response_files: count_tree_files(&layout.response_handle_root), + automation_files: u64::from(tree_has_files(&layout.dashboard_root)), + payload_files: u64::from(tree_has_files(&layout.lcm_payload_root)), + response_files: u64::from(tree_has_files(&layout.response_handle_root)), } } +async fn store_identity_integrity_is_healthy(layout: &StoreLayout) -> bool { + let Ok(authority) = DatabaseAuthority::for_runtime(&layout.graph_db_path, "store repair") + else { + return false; + }; + let Ok((db, _)) = Database::open_read_only(&layout.graph_db_path, &authority).await else { + return false; + }; + db.close(); + true +} + /// Check the common exact-root case without counting every row in large /// session tables. Exact counts are needed only when constructing an /// ambiguity/cutover diagnostic; this predicate only needs to distinguish a @@ -1480,50 +1519,38 @@ async fn store_identity_has_bounded_population_evidence(layout: &StoreLayout) -> } branch_meta::load_branch_meta(&layout.data_root).is_some_and(|meta| meta.branches.len() > 1) - || count_tree_files(&layout.dashboard_root) > 0 - || count_tree_files(&layout.lcm_payload_root) > 0 - || count_tree_files(&layout.response_handle_root) > 0 + || tree_has_files(&layout.dashboard_root) + || tree_has_files(&layout.lcm_payload_root) + || tree_has_files(&layout.response_handle_root) } async fn table_has_rows(connection: &libsql::Connection, table: &str) -> bool { - let Ok(mut rows) = connection - .query(&format!("SELECT 1 FROM {table} LIMIT 1"), ()) - .await - else { - return false; - }; - rows.next().await.ok().flatten().is_some() + table_presence(connection, table).await.unwrap_or(false) } -async fn count_rows(connection: &libsql::Connection, table: &str) -> u64 { - let Ok(mut rows) = connection - .query(&format!("SELECT COUNT(*) FROM {table}"), ()) - .await - else { - return 0; - }; - rows.next() - .await - .ok() - .flatten() - .and_then(|row| row.get::(0).ok()) - .and_then(|count| u64::try_from(count).ok()) - .unwrap_or(0) +async fn table_presence(connection: &libsql::Connection, table: &str) -> Result { + let mut rows = connection + .query(&format!("SELECT 1 FROM {table} LIMIT 1"), ()) + .await?; + Ok(rows.next().await?.is_some()) } -fn count_tree_files(root: &Path) -> u64 { - let Ok(entries) = std::fs::read_dir(root) else { - return 0; - }; - entries - .flatten() - .map(|entry| entry.path()) - .map(|path| match std::fs::symlink_metadata(&path) { - Ok(metadata) if metadata.is_file() => 1, - Ok(metadata) if metadata.is_dir() => count_tree_files(&path), - _ => 0, - }) - .sum() +fn tree_has_files(root: &Path) -> bool { + let mut pending = vec![root.to_path_buf()]; + while let Some(directory) = pending.pop() { + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.is_file() => return true, + Ok(metadata) if metadata.is_dir() => pending.push(path), + _ => {} + } + } + } + false } fn identity_cutover_conflict( diff --git a/tests/storage_suite/storage_resolver_test.rs b/tests/storage_suite/storage_resolver_test.rs index 3817793ca..b9b3f8074 100644 --- a/tests/storage_suite/storage_resolver_test.rs +++ b/tests/storage_suite/storage_resolver_test.rs @@ -1126,6 +1126,9 @@ async fn corrupt_nonempty_cutover_store_reports_both_shards_without_switching() old.add_fact(fact_request("legacy split identity fact")) .await .unwrap(); + old.add_fact(fact_request("second legacy split identity fact")) + .await + .unwrap(); let original_root = old.store_layout().data_root.clone(); old.checkpoint().await.unwrap(); old.close(); @@ -1160,6 +1163,25 @@ async fn corrupt_nonempty_cutover_store_reports_both_shards_without_switching() }) .await ); + assert!( + sessions + .upsert_session(&SessionRecord { + provider: "codex".to_string(), + session_id: "second-cutover-session".to_string(), + project_key: cutover_project_id.clone(), + project_path: project.to_string_lossy().to_string(), + title: Some("second cutover session".to_string()), + started_at: Some(1_800_000_011), + ended_at: None, + transcript_path: None, + metadata_json: None, + parent_session_id: None, + is_subagent: false, + agent_id: None, + parent_tool_use_id: None, + }) + .await + ); sessions.checkpoint().await; sessions.close(); write_repository_identity_marker(&project, &cutover_project_id).unwrap(); @@ -1172,6 +1194,7 @@ async fn corrupt_nonempty_cutover_store_reports_both_shards_without_switching() assert!(message.contains(&cutover_project_id), "{message}"); assert!(message.contains(legacy_project_id), "{message}"); assert!(message.contains("graph_health=corrupt"), "{message}"); + assert!(message.contains("count_mode=presence_only"), "{message}"); assert!(message.contains("sessions=1"), "{message}"); assert!(message.contains("facts=1"), "{message}"); assert!(message.contains("no files changed"), "{message}"); From c8dd768126383ea7364724abdbd05e23c48ea27c Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Tue, 11 Aug 2026 12:30:52 +0200 Subject: [PATCH 11/20] docs(changeset): cover bounded inventory --- .changeset/bounded-daemon-maintenance-shutdown.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/bounded-daemon-maintenance-shutdown.md b/.changeset/bounded-daemon-maintenance-shutdown.md index 916f252e5..86ae20721 100644 --- a/.changeset/bounded-daemon-maintenance-shutdown.md +++ b/.changeset/bounded-daemon-maintenance-shutdown.md @@ -2,4 +2,4 @@ "tracedecay": patch --- -Keep daemon shutdown bounded while background maintenance is active, and use lightweight read-only presence probes during exact-root store selection instead of full integrity scans. +Keep daemon shutdown bounded while background maintenance is active, and use lightweight read-only presence probes during exact-root store selection and identity-conflict reporting instead of full store scans. From 6e92ed4f3d5501700a666b3b103119413b70d6d1 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Tue, 11 Aug 2026 12:40:38 +0200 Subject: [PATCH 12/20] fix(storage): fail closed on unreadable inventory --- src/tracedecay/lifecycle.rs | 117 +++++++++++++------ tests/storage_suite/storage_resolver_test.rs | 54 +++++++++ 2 files changed, 136 insertions(+), 35 deletions(-) diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index b26dc6e88..1cf23e0dc 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -1341,6 +1341,7 @@ struct StoreIdentityInventory { project_id: String, data_root: PathBuf, graph_health: &'static str, + auxiliary_health: &'static str, nodes: u64, files: u64, facts: u64, @@ -1355,7 +1356,7 @@ struct StoreIdentityInventory { impl StoreIdentityInventory { fn is_healthy(&self) -> bool { - self.graph_health == "healthy" + self.graph_health == "healthy" && self.auxiliary_health == "healthy" } fn is_pristine(&self) -> bool { @@ -1377,10 +1378,11 @@ impl std::fmt::Display for StoreIdentityInventory { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( formatter, - "project_id={} path='{}' graph_health={} count_mode=presence_only nodes={} files={} facts={} sessions={} messages={} lcm={} branches={} automation_files={} payload_files={} response_files={}", + "project_id={} path='{}' graph_health={} auxiliary_health={} count_mode=presence_only nodes={} files={} facts={} sessions={} messages={} lcm={} branches={} automation_files={} payload_files={} response_files={}", self.project_id, self.data_root.display(), self.graph_health, + self.auxiliary_health, self.nodes, self.files, self.facts, @@ -1425,23 +1427,48 @@ async fn store_identity_inventory(layout: &StoreLayout) -> StoreIdentityInventor Err(_) => ("missing", 0, 0, 0), }; - let (sessions, messages, lcm_rows) = if let Some(db) = - crate::global_db::GlobalDb::open_read_only_at(&layout.sessions_db_path).await - { - let conn = db.dashboard_connection(); - let counts = ( - u64::from(table_has_rows(&conn, "sessions").await), - u64::from(table_has_rows(&conn, "session_messages").await), - u64::from( - table_has_rows(&conn, "lcm_raw_messages").await - || table_has_rows(&conn, "lcm_summary_nodes").await, - ), - ); - db.close(); - counts - } else { - (0, 0, 0) - }; + let session_presence: Result<(u64, u64, u64)> = + match crate::global_db::GlobalDb::open_read_only_at(&layout.sessions_db_path).await { + Some(db) => { + let conn = db.dashboard_connection(); + let presence = async { + let sessions = table_presence(&conn, "sessions").await?; + let messages = table_presence(&conn, "session_messages").await?; + let raw_lcm = table_presence(&conn, "lcm_raw_messages").await?; + let summary_lcm = table_presence(&conn, "lcm_summary_nodes").await?; + Ok(( + u64::from(sessions), + u64::from(messages), + u64::from(raw_lcm || summary_lcm), + )) + } + .await; + db.close(); + presence + } + None if layout.sessions_db_path.exists() => Err(TraceDecayError::Config { + message: "store session inventory is unreadable".to_string(), + }), + None => Ok((0, 0, 0)), + }; + + let branch_presence = branch_inventory(&layout.data_root); + let tree_presence: std::io::Result<(u64, u64, u64)> = (|| { + Ok(( + u64::from(tree_has_files(&layout.dashboard_root)?), + u64::from(tree_has_files(&layout.lcm_payload_root)?), + u64::from(tree_has_files(&layout.response_handle_root)?), + )) + })(); + let auxiliary_health = + if session_presence.is_ok() && branch_presence.is_ok() && tree_presence.is_ok() { + "healthy" + } else { + "unreadable" + }; + let (sessions, messages, lcm_rows) = session_presence.unwrap_or((0, 0, 0)); + let branches = branch_presence.unwrap_or(0); + let (automation_files, payload_files, response_files) = tree_presence.unwrap_or((0, 0, 0)); StoreIdentityInventory { project_id: layout @@ -1451,17 +1478,17 @@ async fn store_identity_inventory(layout: &StoreLayout) -> StoreIdentityInventor .unwrap_or_else(|| "unknown".to_string()), data_root: layout.data_root.clone(), graph_health, + auxiliary_health, nodes, files, facts, sessions, messages, lcm_rows, - branches: branch_meta::load_branch_meta(&layout.data_root) - .map_or(0, |meta| meta.branches.len()), - automation_files: u64::from(tree_has_files(&layout.dashboard_root)), - payload_files: u64::from(tree_has_files(&layout.lcm_payload_root)), - response_files: u64::from(tree_has_files(&layout.response_handle_root)), + branches, + automation_files, + payload_files, + response_files, } } @@ -1519,9 +1546,9 @@ async fn store_identity_has_bounded_population_evidence(layout: &StoreLayout) -> } branch_meta::load_branch_meta(&layout.data_root).is_some_and(|meta| meta.branches.len() > 1) - || tree_has_files(&layout.dashboard_root) - || tree_has_files(&layout.lcm_payload_root) - || tree_has_files(&layout.response_handle_root) + || tree_has_files(&layout.dashboard_root).unwrap_or(true) + || tree_has_files(&layout.lcm_payload_root).unwrap_or(true) + || tree_has_files(&layout.response_handle_root).unwrap_or(true) } async fn table_has_rows(connection: &libsql::Connection, table: &str) -> bool { @@ -1535,22 +1562,42 @@ async fn table_presence(connection: &libsql::Connection, table: &str) -> Result< Ok(rows.next().await?.is_some()) } -fn tree_has_files(root: &Path) -> bool { +fn branch_inventory(data_root: &Path) -> std::result::Result { + let path = data_root.join(storage::BRANCH_META_FILENAME); + match std::fs::symlink_metadata(&path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0), + Err(_) => Err(()), + Ok(metadata) if metadata.file_type().is_file() => branch_meta::load_branch_meta(data_root) + .map(|meta| meta.branches.len()) + .ok_or(()), + Ok(_) => Err(()), + } +} + +fn tree_has_files(root: &Path) -> std::io::Result { let mut pending = vec![root.to_path_buf()]; while let Some(directory) = pending.pop() { - let Ok(entries) = std::fs::read_dir(directory) else { - continue; + let entries = match std::fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), }; - for entry in entries.flatten() { - let path = entry.path(); + for entry in entries { + let path = match entry { + Ok(entry) => entry.path(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + }; match std::fs::symlink_metadata(&path) { - Ok(metadata) if metadata.is_file() => return true, + Ok(metadata) if metadata.is_file() => return Ok(true), Ok(metadata) if metadata.is_dir() => pending.push(path), - _ => {} + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), } } } - false + Ok(false) } fn identity_cutover_conflict( diff --git a/tests/storage_suite/storage_resolver_test.rs b/tests/storage_suite/storage_resolver_test.rs index b9b3f8074..393add81e 100644 --- a/tests/storage_suite/storage_resolver_test.rs +++ b/tests/storage_suite/storage_resolver_test.rs @@ -1053,6 +1053,60 @@ async fn empty_cutover_store_is_atomically_replaced_by_healthy_legacy_store() { repaired.close(); } +#[tokio::test] +async fn unreadable_cutover_sessions_block_identity_repair() { + let _guard = HOME_ENV_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + let home = test_home(&dir); + let profile_root = home.join(".tracedecay"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), "pub fn guarded_cutover() {}\n").unwrap(); + let _home_guard = HomeGuard::set(&home); + init_repo_with_commit(&project); + + let old = TraceDecay::init(&project).await.unwrap(); + old.add_fact(fact_request("healthy guarded legacy fact")) + .await + .unwrap(); + let original_root = old.store_layout().data_root.clone(); + old.checkpoint().await.unwrap(); + old.close(); + fs::remove_file(repository_identity_path(&project).unwrap()).unwrap(); + remove_sqlite_family(&profile_root.join("global.db")); + + let legacy_project_id = "proj_guarded_legacy"; + let legacy_root = profile_root.join(format!("projects/{legacy_project_id}")); + relocate_store_as_legacy(&original_root, &legacy_root, &project, legacy_project_id); + + let cutover = default_profile_sharded_layout(&project, &profile_root).unwrap(); + let cutover_project_id = cutover.identity.project_id.clone().unwrap(); + initialize_empty_profile_layout(&cutover).await; + remove_sqlite_family(&cutover.sessions_db_path); + fs::create_dir_all(&cutover.sessions_db_path).unwrap(); + write_repository_identity_marker(&project, &cutover_project_id).unwrap(); + + let error = match TraceDecay::open(&project).await { + Ok(graph) => { + graph.close(); + panic!("unreadable auxiliary state must block identity repair"); + } + Err(error) => error, + }; + let message = error.to_string(); + assert!(message.contains("identity cutover conflict"), "{message}"); + assert!(message.contains("auxiliary_health=unreadable"), "{message}"); + assert!(message.contains("no files changed"), "{message}"); + assert_eq!( + read_repository_identity_marker(&project) + .unwrap() + .unwrap() + .project_id, + cutover_project_id + ); + assert!(cutover.data_root.join(STORE_MANIFEST_FILENAME).is_file()); +} + #[tokio::test] async fn empty_cutover_store_adopts_healthy_legacy_linked_worktree_store() { let _guard = HOME_ENV_LOCK.lock().await; From 2467ff9dc5609916a28304384f0b1341639e3050 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Tue, 11 Aug 2026 13:17:15 +0200 Subject: [PATCH 13/20] perf(storage): avoid duplicate recovery scans --- .../bounded-daemon-maintenance-shutdown.md | 2 +- src/tracedecay/lifecycle.rs | 76 +++---------------- 2 files changed, 12 insertions(+), 66 deletions(-) diff --git a/.changeset/bounded-daemon-maintenance-shutdown.md b/.changeset/bounded-daemon-maintenance-shutdown.md index 86ae20721..5915262bd 100644 --- a/.changeset/bounded-daemon-maintenance-shutdown.md +++ b/.changeset/bounded-daemon-maintenance-shutdown.md @@ -2,4 +2,4 @@ "tracedecay": patch --- -Keep daemon shutdown bounded while background maintenance is active, and use lightweight read-only presence probes during exact-root store selection and identity-conflict reporting instead of full store scans. +Keep daemon shutdown bounded while background maintenance is active, use lightweight read-only presence probes during exact-root store selection and identity-conflict reporting, and avoid repeating successful full integrity scans during crash recovery. diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index 1cf23e0dc..502a5943e 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -449,35 +449,11 @@ impl TraceDecay { .await; } }; - let integrity = verification.quick_check().await; + // `open_read_only` already completes the full read-only integrity + // validation before returning. Repeating `quick_check` here (and + // again after the writable open) turns one crash-recovery scan + // into three identical full-store scans. verification.close(); - match integrity { - Ok(true) => {} - Ok(false) => { - drop(recovery_lock); - return Self::recover_corrupt_branch_or_fail( - project_root, - open_options, - &store_layout, - &db_path, - "read-only SQLite quick_check did not return ok", - repair_corrupt_branch, - ) - .await; - } - Err(error) => { - drop(recovery_lock); - return Self::recover_corrupt_branch_or_fail( - project_root, - open_options, - &store_layout, - &db_path, - error, - repair_corrupt_branch, - ) - .await; - } - } } // Ordinary opens never replace database files. A daemon or another MCP @@ -505,43 +481,13 @@ impl TraceDecay { == Some(FULL_REINDEX_REQUIRED_VALUE); let needs_reindex = migrated || reindex_pending; - // If the sentinel was set but the database opened successfully, run a - // quick integrity check. - if crashed { - match db.quick_check().await { - Ok(true) => { - if !needs_reindex { - clear_dirty_sentinel_at(&active_graph_layout.dirty_path); - clear_dirty_sentinel_at(&store_layout.dirty_path); - } - } - Ok(false) => { - db.close(); - drop(recovery_lock); - return Self::recover_corrupt_branch_or_fail( - project_root, - open_options, - &store_layout, - &db_path, - "SQLite quick_check did not return ok", - repair_corrupt_branch, - ) - .await; - } - Err(e) => { - db.close(); - drop(recovery_lock); - return Self::recover_corrupt_branch_or_fail( - project_root, - open_options, - &store_layout, - &db_path, - e, - repair_corrupt_branch, - ) - .await; - } - } + // The read-only preflight above already validated the exact WAL-aware + // recovery set while both locks were held. A successful writable open + // does not need to scan the same pages again. Reindexing owns sentinel + // cleanup when migration requires it. + if crashed && !needs_reindex { + clear_dirty_sentinel_at(&active_graph_layout.dirty_path); + clear_dirty_sentinel_at(&store_layout.dirty_path); } let ts = Self { From 82876ea591988eb0e3d242f1e3d727a18991d81d Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Tue, 11 Aug 2026 14:04:33 +0200 Subject: [PATCH 14/20] fix(storage): isolate branch recovery markers Branch-scoped stores must not inherit repository-wide dirty state from sibling worktree syncs. Reuse Database::open's read-only validation to avoid a second full recovery scan. --- src/tracedecay/lifecycle.rs | 43 ++++--------- src/tracedecay/locking.rs | 4 +- tests/storage_suite/branch_db_safety_test.rs | 64 +++++++++++++++++++- 3 files changed, 77 insertions(+), 34 deletions(-) diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index 502a5943e..4f6dcc601 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -411,8 +411,9 @@ impl TraceDecay { // If the dirty sentinel exists, a previous sync/index was interrupted. // Check integrity and rebuild if necessary. + let active_graph_is_root = db_path == store_layout.graph_db_path; let crashed = has_dirty_sentinel_at(&active_graph_layout.dirty_path) - || has_dirty_sentinel_at(&store_layout.dirty_path); + || (active_graph_is_root && has_dirty_sentinel_at(&store_layout.dirty_path)); if crashed { eprintln!( "[tracedecay] previous operation was interrupted — checking database integrity…" @@ -421,9 +422,9 @@ impl TraceDecay { // A dirty marker can also describe a sync that is still active in a // peer process. Recovery must own both graph-local and legacy locks so - // it cannot race that writer or clear its sentinel. Preflight through - // the read-only connection before Database::open applies writable - // pragmas or migrations to a potentially damaged recovery set. + // it cannot race that writer or clear its sentinel. Database::open + // performs the single read-only integrity validation before applying + // writable pragmas or migrations to a potentially damaged recovery set. let mut recovery_lock = if crashed { Some(try_acquire_graph_sync_locks( &active_graph_layout.sync_lock_path, @@ -432,29 +433,6 @@ impl TraceDecay { } else { None }; - if crashed { - let authority = DatabaseAuthority::for_runtime(&db_path, "crash verification")?; - let verification = match Database::open_read_only(&db_path, &authority).await { - Ok((db, _)) => db, - Err(error) => { - drop(recovery_lock); - return Self::recover_corrupt_branch_or_fail( - project_root, - open_options, - &store_layout, - &db_path, - error, - repair_corrupt_branch, - ) - .await; - } - }; - // `open_read_only` already completes the full read-only integrity - // validation before returning. Repeating `quick_check` here (and - // again after the writable open) turns one crash-recovery scan - // into three identical full-store scans. - verification.close(); - } // Ordinary opens never replace database files. A daemon or another MCP // process may still hold the current DB/WAL/SHM inodes, and deleting @@ -481,13 +459,14 @@ impl TraceDecay { == Some(FULL_REINDEX_REQUIRED_VALUE); let needs_reindex = migrated || reindex_pending; - // The read-only preflight above already validated the exact WAL-aware - // recovery set while both locks were held. A successful writable open - // does not need to scan the same pages again. Reindexing owns sentinel - // cleanup when migration requires it. + // Database::open validated the exact WAL-aware recovery set while both + // locks were held. Reindexing owns sentinel cleanup when migration + // requires it. if crashed && !needs_reindex { clear_dirty_sentinel_at(&active_graph_layout.dirty_path); - clear_dirty_sentinel_at(&store_layout.dirty_path); + if active_graph_is_root { + clear_dirty_sentinel_at(&store_layout.dirty_path); + } } let ts = Self { diff --git a/src/tracedecay/locking.rs b/src/tracedecay/locking.rs index 29bcd8387..da0a394ad 100644 --- a/src/tracedecay/locking.rs +++ b/src/tracedecay/locking.rs @@ -169,7 +169,9 @@ impl super::TraceDecay { ) -> Result { let epoch = next_epoch(); let mut paths = vec![self.active_graph_layout.dirty_path.clone()]; - if self.active_graph_layout.dirty_path != self.store_layout.dirty_path { + if self.db_path() == self.store_layout.graph_db_path + && self.active_graph_layout.dirty_path != self.store_layout.dirty_path + { paths.push(self.store_layout.dirty_path.clone()); } for path in &paths { diff --git a/tests/storage_suite/branch_db_safety_test.rs b/tests/storage_suite/branch_db_safety_test.rs index d83103de0..012914929 100644 --- a/tests/storage_suite/branch_db_safety_test.rs +++ b/tests/storage_suite/branch_db_safety_test.rs @@ -86,6 +86,64 @@ async fn open_untracked_project() -> (IsolatedEnv, PathBuf, TraceDecay) { (env, project, feature) } +#[tokio::test] +async fn branch_sync_publishes_only_graph_local_dirty_marker() { + let (_env, project, feature) = open_untracked_project().await; + let active_dirty = PathBuf::from(format!("{}.dirty", feature.db_path().display())); + let legacy_root_dirty = feature.store_layout().dirty_path.clone(); + let observed_active_marker = std::cell::Cell::new(false); + + fs::write( + project.join("src/untracked_only.rs"), + "pub fn untracked_only_changed() {}\n", + ) + .unwrap(); + feature + .sync_with_progress_verbose( + |_, _, _| { + if active_dirty.exists() { + observed_active_marker.set(true); + assert!( + !legacy_root_dirty.exists(), + "branch sync must not publish the repository-wide legacy dirty marker" + ); + } + }, + |_| {}, + ) + .await + .unwrap(); + + assert!( + observed_active_marker.get(), + "test must observe the graph-local marker while the sync lease is active" + ); + assert!(!active_dirty.exists()); + assert!(!legacy_root_dirty.exists()); +} + +#[tokio::test] +async fn branch_open_ignores_and_preserves_root_legacy_dirty_marker() { + let (_env, project, feature) = open_untracked_project().await; + let active_dirty = PathBuf::from(format!("{}.dirty", feature.db_path().display())); + let legacy_root_dirty = feature.store_layout().dirty_path.clone(); + let legacy_bytes = b"interrupted root graph writer"; + feature.close(); + + fs::write(&legacy_root_dirty, legacy_bytes).unwrap(); + let reopened = TraceDecay::open(&project) + .await + .expect("an unrelated root marker must not force branch recovery"); + + assert!(!active_dirty.exists()); + assert_eq!( + fs::read(&legacy_root_dirty).unwrap(), + legacy_bytes, + "branch open must preserve recovery evidence owned by the root graph" + ); + reopened.close(); +} + #[tokio::test] // Regression: init and reopen must use the same graph-scoped lock. async fn init_index_uses_graph_specific_sync_lock() { @@ -148,7 +206,11 @@ async fn corrupt_derived_branch_store_is_preserved_and_rebuilt_automatically() { "the rebuilt branch index must include branch-only working-tree symbols" ); assert_eq!(fs::read(&layout.sessions_db_path).unwrap(), sessions_bytes); - assert!(!layout.dirty_path.exists()); + assert_eq!( + fs::read(&layout.dirty_path).unwrap(), + dirty_bytes, + "derived-branch repair must preserve unrelated root-graph recovery evidence" + ); let recovery_root = layout.data_root.join("recovery"); let recovery_dirs = fs::read_dir(&recovery_root) From 043a047e03b87cdeb0bf77241a852ec2bf0f80d7 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Tue, 11 Aug 2026 14:15:44 +0200 Subject: [PATCH 15/20] fix(storage): reject empty recovery stores Dirty zero-length databases must fail before writable initialization so recovery bytes and markers remain available for offline repair. --- src/tracedecay/lifecycle.rs | 23 ++++++++++++++ tests/storage_suite/corruption_test.rs | 43 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index 4f6dcc601..ecf849f22 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -433,6 +433,29 @@ impl TraceDecay { } else { None }; + if crashed { + let invalid_recovery_set = match std::fs::metadata(&db_path) { + Ok(metadata) if metadata.len() > 0 => None, + Ok(_) => Some( + "dirty database is zero-length; refusing writable initialization".to_string(), + ), + Err(error) => Some(format!( + "failed to inspect dirty database before writable open: {error}" + )), + }; + if let Some(detail) = invalid_recovery_set { + drop(recovery_lock); + return Self::recover_corrupt_branch_or_fail( + project_root, + open_options, + &store_layout, + &db_path, + detail, + repair_corrupt_branch, + ) + .await; + } + } // Ordinary opens never replace database files. A daemon or another MCP // process may still hold the current DB/WAL/SHM inodes, and deleting diff --git a/tests/storage_suite/corruption_test.rs b/tests/storage_suite/corruption_test.rs index 3803353c0..54575ce8d 100644 --- a/tests/storage_suite/corruption_test.rs +++ b/tests/storage_suite/corruption_test.rs @@ -564,6 +564,49 @@ async fn dirty_open_checks_integrity_before_writable_migration() Ok(()) } +#[tokio::test] +async fn dirty_open_rejects_zero_length_database_before_writable_initialization() +-> std::result::Result<(), Box> { + let dir = TempDir::new()?; + let project_root = dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let open_options = TraceDecayOpenOptions { + profile_root: Some(dir.path().join("profile")), + global_db_path: Some(dir.path().join("global.db")), + }; + + let ts = TraceDecay::init_with_options(&project_root, open_options.clone()).await?; + let layout = ts.store_layout().clone(); + ts.close(); + + std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&layout.graph_db_path)?; + let marker = b"pid=99999\nversion=test"; + std::fs::write(&layout.dirty_path, marker)?; + + let error = match TraceDecay::open_with_options(&project_root, open_options).await { + Ok(_) => panic!("a dirty zero-length database must require explicit recovery"), + Err(error) => error, + }; + assert!( + error.to_string().contains("database recovery required"), + "truncated stores must use the existing recovery-required path: {error}" + ); + assert_eq!( + std::fs::metadata(&layout.graph_db_path)?.len(), + 0, + "recovery detection must not initialize or migrate the truncated database" + ); + assert_eq!( + std::fs::read(&layout.dirty_path)?, + marker, + "recovery evidence must remain until explicit repair" + ); + Ok(()) +} + #[tokio::test] async fn dirty_open_reuses_recovery_lock_for_migration_reindex() -> std::result::Result<(), Box> { From 71b19fc1f9ed03e8a5be7507d7d241b3dffd18aa Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Tue, 11 Aug 2026 16:40:27 +0200 Subject: [PATCH 16/20] fix(storage): harden recovery validation Treat unreadable auxiliary state as a conflict, validate the serving branch before identity repair, and recheck cached writable connections during dirty recovery. --- .../src/db/connection.rs | 23 +++ src/tracedecay/lifecycle.rs | 58 +++++--- tests/storage_suite/corruption_test.rs | 58 +++++++- tests/storage_suite/storage_resolver_test.rs | 138 ++++++++++++++++++ 4 files changed, 259 insertions(+), 18 deletions(-) diff --git a/crates/tracedecay-runtime-core/src/db/connection.rs b/crates/tracedecay-runtime-core/src/db/connection.rs index c7facdfc7..69882d4ac 100644 --- a/crates/tracedecay-runtime-core/src/db/connection.rs +++ b/crates/tracedecay-runtime-core/src/db/connection.rs @@ -105,6 +105,26 @@ impl Database { /// Returns `(Self, migrated)` where `migrated` is `true` if schema /// migrations were applied during open. pub async fn open(db_path: &Path, authority: &DatabaseAuthority) -> Result<(Self, bool)> { + Self::open_inner(db_path, authority, false).await + } + + /// Opens an existing database while revalidating a cached writable + /// connection before reuse. Dirty-store recovery uses this entry point so + /// an in-process handle cannot bypass validation of the on-disk recovery + /// set before its marker is cleared. + #[doc(hidden)] + pub async fn open_revalidating_cached( + db_path: &Path, + authority: &DatabaseAuthority, + ) -> Result<(Self, bool)> { + Self::open_inner(db_path, authority, true).await + } + + async fn open_inner( + db_path: &Path, + authority: &DatabaseAuthority, + revalidate_cached: bool, + ) -> Result<(Self, bool)> { let authority = authority.hold_for(db_path, "open")?; let slot = database_slot(authority.canonical_database_path()); let mut open = slot.lock().await; @@ -112,6 +132,9 @@ impl Database { if !inner.writable { return Err(integrity::read_only_upgrade_error(db_path, "open")); } + if revalidate_cached { + integrity::validate_read_only(db_path).await?; + } return Ok((Self { inner }, false)); } let is_fresh = std::fs::metadata(db_path) diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index ecf849f22..a8cba9075 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -461,7 +461,11 @@ impl TraceDecay { // process may still hold the current DB/WAL/SHM inodes, and deleting // them here would split readers and writers across different stores. let authority = DatabaseAuthority::for_runtime(&db_path, "open project store")?; - let open_result = Database::open(&db_path, &authority).await; + let open_result = if crashed { + Database::open_revalidating_cached(&db_path, &authority).await + } else { + Database::open(&db_path, &authority).await + }; let (db, migrated) = match open_result { Ok(pair) => pair, Err(e) if Database::is_corruption_error(&e) || crashed => { @@ -1441,11 +1445,17 @@ async fn store_identity_inventory(layout: &StoreLayout) -> StoreIdentityInventor } async fn store_identity_integrity_is_healthy(layout: &StoreLayout) -> bool { - let Ok(authority) = DatabaseAuthority::for_runtime(&layout.graph_db_path, "store repair") + let active_branch = branch::current_branch(&layout.project_root); + let (serving_graph_db_path, _, _) = TraceDecay::resolve_db_for_branch( + &layout.project_root, + &layout.data_root, + active_branch.as_deref(), + ); + let Ok(authority) = DatabaseAuthority::for_runtime(&serving_graph_db_path, "store repair") else { return false; }; - let Ok((db, _)) = Database::open_read_only(&layout.graph_db_path, &authority).await else { + let Ok((db, _)) = Database::open_read_only(&serving_graph_db_path, &authority).await else { return false; }; db.close(); @@ -1480,23 +1490,37 @@ async fn store_identity_has_bounded_population_evidence(layout: &StoreLayout) -> return true; } - if let Some(db) = crate::global_db::GlobalDb::open_read_only_at(&layout.sessions_db_path).await - { - let conn = db.dashboard_connection(); - let sessions_are_populated = table_has_rows(&conn, "sessions").await - || table_has_rows(&conn, "session_messages").await - || table_has_rows(&conn, "lcm_raw_messages").await - || table_has_rows(&conn, "lcm_summary_nodes").await; - db.close(); - if sessions_are_populated { - return true; + match crate::global_db::GlobalDb::open_read_only_at(&layout.sessions_db_path).await { + Some(db) => { + let conn = db.dashboard_connection(); + let sessions_are_populated = table_has_rows(&conn, "sessions").await + || table_has_rows(&conn, "session_messages").await + || table_has_rows(&conn, "lcm_raw_messages").await + || table_has_rows(&conn, "lcm_summary_nodes").await; + db.close(); + if sessions_are_populated { + return true; + } } + None if layout.sessions_db_path.exists() => return false, + None => {} } - branch_meta::load_branch_meta(&layout.data_root).is_some_and(|meta| meta.branches.len() > 1) - || tree_has_files(&layout.dashboard_root).unwrap_or(true) - || tree_has_files(&layout.lcm_payload_root).unwrap_or(true) - || tree_has_files(&layout.response_handle_root).unwrap_or(true) + let branches_are_populated = match branch_inventory(&layout.data_root) { + Ok(branches) => branches > 1, + Err(()) => return false, + }; + let tree_presence = ( + tree_has_files(&layout.dashboard_root), + tree_has_files(&layout.lcm_payload_root), + tree_has_files(&layout.response_handle_root), + ); + let (automation_files, payload_files, response_files) = match tree_presence { + (Ok(automation), Ok(payloads), Ok(responses)) => (automation, payloads, responses), + _ => return false, + }; + + branches_are_populated || automation_files || payload_files || response_files } async fn table_has_rows(connection: &libsql::Connection, table: &str) -> bool { diff --git a/tests/storage_suite/corruption_test.rs b/tests/storage_suite/corruption_test.rs index 54575ce8d..a636ff29d 100644 --- a/tests/storage_suite/corruption_test.rs +++ b/tests/storage_suite/corruption_test.rs @@ -13,8 +13,8 @@ use crate::support; use std::io::{Seek, Write}; use tempfile::TempDir; -use tracedecay::db::Database; use tracedecay::db::migrations::{FULL_REINDEX_REQUIRED_KEY, FULL_REINDEX_REQUIRED_VALUE}; +use tracedecay::db::{Database, DatabaseAuthority}; use tracedecay::tracedecay::{TraceDecay, TraceDecayOpenOptions, try_acquire_sync_lock_at}; use tracedecay::types::*; @@ -564,6 +564,62 @@ async fn dirty_open_checks_integrity_before_writable_migration() Ok(()) } +#[tokio::test] +async fn dirty_reopen_revalidates_reused_writable_connection() +-> std::result::Result<(), Box> { + let dir = TempDir::new()?; + let project_root = dir.path().join("repo"); + std::fs::create_dir_all(&project_root)?; + let open_options = TraceDecayOpenOptions { + profile_root: Some(dir.path().join("profile")), + global_db_path: Some(dir.path().join("global.db")), + }; + + let live = TraceDecay::init_with_options(&project_root, open_options.clone()).await?; + let layout = live.store_layout().clone(); + let nodes: Vec = (0..100) + .map(|index| { + sample_node( + &format!("cached-dirty-{index}"), + &format!("cached_dirty_function_with_long_name_{index}"), + ) + }) + .collect(); + live.db().insert_nodes(&nodes).await?; + live.checkpoint().await?; + + let mut file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&layout.graph_db_path)?; + let offset = std::cmp::min(file.metadata()?.len() / 2, 8192); + file.seek(std::io::SeekFrom::Start(offset))?; + file.write_all(&[0xFF; 256])?; + file.sync_all()?; + drop(file); + std::fs::write(&layout.dirty_path, "pid=99999\nversion=test")?; + let validation_authority = + DatabaseAuthority::acquire_test(&layout.graph_db_path, "cached dirty fixture")?; + assert!( + Database::open_read_only(&layout.graph_db_path, &validation_authority) + .await + .is_err(), + "fixture corruption must fail independent read-only validation" + ); + + let result = TraceDecay::open_with_options(&project_root, open_options).await; + assert!( + result.is_err(), + "dirty reopen must revalidate a reused writable connection" + ); + assert!( + layout.dirty_path.exists(), + "failed cached-connection recovery must preserve the dirty marker" + ); + live.close(); + Ok(()) +} + #[tokio::test] async fn dirty_open_rejects_zero_length_database_before_writable_initialization() -> std::result::Result<(), Box> { diff --git a/tests/storage_suite/storage_resolver_test.rs b/tests/storage_suite/storage_resolver_test.rs index 393add81e..5a968f366 100644 --- a/tests/storage_suite/storage_resolver_test.rs +++ b/tests/storage_suite/storage_resolver_test.rs @@ -1107,6 +1107,65 @@ async fn unreadable_cutover_sessions_block_identity_repair() { assert!(cutover.data_root.join(STORE_MANIFEST_FILENAME).is_file()); } +#[tokio::test] +async fn unreadable_cutover_artifact_tree_blocks_exact_root_fast_path() { + let _guard = HOME_ENV_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + let home = test_home(&dir); + let profile_root = home.join(".tracedecay"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write( + project.join("src/lib.rs"), + "pub fn guarded_artifacts() {}\n", + ) + .unwrap(); + let _home_guard = HomeGuard::set(&home); + init_repo_with_commit(&project); + + let old = TraceDecay::init(&project).await.unwrap(); + old.add_fact(fact_request("healthy artifact legacy fact")) + .await + .unwrap(); + let original_root = old.store_layout().data_root.clone(); + old.checkpoint().await.unwrap(); + old.close(); + fs::remove_file(repository_identity_path(&project).unwrap()).unwrap(); + remove_sqlite_family(&profile_root.join("global.db")); + + let legacy_project_id = "proj_guarded_artifact_legacy"; + let legacy_root = profile_root.join(format!("projects/{legacy_project_id}")); + relocate_store_as_legacy(&original_root, &legacy_root, &project, legacy_project_id); + + let cutover = default_profile_sharded_layout(&project, &profile_root).unwrap(); + let cutover_project_id = cutover.identity.project_id.clone().unwrap(); + initialize_empty_profile_layout(&cutover).await; + fs::write(&cutover.dashboard_root, b"not a directory").unwrap(); + fs::create_dir_all(&cutover.lcm_payload_root).unwrap(); + fs::write(cutover.lcm_payload_root.join("payload.json"), b"{}").unwrap(); + write_repository_identity_marker(&project, &cutover_project_id).unwrap(); + + let error = match TraceDecay::open(&project).await { + Ok(graph) => { + graph.close(); + panic!("unreadable artifact state must block exact-root selection"); + } + Err(error) => error, + }; + let message = error.to_string(); + assert!(message.contains("identity cutover conflict"), "{message}"); + assert!(message.contains("auxiliary_health=unreadable"), "{message}"); + assert!(message.contains("no files changed"), "{message}"); + assert_eq!( + read_repository_identity_marker(&project) + .unwrap() + .unwrap() + .project_id, + cutover_project_id + ); + assert!(cutover.data_root.join(STORE_MANIFEST_FILENAME).is_file()); +} + #[tokio::test] async fn empty_cutover_store_adopts_healthy_legacy_linked_worktree_store() { let _guard = HOME_ENV_LOCK.lock().await; @@ -1164,6 +1223,85 @@ async fn empty_cutover_store_adopts_healthy_legacy_linked_worktree_store() { repaired.close(); } +#[tokio::test] +async fn empty_cutover_store_rejects_candidate_with_corrupt_serving_branch() { + let _guard = HOME_ENV_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + let linked = dir.path().join("repo-linked"); + let home = test_home(&dir); + let profile_root = home.join(".tracedecay"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), "pub fn guarded_branch() {}\n").unwrap(); + let _home_guard = HomeGuard::set(&home); + init_repo_with_commit(&project); + git( + &project, + &[ + "worktree", + "add", + "-b", + "feature/corrupt-legacy-branch", + linked.to_str().unwrap(), + ], + ); + + let old = TraceDecay::init(&project).await.unwrap(); + old.add_fact(fact_request("healthy root with corrupt serving branch")) + .await + .unwrap(); + let original_root = old.store_layout().data_root.clone(); + let branch = TraceDecay::open(&linked).await.unwrap(); + let branch_relative_path = branch + .db_path() + .strip_prefix(&original_root) + .unwrap() + .to_path_buf(); + branch.checkpoint().await.unwrap(); + branch.close(); + old.checkpoint().await.unwrap(); + old.close(); + + fs::remove_file(repository_identity_path(&linked).unwrap()).unwrap(); + remove_sqlite_family(&profile_root.join("global.db")); + + let legacy_project_id = "proj_corrupt_serving_branch_legacy"; + let legacy_root = profile_root.join(format!("projects/{legacy_project_id}")); + relocate_store_as_legacy(&original_root, &legacy_root, &linked, legacy_project_id); + let legacy_branch_db = legacy_root.join(branch_relative_path); + let mut corrupted = fs::read(&legacy_branch_db).unwrap(); + corrupted[..16].copy_from_slice(b"not-a-sqlite-db!"); + fs::write(&legacy_branch_db, &corrupted).unwrap(); + + let cutover = default_profile_sharded_layout(&linked, &profile_root).unwrap(); + let cutover_project_id = cutover.identity.project_id.clone().unwrap(); + initialize_empty_profile_layout(&cutover).await; + write_repository_identity_marker(&linked, &cutover_project_id).unwrap(); + + let error = match TraceDecay::open(&linked).await { + Ok(graph) => { + graph.close(); + panic!("identity repair must validate the serving branch before mutation"); + } + Err(error) => error, + }; + let message = error.to_string(); + assert!(message.contains("identity cutover conflict"), "{message}"); + assert!( + message.contains("candidate failed full integrity validation"), + "{message}" + ); + assert_eq!( + read_repository_identity_marker(&linked) + .unwrap() + .unwrap() + .project_id, + cutover_project_id + ); + assert!(cutover.data_root.join(STORE_MANIFEST_FILENAME).is_file()); + assert!(legacy_root.join(STORE_MANIFEST_FILENAME).is_file()); +} + #[tokio::test] async fn corrupt_nonempty_cutover_store_reports_both_shards_without_switching() { let _guard = HOME_ENV_LOCK.lock().await; From a818fcdcbc8f90e00fbf95437f43dd05e41001e3 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Tue, 11 Aug 2026 16:55:55 +0200 Subject: [PATCH 17/20] fix(storage): validate auxiliary health first Establish every bounded inventory health signal before populated graph or session data may authorize exact-root selection. --- src/tracedecay/lifecycle.rs | 60 ++++++++++++-------- tests/storage_suite/storage_resolver_test.rs | 38 +++++++++++++ 2 files changed, 73 insertions(+), 25 deletions(-) diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index a8cba9075..6daa9e9ba 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -1482,29 +1482,38 @@ async fn store_identity_has_bounded_population_evidence(layout: &StoreLayout) -> }) else { return false; }; - let graph_is_populated = table_has_rows(db.conn(), "nodes").await - || table_has_rows(db.conn(), "files").await - || table_has_rows(db.conn(), "memory_facts").await; + let graph_presence = ( + table_presence(db.conn(), "nodes").await, + table_presence(db.conn(), "files").await, + table_presence(db.conn(), "memory_facts").await, + ); db.close(); - if graph_is_populated { - return true; - } + let graph_is_populated = match graph_presence { + (Ok(nodes), Ok(files), Ok(facts)) => nodes || files || facts, + _ => return false, + }; - match crate::global_db::GlobalDb::open_read_only_at(&layout.sessions_db_path).await { - Some(db) => { - let conn = db.dashboard_connection(); - let sessions_are_populated = table_has_rows(&conn, "sessions").await - || table_has_rows(&conn, "session_messages").await - || table_has_rows(&conn, "lcm_raw_messages").await - || table_has_rows(&conn, "lcm_summary_nodes").await; - db.close(); - if sessions_are_populated { - return true; + let sessions_are_populated = + match crate::global_db::GlobalDb::open_read_only_at(&layout.sessions_db_path).await { + Some(db) => { + let conn = db.dashboard_connection(); + let session_presence = ( + table_presence(&conn, "sessions").await, + table_presence(&conn, "session_messages").await, + table_presence(&conn, "lcm_raw_messages").await, + table_presence(&conn, "lcm_summary_nodes").await, + ); + db.close(); + match session_presence { + (Ok(sessions), Ok(messages), Ok(raw_lcm), Ok(summary_lcm)) => { + sessions || messages || raw_lcm || summary_lcm + } + _ => return false, + } } - } - None if layout.sessions_db_path.exists() => return false, - None => {} - } + None if layout.sessions_db_path.exists() => return false, + None => false, + }; let branches_are_populated = match branch_inventory(&layout.data_root) { Ok(branches) => branches > 1, @@ -1520,11 +1529,12 @@ async fn store_identity_has_bounded_population_evidence(layout: &StoreLayout) -> _ => return false, }; - branches_are_populated || automation_files || payload_files || response_files -} - -async fn table_has_rows(connection: &libsql::Connection, table: &str) -> bool { - table_presence(connection, table).await.unwrap_or(false) + graph_is_populated + || sessions_are_populated + || branches_are_populated + || automation_files + || payload_files + || response_files } async fn table_presence(connection: &libsql::Connection, table: &str) -> Result { diff --git a/tests/storage_suite/storage_resolver_test.rs b/tests/storage_suite/storage_resolver_test.rs index 5a968f366..a86c9d06a 100644 --- a/tests/storage_suite/storage_resolver_test.rs +++ b/tests/storage_suite/storage_resolver_test.rs @@ -12,6 +12,7 @@ use tracedecay::config::{TraceDecayConfig, USER_DATA_DIR_ENV}; use tracedecay::config::{ discover_project_root, get_config_path, load_config, save_config_to_path, }; +use tracedecay::db::{Database, DatabaseAuthority}; use tracedecay::global_db::GlobalDb; use tracedecay::mcp::response_handles::{ ResponseHandleLookup, retrieve_response_handle, store_response_handle, @@ -1082,6 +1083,21 @@ async fn unreadable_cutover_sessions_block_identity_repair() { let cutover = default_profile_sharded_layout(&project, &profile_root).unwrap(); let cutover_project_id = cutover.identity.project_id.clone().unwrap(); initialize_empty_profile_layout(&cutover).await; + let authority = + DatabaseAuthority::acquire_test(&cutover.graph_db_path, "populated cutover").unwrap(); + let (graph, _) = Database::open(&cutover.graph_db_path, &authority) + .await + .unwrap(); + graph + .conn() + .execute( + "INSERT INTO memory_facts (content, category) VALUES ('populated cutover graph', 'test')", + (), + ) + .await + .unwrap(); + graph.checkpoint().await.unwrap(); + graph.close(); remove_sqlite_family(&cutover.sessions_db_path); fs::create_dir_all(&cutover.sessions_db_path).unwrap(); write_repository_identity_marker(&project, &cutover_project_id).unwrap(); @@ -1140,6 +1156,28 @@ async fn unreadable_cutover_artifact_tree_blocks_exact_root_fast_path() { let cutover = default_profile_sharded_layout(&project, &profile_root).unwrap(); let cutover_project_id = cutover.identity.project_id.clone().unwrap(); initialize_empty_profile_layout(&cutover).await; + let sessions = GlobalDb::open_at(&cutover.sessions_db_path).await.unwrap(); + assert!( + sessions + .upsert_session(&SessionRecord { + provider: "codex".to_string(), + session_id: "populated-artifact-cutover".to_string(), + project_key: cutover_project_id.clone(), + project_path: project.to_string_lossy().to_string(), + title: Some("populated artifact cutover".to_string()), + started_at: Some(1_800_000_020), + ended_at: None, + transcript_path: None, + metadata_json: None, + parent_session_id: None, + is_subagent: false, + agent_id: None, + parent_tool_use_id: None, + }) + .await + ); + sessions.checkpoint().await; + sessions.close(); fs::write(&cutover.dashboard_root, b"not a directory").unwrap(); fs::create_dir_all(&cutover.lcm_payload_root).unwrap(); fs::write(cutover.lcm_payload_root.join("payload.json"), b"{}").unwrap(); From a79f70cc3bb4b307ff17444a3adafaae8b52f670 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Wed, 12 Aug 2026 06:16:27 +0200 Subject: [PATCH 18/20] fix(storage): trust exact worktree registry aliases --- src/tracedecay/lifecycle.rs | 34 +++- tests/storage_suite/storage_resolver_test.rs | 181 ++++++++++++++++++- 2 files changed, 207 insertions(+), 8 deletions(-) diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index 6daa9e9ba..a22f6a76b 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -182,16 +182,33 @@ impl TraceDecay { } let mut selected = storage::resolve_persisted_layout(project_root, &profile_root)?; + let mut selected_via_exact_registry_alias = false; let git_common_dir = (!crate::worktree::is_detached_linked_worktree(project_root)) .then(|| crate::worktree::git_common_dir(project_root)) .flatten(); if selected.is_none() && let Some(global_db) = open_options.open_global_db().await { - if let Some(resolution) = global_db - .resolve_project_store_by_identity(project_root, git_common_dir.as_deref()) - .await - { + let resolution = match global_db.resolve_project_store_by_alias(project_root).await { + Some(resolution) => { + selected_via_exact_registry_alias = resolution + .project + .git_common_dir + .as_deref() + .zip(git_common_dir.as_deref()) + .is_some_and(|(registered, live)| { + crate::global_db::GlobalDb::canonical_project_key(Path::new(registered)) + == crate::global_db::GlobalDb::canonical_project_key(live) + }); + Some(resolution) + } + None => { + global_db + .resolve_project_store_by_identity(project_root, git_common_dir.as_deref()) + .await + } + }; + if let Some(resolution) = resolution { selected = Some(storage::profile_sharded_layout( project_root, &profile_root, @@ -217,6 +234,7 @@ impl TraceDecay { selected, candidates, selected_manifest_matches_exact_root, + selected_via_exact_registry_alias, allow_repair, ) .await? @@ -231,15 +249,17 @@ impl TraceDecay { selected: Option, candidates: Vec, selected_manifest_matches_exact_root: bool, + selected_via_exact_registry_alias: bool, allow_repair: bool, ) -> Result> { - // A populated store selected by the repository marker or registry - // remains authoritative when its own manifest names this exact root. + // A populated store remains authoritative when its own manifest names + // this exact root or the registry selected it through this exact path + // alias. Shared Git identity alone does not grant this precedence. // This resolver uses bounded presence probes only; the subsequent // serving open performs full integrity validation and fails closed. // Legacy duplicates stay untouched, while an empty or unreadable // selected store still reaches the fail-closed diagnostics. - if selected_manifest_matches_exact_root + if (selected_manifest_matches_exact_root || selected_via_exact_registry_alias) && !candidates.is_empty() && let Some(selected) = selected.as_ref() { diff --git a/tests/storage_suite/storage_resolver_test.rs b/tests/storage_suite/storage_resolver_test.rs index a86c9d06a..a833e0756 100644 --- a/tests/storage_suite/storage_resolver_test.rs +++ b/tests/storage_suite/storage_resolver_test.rs @@ -13,7 +13,7 @@ use tracedecay::config::{ discover_project_root, get_config_path, load_config, save_config_to_path, }; use tracedecay::db::{Database, DatabaseAuthority}; -use tracedecay::global_db::GlobalDb; +use tracedecay::global_db::{GlobalDb, StoreInstanceUpsert}; use tracedecay::mcp::response_handles::{ ResponseHandleLookup, retrieve_response_handle, store_response_handle, }; @@ -1807,6 +1807,185 @@ async fn registered_exact_root_ignores_sibling_worktree_manifests() { assert_path_eq(&layout.data_root, &main_data_root); } +#[tokio::test] +async fn linked_worktree_exact_registry_alias_ignores_duplicate_shared_legacy_manifests() { + let _guard = HOME_ENV_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + let worktree = dir.path().join("repo-wt"); + let unregistered_worktree = dir.path().join("repo-wt-unregistered"); + let home = test_home(&dir); + let profile_root = home.join(".tracedecay"); + let global_db_path = profile_root.join("global.db"); + let _home_guard = HomeGuard::set(&home); + + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), "pub fn canonical() {}\n").unwrap(); + init_repo_with_commit(&project); + + let canonical = TraceDecay::init(&project).await.unwrap(); + canonical.index_all().await.unwrap(); + let canonical_project_id = canonical + .store_layout() + .identity + .project_id + .clone() + .unwrap(); + let canonical_data_root = canonical.store_layout().data_root.clone(); + canonical.close(); + + git( + &project, + &[ + "worktree", + "add", + "-b", + "feature/exact-registry-alias", + worktree.to_str().unwrap(), + ], + ); + let linked = TraceDecay::open(&worktree).await.unwrap(); + linked.close(); + git( + &project, + &[ + "worktree", + "add", + "-b", + "feature/shared-git-fallback", + unregistered_worktree.to_str().unwrap(), + ], + ); + + let global_db = GlobalDb::open().await.unwrap(); + let registered = global_db + .resolve_project_store_by_alias(&worktree) + .await + .expect("opening the linked worktree must register its exact path alias"); + assert_eq!(registered.project.project_id, canonical_project_id); + + let mut legacy_roots = Vec::new(); + for project_id in ["proj_shared_legacy_one", "proj_shared_legacy_two"] { + let candidate_source = dir.path().join(format!("{project_id}-source")); + fs::create_dir_all(candidate_source.join("src")).unwrap(); + fs::write( + candidate_source.join("src/lib.rs"), + format!("pub fn {}() {{}}\n", project_id), + ) + .unwrap(); + init_repo_with_commit(&candidate_source); + + let candidate = TraceDecay::init(&candidate_source).await.unwrap(); + candidate.index_all().await.unwrap(); + let candidate_root = candidate.store_layout().data_root.clone(); + candidate.close(); + + let candidate_git_common_dir = tracedecay::worktree::git_common_dir(&candidate_source) + .expect("candidate must have its own Git identity"); + global_db + .upsert_code_project( + project_id, + &candidate_source, + Some(&candidate_git_common_dir), + None, + Some("main"), + ) + .await + .unwrap(); + + let legacy_root = profile_root.join(format!("projects/{project_id}")); + relocate_store_as_legacy(&candidate_root, &legacy_root, &project, project_id); + global_db + .upsert_store_instance(StoreInstanceUpsert { + store_id: format!("store_{project_id}"), + project_id: project_id.to_string(), + store_kind: "code_project".to_string(), + storage_mode: "profile_sharded".to_string(), + store_relpath: format!("projects/{project_id}"), + manifest_relpath: Some(format!( + "projects/{project_id}/{STORE_MANIFEST_FILENAME}" + )), + last_verified_at: None, + last_write_at: None, + }) + .await + .unwrap(); + fs::write(legacy_root.join("untouched-sentinel"), project_id).unwrap(); + legacy_roots.push(( + project_id, + legacy_root.clone(), + fs::read(legacy_root.join(STORE_MANIFEST_FILENAME)).unwrap(), + )); + } + + fs::remove_file(repository_identity_path(&worktree).unwrap()).unwrap(); + let fallback_error = TraceDecay::resolve_store_layout_for_identity_with_options( + &unregistered_worktree, + &TraceDecayOpenOptions { + profile_root: Some(profile_root.clone()), + global_db_path: Some(global_db_path.clone()), + }, + ) + .await + .expect_err("generic Git-common-dir fallback must remain fail-closed"); + assert!( + fallback_error + .to_string() + .contains("ambiguous legacy profile stores") + ); + + global_db + .upsert_project_alias(&unregistered_worktree, "proj_shared_legacy_one") + .await + .unwrap(); + let stale_alias = global_db + .resolve_project_store_by_alias(&unregistered_worktree) + .await + .expect("the reused path must resolve through its stale exact alias"); + assert_eq!(stale_alias.project.project_id, "proj_shared_legacy_one"); + TraceDecay::resolve_store_layout_for_identity_with_options( + &unregistered_worktree, + &TraceDecayOpenOptions { + profile_root: Some(profile_root.clone()), + global_db_path: Some(global_db_path.clone()), + }, + ) + .await + .expect_err("an exact alias from a different Git identity must remain fail-closed"); + global_db + .upsert_project_alias(&unregistered_worktree, &canonical_project_id) + .await + .unwrap(); + + let layout = TraceDecay::resolve_store_layout_for_identity_with_options( + &worktree, + &TraceDecayOpenOptions { + profile_root: Some(profile_root), + global_db_path: Some(global_db_path), + }, + ) + .await + .expect("the exact linked-worktree alias must keep the canonical selected store authoritative"); + + assert_eq!( + layout.identity.project_id.as_deref(), + Some(canonical_project_id.as_str()) + ); + assert_path_eq(&layout.data_root, &canonical_data_root); + for (project_id, legacy_root, manifest_before) in legacy_roots { + assert_eq!( + fs::read_to_string(legacy_root.join("untouched-sentinel")).unwrap(), + project_id, + "legacy stores must remain untouched as recoverable history" + ); + assert_eq!( + fs::read(legacy_root.join(STORE_MANIFEST_FILENAME)).unwrap(), + manifest_before, + "legacy manifests must not be rewritten" + ); + } +} + #[tokio::test] async fn linked_worktree_uses_initialized_git_common_dir_store_without_init() { let _guard = HOME_ENV_LOCK.lock().await; From df96e9b5c2ba3aba162ab47c91777567d17ae181 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Wed, 12 Aug 2026 06:25:07 +0200 Subject: [PATCH 19/20] fix(storage): preserve exact-root precedence --- crates/tracedecay-runtime-core/src/storage.rs | 26 ++++--- src/tracedecay/lifecycle.rs | 11 ++- tests/storage_suite/storage_resolver_test.rs | 72 +++++++++++++++++++ 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/crates/tracedecay-runtime-core/src/storage.rs b/crates/tracedecay-runtime-core/src/storage.rs index 9ed413515..296db0e63 100644 --- a/crates/tracedecay-runtime-core/src/storage.rs +++ b/crates/tracedecay-runtime-core/src/storage.rs @@ -447,7 +447,7 @@ pub fn matching_legacy_profile_layouts( project_root: &Path, profile_root: &Path, excluded_project_id: Option<&str>, -) -> Result<(Vec, bool)> { +) -> Result<(Vec, bool, bool)> { matching_legacy_profile_layouts_with_git_identity_resolver( project_root, profile_root, @@ -463,14 +463,14 @@ fn matching_legacy_profile_layouts_with_git_identity_resolver( excluded_project_id: Option<&str>, mut is_detached_linked_worktree: D, mut git_identity: G, -) -> Result<(Vec, bool)> +) -> Result<(Vec, bool, bool)> where D: FnMut(&Path) -> bool, G: FnMut(&Path) -> crate::worktree::GitRepoIdentityOutcome, { let projects_root = profile_root.join("projects"); let Ok(entries) = fs::read_dir(&projects_root) else { - return Ok((Vec::new(), false)); + return Ok((Vec::new(), false, false)); }; let mut manifest_paths = entries .flatten() @@ -503,6 +503,7 @@ where // manifest overrides the selected identity. Otherwise the shared-Git // recovery path still runs, and the caller decides whether a selected // identity naming this exact checkout outranks what it finds. + let candidates_match_exact_root = !exact_manifests.is_empty(); let matching_manifests = if exact_manifests.is_empty() { let project_git_common_dir = (!is_detached_linked_worktree(project_root)) .then(|| match git_identity(project_root) { @@ -588,7 +589,11 @@ where } layouts.push(layout); } - Ok((layouts, selected_manifest_matches_exact_root)) + Ok(( + layouts, + selected_manifest_matches_exact_root, + candidates_match_exact_root, + )) } pub fn retire_identity_cutover_manifest(layout: &StoreLayout) -> Result { @@ -1350,7 +1355,7 @@ mod tests { ) .unwrap(); - let (layouts, _) = matching_legacy_profile_layouts_with_git_identity_resolver( + let (layouts, _, _) = matching_legacy_profile_layouts_with_git_identity_resolver( &project_root, &profile_root, None, @@ -1360,7 +1365,7 @@ mod tests { .unwrap(); assert!(layouts.is_empty(), "a timed-out current root cannot match"); - let (layouts, _) = matching_legacy_profile_layouts_with_git_identity_resolver( + let (layouts, _, _) = matching_legacy_profile_layouts_with_git_identity_resolver( &project_root, &profile_root, None, @@ -1417,7 +1422,7 @@ mod tests { write_manifest(&profile_root, "proj_unrelated", &unrelated_root); let resolver_calls = RefCell::new(Vec::new()); - let (layouts, selected_manifest_matches_exact_root) = + let (layouts, selected_manifest_matches_exact_root, candidates_match_exact_root) = matching_legacy_profile_layouts_with_git_identity_resolver( &project_root, &profile_root, @@ -1440,13 +1445,14 @@ mod tests { Some("proj_exact") ); assert!(!selected_manifest_matches_exact_root); + assert!(candidates_match_exact_root); assert!( resolver_calls.borrow().is_empty(), "exact-root selection must not invoke shared-Git discovery" ); resolver_calls.borrow_mut().clear(); - let (layouts, selected_manifest_matches_exact_root) = + let (layouts, selected_manifest_matches_exact_root, candidates_match_exact_root) = matching_legacy_profile_layouts_with_git_identity_resolver( &project_root, &profile_root, @@ -1472,6 +1478,7 @@ mod tests { selected_manifest_matches_exact_root, "the caller decides whether the selected exact root outranks recovery" ); + assert!(!candidates_match_exact_root); assert_eq!( resolver_calls.borrow().as_slice(), [project_root, unrelated_root], @@ -1548,7 +1555,7 @@ mod tests { write_manifest(&profile_root, "proj_historical", &historical_root); let resolver_calls = RefCell::new(Vec::new()); - let (layouts, selected_manifest_matches_exact_root) = + let (layouts, selected_manifest_matches_exact_root, candidates_match_exact_root) = matching_legacy_profile_layouts_with_git_identity_resolver( &worktree_root, &profile_root, @@ -1568,6 +1575,7 @@ mod tests { assert_eq!(layouts.len(), 1); assert!(!selected_manifest_matches_exact_root); + assert!(!candidates_match_exact_root); assert_eq!( resolver_calls.borrow().as_slice(), [worktree_root, historical_root], diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index a22f6a76b..2d82c1b66 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -227,7 +227,11 @@ impl TraceDecay { // stay behind the rare paths that actually compare stores. Resolving a // layout is on every open, including fail-closed clients that must not // touch the store at all. - let (candidates, selected_manifest_matches_exact_root) = + let ( + candidates, + selected_manifest_matches_exact_root, + candidates_match_exact_root, + ) = storage::matching_legacy_profile_layouts(project_root, &profile_root, selected_id)?; Self::choose_identity_layout( project_root, @@ -235,6 +239,7 @@ impl TraceDecay { candidates, selected_manifest_matches_exact_root, selected_via_exact_registry_alias, + candidates_match_exact_root, allow_repair, ) .await? @@ -250,6 +255,7 @@ impl TraceDecay { candidates: Vec, selected_manifest_matches_exact_root: bool, selected_via_exact_registry_alias: bool, + candidates_match_exact_root: bool, allow_repair: bool, ) -> Result> { // A populated store remains authoritative when its own manifest names @@ -259,7 +265,8 @@ impl TraceDecay { // serving open performs full integrity validation and fails closed. // Legacy duplicates stay untouched, while an empty or unreadable // selected store still reaches the fail-closed diagnostics. - if (selected_manifest_matches_exact_root || selected_via_exact_registry_alias) + if (selected_manifest_matches_exact_root + || (selected_via_exact_registry_alias && !candidates_match_exact_root)) && !candidates.is_empty() && let Some(selected) = selected.as_ref() { diff --git a/tests/storage_suite/storage_resolver_test.rs b/tests/storage_suite/storage_resolver_test.rs index a833e0756..c89e73339 100644 --- a/tests/storage_suite/storage_resolver_test.rs +++ b/tests/storage_suite/storage_resolver_test.rs @@ -1986,6 +1986,78 @@ async fn linked_worktree_exact_registry_alias_ignores_duplicate_shared_legacy_ma } } +#[tokio::test] +async fn linked_worktree_exact_manifest_overrides_canonical_exact_registry_alias() { + let _guard = HOME_ENV_LOCK.lock().await; + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + let worktree = dir.path().join("repo-wt"); + let candidate_source = dir.path().join("candidate-source"); + let home = test_home(&dir); + let profile_root = home.join(".tracedecay"); + let global_db_path = profile_root.join("global.db"); + let _home_guard = HomeGuard::set(&home); + + for root in [&project, &candidate_source] { + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/lib.rs"), "pub fn indexed() {}\n").unwrap(); + init_repo_with_commit(root); + } + + let canonical = TraceDecay::init(&project).await.unwrap(); + canonical.index_all().await.unwrap(); + let canonical_project_id = canonical.store_layout().identity.project_id.clone().unwrap(); + canonical.close(); + + git( + &project, + &[ + "worktree", + "add", + "-b", + "feature/exact-manifest-over-alias", + worktree.to_str().unwrap(), + ], + ); + let linked = TraceDecay::open(&worktree).await.unwrap(); + linked.close(); + let global_db = GlobalDb::open().await.unwrap(); + assert_eq!( + global_db + .resolve_project_store_by_alias(&worktree) + .await + .unwrap() + .project + .project_id, + canonical_project_id + ); + + let candidate = TraceDecay::init(&candidate_source).await.unwrap(); + candidate.index_all().await.unwrap(); + let candidate_root = candidate.store_layout().data_root.clone(); + candidate.close(); + let exact_project_id = "proj_linked_exact_over_registry_alias"; + let exact_root = profile_root.join(format!("projects/{exact_project_id}")); + relocate_store_as_legacy(&candidate_root, &exact_root, &worktree, exact_project_id); + + fs::remove_file(repository_identity_path(&worktree).unwrap()).unwrap(); + let layout = TraceDecay::resolve_store_layout_for_identity_with_options( + &worktree, + &TraceDecayOpenOptions { + profile_root: Some(profile_root), + global_db_path: Some(global_db_path), + }, + ) + .await + .expect("the exact-root candidate must override the canonical exact registry alias"); + + assert_eq!( + layout.identity.project_id.as_deref(), + Some(exact_project_id) + ); + assert_path_eq(&layout.data_root, &exact_root); +} + #[tokio::test] async fn linked_worktree_uses_initialized_git_common_dir_store_without_init() { let _guard = HOME_ENV_LOCK.lock().await; From 91944bc6ce0828e9556411de45be135249a2a677 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Wed, 12 Aug 2026 06:34:02 +0200 Subject: [PATCH 20/20] fix(storage): honor aliases with repository markers --- src/tracedecay/lifecycle.rs | 34 ++++++++++++++------ tests/storage_suite/storage_resolver_test.rs | 27 ++++++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index 2d82c1b66..5d650b372 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -186,20 +186,22 @@ impl TraceDecay { let git_common_dir = (!crate::worktree::is_detached_linked_worktree(project_root)) .then(|| crate::worktree::git_common_dir(project_root)) .flatten(); + let alias_matches_live_git_identity = |registered_git_common_dir: Option<&str>| { + registered_git_common_dir + .zip(git_common_dir.as_deref()) + .is_some_and(|(registered, live)| { + crate::global_db::GlobalDb::canonical_project_key(Path::new(registered)) + == crate::global_db::GlobalDb::canonical_project_key(live) + }) + }; if selected.is_none() && let Some(global_db) = open_options.open_global_db().await { let resolution = match global_db.resolve_project_store_by_alias(project_root).await { Some(resolution) => { - selected_via_exact_registry_alias = resolution - .project - .git_common_dir - .as_deref() - .zip(git_common_dir.as_deref()) - .is_some_and(|(registered, live)| { - crate::global_db::GlobalDb::canonical_project_key(Path::new(registered)) - == crate::global_db::GlobalDb::canonical_project_key(live) - }); + selected_via_exact_registry_alias = alias_matches_live_git_identity( + resolution.project.git_common_dir.as_deref(), + ); Some(resolution) } None => { @@ -233,6 +235,20 @@ impl TraceDecay { candidates_match_exact_root, ) = storage::matching_legacy_profile_layouts(project_root, &profile_root, selected_id)?; + if selected.is_some() + && !candidates.is_empty() + && !selected_manifest_matches_exact_root + && !candidates_match_exact_root + && !selected_via_exact_registry_alias + && let Some(global_db) = open_options.open_global_db().await + && let Some(resolution) = global_db.resolve_project_store_by_alias(project_root).await + { + selected_via_exact_registry_alias = selected + .as_ref() + .and_then(|layout| layout.identity.project_id.as_deref()) + == Some(resolution.project.project_id.as_str()) + && alias_matches_live_git_identity(resolution.project.git_common_dir.as_deref()); + } Self::choose_identity_layout( project_root, selected, diff --git a/tests/storage_suite/storage_resolver_test.rs b/tests/storage_suite/storage_resolver_test.rs index c89e73339..5e38771ac 100644 --- a/tests/storage_suite/storage_resolver_test.rs +++ b/tests/storage_suite/storage_resolver_test.rs @@ -1918,6 +1918,33 @@ async fn linked_worktree_exact_registry_alias_ignores_duplicate_shared_legacy_ma )); } + let marker_selected = TraceDecay::resolve_store_layout_for_identity_with_options( + &worktree, + &TraceDecayOpenOptions { + profile_root: Some(profile_root.clone()), + global_db_path: Some(global_db_path.clone()), + }, + ) + .await + .expect("the exact linked-worktree alias must authorize its repository-marker selection"); + assert_eq!( + marker_selected.identity.project_id.as_deref(), + Some(canonical_project_id.as_str()) + ); + assert_path_eq(&marker_selected.data_root, &canonical_data_root); + for (project_id, legacy_root, manifest_before) in &legacy_roots { + assert_eq!( + fs::read_to_string(legacy_root.join("untouched-sentinel")).unwrap(), + *project_id, + "marker resolution must leave legacy stores untouched" + ); + assert_eq!( + fs::read(legacy_root.join(STORE_MANIFEST_FILENAME)).unwrap(), + *manifest_before, + "marker resolution must not rewrite legacy manifests" + ); + } + fs::remove_file(repository_identity_path(&worktree).unwrap()).unwrap(); let fallback_error = TraceDecay::resolve_store_layout_for_identity_with_options( &unregistered_worktree,