feat(desktop): scope managed agents per community - #4
Merged
Merged
Conversation
yjc801
force-pushed
the
claude/velvet-buzz-context-660d0f
branch
from
August 9, 2026 05:23
834194b to
89d0a08
Compare
Managed agents live in one global store shared by every community, so every picker offered every record — including several identically-named identities provisioned separately per community (three "Bumble"s, distinguishable only by npub). Only one has a harness in the community being viewed; mentioning either of the others is silently dead, because nothing fails: the p-tag is delivered to a relay where that pubkey was never started. Model (matches Slack/GitHub/Discord: global definition, per-tenant installed identity): a persona stays global; a managed agent instance now belongs to a community. New `community_relay_url` on the record (canonical relay URL; null = unscoped/shared, offered everywhere). Display and name-uniqueness scope ONLY — spawn resolution (`effective_agent_relay_url`, agents-everywhere block#2122) is untouched, so the change is rollback-safe. - Field: `#[serde(default)]`, deliberately NO `skip_serializing_if` — the boot backfill is idempotent by key presence, so an explicit unscope must persist a literal null or the next boot would re-bind it from the legacy pin. - Boot backfill (migration/community_scope.rs): derives the binding from the legacy creation-era `relay_url` pin when present; blank pin carries no evidence and stays unscoped. Verified byte-stable on a second run against a copy of a live store. - Creation stamps the active community at all three minting sites and enforces per-(community, name) uniqueness inside the store lock (case-insensitive, instances only, unscoped collides everywhere; create-time only — pre-existing duplicates keep working). The create dialog preflights the same rule client-side so a collision rejects inline instead of surfacing after the persona exists. - Pickers scope through `managedAgentBelongsToCommunity` (directory presence kept as an override: an agent registered here is mentionable here regardless of its binding): mentions, new-message recipients, members sidebar, projects prompt, Pulse, and the Agents page — which partitions into in-community + a collapsed "From other communities" group. Resolution of already-present pubkeys (history, membership, runtime, search authors, tray) stays deliberately unfiltered. - New `set_managed_agent_community` command + `AgentCommunityScopeBadge` (badge + menu on agent cards/rows): assign to the active community, move, or share across all communities. File-size ratchet extractions along the way: migration/json_patch.rs, managed_agents/{community_scope,record_views}.rs, commands/agent_create_support.rs, shared/api/{managedAgent, managedAgentRaw}.ts, messages/lib/managedAgentMentionMaps.ts, and two exhaustive Rust test fixtures converted to the JSON-fixture convention. Verified: cargo test 2283 passed; desktop tests 4562 passed; tsc, clippy -D warnings, cargo fmt, pnpm check, git diff --check all clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Junchao Yan <yjc801@gmail.com>
Addresses review findings 1, 2 and 4. Finding 1 — backfill scope before typed store rewrites. `community_relay_url` is `#[serde(default)]` with no `skip_serializing_if`, and the persona fold deserializes and re-serializes the whole managed-agent store, so it turns "key absent" into an explicit `null`. Running the backfill after the fold therefore stranded every pre-existing instance on any upgrade that still had personas.json: the key-presence idempotency guard read that null as a deliberate unscope and never derived the binding from the non-empty legacy pin. The backfill now runs before the first typed rewrite; the steps ahead of it are raw JSON patches, so key absence still survives to that point, and records added later serialize their own explicit null, which is the correct unscoped binding for a definition. Corrects the fold's doc comment, which claimed instance records pass through byte-identical. Finding 2 — restrict automatic reuse to the active community. The reuse selectors filtered only on persona/command and channel membership, so provisioning a persona in community B attached and started the instance minted for A instead of minting B's own; the preset path matched on a deliberately generic runtime-id name with the same gap. Reuse is now gated on `managedAgentIsReusableInCommunity`, threaded as a required argument through every selector so no caller can omit it. That rule is deliberately stricter than the picker's visibility predicate: directory presence proves an identity runs here, which is the right signal for showing a row and the wrong one for adopting a record. Unscoped instances stay shared; an unresolved community fails closed, because minting a duplicate is recoverable and adopting another community's identity is the defect. An agent already in the channel is an established binding rather than a fresh adoption, so it stays unscoped. Finding 4 — use community scope for Welcome agent selection. Both selectors compared the legacy `relayUrl` creation pin, which a move deliberately leaves untouched, so a moved Welcome agent was invisible in the community it now belongs to — its replacement create then hit the new scoped name collision rule — and remained selectable in the one it left. Both now route through `managedAgentBelongsToCommunity`. The existing relay-scoped tests expressed the right intent through the wrong field and were moved onto `communityRelayUrl`. Signed-off-by: Junchao Yan <yjc801@gmail.com>
Addresses review finding 3. Both import paths stamped the active community scope onto minted instances but checked only for duplicate pubkeys under the store lock, so importing a snapshot named Bumble into a community that already offers Bumble persisted a second same-name scoped instance — recreating exactly the ambiguous picker entry this scoping exists to remove. Persona snapshot import now resolves its scope through `mint_scope_and_check_name`, the same helper `create_managed_agent` uses, so the check and the write share one critical section. Team snapshot import checks every member inside the lock, accumulating the members as it goes so they are validated against one another as well as against the existing store: one snapshot can carry two members that collide with each other while neither collides with anything already stored. Signed-off-by: Junchao Yan <yjc801@gmail.com>
Addresses round-2 review findings 1, 2 and 3.
1. Thread the active community through batch provisioning.
`createChannelManagedAgents` built its reuse context without
`activeCommunityRelayUrl`, so every batch caller — add-team/add-bot dialogs,
quick bot drop, mention-send, template application — handed the new reuse
predicate `undefined`. A record bound to the CURRENT community then failed
closed, was skipped, and provisioning fell through to `createManagedAgent`,
where the new scoped name-uniqueness gate rejected the replacement: ordinary
same-community reuse broken in the name of blocking cross-community adoption.
The scope is now a required argument rather than an optional context field.
`ChannelAgentCommunityContext` (`{ activeCommunityRelayUrl: string | null }`)
is required by `createChannelManagedAgents` and
`ensureChannelAgentPresetInChannel`; `ChannelAgentProvisionContext` extends it
with the batch-shared `managedAgents` / `channelMemberPubkeys` and is required
by `provisionChannelManagedAgent` and `createChannelManagedAgent`. A new call
site cannot omit the scope without a type error, and `null` stays the explicit
"unresolved community" value the reuse predicate fails closed on. Making the
reuse inputs non-optional also retires the two `context?.managedAgents &&
context.channelMemberPubkeys` guards, which no caller could trip.
Production callers updated: `useCreateChannelManagedAgentMutation`,
`useCreateChannelManagedAgentsMutation`, and `useApplyTemplate` now read
`useActiveCommunityRelayUrl()` and pass it down.
2. Restore the snapshot importer below the repository size ratchet.
The uniqueness fix pushed `snapshot/import.rs` to 1,010 lines against a 1,000
ceiling, failing `check:file-sizes`. Its two inline `#[cfg(test)]` modules move
verbatim to a sibling `snapshot/import_tests.rs`, matching the module's
existing `snapshot/tests_*.rs` convention; only the two `use` paths changed
(`super::` -> `super::super::`). import.rs is now 874 lines.
3. Run rustfmt on the team import change.
`use crate::managed_agents::{...}` in `commands/team_snapshot.rs` had
`AgentDefinition` split onto its own line; the three type names fit one line.
Signed-off-by: Junchao Yan <yjc801@gmail.com>
Addresses round-3 review findings 1 and 2. 1. Preserve the security inventories when moving snapshot tests. Splitting BOTH inline test modules out of `snapshot/import.rs` was not the pure move I claimed: `egress_guard_tests.rs` scans source files and pins two inventories by path. `EVENTS_INVENTORY` attributes 2 egress-URL sites to import.rs (boundary 7 plus its in-file injection fixture), and the key-backup source allowlist lists import.rs by name. Relocating the fixture dropped import.rs to 1 site and put the fixture in a file on neither list, so `events_url_inventory_is_fully_guarded` and `ncryptsec_handling_is_confined_to_allowlisted_files` both failed. `egress_guard_tests` moves back inline, verbatim. The avatar tests alone carry the line-count relief (import.rs is 907 lines against the 1,000 ceiling), so nothing is gained by relocating the fixture — and the alternative, widening two security inventories to buy line count, is the wrong trade. `egress_guard_tests.rs` already documents this module as boundary 7's injection test by path, and that statement is true again. The sibling file's own header records why only the avatar tests live there, and it names no scanner needle of its own. 2. Apply rustfmt's actual wrapping. `save_teams` moves to the following line in the `crate::managed_agents` use group, per the reviewer's `cargo fmt --check` output. My previous rewrap was computed by hand rather than observed; the width arithmetic put that line at exactly 100 columns, and rustfmt does not accept it there. Signed-off-by: Junchao Yan <yjc801@gmail.com>
Rebasing onto main put `hooks.ts` at 994 lines before this branch touches it, so the four `useActiveCommunityRelayUrl()` reads that thread community scope through the batch provisioning hooks pushed it to 1006 — over the 1000-line ceiling `check:file-sizes` enforces. Moves the five channel-scoped provisioning mutations (attach, ensure preset, create one, provision, create batch) to a sibling `channelAgentMutations.ts`. That is exactly the cluster this branch grew, and it is the only user of `invalidateAgentQueriesInBackground` and `isCachedDmChannel`, which are now exported from `hooks.ts` rather than moved: the new module imports one-directionally, matching how `teamHooks.ts` sits under `hooks.ts` with no cycle. Consumers import the five hooks from the new path directly instead of re-exporting through `hooks.ts`, which would have made the dependency circular. `hooks.ts` is 781 lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Junchao Yan <yjc801@gmail.com>
yjc801
force-pushed
the
claude/velvet-buzz-context-660d0f
branch
from
August 9, 2026 05:46
89d0a08 to
d935214
Compare
yjc801
added a commit
that referenced
this pull request
Aug 10, 2026
…entity (#14) * feat(desktop): move an agent between local and remote, keeping its identity Where an agent runs is currently fixed at creation. The only way to change it is delete-and-recreate, which permanently destroys the keypair, channel grants, git ACL, auth tag and NIP-AE engrams — Buzz has no key import. This adds the backend half of a real migration: pubkey and everything keyed to it survive the move. `RunOnSummarySection.tsx:9` justifies the current immutability with "a provider-backed agent's deployment holds its private key; there is no migrate operation". That does not hold up — `record.private_key_nsec` lives in the desktop store and the sprites backend already COPIES it to the sprite on every provider deploy (`env.rs:266`). Migration copies a key to a provider exactly as creating a remote agent does, and the desktop keeps its copy, which is why the reverse direction is symmetric and nearly free. The invariant this protects is one identity, one live harness. Two harnesses signing as one pubkey means doubled replies, flapping presence, and concurrent engram writes against the same (agent, owner) pair. The two directions are not symmetric, and that shapes the design. Liveness is only observable for local agents: `sync_managed_agent_processes` reconciles real processes, so a surviving pid is authoritative. Provider agents report `deployed`/`not_deployed` from `backend_agent_id`, which is INFRASTRUCTURE EXISTENCE, not liveness — runtime.rs's own comment notes a sprite stays "deployed" after !shutdown — and relay presence, the real signal, is polled by the frontend and never reaches this process. `stop_managed_agent` also rejects non-local backends outright, so no backend call could establish stoppedness. Rather than check something it cannot see, the command takes an explicit `remote_confirmed_stopped` assertion from the caller and documents why. Honest beats reassuring for a guard whose failure mode is a duplicate agent. Guards live in a new `backend_migration` module as a pure function so they are testable without an AppHandle, mirroring how PR #4 extracted `community_scope`. Preconditions are a struct rather than three positional bools — that argument list is exactly how a guard silently inverts. Two deliberate choices worth not undoing: - `backend_agent_id` is PRESERVED when leaving a provider. It is the only pointer back to infrastructure that still exists and still holds a copy of this agent's key; clearing it would strand the deployment unnameable. It is ignored for display while the backend is Local. - This is not a field on `UpdateManagedAgentRequest`. An ordinary edit-dialog save must never be able to change where an agent runs. Backend only — there is no UI surface yet, so the command is unreachable from the app. The dialog and the Agents-page action follow. Signed-off-by: Junchao Yan <yjc801@gmail.com> * feat(desktop): surface agent migration on the Agents page Completes the feature: the command from the previous commit was registered but unreachable. Adds the API wrapper, the mutation hook, a migrate dialog, and a "Move" control on each agent row. The load-bearing piece is `migrationGate` — the UI half of a guard the backend cannot finish alone. `set_managed_agent_backend` verifies local liveness (a surviving pid after process sync is authoritative) but has no signal for a remote harness: provider status reports deployed/not_deployed, which is infrastructure existence, and relay presence is polled in the frontend and never reaches that process. So the command accepts `remote_confirmed_stopped` as an assertion, and this gate is what makes the assertion true — it forwards `true` only when presence says the agent is offline. It fails closed in three distinct ways, which is the whole point: presence not yet loaded, presence loaded but absent for this agent, and any status other than offline all block the move. Absence is not evidence of offline — an agent whose heartbeat never arrived looks identical to one that stopped. The cost of guessing wrong toward "offline" is two harnesses on one key; the cost of guessing wrong the other way is waiting a few seconds. Also of note: - `away` counts as running. Only `offline` releases the gate. - `agent.status` never substitutes for presence on a provider agent. Even `not_deployed` blocks while presence says online, because it only means no infrastructure is recorded. - A running LOCAL agent is blocked regardless of presence — a local process can be alive while the relay shows it offline. Different signals, different questions. The dialog reuses the create flow's `WhereToRunSection` rather than a parallel picker, so provider discovery, schema probing and config validation stay in one place; a second implementation would drift, and "where this agent runs" is exactly where a divergence would go unnoticed. It reseeds from the agent's current location on every open, so a half-finished edit never becomes a silent default, and it states the two things a user cannot recover by guessing: working files stay behind, and (local→remote only) the private key is copied to the provider and stays there until that deployment is destroyed. `RunOnSummarySection`'s doc comment asserted that no migrate operation exists — it was the codebase's own justification for the gap, and the first thing a future reader would trust. Rewritten to point at the new command and say why migration is a separate operation rather than an edit-dialog field. `presenceLoaded` is threaded into `AgentSummary` because the gate must distinguish "offline" from "not loaded yet"; `presenceStatus` alone cannot. Signed-off-by: Junchao Yan <yjc801@gmail.com> * fix(desktop): make the migration guards read the state they claim to Review round 1 on PR #14. Four of the five findings were real defects in the guard I wrote; the fifth was the Tauri formatter. All verified in source before fixing, none disputed. **Local liveness was structurally dead.** The check read `record.runtime_pid`, but `sync_managed_agent_processes` — which this very command runs three lines earlier — unconditionally `take()`s that field on every record as legacy bookkeeping (runtime/lifecycle.rs:117). So the guard evaluated false for a normally running local agent, allowed Local → Provider with the child still alive, and left it alive: `stop_managed_agent` then rejects the record because it is no longer Local, and the next provider start produces exactly the two-live-harness state this command exists to prevent. Liveness now comes from `local_harness_alive`: every tracked runtime key for the pubkey in *any* community, plus this instance's on-disk pair receipts (the untracked-pair case). Not workspace-scoped, unlike `build_managed_agent_summary` — a pair alive in another community is just as fatal to changing where this identity runs. **A provider deploy could outlive the record it was deploying.** Callers resolve the provider under the store lock, release it, then spend up to the deploy timeout in an external process; `deploy_to_provider` reacquires the lock afterwards and writes `backend_agent_id` without rechecking. A move to Local landing in that window is durable before the deploy finishes, so the deploy still starts a remote harness while the record says Local — and Local permits a second, local one. A post-result check cannot help: the external effect has already happened. Added a per-agent fence (`begin_backend_transition`) taken by both `set_managed_agent_backend` and `deploy_to_provider`, spanning the external call, plus a backend re-read under it. Always acquired *before* the store lock, so the two locks have a fixed order. Fencing inside `deploy_to_provider` rather than `start_managed_agent` covers the create-time and owner-access-reconcile deploys too. The mirrored direction needs no fence: `start_pair` re-checks `backend == Local` under the same store lock it spawns and registers within, so a migration either loses that race or is refused by the liveness check. **A preserved `backend_agent_id` was unattributed, and that lost both consumers.** `delete_managed_agent` guards on `backend != Local`, so after Provider → Local the delete proceeded without `force_remote_delete` and erased the key plus the last pointer to a deployment that still holds a copy of that key. Provider A → Provider B was worse: A's id made B read as `deployed`, and B's first deploy overwrote it. The pointer is now *retired* rather than left behind — moved into a new `residual_deployments` list with the provider that issued it, and cleared from `backend_agent_id`. Same provider with a new config is not a retirement; that deployment is still live. The deletion guard, the frontend force flag, its confirmation copy, and the e2e bridge mock all read the residual list. **The shared-compute guard read a field that is documented as not authoritative.** `record.relay_mesh` is a back-compat marker: a linked instance's definition can switch away from mesh while the record bytes still say mesh, and a blank definition can inherit global `relay-mesh` with no marker at all. The guard therefore blocked legal migrations and accepted impossible ones — the latter saving Provider and only failing later in `build_deploy_payload`. Now resolved through `resolve_effective_relay_mesh_model_id`, matching local start and deploy preflight. Also mocks `set_managed_agent_backend` in the e2e bridge: the Agents-page Move action landed on this branch after the review, and without a mock it was unreachable in mock mode. `just ci` green end to end, including the Tauri format check that was failing at the reviewed head. Signed-off-by: Junchao Yan <yjc801@gmail.com> Co-authored-by: Junchao Yan <yjc801@gmail.com> * fix(desktop): make the migration entry point reachable and residual-safe Round 2 review fixes. Persona deletion re-derived the orphan predicate rather than sharing it, so it only knew the first of the two ways an agent can hold provider infrastructure. An agent that had moved Provider -> Local reads `Local` with a cleared `backend_agent_id`, so the cascade was admitted and the record — and its key — destroyed while the retired deployment kept its copy. It now calls `orphans_infrastructure`, the same predicate single-agent deletion guards on. The Move affordance was mounted only on `ManagedAgentRow`, which nothing renders: its only caller is `AgentGroupRows`, and that has no caller at all. `AgentsView` renders `UnifiedAgentsSection`. The entry point moves to the profile panel's settings menu, beside the other whole-agent operations, as `useAgentRunLocationMove` — two nodes rather than a component, because a dialog mounted inside `DropdownMenuContent` unmounts with the menu that closes when the dialog takes focus. An e2e spec walks card -> panel -> menu -> dialog so reachability is pinned by a browser rather than by argument. `WhereToRunSection` hid itself whenever provider discovery came back empty, which is right when creating an agent and traps an existing one: a remote agent whose provider binary was removed lost its only way home, even though moving to Local needs no provider binary. `runOnOptions` now keeps the current provider listed whether or not discovery found it, and the section hides only when there is genuinely nothing to choose between. `unchanged` compared provider ids alone, so every config-only edit left "Move agent" disabled — unreachable despite the backend accepting same-provider, new-config as a real transition. It now compares the config by value. Finally, a provider that derives its id from the agent hands back the same id after A -> Local -> A, leaving one deployment recorded as both current and abandoned; `reclaim_residual_deployment` drops the exact match on a successful deploy. The edit dialog no longer tells users to recreate the agent. Signed-off-by: Junchao Yan <yjc801@gmail.com> * fix(desktop): qualify residual deployments by scope, and disclose them on delete Round 3 review fixes. `(provider_id, agent_id)` is not a complete deployment identity. `docs/remote-agents.md` I4 guarantees at most one live instance per key per **deployment scope**, and states outright that the protocol cannot prevent one key living in two scopes; for the Kubernetes provider that scope is the `context` and `namespace` in `provider_config`, and instance names are deterministic. The same `(provider, agent_id)` therefore names a different pod, holding its own copy of the key, in every namespace. So residuals carry the config they were deployed with, and both halves match on it. Redeploying into namespace B no longer discards the residual naming the pod in namespace A. `retire_deployment_pointer` loses its same-provider early return for the same reason: a namespace edit strands the old pod exactly as switching providers does, and that path is reachable now that config-only edits can be submitted. The desktop cannot tell a scope key from a tuning key — the deploy response carries `agent_id` and `fresh_generation`, never a scope handle — so every config difference counts as possibly-different infrastructure. That keeps a residual which is sometimes really the live deployment, over-warning on deletion; the opposite error orphans a pod and Secret holding the private key, silently. Deletion also has to say so. The profile path passes `skipRemoteDeleteConfirm`, which suppresses the residual `window.confirm` while still sending `forceRemoteDelete`, so the dialog is the only disclosure a user gets — and keyed on the backend alone it told an agent with orphanable infrastructure only that a local process would stop. Proving that in a browser surfaced two mock-fidelity bugs behind it: `cloneManagedAgent` is a field-by-field clone that omitted `residual_deployments`, so the bridge's retirement was invisible to the app whatever it wrote, and the mock kept the same-provider early return this commit removes from the real rule. Signed-off-by: Junchao Yan <yjc801@gmail.com> * fix(desktop): retain residual deployments instead of reclaiming them Raw provider config cannot prove effective deployment scope, so no equality test here can decide that a redeploy landed on the deployment a previous move retired. The Kubernetes provider's `context` is optional, and an omitted one resolves from the machine's *current* kubeconfig at deploy time (`config.rs` `context: Option<String>` — "`None` uses the current context"; `client.rs` `connect` hands it to `KubeConfigOptions`). Deploy in cluster A without an explicit context, move to Local, change current-context to cluster B, move back: the saved config is identical, the deterministic pod name is identical, and the cluster is not. Both previous keyings — `(provider_id, agent_id)`, then that plus the config — would drop the entry naming the pod and Secret in A that still hold the private key. This is not specific to Kubernetes. Any provider config key that is optional and environment-resolved has the same shape, and the desktop observes none of that resolution: the deploy response carries `agent_id` and `fresh_generation`, never a scope handle. So `reclaim_residual_deployment` is removed rather than re-keyed, and residuals are retained unconditionally. That reinstates the duplicate warning after A -> Local -> A as a deliberate cost: retention is wrong about a *message*, reclaiming is wrong about a *pointer*, and only the second is unrecoverable. The delete disclosures now state the ambiguity rather than asserting the deployment was abandoned. Deciding this properly needs a provider-returned stable scope identity, which is a protocol addition. Signed-off-by: Junchao Yan <yjc801@gmail.com> --------- Signed-off-by: Junchao Yan <yjc801@gmail.com>
yjc801
added a commit
that referenced
this pull request
Aug 21, 2026
Resolves the desktop agent-mention conflicts from upstream block#6338 ("Fix cross-owner relay agent mentions in owner-only builds"), which removed the owner-only gate from mention admission that this fork had also been carrying. - agentAutocompleteEligibility: take upstream's removal of the ownerOnly/isManagedAgent/ownerPubkey gate; keep the fork's lenient channel-member branch (#5) and its directoryAgentPubkeys input, which upstream's change does not cover. - agentMentionRevalidation: same — drop the owner-profile proof fetch, keep the roster fetch that the member branch depends on. - useMentions: keep the fork's per-community relay URL (#4), drop the owner-only query. - MembersSidebar: keep the fork's MembersSidebarAddMemberRows split and delete upstream's duplicate AddMemberSearchResultRow.tsx. - relayReconnectReplay.test.mjs: upstream's new coupling guard reads the drift literal from ingest.rs; this fork hoists it into buzz-core for buzz-waker, so point the guard at the real definition. Validated: desktop tsc, 5438 desktop tests, biome, px/file-size guards, cargo fmt, cargo check (workspace + Tauri, all targets), 2781 Tauri lib tests. Signed-off-by: Junchao Yan <yjc801@gmail.com>
This was referenced Aug 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Managed agents live in one global store shared by every community, so every picker offered every record — including identically-named identities provisioned separately per community (three "Bumble"s, distinguishable only by npub). Only one of them has a harness in the community being viewed; mentioning either of the others is silently dead — nothing fails, the p-tag is simply delivered to a relay where that pubkey was never started.
This scopes managed-agent instances per community, following the model every comparable platform converged on (Slack bot-user-per-workspace, GitHub App installation-per-org, Discord bot-per-guild): the persona stays global (the definition layer), the instance belongs to a community.
What changed
community_relay_url: Option<String>onManagedAgentRecord/Summary(canonical relay URL;null= unscoped/shared, offered everywhere). Display + name-uniqueness scope only — spawn resolution (effective_agent_relay_url, agents-everywhere feat(desktop+acp): spawn a harness per (agent, community) pair at GUI startup — warm sockets, lazy LLM pool block/buzz#2122) is untouched, so this is rollback-safe.skip_serializing_if: the boot backfill is idempotent by key presence, so an explicit unscope must persist a literalnullor the next boot would re-derive a binding from the legacy pin.migration/community_scope.rs): binds records from the legacy creation-erarelay_urlpin when present; a blank pin carries no evidence and stays unscoped (no hardcoded pubkeys/hostnames).(community, name)uniqueness inside the store lock — case-insensitive, instances only, unscoped collides everywhere, create-time only (pre-existing duplicates keep working). The create dialog preflights the same rule client-side so a collision rejects inline instead of surfacing after the persona already exists.managedAgentBelongsToCommunity(directory presence kept as an override — an agent registered on the active relay is mentionable there regardless of its binding): mention autocomplete, new-message recipients, members sidebar add-member, projects agent prompt, Pulse, and the Agents page, which partitions into in-community + a collapsed "From other communities" group. Resolution of already-present pubkeys (message history, membership, runtime, search authors, tray) is deliberately left unfiltered — scoping those would render names as raw npubs.set_managed_agent_communitycommand +AgentCommunityScopeBadge(badge + menu on agent cards/rows): Use only in <community>, Move to <community>, Share across all communities. Bound-to-current-community agents render no badge.Reviewer notes
migration/json_patch.rs,managed_agents/{community_scope,record_views}.rs,commands/agent_create_support.rs,shared/api/{managedAgent,managedAgentRaw}.ts(re-exported fromtypes.ts/tauri.ts, consumer import surface unchanged),messages/lib/managedAgentMentionMaps.ts. Two exhaustive Rust test fixtures were converted to the JSON-fixture convention so future record fields don't churn them.BUZZ_COMMUNITY_SCOPE_MANUAL_DIR) that runs the real code against a copy of a store.Related issue
None found (fork). Root cause traces back to the multi-community split this fork accumulated: one identity per agent per community, all offered everywhere.
Testing
cargo test(desktop crate): 2283 passed — includes 7 new migration tests (pinned→bound, blank→null, non-canonical→canonical, explicit-null preserved, unparsable-pin degrades, byte-identical second run), 6 new collision-rule tests, and the existingspawn_snapshot(no restart-badge drift from the new field) andagent_snapshotguards (!json.contains("relay_url")covers the new field by substring — it cannot leak into shareable agent cards).communityScoperule tests.tsc --noEmit,cargo clippy --all-targets -- -D warnings,cargo fmt --check, fullpnpm check(biome + file-size + px-text + pubkey-truncation),git diff --check: all clean.--no-sign, nomesh-llm) against the real profile: three same-named identities collapse to one per picker; foreign identities reachable via the Agents-page disclosure; assign/move/share actions wired. No screenshots yet — will follow after the live pass.🤖 Generated with Claude Code