Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ use tracedecay_code_index_retention::code_index_generations::{
code_text_artifacts_root, scoped_code_index_store_root,
};

use super::super::graph_activation::install_injected_activation_gate;
use super::super::graph_activation::{
injected_activation_attempt_count, install_injected_activation_gate,
set_injected_activation_failures,
};
use super::CodeIndexSchedulerRegistryV1;

/// Ceiling on how long a test waits for the worker to reach the asserted
Expand All @@ -37,6 +40,11 @@ const CONVERGENCE_DEADLINE: Duration = Duration::from_secs(30);
/// Poll spacing while waiting on the freshness projection.
const POLL_SPACING: Duration = Duration::from_millis(50);

/// More injected activation failures than any bounded test window can drain,
/// so a seat observed under this injection is never a pass that simply
/// outlasted the injection and activated for real.
const UNDRAINABLE_ACTIVATION_FAILURES: usize = 10_000;

struct Fixture {
_root: TempDir,
project: std::path::PathBuf,
Expand Down Expand Up @@ -147,6 +155,26 @@ impl Fixture {
}
last
}

/// Poll the real serving slot until something is seated, waking the
/// worker between observations exactly as the periodic cadence does.
async fn wait_for_seated_generation(
&self,
) -> Option<std::sync::Arc<super::super::CodeIndexPublishedGenerationV1>> {
let deadline = tokio::time::Instant::now() + CONVERGENCE_DEADLINE;
loop {
let seated = self
.registry
.serving_code_scope(&self.project)
.await
.and_then(|scope| scope.serving_generation);
if seated.is_some() || tokio::time::Instant::now() >= deadline {
return seated;
}
self.wake_without_new_input().await;
tokio::time::sleep(POLL_SPACING).await;
}
}
}

/// An owned legacy artifacts root with a permissive mode is exactly the state
Expand Down Expand Up @@ -403,6 +431,42 @@ async fn fresh_graph_activation_starts_while_the_clone_successor_is_pending() {
fixture.registry.shutdown().await;
}

/// Exact and lexical serving does not depend on native graph. A retryable
/// activation failure used to replace the whole prepared triple with
/// `Ok((Err, None, None))`, which failed the serving swap's own guard, so the
/// sealed generation never reached the slot and search kept the predecessor
/// for the entire activation backoff. Under an activation that keeps failing
/// retryably, that is starvation: no pass ever seats.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn text_seats_while_graph_activation_keeps_failing_retryably() {
let (fixture, admission) =
Fixture::mount_with_poisoned_artifacts_root_held("project.seat-through-retry", |_| {})
.await;
let scope = fixture
.registry
.serving_code_scope(&fixture.project)
.await
.expect("mounted scope");
// Injected deadline failures are the retryable class, and they carry no
// conflict verdict, so every attempt takes the retry arm rather than
// falling through to the terminal one that already keeps the seat.
set_injected_activation_failures(&scope.worktree_id, UNDRAINABLE_ACTIVATION_FAILURES);
drop(admission);

let seated = fixture.wait_for_seated_generation().await;
let attempts = injected_activation_attempt_count(&scope.worktree_id);
assert!(
attempts > 0,
"the fixture must observe a real graph activation attempt, otherwise the seat proves nothing"
);
assert!(
seated.is_some(),
"the sealed generation must take the serving seat while graph activation retries \
(activation attempts: {attempts})"
);
fixture.registry.shutdown().await;
}

