Skip to content

fix(channels): rank explicit per-peer bindings above the sidecar instance default#6258

Merged
houko merged 3 commits into
librefang:mainfrom
neo-wanderer:fix/inbound-binding-cache-unspawned-agents
Jun 21, 2026
Merged

fix(channels): rank explicit per-peer bindings above the sidecar instance default#6258
houko merged 3 commits into
librefang:mainfrom
neo-wanderer:fix/inbound-binding-cache-unspawned-agents

Conversation

@neo-wanderer

@neo-wanderer neo-wanderer commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Fixes #6257.

Problem

A sidecar channel configured with default_agent seeds a channel-wide instance default. An explicit per-conversation [[bindings]] pinned to a peer_id (a specific room / DM) was then never honored for inbound traffic — every inbound message routed to the instance default, regardless of the room. The per-room binding matched the peer but was never consulted.

Cause: in resolve_or_fallback (crates/librefang-channels/src/bridge.rs) the instance default (branch 5) is consulted before the [[bindings]] router chain (branch 6). A binding pinning a specific peer_id is more specific than a channel-wide default and must outrank it.

Secondary latent bug (surfaces once precedence is fixed): the router's name→id cache is seeded at channel-bridge startup from agent_registry().list_arcs() (spawned agents only). A defined-but-unspawned agent — one that spawns lazily on its first cron fire, or was reconciled to Suspended after an unclean shutdown — is absent, so even when its binding is reached the name lookup misses and routing falls to the default.

Changes

  • AgentRouter::resolve_specific_binding (router.rs): evaluates only bindings whose match_rule pins a peer_id. Consulted in resolve_or_fallback between the sticky-holder check and the instance default. Channel-only bindings stay in the lower-precedence resolve_with_context chain, under the instance default — preserving the existing RFC: rethink channel inbound routing — HITL vs AITL topology, channel-instance binding, per-conversation /agent override #5671 ordering.
  • binding_context (bridge.rs): extracted so the per-peer check and the legacy chain evaluate the identical context.
  • Router name-cache seeding (channel_bridge.rs): seed from the canonical agent-identity registry (agent_identities().list() — every defined agent, spawn-independent, refs feat: Persist canonical UUID per agent name to prevent silent history loss on respawn #4614) in addition to the live spawned set; the spawned set is applied last so the live id wins for hand-derived <hand>:<role> agents not in the identity registry. Extracted as seed_router_agent_names.
  • Updated the resolve_or_fallback precedence doc-comment (now 7 tiers).

Verification

  • cargo check -p librefang-channels -p librefang-api --lib — clean.
  • cargo clippy -p librefang-channels -p librefang-api --lib --all-targets -- -D warnings — clean.
  • New unit tests, all green:
    • router::tests::resolve_specific_binding_only_matches_peer_id_rules — only peer-pinned rules resolve; channel-only excluded.
    • bridge::tests::peer_binding_outranks_instance_default — end-to-end precedence.
    • channel_bridge::tests::seed_router_includes_defined_but_unspawned_agents — defined-but-unspawned bound agent resolves instead of falling to default.
    • channel_bridge::tests::seed_router_spawned_overlays_identity — spawned id overrides canonical for a shared name.
    • Existing sticky_holder_outranks_instance_default / conversation_override_outranks_sticky_holder unchanged.

The router's name->id cache (used by `resolve_binding` to map a binding's
target agent name to an `AgentId`) was seeded at channel-bridge startup
from `kernel.agent_registry().list_arcs()` alone.
That registry only holds *spawned* agents.
Standalone agents that spawn lazily (e.g. on their first cron fire), and
any agent reconciled to Suspended after an unclean shutdown, are absent
from it at bridge-startup.
Their channel bindings then matched on `peer_id` but failed the name->id
lookup, so inbound traffic silently fell through to the system default
agent — which has none of the bound agent's context.

Seed the cache from the canonical agent-identity registry (every
*defined* agent's name -> canonical UUID, spawn-independent, refs librefang#4614)
in addition to the live spawned set.
The spawned set is applied last so the live id wins for hand-derived
`<hand>:<role>` agents that are not in the identity registry.

Extract the seeding into `seed_router_agent_names` and add unit tests:
a defined-but-unspawned bound agent now resolves instead of falling to
the default agent, and the spawned id overrides the canonical id for a
shared name.
…ault

A config `[[bindings]]` whose `match_rule` pins a `peer_id` is the
operator's deliberate route for one specific conversation (a Matrix
room, a DM peer).
It is strictly more specific than the channel-wide instance default
seeded from `[[sidecar_channels]] agent`, yet `resolve_or_fallback`
consulted the instance default first (the per-`[[bindings]]` chain sat
below it as a "legacy fallback").

As a result a sidecar `default_agent` shadowed every per-room binding:
the channel-wide default resolved first and the more-specific binding
never got a turn, so all inbound traffic on that channel collapsed onto
the default agent regardless of the room it arrived in.

Add `AgentRouter::resolve_specific_binding`, which evaluates only
bindings that pin a `peer_id`, and consult it in `resolve_or_fallback`
between the sticky-holder check and the instance default.
Channel-only bindings (no `peer_id`) stay in the lower-precedence router
chain, under the instance default, preserving the existing ordering.

Extract `binding_context` so the per-peer check and the legacy chain
evaluate the identical context.

Tests: `resolve_specific_binding` matches only peer-pinned rules;
`peer_binding_outranks_instance_default` covers the end-to-end
precedence; the existing sticky/override precedence tests are unchanged.
@github-actions github-actions Bot added area/channels Messaging channel adapters size/L 250-999 lines changed labels Jun 20, 2026

@houko houko left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Reviewed the diff: the fix inserts an explicit per-peer binding tier between the sticky-holder check and the channel-wide instance default, iterating self.bindings (stored sorted by specificity descending, so the resolution is deterministic), and correctly falls through to the instance default when the matched agent isn't channel-eligible rather than stranding the message. The binding_context extraction is a faithful refactor, and there's an end-to-end #[tokio::test] (peer_binding_outranks_instance_default) driving the real resolve_or_fallback plus unit tests. CI is fully green.

@houko
houko merged commit 24b9fe1 into librefang:main Jun 21, 2026
31 checks passed
@github-actions github-actions Bot added the ready-for-review PR is ready for maintainer review label Jun 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/channels Messaging channel adapters ready-for-review PR is ready for maintainer review size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(channels): per-room binding shadowed by sidecar default_agent (instance default outranks peer binding)

2 participants