From 7bd29d693ce5e5df8aa1a58edf66375c4a81fbbb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 06:49:09 +0000 Subject: [PATCH 01/10] fix(extraction): bind a unique trait bound as the callee A type parameter with one trait bound names Trait::method. Two bounds still abstain. Dotted calls do not regain the bare method name #1814 removed, and self still names the enclosing type. Co-authored-by: Zack Jackson --- .../src/rust_extractor.rs | 256 +++++++++++++++++- .../tests/main/rust.rs | 87 ++++++ crates/tracedecay-code-index/src/chunks.rs | 65 +++++ 3 files changed, 405 insertions(+), 3 deletions(-) diff --git a/crates/tracedecay-code-extraction/src/rust_extractor.rs b/crates/tracedecay-code-extraction/src/rust_extractor.rs index 17261a1a99..baf5aadbd5 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 @@ -1704,6 +1768,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" => { @@ -1715,14 +1790,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)), @@ -1759,7 +1834,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; } @@ -1812,10 +1887,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 7e74529f99..becfee81dc 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 wildcard_imports_retain_unresolved_dependencies_alongside_named_bindings() { let result = RustExtractor.extract( diff --git a/crates/tracedecay-code-index/src/chunks.rs b/crates/tracedecay-code-index/src/chunks.rs index 85f27ed90c..3b2896c71a 100644 --- a/crates/tracedecay-code-index/src/chunks.rs +++ b/crates/tracedecay-code-index/src/chunks.rs @@ -4452,6 +4452,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 rust_type_path_alias_parses_ufcs_trait_impl_methods() { assert_eq!( From 218ae5cbf3d1656125df0a6fd73784923d56678a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 06:52:19 +0000 Subject: [PATCH 02/10] fix(code-index): stamp continuations before the seat looks idle A seat waiter that sees reconcile_in_progress at zero treats the owner as finished. Noting BusyFollowUp after that drop sampled an empty slot and then raced the failure ceiling. The stamp now holds the pass guard for the note only. A store directory that does not exist yet is an unpublished plan, not Storage(NotFound). Planning against latest_generation_id before cold open created the scoped store was that error. The retention journey now re-reads the store on the serving-seat signal. Co-authored-by: Zack Jackson --- .../src/code_index_generations.rs | 38 +++++++ .../src/code_index_generations/tests.rs | 19 ++++ .../src/code_index_scheduler/registry.rs | 15 +++ .../code_index_scheduler/registry/mount.rs | 106 ++++++++++++++---- .../generation_retention_test.rs | 52 +++++++-- 5 files changed, 202 insertions(+), 28 deletions(-) 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 b4976c6602..2525b4948d 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,25 @@ 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, and a waiter that only + // saw `latest_generation_id` plans against a path canonicalize then + // reports as `Storage(NotFound)`. That is an unpublished store, the same + // typed state as a directory with no pointer, not a storage failure the + // failure ceiling then retries. + 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 2dbe91a0c7..f403f1b703 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 @@ -3072,3 +3072,22 @@ fn recovery_completes_a_committed_rewrite_that_never_reached_the_pointer() { plan_code_generation_retention(fixture.store.path(), &BTreeSet::new()) .expect("a recovered store must stay plannable"); } + +#[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/registry.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry.rs index a5392c83b1..8e4341a777 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 @@ -2109,6 +2109,21 @@ impl CodeIndexSchedulerRegistryV1 { } } + /// Stamp a continuation while `reconcile_in_progress` still reports this pass. + /// + /// A seat waiter that sees the pass counter at zero treats the owner as + /// idle. Noting `BusyFollowUp` after that drop is the race: the waiter + /// samples an empty slot, then loses to the stamp and burns the failure + /// ceiling. The guard lives only for the note. + fn note_visible_worker_continuation( + passes: &Arc, + pending_wake: &PendingWakeV1, + wake: &tokio::sync::Notify, + ) { + let _visible = super::ReconcilePassGuard::enter(passes); + Self::note_worker_continuation(pending_wake, wake); + } + /// Claim the pending wake as one reconcile's arrival, at the instant the /// scheduler dequeues it. /// 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..a38c3733ad 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 @@ -1168,7 +1168,20 @@ impl CodeIndexSchedulerRegistryV1 { // A successor-only retained projection holds no pass guard of // its own; keeping the worker's guard through graph seat would // report rebuild_in_flight for clone backfill that is not - // exact/lexical work. + // exact/lexical work. Stamp the continuation this projection + // already owes before that drop, so idle means the slot is set. + if let Some(outcome) = published_text_projection_outcome.as_ref() { + let schedule_continuation = match outcome { + PublishedTextProjectionOutcomeV1::Finished => graph_text + .as_ref() + .is_some_and(LatestCodeTextGenerationV1::text_projection_needs_work), + PublishedTextProjectionOutcomeV1::Unfinished => true, + PublishedTextProjectionOutcomeV1::Shutdown => false, + }; + if schedule_continuation { + Self::note_worker_continuation(&worker_pending_wake, &worker_wake); + } + } if retained_text_projection.is_none() || retained_projection_successor_only { drop(reconcile_pass.take()); } @@ -1400,8 +1413,13 @@ impl CodeIndexSchedulerRegistryV1 { // all and never published the successor generation. The // `retained_graph_head_recovery_attempted` guard above is // now false for every later pass, so this cannot spin - // another retained-recovery Noop. - Self::note_worker_continuation(&worker_pending_wake, &worker_wake); + // another retained-recovery Noop. The optional-graph drop + // may already have happened; re-enter the pass for the stamp. + Self::note_visible_worker_continuation( + &worker_reconcile_in_progress, + &worker_pending_wake, + &worker_wake, + ); } // A recovered revision-7 verified head already serves its // native graph from the retained text owner, and that owner @@ -1548,8 +1566,12 @@ impl CodeIndexSchedulerRegistryV1 { if roster_refusal_rebuild { // One pass, claimed from the scheduler, so // a refusal that keeps reproducing cannot - // spin this worker. - Self::note_worker_continuation( + // spin this worker. Stamp while a pass + // guard is held: the step guard above has + // already dropped, and an idle read must + // not sample the empty slot. + Self::note_visible_worker_continuation( + &worker_reconcile_in_progress, &worker_pending_wake, &worker_wake, ); @@ -1714,7 +1736,15 @@ impl CodeIndexSchedulerRegistryV1 { .as_ref() .is_some_and(LatestCodeTextGenerationV1::text_projection_needs_work) { - Self::note_worker_continuation(&worker_pending_wake, &worker_wake); + // Stamped before the optional-graph guard drop + // when this outcome was already known. Re-enter + // so a reader that cleared the slot during + // graph cannot sample the stamp as idle. + Self::note_visible_worker_continuation( + &worker_reconcile_in_progress, + &worker_pending_wake, + &worker_wake, + ); } // Large text projections can outlive the bounded // source proof established before publication. The @@ -1784,7 +1814,11 @@ impl CodeIndexSchedulerRegistryV1 { "the publication's text owner did not finish its projection; \ the sealed generation stays unseated until it does" ); - Self::note_worker_continuation(&worker_pending_wake, &worker_wake); + Self::note_visible_worker_continuation( + &worker_reconcile_in_progress, + &worker_pending_wake, + &worker_wake, + ); } } // Keep the pass lifetime around the post-projection source @@ -1965,7 +1999,11 @@ impl CodeIndexSchedulerRegistryV1 { if text_latest.text_projection_needs_work() && !text_latest.query_owners_are_ready() { - Self::note_worker_continuation(&worker_pending_wake, &worker_wake); + Self::note_visible_worker_continuation( + &worker_reconcile_in_progress, + &worker_pending_wake, + &worker_wake, + ); } } Ok(Err(error)) => { @@ -1994,7 +2032,27 @@ impl CodeIndexSchedulerRegistryV1 { } // The source proof and serving witness are now published as // one lifecycle. Optional receipts do not keep source - // verification in flight. + // verification in flight. Stamp a clone-backfill continuation + // before the drop: a seat waiter that sees the counter at zero + // must already observe the slot, or it races the failure ceiling. + if clone_backfill_waiting_for_source + && matches!( + &result, + Ok((Ok(CodeIndexReconcileOutcomeV1::Noop(_)), _, _)) + ) + && worker_text_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + && worker_source_freshness + .ready_without_stat(&worker_project_root, &worker_shutting_down) + { + Self::note_visible_worker_continuation( + &worker_reconcile_in_progress, + &worker_pending_wake, + &worker_wake, + ); + } drop(reconcile_pass.take()); if let Ok((Ok(outcome), _, _)) = &result { // A pass that ran to a terminal outcome proves neither the @@ -2046,12 +2104,8 @@ impl CodeIndexSchedulerRegistryV1 { ); } worker_serving_generation_changed.send_replace(()); - // The retained slice was checked before reconciliation - // renewed this proof. Preserve its wake now that source - // is current, without requiring another query arrival. - if clone_backfill_waiting_for_source { - Self::note_worker_continuation(&worker_pending_wake, &worker_wake); - } + // The clone-backfill continuation was stamped before + // this pass dropped `reconcile_in_progress`. } } else { // Surface bounded non-terminal failure without new project-path data. @@ -2269,7 +2323,6 @@ impl CodeIndexSchedulerRegistryV1 { PublishedTextProjectionOutcomeV1::Unfinished } }; - drop(reconcile_pass.take()); match outcome { PublishedTextProjectionOutcomeV1::Finished if !retained_head_recovered_without_complete_replay @@ -2281,7 +2334,11 @@ impl CodeIndexSchedulerRegistryV1 { // full replay can proceed without overlapping it. // A clone-fingerprint backfill changed no owner // the seat reads, so it owes no such pass. - Self::note_worker_continuation(&worker_pending_wake, &worker_wake); + Self::note_visible_worker_continuation( + &worker_reconcile_in_progress, + &worker_pending_wake, + &worker_wake, + ); } PublishedTextProjectionOutcomeV1::Finished => { let installed_owner_still_needs_work = worker_text_generation @@ -2292,7 +2349,11 @@ impl CodeIndexSchedulerRegistryV1 { LatestCodeTextGenerationV1::text_projection_needs_work, ); if installed_owner_still_needs_work { - Self::note_worker_continuation(&worker_pending_wake, &worker_wake); + Self::note_visible_worker_continuation( + &worker_reconcile_in_progress, + &worker_pending_wake, + &worker_wake, + ); } } PublishedTextProjectionOutcomeV1::Shutdown => { @@ -2313,9 +2374,16 @@ impl CodeIndexSchedulerRegistryV1 { // stopped short would sleep until an unrelated // arrival, exactly as the inline slice's own // follow-up notify prevented. - Self::note_worker_continuation(&worker_pending_wake, &worker_wake); + Self::note_visible_worker_continuation( + &worker_reconcile_in_progress, + &worker_pending_wake, + &worker_wake, + ); } } + // The continuation is already in the slot. This drop is the + // first moment the pass looks idle. + drop(reconcile_pass.take()); } if worker_shutting_down.load(Ordering::Acquire) { tracing::info!( 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 b0d224dfec..4e7da22927 100644 --- a/crates/tracedecay/src/daemon/production_harness/generation_retention_test.rs +++ b/crates/tracedecay/src/daemon/production_harness/generation_retention_test.rs @@ -11,7 +11,8 @@ use super::journey_test_support::git; use super::*; use crate::daemon::maintenance::project_store_maintenance_lease; use tracedecay_code_index_retention::code_index_generations::{ - MAX_CODE_GENERATION_RETENTION_BATCH_V1, prepare_next_code_generation_retention_cancellable, + CodeGenerationRetentionErrorV1, MAX_CODE_GENERATION_RETENTION_BATCH_V1, + prepare_next_code_generation_retention_cancellable, }; use tracedecay_maintenance::tick::{MaintenanceContinuation, MaintenanceTickOutcome}; @@ -97,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 @@ -114,13 +121,40 @@ 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"); - let plan = prepare_next_code_generation_retention_cancellable( - &code_store_root, - &BTreeSet::new(), - &|| false, - Some(&graph_replay_pool_root), - ) - .expect("code generation retention plan"); + // Text seating moves `latest_generation_id` before the scoped store + // exists and before the superseded sealed file is collectable. Planning + // once at that instant is the race: canonicalize returns NotFound, and + // a wall-clock retry of the same snapshot hits the failure ceiling. + // Wake on the serving seat and re-read the store. + 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, + &BTreeSet::new(), + &|| false, + Some(&graph_replay_pool_root), + ) { + Ok(plan) + if plan + .collectable_generations + .iter() + .any(|generation| generation.generation_id == first_source) => + { + return plan; + } + Ok(_) => {} + Err(CodeGenerationRetentionErrorV1::GenerationStoreBusy) => {} + Err(error) => panic!("code generation retention plan: {error}"), + } + serving_seats + .changed() + .await + .expect("the seating channel stays open while the registry lives"); + } + }) + .await + .expect("superseded source became collectable after the serving seat moved"); let first_candidate = plan .collectable_generations .iter() From 543b6e253805e5c142e30675e0ddf4d12af2e536 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 19 Sep 2026 08:22:00 +0000 Subject: [PATCH 03/10] style(session-temporal): keep the test module last and rustfmt master Master run 35431539771 failed Check formatting (projector.rs, query.rs) and Clippy (items_after_test_module in query.rs) after #1844/#1845 merged without CI. Co-Authored-By: Claude Fable 5.1 --- .../projector.rs | 8 ++--- .../src/query.rs | 29 +++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/projector.rs b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/projector.rs index 10818733c1..632898ca24 100644 --- a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/projector.rs +++ b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/projector.rs @@ -119,11 +119,11 @@ impl SessionTemporalRefreshProjector for CanonicalSessionTemporalProjector { // Empty remaining range is a durable no-op: terminalize with an // empty complete progress batch instead of deferring forever. Ok(None) => canonical_noop_complete_effect(&recovery), - Err(error) if error.is_storage() => Err( - SessionTemporalRefreshProjectorError::retryable(format!( + Err(error) if error.is_storage() => { + Err(SessionTemporalRefreshProjectorError::retryable(format!( "source_busy: {error}" - )), - ), + ))) + } Err(_) => Err(SessionTemporalRefreshProjectorError::terminal( "projector_failed", )), diff --git a/crates/tracedecay-session-temporal-store/src/query.rs b/crates/tracedecay-session-temporal-store/src/query.rs index 367b14bb79..ae0ab6b4de 100644 --- a/crates/tracedecay-session-temporal-store/src/query.rs +++ b/crates/tracedecay-session-temporal-store/src/query.rs @@ -246,8 +246,7 @@ pub(super) async fn read_observations( // ceiling. Split until a single observation remains; that // observation is then a typed storage failure, not a retry // that looks like a busy source. - if observation_prefetch_exceeded_materialization_limit(&error) && chunk.len() > 1 - { + if observation_prefetch_exceeded_materialization_limit(&error) && chunk.len() > 1 { let mid = start + chunk.len() / 2; pending.push((mid, end)); pending.push((start, mid)); @@ -289,11 +288,18 @@ fn observation_prefetch_exceeded_materialization_limit(error: &SessionStoreError } } +/// The error `read_observation` raises for an id the store does not hold, reused +/// by callers that resolve prefetched observations out of a batch map. +pub(super) fn missing_observation(observation_id: &CanonicalObservationIdV1) -> SessionStoreError { + storage_message( + PERSIST_OPERATION, + format!("source observation {} is missing", observation_id.as_str()), + ) +} + #[cfg(test)] mod tests { - use super::{ - PERSIST_OPERATION, observation_prefetch_exceeded_materialization_limit, storage, - }; + use super::{PERSIST_OPERATION, observation_prefetch_exceeded_materialization_limit, storage}; #[test] fn materialization_limit_is_the_prefetch_split_signal() { @@ -308,15 +314,8 @@ mod tests { assert!(observation_prefetch_exceeded_materialization_limit( &exceeded )); - assert!(!observation_prefetch_exceeded_materialization_limit(&locked)); + assert!(!observation_prefetch_exceeded_materialization_limit( + &locked + )); } } - -/// The error `read_observation` raises for an id the store does not hold, reused -/// by callers that resolve prefetched observations out of a batch map. -pub(super) fn missing_observation(observation_id: &CanonicalObservationIdV1) -> SessionStoreError { - storage_message( - PERSIST_OPERATION, - format!("source observation {} is missing", observation_id.as_str()), - ) -} From 250e318e8ebfc8272366a50586bb8bf2d0c4bd41 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 19 Sep 2026 09:15:34 +0000 Subject: [PATCH 04/10] chore(code-index): move Rust extraction to extractor.rust.v12 A receiver typed by a type parameter with one trait bound now names `Trait::method`, so the same bytes produce different reference rows than extractor.rust.v11 does. `generation_language_revisions_match` compares a sealed generation's extractor revisions against the registry, so without a bump every generation already sealed at v11 keeps the missing trait callee this change exists to add until some unrelated edit forces re-extraction. Re-pin the fixtures that carry the revision string, exactly as 1ebadc81f9 did for v11: - `canonical_rows_digest_matches_pinned_identity`: the revision is part of the batch identity, so the pinned rows digest moves to sha256:4e483806df. - `partitioned_codec_has_stable_bytes_and_round_trips`: the revision sits in every sealed file segment. v11 and v12 are the same length, so all four segment sizes are unchanged (11_071, 5_171, 6_279, 6_837) and only the digests move: an identity change, not container drift. - The two worker tests and the reconcile test that assert the current revision after a forced re-extraction. Co-Authored-By: Claude Fable 5.1 --- .../src/code_index_scheduler/tests/reconcile.rs | 2 +- crates/tracedecay-code-index/src/extract.rs | 10 ++++++---- crates/tracedecay-code-index/src/languages.rs | 8 +++++--- .../src/production/worker_tests.rs | 4 ++-- .../tests/code_index_suite/production_orchestration.rs | 10 +++++----- 5 files changed, 19 insertions(+), 15 deletions(-) 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 44e4d9f5b1..5f19b178a6 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/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, ), ]; From e3b140e26e47fe5cf25ebb3ad8af72fbec7328de Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 19 Sep 2026 08:22:00 +0000 Subject: [PATCH 05/10] style(session-temporal): keep the test module last and rustfmt master Master run 35431539771 failed Check formatting (projector.rs, query.rs) and Clippy (items_after_test_module in query.rs) after #1844/#1845 merged without CI. Co-Authored-By: Claude Fable 5.1 --- .../projector.rs | 8 ++--- .../src/query.rs | 29 +++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/projector.rs b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/projector.rs index 10818733c1..632898ca24 100644 --- a/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/projector.rs +++ b/crates/tracedecay-session-runtime/src/session_temporal_refresh_scheduler/projector.rs @@ -119,11 +119,11 @@ impl SessionTemporalRefreshProjector for CanonicalSessionTemporalProjector { // Empty remaining range is a durable no-op: terminalize with an // empty complete progress batch instead of deferring forever. Ok(None) => canonical_noop_complete_effect(&recovery), - Err(error) if error.is_storage() => Err( - SessionTemporalRefreshProjectorError::retryable(format!( + Err(error) if error.is_storage() => { + Err(SessionTemporalRefreshProjectorError::retryable(format!( "source_busy: {error}" - )), - ), + ))) + } Err(_) => Err(SessionTemporalRefreshProjectorError::terminal( "projector_failed", )), diff --git a/crates/tracedecay-session-temporal-store/src/query.rs b/crates/tracedecay-session-temporal-store/src/query.rs index 367b14bb79..ae0ab6b4de 100644 --- a/crates/tracedecay-session-temporal-store/src/query.rs +++ b/crates/tracedecay-session-temporal-store/src/query.rs @@ -246,8 +246,7 @@ pub(super) async fn read_observations( // ceiling. Split until a single observation remains; that // observation is then a typed storage failure, not a retry // that looks like a busy source. - if observation_prefetch_exceeded_materialization_limit(&error) && chunk.len() > 1 - { + if observation_prefetch_exceeded_materialization_limit(&error) && chunk.len() > 1 { let mid = start + chunk.len() / 2; pending.push((mid, end)); pending.push((start, mid)); @@ -289,11 +288,18 @@ fn observation_prefetch_exceeded_materialization_limit(error: &SessionStoreError } } +/// The error `read_observation` raises for an id the store does not hold, reused +/// by callers that resolve prefetched observations out of a batch map. +pub(super) fn missing_observation(observation_id: &CanonicalObservationIdV1) -> SessionStoreError { + storage_message( + PERSIST_OPERATION, + format!("source observation {} is missing", observation_id.as_str()), + ) +} + #[cfg(test)] mod tests { - use super::{ - PERSIST_OPERATION, observation_prefetch_exceeded_materialization_limit, storage, - }; + use super::{PERSIST_OPERATION, observation_prefetch_exceeded_materialization_limit, storage}; #[test] fn materialization_limit_is_the_prefetch_split_signal() { @@ -308,15 +314,8 @@ mod tests { assert!(observation_prefetch_exceeded_materialization_limit( &exceeded )); - assert!(!observation_prefetch_exceeded_materialization_limit(&locked)); + assert!(!observation_prefetch_exceeded_materialization_limit( + &locked + )); } } - -/// The error `read_observation` raises for an id the store does not hold, reused -/// by callers that resolve prefetched observations out of a batch map. -pub(super) fn missing_observation(observation_id: &CanonicalObservationIdV1) -> SessionStoreError { - storage_message( - PERSIST_OPERATION, - format!("source observation {} is missing", observation_id.as_str()), - ) -} From fea4c18a394361f4cfd88f428fcad05085d54e6b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 19 Sep 2026 10:43:06 +0000 Subject: [PATCH 06/10] style: drop the commit-message prose the batch-c fixes inlined Two comments added by this branch's own integration commits restate their commit bodies in the source. codex.rs: the revert of #1812 (7d678d8fba) pasted eleven lines of the rollback/StalePreview post-mortem above the deferral it restores. The revert's message already holds it, and the branch's own unit guard `prepare_defers_when_no_plugin_cli_resolves` is what keeps the deferral from regressing. The function is now byte-identical to pre-#1812 master. core_client.rs: the doc on `is_project_open_retryable_error` (e6f94cdb90) ended by naming the reset-recovery journey and the error string it emitted. That couples the comment to a test name nothing enforces. The sentence before it already states why the open subset alone is wrong, and the unit test in the same file pins the revoked retry. No behavior change. Co-Authored-By: Claude Opus 5 (1M context) --- crates/tracedecay-agent-hosts/src/agents/codex.rs | 11 ----------- crates/tracedecay/src/daemon/core_client.rs | 3 +-- 2 files changed, 1 insertion(+), 13 deletions(-) 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/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) } From fa8eeee8fe136dc6bb015c285e83fb9ec4648d1b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 19 Sep 2026 14:06:06 +0000 Subject: [PATCH 07/10] test(code-index): assert the faulted pointer without following symlinks `Path::is_dir` follows symlinks, so a pointer slot replaced by a symlink to any directory would satisfy it and hide exactly the defect this test guards: publication overwriting a slot it never observed. `read_optional_active_pointer` classifies the slot with `std::fs::symlink_metadata`, so the test now reads it the same way the production store does, and fails outright when the node is gone. Co-Authored-By: Claude Fable 5.1 --- .../code_index_ignored_dependencies_test/flight_tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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" ); From a2021dddc61fbbe11443dcccae9001ee6610b3ce Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 19 Sep 2026 14:06:56 +0000 Subject: [PATCH 08/10] refactor(contracts): build the runtime-mounting refusal in one place `runtime_mounting_problem` and `project_open_refusal_response` each spelled out the same `RUNTIME_MOUNTING_REASON_CODE` diagnostic and message. That is the one code the one-shot client re-sends on, so two hand-written copies could drift apart and silently split the mounting window from the answer a client is allowed to retry. Both now call `ApplicationProblem::runtime_mounting()`. The tracedecay-mcp test that constructs the same wire value by hand stays as it is: it is the input fixture its own assertion is checked against. Co-Authored-By: Claude Fable 5.1 --- crates/tracedecay-contracts/src/result/problem.rs | 10 ++++++++++ .../tracedecay-daemon-service/src/invocation/work.rs | 8 +------- crates/tracedecay/src/daemon/invocation_dispatch.rs | 7 +------ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/tracedecay-contracts/src/result/problem.rs b/crates/tracedecay-contracts/src/result/problem.rs index 013d638b55..7c15488458 100644 --- a/crates/tracedecay-contracts/src/result/problem.rs +++ b/crates/tracedecay-contracts/src/result/problem.rs @@ -759,6 +759,16 @@ impl ApplicationProblem { } } + /// The refusal for an admitted project whose runtime has not finished + /// mounting. Every producer of that window builds it here, so one place + /// decides which code the one-shot client re-sends on. + 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/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( From 70a1f038a83428d2a5a6ece2db25b7b4c3a1495e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 19 Sep 2026 14:37:23 +0000 Subject: [PATCH 09/10] style: cut the comment sermons this batch wrote `runtime_mounting`'s second sentence argued for the refactor that created it rather than stating a contract, and the retention journey's comment spent nine lines narrating a race the loop below it already shows. Both keep only the part a reader cannot recover from the code: what the constructor answers, and why the first plan the planner returns is not the one the test wants. The comment audit also proposed a `CanonicalProjectRoot` newtype, a retyped `prepare_next_code_generation_retention_cancellable` store root, and relocating the extractor revision onto `RustExtractor`. All three are design changes to PRs that were reviewed as they stand, so they are left for their own change rather than folded in here. Co-Authored-By: Claude Fable 5.1 --- crates/tracedecay-contracts/src/result/problem.rs | 3 +-- .../production_harness/generation_retention_test.rs | 13 ++++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/crates/tracedecay-contracts/src/result/problem.rs b/crates/tracedecay-contracts/src/result/problem.rs index 7c15488458..a44e21648e 100644 --- a/crates/tracedecay-contracts/src/result/problem.rs +++ b/crates/tracedecay-contracts/src/result/problem.rs @@ -760,8 +760,7 @@ impl ApplicationProblem { } /// The refusal for an admitted project whose runtime has not finished - /// mounting. Every producer of that window builds it here, so one place - /// decides which code the one-shot client re-sends on. + /// mounting. pub fn runtime_mounting() -> Self { Self::unavailable(SafeDiagnostic { code: RUNTIME_MOUNTING_REASON_CODE.to_owned(), 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 a58fd7f5cd..d2b47bd5f7 100644 --- a/crates/tracedecay/src/daemon/production_harness/generation_retention_test.rs +++ b/crates/tracedecay/src/daemon/production_harness/generation_retention_test.rs @@ -121,15 +121,10 @@ 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"); - // Text seating moves `latest_generation_id` before the scoped store - // exists and before the superseded sealed file is collectable. Planning - // once at that instant is the race: canonicalize returns NotFound, and - // a wall-clock retry of the same snapshot hits the failure ceiling. - // Wake on the serving seat and re-read the store. The planner also probes - // the generation-store lock and the graph replay pool, answering - // `GenerationStoreBusy` or `GraphReplayPoolBusy` whenever a writer owns - // one; neither lock state publishes a seat, so the wait keeps the short - // maintenance-style tick as its floor. + // 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 { From 735bfbde8f090a5064ba928d0825373b82f7f162 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 19 Sep 2026 17:18:00 +0000 Subject: [PATCH 10/10] fix(retention): one contract for a missing scope root Two landed changes disagreed about a store root that does not exist yet. #1811 deferred with `GenerationStoreBusy`, #1836 returned the unpublished plan. Both tests survived the batch-D merge, so `preparation_defers_when_the_scope_root_does_not_exist_yet` failed deterministically against #1836's early return. Keep the unpublished plan. The only production caller is `tracedecay-maintenance::store_maintenance::run_code_generation_retention`. It routes `GenerationStoreBusy` through `defer_generation_store_busy`, which logs `retention_degraded failure=generation_store_busy` and returns `Failed`. `generation.rs` turns `Failed` into `MaintenanceTickOutcome` `Retry`, which `tick.rs` schedules on the short `retry_delay` and counts in `daemon.maintenance.generation.retry_total`. That deferral is a counted, logged failure, not a quiet wait. An empty plan returns `Complete`, which `tick.rs` schedules on the ordinary `interval`. Neither answer is terminal, so no caller plans once and stops. The maintenance loop re-plans every tick, and the mounted retention journey loops on `Ok(_)` and `GenerationStoreBusy` alike. The difference is duration. An absent scope root has no publisher to wait for, because cold open creates it inside the worker, so a mounted project that is never indexed keeps an absent root for the life of the daemon and the deferral would report degraded on every tick. The unpublished plan still converges the moment the store appears. The failure ceiling #1836 names is `SERVING_SEAT_FAILURE_CEILING`, a test-only wall bound on a positive serving-seat wait in `code_index_scheduler/tests/mod.rs`. It consumes no retention error, so it constrains neither contract. #1811's deeper `NotFound` mapping stays. `deferred_if_absent` still covers the two enumerate-then-open sites, where `read_dir` already proved the name existed and a peer can unlink it before the open. That race is unreachable from the root check, and d6d8665a80 already reverted the two durable-state sites on the same reasoning. Co-Authored-By: Claude Fable 5.1 --- .../src/code_index_generations.rs | 15 ++++++++++----- .../src/code_index_generations/tests.rs | 17 ----------------- 2 files changed, 10 insertions(+), 22 deletions(-) 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 c30c42782d..e6009275fb 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations.rs @@ -814,12 +814,17 @@ 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, and a waiter that only - // saw `latest_generation_id` plans against a path canonicalize then + // 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, not a storage failure the - // failure ceiling then retries. + // 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 => { 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 d74da1bc10..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");