diff --git a/crates/tracedecay-application/src/diagnostics_producer.rs b/crates/tracedecay-application/src/diagnostics_producer.rs index a04c0fff56..c42994382f 100644 --- a/crates/tracedecay-application/src/diagnostics_producer.rs +++ b/crates/tracedecay-application/src/diagnostics_producer.rs @@ -11,7 +11,7 @@ //! generation carries one snapshot of all their merged findings, but a project //! is rechecked only when the generation changed a file that can change what //! tsc reports for it; the rest republish their previous result without -//! running tsc. Each run's outcome is recorded per project root, and each +//! running tsc. Each run's outcome is published to the owning project's status handle, and each //! tsconfig's check within it, so a read can name the real state of the //! project that owns the file it asked about (still running, compiler failed, //! compiler missing, nothing resolved) instead of a bare "no producer" or a @@ -20,7 +20,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, RwLock}; use futures_util::FutureExt; use futures_util::future::join_all; @@ -82,54 +82,77 @@ pub enum TypeScriptProjectCheckV1 { }, } -#[derive(Clone)] -struct ProducerRecord { +/// The latest producer outcome of one project owner: the run and how each +/// tsconfig fared in it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TypeScriptProducerStatusV1 { run: CompilerProducerRunV1, checks: BTreeMap, } -fn last_runs() -> &'static Mutex> { - static LAST_RUNS: OnceLock>> = OnceLock::new(); - LAST_RUNS.get_or_init(|| Mutex::new(BTreeMap::new())) -} +impl TypeScriptProducerStatusV1 { + #[must_use] + pub fn new( + run: CompilerProducerRunV1, + checks: BTreeMap, + ) -> Self { + Self { run, checks } + } -fn last_record(project_root: &Path) -> Option { - last_runs() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .get(project_root) - .cloned() + #[must_use] + pub fn run(&self) -> &CompilerProducerRunV1 { + &self.run + } } -/// The most recent producer run recorded for `project_root`, if any. -#[must_use] -pub fn last_producer_run(project_root: &Path) -> Option { - last_record(project_root).map(|record| record.run) +/// Read view of one project owner's producer status. The producer task and the +/// owner's diagnostics reads share one handle created with the owner, so a +/// successor owner at the same path starts with no status and a superseded +/// producer can only publish into the handle it was given. +#[derive(Clone, Debug, Default)] +pub struct TypeScriptProducerStatusHandleV1 { + latest: Arc>>>, } -fn record_producer_run( - project_root: &Path, - run: &CompilerProducerRunV1, - checks: BTreeMap, -) { - let record = ProducerRecord { - run: run.clone(), - checks, - }; - last_runs() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(project_root.to_path_buf(), record); +impl TypeScriptProducerStatusHandleV1 { + fn publish(&self, status: TypeScriptProducerStatusV1) { + *self + .latest + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::new(status)); + } + + /// The most recent producer status, if the producer has run. + #[must_use] + pub fn current(&self) -> Option> { + self.latest + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } } /// What one project root's producer remembers between generations: the file /// digests of the generation it last checked and how each project fared. -#[derive(Default)] pub struct TypeScriptProducerStateV1 { + status: TypeScriptProducerStatusHandleV1, checked_files: Option>, projects: BTreeMap, } +impl TypeScriptProducerStateV1 { + /// State for one project owner's producer, publishing its status into + /// `status`. + #[must_use] + pub fn new(status: TypeScriptProducerStatusHandleV1) -> Self { + Self { + status, + checked_files: None, + projects: BTreeMap::new(), + } + } +} + #[derive(Clone)] enum ProjectResult { Checked { @@ -154,8 +177,8 @@ const CONCURRENT_CHECKS: usize = 4; /// `build_info_dir`, only when `identity` changed a file that can change what /// tsc reports for it since the generation `state` last checked; every other /// project republishes its previous result without running tsc. The outcome -/// is recorded for [`last_producer_run`], and each tsconfig's check for -/// [`typescript_diagnostics_availability`]. +/// and each tsconfig's check are published to the status handle `state` was +/// built with, which [`typescript_diagnostics_availability`] reads. /// /// A clean project publishes an empty snapshot, which is what lets a read /// answer "no diagnostics" as evidence instead of as a missing producer. @@ -185,7 +208,9 @@ pub async fn run_typescript_producer_v1( &mut checks, ) .await; - record_producer_run(project_root, &run, checks); + state + .status + .publish(TypeScriptProducerStatusV1::new(run.clone(), checks)); run } @@ -477,11 +502,12 @@ pub enum TypeScriptDiagnosticsAvailabilityV1 { /// Resolves what a read can truthfully say about the TypeScript producer: the /// project owning `file` (or, for a workspace read, the first checkable one), -/// its filesystem state, and the last recorded run. +/// its filesystem state, and the owner's last published run. #[must_use] pub fn typescript_diagnostics_availability( project_root: &Path, file: Option<&Path>, + status: &TypeScriptProducerStatusHandleV1, ) -> TypeScriptDiagnosticsAvailabilityV1 { let project = match file { Some(file) => match typescript_file_owner(project_root, file) { @@ -504,12 +530,12 @@ pub fn typescript_diagnostics_availability( let TypeScriptProject { tsconfig, compiler } = project; match compiler { Some(compiler) => { - let record = last_record(project_root); + let status = status.current(); TypeScriptDiagnosticsAvailabilityV1::Configured { - check: record + check: status .as_ref() - .and_then(|record| record.checks.get(&tsconfig).cloned()), - last_run: record.map(|record| record.run), + .and_then(|status| status.checks.get(&tsconfig).cloned()), + last_run: status.map(|status| status.run.clone()), tsconfig, compiler, } @@ -542,9 +568,9 @@ mod tests { use tracedecay_runtime_core::test_executable::write_executable_script; use super::{ - CompilerProducerRunV1, TypeScriptDiagnosticsAvailabilityV1, TypeScriptProjectCheckV1, - compiler_diagnostic, last_producer_run, record_producer_run, - typescript_diagnostics_availability, + CompilerProducerRunV1, TypeScriptDiagnosticsAvailabilityV1, + TypeScriptProducerStatusHandleV1, TypeScriptProducerStatusV1, TypeScriptProjectCheckV1, + compiler_diagnostic, typescript_diagnostics_availability, }; #[cfg(unix)] use super::{TypeScriptProducerStateV1, run_typescript_producer_v1}; @@ -585,16 +611,21 @@ mod tests { } #[test] - fn the_last_run_is_recorded_per_project_root() { - let root = tempfile::tempdir().expect("project root"); - assert_eq!(last_producer_run(root.path()), None); + fn a_status_handle_publishes_to_its_owner_only() { + let owner = TypeScriptProducerStatusHandleV1::default(); + assert_eq!(owner.current(), None); let run = CompilerProducerRunV1::CompilerFailed { reason: "tsc exited with 2".to_owned(), }; - record_producer_run(root.path(), &run, BTreeMap::new()); - assert_eq!(last_producer_run(root.path()), Some(run)); - let other = tempfile::tempdir().expect("other root"); - assert_eq!(last_producer_run(other.path()), None); + owner.publish(TypeScriptProducerStatusV1::new( + run.clone(), + BTreeMap::new(), + )); + assert_eq!( + owner.current().map(|status| status.run().clone()), + Some(run) + ); + assert_eq!(TypeScriptProducerStatusHandleV1::default().current(), None); } /// A read about a file reports the check of the tsconfig that owns it, @@ -615,9 +646,9 @@ mod tests { let failed = TypeScriptProjectCheckV1::Failed { reason: "TS5083: Cannot read file".to_owned(), }; - record_producer_run( - root, - &CompilerProducerRunV1::CodeIndexGenerationUnavailable, + let status = TypeScriptProducerStatusHandleV1::default(); + status.publish(TypeScriptProducerStatusV1::new( + CompilerProducerRunV1::CodeIndexGenerationUnavailable, BTreeMap::from([ ( root.join("packages/app/tsconfig.json"), @@ -625,11 +656,15 @@ mod tests { ), (lib.clone(), failed.clone()), ]), - ); + )); let TypeScriptDiagnosticsAvailabilityV1::Configured { tsconfig, check, .. - } = typescript_diagnostics_availability(root, Some(Path::new("packages/lib/src/a.ts"))) + } = typescript_diagnostics_availability( + root, + Some(Path::new("packages/lib/src/a.ts")), + &status, + ) else { panic!("lib owns its sources and the root compiler checks it"); }; @@ -797,7 +832,7 @@ mod tests { #[tokio::test] async fn only_projects_a_generation_changed_are_rechecked() { let fixture = ProducerFixture::new(); - let mut state = TypeScriptProducerStateV1::default(); + let mut state = TypeScriptProducerStateV1::new(TypeScriptProducerStatusHandleV1::default()); let both = vec![ ( "file.packages.app.src.index.ts".to_owned(), @@ -847,4 +882,36 @@ mod tests { ); assert_eq!(fixture.checked(), ["broken"]); } + + /// A producer run reports to the handle of the owner it was built for. A + /// successor owner opened at the same path reads no status until its own + /// producer runs, so the predecessor's run is never taken for its own. + #[cfg(unix)] + #[tokio::test] + async fn a_successor_owner_does_not_inherit_the_predecessors_status() { + let fixture = ProducerFixture::new(); + let predecessor = TypeScriptProducerStatusHandleV1::default(); + let mut state = TypeScriptProducerStateV1::new(predecessor.clone()); + let run = fixture.run("generation.producer.1", &mut state).await; + assert_eq!( + predecessor.current().map(|status| status.run().clone()), + Some(run) + ); + + let successor = TypeScriptProducerStatusHandleV1::default(); + let file = Path::new("packages/app/src/index.ts"); + let TypeScriptDiagnosticsAvailabilityV1::Configured { + check, last_run, .. + } = typescript_diagnostics_availability(&fixture.root, Some(file), &successor) + else { + panic!("app is checkable"); + }; + assert_eq!((check, last_run), (None, None)); + let TypeScriptDiagnosticsAvailabilityV1::Configured { check, .. } = + typescript_diagnostics_availability(&fixture.root, Some(file), &predecessor) + else { + panic!("app is checkable"); + }; + assert_eq!(check, Some(TypeScriptProjectCheckV1::Checked)); + } } diff --git a/crates/tracedecay-application/src/primitives/production.rs b/crates/tracedecay-application/src/primitives/production.rs index d97e35deea..4e74e9cda5 100644 --- a/crates/tracedecay-application/src/primitives/production.rs +++ b/crates/tracedecay-application/src/primitives/production.rs @@ -30,6 +30,7 @@ use super::runtime::{ }; use super::symbol_graph::SymbolGraphCursorPort; use crate::code_index::CodeIndexIgnoredDependencyAdmissionPortV1; +use crate::diagnostics_producer::TypeScriptProducerStatusHandleV1; use crate::diagnostics_publication::CodeIndexPublicationIdentityPortV1; use crate::diagnostics_query::DiagnosticQueryCursor; use crate::lsp_runtime::LspCodeIndexProjectionIdentityPort; @@ -868,6 +869,7 @@ pub struct ProductionPrimitiveOpenRequestV1 { convergence_park: Arc, access: ProjectSourceAccessSnapshot, admitted_root_uri: String, + compiler_status: TypeScriptProducerStatusHandleV1, } impl ProductionPrimitiveOpenRequestV1 { @@ -878,6 +880,7 @@ impl ProductionPrimitiveOpenRequestV1 { temporal: Arc, access: ProjectSourceAccessSnapshot, admitted_root_uri: String, + compiler_status: TypeScriptProducerStatusHandleV1, ) -> Self { Self { source_runtime, @@ -890,6 +893,7 @@ impl ProductionPrimitiveOpenRequestV1 { convergence_park: code.convergence_park, access, admitted_root_uri, + compiler_status, } } } @@ -910,6 +914,7 @@ pub async fn open_production_primitive_runtime( convergence_park, access, admitted_root_uri, + compiler_status, } = request; let database = source_runtime.db().clone(); let project_root = source_runtime.project_root().to_path_buf(); @@ -987,6 +992,7 @@ pub async fn open_production_primitive_runtime( test_runs, test_run_scope, convergence_park, + compiler_status, ) } diff --git a/crates/tracedecay-application/src/primitives/runtime.rs b/crates/tracedecay-application/src/primitives/runtime.rs index 1175a62fff..cee53d825a 100644 --- a/crates/tracedecay-application/src/primitives/runtime.rs +++ b/crates/tracedecay-application/src/primitives/runtime.rs @@ -50,8 +50,8 @@ use super::symbol_graph::{CanonicalSymbolGraphAdapter, SymbolGraphCursorPort}; use crate::ProjectSourceAccessSnapshot; use crate::code_index::CodeIndexIgnoredDependencyAdmissionPortV1; use crate::diagnostics_producer::{ - CompilerProducerRunV1, TypeScriptDiagnosticsAvailabilityV1, TypeScriptProjectCheckV1, - typescript_diagnostics_availability, + CompilerProducerRunV1, TypeScriptDiagnosticsAvailabilityV1, TypeScriptProducerStatusHandleV1, + TypeScriptProjectCheckV1, typescript_diagnostics_availability, }; use crate::managed_test_runs::{ ManagedTestRunCurrentScope, ManagedTestRunReadOutcome, ManagedTestRunStaleReason, @@ -344,6 +344,9 @@ pub struct OwnedPrimitiveRuntime { /// The filesystem root `admitted_root_uri` names; diagnostics reads probe /// the producer state here when no publication exists. admitted_project_root: PathBuf, + /// This owner's TypeScript producer status; diagnostics reads that find no + /// publication describe it, never another owner's run at the same path. + compiler_status: TypeScriptProducerStatusHandleV1, test_runs: RetainedManagedTestRuns, test_run_scope: Arc, convergence_park: Arc, @@ -538,6 +541,7 @@ pub fn open_primitive_project_runtime( test_runs: RetainedManagedTestRuns, test_run_scope: Arc, convergence_park: Arc, + compiler_status: TypeScriptProducerStatusHandleV1, ) -> Result { scope.validate()?; let admitted_project_root = validate_admitted_root_uri(&admitted_root_uri)?; @@ -575,6 +579,7 @@ pub fn open_primitive_project_runtime( access, admitted_root_uri, admitted_project_root, + compiler_status, test_runs, test_run_scope, convergence_park, @@ -928,6 +933,7 @@ async fn dispatch_admitted( evidence.omissions.first().map(|omission| omission.reason), &runtime.admitted_project_root, file, + &runtime.compiler_status, ); } // Another package's publication is no evidence about a file whose @@ -937,8 +943,11 @@ async fn dispatch_admitted( .payload .as_ref() .is_some_and(|result| result.diagnostics.is_empty()) - && let Some(unchecked) = - unchecked_owner_problem(&runtime.admitted_project_root, file)? + && let Some(unchecked) = unchecked_owner_problem( + &runtime.admitted_project_root, + file, + &runtime.compiler_status, + )? { return problem(&context, &operation, unchecked); } @@ -1849,11 +1858,12 @@ fn diagnostics_unavailable_problem( reason: Option, project_root: &Path, file: Option<&Path>, + status: &TypeScriptProducerStatusHandleV1, ) -> Result, ApplicationContractError> { problem( context, operation, - diagnostics_absence_problem(reason, project_root, file)?, + diagnostics_absence_problem(reason, project_root, file, status)?, ) } @@ -1867,6 +1877,7 @@ fn diagnostics_absence_problem( reason: Option, project_root: &Path, file: Option<&Path>, + status: &TypeScriptProducerStatusHandleV1, ) -> Result { let (code, message) = match reason { Some(OmissionReason::Stale) => ( @@ -1875,7 +1886,7 @@ fn diagnostics_absence_problem( .to_owned(), ), Some(OmissionReason::Unsupported) => { - return unpublished_diagnostics_problem(project_root, file); + return unpublished_diagnostics_problem(project_root, file, status); } Some(OmissionReason::Redacted) => ( "application.diagnostics.redacted", @@ -1901,8 +1912,9 @@ fn diagnostics_absence_problem( fn unpublished_diagnostics_problem( project_root: &Path, file: Option<&Path>, + status: &TypeScriptProducerStatusHandleV1, ) -> Result { - let problem = match typescript_diagnostics_availability(project_root, file) { + let problem = match typescript_diagnostics_availability(project_root, file, status) { TypeScriptDiagnosticsAvailabilityV1::NoTsconfig { searched } => { no_tsconfig_problem(file, &searched) } @@ -1982,8 +1994,9 @@ fn unpublished_diagnostics_problem( fn unchecked_owner_problem( project_root: &Path, file: &Path, + status: &TypeScriptProducerStatusHandleV1, ) -> Result, ApplicationContractError> { - let problem = match typescript_diagnostics_availability(project_root, Some(file)) { + let problem = match typescript_diagnostics_availability(project_root, Some(file), status) { TypeScriptDiagnosticsAvailabilityV1::CompilerMissing { tsconfig, install_command, @@ -2091,9 +2104,9 @@ mod tests { use super::{ ExtendedPrimitivePort, MAX_SAFE_DIAGNOSTIC_MESSAGE_BYTES, OmissionReason, PrimitiveCapacity, PrimitiveDispatch, PrimitiveRequest, StorageStatusPrimitiveRequest, - diagnostics_absence_problem, safe_problem_message, session_structural_refusal_problem, - symbol_temporal_state, unchecked_owner_problem, unpublished_diagnostics_problem, - valid_owned_primitive_request, validate_admitted_root_uri, + TypeScriptProducerStatusHandleV1, diagnostics_absence_problem, safe_problem_message, + session_structural_refusal_problem, symbol_temporal_state, unchecked_owner_problem, + unpublished_diagnostics_problem, valid_owned_primitive_request, validate_admitted_root_uri, }; use tracedecay_contracts::retrieval::{ CodeGraphReadFreshnessV1, GraphRelationRequest, ImplementationSelector, @@ -2173,8 +2186,13 @@ mod tests { "application.diagnostics.unsupported", ), ] { - let problem = diagnostics_absence_problem(reason, project.path(), None) - .expect("typed diagnostics absence"); + let problem = diagnostics_absence_problem( + reason, + project.path(), + None, + &TypeScriptProducerStatusHandleV1::default(), + ) + .expect("typed diagnostics absence"); assert_eq!(problem.kind(), kind, "reason {reason:?}"); assert_eq!( problem @@ -2196,6 +2214,7 @@ mod tests { fn unpublished_diagnostics_name_the_producer_route() { let project = tempfile::tempdir().expect("project root"); let root = project.path(); + let status = TypeScriptProducerStatusHandleV1::default(); let file = Path::new("packages/app/src/index.ts"); for (scope, detail) in [ @@ -2223,7 +2242,8 @@ mod tests { }, ), ] { - let no_tsconfig = unpublished_diagnostics_problem(root, scope).expect("no tsconfig"); + let no_tsconfig = + unpublished_diagnostics_problem(root, scope, &status).expect("no tsconfig"); assert_eq!(no_tsconfig.detail(), Some(&detail)); assert_eq!(no_tsconfig.kind(), ApplicationProblemKind::Unsupported); assert_eq!( @@ -2254,7 +2274,8 @@ mod tests { .expect("package tsconfig"); std::fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n").expect("lock"); for scope in [None, Some(file)] { - let missing = unpublished_diagnostics_problem(root, scope).expect("missing compiler"); + let missing = + unpublished_diagnostics_problem(root, scope, &status).expect("missing compiler"); assert_eq!(missing.kind(), ApplicationProblemKind::Unsupported); assert_eq!(missing.legal_actions(), [LegalAction::Refresh]); assert_eq!( @@ -2269,7 +2290,7 @@ mod tests { ); } assert!( - unchecked_owner_problem(root, file) + unchecked_owner_problem(root, file, &status) .expect("owner state") .is_some(), "an empty page is no evidence for a file whose compiler is missing" @@ -2280,11 +2301,11 @@ mod tests { std::fs::write(bin.join(if cfg!(windows) { "tsc.cmd" } else { "tsc" }), "") .expect("workspace tsc"); assert!( - unchecked_owner_problem(root, file) + unchecked_owner_problem(root, file, &status) .expect("owner state") .is_none() ); - let pending = unpublished_diagnostics_problem(root, Some(file)).expect("pending"); + let pending = unpublished_diagnostics_problem(root, Some(file), &status).expect("pending"); assert_eq!( pending.detail(), Some(&ApplicationProblemDetailV1::DiagnosticsPending { diff --git a/crates/tracedecay/src/daemon/project_open_owners.rs b/crates/tracedecay/src/daemon/project_open_owners.rs index 25a14b809b..c01b17a082 100644 --- a/crates/tracedecay/src/daemon/project_open_owners.rs +++ b/crates/tracedecay/src/daemon/project_open_owners.rs @@ -23,6 +23,7 @@ use tracedecay_agent_hosts::native_integration::{ DaemonNativeIntegrationAnalysisV1, DaemonNativeIntegrationServiceRegistry, NativeIntegrationTargetV1, }; +use tracedecay_application::diagnostics_producer::TypeScriptProducerStatusHandleV1; use tracedecay_application::lsp_runtime::DaemonLspSessionFactory; use tracedecay_application::source_authorization::ProjectSourceAccessSnapshot; use tracedecay_code_index_runtime::git_transactions::DaemonGitIndexTransactionServiceRegistry; @@ -169,6 +170,10 @@ pub(crate) async fn install_project_open_source_edit_owners_for_test( /// callable-code authorization and the primitive runtime. Returns the /// admitted root URI they were bound to. #[tracing::instrument(name = "daemon.project.owners.code_reads", level = "trace", skip_all)] +#[expect( + clippy::too_many_arguments, + reason = "Project-open wiring hands each owner its admitted authorities explicitly." +)] async fn register_project_code_read_owners( invocation: &DaemonInvocationState, project_root: &Path, @@ -177,6 +182,7 @@ async fn register_project_code_read_owners( access: &ProjectSourceAccessSnapshot, session_db: tracedecay_global_db::RegisteredGlobalDbLeaseV1, temporal: Arc, + compiler_status: TypeScriptProducerStatusHandleV1, ) -> Result { // Primitive reads are part of the admitted core route. Publish their // runtime and callable-code authorization before the slower mutation, @@ -219,6 +225,7 @@ async fn register_project_code_read_owners( session_db, temporal, access.clone(), + compiler_status, ) .await } @@ -271,6 +278,7 @@ pub(super) async fn register_reset_required_route_code_read_owners( &access, session_store, Arc::new(primitive_runtime::ResetRequiredSessionLookupV1), + TypeScriptProducerStatusHandleV1::default(), )) .await .map(drop) @@ -380,6 +388,9 @@ pub(super) async fn register_project_open_production_owners( let temporal = Arc::new(DaemonSessionLookupPrimitiveV1::new( server.project_session_application_retrieval_service(&access.scope)?, )); + // One status handle per project owner: the producer publishes into it and + // this owner's diagnostics reads describe it. + let compiler_status = TypeScriptProducerStatusHandleV1::default(); let admitted_root_uri = Box::pin(register_project_code_read_owners( invocation, project_root, @@ -388,6 +399,7 @@ pub(super) async fn register_project_open_production_owners( &access, session_db.clone(), temporal, + compiler_status.clone(), )) .await?; owner_phase("code_read_owners_registered"); @@ -792,6 +804,7 @@ pub(super) async fn register_project_open_production_owners( project_root.to_path_buf(), &scope, Arc::clone(&graph), + compiler_status, ); tracing::info!( diff --git a/crates/tracedecay/src/daemon/project_open_owners/compiler_diagnostics_producer.rs b/crates/tracedecay/src/daemon/project_open_owners/compiler_diagnostics_producer.rs index ac17ea0262..1c76d13fbc 100644 --- a/crates/tracedecay/src/daemon/project_open_owners/compiler_diagnostics_producer.rs +++ b/crates/tracedecay/src/daemon/project_open_owners/compiler_diagnostics_producer.rs @@ -13,7 +13,8 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use tracedecay_application::diagnostics_producer::{ - CompilerProducerRunV1, TypeScriptProducerStateV1, run_typescript_producer_v1, + CompilerProducerRunV1, TypeScriptProducerStateV1, TypeScriptProducerStatusHandleV1, + run_typescript_producer_v1, }; use tracedecay_application::diagnostics_store::DiagnosticsStore; use tracedecay_code_index_runtime::code_index_scheduler::CodeIndexOwnerSignalsV1; @@ -39,6 +40,7 @@ pub(super) fn spawn_typescript_diagnostics_producer( project_root: PathBuf, scope: &tracedecay_contracts::ResolvedScope, graph: Arc, + status: TypeScriptProducerStatusHandleV1, ) -> bool { if tracedecay_code_index_runtime::project_reads::code_index_disabled_for_scope( &invocation.code_index_schedulers, @@ -72,7 +74,7 @@ pub(super) fn spawn_typescript_diagnostics_producer( let schedulers = invocation.code_index_schedulers.clone(); let mut signals = CodeIndexOwnerSignalsV1::subscribe(&schedulers, &project_root).await; let build_info_dir = graph.store_layout().data_root.join(BUILD_INFO_DIR); - let mut state = TypeScriptProducerStateV1::default(); + let mut state = TypeScriptProducerStateV1::new(status); // One producer run per generation, whatever its outcome: the root's // signals also wake on serving and pass changes, and a project // whose compiler keeps failing must not re-run it on each of them. diff --git a/crates/tracedecay/src/daemon/project_open_owners/primitive_runtime.rs b/crates/tracedecay/src/daemon/project_open_owners/primitive_runtime.rs index 0bcc6488fa..3464b0cda9 100644 --- a/crates/tracedecay/src/daemon/project_open_owners/primitive_runtime.rs +++ b/crates/tracedecay/src/daemon/project_open_owners/primitive_runtime.rs @@ -3,6 +3,7 @@ use std::path::Path; use std::sync::Arc; +use tracedecay_application::diagnostics_producer::TypeScriptProducerStatusHandleV1; use tracedecay_application::primitives::{ ProductionPrimitiveCodeAuthoritiesV1, ProductionPrimitiveOpenRequestV1, admitted_root_uri_for_project, @@ -34,6 +35,10 @@ impl TemporalRetrievalPort for ResetRequiredSessionLookupV1 { } #[tracing::instrument(name = "daemon.project.owners.primitive", level = "trace", skip_all)] +#[expect( + clippy::too_many_arguments, + reason = "Project-open wiring hands each owner its admitted authorities explicitly." +)] pub(super) async fn open_and_register_project_primitive_runtime( invocation: &DaemonInvocationState, project_root: &Path, @@ -42,6 +47,7 @@ pub(super) async fn open_and_register_project_primitive_runtime( session_db: tracedecay_global_db::RegisteredGlobalDbLeaseV1, temporal: Arc, access: ProjectSourceAccessSnapshot, + compiler_status: TypeScriptProducerStatusHandleV1, ) -> Result { let admitted_root_uri = admitted_root_uri_for_project(project_root).map_err(|error| TraceDecayError::Config { @@ -76,6 +82,7 @@ pub(super) async fn open_and_register_project_primitive_runtime( temporal, access, admitted_root_uri.clone(), + compiler_status, ), ) .await diff --git a/crates/tracedecay/src/daemon/tests/ownership.rs b/crates/tracedecay/src/daemon/tests/ownership.rs index 98d75f79f6..4e8db89fea 100644 --- a/crates/tracedecay/src/daemon/tests/ownership.rs +++ b/crates/tracedecay/src/daemon/tests/ownership.rs @@ -445,11 +445,36 @@ async fn mounted_graph_listing_takes_no_server_lease_while_it_reads() { .await .expect("mount project server"); + engine.lifecycle.begin_draining(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + engine.lifecycle.wait_for_idle(), + ) + .await + .expect("fixture startup owners must release their server handles"); + let startup_lifecycle = DaemonLifecycle::default(); + let startup_server = Arc::clone(&server); + let (startup_entered, startup_parked) = tokio::sync::oneshot::channel(); + super::super::project_open_orchestration::spawn_lifecycle_automation_scheduler_activation( + startup_lifecycle.clone(), + async move { + startup_entered.send(()).expect("startup owner entered"); + std::future::pending::<()>().await; + drop(startup_server); + }, + ); + startup_parked.await.expect("startup owner parked"); + + let graph = server.cg().await; + let key = ProjectServerKey::from_open_project(&graph, &handshake).expect("mounted owner key"); + let route = ProjectRouteKey::from_handshake(graph.project_root(), &handshake) + .expect("mounted route key"); + let server_witness = Arc::downgrade(&server); let swap = server.graph_slot().write_owned().await; + drop(server); let mut registry_gate = Some(engine.store_administration.project_servers().lock().await); let listing = engine.store_administration.mounted_project_graphs(); let mut listing = std::pin::pin!(listing); - let handles = Arc::strong_count(&server); let registry_lock = engine.store_administration.project_servers().lock(); let mut registry_lock = std::pin::pin!(registry_lock); // Force the first listing poll to wait for another registry user. Tokio's @@ -457,7 +482,7 @@ async fn mounted_graph_listing_takes_no_server_lease_while_it_reads() { // Drive both futures with real wakeups: the fair registry mutex admits // the listing before this later lock request, then must release it while // the graph write guard still prevents the listing from completing. - let registry = tokio::time::timeout( + let mut registry = tokio::time::timeout( std::time::Duration::from_secs(5), std::future::poll_fn(|context| { assert!( @@ -470,14 +495,44 @@ async fn mounted_graph_listing_takes_no_server_lease_while_it_reads() { ) .await .expect("a parked graph listing must release the registry lock for route binds"); + assert_eq!(registry.servers.len(), 1); + assert!( + registry + .retire_lru_ready_under_graph_pressure(|server| Arc::strong_count(server) > 1) + .is_err(), + "the controlled startup handle must deny retirement while the listing is parked" + ); + assert!(registry.get(&key).is_some()); + startup_lifecycle.begin_draining(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + startup_lifecycle.wait_for_idle(), + ) + .await + .expect("startup owner must release its handle while the listing is parked"); + let (owner, retired) = registry + .retire_lru_ready_under_graph_pressure(|server| Arc::strong_count(server) > 1) + .expect("a parked graph listing must not lease the idle server") + .expect("the idle owner must be eligible for retirement"); + assert_eq!(owner, key.owner); + assert_eq!(retired.len(), 1); + let (retired_key, retired_server) = retired.into_iter().next().expect("retired server"); + assert_eq!(retired_key, key); assert_eq!( - Arc::strong_count(&server), - handles, - "a parked graph listing must hold no server handle" + Arc::as_ptr(&retired_server), + server_witness.as_ptr(), + "retirement must transfer the exact listed server" ); + assert!(registry.get(&key).is_none()); + assert!(registry.get_route(&route).is_none()); + registry.insert_route(route, retired_key, retired_server); drop(registry); drop(swap); - assert_eq!(listing.await.len(), 1); + let listed = listing.await; + assert_eq!(listed.len(), 1); + assert!(Arc::ptr_eq(&listed[0], &graph)); + drop((listed, graph)); + engine.shutdown_all().await; } /// A mounted route alias answers without re-running registry admission: the