Mention picker re-mints a persona on a second device: inbound kind:30177 persona_id link is discarded
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## Summary
On a second device belonging to the same owner, the `@` mention picker offers a persona as *mintable* even when an instance of that persona is already a member of the channel and running elsewhere. Selecting it creates a brand-new agent identity (new keypair, new `kind:30177`) instead of p-tagging the existing one.
The information needed to prevent this is already on the wire — `ManagedAgentEventContent.persona_id` — but the receiving device discards the whole event because it has no local record to merge into, so it never learns the `pubkey → persona_id` mapping that the picker's dedup depends on.
Line numbers below are from **v0.5.0**.
## Repro
1. Mac A: create persona *Penelope*, mint an instance, add it to `#channel`. Works.
2. Mac B: same owner account, app freshly launched. The persona definition (`kind:30175`) syncs and auto-inserts. No local instance is created (by design).
3. Mac B: in `#channel`, type `@Pen…`. The picker shows **two** entries both labeled "Penelope" — the channel member, and a mintable persona.
4. Selecting the persona entry mints a second identity for the same persona and attaches it to the channel.
Now every `@Penelope` is a coin flip between two identities, and messages addressed to the one whose harness lives on the other machine are silently dropped.
## Root cause
`agentIdentityKey` in `desktop/src/features/agents/lib/agentAutocompleteEligibility.ts:114` is what should collapse these two candidates:
```ts
if (candidate.personaId) {
return `persona:${candidate.personaId}`;
}
```
and `agentCandidateRank` (same file, :158) already ranks `isMember` first, so on a merge the live channel member would correctly win over the mintable persona.
The merge never happens because the **member candidate has no `personaId` on device B**. `desktop/src/features/messages/lib/useMentions.ts:316`:
```ts
personaId: managedAgentPersonaIdsByPubkey.get(pubkey) ?? linkedPersonaId,
```
`managedAgentPersonaIdsByPubkey` is derived from the *local* managed-agent table, which on device B has no row for that pubkey. `linkedPersonaId` only resolves when a persona id happens to equal the agent pubkey, which is never true for minted instances. So the member candidate keys on label instead, the persona candidate keys on `persona:`, and both survive into the list.
The persona is offered as mintable because the same local table gates it (`useMentions.ts:389`):
```ts
const personaCandidates = activePersonas
.filter((persona) => !managedAgentPersonaIds.has(persona.id))
```
and selecting it routes to `createPersonaAgentMutation` in `desktop/src/features/messages/ui/useMentionSendFlow.ts:354`.
## Why the local table is empty — and why that's the fixable part
`apply_inbound_managed_agent` in `desktop/src-tauri/src/commands/personas/mod.rs:839` drops the inbound `kind:30177` projection entirely when there is no local match:
```rust
/// No match is a no-op: managed agents carry device-local secrets and are never
/// minted from a relay event — an agent that does not already exist locally has
/// no secret key to run with, so inserting a secretless shell would be useless
/// and misleading. This diverges from the persona path, which DOES insert on no
/// match (personas are secretless definitions).
```
The security reasoning is right and shouldn't change: device B must not fabricate a runnable record it has no key for. But the projection it throws away contains `persona_id`, which is not a secret and is exactly the join key the picker needs. Dropping the event is stricter than dropping the secrets.
## Impact
- Duplicate identities accumulate silently; each `@mention` from the non-minting device creates another.
- Mentions become nondeterministic once duplicates exist.
- Messages to the identity whose harness lives on the other machine are dropped with no error (correctly — `useMentionSendFlow.ts:260` skips non-local agents — but the user sees only silence).
- Related second-order failure: if device B has a *stale* local record (from an earlier accidental mint) whose harness was since deleted, the mention resolves to it and device B attempts a local `startAgentMutation` (`useMentionSendFlow.ts:265`), producing `cannot spawn agent : harness "" was deleted`.
## Suggested fix
Keep the no-mint rule; record the association only.
On no-match in `apply_inbound_managed_agent`, persist the `(pubkey, persona_id, name)` triple into a separate non-runnable index rather than into `managed-agents.json`. Then source `managedAgentPersonaIdsByPubkey` from local records ∪ that index. The existing `agentIdentityKey` / `agentCandidateRank` logic then does the right thing with no further change: the two candidates collapse and the channel member wins.
A narrower alternative, if a new store is unwelcome: have the relay agent directory (`relayAgentsQuery`) carry `persona_id`, so `useMentions.ts:341` and `:316` can resolve it without any local state. That fixes the picker but leaves other cross-device persona↔instance lookups unaware.
Either way the desired end state is the one the code already reaches for on a single device — a persona with a live instance anywhere in the channel is *tagged*, not re-minted.
## Environment
- Buzz desktop v0.5.0, two macOS machines, one owner account
- Self-hosted relay (`ghcr.io/block/buzz`), agent runs under a custom harness on machine A
Contributor guide
Research direction
Start at desktop/src-tauri/src/commands/personas/mod.rs:839 to trace how an unmatched inbound kind:30177 event is handled, then follow managedAgentPersonaIdsByPubkey and candidate construction in desktop/src/features/messages/lib/useMentions.ts. Compare the picker behavior through agentAutocompleteEligibility.ts and useMentionSendFlow.ts; done means the persona_id mapping survives on the second device without creating a runnable local agent, the channel member and mintable entry collapse, and the member is selected.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust, typescript
- Domain
- desktop, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 58/100