From 38cb69aeb797d562642f7c8af0351f0426532052 Mon Sep 17 00:00:00 2001 From: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz> Date: Wed, 26 Aug 2026 15:55:28 -0600 Subject: [PATCH] fix(desktop): discard stale team membership stages Signed-off-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/commands/teams.rs | 22 +++++++++----- desktop/src-tauri/src/commands/teams_tests.rs | 15 ++++++++-- desktop/src-tauri/src/migration.rs | 30 +++++-------------- .../src/migration/team_membership.rs | 13 ++++++++ 4 files changed, 47 insertions(+), 33 deletions(-) diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index fdf61fd53f..5b9fd207e6 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -78,25 +78,23 @@ fn clear_pending_team_membership(app: &AppHandle) -> Result<(), String> { enum PendingTeamMembershipState { Pending, Superseded, + MissingTeam, + UnexpectedRoster, } fn pending_team_membership_state( pending: &PendingTeamMembershipUpdate, teams: &[TeamRecord], ) -> Result { - let team = teams - .iter() - .find(|team| team.id == pending.team_id) - .ok_or_else(|| format!("pending team {} no longer exists", pending.team_id))?; + let Some(team) = teams.iter().find(|team| team.id == pending.team_id) else { + return Ok(PendingTeamMembershipState::MissingTeam); + }; if team.persona_ids == pending.current_persona_ids { Ok(PendingTeamMembershipState::Pending) } else if team.persona_ids == pending.previous_persona_ids { Ok(PendingTeamMembershipState::Superseded) } else { - Err(format!( - "pending team {} has an unexpected roster; save the team again", - pending.team_id - )) + Ok(PendingTeamMembershipState::UnexpectedRoster) } } @@ -117,6 +115,14 @@ pub(crate) fn replay_pending_team_membership(app: &AppHandle) -> Result<(), Stri .map_err(|error| format!("could not replay pending team update: {error}"))?; } PendingTeamMembershipState::Superseded => {} + PendingTeamMembershipState::MissingTeam => eprintln!( + "buzz-desktop: pending-team-membership: discarding staged update for missing team {:?}", + pending.team_id + ), + PendingTeamMembershipState::UnexpectedRoster => eprintln!( + "buzz-desktop: pending-team-membership: discarding staged update for team {:?} with a different roster", + pending.team_id + ), } clear_pending_team_membership(app) } diff --git a/desktop/src-tauri/src/commands/teams_tests.rs b/desktop/src-tauri/src/commands/teams_tests.rs index 793a25a0ae..42905e5d72 100644 --- a/desktop/src-tauri/src/commands/teams_tests.rs +++ b/desktop/src-tauri/src/commands/teams_tests.rs @@ -553,11 +553,12 @@ fn detach_outside_roster_is_scoped_to_this_team_and_absent_personas() { ); } -/// A staged update only replays when the team still has the staged roster. -/// A prior roster means the team write did not land, so the stale stage is safe +/// A staged update replays only when the team keeps the staged roster. The +/// prior roster means the team write did not land. A missing team or a different +/// roster means an inbound event superseded the stage. Each stale stage is safe /// to clear without a membership change. #[test] -fn pending_membership_state_distinguishes_pending_and_superseded_writes() { +fn pending_membership_state_distinguishes_replay_and_stale_stages() { let pending = PendingTeamMembershipUpdate { team_id: "team-a".to_string(), previous_persona_ids: ids(&["duncan"]), @@ -572,6 +573,14 @@ fn pending_membership_state_distinguishes_pending_and_superseded_writes() { pending_team_membership_state(&pending, &[team("team-a", &["duncan"])]), Ok(PendingTeamMembershipState::Superseded) )); + assert!(matches!( + pending_team_membership_state(&pending, &[]), + Ok(PendingTeamMembershipState::MissingTeam) + )); + assert!(matches!( + pending_team_membership_state(&pending, &[team("team-a", &["paul"])]), + Ok(PendingTeamMembershipState::UnexpectedRoster) + )); } /// The durable replay uses the original replace delta after the first agent diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index e7e384972a..484837397b 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -130,10 +130,9 @@ pub fn run_boot_migrations_after_reset(app: &tauri::AppHandle) { } fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { - // Initialize the process-lifetime nest directory before any filesystem - // operation that calls nest_dir(). The discriminator matches the existing - // pattern used by reconcile_target_dir: dev instances have an app-data-dir - // name starting with CANONICAL_DEV_IDENTIFIER. + // Initialize the process-lifetime nest directory before filesystem access + // that calls nest_dir(). The discriminator matches reconcile_target_dir: + // dev instances have an app-data-dir name starting with CANONICAL_DEV_IDENTIFIER. let is_dev = if let Ok(data_dir) = app.path().app_data_dir() { let dev = data_dir .file_name() @@ -145,12 +144,10 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { false }; - // On dev builds, copy `.repos-dir` from ~/.buzz → ~/.buzz-dev BEFORE - // control returns to lib.rs where resolve_repos_at_boot() reads it. This - // ensures the dev nest boots with the correct workspace on its first launch, - // matching what the prod nest had configured. Skip-if-dest-exists so it is - // idempotent and never clobbers a value the dev nest already set explicitly. - // Uses the composed helper so gate + migration share the tested code path. + // On dev builds, copy `.repos-dir` from ~/.buzz → ~/.buzz-dev before + // resolve_repos_at_boot() reads it. Skip-if-dest-exists so it is idempotent + // and never clobbers a value the dev nest already set explicitly. + // The composed helper keeps gate + migration on the tested code path. if let (Some(home), Some(dev_nest)) = (dirs::home_dir(), crate::managed_agents::nest_dir()) { maybe_migrate_dev_repos_dir(is_dev, reset_completed, &home, &dev_nest); } @@ -187,7 +184,7 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) { // Repair dropped team↔member links, then detach directory-backed teams, // gated on a clean repair so a failure preserves `source_dir` for a retry. team_membership::repair_then_detach_teams(app); - replay_pending_team_membership_update(app); + team_membership::replay_pending_team_membership_update(app); reconcile_provider_mcp_commands(app); reconcile_databricks_v1_to_v2(app); materialize_agent_runtimes(app); @@ -1370,17 +1367,6 @@ pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) { mod materialize; pub use materialize::materialize_agent_runtimes; -fn replay_pending_team_membership_update(app: &tauri::AppHandle) { - let state = app.state::(); - let Ok(_store_guard) = state.managed_agents_store_lock.lock() else { - eprintln!("buzz-desktop: pending-team-membership: cannot lock agent store"); - return; - }; - if let Err(error) = crate::commands::replay_pending_team_membership(app) { - eprintln!("buzz-desktop: pending-team-membership: {error}"); - } -} - mod fold; pub use fold::fold_personas_into_agent_store; use fold::load_persona_runtimes; diff --git a/desktop/src-tauri/src/migration/team_membership.rs b/desktop/src-tauri/src/migration/team_membership.rs index 632714f3f9..19ba59fd4a 100644 --- a/desktop/src-tauri/src/migration/team_membership.rs +++ b/desktop/src-tauri/src/migration/team_membership.rs @@ -33,6 +33,8 @@ use std::collections::HashMap; use std::path::Path; +use tauri::Manager; + use crate::managed_agents::{team_persona_key, ManagedAgentRecord, TeamRecord}; /// Repair stale team `persona_ids`/instance `team_id`, then detach @@ -55,6 +57,17 @@ pub(super) fn repair_then_detach_teams(app: &tauri::AppHandle) { ); } +pub(super) fn replay_pending_team_membership_update(app: &tauri::AppHandle) { + let state = app.state::(); + let Ok(_store_guard) = state.managed_agents_store_lock.lock() else { + eprintln!("buzz-desktop: pending-team-membership: cannot lock agent store"); + return; + }; + if let Err(error) = crate::commands::replay_pending_team_membership(app) { + eprintln!("buzz-desktop: pending-team-membership: {error}"); + } +} + /// Gate `detach` on a successful `repair`: run detach only when repair returned /// `Ok`. Injected ops keep the gate `AppHandle`-free so a failing repair's /// skip-detach behavior is unit-testable without a filesystem fault.