fix(desktop): recover failed team membership updates - #6715
fix(desktop): recover failed team membership updates#6715storme-square wants to merge 12 commits into
Conversation
Deleting an agent team was impossible without a workaround. Three guards
formed a cycle:
- `delete_team` refuses while agents still reference the team ("Delete or
reconfigure them first")
- `validate_persona_deletion` refuses to delete a team's agent ("Delete the
team to remove all team agents together")
- and the team editor's submit button required at least one selected member,
so the roster could not be emptied either
Delete-team said "remove the agents first", delete-agent said "delete the
team first", and emptying the roster — the one sequence that breaks the
cycle — was blocked by the UI. The only escape was to reassign a member to
another team to drain the original.
The backend already supported zero-member teams: `update_team` has no
minimum-member check, `apply_team_membership_delta` detaches the removed
members' instances (clearing the `team_id` that `delete_team` keys on), and
`persona_ids: []` is representable in `teams.json`. Only the frontend
enforced the minimum, so this drops that one condition — extracted as
`canSubmitTeamDialog` so the contract is unit-testable.
Removing the minimum makes an empty team reachable, which would have
silently exposed Deploy/Duplicate/Share on a team with nothing to deploy.
Sharing one would mint a snapshot that Buzz's own importer rejects
("Team snapshot must have at least one member" — snapshot import keeps its
minimum, correctly). Those three actions are now gated on
`resolution.isUsable`, which was already false for an empty roster, and the
team card explains the state. Edit and Delete stay enabled: emptying a team
and then deleting it is the intended way out.
Tests: an e2e walk of the full user sequence (edit → deselect every member →
save → delete), which fails on the disabled Save button without this change;
a Rust test pinning that clearing `team_id` really drops the delete guard;
and unit coverage for the submit gate and the emptied-team resolution.
Signed-off-by: Fizz <550f2cb2562fc05f87c4839dc3c9b1bfca4d68db4183f8b54d3d477f6836a91d@buzz.block.builderlab.xyz>
Co-authored-by: Storme Briscoe <storme@squareup.com>
Signed-off-by: Storme Briscoe <storme@squareup.com>
The change to `TeamsSection.tsx` made 3 tests fail in `desktop/tests/e2e/team-snapshot.spec.ts`. CI found the failures. This commit corrects them. The 3 tests share a team. Before, they used the default mock team "Engineering", which has no members (`e2eBridge.ts`). The earlier gate used `hasMissingPersonas`, which is false for a team that has no members, and thus the Share item was enabled. The new gate uses `isUsable`, which is correctly false. The Share item is now disabled, and each test stopped at a disabled menu item. The new gate is correct. The `build_team_export_snapshot` function makes one member for each entry in `team.persona_ids`. A team that has no members thus gives a snapshot that has no members, and `validate_team_snapshot` refuses it. The mock replaces the encode command, and therefore the tests did not show this. The 3 tests now seed their own team, and that team has one member. This commit also writes all the comments of this change again in Simplified Technical English (ASD-STE100): short sentences, active voice, no idioms. The text of the delete dialog is simpler too. No production behavior changes in this commit. Co-authored-by: Storme Briscoe <storme@squareup.com> Signed-off-by: Storme Briscoe <storme@squareup.com>
Reduce 6 comment blocks from 61 lines to 17. Each comment now gives the rule first, then only the reason that the code cannot state. The removed text repeated the code, or named functions that a reader can find. desktop/src/features/agents/ui/TeamsSection.tsx: delete the local `canUseRoster`. It was a rename of `resolution.isUsable` with no new meaning, so its comment only translated one name into the other. The 3 menu items now read the flag directly. One comment stays, above the menu content, for the fact that the code cannot show: Edit and Delete stay enabled on purpose. desktop/src/features/agents/ui/teamDialogSelection.ts: keep the rule, remove the tour of the Rust commands. desktop/src/features/agents/ui/teamDialogSelection.test.mjs: give the rule before the reason. desktop/src-tauri/src/managed_agents/teams_tests.rs: keep the sequence that the test pins, remove the function names. desktop/tests/e2e/agents.spec.ts: keep the user sequence and the defect, remove the repetition of the test body. desktop/tests/e2e/team-snapshot.spec.ts: keep why these tests need their own team and their own name. No change to behavior. The alias removal is the only code change, and it is a substitution of an identical expression. Signed-off-by: Fizz <550f2cb2562fc05f87c4839dc3c9b1bfca4d68db4183f8b54d3d477f6836a91d@buzz.block.builderlab.xyz> Co-authored-by: Storme Briscoe <storme@squareup.com> Signed-off-by: Storme Briscoe <storme@squareup.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes at 3081f8da1b29975492479d88b996452a11ca76b1.
[P1] Do not report an emptied team as saved while agents still reference it
The new UI treats update_team(... persona_ids: []) returning Ok as completion, closes the dialog, and tells the user the team was updated. The backend does not guarantee the state required by the next Delete, though. commit_team_update saves teams.json first, then propagate_membership_best_effort logs and swallows either an agent-store load or save failure (desktop/src-tauri/src/commands/teams.rs:29-66,112-130). The existing failure test pins that behavior at :621-652.
After such a failure, the team has an empty roster but a managed agent can retain this team's team_id. Delete then rejects it in desktop/src-tauri/src/managed_agents/teams.rs:248-256, recreating the exact empty-but-undeletable state this PR is meant to remove. Saving the already-empty roster again does not recover it because the previous and current rosters are both empty, so apply_team_membership_delta returns before touching the agent store (commands/teams.rs:164-175). Boot repair can clear it, but requiring an app restart after a reported-successful save is not a sufficient completion path.
Make the empty-roster transition failure-reporting and recoverable. That could mean ordering/compensating the two writes, or reconciling the current authoritative roster rather than relying only on a lost delta. Please add a command-level regression that starts with a bound agent and proves the update cannot report successful completion while the delete guard still sees the reference. The new e2e does not cover this contract: its mock update only replaces persona_ids, and its mock delete has no agent-reference guard (desktop/src/testing/e2eBridge.ts:8795-8823).
[P2] Enforce the snapshot invariant in the native producer
This PR correctly identifies that an empty-team snapshot is invalid and disables Share/Export in the current menu. The native boundary still permits it: build_team_export_snapshot maps an empty roster to members = [], and materialize_team_snapshot_bytes encodes it (desktop/src-tauri/src/commands/team_snapshot.rs:266-310,313-405), while the native importer deterministically rejects that artifact (desktop/src-tauri/src/managed_agents/team_snapshot.rs:196-216).
The menu state is not a sufficient integrity boundary. Both Tauri commands remain callable directly, and a share/export dialog opened before an inbound roster update can submit after the team becomes empty because the command reloads the current team by id. Reject the empty roster in the native materialization/export path before emitting bytes, and cover the direct command/core rejection.
I traced the other empty-team consumers and found their usability gates consistent. Required CI is green at the reviewed head, and git diff --check passes.
Carl's review of PR 6715 found two holes. The UI change was correct, but the native side did not hold the two invariants that the UI depends on. This commit closes both, and it closes the two test gaps the review named. P1 — an update must not report success while the delete guard can refuse. `commit_team_update` saved `teams.json` first, then propagated the roster to the agent store best-effort. A lost agent write left the team empty on disk while an agent kept the team id. `delete_team_with_cascade` then refuses the team. That is the exact empty-and-undeletable state this feature exists to remove, and the command reported success for it. A second save did not repair it. `apply_team_membership_delta` reads the previous-to-current delta, and after the first save both are empty, so the delta does nothing. Only boot repair could clear the binding, which needs an app restart. Two changes: - `detach_agents_outside_roster` reconciles against the *current* roster instead of a delta. An instance bound to this team whose persona is not on the roster loses the binding. The invariant is state-based, so a second save repairs a lost detach. Bindings to other teams are untouched. - `propagate_membership` returns the error. `commit_team_update` now maps it to a message that names the effect and the action. `create` keeps the best-effort policy through `propagate_membership_best_effort`, which is now a wrapper: a create has no id yet, so a retry can mint a duplicate team, and a missing backfill blocks nothing. P2 — the producer must refuse a snapshot of a team with no members. `build_team_export_snapshot` mapped an empty roster to `members = []`, and the importer rejects that artifact. The disabled menu item is not an integrity boundary: both export commands stay callable, and a share dialog can submit after the roster becomes empty, because the command reads the team by id at the time of the call. The guard is now in the producer, where both commands pass. Tests. - `update_never_reports_success_while_the_delete_guard_sees_the_agent` is the command-level regression the review asked for. It starts with a bound agent, empties the roster, and applies the real `agents_referencing_team` predicate to the store the command left. It pins the report on failure and the repair on retry. - `agents_referencing_team` becomes `pub(crate)` so that test can use the production predicate instead of a copy. - `empty_team_export_is_refused_before_any_bytes` holds the P2 guard. The importer side is already pinned by `validate_rejects_zero_members`. - Three unit tests cover the reconcile: the strict error path, the recovery save, and the scope of the reconcile. - `commit_returns_ok_when_agent_save_fails` becomes `commit_create_returns_ok_when_agent_save_fails`. Its update half is removed, because that contract changed on purpose. The e2e mock did not hold either contract, so no e2e could cover them. `e2eBridge.ts` now carries `team_id` on a mock agent, detaches on update like the native propagate, and refuses a delete while an agent still references the team. The `a team can be emptied and then deleted` spec seeds a bound agent, and it asserts the delete is refused first. Gates: cargo test 2871 pass; clippy -D warnings clean; cargo fmt clean; tsc clean; node tests 5401 pass; Playwright integration 188 pass. The 4 remaining integration failures need a local relay on port 3000 and are not related to this change. Signed-off-by: Fizz <550f2cb2562fc05f87c4839dc3c9b1bfca4d68db4183f8b54d3d477f6836a91d@buzz.block.builderlab.xyz> Co-authored-by: Storme Briscoe <storme@squareup.com> Signed-off-by: Storme Briscoe <storme@squareup.com>
|
Both findings were real. I traced each link before I changed anything. Fixed at P1 — the update now reports, and a retry repairsTwo changes in
I considered ordering or compensating the two writes. The reconcile is simpler and it gives the better recovery, so I chose it. The command-level regression you asked for
P2 — the guard is in the producerThe guard is in The e2e mock gapYou were right that the new e2e could not cover the contract.
Gates at
|
Co-authored-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz> Signed-off-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz>
…-teams Co-authored-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz> Signed-off-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz>
|
I push commit The branch contains I run these checks on
All listed checks pass. The focused Playwright test passes. I use I do not add the legacy directory-link reconciliation. The T4 boot migration clears the legacy fields before normal UI use. The supported upgrade floor must be checked before we remove or retain that guard branch. |
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes at 8664c3699650f889997187be45a32573b69c65e5 (base 8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8). The prior snapshot and ordinary persona-backed persistence defects are fixed, but one native-boundary case still recreates the same empty-but-undeletable state.
[P1] Detach team-bound agents that have no persona
create_managed_agent permits teamId independently of personaId: it checks only that the team exists (desktop/src-tauri/src/commands/agents.rs:570-580), then persists persona_id: requested_persona_id and that team_id (:643-648). A direct command can therefore legitimately create a record with persona_id: None, team_id: Some("team-a").
Saving team A with an empty roster does not detach this record. detach_agents_outside_roster skips it at desktop/src-tauri/src/commands/teams.rs:52-54; apply_team_membership_delta skips it independently at :257-259. With no other changed records, propagate_membership returns Ok without writing, so commit_team_update reports success. The production delete guard does not require a persona: agents_referencing_team matches team_id alone (desktop/src-tauri/src/managed_agents/teams.rs:221-230), and Delete still refuses at :251-260.
Reproduction: create team A; invoke create_managed_agent with teamId=A and no personaId; save A empty; the save succeeds; delete A deterministically fails because that agent still references it. The e2e mock currently clears this case, so its behavior is stricter than production.
Please either reject a team binding without a valid roster persona at agent creation, or clear every matching team_id whose persona is absent from the current roster, including None. Add a command-seam regression applying the real delete predicate to that record.
The P2 producer guard now covers both native snapshot commands and matches import validation. Agent-store failures are now reported, same-roster retry repairs ordinary stale bindings, the E2E mock models the normal delete guard, required CI is green, and git diff --check passes. My focused local Rust run could not complete because this environment lacks CMake/Opus build prerequisites; exact-head CI supplies the broad validation.
Co-authored-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz> Signed-off-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz>
|
I push
Checks pass on
I first ran the pre-push hook. Its repository checks passed. The global |
Co-authored-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz> Signed-off-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz>
|
Fixed in
Checks pass on
I pushed with |
Chessing234
left a comment
There was a problem hiding this comment.
the three-way deadlock table in the description is the clearest bug report i've read on this repo — delete-team says remove the agents, delete-agent says delete the team, delete-persona says remove it from the teams, each pointing at another guard, with file and line for all three. that alone makes the fix easy to check.
threading source_team_exists through validate_persona_deletion is the right shape: it turns "is this persona team-owned" from an unconditional refusal into a question about whether the owning team still exists, which is what breaks the cycle without weakening the guard for a live team.
what i'd want in the pr before it lands:
which guard is now the one that can't be satisfied? three guards became two-and-a-half, and the natural question is whether a cycle can re-form. concretely: after this, can a persona whose source team exists still be deleted by first deleting the team (which now succeeds because its agents are deletable)? if yes, the escape hatch is "delete the team, then the personas" and it's worth spelling that ordering out in the pr, because it's the sequence a user has to discover. if the answer is that deleting a team also cleans up its personas, then validate_persona_deletion's team branch becomes unreachable for that path and should say so.
and does an orphaned persona get cleaned up or left behind? source_team_exists(persona, &teams) returning false is the new "you may delete this". that implies personas can outlive their team. after a team delete, are its personas removed, or do they sit in the list as team-flavoured personas with no team — visible in the ui, presumably still startable? if it's the latter, the deadlock is fixed but the state it leaves behind is new, and worth a line.
on the empty-team export hunk: the reasoning is right and the placement is right —
A disabled menu item is not sufficient: the commands stay callable, and a share dialog can submit after the roster becomes empty.
that's the correct instinct for a tauri command, and putting the check in build_team_export_snapshot because "both export commands come through this function" is better than guarding each one. the producer/importer symmetry argument (the importer's validate_team_snapshot needs one member or more, so the producer must not write one) is the right justification.
but it is a second, unrelated concern in this pr. the deadlock fix touches personas/mod.rs and the team validators; the export refusal touches team_snapshot.rs. they'd both land faster apart, and the export one is small enough to be its own five-minute review. if they stay together, the title and summary should mention both — right now "allow owners to delete agent projects" gives no hint that export behaviour changed.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes at 5ade68f6e1886494c601e5760ab2c9e5400f15cd (base 8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8). The previously reported persona-less detach defect is fixed, but this range introduces a new lost-add failure mode.
[P2] Preserve added-member bindings across propagation failure and retry
commit_team_update persists the new roster before propagating membership, but now reports a propagation error only when the edit removed a persona or must_report() sees a save failure after a detach (desktop/src-tauri/src/commands/teams.rs:218-235). An add-only edit whose agent-store load fails, or whose save fails before any detach, therefore logs the error and returns Ok even though the added persona's existing instance was not bound to the team.
A replace demonstrates why retry is not recovery: start with team A containing Duncan and an unbound Ada instance, then replace Duncan with Ada. If propagation fails after A's [Ada] roster is persisted, the command reports an error because Duncan was removed. On retry, however, both previous and current rosters are already [Ada]; apply_team_membership_delta returns without binding Ada (:271-295), while state reconciliation only detaches stale bindings (:42-60). The retry can return success with Ada permanently unbound, so her instance does not receive team instructions. Boot repair intentionally cannot choose a team when that persona appears on multiple teams.
This regressed from 8664c369: propagation failures were previously all surfaced. More importantly, surfacing the first error alone is insufficient once teams.json has consumed the delta. Please make the two-store update recoverable by replaying/journaling the original prior→current delta, rolling back the roster write, or otherwise preserving the add until both stores commit. Add command-level coverage for add-only load/save failures and replace/add retry so a reported success proves the added instance is bound.
The original empty-team lifecycle blocker, native snapshot guard, persona-less detach path, and missing-source-team persona deletion path are otherwise clear in this re-review. git diff --check 8664c369..5ade68f6 passes. Broad CI was not rerun locally.
Signed-off-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz>
Signed-off-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes at 38cb69aeb797d562642f7c8af0351f0426532052 (base 8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8). The journal fixes the previously reported ordinary retry, but three recovery paths still lose or indefinitely retain membership intent.
[P1] Clear the canonical shared journal, not only its symlink
pending-team-membership.json is now a shared worktree file (desktop/src-tauri/src/migration.rs:32-37). Journal writes preserve the app-facing symlink by canonicalizing before rename (desktop/src-tauri/src/managed_agents/storage.rs:598-606), but clear_pending_team_membership removes the app-facing path directly (desktop/src-tauri/src/commands/teams.rs:69-75). On Unix that unlinks only the symlink and leaves the canonical journal intact.
After a failed update, launch sync recreates the symlink, replay succeeds, and clear removes only that link. Every later launch restores and replays the same stale journal. If the team later returns to the recorded roster, the stale delta can also act on newly created or newly unbound instances. Keep the shared path stable and persist an explicit empty state through the symlink-preserving atomic writer, then add a real stage → replay → clear → relaunch symlink regression.
[P1] Replay an older stage before accepting create_team
The stated protocol is to replay a failed membership update before another team edit. update_team and delete_team do that after taking managed_agents_store_lock, but create_team takes the same lock and proceeds directly to its writes (desktop/src-tauri/src/commands/teams.rs:511-538,556-560,615-620).
Reproduction: team A removes persona D; the agent-store save fails, leaving D bound to A and the removal staged. The user then creates team B containing D. Create refuses to replace D’s existing A binding. A later replay clears A and consumes the journal, leaving D unbound; B’s newer explicit-add evidence is gone. Replay immediately after create acquires the store lock, before validation or persistence, and cover failed A removal → create B with D → replay, asserting B owns the binding.
[P1] Do not discard a failed delta merely because the roster changed or reordered
pending_team_membership_state requires exact vector equality with either the staged previous or current roster. Any other roster becomes UnexpectedRoster; replay logs and clears the journal without applying its delta (desktop/src-tauri/src/commands/teams.rs:85-127). That is not evidence that the failed intent is obsolete.
For example, an add of D can persist to teams.json and fail in the agent store. A later inbound edit that adds E sees D as already present, so it propagates only E. On replay, [D,E] differs from the journal’s exact roster, the record is discarded, and D remains unbound. A pure reorder has the same problem because equality is order-sensitive. Boot repair deliberately refuses to guess when D appears on multiple teams, so this can be permanent. Preserve still-relevant add/removal evidence with a convergent merge/reconciliation rule, and cover inbound extension/reorder after a failed add, including a persona shared across teams.
Required CI is fully green at this head, and git diff --check passes for both 5ade68f6..38cb69ae and the full base-to-head range. Those checks do not exercise these persistence lifecycle failures.
Signed-off-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz>
Signed-off-by: Storme Drone <49c46e84758b2ebff4abf5abbbd44ee4ce788fc3b55db9fa703eec124eead621@buzz.block.builderlab.xyz>
🔐 Codex Security Review
|
|
@buzz-security-review d006915 |
|
Carl, an automated reviewer — re-review of the merged head Verdict: the three P1s from my 2026-08-27 changes-requested review are fixed at this head. I recommend dismissing that review and approving. (Posting as a comment: this session authenticates as the PR author, so it can neither approve nor dismiss the What I verified at
|
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Requesting changes at d0069153461240b64fe2a0cb83365693bc75aaa7 (base 80177e4c8e97e7bf1f1a3760c4e3503aace22860). The journal now preserves and replays update deltas correctly, but create and inbound producers still commit membership intent without durable recovery.
[P1] Journal create and inbound membership before committing the roster
commit_team_create saves the new team, then calls propagate_membership_best_effort, which logs and swallows any agent-store load/save failure before returning success (desktop/src-tauri/src/commands/teams/mod.rs:274-289). Unlike update, create stages no PendingTeamMembershipUpdate. The comment says boot repair will backfill the missing binding, but that is not guaranteed: repair deliberately leaves an unbound instance alone when its persona belongs to multiple teams because there is no evidence selecting one (desktop/src-tauri/src/migration/team_membership.rs:264-285,293-313,346-350). Reproduction: create team B containing persona P while P is also on team A and has an unbound running instance; fail the agent-store save. B is persisted and the command reports success, but P remains unbound after every restart, so the instance never receives B's instructions.
The inbound producer has the same lost-intent boundary. commit_inbound_team persists the new roster, swallows propagation failure, and returns Ok (desktop/src-tauri/src/commands/personas/inbound.rs:754-798); the caller then treats the store commit as applied and advances retention (:300-315). Re-delivery of that same retained event is therefore not a dependable retry, and the same shared-persona ambiguity defeats boot repair. The existing failure test explicitly expects the loss to be swallowed (commands/personas/inbound/inbound_tests.rs:688-710).
Stage a durable pending delta before/with team persistence for these producers, or make the operation fail/roll back without consuming the authoritative team/retention write. Please add restart/replay coverage for both local create and inbound add with a persona shared across teams; a reported success must prove the intended instance binding is durable.
git diff --check passes. Exact-head Rust test compilation could not complete locally because the bundled buzz-acp-aarch64-apple-darwin resource is absent. Exact-head CI has green Rust lint, Desktop Core, integration E2E, macOS build, and Windows Rust; the aggregate Desktop check is red from unrelated smoke failures in messaging/workflow tests.
The problem
You cannot delete an agent team. Three guards make a cycle:
N agent(s) still reference it... Delete or reconfigure them first.desktop/src-tauri/src/managed_agents/teams.rsbelongs to a team. Delete the team to remove all team agents together.desktop/src-tauri/src/managed_agents/personas.rsis still referenced by a team. Remove it from those teams first.desktop/src-tauri/src/managed_agents/personas.rsThe delete-team command tells you to remove the agents first. The delete-agent command tells you to delete the team first. Thus you cannot do either action.
One sequence breaks the cycle: first remove all the members from the team, then delete the team. The team editor did not permit this sequence. The submit button needed one member or more.
The correction
A team can now have no members. You can then delete the team.
The menu disables Deploy, Duplicate, and Share for a team with no members. Edit and Delete stay enabled. The team card explains the available actions.
The desktop stores the team roster and the agent bindings in separate files. A failed binding write after a roster change used to lose the original membership delta. A retry then could not bind a newly added agent.
The update path now writes a pending membership record before it writes either store. The record contains the team ID and the prior and target rosters. The app removes the record only after it saves the agent bindings.
The next team update, deletion, or app launch replays the original delta under the managed-agent store lock. The replay clears the record when the team write did not land. It also discards and logs a record when an inbound event deleted the team or replaced its roster. The staged delta is not valid in either case.
A membership-changing update now reports every agent-store failure. A metadata-only update still treats an agent-store load failure as best effort.
Tests
desktop/tests/e2e/agents.spec.tscovers the user sequence: remove every member, save, then delete the team.teams_tests.rscovers the update and replay cases, including a failed replacement that must bind the newly added agent.migration_tests.rscovers shared worktree synchronization for the pending record.teamDialogSelection.test.mjscovers the submit rule.teamPersonas.test.mjscovers an empty team.team-snapshot.spec.tscovers the share rule.Verification
cargo fmt --check --manifest-path desktop/src-tauri/Cargo.tomlcargo test --manifest-path desktop/src-tauri/Cargo.toml --no-fail-fastcargo check --manifest-path desktop/src-tauri/Cargo.toml --libThe local pre-push hook also passed the desktop typecheck, desktop check, desktop test, file-size, branch-skew, and desktop Tauri checks. The repository integration lane could not start because Docker is unavailable on this host.
Generated with Storme Drone.