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 <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-08-05 21:42:51 -06:00
co-authored by Carl
parent 6971822eee
commit a9b648b448
3 changed files with 271 additions and 172 deletions
+65 -132
View File
@@ -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<ManagedAgentSummary, String> {
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::<AppState>();
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::<AppState>();
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;
@@ -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<ManagedAgentSummary, String> {
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(),
)
}
@@ -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<ManagedAgentRecord> {
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<ManagedAgentRecord, String> {
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<ManagedAgentSummary, String> {
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();