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
48 changes: 35 additions & 13 deletions crates/tracedecay-contracts/src/retrieval/git_context_surface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,15 @@ pub struct GitContextSymbolV1 {
pub line: u32,
}

/// Why co-change mining produced no answer, so an empty
/// `co_change_partners` does not read as "no partner is missing".
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CoChangeUnavailableV1 {
pub reason: String,
pub retryable: bool,
}

#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct DiffContextResultV1 {
Expand All @@ -244,12 +253,14 @@ pub struct DiffContextResultV1 {
/// still unexplored.
pub impact_complete: bool,
pub affected_tests: Vec<String>,
/// Present when Git history could not be mined for co-change partners.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub co_change_unavailable: Option<CoChangeUnavailableV1>,
/// The worktree verdict a served graph read opens with.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub freshness: Option<PrimitiveSearchFreshnessV1>,
/// Partners that share at least three commits with a changed file in the
/// last 18 months and are not part of this diff. At most eight. Omitted
/// when history has none, so a diff with no partners keeps its old shape.
/// Likely missing companions from bounded Git history. At most eight.
/// Omitted when no partner clears the coupling threshold.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub co_change_partners: Vec<CoChangePartnerV1>,
/// Budget accounting when the request supplies `budget_tokens`.
Expand Down Expand Up @@ -331,16 +342,6 @@ impl TestGateV1 {
}
}

impl DiffContextResultV1 {
pub fn co_change_partner(file: String, partner: String, together: usize) -> CoChangePartnerV1 {
CoChangePartnerV1 {
file,
partner,
together,
}
}
}

