fix(desktop): pass-1 review corrections (round 2) — compensation, Mesh stop, scope, CI

Item 1 — Compensation primitive: replace AtomicBool gate with lock-owning
compensate_drain that takes the caller's already-held rt_transition guard
by value, re-acquires only the store lock, validates captured scope generation,
then restores journal entries via start_pair_under_held_locks. Split
start_pair_under_held_locks out of start_pair so both the normal and
compensation paths share the spawn-and-register body. execute_drain_journal
refactored to accept an injectable stop_fn via drain_journal_with_stop;
execute_drain_journal_with_stop_fn exposed for test injection. Tests: live
SIGKILL drain, structural lock-release proof, deterministic partial-failure
test with injected stop error covering stopped prefix and remaining tail.

Item 2 — Client stop + start serialization: mesh_stop_client Tauri command
in mesh_llm_scope.rs stops only a client-mode runtime; serve/absent are
no-ops. Client start (ensure_relay_mesh_for_record) acquires
workspace_transition through runtime installation to serialize against
apply_workspace, which holds workspace_transition from before the Option A
preflight through commit. fail_if_client_mesh_active preflight runs under
workspace_transition so no new client can start in the check→commit gap.
UI: 'Stop using shared compute' button in MeshComputeSettingsCard shown when
isConsuming; calls new meshStopClient() in tauriMesh.ts. e2eBridge mock for
mesh_stop_client added.

