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 a5392c83b1..c39518c389 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 ac239ce49c..ed8f3e88f6 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" + ); +} 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 151889363d..d9c6aec775 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 5b02449a74..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,6 +24,8 @@ use tracedecay::daemon::ProductionProjectCompositionHarnessV1; use tracedecay_mcp::JsonRpcResponse; const RECEIPT_TIMEOUT: Duration = Duration::from_secs(90); +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") @@ -226,12 +228,14 @@ 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 { + // 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..128_u32 { + 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} }}" @@ -269,7 +273,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(