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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/tracedecay-dashboard-api/src/loom_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1616,6 +1616,9 @@ 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,
capture_window: None,
}
}

Expand Down
47 changes: 45 additions & 2 deletions crates/tracedecay-sessions/src/runtime/git_correlation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 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
Expand All @@ -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 {
Expand Down Expand Up @@ -123,6 +132,35 @@ pub struct SessionGitSpan {
pub last_ts: i64,
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<CaptureWindow>,
}

/// 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 {
/// 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)]
Expand Down Expand Up @@ -1177,7 +1215,12 @@ fn span_hit(spans: &[&SessionGitSpan]) -> SessionGitCorrelationHit {
.collect::<BTreeSet<_>>();
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::<BTreeSet<_>>();
SessionGitCorrelationHit {
provider: providers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ pub fn stable_backfill_span(
last_ts,
event_count: 2,
source: super::SpanSource::Backfill,
branch_provenance: super::BranchProvenance::Inferred,
capture_window: None,
}
}

Expand All @@ -200,7 +202,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()
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
&& span.worktree == worktree
&& span.source == observation.source
&& observation_extends_span(
Expand All @@ -209,6 +211,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) => {
Expand All @@ -222,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);
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}
span
}
None => SessionGitSpan {
Expand All @@ -235,6 +252,8 @@ pub(super) fn transcript_spans_from_observations(
last_ts: observation.ts,
event_count: 1,
source: observation.source,
branch_provenance: super::BranchProvenance::Captured,
capture_window: None,
},
};
if let Some(candidate) = candidates
Expand Down Expand Up @@ -268,6 +287,7 @@ pub(super) fn merge_span(spans: &mut Vec<SessionGitSpan>, 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
Expand All @@ -280,13 +300,69 @@ pub(super) fn merge_span(spans: &mut Vec<SessionGitSpan>, 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());
}
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. 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,
) -> 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::<Vec<_>>();
if overlapping.is_empty()
|| overlapping
.iter()
.any(|span| span.branch.is_some() && span.branch != inferred.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;
}
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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

pub(super) fn merge_commit(
commits: &mut Vec<CommitSessionRecord>,
incoming: &CommitSessionRecord,
Expand Down
Loading
Loading