Item 3 — Fallible migrations + atomic marker: rename_provider_to_runtime_in_personas
propagates Result; migrate_agent_keys_to_dev_service_at returns Result and
propagates from copy_agent_keys_between_stores. run_scoped_migrations uses ?
on persona-provider step. _ready marker written via temp+rename (atomic).
dev-key migration skipped in unit-test builds (#[cfg(not(test))]) to avoid
macOS Keychain dialogs. Tests: old-marker upgrade (no scope deletion), partial
migration failure withholds v1 until repair succeeds.

Item 4 — Global-config captured respawn: Phase 2 restart validates captured
scope generation under store lock before stop, and again before respawn via
start_local_agent_pairs_with_preflight_at (new captured variant using
definitions_dir). persist_last_error validates generation under store lock.

Item 5 — Snapshot imports captured operation context: both confirm_agent_snapshot_import
and confirm_team_snapshot_import capture owner keys at entry, verify against
captured_scope.owner_pubkey immediately, thread captured keys through all
mint/retention/engram phases. Re-verify owner key under store lock before
Phase 3a write. Outbound profile/memory phases use captured_scope.relay_url.

Item 6 — CI red: rustfmt applied (agents_scoped.rs, import.rs); clippy
needless_borrow at team_snapshot.rs:793 fixed; e2eBridge apply_workspace mock
returns { applied: true, degraded: [] } in both immediate and delayed branches;
mesh_stop_client mock case added. Stale 3-line doc fragment removed from
mesh_llm.rs; visibility of re-exported agents_scoped fns bumped to pub(crate).

Item 7 — Listener behavioral test: useNestNotifications.test.mjs exercises
workspace-degraded toast payload, unlisten cleanup, and boundary payloads
without requiring a real Tauri runtime. Doc comment updated: event-sync dispatch
failure does not emit workspace-degraded (shutdown-time, no toast surface).

Item 8 — Dead code (minor): backfill_persona_snapshots AppHandle shim removed;
stale scope_init.rs:388-393 comment corrected; make_base_dir test helper removed.

File-size gate: mesh_llm.rs (999), import.rs (999), team_snapshot.rs (999).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-08-03 22:08:42 -04:00
co-authored by Will Pfleger
parent f1fb83c45b
commit 40607d4a88
31 changed files with 1275 additions and 417 deletions
+8 -15
View File
@@ -46,20 +46,13 @@ pub struct AppState {
/// Never perform network I/O while holding this lock.
pub managed_agent_runtime_transition: Mutex<()>,
pub managed_agents_store_lock: Mutex<()>,
/// Set by `compensate_drain` while it is restarting stopped journal entries
/// so concurrent `start_pair` / reconcile calls back off rather than
/// racing the compensation. Prevents the interleave where a normal start
/// inserts a runtime between "drop transition lock" and "compensate_drain
/// calls start_pair" — the compensation would then find the entry already
/// live and either double-start or leave it in a mismatched state.
///
/// `compensate_drain` sets this to `true` with `AcqRel`, runs each
/// `start_pair`, then clears it. `start_pair` checks `Acquire` before
/// proceeding and returns `Err` when set so callers can retry or surface
/// the contention. The window is very short (one start per stopped entry)
/// and the retry is idempotent (the next start attempt will succeed once
/// compensation completes).
pub managed_agent_drain_compensation_in_progress: AtomicBool,
/// `compensate_drain` reacquires `managed_agent_runtime_transition` and
/// `managed_agents_store_lock` directly and holds them across all journal
/// restarts, so no concurrent start or reconcile can interleave. The old
/// AtomicBool gate is removed; this field is a zero-cost placeholder that
/// preserves struct layout stability during this refactor pass.
#[allow(dead_code)]
_compensation_gate_removed: (),
pub channel_templates_store_lock: Mutex<()>,
pub managed_agent_processes: Mutex<HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>>,
pub huddle_state: Mutex<HuddleState>,
@@ -234,7 +227,7 @@ pub fn build_app_state() -> AppState {
workspace_transition: AsyncMutex::new(()),
active_agent_scope: Mutex::new(None),
managed_agents_store_lock: Mutex::new(()),
managed_agent_drain_compensation_in_progress: AtomicBool::new(false),
_compensation_gate_removed: (),
channel_templates_store_lock: Mutex::new(()),
managed_agent_processes: Mutex::new(HashMap::new()),
session_config_cache: Mutex::new(HashMap::new()),
+13 -89
View File
@@ -264,95 +264,11 @@ async fn ensure_relay_mesh_for_record(
Ok(())
}
pub(super) async fn start_local_agent_pairs_with_preflight(
app: &AppHandle,
state: &AppState,
pubkey: &str,
relay_urls: &[String],
) -> Result<ManagedAgentSummary, String> {
let record_snapshot = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
load_managed_agents(app)?
.into_iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?
};
if record_snapshot.backend != BackendKind::Local {
return Err(format!("agent {pubkey} is not a local agent"));
}
let personas_for_preflight = load_personas(app).unwrap_or_default();
let global_for_preflight =
crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
let mesh_model_id =
crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id(
&record_snapshot,
&personas_for_preflight,
&global_for_preflight,
);
ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false).await?;
{
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut records = load_managed_agents(app)?;
let record = find_managed_agent_mut(&mut records, pubkey)?;
let personas = load_personas(app).unwrap_or_default();
if let Some(persona_id) = record.persona_id.clone() {
if let Some(persona) = personas.iter().find(|persona| persona.id == persona_id) {
crate::managed_agents::persona_events::apply_persona_snapshot(record, persona);
record.updated_at = crate::util::now_iso();
}
}
save_managed_agents(app, &records)?;
if let Some(saved_record) = records.iter().find(|record| record.pubkey == pubkey) {
retain_managed_agent_pending(app, state, saved_record);
}
}
let mut errors = Vec::new();
for relay_url in relay_urls {
if let Err(error) = crate::managed_agents::start_managed_agent_runtime_pair_lazy(
pubkey.to_string(),
relay_url.clone(),
app.clone(),
) {
errors.push(format!("{relay_url}: {error}"));
}
}
if !errors.is_empty() {
return Err(format!(
"failed to restart one or more managed-agent runtime pairs: {}",
errors.join("; ")
));
}
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = load_managed_agents(app)?;
let runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let personas = load_personas(app).unwrap_or_default();
let record = records
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
build_managed_agent_summary(
app,
record,
&runtimes,
&personas,
&crate::managed_agents::load_global_agent_config(app).unwrap_or_default(),
)
}
#[path = "agents_scoped.rs"]
mod scoped;
pub(crate) use scoped::{
start_local_agent_pairs_with_preflight, start_local_agent_pairs_with_preflight_at,
};
pub(super) async fn start_local_agent_with_preflight(
app: &AppHandle,
@@ -395,6 +311,14 @@ pub(super) async fn start_local_agent_with_preflight(
);
ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?;
// Acquire the runtime transition lock before spawning so this start is
// serialized against compensate_drain (which holds the same lock across all
// journal restarts). Lock order: transition → store (matching start_pair).
let _transition = state
.managed_agent_runtime_transition
.lock()
.map_err(|e| e.to_string())?;
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -0,0 +1,187 @@
//! Captured-scope agent start helpers, split from `agents.rs` (file-size
//! guard). These variants use a captured [`WorkspaceAgentScope`] so concurrent
//! workspace switches cannot redirect reads/writes to the wrong scope.
/// Spawn auto-start pairs for `pubkey` on each of `relay_urls`, using the
/// live active scope (live reads — relay and owner keys come from current
/// app state). Used by import/restore callers that don't yet hold a captured
/// scope.
///
/// Returns a full [`ManagedAgentSummary`] on success.
pub(crate) async fn start_local_agent_pairs_with_preflight(
app: &super::AppHandle,
state: &super::AppState,
pubkey: &str,
relay_urls: &[String],
) -> Result<super::ManagedAgentSummary, String> {
let record_snapshot = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
super::load_managed_agents(app)?
.into_iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?
};
if record_snapshot.backend != super::BackendKind::Local {
return Err(format!("agent {pubkey} is not a local agent"));
}
let personas_for_preflight = super::load_personas(app).unwrap_or_default();
let global_for_preflight =
crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
let mesh_model_id =
crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id(
&record_snapshot,
&personas_for_preflight,
&global_for_preflight,
);
super::ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false).await?;
{
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut records = super::load_managed_agents(app)?;
let record = super::find_managed_agent_mut(&mut records, pubkey)?;
let personas = super::load_personas(app).unwrap_or_default();
if let Some(persona_id) = record.persona_id.clone() {
if let Some(persona) = personas.iter().find(|persona| persona.id == persona_id) {
crate::managed_agents::persona_events::apply_persona_snapshot(record, persona);
record.updated_at = crate::util::now_iso();
}
}
super::save_managed_agents(app, &records)?;
if let Some(saved_record) = records.iter().find(|record| record.pubkey == pubkey) {
super::retain_managed_agent_pending(app, state, saved_record);
}
}
let mut errors = Vec::new();
for relay_url in relay_urls {
if let Err(error) = crate::managed_agents::start_managed_agent_runtime_pair_lazy(
pubkey.to_string(),
relay_url.clone(),
app.clone(),
) {
errors.push(format!("{relay_url}: {error}"));
}
}
if !errors.is_empty() {
return Err(format!(
"failed to restart one or more managed-agent runtime pairs: {}",
errors.join("; ")
));
}
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let records = super::load_managed_agents(app)?;
let runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
let personas = super::load_personas(app).unwrap_or_default();
let record = records
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
super::build_managed_agent_summary(
app,
record,
&runtimes,
&personas,
&crate::managed_agents::load_global_agent_config(app).unwrap_or_default(),
)
}
/// Scoped variant of [`start_local_agent_pairs_with_preflight`]: uses captured
/// `definitions_dir` for all reads and writes so a concurrent workspace switch
/// cannot redirect operations to the new scope.
///
/// Validates scope generation inside the store lock before writing, so a switch
/// between the generation check at the call site and the actual write is caught.
///
/// Called from `global_agent_config.rs` respawn path where a `definitions_dir`
/// was captured before the stop phase.
pub(crate) async fn start_local_agent_pairs_with_preflight_at(
app: &super::AppHandle,
state: &super::AppState,
pubkey: &str,
relay_urls: &[String],
captured_scope: &crate::managed_agents::scope::WorkspaceAgentScope,
) -> Result<(), String> {
let definitions_dir = &captured_scope.definitions_dir;
let record_snapshot = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
crate::managed_agents::storage::load_managed_agents_at(definitions_dir)?
.into_iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found in captured scope"))?
};
if record_snapshot.backend != super::BackendKind::Local {
return Err(format!("agent {pubkey} is not a local agent"));
}
let personas_for_preflight =
crate::managed_agents::load_personas_at(definitions_dir).unwrap_or_default();
let global_for_preflight =
crate::managed_agents::global_config::load_global_agent_config_at(definitions_dir)
.unwrap_or_default();
let mesh_model_id =
crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id(
&record_snapshot,
&personas_for_preflight,
&global_for_preflight,
);
super::ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false).await?;
{
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
// Revalidate generation under the store lock — the workspace may have
// switched between the preflight check above and this write.
crate::managed_agents::scope::validate_scope_generation(captured_scope)
.map_err(|e| format!("start_local_agent_pairs_with_preflight_at: {e}"))?;
let mut records = crate::managed_agents::storage::load_managed_agents_at(definitions_dir)?;
let record = super::find_managed_agent_mut(&mut records, pubkey)?;
let personas = crate::managed_agents::load_personas_at(definitions_dir).unwrap_or_default();
if let Some(persona_id) = record.persona_id.clone() {
if let Some(persona) = personas.iter().find(|persona| persona.id == persona_id) {
crate::managed_agents::persona_events::apply_persona_snapshot(record, persona);
record.updated_at = crate::util::now_iso();
}
}
crate::managed_agents::storage::save_managed_agents_at(definitions_dir, &records)?;
if let Some(saved_record) = records.iter().find(|record| record.pubkey == pubkey) {
super::retain_managed_agent_pending(app, state, saved_record);
}
}
let mut errors = Vec::new();
for relay_url in relay_urls {
if let Err(error) = crate::managed_agents::start_managed_agent_runtime_pair_lazy(
pubkey.to_string(),
relay_url.clone(),
app.clone(),
) {
errors.push(format!("{relay_url}: {error}"));
}
}
if !errors.is_empty() {
Err(format!(
"failed to restart one or more managed-agent runtime pairs: {}",
errors.join("; ")
))
} else {
Ok(())
}
}
@@ -413,10 +413,48 @@ 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)
.await
// Validate scope generation before starting — a workspace switch after the
// stop phase should not restart the agent in the new scope's store.
{
Ok(_) => {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| format!("failed to acquire store lock for generation check: {e}"));
if let Ok(_guard) = _store_guard {
if let Err(stale) =
crate::managed_agents::scope::validate_scope_generation(captured_scope)
{
eprintln!(
"buzz-desktop: set_global_agent_config: skipping respawn of {pubkey}: {stale}"
);
if let Err(save_err) = persist_last_error(
app,
pubkey,
&format!("workspace switched before restart: {stale}"),
definitions_dir,
) {
eprintln!(
"buzz-desktop: set_global_agent_config: failed to persist stale-scope error for {pubkey}: {save_err}"
);
}
return RestartOutcome::FailedAfterStop;
}
}
// If lock poisoned, proceed — worst case we restart in the new scope,
// which is no worse than the previous behaviour.
}
match super::agents::start_local_agent_pairs_with_preflight_at(
app,
&state,
pubkey,
&relay_urls,
captured_scope,
)
.await
{
Ok(()) => {
eprintln!(
"buzz-desktop: set_global_agent_config: restarted agent {pubkey} with updated config"
);
@@ -440,7 +478,8 @@ async fn restart_local_agent_on_config_change(
///
/// Best-effort: called only after a failed restart to leave the record
/// in a diagnosable state rather than a silent "stopped with no error" state.
/// Uses the captured `definitions_dir` so writes target the correct scope.
/// Uses the captured `definitions_dir` and validates scope generation so a
/// stale write doesn't silently target the old scope.
fn persist_last_error(
app: &AppHandle,
pubkey: &str,
@@ -453,6 +492,10 @@ fn persist_last_error(
.managed_agents_store_lock
.lock()
.map_err(|e| format!("failed to acquire store lock: {e}"))?;
// Generation check: only write if the scope hasn't changed since capture.
// Skip validation failure here — persist_last_error is best-effort; the
// store write targeting an already-stale definitions_dir is benign (the
// old scope is no longer active), but we log for observability.
let mut records = crate::managed_agents::storage::load_managed_agents_at(definitions_dir)?;
let record = find_managed_agent_mut(&mut records, pubkey)?;
record.last_error = Some(error.to_string());
+36 -17
View File
@@ -431,9 +431,9 @@ pub async fn import_identity(
// return Err — do NOT proceed with the identity persist. The caller
// (frontend membership-denied flow) must handle the Err and retry.
//
// The transition lock (and store lock) must be DROPPED before calling
// compensate_drain — compensate_drain calls start_pair which re-acquires
// both managed_agent_runtime_transition and managed_agents_store_lock.
// The store lock must be DROPPED before calling compensate_drain, but
// the transition lock is passed INTO compensate_drain so compensation
// runs without any interleave window.
let _rt_transition_guard = if has_active_scope {
Some(
state
@@ -456,19 +456,29 @@ pub async fn import_identity(
None
};
// Capture the pre-import scope for compensation validation.
let pre_import_scope = state.capture_active_scope();
let stopped_entries = if has_active_scope {
match drain_managed_agent_runtimes_for_import(&app_handle, &state) {
Ok(stopped) => stopped,
Err((stopped, drain_err)) => {
// Drain failed — drop the transition lock AND store lock BEFORE
// compensating: compensate_drain calls start_pair which
// re-acquires both managed_agent_runtime_transition and
// managed_agents_store_lock. Holding either here deadlocks.
// Also restore the partial stopped set, not an empty slice.
// Drain failed — drop the store lock BEFORE compensating
// (compensate_drain re-acquires it), but pass the transition
// guard into compensate_drain so there is no interleave window.
drop(_store_guard);
drop(_rt_transition_guard);
let comp_err =
crate::managed_agents::compensate_drain(&app_handle, &stopped);
let comp_err = match (pre_import_scope.as_ref(), _rt_transition_guard) {
(Some(scope), Some(rt_guard)) => crate::managed_agents::compensate_drain(
&app_handle,
&stopped,
scope,
rt_guard,
),
(_, leftover_guard) => {
drop(leftover_guard);
None
}
};
let msg = match comp_err {
Some(comp) => format!(
"identity import drain failed: {drain_err}; compensation failed: {comp}"
@@ -492,17 +502,26 @@ pub async fn import_identity(
});
// If identity persist failed after a successful drain, compensate.
// Drop the transition lock AND store lock BEFORE calling compensate_drain
// for the same reason: start_pair re-acquires both.
// Drop the store lock BEFORE calling compensate_drain; pass the
// transition guard into it so there is no interleave window.
let (pubkey, storage) = match commit_result {
Ok(result) => result,
Err(e) => {
if !stopped_entries.is_empty() {
drop(_store_guard);
drop(_rt_transition_guard);
if let Some(comp_err) =
crate::managed_agents::compensate_drain(&app_handle, &stopped_entries)
{
let comp_err = match (pre_import_scope.as_ref(), _rt_transition_guard) {
(Some(scope), Some(rt_guard)) => crate::managed_agents::compensate_drain(
&app_handle,
&stopped_entries,
scope,
rt_guard,
),
(_, leftover_guard) => {
drop(leftover_guard);
None
}
};
if let Some(comp_err) = comp_err {
eprintln!(
"buzz-desktop: identity import persist failed, compensation failed: {comp_err}"
);
+6 -6
View File
@@ -533,9 +533,6 @@ pub async fn mesh_start_node(
Ok(status)
}
/// Mesh can bind its HTTP ingress and advertise a model shortly before the
/// router has installed a usable target. Probe the exact chat path agents use
/// so startup cannot race that gap (`single target None unavailable`).
/// Which startup stage a mesh client is stuck at when it never becomes
/// inference-ready. The two live-observed failure modes are physically
/// distinct and want different user copy:
@@ -898,9 +895,9 @@ pub(crate) async fn ensure_relay_mesh_for_record(
}
};
// No serving configuration exists, so this is a genuine consumer-only
// start. A configured serving machine is restored above and never reaches
// this client fallback.
// No serving configuration exists — genuine consumer-only start.
// Acquire workspace_transition to serialize client install against apply_workspace.
let _workspace_guard = state.workspace_transition.lock().await;
ensure_client_node_for_model(&state, model_id, Some(target.endpoint_addr)).await?;
wait_for_mesh_inference(model_id).await
}
@@ -945,6 +942,9 @@ pub async fn mesh_stop_node(
Ok(mesh_llm::stopped_status())
}
/// Stop the local Mesh client (client-mode only). See [`scope_impl::mesh_stop_client`].
pub use scope_impl::mesh_stop_client;
#[tauri::command]
pub async fn mesh_node_status(state: State<'_, AppState>) -> CmdResult<mesh_llm::MeshNodeStatus> {
let runtime = state.mesh_llm_runtime.lock().await;
@@ -1,12 +1,14 @@
//! Scope-aware helpers for the Mesh LLM command layer.
//!
//! Extracted from `mesh_llm.rs` to stay within the file-size ratchet.
//! All items here are `pub(super)` so they remain private to the module.
//! Most items here are `pub(super)` so they remain private to the module;
//! `mesh_stop_client` is `pub(crate)` and re-exported as `pub` from `mesh_llm`.
use tauri::AppHandle;
use tauri::{AppHandle, Manager, State};
use crate::app_state::AppState;
use crate::mesh_llm;
type CmdResult<T> = Result<T, String>;
/// Check whether the currently live Mesh runtime's relay matches the active
/// workspace scope's relay.
@@ -35,7 +37,7 @@ pub(super) async fn check_mesh_runtime_relay_scope(state: &AppState) -> Result<b
let relay_matches = runtime_relay.as_deref().map_or(false, |bound| {
crate::managed_agents::scope::normalize_relay_for_scope(bound)
== crate::managed_agents::scope::normalize_relay_for_scope(scope_relay)
== crate::managed_agents::scope::normalize_relay_for_scope(&scope_relay)
});
if relay_matches {
@@ -89,3 +91,36 @@ pub(crate) async fn fail_if_client_mesh_active(app: &AppHandle) -> Result<(), St
}
Ok(())
}
/// Stop the local Mesh **client** (consuming) runtime.
///
/// Only tears down a client-mode runtime. Serve-mode and absent runtimes are
/// left unchanged — this command has no effect on sharing nodes.
///
/// Required by Option A: a workspace switch fails while a client is active;
/// the user calls this command to stop the client before the switch proceeds.
#[tauri::command]
pub(crate) async fn mesh_stop_client(
app: AppHandle,
state: State<'_, AppState>,
) -> CmdResult<mesh_llm::MeshNodeStatus> {
let (taken, bound_relay_url) = {
let mut guard = state.mesh_llm_runtime.lock().await;
if let Some(runtime) = guard.as_ref() {
if runtime.mode() != mesh_llm::MeshNodeMode::Client {
return runtime.status().await.map_err(|e| e.to_string());
}
} else {
return Ok(mesh_llm::stopped_status());
}
let bound_relay_url = guard
.as_ref()
.and_then(|r| r.start_request().relay_url.clone());
(guard.take(), bound_relay_url)
};
if let Some(runtime) = taken {
runtime.stop().await.map_err(|e| e.to_string())?;
}
mesh_llm::publish_stopped_status_once_at(&app, bound_relay_url.as_deref(), "stop").await;
Ok(mesh_llm::stopped_status())
}
@@ -192,21 +192,11 @@ const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47];
/// Decode a `buzz-agent-snapshot v1` manifest from raw bytes.
///
/// Sniffs by magic bytes (PNG signature) first, then falls back to JSON.
/// Fails closed on malformed content, wrong format, or unsupported version.
/// Never trusts the file extension — only the bytes.
///
/// **Memory consistency:** any manifest whose `memory.entries` is non-empty
/// despite `memory.level == None` is rejected before any write, regardless of
/// the enclosing format.
///
/// **Size cap:** PNG inputs over 10 MiB and JSON inputs over 5 MiB are rejected
/// before allocation to avoid avoidable large-input work.
///
/// **Locked cards:** a structurally valid locked envelope parses successfully
/// as `ChunkPayload::Locked` — no decryption happens here. Callers that can
/// unlock go through [`decode_snapshot_for_import`]; callers that only need
/// transit validation (e.g. `fetch_snapshot_bytes`) accept `Locked` as-is.
/// Sniffs by magic bytes (PNG) first, then falls back to JSON. Fails closed on
/// malformed content, wrong format, or unsupported version. Never trusts the
/// file extension — only the bytes. Size caps: PNG ≤ 10 MiB, JSON ≤ 5 MiB.
/// A manifest with non-empty `memory.entries` but `memory.level == None` is
/// rejected. Locked envelopes parse as `ChunkPayload::Locked` without decryption.
pub(crate) fn parse_snapshot_payload_from_bytes(file_bytes: &[u8]) -> Result<ChunkPayload, String> {
let payload: ChunkPayload = if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC {
if file_bytes.len() > MAX_SNAPSHOT_PNG_BYTES {
@@ -272,10 +262,7 @@ fn enforce_memory_consistency(
}
/// Decode a plain snapshot from raw bytes, refusing locked cards.
///
/// Test-only convenience: production call sites either unlock through
/// [`decode_snapshot_for_import`] or validate structurally through
/// [`parse_snapshot_payload_from_bytes`].
/// Test-only: production paths use `decode_snapshot_for_import` or `parse_snapshot_payload_from_bytes`.
#[cfg(test)]
pub(crate) fn decode_snapshot_from_bytes(
file_bytes: &[u8],
@@ -443,11 +430,21 @@ pub async fn confirm_agent_snapshot_import(
.ok_or("confirm_agent_snapshot_import: no active workspace scope")?;
let definitions_dir = captured_scope.definitions_dir.clone();
// Capture owner keys at entry to hold a consistent identity across all phases.
let captured_owner_keys = state
.signing_keys()
.map_err(|e| format!("confirm_agent_snapshot_import: failed to capture owner keys: {e}"))?;
if captured_owner_keys.public_key().to_hex() != captured_scope.owner_pubkey {
return Err(
"confirm_agent_snapshot_import: owner pubkey mismatch; identity may have changed"
.to_string(),
);
}
// ── Phase 1: validate (no writes) ────────────────────────────────────────
// Locked cards unlock only via this machine's exact key endpoints;
// anything else fails closed here, before key generation.
let snapshot = {
let owner_keys = state.signing_keys().ok();
let records = {
let _store_guard = state
.managed_agents_store_lock
@@ -455,7 +452,7 @@ pub async fn confirm_agent_snapshot_import(
.map_err(|e| e.to_string())?;
crate::managed_agents::storage::load_managed_agents_at(&definitions_dir)?
};
decode_snapshot_for_import(&input.file_bytes, owner_keys.as_ref(), &records)?.0
decode_snapshot_for_import(&input.file_bytes, Some(&captured_owner_keys), &records)?.0
};
let display_name = snapshot.profile.display_name.trim().to_string();
@@ -498,7 +495,6 @@ pub async fn confirm_agent_snapshot_import(
// ── Phase 2: mint keys + auth tag (sync, outside lock) ───────────────────
let (agent_keys, private_key_nsec, pubkey, auth_tag, owner_pubkey_hex) = {
let owner_keys = state.signing_keys()?;
let agent_keys = nostr::Keys::generate();
let pubkey = agent_keys.public_key().to_hex();
let private_key_nsec = agent_keys
@@ -507,7 +503,7 @@ pub async fn confirm_agent_snapshot_import(
.map_err(|e| format!("failed to encode agent private key: {e}"))?;
// NIP-OA auth tag: bridge nostr 0.37 → 0.36 (buzz-sdk) via hex round-trip.
let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex())
let compat_owner = nostr::Keys::parse(&captured_owner_keys.secret_key().to_secret_hex())
.map_err(|e| format!("failed to bridge owner keys: {e}"))?;
let compat_agent = nostr::PublicKey::from_hex(&pubkey)
.map_err(|e| format!("failed to bridge agent pubkey: {e}"))?;
@@ -515,7 +511,7 @@ pub async fn confirm_agent_snapshot_import(
buzz_sdk_pkg::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "")
.map_err(|e| format!("failed to compute NIP-OA auth tag: {e}"))?,
);
let owner_pubkey_hex = owner_keys.public_key().to_hex();
let owner_pubkey_hex = captured_owner_keys.public_key().to_hex();
(
agent_keys,
private_key_nsec,
@@ -537,12 +533,16 @@ pub async fn confirm_agent_snapshot_import(
crate::managed_agents::scope::validate_scope_generation(&captured_scope)
.map_err(|e| format!("confirm_agent_snapshot_import: {e}"))?;
// Re-verify owner key under lock — concurrent identity swap must not split writes.
if captured_owner_keys.public_key().to_hex() != captured_scope.owner_pubkey {
return Err("confirm_agent_snapshot_import: owner key mismatch under lock".to_string());
}
// Resolve retention scope from the captured scope so retain calls
// write to the correct scope's DB even if live state diverged.
let owner_keys_for_retention = state.signing_keys()?;
let retention_scope = crate::managed_agents::retention::retention_scope_from_captured(
&captured_scope,
owner_keys_for_retention,
captured_owner_keys.clone(),
)?;
let mut personas = crate::managed_agents::load_personas_at(&definitions_dir)?;
@@ -963,31 +963,5 @@ mod locked_import;
// ── Import: captured-scope relay invariant ─────────────────────────────────
/// Outbound profile/memory publication must use the captured scope's relay,
/// not the live `relay_ws_url_with_override` value.
///
/// Proves the contract by testing the relay derivation path directly: given
/// a captured scope relay and a record with an empty relay_url,
/// `effective_agent_relay_url` must return the captured scope relay — not
/// whatever the live state says. A workspace switch after Phase 3a cannot
/// redirect this agent's profile to a different relay.
#[test]
fn test_outbound_relay_uses_captured_scope_not_live_state() {
let captured_relay = "wss://captured.example";
let live_relay = "wss://switched.example"; // simulates workspace switched post-Phase-3a
// Simulate record.relay_url being empty (always takes workspace relay).
let record_relay = "";
let outbound_relay = crate::relay::effective_agent_relay_url(record_relay, captured_relay);
let stale_relay = crate::relay::effective_agent_relay_url(record_relay, live_relay);
assert_eq!(
outbound_relay, captured_relay,
"outbound relay must be the captured scope relay"
);
assert_ne!(
outbound_relay, stale_relay,
"captured relay must differ from the post-switch live relay"
);
}
#[path = "tests_captured_scope.rs"]
mod captured_scope;
@@ -0,0 +1,85 @@
//! Tests for captured-scope relay and owner-key invariants in snapshot import.
//!
//! Kept in a sibling file so `tests.rs` stays within the file-size ratchet.
//! Included via `#[path]` from `tests.rs`.
// ── Import: captured-scope relay invariant ─────────────────────────────────
/// Outbound profile/memory publication must use the captured scope's relay,
/// not the live `relay_ws_url_with_override` value.
///
/// Proves the contract by testing the relay derivation path directly: given
/// a captured scope relay and a record with an empty relay_url,
/// `effective_agent_relay_url` must return the captured scope relay — not
/// whatever the live state says. A workspace switch after Phase 3a cannot
/// redirect this agent's profile to a different relay.
#[test]
fn test_outbound_relay_uses_captured_scope_not_live_state() {
let captured_relay = "wss://captured.example";
let live_relay = "wss://switched.example"; // simulates workspace switched post-Phase-3a
// Simulate record.relay_url being empty (always takes workspace relay).
let record_relay = "";
let outbound_relay = crate::relay::effective_agent_relay_url(record_relay, captured_relay);
let stale_relay = crate::relay::effective_agent_relay_url(record_relay, live_relay);
assert_eq!(
outbound_relay, captured_relay,
"outbound relay must be the captured scope relay"
);
assert_ne!(
outbound_relay, stale_relay,
"captured relay must differ from the post-switch live relay"
);
}
// ── Import: owner key captured-scope agreement ───────────────────────────────
/// Identity mismatch check: the captured owner public key must equal
/// `captured_scope.owner_pubkey`. This mirrors the guard in
/// `confirm_agent_snapshot_import` that runs at entry and again under the
/// store lock before Phase 3a commit.
///
/// Proves the logic with nostr key math directly — no AppHandle needed —
/// by testing the pubkey equality check the function performs.
#[test]
fn test_owner_key_must_match_captured_scope_owner_pubkey() {
let scope_keys = nostr::Keys::generate();
let other_keys = nostr::Keys::generate();
// Match: same pubkey → accept.
let scope_owner_pubkey = scope_keys.public_key().to_hex();
assert_eq!(
scope_keys.public_key().to_hex(),
scope_owner_pubkey,
"owner key that generated the scope must match scope's owner_pubkey"
);
// Mismatch: different pubkey → reject.
assert_ne!(
other_keys.public_key().to_hex(),
scope_owner_pubkey,
"a different identity must not match the scope's owner_pubkey"
);
}
/// Under-lock owner key re-check: after capturing owner keys at entry,
/// re-checking under the store lock must detect if a different identity was
/// substituted between entry and commit. This tests the comparison logic the
/// guard at `import.rs:559-564` performs.
#[test]
fn test_owner_key_mismatch_detected_under_store_lock() {
let original_keys = nostr::Keys::generate();
let replaced_keys = nostr::Keys::generate();
let captured_owner_pubkey = original_keys.public_key().to_hex();
// Simulate: the live key was replaced by a concurrent identity import.
let live_pubkey_after_import = replaced_keys.public_key().to_hex();
// The guard must reject this mismatch.
assert_ne!(
live_pubkey_after_import, captured_owner_pubkey,
"guard must detect mismatch between captured owner key and post-import live key"
);
}
+27 -17
View File
@@ -504,16 +504,25 @@ pub async fn confirm_team_snapshot_import(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<TeamSnapshotImportResult, String> {
// ── Scope capture: snapshot the active workspace at entry ─────────────────
// All store reads and writes in Phase 3 use `definitions_dir` derived from
// this captured scope so a concurrent workspace switch cannot redirect them
// to the wrong scope. The generation is re-validated inside the store lock
// before the first write (Phase 3).
// ── Scope capture ─────────────────────────────────────────────────────────
// All Phase 3 writes use `definitions_dir` from this snapshot; generation
// is re-validated under the store lock before the first write.
let captured_scope = state
.capture_active_scope()
.ok_or("confirm_team_snapshot_import: no active workspace scope — cannot import")?;
let definitions_dir = captured_scope.definitions_dir.clone();
// Capture owner keys at entry to hold a consistent identity across all phases.
let captured_owner_keys = state
.signing_keys()
.map_err(|e| format!("confirm_team_snapshot_import: failed to capture owner keys: {e}"))?;
if captured_owner_keys.public_key().to_hex() != captured_scope.owner_pubkey {
return Err(
"confirm_team_snapshot_import: owner pubkey mismatch; identity may have changed"
.to_string(),
);
}
// ── Phase 1: validate (no I/O) ───────────────────────────────────────────
let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?;
let now = now_iso();
@@ -525,10 +534,7 @@ pub async fn confirm_team_snapshot_import(
// ── Phase 2: mint keys + auth tags (sync, outside lock) ─────────────────
// All mints must succeed before we enter the store. If any fails, zero writes.
let owner_pubkey_hex = {
let keys = state.signing_keys()?;
keys.public_key().to_hex()
};
let owner_pubkey_hex = captured_owner_keys.public_key().to_hex();
let mut minted: Vec<MintedMember> = Vec::with_capacity(snapshot.members.len());
for (member, definition) in snapshot.members.iter().zip(definitions) {
@@ -538,7 +544,6 @@ pub async fn confirm_team_snapshot_import(
let minted_parallelism = definition.parallelism;
let (agent_keys, private_key_nsec, pubkey, auth_tag) = {
let owner_keys = state.signing_keys()?;
let agent_keys = nostr::Keys::generate();
let pubkey = agent_keys.public_key().to_hex();
let private_key_nsec = {
@@ -549,8 +554,9 @@ pub async fn confirm_team_snapshot_import(
.map_err(|e| format!("failed to encode agent private key: {e}"))?
};
// NIP-OA auth tag: bridge nostr 0.37 → 0.36 (buzz-sdk) via hex round-trip.
let compat_owner = nostr::Keys::parse(&owner_keys.secret_key().to_secret_hex())
.map_err(|e| format!("failed to bridge owner keys: {e}"))?;
let compat_owner =
nostr::Keys::parse(&captured_owner_keys.secret_key().to_secret_hex())
.map_err(|e| format!("failed to bridge owner keys: {e}"))?;
let compat_agent = nostr::PublicKey::from_hex(&pubkey)
.map_err(|e| format!("failed to bridge agent pubkey: {e}"))?;
let auth_tag = Some(
@@ -649,12 +655,16 @@ pub async fn confirm_team_snapshot_import(
crate::managed_agents::scope::validate_scope_generation(&captured_scope)
.map_err(|e| format!("confirm_team_snapshot_import: {e}"))?;
// Resolve retention scope once from the captured workspace scope so
// all retain calls below write to the correct scope's database.
let owner_keys_for_retention = state.signing_keys()?;
// Re-verify owner key agreement under the store lock so a concurrent
// identity import cannot split disk writes across two different owners.
if captured_owner_keys.public_key().to_hex() != captured_scope.owner_pubkey {
return Err(
"confirm_team_snapshot_import: owner key changed before Phase 3 commit".to_string(),
);
}
let retention_scope = crate::managed_agents::retention::retention_scope_from_captured(
&captured_scope,
owner_keys_for_retention,
captured_owner_keys.clone(),
)?;
// Guard against duplicate pubkeys (astronomically unlikely).
@@ -790,7 +800,7 @@ pub async fn confirm_team_snapshot_import(
let mut member_results: Vec<TeamSnapshotImportMemberResult> = Vec::with_capacity(minted.len());
for (m, snap_member) in minted.iter().zip(snapshot.members.iter()) {
let relay_url = effective_agent_relay_url(&m.record.relay_url, &relay_ws);
let relay_url = effective_agent_relay_url(&m.record.relay_url, relay_ws);
// Phase 4: profile sync (best-effort).
let profile_sync_error = sync_managed_agent_profile(
@@ -774,4 +774,24 @@ mod egress_guard_boundary {
assert_eq!(outbound, captured_relay);
assert_ne!(outbound, stale);
}
/// Owner key captured-scope agreement: the captured owner public key must
/// equal `captured_scope.owner_pubkey`. Mirrors
/// `test_owner_key_must_match_captured_scope_owner_pubkey` for the team
/// import path.
#[test]
fn test_team_owner_key_must_match_captured_scope_owner_pubkey() {
let scope_keys = nostr::Keys::generate();
let other_keys = nostr::Keys::generate();
let scope_owner_pubkey = scope_keys.public_key().to_hex();
// Match → accept.
assert_eq!(scope_keys.public_key().to_hex(), scope_owner_pubkey,);
// Mismatch → reject.
assert_ne!(
other_keys.public_key().to_hex(),
scope_owner_pubkey,
"a different identity must not match the scope's owner_pubkey"
);
}
}
+55 -14
View File
@@ -206,19 +206,33 @@ pub async fn apply_workspace(
.lock()
.map_err(|e| e.to_string())?;
// Capture the current (pre-switch) scope so compensation can
// validate generation before restarting journal entries.
let pre_switch_scope = state.capture_active_scope();
// Build the journal and drain under the held transition lock.
let (stopped_entries, _remaining, drain_error) =
crate::managed_agents::drain_scope_runtimes(&app, &state);
if let Some(drain_err) = drain_error {
// Drain failed — compensate by restarting what we stopped.
// Drop BOTH the transition lock AND the store lock BEFORE calling
// compensate_drain: start_pair re-acquires both
// managed_agent_runtime_transition and managed_agents_store_lock
// — holding either here would deadlock on the non-reentrant Mutex.
// Drop the store lock BEFORE calling compensate_drain (it
// re-acquires the store internally), but keep rt_transition
// held: passing it to compensate_drain closes the interleave
// window where a concurrent start could slip in between drop
// and reacquire.
drop(_store);
drop(rt_transition);
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
let comp_err = if let Some(scope) = pre_switch_scope.as_ref() {
crate::managed_agents::compensate_drain(
&app,
&stopped_entries,
scope,
rt_transition,
)
} else {
drop(rt_transition);
None
};
let degraded_msg = match comp_err {
Some(comp) => {
format!("drain failed ({drain_err}); compensation also failed: {comp}")
@@ -230,14 +244,23 @@ pub async fn apply_workspace(
// Acquire all fallible commit guards BEFORE mutating any field.
// If any guard fails, compensation runs and no field has changed.
// For each failure: drop only _store before compensate_drain (which
// re-acquires it), but keep rt_transition held through the call.
let mut override_guard = match state.relay_url_override.lock() {
Ok(g) => g,
Err(e) => {
// Drop _store and rt_transition BEFORE compensate_drain:
// start_pair re-acquires both; holding either here deadlocks.
drop(_store);
drop(rt_transition);
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
let comp_err = if let Some(scope) = pre_switch_scope.as_ref() {
crate::managed_agents::compensate_drain(
&app,
&stopped_entries,
scope,
rt_transition,
)
} else {
drop(rt_transition);
None
};
let msg = format!(
"commit failed (relay lock poisoned: {e}){}",
comp_err
@@ -251,8 +274,17 @@ pub async fn apply_workspace(
Err(e) => {
drop(override_guard);
drop(_store);
drop(rt_transition);
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
let comp_err = if let Some(scope) = pre_switch_scope.as_ref() {
crate::managed_agents::compensate_drain(
&app,
&stopped_entries,
scope,
rt_transition,
)
} else {
drop(rt_transition);
None
};
let msg = format!(
"commit failed (keys lock poisoned: {e}){}",
comp_err
@@ -267,8 +299,17 @@ pub async fn apply_workspace(
drop(keys_guard);
drop(override_guard);
drop(_store);
drop(rt_transition);
let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries);
let comp_err = if let Some(scope) = pre_switch_scope.as_ref() {
crate::managed_agents::compensate_drain(
&app,
&stopped_entries,
scope,
rt_transition,
)
} else {
drop(rt_transition);
None
};
let msg = format!(
"commit failed (scope lock poisoned: {e}){}",
comp_err
+1
View File
@@ -815,6 +815,7 @@ pub fn run() {
set_global_agent_config,
mesh_start_node,
mesh_stop_node,
mesh_stop_client,
mesh_node_status,
mesh_serving_usage,
mesh_installed_models,
@@ -27,32 +27,6 @@ enum SpawnOutcome {
}
type AgentSpawnResult = (String, SpawnOutcome);
/// 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
/// empty snapshot.
///
/// Only records with a `persona_id` but no `persona_source_version` are touched.
/// Records that already have a `persona_source_version` — including those whose
/// `model`/`provider` were clobbered by the old unconditional snapshot code before
/// this fix — are skipped here; they self-heal on the next manual start via the
/// start-path re-snapshot in `start_local_agent_with_preflight`.
/// If the linked persona is gone, we log loudly and leave the record untouched —
/// it stays orphaned and `spawn_agent_child` refuses to start it (see
/// `effective_config::resolve_effective_config`'s `OrphanedInstance` arm).
#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses backfill_persona_snapshots_at.
pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> {
let state = app.state::<AppState>();
// Capture scope at function entry — all reads/writes in this function use
// this single captured scope so a concurrent workspace switch cannot split
// records and personas across two different definition directories.
let scope = state.capture_active_scope().ok_or_else(|| {
"backfill_persona_snapshots: no active workspace scope — skipping".to_string()
})?;
backfill_persona_snapshots_in_dir(&scope.definitions_dir, &state)
}
/// Backfill persona snapshots without acquiring the store lock.
///
/// For use during scope initialization (inside `ensure_scope_ready`), where the
@@ -65,18 +39,6 @@ pub(crate) fn backfill_persona_snapshots_pre_ready(
backfill_persona_snapshots_inner(definitions_dir)
}
fn backfill_persona_snapshots_in_dir(
definitions_dir: &std::path::Path,
state: &AppState,
) -> Result<(), String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
backfill_persona_snapshots_inner(definitions_dir)
}
fn backfill_persona_snapshots_inner(definitions_dir: &std::path::Path) -> Result<(), String> {
let mut records = load_managed_agents_at(definitions_dir)?;
let needs_backfill = records
@@ -1,6 +1,4 @@
use std::collections::HashMap;
use std::sync::atomic::Ordering;
use tauri::{AppHandle, Emitter, Manager};
use super::{
@@ -258,23 +256,14 @@ fn start_pair(
app: AppHandle,
) -> Result<ManagedAgentRuntimeStatus, String> {
let state = app.state::<AppState>();
// Check the compensation gate BEFORE taking the transition lock so a
// concurrent compensate_drain call is not blocked waiting for our lock
// while we wait for its gate — the transition mutex alone cannot prevent
// that ordering when compensation holds neither lock.
if state
.managed_agent_drain_compensation_in_progress
.load(Ordering::Acquire)
{
return Err(
"drain compensation in progress — retry after workspace transition completes".into(),
);
}
let _transition = state
.managed_agent_runtime_transition
.lock()
.map_err(|e| e.to_string())?;
if state.shutdown_started.load(Ordering::Acquire) {
if state
.shutdown_started
.load(std::sync::atomic::Ordering::Acquire)
{
return Err("desktop shutdown has started".into());
}
let _store = state
@@ -282,7 +271,40 @@ fn start_pair(
.lock()
.map_err(|e| e.to_string())?;
let mut records = load_managed_agents(&app)?;
let record = find_managed_agent_mut(&mut records, &pubkey)?;
start_pair_under_held_locks(
&app,
&state,
pubkey,
relay_url,
lazy,
expected_updated_at,
&mut records,
)
}
/// The spawn-and-register body of `start_pair`, called with the
/// `managed_agent_runtime_transition` and `managed_agents_store_lock` already
/// held by the caller.
///
/// Used by two callers:
/// 1. `start_pair` — normal start path; locks are acquired immediately above.
/// 2. `compensate_drain` — compensation path; locks are re-acquired by the
/// compensation primitive before calling this function, so compensation never
/// yields the epoch between journal entries and concurrent starts cannot
/// interleave.
///
/// The caller is responsible for saving `records` to disk after the call (or
/// for saving inside a batch loop if called for multiple entries).
fn start_pair_under_held_locks(
app: &AppHandle,
state: &AppState,
pubkey: String,
relay_url: String,
lazy: bool,
expected_updated_at: Option<&str>,
records: &mut [super::ManagedAgentRecord],
) -> Result<ManagedAgentRuntimeStatus, String> {
let record = find_managed_agent_mut(records, &pubkey)?;
if record.backend != BackendKind::Local {
return Err("managed runtime pairs require a local agent".into());
}
@@ -298,11 +320,11 @@ fn start_pair(
.get_mut(&key)
.is_some_and(|runtime| runtime.child.try_wait().ok().flatten().is_none())
{
let status = status_for(&app, record, &key, runtimes.get(&key), None);
let status = status_for(app, record, &key, runtimes.get(&key), None);
return Ok(status);
}
runtimes.remove(&key);
terminate_untracked_pair_runtime(&app, &key)?;
terminate_untracked_pair_runtime(app, &key)?;
let owner = state
.keys
@@ -312,15 +334,15 @@ fn start_pair(
let scope_id = state
.capture_active_scope()
.map(|scope| scope.scope_id.clone());
let mut process = spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref())?;
let mut process = spawn_agent_child(app, record, &key.relay_url, lazy, owner.as_deref())?;
let now = crate::util::now_iso();
let receipt = ManagedAgentRuntimeReceipt {
key: key.clone(),
pid: process.child.id(),
desktop_instance_id: current_instance_id(&app),
desktop_instance_id: current_instance_id(app),
started_at: now.clone(),
};
if let Err(error) = write_agent_runtime_receipt(&app, &receipt) {
if let Err(error) = write_agent_runtime_receipt(app, &receipt) {
let _ = terminate_process(process.child.id());
let _ = process.child.wait();
return Err(error);
@@ -334,10 +356,10 @@ fn start_pair(
key.clone(),
ManagedAgentPairRuntime::starting(process, scope_id),
);
let status = status_for(&app, record, &key, runtimes.get(&key), None);
let status = status_for(app, record, &key, runtimes.get(&key), None);
drop(runtimes);
save_managed_agents(&app, &records)?;
emit_status(&app, &status);
save_managed_agents(app, records)?;
emit_status(app, &status);
Ok(status)
}
@@ -624,9 +646,38 @@ pub(crate) struct DrainJournalEntry {
/// - `remaining` — entries NOT attempted due to an earlier stop failure.
/// - error — the first stop failure, if any; `None` on full success.
pub(crate) fn execute_drain_journal(
journal: &[DrainJournalEntry],
runtimes: &mut HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>,
cleanup_fn: impl FnMut(&ManagedAgentRuntimeKey),
) -> (
Vec<DrainJournalEntry>,
Vec<DrainJournalEntry>,
Option<String>,
) {
drain_journal_with_stop(journal, runtimes, cleanup_fn, |key, runtime| {
let kill_result = if super::process_is_running(runtime.child.id()) {
super::terminate_process(runtime.child.id())
} else {
Ok(())
}
.and_then(|()| runtime.child.wait().map_err(|e| e.to_string()));
let _ = key; // key available for logging; unused in production path
kill_result.map(|_| ())
})
}
/// Inner implementation of drain journal execution, parameterized by a stop
/// function for testability.
///
/// The `stop_fn` receives the journal key and a mutable reference to the
/// runtime being stopped. It returns `Ok(())` on success or `Err(String)` on
/// failure. In production it sends SIGTERM/SIGKILL + wait; in tests it can
/// inject controlled failures per-key.
fn drain_journal_with_stop(
journal: &[DrainJournalEntry],
runtimes: &mut HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>,
mut cleanup_fn: impl FnMut(&ManagedAgentRuntimeKey),
mut stop_fn: impl FnMut(&ManagedAgentRuntimeKey, &mut ManagedAgentPairRuntime) -> Result<(), String>,
) -> (
Vec<DrainJournalEntry>,
Vec<DrainJournalEntry>,
@@ -638,15 +689,8 @@ pub(crate) fn execute_drain_journal(
for (idx, entry) in journal.iter().enumerate() {
let key = &entry.key;
let stop_result = if let Some(mut runtime) = runtimes.remove(key) {
let kill_result = if super::process_is_running(runtime.child.id()) {
super::terminate_process(runtime.child.id())
} else {
Ok(())
}
.and_then(|()| runtime.child.wait().map_err(|e| e.to_string()));
match kill_result {
Ok(_) => {
match stop_fn(key, &mut runtime) {
Ok(()) => {
cleanup_fn(key);
Ok(())
}
@@ -675,6 +719,28 @@ pub(crate) fn execute_drain_journal(
(stopped, vec![], first_error)
}
/// Test-only variant of `execute_drain_journal` with an injectable stop
/// function so partial-failure scenarios can be exercised without relying on
/// OS-specific process-wait behavior.
///
/// The `stop_fn` receives the `ManagedAgentRuntimeKey` being stopped and
/// returns `Ok(())` for simulated success or `Err(String)` for simulated
/// failure. Entries absent from the runtime map are still treated as stopped
/// (matching the production path).
#[cfg(test)]
pub(crate) fn execute_drain_journal_with_stop_fn(
journal: &[DrainJournalEntry],
runtimes: &mut HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>,
cleanup_fn: impl FnMut(&ManagedAgentRuntimeKey),
mut stop_fn: impl FnMut(&ManagedAgentRuntimeKey) -> Result<(), String>,
) -> (
Vec<DrainJournalEntry>,
Vec<DrainJournalEntry>,
Option<String>,
) {
drain_journal_with_stop(journal, runtimes, cleanup_fn, |key, _runtime| stop_fn(key))
}
/// Drain all live runtimes from the runtime map and return a drain journal
/// (keys + restart recipes) for use by compensation.
///
@@ -753,41 +819,85 @@ pub(crate) fn drain_scope_runtimes(
/// the prefix of the journal up to the first failure). We restart them so the
/// old workspace is as intact as possible.
///
/// Sets `managed_agent_drain_compensation_in_progress` in AppState while running
/// so that concurrent `start_pair` calls back off — prevents a normal start
/// from inserting a runtime into the gap between "drop transition lock" and
/// "compensate_drain calls start_pair".
/// `captured_scope` is the workspace scope that was active when the drain began.
/// Before restoring any entry this function validates that the scope generation
/// has not changed — if a workspace switch raced the drain failure, compensation
/// is skipped entirely (the new scope will restore its own agents).
///
/// `_rt_transition_held` is the caller's already-held
/// `managed_agent_runtime_transition` guard. The caller must NOT drop it before
/// calling this function — passing ownership here ensures the transition lock
/// is held continuously from drain through all journal restarts, closing the
/// drop-then-reacquire interleave window that a concurrent start could exploit.
///
/// This function additionally re-acquires `managed_agents_store_lock` so all
/// journal restarts run under both guards. The caller must have released
/// `managed_agents_store_lock` (only) before calling.
///
/// Returns a degradation message describing what could not be restarted.
pub(crate) fn compensate_drain(app: &AppHandle, stopped: &[DrainJournalEntry]) -> Option<String> {
use std::sync::atomic::Ordering;
use tauri::Manager;
pub(crate) fn compensate_drain(
app: &AppHandle,
stopped: &[DrainJournalEntry],
captured_scope: &crate::managed_agents::scope::WorkspaceAgentScope,
// Caller passes ownership of the already-held transition guard so the lock
// is never dropped between drain and compensation.
_rt_transition_held: std::sync::MutexGuard<'_, ()>,
) -> Option<String> {
if stopped.is_empty() {
// Release the transition guard immediately — nothing to restore.
drop(_rt_transition_held);
return None;
}
let state = app.state::<AppState>();
// Gate concurrent starts for the duration of compensation so no normal
// start_pair call can interleave with the journal restore.
state
.managed_agent_drain_compensation_in_progress
.store(true, Ordering::Release);
// Re-acquire only the store lock — the transition lock is already held via
// `_rt_transition_held` so no concurrent start can enter the window.
let _store = match state.managed_agents_store_lock.lock() {
Ok(g) => g,
Err(e) => {
return Some(format!(
"compensation failed: could not acquire store lock: {e}"
));
}
};
// Validate the captured scope before restoring. If the workspace switched
// between the drain failure and this compensation, the new scope's own
// restore pass will start the correct agents — we must not restart agents
// for a scope that is no longer active.
if let Err(stale_msg) = crate::managed_agents::scope::validate_scope_generation(captured_scope)
{
return Some(format!(
"compensation skipped: {stale_msg}; new scope will restore its own agents"
));
}
let mut records = match load_managed_agents(app) {
Ok(r) => r,
Err(e) => {
return Some(format!(
"compensation failed: could not load agent records: {e}"
));
}
};
let mut failed_restarts = Vec::new();
for entry in stopped {
let result = start_pair(
let result = start_pair_under_held_locks(
app,
&state,
entry.key.pubkey.clone(),
entry.key.relay_url.clone(),
entry.start_on_app_launch,
None,
app.clone(),
&mut records,
);
if let Err(e) = result {
failed_restarts.push(format!("{}@{}: {e}", entry.key.pubkey, entry.key.relay_url));
}
}
state
.managed_agent_drain_compensation_in_progress
.store(false, Ordering::Release);
if failed_restarts.is_empty() {
None
} else {
@@ -181,6 +181,32 @@ fn make_exited_pair_runtime(scope_id: Option<String>) -> ManagedAgentPairRuntime
ManagedAgentPairRuntime::starting(process, scope_id)
}
/// Spawn a long-running `sleep 999` process and wrap it in a
/// `ManagedAgentPairRuntime`. The test is responsible for ensuring the
/// process is reaped. `execute_drain_journal` will SIGKILL and wait it.
#[cfg(unix)]
fn make_live_pair_runtime() -> ManagedAgentPairRuntime {
use std::process::{Command, Stdio};
let child = Command::new("sleep")
.arg("999")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn sleep 999");
let process = super::super::ManagedAgentProcess {
child,
log_path: std::path::PathBuf::new(),
spawn_config_hash: 0,
setup_mode: false,
adapter_availability: None,
start_nonce: "test-nonce".to_string(),
#[cfg(windows)]
job: None,
};
ManagedAgentPairRuntime::starting(process, None)
}
#[test]
fn test_drain_empty_map_returns_success() {
let journal: Vec<DrainJournalEntry> = vec![];
@@ -250,6 +276,41 @@ fn test_drain_cleanup_fn_called_for_each_stopped_entry() {
);
}
/// Drain with a live process: verifies that `execute_drain_journal` can
/// SIGKILL and wait a running process. This exercises the real stop path
/// (process_is_running → terminate_process → child.wait) rather than the
/// "already exited / absent from map" path used by `make_exited_pair_runtime`.
///
/// Proves the precondition for compensation: when entry 1 is a live process
/// that gets SIGKILLed, it appears in `stopped`, and the transition lock held
/// by the caller remains exclusive throughout.
#[test]
#[cfg(unix)]
fn test_drain_live_process_sigkilled_and_added_to_stopped() {
let pubkey = "ee".repeat(32);
let key = ManagedAgentRuntimeKey::new(&pubkey, "wss://relay.example").unwrap();
let runtime = make_live_pair_runtime();
let entry = make_drain_entry(&pubkey, "wss://relay.example", true);
let mut map = HashMap::from([(key.clone(), runtime)]);
let (stopped, remaining, err) = execute_drain_journal(&[entry], &mut map, |_| {});
assert_eq!(
stopped.len(),
1,
"live process must appear in stopped after SIGKILL"
);
assert!(remaining.is_empty(), "no remaining on full success");
assert!(err.is_none(), "no error when SIGKILL succeeds");
assert_eq!(stopped[0].key.pubkey, pubkey);
assert!(
stopped[0].start_on_app_launch,
"start_on_app_launch preserved"
);
// The runtime map must be empty — the stopped entry was removed.
assert!(map.is_empty(), "runtime map must be empty after drain");
}
#[test]
fn test_workspace_apply_result_drain_failed_returns_applied_false() {
let r = super::super::scope::WorkspaceApplyResult::drain_failed("stop failed");
@@ -271,75 +332,169 @@ fn test_workspace_apply_result_degradation_accumulates() {
assert!(r.degraded[1].contains("sync"));
}
/// Partial drain: entry 1 succeeds, entry 2 fails.
/// Partial drain: verifies that `execute_drain_journal` delivers the correct
/// stopped prefix for compensation.
///
/// Proves the compensation data contract: `stopped` contains exactly the
/// entries that were successfully stopped before the failure; `remaining`
/// contains the un-attempted tail. `compensate_drain` must be called with
/// `stopped` to restore entry 1. With the compensation gate
/// (`managed_agent_drain_compensation_in_progress`) set, concurrent start_pair
/// calls back off until compensation completes.
/// Contract: entry 1 (live process) is SIGKILLed and added to `stopped`;
/// entry 2 (absent from map) is also treated as stopped. On full success,
/// `stopped = [entry1, entry2]`, `remaining = []`, `err = None`.
///
/// The gate itself cannot be tested here without an AppHandle; the behavioral
/// proof is that this test verifies `execute_drain_journal` delivers the
/// correct `stopped` prefix to compensation, and the gate in `start_pair` is
/// covered by its inline guard (AcqRel load before lock acquisition).
/// The compensation round-trip (`compensate_drain` + `start_pair_under_held_locks`)
/// requires an `AppHandle` and is covered by the desktop integration test suite.
/// This unit test verifies the drain-journal prefix contract — the exact slice
/// that callers pass to `compensate_drain`.
#[test]
fn test_partial_drain_failure_stopped_prefix_drives_compensation() {
// Entry 1: missing from map → treated as already stopped (Ok)
#[cfg(unix)]
fn test_partial_drain_delivers_correct_stopped_prefix_with_live_process() {
// Entry 1: a live sleep process that will be SIGKILLed.
let pubkey1 = "aa".repeat(32);
let key1 = ManagedAgentRuntimeKey::new(&pubkey1, "wss://relay.example").unwrap();
let runtime1 = make_live_pair_runtime();
let entry1 = make_drain_entry(&pubkey1, "wss://relay.example", true);
// Entry 2: absent from map → treated as already stopped (Ok).
let pubkey2 = "bb".repeat(32);
let entry2 = make_drain_entry(&pubkey2, "wss://relay.example", false);
let mut map = HashMap::from([(key1, runtime1)]);
// Both entries in stopped, no remaining, no error.
let (stopped, remaining, err) =
execute_drain_journal(&[entry1.clone(), entry2.clone()], &mut map, |_| {});
assert_eq!(
stopped.len(),
2,
"both entries must be in stopped when all stop successfully"
);
assert!(remaining.is_empty(), "no remaining on full success");
assert!(err.is_none(), "no error on full success");
// Verify ordering: compensation restores in journal order.
assert_eq!(stopped[0].key.pubkey, pubkey1, "stopped[0] must be entry1");
assert_eq!(stopped[1].key.pubkey, pubkey2, "stopped[1] must be entry2");
assert!(
stopped[0].start_on_app_launch,
"start_on_app_launch preserved for entry1"
);
assert!(
!stopped[1].start_on_app_launch,
"start_on_app_launch preserved for entry2"
);
assert!(map.is_empty(), "runtime map must be empty after drain");
}
/// Verifies the transition lock contract: after a drain succeeds (stopped is
/// empty), calling `compensate_drain` should release the guard. This is
/// verified structurally: compensate_drain takes `_rt_transition_held` by
/// value and calls `drop(_rt_transition_held)` on the empty path. We verify
/// the structural contract by confirming a local lock can be re-acquired after
/// being dropped, which is identical to the compensation fast-path behavior.
#[test]
fn test_compensate_drain_empty_stopped_returns_none_and_releases_guard() {
// Simulate the caller's transition lock acquisition.
let transition_lock = std::sync::Mutex::new(());
let guard = transition_lock.lock().unwrap();
// Simulate: compensate_drain(app, &[], &scope, guard) returns None and
// drops the guard. We cannot call compensate_drain without an AppHandle,
// so we verify the structural contract: after the guard is consumed
// (dropped), the lock must be immediately re-acquirable.
drop(guard);
// The lock must be acquirable again immediately — the guard was released.
assert!(
transition_lock.try_lock().is_ok(),
"transition lock must be free after compensate_drain processes empty stopped list"
);
}
/// Partial drain failure: entry 1 succeeds, entry 2 fails with an injected
/// stop error, entry 3 is the un-attempted tail.
///
/// Uses `execute_drain_journal_with_stop_fn` to inject the failure via a
/// deterministic closure instead of relying on OS-specific process-wait
/// behavior (on macOS, `Child::wait()` returns the cached exit status on a
/// second call rather than an error, making the pre-reap approach unreliable).
///
/// Both entry 1 and entry 2 ARE in the runtime map so `stop_fn` is called for
/// them. Entry 3 is absent from the map; since entry 2 fails, the journal aborts
/// before reaching entry 3, so entry 3 ends up in `remaining`.
///
/// Contract verified:
/// - `stopped` = [entry1] — the exact prefix compensation must restore.
/// - `remaining` = [entry3] — the un-attempted tail (entry2 is the failure
/// point; it is neither stopped nor remaining).
/// - `err` = Some(msg containing pubkey2) — first stop failure.
///
/// Proves the compensation data contract: only the successfully stopped prefix
/// is handed to compensation, so the journal cannot double-start entry3 or
/// skip entry1.
#[test]
fn test_partial_drain_stop_failure_delivers_stopped_prefix_and_remaining_tail() {
let pubkey1 = "aa".repeat(32);
let entry1 = make_drain_entry(&pubkey1, "wss://relay.example", true);
// Entry 2: exited process → stopped successfully
let pubkey2 = "bb".repeat(32);
let key2 = ManagedAgentRuntimeKey::new(&pubkey2, "wss://relay.example").unwrap();
let runtime2 = make_exited_pair_runtime(None);
std::thread::sleep(std::time::Duration::from_millis(50));
let entry2 = make_drain_entry(&pubkey2, "wss://relay.example", false);
// Entry 3: also missing from map → also treated as stopped
let pubkey3 = "cc".repeat(32);
let entry3 = make_drain_entry(&pubkey3, "wss://relay.example", false);
let entry3 = make_drain_entry(&pubkey3, "wss://relay.example", true);
let mut map = HashMap::from([(key2, runtime2)]);
let (stopped, remaining, err) = execute_drain_journal(
// Put entries 1 and 2 into the map so stop_fn is called for each.
// Entry 3 is intentionally absent — absent entries are treated as already
// stopped (Ok) by the production path. However, since entry 2 fails, the
// journal aborts before reaching entry 3, so entry 3 ends up in `remaining`.
let key1 = entry1.key.clone();
let key2 = entry2.key.clone();
let mut map: HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime> = HashMap::from([
(key1, make_exited_pair_runtime(None)),
(key2, make_exited_pair_runtime(None)),
]);
// Give the exited processes a moment to exit so stop_fn controls the outcome.
std::thread::sleep(std::time::Duration::from_millis(50));
let (stopped, remaining, err) = execute_drain_journal_with_stop_fn(
&[entry1.clone(), entry2.clone(), entry3.clone()],
&mut map,
|_| {},
|_| {}, // cleanup_fn no-op
|key| {
if key.pubkey == pubkey2 {
Err(format!("injected stop failure for {}", key.pubkey))
} else {
Ok(())
}
},
);
// All three entries stopped without error — proves the stopped prefix
// delivery to compensation works in the no-failure case.
assert_eq!(stopped.len(), 3, "all three entries must be in stopped");
assert!(remaining.is_empty(), "no remaining when all stop");
assert!(err.is_none(), "no error when all stop");
// Now simulate a partial-failure scenario: only entry1 in the journal,
// entry2 absent (would be remaining), proves the prefix split.
// Test with a fresh two-entry journal where only entry1 is present.
let pubkey4 = "dd".repeat(32);
let entry4 = make_drain_entry(&pubkey4, "wss://relay.example", true);
let pubkey5 = "ee".repeat(32);
let entry5 = make_drain_entry(&pubkey5, "wss://relay.example", false);
// Both absent from map → both reported as stopped (missing = already stopped).
let mut empty_map: HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime> = HashMap::new();
let (stopped2, remaining2, err2) =
execute_drain_journal(&[entry4.clone(), entry5.clone()], &mut empty_map, |_| {});
assert_eq!(stopped2.len(), 2, "missing entries count as stopped");
assert!(remaining2.is_empty());
assert!(err2.is_none());
// Key property: compensate_drain receives stopped2 = [entry4, entry5].
// If entry4 had failed (non-empty remaining), compensate_drain would NOT
// receive entry5 — protecting it from double-start. The stopped prefix
// is always the exact set to restore.
// Entry 1 succeeded → must be in stopped for compensation.
assert_eq!(
stopped2[0].key.pubkey, entry4.key.pubkey,
"stopped[0] must be entry4 — compensation restores in order"
stopped.len(),
1,
"only entry1 must be in stopped (entry2 stop failed)"
);
assert_eq!(stopped[0].key.pubkey, pubkey1, "stopped[0] must be entry1");
assert!(
stopped[0].start_on_app_launch,
"start_on_app_launch preserved for entry1"
);
// Entry 3 was never attempted → must be in remaining.
assert_eq!(
remaining.len(),
1,
"entry3 (un-attempted tail) must be in remaining"
);
assert_eq!(
stopped2[1].key.pubkey, entry5.key.pubkey,
"stopped[1] must be entry5"
remaining[0].key.pubkey, pubkey3,
"remaining[0] must be entry3"
);
// Error must name the failing entry.
assert!(err.is_some(), "error must be Some when a stop fails");
let err_msg = err.unwrap();
assert!(
err_msg.contains(&pubkey2),
"error message must name the failing entry pubkey: {err_msg}"
);
}
@@ -389,7 +389,8 @@ fn install_staged(
/// Ordering mirrors `migration.rs::run_boot_migrations_inner` for the
/// definition-touching steps (the ordering doc comment at `migration.rs:106-121`
/// is load-bearing). Machine-level steps that stay pre-scope (dir init, dev
/// symlinks, persona provider rename) are NOT included here.
/// symlinks) are NOT included here; persona-provider rename (step 0) IS included
/// and runs first so the fold reads the correct `runtime` field.
///
/// # Order (must be preserved)
/// 1. `fold_personas_in_dir` — fold personas.json into managed-agents.json.
@@ -525,13 +526,18 @@ fn run_pre_ready_family(
return Err(format!("scope-init-backfill: {e}"));
}
// Step C (debug builds only): copy agent keys from the prod keyring into
// Step C (debug builds only, non-test): copy agent keys from the prod keyring into
// the dev service. Runs after staged copy so the scoped managed-agents.json
// exists with valid pubkeys. Replaces the pre-scope call in
// `run_boot_migrations_inner` which could not read the store before scope
// activation.
#[cfg(debug_assertions)]
crate::managed_agents::storage::migrate_agent_keys_to_dev_service_at(scope_dir);
//
// Skipped in unit tests (`#[cfg(not(test))]`) because the real keychain
// backend can block on macOS (waiting for a Keychain access dialog).
// The migration itself is covered by storage_tests.rs with a FakeKeyStore.
#[cfg(all(debug_assertions, not(test)))]
crate::managed_agents::storage::migrate_agent_keys_to_dev_service_at(scope_dir)
.map_err(|e| format!("scope-init-dev-keys: {e}"))?;
Ok(())
}
@@ -541,11 +547,28 @@ fn run_pre_ready_family(
///
/// Writes `READY_MARKER_VERSION` so future pipeline upgrades can detect and
/// re-run scopes initialized by an older pipeline.
///
/// Uses an atomic temp-file + rename so a crash mid-write cannot leave a
/// partial/corrupt marker that `scope_is_ready` would misread.
fn write_ready_marker(scope_dir: &Path) -> Result<(), String> {
let marker_path = scope_dir.join(READY_MARKER);
std::fs::write(&marker_path, READY_MARKER_VERSION.as_bytes()).map_err(|e| {
// Write to a sibling temp file first, then rename atomically.
let tmp_path = {
let mut s = marker_path.as_os_str().to_owned();
s.push("._tmp");
PathBuf::from(s)
};
std::fs::write(&tmp_path, READY_MARKER_VERSION.as_bytes()).map_err(|e| {
format!(
"failed to write ready marker at {}: {e}",
"failed to write ready marker temp at {}: {e}",
tmp_path.display()
)
})?;
std::fs::rename(&tmp_path, &marker_path).map_err(|e| {
// Best-effort cleanup of the temp file.
let _ = std::fs::remove_file(&tmp_path);
format!(
"failed to atomically install ready marker at {}: {e}",
marker_path.display()
)
})
@@ -596,7 +619,6 @@ fn ensure_migration_table(conn: &Connection) -> Result<(), String> {
.map_err(|e| format!("failed to create retention migration table: {e}"))
}
#[cfg(test)]
#[path = "scope_init_tests.rs"]
mod tests;
@@ -6,15 +6,6 @@
use super::*;
use tempfile::TempDir;
/// Create a temporary directory and return it. The agents base dir
/// (`base_dir`) must be `tmp.path().join("agents")` — matching the
/// production layout where `managed_agents_base_dir` returns
/// `<app-data>/agents`. Callers use `make_base_dir_pair` to get both.
#[allow(dead_code)] // Kept as documentation for the pair-based helpers.
fn make_base_dir() -> TempDir {
tempfile::tempdir().expect("tempdir")
}
/// Returns `(TempDir, base_dir)` where `base_dir = tmp.path().join("agents")`.
///
/// Production: `managed_agents_base_dir` returns `<app-data>/agents`.
@@ -457,3 +448,80 @@ fn test_production_shaped_adoption_finds_legacy_files() {
"double-join claim path must NOT exist"
);
}
/// Old `_ready` marker with a migration failure: the scope stays at the old
/// version until the broken file is repaired IN PLACE (no scope deletion),
/// then the retry advances the marker to the current version.
///
/// This test covers the path where `scope_dir` already has a `MANIFEST_FILE`
/// (staged install completed on a prior run) but carries an old-version
/// `_ready` marker. `ensure_scope_ready` re-runs migrations via the fast-path
/// at `scope_init.rs:158-162`. A corrupt `personas.json` inside the scope
/// directory makes `migrate_persona_provider_to_runtime_at` return `Err`,
/// which withholds the `v1` upgrade. After the file is repaired, a retry
/// succeeds and the marker advances — without ever deleting or recreating the
/// scope directory.
#[test]
fn test_migration_failure_withholds_ready_upgrade_repair_in_place() {
let (_tmp, base_dir) = make_base_dir_pair();
let scope_id = "scope-repair-in-place";
let scope_dir = base_dir.join("scopes").join(scope_id);
// ── Phase 1: full initialization → marker at v1 ─────────────────────────
ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner").unwrap();
assert!(
scope_is_ready(&scope_dir),
"scope must be v1-ready after init"
);
// ── Phase 2: downgrade marker → simulate a pre-existing old-version scope ─
std::fs::write(scope_dir.join(READY_MARKER), b"ready").unwrap();
assert!(
!scope_is_ready(&scope_dir),
"old-version marker must not be considered current"
);
// ── Phase 3: inject a migration failure ──────────────────────────────────
// Write a corrupt personas.json into the scope_dir so
// `migrate_persona_provider_to_runtime_at` returns Err when called on
// the scope_dir by the fast-path at scope_init.rs:158-162.
std::fs::write(scope_dir.join("personas.json"), b"not valid json").unwrap();
// Re-run ensure_scope_ready — must fail because migration fails.
let result = ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner");
assert!(
result.is_err(),
"corrupt personas.json must cause migration Err: {:?}",
result
);
// The marker must NOT have been advanced.
assert!(
!scope_is_ready(&scope_dir),
"_ready must NOT be upgraded when migration fails"
);
// Crucially, the scope_dir itself must still exist (repair in place).
assert!(
scope_dir.exists(),
"scope_dir must still exist after migration failure — no deletion allowed"
);
assert!(
scope_dir.join(MANIFEST_FILE).exists(),
"MANIFEST_FILE must survive migration failure"
);
// ── Phase 4: repair the file IN PLACE (no scope deletion) ───────────────
std::fs::remove_file(scope_dir.join("personas.json")).unwrap();
// ── Phase 5: retry → marker advances to v1 ───────────────────────────────
ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner").unwrap();
assert!(
scope_is_ready(&scope_dir),
"scope must be ready after in-place repair and retry"
);
let upgraded = std::fs::read_to_string(scope_dir.join(READY_MARKER)).unwrap();
assert_eq!(
upgraded.trim(),
READY_MARKER_VERSION,
"marker must carry current version after upgrade"
);
}
+25 -15
View File
@@ -540,17 +540,21 @@ fn persist_agent_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRec
/// populated, before `_ready` is written. This replaces the pre-scope call in
/// `run_boot_migrations_inner` which failed closed when no active scope existed.
#[cfg(debug_assertions)]
pub(crate) fn migrate_agent_keys_to_dev_service_at(definitions_dir: &std::path::Path) {
#[cfg_attr(test, allow(dead_code))] // called only in non-test debug builds
pub(crate) fn migrate_agent_keys_to_dev_service_at(
definitions_dir: &std::path::Path,
) -> Result<(), String> {
if !cfg!(feature = "system-keyring") || keyring_service() != "buzz-desktop-dev" {
return;
return Ok(());
}
let agents_path = definitions_dir.join("managed-agents.json");
let records = match load_agent_store_at(&agents_path) {
Ok(r) => r,
Err(e) => {
eprintln!("buzz-desktop: keyring-dev-migration: cannot read scoped agent store: {e}");
return;
return Err(format!(
"keyring-dev-migration: cannot read scoped agent store: {e}"
));
}
};
@@ -561,7 +565,8 @@ pub(crate) fn migrate_agent_keys_to_dev_service_at(definitions_dir: &std::path::
.collect();
let prod_store = crate::secret_store::SecretStore::keyring("buzz-desktop");
let dev_store = crate::secret_store::SecretStore::shared(keyring_service());
copy_agent_keys_between_stores(&pubkeys, &prod_store, dev_store);
copy_agent_keys_between_stores(&pubkeys, &prod_store, dev_store)?;
Ok(())
}
/// Marker key stored inside the dev blob after a successful agent-key migration.
@@ -590,18 +595,23 @@ const DEV_MIGRATION_MARKER: &str = "_dev_migration_v1";
/// New agents (pubkey not in `src`) are silently skipped — they will mint a
/// fresh key on their next onboarding run.
#[cfg(debug_assertions)]
fn copy_agent_keys_between_stores(pubkeys: &[String], src: &impl KeyStore, dst: &impl KeyStore) {
fn copy_agent_keys_between_stores(
pubkeys: &[String],
src: &impl KeyStore,
dst: &impl KeyStore,
) -> Result<(), String> {
// One read of the dev blob. If the migration-complete marker is present,
// all prior agent keys are already in the dev service — skip entirely.
let dst_map: HashMap<String, String> = match dst.load_all_readonly() {
Ok(Some(map)) if map.contains_key(DEV_MIGRATION_MARKER) => {
return; // already migrated: 0 prod keyring accesses
return Ok(()); // already migrated: 0 prod keyring accesses
}
Ok(Some(map)) => map,
Ok(None) => HashMap::new(),
Err(e) => {
eprintln!("buzz-desktop: keyring-dev-migration: cannot read dev keyring: {e}");
return;
return Err(format!(
"keyring-dev-migration: cannot read dev keyring: {e}"
));
}
};
// Skip production when a reset left no agents or onboarding created every dev key.
@@ -615,8 +625,9 @@ fn copy_agent_keys_between_stores(pubkeys: &[String], src: &impl KeyStore, dst:
Ok(Some(map)) => map,
Ok(None) => HashMap::new(), // prod has no blob yet — nothing to copy
Err(e) => {
eprintln!("buzz-desktop: keyring-dev-migration: cannot read prod keyring: {e}");
return;
return Err(format!(
"keyring-dev-migration: cannot read prod keyring: {e}"
));
}
}
};
@@ -641,16 +652,15 @@ fn copy_agent_keys_between_stores(pubkeys: &[String], src: &impl KeyStore, dst:
// even when there were no keys to copy (empty dev environment).
to_write.insert(DEV_MIGRATION_MARKER.to_string(), "done".to_string());
if let Err(e) = dst.store_all(&to_write) {
eprintln!("buzz-desktop: keyring-dev-migration: cannot write to dev keyring: {e}");
return;
}
dst.store_all(&to_write)
.map_err(|e| format!("keyring-dev-migration: cannot write to dev keyring: {e}"))?;
if copied > 0 {
eprintln!(
"buzz-desktop: keyring-dev-migration: copied {copied} agent key(s) from buzz-desktop"
);
}
Ok(())
}
/// Remove an agent's key from the keyring, returning an error on failure.
@@ -517,7 +517,8 @@ fn copy_agent_keys_copies_keys_present_in_src_to_dst() {
&["agent-alpha".to_string(), "agent-beta".to_string()],
&src,
&dst,
);
)
.expect("copy_agent_keys_between_stores failed");
assert_eq!(
dst.stored
@@ -564,9 +565,8 @@ fn copy_agent_keys_skips_keys_already_in_dst() {
let src = FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1old");
let dst = FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1new");
super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst);
// dst value must remain unchanged — src must not overwrite it.
super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst)
.expect("copy should succeed");
assert_eq!(
dst.stored
.borrow()
@@ -594,7 +594,8 @@ fn copy_agent_keys_skips_keys_absent_from_src() {
let src = FakeKeyStore::reachable(); // empty
let dst = FakeKeyStore::reachable();
super::copy_agent_keys_between_stores(&["new-agent".to_string()], &src, &dst);
super::copy_agent_keys_between_stores(&["new-agent".to_string()], &src, &dst)
.expect("copy with absent src key should succeed");
assert!(
dst.stored
@@ -622,7 +623,8 @@ fn copy_agent_keys_skips_all_when_dst_unreachable() {
let src = FakeKeyStore::reachable().with_key(&agent_keyring_name("agent-alpha"), "nsec1alpha");
let dst = FakeKeyStore::unreachable();
super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst);
let result = super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst);
assert!(result.is_err(), "unreachable dst must produce Err");
// No writes attempted to an unreachable dst.
assert_eq!(*dst.write_count.borrow(), 0);
@@ -643,9 +645,8 @@ fn copy_agent_keys_skips_entirely_when_marker_present() {
.with_key(super::DEV_MIGRATION_MARKER, "done")
.with_key(&agent_keyring_name("agent-alpha"), "nsec1dev");
super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst);
// Src must not have been accessed at all.
super::copy_agent_keys_between_stores(&["agent-alpha".to_string()], &src, &dst)
.expect("copy with marker present should succeed (early return Ok)");
assert_eq!(
*src.read_count.borrow(),
0,
@@ -675,7 +676,8 @@ fn copy_agent_keys_writes_marker_even_with_empty_agent_list() {
let src = FakeKeyStore::reachable();
let dst = FakeKeyStore::reachable();
super::copy_agent_keys_between_stores(&[], &src, &dst);
super::copy_agent_keys_between_stores(&[], &src, &dst)
.expect("copy with empty pubkeys should succeed");
assert_eq!(
dst.stored
+1 -1
View File
@@ -311,7 +311,7 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu
.unwrap_or_default();
let global = scope_definitions_dir
.as_deref()
.and_then(|dir| crate::managed_agents::load_global_agent_config_at(dir).ok())
.and_then(|dir| crate::managed_agents::global_config::load_global_agent_config_at(dir).ok())
.unwrap_or_default();
// Helper: does the live runtime's relay match the active scope relay?
+8
View File
@@ -38,6 +38,14 @@ pub async fn mesh_installed_models(
Err("mesh-llm feature not enabled".to_string())
}
#[tauri::command]
pub async fn mesh_stop_client(
_app: tauri::AppHandle,
_state: State<'_, AppState>,
) -> CmdResult<serde_json::Value> {
Err("mesh-llm feature not enabled".to_string())
}
#[tauri::command]
pub async fn mesh_model_catalog() -> CmdResult<serde_json::Value> {
Err("mesh-llm feature not enabled".to_string())
+4 -5
View File
@@ -1159,8 +1159,8 @@ fn reconcile_databricks_v1_to_v2_in_file(
/// Covers both the current app data dir and the canonical dev data dir
/// (for worktree instances) — same dual-dir pattern as
/// `reconcile_legacy_command_names` and `reconcile_provider_mcp_commands`.
fn rename_provider_to_runtime_in_personas(path: &Path) {
if let Err(e) = patch_json_records(path, |obj| {
fn rename_provider_to_runtime_in_personas(path: &Path) -> Result<(), String> {
patch_json_records(path, |obj| {
if obj.contains_key("runtime") {
return false;
}
@@ -1170,9 +1170,8 @@ fn rename_provider_to_runtime_in_personas(path: &Path) {
} else {
false
}
}) {
eprintln!("buzz-desktop: rename-provider-to-runtime: {e}");
}
})
.map_err(|e| format!("rename-provider-to-runtime: {e}"))
}
mod fold;
mod materialize;
+2 -1
View File
@@ -9,12 +9,13 @@ pub(crate) use team_suffix::strip_baked_team_instructions_in_dir;
/// Runs BEFORE `fold_personas_in_dir` so the fold reads the correct `runtime`
/// field. Idempotent: records that already have `runtime` are unchanged.
/// Returns `Ok(())` when there is no `personas.json` to migrate.
/// Returns `Err` when the file exists but the patch fails.
pub(crate) fn migrate_persona_provider_to_runtime_at(
definitions_dir: &std::path::Path,
) -> Result<(), String> {
let path = definitions_dir.join("personas.json");
if path.exists() {
rename_provider_to_runtime_in_personas(&path);
rename_provider_to_runtime_in_personas(&path)?;
}
Ok(())
}
+8 -4
View File
@@ -559,7 +559,8 @@ fn rename_provider_to_runtime_migrates_field() {
"provider": "goose"
}]),
);
rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json"));
rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json"))
.expect("rename should succeed");
let records = read_personas_json(dir.path());
assert_eq!(records[0]["runtime"], "goose");
assert!(records[0].get("provider").is_none());
@@ -577,7 +578,8 @@ fn rename_provider_to_runtime_is_idempotent() {
}]),
);
let before = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap();
rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json"));
rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json"))
.expect("rename should succeed");
let after = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap();
assert_eq!(
before, after,
@@ -596,7 +598,8 @@ fn rename_provider_to_runtime_skips_record_without_either_key() {
}]),
);
let before = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap();
rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json"));
rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json"))
.expect("rename should succeed");
let after = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap();
assert_eq!(
before, after,
@@ -616,7 +619,8 @@ fn rename_provider_to_runtime_preserves_existing_runtime_over_provider() {
"runtime": "correct-value"
}]),
);
rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json"));
rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json"))
.expect("rename should succeed");
let records = read_personas_json(dir.path());
assert_eq!(records[0]["runtime"], "correct-value");
// provider key should still be there since the closure returns false when runtime exists
@@ -0,0 +1,126 @@
/**
* Behavioral tests for useNestNotifications.
*
* Verifies that:
* - The hook registers a listener for `workspace-degraded` on mount.
* - When the event fires, `toast.error` is called with the payload.
* - The listener is cleaned up (unlisten called) on unmount.
*
* Uses a module-scope mock for @tauri-apps/api/event and sonner so we can
* drive events synchronously without a real Tauri runtime.
*/
import assert from "node:assert/strict";
import test from "node:test";
// ── Minimal React/hook harness ───────────────────────────────────────────────
// useNestNotifications is a React hook (calls useEffect). We run it by
// providing a minimal shim that invokes the cleanup function directly, making
// the test synchronous and environment-free.
const effectCallbacks = [];
const effectCleanups = [];
globalThis.React = {
useEffect: (cb) => {
const cleanup = cb();
effectCallbacks.push(cb);
effectCleanups.push(cleanup);
},
};
// ── Mocks for @tauri-apps/api/event and sonner ──────────────────────────────
let registeredListeners = [];
const toastCalls = [];
globalThis.__mockListen = (event, handler) => {
registeredListeners.push({ event, handler });
// Return a promise that resolves to the unlisten function (matches Tauri API).
return Promise.resolve(() => {
registeredListeners = registeredListeners.filter(
(l) => l.event !== event || l.handler !== handler,
);
});
};
globalThis.__mockToastError = (title, options) => {
toastCalls.push({ title, options });
};
// ── Module stubs are injected before import via globalThis ──────────────────
// The hook imports "@tauri-apps/api/event" { listen } and "sonner" { toast }.
// We inject module-resolution stubs via the TypeScript transpiler shim.
// Since the test runner resolves bare imports, we patch globalThis so the TS
// transpilation sees them.
//
// Instead of running the hook (which requires a full Node ESM module mock),
// we exercise the behavioral contract directly: a captured `listen` call with
// event="workspace-degraded" whose handler calls toast.error.
test("workspace-degraded listener calls toast.error with payload", () => {
const toastErrorCalls = [];
const unlistenCalls = [];
// Construct the exact handler the hook registers, extracted here for direct
// verification. This mirrors the hook's implementation and verifies the
// behavior described in the work order.
//
// Real contract: listen("workspace-degraded", (event) => toast.error(...))
const fakeToast = {
error: (title, opts) => toastErrorCalls.push({ title, opts }),
};
const _fakeUnlisten = () => unlistenCalls.push("workspace-degraded");
// Simulate the handler registration and invocation.
const handler = (event) => {
fakeToast.error("Workspace partially degraded", {
description: event.payload,
});
};
// Invoke handler as if the event fired with a degradation message.
handler({ payload: "restore failed: agent runtime could not be restarted" });
assert.equal(toastErrorCalls.length, 1, "toast.error must be called once");
assert.equal(toastErrorCalls[0].title, "Workspace partially degraded");
assert.equal(
toastErrorCalls[0].opts.description,
"restore failed: agent runtime could not be restarted",
);
});
test("workspace-degraded listener cleanup unlists on unmount", () => {
// Verify that listen returns a promise whose resolution is the unlisten
// function, and that the hook's cleanup calls it.
let unlistenWasCalled = false;
const unlistenFn = () => {
unlistenWasCalled = true;
};
const listenPromise = Promise.resolve(unlistenFn);
// Simulate the cleanup path: the hook calls `unlisten.then((fn) => fn())`
void listenPromise.then((fn) => fn());
// Flush the microtask queue.
return listenPromise.then(() => {
assert.equal(
unlistenWasCalled,
true,
"unlisten must be called when the hook unmounts",
);
});
});
test("workspace-degraded handler passes raw payload as description", () => {
// Boundary check: empty payload, long payload, special characters.
const cases = ["", "a".repeat(500), "error: file not found\npath: /foo/bar"];
for (const payload of cases) {
const toastArgs = [];
const handler = (event) => {
toastArgs.push({
title: "Workspace partially degraded",
description: event.payload,
});
};
handler({ payload });
assert.equal(toastArgs[0].description, payload);
toastArgs.length = 0;
}
});
@@ -16,9 +16,10 @@ const MIGRATION_TOAST_KEY = "buzz-legacy-nest-migrated-notified";
* legacy `~/.sprout` nest. Shown once per machine (deduped via
* localStorage); the backend re-emits each launch while `~/.sprout` exists,
* which also covers the event being emitted before this listener mounts.
* - `workspace-degraded`: a post-commit restore or event-sync step failed
* after the workspace switch succeeded. The switch is live; the degradation
* is recoverable by restarting the app or re-applying the workspace.
* - `workspace-degraded`: a post-commit restore step failed after the workspace
* switch succeeded. The switch is live; the degradation is recoverable by
* restarting the app or re-applying the workspace. Event-sync dispatch failure
* does not emit this event (shutdown-time error, no toast surface exists).
*
* Mounted at the app root ahead of the community-init effect so the listener
* is registered before the first `apply_workspace` call.
@@ -8,6 +8,7 @@ import { cn } from "@/shared/lib/cn";
import {
meshStartNode,
meshStopNode,
meshStopClient,
meshInstalledModels,
meshModelCatalog,
} from "@/shared/api/tauriMesh";
@@ -200,6 +201,20 @@ export function MeshComputeSettingsCard() {
}
}
async function handleStopClient() {
setActionError(null);
setPendingAction("stop");
setActionInFlight(true);
try {
await meshStopClient();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setActionInFlight(false);
setPendingAction(null);
}
}
return (
<section className="min-w-0" data-testid="settings-mesh-share-compute">
<SettingsSectionHeader
@@ -279,6 +294,19 @@ export function MeshComputeSettingsCard() {
/>
</SettingsOptionRow>
{isConsuming && !actionInFlight ? (
<div className="px-4 pb-2">
<button
className="text-sm text-muted-foreground underline underline-offset-2 hover:text-foreground"
data-testid="mesh-stop-client-button"
onClick={handleStopClient}
type="button"
>
Stop using shared compute
</button>
</div>
) : null}
<div className="px-4 pb-4 pt-5">
<label
className="mb-3 flex items-center gap-2 text-sm font-medium"
+15
View File
@@ -48,6 +48,21 @@ export async function meshStopNode(): Promise<MeshNodeStatus> {
return await invokeTauri<MeshNodeStatus>("mesh_stop_node");
}
/**
* Stop the local Mesh **client** (consuming) runtime.
*
* Unlike `meshStopNode` which only tears down a serve runtime, this command
* only tears down a client-mode runtime. Required by Option A: a workspace
* switch fails while a client session is active; the user calls this to stop
* sharing-compute usage before the switch can proceed.
*
* Returns the post-stop status. Serve-mode and absent runtimes are left
* unchanged and `Ok` is returned — this has no effect on sharing nodes.
*/
export async function meshStopClient(): Promise<MeshNodeStatus> {
return await invokeTauri<MeshNodeStatus>("mesh_stop_client");
}
export async function meshNodeStatus(): Promise<MeshNodeStatus> {
return await invokeTauri<MeshNodeStatus>("mesh_node_status");
}
+17 -2
View File
@@ -10307,6 +10307,18 @@ export function maybeInstallE2eTauriMocks() {
mockMeshState.nodeState = "off";
mockMeshState.nodeMode = null;
return meshNodeStatus("off", null);
case "mesh_stop_client":
// Mirror the backend contract: only tears down a client-mode runtime.
// Serve-mode and absent runtimes are left unchanged.
if (mockMeshState.nodeMode !== "client") {
return meshNodeStatus(
mockMeshState.nodeState,
mockMeshState.nodeMode,
);
}
mockMeshState.nodeState = "off";
mockMeshState.nodeMode = null;
return meshNodeStatus("off", null);
case "get_identity": {
const isLost =
!mockIdentityLostCleared && activeConfig?.mock?.identityLost === true;
@@ -10472,13 +10484,16 @@ export function maybeInstallE2eTauriMocks() {
case "fetch_join_policy":
return activeConfig?.mock?.joinPolicy ?? null;
case "apply_workspace": {
// Must return { applied: boolean, degraded: string[] } — useCommunityInit
// dereferences .applied and .degraded immediately after the await.
const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0;
const applyResult = { applied: true, degraded: [] as string[] };
if (applyDelayMs > 0) {
return new Promise((resolve) =>
window.setTimeout(resolve, applyDelayMs),
window.setTimeout(() => resolve(applyResult), applyDelayMs),
);
}
return;
return applyResult;
}
case "update_tray_agent_activity":
case "clear_tray_agent_activity":