mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
1000 lines
42 KiB
Rust
1000 lines
42 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use tauri::AppHandle;
|
|
|
|
use super::agent_env::{build_buzz_agent_provider_defaults, idle_pool_sleep_env};
|
|
|
|
use crate::{
|
|
managed_agents::{
|
|
append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path,
|
|
missing_command_message, normalize_agent_args, open_log_file, resolve_command,
|
|
spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord,
|
|
ManagedAgentRuntimeKey, ManagedAgentSummary,
|
|
},
|
|
util::now_iso,
|
|
};
|
|
|
|
use super::claude_config::{apply_claude_model_env, apply_effort_env};
|
|
mod path;
|
|
pub(in crate::managed_agents) use path::build_augmented_path;
|
|
pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited};
|
|
|
|
pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondToEnv};
|
|
|
|
mod metadata;
|
|
pub(crate) use metadata::{
|
|
apply_agent_display_env, resolve_session_title, runtime_metadata_env_vars,
|
|
scrub_unconfigured_openai_compat_key, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR,
|
|
};
|
|
|
|
mod stop;
|
|
pub(crate) use stop::managed_agent_runtime_keys;
|
|
pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair};
|
|
|
|
mod sweep;
|
|
pub(crate) use sweep::sweep_untracked_bundle_harnesses;
|
|
|
|
mod process;
|
|
#[cfg(test)]
|
|
use process::{
|
|
buzz_marker_entry, name_matches_interpreter, name_matches_known_binary,
|
|
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,
|
|
};
|
|
|
|
mod orphan_sweep;
|
|
#[cfg(target_os = "macos")]
|
|
use orphan_sweep::proc_pidinfo;
|
|
pub(crate) use orphan_sweep::{
|
|
sweep_orphaned_agent_processes, sweep_system_agent_processes,
|
|
sweep_system_agent_processes_with_grace,
|
|
};
|
|
#[cfg(target_os = "macos")]
|
|
use orphan_sweep::{BSDInfo, PROC_PIDTBSDINFO};
|
|
#[cfg(unix)]
|
|
use process::resolve_pgids_and_kill;
|
|
|
|
mod instance_reaper;
|
|
pub(crate) use instance_reaper::reap_dead_instance_agents;
|
|
#[cfg(test)]
|
|
use instance_reaper::{buffer_contains_identifier, is_desktop_binary};
|
|
|
|
// Exact-path harness sweep lives in runtime/sweep.rs (re-exported above).
|
|
|
|
mod lifecycle;
|
|
#[cfg(test)]
|
|
use lifecycle::kill_stale_tracked_processes_with;
|
|
pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes};
|
|
mod spawn_key; // production spawn-key derivation + its regressions
|
|
pub(crate) use spawn_key::bound_runtime_key;
|
|
|
|
/// 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
|
|
/// records that cannot form a valid pair key yet (e.g. key-less agents that
|
|
/// mint keys on first start).
|
|
pub(crate) fn workspace_pair_key(
|
|
app: &AppHandle,
|
|
record: &ManagedAgentRecord,
|
|
) -> Option<ManagedAgentRuntimeKey> {
|
|
use tauri::Manager;
|
|
let state = app.state::<crate::app_state::AppState>();
|
|
resolve_workspace_pair_key(
|
|
&record.pubkey,
|
|
&record.relay_url,
|
|
&crate::relay::relay_ws_url_with_override(&state),
|
|
)
|
|
}
|
|
|
|
/// Pure core of [`workspace_pair_key`]: workspace-relay resolution (legacy
|
|
/// record pins ignored) plus canonical key construction, kept `AppHandle`-free
|
|
/// so summary/stop scoping semantics are unit-testable.
|
|
pub(crate) fn resolve_workspace_pair_key(
|
|
pubkey: &str,
|
|
record_relay_url: &str,
|
|
workspace_relay_url: &str,
|
|
) -> Option<ManagedAgentRuntimeKey> {
|
|
let effective_relay =
|
|
crate::relay::effective_agent_relay_url(record_relay_url, workspace_relay_url);
|
|
ManagedAgentRuntimeKey::new(pubkey.to_string(), &effective_relay).ok()
|
|
}
|
|
|
|
pub fn build_managed_agent_summary(
|
|
app: &AppHandle,
|
|
record: &ManagedAgentRecord,
|
|
runtimes: &HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>,
|
|
personas: &[crate::managed_agents::types::AgentDefinition],
|
|
teams: &[crate::managed_agents::TeamRecord],
|
|
global_config: &crate::managed_agents::GlobalAgentConfig,
|
|
) -> Result<ManagedAgentSummary, String> {
|
|
use crate::managed_agents::BackendKind;
|
|
|
|
// Community-scoped truth: this summary describes the pair for the active
|
|
// workspace relay. An agent running only in another community must read
|
|
// as stopped here — matching by pubkey alone would show every community a
|
|
// green light as long as any pair anywhere is alive.
|
|
let pair_key = workspace_pair_key(app, record);
|
|
let pair_runtime = pair_key.as_ref().and_then(|key| runtimes.get(key));
|
|
|
|
let (status, pid, log_path) = if record.backend != BackendKind::Local {
|
|
// Two-axis status model for remote agents:
|
|
//
|
|
// Control-plane (this field): "deployed" = provider has been invoked and
|
|
// returned a backend_agent_id. "not_deployed" = no deploy call yet (or it
|
|
// failed). This axis tracks whether infrastructure *exists*, not whether
|
|
// the process is currently running.
|
|
//
|
|
// Live axis (relay presence, polled by frontend): online/away/offline.
|
|
// Shown as a PresenceDot next to the agent name. This is the real-time
|
|
// signal for whether the harness is connected.
|
|
//
|
|
// After !shutdown the agent goes offline (presence) but stays "deployed"
|
|
// (infrastructure still exists). This is intentional — the provider may
|
|
// have allocated a VM/container that persists across process restarts.
|
|
// A future provider `undeploy` operation (v2) will handle teardown.
|
|
let status = if record.backend_agent_id.is_some() {
|
|
"deployed".to_string()
|
|
} else {
|
|
"not_deployed".to_string()
|
|
};
|
|
(status, None, String::new())
|
|
} else {
|
|
let persisted_pid = record.runtime_pid.filter(|pid| process_is_running(*pid));
|
|
if let Some(runtime) = pair_runtime {
|
|
(
|
|
"running".to_string(),
|
|
Some(runtime.child.id()),
|
|
runtime.log_path.display().to_string(),
|
|
)
|
|
} else if let Some(pid) = persisted_pid {
|
|
(
|
|
"running".to_string(),
|
|
Some(pid),
|
|
managed_agent_log_path(app, &record.pubkey)?
|
|
.display()
|
|
.to_string(),
|
|
)
|
|
} else {
|
|
(
|
|
"stopped".to_string(),
|
|
None,
|
|
managed_agent_log_path(app, &record.pubkey)?
|
|
.display()
|
|
.to_string(),
|
|
)
|
|
}
|
|
};
|
|
|
|
let (persona_out_of_date, persona_orphaned) = persona_drift_state(record, personas);
|
|
|
|
let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config(
|
|
record,
|
|
personas,
|
|
global_config,
|
|
);
|
|
let (effective_model, effective_provider, effective_prompt, model_source) = match effective_cfg
|
|
{
|
|
crate::managed_agents::effective_config::EffectiveConfigResult::Resolved(cfg) => {
|
|
let source = cfg.model.source.clone();
|
|
(
|
|
cfg.model.value,
|
|
cfg.provider.value,
|
|
cfg.system_prompt.value,
|
|
Some(source),
|
|
)
|
|
}
|
|
crate::managed_agents::effective_config::EffectiveConfigResult::OrphanedInstance {
|
|
record_pubkey,
|
|
missing_persona_id,
|
|
} => {
|
|
eprintln!(
|
|
"orphaned agent instance: pubkey={record_pubkey}, missing_persona_id={missing_persona_id}"
|
|
);
|
|
(None, None, None, None)
|
|
}
|
|
};
|
|
|
|
// Restart badge: the running process stamped the effective spawn config
|
|
// it was launched with; recompute a prospective one from current disk
|
|
// state and report every differing field. Only the tracked live pair for
|
|
// THIS workspace can drift — stopped agents spawn fresh, adopted
|
|
// (runtime_pid-only) processes have no stamp to compare, and pairs running
|
|
// for other communities are judged in their own community (comparing them
|
|
// against this workspace's relay would flag a spurious restart on every
|
|
// community switch).
|
|
//
|
|
// Adapter-availability drift (codex only) contributes its own synthetic
|
|
// entry, so an out-of-band adapter change (manual npm install/downgrade)
|
|
// that Phase-1 auto-restart doesn't cover still shows the user what moved.
|
|
// The cache is read-only here — no subprocess is spawned.
|
|
//
|
|
// Global config drives both the prospective snapshot and the descriptor
|
|
// env layering below — the caller loads it once and passes it in, so
|
|
// list-style callers pay one disk read per call rather than one per record.
|
|
|
|
// The prospective side is computed only for a tracked pair: an unstamped
|
|
// agent has nothing to compare against.
|
|
let tracked_spawn = pair_key.as_ref().zip(pair_runtime).map(|(key, runtime)| {
|
|
let current = crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot(
|
|
record,
|
|
personas,
|
|
teams,
|
|
&key.relay_url,
|
|
global_config,
|
|
super::owner_only_access_build(),
|
|
);
|
|
(runtime, current)
|
|
});
|
|
let restart_diff = crate::managed_agents::spawn_snapshot::eligible_restart_diff(
|
|
persona_orphaned,
|
|
tracked_spawn.as_ref().map(|(runtime, current)| {
|
|
crate::managed_agents::spawn_snapshot::TrackedSpawnState {
|
|
stamped: &runtime.spawn_config,
|
|
current,
|
|
stamped_availability: runtime.adapter_availability.as_ref(),
|
|
current_availability: super::adapter_availability_cached(),
|
|
}
|
|
}),
|
|
);
|
|
// One vector is the whole truth: badge on ⟺ there is a diff to show.
|
|
let needs_restart = !restart_diff.is_empty();
|
|
|
|
// Resolve the effective harness via the single typed descriptor — same resolver
|
|
// as spawn, so the UI reflects the persona's current harness (or explicit pin).
|
|
let descriptor = crate::managed_agents::resolve_effective_harness_descriptor(
|
|
record,
|
|
personas,
|
|
global_config,
|
|
)
|
|
.unwrap_or_else(|e| {
|
|
// Dangling harness — surface the missing id so the UI tells the same
|
|
// story as spawn (which refuses with a sentence), rather than silently
|
|
// showing the default-command fallback as if the agent were healthy.
|
|
let cmd = match crate::managed_agents::dangling_harness_id(&e) {
|
|
Some(id) => crate::managed_agents::dangling_harness_display(id),
|
|
None => crate::managed_agents::record_agent_command(record, personas),
|
|
};
|
|
let args = normalize_agent_args(&cmd, record.agent_args.clone());
|
|
crate::managed_agents::readiness::EffectiveHarnessDescriptor {
|
|
command: cmd,
|
|
args,
|
|
env: Default::default(),
|
|
}
|
|
});
|
|
let effective_mcp_command = known_acp_runtime(&descriptor.command)
|
|
.and_then(|r| r.mcp_command)
|
|
.unwrap_or("")
|
|
.to_string();
|
|
|
|
Ok(ManagedAgentSummary {
|
|
pubkey: record.pubkey.clone(),
|
|
name: record.name.clone(),
|
|
persona_id: record.persona_id.clone(),
|
|
runtime: record.runtime.clone(),
|
|
team_id: record.team_id.clone(),
|
|
relay_url: record.relay_url.clone(),
|
|
acp_command: record.acp_command.clone(),
|
|
agent_command: descriptor.command,
|
|
agent_command_override: record.agent_command_override.clone(),
|
|
agent_args: descriptor.args,
|
|
mcp_command: effective_mcp_command,
|
|
turn_timeout_seconds: record.turn_timeout_seconds,
|
|
idle_timeout_seconds: record.idle_timeout_seconds,
|
|
max_turn_duration_seconds: record.max_turn_duration_seconds,
|
|
parallelism: record.parallelism,
|
|
system_prompt: effective_prompt,
|
|
avatar_url: record.avatar_url.clone(),
|
|
model: effective_model,
|
|
model_source,
|
|
provider: effective_provider,
|
|
persona_out_of_date,
|
|
persona_orphaned,
|
|
needs_restart,
|
|
restart_diff,
|
|
env_vars: record.env_vars.clone(),
|
|
backend: record.backend.clone(),
|
|
backend_agent_id: record.backend_agent_id.clone(),
|
|
status,
|
|
pid,
|
|
created_at: record.created_at.clone(),
|
|
updated_at: record.updated_at.clone(),
|
|
last_started_at: record.last_started_at.clone(),
|
|
last_stopped_at: record.last_stopped_at.clone(),
|
|
last_exit_code: record.last_exit_code,
|
|
last_error: record.last_error.clone(),
|
|
last_error_code: record.last_error_code,
|
|
start_on_app_launch: record.start_on_app_launch,
|
|
auto_restart_on_config_change: record.auto_restart_on_config_change,
|
|
log_path,
|
|
respond_to: record.respond_to,
|
|
respond_to_allowlist: record.respond_to_allowlist.clone(),
|
|
})
|
|
}
|
|
|
|
pub fn find_managed_agent_mut<'a>(
|
|
records: &'a mut [ManagedAgentRecord],
|
|
pubkey: &str,
|
|
) -> Result<&'a mut ManagedAgentRecord, String> {
|
|
records
|
|
.iter_mut()
|
|
.find(|record| record.pubkey == pubkey)
|
|
.ok_or_else(|| format!("agent {pubkey} not found"))
|
|
}
|
|
|
|
/// Pure decision function for the inbound author gate env vars.
|
|
///
|
|
/// Returns the env vars to **set** and the env vars to **remove**. Removal is
|
|
/// belt-and-suspenders: an inherited parent env var must not leak into a
|
|
/// child agent and silently change its security posture.
|
|
///
|
|
/// The `owner_hex` argument is the current workspace owner pubkey. It's used
|
|
/// as a fallback for legacy records (`auth_tag.is_none()`) — without it, the
|
|
/// harness's owner cache stays empty and `owner-only` / `allowlist` modes
|
|
/// drop everything.
|
|
///
|
|
/// Returns `Err(...)` if the record's allowlist fails validation. The harness
|
|
/// validates too, but doing it here means we never spawn a doomed process.
|
|
pub(crate) fn build_respond_to_env(
|
|
record: &ManagedAgentRecord,
|
|
owner_hex: Option<&str>,
|
|
) -> Result<RespondToEnv, String> {
|
|
build_respond_to_env_with_policy(record, owner_hex, super::owner_only())
|
|
}
|
|
|
|
pub(crate) fn configure_runtime_cli(
|
|
command: &mut std::process::Command,
|
|
runtime: Option<&KnownAcpRuntime>,
|
|
) {
|
|
let Some(runtime) = runtime else {
|
|
return;
|
|
};
|
|
if runtime.id != "claude" {
|
|
return;
|
|
}
|
|
if let Some(cli_path) = runtime.underlying_cli.and_then(resolve_command) {
|
|
// On Windows, `.cmd` and `.bat` files are batch shims — they cannot be
|
|
// passed directly to `CreateProcess` and cause EINVAL when the Claude
|
|
// adapter tries to spawn them (issue #2397). Skip setting
|
|
// `CLAUDE_CODE_EXECUTABLE` for shim paths so the adapter falls back to
|
|
// its own PATH lookup and finds the real binary instead.
|
|
// Non-Windows: `.cmd`/`.bat` are valid executables and must be assigned.
|
|
if should_skip_claude_executable(&cli_path, cfg!(windows)) {
|
|
return;
|
|
}
|
|
command.env("CLAUDE_CODE_EXECUTABLE", cli_path);
|
|
}
|
|
}
|
|
|
|
/// Spawn an agent process without holding any locks on records or runtimes.
|
|
/// Returns the child process and log path on success. The caller is responsible
|
|
/// for updating `ManagedAgentRecord` fields and inserting into the runtimes map.
|
|
///
|
|
/// `owner_hex`: the workspace owner's pubkey, used as a fallback for legacy
|
|
/// records that have no NIP-OA `auth_tag`. See `build_respond_to_env`.
|
|
pub fn spawn_agent_child(
|
|
app: &AppHandle,
|
|
record: &ManagedAgentRecord,
|
|
relay_url: &str,
|
|
lazy: bool,
|
|
owner_hex: Option<&str>,
|
|
) -> Result<crate::managed_agents::ManagedAgentProcess, String> {
|
|
if let Some(error) = spawn_key_refusal(record) {
|
|
return Err(error);
|
|
}
|
|
let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?;
|
|
// Resolve the effective harness (agent command) from the linked persona, so
|
|
// persona harness edits propagate on the next spawn; an explicit per-agent
|
|
// override wins. `agent_args` and `mcp_command` are pure derivations of the
|
|
// command, so we recompute them from the effective value rather than the
|
|
// frozen record snapshot. Mirrors the model resolution below.
|
|
let personas = super::load_personas(app).unwrap_or_default();
|
|
let teams = super::load_teams(app).unwrap_or_default();
|
|
// Load global config once; used for runtime_metadata_env_vars (model/provider fallback)
|
|
// and for the env-var merge at spawn time.
|
|
let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
|
|
|
|
// Resolve model/provider/prompt ONCE, here, at the shared spawn boundary —
|
|
// the single source both the env writes below and the spawn-config snapshot
|
|
// read from. Previously prompt was read from the record's own (possibly
|
|
// stale, Phase-A-snapshot) bytes while model/provider were resolved live
|
|
// from `personas`; a definition edit landing between a caller's snapshot
|
|
// apply and this spawn could hand a fresh model/provider to a stale
|
|
// prompt. This also folds in orphan refusal via `require_resolved`: every
|
|
// caller (interactive start, launch restore, `start_managed_agent_process`)
|
|
// inherits it — no caller can bypass this by reaching `spawn_agent_child`
|
|
// directly. Checked before any side effect (log marker, log file, process
|
|
// spawn) so a refused spawn leaves no trace.
|
|
let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config(
|
|
record, &personas, &global,
|
|
)
|
|
.require_resolved()?;
|
|
|
|
// Single typed resolver: validates runtime id (dangling harness → Err), resolves
|
|
// command, args (instance wins over definition default), and the full env layer stack.
|
|
// This is the sole path for harness-definition lookup — spawn, snapshot,
|
|
// summary, and model probes all consume this descriptor rather than
|
|
// assembling values inline.
|
|
// Like the orphan refusal above, this runs before any side effect so a refused
|
|
// spawn leaves no trace.
|
|
let descriptor =
|
|
crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global)
|
|
.map_err(|e| {
|
|
format!(
|
|
"cannot spawn agent {}: {}",
|
|
record.pubkey,
|
|
crate::managed_agents::user_facing_harness_error(&e)
|
|
)
|
|
})?;
|
|
let effective_command = &descriptor.command;
|
|
let agent_args = &descriptor.args;
|
|
|
|
let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?;
|
|
append_log_marker(
|
|
&log_path,
|
|
&format!(
|
|
"\n=== starting {} ({}) at {} ===",
|
|
record.name,
|
|
record.pubkey,
|
|
now_iso()
|
|
),
|
|
)?;
|
|
|
|
let stdout = open_log_file(&log_path)?;
|
|
let stderr = stdout
|
|
.try_clone()
|
|
.map_err(|error| format!("failed to clone log handle: {error}"))?;
|
|
let resolved_acp_command = resolve_command(&record.acp_command)
|
|
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;
|
|
let effective_mcp_command = known_acp_runtime(effective_command)
|
|
.and_then(|r| r.mcp_command)
|
|
.unwrap_or("");
|
|
let resolved_mcp_command: Option<std::path::PathBuf> = if effective_mcp_command.is_empty() {
|
|
None
|
|
} else {
|
|
match resolve_command(effective_mcp_command) {
|
|
Some(path) => Some(path),
|
|
None => {
|
|
eprintln!(
|
|
"buzz-desktop: mcp_command {effective_mcp_command:?} not found, skipping"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
};
|
|
// Resolve agent command to a full path (DMG launches have minimal PATH).
|
|
let resolved_agent_command = resolve_command(effective_command)
|
|
.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();
|
|
|
|
// Augment PATH for DMG launches so child processes can find:
|
|
// - bundled CLI via ~/.local/bin symlink
|
|
// - nvm-managed node/npm (nvm initializes only in interactive shells)
|
|
// - bundled sidecars (buzz, buzz-acp, etc.) via exe parent (Contents/MacOS/)
|
|
// - runtimes (node, python, etc.) via login shell PATH
|
|
let nvm_bin = dirs::home_dir()
|
|
.as_deref()
|
|
.and_then(super::find_nvm_default_bin);
|
|
let augmented_path = build_augmented_path(
|
|
dirs::home_dir(),
|
|
std::env::current_exe()
|
|
.ok()
|
|
.and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)),
|
|
login_shell_path(),
|
|
nvm_bin,
|
|
);
|
|
|
|
let mut command = std::process::Command::new(&resolved_acp_command);
|
|
if let Some(home) = super::default_agent_workdir() {
|
|
command.current_dir(home);
|
|
}
|
|
command.stdin(std::process::Stdio::null());
|
|
command.stdout(std::process::Stdio::from(stdout));
|
|
command.stderr(std::process::Stdio::from(stderr));
|
|
if let Some(ref path) = augmented_path {
|
|
command.env("PATH", path);
|
|
}
|
|
command.env("RUST_LOG", child_rust_log_filter());
|
|
command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec);
|
|
command.env("BUZZ_RELAY_URL", &effective_relay_url);
|
|
command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" });
|
|
command.env("BUZZ_ACP_IDLE_POOL_SLEEP", idle_pool_sleep_env(lazy));
|
|
command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command);
|
|
command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(","));
|
|
match &resolved_mcp_command {
|
|
Some(mcp_cmd) => {
|
|
command.env("BUZZ_ACP_MCP_COMMAND", mcp_cmd);
|
|
}
|
|
None => {
|
|
command.env("BUZZ_ACP_MCP_COMMAND", "");
|
|
}
|
|
}
|
|
// Enable MCP hook tools (_Stop, _PostCompact) for agents that need them.
|
|
// Uses "*" because build_mcp_servers() hard-codes the server name to "buzz-mcp".
|
|
let runtime_meta = known_acp_runtime(effective_command);
|
|
if runtime_meta.is_some_and(|r| r.mcp_hooks) {
|
|
command.env("MCP_HOOK_SERVERS", "*");
|
|
}
|
|
|
|
// ── Readiness check: set setup-payload if agent is not ready ─────────────
|
|
//
|
|
// Build the effective env the agent would have at start-time, run the
|
|
// readiness predicate, and if anything is missing, serialize the payload
|
|
// into BUZZ_ACP_SETUP_PAYLOAD. buzz-acp detects this env var on startup
|
|
// and enters the minimal setup-listener mode instead of the agent pool.
|
|
//
|
|
// SECURITY: BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS so user env
|
|
// cannot set it, but we also explicitly remove it after writing user env
|
|
// to guard against the parent-process environment. We then set it only
|
|
// when desktop has computed NotReady — the desktop is the sole readiness
|
|
// source and buzz-acp only transports the payload.
|
|
//
|
|
// The JSON format mirrors `setup_mode::SetupPayload` in buzz-acp:
|
|
// { "agent_name": "...", "agent_pubkey": "...", "requirements": [{ "surface": "...", ... }] }
|
|
//
|
|
// `spawned_setup_mode` is captured outside the block so it can be stamped
|
|
// on `ManagedAgentProcess` — used by `install_acp_runtime` to target only
|
|
// stuck agents for auto-restart.
|
|
let spawned_setup_mode;
|
|
{
|
|
use crate::managed_agents::readiness::EffectiveAgentEnv;
|
|
use crate::managed_agents::{agent_readiness, AgentReadiness, Requirement};
|
|
|
|
// Construct EffectiveAgentEnv from the descriptor computed above — no second
|
|
// resolver call; the descriptor's env is already the fully layered result.
|
|
let effective = EffectiveAgentEnv {
|
|
env: descriptor.env.clone(),
|
|
config_file_path: runtime_meta.and_then(|r| r.config_file_path),
|
|
effective_command: descriptor.command.clone(),
|
|
};
|
|
let setup_payload_json =
|
|
if let AgentReadiness::NotReady { requirements } = agent_readiness(&effective) {
|
|
let reqs: Vec<serde_json::Value> = requirements
|
|
.into_iter()
|
|
.map(|r| match r {
|
|
Requirement::NormalizedField { field } => serde_json::json!({
|
|
"surface": "normalized_field",
|
|
"field": field,
|
|
}),
|
|
Requirement::EnvKey { key } => serde_json::json!({
|
|
"surface": "env_key",
|
|
"key": key,
|
|
}),
|
|
Requirement::ConfigInvalid { message } => serde_json::json!({
|
|
"surface": "config_invalid", "message": message,
|
|
}),
|
|
Requirement::CliLogin {
|
|
probe_args,
|
|
setup_copy,
|
|
availability,
|
|
} => serde_json::json!({
|
|
"surface": "cli_login",
|
|
"probe_args": probe_args,
|
|
"setup_copy": setup_copy,
|
|
"availability": availability,
|
|
}),
|
|
Requirement::CliConfigInvalid {
|
|
probe_args,
|
|
setup_copy,
|
|
diagnostic,
|
|
} => serde_json::json!({
|
|
"surface": "cli_config_invalid",
|
|
"probe_args": probe_args,
|
|
"setup_copy": setup_copy,
|
|
"diagnostic": diagnostic,
|
|
}),
|
|
Requirement::GitBash => serde_json::json!({
|
|
"surface": "git_bash",
|
|
}),
|
|
Requirement::MissingBinary { command } => serde_json::json!({
|
|
"surface": "missing_binary",
|
|
"command": command,
|
|
}),
|
|
})
|
|
.collect();
|
|
let payload = serde_json::json!({
|
|
"agent_name": record.name,
|
|
"agent_pubkey": record.pubkey,
|
|
"requirements": reqs,
|
|
});
|
|
match serde_json::to_string(&payload) {
|
|
Ok(json) => Some(json),
|
|
Err(e) => {
|
|
eprintln!(
|
|
"buzz-desktop: failed to serialize setup payload for {}: {e}",
|
|
record.name
|
|
);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
spawned_setup_mode = setup_payload_json.is_some();
|
|
|
|
// Strip the key from the process-spawned command on every path.
|
|
// Two independent guards protect the invariant:
|
|
// 1. BUZZ_ACP_SETUP_PAYLOAD is in RESERVED_ENV_KEYS, so
|
|
// merged_user_env() can never write it via saved/persona env.
|
|
// 2. This env_remove() clears any ambient parent-process value
|
|
// inherited by std::process::Command before we conditionally
|
|
// set the desktop-computed trusted value below.
|
|
// Note: merged_user_env() is written further below in this function;
|
|
// ordering relative to that call is NOT what makes this safe — the
|
|
// reserved-key strip (guard 1) handles user env regardless of order.
|
|
command.env_remove("BUZZ_ACP_SETUP_PAYLOAD");
|
|
|
|
// Set the payload only when desktop computed NotReady.
|
|
if let Some(json) = setup_payload_json {
|
|
command.env("BUZZ_ACP_SETUP_PAYLOAD", json);
|
|
eprintln!(
|
|
"buzz-desktop: agent {} not ready — spawning in setup-listener mode",
|
|
record.name
|
|
);
|
|
}
|
|
}
|
|
// Emit BUZZ_ACP_IDLE_TIMEOUT only when explicitly set; the harness
|
|
// DEFAULT_IDLE_TIMEOUT_SECS is the single source of truth. The deprecated
|
|
// BUZZ_ACP_TURN_TIMEOUT pinned agents to a stale default (320s).
|
|
if let Some(idle) = record.idle_timeout_seconds {
|
|
command.env("BUZZ_ACP_IDLE_TIMEOUT", idle.to_string());
|
|
}
|
|
|
|
if let Some(max_dur) = record.max_turn_duration_seconds {
|
|
command.env("BUZZ_ACP_MAX_TURN_DURATION", max_dur.to_string());
|
|
}
|
|
let acp_n = super::acp_agents_value(effective_command, record.parallelism);
|
|
command.env("BUZZ_ACP_AGENTS", acp_n);
|
|
command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer");
|
|
command.env("BUZZ_ACP_DEDUP", "queue");
|
|
if let Some(meta) = runtime_meta {
|
|
for (key, value) in meta.default_env {
|
|
if std::env::var(key).is_err() {
|
|
command.env(key, value);
|
|
}
|
|
}
|
|
}
|
|
let team_instructions = super::spawn_snapshot::effective_team_instructions(record, &teams);
|
|
if let Some(instructions) = &team_instructions {
|
|
command.env("BUZZ_ACP_TEAM_INSTRUCTIONS", instructions);
|
|
} else {
|
|
command.env_remove("BUZZ_ACP_TEAM_INSTRUCTIONS");
|
|
}
|
|
|
|
// Prompt, model, and provider all come from the single `effective_cfg`
|
|
// resolved at the top of this function — the SAME resolve the spawn-config
|
|
// snapshot reads, so env write and restart badge cannot disagree. Linked
|
|
// instances never consult the record's own model/provider/prompt bytes;
|
|
// definition-less instances fall back to their own fields, then global.
|
|
//
|
|
// Derive the mesh decision BEFORE moving fields out — `relay_mesh_model_id`
|
|
// is the single authoritative gate; the mesh-llm block below MUST use it
|
|
// rather than re-deriving from `effective_provider` to keep preflight and
|
|
// spawn semantics in lock-step (see `EffectiveAgentConfig::relay_mesh_model_id`).
|
|
#[cfg(feature = "mesh-llm")]
|
|
let mesh_model_id = effective_cfg.relay_mesh_model_id();
|
|
let effective_prompt = effective_cfg.system_prompt.value;
|
|
let effective_model = effective_cfg.model.value;
|
|
let effective_provider = effective_cfg.provider.value;
|
|
|
|
if let Some(prompt) = &effective_prompt {
|
|
command.env("BUZZ_ACP_SYSTEM_PROMPT", prompt);
|
|
} else {
|
|
command.env_remove("BUZZ_ACP_SYSTEM_PROMPT");
|
|
}
|
|
// Shared compute stores `auto`, but the wire name is MeshLLM's virtual
|
|
// `mesh` model. Translate here too, so the harness and the LLM client are
|
|
// told the same thing: `BUZZ_ACP_MODEL=auto` would name a model the mesh
|
|
// never advertises, leaving buzz-acp to warn and fall back on every new
|
|
// session while `BUZZ_AGENT_MODEL` said `mesh`.
|
|
#[cfg(feature = "mesh-llm")]
|
|
let acp_model = match (&mesh_model_id, effective_model.as_deref()) {
|
|
(Some(mesh_model_id), _) => Some(super::relay_mesh_wire_model(mesh_model_id).to_string()),
|
|
(None, model) => model.map(str::to_owned),
|
|
};
|
|
#[cfg(not(feature = "mesh-llm"))]
|
|
let acp_model = effective_model.as_deref().map(str::to_owned);
|
|
if let Some(model) = acp_model.as_deref() {
|
|
command.env("BUZZ_ACP_MODEL", model);
|
|
} else {
|
|
command.env_remove("BUZZ_ACP_MODEL");
|
|
}
|
|
// Session title for the harness to pass out-of-band on `session/new`. The
|
|
// adapter names the session after it; it never reaches the prompt, so this
|
|
// is display metadata only. The spawn-config snapshot records the same
|
|
// resolve, so a rename raises the restart badge instead of leaving the
|
|
// process stale.
|
|
apply_agent_display_env(
|
|
&mut command,
|
|
resolve_session_title(record.display_name.as_deref(), &record.name),
|
|
);
|
|
build_buzz_agent_provider_defaults(&mut command);
|
|
if let Some(meta) = runtime_meta {
|
|
for (key, value) in runtime_metadata_env_vars(
|
|
meta.model_env_var,
|
|
meta.provider_env_var,
|
|
meta.provider_locked,
|
|
effective_model.as_deref(),
|
|
effective_provider.as_deref(),
|
|
) {
|
|
command.env(key, value);
|
|
}
|
|
}
|
|
command.env_remove("BUZZ_ACP_PRIVATE_KEY");
|
|
command.env_remove("BUZZ_ACP_API_TOKEN");
|
|
command.env_remove("BUZZ_API_TOKEN");
|
|
|
|
if let Some(ref auth_tag) = record.auth_tag {
|
|
command.env("BUZZ_AUTH_TAG", auth_tag);
|
|
} else {
|
|
command.env_remove("BUZZ_AUTH_TAG");
|
|
}
|
|
|
|
// Inbound author gate: who is this agent allowed to respond to?
|
|
// Validation is strict here — a malformed allowlist on disk fails before
|
|
// we spawn anything (the harness would also reject it, but we'd rather
|
|
// fail with a clear error than crash-loop the child).
|
|
let (gate_set, gate_remove) = build_respond_to_env(record, owner_hex)?;
|
|
for (key, value) in &gate_set {
|
|
command.env(key, value);
|
|
}
|
|
for key in &gate_remove {
|
|
command.env_remove(key);
|
|
}
|
|
|
|
command.env("BUZZ_ACP_RELAY_OBSERVER", "true");
|
|
|
|
// Git credential helper: NIP-98 auth for Buzz relay git via git-credential-nostr.
|
|
// Ephemeral GIT_CONFIG_COUNT env vars scoped to relay HTTP URL; NOSTR_PRIVATE_KEY mirrors BUZZ_PRIVATE_KEY.
|
|
if let Some(cred_helper) = resolve_command("git-credential-nostr") {
|
|
let relay_http_url = crate::relay::relay_http_base_url(&effective_relay_url);
|
|
|
|
command.env("NOSTR_PRIVATE_KEY", &record.private_key_nsec);
|
|
command.env("GIT_TERMINAL_PROMPT", "0");
|
|
command.env("GIT_CONFIG_COUNT", "2");
|
|
command.env(
|
|
"GIT_CONFIG_KEY_0",
|
|
format!("credential.{relay_http_url}/git.helper"),
|
|
);
|
|
let helper = cred_helper.to_string_lossy().replace('\\', "/");
|
|
command.env("GIT_CONFIG_VALUE_0", helper);
|
|
command.env(
|
|
"GIT_CONFIG_KEY_1",
|
|
format!("credential.{relay_http_url}/git.useHttpPath"),
|
|
);
|
|
command.env("GIT_CONFIG_VALUE_1", "true");
|
|
} else {
|
|
eprintln!(
|
|
"buzz-desktop: git-credential-nostr not found — agent {} will not have automatic Buzz git auth",
|
|
record.name,
|
|
);
|
|
}
|
|
|
|
for (key, value) in &descriptor.env {
|
|
command.env(key, value);
|
|
}
|
|
|
|
scrub_unconfigured_openai_compat_key(
|
|
&mut command,
|
|
effective_provider.as_deref(),
|
|
&descriptor.env,
|
|
);
|
|
|
|
// (ANTHROPIC_MODEL is applied post-loop for the same reason). When effort_level is
|
|
// None there is no canonical value to assert, so env passthrough stands — user env
|
|
// legitimately seeds startup effort in that case.
|
|
apply_effort_env(&mut command, record.effort_level.as_deref());
|
|
|
|
// A1: for local claude agents, ANTHROPIC_MODEL is the single startup model authority.
|
|
// BUZZ_ACP_MODEL is removed (live ACP switches only; two authorities in the same env
|
|
// would be ambiguous).
|
|
if record.backend == super::BackendKind::Local && runtime_meta.is_some_and(|r| r.id == "claude")
|
|
{
|
|
apply_claude_model_env(&mut command, effective_model.as_deref());
|
|
}
|
|
configure_runtime_cli(&mut command, runtime_meta);
|
|
|
|
// Buzz shared compute is stored as a native provider; derive the OpenAI-compatible
|
|
// transport at spawn time and scrub any unrelated ambient OpenAI key.
|
|
// Gate on `mesh_model_id` (derived from `effective_cfg.relay_mesh_model_id()`
|
|
// above) — not on `effective_provider` directly — so the mesh gate here
|
|
// uses the same trim semantics as the preflight callers.
|
|
#[cfg(feature = "mesh-llm")]
|
|
if let Some(ref mesh_model_id) = mesh_model_id {
|
|
let mesh_env = super::relay_mesh_process_env(&descriptor.env, mesh_model_id);
|
|
command.env_remove("OPENAI_API_KEY");
|
|
for (key, value) in mesh_env {
|
|
command.env(key, value);
|
|
}
|
|
}
|
|
|
|
// Stamp desktop ownership and an unpredictable harness-generation identity.
|
|
let start_nonce = uuid::Uuid::new_v4().simple().to_string();
|
|
command
|
|
.env("BUZZ_MANAGED_AGENT", current_instance_id(app))
|
|
.env("BUZZ_MANAGED_AGENT_START_NONCE", &start_nonce);
|
|
|
|
// Stamp the effective spawn config from the values that populated the
|
|
// `Command` above, BEFORE spawning. Re-resolving after `spawn()` would let
|
|
// a persona/harness/global edit landing in between stamp the NEW config
|
|
// onto a child running the OLD one, silently suppressing the badge.
|
|
let spawn_config = super::spawn_snapshot::SpawnConfigSnapshot::from_inputs(
|
|
super::spawn_snapshot::SpawnConfigInputs {
|
|
record,
|
|
descriptor: &descriptor,
|
|
relay_url: &effective_relay_url,
|
|
team_instructions: team_instructions.as_deref(),
|
|
system_prompt: effective_prompt.as_deref(),
|
|
model: effective_model.as_deref(),
|
|
provider: effective_provider.as_deref(),
|
|
enforced_owner_only: super::owner_only_access_build(),
|
|
},
|
|
);
|
|
|
|
// Spawn the harness in its own process group so we can kill the entire
|
|
// tree (harness + MCP servers + agent subprocesses) on shutdown.
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::process::CommandExt;
|
|
command.process_group(0);
|
|
}
|
|
// Windows: suppress the harness console window. Without this a bare
|
|
// terminal pops for buzz-acp.exe and lingers (the app itself sets
|
|
// windows_subsystem="windows", but the spawned child does not inherit it).
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
}
|
|
|
|
let child = command.spawn().map_err(|error| {
|
|
format!(
|
|
"failed to spawn `{}` for agent {}: {error}",
|
|
resolved_acp_command.display(),
|
|
record.name
|
|
)
|
|
})?;
|
|
|
|
// Stamp the adapter availability for runtimes with a version gate (codex
|
|
// only). The summary builder compares this against the current cached value
|
|
// to detect out-of-band adapter changes after spawn (Phase-2 badge fallback).
|
|
// Non-codex runtimes get `None` — nothing changes for them.
|
|
// When the cache is cold (e.g. Doctor just installed and cleared the cache),
|
|
// `adapter_availability_cached()` returns `None`, so the stamp is `None` and
|
|
// the drift check is skipped until discovery warms the cache — preventing a
|
|
// false restart badge immediately after auto-restart.
|
|
let spawned_adapter_availability = if runtime_meta.is_some_and(|r| r.id == "codex") {
|
|
super::adapter_availability_cached()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Windows: assign the harness to a Job Object so its whole tree dies with
|
|
// the handle. The Unix process-group equivalent is set above.
|
|
#[cfg(windows)]
|
|
return Ok(super::process_lifecycle::finish_spawn(
|
|
child,
|
|
log_path,
|
|
spawn_config,
|
|
spawned_setup_mode,
|
|
spawned_adapter_availability,
|
|
start_nonce,
|
|
&record.name,
|
|
));
|
|
#[cfg(not(windows))]
|
|
Ok(crate::managed_agents::ManagedAgentProcess {
|
|
child,
|
|
log_path,
|
|
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(),
|
|
}
|
|
}
|
|
|
|
/// Spawn (or adopt) the runtime pair for `record` on the caller's bound
|
|
/// workspace relay. `workspace_relay` can only be produced by
|
|
/// `bind_expected_relay_scope`, so this spawn consumes — by construction — the
|
|
/// exact workspace-relay read the caller's scope assertion passed on; it never
|
|
/// re-reads the mutable override (see `relay::scope`). The key comes from
|
|
/// [`bound_runtime_key`] — the seam the spawn-key regressions exercise.
|
|
pub fn start_managed_agent_process(
|
|
app: &AppHandle,
|
|
record: &mut ManagedAgentRecord,
|
|
runtimes: &mut HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>,
|
|
owner_hex: Option<&str>,
|
|
workspace_relay: &crate::relay::ScopedWorkspaceRelay,
|
|
) -> Result<(), String> {
|
|
let key = bound_runtime_key(record, workspace_relay)?;
|
|
if let Some(runtime) = runtimes.get_mut(&key) {
|
|
if runtime
|
|
.child
|
|
.try_wait()
|
|
.map_err(|error| format!("failed to inspect running process: {error}"))?
|
|
.is_none()
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
runtimes.remove(&key);
|
|
super::remove_agent_runtime_receipt(app, &key);
|
|
}
|
|
|
|
// 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 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(),
|
|
};
|
|
if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) {
|
|
let _ = terminate_process(process.child.id());
|
|
let _ = process.child.wait();
|
|
return Err(error);
|
|
}
|
|
|
|
record.updated_at = now.clone();
|
|
record.last_started_at = Some(now);
|
|
record.last_stopped_at = None;
|
|
record.last_exit_code = None;
|
|
record.last_error = None;
|
|
record.last_error_code = None;
|
|
|
|
runtimes.insert(key, ManagedAgentPairRuntime::starting(process));
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test_fixtures;
|
|
#[cfg(test)]
|
|
mod tests;
|