From b80dd58dfe560c2091346dd6f2140a174c531614 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 23:11:59 +0000 Subject: [PATCH 1/3] fix(code-index): seat text through retryable graph activation A retryable graph activation used to erase the prepared serving candidate, and an unfinished clone-fingerprint successor withheld the same seat after exact and lexical owners were ready. Keep the candidate in both cases so search can move off the predecessor while graph retries. Co-authored-by: Zack Jackson --- .../src/code_index_scheduler/registry.rs | 38 ++++++++++++++++ .../code_index_scheduler/registry/mount.rs | 26 ++++++++++- .../registry/seat_swap_tests.rs | 45 +++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/seat_swap_tests.rs diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs index fc9c8db791..1b5fdc207a 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs @@ -56,6 +56,8 @@ mod query_authority; mod reconcile_failure_isolation_tests; mod scope_identity; #[cfg(test)] +mod seat_swap_tests; +#[cfg(test)] mod serving_readiness_tests; mod serving_reads; @@ -352,6 +354,42 @@ impl ServingSwapOutcomeV1 { } } +/// The prepared generation id after a graph-activation failure. +/// +/// Retryable activation used to replace the prepared triple with +/// `Ok((Err, None, None))`, so the swap never ran and search kept the +/// predecessor for the whole backoff. Both retryable and terminal failures +/// now leave the sealed text generation in place; only graph readiness +/// retries or becomes unavailable. +pub(super) fn serving_generation_after_activation_failure<'a>( + prepared_generation: Option<&'a str>, + retryable: bool, + repeated_conflict: bool, +) -> Option<&'a str> { + if activation_failure_keeps_serving_candidate(retryable, repeated_conflict) { + prepared_generation + } else { + None + } +} + +fn activation_failure_keeps_serving_candidate(retryable: bool, repeated_conflict: bool) -> bool { + // `retryable && !repeated_conflict` used to wipe the candidate. Terminal + // failures already kept it. Both now keep it; the flags stay so a later + // change cannot drop only the retryable arm without this predicate. + let _ = (retryable, repeated_conflict); + true +} + +/// An unfinished text projection withholds the serving seat only when exact +/// or lexical owners are still missing. +/// +/// A clone-fingerprint successor keeps `text_projection_needs_work` after +/// those owners are ready. That is not `published_text_owner_unfinished`. +pub(super) fn text_projection_unfinished_withholds_seat(exact_and_lexical_ready: bool) -> bool { + !exact_and_lexical_ready +} + #[cfg(any(test, feature = "test-helpers"))] struct ColdMountFinalCommitGateV1 { project_root: PathBuf, 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 3429b111e1..4bdf2be067 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 @@ -1638,6 +1638,11 @@ impl CodeIndexSchedulerRegistryV1 { // A conflict verdict identical to the previous // attempt's for this same generation is deterministic // and falls through to the terminal arm instead. + // + // The prepared text candidate stays. Wiping it to + // `Ok((Err, None, None))` skipped the serving swap, + // so search kept the predecessor while graph backoff + // ran. if error.is_retryable_activation() && !repeated_conflict { last_seat_conflict = error .activation_conflict_context() @@ -1654,7 +1659,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); @@ -1668,7 +1673,12 @@ 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)); + if !super::activation_failure_keeps_serving_candidate( + error.is_retryable_activation(), + repeated_conflict, + ) { + result = Ok((Err(error), None, None)); + } } else { next_seat_attempt_at = None; seat_retry_backoff = ACTIVATION_RETRY_BACKOFF_FLOOR; @@ -1699,6 +1709,18 @@ impl CodeIndexSchedulerRegistryV1 { // and serving-swap boundary. Graph work above ran only when // the outcome was ready. if let Some(outcome) = published_text_projection_outcome.take() { + // A clone-fingerprint successor is still `Unfinished` work + // after exact and lexical owners are ready. That must not + // clear the prepared generation the way a missing owner does. + let owners_ready = exact_and_lexical_ready_for_graph(graph_text.as_ref()); + let outcome = match outcome { + PublishedTextProjectionOutcomeV1::Unfinished + if !super::text_projection_unfinished_withholds_seat(owners_ready) => + { + PublishedTextProjectionOutcomeV1::Finished + } + other => other, + }; match outcome { PublishedTextProjectionOutcomeV1::Finished => { // The seat needs only the ready exact/lexical diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/seat_swap_tests.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/seat_swap_tests.rs new file mode 100644 index 0000000000..150a741eac --- /dev/null +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/seat_swap_tests.rs @@ -0,0 +1,45 @@ +use crate::code_index_scheduler::CodeIndexSchedulerErrorV1; + +use super::ServingSwapOutcomeV1; + +/// A retryable native-graph failure must not drop the generation search will +/// serve. The swap installs that same id; graph activation retries beside it. +#[test] +fn retryable_activation_keeps_the_serving_generation_matched() { + let error = CodeIndexSchedulerErrorV1::GraphActivation( + "graph runtime unavailable during activation".to_owned(), + ); + assert!( + error.is_retryable_activation(), + "GraphActivation is the retryable class that used to erase the seat candidate" + ); + let prepared = Some("generation.head"); + let seated = super::serving_generation_after_activation_failure( + prepared, + error.is_retryable_activation(), + false, + ); + assert_eq!( + seated, prepared, + "retryable graph activation must leave the prepared generation on the seat" + ); + let outcome = ServingSwapOutcomeV1::decide(true, true, seated.is_some()); + assert!( + outcome.installs(), + "the serving swap still writes the slot when the candidate survives: {outcome:?}" + ); +} + +/// Clone-fingerprint backfill is still unfinished after exact and lexical +/// owners are ready. That successor is not `published_text_owner_unfinished`. +#[test] +fn unfinished_clone_fingerprint_successor_is_not_text_projection_unfinished() { + assert!( + !super::text_projection_unfinished_withholds_seat(true), + "ready exact and lexical owners must still seat while the clone successor runs" + ); + assert!( + super::text_projection_unfinished_withholds_seat(false), + "missing exact or lexical owners still withhold the seat" + ); +} From 29f5d1c21c1442a3c717e8a52c91e2bc6f37527e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 00:05:03 +0000 Subject: [PATCH 2/3] test(code-index): classify rebuild corpus as relation-free Co-authored-by: Zack Jackson --- crates/tracedecay-code-index/src/chunks.rs | 39 +++++++++++++++++++ .../graph_rebuild_status_test.rs | 30 ++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/crates/tracedecay-code-index/src/chunks.rs b/crates/tracedecay-code-index/src/chunks.rs index 5eee31f0e9..dacf1ea87e 100644 --- a/crates/tracedecay-code-index/src/chunks.rs +++ b/crates/tracedecay-code-index/src/chunks.rs @@ -4163,6 +4163,45 @@ pub fn real_symbol() {} )); } + /// Independent top-level functions remain searchable symbols without + /// fabricating semantic relations between them. The extractor observes + /// file containment, but file nodes are not symbol rows, so those + /// observations abstain instead of entering the canonical edge census. + #[test] + fn independent_top_level_functions_have_symbols_without_relations() { + let source = "pub fn refresh_probe_0000_000(input: u32) -> u32 { input + 0 }\n\ + pub fn refresh_probe_0000_001(input: u32) -> u32 { input + 1 }\n"; + let file = validated_file("src/refresh_batch/file_0000.rs", source.as_bytes()); + let batch = batch_for(&file, ParseOutcomeV1::Complete); + let artifacts = chunker() + .index_file(&file, &batch, &rust_descriptor(), &NeverCancelled) + .expect("indexing succeeds"); + + assert_eq!(artifacts.symbols.len(), 2); + assert!( + artifacts.edges.is_empty(), + "independent functions must not fabricate canonical relations: {:?}", + artifacts.edges + ); + assert_eq!( + artifacts + .edge_abstentions + .iter() + .filter(|abstention| { + abstention.source_node_id.starts_with("file:") + && abstention.legacy_kind == EdgeKind::Contains.as_str() + && matches!( + &abstention.reason, + CodeIndexEdgeAbstentionReasonV1::MissingSymbolEndpoint + ) + }) + .count(), + 2, + "each file-root Contains observation must abstain: {:?}", + artifacts.edge_abstentions + ); + } + /// Two same-line methods share kind, name, and start line; the parser must /// still hand this path distinct endpoints so each `Contains`/`Calls` /// relation binds to its own symbol instead of abstaining or cross-binding. diff --git a/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs b/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs index 5b02449a74..827fae529b 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs @@ -24,6 +24,10 @@ use tracedecay::daemon::ProductionProjectCompositionHarnessV1; use tracedecay_mcp::JsonRpcResponse; const RECEIPT_TIMEOUT: Duration = Duration::from_secs(90); +const BACKGROUND_FILE_COUNT: u32 = 768; +const BACKGROUND_SYMBOLS_PER_FILE: u32 = 128; +const REFRESHED_SYMBOL_COUNT: u64 = + BACKGROUND_FILE_COUNT as u64 * BACKGROUND_SYMBOLS_PER_FILE as u64 + 1; fn git(project: &Path, args: &[&str]) { let output = Command::new("git") @@ -137,6 +141,23 @@ fn result_paths(search: &Value) -> Vec<&str> { .collect() } +fn assert_relation_free_refresh_census(status: &Value, generation: &str) { + let census = &status["graph_statistics"]; + assert_eq!(census["state"], "observed", "{status}"); + assert_eq!(census["generation_id"], generation, "{status}"); + assert_eq!(census["freshness"]["state"], "current", "{status}"); + assert_eq!( + census["symbol_count"].as_u64(), + Some(REFRESHED_SYMBOL_COUNT), + "the relation-free refresh corpus changed shape: {status}" + ); + assert_eq!( + census["edge_count"].as_u64(), + Some(0), + "independent top-level functions contain no project calls or imports: {status}" + ); +} + async fn wait_for_current_generation( harness: &ProductionProjectCompositionHarnessV1, project: &Path, @@ -226,12 +247,12 @@ async fn wait_for_background_refresh( .unwrap_or_else(|_| panic!("reopen omitted background-refresh status: {last_status}")); } -fn install_background_batch(isolation_root: &Path, project: &Path) { +fn install_relation_free_background_batch(isolation_root: &Path, project: &Path) { let staging = isolation_root.join("refresh-batch-staging"); fs::create_dir_all(&staging).expect("background batch staging directory"); - for file_index in 0..768_u32 { + for file_index in 0..BACKGROUND_FILE_COUNT { let mut source = String::new(); - for symbol_index in 0..128_u32 { + for symbol_index in 0..BACKGROUND_SYMBOLS_PER_FILE { writeln!( source, "pub fn refresh_probe_{file_index:04}_{symbol_index:03}(input: u32) -> u32 {{ input + {symbol_index} }}" @@ -269,7 +290,7 @@ async fn background_refresh_and_reopen_report_only_servable_generations_inner() let initial_generation = wait_for_current_generation(&harness, &project, &initial_revision, "before_reopen").await; - install_background_batch(isolation.path(), &project); + install_relation_free_background_batch(isolation.path(), &project); commit_all(&project, "install background refresh batch"); let refreshed_revision = head(&project); let receipt = tool( @@ -297,6 +318,7 @@ async fn background_refresh_and_reopen_report_only_servable_generations_inner() refreshed_generation, initial_generation, "the completed refresh must atomically replace the retained generation" ); + assert_relation_free_refresh_census(&status(&harness, &project).await, &refreshed_generation); harness.shutdown().await; fs::write( From e9d7b7f98d25e1686aaa216a1d7b4797d9d94667 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 00:14:31 +0000 Subject: [PATCH 3/3] test(code-index): prove relation-free production census Co-authored-by: Zack Jackson --- crates/tracedecay-code-index/src/chunks.rs | 39 ------------ .../production_orchestration.rs | 60 ++++++++++++++++++- .../graph_rebuild_status_test.rs | 30 ++-------- 3 files changed, 64 insertions(+), 65 deletions(-) diff --git a/crates/tracedecay-code-index/src/chunks.rs b/crates/tracedecay-code-index/src/chunks.rs index dacf1ea87e..5eee31f0e9 100644 --- a/crates/tracedecay-code-index/src/chunks.rs +++ b/crates/tracedecay-code-index/src/chunks.rs @@ -4163,45 +4163,6 @@ pub fn real_symbol() {} )); } - /// Independent top-level functions remain searchable symbols without - /// fabricating semantic relations between them. The extractor observes - /// file containment, but file nodes are not symbol rows, so those - /// observations abstain instead of entering the canonical edge census. - #[test] - fn independent_top_level_functions_have_symbols_without_relations() { - let source = "pub fn refresh_probe_0000_000(input: u32) -> u32 { input + 0 }\n\ - pub fn refresh_probe_0000_001(input: u32) -> u32 { input + 1 }\n"; - let file = validated_file("src/refresh_batch/file_0000.rs", source.as_bytes()); - let batch = batch_for(&file, ParseOutcomeV1::Complete); - let artifacts = chunker() - .index_file(&file, &batch, &rust_descriptor(), &NeverCancelled) - .expect("indexing succeeds"); - - assert_eq!(artifacts.symbols.len(), 2); - assert!( - artifacts.edges.is_empty(), - "independent functions must not fabricate canonical relations: {:?}", - artifacts.edges - ); - assert_eq!( - artifacts - .edge_abstentions - .iter() - .filter(|abstention| { - abstention.source_node_id.starts_with("file:") - && abstention.legacy_kind == EdgeKind::Contains.as_str() - && matches!( - &abstention.reason, - CodeIndexEdgeAbstentionReasonV1::MissingSymbolEndpoint - ) - }) - .count(), - 2, - "each file-root Contains observation must abstain: {:?}", - artifacts.edge_abstentions - ); - } - /// Two same-line methods share kind, name, and start line; the parser must /// still hand this path distinct endpoints so each `Contains`/`Calls` /// relation binds to its own symbol instead of abstaining or cross-binding. diff --git a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs index 8d0542e3f6..03c966a914 100644 --- a/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs +++ b/crates/tracedecay-code-index/tests/code_index_suite/production_orchestration.rs @@ -11,7 +11,10 @@ use sha2::{Digest, Sha256}; use tracedecay_code_extraction::incremental::ParseLimits; use tracedecay_code_index::{ capabilities::expected_seal_digest, - chunks::{CodeIndexImportEvidenceV1, ExtractionAdmittedCodeSearchChunkV1, content_digest}, + chunks::{ + CodeIndexEdgeAbstentionReasonV1, CodeIndexImportEvidenceV1, + ExtractionAdmittedCodeSearchChunkV1, content_digest, + }, clones::{CloneBodyEligibilityV1, CloneBodyOccurrenceV1}, graph_projection::{ CODE_GRAPH_PROJECTOR_REVISION, CodeGraphProjectionError, @@ -37,7 +40,7 @@ use tracedecay_code_index::{ }; use tracedecay_domain::{ ChunkerRevision, CodeGenerationId, CodeGenerationManifestV1, CodeSearchChunkGrainV1, CommitId, - EdgeAuthorityV1, ExtractorRevision, FileOccurrenceId, LanguageId, ManifestDigest, + EdgeAuthorityV1, EdgeKind, ExtractorRevision, FileOccurrenceId, LanguageId, ManifestDigest, PolicyRevisionId, PrivacyDomainId, ProjectId, ProjectionBatchRequestV1, ProjectionKeyV1, ProjectionKindV1, ProjectionOperationV1, ProjectionOutcomeV1, ProviderEvaluationStateV1, RefId, RelationEdgeKindV1, RepositoryDirtyStateV1, RepositoryId, SanitizationReceiptId, @@ -376,6 +379,59 @@ pub(super) fn request_with_source( request } +#[test] +fn relation_free_function_batch_seals_without_canonical_edges() { + let source = (0..128_u32) + .map(|symbol_index| { + format!( + "pub fn refresh_probe_0000_{symbol_index:03}(input: u32) -> u32 {{ input + {symbol_index} }}\n" + ) + }) + .collect::(); + let generation = CodeIndexProductionOwnerV1::new( + config(), + SharedPublicationStore::default(), + ApplyingProjectionSink, + ) + .expect("production owner") + .build_and_publish( + request_with_source( + "file.relation-free", + 1_100_000, + "commit.relation-free", + "tree.relation-free", + &source, + ), + &ActiveControl, + ) + .expect("relation-free generation publishes"); + + let statistics = generation + .generation_statistics() + .expect("generation census"); + assert_eq!(statistics.symbol_count, 128); + assert_eq!(statistics.edge_count, 0); + assert!( + generation.edges().is_empty(), + "independent functions must not fabricate canonical relations: {:?}", + generation.edges() + ); + assert_eq!( + generation + .edge_abstentions() + .iter() + .filter(|abstention| { + abstention.source_node_id.starts_with("file:") + && abstention.legacy_kind == EdgeKind::Contains.as_str() + && abstention.reason == CodeIndexEdgeAbstentionReasonV1::MissingSymbolEndpoint + }) + .count(), + 128, + "each file-root Contains observation must abstain: {:?}", + generation.edge_abstentions() + ); +} + #[test] fn cross_file_edges_require_path_binding_evidence() { let sources = [ diff --git a/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs b/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs index 827fae529b..c21ef89602 100644 --- a/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs +++ b/crates/tracedecay/tests/transport_acceptance_suite/graph_rebuild_status_test.rs @@ -24,10 +24,8 @@ use tracedecay::daemon::ProductionProjectCompositionHarnessV1; use tracedecay_mcp::JsonRpcResponse; const RECEIPT_TIMEOUT: Duration = Duration::from_secs(90); -const BACKGROUND_FILE_COUNT: u32 = 768; -const BACKGROUND_SYMBOLS_PER_FILE: u32 = 128; -const REFRESHED_SYMBOL_COUNT: u64 = - BACKGROUND_FILE_COUNT as u64 * BACKGROUND_SYMBOLS_PER_FILE as u64 + 1; +const RELATION_FREE_BACKGROUND_FILE_COUNT: u32 = 768; +const RELATION_FREE_FUNCTIONS_PER_FILE: u32 = 128; fn git(project: &Path, args: &[&str]) { let output = Command::new("git") @@ -141,23 +139,6 @@ fn result_paths(search: &Value) -> Vec<&str> { .collect() } -fn assert_relation_free_refresh_census(status: &Value, generation: &str) { - let census = &status["graph_statistics"]; - assert_eq!(census["state"], "observed", "{status}"); - assert_eq!(census["generation_id"], generation, "{status}"); - assert_eq!(census["freshness"]["state"], "current", "{status}"); - assert_eq!( - census["symbol_count"].as_u64(), - Some(REFRESHED_SYMBOL_COUNT), - "the relation-free refresh corpus changed shape: {status}" - ); - assert_eq!( - census["edge_count"].as_u64(), - Some(0), - "independent top-level functions contain no project calls or imports: {status}" - ); -} - async fn wait_for_current_generation( harness: &ProductionProjectCompositionHarnessV1, project: &Path, @@ -250,9 +231,11 @@ async fn wait_for_background_refresh( fn install_relation_free_background_batch(isolation_root: &Path, project: &Path) { let staging = isolation_root.join("refresh-batch-staging"); fs::create_dir_all(&staging).expect("background batch staging directory"); - for file_index in 0..BACKGROUND_FILE_COUNT { + // This is a throughput fixture, not an edge fixture: every function is + // independent, so the sealed canonical edge census is intentionally zero. + for file_index in 0..RELATION_FREE_BACKGROUND_FILE_COUNT { let mut source = String::new(); - for symbol_index in 0..BACKGROUND_SYMBOLS_PER_FILE { + for symbol_index in 0..RELATION_FREE_FUNCTIONS_PER_FILE { writeln!( source, "pub fn refresh_probe_{file_index:04}_{symbol_index:03}(input: u32) -> u32 {{ input + {symbol_index} }}" @@ -318,7 +301,6 @@ async fn background_refresh_and_reopen_report_only_servable_generations_inner() refreshed_generation, initial_generation, "the completed refresh must atomically replace the retained generation" ); - assert_relation_free_refresh_census(&status(&harness, &project).await, &refreshed_generation); harness.shutdown().await; fs::write(