From a9b648b4481fd8dfe6da682a706fb43883b7709b Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 21:29:01 -0600 Subject: [PATCH] fix(agents): preserve managed overlay lifecycle Route disk-backed overlay agents through the established preflight, provider, profile, persistence, and runtime transition paths. Materialize fresh-device local records before start so stop, delete, and shutdown can manage them. Co-authored-by: Carl Signed-off-by: Wes --- desktop/src-tauri/src/commands/agents.rs | 197 ++++++------------ .../src/commands/agents_lifecycle.rs | 110 ++++++++++ .../managed_agents/private_config_overlay.rs | 136 ++++++++---- 3 files changed, 271 insertions(+), 172 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agents_lifecycle.rs diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index d21aa56ad..88b1b8047 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -317,99 +317,6 @@ pub(super) async fn start_local_agent_pairs_with_preflight( ) } -pub(super) async fn start_local_agent_with_preflight( - app: &AppHandle, - state: &AppState, - pubkey: &str, - owner_hex: &str, - allow_fresh_create_start: bool, -) -> Result { - let record_snapshot = { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let records = load_managed_agents(app)?; - records - .iter() - .find(|record| record.pubkey == pubkey) - .cloned() - .ok_or_else(|| format!("agent {pubkey} not found"))? - }; - - if record_snapshot.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is not a local agent")); - } - - // Preflight against the same resolution spawn uses — `resolve_effective_config` - // (definition → global fallback). A linked instance's own `provider`/`model`/ - // `relay_mesh` bytes never contribute: this reads the CURRENT definition - // directly, so a definition edit that flips `provider` to/from relay-mesh - // between saves is reflected here without needing a prospective re-snapshot; - // for a global-inherited blank definition, it also folds in the global - // default, which record-byte sniffing could never see. - let personas = load_personas(app).unwrap_or_default(); - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let mesh_model_id = - crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( - &record_snapshot, - &personas, - &global, - ); - ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; - - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|e| e.to_string())?; - let record = find_managed_agent_mut(&mut records, pubkey)?; - if record.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is no longer a local agent")); - } - // Re-snapshot the persona onto the record at every spawn so the agent always - // starts with the current persona config (system_prompt, model, provider, - // runtime). This clears the "out of date" drift badge without requiring a - // delete+recreate. See `apply_persona_snapshot` for the precedence and - // env-override self-heal rules. - // Load personas once: used for snapshot application below and summary build - // at the end — avoids a second disk read for the same file in the same call. - let personas = load_personas(app).unwrap_or_default(); - if let Some(persona_id) = record.persona_id.clone() { - match personas.iter().find(|p| p.id == persona_id) { - Some(persona) => { - crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = crate::util::now_iso(); - } - None => { - return Err( - crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), - ); - } - } - } - start_managed_agent_process(app, record, &mut runtimes, Some(owner_hex))?; - save_managed_agents(app, &records)?; - if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { - retain_managed_agent_pending(app, state, saved_record); - } - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - build_managed_agent_summary( - app, - record, - &runtimes, - &personas, - &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(), - ) -} - /// Deploy an agent to a provider backend. Resolves the binary, calls deploy via /// spawn_blocking, and persists the result (backend_agent_id or last_error). /// @@ -1039,27 +946,11 @@ pub async fn start_managed_agent( // Snapshot the workspace owner pubkey for the legacy auth_tag fallback. // Read outside the records lock to keep lock ordering simple. let owner_hex = workspace_owner_hex(&state)?; - let local_records = { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - load_managed_agents(&app)? - }; - if state - .private_managed_agent_overlay - .lock() - .map_err(|error| error.to_string())? - .contains(&pubkey) - { - return crate::managed_agents::private_config_overlay::start_relay_only_agent( - &app, - &state, - &pubkey, - &owner_hex, - &local_records, - ); - } + // A fresh-device relay record needs a durable lifecycle anchor so stop, + // delete, runtime polling, and shutdown can find it. + crate::managed_agents::private_config_overlay::materialize_relay_only_agent( + &app, &state, &pubkey, + )?; enum StartTarget { Local, Provider { @@ -1091,14 +982,18 @@ pub async fn start_managed_agent( state.clear_agent_session_caches(pubkey); } - let record = find_managed_agent_mut(&mut records, &pubkey)?; + let disk_record = find_managed_agent_mut(&mut records, &pubkey)?; + let record = crate::managed_agents::private_config_overlay::resolved_local_record( + &state, + disk_record, + )?; // Resolve the effective harness for the avatar-fallback derivation in // profile reconcile (the create-time snapshot may be empty or stale for // a persona-inherited harness). let reconcile_personas = load_personas(&app).unwrap_or_default(); let reconcile_effective_command = - crate::managed_agents::record_agent_command(record, &reconcile_personas); + crate::managed_agents::record_agent_command(&record, &reconcile_personas); let reconcile = ProfileReconcileData { private_key_nsec: record.private_key_nsec.clone(), @@ -1117,7 +1012,7 @@ pub async fn start_managed_agent( StartTarget::Provider { backend: record.backend.clone(), cached_binary_path: record.provider_binary_path.clone(), - agent_json: build_deploy_payload(&app, &state, record)?, + agent_json: build_deploy_payload(&app, &state, &record)?, } }; @@ -1158,10 +1053,13 @@ pub async fn start_managed_agent( .iter() .find(|r| r.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; + let record = crate::managed_agents::private_config_overlay::resolved_local_record( + &state, record, + )?; let personas = load_personas(&app).unwrap_or_default(); build_managed_agent_summary( &app, - record, + &record, &runtimes, &personas, &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), @@ -1209,6 +1107,10 @@ pub async fn stop_managed_agent( use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); + let _transition_guard = state + .managed_agent_runtime_transition + .lock() + .map_err(|error| error.to_string())?; let _store_guard = state .managed_agents_store_lock .lock() @@ -1228,28 +1130,34 @@ pub async fn stop_managed_agent( state.clear_agent_session_caches(pubkey); } - { - let record = find_managed_agent_mut(&mut records, &pubkey)?; + let resolved_record = { + let disk_record = find_managed_agent_mut(&mut records, &pubkey)?; + let mut resolved = + crate::managed_agents::private_config_overlay::resolved_local_record( + &state, + disk_record, + )?; // Remote agents are stopped via !shutdown @mention from the frontend, - // not via this backend command. Reject the call. - if record.backend != BackendKind::Local { + // not via this backend command. Reject using the relay-resolved backend. + if resolved.backend != BackendKind::Local { return Err( "remote agents are stopped via !shutdown message, not this command".to_string(), ); } // Pair-scoped: stops only the active workspace's pair; delete and // the config-restart flows still drain every pair. - stop_managed_agent_workspace_pair(&app, record, &mut runtimes)?; - } + stop_managed_agent_workspace_pair(&app, &mut resolved, &mut runtimes)?; + crate::managed_agents::private_config_overlay::copy_lifecycle_state( + disk_record, + &resolved, + ); + resolved + }; save_managed_agents(&app, &records)?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; let personas = load_personas(&app).unwrap_or_default(); build_managed_agent_summary( &app, - record, + &resolved_record, &runtimes, &personas, &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), @@ -1270,6 +1178,10 @@ pub async fn delete_managed_agent( use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); + let _transition_guard = state + .managed_agent_runtime_transition + .lock() + .map_err(|error| error.to_string())?; { let _store_guard = state .managed_agents_store_lock @@ -1298,7 +1210,19 @@ pub async fn delete_managed_agent( // invariant — a buggy or compromised IPC caller cannot silently orphan a live // remote deployment. The frontend sends force_remote_delete: true only after // the user confirms the orphan warning. - if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { + let resolved_record = + if let Some(record) = records.iter().find(|record| record.pubkey == pubkey) { + Some( + state + .private_managed_agent_overlay + .lock() + .map_err(|error| error.to_string())? + .resolve_local_record(record), + ) + } else { + None + }; + if let Some(record) = resolved_record.as_ref() { if record.backend != BackendKind::Local && record.backend_agent_id.is_some() && !force_remote_delete.unwrap_or(false) @@ -1310,8 +1234,8 @@ pub async fn delete_managed_agent( } } - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { - stop_managed_agent_process(&app, record, &mut runtimes)?; + if let Some(mut record) = resolved_record { + stop_managed_agent_process(&app, &mut record, &mut runtimes)?; } state.clear_agent_session_caches(&pubkey); let initial_len = records.len(); @@ -1320,6 +1244,11 @@ pub async fn delete_managed_agent( return Err(format!("agent {pubkey} not found")); } save_managed_agents(&app, &records)?; + state + .private_managed_agent_overlay + .lock() + .map_err(|error| error.to_string())? + .remove(&pubkey); // Remove the agent's nsec from the keyring after the record is gone. crate::managed_agents::delete_agent_key(&pubkey); // Tombstone-after-validation: only reached past the deployed-remote @@ -1344,6 +1273,10 @@ pub async fn delete_managed_agent( // 2. Harness sees it, exits gracefully, sets presence to "offline" // 3. Desktop's existing presence polling sees "offline" — UI updates automatically // No backend Tauri command needed. Presence IS the status. +#[path = "agents_lifecycle.rs"] +mod lifecycle; +use lifecycle::start_local_agent_with_preflight; + #[path = "agents_deploy.rs"] mod deploy; pub(super) mod provider_access; diff --git a/desktop/src-tauri/src/commands/agents_lifecycle.rs b/desktop/src-tauri/src/commands/agents_lifecycle.rs new file mode 100644 index 000000000..a08c496a7 --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_lifecycle.rs @@ -0,0 +1,110 @@ +use super::*; + +pub(super) async fn start_local_agent_with_preflight( + app: &AppHandle, + state: &AppState, + pubkey: &str, + owner_hex: &str, + allow_fresh_create_start: bool, +) -> Result { + let record_snapshot = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(app)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + crate::managed_agents::private_config_overlay::resolved_local_record(state, record)? + }; + + if record_snapshot.backend != BackendKind::Local { + return Err(format!("agent {pubkey} is not a local agent")); + } + + // Preflight against the same resolution spawn uses — `resolve_effective_config` + // (definition → global fallback). A linked instance's own `provider`/`model`/ + // `relay_mesh` bytes never contribute: this reads the CURRENT definition + // directly, so a definition edit that flips `provider` to/from relay-mesh + // between saves is reflected here without needing a prospective re-snapshot; + // for a global-inherited blank definition, it also folds in the global + // default, which record-byte sniffing could never see. + let personas = load_personas(app).unwrap_or_default(); + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let mesh_model_id = + crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( + &record_snapshot, + &personas, + &global, + ); + ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; + + let _transition_guard = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + if state + .shutdown_started + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("desktop shutdown has started".into()); + } + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let disk_record = find_managed_agent_mut(&mut records, pubkey)?; + let mut resolved_record = + crate::managed_agents::private_config_overlay::resolved_local_record(state, disk_record)?; + if resolved_record.backend != BackendKind::Local { + return Err(format!("agent {pubkey} is no longer a local agent")); + } + // Re-snapshot the persona onto the resolved spawn record at every start so + // local persona state retains its established precedence without writing + // relay-owned configuration into the device-local migration record. + // Load personas once: used for snapshot application below and summary build + // at the end — avoids a second disk read for the same file in the same call. + let personas = load_personas(app).unwrap_or_default(); + if let Some(persona_id) = resolved_record.persona_id.clone() { + match personas.iter().find(|p| p.id == persona_id) { + Some(persona) => { + crate::managed_agents::persona_events::apply_persona_snapshot( + &mut resolved_record, + persona, + ); + resolved_record.updated_at = crate::util::now_iso(); + } + None => { + return Err( + crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), + ); + } + } + } + start_managed_agent_process(app, &mut resolved_record, &mut runtimes, Some(owner_hex))?; + // Persist operational lifecycle metadata only. Relay-owned configuration + // remains an in-memory overlay and is never copied over device-local fields. + crate::managed_agents::private_config_overlay::copy_lifecycle_state( + disk_record, + &resolved_record, + ); + save_managed_agents(app, &records)?; + // Retain the relay-resolved configuration. The projection equality guard + // makes a runtime-only start a no-op, while avoiding resurrection of stale + // disk config when this device is following a newer relay snapshot. + retain_managed_agent_pending(app, state, &resolved_record); + build_managed_agent_summary( + app, + &resolved_record, + &runtimes, + &personas, + &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(), + ) +} diff --git a/desktop/src-tauri/src/managed_agents/private_config_overlay.rs b/desktop/src-tauri/src/managed_agents/private_config_overlay.rs index 2ff9f15e8..eb99810d2 100644 --- a/desktop/src-tauri/src/managed_agents/private_config_overlay.rs +++ b/desktop/src-tauri/src/managed_agents/private_config_overlay.rs @@ -3,10 +3,9 @@ use std::collections::{BTreeMap, HashMap}; use buzz_core_pkg::private_managed_agent::Payload; use super::{ - build_managed_agent_summary, load_personas, start_managed_agent_process, validate_respond_to_allowlist, validate_user_env_keys, BackendKind, ManagedAgentRecord, - ManagedAgentSummary, RelayMeshConfig, RespondTo, DEFAULT_ACP_COMMAND, - DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + RelayMeshConfig, RespondTo, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, + DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }; #[derive(Clone)] @@ -210,22 +209,26 @@ impl PrivateConfigOverlay { self.0.remove(pubkey); } - pub(crate) fn contains(&self, pubkey: &str) -> bool { - self.0.contains_key(pubkey) + pub(crate) fn resolve_local_record(&self, record: &ManagedAgentRecord) -> ManagedAgentRecord { + let mut resolved = record.clone(); + if let Some(patch) = self.0.get(&record.pubkey) { + patch.apply(&mut resolved); + } + resolved } - pub(crate) fn resolved_record( + pub(crate) fn materialize_relay_only_record( &self, pubkey: &str, local: &[ManagedAgentRecord], ) -> Option { - let patch = self.0.get(pubkey)?; - let mut record = local - .iter() - .find(|record| record.pubkey == pubkey) - .cloned() - .unwrap_or_else(|| patch.fresh_record()); - patch.apply(&mut record); + if local.iter().any(|record| record.pubkey == pubkey) { + return None; + } + let mut record = self.0.get(pubkey)?.fresh_record(); + // Persona definitions are device-local. A fresh device can still run the + // complete relay snapshot, but must not bind it to an absent local persona. + record.persona_id = None; Some(record) } @@ -248,40 +251,66 @@ impl PrivateConfigOverlay { } } -pub(crate) fn start_relay_only_agent( +pub(crate) fn resolved_local_record( + state: &crate::app_state::AppState, + record: &ManagedAgentRecord, +) -> Result { + state + .private_managed_agent_overlay + .lock() + .map_err(|error| error.to_string()) + .map(|overlay| overlay.resolve_local_record(record)) +} + +pub(crate) fn copy_lifecycle_state( + destination: &mut ManagedAgentRecord, + source: &ManagedAgentRecord, +) { + destination.runtime_pid = source.runtime_pid; + destination + .last_started_at + .clone_from(&source.last_started_at); + destination + .last_stopped_at + .clone_from(&source.last_stopped_at); + destination.last_exit_code = source.last_exit_code; + destination.last_error.clone_from(&source.last_error); + destination.last_error_code = source.last_error_code; +} + +pub(crate) fn materialize_relay_only_agent( app: &tauri::AppHandle, state: &crate::app_state::AppState, pubkey: &str, - owner_hex: &str, - local_records: &[ManagedAgentRecord], -) -> Result { - let mut record = state +) -> Result<(), String> { + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|error| error.to_string())?; + if state + .shutdown_started + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("desktop shutdown has started".into()); + } + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut records = super::load_managed_agents(app)?; + let relay_only = state .private_managed_agent_overlay .lock() .map_err(|error| error.to_string())? - .resolved_record(pubkey, local_records) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - if local_records.iter().all(|local| local.pubkey != pubkey) { - record.persona_id = None; + .materialize_relay_only_record(pubkey, &records); + if let Some(record) = relay_only { + if record.backend != BackendKind::Local { + return Err("relay-only provider agents cannot be started on this device".into()); + } + records.push(record); + super::save_managed_agents(app, &records)?; } - if record.backend != BackendKind::Local { - return Err("relay-only provider agents cannot be started on this device".into()); - } - let personas = load_personas(app).unwrap_or_default(); - super::try_record_agent_command(&record, &personas) - .map_err(|error| super::user_facing_harness_error(&error))?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|e| e.to_string())?; - start_managed_agent_process(app, &mut record, &mut runtimes, Some(owner_hex))?; - build_managed_agent_summary( - app, - &record, - &runtimes, - &personas, - &super::load_global_agent_config(app).unwrap_or_default(), - ) + Ok(()) } #[cfg(test)] @@ -352,6 +381,33 @@ mod tests { assert_eq!(local, original); } + #[test] + fn materializes_only_relay_only_record_and_preserves_disk_overlay() { + let mut overlay = PrivateConfigOverlay::default(); + overlay.insert(payload("aa", "relay local")).unwrap(); + overlay.insert(payload("bb", "relay only")).unwrap(); + let mut local = overlay.0["aa"].fresh_record(); + local.name = "disk".into(); + local.private_key_nsec = "device-local-key".into(); + + let resolved = overlay.resolve_local_record(&local); + assert_eq!(resolved.name, "relay local"); + assert_eq!(resolved.private_key_nsec, "nsec-test"); + assert_eq!(local.name, "disk"); + assert_eq!(local.private_key_nsec, "device-local-key"); + assert!(overlay + .materialize_relay_only_record("aa", std::slice::from_ref(&local)) + .is_none()); + + let relay_only = overlay + .materialize_relay_only_record("bb", &[local]) + .unwrap(); + assert_eq!(relay_only.name, "relay only"); + assert_eq!(relay_only.private_key_nsec, "nsec-test"); + assert_eq!(relay_only.backend, BackendKind::Local); + assert!(relay_only.persona_id.is_none()); + } + #[test] fn rejected_patch_preserves_cached_value_and_clear_drops_scope() { let mut overlay = PrivateConfigOverlay::default();