fix(desktop): repair dropped team membership links at boot and on edit (#5904)

Two membership-propagation defects let an agent team silently lose
members — both observed live on Will's store (Sietch Tabr), not
hypothetical.

**Stale `persona_ids` dropped on save.** Team records written before
persona ids were namespaced hold bare slugs (`thufir`) instead of the
namespaced id (`sietch-tabr:thufir`). Nothing rewrites them, and the
interactive save path (`ensure_persona_ids_are_active`) *drops* any id
it cannot resolve — so the next in-app save shrinks the team. This nuked
four of five Sietch Tabr members.

**`team_id` drifts from team membership.** Team instructions are
injected at spawn by matching `record.team_id`
(`spawn_snapshot::effective_team_instructions`), so an instance's
binding must track its persona's membership. It drifts two ways: adding
a persona to a team leaves the persona's already-running instances at
`team_id: null` (a member in the roster but not in behavior — seen
twice, Gurney and Hayt), and removing a persona while keeping its agents
leaves the kept instance bound to a team that no longer lists it (still
drawing that team's instructions at spawn).

## Fix

A boot migration (`migration/team_membership.rs`) heals existing stores
in one pass over `teams.json` + `managed-agents.json`:

- **Rewrite stale ids.** A stale id is one no definition slug resolves.
Its target is the definition whose `source_team_persona_slug` equals the
bare slug, scoped to the team's source team (via `source_dir` for a
directory-backed team, or the unique `source_team` among resolvable
members for a detached one). Rewrite only when exactly one candidate
matches; zero or many leave the id in place — strictly safer than the
save path, which drops it.
- **Repair `team_id`.** Backfill an instance whose persona is a team
member but whose own binding is unset, and heal a stale binding whose
team no longer lists the persona (re-point when exactly one *other* team
claims it, otherwise unbind). Both directions gate on single-team
evidence — a persona spanning several teams has none (JSON team order is
not ownership), so it is left as-is and logged. A binding whose team
still lists the persona is authoritative and never touched.

Runs BEFORE `detach_directory_backed_teams` (so a not-yet-detached team
can still be scoped by its `source_dir`) and before any UI save can drop
an id. Rewrite-or-leave converges to a fixed point, so a second boot is
a no-op; the store is backed up once before either write.

The edit path (`commands/teams.rs`) propagates a membership change to
live instances immediately, without waiting for the next boot, scoped to
the delta between the pre-edit and post-edit rosters:

- **Added personas** (on the team now, not before) backfill `team_id` on
their unbound instances. An explicit add is legitimate binding evidence
even for a persona shared across teams — unlike the order-blind boot
case.
- **Removed personas** (on the team before, not now) clear `team_id` on
instances bound to *this* team (bindings to other teams are untouched),
so a "keep agents" removal stops feeding a kept instance the old team's
instructions.
- **Delta-scoping keeps a metadata-only edit inert:** with no roster
change, no instance is re-pointed — a shared unbound persona is never
silently bound to whichever team was edited last.

Propagation is best-effort after the authoritative `save_teams`
(mirroring `retain_team_pending`): the team already exists on disk, and
boot repair is the designed retry for a stale/unset binding, so a
secondary `managed-agents.json` write failure no longer fails a command
whose team write succeeded — which would otherwise let a UI retry mint a
duplicate team.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-17 14:44:43 -07:00
committed by GitHub
co-authored by Duncan
parent a282e0643f
commit 57feca2f20
12 changed files with 1874 additions and 70 deletions
@@ -255,8 +255,14 @@ fn reconcile_inbound_persona_event_blocking(
}
KIND_TEAM => {
let mut teams = load_teams(&app)?;
apply_inbound_team(&mut teams, d_tag, team_content_from_event(&event)?);
save_teams(&app, &teams)?;
commit_inbound_team(
&mut teams,
d_tag,
team_content_from_event(&event)?,
|teams| save_teams(&app, teams),
|| load_managed_agents(&app),
|records| save_managed_agents(&app, records),
)?;
}
KIND_MANAGED_AGENT => {
let mut agents = load_managed_agents(&app)?;
@@ -584,6 +590,53 @@ fn apply_inbound_managed_agent(
false
}
/// In-memory core of the inbound `KIND_TEAM` reconcile: capture the matched
/// team's roster *before* applying the inbound projection, apply it, persist
/// teams authoritatively, then propagate the prior→current membership delta to
/// live instances best-effort — the same binding semantics the local
/// create/update commands use. Without this, a 30176 team edit from another
/// device lands on `teams.json` but never touches `ManagedAgentRecord.team_id`:
/// an added persona's running instances stay unbound (member in roster, not in
/// behavior) and a removed persona's instances keep drawing the old team's
/// instructions at spawn until restart.
///
/// A no-match insert has no prior roster, so its whole roster is the added
/// delta — symmetric with `commit_team_create`. Injected persistence keeps it
/// `AppHandle`-free so the prior-roster capture and delta direction are
/// unit-testable; a `persist_teams` error propagates, agent IO is best-effort
/// (mirrors the local command path: the authoritative team write already
/// landed, and boot repair is the designed retry for a stale binding).
fn commit_inbound_team(
teams: &mut Vec<TeamRecord>,
d_tag: String,
inbound: TeamEventContent,
persist_teams: impl FnOnce(&[TeamRecord]) -> Result<(), String>,
load_agents: impl FnOnce() -> Result<Vec<ManagedAgentRecord>, String>,
save_agents: impl FnOnce(&[ManagedAgentRecord]) -> Result<(), String>,
) -> Result<(), String> {
let team_id = d_tag.clone();
let previous_persona_ids = teams
.iter()
.find(|record| record.id == team_id)
.map(|record| record.persona_ids.clone())
.unwrap_or_default();
apply_inbound_team(teams, d_tag, inbound);
let current_persona_ids = teams
.iter()
.find(|record| record.id == team_id)
.map(|record| record.persona_ids.clone())
.unwrap_or_default();
persist_teams(teams)?;
crate::commands::teams::propagate_membership_best_effort(
&team_id,
&previous_persona_ids,
&current_persona_ids,
load_agents,
save_agents,
);
Ok(())
}
/// Merge an inbound kind:30176 team projection into the local set.
///
/// Matches the local record whose `id` equals the event's d-tag (the d-tag IS
@@ -546,6 +546,176 @@ fn inbound_team_no_match_inserts_idempotently() {
assert_eq!(teams.len(), 2, "re-receive of inserted team no-ops");
}
// ── Inbound team → membership propagation (commit_inbound_team wiring) ─────
use std::cell::RefCell;
/// A running instance of `persona_id`, optionally bound to a team.
fn team_instance(seed: char, persona_id: &str, team_id: Option<&str>) -> ManagedAgentRecord {
let mut record = local_agent();
record.pubkey = seed.to_string().repeat(64);
record.name = persona_id.to_string();
record.persona_id = Some(persona_id.to_string());
record.team_id = team_id.map(str::to_string);
record
}
/// An inbound team edit that ADDS a persona must bind that persona's unbound
/// running instances to the team — exactly like a local `update_team`. Without
/// the propagation wiring the instance stays unbound (member in roster, not in
/// behavior) until restart.
#[test]
fn inbound_team_add_binds_unbound_instance_through_wiring() {
let mut teams = vec![local_team()];
teams[0].persona_ids = vec!["p-existing".to_string()];
let existing = vec![
team_instance('a', "p-added", None),
team_instance('b', "p-existing", Some(TEAM_ID)),
];
let saved = RefCell::new(None);
commit_inbound_team(
&mut teams,
TEAM_ID.to_string(),
TeamEventContent {
name: "Team".to_string(),
description: None,
instructions: None,
persona_ids: Some(vec!["p-existing".to_string(), "p-added".to_string()]),
},
|_| Ok(()),
|| Ok(existing.clone()),
|records| {
*saved.borrow_mut() = Some(records.to_vec());
Ok(())
},
)
.expect("inbound add succeeds");
let saved = saved
.borrow()
.clone()
.expect("add must save the agent store");
assert_eq!(
saved[0].team_id.as_deref(),
Some(TEAM_ID),
"the added persona's unbound instance is bound to the team"
);
assert_eq!(
saved[1].team_id.as_deref(),
Some(TEAM_ID),
"an instance already on the team is untouched"
);
}
/// An inbound team edit that REMOVES a persona ("keep agents") must detach that
/// persona's instances bound to this team, so a kept instance stops drawing the
/// team's instructions at spawn.
#[test]
fn inbound_team_removal_detaches_instance_through_wiring() {
let mut teams = vec![local_team()];
teams[0].persona_ids = vec!["p-removed".to_string()];
let existing = vec![team_instance('a', "p-removed", Some(TEAM_ID))];
let saved = RefCell::new(None);
commit_inbound_team(
&mut teams,
TEAM_ID.to_string(),
TeamEventContent {
name: "Team".to_string(),
description: None,
instructions: None,
persona_ids: Some(vec![]),
},
|_| Ok(()),
|| Ok(existing.clone()),
|records| {
*saved.borrow_mut() = Some(records.to_vec());
Ok(())
},
)
.expect("inbound removal succeeds");
let saved = saved
.borrow()
.clone()
.expect("removal must save the agent store");
assert_eq!(
saved[0].team_id, None,
"the removed persona's instance is detached from the team"
);
}
/// An inbound edit that omits `persona_ids` (a pre-always-publish client)
/// preserves local membership, so the delta is empty and no instance is
/// re-pointed — a metadata-only inbound edit must not disturb bindings.
#[test]
fn inbound_team_omitted_roster_leaves_bindings_untouched() {
let mut teams = vec![local_team()];
teams[0].persona_ids = vec!["p-a".to_string()];
let existing = vec![team_instance('a', "p-a", None)];
let saved = RefCell::new(None);
commit_inbound_team(
&mut teams,
TEAM_ID.to_string(),
team_content_omitting_optional_fields("Renamed"),
|_| Ok(()),
|| Ok(existing.clone()),
|records| {
*saved.borrow_mut() = Some(records.to_vec());
Ok(())
},
)
.expect("inbound metadata-only edit succeeds");
assert!(
saved.borrow().is_none(),
"an empty membership delta writes nothing to the agent store"
);
}
/// A failing agent-store write after the authoritative `save_teams` is
/// swallowed: the inbound reconcile still succeeds (boot repair is the retry),
/// so a secondary-store hiccup never aborts an inbound event whose team write
/// already landed.
#[test]
fn inbound_team_swallows_agent_store_failure() {
let mut teams = vec![local_team()];
teams[0].persona_ids = vec![];
commit_inbound_team(
&mut teams,
TEAM_ID.to_string(),
TeamEventContent {
name: "Team".to_string(),
description: None,
instructions: None,
persona_ids: Some(vec!["p-added".to_string()]),
},
|_| Ok(()),
|| Err("agent store unreadable".to_string()),
|_| Ok(()),
)
.expect("inbound reconcile swallows secondary-store failure");
}
/// A `persist_teams` error propagates — the authoritative team write failing is
/// a real reconcile failure, unlike best-effort agent IO.
#[test]
fn inbound_team_propagates_persist_teams_error() {
let mut teams = vec![local_team()];
let err = commit_inbound_team(
&mut teams,
TEAM_ID.to_string(),
team_content("Team"),
|_| Err("disk full".to_string()),
|| Ok(vec![]),
|_| Ok(()),
)
.expect_err("a failed team persist must propagate");
assert_eq!(err, "disk full");
}
// ── Tombstone (kind:5) consume ────────────────────────────────────────────
fn deletion_event(coord: &str) -> nostr::Event {
+449 -17
View File
@@ -4,8 +4,9 @@ use uuid::Uuid;
use crate::{
app_state::AppState,
managed_agents::{
delete_team_with_cascade, ensure_persona_ids_are_active, load_personas, load_teams,
save_teams, try_regenerate_nest, CreateTeamRequest, TeamRecord, UpdateTeamRequest,
delete_team_with_cascade, ensure_persona_ids_are_active, load_managed_agents,
load_personas, load_teams, save_managed_agents, save_teams, try_regenerate_nest,
CreateTeamRequest, TeamRecord, UpdateTeamRequest,
},
util::now_iso,
};
@@ -25,6 +26,174 @@ fn trim_optional(value: Option<String>) -> Option<String> {
})
}
/// Propagate a team's membership *change* to its members' already-running
/// instances, best-effort. Loads the agent store, applies the roster delta via
/// [`apply_team_membership_delta`], and re-saves only when something changed;
/// any load/save error is logged and swallowed. Called after the authoritative
/// `save_teams` succeeds — the team already exists on disk and boot repair is
/// the designed retry for a stale/unset binding, so a secondary-store hiccup
/// must not fail a command whose team write already landed (a UI retry would
/// then mint a duplicate team).
///
/// `load_agents`/`save_agents` are injected so the command wiring (prior-roster
/// capture, delta direction, and this best-effort policy) is unit-testable
/// without an `AppHandle`; the commands pass the real store IO.
///
/// Shared with the inbound reconcile path (`commands::personas::inbound`): a
/// 30176 team edit arriving from another device must bind/detach instances the
/// same way a local edit does, so both call this one wrapper.
pub(in crate::commands) fn propagate_membership_best_effort(
team_id: &str,
previous_persona_ids: &[String],
current_persona_ids: &[String],
load_agents: impl FnOnce() -> Result<Vec<crate::managed_agents::ManagedAgentRecord>, String>,
save_agents: impl FnOnce(&[crate::managed_agents::ManagedAgentRecord]) -> Result<(), String>,
) {
let result = (|| -> Result<(), String> {
let mut records = load_agents()?;
if apply_team_membership_delta(
&mut records,
team_id,
previous_persona_ids,
current_persona_ids,
) {
save_agents(&records)?;
}
Ok(())
})();
if let Err(e) = result {
eprintln!("buzz-desktop: team-membership-propagate: {e}");
}
}
/// In-memory core of [`create_team`]: push the built team, persist teams
/// authoritatively, then propagate its whole roster (no prior members ⇒ the
/// whole roster is the added delta) to live instances best-effort. Decoupled
/// from the `AppHandle` shell via injected persistence so the create wiring is
/// unit-testable. A `persist_teams` error propagates; agent IO is best-effort.
fn commit_team_create(
teams: &mut Vec<TeamRecord>,
team: TeamRecord,
persist_teams: impl FnOnce(&[TeamRecord]) -> Result<(), String>,
load_agents: impl FnOnce() -> Result<Vec<crate::managed_agents::ManagedAgentRecord>, String>,
save_agents: impl FnOnce(&[crate::managed_agents::ManagedAgentRecord]) -> Result<(), String>,
) -> Result<TeamRecord, String> {
teams.push(team.clone());
persist_teams(teams)?;
propagate_membership_best_effort(&team.id, &[], &team.persona_ids, load_agents, save_agents);
Ok(team)
}
/// In-memory core of [`update_team`]: mutate the matching team, capturing its
/// roster *before* the edit, persist teams authoritatively, then propagate the
/// prior→current delta to live instances best-effort. The prior-roster capture
/// and its use as the delta baseline live here — not at a command call site —
/// so a miswire to the wrong baseline is caught by a test. Injected persistence
/// keeps it `AppHandle`-free; a `persist_teams` error propagates, agent IO is
/// best-effort. Returns the updated team.
#[allow(clippy::too_many_arguments)]
fn commit_team_update(
teams: &mut [TeamRecord],
id: &str,
name: String,
description: Option<String>,
instructions: Option<String>,
persona_ids: Vec<String>,
now: String,
persist_teams: impl FnOnce(&[TeamRecord]) -> Result<(), String>,
load_agents: impl FnOnce() -> Result<Vec<crate::managed_agents::ManagedAgentRecord>, String>,
save_agents: impl FnOnce(&[crate::managed_agents::ManagedAgentRecord]) -> Result<(), String>,
) -> Result<TeamRecord, String> {
let team = teams
.iter_mut()
.find(|record| record.id == id)
.ok_or_else(|| format!("team {id} not found"))?;
// Capture the pre-edit roster before mutation: the propagation delta
// (added → backfill, removed → detach) is computed against it.
let previous_persona_ids = team.persona_ids.clone();
team.name = name;
team.description = description;
team.instructions = instructions;
team.persona_ids = persona_ids;
team.updated_at = now;
let updated = team.clone();
persist_teams(teams)?;
propagate_membership_best_effort(
&updated.id,
&previous_persona_ids,
&updated.persona_ids,
load_agents,
save_agents,
);
Ok(updated)
}
/// Pure core of the membership propagation: apply the roster delta to `records`
/// in place and report whether anything changed. Decoupled from the store IO so
/// the binding rules are unit-testable.
///
/// Two directions, keyed on the delta between the pre-edit and post-edit
/// rosters:
///
/// - **Added** (`current` but not `previous`): backfill `team_id` on the
/// persona's *unbound* instances, so an added persona spawns with the team's
/// instructions (`spawn_snapshot::effective_team_instructions` keys on
/// `record.team_id`). Only an unset field is set — a shared persona keeps an
/// existing binding — and an explicit add is legitimate binding evidence even
/// when the persona belongs to several teams.
/// - **Removed** (`previous` but not `current`): clear `team_id` on instances
/// bound to *this* team, so a "keep agents" removal stops feeding a kept
/// instance the instructions of a team it no longer belongs to. Bindings to
/// other teams are untouched.
///
/// Delta-scoping is what keeps a metadata-only edit inert: with no roster
/// change both sets are empty and no instance is re-pointed — a shared unbound
/// persona is not silently bound to whichever team was last edited. `create`
/// has no prior roster, so it passes an empty `previous` and the whole roster is
/// "added" (the pre-fix whole-roster backfill). A persona both removed and
/// re-added in one edit appears in neither set (set difference, not
/// operation order), so its binding is left as-is.
fn apply_team_membership_delta(
records: &mut [crate::managed_agents::ManagedAgentRecord],
team_id: &str,
previous_persona_ids: &[String],
current_persona_ids: &[String],
) -> bool {
let added: Vec<&str> = current_persona_ids
.iter()
.filter(|id| !previous_persona_ids.iter().any(|p| p == *id))
.map(String::as_str)
.collect();
let removed: Vec<&str> = previous_persona_ids
.iter()
.filter(|id| !current_persona_ids.iter().any(|p| p == *id))
.map(String::as_str)
.collect();
if added.is_empty() && removed.is_empty() {
return false;
}
let mut changed = false;
for record in records.iter_mut() {
if record.pubkey.is_empty() {
continue;
}
let Some(persona_id) = record.persona_id.as_deref() else {
continue;
};
if record.team_id.is_none() && added.contains(&persona_id) {
record.team_id = Some(team_id.to_string());
changed = true;
} else if record.team_id.as_deref() == Some(team_id) && removed.contains(&persona_id) {
record.team_id = None;
changed = true;
}
}
changed
}
/// Retain a freshly authored team event in the local store, flagged for relay
/// sync. Called inside a command's `managed_agents_store_lock`-held body after
/// `save_teams`; the background flush loop publishes it out-of-band.
@@ -171,8 +340,13 @@ pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result<Tea
created_at: now.clone(),
updated_at: now,
};
teams.push(team.clone());
save_teams(&app, &teams)?;
let team = commit_team_create(
&mut teams,
team,
|teams| save_teams(&app, teams),
|| load_managed_agents(&app),
|records| save_managed_agents(&app, records),
)?;
// Created teams are always non-builtin; publish to the relay.
retain_team_pending(&app, &state, &team);
Ok(team)
@@ -197,19 +371,18 @@ pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result<Tea
let personas = load_personas(&app)?;
ensure_persona_ids_are_active(&personas, &input.persona_ids)?;
let mut teams = load_teams(&app)?;
let team = teams
.iter_mut()
.find(|record| record.id == input.id)
.ok_or_else(|| format!("team {} not found", input.id))?;
team.name = name;
team.description = description;
team.instructions = instructions;
team.persona_ids = input.persona_ids;
team.updated_at = now_iso();
let updated = team.clone();
save_teams(&app, &teams)?;
let updated = commit_team_update(
&mut teams,
&input.id,
name,
description,
instructions,
input.persona_ids,
now_iso(),
|teams| save_teams(&app, teams),
|| load_managed_agents(&app),
|records| save_managed_agents(&app, records),
)?;
// Built-in teams are not owner-authored — never publish them.
if !updated.is_builtin {
retain_team_pending(&app, &state, &updated);
@@ -220,6 +393,265 @@ pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result<Tea
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[cfg(test)]
mod tests {
use super::{apply_team_membership_delta, commit_team_create, commit_team_update};
use crate::managed_agents::{ManagedAgentRecord, TeamRecord};
use std::cell::RefCell;
/// A running instance: `pubkey` set, linked to a persona, optional binding.
fn instance(seed: char, persona_id: &str, team_id: Option<&str>) -> ManagedAgentRecord {
let mut record = serde_json::from_value::<ManagedAgentRecord>(serde_json::json!({
"pubkey": seed.to_string().repeat(64),
"name": persona_id,
"persona_id": persona_id,
"relay_url": "ws://localhost:3000",
"acp_command": "buzz-acp",
"agent_command": "goose",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 320,
"system_prompt": "prompt",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
}))
.unwrap();
record.team_id = team_id.map(str::to_string);
record
}
fn ids(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
/// A metadata-only edit (no roster change) never re-points an instance —
/// including an unbound instance of a persona this team shares with another.
#[test]
fn metadata_only_edit_leaves_bindings_untouched() {
let mut records = vec![instance('a', "duncan", None)];
let roster = ids(&["duncan"]);
assert!(!apply_team_membership_delta(
&mut records,
"team-a",
&roster,
&roster
));
assert_eq!(records[0].team_id, None);
}
/// Only the *added* persona's unbound instance is bound; an untouched member
/// already present in the previous roster is not re-pointed.
#[test]
fn added_persona_backfills_only_its_unbound_instance() {
let mut records = vec![
instance('a', "duncan", None),
instance('b', "paul", Some("team-b")),
];
assert!(apply_team_membership_delta(
&mut records,
"team-a",
&ids(&["paul"]),
&ids(&["paul", "duncan"]),
));
assert_eq!(records[0].team_id.as_deref(), Some("team-a"));
// Paul was already on the team and bound elsewhere — untouched.
assert_eq!(records[1].team_id.as_deref(), Some("team-b"));
}
/// An added persona binds even when shared across teams: an explicit add is
/// legitimate evidence (unlike the boot-repair's order-blind case).
#[test]
fn added_shared_persona_binds_to_the_edited_team() {
let mut records = vec![instance('a', "duncan", None)];
assert!(apply_team_membership_delta(
&mut records,
"team-a",
&[],
&ids(&["duncan"]),
));
assert_eq!(records[0].team_id.as_deref(), Some("team-a"));
}
/// Removing a persona ("keep agents") clears its binding to *this* team so a
/// kept instance stops drawing the team's instructions at spawn.
#[test]
fn removed_persona_detaches_instance_bound_to_this_team() {
let mut records = vec![instance('a', "duncan", Some("team-a"))];
assert!(apply_team_membership_delta(
&mut records,
"team-a",
&ids(&["duncan"]),
&[],
));
assert_eq!(records[0].team_id, None);
}
/// Removal only clears a binding pointing at *this* team — an instance of
/// the same persona bound to a different team is left alone.
#[test]
fn removed_persona_leaves_other_team_binding_untouched() {
let mut records = vec![instance('a', "duncan", Some("team-b"))];
assert!(!apply_team_membership_delta(
&mut records,
"team-a",
&ids(&["duncan"]),
&[],
));
assert_eq!(records[0].team_id.as_deref(), Some("team-b"));
}
/// A minimal owner-authored team record for wiring tests.
fn team(id: &str, persona_ids: &[&str]) -> TeamRecord {
TeamRecord {
id: id.to_string(),
name: id.to_string(),
description: None,
instructions: None,
persona_ids: ids(persona_ids),
is_builtin: false,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
}
}
/// Records the injected store IO a commit performs, so a test can assert
/// the wiring saved (or deliberately did not) the agent store.
#[derive(Default)]
struct StoreSpy {
saved: Option<Vec<ManagedAgentRecord>>,
}
/// Metadata-only `update_team` must pass the TRUE prior roster into the
/// delta, so an unchanged roster is an empty delta and no agent write fires.
/// The `&previous_persona_ids` → `&[]` miswire would drop the prior roster,
/// making the whole roster look "added" and re-pointing the unbound instance.
#[test]
fn commit_team_update_uses_true_prior_roster() {
let mut teams = vec![team("team-a", &["duncan"])];
let existing = vec![instance('a', "duncan", None)];
let spy = RefCell::new(StoreSpy::default());
let updated = commit_team_update(
&mut teams,
"team-a",
"Team A".to_string(),
None,
Some("new instructions".to_string()),
ids(&["duncan"]),
"2026-02-02T00:00:00Z".to_string(),
|_| Ok(()),
|| Ok(existing.clone()),
|records| {
spy.borrow_mut().saved = Some(records.to_vec());
Ok(())
},
)
.expect("metadata-only update succeeds");
assert_eq!(updated.instructions.as_deref(), Some("new instructions"));
// Empty delta ⇒ nothing changed ⇒ no save (the true-prior-roster gate).
assert!(
spy.borrow().saved.is_none(),
"metadata-only edit must not write the agent store"
);
}
/// Removing a persona from the roster must reach the detach branch through
/// the command wiring: the instance bound to this team is cleared and saved.
#[test]
fn commit_team_update_removal_detaches_through_wiring() {
let mut teams = vec![team("team-a", &["duncan"])];
let existing = vec![instance('a', "duncan", Some("team-a"))];
let spy = RefCell::new(StoreSpy::default());
commit_team_update(
&mut teams,
"team-a",
"team-a".to_string(),
None,
None,
ids(&[]),
"2026-02-02T00:00:00Z".to_string(),
|_| Ok(()),
|| Ok(existing.clone()),
|records| {
spy.borrow_mut().saved = Some(records.to_vec());
Ok(())
},
)
.expect("removal update succeeds");
let saved = spy.borrow().saved.clone().expect("detach must save");
assert_eq!(saved[0].team_id, None, "removed persona detaches from team");
}
/// `create_team` has no prior roster, so its whole roster is the added delta:
/// the unbound instance of a listed persona is bound through the wiring.
#[test]
fn commit_team_create_treats_full_roster_as_added() {
let mut teams: Vec<TeamRecord> = Vec::new();
let existing = vec![instance('a', "duncan", None)];
let spy = RefCell::new(StoreSpy::default());
let created = commit_team_create(
&mut teams,
team("team-a", &["duncan"]),
|_| Ok(()),
|| Ok(existing.clone()),
|records| {
spy.borrow_mut().saved = Some(records.to_vec());
Ok(())
},
)
.expect("create succeeds");
assert_eq!(created.id, "team-a");
let saved = spy.borrow().saved.clone().expect("backfill must save");
assert_eq!(
saved[0].team_id.as_deref(),
Some("team-a"),
"whole roster is the added delta on create"
);
}
/// A failing secondary agent write after successful `save_teams` is
/// swallowed: both commits still return the persisted team. Otherwise a UI
/// retry of a create whose team already landed would mint a duplicate.
#[test]
fn commit_returns_ok_when_agent_save_fails() {
let mut teams: Vec<TeamRecord> = Vec::new();
let created = commit_team_create(
&mut teams,
team("team-a", &["duncan"]),
|_| Ok(()),
|| Ok(vec![instance('a', "duncan", None)]),
|_| Err("disk full".to_string()),
)
.expect("create swallows secondary-store failure");
assert_eq!(created.id, "team-a");
let mut teams = vec![team("team-a", &["duncan"])];
let updated = commit_team_update(
&mut teams,
"team-a",
"team-a".to_string(),
None,
None,
ids(&[]),
"2026-02-02T00:00:00Z".to_string(),
|_| Ok(()),
|| Err("agent store unreadable".to_string()),
|_| Ok(()),
)
.expect("update swallows secondary-store failure");
assert_eq!(updated.persona_ids, Vec::<String>::new());
}
}
#[tauri::command]
pub async fn delete_team(id: String, app: AppHandle) -> Result<(), String> {
use tauri::Manager;
+26 -3
View File
@@ -233,16 +233,39 @@ pub async fn apply_workspace(
// Adopt whatever the pre-scoping release left queued in the global
// retention database BEFORE the scoped reconcile and flush run, so
// stranded tombstones and archive requests publish on this boot
// instead of being abandoned by the storage cutover.
// instead of being abandoned by the storage cutover. Best-effort:
// it is not a prerequisite for the superseding head — the team leg
// below builds the repaired roster's head fresh from disk with a
// monotonic `created_at` regardless of what the legacy copy left.
migrate_legacy_retention_into(&restore_app, &scope);
crate::event_sync::spawn_event_sync(
// Await the reconcile to completion — do NOT spawn it — and
// propagate its failure. The boot migration may have repaired team
// membership on disk; the frontend starts inbound history replay
// the moment `useCommunityInit` observes the applied workspace, and
// an old relay team head could otherwise win that race and overwrite
// the repaired `persona_ids`. The team leg is fatal (see
// `run_event_sync`): only its success durably retains the corrected
// head with a superseding `monotonic_created_at`, so
// `retain_inbound_event`'s equal/older guard rejects the stale head.
// On failure we return `Err` — the command reports failure,
// `useCommunityInit` never exposes the community, and inbound replay
// never starts against an un-superseded disk state.
crate::event_sync::run_event_sync_blocking(
restore_app.clone(),
scope.owner_keys,
scope.db_path,
)
.await?;
}
Err(error) => {
eprintln!("buzz-desktop: scoped event-sync unavailable after workspace apply: {error}");
// Scope resolution is a prerequisite for establishing the
// superseding head, so its failure is fatal for the same reason:
// without a scope we cannot retain the repaired roster ahead of an
// inbound replay. Fail the command rather than silently opening the
// inbound lane.
return Err(format!(
"scoped event-sync unavailable after workspace apply: {error}"
));
}
}
+39 -25
View File
@@ -13,32 +13,44 @@ use std::path::Path;
/// `sync_team_personas` wrote in [`crate::migration::run_boot_migrations`]
/// (see its `# Ordering` guard). Event signing needs the resolved owner keys,
/// so this runs after identity resolution, not in the boot migrations.
pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys, db_path: &Path) {
pub fn run_event_sync(
app: &tauri::AppHandle,
owner_keys: &nostr::Keys,
db_path: &Path,
) -> Result<(), String> {
// Persona and agent legs stay best-effort: they log and swallow, and their
// failure does not undo the boot team-membership repair. The team leg is
// fatal — it establishes the superseding local head (a monotonic
// `created_at`) that lets `retain_inbound_event`'s equal/older guard reject
// a stale relay roster. If it fails, the caller must not let the frontend
// expose the community and start inbound replay against an un-superseded
// disk state.
migrate_personas_to_events(app, owner_keys, db_path);
migrate_teams_to_events(app, owner_keys, db_path);
migrate_teams_to_events(app, owner_keys, db_path)?;
crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys, db_path);
Ok(())
}
/// Spawn the best-effort event reconcile off the synchronous Tauri setup path.
/// Run the scoped event reconcile to completion on the blocking pool.
///
/// The owner keys are cloned before spawning so the task never touches the
/// `AppState::keys` mutex. The reconcile itself is still synchronous JSON,
/// SQLite, and signing work, so it runs on the blocking pool rather than an
/// async worker.
pub fn spawn_event_sync(
/// Callers that must not let downstream work observe a not-yet-retained disk
/// state (e.g. `apply_workspace` before the frontend can start inbound history
/// replay) await this so the repaired local heads are durably retained — with a
/// superseding `monotonic_created_at` — before an old relay head can race in.
/// The owner keys are moved in so the task never touches the `AppState::keys`
/// mutex; the reconcile itself is synchronous JSON/SQLite/signing work, so it
/// runs on the blocking pool rather than an async worker.
///
/// Returns `Err` if the task fails to join or the fatal team leg errors, so the
/// caller can withhold community exposure until the superseding head is durable.
pub async fn run_event_sync_blocking(
app: tauri::AppHandle,
owner_keys: nostr::Keys,
db_path: std::path::PathBuf,
) {
tauri::async_runtime::spawn(async move {
if let Err(e) = tauri::async_runtime::spawn_blocking(move || {
run_event_sync(&app, &owner_keys, &db_path);
})
) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || run_event_sync(&app, &owner_keys, &db_path))
.await
{
eprintln!("buzz-desktop: event-sync: spawn_blocking failed: {e}");
}
});
.map_err(|e| format!("event-sync: spawn_blocking failed: {e}"))?
}
/// Reconcile `personas.json` into the persona-event retention store.
@@ -219,21 +231,23 @@ fn migrate_personas_in_dir_at(
///
/// Must run after the persisted identity is resolved (it signs each event with
/// the owner's keys).
pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys, db_path: &Path) {
pub fn migrate_teams_to_events(
app: &tauri::AppHandle,
keys: &nostr::Keys,
db_path: &Path,
) -> Result<(), String> {
use crate::managed_agents::managed_agents_base_dir;
let Ok(base_dir) = managed_agents_base_dir(app) else {
return;
};
let base_dir = managed_agents_base_dir(app)
.map_err(|e| format!("team-event-migration: base dir unavailable: {e}"))?;
match migrate_teams_in_dir_at(&base_dir, keys, db_path) {
Ok(0) => {}
Ok(0) => Ok(()),
Ok(migrated) => {
eprintln!("buzz-desktop: team-event-migration: {migrated} teams migrated to retention");
Ok(())
}
Err(e) => {
eprintln!("buzz-desktop: team-event-migration: {e}");
}
Err(e) => Err(format!("team-event-migration: {e}")),
}
}
@@ -133,3 +133,132 @@ fn migrate_teams_no_file_is_noop() {
let keys = nostr::Keys::generate();
assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 0);
}
/// Error-contract for the fatal team leg. `run_event_sync` propagates a team
/// leg failure via `?`, and `apply_workspace` returns that `Err` so the
/// frontend never exposes the community against an un-superseded disk state.
/// This proves the leg genuinely surfaces failure (rather than logging and
/// swallowing) on an unreadable store — the precondition that made the
/// propagation load-bearing.
#[test]
fn migrate_teams_surfaces_error_on_unparseable_store() {
let base = tempfile::tempdir().unwrap();
std::fs::write(base.path().join("teams.json"), "{ not valid json").unwrap();
let keys = nostr::Keys::generate();
assert!(migrate_teams_in_dir(base.path(), &keys).is_err());
}
/// Build a signed inbound team head at an explicit `created_at`, mirroring a
/// relay replay of a stale, pre-namespacing roster.
fn stale_inbound_head(
keys: &nostr::Keys,
id: &str,
bare_persona_ids: &[&str],
created_at: i64,
) -> crate::managed_agents::retention::RetainedEvent {
use crate::managed_agents::{team_events::build_team_event, TeamRecord};
use buzz_core_pkg::kind::KIND_TEAM;
use nostr::JsonUtil;
let record = TeamRecord {
id: id.to_string(),
name: "Sietch Tabr".to_string(),
description: None,
instructions: None,
persona_ids: bare_persona_ids.iter().map(|s| s.to_string()).collect(),
is_builtin: false,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: "2025-01-01T00:00:00Z".to_string(),
updated_at: "2025-01-01T00:00:00Z".to_string(),
};
let event = build_team_event(&record)
.unwrap()
.custom_created_at(nostr::Timestamp::from(created_at as u64))
.sign_with_keys(keys)
.unwrap();
crate::managed_agents::retention::RetainedEvent {
kind: KIND_TEAM,
pubkey: keys.public_key().to_hex(),
d_tag: id.to_string(),
content: event.content.to_string(),
created_at: event.created_at.as_secs() as i64,
raw_event: event.as_json(),
pending_sync: false,
}
}
/// Finding-1 retention-precedence guarantee. This proves the *mechanic* the
/// awaited-reconcile ordering relies on — it does not itself exercise
/// `apply_workspace` (an `AppHandle`-level path). Given the boot reconcile has
/// retained the repaired namespaced roster with a monotonic `created_at`
/// (reconcile-first), a stale relay head replayed afterward is older, so
/// `retain_inbound_event` skips it and the repaired roster stays. The
/// inbound-first lane is the counterfactual the ordering closes: with no
/// repaired head retained yet, the very same stale head is applied and restores
/// bare membership. Retention order is the only difference between the lanes;
/// `apply_workspace` awaiting the reconcile (see `commands/workspace.rs`) is
/// what forces the reconcile-first order in production.
#[test]
fn reconcile_first_makes_stale_inbound_team_head_lose() {
use crate::managed_agents::retention::{
get_retained_event, open_retention_db, retain_inbound_event, InboundOutcome,
};
use buzz_core_pkg::kind::KIND_TEAM;
let keys = nostr::Keys::generate();
let pubkey = keys.public_key().to_hex();
let repaired = serde_json::json!([{
"id": "sietch-tabr",
"name": "Sietch Tabr",
"persona_ids": ["sietch-tabr:thufir", "sietch-tabr:paul", "sietch-tabr:duncan"],
"is_builtin": false,
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}]);
let bare = ["thufir", "paul", "duncan"];
// Reconcile-first lane (the fix): the awaited boot reconcile retains the
// repaired namespaced roster with a monotonic `created_at`; a stale relay
// head replayed afterward is older, so `retain_inbound_event` skips it and
// the retained roster stays repaired.
let ordered = tempfile::tempdir().unwrap();
let ordered_db = ordered.path().join("retention.db");
write_base_teams(ordered.path(), &repaired);
assert_eq!(
migrate_teams_in_dir_at(ordered.path(), &keys, &ordered_db).unwrap(),
1
);
let conn = open_retention_db(&ordered_db).unwrap();
let repaired_head = get_retained_event(&conn, KIND_TEAM, &pubkey, "sietch-tabr")
.unwrap()
.unwrap();
let stale = stale_inbound_head(&keys, "sietch-tabr", &bare, repaired_head.created_at - 1);
assert_eq!(
retain_inbound_event(&conn, &stale).unwrap(),
InboundOutcome::Skipped
);
let head = get_retained_event(&conn, KIND_TEAM, &pubkey, "sietch-tabr")
.unwrap()
.unwrap();
assert!(head.content.contains("sietch-tabr:thufir"));
assert!(!head.content.contains("\"thufir\""));
// Inbound-first lane (the race the fix closes): with no repaired head
// retained yet, the very same stale relay head is applied, restoring the
// bare pre-namespacing roster. Ordering is the only difference.
let raced = tempfile::tempdir().unwrap();
let raced_db = raced.path().join("retention.db");
let raced_conn = open_retention_db(&raced_db).unwrap();
let stale = stale_inbound_head(&keys, "sietch-tabr", &bare, repaired_head.created_at - 1);
assert_eq!(
retain_inbound_event(&raced_conn, &stale).unwrap(),
InboundOutcome::Applied
);
let head = get_retained_event(&raced_conn, KIND_TEAM, &pubkey, "sietch-tabr")
.unwrap()
.unwrap();
assert!(head.content.contains("\"thufir\""));
}
@@ -39,6 +39,7 @@ pub(crate) mod spawn_snapshot;
pub(crate) mod storage;
pub(crate) mod team_events;
mod team_repair;
pub(crate) use team_repair::team_persona_key;
mod teams;
mod types;
+7 -7
View File
@@ -149,8 +149,7 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) {
// 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 the gate + migration run through the same
// code path that the behavioral test exercises.
// Uses the composed helper so gate + migration share 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);
}
@@ -181,11 +180,12 @@ fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) {
strip_baked_team_instructions(app);
refresh_builtin_agent_avatars(app);
// B5: manufacture definitions for standalone agents AFTER the fold (so
// pre-existing definition slugs are present for collision checks) and
// before event sync republishes — the backfilled link is what flips the
// 30177 projection to its slim shape.
// pre-existing definition slugs exist for collision checks) and before event
// sync republishes — the backfilled link flips the 30177 projection.
backfill_standalone_agents(app);
detach_directory_backed_teams(app);
// 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);
reconcile_provider_mcp_commands(app);
reconcile_databricks_v1_to_v2(app);
materialize_agent_runtimes(app);
@@ -1373,8 +1373,8 @@ use fold::load_persona_runtimes;
mod backfill;
pub use backfill::backfill_standalone_agents;
mod detach;
pub use detach::detach_directory_backed_teams;
mod pollen;
mod team_membership;
pub(crate) use pollen::*;
mod team_suffix;
pub use team_suffix::strip_baked_team_instructions;
+6 -16
View File
@@ -9,10 +9,12 @@ use crate::managed_agents::{ManagedAgentRecord, TeamRecord};
/// Lift pack instructions into `TeamRecord.instructions` and detach
/// directory-backed teams from their source directories.
///
/// Runs on app launch if any `TeamRecord` still has `source_dir` set.
/// Both output files are written atomically (temp-file + rename), so a crash
/// mid-write leaves the previous version intact and the migration can safely
/// retry on next boot.
/// Core logic, decoupled from the Tauri `AppHandle` for testing.
///
/// Runs on app launch (gated on a clean team-membership repair) if any
/// `TeamRecord` still has `source_dir` set. Both output files are written
/// atomically (temp-file + rename), so a crash mid-write leaves the previous
/// version intact and the migration can safely retry on next boot.
///
/// Steps (written last so the idempotency gate stays open until both files
/// are committed):
@@ -24,18 +26,6 @@ use crate::managed_agents::{ManagedAgentRecord, TeamRecord};
/// `instructions` if the field is not already set.
/// 4. Clear `source_dir`, `is_symlink`, `symlink_target`, `version` on each
/// directory-backed `TeamRecord`.
pub fn detach_directory_backed_teams(app: &tauri::AppHandle) {
let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else {
return;
};
match detach_directory_backed_teams_in_dir(&base_dir) {
Ok(0) => {}
Ok(n) => eprintln!("buzz-desktop: detach-dir-teams: detached {n} directory-backed team(s)"),
Err(e) => eprintln!("buzz-desktop: detach-dir-teams: {e}"),
}
}
/// Core logic, decoupled from the Tauri `AppHandle` for testing.
///
/// `base_dir` is the managed-agents base directory (`<AppDataDir>/agents/`).
/// Returns the number of teams detached (0 = nothing to do).
@@ -0,0 +1,353 @@
//! Repair team↔member links that a membership edit failed to propagate.
//!
//! Two independent defects, both rooted in a team-membership change not
//! reaching the records that depend on it, are healed in one pass over
//! `teams.json` + `managed-agents.json`:
//!
//! 1. **Stale `persona_ids`.** Team records written before persona ids were
//! namespaced hold bare slugs (`thufir`) instead of the namespaced id
//! (`sietch-tabr:thufir`). Nothing rewrites them, and the interactive save
//! path (`ensure_persona_ids_are_active`) *drops* an id it cannot resolve —
//! silently shrinking the team. This migration rewrites a stale id to the
//! persona it names whenever that persona is unambiguous, and — unlike the
//! save path — never drops one it cannot resolve.
//!
//! 2. **Orphaned or stale instance `team_id`.** Team instructions are injected
//! at spawn by matching `record.team_id`
//! (`spawn_snapshot::effective_team_instructions`), so an instance's binding
//! must track its persona's membership. Two ways it drifts: adding a persona
//! to a team does not backfill `team_id` on that persona's already-running
//! instances (a member in the roster but not in behavior), and removing a
//! persona while keeping its agents leaves the binding pointing at a team
//! that no longer lists it (still drawing that team's instructions). This
//! backfills an unset binding and heals a stale one — always on the same
//! single-team evidence rule, never guessing across teams.
//!
//! The stale-id rewrite is strictly additive (rewrite-or-leave); the binding
//! repair converges to a fixed point (bound-to-a-listing-team or unbound), so a
//! second boot is a clean no-op either way. Runs BEFORE
//! `detach_directory_backed_teams` so a not-yet-detached directory-backed team
//! can still be scoped by its `source_dir`, and before any UI save can drop an
//! unresolvable id.
use std::collections::HashMap;
use std::path::Path;
use crate::managed_agents::{team_persona_key, ManagedAgentRecord, TeamRecord};
/// Repair stale team `persona_ids`/instance `team_id`, then detach
/// directory-backed teams — but only when the repair succeeded.
///
/// `repair` clears no `source_dir`; the downstream detach does. A stale bare
/// slug shared across source teams is disambiguated by `source_dir`, so if
/// repair fails (its backup or write errored) and detach still ran, the next
/// boot would see only ambiguous candidates and the original membership-loss
/// path recurs. Gating detach on a clean repair preserves `source_dir` as retry
/// evidence for that boot; the next boot retries repair and, once clean,
/// detaches.
pub(super) fn repair_then_detach_teams(app: &tauri::AppHandle) {
let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else {
return;
};
orchestrate_repair_then_detach(
|| repair_team_membership_in_dir(&base_dir),
|| super::detach::detach_directory_backed_teams_in_dir(&base_dir),
);
}
/// 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.
fn orchestrate_repair_then_detach(
repair: impl FnOnce() -> Result<usize, String>,
detach: impl FnOnce() -> Result<usize, String>,
) {
match repair() {
Ok(repaired) => {
if repaired > 0 {
eprintln!("buzz-desktop: team-membership-repair: repaired {repaired} record(s)");
}
match detach() {
Ok(0) => {}
Ok(n) => {
eprintln!(
"buzz-desktop: detach-dir-teams: detached {n} directory-backed team(s)"
)
}
Err(e) => eprintln!("buzz-desktop: detach-dir-teams: {e}"),
}
}
Err(e) => eprintln!(
"buzz-desktop: team-membership-repair: {e} — skipping directory-backed detach this \
boot to preserve source_dir for a clean-repair retry"
),
}
}
/// Core logic, decoupled from the Tauri `AppHandle` for testing.
///
/// `base_dir` is the managed-agents base directory (`<AppDataDir>/agents/`).
/// Returns the number of records changed across both files (0 = nothing to do,
/// nothing written, so a re-run is a clean no-op).
pub(super) fn repair_team_membership_in_dir(base_dir: &Path) -> Result<usize, String> {
let teams_path = base_dir.join("teams.json");
let agents_path = base_dir.join("managed-agents.json");
// Definitions and teams both live in these two files; without either there
// is nothing to link.
if !teams_path.exists() || !agents_path.exists() {
return Ok(0);
}
let teams_content = std::fs::read_to_string(&teams_path)
.map_err(|e| format!("failed to read teams.json: {e}"))?;
let mut teams: Vec<TeamRecord> = serde_json::from_str(&teams_content)
.map_err(|e| format!("failed to parse teams.json: {e}"))?;
let agents_content = std::fs::read_to_string(&agents_path)
.map_err(|e| format!("failed to read managed-agents.json: {e}"))?;
let mut agents: Vec<ManagedAgentRecord> = serde_json::from_str(&agents_content)
.map_err(|e| format!("failed to parse managed-agents.json: {e}"))?;
let rewrites = rewrite_stale_persona_ids(&mut teams, &agents);
let backfills = backfill_instance_team_ids(&teams, &mut agents);
if rewrites == 0 && backfills == 0 {
return Ok(0);
}
// Pre-migration backups, both taken BEFORE either live store write: the
// stated contract is a full recovery pair even if a crash lands between the
// two writes, so neither store may be rewritten until both pristine backups
// exist. A stale bare slug shared across source teams is disambiguated by
// `source_dir`, which the downstream detach clears — so the pristine
// pre-repair `teams.json` is the evidence a retry needs. Each backup is
// created once (create-new), so a re-run after a partial failure never
// overwrites the pristine copy with a half-migrated snapshot.
if rewrites > 0 {
let bak = crate::util::resolved_backup_path(
&teams_path,
"teams.json.pre-team-membership-repair.bak",
);
crate::util::create_restricted_backup_once(&bak, teams_content.as_bytes())
.map_err(|e| format!("failed to write teams.json backup: {e}"))?;
}
if backfills > 0 {
let bak = crate::util::resolved_backup_path(
&agents_path,
"managed-agents.json.pre-team-membership-repair.bak",
);
crate::util::create_restricted_backup_once(&bak, agents_content.as_bytes())
.map_err(|e| format!("failed to write managed-agents.json backup: {e}"))?;
}
if rewrites > 0 {
let payload = serde_json::to_vec_pretty(&teams)
.map_err(|e| format!("failed to serialize teams.json: {e}"))?;
crate::managed_agents::atomic_write_json(&teams_path, &payload)?;
}
if backfills > 0 {
// Restricted: this store can carry plaintext agent nsecs on a
// keyringless host (SECURITY.md:90).
let payload = serde_json::to_vec_pretty(&agents)
.map_err(|e| format!("failed to serialize managed-agents.json: {e}"))?;
crate::managed_agents::atomic_write_json_restricted(&agents_path, &payload)?;
}
Ok(rewrites + backfills)
}
/// Set of persona ids that resolve to a definition — the definition records are
/// the key-less unified-store entries (`pubkey == ""`); their `slug` is the id
/// a team references.
fn resolvable_ids(agents: &[ManagedAgentRecord]) -> Vec<&str> {
agents
.iter()
.filter(|r| r.pubkey.is_empty())
.filter_map(|r| r.slug.as_deref())
.collect()
}
/// Rewrite each team's stale `persona_ids` to the persona they name, when
/// unambiguous. Returns the number of ids rewritten.
///
/// An id is *stale* when no definition slug equals it. Its repair target is the
/// definition whose `source_team_persona_slug` equals the stale id — i.e. the
/// bare slug is the pre-namespacing form of that persona's namespaced slug. The
/// rewrite happens only when exactly one such definition exists (optionally
/// scoped to the team's source team); zero or many candidates leave the id
/// untouched, which is strictly safer than the save path that drops it.
fn rewrite_stale_persona_ids(teams: &mut [TeamRecord], agents: &[ManagedAgentRecord]) -> usize {
let resolvable = resolvable_ids(agents);
let definitions: Vec<&ManagedAgentRecord> =
agents.iter().filter(|r| r.pubkey.is_empty()).collect();
let mut rewritten = 0usize;
for team in teams.iter_mut() {
// Scope candidate personas to this team's source team when derivable:
// a directory-backed team keys off its source_dir name; a detached team
// keys off the unique source_team of its already-resolvable members.
let scope = team_source_scope(team, &definitions);
for id in team.persona_ids.iter_mut() {
if resolvable.contains(&id.as_str()) {
continue;
}
let candidates: Vec<&&ManagedAgentRecord> = definitions
.iter()
.filter(|d| d.source_team_persona_slug.as_deref() == Some(id.as_str()))
.filter(|d| match scope.as_deref() {
Some(team_key) => d.source_team.as_deref() == Some(team_key),
None => true,
})
.collect();
let [only] = candidates.as_slice() else {
eprintln!(
"buzz-desktop: team-membership-repair: team {:?}: leaving unresolvable \
persona id {:?} ({} candidate(s))",
team.id,
id,
candidates.len()
);
continue;
};
if let Some(slug) = only.slug.as_deref() {
*id = slug.to_string();
rewritten += 1;
}
}
}
rewritten
}
/// The source-team key that scopes a team's persona candidates, or `None` when
/// it cannot be derived (matching then falls back to a global unique slug).
///
/// Directory-backed teams use `team_persona_key` (the pack manifest id). A
/// detached team (`source_dir` cleared) has no such key, so we infer it from
/// the unique `source_team` among its members that already resolve.
fn team_source_scope(team: &TeamRecord, definitions: &[&ManagedAgentRecord]) -> Option<String> {
if team.source_dir.is_some() {
return Some(team_persona_key(team).to_string());
}
let mut source_teams: Vec<&str> = team
.persona_ids
.iter()
.filter_map(|id| {
definitions
.iter()
.find(|d| d.slug.as_deref() == Some(id.as_str()))
.and_then(|d| d.source_team.as_deref())
})
.collect();
source_teams.sort_unstable();
source_teams.dedup();
match source_teams.as_slice() {
[only] => Some((*only).to_string()),
_ => None,
}
}
/// Repair instance `team_id` against the current rosters. Returns the number of
/// instances changed.
///
/// Two directions, both conservative and evidence-gated:
///
/// - **Unbound → bound (backfill).** An instance whose persona is a team member
/// but whose own `team_id` is unset is bound to that team, so it spawns with
/// the team's instructions. Only when the persona belongs to *exactly one*
/// team — a persona spanning several teams has no evidence selecting one
/// (JSON team order is not ownership), so it is left unbound and logged.
/// - **Stale binding → cleared or re-pointed.** An instance bound to a team
/// whose roster no longer lists its persona (a "keep agents" removal left the
/// binding behind, so the kept instance keeps drawing that team's
/// instructions at spawn) is healed: re-pointed when the persona now belongs
/// to exactly one *other* team (same single-evidence rule), otherwise unbound
/// and logged. A binding whose team still lists the persona is authoritative
/// and never touched.
///
/// Idempotent: after a repair every instance is either bound to a team that
/// lists it or unbound with no single-team evidence, so a second pass is a
/// no-op.
fn backfill_instance_team_ids(teams: &[TeamRecord], agents: &mut [ManagedAgentRecord]) -> usize {
// persona_id → the sole team referencing it, or None once a *distinct*
// second team is seen (ambiguous → never used as binding evidence). A
// persona listed twice within one team is not ambiguity — duplicates are
// not prohibited at the storage boundary (`ensure_persona_ids_are_active`
// checks existence only; create/update/inbound persist the vector
// unchanged), so poisoning on a same-team repeat would strand a
// legitimately single-team instance.
let mut persona_to_team: HashMap<&str, Option<&str>> = HashMap::new();
// Team ids that exist in the store, and the (team_id, persona_id) pairs they
// list. A binding is *stale* only when its team still exists but no longer
// lists the persona — a binding to an absent team is left alone (it already
// degrades to no instructions via `effective_team_instructions`, and a
// deleted team is not this repair's concern).
let mut team_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut membership: std::collections::HashSet<(&str, &str)> = std::collections::HashSet::new();
for team in teams {
team_ids.insert(team.id.as_str());
for persona_id in &team.persona_ids {
membership.insert((team.id.as_str(), persona_id.as_str()));
persona_to_team
.entry(persona_id.as_str())
.and_modify(|slot| {
if slot.is_some_and(|seen| seen != team.id.as_str()) {
*slot = None;
}
})
.or_insert(Some(team.id.as_str()));
}
}
let mut repaired = 0usize;
for agent in agents.iter_mut() {
if agent.pubkey.is_empty() {
continue;
}
let Some(persona_id) = agent.persona_id.as_deref() else {
continue;
};
match agent.team_id.as_deref() {
// Live binding, or a binding to an absent team: leave it. A binding
// is only stale when its team exists and dropped the persona.
Some(bound)
if !team_ids.contains(bound) || membership.contains(&(bound, persona_id)) => {}
// Stale binding: the still-present bound team dropped this persona.
// Re-point on single-team evidence, else unbind — never guess.
Some(_) => match persona_to_team.get(persona_id) {
Some(Some(team_id)) => {
agent.team_id = Some((*team_id).to_string());
repaired += 1;
}
_ => {
eprintln!(
"buzz-desktop: team-membership-repair: unbinding instance {:?} — persona \
{persona_id:?} left its team's roster with no single-team successor",
agent.pubkey
);
agent.team_id = None;
repaired += 1;
}
},
// Unbound: backfill on single-team evidence.
None => match persona_to_team.get(persona_id) {
Some(Some(team_id)) => {
agent.team_id = Some((*team_id).to_string());
repaired += 1;
}
Some(None) => eprintln!(
"buzz-desktop: team-membership-repair: leaving instance {:?} unbound — persona \
{persona_id:?} spans multiple teams",
agent.pubkey
),
None => {}
},
}
}
repaired
}
#[cfg(test)]
#[path = "team_membership_tests.rs"]
mod tests;
@@ -0,0 +1,625 @@
use super::repair_team_membership_in_dir;
use crate::migration::test_support::{
read_agents_json, read_teams_json, write_agents_json, write_teams_json,
};
use std::path::{Path, PathBuf};
fn base(dir: &Path) -> PathBuf {
dir.join("agents")
}
/// A key-less definition record: `pubkey == ""`, persona id == `slug`.
/// `source_team` is the manifest id; `source_team_persona_slug` is the
/// pre-namespacing bare slug a stale team id would carry.
fn definition(slug: &str, source_team: &str, bare_slug: &str) -> serde_json::Value {
serde_json::json!({
"name": slug,
"pubkey": "",
"relay_url": "ws://localhost:3000",
"acp_command": "buzz-acp",
"agent_command": "goose",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 320,
"parallelism": 4,
"system_prompt": "prompt",
"model": "gpt-x",
"provider": "openai",
"env_vars": {},
"start_on_app_launch": true,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"slug": slug,
"source_team": source_team,
"source_team_persona_slug": bare_slug,
})
}
/// A standalone definition with no team provenance (persona id == slug).
fn standalone_definition(slug: &str) -> serde_json::Value {
serde_json::json!({
"name": slug,
"pubkey": "",
"relay_url": "ws://localhost:3000",
"acp_command": "buzz-acp",
"agent_command": "goose",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 320,
"parallelism": 4,
"system_prompt": "prompt",
"model": "gpt-x",
"provider": "openai",
"env_vars": {},
"start_on_app_launch": true,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"slug": slug,
})
}
/// A running instance record: `pubkey` set, linked to a persona by `persona_id`.
fn instance(pubkey_seed: char, persona_id: &str, team_id: Option<&str>) -> serde_json::Value {
let mut record = serde_json::json!({
"name": persona_id,
"pubkey": pubkey_seed.to_string().repeat(64),
"relay_url": "ws://localhost:3000",
"acp_command": "buzz-acp",
"agent_command": "goose",
"agent_args": [],
"mcp_command": "",
"turn_timeout_seconds": 320,
"parallelism": 4,
"system_prompt": "prompt",
"model": "gpt-x",
"provider": "openai",
"env_vars": {},
"start_on_app_launch": true,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"persona_id": persona_id,
});
record["team_id"] = match team_id {
Some(id) => serde_json::json!(id),
None => serde_json::Value::Null,
};
record
}
fn team(id: &str, persona_ids: &[&str]) -> serde_json::Value {
serde_json::json!({
"id": id,
"name": "Sietch Tabr",
"description": null,
"persona_ids": persona_ids,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
})
}
fn team_persona_ids(dir: &Path, id: &str) -> Vec<String> {
read_teams_json(dir)
.into_iter()
.find(|t| t["id"] == id)
.unwrap()["persona_ids"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect()
}
fn instance_team_id(dir: &Path, pubkey_seed: char) -> Option<String> {
read_agents_json(dir)
.into_iter()
.find(|r| r["pubkey"].as_str() == Some(&pubkey_seed.to_string().repeat(64)))
.unwrap()["team_id"]
.as_str()
.map(str::to_string)
}
const TEAM_ID: &str = "ab5c038c-1b12-46e2-8283-d6f7c0606fce";
const ST: &str = "com.wpfleger.sietch-tabr";
/// Will's pre-fix store: the team holds four bare pre-namespacing ids plus one
/// resolvable standalone id. Each bare id names exactly one team persona, so
/// all four are rewritten to their namespaced slug and the standalone id is
/// left untouched — the class the save path silently drops.
#[test]
fn rewrites_bare_ids_to_namespaced_slugs() {
let dir = tempfile::tempdir().unwrap();
write_teams_json(
dir.path(),
&serde_json::json!([team(
TEAM_ID,
&["369695d6", "thufir", "paul", "duncan", "alia"]
)]),
);
write_agents_json(
dir.path(),
&serde_json::json!([
standalone_definition("369695d6"),
definition("sietch-tabr:thufir", ST, "thufir"),
definition("sietch-tabr:paul", ST, "paul"),
definition("sietch-tabr:duncan", ST, "duncan"),
definition("sietch-tabr:alia", ST, "alia"),
]),
);
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 4);
assert_eq!(
team_persona_ids(dir.path(), TEAM_ID),
vec![
"369695d6",
"sietch-tabr:thufir",
"sietch-tabr:paul",
"sietch-tabr:duncan",
"sietch-tabr:alia",
]
);
}
/// A directory-backed team scopes candidates by its `source_dir` name (the pack
/// manifest id), so a bare slug that appears under two different source teams is
/// disambiguated to the one this team is sourced from.
#[test]
fn scopes_candidates_by_source_dir_for_directory_backed_team() {
let dir = tempfile::tempdir().unwrap();
let mut t = team(TEAM_ID, &["thufir"]);
t["source_dir"] = serde_json::json!(format!("/packs/{ST}"));
write_teams_json(dir.path(), &serde_json::json!([t]));
write_agents_json(
dir.path(),
&serde_json::json!([
definition("sietch-tabr:thufir", ST, "thufir"),
// A collision: a different team also has a persona whose bare slug
// is "thufir". Without source scoping this would be ambiguous.
definition("other:thufir", "com.other.pack", "thufir"),
]),
);
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1);
assert_eq!(
team_persona_ids(dir.path(), TEAM_ID),
vec!["sietch-tabr:thufir"]
);
}
/// A bare id that names two personas with no usable scope is ambiguous: the
/// migration leaves it in place (strictly safer than the save path, which drops
/// it) and the file is not rewritten.
#[test]
fn leaves_ambiguous_id_in_place_without_writing() {
let dir = tempfile::tempdir().unwrap();
// Detached team (no source_dir) with a single stale member => no resolvable
// sibling to infer a source-team scope from.
write_teams_json(dir.path(), &serde_json::json!([team(TEAM_ID, &["thufir"])]));
write_agents_json(
dir.path(),
&serde_json::json!([
definition("sietch-tabr:thufir", ST, "thufir"),
definition("other:thufir", "com.other.pack", "thufir"),
]),
);
let before = std::fs::read_to_string(base(dir.path()).join("teams.json")).unwrap();
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 0);
assert_eq!(team_persona_ids(dir.path(), TEAM_ID), vec!["thufir"]);
assert_eq!(
std::fs::read_to_string(base(dir.path()).join("teams.json")).unwrap(),
before,
"an ambiguous-only store is never rewritten"
);
assert!(
!base(dir.path())
.join("teams.json.pre-team-membership-repair.bak")
.exists(),
"no backup when nothing is repaired"
);
}
/// A detached team infers its source-team scope from the unique `source_team`
/// among its already-resolvable members, so a bare id is disambiguated even
/// without a `source_dir`.
#[test]
fn infers_scope_from_resolvable_siblings_when_detached() {
let dir = tempfile::tempdir().unwrap();
write_teams_json(
dir.path(),
&serde_json::json!([team(TEAM_ID, &["sietch-tabr:paul", "thufir"])]),
);
write_agents_json(
dir.path(),
&serde_json::json!([
definition("sietch-tabr:paul", ST, "paul"),
definition("sietch-tabr:thufir", ST, "thufir"),
definition("other:thufir", "com.other.pack", "thufir"),
]),
);
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1);
assert_eq!(
team_persona_ids(dir.path(), TEAM_ID),
vec!["sietch-tabr:paul", "sietch-tabr:thufir"]
);
}
/// Backfill sets `team_id` on an instance whose persona is a team member but
/// whose own `team_id` is null (the Gurney case), and leaves an already-bound
/// instance untouched (a persona shared across teams keeps its binding).
#[test]
fn backfills_null_team_id_but_never_re_points_a_bound_instance() {
let dir = tempfile::tempdir().unwrap();
write_teams_json(
dir.path(),
&serde_json::json!([team(TEAM_ID, &["sietch-tabr:gurney", "sietch-tabr:hayt"])]),
);
write_agents_json(
dir.path(),
&serde_json::json!([
definition("sietch-tabr:gurney", ST, "gurney"),
definition("sietch-tabr:hayt", ST, "hayt"),
instance('g', "sietch-tabr:gurney", None),
instance('h', "sietch-tabr:hayt", Some("other-team")),
]),
);
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1);
assert_eq!(instance_team_id(dir.path(), 'g').as_deref(), Some(TEAM_ID));
assert_eq!(
instance_team_id(dir.path(), 'h').as_deref(),
Some("other-team"),
"an already-bound instance is never re-pointed"
);
}
/// A legacy unbound instance whose persona belongs to *two* teams is left
/// unbound: JSON team order is not ownership evidence, and the product permits
/// one persona under multiple teams with distinct instructions. Its team
/// sibling — a persona in only one team — is still backfilled in the same pass.
#[test]
fn leaves_unbound_instance_of_a_multi_team_persona_unbound() {
let dir = tempfile::tempdir().unwrap();
write_teams_json(
dir.path(),
&serde_json::json!([
team(TEAM_ID, &["sietch-tabr:duncan", "sietch-tabr:paul"]),
team("other-team", &["sietch-tabr:duncan"]),
]),
);
write_agents_json(
dir.path(),
&serde_json::json!([
definition("sietch-tabr:duncan", ST, "duncan"),
definition("sietch-tabr:paul", ST, "paul"),
instance('d', "sietch-tabr:duncan", None),
instance('p', "sietch-tabr:paul", None),
]),
);
// Only Paul (single-team) is backfilled; Duncan (two teams) stays unbound.
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1);
assert_eq!(instance_team_id(dir.path(), 'd'), None);
assert_eq!(instance_team_id(dir.path(), 'p').as_deref(), Some(TEAM_ID));
}
/// A persona listed twice within a *single* team is not ambiguity — the storage
/// boundary does not dedupe `persona_ids`. Its unbound instance is still bound
/// to that one team; only a *distinct* second team poisons the entry.
#[test]
fn same_team_duplicate_persona_id_still_backfills() {
let dir = tempfile::tempdir().unwrap();
write_teams_json(
dir.path(),
&serde_json::json!([team(TEAM_ID, &["sietch-tabr:duncan", "sietch-tabr:duncan"])]),
);
write_agents_json(
dir.path(),
&serde_json::json!([
definition("sietch-tabr:duncan", ST, "duncan"),
instance('d', "sietch-tabr:duncan", None),
]),
);
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1);
assert_eq!(instance_team_id(dir.path(), 'd').as_deref(), Some(TEAM_ID));
}
/// A stale binding — the bound team no longer lists the instance's persona (a
/// "keep agents" removal left it behind) — is cleared when no other single team
/// claims the persona, so the kept instance stops drawing that team's
/// instructions at spawn.
#[test]
fn clears_stale_binding_when_persona_left_its_team() {
let dir = tempfile::tempdir().unwrap();
// The team no longer lists gurney; the instance is still bound to it.
write_teams_json(
dir.path(),
&serde_json::json!([team(TEAM_ID, &["sietch-tabr:paul"])]),
);
write_agents_json(
dir.path(),
&serde_json::json!([
definition("sietch-tabr:gurney", ST, "gurney"),
definition("sietch-tabr:paul", ST, "paul"),
instance('g', "sietch-tabr:gurney", Some(TEAM_ID)),
]),
);
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1);
assert_eq!(instance_team_id(dir.path(), 'g'), None);
}
/// A stale binding is *re-pointed* — not merely cleared — when the persona now
/// belongs to exactly one other team, matching the single-evidence backfill
/// rule.
#[test]
fn repoints_stale_binding_to_the_sole_successor_team() {
let dir = tempfile::tempdir().unwrap();
// gurney left TEAM_ID but is the sole member of other-team.
write_teams_json(
dir.path(),
&serde_json::json!([
team(TEAM_ID, &["sietch-tabr:paul"]),
team("other-team", &["sietch-tabr:gurney"]),
]),
);
write_agents_json(
dir.path(),
&serde_json::json!([
definition("sietch-tabr:gurney", ST, "gurney"),
definition("sietch-tabr:paul", ST, "paul"),
instance('g', "sietch-tabr:gurney", Some(TEAM_ID)),
]),
);
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1);
assert_eq!(
instance_team_id(dir.path(), 'g').as_deref(),
Some("other-team")
);
}
/// A binding whose team still lists the persona is authoritative — a repair pass
/// leaves it untouched even when that persona also belongs to another team.
#[test]
fn leaves_live_binding_untouched_for_multi_team_persona() {
let dir = tempfile::tempdir().unwrap();
write_teams_json(
dir.path(),
&serde_json::json!([
team(TEAM_ID, &["sietch-tabr:duncan"]),
team("other-team", &["sietch-tabr:duncan"]),
]),
);
write_agents_json(
dir.path(),
&serde_json::json!([
definition("sietch-tabr:duncan", ST, "duncan"),
instance('d', "sietch-tabr:duncan", Some(TEAM_ID)),
]),
);
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 0);
assert_eq!(instance_team_id(dir.path(), 'd').as_deref(), Some(TEAM_ID));
}
/// A store that needs no repair is a clean no-op: `Ok(0)`, no write, no backup.
#[test]
fn clean_store_is_a_no_op() {
let dir = tempfile::tempdir().unwrap();
write_teams_json(
dir.path(),
&serde_json::json!([team(TEAM_ID, &["sietch-tabr:paul"])]),
);
write_agents_json(
dir.path(),
&serde_json::json!([
definition("sietch-tabr:paul", ST, "paul"),
instance('p', "sietch-tabr:paul", Some(TEAM_ID)),
]),
);
let teams_before = std::fs::read_to_string(base(dir.path()).join("teams.json")).unwrap();
let agents_before =
std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap();
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 0);
assert_eq!(
std::fs::read_to_string(base(dir.path()).join("teams.json")).unwrap(),
teams_before
);
assert_eq!(
std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(),
agents_before
);
}
/// The full repair is idempotent: a second boot over the already-repaired store
/// finds nothing to do and does not write.
#[test]
fn second_run_is_a_no_op() {
let dir = tempfile::tempdir().unwrap();
write_teams_json(dir.path(), &serde_json::json!([team(TEAM_ID, &["thufir"])]));
write_agents_json(
dir.path(),
&serde_json::json!([
definition("sietch-tabr:thufir", ST, "thufir"),
instance('t', "sietch-tabr:thufir", None),
]),
);
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 2);
let teams_after = std::fs::read_to_string(base(dir.path()).join("teams.json")).unwrap();
let agents_after =
std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap();
assert_eq!(
repair_team_membership_in_dir(&base(dir.path())).unwrap(),
0,
"second run finds nothing"
);
assert_eq!(
std::fs::read_to_string(base(dir.path()).join("teams.json")).unwrap(),
teams_after
);
assert_eq!(
std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(),
agents_after
);
}
#[test]
fn missing_store_is_a_no_op() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(base(dir.path())).unwrap();
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 0);
}
#[test]
fn unparseable_store_errors_without_writing() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(base(dir.path())).unwrap();
let teams_path = base(dir.path()).join("teams.json");
std::fs::write(&teams_path, "{ not json").unwrap();
write_agents_json(dir.path(), &serde_json::json!([]));
let err = repair_team_membership_in_dir(&base(dir.path())).unwrap_err();
assert!(err.contains("failed to parse"), "unexpected error: {err}");
assert_eq!(
std::fs::read_to_string(&teams_path).unwrap(),
"{ not json",
"a corrupt store is left for manual recovery"
);
}
/// The teams.json backup captures the pre-migration bytes and is written once.
#[test]
fn writes_teams_backup_once() {
let dir = tempfile::tempdir().unwrap();
write_teams_json(dir.path(), &serde_json::json!([team(TEAM_ID, &["thufir"])]));
write_agents_json(
dir.path(),
&serde_json::json!([definition("sietch-tabr:thufir", ST, "thufir")]),
);
let bak = base(dir.path()).join("teams.json.pre-team-membership-repair.bak");
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 1);
let bak_content = std::fs::read_to_string(&bak).unwrap();
assert!(
bak_content.contains("\"thufir\""),
"backup holds the pre-migration stale id"
);
}
/// Both pristine backups are created BEFORE either live store is rewritten, so
/// a crash between the two writes still leaves a full recovery pair (Carl's
/// backup-contract finding). A stale bare slug on the team (drives the teams
/// rewrite) plus an unbound instance (drives the agents backfill) exercises
/// both stores; each backup must hold the pre-migration bytes.
#[test]
fn both_backups_precede_either_live_write() {
let dir = tempfile::tempdir().unwrap();
write_teams_json(dir.path(), &serde_json::json!([team(TEAM_ID, &["thufir"])]));
write_agents_json(
dir.path(),
&serde_json::json!([
definition("sietch-tabr:thufir", ST, "thufir"),
instance('t', "sietch-tabr:thufir", None),
]),
);
let teams_bak = base(dir.path()).join("teams.json.pre-team-membership-repair.bak");
let agents_bak = base(dir.path()).join("managed-agents.json.pre-team-membership-repair.bak");
assert_eq!(repair_team_membership_in_dir(&base(dir.path())).unwrap(), 2);
// teams.json backup holds the stale bare slug (pre-rewrite bytes).
let teams_bak_content = std::fs::read_to_string(&teams_bak).unwrap();
assert!(
teams_bak_content.contains("\"thufir\"")
&& !teams_bak_content.contains("sietch-tabr:thufir"),
"teams backup captures pre-rewrite bytes"
);
// managed-agents.json backup holds the null binding (pre-backfill bytes).
let agents_bak_content = std::fs::read_to_string(&agents_bak).unwrap();
assert!(
agents_bak_content.contains("\"team_id\": null"),
"agents backup captures pre-backfill bytes"
);
}
// ── repair→detach orchestration gate (Carl's finding #2) ──────────────────
use super::orchestrate_repair_then_detach;
use std::cell::Cell;
/// A failed repair must SKIP the directory-backed detach: detach clears
/// `source_dir`, the disambiguating evidence a clean-repair retry needs, so
/// running it after a repair error would let the original membership-loss path
/// recur on the next boot.
#[test]
fn failed_repair_skips_detach() {
let detach_ran = Cell::new(false);
orchestrate_repair_then_detach(
|| Err("repair write failed".to_string()),
|| {
detach_ran.set(true);
Ok(0)
},
);
assert!(
!detach_ran.get(),
"detach must not run when repair failed — source_dir is preserved for retry"
);
}
/// A successful repair runs detach, whether or not the repair changed anything
/// (a clean-store boot with directory-backed teams still needs detaching).
#[test]
fn successful_repair_runs_detach() {
let detach_ran = Cell::new(false);
orchestrate_repair_then_detach(
|| Ok(0),
|| {
detach_ran.set(true);
Ok(1)
},
);
assert!(
detach_ran.get(),
"detach runs after a clean repair even when repair changed nothing"
);
}
/// End-to-end discriminating proof: a failed repair must leave a
/// directory-backed team's `source_dir` intact, because the gate skips the real
/// detach op that would otherwise clear it. The store here is fully valid — so
/// detach WOULD succeed and strip `source_dir` if the gate let it run — which
/// is what makes this catch a gate that runs detach unconditionally.
#[test]
fn failed_repair_preserves_source_dir_against_real_detach() {
let dir = tempfile::tempdir().unwrap();
let base_dir = base(dir.path());
let mut t = team(TEAM_ID, &["sietch-tabr:thufir"]);
t["source_dir"] = serde_json::json!(format!("/packs/{ST}"));
write_teams_json(dir.path(), &serde_json::json!([t]));
write_agents_json(
dir.path(),
&serde_json::json!([definition("sietch-tabr:thufir", ST, "thufir")]),
);
orchestrate_repair_then_detach(
|| Err("repair backup write failed".to_string()),
|| super::super::detach::detach_directory_backed_teams_in_dir(&base_dir),
);
let source_dir = read_teams_json(dir.path())
.into_iter()
.find(|t| t["id"] == TEAM_ID)
.unwrap()["source_dir"]
.clone();
assert_eq!(
source_dir,
serde_json::json!(format!("/packs/{ST}")),
"a failed repair must preserve source_dir — detach never ran to clear it"
);
}
@@ -29,3 +29,17 @@ pub(crate) fn read_personas_json(dir: &Path) -> Vec<serde_json::Value> {
let content = std::fs::read_to_string(dir.join("agents/personas.json")).unwrap();
serde_json::from_str(&content).unwrap()
}
pub(crate) fn write_teams_json(dir: &Path, records: &serde_json::Value) {
std::fs::create_dir_all(dir.join("agents")).unwrap();
std::fs::write(
dir.join("agents/teams.json"),
serde_json::to_vec_pretty(records).unwrap(),
)
.unwrap();
}
pub(crate) fn read_teams_json(dir: &Path) -> Vec<serde_json::Value> {
let content = std::fs::read_to_string(dir.join("agents/teams.json")).unwrap();
serde_json::from_str(&content).unwrap()
}