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.
-
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
}
-
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.
-
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
-
Group / channel chats only — direct messages skip the registry entirely (no overlap risk by definition).
-
Observability: emit a thread_ownership.skipped { agent_id, reason } metric so users can see when claims are suppressing replies.
Acceptance criteria
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.
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-ownershipsolves this by making thread-level claims explicit:{channel}:{thread} → claimed_agent_id, with a 5-minute TTL on each entry.{ cancel: true }from its message-sending hook.Proposed approach
Single-process v1 first; defer multi-process until there's demand.
New module
librefang-channels::thread_ownership:Bridge integration (
bridge.rs):(channel, thread), ask the registry. If claimed by another agent, drop without dispatch.Configurable per-agent and per-channel:
Group / channel chats only — direct messages skip the registry entirely (no overlap risk by definition).
Observability: emit a
thread_ownership.skipped { agent_id, reason }metric so users can see when claims are suppressing replies.Acceptance criteria
ThreadOwnershipRegistrywith claim / extend / get / sweep-expired API.chat_type = direct.thread_ownership_enabled = falseallows duplicate replies (back-compat for users who actually want broadcast).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.