From 6102c1cfddce8602e44ec40a960534e7f83d3a00 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 20 Sep 2026 03:37:06 +0000 Subject: [PATCH 1/5] test(code-index): prove text seats while graph activation retries Under a graph activation that keeps failing retryably, no pass ever installs the sealed generation in the serving slot. The run below made 72 real activation attempts across 30 seconds and seated nothing, so search holds its predecessor for the whole backoff while a complete generation sits on disk. The failure is asserted through the real worker loop and the real serving slot, not a helper: the fixture mounts a clean checkout, injects more retryable activation failures than the window can drain, and polls `serving_code_scope`. Draining the injection is therefore not a way to pass; only seating text while graph is still failing is. Ports #1562 (b80dd58dfe), whose own test asserted a helper that returns a constant and so could not fail. Co-Authored-By: Claude Fable 5.1 --- .../registry/convergence_park_tests.rs | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) 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) From 6945260f87fdd318590cd156f7aa316a7236d9a6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 20 Sep 2026 03:37:07 +0000 Subject: [PATCH 2/5] fix(code-index): seat text through retryable graph activation Exact and lexical serving does not depend on native graph, and the terminal-failure arm already says so: it marks graph unavailable and leaves the seat alone. The retryable arm did the opposite. It rewrote the pass result to `Ok((Err, None, None))`, which fails the serving swap's own `Ok((Ok(_), Some(latest), _))` guard, so the swap never ran. A retry is a weaker verdict than terminal, and it was seating less. Under an activation that keeps failing retryably that is starvation, not a delay: every pass rebuilt the same candidate and threw it away. The repro committed before this one made 72 activation attempts across 30 seconds and seated nothing; it now seats on the first pass, in under a second. The fix is the deletion. Ports #1562 (b80dd58dfe), which reached the same behavior by wrapping the assignment in a predicate that ignores both arguments and returns `true`, leaving the line dead rather than absent and warning on every build. The seat swap, the retry backoff, the conflict verdict, and the terminal arm are all untouched. Co-Authored-By: Claude Fable 5.1 --- .../src/code_index_scheduler/registry/mount.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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; From ef9149d35feb3acba15219a668ad90d1bd7d23a5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 20 Sep 2026 03:37:07 +0000 Subject: [PATCH 3/5] test(mcp): prove tracedecay_affected answers an unpublished path A changed-file list is a git diff, so it routinely names paths the current generation never published: a new file, a deleted one, a rename's old path, a manifest. `tracedecay_affected` frontiers every requested file without checking it against the published catalog, so such a path reaches adjacency with an empty seed list. Adjacency refuses an empty seed list by contract, and that refusal maps to a non-retryable `code-graph-invalid-request`, so one unindexed entry costs the caller every other file's answer. The assertion runs against the real daemon fixture already warm in this test and fails on the tool result itself, not on a helper. Ports #1762's contract claim, not its diff: master's `affected_traversal_batches_one_database_read_per_frontier` stays, because it is the only per-frontier read-count assertion in the workspace. Co-Authored-By: Claude Fable 5.1 --- .../mcp_handler_test/graph_query_test.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) 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")] From f0994a9cf997fefdb46f6b271b879d2180a9cfdc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 20 Sep 2026 03:37:08 +0000 Subject: [PATCH 4/5] fix(graph-query): answer file neighbors for an unpublished path `symbols_in_logical_file` documents an empty vector as the truthful answer for a path the generation never published, and `entity_ids` refuses an empty seed list as a contract violation. `file_neighbors` sat between the two and translated neither, so the refusal escaped as a non-retryable `code-graph-invalid-request`. The guard goes in the shared function rather than in `tracedecay_affected`, because three callers reach it with unvalidated paths: the MCP affected handler, `primitives/support.rs`, and the `file_dependents` primitive, and the last reports `OmissionReason::Unavailable` for what is really an empty answer. `incoming_edges` and `edges_among` in this same impl already carry this guard; `file_neighbors` was the one adjacency call site without it. Ports #1762's `queries.rs` hunk only. Its deletion of master's `affected_traversal_batches_one_database_read_per_frontier` is not taken: its hop-distance test asserts distances, not the per-frontier read count, so it would not catch an N+1 regression, a lost frontier sort, or a test file re-expanded as an interior node. Co-Authored-By: Claude Fable 5.1 --- crates/tracedecay-graph-query/src/queries.rs | 9 +++++++++ 1 file changed, 9 insertions(+) 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( From 6c9af777340c55e15c414768862712d17793a61e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 20 Sep 2026 06:04:01 +0000 Subject: [PATCH 5/5] test(daemon): assert typed graph state on the seated generation The cold-mount replay test asserted that a permanently refused graph replay leaves the complete serving slot empty. That held only because a retryable activation failure starved the seat, the defect this branch fixes: the read-only publication error is a GraphActivation error with no conflict verdict, so it takes the retry arm on every attempt, and the sealed generation now seats while native graph keeps retrying. The old assertion raced the seat and failed once it lost. The contract lives on the seated generation instead: it is the retained generation, its graph readiness is not Ready, and it exposes no interactive graph store. Co-Authored-By: Claude Fable 5.1 --- ...de_index_runtime_graph_activation_tests.rs | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) 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