fix(gateway): pin channel registry at startup to survive registry swaps

Channel plugin resolution fails with 'Channel is unavailable: <channel>'
after the active plugin registry is replaced at runtime. The root cause is
that getChannelPlugin() resolves against the live registry snapshot, which
is replaced when non-primary registry loads (e.g., config-schema reads)
call loadOpenClawPlugins(). If the replacement registry does not carry the
same channel entries, outbound message delivery and subagent announce
silently break.

This mirrors the existing pinActivePluginHttpRouteRegistry pattern: the
channel registry is pinned at gateway startup and released on shutdown.
Subsequent setActivePluginRegistry calls no longer evict the channel
snapshot, so getChannelPlugin() always resolves against the registry that
was active when the gateway booted.
This commit is contained in:
affsantos
2026-03-24 21:22:42 +01:00
committed by Peter Steinberger
parent 4d41b8664c
commit 3a4c860798
4 changed files with 149 additions and 3 deletions

View File

@@ -7,6 +7,8 @@ type RegistryState = {
registry: PluginRegistry | null;
httpRouteRegistry: PluginRegistry | null;
httpRouteRegistryPinned: boolean;
channelRegistry: PluginRegistry | null;
channelRegistryPinned: boolean;
key: string | null;
version: number;
};
@@ -20,6 +22,8 @@ const state: RegistryState = (() => {
registry: null,
httpRouteRegistry: null,
httpRouteRegistryPinned: false,
channelRegistry: null,
channelRegistryPinned: false,
key: null,
version: 0,
};
@@ -32,6 +36,9 @@ export function setActivePluginRegistry(registry: PluginRegistry, cacheKey?: str
if (!state.httpRouteRegistryPinned) {
state.httpRouteRegistry = registry;
}
if (!state.channelRegistryPinned) {
state.channelRegistry = registry;
}
state.key = cacheKey ?? null;
state.version += 1;
}
@@ -46,6 +53,9 @@ export function requireActivePluginRegistry(): PluginRegistry {
if (!state.httpRouteRegistryPinned) {
state.httpRouteRegistry = state.registry;
}
if (!state.channelRegistryPinned) {
state.channelRegistry = state.registry;
}
state.version += 1;
}
return state.registry;
@@ -91,6 +101,40 @@ export function resolveActivePluginHttpRouteRegistry(fallback: PluginRegistry):
return routeRegistry;
}
/** Pin the channel registry so that subsequent `setActivePluginRegistry` calls
* do not replace the channel snapshot used by `getChannelPlugin`. Call at
* gateway startup after the initial plugin load so that config-schema reads
* and other non-primary registry loads cannot evict channel plugins. */
export function pinActivePluginChannelRegistry(registry: PluginRegistry) {
state.channelRegistry = registry;
state.channelRegistryPinned = true;
}
export function releasePinnedPluginChannelRegistry(registry?: PluginRegistry) {
if (registry && state.channelRegistry !== registry) {
return;
}
state.channelRegistryPinned = false;
state.channelRegistry = state.registry;
}
/** Return the registry that should be used for channel plugin resolution.
* When pinned, this returns the startup registry regardless of subsequent
* `setActivePluginRegistry` calls. */
export function getActivePluginChannelRegistry(): PluginRegistry | null {
return state.channelRegistry ?? state.registry;
}
export function requireActivePluginChannelRegistry(): PluginRegistry {
const existing = getActivePluginChannelRegistry();
if (existing) {
return existing;
}
const created = requireActivePluginRegistry();
state.channelRegistry = created;
return created;
}
export function getActivePluginRegistryKey(): string | null {
return state.key;
}
@@ -103,6 +147,8 @@ export function resetPluginRuntimeStateForTest(): void {
state.registry = null;
state.httpRouteRegistry = null;
state.httpRouteRegistryPinned = false;
state.channelRegistry = null;
state.channelRegistryPinned = false;
state.key = null;
state.version += 1;
}