/// A symbol the exact base/head branch-generation comparison reports.
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
Expand Down Expand Up @@ -598,6 +599,13 @@ pub struct PrContextCompleteV1 {
pub commits: Vec<GitCommitSubjectV1>,
pub files_changed: usize,
pub changes: Vec<GitFileChangeV1>,
/// Files that usually change with a changed file, per bounded Git
/// history, but are absent from this change set.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub co_change_partners: Vec<CoChangePartnerV1>,
/// Present when Git history could not be mined for co-change partners.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub co_change_unavailable: Option<CoChangeUnavailableV1>,
pub symbols_added: usize,
pub symbols_removed: usize,
pub symbols_modified: usize,
Expand Down Expand Up @@ -637,6 +645,13 @@ pub struct PrContextSymbolsUnavailableV1 {
pub commits: Vec<GitCommitSubjectV1>,
pub files_changed: usize,
pub changes: Vec<GitFileChangeV1>,
/// Files that usually change with a changed file, per bounded Git
/// history, but are absent from this change set.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub co_change_partners: Vec<CoChangePartnerV1>,
/// Present when Git history could not be mined for co-change partners.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub co_change_unavailable: Option<CoChangeUnavailableV1>,
pub symbols_added: usize,
pub symbols_removed: usize,
pub symbols_modified: usize,
Expand Down Expand Up @@ -669,6 +684,13 @@ pub struct PrContextGraphPendingV1 {
pub commits: Vec<GitCommitSubjectV1>,
pub files_changed: usize,
pub changes: Vec<GitFileChangeV1>,
/// Files that usually change with a changed file, per bounded Git
/// history, but are absent from this change set.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub co_change_partners: Vec<CoChangePartnerV1>,
/// Present when Git history could not be mined for co-change partners.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub co_change_unavailable: Option<CoChangeUnavailableV1>,
pub symbols_added: usize,
pub symbols_modified: usize,
pub added: Vec<PrSymbolEntryV1>,
Expand Down
13 changes: 7 additions & 6 deletions crates/tracedecay-contracts/src/retrieval/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,13 @@ pub use git_context_surface::{
BranchSearchHitV1, BranchSearchPageV1, BranchSearchResultV1, BranchSearchSurfaceRequestV1,
BranchSearchUnavailableV1, BranchSnapshotEntryV1, BranchSymbolChangeV1, BranchSymbolV1,
ChangelogCompleteV1, ChangelogPartialV1, ChangelogResultV1, ChangelogSurfaceRequestV1,
CommitCategoryV1, CommitContextResultV1, CommitContextSummaryV1, CommitContextSurfaceRequestV1,
CommitFileRoleV1, CommitSymbolEntryV1, CommitSymbolV1, ConfigSummaryKindV1, ConfigSummaryV1,
DiffContextResultV1, DiffContextSurfaceRequestV1, GitCommitSubjectV1, GitComparedSymbolV1,
GitContextSymbolV1, GitFileChangeStatusV1, GitFileChangeV1, GitFileRoleV1, GitPageStatusV1,
GitReadCompleteV1, GitReadPartialV1, GitReadUnavailableV1, GitReferenceLimitV1,
GitResultLimitV1, GitToolErrorKindV1, GitToolErrorV1, GitToolFailureV1, GitToolOperationV1,
CoChangePartnerV1, CoChangeUnavailableV1, CommitCategoryV1, CommitContextResultV1,
CommitContextSummaryV1, CommitContextSurfaceRequestV1, CommitFileRoleV1, CommitSymbolEntryV1,
CommitSymbolV1, ConfigSummaryKindV1, ConfigSummaryV1, DiffContextResultV1,
DiffContextSurfaceRequestV1, GitCommitSubjectV1, GitComparedSymbolV1, GitContextSymbolV1,
GitFileChangeStatusV1, GitFileChangeV1, GitFileRoleV1, GitPageStatusV1, GitReadCompleteV1,
GitReadPartialV1, GitReadUnavailableV1, GitReferenceLimitV1, GitResultLimitV1,
GitToolErrorKindV1, GitToolErrorV1, GitToolFailureV1, GitToolOperationV1,
IncompatibleCallSiteV1, PrAnalysisCoverageV1, PrContextCompleteV1, PrContextGraphPendingV1,
PrContextResultV1, PrContextSurfaceRequestV1, PrContextSymbolsUnavailableV1,
PrCoverageSelectionV1, PrSelectionCoverageV1, PrSymbolChangesCompleteV1, PrSymbolEntryV1,
Expand Down
175 changes: 148 additions & 27 deletions crates/tracedecay-mcp/src/handlers/git/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,21 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tracedecay_code_index::graph_projection::CodeGraphSymbolSummaryV1;
use tracedecay_contracts::retrieval::{
ChangelogCompleteV1, ChangelogPartialV1, ChangelogSurfaceRequestV1, CommitCategoryV1,
CommitContextSummaryV1, CommitContextSurfaceRequestV1, CommitFileRoleV1, CommitSymbolEntryV1,
CommitSymbolV1, ConfigSummaryKindV1, ConfigSummaryV1, DiffContextResultV1,
DiffContextSurfaceRequestV1, GitComparedSymbolV1, GitContextSymbolV1, GitReadCompleteV1,
GitReadPartialV1, GitReadUnavailableV1, PrAnalysisCoverageV1, PrContextCompleteV1,
PrContextGraphPendingV1, PrContextSurfaceRequestV1, PrContextSymbolsUnavailableV1,
PrCoverageSelectionV1, PrSelectionCoverageV1, PrSymbolChangesCompleteV1, PrSymbolEntryV1,
PrSymbolPageV1, PrSymbolSelectionV1, SymbolChangesCompleteV1, SymbolChangesUnavailableV1,
ChangelogCompleteV1, ChangelogPartialV1, ChangelogSurfaceRequestV1, CoChangePartnerV1,
CoChangeUnavailableV1, CommitCategoryV1, CommitContextSummaryV1, CommitContextSurfaceRequestV1,
CommitFileRoleV1, CommitSymbolEntryV1, CommitSymbolV1, ConfigSummaryKindV1, ConfigSummaryV1,
DiffContextResultV1, DiffContextSurfaceRequestV1, GitComparedSymbolV1, GitContextSymbolV1,
GitReadCompleteV1, GitReadPartialV1, GitReadUnavailableV1, PrAnalysisCoverageV1,
PrContextCompleteV1, PrContextGraphPendingV1, PrContextSurfaceRequestV1,
PrContextSymbolsUnavailableV1, PrCoverageSelectionV1, PrSelectionCoverageV1,
PrSymbolChangesCompleteV1, PrSymbolEntryV1, PrSymbolPageV1, PrSymbolSelectionV1,
SymbolChangesCompleteV1, SymbolChangesUnavailableV1,
};
use tracedecay_contracts::{InvocationAnalyticsV1, PrContextAnalyticsV1, PrContextStageTimingsV1};
use tracedecay_domain::{CanonicalRelationEdgeV1, RelationEdgeKindV1, SymbolOccurrenceId};
use tracedecay_graph_query::VerifiedGraphQuery;
use tracedecay_runtime_core::git::GitCommandBounds;
use tracedecay_runtime_core::git::cochange::co_change_partners;
use tracedecay_runtime_core::git::cochange::{CoChangeError, co_change_partners};
use tracedecay_runtime_core::git::{GitCommandBounds, GitCommandError};

const VERIFIED_GRAPH_MAX_SYMBOLS: usize = 500_000;
const VERIFIED_GRAPH_MAX_RELATIONS: usize = 2_000_000;
Expand Down Expand Up @@ -307,6 +308,85 @@ fn context_symbol(symbol: &CodeGraphSymbolSummaryV1) -> Result<GitContextSymbolV
})
}

struct CoChangeEvidence {
missing: Vec<CoChangePartnerV1>,
unavailable: Option<CoChangeUnavailableV1>,
}

struct CancelGitReadOnDrop(tracedecay_runtime_core::cancellation::CancellationToken);

impl Drop for CancelGitReadOnDrop {
fn drop(&mut self) {
self.0.cancel();
}
}

fn co_change_retryable(error: &CoChangeError) -> bool {
matches!(
error,
CoChangeError::Command(
GitCommandError::Cancelled
| GitCommandError::DeadlineExceeded
| GitCommandError::ReadOutput { .. }
| GitCommandError::Wait(_)
)
)
}

async fn co_change_evidence(
ctx: &McpToolContext<'_>,
history: String,
tree: String,
changed_files: Vec<String>,
) -> CoChangeEvidence {
let unavailable = |reason: String, retryable: bool| CoChangeEvidence {
missing: Vec::new(),
unavailable: Some(CoChangeUnavailableV1 { reason, retryable }),
};
let cancel = tracedecay_runtime_core::cancellation::CancellationToken::new();
let _cancel_on_drop = CancelGitReadOnDrop(cancel.clone());
let mut bounds = GitCommandBounds {
cancel: Some(cancel.clone()),
..Default::default()
};
if let Some(deadline) = ctx.deadline() {
let Some(remaining) = tracedecay_daemon_protocol::deadline_remaining(deadline) else {
return unavailable("Git co-change request deadline exceeded".to_owned(), true);
};
bounds.deadline = bounds.deadline.min(std::time::Instant::now() + remaining);
}
let root = ctx.project_root().to_path_buf();
let mut worker = tokio::task::spawn_blocking(move || {
co_change_partners(&root, &history, &tree, &changed_files, &bounds)
});
let joined = match ctx.cancellation() {
Some(signal) => tokio::select! {
biased;
joined = &mut worker => joined,
() = signal.cancelled() => {
cancel.cancel();
worker.await
}
},
None => worker.await,
};
match joined {
Ok(Ok(partners)) => CoChangeEvidence {
missing: partners
.into_iter()
.map(|partner| CoChangePartnerV1 {
file: partner.file,
partner: partner.partner,
together: partner.together,
})
.collect(),
unavailable: None,
},
Ok(Err(error)) => unavailable(error.to_string(), co_change_retryable(&error)),
Err(join_error) => unavailable(format!("co-change task failed: {join_error}"), true),
}
}

struct BlastRow {
name: String,
id: SymbolOccurrenceId,
Expand Down Expand Up @@ -658,27 +738,18 @@ where
let mut tests_sorted: Vec<String> = affected_tests.into_iter().collect();
tests_sorted.sort();

let co_change = tracing::Instrument::instrument(
co_change_evidence(ctx, "HEAD".to_owned(), "HEAD".to_owned(), files.clone()),
tracing::trace_span!("mcp.git.diff_context.co_change"),
)
.await;

let touched_files = unique_file_paths(
all_touched_files
.iter()
.map(String::as_str)
.chain(files.iter().map(String::as_str)),
);
let partners = if files.is_empty() {
Vec::new()
} else {
let project_root = ctx.project_root().to_path_buf();
let changed_files = files.clone();
blocking_git_span("co-change", move || {
co_change_partners(&project_root, &changed_files, &GitCommandBounds::default())
})
.await??
.into_iter()
.map(|partner| {
DiffContextResultV1::co_change_partner(partner.file, partner.partner, partner.together)
})
.collect()
};

let mut caller_edges: Vec<CanonicalRelationEdgeV1> = Vec::new();
let test_gate = if blast.is_empty() {
Expand Down Expand Up @@ -752,8 +823,9 @@ where
impacted_symbols,
impact_complete: impacted.complete,
affected_tests: tests_sorted,
co_change_partners: co_change.missing,
co_change_unavailable: co_change.unavailable,
freshness: None,
co_change_partners: partners,
token_budget: None,
test_gate,
signature_edits,
Expand Down Expand Up @@ -1233,6 +1305,7 @@ struct PrContextGitEvidence {
merge_base: String,
commits: Vec<GitCommitSubjectV1>,
changes: Vec<GitFileChangeV1>,
co_change: CoChangeEvidence,
}

impl PrContextGitEvidence {
Expand All @@ -1254,6 +1327,8 @@ impl PrContextGitEvidence {
commits: self.commits,
files_changed: self.changes.len(),
changes: self.changes,
co_change_partners: self.co_change.missing,
co_change_unavailable: self.co_change.unavailable,
symbols_added: 0,
symbols_removed: 0,
symbols_modified: 0,
Expand Down Expand Up @@ -1338,6 +1413,19 @@ where
});
let changed_files: Vec<String> = changes.iter().map(|change| change.path.clone()).collect();
let changed_paths = changed_files.iter().cloned().collect::<HashSet<_>>();
// History up to the merge base is the evidence; the compared change set
// is what it is checked against, and partners must survive at head.
let co_change = tracing::Instrument::instrument(
co_change_evidence(
ctx,
merge_base.clone(),
head_oid.clone(),
changed_files.clone(),
),
tracing::trace_span!("mcp.pr_context.co_change"),
)
.await;
controls.checkpoint()?;

let maximum_symbols = request
.maximum_symbols
Expand All @@ -1353,6 +1441,7 @@ where
merge_base,
commits,
changes,
co_change,
};

let stage_started = std::time::Instant::now();
Expand Down Expand Up @@ -1401,6 +1490,8 @@ where
commits: evidence.commits,
files_changed: evidence.changes.len(),
changes: evidence.changes,
co_change_partners: evidence.co_change.missing,
co_change_unavailable: evidence.co_change.unavailable,
symbols_added: 0,
symbols_modified: 0,
added: Vec::new(),
Expand Down Expand Up @@ -1625,7 +1716,7 @@ where
candidates.truncate(maximum_symbols);
let shares = request
.budget_tokens
.map(|budget| crate::handlers::token_budget::quotas(budget, &[25, 15, 25, 15, 20]));
.map(|budget| crate::handlers::token_budget::quotas(budget, &[22, 13, 22, 13, 18, 12]));
let mut symbol_tokens = [0_u32; 3];
let mut symbol_totals = [0_u32; 3];
let mut symbol_shown = [0_u32; 3];
Expand Down Expand Up @@ -1866,6 +1957,8 @@ where
commits: evidence.commits,
files_changed: evidence.changes.len(),
changes: evidence.changes,
co_change_partners: evidence.co_change.missing,
Comment thread
ScriptedAlchemy marked this conversation as resolved.
co_change_unavailable: evidence.co_change.unavailable,
symbols_added: added.len(),
symbols_removed: removed.len(),
symbols_modified: modified.len(),
Expand Down Expand Up @@ -1922,6 +2015,11 @@ where
&mut result.impacted_modules,
shares[4],
)?);
sections.push(crate::handlers::token_budget::trim_section(
"co_change_partners",
&mut result.co_change_partners,
shares[5],
)?);
if sections[3].shown < sections[3].total {
result.affected_tests_coverage.complete = false;
}
Expand Down Expand Up @@ -1967,6 +2065,29 @@ fn pr_context_completion(
completion
}

#[cfg(test)]
mod co_change_error_tests {
use super::co_change_retryable;
use tracedecay_runtime_core::git::GitCommandError;
use tracedecay_runtime_core::git::cochange::CoChangeError;

#[test]
fn only_transient_command_failures_are_retryable() {
assert!(co_change_retryable(&CoChangeError::Command(
GitCommandError::DeadlineExceeded
)));
assert!(!co_change_retryable(&CoChangeError::Command(
GitCommandError::OutputLimitExceeded {
stream: "stdout",
bound: 1,
}
)));
assert!(!co_change_retryable(&CoChangeError::NonZeroExit(
"bad revision".to_owned()
)));
}
}

#[cfg(test)]
mod blocking_git_span_tests {
use super::{
Expand Down
Loading
Loading