Skip to content

Thread ownership: prevent multi-agent duplicate replies in shared channels #3334

Description

@houko

Problem

When a user deploys two or more LibreFang agents into the same shared channel (e.g. one Slack workspace with both a "support" agent and a "research" agent), there is no coordination layer that prevents both agents from replying to the same thread. Both adapters read the same incoming message, both pass tool/intent gates, both reply.

This is fine for direct messages (different DM threads, no overlap) but for group / channel chats it produces duplicate responses, contradictory advice, and a confusing UX. Today the user has to scope each agent to a different channel via allowlist, which throws away the value of having multiple specialists in one room.

Reference: openclaw

extensions/thread-ownership solves this by making thread-level claims explicit:

  • An in-memory map keyed {channel}:{thread} → claimed_agent_id, with a 5-minute TTL on each entry.
  • When a message lands, the bridge checks if the thread is already claimed; if so, every agent except the claimed one returns { cancel: true } from its message-sending hook.
  • An agent's @-mention re-claims the thread for that agent (5-minute window for follow-ups).
  • A/B test channels can be configured to route by hash, so traffic is split deterministically across agents in opt-in test channels.
  • For multi-process deployments (multiple daemons sharing a Slack forwarder), claims are forwarded to the slack-forwarder ownership API so the in-memory map is shared.

Proposed approach

Single-process v1 first; defer multi-process until there's demand.

  1. New module librefang-channels::thread_ownership:

    pub struct ThreadOwnershipRegistry {
        claims: DashMap<ThreadKey, Claim>,
    }
    
    #[derive(Hash, Eq, PartialEq)]
    pub struct ThreadKey {
        channel: String,    // adapter-qualified, e.g. "slack:T01/C02"
        thread: String,     // thread_ts for slack, message_id for others
    }
    
    pub struct Claim {
        agent_id: AgentId,
        claimed_at: Instant,
        ttl: Duration,        // default 5 min
    }
  2. Bridge integration (bridge.rs):

    • On incoming message: extract (channel, thread), ask the registry. If claimed by another agent, drop without dispatch.
    • On outbound reply: claim or extend the claim for the replying agent.
    • On @-mention of a specific agent: re-claim for that agent and clear the previous claim.
  3. Configurable per-agent and per-channel:

    # global default
    [thread_ownership]
    enabled = true
    ttl_seconds = 300
    
    # opt out per channel for special cases (e.g. a "broadcast" channel where everyone replies)
    [channels.slack.overrides]
    thread_ownership_enabled = false   # this channel disables claiming
  4. Group / channel chats only — direct messages skip the registry entirely (no overlap risk by definition).

  5. Observability: emit a thread_ownership.skipped { agent_id, reason } metric so users can see when claims are suppressing replies.

Acceptance criteria

  • ThreadOwnershipRegistry with claim / extend / get / sweep-expired API.
  • Bridge integration in inbound and outbound paths.
  • DM bypass: registry never consulted for chat_type = direct.
  • @-mention re-claim: spawning a fresh test where agent A claimed, then B is @-mentioned, verify B is allowed to reply.
  • TTL expiry: claim auto-releases after the configured TTL; new agent can then claim.
  • Per-channel override: a channel marked thread_ownership_enabled = false allows duplicate replies (back-compat for users who actually want broadcast).
  • Metric / log entry on each suppression so users can debug "why didn't agent X reply?"
  • Integration test: two agents bound to one mock-Slack adapter, both tagged on the same incoming message; verify only the first to dispatch wins, the second is suppressed with the expected log line.

Multi-process / multi-daemon coordination (the slack-forwarder shared-store model) is out of scope; track as a follow-up if a user reports the duplicate-reply problem across daemons.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/channelsMessaging channel adaptersdifficulty/mediumHalf day, some familiarity with the crateenhancementNew feature or requestfeature-parityPorted from OpenFanghas-prA pull request has been linked to this issueseverity/mediumBug or limitation with workaround / partial impact

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions