Skip to content

fix(channels): use pinned channel registry for outbound adapter resolution#55202

Merged
obviyus merged 1 commit into
openclaw:mainfrom
GodsBoy:fix/outbound-registry-pinning
Mar 29, 2026
Merged

fix(channels): use pinned channel registry for outbound adapter resolution#55202
obviyus merged 1 commit into
openclaw:mainfrom
GodsBoy:fix/outbound-registry-pinning

Conversation

@GodsBoy

@GodsBoy GodsBoy commented Mar 26, 2026

Copy link
Copy Markdown
Contributor

Summary

loadChannelOutboundAdapter (via createChannelRegistryLoader in src/channels/plugins/registry-loader.ts) was reading from getActivePluginRegistry() — the unpinned active registry that gets replaced whenever loadOpenClawPlugins() runs.

Multiple code paths replace the active registry after boot:

  • Config schema reads (runtime-schema.ts)
  • Plugin status queries (plugins/status.ts)
  • Tool listings (plugins/tools.ts)
  • Provider resolution (providers.runtime.ts)
  • TTS/image-gen/media provider lookups

After replacement, the active registry may omit channel entries or carry them in setup mode without outbound adapters, causing:

Outbound not configured for channel: telegram

The channel inbound path already uses the pinned registry (getActivePluginChannelRegistry() via requireActivePluginChannelRegistry in channels/plugins/registry.ts), which is frozen at gateway startup and survives all subsequent setActivePluginRegistry calls. The outbound path was the only consumer still reading from the unpinned surface.

Fix

Two-line change in registry-loader.ts: switch from getActivePluginRegistry() to getActivePluginChannelRegistry(). This aligns outbound adapter resolution with the same pinned channel registry that inbound already uses.

Registry divergence diagram

Boot:  loadPlugins → setActivePluginRegistry(R1) → pinChannelRegistry(R1)
                     ┌─────────────────────────┐
                     │ Active registry: R1      │ ← both surfaces point here
                     │ Pinned channel reg: R1   │
                     └─────────────────────────┘

Post-boot:  config-schema read → loadPlugins → setActivePluginRegistry(R2)
                     ┌─────────────────────────┐
                     │ Active registry: R2      │ ← no channel entries
                     │ Pinned channel reg: R1   │ ← still has telegram
                     └─────────────────────────┘

Inbound:  getChannelPlugin("telegram")        → reads R1 (pinned) ✓
Outbound: loadChannelOutboundAdapter("telegram") → was reading R2 (active) ✗
                                                 → now reads R1 (pinned) ✓

Test plan

  • Added regression test: pins registry with telegram outbound adapter, replaces active registry with empty one, asserts loadChannelOutboundAdapter still resolves the adapter
  • Existing channel pin tests pass (9/9)
  • Heartbeat runner tests pass (64/64)
  • Outbound delivery tests pass (95/95)
  • Channel outbound tests pass (18/18)
  • pnpm tsgo — zero type errors
  • pnpm lint (oxlint) — zero warnings/errors

Fixes #54745
Fixes #54013
Related: #50721

@greptile-apps

greptile-apps Bot commented Mar 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a registry divergence bug where the channel outbound adapter loader (createChannelRegistryLoader in registry-loader.ts) was reading from the unpinned active registry (getActivePluginRegistry()), while the inbound path was already correctly reading from the pinned channel registry (getActivePluginChannelRegistry()). Post-boot operations that call setActivePluginRegistry (config-schema reads, plugin status queries, tool listings, etc.) would replace the active registry with one that lacks channel entries, causing "Outbound not configured for channel: telegram" errors.\n\n- Fix: Two-line change in registry-loader.ts switching getActivePluginRegistry()getActivePluginChannelRegistry(), aligning outbound resolution with the pinned surface.\n- Cache correctness: The registry !== lastRegistry invalidation logic remains correct — when pinned the reference is stable (cache is never spuriously busted), and when unpinned syncTrackedSurface keeps the channel surface in sync with the active registry.\n- Test coverage: A new regression test pins a telegram adapter registry, replaces the active registry with an empty one, and asserts the adapter is still resolved — directly covering the failure scenario.\n- Scope: Minimal and surgical; no collateral changes.

Confidence Score: 5/5

Safe to merge — minimal two-line fix with a direct regression test and no side-effects.

The change is exactly the right fix: the outbound surface was the only remaining consumer of the unpinned registry, and the patch aligns it with the already-correct inbound path. The cache invalidation logic is unaffected. The new test directly reproduces the reported failure. All existing tests continue to pass per the test plan.

No files require special attention.

Important Files Changed

Filename Overview
src/channels/plugins/registry-loader.ts Two-line fix: replaces getActivePluginRegistry() with getActivePluginChannelRegistry() so the outbound adapter loader reads from the same pinned channel surface as the inbound path. Logic is correct and the cache invalidation still works properly.
src/plugins/runtime.channel-pin.test.ts Adds a targeted regression test that pins a startup registry with a telegram outbound adapter, then replaces the active registry with an empty one, and asserts loadChannelOutboundAdapter still resolves the adapter. Directly covers the bug scenario.

Comments Outside Diff (1)

  1. src/channels/plugins/registry-loader.ts, line 17-20 (link)

    P2 Cache invalidation still correct with pinned surface

    The registry !== lastRegistry check is fine under the new surface. When the channel registry is pinned, getActivePluginChannelRegistry() keeps returning the same object reference, so the cache is never spuriously cleared — which is an improvement over the old behaviour where every setActivePluginRegistry call (config-schema loads, etc.) would return a new object and bust the cache. When the channel registry is not yet pinned, syncTrackedSurface keeps state.channel.registry in lock-step with state.activeRegistry, so the cache still invalidates on legitimate registry swaps. No issues here, just noting the analysis for reviewers.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/channels/plugins/registry-loader.ts
    Line: 17-20
    
    Comment:
    **Cache invalidation still correct with pinned surface**
    
    The `registry !== lastRegistry` check is fine under the new surface. When the channel registry is pinned, `getActivePluginChannelRegistry()` keeps returning the same object reference, so the cache is never spuriously cleared — which is an improvement over the old behaviour where every `setActivePluginRegistry` call (config-schema loads, etc.) would return a new object and bust the cache. When the channel registry is not yet pinned, `syncTrackedSurface` keeps `state.channel.registry` in lock-step with `state.activeRegistry`, so the cache still invalidates on legitimate registry swaps. No issues here, just noting the analysis for reviewers.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/channels/plugins/registry-loader.ts
Line: 17-20

Comment:
**Cache invalidation still correct with pinned surface**

The `registry !== lastRegistry` check is fine under the new surface. When the channel registry is pinned, `getActivePluginChannelRegistry()` keeps returning the same object reference, so the cache is never spuriously cleared — which is an improvement over the old behaviour where every `setActivePluginRegistry` call (config-schema loads, etc.) would return a new object and bust the cache. When the channel registry is not yet pinned, `syncTrackedSurface` keeps `state.channel.registry` in lock-step with `state.activeRegistry`, so the cache still invalidates on legitimate registry swaps. No issues here, just noting the analysis for reviewers.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(channels): use pinned channel regist..." | Re-trigger Greptile

@GodsBoy

GodsBoy commented Mar 26, 2026

Copy link
Copy Markdown
Contributor Author

@steipete @vincentkoc — this is a one-line fix for the root cause behind #54745 and #54013.

TL;DR: loadChannelOutboundAdapter was reading from the unpinned active registry instead of the pinned channel registry. After any post-boot loadOpenClawPlugins() call (config schema reads, plugin status queries, tool listings, etc.), the active registry gets replaced and may lack channel outbound adapters — causing Outbound not configured for channel: telegram.

The inbound path (getChannelPlugin) already uses the pinned channel registry. This PR aligns the outbound path to do the same.

Changes:

  • registry-loader.ts: getActivePluginRegistry()getActivePluginChannelRegistry() (2 lines)
  • Regression test: pins a telegram adapter, replaces the active registry, asserts the adapter still resolves

All CI green, Greptile 5/5.

@GodsBoy

GodsBoy commented Mar 29, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main — test file auto-merged cleanly despite the channel-pin test refactoring.

Two-line fix: switches outbound adapter resolution from the unpinned active registry to the pinned channel registry, matching what inbound already does. One new test verifies outbound resolves from the pinned registry after an active registry replacement.

@obviyus — this complements the registry reuse work you merged in #56240. The outbound path was the last consumer still reading from the unpinned surface.
@joshavant — plugin registry layer, your subsystem.

All checks green locally: 9/9 channel-pin tests, pnpm check 0 errors.

@obviyus obviyus self-assigned this Mar 29, 2026
…ution

loadChannelOutboundAdapter (via createChannelRegistryLoader) was reading
from getActivePluginRegistry() — the unpinned active registry that gets
replaced whenever loadOpenClawPlugins() runs (config schema reads, plugin
status queries, tool listings, etc.).

After replacement, the active registry may omit channel entries or carry
them in setup mode without outbound adapters, causing:

  Outbound not configured for channel: telegram

The channel inbound path already uses the pinned registry
(getActivePluginChannelRegistry) which is frozen at gateway startup and
survives all subsequent registry replacements. This commit aligns the
outbound path to use the same pinned surface.

Adds a regression test that pins a registry with a telegram outbound
adapter, replaces the active registry with an empty one, then asserts
loadChannelOutboundAdapter still resolves the adapter.

Fixes openclaw#54745
Fixes openclaw#54013
@obviyus obviyus force-pushed the fix/outbound-registry-pinning branch from d2af02e to 16bcd77 Compare March 29, 2026 07:23

@obviyus obviyus 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.

Reviewed latest changes; landing now.

@obviyus obviyus merged commit bc9c074 into openclaw:main Mar 29, 2026
8 checks passed
@obviyus

obviyus commented Mar 29, 2026

Copy link
Copy Markdown
Contributor

Landed on main.

Thanks @GodsBoy.

@chosen67one-source

This comment was marked as spam.

@chosen67one-source

This comment was marked as spam.

@chosen67one-source chosen67one-source left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Change

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

3 participants