From a7d1357c849d0276965dc6007967ca7c459d4df3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 7 Sep 2026 12:29:31 -0400 Subject: [PATCH] fix(desktop): integrate lifecycle with captured launch admission Use producer preflight authority and shared captured Start/Restart admission. Capture the existing explicit Start Stop fence and runtime generation before await; keep probes and Restart without resume authority and preserve Failed after destructive Stop. Broker remains provisioning-unavailable. Native compilation remains gated on CI-only draft authorization; local formatting and integrated frontend checks pass. Signed-off-by: Logan Johnson --- .../src/commands/desktop_lifecycle.rs | 39 ++++---- .../desktop_lifecycle/configurations.rs | 90 +++++++++++++++---- desktop/src/features/agents/AGENTS.md | 8 +- 3 files changed, 102 insertions(+), 35 deletions(-) diff --git a/desktop/src-tauri/src/commands/desktop_lifecycle.rs b/desktop/src-tauri/src/commands/desktop_lifecycle.rs index 156e5f58c4..e709ff5a2c 100644 --- a/desktop/src-tauri/src/commands/desktop_lifecycle.rs +++ b/desktop/src-tauri/src/commands/desktop_lifecycle.rs @@ -339,7 +339,7 @@ fn execute( conn: &rusqlite::Connection, owner: &str, request: &Request, - prepared: Result<&managed_agents::runtime_configurations::PreparedLaunch, &Outcome>, + prepared: Result<&configurations::CapturedLaunch, &Outcome>, ) -> Result { let target = &request.target; if request.configuration.is_none() { @@ -375,37 +375,40 @@ fn execute( community: &target.community, agent: &target.agent, }, - plan.record(), + plan.plan.record(), ) { Ok(b) => b, Err(outcome) => return Ok(outcome), }; - if request.action == Action::Restart - && managed_agents::stop_pair_locked( - target.agent.clone(), - target.community.clone(), - app.clone(), - ) - .is_err() - { - return Ok(Outcome::Failed); - } - // Existing transition lock spans final intent check, ordinary launch and registration. + // Shared admission validates the captured Stop fence and generation BEFORE + // destructive Restart. The transition lock spans that Stop, launch and receipt. if placement::blocked(conn, &target.agent, &target.desktop)? { return Ok(Outcome::Unknown); } - match managed_agents::start_pair_prepared_locked( + match managed_agents::start_pair_captured_locked( target.agent.clone(), target.community.clone(), true, None, - true, + plan.resume.as_ref(), Some(&broker), - Some(plan), + &plan.plan, + false, // Explicit wire reference, not the destination's next selection. + request.action == Action::Restart, + Some(&plan.generation), app.clone(), ) { - Ok(_) => Ok(Outcome::Running), - Err(_) => Ok(Outcome::Failed), + Ok(status) + if status.running_configuration == request.configuration + && !matches!( + status.lifecycle, + managed_agents::ManagedAgentRuntimeLifecycle::Failed + | managed_agents::ManagedAgentRuntimeLifecycle::Stopped + ) => + { + Ok(Outcome::Running) + } + Ok(_) | Err(_) => Ok(Outcome::Failed), } } diff --git a/desktop/src-tauri/src/commands/desktop_lifecycle/configurations.rs b/desktop/src-tauri/src/commands/desktop_lifecycle/configurations.rs index 0ffb91f2d6..39bf5b07f5 100644 --- a/desktop/src-tauri/src/commands/desktop_lifecycle/configurations.rs +++ b/desktop/src-tauri/src/commands/desktop_lifecycle/configurations.rs @@ -3,6 +3,13 @@ use super::*; use buzz_core_pkg::desktop_lifecycle::{CatalogPage, Observation, RuntimeConfigurationSummary}; use managed_agents::runtime_configurations::{self as configurations, PreparedLaunch}; +/// The shared launch plan and its pre-await lifecycle fences; never wire data. +pub(super) struct CapturedLaunch { + pub(super) plan: PreparedLaunch, + pub(super) resume: Option, + pub(super) generation: Option, +} + fn record(app: &AppHandle, agent: &str) -> Result { let state = app.state::(); let _store = state @@ -19,16 +26,51 @@ pub(super) async fn prepare( app: &AppHandle, owner: &str, request: &Request, -) -> Result { +) -> Result { let reference = request.configuration.as_ref().ok_or(Outcome::Ineligible)?; - let record = record(app, &request.target.agent).map_err(|_| Outcome::Ineligible)?; - let plan = configurations::prepare_for_app( - app, - &record, - Some(reference), - owner, - &request.target.community, - ) + let mut captured = (|| -> Result { + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + scope(app, &state, owner, &request.target.community)?; + let record = record(app, &request.target.agent)?; + let plan = configurations::prepare_for_app( + app, + &record, + Some(reference), + owner, + &request.target.community, + )?; + let key = managed_agents::ManagedAgentRuntimeKey::new( + &request.target.agent, + &request.target.community, + )?; + // Only explicit Start can supersede an older Stop. Capture its existing + // shared fence before await; probes and Restart receive no resume authority. + let resume = if request.action == Action::Start { + Some(managed_agents::remote_stop::capture_resume( + app, + &key, + &request.target.community, + owner, + )?) + } else { + None + }; + let generation = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())? + .get(&key) + .map(|runtime| runtime.start_nonce.clone()); + Ok(CapturedLaunch { + plan, + resume, + generation, + }) + })() .map_err(|_| Outcome::Ineligible)?; // Do not advertise an eligible Switch that is known to fail after source Stop. broker_launch::provision( @@ -37,20 +79,38 @@ pub(super) async fn prepare( community: &request.target.community, agent: &request.target.agent, }, - plan.record(), + captured.plan.record(), )?; - configurations::preflight_prepared(app, &plan, owner, &request.target.community) - .await - .map_err(|_| Outcome::Ineligible)?; - Ok(plan) + configurations::preflight_with( + &mut captured.plan, + owner, + &request.target.community, + false, + |model, allow| async move { + #[cfg(feature = "mesh-llm")] + { + crate::commands::ensure_relay_mesh_for_record(app, model.as_deref(), allow).await + } + #[cfg(not(feature = "mesh-llm"))] + { + let _ = (app, model, allow); + Ok(()) + } + }, + ) + .await + .map_err(|_| Outcome::Ineligible)?; + Ok(captured) } pub(super) fn revalidate( app: &AppHandle, owner: &str, request: &Request, - plan: &PreparedLaunch, + captured: &CapturedLaunch, ) -> Result<(), String> { + let plan = &captured.plan; + plan.require_preflight()?; plan.check_scope(Some(owner), &request.target.community)?; if plan.configuration().as_ref() != request.configuration.as_ref() { return Err("Prepared configuration does not match the request".into()); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index bc048f8b2e..b95e1ccd7b 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -463,8 +463,12 @@ confirms source Stop, then requests a fresh Start of that exact revision. Prefli and Catalog cannot write placement intent or stop a process. The native consumer uses ordinary asynchronous preflight outside the transition lock, revalidates the immutable plan inside it, and delegates to the shared prepared launcher under the -existing admission/Stop fences. No second spawn path or launch authority is allowed. -Missing/edited targets fail rather than substitute another configuration. Remote +existing admission/Stop fences. Only explicit Start captures the shared Stop-fence +resume ticket before preflight; probes and Restart never receive that authority. +Shared admission checks the captured runtime generation before destructive Restart, +and a post-Stop Failed status must never become a Running response. No second spawn +path or launch authority is allowed. Missing/edited targets fail rather than +substitute another configuration. Remote keyless provisioning remains its existing independent fail-closed gate; a known provisioning failure must exclude Switch before source Stop.