fix(channels): rank explicit per-peer bindings above the sidecar instance default#6258
Merged
houko merged 3 commits intoJun 21, 2026
Conversation
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.
houko
approved these changes
Jun 21, 2026
houko
left a comment
Contributor
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6257.
Problem
A sidecar channel configured with
default_agentseeds a channel-wide instance default. An explicit per-conversation[[bindings]]pinned to apeer_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 specificpeer_idis 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 whosematch_rulepins apeer_id. Consulted inresolve_or_fallbackbetween the sticky-holder check and the instance default. Channel-only bindings stay in the lower-precedenceresolve_with_contextchain, 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.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 asseed_router_agent_names.resolve_or_fallbackprecedence 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.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.sticky_holder_outranks_instance_default/conversation_override_outranks_sticky_holderunchanged.