From b7b3d747c8ccff7d1daffbc94126c0adfbc881d5 Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Sun, 4 Oct 2026 19:24:56 +0000 Subject: [PATCH 1/5] fix(git): attribute inferred branches to the captured span Reflog backfill no longer writes a second span beside the ingest capture span of the same session window. The inferred branch is folded onto the captured span with BranchProvenance::Inferred, so readers see one span, the captured branch stays null (#2980), and replace_backfill_session clears only the inference on revisit (#3024). Refs #3052 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../tracedecay-dashboard-api/src/loom_api.rs | 2 + .../src/runtime/git_correlation.rs | 30 +++- .../runtime/git_correlation/attribution.rs | 42 +++++- .../git_correlation/backfill/bounded/tests.rs | 135 +++++++++++++++++- .../src/runtime/git_correlation/rows.rs | 99 ++++++++----- .../git_correlation/rows_view_tests.rs | 4 + .../src/runtime/git_correlation/tests.rs | 1 + 7 files changed, 270 insertions(+), 43 deletions(-) diff --git a/crates/tracedecay-dashboard-api/src/loom_api.rs b/crates/tracedecay-dashboard-api/src/loom_api.rs index 2db04e67e0..2a512bc0a2 100644 --- a/crates/tracedecay-dashboard-api/src/loom_api.rs +++ b/crates/tracedecay-dashboard-api/src/loom_api.rs @@ -1616,6 +1616,8 @@ mod tests { last_ts: 1_700_001_020, event_count: 2, source: tracedecay_sessions::runtime::git_correlation::SpanSource::Ingest, + branch_provenance: + tracedecay_sessions::runtime::git_correlation::BranchProvenance::Captured, } } diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation.rs b/crates/tracedecay-sessions/src/runtime/git_correlation.rs index af81db8603..43249dacf1 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation.rs @@ -26,7 +26,7 @@ const MIGRATION_NAME: &str = "git_correlation"; /// Schema version of the Git evidence rows, convergence receipts and /// watermarks. A store recorded at any other version is refused with a typed /// reset; nothing converts an older shape. -pub const GIT_CORRELATION_SCHEMA_VERSION: i64 = 7; +pub const GIT_CORRELATION_SCHEMA_VERSION: i64 = 8; pub const DEFAULT_SPAN_MERGE_GAP_SECS: i64 = 30 * 60; pub const DEFAULT_SPAN_OBSERVATION_DEBOUNCE_SECS: i64 = 30; // The scope value type and session cap are owned by the LCM engine crate so @@ -44,6 +44,15 @@ pub enum SpanSource { Backfill, } +/// Who asserted a span's `branch`: the host that captured the activity, or +/// the reflog inference that a session revisit replaces wholesale. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BranchProvenance { + Captured, + Inferred, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SpanOverlapKind { @@ -123,6 +132,18 @@ pub struct SessionGitSpan { pub last_ts: i64, pub event_count: i64, pub source: SpanSource, + pub branch_provenance: BranchProvenance, +} + +impl SessionGitSpan { + /// The branch the capturing host observed; an inferred branch is never + /// reported as captured. + pub fn captured_branch(&self) -> Option<&str> { + match self.branch_provenance { + BranchProvenance::Captured => self.branch.as_deref(), + BranchProvenance::Inferred => None, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1177,7 +1198,12 @@ fn span_hit(spans: &[&SessionGitSpan]) -> SessionGitCorrelationHit { .collect::>(); let sources = spans .iter() - .map(|span| format!("{:?}", span.source).to_ascii_lowercase()) + .flat_map(|span| { + let inferred = (span.branch_provenance == BranchProvenance::Inferred) + .then_some(SpanSource::Backfill); + std::iter::once(span.source).chain(inferred) + }) + .map(|source| format!("{source:?}").to_ascii_lowercase()) .collect::>(); SessionGitCorrelationHit { provider: providers diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs index 336c96db95..45ceb4f7b5 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs @@ -185,6 +185,7 @@ pub fn stable_backfill_span( last_ts, event_count: 2, source: super::SpanSource::Backfill, + branch_provenance: super::BranchProvenance::Inferred, } } @@ -200,7 +201,7 @@ pub(super) fn transcript_spans_from_observations( providers_compatible(&span.provider, &observation.provider) && span.session_id == observation.session_id && span.thread_id == observation.thread_id - && span.branch == observation.branch + && span.captured_branch() == observation.branch.as_deref() && span.worktree == worktree && span.source == observation.source && observation_extends_span( @@ -235,6 +236,7 @@ pub(super) fn transcript_spans_from_observations( last_ts: observation.ts, event_count: 1, source: observation.source, + branch_provenance: super::BranchProvenance::Captured, }, }; if let Some(candidate) = candidates @@ -268,6 +270,7 @@ pub(super) fn merge_span(spans: &mut Vec, incoming: &SessionGitS && span.session_id == incoming.session_id && span.thread_id == incoming.thread_id && span.branch == incoming.branch + && span.branch_provenance == incoming.branch_provenance && span.worktree == incoming.worktree && span.source == incoming.source && incoming.first_ts <= span.last_ts @@ -287,6 +290,43 @@ pub(super) fn merge_span(spans: &mut Vec, incoming: &SessionGitS true } +/// Attributes a reflog-inferred branch to the captured spans of the same +/// session and worktree that `inferred` overlaps, so capture and inference +/// share one span. Captured branches are never replaced. Returns `false` when +/// no captured span overlaps, or one already carries a different inference; +/// the inferred span then stands on its own. +pub(super) fn infer_captured_branch( + spans: &mut [SessionGitSpan], + inferred: &SessionGitSpan, +) -> bool { + let mut overlapping = spans + .iter_mut() + .filter(|span| { + span.source != super::SpanSource::Backfill + && providers_compatible(&span.provider, &inferred.provider) + && span.session_id == inferred.session_id + && span.worktree == inferred.worktree + && inferred.first_ts <= span.last_ts + && inferred.last_ts >= span.first_ts + }) + .collect::>(); + if overlapping.is_empty() + || overlapping.iter().any(|span| { + span.branch_provenance == super::BranchProvenance::Inferred + && span.branch != inferred.branch + }) + { + return false; + } + for span in &mut overlapping { + if span.branch.is_none() { + span.branch.clone_from(&inferred.branch); + span.branch_provenance = super::BranchProvenance::Inferred; + } + } + true +} + pub(super) fn merge_commit( commits: &mut Vec, incoming: &CommitSessionRecord, diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs index 097002f5eb..b535c17e32 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs @@ -10,9 +10,9 @@ use tracedecay_runtime_core::db::engine::{ use super::*; use crate::runtime::git_correlation::{ - GIT_HISTORY_SEQUENCE_FRONTIER_KEY, GitEvidenceBatch, GitEvidencePass, GitEvidenceWriter, - converge_git_evidence_pass, ensure_git_correlation_receipt_schema_in_transaction, - open_git_evidence_view, read_meta_value, + CommitRelationFilter, GIT_HISTORY_SEQUENCE_FRONTIER_KEY, GitEvidenceBatch, GitEvidencePass, + GitEvidenceWriter, GitRefFilter, SessionsForQuery, converge_git_evidence_pass, + ensure_git_correlation_receipt_schema_in_transaction, open_git_evidence_view, read_meta_value, }; impl GitCorrelationWriteTxn for Transaction { @@ -556,6 +556,134 @@ async fn in_place_history_rewrites_revisit_and_retract_old_inference() { } } +/// Capture and reflog inference describe the same session window: readers see +/// one span whose branch is inferred, while the captured branch stays null and +/// the inference alone is retracted when the session is revisited. +#[tokio::test] +async fn reflog_inference_attributes_the_captured_span_and_retracts_alone() { + let repository = repository_fixture(); + let directory = tempfile::tempdir().unwrap(); + let store = prepare_store(&directory.path().join("sessions.db"), repository.path()).await; + let active_at = head_commit_time(repository.path()); + store + .connection + .execute("UPDATE sessions SET started_at = NULL, ended_at = NULL", ()) + .await + .unwrap(); + store + .connection + .execute( + "UPDATE lcm_raw_messages SET timestamp = ?1", + params![active_at], + ) + .await + .unwrap(); + let transaction = store.open_write_transaction().await.unwrap(); + let mut writer = GitEvidenceWriter::open(&transaction).await.unwrap(); + writer + .apply(GitEvidenceBatch { + observations: vec![crate::runtime::git_correlation::SpanObservation { + provider: "codex".to_owned(), + session_id: "session-1".to_owned(), + thread_id: None, + branch: None, + worktree: repository.path().to_string_lossy().into_owned(), + ts: active_at, + source: crate::runtime::git_correlation::SpanSource::Ingest, + }], + merge_gap_secs: DEFAULT_SPAN_MERGE_GAP_SECS, + ..GitEvidenceBatch::default() + }) + .await + .unwrap(); + writer.finish().await.unwrap(); + transaction.commit().await.unwrap(); + + converge_git_evidence_pass(&store, &SystemGit, None) + .await + .unwrap(); + assert_eq!( + scalar(&store, "SELECT COUNT(*) FROM git_evidence_span").await, + 1, + "capture and inference must share one span" + ); + let main = SessionsForQuery { + git_ref: GitRefFilter::Branch("main".to_owned()), + since: None, + until: None, + limit: 10, + }; + let view = open_git_evidence_view(&store.connection) + .await + .unwrap() + .unwrap(); + let hits = view + .sessions_for(&main, CommitRelationFilter::All) + .await + .unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].span_count, 1); + assert_eq!(hits[0].branch.as_deref(), Some("main")); + assert_eq!(hits[0].sources, ["backfill", "ingest"]); + let span = &view + .session_evidence(&std::collections::BTreeSet::from(["session-1".to_owned()])) + .await + .unwrap() + .0[0]; + assert_eq!( + span.source, + crate::runtime::git_correlation::SpanSource::Ingest + ); + assert_eq!( + span.captured_branch(), + None, + "inference never becomes capture" + ); + drop(view); + + let plain = directory.path().join("plain directory"); + std::fs::create_dir(&plain).unwrap(); + store + .connection + .execute( + "UPDATE sessions SET metadata_json = ?1", + params![serde_json::json!({"codex_session_cwd": plain.to_str().unwrap()}).to_string()], + ) + .await + .unwrap(); + let revisited = converge_git_evidence_pass(&store, &SystemGit, None) + .await + .unwrap() + .pass; + assert_eq!(revisited.backfill.sessions_scanned, 1); + assert_eq!( + scalar(&store, "SELECT COUNT(*) FROM git_evidence_span").await, + 1, + "retracting inference keeps the captured span" + ); + let view = open_git_evidence_view(&store.connection) + .await + .unwrap() + .unwrap(); + assert!( + view.sessions_for(&main, CommitRelationFilter::All) + .await + .unwrap() + .is_empty() + ); + let (spans, commits) = view + .session_evidence(&std::collections::BTreeSet::from(["session-1".to_owned()])) + .await + .unwrap(); + assert_eq!(spans[0].branch, None); + assert_eq!(spans[0].captured_branch(), None); + assert!( + commits.iter().all(|record| record.branch.is_none() + && record.evidence != crate::runtime::git_correlation::CommitEvidence::ReflogOverlap), + "relations derived from the inferred branch must be retracted: {commits:#?}" + ); +} + /// A fresh project has never recorded Git evidence. Reporting that as a /// retryable unavailability put every fresh project's ingest into an endless /// retry loop. @@ -616,6 +744,7 @@ async fn archived_branch_is_limited_coverage_without_losing_observed_span() { last_ts: 2, event_count: 2, source: crate::runtime::git_correlation::SpanSource::Ingest, + branch_provenance: crate::runtime::git_correlation::BranchProvenance::Captured, }; ensure_git_correlation_receipt_schema_in_transaction(&store.connection) .await diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs index 47f162093d..e87775632c 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs @@ -11,7 +11,9 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use tracedecay_runtime_core::db::engine::{Executor, QueryExecutor, Row, params}; -use super::attribution::{merge_commit, merge_span, transcript_spans_from_observations}; +use super::attribution::{ + infer_captured_branch, merge_commit, merge_span, transcript_spans_from_observations, +}; use super::{ CommitRelationFilter, CommitSessionRecord, CorrelationIndexHealth, CorrelationIndexPresence, GitCorrelationError, GitRefFilter, GitScopeFilter, SessionGitCorrelationHit, SessionGitSpan, @@ -161,7 +163,9 @@ impl<'t, T: Executor + ?Sized> GitEvidenceWriter<'t, T> { /// Start replacement of one retained-history revision. Explicit capture /// and hook observations remain facts; inferred backfill evidence is - /// retracted before this revision's staged pages are published. + /// retracted before this revision's staged pages are published: inferred + /// spans are deleted and a branch inferred onto a captured span is + /// cleared, keeping the captured span. pub async fn replace_backfill_session( &mut self, provider: &str, @@ -198,6 +202,19 @@ impl<'t, T: Executor + ?Sized> GitEvidenceWriter<'t, T> { } let (spans, commits) = load_session_rows(self.transaction, &BTreeSet::from([session_id.to_owned()])).await?; + let mut retracted = BTreeSet::new(); + for span in spans.values().filter(|span| { + span.provider == provider && span.branch_provenance == super::BranchProvenance::Inferred + }) { + retracted.insert(span.span_id.clone()); + if span.source == super::SpanSource::Backfill { + continue; + } + let mut captured = span.clone(); + captured.branch = None; + captured.branch_provenance = super::BranchProvenance::Captured; + self.upsert_span(&captured).await?; + } let removed = spans .values() .filter(|span| span.provider == provider && span.source == super::SpanSource::Backfill) @@ -220,7 +237,7 @@ impl<'t, T: Executor + ?Sized> GitEvidenceWriter<'t, T> { || record .span_id .as_ref() - .is_some_and(|id| removed.contains(id))) + .is_some_and(|id| retracted.contains(id))) }) { self.transaction .execute( @@ -243,6 +260,40 @@ impl<'t, T: Executor + ?Sized> GitEvidenceWriter<'t, T> { Ok(()) } + async fn upsert_span(&mut self, span: &SessionGitSpan) -> Result<(), GitCorrelationError> { + let record = serde_json::to_string(span)?; + self.transaction + .execute( + "INSERT INTO git_evidence_span( + span_id, session_id, provider, branch, worktree, + first_ts, last_ts, sequence, record + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(span_id) DO UPDATE SET + session_id = excluded.session_id, + provider = excluded.provider, + branch = excluded.branch, + worktree = excluded.worktree, + first_ts = excluded.first_ts, + last_ts = excluded.last_ts, + sequence = excluded.sequence, + record = excluded.record", + params![ + span.span_id.as_str(), + span.session_id.as_str(), + span.provider.as_str(), + span.branch.as_deref(), + span.worktree.as_str(), + span.first_ts, + span.last_ts, + self.sequence, + record.as_str() + ], + ) + .await?; + self.change_digest.push(record); + Ok(()) + } + /// Merges `batch` into the stored rows of the sessions it names and /// upserts every row whose canonical record changed. pub async fn apply( @@ -283,10 +334,13 @@ impl<'t, T: Executor + ?Sized> GitEvidenceWriter<'t, T> { for mut incoming in candidates { validate_span(&incoming)?; incoming.worktree = normalize_worktree(&incoming.worktree); - merge_span( - spans.entry(incoming.session_id.clone()).or_default(), - &incoming, - ); + let session_spans = spans.entry(incoming.session_id.clone()).or_default(); + if incoming.source == super::SpanSource::Backfill + && infer_captured_branch(session_spans, &incoming) + { + continue; + } + merge_span(session_spans, &incoming); } for mut incoming in incoming_commits { validate_commit_record(&incoming)?; @@ -319,36 +373,7 @@ impl<'t, T: Executor + ?Sized> GitEvidenceWriter<'t, T> { if !stored_spans.contains_key(&span.span_id) { self.spans_added = self.spans_added.saturating_add(1); } - let record = serde_json::to_string(span)?; - self.transaction - .execute( - "INSERT INTO git_evidence_span( - span_id, session_id, provider, branch, worktree, - first_ts, last_ts, sequence, record - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) - ON CONFLICT(span_id) DO UPDATE SET - session_id = excluded.session_id, - provider = excluded.provider, - branch = excluded.branch, - worktree = excluded.worktree, - first_ts = excluded.first_ts, - last_ts = excluded.last_ts, - sequence = excluded.sequence, - record = excluded.record", - params![ - span.span_id.as_str(), - span.session_id.as_str(), - span.provider.as_str(), - span.branch.as_deref(), - span.worktree.as_str(), - span.first_ts, - span.last_ts, - self.sequence, - record.as_str() - ], - ) - .await?; - self.change_digest.push(record); + self.upsert_span(span).await?; write.spans_changed += 1; } for record in session_commits.iter() { diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/rows_view_tests.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/rows_view_tests.rs index 482772067e..315a8e07d8 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/rows_view_tests.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/rows_view_tests.rs @@ -44,6 +44,7 @@ fn seeded_projection(sessions: usize) -> GitEvidenceProjectionV1 { last_ts: main_first + 50, event_count: 2, source: SpanSource::Ingest, + branch_provenance: BranchProvenance::Captured, }); if index % 4 == 0 { spans.push(SessionGitSpan { @@ -57,6 +58,7 @@ fn seeded_projection(sessions: usize) -> GitEvidenceProjectionV1 { last_ts: main_first - 480, event_count: 3, source: SpanSource::Backfill, + branch_provenance: BranchProvenance::Inferred, }); } spans.push(SessionGitSpan { @@ -70,6 +72,7 @@ fn seeded_projection(sessions: usize) -> GitEvidenceProjectionV1 { last_ts: BASE_TS + 100 * index as i64 + 40, event_count: 1, source: SpanSource::HookRoute, + branch_provenance: BranchProvenance::Captured, }); let commit_sha = sha(index / 8, (index / 4) % 2); let produced = index % 4 == 0; @@ -351,6 +354,7 @@ async fn rows_view_reports_empty_row_families() { last_ts: 2, event_count: 1, source: SpanSource::Ingest, + branch_provenance: BranchProvenance::Captured, }], Vec::new(), ) diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/tests.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/tests.rs index 26b2cc5c9f..23831002f7 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/tests.rs @@ -20,6 +20,7 @@ fn span( last_ts, event_count: 2, source: SpanSource::Ingest, + branch_provenance: BranchProvenance::Captured, } } From 6cc2033a6465651f62d3b6b0f77a703ccfedbad8 Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Sun, 4 Oct 2026 20:16:17 +0000 Subject: [PATCH 2/5] fix(git): bound inference folds and keep captured commit evidence Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../runtime/git_correlation/attribution.rs | 24 +- .../git_correlation/backfill/bounded/tests.rs | 374 ++++++++++++++++++ .../src/runtime/git_correlation/rows.rs | 23 +- 3 files changed, 408 insertions(+), 13 deletions(-) diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs index 45ceb4f7b5..4aea7daede 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs @@ -210,6 +210,12 @@ pub(super) fn transcript_spans_from_observations( observation.ts, merge_gap_secs, ) + // An inference-tagged span is bounded by its evidence: a + // later branchless observation merges only inside its + // window, never stretches the inferred branch over time + // the reflog segment did not cover. + && (span.branch_provenance != super::BranchProvenance::Inferred + || (observation.ts >= span.first_ts && observation.ts <= span.last_ts)) }); let span = match existing { Some(existing) => { @@ -292,9 +298,11 @@ pub(super) fn merge_span(spans: &mut Vec, incoming: &SessionGitS /// Attributes a reflog-inferred branch to the captured spans of the same /// session and worktree that `inferred` overlaps, so capture and inference -/// share one span. Captured branches are never replaced. Returns `false` when -/// no captured span overlaps, or one already carries a different inference; -/// the inferred span then stands on its own. +/// share one span. Captured branches are never replaced. The overlapping +/// spans absorb the segment's window and event count, so nothing the +/// inference covered is dropped. Returns `false` when no captured span +/// overlaps, or an overlapping span already names a different branch — +/// captured or inferred — leaving the inference to stand on its own. pub(super) fn infer_captured_branch( spans: &mut [SessionGitSpan], inferred: &SessionGitSpan, @@ -311,10 +319,9 @@ pub(super) fn infer_captured_branch( }) .collect::>(); if overlapping.is_empty() - || overlapping.iter().any(|span| { - span.branch_provenance == super::BranchProvenance::Inferred - && span.branch != inferred.branch - }) + || overlapping + .iter() + .any(|span| span.branch.is_some() && span.branch != inferred.branch) { return false; } @@ -323,6 +330,9 @@ pub(super) fn infer_captured_branch( span.branch.clone_from(&inferred.branch); span.branch_provenance = super::BranchProvenance::Inferred; } + span.first_ts = span.first_ts.min(inferred.first_ts); + span.last_ts = span.last_ts.max(inferred.last_ts); + span.event_count = span.event_count.max(inferred.event_count); } true } diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs index b535c17e32..a3c8cc2a27 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs @@ -684,6 +684,380 @@ async fn reflog_inference_attributes_the_captured_span_and_retracts_alone() { ); } +/// A captured span that names a different branch contradicts the reflog +/// inference: the inference cannot fold, so it keeps its own span rather +/// than being dropped silently. +#[tokio::test] +async fn conflicting_captured_branch_keeps_the_inferred_span() { + let repository = repository_fixture(); + let directory = tempfile::tempdir().unwrap(); + let store = prepare_store(&directory.path().join("sessions.db"), repository.path()).await; + let active_at = head_commit_time(repository.path()); + store + .connection + .execute("UPDATE sessions SET started_at = NULL, ended_at = NULL", ()) + .await + .unwrap(); + store + .connection + .execute( + "UPDATE lcm_raw_messages SET timestamp = ?1", + params![active_at], + ) + .await + .unwrap(); + let transaction = store.open_write_transaction().await.unwrap(); + let mut writer = GitEvidenceWriter::open(&transaction).await.unwrap(); + writer + .apply(GitEvidenceBatch { + observations: vec![crate::runtime::git_correlation::SpanObservation { + provider: "codex".to_owned(), + session_id: "session-1".to_owned(), + thread_id: None, + branch: Some("feature".to_owned()), + worktree: repository.path().to_string_lossy().into_owned(), + ts: active_at, + source: crate::runtime::git_correlation::SpanSource::Ingest, + }], + merge_gap_secs: DEFAULT_SPAN_MERGE_GAP_SECS, + ..GitEvidenceBatch::default() + }) + .await + .unwrap(); + writer.finish().await.unwrap(); + transaction.commit().await.unwrap(); + + converge_git_evidence_pass(&store, &SystemGit, None) + .await + .unwrap(); + assert_eq!( + scalar(&store, "SELECT COUNT(*) FROM git_evidence_span").await, + 2, + "an inference the capture contradicts must keep its own span" + ); + let view = open_git_evidence_view(&store.connection) + .await + .unwrap() + .unwrap(); + for (branch, sources) in [("main", vec!["backfill"]), ("feature", vec!["ingest"])] { + let hits = view + .sessions_for( + &SessionsForQuery { + git_ref: GitRefFilter::Branch(branch.to_owned()), + since: None, + until: None, + limit: 10, + }, + CommitRelationFilter::All, + ) + .await + .unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].span_count, 1); + assert_eq!(hits[0].branch.as_deref(), Some(branch)); + assert_eq!(hits[0].sources, sources); + } +} + +/// Folding keeps the segment's own coverage: the captured span absorbs the +/// reflog window instead of replacing it with capture-only bounds, so a +/// time-filtered query still sees everything the inference covered. +#[tokio::test] +async fn folded_inference_absorbs_the_segment_window() { + let repository = repository_fixture(); + let directory = tempfile::tempdir().unwrap(); + let store = prepare_store(&directory.path().join("sessions.db"), repository.path()).await; + let active_at = head_commit_time(repository.path()); + let transaction = store.open_write_transaction().await.unwrap(); + let mut writer = GitEvidenceWriter::open(&transaction).await.unwrap(); + writer + .apply(GitEvidenceBatch { + observations: vec![crate::runtime::git_correlation::SpanObservation { + provider: "codex".to_owned(), + session_id: "session-1".to_owned(), + thread_id: None, + branch: None, + worktree: repository.path().to_string_lossy().into_owned(), + ts: active_at, + source: crate::runtime::git_correlation::SpanSource::Ingest, + }], + merge_gap_secs: DEFAULT_SPAN_MERGE_GAP_SECS, + ..GitEvidenceBatch::default() + }) + .await + .unwrap(); + writer + .apply(GitEvidenceBatch { + spans: vec![crate::runtime::git_correlation::stable_backfill_span( + "codex", + "session-1", + Some("main"), + &repository.path().to_string_lossy(), + active_at - 50, + active_at + 600, + )], + merge_gap_secs: DEFAULT_SPAN_MERGE_GAP_SECS, + ..GitEvidenceBatch::default() + }) + .await + .unwrap(); + writer.finish().await.unwrap(); + transaction.commit().await.unwrap(); + + let view = open_git_evidence_view(&store.connection) + .await + .unwrap() + .unwrap(); + let (spans, _) = view + .session_evidence(&std::collections::BTreeSet::from(["session-1".to_owned()])) + .await + .unwrap(); + assert_eq!(spans.len(), 1); + assert_eq!( + (spans[0].first_ts, spans[0].last_ts), + (active_at - 50, active_at + 600), + "the folded span covers the union of capture and inference evidence" + ); + assert_eq!(spans[0].captured_branch(), None); + // A query window inside the segment but past the capture still hits. + let hits = view + .sessions_for( + &SessionsForQuery { + git_ref: GitRefFilter::Branch("main".to_owned()), + since: Some(active_at + 300), + until: None, + limit: 10, + }, + CommitRelationFilter::All, + ) + .await + .unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].sources, ["backfill", "ingest"]); +} + +/// An inference-tagged span is bounded by its evidence window. A branchless +/// observation beyond it does not stretch the inferred branch over time the +/// reflog segment never covered: capture opens its own span instead. +#[tokio::test] +async fn branchless_capture_beyond_an_inferred_window_opens_its_own_span() { + let repository = repository_fixture(); + let directory = tempfile::tempdir().unwrap(); + let store = prepare_store(&directory.path().join("sessions.db"), repository.path()).await; + let active_at = head_commit_time(repository.path()); + let apply_observation = |ts: i64| GitEvidenceBatch { + observations: vec![crate::runtime::git_correlation::SpanObservation { + provider: "codex".to_owned(), + session_id: "session-1".to_owned(), + thread_id: None, + branch: None, + worktree: repository.path().to_string_lossy().into_owned(), + ts, + source: crate::runtime::git_correlation::SpanSource::Ingest, + }], + merge_gap_secs: DEFAULT_SPAN_MERGE_GAP_SECS, + ..GitEvidenceBatch::default() + }; + let transaction = store.open_write_transaction().await.unwrap(); + let mut writer = GitEvidenceWriter::open(&transaction).await.unwrap(); + writer.apply(apply_observation(active_at)).await.unwrap(); + writer.finish().await.unwrap(); + transaction.commit().await.unwrap(); + + store + .connection + .execute("UPDATE sessions SET started_at = NULL, ended_at = NULL", ()) + .await + .unwrap(); + store + .connection + .execute( + "UPDATE lcm_raw_messages SET timestamp = ?1", + params![active_at], + ) + .await + .unwrap(); + converge_git_evidence_pass(&store, &SystemGit, None) + .await + .unwrap(); + assert_eq!( + scalar(&store, "SELECT COUNT(*) FROM git_evidence_span").await, + 1, + "capture and inference share one span" + ); + + let transaction = store.open_write_transaction().await.unwrap(); + let mut writer = GitEvidenceWriter::open(&transaction).await.unwrap(); + writer + .apply(apply_observation(active_at + 600)) + .await + .unwrap(); + writer.finish().await.unwrap(); + transaction.commit().await.unwrap(); + + let view = open_git_evidence_view(&store.connection) + .await + .unwrap() + .unwrap(); + let (spans, _) = view + .session_evidence(&std::collections::BTreeSet::from(["session-1".to_owned()])) + .await + .unwrap(); + assert_eq!( + spans.len(), + 2, + "later branchless capture opens its own span" + ); + let inferred = spans + .iter() + .find(|span| span.branch.as_deref() == Some("main")) + .expect("the inference stays on its evidence window"); + assert_eq!( + (inferred.first_ts, inferred.last_ts), + (active_at, active_at), + "the inferred branch does not cover activity past its segment" + ); + let captured = spans + .iter() + .find(|span| span.branch.is_none()) + .expect("the later observation is captured, not inferred"); + assert_eq!(captured.first_ts, active_at + 600); + assert_eq!( + captured.branch_provenance, + crate::runtime::git_correlation::BranchProvenance::Captured + ); +} + +/// Revisiting a session retracts only what the inference produced. A commit +/// linked to the captured span by capture-side evidence survives; the +/// records the inferred branch admitted are deleted with it. +#[tokio::test] +async fn revisit_keeps_captured_commit_evidence_on_the_surviving_span() { + let repository = repository_fixture(); + let directory = tempfile::tempdir().unwrap(); + let store = prepare_store(&directory.path().join("sessions.db"), repository.path()).await; + let active_at = head_commit_time(repository.path()); + store + .connection + .execute("UPDATE sessions SET started_at = NULL, ended_at = NULL", ()) + .await + .unwrap(); + store + .connection + .execute( + "UPDATE lcm_raw_messages SET timestamp = ?1", + params![active_at], + ) + .await + .unwrap(); + let transaction = store.open_write_transaction().await.unwrap(); + let mut writer = GitEvidenceWriter::open(&transaction).await.unwrap(); + writer + .apply(GitEvidenceBatch { + observations: vec![crate::runtime::git_correlation::SpanObservation { + provider: "codex".to_owned(), + session_id: "session-1".to_owned(), + thread_id: None, + branch: None, + worktree: repository.path().to_string_lossy().into_owned(), + ts: active_at, + source: crate::runtime::git_correlation::SpanSource::Ingest, + }], + merge_gap_secs: DEFAULT_SPAN_MERGE_GAP_SECS, + ..GitEvidenceBatch::default() + }) + .await + .unwrap(); + writer.finish().await.unwrap(); + transaction.commit().await.unwrap(); + + let view = open_git_evidence_view(&store.connection) + .await + .unwrap() + .unwrap(); + let captured_span_id = view + .session_evidence(&std::collections::BTreeSet::from(["session-1".to_owned()])) + .await + .unwrap() + .0[0] + .span_id + .clone(); + drop(view); + + let transaction = store.open_write_transaction().await.unwrap(); + let mut writer = GitEvidenceWriter::open(&transaction).await.unwrap(); + writer + .apply(GitEvidenceBatch { + commits: vec![crate::runtime::git_correlation::CommitSessionRecord { + commit_sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned(), + provider: "codex".to_owned(), + session_id: "session-1".to_owned(), + branch: None, + worktree: Some(repository.path().to_string_lossy().into_owned()), + committed_at: active_at, + span_overlap_kind: crate::runtime::git_correlation::SpanOverlapKind::Direct, + span_id: Some(captured_span_id), + relation: crate::runtime::git_correlation::CommitRelation::Produced, + evidence: crate::runtime::git_correlation::CommitEvidence::ToolResult, + confidence: 100, + evidence_message_id: None, + }], + merge_gap_secs: DEFAULT_SPAN_MERGE_GAP_SECS, + ..GitEvidenceBatch::default() + }) + .await + .unwrap(); + writer.finish().await.unwrap(); + transaction.commit().await.unwrap(); + + converge_git_evidence_pass(&store, &SystemGit, None) + .await + .unwrap(); + + let plain = directory.path().join("plain directory"); + std::fs::create_dir(&plain).unwrap(); + store + .connection + .execute( + "UPDATE sessions SET metadata_json = ?1", + params![serde_json::json!({"codex_session_cwd": plain.to_str().unwrap()}).to_string()], + ) + .await + .unwrap(); + let revisited = converge_git_evidence_pass(&store, &SystemGit, None) + .await + .unwrap() + .pass; + assert_eq!(revisited.backfill.sessions_scanned, 1); + assert_eq!( + scalar(&store, "SELECT COUNT(*) FROM git_evidence_span").await, + 1, + "retracting inference keeps the captured span" + ); + let view = open_git_evidence_view(&store.connection) + .await + .unwrap() + .unwrap(); + let (spans, commits) = view + .session_evidence(&std::collections::BTreeSet::from(["session-1".to_owned()])) + .await + .unwrap(); + assert_eq!(spans[0].branch, None); + assert_eq!(spans[0].captured_branch(), None); + assert!( + commits.iter().any(|record| { + record.commit_sha == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + && record.evidence == crate::runtime::git_correlation::CommitEvidence::ToolResult + }), + "captured commit evidence on the surviving span must be kept: {commits:#?}" + ); + assert!( + commits.iter().all(|record| record.branch.is_none() + && record.evidence != crate::runtime::git_correlation::CommitEvidence::ReflogOverlap), + "relations derived from the inferred branch must be retracted: {commits:#?}" + ); +} + /// A fresh project has never recorded Git evidence. Reporting that as a /// retryable unavailability put every fresh project's ingest into an endless /// retry loop. diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs index e87775632c..f0658fd1bb 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs @@ -202,14 +202,14 @@ impl<'t, T: Executor + ?Sized> GitEvidenceWriter<'t, T> { } let (spans, commits) = load_session_rows(self.transaction, &BTreeSet::from([session_id.to_owned()])).await?; - let mut retracted = BTreeSet::new(); + let mut cleared = BTreeSet::new(); for span in spans.values().filter(|span| { span.provider == provider && span.branch_provenance == super::BranchProvenance::Inferred }) { - retracted.insert(span.span_id.clone()); if span.source == super::SpanSource::Backfill { continue; } + cleared.insert(span.span_id.clone()); let mut captured = span.clone(); captured.branch = None; captured.branch_provenance = super::BranchProvenance::Captured; @@ -231,13 +231,24 @@ impl<'t, T: Executor + ?Sized> GitEvidenceWriter<'t, T> { self.change_digest .push(serde_json::to_string(&("delete_span", span_id))?); } + // A relation dies with the evidence it was derived from: every record + // on a removed inferred span goes, and so does any record the + // inference itself produced (a branchless ReflogOverlap, or the + // TimeOverlap the inferred branch admitted). Captured evidence linked + // to a surviving captured span stays — the span keeps its identity, + // only its inferred branch is cleared. for record in commits.values().filter(|record| { record.provider == provider && (record.evidence == super::CommitEvidence::ReflogOverlap - || record - .span_id - .as_ref() - .is_some_and(|id| retracted.contains(id))) + || record.span_id.as_ref().is_some_and(|id| { + removed.contains(id) + || (cleared.contains(id) + && matches!( + record.evidence, + super::CommitEvidence::ReflogOverlap + | super::CommitEvidence::TimeOverlap + )) + })) }) { self.transaction .execute( From 2d4a17e4a6d62e6833373a74ffd196251b1b0e0e Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Sun, 4 Oct 2026 21:51:37 +0000 Subject: [PATCH 3/5] fix(git): restore captured span bounds when inference retracts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../tracedecay-dashboard-api/src/loom_api.rs | 1 + .../src/runtime/git_correlation.rs | 19 ++- .../runtime/git_correlation/attribution.rs | 18 +++ .../git_correlation/backfill/bounded/tests.rs | 118 ++++++++++++++++++ .../src/runtime/git_correlation/rows.rs | 15 ++- .../git_correlation/rows_view_tests.rs | 4 + .../src/runtime/git_correlation/tests.rs | 1 + 7 files changed, 172 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay-dashboard-api/src/loom_api.rs b/crates/tracedecay-dashboard-api/src/loom_api.rs index 2a512bc0a2..96c8559c2b 100644 --- a/crates/tracedecay-dashboard-api/src/loom_api.rs +++ b/crates/tracedecay-dashboard-api/src/loom_api.rs @@ -1618,6 +1618,7 @@ mod tests { source: tracedecay_sessions::runtime::git_correlation::SpanSource::Ingest, branch_provenance: tracedecay_sessions::runtime::git_correlation::BranchProvenance::Captured, + capture_window: None, } } diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation.rs b/crates/tracedecay-sessions/src/runtime/git_correlation.rs index 43249dacf1..2483efc881 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation.rs @@ -26,7 +26,7 @@ const MIGRATION_NAME: &str = "git_correlation"; /// Schema version of the Git evidence rows, convergence receipts and /// watermarks. A store recorded at any other version is refused with a typed /// reset; nothing converts an older shape. -pub const GIT_CORRELATION_SCHEMA_VERSION: i64 = 8; +pub const GIT_CORRELATION_SCHEMA_VERSION: i64 = 9; pub const DEFAULT_SPAN_MERGE_GAP_SECS: i64 = 30 * 60; pub const DEFAULT_SPAN_OBSERVATION_DEBOUNCE_SECS: i64 = 30; // The scope value type and session cap are owned by the LCM engine crate so @@ -133,6 +133,23 @@ pub struct SessionGitSpan { pub event_count: i64, pub source: SpanSource, pub branch_provenance: BranchProvenance, + /// Capture-only bounds held before an inference fold unioned a reflog + /// segment's window into this span. `replace_backfill_session` restores + /// them when the inference is retracted, so segment-covered time never + /// outlives the evidence it came from. `None` while no inference is + /// folded in. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capture_window: Option, +} + +/// The `first_ts`/`last_ts`/`event_count` a captured span had before an +/// inference unioned its segment window in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CaptureWindow { + pub first_ts: i64, + pub last_ts: i64, + pub event_count: i64, } impl SessionGitSpan { diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs index 4aea7daede..18e8bd9562 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs @@ -186,6 +186,7 @@ pub fn stable_backfill_span( event_count: 2, source: super::SpanSource::Backfill, branch_provenance: super::BranchProvenance::Inferred, + capture_window: None, } } @@ -229,6 +230,15 @@ pub(super) fn transcript_spans_from_observations( if extends { span.event_count = span.event_count.saturating_add(1); } + if let Some(capture) = span.capture_window.as_mut() { + let capture_extends = + observation.ts < capture.first_ts || observation.ts > capture.last_ts; + capture.first_ts = capture.first_ts.min(observation.ts); + capture.last_ts = capture.last_ts.max(observation.ts); + if capture_extends { + capture.event_count = capture.event_count.saturating_add(1); + } + } span } None => SessionGitSpan { @@ -243,6 +253,7 @@ pub(super) fn transcript_spans_from_observations( event_count: 1, source: observation.source, branch_provenance: super::BranchProvenance::Captured, + capture_window: None, }, }; if let Some(candidate) = candidates @@ -326,6 +337,13 @@ pub(super) fn infer_captured_branch( return false; } for span in &mut overlapping { + if span.capture_window.is_none() { + span.capture_window = Some(super::CaptureWindow { + first_ts: span.first_ts, + last_ts: span.last_ts, + event_count: span.event_count, + }); + } if span.branch.is_none() { span.branch.clone_from(&inferred.branch); span.branch_provenance = super::BranchProvenance::Inferred; diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs index a3c8cc2a27..fbfdba645a 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs @@ -1058,6 +1058,123 @@ async fn revisit_keeps_captured_commit_evidence_on_the_surviving_span() { ); } +/// A fold unions the segment's window and count into the captured span only +/// while the inference lives. Retracting the inference must restore the +/// capture-only values, or the session keeps claiming activity the +/// retracted evidence alone covered. +#[tokio::test] +async fn revisit_restores_the_capture_window_a_fold_unioned() { + let repository = repository_fixture(); + let directory = tempfile::tempdir().unwrap(); + let store = prepare_store(&directory.path().join("sessions.db"), repository.path()).await; + let active_at = head_commit_time(repository.path()); + store + .connection + .execute("UPDATE sessions SET started_at = NULL, ended_at = NULL", ()) + .await + .unwrap(); + store + .connection + .execute( + "UPDATE lcm_raw_messages SET timestamp = ?1", + params![active_at], + ) + .await + .unwrap(); + let transaction = store.open_write_transaction().await.unwrap(); + let mut writer = GitEvidenceWriter::open(&transaction).await.unwrap(); + writer + .apply(GitEvidenceBatch { + observations: vec![crate::runtime::git_correlation::SpanObservation { + provider: "codex".to_owned(), + session_id: "session-1".to_owned(), + thread_id: None, + branch: None, + worktree: repository.path().to_string_lossy().into_owned(), + ts: active_at, + source: crate::runtime::git_correlation::SpanSource::Ingest, + }], + spans: vec![crate::runtime::git_correlation::stable_backfill_span( + "codex", + "session-1", + Some("main"), + &repository.path().to_string_lossy(), + active_at - 50, + active_at + 600, + )], + merge_gap_secs: DEFAULT_SPAN_MERGE_GAP_SECS, + ..GitEvidenceBatch::default() + }) + .await + .unwrap(); + writer.finish().await.unwrap(); + transaction.commit().await.unwrap(); + + let view = open_git_evidence_view(&store.connection) + .await + .unwrap() + .unwrap(); + let (spans, _) = view + .session_evidence(&std::collections::BTreeSet::from(["session-1".to_owned()])) + .await + .unwrap(); + assert_eq!( + (spans[0].first_ts, spans[0].last_ts, spans[0].event_count), + (active_at - 50, active_at + 600, 2), + "the folded span carries the segment's coverage while the inference lives" + ); + drop(view); + + let plain = directory.path().join("plain directory"); + std::fs::create_dir(&plain).unwrap(); + store + .connection + .execute( + "UPDATE sessions SET metadata_json = ?1", + params![serde_json::json!({"codex_session_cwd": plain.to_str().unwrap()}).to_string()], + ) + .await + .unwrap(); + let revisited = converge_git_evidence_pass(&store, &SystemGit, None) + .await + .unwrap() + .pass; + assert_eq!(revisited.backfill.sessions_scanned, 1); + + let view = open_git_evidence_view(&store.connection) + .await + .unwrap() + .unwrap(); + let (spans, _) = view + .session_evidence(&std::collections::BTreeSet::from(["session-1".to_owned()])) + .await + .unwrap(); + assert_eq!(spans.len(), 1); + assert_eq!( + (spans[0].first_ts, spans[0].last_ts, spans[0].event_count), + (active_at, active_at, 1), + "retracting the inference restores the capture-only window: {spans:#?}" + ); + assert_eq!(spans[0].capture_window, None); + assert!( + view.sessions_for( + &SessionsForQuery { + git_ref: crate::runtime::git_correlation::GitRefFilter::Worktree( + repository.path().to_string_lossy().into_owned(), + ), + since: Some(active_at + 300), + until: None, + limit: 10, + }, + crate::runtime::git_correlation::CommitRelationFilter::All, + ) + .await + .unwrap() + .is_empty(), + "segment-covered time must not outlive the retracted inference" + ); +} + /// A fresh project has never recorded Git evidence. Reporting that as a /// retryable unavailability put every fresh project's ingest into an endless /// retry loop. @@ -1119,6 +1236,7 @@ async fn archived_branch_is_limited_coverage_without_losing_observed_span() { event_count: 2, source: crate::runtime::git_correlation::SpanSource::Ingest, branch_provenance: crate::runtime::git_correlation::BranchProvenance::Captured, + capture_window: None, }; ensure_git_correlation_receipt_schema_in_transaction(&store.connection) .await diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs index f0658fd1bb..1f8a40713f 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs @@ -204,15 +204,24 @@ impl<'t, T: Executor + ?Sized> GitEvidenceWriter<'t, T> { load_session_rows(self.transaction, &BTreeSet::from([session_id.to_owned()])).await?; let mut cleared = BTreeSet::new(); for span in spans.values().filter(|span| { - span.provider == provider && span.branch_provenance == super::BranchProvenance::Inferred + span.provider == provider + && (span.branch_provenance == super::BranchProvenance::Inferred + || span.capture_window.is_some()) }) { if span.source == super::SpanSource::Backfill { continue; } cleared.insert(span.span_id.clone()); let mut captured = span.clone(); - captured.branch = None; - captured.branch_provenance = super::BranchProvenance::Captured; + if captured.branch_provenance == super::BranchProvenance::Inferred { + captured.branch = None; + captured.branch_provenance = super::BranchProvenance::Captured; + } + if let Some(capture) = captured.capture_window.take() { + captured.first_ts = capture.first_ts; + captured.last_ts = capture.last_ts; + captured.event_count = capture.event_count; + } self.upsert_span(&captured).await?; } let removed = spans diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/rows_view_tests.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/rows_view_tests.rs index 315a8e07d8..e2f4845efe 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/rows_view_tests.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/rows_view_tests.rs @@ -45,6 +45,7 @@ fn seeded_projection(sessions: usize) -> GitEvidenceProjectionV1 { event_count: 2, source: SpanSource::Ingest, branch_provenance: BranchProvenance::Captured, + capture_window: None, }); if index % 4 == 0 { spans.push(SessionGitSpan { @@ -59,6 +60,7 @@ fn seeded_projection(sessions: usize) -> GitEvidenceProjectionV1 { event_count: 3, source: SpanSource::Backfill, branch_provenance: BranchProvenance::Inferred, + capture_window: None, }); } spans.push(SessionGitSpan { @@ -73,6 +75,7 @@ fn seeded_projection(sessions: usize) -> GitEvidenceProjectionV1 { event_count: 1, source: SpanSource::HookRoute, branch_provenance: BranchProvenance::Captured, + capture_window: None, }); let commit_sha = sha(index / 8, (index / 4) % 2); let produced = index % 4 == 0; @@ -355,6 +358,7 @@ async fn rows_view_reports_empty_row_families() { event_count: 1, source: SpanSource::Ingest, branch_provenance: BranchProvenance::Captured, + capture_window: None, }], Vec::new(), ) diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/tests.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/tests.rs index 23831002f7..19e481d39c 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/tests.rs @@ -21,6 +21,7 @@ fn span( event_count: 2, source: SpanSource::Ingest, branch_provenance: BranchProvenance::Captured, + capture_window: None, } } From a2f6f536496478f7676e3db69e8b2f7efcf5ac06 Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Sun, 4 Oct 2026 22:58:04 +0000 Subject: [PATCH 4/5] fix(git): keep grown capture windows when folded spans merge Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../runtime/git_correlation/attribution.rs | 8 ++ .../git_correlation/backfill/bounded/tests.rs | 125 ++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs index 18e8bd9562..de897987e7 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs @@ -300,6 +300,14 @@ pub(super) fn merge_span(spans: &mut Vec, incoming: &SessionGitS existing.first_ts = existing.first_ts.min(incoming.first_ts); existing.last_ts = existing.last_ts.max(incoming.last_ts); existing.event_count = existing.event_count.max(incoming.event_count); + existing.capture_window = match (existing.capture_window, incoming.capture_window) { + (Some(a), Some(b)) => Some(super::CaptureWindow { + first_ts: a.first_ts.min(b.first_ts), + last_ts: a.last_ts.max(b.last_ts), + event_count: a.event_count.max(b.event_count), + }), + (a, b) => a.or(b), + }; return *existing != previous; } else { spans.push(incoming.clone()); diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs index fbfdba645a..93b285717f 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/backfill/bounded/tests.rs @@ -1175,6 +1175,131 @@ async fn revisit_restores_the_capture_window_a_fold_unioned() { ); } +/// An observation landing inside the folded window extends the capture-only +/// window, not the unioned one. The merge that persists it must keep the +/// grown snapshot, or a later revisit restores bounds the capture evidence +/// already moved past. +#[tokio::test] +async fn folded_span_keeps_capture_growth_through_revisit() { + let repository = repository_fixture(); + let directory = tempfile::tempdir().unwrap(); + let store = prepare_store(&directory.path().join("sessions.db"), repository.path()).await; + let active_at = head_commit_time(repository.path()); + store + .connection + .execute("UPDATE sessions SET started_at = NULL, ended_at = NULL", ()) + .await + .unwrap(); + store + .connection + .execute( + "UPDATE lcm_raw_messages SET timestamp = ?1", + params![active_at], + ) + .await + .unwrap(); + let transaction = store.open_write_transaction().await.unwrap(); + let mut writer = GitEvidenceWriter::open(&transaction).await.unwrap(); + writer + .apply(GitEvidenceBatch { + observations: vec![crate::runtime::git_correlation::SpanObservation { + provider: "codex".to_owned(), + session_id: "session-1".to_owned(), + thread_id: None, + branch: None, + worktree: repository.path().to_string_lossy().into_owned(), + ts: active_at, + source: crate::runtime::git_correlation::SpanSource::Ingest, + }], + spans: vec![crate::runtime::git_correlation::stable_backfill_span( + "codex", + "session-1", + Some("main"), + &repository.path().to_string_lossy(), + active_at - 50, + active_at + 600, + )], + merge_gap_secs: DEFAULT_SPAN_MERGE_GAP_SECS, + ..GitEvidenceBatch::default() + }) + .await + .unwrap(); + writer.finish().await.unwrap(); + transaction.commit().await.unwrap(); + + // A later branchless observation lands inside the folded window but past + // the capture-only tail: the unioned window does not move, the snapshot + // grows to (active_at, active_at + 10). + let transaction = store.open_write_transaction().await.unwrap(); + let mut writer = GitEvidenceWriter::open(&transaction).await.unwrap(); + writer + .apply(GitEvidenceBatch { + observations: vec![crate::runtime::git_correlation::SpanObservation { + provider: "codex".to_owned(), + session_id: "session-1".to_owned(), + thread_id: None, + branch: None, + worktree: repository.path().to_string_lossy().into_owned(), + ts: active_at + 10, + source: crate::runtime::git_correlation::SpanSource::Ingest, + }], + merge_gap_secs: DEFAULT_SPAN_MERGE_GAP_SECS, + ..GitEvidenceBatch::default() + }) + .await + .unwrap(); + writer.finish().await.unwrap(); + transaction.commit().await.unwrap(); + + let view = open_git_evidence_view(&store.connection) + .await + .unwrap() + .unwrap(); + let (spans, _) = view + .session_evidence(&std::collections::BTreeSet::from(["session-1".to_owned()])) + .await + .unwrap(); + assert_eq!( + spans[0].capture_window, + Some(crate::runtime::git_correlation::CaptureWindow { + first_ts: active_at, + last_ts: active_at + 10, + event_count: 2, + }), + "the merge keeps the capture growth for a later revisit: {spans:#?}" + ); + drop(view); + + let plain = directory.path().join("plain directory"); + std::fs::create_dir(&plain).unwrap(); + store + .connection + .execute( + "UPDATE sessions SET metadata_json = ?1", + params![serde_json::json!({"codex_session_cwd": plain.to_str().unwrap()}).to_string()], + ) + .await + .unwrap(); + converge_git_evidence_pass(&store, &SystemGit, None) + .await + .unwrap(); + + let view = open_git_evidence_view(&store.connection) + .await + .unwrap() + .unwrap(); + let (spans, _) = view + .session_evidence(&std::collections::BTreeSet::from(["session-1".to_owned()])) + .await + .unwrap(); + assert_eq!(spans.len(), 1); + assert_eq!( + (spans[0].first_ts, spans[0].last_ts, spans[0].event_count), + (active_at, active_at + 10, 2), + "the restore keeps activity captured after the inference: {spans:#?}" + ); +} + /// A fresh project has never recorded Git evidence. Reporting that as a /// retryable unavailability put every fresh project's ingest into an endless /// retry loop. From 54bf6d9ad9711f210f82375480c7527c6fe41151 Mon Sep 17 00:00:00 2001 From: "zackary.l.jackson" Date: Mon, 5 Oct 2026 06:28:13 +0000 Subject: [PATCH 5/5] style: unslop prose comments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/runtime/git_correlation/attribution.rs | 4 ++-- .../tracedecay-sessions/src/runtime/git_correlation/rows.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs index de897987e7..433ecf17fc 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/attribution.rs @@ -320,8 +320,8 @@ pub(super) fn merge_span(spans: &mut Vec, incoming: &SessionGitS /// share one span. Captured branches are never replaced. The overlapping /// spans absorb the segment's window and event count, so nothing the /// inference covered is dropped. Returns `false` when no captured span -/// overlaps, or an overlapping span already names a different branch — -/// captured or inferred — leaving the inference to stand on its own. +/// overlaps, or an overlapping span already names a different branch, +/// captured or inferred, leaving the inference to stand on its own. pub(super) fn infer_captured_branch( spans: &mut [SessionGitSpan], inferred: &SessionGitSpan, diff --git a/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs b/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs index 1f8a40713f..2ae4f4b05f 100644 --- a/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs +++ b/crates/tracedecay-sessions/src/runtime/git_correlation/rows.rs @@ -244,7 +244,7 @@ impl<'t, T: Executor + ?Sized> GitEvidenceWriter<'t, T> { // on a removed inferred span goes, and so does any record the // inference itself produced (a branchless ReflogOverlap, or the // TimeOverlap the inferred branch admitted). Captured evidence linked - // to a surviving captured span stays — the span keeps its identity, + // to a surviving captured span stays. The span keeps its identity, // only its inferred branch is cleared. for record in commits.values().filter(|record| { record.provider == provider