diff --git a/crates/tracedecay-application/src/primitives/production/extended_primitive.rs b/crates/tracedecay-application/src/primitives/production/extended_primitive.rs index 7e0fa487ca..eecd417958 100644 --- a/crates/tracedecay-application/src/primitives/production/extended_primitive.rs +++ b/crates/tracedecay-application/src/primitives/production/extended_primitive.rs @@ -758,20 +758,6 @@ impl ExtendedPrimitivePort for TraceDecayExtendedPrimitivePortV1 { if !matches!(current.coverage, DiagnosticQueryCoverage::Complete) { return diagnostics_unavailable(finished_at, OmissionReason::Unavailable); } - let Some(identity) = self - .diagnostic_identity - .resolve(self.source_runtime.project_root().to_path_buf()) - .await - else { - return diagnostics_unavailable(finished_at, OmissionReason::Unavailable); - }; - let scope = context.request.scope(); - if identity.repository() != &scope.repository_id - || identity.worktree() != Some(&scope.worktree_id) - || identity.reference() != scope.reference.as_ref() - { - return diagnostics_unavailable(finished_at, OmissionReason::Stale); - } let document_path = match &request.scope { super::super::runtime::DiagnosticsPrimitiveScope::Workspace => None, super::super::runtime::DiagnosticsPrimitiveScope::File(path) => { @@ -784,12 +770,6 @@ impl ExtendedPrimitivePort for TraceDecayExtendedPrimitivePortV1 { OmissionReason::Unavailable, ); }; - if identity.file(&path).is_none() { - return diagnostics_unavailable( - finished_at, - OmissionReason::Unavailable, - ); - } Some(path) } super::super::runtime::DiagnosticsPrimitiveScope::Package(_) => { @@ -809,9 +789,32 @@ impl ExtendedPrimitivePort for TraceDecayExtendedPrimitivePortV1 { return diagnostics_unavailable(finished_at, OmissionReason::Unavailable); } }; - if current_index.code_generation_id != *identity.generation_id() { + if current_index.code_generation_id != current_generation { return diagnostics_unavailable(finished_at, OmissionReason::Stale); } + // The code index retains the published generation; an identity + // it cannot resolve is one whose source proof moved and is + // being renewed, so the publication is stale, not absent. + let Some(identity) = self + .diagnostic_identity + .resolve(self.source_runtime.project_root().to_path_buf()) + .await + else { + return diagnostics_unavailable(finished_at, OmissionReason::Stale); + }; + let scope = context.request.scope(); + if identity.repository() != &scope.repository_id + || identity.worktree() != Some(&scope.worktree_id) + || identity.reference() != scope.reference.as_ref() + { + return diagnostics_unavailable(finished_at, OmissionReason::Stale); + } + if document_path + .as_deref() + .is_some_and(|path| identity.file(path).is_none()) + { + return diagnostics_unavailable(finished_at, OmissionReason::Unavailable); + } if current_generation != *identity.generation_id() { return diagnostics_unavailable(finished_at, OmissionReason::Stale); } diff --git a/crates/tracedecay-application/src/primitives/symbol_graph.rs b/crates/tracedecay-application/src/primitives/symbol_graph.rs index f18ebdba37..23f0206a8b 100644 --- a/crates/tracedecay-application/src/primitives/symbol_graph.rs +++ b/crates/tracedecay-application/src/primitives/symbol_graph.rs @@ -1324,6 +1324,16 @@ async fn complete_or_failed( page.related_edge_count = related_edge_count; page.support_gaps = gaps; let receipt = graph.cost.receipt(); + let Some(finished_at) = i64::try_from(receipt.wall_micros) + .ok() + .and_then(|wall| context.observed_at.0.checked_add(wall)) + .map(UtcMicros) + else { + return failed( + context, + "graph read end time is outside the timestamp domain", + ); + }; let budget = OperationBudgetUsage { elapsed_micros: receipt.wall_micros, ..OperationBudgetUsage::default() @@ -1332,14 +1342,14 @@ async fn complete_or_failed( if page.support_gaps.is_empty() { SymbolGraphPortOutcome::Completed { page, - finished_at: context.observed_at, + finished_at, budget, cost, } } else { SymbolGraphPortOutcome::Partial { page, - finished_at: context.observed_at, + finished_at, budget, cost, } 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 73b3ce68da..3acceb6a2f 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 @@ -3691,7 +3691,10 @@ impl CodeIndexSchedulerRegistryV1 { ) -> Option { let root = canonical_existing_identity(&project_root).ok()?; - let root_generation = self.latest_text_serving_for_root(&root).await?; + // Identity, not serving: this reads only the retained generation's + // manifest and snapshot. The TypeScript producer publishes at the + // seal, so a warming lexical artifact must not hide that publication. + let root_generation = self.retained_text_owner_for_root(&root).await?; let scope = match scope { Some(scope) => scope, None => { @@ -3706,7 +3709,7 @@ impl CodeIndexSchedulerRegistryV1 { .ok()? } }; - let (current, fresh) = self.latest_text_serving_freshness_for_scope(&scope).await?; + let (current, fresh) = self.retained_text_owner_freshness_for_scope(&scope).await?; if !fresh || root_generation.metadata().manifest().generation_id != current.metadata().manifest().generation_id diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/ignored_dependencies.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/ignored_dependencies.rs index 2fd91aebad..f84b4936b7 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/ignored_dependencies.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/ignored_dependencies.rs @@ -406,6 +406,7 @@ impl CodeIndexSchedulerRegistryV1 { repository_id, worktree_id, serving_generation, + graph_activation_enabled, flights, hints, wake, @@ -428,6 +429,7 @@ impl CodeIndexSchedulerRegistryV1 { worktree.repository_id.clone(), worktree.worktree_id.clone(), Arc::clone(&worktree.serving_generation), + worktree.graph_activation.policy().is_enabled(), Arc::clone(&worktree.ignored_dependency_admissions), Arc::clone(&worktree.hints), Arc::clone(&worktree.wake), @@ -435,6 +437,18 @@ impl CodeIndexSchedulerRegistryV1 { Arc::clone(&worktree.pending_wake), ) }; + // A ready generation may serve from its text owner with no decoded + // seat; the whole decode runs only on demand. Admission builds on the + // decoded generation, so a refusal for an empty seat demands it and + // the caller's retry finds it seated. + if graph_activation_enabled + && serving_generation + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none() + { + self.request_complete_generation(&project_root).await; + } let (flight, owns_flight) = { let mut active = flights .lock() diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/owner_signals.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/owner_signals.rs index 47b72c7f6d..7730563c1f 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/owner_signals.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/owner_signals.rs @@ -266,14 +266,14 @@ impl CodeIndexSchedulerRegistryV1 { ) -> Result { let deadline = tokio::time::Instant::now() + budget; if target != CodeIndexReadinessTargetV1::Fresh - && self + && let Some(reading) = self .dashboard_freshness_read(project_root) .await? - .is_some_and(|freshness| { - freshness.readiness(target) == CodeIndexReadinessV1::Reached - }) + .filter(|freshness| freshness.readiness(target) == CodeIndexReadinessV1::Reached) { - return Ok(CodeIndexReadinessWaitReadV1::Reached); + return Ok(CodeIndexReadinessWaitReadV1::Reached { + reading: Box::new(reading), + }); } let mut signals = CodeIndexOwnerSignalsV1::subscribe(self, project_root).await; // The caller's budget bounds the sweep, and an unproven source cannot @@ -307,7 +307,9 @@ impl CodeIndexSchedulerRegistryV1 { if let Some(freshness) = last.as_ref() { match freshness.readiness(target) { CodeIndexReadinessV1::Reached => { - return Ok(CodeIndexReadinessWaitReadV1::Reached); + return Ok(CodeIndexReadinessWaitReadV1::Reached { + reading: Box::new(freshness.clone()), + }); } CodeIndexReadinessV1::Unreachable { reason } => { return Ok(CodeIndexReadinessWaitReadV1::Unreachable { reason }); 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 affbbefc07..369a6c7616 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 @@ -3036,7 +3036,8 @@ async fn sealed_publication_identity_answers_before_the_generation_seats() { .expect("publication projection gate stays armed"); assert!( - CodeIndexPublicationIdentityPortV1::resolve(®istry, fixture.path().to_path_buf()) + registry + .latest_text_serving_for_root(fixture.path()) .await .is_none(), "no generation serves while its text projection is held" @@ -3061,16 +3062,26 @@ async fn sealed_publication_identity_answers_before_the_generation_seats() { .is_none(), "a caller that handled the sealed generation learns of no newer seal" ); + // A diagnostics read validates the seal-time publication against this + // identity, so it must name the sealed generation before text serves. + let read_identity = + CodeIndexPublicationIdentityPortV1::resolve(®istry, fixture.path().to_path_buf()) + .await + .expect("the retained generation answers identity while its text projection is held"); + assert_eq!(read_identity.generation_id(), sealed.generation_id()); release_projection .send(()) .expect("release publication projection"); let serving = wait_until_serving_seat(®istry, fixture.path(), Duration::from_secs(10), || { - CodeIndexPublicationIdentityPortV1::resolve(®istry, fixture.path().to_path_buf()) + registry.latest_text_serving_for_root(fixture.path()) }) .await; - assert_eq!(serving.generation_id(), sealed.generation_id()); + assert_eq!( + &serving.metadata().manifest().generation_id, + sealed.generation_id() + ); registry.shutdown().await; } @@ -6709,7 +6720,7 @@ async fn readiness_wait_reaches_ready_exactly_when_the_held_graph_publishes() { assert!( matches!( reached, - tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached + tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached { .. } ), "{reached:?}" ); @@ -6736,7 +6747,7 @@ async fn readiness_wait_reaches_ready_exactly_when_the_held_graph_publishes() { assert!( matches!( graph_ready, - tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached + tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached { .. } ), "{graph_ready:?}" ); @@ -6799,7 +6810,7 @@ async fn fresh_wait_on_a_current_index_reaches_inside_one_second() { assert!( matches!( outcome, - tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached + tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached { .. } ), "{outcome:?}" ); @@ -6831,7 +6842,7 @@ async fn fresh_wait_catches_an_unreported_save_and_returns_after_its_reindex() { assert!( matches!( outcome, - tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached + tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached { .. } ), "{outcome:?}" ); @@ -6878,7 +6889,7 @@ async fn fresh_wait_verifies_the_source_while_a_pass_holds_the_scheduler() { assert!( matches!( quiet, - tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached + tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached { .. } ), "{quiet:?}" ); @@ -6903,7 +6914,7 @@ async fn fresh_wait_verifies_the_source_while_a_pass_holds_the_scheduler() { assert!( matches!( reindexed, - tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached + tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached { .. } ), "{reindexed:?}" ); diff --git a/crates/tracedecay-contracts/src/code_index_freshness.rs b/crates/tracedecay-contracts/src/code_index_freshness.rs index 26f57dd85a..6d8db25ab3 100644 --- a/crates/tracedecay-contracts/src/code_index_freshness.rs +++ b/crates/tracedecay-contracts/src/code_index_freshness.rs @@ -708,7 +708,11 @@ impl CodeIndexWorktreeFreshnessV1 { /// What a readiness wait observed when it ended. #[derive(Clone, Debug)] pub enum CodeIndexReadinessWaitReadV1 { - Reached, + /// `reading` is the reading that satisfied the target; a response built + /// on the wait describes this reading, not a later one. + Reached { + reading: Box, + }, /// The budget elapsed; `last` is the last reading, `None` while the root /// was never mounted. TimedOut { diff --git a/crates/tracedecay-mcp/src/handlers/info/status.rs b/crates/tracedecay-mcp/src/handlers/info/status.rs index 5b0236353a..dbd3f0d3d8 100644 --- a/crates/tracedecay-mcp/src/handlers/info/status.rs +++ b/crates/tracedecay-mcp/src/handlers/info/status.rs @@ -45,7 +45,7 @@ pub fn readiness_wait_outcome( read: CodeIndexReadinessWaitReadV1, ) -> CodeIndexReadinessWaitOutcomeV1 { match read { - CodeIndexReadinessWaitReadV1::Reached => CodeIndexReadinessWaitOutcomeV1::Reached, + CodeIndexReadinessWaitReadV1::Reached { .. } => CodeIndexReadinessWaitOutcomeV1::Reached, CodeIndexReadinessWaitReadV1::TimedOut { last } => { CodeIndexReadinessWaitOutcomeV1::TimedOut { last_state: last @@ -363,7 +363,8 @@ fn code_index_freshness_status( /// Computes `tracedecay_status`. `server_stats` is the serving MCP server's /// request counters; `wait` is the readiness wait the owner held the read -/// for, when the request asked for one. +/// for, when the request asked for one, and `reached_freshness` the reading +/// that satisfied it, which the payload reports instead of a later reading. #[hotpath::measure(label = "mcp.info.status.total")] pub async fn compute_status( ctx: &McpToolContext<'_>, @@ -371,6 +372,7 @@ pub async fn compute_status( server_stats: Option, scope_prefix: Option<&str>, wait: Option, + reached_freshness: Option, ) -> Result { if request.admission_only { return Ok(StatusResultV1::Admission(StatusAdmissionV1 { @@ -385,11 +387,20 @@ pub async fn compute_status( // commonly push status over the response-frame budget, and the truncated // body is not something the caller should reassemble into context. Opt in // when the full diagnostic section is the thing being asked for. - let freshness_payload = hotpath::future!( - ctx.freshness(), - label = "mcp.info.status.code_index_freshness" - ) - .await; + let freshness_payload = match reached_freshness { + Some(reading) => Some( + tracedecay_contracts::code_index_freshness::CodeIndexFreshnessPayloadV1::from_scheduler_read( + Some(reading), + ), + ), + None => { + hotpath::future!( + ctx.freshness(), + label = "mcp.info.status.code_index_freshness" + ) + .await + } + }; let (code_index_freshness, code_index_freshness_warning, retrieval_serving) = code_index_freshness_status(freshness_payload.as_ref()); let github_source = match github_source_status_v1(ctx.project_root()) { @@ -1081,6 +1092,7 @@ mod tests { coverage: CodeIndexFreshnessCoverageV1::PartialRefreshInProgress, ..Default::default() }; + let reached_reading = Box::new(seated_graph_pending.clone()); for (last, expected) in [ (Some(Box::new(seated_graph_pending)), "current"), (Some(Box::new(rebuilding)), "warming"), @@ -1096,7 +1108,9 @@ mod tests { } assert_eq!( serde_json::to_value(readiness_wait_outcome( - CodeIndexReadinessWaitReadV1::Reached + CodeIndexReadinessWaitReadV1::Reached { + reading: reached_reading + } )) .expect("outcome serializes"), serde_json::json!({ "outcome": "reached" }) diff --git a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs index b88e8144f6..2c5bba4443 100644 --- a/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs +++ b/crates/tracedecay-query/src/retrieval/lexical/projection/artifact/reader.rs @@ -388,6 +388,9 @@ impl CodeLexicalArtifactReaderV1 { ) .map_err(|error| map_reader_open_error(path, error))?; verify_named_path_identity(path, &file)?; + // An unserved revision's receipt has another shape; classify the + // revision before decoding it so an old artifact is Incompatible. + verify_artifact_state_revision(&connection, control)?; let receipt_bytes: Vec = connection .query_row( "SELECT receipt FROM artifact_state WHERE singleton = 1", diff --git a/crates/tracedecay/src/daemon/code_index_runtime_graph_activation_tests.rs b/crates/tracedecay/src/daemon/code_index_runtime_graph_activation_tests.rs index 854a543af1..e1805e2297 100644 --- a/crates/tracedecay/src/daemon/code_index_runtime_graph_activation_tests.rs +++ b/crates/tracedecay/src/daemon/code_index_runtime_graph_activation_tests.rs @@ -1558,7 +1558,7 @@ async fn first_index_serves_graph_reads_without_decoding_the_generation() { assert!( matches!( reached, - tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached + tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached { .. } ), "the first index must reach fresh with graph serving: {reached:?}" ); diff --git a/crates/tracedecay/src/daemon/tests/runtime_identity.rs b/crates/tracedecay/src/daemon/tests/runtime_identity.rs index 0819de8572..c0b91d07f5 100644 --- a/crates/tracedecay/src/daemon/tests/runtime_identity.rs +++ b/crates/tracedecay/src/daemon/tests/runtime_identity.rs @@ -278,19 +278,31 @@ async fn concurrent_same_identity_worktrees_keep_exact_server_and_scheduler_bind notify_workspace_open(linked_server.as_ref(), linked_session_id, &linked).await; let routed = files_for_session(primary_server.as_ref(), linked_session_id).await; assert!( - routed.result.is_none(), - "a linked worktree without the watch opt-in must not serve a file listing: {routed:?}" + routed.error.is_none(), + "the graph-tool owner refuses as an isError tool result: {routed:?}" ); - let routed_error = routed - .error - .as_ref() - .unwrap_or_else(|| panic!("linked route must refuse with a typed error: {routed:?}")); - let routed_data = routed_error - .data + let routed_result = routed + .result .as_ref() - .unwrap_or_else(|| panic!("linked-route refusal must be structured: {routed_error:?}")); - assert_eq!(routed_data["reason_code"], "code-graph-unavailable"); - assert_eq!(routed_data["tool"], "tracedecay_files"); + .unwrap_or_else(|| panic!("linked route must answer a refusal result: {routed:?}")); + assert_eq!( + routed_result["isError"], + serde_json::json!(true), + "a linked worktree without the watch opt-in must not serve a file listing: {routed_result}" + ); + let problem = &routed_result["problem"]; + assert_eq!( + (&problem["kind"], &problem["code"], &problem["message"]), + ( + &serde_json::json!("unavailable"), + &serde_json::json!("code-graph-unavailable"), + &serde_json::json!( + "the exact project code graph is unavailable: the verified code graph is not \ + ready for the exact project root" + ), + ), + "{routed_result}" + ); // The refusal is exact to the route, not a project-wide outage: the primary // route shares the same store authority, is admitted, and still answers the diff --git a/crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs b/crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs index 510f1195ee..e8d53d7e84 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/dispatch_groups.rs @@ -10,7 +10,8 @@ use tracedecay_global_db::RegisteredGlobalDbLeaseV1; use tracedecay_project::project::TraceDecay; use tracedecay_contracts::code_index_freshness::{ - CodeIndexFreshnessReader, CodeIndexReadinessWaitOutcomeV1, CodeIndexReadinessWaitV1, + CodeIndexFreshnessReader, CodeIndexReadinessWaitOutcomeV1, CodeIndexReadinessWaitReadV1, + CodeIndexReadinessWaitV1, CodeIndexWorktreeFreshnessV1, }; use tracedecay_contracts::retrieval::{ ActiveProjectSurfaceRequestV1, AdminCliResultV1, AdminProjectSurfaceRequestV1, @@ -387,11 +388,13 @@ async fn compute_project_info( let request: StatusSurfaceRequestV1 = decode_primitive_request(args, tool_name)?; // Wait before admitting snapshots, so the payload describes the // worktree the wait ended on. - let wait = match request.wait_for { + let (wait, reached_freshness) = match request.wait_for { Some(wait_for) => { - Some(status_readiness_wait(options, cg.project_root(), wait_for).await?) + let (outcome, reached) = + status_readiness_wait(options, cg.project_root(), wait_for).await?; + (Some(outcome), reached) } - None => None, + None => (None, None), }; let project = admitted_project_authorities(cg, options)?; let snapshots = admitted_status_snapshots(options).await; @@ -402,6 +405,7 @@ async fn compute_project_info( options.server_stats.clone(), scope_prefix, wait, + reached_freshness, ) .await .map(GraphToolResultV1::Status) @@ -549,7 +553,8 @@ async fn admitted_runtime_snapshots( } /// Hold a status read until the project reaches the requested readiness, -/// for at most the caller's `timeout_ms`. +/// for at most the caller's `timeout_ms`, returning the reading that reached +/// it alongside the outcome. /// /// The budget is the caller's; a budget this call cannot live out is refused /// rather than shortened. Cancellation ends the wait with a typed outcome and @@ -558,7 +563,10 @@ async fn status_readiness_wait( options: &ToolCallRegistryOptions<'_>, project_root: &std::path::Path, request: CodeIndexReadinessWaitV1, -) -> Result { +) -> Result<( + CodeIndexReadinessWaitOutcomeV1, + Option, +)> { let budget = std::time::Duration::from_millis(request.timeout_ms); let dispatch_budget = tool_dispatch_budget("tracedecay_status", options.application_deadline.as_ref()) @@ -573,9 +581,12 @@ async fn status_readiness_wait( }); } let Some(waiter) = options.code_index_readiness_waiter.as_ref() else { - return Ok(CodeIndexReadinessWaitOutcomeV1::Unavailable { - reason: "code_index_scheduler_authority_not_attached".to_owned(), - }); + return Ok(( + CodeIndexReadinessWaitOutcomeV1::Unavailable { + reason: "code_index_scheduler_authority_not_attached".to_owned(), + }, + None, + )); }; let wait = waiter(project_root.to_path_buf(), request.state, budget); let cancelled = async { @@ -586,12 +597,22 @@ async fn status_readiness_wait( }; Ok(tokio::select! { biased; - () = cancelled => CodeIndexReadinessWaitOutcomeV1::Unavailable { reason: "request_cancelled".to_owned() }, + () = cancelled => (CodeIndexReadinessWaitOutcomeV1::Unavailable { reason: "request_cancelled".to_owned() }, None), read = wait => match read { - Ok(read) => portable_info::readiness_wait_outcome(read), - Err(_) => CodeIndexReadinessWaitOutcomeV1::Unavailable { - reason: "code_index_freshness_read_failed".to_owned(), - }, + Ok(read) => { + let reached = match &read { + CodeIndexReadinessWaitReadV1::Reached { reading } => Some(reading.as_ref().clone()), + CodeIndexReadinessWaitReadV1::TimedOut { .. } + | CodeIndexReadinessWaitReadV1::Unreachable { .. } => None, + }; + (portable_info::readiness_wait_outcome(read), reached) + } + Err(_) => ( + CodeIndexReadinessWaitOutcomeV1::Unavailable { + reason: "code_index_freshness_read_failed".to_owned(), + }, + None, + ), }, }) } diff --git a/crates/tracedecay/tests/daemon_suite/indexing_lifecycle_test.rs b/crates/tracedecay/tests/daemon_suite/indexing_lifecycle_test.rs index ebe8d3af92..2147ce9fac 100644 --- a/crates/tracedecay/tests/daemon_suite/indexing_lifecycle_test.rs +++ b/crates/tracedecay/tests/daemon_suite/indexing_lifecycle_test.rs @@ -342,27 +342,47 @@ async fn ignored_dependency_admission_survives_physical_daemon_restart_without_w "dependency starts outside the index: {absent}" ); - let error = tokio::time::timeout( - RECEIPT_TIMEOUT, - call_tool( - &socket, - &handshake, - "tracedecay_find_exact_symbol", - json!({ - "name": "LifecycleIgnoredDependency", - "limit": 5, - "lazy_index_ignored_dependencies": true, - }), - ), - ) + // A ready generation may serve without its decoded seat; the first + // admission demands that decode and asks for a retry until it seats. + let refusal = tokio::time::timeout(RECEIPT_TIMEOUT, async { + loop { + let refusal = call_tool( + &socket, + &handshake, + "tracedecay_find_exact_symbol", + json!({ + "name": "LifecycleIgnoredDependency", + "limit": 5, + "lazy_index_ignored_dependencies": true, + }), + ) + .await + .expect("the owner answers lazy admission as a tool result"); + if refusal["structuredContent"]["problem"]["code"] + != "application.symbol-graph.ignored-dependency-scheduler-unavailable" + { + break refusal; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + }) .await - .expect("lazy admission timed out") - .expect_err("generation-advancing admission must require a retry"); - assert!( - error - .to_string() - .contains("advanced the graph generation; retry the request"), - "lazy admission returned the wrong typed retry: {error}" + .expect("lazy admission timed out"); + let problem = &refusal["structuredContent"]["problem"]; + assert_eq!( + ( + &refusal["isError"], + &problem["code"], + &problem["message"], + &problem["legal_actions"], + ), + ( + &json!(true), + &json!("application.symbol-graph.ignored-dependency-generation-advanced"), + &json!("ignored dependency indexing advanced the graph generation; retry the request"), + &json!(["retry"]), + ), + "generation-advancing admission must require a retry: {refusal}" ); let advanced = read_active_generation(environment.home(), &project); assert_ne!( diff --git a/crates/tracedecay/tests/mcp_suite/diagnostics_read_test.rs b/crates/tracedecay/tests/mcp_suite/diagnostics_read_test.rs index 0132ccf3da..b8f20dfd66 100644 --- a/crates/tracedecay/tests/mcp_suite/diagnostics_read_test.rs +++ b/crates/tracedecay/tests/mcp_suite/diagnostics_read_test.rs @@ -170,6 +170,69 @@ async fn typescript_project_publishes_diagnostics_from_its_own_compiler() { fixture.harness.shutdown().await; } +/// Moving the source proof (Git metadata the fence samples) without changing +/// the sealed source leaves the published finding valid but unverified. Until +/// the code index renews the proof, the read reports that publication stale; +/// "the diagnostic authority is unavailable" would tell a caller to give up. +#[cfg(unix)] +#[tokio::test] +async fn moved_source_proof_reads_the_publication_as_stale_until_renewed() { + use crate::common::fixture::{TypeScriptFixtureCompiler, write_typescript_diagnostics_fixture}; + use crate::support::production_composition_fixture_with_sources; + + let fixture = production_composition_fixture_with_sources(|project| { + write_typescript_diagnostics_fixture(project, TypeScriptFixtureCompiler::Present); + }) + .await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + wait_for_current_graph(&server).await; + let arguments = json!({"scope": "file", "path": "src/index.ts", "maximum_diagnostics": 10}); + let published = await_published_diagnostics(&server, arguments.clone()).await; + assert_eq!(published_records(&published).len(), 1, "{published}"); + + let index = std::fs::File::options() + .write(true) + .open(fixture.project_root.join(".git/index")) + .expect("open the git index"); + index + .set_modified(std::time::SystemTime::now() + Duration::from_secs(5)) + .expect("move the git index mtime"); + drop(index); + + let mut codes = Vec::new(); + let renewed = loop { + let result = + handle_real_server_tool_call(&server, "tracedecay_diagnostics", arguments.clone()) + .await; + if result["isError"] != json!(true) { + break result; + } + codes.push(result["structuredContent"]["problem"]["code"].clone()); + assert!( + codes.len() < 120, + "the code index never renewed the moved proof: {result}" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + }; + assert!( + !codes.is_empty() + && codes + .iter() + .all(|code| code == "application.diagnostics.stale"), + "a moved proof over a valid publication reads as stale until renewed: {codes:?}" + ); + assert_eq!( + published_records(&renewed), + published_records(&published), + "the renewed proof serves the same finding" + ); + + fixture.harness.shutdown().await; +} + /// The same project before `npm install`: the read must carry the exact setup /// command and a legal action, never `Legal actions: none`. #[cfg(unix)] diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test/typed_evidence_trailers.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test/typed_evidence_trailers.rs index c48f21e0aa..57eee3bae6 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test/typed_evidence_trailers.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test/typed_evidence_trailers.rs @@ -410,6 +410,17 @@ async fn typed_callers_carry_their_read_cost() { payload["outcome"]["value"]["execution"]["budget"]["elapsed_micros"], cost["wall_micros"], "the receipt's elapsed time is the metered read's wall time: {payload:#}" ); + let execution = &payload["outcome"]["value"]["execution"]; + let interval = execution["ended_at"] + .as_i64() + .zip(execution["started_at"].as_i64()) + .map(|(ended, started)| ended - started) + .unwrap_or_else(|| panic!("the receipt names its interval: {payload:#}")); + assert_eq!( + Some(interval), + cost["wall_micros"].as_i64(), + "the receipt ends where the metered read ended: {payload:#}" + ); let markdown = call( &fixture,