diff --git a/crates/tracedecay-agent-hosts/src/agents/codex.rs b/crates/tracedecay-agent-hosts/src/agents/codex.rs index d9f9a0246e..6141304f00 100644 --- a/crates/tracedecay-agent-hosts/src/agents/codex.rs +++ b/crates/tracedecay-agent-hosts/src/agents/codex.rs @@ -92,17 +92,6 @@ impl AgentIntegration for CodexIntegration { // Core apply drives `codex plugin add` when the host CLI is present. // When it is not, stop with the same backtick remediation preflight // uses so operators (and lifecycle tests) can activate natively. - // - // `Ready` here is a promise that Core apply can complete, so it must - // not be returned when no `codex` resolves. Returning it anyway opens - // a component transaction that can only die in activation with - // `HostCliUnavailable`; the rollback leaves a `RolledBack` journal - // whose registration backup pins `config.toml` and the versioned - // plugin cache as they were *before* the operator runs the printed - // `codex plugin add`. The next lifecycle command starts with - // `recover_host`, replays that stale rollback over the now-remediated - // host, and refuses with `StalePreview` -- making the remediation this - // very error prints impossible to follow. if plugin_registry::require_codex_plugin_cli().is_err() { let marketplace_name = codex_exact_personal_marketplace_name(&ctx.home) .ok() diff --git a/crates/tracedecay-code-extraction/src/rust_extractor.rs b/crates/tracedecay-code-extraction/src/rust_extractor.rs index c00f740eda..973e7b59c4 100644 --- a/crates/tracedecay-code-extraction/src/rust_extractor.rs +++ b/crates/tracedecay-code-extraction/src/rust_extractor.rs @@ -61,6 +61,70 @@ impl ReceiverTypes { } } +/// One type parameter's trait bounds, as a method owner. +/// +/// `T: Processor` makes `value.process()` the callee `Processor::process`. +/// Two bounds, or a bound the syntax does not name, stay unresolved so the +/// call is not attached to both traits. This does not rename `self`: #1814 +/// still records that binding from the enclosing type. +enum ParamBound { + Unbound, + Unique(String), + Ambiguous, +} + +#[derive(Default)] +struct TraitBounds { + parameters: BTreeMap, +} + +struct BoundClause { + paths: Vec, + ambiguous: bool, +} + +impl TraitBounds { + fn declare(&mut self, name: String) { + self.parameters.insert(name, ParamBound::Unbound); + } + + fn knows(&self, name: &str) -> bool { + self.parameters.contains_key(name) + } + + fn constrain(&mut self, name: &str, clause: BoundClause) { + if clause.paths.is_empty() && !clause.ambiguous { + return; + } + let Some(slot) = self.parameters.get_mut(name) else { + return; + }; + if clause.ambiguous || clause.paths.len() != 1 { + *slot = ParamBound::Ambiguous; + return; + } + let Some(path) = clause.paths.into_iter().next() else { + *slot = ParamBound::Ambiguous; + return; + }; + match slot { + ParamBound::Unbound => *slot = ParamBound::Unique(path), + ParamBound::Unique(existing) if existing == &path => {} + ParamBound::Unique(_) | ParamBound::Ambiguous => *slot = ParamBound::Ambiguous, + } + } + + /// Replace a written type-parameter name with its unique trait. Any other + /// path, including the enclosing type recorded for `self`, is unchanged. + fn resolve(&self, path: String) -> Option { + match self.parameters.get(path.as_str()) { + Some(ParamBound::Unique(bound)) => Some(bound.clone()), + Some(ParamBound::Ambiguous) => None, + Some(ParamBound::Unbound) | None => Some(path), + } + } +} + /// Internal state used during AST traversal. /// /// Borrows the caller's source for the lifetime of the walk: copying the @@ -1738,6 +1802,17 @@ impl RustExtractor { node: TsNode<'_>, function: TsNode<'_>, receivers: &mut ReceiverTypes, + ) { + let bounds = Self::trait_bounds_for(state, function); + Self::collect_receiver_bindings(state, node, function, receivers, &bounds); + } + + fn collect_receiver_bindings( + state: &ExtractionState<'_>, + node: TsNode<'_>, + function: TsNode<'_>, + receivers: &mut ReceiverTypes, + bounds: &TraitBounds, ) { match node.kind() { "self_parameter" => { @@ -1749,14 +1824,14 @@ impl RustExtractor { if let Some(pattern) = node.child_by_field_name("pattern") { let type_path = node .child_by_field_name("type") - .and_then(|ty| Self::stated_type_path(state, ty)); + .and_then(|ty| Self::receiver_type_path(state, ty, bounds)); Self::record_receiver_pattern(state, pattern, type_path, receivers); } } "let_declaration" => { if let Some(pattern) = node.child_by_field_name("pattern") { let type_path = match node.child_by_field_name("type") { - Some(ty) => Self::stated_type_path(state, ty), + Some(ty) => Self::receiver_type_path(state, ty, bounds), None => node .child_by_field_name("value") .and_then(|value| Self::stated_initializer_type_path(state, value)), @@ -1793,7 +1868,7 @@ impl RustExtractor { let mut cursor = node.walk(); if cursor.goto_first_child() { loop { - Self::collect_receiver_types(state, cursor.node(), function, receivers); + Self::collect_receiver_bindings(state, cursor.node(), function, receivers, bounds); if !cursor.goto_next_sibling() { break; } @@ -1846,10 +1921,185 @@ impl RustExtractor { "dynamic_type" | "abstract_type" => ty .child_by_field_name("trait") .and_then(|inner| Self::stated_type_path(state, inner)), + "higher_ranked_trait_bound" => ty + .child_by_field_name("type") + .and_then(|inner| Self::stated_type_path(state, inner)), + // `impl Trait + 'a` still names that trait. Two nominals do not. + "bounded_type" => Self::unique_sum_type_path(state, ty), _ => None, } } + /// A parameter type, with a type parameter replaced by its unique trait + /// bound. `Self` is left as #1814 mapped it: the enclosing type, not the + /// trait the parameter happens to implement. + fn receiver_type_path( + state: &ExtractionState<'_>, + ty: TsNode<'_>, + bounds: &TraitBounds, + ) -> Option { + if Self::annotation_is_self(state, ty) { + return Self::enclosing_receiver_type(state); + } + bounds.resolve(Self::stated_type_path(state, ty)?) + } + + fn annotation_is_self(state: &ExtractionState<'_>, ty: TsNode<'_>) -> bool { + match ty.kind() { + "type_identifier" => state.node_text(ty) == "Self", + "reference_type" => ty + .child_by_field_name("type") + .is_some_and(|inner| Self::annotation_is_self(state, inner)), + _ => false, + } + } + + fn unique_sum_type_path(state: &ExtractionState<'_>, ty: TsNode<'_>) -> Option { + let mut found = None; + let mut cursor = ty.walk(); + if !cursor.goto_first_child() { + return None; + } + loop { + let child = cursor.node(); + if child.is_named() { + let path = match child.kind() { + "lifetime" | "use_bounds" => None, + "bounded_type" => Self::unique_sum_type_path(state, child), + _ => Self::stated_type_path(state, child), + }; + match path { + None if matches!(child.kind(), "lifetime" | "use_bounds") => {} + None => return None, + Some(path) => { + if found.replace(path).is_some() { + return None; + } + } + } + } + if !cursor.goto_next_sibling() { + break; + } + } + found + } + + fn trait_bounds_for(state: &ExtractionState<'_>, function: TsNode<'_>) -> TraitBounds { + let mut ancestors = Vec::new(); + let mut current = function.parent(); + while let Some(node) = current { + if matches!(node.kind(), "function_item" | "function_signature_item") { + break; + } + if matches!(node.kind(), "impl_item" | "trait_item") { + ancestors.push(node); + } + current = node.parent(); + } + ancestors.reverse(); + let mut bounds = TraitBounds::default(); + for item in ancestors { + Self::absorb_generic_bounds(state, item, &mut bounds); + } + Self::absorb_generic_bounds(state, function, &mut bounds); + bounds + } + + fn absorb_generic_bounds( + state: &ExtractionState<'_>, + item: TsNode<'_>, + bounds: &mut TraitBounds, + ) { + if let Some(parameters) = item.child_by_field_name("type_parameters") { + let mut cursor = parameters.walk(); + if cursor.goto_first_child() { + loop { + let child = cursor.node(); + if child.kind() == "type_parameter" + && let Some(name_node) = child.child_by_field_name("name") + { + let name = state.node_text(name_node).to_owned(); + bounds.declare(name.clone()); + if let Some(clause) = child.child_by_field_name("bounds") { + bounds.constrain(&name, Self::trait_bound_clause(state, clause)); + } + } + if !cursor.goto_next_sibling() { + break; + } + } + } + } + let Some(where_clause) = Self::child_of_kind(item, "where_clause") else { + return; + }; + let mut cursor = where_clause.walk(); + if !cursor.goto_first_child() { + return; + } + loop { + let child = cursor.node(); + if child.kind() == "where_predicate" + && let Some(left) = child.child_by_field_name("left") + && left.kind() == "type_identifier" + { + let name = state.node_text(left); + if bounds.knows(name) + && let Some(clause) = child.child_by_field_name("bounds") + { + bounds.constrain(name, Self::trait_bound_clause(state, clause)); + } + } + if !cursor.goto_next_sibling() { + break; + } + } + } + + fn trait_bound_clause(state: &ExtractionState<'_>, bounds: TsNode<'_>) -> BoundClause { + let mut clause = BoundClause { + paths: Vec::new(), + ambiguous: false, + }; + let mut cursor = bounds.walk(); + if !cursor.goto_first_child() { + return clause; + } + loop { + let child = cursor.node(); + if child.is_named() { + match child.kind() { + "lifetime" | "use_bounds" | "removed_trait_bound" => {} + _ => match Self::stated_type_path(state, child) { + Some(path) => clause.paths.push(path), + None => clause.ambiguous = true, + }, + } + } + if !cursor.goto_next_sibling() { + break; + } + } + clause + } + + fn child_of_kind<'t>(node: TsNode<'t>, kind: &str) -> Option> { + let mut cursor = node.walk(); + if !cursor.goto_first_child() { + return None; + } + loop { + let child = cursor.node(); + if child.kind() == kind { + return Some(child); + } + if !cursor.goto_next_sibling() { + return None; + } + } + } + /// The type a `let` initialiser states in syntax: a `T { .. }` literal, /// optionally behind `?`. Method names are never return-type evidence. /// Abstain rather than fabricate a receiver type. diff --git a/crates/tracedecay-code-extraction/tests/main/rust.rs b/crates/tracedecay-code-extraction/tests/main/rust.rs index 664d131ec8..bd9fc4cf10 100644 --- a/crates/tracedecay-code-extraction/tests/main/rust.rs +++ b/crates/tracedecay-code-extraction/tests/main/rust.rs @@ -1237,6 +1237,93 @@ fn make() -> Vec { Vec::new() } ); } +#[test] +fn trait_bound_calls_name_the_trait_without_a_bare_method() { + let source = r#" +trait Processor { + fn process(&self, input: u32) -> u32; + fn via_self(&self, input: u32) -> u32 { + self.process(input) + } +} +trait Other { + fn process(&self, input: u32) -> u32; +} +struct Doubler; +impl Doubler { + fn kick(&self, input: u32) -> u32 { + self.process(input) + } +} +impl Processor for Doubler { + fn process(&self, input: u32) -> u32 { + input * 2 + } +} +fn via_dyn(processor: &dyn Processor, input: u32) -> u32 { + processor.process(input) +} +fn via_impl(processor: impl Processor + 'static, input: u32) -> u32 { + processor.process(input) +} +fn via_bound(processor: &T, input: u32) -> u32 { + processor.process(input) +} +fn via_where(processor: &T, input: u32) -> u32 +where + T: Processor, +{ + processor.process(input) +} +fn ambiguous(processor: &T, input: u32) -> u32 { + processor.process(input) +} +"#; + let result = RustExtractor.extract("src/lib.rs", source); + assert!(result.errors.is_empty(), "{:?}", result.errors); + let from = |name: &str| { + let function = result + .nodes + .iter() + .find(|node| { + matches!(node.kind, NodeKind::Function | NodeKind::Method) && node.name == name + }) + .unwrap_or_else(|| panic!("{name} is extracted")); + result + .unresolved_refs + .iter() + .filter(|reference| { + reference.reference_kind == EdgeKind::Calls && reference.from_node_id == function.id + }) + .map(|reference| reference.reference_name.as_str()) + .collect::>() + }; + + for owner in ["via_self", "via_dyn", "via_impl", "via_bound", "via_where"] { + let names = from(owner); + assert!( + names.contains(&"Processor::process"), + "{owner} must name the trait callee: {names:?}" + ); + assert!( + !names.contains(&"process"), + "{owner} must not reintroduce the bare method name: {names:?}" + ); + } + let kick = from("kick"); + assert!( + kick.contains(&"Doubler::process") && !kick.contains(&"Processor::process"), + "self in an inherent impl stays the type, not the trait: {kick:?}" + ); + assert!(!kick.contains(&"process"), "{kick:?}"); + let ambiguous = from("ambiguous"); + assert!( + !ambiguous.iter().any(|name| name.contains("::process")), + "two trait bounds must not pick a callee: {ambiguous:?}" + ); + assert!(!ambiguous.contains(&"process"), "{ambiguous:?}"); +} + #[test] fn self_receiver_names_carry_module_scope_and_the_outer_as_delimiter() { let source = r#" diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations.rs b/crates/tracedecay-code-index-retention/src/code_index_generations.rs index 103310f3c0..e6009275fb 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations.rs @@ -778,6 +778,25 @@ pub fn plan_code_generation_retention_with_verification( ) } +/// A store directory that does not exist yet has nothing to collect. The +/// sealer creates that directory on open; until then the census is the same +/// unpublished plan an empty directory with no pointer produces. +fn unpublished_store_plan( + vector_readable_sources: &BTreeSet, +) -> CodeGenerationRetentionPlanV1 { + CodeGenerationRetentionPlanV1 { + active_generation_id: None, + vector_readable_sources: vector_readable_sources.clone(), + superseded_generations: Vec::new(), + collectable_generations: Vec::new(), + collectable_text_artifacts: Vec::new(), + collectable_generation_segments: GenerationSegmentCensusV1::NoneFound, + text_artifact_inventory_bytes: 0, + verification: GenerationDigestVerificationV1::Full, + active_pointer: None, + } +} + /// Recover any bounded prior apply, then build the next fully verified /// collection unit while preserving the caller's cancellation authority. /// @@ -795,6 +814,30 @@ pub fn prepare_next_code_generation_retention_cancellable( if observe_cancel(is_cancelled) { return Err(CodeGenerationRetentionErrorV1::Cancelled); } + // The serving seat can name a generation before the scoped store + // directory exists. Cold open creates it inside the worker, so a waiter + // that only saw `latest_generation_id` plans against a path canonicalize + // reports as `Storage(NotFound)`. That is an unpublished store, the same + // typed state as a directory with no pointer. It is not + // `GenerationStoreBusy` either. An absent root has no publisher to wait + // for and stays absent until the project is first indexed, while the only + // production caller turns that deferral into a failed maintenance tick on + // the short retry delay, so a never-indexed project would report degraded + // on every tick forever. The enumerate-then-open sites below keep the + // deferral, where `read_dir` already proved the name existed. + match std::fs::metadata(store_root) { + Ok(metadata) if metadata.is_dir() => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(unpublished_store_plan(vector_readable_sources)); + } + Ok(_) => { + return Err(CodeGenerationRetentionErrorV1::UnsafeState(format!( + "code-generation store '{}' is not a directory", + store_root.display() + ))); + } + Err(error) => return Err(storage(error)), + } recover_code_generation_retention_cancellable( store_root, vector_readable_sources, diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs index 47d6d406f9..e97d9ee5be 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/tests.rs @@ -1547,23 +1547,6 @@ fn idle_maintenance_preparation_stays_metadata_only() { ); } -#[test] -fn preparation_defers_when_the_scope_root_does_not_exist_yet() { - let parent = tempfile::TempDir::new().expect("parent"); - let missing = parent.path().join("not-created"); - let error = prepare_next_code_generation_retention_cancellable( - &missing, - &BTreeSet::new(), - &|| false, - None, - ) - .expect_err("an unpublished scope root has no census"); - assert!( - matches!(error, CodeGenerationRetentionErrorV1::GenerationStoreBusy), - "a missing scope root is the publisher's create window, not a storage failure: {error:?}" - ); -} - #[test] fn metadata_only_segment_census_observes_at_most_one_directory_entry() { let store = tempfile::TempDir::new().expect("create unpublished store"); @@ -3160,3 +3143,22 @@ fn vanished_listed_generation_open_defers_instead_of_storage_loss() { "non-NotFound census I/O stays a storage failure: {storage_error:?}" ); } + +#[test] +fn missing_store_is_an_unpublished_plan_not_a_storage_failure() { + let missing = std::env::temp_dir().join(format!( + "tracedecay-missing-code-store-{}", + std::process::id() + )); + assert!(!missing.exists()); + let plan = prepare_next_code_generation_retention_cancellable( + &missing, + &BTreeSet::new(), + &|| false, + None, + ) + .expect("a store that has not been opened is unpublished"); + assert_eq!(plan.active_generation_id, None); + assert!(plan.collectable_generations.is_empty()); + assert!(!plan.has_collectable_work()); +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs index 72b2c508cf..ebe1870245 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs @@ -851,7 +851,7 @@ fn retained_stale_rust_extractor_generation_is_refused_and_rebuilt() { .iter() .find(|(language, _)| language.as_str() == "rust") .map(|(_, revision)| revision.as_str()), - Some("extractor.rust.v11") + Some("extractor.rust.v12") ); } diff --git a/crates/tracedecay-code-index/src/chunks.rs b/crates/tracedecay-code-index/src/chunks.rs index 08e9be3684..15fd1f44b0 100644 --- a/crates/tracedecay-code-index/src/chunks.rs +++ b/crates/tracedecay-code-index/src/chunks.rs @@ -4458,6 +4458,71 @@ pub fn real_symbol() {} ); } + #[test] + fn trait_bound_method_call_binds_the_trait_callee() { + let source = concat!( + "pub trait Processor {\n", + " fn process(&self, input: u32) -> u32;\n", + " fn via_self(&self, input: u32) -> u32 { self.process(input) }\n", + "}\n", + "pub trait Other { fn process(&self, input: u32) -> u32; }\n", + "pub struct Doubler;\n", + "impl Doubler { fn kick(&self, input: u32) -> u32 { self.process(input) } }\n", + "impl Processor for Doubler { fn process(&self, input: u32) -> u32 { input * 2 } }\n", + "pub fn via_dyn(processor: &dyn Processor, input: u32) -> u32 { processor.process(input) }\n", + "pub fn via_impl(processor: impl Processor + 'static, input: u32) -> u32 { processor.process(input) }\n", + "pub fn via_bound(processor: &T, input: u32) -> u32 { processor.process(input) }\n", + "pub fn via_where(processor: &T, input: u32) -> u32 where T: Processor { processor.process(input) }\n", + "pub fn ambiguous(processor: &T, input: u32) -> u32 { processor.process(input) }\n", + ); + let file = validated_file("src/lib.rs", source.as_bytes()); + let batch = batch_for(&file, ParseOutcomeV1::Complete); + let artifacts = chunker() + .index_file(&file, &batch, &rust_descriptor(), &NeverCancelled) + .expect("indexing succeeds"); + let qualified = |occurrence: &SymbolOccurrenceId| { + artifacts + .symbols + .iter() + .find(|symbol| &symbol.occurrence == occurrence) + .map(|symbol| symbol.qualified_name.as_str()) + .unwrap_or("") + }; + let mut calls = artifacts + .edges + .iter() + .filter(|edge| edge.kind == RelationEdgeKindV1::Calls) + .map(|edge| { + ( + qualified(&edge.from_occurrence).to_owned(), + qualified(&edge.to_occurrence).to_owned(), + ) + }) + .collect::>(); + calls.sort(); + + let trait_method = "src/lib.rs::Processor::process"; + let impl_method = "src/lib.rs::::process"; + assert_eq!( + calls, + vec![ + ( + "src/lib.rs::Doubler::kick".to_owned(), + impl_method.to_owned() + ), + ( + "src/lib.rs::Processor::via_self".to_owned(), + trait_method.to_owned() + ), + ("src/lib.rs::via_bound".to_owned(), trait_method.to_owned()), + ("src/lib.rs::via_dyn".to_owned(), trait_method.to_owned()), + ("src/lib.rs::via_impl".to_owned(), trait_method.to_owned()), + ("src/lib.rs::via_where".to_owned(), trait_method.to_owned()), + ], + "a unique trait bound is the callee; two bounds and a bare method name are not: {calls:?}" + ); + } + #[test] fn self_call_inside_an_inline_module_binds_the_module_scoped_method() { let source = concat!( diff --git a/crates/tracedecay-code-index/src/extract.rs b/crates/tracedecay-code-index/src/extract.rs index cd4f39eb1a..3aedf93cb1 100644 --- a/crates/tracedecay-code-index/src/extract.rs +++ b/crates/tracedecay-code-index/src/extract.rs @@ -777,12 +777,14 @@ mod tests { // typed value and no longer fabricates receiver types for method // initializers; v9 adds the clone-body token bound and v10 the byte // bound; v11 drops the bare method name of a dotted call and types - // `self` from the enclosing impl or trait. The revision is part of the - // batch identity, so the pinned digest moves with it. - assert_eq!(descriptor.extractor_revision.as_str(), "extractor.rust.v11"); + // `self` from the enclosing impl or trait; v12 binds a receiver typed + // by a type parameter with one trait bound to `Trait::method`. The + // revision is part of the batch identity, so the pinned digest moves + // with it. + assert_eq!(descriptor.extractor_revision.as_str(), "extractor.rust.v12"); assert_eq!( extraction.batch().rows_digest.as_str(), - "sha256:e92b7ad8f93e3576c70adafb0690d064bd996c207a4ecd7b855c96e3ad3959ad" + "sha256:4e483806dfce308dfc97ec460c65f674b28f2dc8bad0e57c28a3b1519c8e495c" ); } diff --git a/crates/tracedecay-code-index/src/languages.rs b/crates/tracedecay-code-index/src/languages.rs index 5d7ea779ed..fdcf1392e6 100644 --- a/crates/tracedecay-code-index/src/languages.rs +++ b/crates/tracedecay-code-index/src/languages.rs @@ -214,9 +214,11 @@ impl StaticLanguageRegistry { // stopped emitting the bare method name of a dotted call, so an // unrelated same-file callable sharing that name is no longer a // caller, and types `self` through the enclosing impl or trait. - // Only re-extraction removes the poisoned record. + // Rust v12 resolves a receiver whose type is a type parameter with + // one trait bound to `Trait::method`, so that call now has a + // callee. Only re-extraction removes the poisoned record. let extractor_revision = if language == "rust" { - 11 + 12 } else if matches!(language.as_str(), "typescript" | "protobuf" | "sql") { 6 } else { @@ -421,7 +423,7 @@ mod tests { assert!(rust.stable_member_spans); assert!(rust.capabilities.extraction); assert_eq!(rust.root_markers, vec!["Cargo.toml".to_owned()]); - assert_eq!(rust.extractor_revision.as_str(), "extractor.rust.v11"); + assert_eq!(rust.extractor_revision.as_str(), "extractor.rust.v12"); assert_eq!( registry diff --git a/crates/tracedecay-code-index/src/production/worker_tests.rs b/crates/tracedecay-code-index/src/production/worker_tests.rs index 7531f94588..ba58d2d4d2 100644 --- a/crates/tracedecay-code-index/src/production/worker_tests.rs +++ b/crates/tracedecay-code-index/src/production/worker_tests.rs @@ -357,7 +357,7 @@ fn extractor_revision_change_reextracts_before_validating_retained_import_rows() assert_eq!( rebuilt.files[0].extraction.extractor_revision.as_str(), - "extractor.rust.v11" + "extractor.rust.v12" ); assert_ne!( rebuilt.files[0].extraction.parser_import_rows_digest, @@ -425,7 +425,7 @@ fn physical_artifact_reuse_rejects_a_stale_extractor_revision() { assert_eq!( rebuilt.files[0].extraction.extractor_revision.as_str(), - "extractor.rust.v11" + "extractor.rust.v12" ); assert!( rebuilt.files[0] 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 6e7d5f7894..f4f4d73f23 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 @@ -3244,22 +3244,22 @@ fn partitioned_codec_fixture() -> ( } const PARTITIONED_FORMAT_STATE_DIGEST: &str = - "sha256:4f78e1a1b0a4ea366f748e4699d4c28d9913782912809bbe2afafba4eac25268"; + "sha256:89fa34dded4c696b44119ee9b658cea4d627fcef4b37b98360ca2298e36d1df3"; const PARTITIONED_FORMAT_SEGMENTS: &[(&str, u64)] = &[ ( - "sha256:7cd13a44df02cc2dc2e13d5a867aaab5410594eaa398fe535ed59450ddc63b36", + "sha256:8caabc1a8e5b468ba6ccd6826e81efa2fa2627763e7217df64ea0f7313a383a6", 11_071, ), ( - "sha256:64b5c4d5c08f363d66c1dc3922fb61df7c8df0b022dab6b72507e61e6e1bf403", + "sha256:c44d7072ba9f13b9e5c734950f94bec9bb646ba4f5b9d6c691d43f822fd657c8", 5_171, ), ( - "sha256:782d1321bdc39aac9efcff85a44f8d48da66e24212d28150020cb04309d23880", + "sha256:7709e7304333ac9c4a94e0e02508029f09712072e1606a707854f42c2c1f9ce3", 6_279, ), ( - "sha256:3a307f49e46059b54a86dac6921287afbe0b25cadc581840b5143ce2bd5a04d1", + "sha256:2da2f3ca63181ae45a17db21cfeb68b8a42ae6e104820cd91a0b5c0673c5d249", 6_837, ), ]; diff --git a/crates/tracedecay-contracts/src/result/problem.rs b/crates/tracedecay-contracts/src/result/problem.rs index 013d638b55..a44e21648e 100644 --- a/crates/tracedecay-contracts/src/result/problem.rs +++ b/crates/tracedecay-contracts/src/result/problem.rs @@ -759,6 +759,15 @@ impl ApplicationProblem { } } + /// The refusal for an admitted project whose runtime has not finished + /// mounting. + pub fn runtime_mounting() -> Self { + Self::unavailable(SafeDiagnostic { + code: RUNTIME_MOUNTING_REASON_CODE.to_owned(), + message: "The project runtime for this operation is still mounting".to_owned(), + }) + } + pub fn admitted_unavailable( classification: ApplicationUnavailableClassV1, diagnostic: SafeDiagnostic, diff --git a/crates/tracedecay-daemon-service/src/invocation/work.rs b/crates/tracedecay-daemon-service/src/invocation/work.rs index 7da13757b9..0b8f2639c0 100644 --- a/crates/tracedecay-daemon-service/src/invocation/work.rs +++ b/crates/tracedecay-daemon-service/src/invocation/work.rs @@ -44,13 +44,7 @@ pub(super) fn concealed_application_problem(request_id: String) -> DaemonInvocat /// Retryable state for an admitted project whose runtime is still mounting. pub(super) fn runtime_mounting_problem(request_id: String) -> DaemonInvocationResponse { - application_problem( - request_id, - ApplicationProblem::unavailable(SafeDiagnostic { - code: tracedecay_contracts::RUNTIME_MOUNTING_REASON_CODE.to_owned(), - message: "The project runtime for this operation is still mounting".to_owned(), - }), - ) + application_problem(request_id, ApplicationProblem::runtime_mounting()) } /// Permanent owner-publication failure. This is not warming: retrying the diff --git a/crates/tracedecay/src/daemon/core_client.rs b/crates/tracedecay/src/daemon/core_client.rs index 0d7cebc839..efefbe4471 100644 --- a/crates/tracedecay/src/daemon/core_client.rs +++ b/crates/tracedecay/src/daemon/core_client.rs @@ -467,8 +467,7 @@ pub async fn call_tool_within( /// saturated open queue) and a retained project server retired mid-response /// during a composition upgrade. The daemon types every one of these /// `retryable: true`; a client that honours only the open subset reports the -/// upgrade window as a hard failure, which is what the reset-recovery journey -/// saw (`project_server_response_revoked` surfaced by `tracedecay tool`). +/// upgrade window as a hard failure. fn is_project_open_retryable_error(error: &TraceDecayError) -> bool { error_is_project_open_retryable(error) || tool_call_transport_error_is_retryable(error) } diff --git a/crates/tracedecay/src/daemon/invocation_dispatch.rs b/crates/tracedecay/src/daemon/invocation_dispatch.rs index 517134a51c..cce0886799 100644 --- a/crates/tracedecay/src/daemon/invocation_dispatch.rs +++ b/crates/tracedecay/src/daemon/invocation_dispatch.rs @@ -880,12 +880,7 @@ fn project_open_refusal_response( if error_is_project_open_retryable(error) { return DaemonInvocationResponse::application_problem( request_id, - tracedecay_contracts::ApplicationProblem::unavailable( - tracedecay_contracts::SafeDiagnostic { - code: tracedecay_contracts::RUNTIME_MOUNTING_REASON_CODE.to_owned(), - message: "The project runtime for this operation is still mounting".to_owned(), - }, - ), + tracedecay_contracts::ApplicationProblem::runtime_mounting(), ); } DaemonInvocationResponse::problem( diff --git a/crates/tracedecay/src/daemon/production_harness/generation_retention_test.rs b/crates/tracedecay/src/daemon/production_harness/generation_retention_test.rs index b82f5dfe19..d2b47bd5f7 100644 --- a/crates/tracedecay/src/daemon/production_harness/generation_retention_test.rs +++ b/crates/tracedecay/src/daemon/production_harness/generation_retention_test.rs @@ -98,7 +98,13 @@ async fn mounted_code_generation_retention_continues_capped_segment_reclamation( .expect("project server") .cg() .await; - let canonical_root = graph.project_root().to_path_buf(); + // The scheduler hashes the canonical project root. A non-canonical + // `project_root()` names a store that is never created, and + // `latest_generation_id` still answers because it canonicalizes itself. + let canonical_root = graph + .project_root() + .canonicalize() + .expect("canonical project root"); let first_source = schedulers .latest_generation_id(&canonical_root) .await @@ -115,14 +121,12 @@ async fn mounted_code_generation_retention_continues_capped_segment_reclamation( &canonical_root, ); let graph_replay_pool_root = graph.db().database_path().with_extension("graph-replay"); - // The planner probes the generation-store lock and answers - // `GenerationStoreBusy` whenever a writer owns the store, and the same - // probe over the graph replay pool answers `GraphReplayPoolBusy`; - // production maintenance defers both and comes back. This route stays - // mounted, so the pass tail that publishes the edits above can still own - // either lock here. Consume the same typed answers instead of reading - // them as failures. - let plan = tokio::time::timeout(Duration::from_secs(30), async { + // Text seating moves `latest_generation_id` before the superseded sealed + // file is collectable, so the first plan the planner returns is not yet + // the answer. A lock holder publishes no seat, which is why the tick + // stays the floor of the wait. + let mut serving_seats = schedulers.subscribe_serving_seats(); + let plan = tokio::time::timeout(Duration::from_mins(2), async { loop { match prepare_next_code_generation_retention_cancellable( &code_store_root, @@ -130,19 +134,30 @@ async fn mounted_code_generation_retention_continues_capped_segment_reclamation( &|| false, Some(&graph_replay_pool_root), ) { - Ok(plan) => return plan, + Ok(plan) + if plan + .collectable_generations + .iter() + .any(|generation| generation.generation_id == first_source) => + { + return plan; + } + Ok(_) => {} Err( CodeGenerationRetentionErrorV1::GenerationStoreBusy | CodeGenerationRetentionErrorV1::GraphReplayPoolBusy, - ) => { - tokio::time::sleep(Duration::from_millis(25)).await; - } + ) => {} Err(error) => panic!("code generation retention plan: {error:?}"), } + tokio::select! { + changed = serving_seats.changed() => changed + .expect("the seating channel stays open while the registry lives"), + () = tokio::time::sleep(Duration::from_millis(25)) => {} + } } }) .await - .expect("code generation retention plan converges"); + .expect("superseded source became collectable after the serving seat moved"); let first_candidate = plan .collectable_generations .iter() diff --git a/crates/tracedecay/tests/daemon_suite/code_index_ignored_dependencies_test/flight_tests.rs b/crates/tracedecay/tests/daemon_suite/code_index_ignored_dependencies_test/flight_tests.rs index 28a66cd569..3c7d0c778a 100644 --- a/crates/tracedecay/tests/daemon_suite/code_index_ignored_dependencies_test/flight_tests.rs +++ b/crates/tracedecay/tests/daemon_suite/code_index_ignored_dependencies_test/flight_tests.rs @@ -368,7 +368,9 @@ async fn coalesced_publication_failure_preserves_the_scheduler_error_family() { assert_publication_error(owner_error); assert_publication_error(follower_error); assert!( - pointer_path.is_dir(), + std::fs::symlink_metadata(&pointer_path) + .expect("faulted pointer remains") + .is_dir(), "publication must not replace a directory pointer it did not observe" );