Description
getChannelPlugin() returns undefined for properly configured core channels (e.g. Telegram) after the active plugin registry is replaced at runtime. This causes the message tool to fail with Unknown channel: telegram even though the channel is in CHANNEL_IDS and was fully functional at startup.
Root Cause
loadOpenClawPlugins() is called at multiple points during runtime — config schema lookups (server-methods/config.ts), maybeBootstrapChannelPlugin() (channel-resolution.ts), provider resolution (plugins/providers.ts), etc. Each call with a different cache key creates a new PluginRegistry and sets it as active via setActivePluginRegistry().
The new registry may have empty channels (if the load was scoped or the channel plugins were not re-initialized). getChannelPlugin() in src/channels/plugins/registry.ts reads from requireActivePluginRegistry().channels, which is now empty. The channel-plugin lookup cache invalidates on version change, reads the empty array, and returns undefined.
Meanwhile, isKnownChannel() / isDeliverableMessageChannel() still returns true because it checks the hardcoded CHANNEL_IDS array in src/channels/registry.ts. This creates a contradictory state: the channel is "known" but has no plugin.
The error surfaces in two places:
src/infra/outbound/message.ts:145 — resolveRequiredPlugin() throws Unknown channel: ${channel}
src/infra/outbound/channel-selection.ts:131 — isKnownChannel() passes but resolveAvailableKnownChannel() fails
There is also a double-lookup race in sendMessage(): resolveRequiredChannel() resolves the channel (line 130), then resolveRequiredPlugin() does a second plugin lookup (line 142). The registry can swap between these two calls.
Reproduction
Most likely to reproduce with:
- Multiple channels configured (e.g. Telegram + Discord)
- Multiple agents or cron jobs triggering config/schema lookups
- Heartbeat or isolated sessions that trigger
loadOpenClawPlugins() with different workspace/config parameters
The bug is intermittent — it only manifests when a message send coincides with a registry swap.
Upstream Context
PR #47902 (a69f6190) correctly identified this class of bug for httpRoutes and introduced the pinned httpRouteRegistry pattern. The same bug exists for channels but was not addressed.
Proposed Fix
Mirror the httpRouteRegistry pinning pattern for channels:
- Add
channelRegistry / channelRegistryPinned state to src/plugins/runtime.ts
- Add
pinActivePluginChannelRegistry() / releasePinnedPluginChannelRegistry() / getActivePluginChannelRegistry() / requireActivePluginChannelRegistry()
- In
setActivePluginRegistry(), only update channelRegistry when not pinned
- Switch
resolveCachedChannelPlugins() in src/channels/plugins/registry.ts to use requireActivePluginChannelRegistry()
- Pin the channel registry alongside the HTTP route registry in gateway startup (
server-runtime-state.ts)
- Harden the channel plugin cache to check both registry object identity AND version number
We have a working implementation: mekenthompson@fe7b8a65d
Related Issues
Environment
- OpenClaw version: affects all versions since plugin registry was introduced
- Channels: Telegram + Discord
- Install: Home Assistant addon (Docker)
- OS: Linux 6.12.67 (x64), Node v22.22.1
Description
getChannelPlugin()returnsundefinedfor properly configured core channels (e.g. Telegram) after the active plugin registry is replaced at runtime. This causes the message tool to fail withUnknown channel: telegrameven though the channel is inCHANNEL_IDSand was fully functional at startup.Root Cause
loadOpenClawPlugins()is called at multiple points during runtime — config schema lookups (server-methods/config.ts),maybeBootstrapChannelPlugin()(channel-resolution.ts), provider resolution (plugins/providers.ts), etc. Each call with a different cache key creates a newPluginRegistryand sets it as active viasetActivePluginRegistry().The new registry may have empty
channels(if the load was scoped or the channel plugins were not re-initialized).getChannelPlugin()insrc/channels/plugins/registry.tsreads fromrequireActivePluginRegistry().channels, which is now empty. The channel-plugin lookup cache invalidates on version change, reads the empty array, and returnsundefined.Meanwhile,
isKnownChannel()/isDeliverableMessageChannel()still returnstruebecause it checks the hardcodedCHANNEL_IDSarray insrc/channels/registry.ts. This creates a contradictory state: the channel is "known" but has no plugin.The error surfaces in two places:
src/infra/outbound/message.ts:145—resolveRequiredPlugin()throwsUnknown channel: ${channel}src/infra/outbound/channel-selection.ts:131—isKnownChannel()passes butresolveAvailableKnownChannel()failsThere is also a double-lookup race in
sendMessage():resolveRequiredChannel()resolves the channel (line 130), thenresolveRequiredPlugin()does a second plugin lookup (line 142). The registry can swap between these two calls.Reproduction
Most likely to reproduce with:
loadOpenClawPlugins()with different workspace/config parametersThe bug is intermittent — it only manifests when a message send coincides with a registry swap.
Upstream Context
PR #47902 (
a69f6190) correctly identified this class of bug forhttpRoutesand introduced the pinnedhttpRouteRegistrypattern. The same bug exists forchannelsbut was not addressed.Proposed Fix
Mirror the
httpRouteRegistrypinning pattern for channels:channelRegistry/channelRegistryPinnedstate tosrc/plugins/runtime.tspinActivePluginChannelRegistry()/releasePinnedPluginChannelRegistry()/getActivePluginChannelRegistry()/requireActivePluginChannelRegistry()setActivePluginRegistry(), only updatechannelRegistrywhen not pinnedresolveCachedChannelPlugins()insrc/channels/plugins/registry.tsto userequireActivePluginChannelRegistry()server-runtime-state.ts)We have a working implementation: mekenthompson@fe7b8a65d
Related Issues
Environment