diff --git a/.changeset/bounded-daemon-maintenance-shutdown.md b/.changeset/bounded-daemon-maintenance-shutdown.md new file mode 100644 index 000000000..5915262bd --- /dev/null +++ b/.changeset/bounded-daemon-maintenance-shutdown.md @@ -0,0 +1,5 @@ +--- +"tracedecay": patch +--- + +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/.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/.changeset/exact-root-inventory-fast-path.md b/.changeset/exact-root-inventory-fast-path.md new file mode 100644 index 000000000..27daaa62e --- /dev/null +++ b/.changeset/exact-root-inventory-fast-path.md @@ -0,0 +1,5 @@ +--- +"tracedecay": patch +--- + +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/.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-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..0f6d0b68c 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,14 @@ 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") + && database_path + .file_name() + .is_some_and(|name| name == "source-sessions.db" || name == "target-sessions.db") + { + 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..f5003feea 100644 --- a/crates/tracedecay-runtime-core/src/db/access/tests.rs +++ b/crates/tracedecay-runtime-core/src/db/access/tests.rs @@ -794,6 +794,71 @@ 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 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"); + 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/crates/tracedecay-runtime-core/src/db/connection.rs b/crates/tracedecay-runtime-core/src/db/connection.rs index 5d981a266..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) @@ -164,6 +187,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 +229,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 +240,7 @@ impl Database { _authority: authority, _slot: None, }); - Ok((Self { inner }, false)) + Ok(Self { inner }) } /// Returns a reference to the underlying libsql connection. @@ -536,6 +585,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/crates/tracedecay-runtime-core/src/storage.rs b/crates/tracedecay-runtime-core/src/storage.rs index 23048be4d..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,8 +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 selected_is_sole_exact_root = - selected_manifest_matches_exact_root && exact_manifests.is_empty(); + 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) { @@ -590,7 +589,11 @@ where } layouts.push(layout); } - Ok((layouts, selected_is_sole_exact_root)) + Ok(( + layouts, + selected_manifest_matches_exact_root, + candidates_match_exact_root, + )) } pub fn retire_identity_cutover_manifest(layout: &StoreLayout) -> Result { @@ -1352,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, @@ -1362,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, @@ -1419,7 +1422,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, candidates_match_exact_root) = matching_legacy_profile_layouts_with_git_identity_resolver( &project_root, &profile_root, @@ -1441,14 +1444,15 @@ 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!(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_is_sole_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, @@ -1471,9 +1475,10 @@ 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!(!candidates_match_exact_root); assert_eq!( resolver_calls.borrow().as_slice(), [project_root, unrelated_root], @@ -1550,7 +1555,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, candidates_match_exact_root) = matching_legacy_profile_layouts_with_git_identity_resolver( &worktree_root, &profile_root, @@ -1569,7 +1574,8 @@ mod tests { .unwrap(); assert_eq!(layouts.len(), 1); - assert!(!selected_is_sole_exact_root); + 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/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/daemon.rs b/src/daemon.rs index 7ffb70728..ac7f2b74f 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2571,7 +2571,7 @@ impl DaemonEngine { 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/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 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/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/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(); 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/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 [ diff --git a/src/tracedecay/lifecycle.rs b/src/tracedecay/lifecycle.rs index d8d72b858..5d650b372 100644 --- a/src/tracedecay/lifecycle.rs +++ b/src/tracedecay/lifecycle.rs @@ -182,16 +182,35 @@ 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(); + 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 { - 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 = alias_matches_live_git_identity( + resolution.project.git_common_dir.as_deref(), + ); + 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, @@ -210,13 +229,33 @@ 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, + 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, candidates, - selected_is_sole_exact_root, + selected_manifest_matches_exact_root, + selected_via_exact_registry_alias, + candidates_match_exact_root, allow_repair, ) .await? @@ -230,17 +269,24 @@ impl TraceDecay { project_root: &Path, selected: Option, candidates: Vec, - selected_is_sole_exact_root: bool, + selected_manifest_matches_exact_root: bool, + selected_via_exact_registry_alias: bool, + candidates_match_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 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 + || (selected_via_exact_registry_alias && !candidates_match_exact_root)) && !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_has_bounded_population_evidence(selected).await { return Ok(Some(selected.clone())); } } @@ -298,6 +344,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(), @@ -309,6 +363,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(), @@ -392,8 +454,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…" @@ -402,9 +465,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, @@ -414,50 +477,26 @@ impl TraceDecay { 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; - } + 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}" + )), }; - let integrity = verification.quick_check().await; - 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; - } + 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; } } @@ -465,7 +504,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 => { @@ -486,42 +529,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; - } + // 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); + if active_graph_is_root { + clear_dirty_sentinel_at(&store_layout.dirty_path); } } @@ -1322,6 +1336,7 @@ struct StoreIdentityInventory { project_id: String, data_root: PathBuf, graph_health: &'static str, + auxiliary_health: &'static str, nodes: u64, files: u64, facts: u64, @@ -1336,7 +1351,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 { @@ -1358,10 +1373,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={} 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, @@ -1379,39 +1395,75 @@ 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), 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 = ( - 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, - ); - 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 @@ -1421,49 +1473,156 @@ 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: count_tree_files(&layout.dashboard_root), - payload_files: count_tree_files(&layout.lcm_payload_root), - response_files: count_tree_files(&layout.response_handle_root), + branches, + automation_files, + payload_files, + response_files, } } -async fn count_rows(connection: &libsql::Connection, table: &str) -> u64 { - let Ok(mut rows) = connection - .query(&format!("SELECT COUNT(*) FROM {table}"), ()) - .await +async fn store_identity_integrity_is_healthy(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, + &layout.data_root, + active_branch.as_deref(), + ); + let Ok(authority) = DatabaseAuthority::for_runtime(&serving_graph_db_path, "store repair") else { - return 0; + return false; }; - rows.next() - .await - .ok() - .flatten() - .and_then(|row| row.get::(0).ok()) - .and_then(|count| u64::try_from(count).ok()) - .unwrap_or(0) + let Ok((db, _)) = Database::open_read_only(&serving_graph_db_path, &authority).await else { + return false; + }; + db.close(); + true } -fn count_tree_files(root: &Path) -> u64 { - let Ok(entries) = std::fs::read_dir(root) else { - return 0; +/// 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_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, + &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_for_presence_probe(&serving_graph_db_path, &authority).await + } + Err(error) => Err(error), + }) else { + return false; }; - 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() + let graph_presence = ( + table_presence(db.conn(), "nodes").await, + table_presence(db.conn(), "files").await, + table_presence(db.conn(), "memory_facts").await, + ); + db.close(); + let graph_is_populated = match graph_presence { + (Ok(nodes), Ok(files), Ok(facts)) => nodes || files || facts, + _ => return false, + }; + + 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 => false, + }; + + 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, + }; + + 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 { + let mut rows = connection + .query(&format!("SELECT 1 FROM {table} LIMIT 1"), ()) + .await?; + Ok(rows.next().await?.is_some()) +} + +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 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 { + 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 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), + } + } + } + Ok(false) } fn identity_cutover_conflict( 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/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() 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) diff --git a/tests/storage_suite/corruption_test.rs b/tests/storage_suite/corruption_test.rs index 3803353c0..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,105 @@ 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> { + 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> { diff --git a/tests/storage_suite/storage_resolver_test.rs b/tests/storage_suite/storage_resolver_test.rs index 9be52b7b6..5e38771ac 100644 --- a/tests/storage_suite/storage_resolver_test.rs +++ b/tests/storage_suite/storage_resolver_test.rs @@ -12,7 +12,8 @@ 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::global_db::GlobalDb; +use tracedecay::db::{Database, DatabaseAuthority}; +use tracedecay::global_db::{GlobalDb, StoreInstanceUpsert}; use tracedecay::mcp::response_handles::{ ResponseHandleLookup, retrieve_response_handle, store_response_handle, }; @@ -1053,6 +1054,156 @@ 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; + 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(); + + 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 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; + 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(); + 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; @@ -1110,6 +1261,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; @@ -1126,6 +1356,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 +1393,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 +1424,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}"); @@ -1394,6 +1647,82 @@ 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_branch_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(); + + 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(); + 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-branch 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; @@ -1478,6 +1807,284 @@ 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(), + )); + } + + 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, + &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_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;