Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -555,9 +555,10 @@ async fn restart_single_agent_after_install(
if record.backend != BackendKind::Local {
return Err(format!("agent {pubkey_owned} is no longer a local agent"));
}
let runtime_keys =
crate::managed_agents::managed_agent_runtime_keys(&runtimes, &pubkey_owned);
if runtime_keys.is_empty() {
// Dial targets (configured spellings) must be collected before the stop.
let restart_targets =
crate::managed_agents::managed_agent_restart_targets(&runtimes, &pubkey_owned);
if restart_targets.is_empty() {
return Err(format!(
"agent {pubkey_owned} no longer has a live pair runtime after sync"
));
Expand Down Expand Up @@ -599,12 +600,12 @@ async fn restart_single_agent_after_install(
stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?;
save_managed_agents(&app_for_stop, &records)?;

Ok(runtime_keys)
Ok(restart_targets)
})
.await;

let runtime_keys = match stop_result {
Ok(Ok(runtime_keys)) => runtime_keys,
let relay_urls = match stop_result {
Ok(Ok(restart_targets)) => restart_targets,
Ok(Err(e)) => {
eprintln!("buzz-desktop: install_acp_runtime: skipping restart of {pubkey}: {e}");
return InstallRestartOutcome::Skipped;
Expand All @@ -617,7 +618,6 @@ async fn restart_single_agent_after_install(
}
};

let relay_urls: Vec<_> = runtime_keys.into_iter().map(|key| key.relay_url).collect();
let state = app.state::<AppState>();
match super::agents::start_local_agent_pairs_with_preflight(app, &state, pubkey, &relay_urls)
.await
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ pub(super) async fn start_local_agent_pairs_with_preflight(
relay_url.clone(),
app.clone(),
) {
errors.push(format!("{relay_url}: {error}"));
errors.push(error);
}
}
if !errors.is_empty() {
Expand Down
16 changes: 9 additions & 7 deletions desktop/src-tauri/src/commands/global_agent_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,12 @@ async fn restart_local_agent_on_config_change(
if record.backend != BackendKind::Local {
return Err(format!("agent {pubkey_owned} is no longer a local agent"));
}
let runtime_keys =
crate::managed_agents::managed_agent_runtime_keys(&runtimes, &pubkey_owned);
if runtime_keys.is_empty() {
// Collect the restart dial targets (each pair's configured connection
// URL, not the canonical key spelling) BEFORE the stop below drops the
// pairs — and their URLs — from the runtimes map.
let restart_targets =
crate::managed_agents::managed_agent_restart_targets(&runtimes, &pubkey_owned);
if restart_targets.is_empty() {
return Err(format!(
"agent {pubkey_owned} no longer has a live pair runtime after sync"
));
Expand Down Expand Up @@ -327,12 +330,12 @@ async fn restart_local_agent_on_config_change(
stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?;
save_managed_agents(&app_for_stop, &records)?;

Ok(runtime_keys)
Ok(restart_targets)
})
.await;

let runtime_keys = match stop_result {
Ok(Ok(runtime_keys)) => runtime_keys,
let relay_urls = match stop_result {
Ok(Ok(restart_targets)) => restart_targets,
Ok(Err(e)) => {
eprintln!("buzz-desktop: set_global_agent_config: skipping restart of {pubkey}: {e}");
return RestartOutcome::Skipped;
Expand All @@ -345,7 +348,6 @@ async fn restart_local_agent_on_config_change(
}
};

let relay_urls: Vec<_> = runtime_keys.into_iter().map(|key| key.relay_url).collect();
use tauri::Manager;
let state = app.state::<AppState>();
match super::agents::start_local_agent_pairs_with_preflight(app, &state, pubkey, &relay_urls)
Expand Down
8 changes: 8 additions & 0 deletions desktop/src-tauri/src/managed_agents/agent_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ pub(crate) fn parse_agent_env_lines(raw: &str) -> Vec<(&str, &str)> {
.collect()
}

pub(super) fn child_rust_log_filter() -> String {
match std::env::var("RUST_LOG") {
Ok(existing) if existing.contains("buzz_acp") => existing,
Ok(existing) if !existing.trim().is_empty() => format!("{existing},buzz_acp=info"),
_ => "buzz_acp=info".to_string(),
}
}

#[cfg(test)]
mod tests {
use super::{
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/managed_agents/process_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ pub fn taskkill_tree(pid: u32) -> Result<(), String> {
pub fn finish_spawn(
child: std::process::Child,
log_path: std::path::PathBuf,
connect_relay_url: String,
spawn_config: super::spawn_snapshot::SpawnConfigSnapshot,
setup_mode: bool,
adapter_availability: Option<super::AcpAvailabilityStatus>,
Expand All @@ -149,6 +150,7 @@ pub fn finish_spawn(
super::ManagedAgentProcess {
child,
log_path,
connect_relay_url,
spawn_config,
setup_mode,
adapter_availability,
Expand Down
203 changes: 156 additions & 47 deletions desktop/src-tauri/src/managed_agents/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@ enum SpawnOutcome {
}
type AgentSpawnResult = (String, SpawnOutcome);

/// Phase-A lookup for the exact requested workspace pair. A canonical-key hit
/// is not enough because loopback spellings can name distinct tenants.
fn phase_a_has_live_requested_pair<T>(
runtimes: &mut std::collections::HashMap<super::ManagedAgentRuntimeKey, T>,
pubkey: &str,
requested_relay_url: &str,
target_matches: impl Fn(&T, &str) -> bool,
mut is_live: impl FnMut(&mut T) -> bool,
) -> bool {
let Ok(key) = super::ManagedAgentRuntimeKey::new(pubkey.to_string(), requested_relay_url)
else {
return false;
};
let Some(runtime) = runtimes.get_mut(&key) else {
return false;
};
target_matches(runtime, requested_relay_url) && is_live(runtime)
}

/// Backfill the pinned persona snapshot for pre-existing agents created before
/// the record became the spawn source of truth. Runs once at launch, before
/// `restore_managed_agents_on_launch` spawns anything, so no agent boots from an
Expand Down Expand Up @@ -165,31 +184,26 @@ pub async fn restore_managed_agents_on_launch(
// replacing the three separate kernel enumerations.
super::sweep_untracked_bundle_harnesses(&tracked_pids);

let candidates: Vec<String> = records
let workspace_relay = crate::relay::relay_ws_url_with_override(&state);
let mut to_start = Vec::new();
for record in records
.iter()
.filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)
.map(|record| record.pubkey.clone())
.collect();

let mut to_start = Vec::new();
for pubkey in &candidates {
if let Some(runtime) = runtimes
.iter_mut()
.find(|(key, _)| key.pubkey == *pubkey)
.map(|(_, runtime)| runtime)
{
if runtime.child.try_wait().ok().flatten().is_none() {
continue;
}
}
if let Some(record) = records.iter().find(|r| r.pubkey == *pubkey) {
if let Some(pid) = record.runtime_pid {
if super::process_is_running(pid) {
continue;
}
}
to_start.push(record.clone());
{
let requested_relay =
crate::relay::effective_agent_relay_url(&record.relay_url, &workspace_relay);
if phase_a_has_live_requested_pair(
&mut runtimes,
&record.pubkey,
&requested_relay,
|runtime, requested| {
super::connection_targets_match(&runtime.connect_relay_url, requested)
},
|runtime| runtime.child.try_wait().ok().flatten().is_none(),
) {
continue;
}
to_start.push(record.clone());
}
agents_to_start = to_start;

Expand Down Expand Up @@ -308,38 +322,48 @@ pub async fn restore_managed_agents_on_launch(
Ok(key) => {
// F2: if a concurrent startup reconcile already
// tracked a live child for this exact pair during
// the Phase A window, leave it alone. Mirrors the
// the Phase A window, leave it alone - but only
// when it dials the requested URL. Mirrors the
// live-child guard in `start_pair`.
let already_live = app
let tracked_outcome = app
.state::<AppState>()
.managed_agent_processes
.lock()
.ok()
.and_then(|mut runtimes| {
runtimes.get_mut(&key).map(|runtime| {
runtime.child.try_wait().ok().flatten().is_none()
})
})
.unwrap_or(false);
if already_live {
SpawnOutcome::Skipped
let runtime = runtimes.get_mut(&key)?;
if runtime.child.try_wait().ok().flatten().is_none() {
return Some(live_pair_outcome(runtime, &relay_url));
}
// A dead tracked entry can be replaced only
// when it belonged to this same target; a
// mismatched entry may carry tenant-scoped
// session cache under the colliding key.
super::ensure_pair_connection_matches(runtime, &relay_url)
.err()
.map(SpawnOutcome::Failed)
});
if let Some(outcome) = tracked_outcome {
outcome
} else {
match super::terminate_untracked_pair_runtime(app, &key)
.and_then(|()| {
// F1: restore spawns lazy, matching
// reconcile and manual start. Eager on
// restore buys nothing — a crashed
// mid-turn session is not resumed by an
// eager child — and silently reintroduces
// N idle brains on every launch.
spawn_agent_child(
app,
record,
&key.relay_url,
true,
owner_hex_ref,
)
}) {
match super::terminate_untracked_pair_runtime(
app, &key, &relay_url,
)
.and_then(|()| {
// F1: restore spawns lazy, matching
// reconcile and manual start. Eager on
// restore buys nothing — a crashed
// mid-turn session is not resumed by an
// eager child — and silently reintroduces
// N idle brains on every launch.
spawn_agent_child(
app,
record,
&relay_url,
true,
owner_hex_ref,
)
}) {
Ok(process) => {
SpawnOutcome::Spawned(key, Box::new(process))
}
Expand Down Expand Up @@ -390,6 +414,11 @@ pub async fn restore_managed_agents_on_launch(
pid: process.child.id(),
desktop_instance_id: super::current_instance_id(app),
started_at: now.clone(),
// Phase B stamped the dial URL onto the process at spawn;
// reading it back here (not recomputing from the record)
// keeps the receipt truthful even if the workspace relay
// changed between phases.
connect_relay_url: Some(process.connect_relay_url.clone()),
};
if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) {
let _ = super::terminate_process(process.child.id());
Expand Down Expand Up @@ -557,3 +586,83 @@ fn persist_restore_error(
record.last_error = Some(error);
save_managed_agents(app, &records)
}

/// Phase-B decision for an already-tracked live pair: reuse (skip spawning)
/// only when the live child dials the requested URL; a cross-spelling child
/// is a connection-target conflict recorded as a failed outcome so Phase C
/// persists the sanitized error instead of silently keeping the wrong tenant.
fn live_pair_outcome(
runtime: &super::ManagedAgentPairRuntime,
requested_relay_url: &str,
) -> SpawnOutcome {
match super::ensure_pair_connection_matches(runtime, requested_relay_url) {
Ok(()) => SpawnOutcome::Skipped,
Err(error) => SpawnOutcome::Failed(error),
}
}

#[cfg(test)]
mod tests {
use super::SpawnOutcome;
use crate::managed_agents::make_pair_runtime_with_connect_url;

#[test]
fn phase_a_skips_only_the_live_requested_pair() {
let pubkey = "aa".repeat(32);
let key = crate::managed_agents::ManagedAgentRuntimeKey::new(
pubkey.clone(),
"ws://localhost:3100",
)
.unwrap();
let mut runtimes =
std::collections::HashMap::from([(key, ("ws://127.0.0.1:3100".to_string(), true))]);
let target_matches = |runtime: &(String, bool), requested: &str| {
super::super::connection_targets_match(&runtime.0, requested)
};
let is_live = |runtime: &mut (String, bool)| runtime.1;

// Same canonical key, different loopback tenant: Phase A must not
// suppress Phase B's explicit connection-target conflict.
assert!(!super::phase_a_has_live_requested_pair(
&mut runtimes,
&pubkey,
"ws://localhost:3100",
target_matches,
is_live,
));
assert!(super::phase_a_has_live_requested_pair(
&mut runtimes,
&pubkey,
"ws://127.0.0.1:3100",
target_matches,
is_live,
));
assert!(!super::phase_a_has_live_requested_pair(
&mut runtimes,
&pubkey,
"wss://other.example",
target_matches,
is_live,
));
}

#[test]
fn restore_reuses_live_pair_only_for_matching_spelling() {
let matching = make_pair_runtime_with_connect_url("ws://localhost:3100");
assert!(matches!(
super::live_pair_outcome(&matching, " ws://localhost:3100 "),
SpawnOutcome::Skipped
));

// localhost and 127.0.0.1 share a canonical key but are distinct
// tenants: restore must record the conflict, not keep the wrong one.
let foreign = make_pair_runtime_with_connect_url("ws://127.0.0.1:3100");
match super::live_pair_outcome(&foreign, "ws://localhost:3100") {
SpawnOutcome::Failed(error) => {
assert!(error.contains("connection-target conflict"));
assert!(!error.contains("3100"), "error must not echo URLs");
}
_ => panic!("cross-spelling live pair must fail, not be reused"),
}
}
}
Loading