Skip to content

feat(desktop): scope managed agents per community - #4

Merged
yjc801 merged 6 commits into
mainfrom
claude/velvet-buzz-context-660d0f
Aug 9, 2026
Merged

yjc801 merged 6 commits into
mainfrom
claude/velvet-buzz-context-660d0f

Conversation

@yjc801

@yjc801 yjc801 commented Aug 8, 2026

Copy link
Copy Markdown
Owner

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

  • New community_relay_url: Option<String> on ManagedAgentRecord/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.
  • The field deliberately has 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-derive a binding from the legacy pin.
  • Boot backfill (migration/community_scope.rs): binds records from the legacy creation-era relay_url pin when present; a blank pin carries no evidence and stays unscoped (no hardcoded pubkeys/hostnames).
  • Creation stamps the active community at all three minting sites (create / agent-card import / team deploy) 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 already exists.
  • Six picker surfaces route through 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.
  • New set_managed_agent_community command + 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

  • Several near-limit files hit the size ratchet; each got a real extraction rather than a limit bump: migration/json_patch.rs, managed_agents/{community_scope,record_views}.rs, commands/agent_create_support.rs, shared/api/{managedAgent,managedAgentRaw}.ts (re-exported from types.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.
  • Rollback caveat (documented in the migration): an older build drops the unknown key on its next store write, so roll-back-then-forward re-derives bindings from the pin — an explicit unscope is the one thing that doesn't round-trip.
  • The backfill includes an env-gated manual-verification test (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 existing spawn_snapshot (no restart-badge drift from the new field) and agent_snapshot guards (!json.contains("relay_url") covers the new field by substring — it cannot leak into shareable agent cards).
  • Desktop JS: 4562 passed — eligibility tests converted to the real field plus new cases (canonical-spelling mismatch, null-active-community fail-open, three-same-named-identities scenario); new communityScope rule tests.
  • tsc --noEmit, cargo clippy --all-targets -- -D warnings, cargo fmt --check, full pnpm check (biome + file-size + px-text + pubkey-truncation), git diff --check: all clean.
  • Backfill verified against a copy of a live 13-record store: 9 pinned records bound to their communities, 4 blank-pin records left unscoped, second run byte-identical.
  • Live verification in progress on a local release build (--no-sign, no mesh-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

@yjc801
yjc801 force-pushed the claude/velvet-buzz-context-660d0f branch from 834194b to 89d0a08 Compare August 9, 2026 05:23
yjc801 and others added 6 commits August 8, 2026 22:39
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
yjc801 force-pushed the claude/velvet-buzz-context-660d0f branch from 89d0a08 to d935214 Compare August 9, 2026 05:46
@yjc801
yjc801 merged commit e127cea into main Aug 9, 2026
20 of 22 checks passed
@yjc801
yjc801 deleted the claude/velvet-buzz-context-660d0f branch August 9, 2026 06:01
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant