From 1331a4b3d3bc2c714856ed4348212e55b0975cd0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 30 Sep 2026 01:34:36 +0000 Subject: [PATCH] fix(feedback): publish the advisory mount's own typed state --- .../src/invocation.rs | 3 +- .../src/invocation/dispatch.rs | 7 +- .../src/invocation/feedback.rs | 46 +++-- crates/tracedecay-daemon-service/src/lib.rs | 3 +- .../advisory_cycle_language_journey_test.rs | 102 ++++++++++++ .../project_open_owners/advisory_runtime.rs | 59 ++++--- .../advisory_runtime/deferred.rs | 157 ++++++++++++++++-- 7 files changed, 320 insertions(+), 57 deletions(-) diff --git a/crates/tracedecay-daemon-service/src/invocation.rs b/crates/tracedecay-daemon-service/src/invocation.rs index dc84213a80..a4e26a0a2c 100644 --- a/crates/tracedecay-daemon-service/src/invocation.rs +++ b/crates/tracedecay-daemon-service/src/invocation.rs @@ -233,7 +233,8 @@ pub use work_routing::DaemonWorkProposalRoutingAuthorityV1; pub use feedback::{ DaemonAdvisoryCycleInvocationFuture, DaemonAdvisoryCycleInvocationOwner, DaemonAdvisoryCycleInvocationPort, DaemonAdvisoryCycleInvocationRequest, - DaemonAdvisoryCycleMountFuture, DaemonAdvisoryCycleMountV1, DaemonFeedbackInvocationOwner, + DaemonAdvisoryCycleMountChangedFuture, DaemonAdvisoryCycleMountFuture, + DaemonAdvisoryCycleMountV1, DaemonFeedbackInvocationOwner, DaemonFeedbackProximityInvocationFuture, DaemonFeedbackProximityInvocationRequest, advisory_cycle_invocation_result, daemon_operation_event_authority, feedback_proximity_invocation_result, diff --git a/crates/tracedecay-daemon-service/src/invocation/dispatch.rs b/crates/tracedecay-daemon-service/src/invocation/dispatch.rs index 4acf2a3785..ef61eea563 100644 --- a/crates/tracedecay-daemon-service/src/invocation/dispatch.rs +++ b/crates/tracedecay-daemon-service/src/invocation/dispatch.rs @@ -513,7 +513,12 @@ impl DaemonInvocationService { cancellation, } => { let advisory_cycle = match advisory_cycle { - Some(owner) if owner.service.mount().await == DaemonAdvisoryCycleMountV1::Answers => { + Some(owner) + if matches!( + owner.service.mount().await, + DaemonAdvisoryCycleMountV1::Answers + ) => + { Some(owner) } _ => { diff --git a/crates/tracedecay-daemon-service/src/invocation/feedback.rs b/crates/tracedecay-daemon-service/src/invocation/feedback.rs index 9fbb9ad3c5..0766e3fc4e 100644 --- a/crates/tracedecay-daemon-service/src/invocation/feedback.rs +++ b/crates/tracedecay-daemon-service/src/invocation/feedback.rs @@ -109,13 +109,16 @@ pub type DaemonFeedbackProximityInvocationFuture<'a> = Pin< >; /// Whether an advisory-cycle owner answers for its project now, or stands in -/// for a full cycle a ready sealed generation is mounting. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// for a full cycle its mount is still publishing. pub enum DaemonAdvisoryCycleMountV1 { Answers, - Mounting, + /// Resolves when the owner's own mount state next moves, so a waiting + /// request re-reads it instead of holding out for its deadline. + Mounting(DaemonAdvisoryCycleMountChangedFuture), } +pub type DaemonAdvisoryCycleMountChangedFuture = Pin + Send>>; + pub type DaemonAdvisoryCycleMountFuture<'a> = Pin + Send + 'a>>; @@ -960,11 +963,12 @@ impl DaemonInvocationService { /// The owner that answers an advisory-cycle request for `project_root`. /// /// A reopened project serves its first requests while project open is - /// still publishing owners, and then while a ready sealed generation - /// mounts the full cycle behind a placeholder. Those requests wait for the - /// publication within their own deadline instead of failing a call a - /// retry would answer. A finished publication without an owner, or an - /// owner that answers, returns at once. + /// still publishing owners, and then while its mount publishes the full + /// cycle behind a placeholder. Those requests wait for the publication + /// within their own deadline instead of failing a call a retry would + /// answer, and re-read the placeholder whenever its mount state moves. A + /// finished publication without an owner, or an owner that answers, + /// returns at once. #[hotpath::measure(label = "daemon.service.feedback.advisory_owner_wait", future = true)] pub(super) async fn answering_advisory_cycle_owner( &self, @@ -975,18 +979,30 @@ impl DaemonInvocationService { loop { let (owner, publication, mut changed) = self.project_runtimes.advisory_cycle_view(project_root); - let waits = match &owner { - Some(owner) => owner.service.mount().await == DaemonAdvisoryCycleMountV1::Mounting, - None => publication == Some(ProjectRuntimePublicationStateV1::Warming), + let mount_changed: DaemonAdvisoryCycleMountChangedFuture = match &owner { + Some(current) => match current.service.mount().await { + DaemonAdvisoryCycleMountV1::Answers => return owner, + DaemonAdvisoryCycleMountV1::Mounting(mount_changed) => mount_changed, + }, + None if publication == Some(ProjectRuntimePublicationStateV1::Warming) => { + Box::pin(std::future::pending()) + } + None => return owner, }; let remaining_micros = deadline.expires_at.0.saturating_sub(now_micros().0); - if !waits || remaining_micros <= 0 { + if remaining_micros <= 0 { return owner; } let remaining = Duration::from_micros(remaining_micros.unsigned_abs()); - match tokio::time::timeout(remaining, changed.changed()).await { - Ok(Ok(())) => {} - Ok(Err(_)) | Err(_) => return owner, + let woke = tokio::time::timeout(remaining, async { + tokio::select! { + published = changed.changed() => published.is_ok(), + () = mount_changed => true, + } + }) + .await; + if !matches!(woke, Ok(true)) { + return owner; } } } diff --git a/crates/tracedecay-daemon-service/src/lib.rs b/crates/tracedecay-daemon-service/src/lib.rs index ecbd94598b..b4e5e947df 100644 --- a/crates/tracedecay-daemon-service/src/lib.rs +++ b/crates/tracedecay-daemon-service/src/lib.rs @@ -91,7 +91,8 @@ pub use invocation::{ BoundedHookOrchestratorV1, ConfigurationRuntimeRefreshFuture, ConfigurationRuntimeRefreshPort, DaemonAdvisoryCycleInvocationFuture, DaemonAdvisoryCycleInvocationOwner, DaemonAdvisoryCycleInvocationPort, DaemonAdvisoryCycleInvocationRequest, - DaemonAdvisoryCycleMountFuture, DaemonAdvisoryCycleMountV1, DaemonAdvisoryRuntimeRegistrar, + DaemonAdvisoryCycleMountChangedFuture, DaemonAdvisoryCycleMountFuture, + DaemonAdvisoryCycleMountV1, DaemonAdvisoryRuntimeRegistrar, DaemonAdvisoryRuntimeRegistrationError, DaemonConfigurationGrantAuthority, DaemonConfigurationRuntimeRegistrar, DaemonContextScoutRuntimeRegistrar, DaemonContextScoutRuntimeRegistrationError, DaemonFeedbackInvocationOwner, diff --git a/crates/tracedecay/src/daemon/production_harness/advisory_cycle_language_journey_test.rs b/crates/tracedecay/src/daemon/production_harness/advisory_cycle_language_journey_test.rs index 06601a05b5..ab00b68a31 100644 --- a/crates/tracedecay/src/daemon/production_harness/advisory_cycle_language_journey_test.rs +++ b/crates/tracedecay/src/daemon/production_harness/advisory_cycle_language_journey_test.rs @@ -156,6 +156,108 @@ async fn first_advisory_cycle_after_a_reopen_answers_without_a_retry() { harness.shutdown().await; } +/// A reopened checkout whose deferred advisory mount fails terminally names +/// that failure as soon as the mount gives up, instead of holding the request +/// to its deadline and then answering the retryable pre-mount state. +#[tokio::test(flavor = "multi_thread")] +#[hotpath::skip] +async fn reopened_checkout_whose_advisory_mount_failed_names_the_failure() { + let isolation = tempfile::TempDir::new().expect("production harness isolation"); + let project = isolation.path().join("project"); + std::fs::create_dir_all(project.join("src")).expect("project source dir"); + std::fs::write( + project.join("src/lib.rs"), + "pub fn failed_mount(value: u32) -> u32 {\n value * 2\n}\n", + ) + .expect("rust source"); + git(&project, &["init", "--quiet", "-b", "feature/failed-mount"]); + git(&project, &["add", "."]); + git(&project, &["commit", "--quiet", "-m", "seed failed mount"]); + + let harness = ProductionProjectCompositionHarnessV1::open(isolation.path(), [project.clone()]) + .await + .expect("first production composition"); + let (refused, settled) = settled_advisory_cycle(&harness, &project, "src/lib.rs").await; + assert!(!refused, "the first open must mount the cycle: {settled}"); + let project_id = tracedecay_domain::ProjectId::new( + harness + .project_id(&project) + .await + .expect("registered project id"), + ) + .expect("project id"); + harness.shutdown().await; + + // A foreign live hook-notice queue for this checkout refuses the reopened + // advisory owner its registration on every attempt. + let canonical_project = std::fs::canonicalize(&project).expect("canonical project"); + let scope = + tracedecay_code_index_runtime::resolved_scope_for_project(&canonical_project, &project_id) + .expect("resolved scope"); + let (hook_project, hook_worktree) = tracedecay_agent_hosts::hooks::hook_scope_locators(&scope); + let foreign = tracedecay_application::advisory::AdvisoryHookNoticeQueueV1::new( + tracedecay_application::feedback::resolve_project_feedback_scope_v1( + &canonical_project, + &scope, + ) + .expect("feedback scope"), + ); + assert!( + tracedecay_application::advisory::register_advisory_hook_notice_queue( + hook_project, + hook_worktree, + &foreign, + ) + ); + + let harness = ProductionProjectCompositionHarnessV1::open(isolation.path(), [project.clone()]) + .await + .expect("reopened production composition"); + let document_uri = url::Url::from_file_path(project.join("src/lib.rs")) + .expect("document uri") + .to_string(); + let request_deadline = tracedecay_mcp::tools::binding::canonical_tool_dispatch_ceiling( + "tracedecay_feedback_advisory_cycle", + ) + .expect("advisory cycle dispatch deadline"); + let started = Instant::now(); + let response = harness + .call_tool( + &project, + "tracedecay_feedback_advisory_cycle", + json!({"document_uri": document_uri, "format": "json"}), + ) + .await + .expect("advisory cycle call"); + let elapsed = started.elapsed(); + let (refused, answer) = tool_answer(&response); + assert!(refused, "a failed mount cannot run the cycle: {answer}"); + let problem = &answer["problem"]; + assert_eq!( + problem["code"], + json!("feedback.advisory-cycle.mount-failed"), + "{answer}" + ); + assert_eq!(problem["kind"], json!("unavailable"), "{answer}"); + assert_eq!( + problem["legal_actions"], + json!(["contact_administrator"]), + "{answer}" + ); + assert!( + elapsed < request_deadline / 2, + "the failure was held for {elapsed:?} of a {request_deadline:?} deadline: {answer}" + ); + harness.shutdown().await; + assert!( + tracedecay_application::advisory::unregister_advisory_hook_notice_queue( + hook_project, + hook_worktree, + &foreign, + ) + ); +} + #[tokio::test(flavor = "multi_thread")] #[hotpath::skip] async fn checkout_without_indexable_source_names_why_the_advisory_cycle_cannot_run() { diff --git a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs index 122b10c77d..001eedbc90 100644 --- a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs +++ b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use sha2::{Digest, Sha256}; +use tokio::sync::watch; use tracedecay_application::advisory::github_runtime::{ ConfiguredGitHubSourceAccessAuthorityV1, GitHubDiscoveryControlV1, GitHubExactCommitDiscoveryOutcomeV1, GitHubProviderLifecycleV1, GitHubSourceAccessAuthorityV1, @@ -115,6 +116,7 @@ mod deferred; mod model; pub(super) use deferred::wait_for_generation_change; +use deferred::{AdvisoryMountPublisherV1, AdvisoryMountStateV1}; pub(crate) use model::ProjectOpenDependentOwnerState; use model::advisory_monotonic_deadline; #[cfg(test)] @@ -1358,12 +1360,14 @@ pub(in crate::daemon) async fn register_project_open_dependent_owners( return Ok(()); } register_project_delivery_read_authority(invocation, project_root, &state).await?; + let (mount_publisher, mount_state) = AdvisoryMountPublisherV1::channel(); // Proximity is a typed read over session/git correlation and the current // code graph. Like Delivery, it must be available before a sealed // generation mounts the full advisory cycle, otherwise HTTP // `/api/feedback/proximity` answers `feedback.proximity.unavailable` // while Work/application are already serving. - register_project_proximity_read_authority(invocation, project_root, &state).await?; + register_project_proximity_read_authority(invocation, project_root, &state, mount_state) + .await?; let indexed_generation = selected_feedback_generation(invocation, project_root, &state.scope).await; if let (Some(lsp_session_factory), Some(indexed_generation)) = @@ -1398,9 +1402,11 @@ pub(in crate::daemon) async fn register_project_open_dependent_owners( invocation.clone(), project_root.to_path_buf(), state, + mount_publisher, ); return Ok(()); } + mount_publisher.publish(AdvisoryMountStateV1::Mounted); tracing::info!( event = "project_open_owner_phase", project = %project_root.display(), @@ -1450,6 +1456,7 @@ pub(in crate::daemon) async fn register_project_open_dependent_owners( invocation.clone(), project_root.to_path_buf(), state, + mount_publisher, ); Ok(()) } @@ -1984,6 +1991,7 @@ async fn register_project_proximity_read_authority( invocation: &DaemonInvocationState, project_root: &Path, state: &ProjectOpenDependentOwnerState, + mount_state: watch::Receiver, ) -> Result<()> { let feedback_scope = match resolve_project_feedback_scope_v1(project_root, &state.scope) { Ok(scope) => scope, @@ -2025,6 +2033,7 @@ async fn register_project_proximity_read_authority( feedback_scope, proximity_read, code_index_schedulers: invocation.code_index_schedulers.clone(), + mount_state, }) as Arc, ); invocation @@ -2053,6 +2062,7 @@ struct ProjectOpenProximityReadOwnerV1 { feedback_scope: FeedbackScopeV1, proximity_read: FeedbackProximityReadRuntimeV1, code_index_schedulers: CodeIndexSchedulerRegistryV1, + mount_state: watch::Receiver, } impl DaemonAdvisoryCycleInvocationPort for ProjectOpenProximityReadOwnerV1 { @@ -2078,6 +2088,9 @@ impl DaemonAdvisoryCycleInvocationPort for ProjectOpenProximityReadOwnerV1 { LegalAction::CorrectRequest, )); } + if let AdvisoryMountStateV1::Failed(failure) = *self.mount_state.borrow() { + return Err(failure.problem()); + } if self .code_index_schedulers .reconciled_without_generation_for_scope(&self.scope) @@ -2096,35 +2109,27 @@ impl DaemonAdvisoryCycleInvocationPort for ProjectOpenProximityReadOwnerV1 { }) } - /// With a ready sealed generation the deferred mount is already upgrading - /// this owner, so a request waits for that publication instead of taking - /// the retryable warming answer. A restart serves its retained generation - /// before its first pass proves the checkout still matches it; that - /// proof, or the successor it finds owed, is what upgrades this owner, so - /// a retained owner waits as well. - // ponytail: a deferred mount that fails terminally leaves this owner in - // place, so such a request waits out its own deadline before the warming - // answer; surfacing the terminal mount failure here would end it early. + /// While the project-open mount is upgrading this owner a request waits + /// for that publication instead of taking the retryable warming answer; + /// a mount still awaiting its first generation, or one that failed, + /// answers at once. fn mount(&self) -> DaemonAdvisoryCycleMountFuture<'_> { + let mut mount_state = self.mount_state.clone(); Box::pin(async move { - if code_index_disabled_for_scope(&self.code_index_schedulers, &self.scope) { - return DaemonAdvisoryCycleMountV1::Answers; - } - if self - .code_index_schedulers - .retained_text_owner_freshness_for_scope(&self.scope) - .await - .is_some() - || self - .code_index_schedulers - .latest_feedback_generation_for_scope(&self.project_root, &self.scope) - .await - .is_some() - { - DaemonAdvisoryCycleMountV1::Mounting - } else { - DaemonAdvisoryCycleMountV1::Answers + let state = *mount_state.borrow_and_update(); + match state { + AdvisoryMountStateV1::AwaitingGeneration | AdvisoryMountStateV1::Failed(_) => { + return DaemonAdvisoryCycleMountV1::Answers; + } + AdvisoryMountStateV1::Mounting | AdvisoryMountStateV1::Mounted => {} } + DaemonAdvisoryCycleMountV1::Mounting(Box::pin(async move { + // Only a `Mounted` publisher closes without a later state; the + // replacement owner's publication wakes that waiter instead. + if mount_state.changed().await.is_err() { + std::future::pending::<()>().await; + } + })) }) } diff --git a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime/deferred.rs b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime/deferred.rs index ebede9f2c6..38801f8f4d 100644 --- a/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime/deferred.rs +++ b/crates/tracedecay/src/daemon/project_open_owners/advisory_runtime/deferred.rs @@ -10,9 +10,108 @@ use super::{ register_production_feedback_and_advisory, register_production_feedback_cycle, selected_feedback_generation, }; -use tracedecay_contracts::now_micros; +use tracedecay_contracts::{ + ApplicationProblem, ApplicationUnavailableClassV1, LegalAction, RetryDirective, SafeDiagnostic, + now_micros, +}; use tracedecay_runtime_core::logging::log_daemon_event; +/// What the project-open advisory mount is doing for its checkout. The +/// pre-mount placeholder owner answers from this state alone. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::daemon::project_open_owners) enum AdvisoryMountStateV1 { + /// No generation this mount could admit exists yet. + AwaitingGeneration, + /// A sealed generation, or a retained one awaiting its source proof, is + /// being mounted. + Mounting, + /// The full advisory owner replaced the placeholder. + Mounted, + Failed(AdvisoryMountFailureV1), +} + +/// Why the advisory mount ended without publishing the full owner. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::daemon::project_open_owners) enum AdvisoryMountFailureV1 { + CodeIndexDisabled, + FeedbackCycle, + LspScopeGrant, + LspOwner, + AdvisoryOwner, + /// The mount task stopped (daemon shutdown or a closed generation + /// channel) before it reached a verdict. + Abandoned, +} + +impl AdvisoryMountFailureV1 { + pub(in crate::daemon::project_open_owners) fn problem(self) -> ApplicationProblem { + let message = match self { + Self::CodeIndexDisabled => { + "The code index was disabled when this checkout opened, so the advisory feedback cycle never mounted" + } + Self::FeedbackCycle => { + "The advisory feedback cycle could not mount its feedback cycle over the sealed code-index generation" + } + Self::LspScopeGrant => { + "The advisory feedback cycle could not obtain its language-server workspace grant" + } + Self::LspOwner => { + "The advisory feedback cycle could not mount its language-server owner" + } + Self::AdvisoryOwner => { + "The advisory feedback cycle owner could not be published for this checkout" + } + Self::Abandoned => { + "The advisory feedback cycle mount stopped before publishing its owner" + } + }; + ApplicationProblem::Unavailable { + classification: ApplicationUnavailableClassV1::Authority, + diagnostic: SafeDiagnostic { + code: "feedback.advisory-cycle.mount-failed".to_owned(), + message: message.to_owned(), + }, + retry: RetryDirective::Never, + legal_actions: vec![LegalAction::ContactAdministrator], + detail: None, + } + } +} + +/// The advisory mount's sole write handle on its published state. Dropping it +/// before a verdict publishes `Failed(Abandoned)`, so no placeholder waits on +/// a mount that no longer runs. +pub(in crate::daemon::project_open_owners) struct AdvisoryMountPublisherV1( + watch::Sender, +); + +impl AdvisoryMountPublisherV1 { + pub(in crate::daemon::project_open_owners) fn channel() + -> (Self, watch::Receiver) { + let (sender, receiver) = watch::channel(AdvisoryMountStateV1::Mounting); + (Self(sender), receiver) + } + + pub(in crate::daemon::project_open_owners) fn publish(&self, state: AdvisoryMountStateV1) { + self.0.send_replace(state); + } +} + +impl Drop for AdvisoryMountPublisherV1 { + fn drop(&mut self) { + self.0.send_if_modified(|state| { + let open = matches!( + state, + AdvisoryMountStateV1::AwaitingGeneration | AdvisoryMountStateV1::Mounting + ); + if open { + *state = AdvisoryMountStateV1::Failed(AdvisoryMountFailureV1::Abandoned); + } + open + }); + } +} + /// The deferred advisory owner is a detached background task: when it gives up /// (or never sees a publication) nothing in the request path reports it, and a /// project silently serves without a feedback cycle. Record every attempt @@ -33,6 +132,7 @@ pub(super) fn spawn( invocation: DaemonInvocationState, project_root: PathBuf, mut state: ProjectOpenDependentOwnerState, + publisher: AdvisoryMountPublisherV1, ) -> bool { // Nothing user-facing may wait on a layer this route disables by contract. // With no code index there is no generation to defer to, so the wait below @@ -42,6 +142,9 @@ pub(super) fn spawn( &state.scope, ) { log_deferred_attempt(&project_root, "code_index_disabled", "terminal"); + publisher.publish(AdvisoryMountStateV1::Failed( + AdvisoryMountFailureV1::CodeIndexDisabled, + )); return false; } // Project-open schedules this owner before code-index activation. Capture @@ -59,7 +162,7 @@ pub(super) fn spawn( .subscribe_generation_publications(); let mut serving_changes = None; let mut partial_publication_retried = false; - loop { + let settled = loop { // Sealing announces durable source before its text owner is // installed. Subscribe before probing that owner so a later // installation can finish this mount without another edit. @@ -69,19 +172,25 @@ pub(super) fn spawn( .subscribe_serving_generation_changes(&project_root) .await; } + publisher.publish(AdvisoryMountStateV1::Mounting); match try_mount(&invocation, &project_root, &mut state).await { - Attempt::Terminal => return, + Attempt::Settled(settled) => break settled, Attempt::RetryPartialPublication if !partial_publication_retried => { partial_publication_retried = true; tokio::task::yield_now().await; continue; } - Attempt::RetryPartialPublication => return, + Attempt::RetryPartialPublication => { + break AdvisoryMountStateV1::Failed(AdvisoryMountFailureV1::AdvisoryOwner); + } Attempt::AwaitNextPublication => { + let awaiting = awaiting_state(&invocation, &state.scope).await; + publisher.publish(awaiting); tracing::info!( event = "advisory_deferred_generation_unavailable", project = %project_root.display(), serving_watch_registered = serving_changes.is_some(), + state = ?awaiting, "waiting after exact complete-generation admission declined" ); } @@ -98,12 +207,29 @@ pub(super) fn spawn( return; } partial_publication_retried = false; - } + }; + publisher.publish(settled); }, label = "daemon.project.owners.advisory_deferred" )) } +/// A retained generation's pending source proof, or the successor it finds +/// owed, publishes the generation this mount then admits, so that wait is +/// still a mount in progress rather than a wait for a first generation. +async fn awaiting_state( + invocation: &DaemonInvocationState, + scope: &tracedecay_contracts::ResolvedScope, +) -> AdvisoryMountStateV1 { + invocation + .code_index_schedulers + .retained_text_owner_freshness_for_scope(scope) + .await + .map_or(AdvisoryMountStateV1::AwaitingGeneration, |_| { + AdvisoryMountStateV1::Mounting + }) +} + /// Waits for the next signal that `project_root` may have a new generation: /// a sealed publication for this root, a serving swap, a retained seat, or a /// root mount. `false` when the scheduler's publication channel closed. @@ -136,7 +262,8 @@ pub(in crate::daemon::project_open_owners) async fn wait_for_generation_change( #[derive(Clone, Copy, PartialEq, Eq)] enum Attempt { - Terminal, + /// `Mounted` or `Failed`: nothing retries this owner after it returns. + Settled(AdvisoryMountStateV1), AwaitNextPublication, RetryPartialPublication, } @@ -169,7 +296,7 @@ async fn try_mount( ) .await { - Ok(()) => Attempt::Terminal, + Ok(()) => Attempt::Settled(AdvisoryMountStateV1::Mounted), Err(_) => classify_failure(invocation, project_root, state).await, }; } @@ -242,7 +369,9 @@ async fn try_mount( "deferred advisory LSP grant is unavailable" ); log_deferred_attempt(project_root, "lsp_scope_grant_failed", &error.to_string()); - return Attempt::Terminal; + return Attempt::Settled(AdvisoryMountStateV1::Failed( + AdvisoryMountFailureV1::LspScopeGrant, + )); } }; let lsp_session_factory = match register_production_lsp_owner( @@ -267,7 +396,9 @@ async fn try_mount( "deferred advisory LSP owner could not mount" ); log_deferred_attempt(project_root, "lsp_owner_failed", &error.to_string()); - return Attempt::Terminal; + return Attempt::Settled(AdvisoryMountStateV1::Failed( + AdvisoryMountFailureV1::LspOwner, + )); } }; state.lsp_session_factory = Some(Arc::clone(&lsp_session_factory)); @@ -301,7 +432,7 @@ async fn try_mount( return classify_failure(invocation, project_root, state).await; } } - Attempt::Terminal + Attempt::Settled(AdvisoryMountStateV1::Mounted) } async fn classify_failure( @@ -332,13 +463,15 @@ async fn classify_failure( // the composition itself is missing rather than early. Nothing retries // this owner after it returns, so name that terminal state here rather // than leave a project serving without a cycle and no evidence why. - Attempt::Terminal + Attempt::Settled(AdvisoryMountStateV1::Failed( + AdvisoryMountFailureV1::FeedbackCycle, + )) }; log_deferred_attempt( project_root, "classified_failure", match attempt { - Attempt::Terminal => "terminal", + Attempt::Settled(_) => "terminal", Attempt::AwaitNextPublication => "await_next_publication", Attempt::RetryPartialPublication => "retry_partial_publication", },