block / block/buzz

Mention picker re-mints a persona on a second device: inbound kind:30177 persona_id link is discarded

Open
#3,414 2 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.