Skip to content

feat(channels): deterministic inbound dispatch — channel-instance binding lookup (#5671 Model A)#6131

Merged
houko merged 9 commits into
mainfrom
channel-dispatch-rewrite
Jun 17, 2026
Merged

feat(channels): deterministic inbound dispatch — channel-instance binding lookup (#5671 Model A)#6131
houko merged 9 commits into
mainfrom
channel-dispatch-rewrite

Conversation

@houko

@houko houko commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

What

Implements the Model A dispatch rewrite from the #5671 RFC: channel inbound routing becomes a deterministic two-level table lookup, removing the production DM-rotation bug. This is PR-B/C/D of the RFC rollout; PR-A (the SidecarChannelConfig.agent / available_agents schema fields) already landed in #6105.

The bug being fixed

resolve_or_fallback applied the same topology-blind priority chain to every inbound message. With no binding, no "assistant" agent, and an expired thread-ownership claim (5-minute TTL), agent selection fell to list_agents().first() — non-deterministic by HashMap iteration order. Two turns of the same DM, sent hours apart, resolved to different agents with different sessions (no shared history). The RFC has the production journal showing a Telegram DM rotating researcherlegal-assistant across two messages.

A DM has one participant talking to one agent — there is nothing for a resolver to decide. The fix makes selection a table lookup instead of a chain.

Changes (three atomic commits)

  • feat(memory) — migration v44 adds channel_instance_defaults (one row per [[sidecar_channels]] instance) and conversation_bindings (per-(instance, conversation) override). New ChannelBindingStore over both; resolve(instance, conversation_id) does the two-level lookup (override → instance default → None). Stores the agent name, not the per-spawn AgentId uuid, since config and the registry resolve by stable name. Wired into MemorySubstrate.
  • feat(kernel) — at boot, seed channel_instance_defaults from each [[sidecar_channels]] agent. Re-seeds (upsert) every boot since config owns the default; per-conversation overrides are untouched.
  • feat(channels)resolve_or_fallback consults the lookup first, keyed by instance = metadata["account_id"] and conversation_id = sender.platform_id (uniform: chat id for groups, peer id for DMs). A configured binding wins outright. New ChannelBridgeHandle::resolve_bound_agent (default None, kernel adapter overrides with the DB lookup).

Scope decisions

  • Non-breaking transition. The legacy chain is kept as a fallback for instances with no binding configured, now emitting a deprecation WARN at the non-deterministic list_agents().first() tail. Configured instances become deterministic; unconfigured ones keep today's behaviour. This is the migration story raised in the RFC review rather than a hard delete.
  • Allowlist + router cache deliberately bypassed on the bound path. Model A explicitly deletes AgentManifest.channels as a dispatch filter, and the deterministic DB lookup replaces the router's set_user_default cache (which only existed to paper over the non-deterministic chain). The operator's explicit [[sidecar_channels]] agent binding is authoritative.
  • Engagement untouched. The HITL/AITL gate (should_process_group_message) and the send path are unchanged — engagement still runs after selection, so groups stay silent-by-default and DMs always engage.
  • Out of scope (later PRs): the /agent slash command (the only writer of conversation_bindings — the table lands empty but read-active), the REST mirror, and the dashboard UI.

Collision to resolve

#4961 is a DRAFT adding AgentManifest.channels as a dispatch filter — that is Model B, which this PR's direction deprecates. It should be redirected before it lands, per the RFC thread.

Verification

Refs #5671. Supersedes the inbound-routing portion of #5323; eliminates the #5294 layers b/c non-determinism by construction.

Evan added 3 commits June 16, 2026 16:00
…tch (#5671)

Adds migration v44 with two SQLite tables backing Model A inbound dispatch and a `ChannelBindingStore` over them.

`channel_instance_defaults` holds one row per `[[sidecar_channels]]` instance (the default agent, seeded from config at boot). `conversation_bindings` holds per-`(instance, conversation)` overrides written by the forthcoming `/agent` command. Both store the agent *name* rather than the per-spawn `AgentId` uuid, since config and the agent registry resolve agents by stable name.

`ChannelBindingStore::resolve(instance, conversation_id)` performs the two-level lookup — conversation override first, then instance default, then `None` — that replaces the non-deterministic `list_agents().first()` fallback in the channel bridge. Wired into `MemorySubstrate` with a `channel_bindings()` accessor.

Covered by 7 unit tests including the per-instance scoping guard (#5672 cross-bot leak) and override-wins-over-default precedence.
After the memory substrate opens, seed `channel_instance_defaults` from every `[[sidecar_channels]] agent`, giving the deterministic two-level dispatch lookup its first level. Config is the source of truth for the default, so this re-seeds (upsert) on every boot; per-conversation `/agent` overrides live in a separate table and are left untouched.

An instance with no `agent` is skipped — its traffic keeps falling through to the legacy resolver chain until the operator sets one, so this is non-breaking for existing deployments.
Replaces the topology-blind agent-selection logic in `resolve_or_fallback` with a deterministic lookup keyed by `(instance, conversation)`: `instance` is the sidecar's config `name` (stamped onto inbound messages as `metadata["account_id"]`), and `conversation_id` is uniformly `sender.platform_id` — the chat id for groups, the peer id for DMs.

A configured channel-instance binding (per-conversation override, else instance default) now wins outright; the operator bound this agent, so neither the legacy chain nor the channel allowlist gets a vote. This removes the production DM-rotation bug where two turns of the same DM resolved to different agents (and different sessions) once the 5-minute thread-ownership claim expired and selection fell to the non-deterministic `list_agents().first()` branch.

The legacy chain is kept as a transitional fallback for instances with no binding configured, now emitting a deprecation WARN at the non-deterministic tail so operators can see they should set `agent` on the instance. The HITL/AITL engagement gate (`should_process_group_message`) and the send path are untouched — engagement still runs after selection.

New `ChannelBridgeHandle::resolve_bound_agent` defaults to `None` (so existing test doubles and the legacy path are unaffected); the kernel adapter overrides it with the DB-backed lookup, resolving the bound agent name to an `AgentId` and falling back gracefully when the name no longer maps to a live agent. Two dispatch precedence tests cover bound-wins and no-binding-falls-through.
@github-actions github-actions Bot added area/channels Messaging channel adapters area/kernel Core kernel (scheduling, RBAC, workflows) size/L 250-999 lines changed has-conflicts PR has merge conflicts that need resolution labels Jun 16, 2026
…ine breaks

All new `///` / `//!` blocks in channel_binding_store.rs and bridge.rs
were wrapped at ~80 columns, breaking mid-sentence.
CLAUDE.md requires line breaks only at sentence boundaries.

@houko houko left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

crates/librefang-channels/src/bridge.rs — binding key for group messages (resolve_or_fallback)

The dispatch comment and the PR body both state:

conversation_id is the chat id for groups and the peer id for DMs — uniformly message.sender.platform_id

But in a Telegram group, sender.platform_id is the sender's user ID, not the group chat ID.
Using it as the binding key would mean two different users in the same group resolve as two different conversations, and a binding seeded for the group-chat ID would never match any inbound message.
For DMs the choice is correct (sender.platform_id == peer ID == the conversation).

Could you confirm one of the following:

  1. The Telegram (and other) adapters intentionally populate sender.platform_id with the chat ID for group messages (i.e. not the sender's user ID), making the field uniform across DMs and groups?
  2. Or the group case should use a different field — e.g. something from message.metadata, or a forthcoming chat_id field — and the current code is a latent bug that only goes unnoticed because configured instances with [[sidecar_channels]] agent set are always DM-only in practice?

The two new tests cover only is_group: false, so neither CI nor the test suite catches the group path today.
If groups simply can't reach the bound path (because the engagement gate keeps them silent before resolve_or_fallback is called), a short comment at that call site would make the intent explicit and prevent a future reader from "fixing" the field to the group chat ID inadvertently.


Generated by Claude Code

…_agent (#5671)

Verifies the real kernel adapter override reads the channel-binding store and resolves the bound agent name to a live AgentId against a fully-booted test kernel. The trait default returns None, so a missing or incorrect override would silently disable deterministic dispatch while the bridge's own mock-handle unit tests stay green — this guards that default-None-disables-feature trap. Also covers the no-binding (fall-through) and dangling-name (graceful None) paths.
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review has-conflicts PR has merge conflicts that need resolution and removed has-conflicts PR has merge conflicts that need resolution ready-for-review PR is ready for maintainer review labels Jun 16, 2026
# Conflicts:
#	crates/librefang-channels/src/bridge.rs
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Jun 17, 2026
Evan added 2 commits June 17, 2026 16:58
…spatch

Reorder resolve_or_fallback so explicit #5323 group addressing (@-mention / alias, then sticky holder) runs before the #5671 channel-instance binding lookup. New precedence: adapter thread-route > @-mention > binding > legacy fallback chain. A fresh @-mention now overrides a configured instance binding, while the binding still applies to otherwise-unaddressed traffic and wins over the router/assistant/first-available fallback.

Update the precedence doc comment to match the new order and add a test asserting an in-group @-mention of a different agent overrides the binding.
…) and default (below)

Resolve_or_fallback previously consulted a single collapsed binding lookup. The two binding levels carry different authority: an explicit per-conversation /agent override is a deliberate user command, while the instance default seeded from [[sidecar_channels]] agent is a weak standing fallback. Ranking them as one block forced a choice between letting the instance default steamroll an in-flight #5323 sticky conversation or letting a stale sticky holder ignore an explicit /agent.

Split the ChannelBridgeHandle trait method resolve_bound_agent into resolve_conversation_override and resolve_instance_default, backed by the store's already-distinct conversation_binding / instance_default getters. New dispatch precedence: thread-route > @mention (#5323) > explicit /agent override > sticky holder (#5323) > instance default > legacy fallback.

Update the precedence doc comment to the 6-tier order, and add tests pinning the split: sticky holder outranks the instance default, the explicit override outranks the sticky holder, and the kernel injection-site test now seeds both levels through the real substrate.
@github-actions github-actions Bot added size/XL 1000+ lines changed has-conflicts PR has merge conflicts that need resolution and removed size/L 250-999 lines changed ready-for-review PR is ready for maintainer review labels Jun 17, 2026
# Conflicts:
#	crates/librefang-memory/src/migration.rs
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Jun 17, 2026
@houko
houko enabled auto-merge (squash) June 17, 2026 08:46
@houko
houko merged commit 7351d30 into main Jun 17, 2026
32 checks passed
@houko
houko deleted the channel-dispatch-rewrite branch June 17, 2026 09:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/channels Messaging channel adapters area/kernel Core kernel (scheduling, RBAC, workflows) ready-for-review PR is ready for maintainer review size/XL 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants