mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
fix(desktop): discard stale team membership stages
Signed-off-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user