fix(desktop): discard stale team membership stages

Signed-off-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz>
This commit is contained in:
Storme Drone
2026-08-26 15:55:28 -06:00
parent fcf83ea24d
commit 38cb69aeb7
4 changed files with 47 additions and 33 deletions

View File

@@ -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<PendingTeamMembershipState, String> {
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)
}

View File

@@ -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

View File

@@ -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::<crate::app_state::AppState>();
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;

View File

@@ -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::<crate::app_state::AppState>();
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.