Skip to content

Commit 585914f

Browse files
xialongleesteipete
andauthored
fix(channels): stop unavailable targets repeating plugin scans (#100377)
* fix(channels): suppress repeated failed bootstrap Co-authored-by: Peter Lee <[email protected]> * docs(changelog): credit recent fixes --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent e0e67ab commit 585914f

4 files changed

Lines changed: 133 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ Docs: https://docs.openclaw.ai
2828

2929
### Fixes
3030

31+
- **Outbound channel bootstrap:** suppress repeated failed plugin activation for the same channel, config, and registry generation while retrying after config or registry reloads. (#100377) Thanks @xialonglee.
32+
- **OpenAI Realtime client-secret deadlines:** bound voice and transcription secret acquisition to 30 seconds through the guarded fetch boundary while preserving authentication and bounded response parsing. (#102860) Thanks @Alix-007.
33+
- **Gateway client watchdog:** keep transport-stall detection active for unbounded and mixed pending requests so dead sockets reject pending requests, reconnect, and never replay rejected requests. (#103407) Thanks @NianJiuZst.
3134
- **iOS Share Extension drafts:** preserve legitimate shared text beginning with scaffold-like prefixes, remove only exact legacy scaffold lines, avoid treating scheme-like prose as a URL, and deduplicate host-mirrored content. (#103453) Thanks @lin-hongkuan.
3235
- **Telegram reasoning previews:** reposition split reasoning previews through deferred deletion so prior preview messages do not remain stale while preserving client scroll position. (#97828) Thanks @ly-wang19.
3336
- **Feishu native-card threading:** normalize whitespace reply targets once and reuse the shared reply mode for card and media parts so native-card topic replies stay in their thread. (#102804) Thanks @sunlit-deng.

src/infra/outbound/channel-bootstrap.runtime.test.ts

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,25 @@ const discordConfig = {
2626
},
2727
} satisfies OpenClawConfig;
2828

29+
const updatedDiscordConfig = {
30+
channels: {
31+
discord: { enabled: true },
32+
},
33+
} satisfies OpenClawConfig;
34+
35+
function pinDiscordSetupShell(): void {
36+
const registry = createEmptyPluginRegistry();
37+
registry.channels = [
38+
{
39+
pluginId: "discord",
40+
plugin: { id: "discord", meta: {} },
41+
source: "setup",
42+
},
43+
] as never;
44+
setActivePluginRegistry(registry);
45+
pinActivePluginChannelRegistry(registry);
46+
}
47+
2948
describe("bootstrapOutboundChannelPlugin", () => {
3049
afterEach(() => {
3150
loaderMocks.resolveRuntimePluginRegistry.mockReset();
@@ -34,16 +53,7 @@ describe("bootstrapOutboundChannelPlugin", () => {
3453
});
3554

3655
it("bootstraps when the selected channel registry has only a setup shell", () => {
37-
const registry = createEmptyPluginRegistry();
38-
registry.channels = [
39-
{
40-
pluginId: "discord",
41-
plugin: { id: "discord", meta: {} },
42-
source: "setup",
43-
},
44-
] as never;
45-
setActivePluginRegistry(registry);
46-
pinActivePluginChannelRegistry(registry);
56+
pinDiscordSetupShell();
4757

4858
bootstrapOutboundChannelPlugin({
4959
channel: "discord",
@@ -143,17 +153,11 @@ describe("bootstrapOutboundChannelPlugin", () => {
143153
expect(loaderMocks.resolveRuntimePluginRegistry).not.toHaveBeenCalled();
144154
});
145155

146-
it("retries when bootstrap returns without making the channel send-capable", () => {
147-
const registry = createEmptyPluginRegistry();
148-
registry.channels = [
149-
{
150-
pluginId: "discord",
151-
plugin: { id: "discord", meta: {} },
152-
source: "setup",
153-
},
154-
] as never;
155-
setActivePluginRegistry(registry);
156-
pinActivePluginChannelRegistry(registry);
156+
it("does not retry an unusable replacement registry in the same generation", () => {
157+
pinDiscordSetupShell();
158+
loaderMocks.resolveRuntimePluginRegistry.mockImplementation(() => {
159+
setActivePluginRegistry(createEmptyPluginRegistry());
160+
});
157161

158162
bootstrapOutboundChannelPlugin({
159163
channel: "discord",
@@ -164,6 +168,39 @@ describe("bootstrapOutboundChannelPlugin", () => {
164168
cfg: discordConfig,
165169
});
166170

171+
expect(loaderMocks.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1);
172+
});
173+
174+
it("does not retry a thrown bootstrap in the same generation", () => {
175+
pinDiscordSetupShell();
176+
loaderMocks.resolveRuntimePluginRegistry.mockImplementation(() => {
177+
throw new Error("load failed");
178+
});
179+
180+
bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig });
181+
bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig });
182+
183+
expect(loaderMocks.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1);
184+
});
185+
186+
it("retries after the runtime config changes", () => {
187+
pinDiscordSetupShell();
188+
bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig });
189+
bootstrapOutboundChannelPlugin({ channel: "discord", cfg: updatedDiscordConfig });
190+
191+
expect(loaderMocks.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(2);
192+
});
193+
194+
it("retains failed attempts when distinct runtime configs interleave", () => {
195+
pinDiscordSetupShell();
196+
loaderMocks.resolveRuntimePluginRegistry.mockImplementation(() => {
197+
setActivePluginRegistry(createEmptyPluginRegistry());
198+
});
199+
200+
bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig });
201+
bootstrapOutboundChannelPlugin({ channel: "discord", cfg: updatedDiscordConfig });
202+
bootstrapOutboundChannelPlugin({ channel: "discord", cfg: discordConfig });
203+
167204
expect(loaderMocks.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(2);
168205
});
169206
});

src/infra/outbound/channel-bootstrap.runtime.ts

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// when only setup-shell metadata is active.
33
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
44
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
5+
import { resolveRuntimeConfigCacheKey } from "../../config/runtime-snapshot.js";
56
import type { OpenClawConfig } from "../../config/types.openclaw.js";
67
import { resolveRuntimePluginRegistry } from "../../plugins/loader.js";
78
import type { PluginChannelRegistration } from "../../plugins/registry-types.js";
@@ -13,11 +14,44 @@ import {
1314
} from "../../plugins/runtime.js";
1415
import type { DeliverableMessageChannel } from "../../utils/message-channel.js";
1516

16-
const bootstrapAttempts = new Set<string>();
17+
const MAX_BOOTSTRAP_CONFIG_GENERATIONS = 64;
18+
let bootstrapRegistryGeneration: string | undefined;
19+
const bootstrapAttemptedChannelsByConfig = new Map<string, Set<DeliverableMessageChannel>>();
1720

18-
/** Clears the per-registry channel bootstrap retry guard for isolated tests. */
21+
function resolveBootstrapRegistryGeneration(): string {
22+
return `${getActivePluginChannelRegistryVersion()}:${getActivePluginRegistryVersion()}`;
23+
}
24+
25+
function resolveBootstrapAttemptedChannels(cfg: OpenClawConfig): Set<DeliverableMessageChannel> {
26+
const registryGeneration = resolveBootstrapRegistryGeneration();
27+
if (registryGeneration !== bootstrapRegistryGeneration) {
28+
bootstrapRegistryGeneration = registryGeneration;
29+
bootstrapAttemptedChannelsByConfig.clear();
30+
}
31+
const configKey = resolveRuntimeConfigCacheKey(cfg);
32+
const existing = bootstrapAttemptedChannelsByConfig.get(configKey);
33+
if (existing) {
34+
bootstrapAttemptedChannelsByConfig.delete(configKey);
35+
bootstrapAttemptedChannelsByConfig.set(configKey, existing);
36+
return existing;
37+
}
38+
// Agent-scoped configs may interleave within one registry generation. Keep a
39+
// bounded LRU so one caller cannot evict another on every delivery attempt.
40+
if (bootstrapAttemptedChannelsByConfig.size >= MAX_BOOTSTRAP_CONFIG_GENERATIONS) {
41+
const oldestConfigKey = bootstrapAttemptedChannelsByConfig.keys().next().value;
42+
if (oldestConfigKey !== undefined) {
43+
bootstrapAttemptedChannelsByConfig.delete(oldestConfigKey);
44+
}
45+
}
46+
const attemptedChannels = new Set<DeliverableMessageChannel>();
47+
bootstrapAttemptedChannelsByConfig.set(configKey, attemptedChannels);
48+
return attemptedChannels;
49+
}
50+
51+
/** Clears the per-generation channel bootstrap retry guard for isolated tests. */
1952
export function resetOutboundChannelBootstrapStateForTests(): void {
20-
bootstrapAttempts.clear();
53+
bootstrapRegistryGeneration = undefined;
54+
bootstrapAttemptedChannelsByConfig.clear();
2155
}
2256

2357
function channelEntryCanSend(entry: PluginChannelRegistration | undefined): boolean {
@@ -59,13 +93,11 @@ export function bootstrapOutboundChannelPlugin(params: {
5993
return;
6094
}
6195

62-
const attemptKey = `${getActivePluginChannelRegistryVersion()}:${getActivePluginRegistryVersion()}:${params.channel}`;
63-
if (bootstrapAttempts.has(attemptKey)) {
96+
const attemptedChannels = resolveBootstrapAttemptedChannels(cfg);
97+
if (attemptedChannels.has(params.channel)) {
6498
return;
6599
}
66-
// Retry once per registry version/channel; failed loads clear the guard below
67-
// so config fixes in the same process can try again.
68-
bootstrapAttempts.add(attemptKey);
100+
attemptedChannels.add(params.channel);
69101

70102
const autoEnabled = applyPluginAutoEnable({ config: cfg });
71103
const defaultAgentId = resolveDefaultAgentId(autoEnabled.config);
@@ -80,10 +112,16 @@ export function bootstrapOutboundChannelPlugin(params: {
80112
allowGatewaySubagentBinding: true,
81113
},
82114
});
83-
if (!canResolveSendCapableChannel(params.channel)) {
84-
bootstrapAttempts.delete(attemptKey);
85-
}
86115
} catch {
87-
bootstrapAttempts.delete(attemptKey);
116+
// Best-effort bootstrap; the caller reports the unavailable channel.
117+
}
118+
// A bootstrap can replace the registry itself. Adopt that generation without
119+
// forgetting failures for interleaved configs; external replacements observed
120+
// before the next attempt still clear the guard above.
121+
bootstrapRegistryGeneration = resolveBootstrapRegistryGeneration();
122+
if (!canResolveSendCapableChannel(params.channel)) {
123+
// Loading can replace the active registry without making this channel usable.
124+
// Carry the failure forward so polling callers wait for config or registry reload.
125+
resolveBootstrapAttemptedChannels(cfg).add(params.channel);
88126
}
89127
}

src/infra/outbound/channel-resolution.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ describe("outbound channel resolution", () => {
492492
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
493493
});
494494

495-
it("retries registry loads after bootstrap does not make the channel send-capable", async () => {
495+
it("does not repeat registry loads after bootstrap misses in the same generation", async () => {
496496
getChannelPluginMock.mockReturnValue(undefined);
497497
const channelResolution = await importChannelResolution("bootstrap-retry");
498498

@@ -509,7 +509,7 @@ describe("outbound channel resolution", () => {
509509
cfg: { channels: {} } as never,
510510
allowBootstrap: true,
511511
});
512-
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(2);
512+
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
513513
});
514514

515515
it("allows another activation attempt when the pinned channel registry version changes", async () => {
@@ -532,6 +532,26 @@ describe("outbound channel resolution", () => {
532532
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(2);
533533
});
534534

535+
it("allows another activation attempt when the active registry version changes", async () => {
536+
getChannelPluginMock.mockReturnValue(undefined);
537+
const channelResolution = await importChannelResolution("active-version-change");
538+
539+
channelResolution.resolveOutboundChannelPlugin({
540+
channel: "alpha",
541+
cfg: { channels: {} } as never,
542+
allowBootstrap: true,
543+
});
544+
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(1);
545+
546+
getActivePluginRegistryVersionMock.mockReturnValue(2);
547+
channelResolution.resolveOutboundChannelPlugin({
548+
channel: "alpha",
549+
cfg: { channels: {} } as never,
550+
allowBootstrap: true,
551+
});
552+
expect(resolveRuntimePluginRegistryMock).toHaveBeenCalledTimes(2);
553+
});
554+
535555
it("resolves message adapters through the activation-aware channel plugin path", async () => {
536556
const message = { send: { text: vi.fn() } };
537557
const plugin = { id: "alpha", message };

0 commit comments

Comments
 (0)