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 @@ -562,9 +562,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 @@ -606,12 +607,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 @@ -624,7 +625,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
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
67 changes: 57 additions & 10 deletions desktop/src-tauri/src/managed_agents/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,21 +308,23 @@ 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 live_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_some() {
return None;
}
Some(live_pair_outcome(runtime, &relay_url))
});
if let Some(outcome) = live_outcome {
outcome
} else {
match super::terminate_untracked_pair_runtime(app, &key)
.and_then(|()| {
Expand All @@ -335,7 +337,7 @@ pub async fn restore_managed_agents_on_launch(
spawn_agent_child(
app,
record,
&key.relay_url,
&relay_url,
true,
owner_hex_ref,
)
Expand Down Expand Up @@ -390,6 +392,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 @@ -489,3 +496,43 @@ 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 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"),
}
}
}
75 changes: 29 additions & 46 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ use std::collections::HashMap;

use tauri::AppHandle;

use super::agent_env::{build_buzz_agent_provider_defaults, idle_pool_sleep_env};
use super::agent_env::{
build_buzz_agent_provider_defaults, child_rust_log_filter, idle_pool_sleep_env,
};

use crate::{
managed_agents::{
Expand All @@ -21,13 +23,14 @@ pub(crate) use path::{compose_path_entries, should_skip_claude_executable, shoul
pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondToEnv};

mod metadata;
use metadata::persona_drift_state;
pub(crate) use metadata::{
apply_agent_display_env, resolve_session_title, runtime_metadata_env_vars,
DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR,
};

mod stop;
pub(crate) use stop::managed_agent_runtime_keys;
pub(crate) use stop::{managed_agent_restart_targets, managed_agent_runtime_keys};
pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair};

mod sweep;
Expand All @@ -40,8 +43,9 @@ use process::{
terminate_runtime_receipt_with, valid_agent_runtime_receipt_with,
};
pub(crate) use process::{
current_instance_id, process_belongs_to_us, process_has_buzz_marker, process_is_running,
terminate_process, terminate_untracked_pair_runtime, valid_agent_runtime_receipt,
connection_relay_url, current_instance_id, ensure_pair_connection_matches,
process_belongs_to_us, process_has_buzz_marker, process_is_running, terminate_process,
terminate_untracked_pair_runtime, valid_agent_runtime_receipt,
};

mod orphan_sweep;
Expand All @@ -68,35 +72,6 @@ mod lifecycle;
use lifecycle::kill_stale_tracked_processes_with;
pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes};

/// Classify an agent's persona against the live catalog for the Agents-menu
/// drift indicator. Returns `(out_of_date, orphaned)`.
///
/// Drift basis is the RECORD's `persona_source_version`, never the engram:
/// - persona_id set + persona present: out_of_date when the snapshot hash
/// differs from the persona's current content hash.
/// - persona_id set + persona gone: orphaned (no current hash to respawn into,
/// so never out_of_date — we must not tell the user to respawn into nothing).
/// - no persona_id: neither — a hand-built agent has no persona to drift from.
fn persona_drift_state(
record: &ManagedAgentRecord,
personas: &[crate::managed_agents::types::AgentDefinition],
) -> (bool, bool) {
let Some(persona_id) = record.persona_id.as_deref() else {
return (false, false);
};
let Some(persona) = personas.iter().find(|p| p.id == persona_id) else {
return (false, true);
};
let current = crate::managed_agents::persona_events::persona_content_hash(
&crate::managed_agents::persona_events::persona_event_content(persona),
);
let out_of_date = record
.persona_source_version
.as_deref()
.is_some_and(|pinned| pinned != current);
(out_of_date, false)
}

/// Resolve the runtime-pair key this record maps to for the active
/// workspace: always the active workspace relay (the legacy per-record relay
/// pin is ignored — see `effective_agent_relay_url`). Returns `None` for
Expand Down Expand Up @@ -496,9 +471,10 @@ pub fn spawn_agent_child(
.map(|p| p.display().to_string())
.unwrap_or_else(|| effective_command.clone());

// The caller supplies the explicit canonical pair relay. This is the only
// relay this child may connect to, regardless of the record/workspace default.
let effective_relay_url = runtime_key.relay_url.clone();
// The canonical relay is only the pair identity. Preserve the configured
// authority for the network connection because relay communities are
// host-derived (`localhost` and `127.0.0.1` can be distinct tenants).
let effective_relay_url = connection_relay_url(relay_url);

// Augment PATH for DMG launches so child processes can find:
// - bundled CLI via ~/.local/bin symlink
Expand Down Expand Up @@ -852,7 +828,11 @@ pub fn spawn_agent_child(
super::spawn_snapshot::SpawnConfigInputs {
record,
descriptor: &descriptor,
relay_url: &effective_relay_url,
// Snapshot the canonical key relay, not the connection URL: the
// restart-drift check recomputes the prospective snapshot with
// `key.relay_url`, and the two must agree even when the configured
// spelling differs from the canonical one.
relay_url: &runtime_key.relay_url,
team_instructions: team_instructions.as_deref(),
system_prompt: effective_prompt.as_deref(),
model: effective_model.as_deref(),
Expand Down Expand Up @@ -907,6 +887,7 @@ pub fn spawn_agent_child(
return Ok(super::process_lifecycle::finish_spawn(
child,
log_path,
effective_relay_url,
spawn_config,
spawned_setup_mode,
spawned_adapter_availability,
Expand All @@ -917,21 +898,14 @@ pub fn spawn_agent_child(
Ok(crate::managed_agents::ManagedAgentProcess {
child,
log_path,
connect_relay_url: effective_relay_url,
spawn_config,
setup_mode: spawned_setup_mode,
adapter_availability: spawned_adapter_availability,
start_nonce,
})
}

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(),
}
}

pub fn start_managed_agent_process(
app: &AppHandle,
record: &mut ManagedAgentRecord,
Expand All @@ -954,6 +928,7 @@ pub fn start_managed_agent_process(
.map_err(|error| format!("failed to inspect running process: {error}"))?
.is_none()
{
ensure_pair_connection_matches(runtime, &relay_url)?;
return Ok(());
}

Expand All @@ -964,13 +939,17 @@ pub fn start_managed_agent_process(
// Scalar PIDs are migration-only and never establish pair liveness.
record.runtime_pid = None;

let mut process = spawn_agent_child(app, record, &key.relay_url, false, owner_hex)?;
let mut process = spawn_agent_child(app, record, &relay_url, false, owner_hex)?;
let now = now_iso();
let receipt = super::ManagedAgentRuntimeReceipt {
key: key.clone(),
pid: process.child.id(),
desktop_instance_id: current_instance_id(app),
started_at: now.clone(),
// The URL the child actually dialed, not the local `relay_url`
// binding — same string by construction here, but reading it off the
// process keeps every receipt site identical to the spawn.
connect_relay_url: Some(process.connect_relay_url.clone()),
};
if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) {
let _ = terminate_process(process.child.id());
Expand All @@ -991,6 +970,10 @@ pub fn start_managed_agent_process(

#[cfg(test)]
mod test_fixtures;
#[cfg(test)]
pub(crate) use test_fixtures::make_pair_runtime_with_connect_url;

#[cfg(test)]
mod connect_url_tests;
#[cfg(test)]
mod tests;
Loading