diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/convergence_park_tests.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/convergence_park_tests.rs index 772b0b1073..caa6884abb 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/convergence_park_tests.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/convergence_park_tests.rs @@ -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 @@ -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, @@ -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> { + 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 @@ -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) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs index ce5dc01754..41dd125fb4 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/mount.rs @@ -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); @@ -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 + // 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; diff --git a/crates/tracedecay-graph-query/src/queries.rs b/crates/tracedecay-graph-query/src/queries.rs index bfd9ebbc44..4addc3d0f3 100644 --- a/crates/tracedecay-graph-query/src/queries.rs +++ b/crates/tracedecay-graph-query/src/queries.rs @@ -361,6 +361,15 @@ impl<'a> GraphQueryManager<'a> { .iter() .map(|symbol| symbol.occurrence.clone()) .collect::>(); + // `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( diff --git a/crates/tracedecay/src/daemon/code_index_runtime_graph_activation_tests.rs b/crates/tracedecay/src/daemon/code_index_runtime_graph_activation_tests.rs index f20d8e610c..00d430ebac 100644 --- a/crates/tracedecay/src/daemon/code_index_runtime_graph_activation_tests.rs +++ b/crates/tracedecay/src/daemon/code_index_runtime_graph_activation_tests.rs @@ -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); @@ -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 diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs index 2ea3be05ea..ed84d29ce1 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs @@ -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")]