diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/serving.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/serving.rs index 394cc15419..bf72387e38 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/serving.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/serving.rs @@ -3273,7 +3273,7 @@ impl LatestCodeTextGenerationV1 { )); }; drop(slot); - let mut publish_claim = TextHeadOpenClaimV1::new(&self.text_projection_build); + let _publish_claim = TextHeadOpenClaimV1::new(&self.text_projection_build); let CodeTextArtifactBuildV1 { builder, source, @@ -3312,7 +3312,6 @@ impl LatestCodeTextGenerationV1 { ) .map_err(map_text_artifact_error)?; let needs_clone_successor = !reader.has_clone_fingerprints(); - let prior = reader.verified_artifact().clone(); // Match the cold-open path: install owners first, then publish Ready. // Publishing Ready before a failed install (admission ceiling / shrink) // would leave dashboard/MCP progress claiming a ready generation that @@ -3320,10 +3319,16 @@ impl LatestCodeTextGenerationV1 { self.install_artifact_owners(reader, reader_reservation)?; self.publish_text_progress_phase(CodeIndexBuildPhaseV1::Ready, 0, 0); if needs_clone_successor { - let source = store.open_sealed_source(&sealed_identity, control)?; - let build = - self.begin_clone_successor(descriptor, prior, sealed_identity, source, control)?; - drop(publish_claim.install(TextHeadOpenBuildV1::CloneSuccessor(build))); + // `begin_clone_successor` copies the whole prior lexical artifact + // before the first page walk. Doing that here kept this advance, + // and the publication pass awaiting it, inside `reconcile_in_progress` + // for the copy. Exact and lexical serving are already installed; + // the copy is not a freshness precondition. Leave the slot pending + // so the retained driver starts the successor after the seat, + // without the receipt guard. The claim stays armed: its drop + // restores only `HeadOpening`, so `CloneSuccessorPending` survives + // and parked wakes are notified. + self.text_projection_build.retain_clone_successor_retry()?; return Ok(false); } Ok(true) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/serving.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/serving.rs index 306fa115a6..03ebce7c9e 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/serving.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/serving.rs @@ -853,6 +853,95 @@ fn clone_successor_keeps_lexical_owners_ready_and_cas_replaces_v14() { assert_eq!(v16_revision, 16); } +/// Exact and lexical readiness is not the clone-successor copy. +/// +/// The publication advance that installs those owners used to call +/// `begin_clone_successor` before returning, and that call copies the whole +/// prior lexical artifact. The freshness receipt awaits that advance, so +/// status stayed non-current for the copy. The successor must still be +/// reported as backfill, and the next advance is what writes its staging file. +#[test] +fn lexical_readiness_leaves_the_clone_successor_uncopied() { + let fixture = GitFixture::new(&[( + "src/lib.rs", + "pub fn alpha() { one(); two(); three(); four(); five(); six(); seven(); eight(); nine(); ten(); }\n", + )]); + let store = TempDir::new().expect("store root"); + let mut scheduler = scheduler( + &fixture, + store.path().to_path_buf(), + Arc::new(SharedCodeIndexBytePoolV1::default()), + ); + published(scheduler.reconcile_now().expect("publish generation")); + let latest = scheduler.latest_complete().expect("latest generation"); + while !latest.query_owners_are_ready() { + latest.advance_text_serving(1).expect("advance V14 build"); + } + let tracedecay_contracts::code_index_freshness::CodeCloneIndexStatusV1::Backfilling { + observation, + } = latest.clone_index_status(false, None) + else { + panic!( + "a generation without clone fingerprints must report backfill once lexical owners serve, got {:?}", + latest.clone_index_status(false, None) + ); + }; + assert_eq!(observation.coverage.completed_source_pages, 0); + assert!( + observation.coverage.total_source_pages > 0, + "the pending successor must name the sealed page count it has not visited" + ); + // Status falls back to the published artifact's bytes when the successor + // has not created a staging file, so the bytes field cannot prove the + // copy stayed off this advance. The slot and the artifacts directory can. + assert!( + matches!( + &*latest.text_projection_build.lock_slot(), + super::super::CodeTextProjectionSlotV1::CloneSuccessorPending + ), + "owner readiness must leave the successor pending" + ); + let staging_names = |root: &std::path::Path| { + std::fs::read_dir(code_text_artifacts_root(root)) + .expect("artifacts root") + .map(|entry| entry.expect("artifact entry").file_name()) + .filter(|name| name.to_string_lossy().ends_with(".staging")) + .collect::>() + }; + assert!( + staging_names(store.path()).is_empty(), + "owner readiness copied the prior lexical artifact: {:?}", + staging_names(store.path()) + ); + + latest + .advance_text_serving(1) + .expect("the retained successor advance copies the prior artifact"); + assert!(latest.query_owners_are_ready()); + assert!( + !matches!( + &*latest.text_projection_build.lock_slot(), + super::super::CodeTextProjectionSlotV1::CloneSuccessorPending + ), + "the next advance must take the pending successor" + ); + + while latest.text_projection_needs_work() { + latest + .advance_text_serving(16) + .expect("finish clone successor"); + } + let revision: i64 = rusqlite::Connection::open(active_text_artifact_path(store.path())) + .expect("open finished artifact") + .query_row( + "SELECT format_revision FROM artifact_state WHERE singleton = 1", + [], + |row| row.get(0), + ) + .expect("read finished revision"); + assert_eq!(revision, 16); +} + #[test] fn clone_status_distinguishes_unavailable_backfill_partial_ready_and_stale() { let fixture = GitFixture::new(&[(