fn run_git_in(root: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1693,7 +1693,7 @@ impl CodeIndexSchedulerRegistryV1 {
retry_delay_micros = retry_delay.as_micros() as u64,
error = %error,
"graph activation failed retryably; the sealed generation \
stays unseated until the scheduled retry"
still seats and the next pass retries native graph"
);
hotpath::gauge!("daemon.code_index.graph_seat.retry_total")
.inc(1_u64);
Expand All @@ -1707,7 +1707,15 @@ impl CodeIndexSchedulerRegistryV1 {
// The scheduled retry is the seat attempt, so it
// must not be turned away as already attempted.
graph_seat_attempted = None;
result = Ok((Err(error), None, None));
// The prepared candidate stays. Rewriting the
// pass result to `Ok((Err, None, None))` here
// failed the serving swap's own guard, so an
// activation that kept failing retryably never
// let any pass seat: search held its predecessor
Comment on lines +1710 to +1714

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the ready graph seat during activation retries

When a worktree already serves a ready graph and a changed generation's activation fails retryably, retaining this prepared candidate lets the later serving swap install the new generation because replace_serving_generation is true, displacing the ready predecessor with a graph whose activation state is still Pending. ProjectCodeGraphServingAuthorityV1::project then accepts that current seat through latest_complete_ready_decoded_for_root_scope, but complete_projection fails at interactive_graph_store, so graph requests become unavailable for the entire retry backoff instead of serving the predecessor as stale. Keep the ready graph authority while publishing the successor through the separate text owner, or make graph routing retain the prior ready authority until activation succeeds.

AGENTS.md reference: AGENTS.md:L186-L188

Useful? React with 馃憤聽/ 馃憥.

// while a complete generation sat on disk. The
// terminal arm below already keeps the seat and
// marks graph unavailable; a retry is a weaker
// verdict than terminal and must not seat less.
} else {
next_seat_attempt_at = None;
seat_retry_backoff = ACTIVATION_RETRY_BACKOFF_FLOOR;
Expand Down
9 changes: 9 additions & 0 deletions crates/tracedecay-graph-query/src/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,15 @@ impl<'a> GraphQueryManager<'a> {
.iter()
.map(|symbol| symbol.occurrence.clone())
.collect::<Vec<_>>();
// `symbols_in_logical_file` answers a path this generation never
// published with an empty vector by contract, and adjacency refuses an
// empty seed list by contract. Without this the seam between the two
// turned "no such file here" into a non-retryable invalid request.
// `incoming_edges` and `edges_among` below already carry this guard;
// this was the one adjacency call site missing it.
if seeds.is_empty() {
return Ok(Vec::new());
}
let edges = hotpath::measure_block!("usecases.graph.file_neighbors.edges", {
if incoming {
self.reader.callers(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,11 @@ fn callers_meta(
}

/// A retained text generation reaches exact/lexical readiness when persistent
/// graph replay is permanently refused. The full graph owner stays absent, so
/// text availability never implies graph availability.
/// graph replay is permanently refused. The retained generation still takes
/// the serving seat, because exact and lexical serving never depended on
/// native graph, but its graph readiness stays typed as not ready and no
/// interactive graph store is exposed, so text availability never implies
/// graph availability.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failed_cold_mount_graph_replay_preserves_retained_text_generation() {
let fixture = GitFixture::new(ALPHA_LIB_V1);
Expand Down Expand Up @@ -269,15 +272,36 @@ async fn failed_cold_mount_graph_replay_preserves_retained_text_generation() {

assert_eq!(
registry.latest_generation_id(fixture.path()).await,
Some(seeded_generation_id),
Some(seeded_generation_id.clone()),
"persistent graph replay failure must not withhold retained text serving"
);
// A retryable activation failure no longer withholds the seat: the sealed
// generation is installed while native graph keeps retrying, so the
// contract lives on the seated generation's typed graph state.
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let seated = loop {
if let Some(seated) = registry.latest_complete_serving_for_scope(&scope).await {
break seated;
}
assert!(
std::time::Instant::now() <= deadline,
"persistent graph replay failure must still seat the retained generation"
);
tokio::time::sleep(Duration::from_millis(10)).await;
};
assert_eq!(
seated.generation().manifest().generation_id,
seeded_generation_id,
"the seat must be the retained generation, not a successor"
);
assert_ne!(
seated.code_graph_serving_readiness(),
tracedecay_contracts::code_index_freshness::CodeGraphServingReadinessV1::Ready,
"persistent graph replay failure must not report a ready graph"
);
assert!(
registry
.latest_complete_serving_for_scope(&scope)
.await
.is_none(),
"persistent graph replay failure must not expose a full graph owner"
seated.interactive_graph_store().is_err(),
"persistent graph replay failure must not expose an interactive graph store"
);
registry.shutdown().await;
graph_runtime
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1073,6 +1073,37 @@ async fn affected_central_daemon_fixture_preserves_set_and_ranks_near_tests_over
payload["ranking_metadata"]["strategy"],
"dependency_distance_then_path"
);

// A changed-file list is a git diff, so it routinely names paths this
// generation never published: a new file, a deleted one, a rename's old
// path, a manifest. Such a path has no symbols, so the traversal reaches
// adjacency with an empty seed list, and adjacency refuses that by
// contract. The refusal used to surface as a non-retryable
// `code-graph-invalid-request` that failed the whole call, so one
// unindexed entry cost the caller every other file's answer.
let unpublished = harness
.call_tool(
&project,
"tracedecay_affected",
json!({"files": ["src/never_published.rs"], "depth": 5, "format": "json"}),
)
.await
.expect("production invocation succeeds")
.result
.expect("an unpublished path is answerable, not an invalid request");
let unpublished: Value = serde_json::from_str(
unpublished["content"][0]["text"]
.as_str()
.expect("affected JSON text"),
)
.expect("affected JSON payload");
assert_eq!(
unpublished["affected_tests"],
json!([]),
"a path this generation never published has no dependents, not an error"
);
assert_eq!(unpublished["ranked_tests"], json!([]));
assert_eq!(unpublished["recommended_tests"], json!([]));
}

#[cfg(feature = "test-transport")]
Expand Down
Loading