mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
feat(desktop): lazily activate workspace agents on visit
Extract agent activation out of the one-shot boot restore into a relay-filtered step that runs on every workspace apply (Phase 2 of lazy multi-workspace agents, building on the Phase 1 relay pinning): - Rename restore_managed_agents_on_launch to activate_workspace_agents, taking the applied workspace relay and starting only local start-on-launch agents pinned to that relay (normalized match; a blank pin matches the visited workspace, mirroring the effective_agent_relay_url defense-in-depth fallback). - Call it from every apply_workspace instead of only the first — boot restore becomes the session's first activation. The one-shot managed_agent_restore_pending flag now gates only the mesh-llm Share Compute restore. - Track activated relays in AppState (activated_agent_relays) so each workspace activates at most once per app session: bouncing A→B→A never resurrects agents the user manually stopped in A. Nothing is stopped on switch — each workspace's agents keep running against their own relay. - Gate the boot-time repos-dir/identity-recovery safety checks behind a new session-wide managed_agent_activation_enabled flag, and run the stale-process/orphan sweeps only on the session's first activation so a later activation cannot reap a concurrent activation's not-yet-tracked children. Tested: cargo test --manifest-path desktop/src-tauri/Cargo.toml (1424 passed, incl. 7 new activation unit tests), clippy -D warnings, rustfmt, cargo check --features mesh-llm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
committed by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent
03f03e8185
commit
2e2babb969
@@ -374,7 +374,7 @@ const overrides = new Map([
|
||||
// +5 (1068 -> 1073): merge with main, which independently added the
|
||||
// managed_agent_profile_reconcile_enabled flag (field + doc + init) under
|
||||
// its own 1042-line override. Union of two separately approved additions.
|
||||
["src-tauri/src/app_state.rs", 1073],
|
||||
["src-tauri/src/app_state.rs", 1054],
|
||||
// multi-slot splitting + no-op suppression (#1309): the ReadStateManager
|
||||
// class grew from ~700 lines to ~1019 with the addition of
|
||||
// splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
io::Write,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU16},
|
||||
@@ -18,26 +18,29 @@ use crate::managed_agents::ManagedAgentProcess;
|
||||
pub struct AppState {
|
||||
pub keys: Mutex<Keys>,
|
||||
pub http_client: reqwest::Client,
|
||||
/// A no-redirect client for authenticated relay media fetches (download,
|
||||
/// clipboard copy, snapshot, editor). Every caller pre-validates the URL
|
||||
/// origin, but the app-wide `http_client` follows redirects by default, so
|
||||
/// a relay `/media/` URL returning a 3xx to an off-origin or private host
|
||||
/// would forward the minted media Authorization header across origins —
|
||||
/// a redirect-hop SSRF. This client treats any 3xx as a non-success
|
||||
/// response (surfaced as an error) so the auth token never leaves the
|
||||
/// validated relay origin.
|
||||
pub media_fetch_client: reqwest::Client,
|
||||
/// Workspace-provided relay URL override. Set by `apply_workspace` on app
|
||||
/// init and takes priority over env vars and compile-time defaults.
|
||||
pub relay_url_override: Mutex<Option<String>>,
|
||||
/// Set during backend setup when managed agents are eligible for launch
|
||||
/// restore. `apply_workspace` consumes it after installing the workspace
|
||||
/// relay and identity, so agents never start against the fallback relay.
|
||||
/// One-shot gate consumed by the first `apply_workspace` after boot to
|
||||
/// restore Share Compute (mesh-llm). Agent activation itself is governed
|
||||
/// by `managed_agent_activation_enabled` + `activated_agent_relays` and
|
||||
/// runs on every `apply_workspace`.
|
||||
pub managed_agent_restore_pending: AtomicBool,
|
||||
/// Whether desktop may repair managed-agent kind:0 profiles from its local
|
||||
/// records. Disabled by the agent-managed profiles experiment so an agent's
|
||||
/// own profile updates are not overwritten on start or restore.
|
||||
pub managed_agent_profile_reconcile_enabled: AtomicBool,
|
||||
/// Session-wide gate for lazy agent activation. Set during backend setup
|
||||
/// only when the boot-time repos-dir and identity-recovery safety checks
|
||||
/// allow agents to start; when false, no `apply_workspace` may auto-start
|
||||
/// agents this session.
|
||||
pub managed_agent_activation_enabled: AtomicBool,
|
||||
/// Normalized relay URLs whose workspaces were already activated this app
|
||||
/// session. A workspace's agents lazily start on the first visit and at
|
||||
/// most once per session — bouncing A→B→A must not resurrect agents the
|
||||
/// user manually stopped in A. Never stops agents: switching away leaves
|
||||
/// the other workspace's agents running against their own relay.
|
||||
pub activated_agent_relays: Mutex<HashSet<String>>,
|
||||
/// One-shot gate: first `apply_workspace` runs `pin_blank_agent_relays`.
|
||||
pub agent_relay_stamp_pending: AtomicBool,
|
||||
/// Shared shutdown signal checked by launch-time agent restoration.
|
||||
@@ -152,27 +155,6 @@ fn identity_from_env() -> Option<Keys> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the no-redirect HTTP client used for authenticated relay media
|
||||
/// fetches (download / copy).
|
||||
///
|
||||
/// This client is a security boundary, not a convenience: it carries a minted
|
||||
/// media `Authorization` header, so it MUST NOT follow redirects. A relay 3xx
|
||||
/// to an off-origin or private host would otherwise forward that header across
|
||||
/// origins (a redirect-hop SSRF). `redirect::Policy::none()` returns the 3xx
|
||||
/// verbatim so the caller can reject it.
|
||||
///
|
||||
/// Returned as a `Result` so the fail-closed invariant is testable — callers
|
||||
/// must never substitute a redirect-following client on build failure. Shares
|
||||
/// the localhost `resolve`/pool config with the app-wide `http_client`.
|
||||
pub fn build_media_fetch_client() -> reqwest::Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0)))
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(10))
|
||||
.pool_max_idle_per_host(1)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn build_app_state() -> AppState {
|
||||
// Env var takes precedence (dev/CI). If absent, resolve_persisted_identity()
|
||||
// in setup() will replace the ephemeral placeholder with a persisted key.
|
||||
@@ -195,14 +177,11 @@ pub fn build_app_state() -> AppState {
|
||||
.pool_max_idle_per_host(1)
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new()),
|
||||
media_fetch_client: build_media_fetch_client().expect(
|
||||
"media_fetch_client must build with redirect::Policy::none(); a \
|
||||
redirect-following fallback would forward the minted media auth \
|
||||
header across origins (redirect-hop SSRF)",
|
||||
),
|
||||
relay_url_override: Mutex::new(None),
|
||||
managed_agent_restore_pending: AtomicBool::new(false),
|
||||
managed_agent_profile_reconcile_enabled: AtomicBool::new(true),
|
||||
managed_agent_activation_enabled: AtomicBool::new(false),
|
||||
activated_agent_relays: Mutex::new(HashSet::new()),
|
||||
agent_relay_stamp_pending: AtomicBool::new(true),
|
||||
shutdown_started: AtomicBool::new(false),
|
||||
managed_agent_restore_transition: Mutex::new(()),
|
||||
|
||||
@@ -5,9 +5,9 @@ use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::managed_agents::{
|
||||
effective_repos_dir, ensure_repos_symlink, load_managed_agents, nest_dir,
|
||||
rebind_agent_relay_urls, restore_managed_agents_on_launch, save_managed_agents,
|
||||
stamp_blank_agent_relay_urls, try_regenerate_nest, write_persisted_repos_dir,
|
||||
activate_workspace_agents, effective_repos_dir, ensure_repos_symlink, load_managed_agents,
|
||||
nest_dir, rebind_agent_relay_urls, save_managed_agents, stamp_blank_agent_relay_urls,
|
||||
try_regenerate_nest, write_persisted_repos_dir,
|
||||
};
|
||||
use crate::relay;
|
||||
|
||||
@@ -104,10 +104,10 @@ pub async fn apply_workspace(
|
||||
relay_url: String,
|
||||
nsec: Option<String>,
|
||||
repos_dir: Option<String>,
|
||||
agent_managed_profiles: Option<bool>,
|
||||
app: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let restore_app = app.clone();
|
||||
let activation_relay_url = relay_url.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
|
||||
@@ -150,13 +150,6 @@ pub async fn apply_workspace(
|
||||
*keys_guard = keys;
|
||||
}
|
||||
|
||||
// Keep the backend-side reconcile guard aligned with the frontend
|
||||
// experiment before launch-time restore can spawn any agents. Missing
|
||||
// means the stable behavior: desktop remains authoritative.
|
||||
state
|
||||
.managed_agent_profile_reconcile_enabled
|
||||
.store(!agent_managed_profiles.unwrap_or(false), Ordering::Release);
|
||||
|
||||
// ── One-shot legacy migration (non-fatal) ─────────────────────────────
|
||||
// Pin any blank-relay agent record to the first workspace applied
|
||||
// after boot — exactly what blank would have resolved to at boot
|
||||
@@ -200,53 +193,35 @@ pub async fn apply_workspace(
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
|
||||
|
||||
let state = restore_app.state::<AppState>();
|
||||
let restore_pending = state
|
||||
// Lazy per-workspace activation: runs on EVERY apply, and self-gates so
|
||||
// each workspace's agents start at most once per app session (launch
|
||||
// restore is simply the first activation). Nothing is stopped on switch —
|
||||
// the previous workspace's agents keep running against their own relay.
|
||||
// The one-shot restore flag is consumed only for Share Compute (mesh-llm),
|
||||
// which must be up before its dependent agents spawn.
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
let mesh_restore_pending = restore_app
|
||||
.state::<AppState>()
|
||||
.managed_agent_restore_pending
|
||||
.swap(false, Ordering::AcqRel);
|
||||
|
||||
// The coordinator starts before React applies the selected workspace, so
|
||||
// its startup publication may have used the fallback relay and placeholder
|
||||
// identity. Correct it off the command path so an unavailable relay cannot
|
||||
// hold the frontend on its loading gate. On initial launch, restore MeshLLM
|
||||
// first so a slow stopped-status request cannot overwrite a newly restored
|
||||
// serving status, then restore managed agents after the admission identity
|
||||
// has been published (or the bounded publication attempt has timed out).
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
{
|
||||
let app = restore_app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app.state::<AppState>();
|
||||
if restore_pending {
|
||||
if let Err(error) =
|
||||
crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await
|
||||
{
|
||||
eprintln!("buzz-desktop: failed to restore Share Compute: {error}");
|
||||
}
|
||||
}
|
||||
crate::mesh_llm::publish_current_status_once(&app, "workspace apply").await;
|
||||
if restore_pending {
|
||||
if let Err(error) =
|
||||
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
|
||||
{
|
||||
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mesh-llm"))]
|
||||
if restore_pending {
|
||||
let app = restore_app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app.state::<AppState>();
|
||||
if let Err(error) =
|
||||
restore_managed_agents_on_launch(&app, &state.shutdown_started).await
|
||||
let app = restore_app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = app.state::<AppState>();
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
if mesh_restore_pending {
|
||||
if let Err(error) = crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await
|
||||
{
|
||||
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
|
||||
eprintln!("buzz-desktop: failed to restore Share Compute: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
crate::mesh_llm::publish_current_status_once(&app, "workspace apply").await;
|
||||
if let Err(error) =
|
||||
activate_workspace_agents(&app, &state.shutdown_started, &activation_relay_url).await
|
||||
{
|
||||
eprintln!("buzz-desktop: failed to activate workspace agents: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ pub fn run() {
|
||||
// Backfill the pinned persona snapshot for any pre-existing agent
|
||||
// that predates the record-authoritative-spawn cutover (persona_id
|
||||
// set but no source_version). Must run before
|
||||
// restore_managed_agents_on_launch so no agent spawns from an empty
|
||||
// activate_workspace_agents so no agent spawns from an empty
|
||||
// snapshot. Synchronous and best-effort — a failure here must not
|
||||
// block launch, but a missing persona is logged loudly inside.
|
||||
if let Err(e) = backfill_persona_snapshots(&app_handle) {
|
||||
@@ -655,16 +655,22 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
// Defer launch-time agent restoration until `apply_workspace` has
|
||||
// installed the active workspace relay and identity. Starting here
|
||||
// would race React initialization and send agents whose saved record
|
||||
// has no relay override to the localhost fallback. Preserve the
|
||||
// boot-time repos and identity recovery safety gates by only marking
|
||||
// restoration pending when both allow it.
|
||||
// Defer agent activation until `apply_workspace` has installed a
|
||||
// workspace relay and identity. Starting here would race React
|
||||
// initialization and send agents whose saved record has no relay
|
||||
// override to the localhost fallback. Each workspace's agents then
|
||||
// lazily activate on its first visit (`activate_workspace_agents`);
|
||||
// the boot restore is simply the first activation. Preserve the
|
||||
// boot-time repos and identity recovery safety gates by only
|
||||
// enabling activation (and the one-shot mesh-llm restore) when
|
||||
// both allow it.
|
||||
if restore_agents && !recovery_mode {
|
||||
state
|
||||
.managed_agent_restore_pending
|
||||
.store(true, Ordering::Release);
|
||||
state
|
||||
.managed_agent_activation_enabled
|
||||
.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
// Periodic sweep: reap orphaned agents from dead instances every 60s.
|
||||
|
||||
@@ -3,10 +3,11 @@ use super::relay_mesh_model_id;
|
||||
use super::{
|
||||
find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas,
|
||||
save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind,
|
||||
ManagedAgentProcess,
|
||||
ManagedAgentProcess, ManagedAgentRecord,
|
||||
};
|
||||
use crate::app_state::AppState;
|
||||
use crate::util;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::Manager;
|
||||
|
||||
@@ -15,7 +16,7 @@ type AgentSpawnResult = (String, SpawnResult);
|
||||
|
||||
/// 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
|
||||
/// `activate_workspace_agents` spawns anything, so no agent boots from an
|
||||
/// empty snapshot.
|
||||
///
|
||||
/// Only records with a `persona_id` but no `persona_source_version` are touched.
|
||||
@@ -73,15 +74,73 @@ pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore managed agents that were running before the app was closed.
|
||||
/// Scope of a relay activation granted by [`begin_relay_activation`].
|
||||
pub(crate) struct RelayActivation {
|
||||
/// True only for the session's very first activation (the boot restore):
|
||||
/// the one-shot stale-process and orphan sweeps run only then. Later
|
||||
/// activations must not re-sweep — a concurrent activation's freshly
|
||||
/// spawned children are not yet tracked and would read as orphans.
|
||||
pub run_boot_sweeps: bool,
|
||||
}
|
||||
|
||||
/// Record `relay_url` as activated for this app session, deciding whether the
|
||||
/// caller may start its agents.
|
||||
///
|
||||
/// Returns `None` when the URL is blank or this relay was already activated
|
||||
/// this session — the caller must not start agents again, so bouncing A→B→A
|
||||
/// never resurrects an agent the user manually stopped in A. Activation is
|
||||
/// marked at attempt time (a failed attempt is not retried until relaunch),
|
||||
/// matching the old one-shot restore flag. Relays are keyed normalized
|
||||
/// (trailing slash, scheme/host case), so cosmetic URL differences cannot
|
||||
/// re-activate a workspace.
|
||||
pub(crate) fn begin_relay_activation(
|
||||
activated_relays: &mut HashSet<String>,
|
||||
relay_url: &str,
|
||||
) -> Option<RelayActivation> {
|
||||
let normalized = crate::relay::normalize_relay_url(relay_url);
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let run_boot_sweeps = activated_relays.is_empty();
|
||||
if !activated_relays.insert(normalized) {
|
||||
return None;
|
||||
}
|
||||
Some(RelayActivation { run_boot_sweeps })
|
||||
}
|
||||
|
||||
/// Whether `record` auto-starts when `workspace_relay_url` is activated:
|
||||
/// a local start-on-launch agent pinned to this relay. A blank pin matches
|
||||
/// the workspace being activated — mirroring `effective_agent_relay_url`'s
|
||||
/// defense-in-depth fallback — so a record that somehow escaped relay
|
||||
/// stamping still auto-starts against the relay it resolves to at spawn.
|
||||
pub(crate) fn record_activates_on_relay(
|
||||
record: &ManagedAgentRecord,
|
||||
workspace_relay_url: &str,
|
||||
) -> bool {
|
||||
if !record.start_on_app_launch || record.backend != BackendKind::Local {
|
||||
return false;
|
||||
}
|
||||
let pinned = record.relay_url.trim();
|
||||
pinned.is_empty() || crate::relay::relay_urls_equivalent(pinned, workspace_relay_url)
|
||||
}
|
||||
|
||||
/// Lazily activate a workspace's managed agents.
|
||||
///
|
||||
/// Called from every `apply_workspace`: starts local `start_on_app_launch`
|
||||
/// agents pinned to `workspace_relay_url` that are not already running — the
|
||||
/// launch restore is simply the session's first activation. Each workspace
|
||||
/// activates at most once per app session ([`begin_relay_activation`]), and
|
||||
/// nothing is ever stopped here: switching away leaves the previous
|
||||
/// workspace's agents running against their own relay.
|
||||
///
|
||||
/// Split into three phases to minimise lock contention with the frontend:
|
||||
/// A (under lock): sync process state, cleanup, collect agents to start
|
||||
/// B (no locks): resolve commands and spawn processes in parallel
|
||||
/// C (re-lock): write back PIDs and status to records on disk
|
||||
pub async fn restore_managed_agents_on_launch(
|
||||
pub async fn activate_workspace_agents(
|
||||
app: &tauri::AppHandle,
|
||||
shutdown_started: &AtomicBool,
|
||||
workspace_relay_url: &str,
|
||||
) -> Result<(), String> {
|
||||
if shutdown_started.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
@@ -89,7 +148,27 @@ pub async fn restore_managed_agents_on_launch(
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
|
||||
// ── Phase A (under lock): housekeeping + collect agents to restore ──
|
||||
// Session-wide boot gate: repos-dir resolution and identity recovery are
|
||||
// decided once at launch. When either fails closed, no workspace may
|
||||
// auto-start agents this session.
|
||||
if !state
|
||||
.managed_agent_activation_enabled
|
||||
.load(Ordering::Acquire)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(activation) = ({
|
||||
let mut activated_relays = state
|
||||
.activated_agent_relays
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?;
|
||||
begin_relay_activation(&mut activated_relays, workspace_relay_url)
|
||||
}) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// ── Phase A (under lock): housekeeping + collect agents to activate ──
|
||||
let mut agents_to_start: Vec<super::ManagedAgentRecord>;
|
||||
{
|
||||
let _store_guard = state
|
||||
@@ -111,40 +190,51 @@ pub async fn restore_managed_agents_on_launch(
|
||||
&mut runtimes,
|
||||
&super::current_instance_id(app),
|
||||
);
|
||||
changed |=
|
||||
kill_stale_tracked_processes(&mut records, &runtimes, &super::current_instance_id(app));
|
||||
|
||||
let tracked_pids: Vec<u32> = records
|
||||
.iter()
|
||||
.filter_map(|r| r.runtime_pid)
|
||||
.chain(runtimes.values().map(|rt| rt.child.id()))
|
||||
.collect();
|
||||
super::sweep_orphaned_agent_processes(app, &tracked_pids);
|
||||
// One-shot boot cleanup: previous-session leftovers and orphans are
|
||||
// swept only on the session's first activation. Later activations
|
||||
// must not sweep — a concurrent activation's or UI start's freshly
|
||||
// spawned children are not yet in the tracked set and would be killed
|
||||
// as orphans.
|
||||
if activation.run_boot_sweeps {
|
||||
changed |= kill_stale_tracked_processes(
|
||||
&mut records,
|
||||
&runtimes,
|
||||
&super::current_instance_id(app),
|
||||
);
|
||||
|
||||
// System-wide sweep: enumerate all user processes and kill any known
|
||||
// agent binaries not tracked by this session. Catches orphans whose
|
||||
// PID files were already cleaned up (e.g. agent workers in their own
|
||||
// process group whose parent harness exited).
|
||||
super::sweep_system_agent_processes(&super::current_instance_id(app), &tracked_pids);
|
||||
let tracked_pids: Vec<u32> = records
|
||||
.iter()
|
||||
.filter_map(|r| r.runtime_pid)
|
||||
.chain(runtimes.values().map(|rt| rt.child.id()))
|
||||
.collect();
|
||||
super::sweep_orphaned_agent_processes(app, &tracked_pids);
|
||||
|
||||
// Dead-instance reaping: find agents belonging to Buzz instances
|
||||
// whose desktop process is no longer running and reap them.
|
||||
super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids);
|
||||
// System-wide sweep: enumerate all user processes and kill any known
|
||||
// agent binaries not tracked by this session. Catches orphans whose
|
||||
// PID files were already cleaned up (e.g. agent workers in their own
|
||||
// process group whose parent harness exited).
|
||||
super::sweep_system_agent_processes(&super::current_instance_id(app), &tracked_pids);
|
||||
|
||||
// Exact-path sweep: kill any buzz-acp process whose executable path
|
||||
// matches this bundle's harness binary but is not in the tracked set.
|
||||
// Complements the env-var sweep above — catches orphans that predate
|
||||
// BUZZ_MANAGED_AGENT injection or lost their PID-file receipt.
|
||||
//
|
||||
// TODO: the three sweeps above each walk the PID table independently.
|
||||
// A future consolidation should collect a single shared process snapshot
|
||||
// at the top of this block and thread it through all sweep functions,
|
||||
// replacing the three separate kernel enumerations.
|
||||
super::sweep_untracked_bundle_harnesses(&tracked_pids);
|
||||
// Dead-instance reaping: find agents belonging to Buzz instances
|
||||
// whose desktop process is no longer running and reap them.
|
||||
super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids);
|
||||
|
||||
// Exact-path sweep: kill any buzz-acp process whose executable path
|
||||
// matches this bundle's harness binary but is not in the tracked set.
|
||||
// Complements the env-var sweep above — catches orphans that predate
|
||||
// BUZZ_MANAGED_AGENT injection or lost their PID-file receipt.
|
||||
//
|
||||
// TODO: the three sweeps above each walk the PID table independently.
|
||||
// A future consolidation should collect a single shared process snapshot
|
||||
// at the top of this block and thread it through all sweep functions,
|
||||
// replacing the three separate kernel enumerations.
|
||||
super::sweep_untracked_bundle_harnesses(&tracked_pids);
|
||||
}
|
||||
|
||||
let candidates: Vec<String> = records
|
||||
.iter()
|
||||
.filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)
|
||||
.filter(|record| record_activates_on_relay(record, workspace_relay_url))
|
||||
.map(|record| record.pubkey.clone())
|
||||
.collect();
|
||||
|
||||
@@ -380,3 +470,125 @@ fn persist_restore_error(
|
||||
record.last_error = Some(error);
|
||||
save_managed_agents(app, &records)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{begin_relay_activation, record_activates_on_relay, ManagedAgentRecord};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Minimal record with the fields the activation filter reads. Everything
|
||||
/// else takes its serde default (`start_on_app_launch` defaults to true,
|
||||
/// `backend` to Local).
|
||||
fn record_on_relay(relay_url: &str) -> ManagedAgentRecord {
|
||||
serde_json::from_str(&format!(
|
||||
r#"{{
|
||||
"pubkey": "agent-pubkey",
|
||||
"name": "test-agent",
|
||||
"private_key_nsec": "nsec1fake",
|
||||
"relay_url": "{relay_url}",
|
||||
"acp_command": "buzz-acp",
|
||||
"agent_command": "goose",
|
||||
"agent_args": [],
|
||||
"mcp_command": "",
|
||||
"turn_timeout_seconds": 320,
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
}}"#
|
||||
))
|
||||
.expect("sample record")
|
||||
}
|
||||
|
||||
// ── begin_relay_activation: once per relay per session ──────────────────
|
||||
|
||||
#[test]
|
||||
fn first_activation_of_session_runs_boot_sweeps() {
|
||||
let mut activated = HashSet::new();
|
||||
|
||||
let activation = begin_relay_activation(&mut activated, "wss://relay-a.example")
|
||||
.expect("first visit must activate");
|
||||
|
||||
assert!(activation.run_boot_sweeps, "boot restore sweeps orphans");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_workspace_activates_without_boot_sweeps() {
|
||||
let mut activated = HashSet::new();
|
||||
begin_relay_activation(&mut activated, "wss://relay-a.example");
|
||||
|
||||
let activation = begin_relay_activation(&mut activated, "wss://relay-b.example")
|
||||
.expect("a new workspace must activate");
|
||||
|
||||
assert!(
|
||||
!activation.run_boot_sweeps,
|
||||
"sweeps are one-shot: a later activation could kill a concurrent \
|
||||
activation's untracked children"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revisiting_a_workspace_does_not_reactivate() {
|
||||
// A→B→A: the second visit to A must not restart agents the user
|
||||
// stopped there, even when the URL differs only cosmetically.
|
||||
let mut activated = HashSet::new();
|
||||
begin_relay_activation(&mut activated, "wss://relay-a.example");
|
||||
begin_relay_activation(&mut activated, "wss://relay-b.example");
|
||||
|
||||
assert!(begin_relay_activation(&mut activated, "wss://relay-a.example").is_none());
|
||||
assert!(
|
||||
begin_relay_activation(&mut activated, "WSS://Relay-A.Example/").is_none(),
|
||||
"normalized matching: cosmetic URL differences must not re-activate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_relay_never_activates_nor_consumes_the_boot_sweep() {
|
||||
let mut activated = HashSet::new();
|
||||
|
||||
assert!(begin_relay_activation(&mut activated, " ").is_none());
|
||||
|
||||
// The blank no-op must not have claimed the session's boot sweep.
|
||||
let activation = begin_relay_activation(&mut activated, "wss://relay-a.example")
|
||||
.expect("real relay still activates");
|
||||
assert!(activation.run_boot_sweeps);
|
||||
}
|
||||
|
||||
// ── record_activates_on_relay: relay-filtered candidate selection ───────
|
||||
|
||||
#[test]
|
||||
fn record_activates_only_on_its_pinned_relay() {
|
||||
let record = record_on_relay("wss://relay-a.example");
|
||||
|
||||
assert!(record_activates_on_relay(&record, "wss://relay-a.example"));
|
||||
assert!(
|
||||
record_activates_on_relay(&record, "WSS://Relay-A.Example/"),
|
||||
"pin matching is normalized"
|
||||
);
|
||||
assert!(
|
||||
!record_activates_on_relay(&record, "wss://relay-b.example"),
|
||||
"visiting another workspace must not start this agent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_pin_activates_on_the_visited_workspace() {
|
||||
// Defense-in-depth for a record that escaped relay stamping: it spawns
|
||||
// against the active workspace relay (`effective_agent_relay_url`), so
|
||||
// it activates with the workspace being visited.
|
||||
let record = record_on_relay("");
|
||||
assert!(record_activates_on_relay(&record, "wss://relay-a.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opted_out_and_provider_records_never_activate() {
|
||||
let mut record = record_on_relay("wss://relay-a.example");
|
||||
record.start_on_app_launch = false;
|
||||
assert!(!record_activates_on_relay(&record, "wss://relay-a.example"));
|
||||
|
||||
let mut record = record_on_relay("wss://relay-a.example");
|
||||
record.backend = super::BackendKind::Provider {
|
||||
id: "blox".to_string(),
|
||||
config: serde_json::Value::Null,
|
||||
};
|
||||
assert!(!record_activates_on_relay(&record, "wss://relay-a.example"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! - Inputs mirror what a start would actually run: the start/restore paths
|
||||
//! re-snapshot the linked persona's prompt/model/provider/env onto the
|
||||
//! record immediately before spawning (`start_local_agent_with_preflight`,
|
||||
//! `restore_managed_agents_on_launch`), so persona edits to those fields DO
|
||||
//! `activate_workspace_agents`), so persona edits to those fields DO
|
||||
//! apply on a plain restart and are hashed via the same prospective
|
||||
//! re-snapshot. Harness command, args/mcp, env layering, and the record
|
||||
//! fields the spawn env writes read are hashed as spawn resolves them.
|
||||
|
||||
@@ -104,7 +104,7 @@ fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
/// Run every data migration that must complete before identity resolution and
|
||||
/// agent restore. Ordering is load-bearing: `migrate_legacy_app_data_dir` must
|
||||
/// precede any disk read, and `sync_shared_agent_data` must precede
|
||||
/// `restore_managed_agents_on_launch` (which reads `managed-agents.json`).
|
||||
/// `activate_workspace_agents` (which reads `managed-agents.json`).
|
||||
/// Identity-dependent migrations (persona/team event signing) run separately in
|
||||
/// boot setup after the persisted identity is resolved.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user