mirror of
https://github.com/block/buzz.git
synced 2026-11-13 04:41:12 +00:00
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>
265 lines
9.6 KiB
Rust
265 lines
9.6 KiB
Rust
use super::*;
|
|
|
|
/// Helper: write a `teams.json` directly in `base_dir` (the migration reads
|
|
/// `base_dir/teams.json`, where `base_dir` is the `agents` dir).
|
|
fn write_base_teams(base_dir: &Path, records: &serde_json::Value) {
|
|
std::fs::write(
|
|
base_dir.join("teams.json"),
|
|
serde_json::to_string_pretty(records).unwrap(),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
fn one_team() -> serde_json::Value {
|
|
serde_json::json!([{
|
|
"id": "team-alpha",
|
|
"name": "Alpha",
|
|
"description": "The alpha team",
|
|
"persona_ids": ["code-reviewer"],
|
|
"is_builtin": false,
|
|
"created_at": "2025-01-01T00:00:00Z",
|
|
"updated_at": "2025-01-01T00:00:00Z"
|
|
}])
|
|
}
|
|
|
|
#[test]
|
|
fn migrate_teams_writes_signed_retention_rows() {
|
|
use crate::managed_agents::retention::{get_retained_event, open_retention_db};
|
|
use buzz_core_pkg::kind::KIND_TEAM;
|
|
|
|
let base = tempfile::tempdir().unwrap();
|
|
write_base_teams(base.path(), &one_team());
|
|
let keys = nostr::Keys::generate();
|
|
let pubkey = keys.public_key().to_hex();
|
|
|
|
assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 1);
|
|
|
|
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
|
|
let row = get_retained_event(&conn, KIND_TEAM, &pubkey, "team-alpha")
|
|
.unwrap()
|
|
.unwrap();
|
|
let event: nostr::Event = nostr::JsonUtil::from_json(&row.raw_event).unwrap();
|
|
assert!(event.verify().is_ok());
|
|
assert!(row.pending_sync);
|
|
assert!(row.content.contains("Alpha"));
|
|
}
|
|
|
|
#[test]
|
|
fn migrate_teams_skips_builtins() {
|
|
use crate::managed_agents::retention::{get_retained_event, open_retention_db};
|
|
use buzz_core_pkg::kind::KIND_TEAM;
|
|
|
|
let base = tempfile::tempdir().unwrap();
|
|
write_base_teams(
|
|
base.path(),
|
|
&serde_json::json!([{
|
|
"id": "builtin-team",
|
|
"name": "Builtin",
|
|
"persona_ids": [],
|
|
"is_builtin": true,
|
|
"created_at": "2025-01-01T00:00:00Z",
|
|
"updated_at": "2025-01-01T00:00:00Z"
|
|
}]),
|
|
);
|
|
let keys = nostr::Keys::generate();
|
|
|
|
assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 0);
|
|
|
|
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
|
|
assert!(get_retained_event(
|
|
&conn,
|
|
KIND_TEAM,
|
|
&keys.public_key().to_hex(),
|
|
"builtin-team"
|
|
)
|
|
.unwrap()
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn migrate_teams_unchanged_second_run_is_noop() {
|
|
let base = tempfile::tempdir().unwrap();
|
|
write_base_teams(base.path(), &one_team());
|
|
let keys = nostr::Keys::generate();
|
|
|
|
assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 1);
|
|
assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn migrate_teams_edited_team_re_retains_pending() {
|
|
use crate::managed_agents::retention::{get_retained_event, mark_synced, open_retention_db};
|
|
use buzz_core_pkg::kind::KIND_TEAM;
|
|
|
|
let base = tempfile::tempdir().unwrap();
|
|
write_base_teams(base.path(), &one_team());
|
|
let keys = nostr::Keys::generate();
|
|
let pubkey = keys.public_key().to_hex();
|
|
|
|
assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 1);
|
|
|
|
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
|
|
let row = get_retained_event(&conn, KIND_TEAM, &pubkey, "team-alpha")
|
|
.unwrap()
|
|
.unwrap();
|
|
mark_synced(
|
|
&conn,
|
|
KIND_TEAM,
|
|
&pubkey,
|
|
"team-alpha",
|
|
row.created_at,
|
|
&row.content,
|
|
)
|
|
.unwrap();
|
|
drop(conn);
|
|
|
|
let mut edited = one_team();
|
|
edited.as_array_mut().unwrap()[0]["description"] = serde_json::json!("Renamed team");
|
|
write_base_teams(base.path(), &edited);
|
|
|
|
assert_eq!(migrate_teams_in_dir(base.path(), &keys).unwrap(), 1);
|
|
|
|
let conn = open_retention_db(&base.path().join("retention.db")).unwrap();
|
|
let row = get_retained_event(&conn, KIND_TEAM, &pubkey, "team-alpha")
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(row.pending_sync);
|
|
assert!(row.content.contains("Renamed team"));
|
|
}
|
|
|
|
#[test]
|
|
fn migrate_teams_no_file_is_noop() {
|
|
let base = tempfile::tempdir().unwrap();
|
|
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\""));
|
|
}
|