fix(gateway): restore dreaming startup reconciliation (#64258)

* gateway: restore dreaming startup reconciliation

* gateway: harden dreaming startup reconciliation

---------

Co-authored-by: mbelinky <mbelinky@users.noreply.github.com>
This commit is contained in:
Mariano
2026-04-10 15:02:19 +02:00
committed by GitHub
parent 383ea34efe
commit 03e19c5436
12 changed files with 283 additions and 17 deletions
+1
View File
@@ -98,6 +98,7 @@ Docs: https://docs.openclaw.ai
- Heartbeat: ignore doc-only Markdown fence markers in the default `HEARTBEAT.md` template so comment-only heartbeat scaffolds skip API calls again. (#63434) Thanks @ravyg.
- Control UI/BTW: render `/btw` side results as dismissible ephemeral cards in the browser, send `/btw` immediately during active runs, and clear stale BTW cards on reset flows so webchat matches the intended detached side-question behavior. (#64290) Thanks @ngutman.
- Reply/skills: keep resolved skill and memory secret config stable through embedded reply runs so raw SecretRefs in secondary skill settings no longer crash replies when the gateway already has the live env. (#64249) Thanks @mbelinky.
- Dreaming/startup: keep plugin-registered startup hooks alive across workspace hook reloads and include dreaming startup owners in the gateway startup plugin scope, so managed Dreaming cron registration comes back reliably after gateway boot. (#62327) Thanks @mbelinky.
## 2026.4.9
@@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => {
const startPluginServices = vi.fn(async () => null);
const startGmailWatcherWithLogs = vi.fn(async () => undefined);
const clearInternalHooks = vi.fn();
const loadInternalHooks = vi.fn(async () => 0);
const setInternalHooksEnabled = vi.fn();
const startGatewayMemoryBackend = vi.fn(async () => undefined);
const scheduleGatewayUpdateCheck = vi.fn(() => () => {});
const startGatewayTailscaleExposure = vi.fn(async () => null);
@@ -20,8 +20,8 @@ const hoisted = vi.hoisted(() => {
return {
startPluginServices,
startGmailWatcherWithLogs,
clearInternalHooks,
loadInternalHooks,
setInternalHooksEnabled,
startGatewayMemoryBackend,
scheduleGatewayUpdateCheck,
startGatewayTailscaleExposure,
@@ -54,8 +54,8 @@ vi.mock("../hooks/gmail-watcher-lifecycle.js", () => ({
}));
vi.mock("../hooks/internal-hooks.js", () => ({
clearInternalHooks: hoisted.clearInternalHooks,
createInternalHookEvent: vi.fn(() => ({})),
setInternalHooksEnabled: hoisted.setInternalHooksEnabled,
triggerInternalHook: vi.fn(async () => undefined),
}));
@@ -104,8 +104,8 @@ describe("startGatewayPostAttachRuntime", () => {
beforeEach(() => {
hoisted.startPluginServices.mockClear();
hoisted.startGmailWatcherWithLogs.mockClear();
hoisted.clearInternalHooks.mockClear();
hoisted.loadInternalHooks.mockClear();
hoisted.setInternalHooksEnabled.mockClear();
hoisted.startGatewayMemoryBackend.mockClear();
hoisted.scheduleGatewayUpdateCheck.mockClear();
hoisted.startGatewayTailscaleExposure.mockClear();
@@ -157,5 +157,6 @@ describe("startGatewayPostAttachRuntime", () => {
expect(unavailableGatewayMethods.has("chat.history")).toBe(false);
expect(hoisted.startPluginServices).toHaveBeenCalledTimes(1);
expect(hoisted.setInternalHooksEnabled).toHaveBeenCalledWith(false);
});
});
+2 -2
View File
@@ -21,8 +21,8 @@ import { resolveStateDir } from "../config/paths.js";
import type { GatewayTailscaleMode } from "../config/types.gateway.js";
import { startGmailWatcherWithLogs } from "../hooks/gmail-watcher-lifecycle.js";
import {
clearInternalHooks,
createInternalHookEvent,
setInternalHooksEnabled,
triggerInternalHook,
} from "../hooks/internal-hooks.js";
import { loadInternalHooks } from "../hooks/loader.js";
@@ -145,7 +145,7 @@ export async function startGatewaySidecars(params: {
}
try {
clearInternalHooks();
setInternalHooksEnabled(params.cfg.hooks?.internal?.enabled !== false);
const loadedCount = await loadInternalHooks(params.cfg, params.defaultWorkspaceDir);
if (loadedCount > 0) {
params.logHooks.info(
+13
View File
@@ -9,6 +9,7 @@ import {
isMessageReceivedEvent,
isMessageSentEvent,
registerInternalHook,
setInternalHooksEnabled,
triggerInternalHook,
unregisterInternalHook,
type AgentBootstrapHookContext,
@@ -22,10 +23,12 @@ const INTERNAL_HOOK_HANDLERS_KEY = Symbol.for("openclaw.internalHookHandlers");
describe("hooks", () => {
beforeEach(() => {
clearInternalHooks();
setInternalHooksEnabled(true);
});
afterEach(() => {
clearInternalHooks();
setInternalHooksEnabled(true);
});
describe("registerInternalHook", () => {
@@ -146,6 +149,16 @@ describe("hooks", () => {
await expect(triggerInternalHook(event)).resolves.not.toThrow();
});
it("skips hook execution when internal hooks are disabled", async () => {
const handler = vi.fn();
registerInternalHook("command:new", handler);
setInternalHooksEnabled(false);
await triggerInternalHook(createInternalHookEvent("command", "new", "test-session"));
expect(handler).not.toHaveBeenCalled();
});
it("stores handlers in the global singleton registry", async () => {
const globalHooks = resolveGlobalSingleton<Map<string, Array<(event: unknown) => unknown>>>(
INTERNAL_HOOK_HANDLERS_KEY,
+12
View File
@@ -204,6 +204,11 @@ const handlers = resolveGlobalSingleton<Map<string, InternalHookHandler[]>>(
INTERNAL_HOOK_HANDLERS_KEY,
() => new Map<string, InternalHookHandler[]>(),
);
const INTERNAL_HOOKS_ENABLED_KEY = Symbol.for("openclaw.internalHooksEnabled");
const internalHooksEnabledState = resolveGlobalSingleton<{ enabled: boolean }>(
INTERNAL_HOOKS_ENABLED_KEY,
() => ({ enabled: true }),
);
const log = createSubsystemLogger("internal-hooks");
/**
@@ -262,6 +267,10 @@ export function clearInternalHooks(): void {
handlers.clear();
}
export function setInternalHooksEnabled(enabled: boolean): void {
internalHooksEnabledState.enabled = enabled;
}
/**
* Get all registered event keys (useful for debugging)
*/
@@ -288,6 +297,9 @@ export function hasInternalHookListeners(type: InternalHookEventType, action: st
* @param event - The event to trigger
*/
export async function triggerInternalHook(event: InternalHookEvent): Promise<void> {
if (!internalHooksEnabledState.enabled) {
return;
}
if (!hasInternalHookListeners(event.type, event.action)) {
return;
}
+38
View File
@@ -12,6 +12,8 @@ import {
getRegisteredEventKeys,
triggerInternalHook,
createInternalHookEvent,
registerInternalHook,
setInternalHooksEnabled,
} from "./internal-hooks.js";
import { loadInternalHooks } from "./loader.js";
@@ -27,6 +29,7 @@ describe("loader", () => {
beforeEach(async () => {
clearInternalHooks();
setInternalHooksEnabled(true);
// Create a temp directory for test modules
tmpDir = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(tmpDir, { recursive: true });
@@ -116,6 +119,7 @@ describe("loader", () => {
afterEach(async () => {
clearInternalHooks();
setInternalHooksEnabled(true);
loggingState.rawConsole = null;
setLoggerOverride(null);
envSnapshot.restore();
@@ -222,6 +226,40 @@ describe("loader", () => {
expect(keys).toContain("command:stop");
});
it("preserves plugin-registered hooks when workspace hooks reload", async () => {
const pluginHandler = vi.fn();
registerInternalHook("gateway:startup", pluginHandler);
const count = await loadInternalHooks(createEnabledHooksConfig(), tmpDir);
expect(count).toBe(0);
expect(getRegisteredEventKeys()).toContain("gateway:startup");
await triggerInternalHook(createInternalHookEvent("gateway", "startup", "gateway:startup"));
expect(pluginHandler).toHaveBeenCalledTimes(1);
});
it("replaces prior workspace hook registrations instead of duplicating them", async () => {
await writeHandlerModule(
"legacy-handler.js",
'export default async function(event) { event.messages.push("reloadable-hook"); }\n',
);
const cfg = createEnabledHooksConfig([
{
event: "command:new",
module: "legacy-handler.js",
},
]);
expect(await loadInternalHooks(cfg, tmpDir)).toBe(1);
expect(await loadInternalHooks(cfg, tmpDir)).toBe(1);
const event = createInternalHookEvent("command", "new", "test-session");
await triggerInternalHook(event);
expect(event.messages.filter((message) => message === "reloadable-hook")).toHaveLength(1);
});
it("should support named exports", async () => {
// Create a handler module with named export
const handlerCode = `
+22 -1
View File
@@ -11,16 +11,23 @@ import type { OpenClawConfig } from "../config/config.js";
import { openBoundaryFile } from "../infra/boundary-file-read.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { shouldIncludeHook } from "./config.js";
import { buildImportUrl } from "./import-url.js";
import type { InternalHookHandler } from "./internal-hooks.js";
import { registerInternalHook } from "./internal-hooks.js";
import { registerInternalHook, unregisterInternalHook } from "./internal-hooks.js";
import { getLegacyInternalHookHandlers } from "./legacy-config.js";
import { resolveFunctionModuleExport } from "./module-loader.js";
import { loadWorkspaceHookEntries } from "./workspace.js";
const log = createSubsystemLogger("hooks:loader");
const LOADED_INTERNAL_HOOK_REGISTRATIONS_KEY = Symbol.for(
"openclaw.loadedInternalHookRegistrations",
);
const loadedHookRegistrations = resolveGlobalSingleton<
Array<{ event: string; handler: InternalHookHandler }>
>(LOADED_INTERNAL_HOOK_REGISTRATIONS_KEY, () => []);
function safeLogValue(value: string): string {
return sanitizeForLog(value);
@@ -40,6 +47,16 @@ function maybeWarnTrustedHookSource(source: string): void {
}
}
function resetLoadedInternalHooks(): void {
while (loadedHookRegistrations.length > 0) {
const registration = loadedHookRegistrations.pop();
if (!registration) {
continue;
}
unregisterInternalHook(registration.event, registration.handler);
}
}
/**
* Load and register all hook handlers
*
@@ -67,6 +84,8 @@ export async function loadInternalHooks(
bundledHooksDir?: string;
},
): Promise<number> {
resetLoadedInternalHooks();
// Hooks are on by default; only skip when explicitly disabled.
if (cfg.hooks?.internal?.enabled === false) {
return 0;
@@ -136,6 +155,7 @@ export async function loadInternalHooks(
for (const event of events) {
registerInternalHook(event, handler);
loadedHookRegistrations.push({ event, handler });
}
log.debug(
@@ -225,6 +245,7 @@ export async function loadInternalHooks(
}
registerInternalHook(handlerConfig.event, handler);
loadedHookRegistrations.push({ event: handlerConfig.event, handler });
log.debug(
`Registered hook (legacy): ${safeLogValue(handlerConfig.event)} -> ${safeLogValue(modulePath)}${exportName !== "default" ? `#${safeLogValue(exportName)}` : ""}`,
);
+3
View File
@@ -7,6 +7,7 @@ import type { OpenClawConfig } from "../config/config.js";
import {
clearInternalHooks,
createInternalHookEvent,
setInternalHooksEnabled,
triggerInternalHook,
} from "./internal-hooks.js";
import { loadInternalHooks } from "./loader.js";
@@ -24,6 +25,7 @@ describe("bundle plugin hooks", () => {
beforeEach(async () => {
clearInternalHooks();
setInternalHooksEnabled(true);
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fsp.mkdir(workspaceDir, { recursive: true });
previousBundledHooksDir = process.env.OPENCLAW_BUNDLED_HOOKS_DIR;
@@ -32,6 +34,7 @@ describe("bundle plugin hooks", () => {
afterEach(() => {
clearInternalHooks();
setInternalHooksEnabled(true);
if (previousBundledHooksDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_HOOKS_DIR;
} else {
+89
View File
@@ -49,6 +49,24 @@ function createManifestRegistryFixture() {
providers: ["demo-provider"],
cliBackends: ["demo-cli"],
},
{
id: "memory-core",
kind: "memory",
channels: [],
origin: "bundled",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
{
id: "memory-lancedb",
kind: "memory",
channels: [],
origin: "bundled",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
},
{
id: "voice-call",
channels: [],
@@ -242,4 +260,75 @@ describe("resolveGatewayStartupPluginIds", () => {
expected: ["demo-channel", "browser"],
});
});
it("includes memory-core at startup when dreaming is enabled", () => {
expectStartupPluginIdsCase({
config: {
channels: {},
plugins: {
entries: {
"memory-core": {
enabled: true,
config: {
dreaming: {
enabled: true,
},
},
},
},
},
} as OpenClawConfig,
expected: ["browser", "memory-core"],
});
});
it("includes the selected memory-slot plugin and memory-core when dreaming is enabled", () => {
expectStartupPluginIdsCase({
config: {
plugins: {
slots: {
memory: "memory-lancedb",
},
entries: {
"memory-core": {
enabled: true,
},
"memory-lancedb": {
enabled: true,
config: {
dreaming: {
enabled: true,
},
},
},
},
},
} as OpenClawConfig,
expected: ["demo-channel", "browser", "memory-core", "memory-lancedb"],
});
});
it("does not bypass activation policy for dreaming startup owners", () => {
expectStartupPluginIdsCase({
config: {
channels: {},
plugins: {
slots: {
memory: "memory-lancedb",
},
entries: {
"memory-lancedb": {
enabled: false,
config: {
dreaming: {
enabled: true,
},
},
},
},
},
} as OpenClawConfig,
expected: ["browser"],
});
});
});
+31 -8
View File
@@ -1,5 +1,10 @@
import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveMemoryDreamingConfig,
resolveMemoryDreamingPluginConfig,
resolveMemoryDreamingPluginId,
} from "../memory-host-sdk/dreaming.js";
import {
createPluginActivationSource,
normalizePluginsConfig,
@@ -28,6 +33,17 @@ function isGatewayStartupSidecar(plugin: PluginManifestRecord): boolean {
return plugin.channels.length === 0 && !hasRuntimeContractSurface(plugin);
}
function resolveGatewayStartupDreamingPluginIds(config: OpenClawConfig): Set<string> {
const dreamingConfig = resolveMemoryDreamingConfig({
pluginConfig: resolveMemoryDreamingPluginConfig(config),
cfg: config,
});
if (!dreamingConfig.enabled) {
return new Set();
}
return new Set(["memory-core", resolveMemoryDreamingPluginId(config)]);
}
export function resolveChannelPluginIds(params: {
config: OpenClawConfig;
workspaceDir?: string;
@@ -96,6 +112,7 @@ export function resolveGatewayStartupPluginIds(params: {
const activationSource = createPluginActivationSource({
config: params.activationSourceConfig ?? params.config,
});
const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config);
return loadPluginManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
@@ -105,9 +122,6 @@ export function resolveGatewayStartupPluginIds(params: {
if (plugin.channels.some((channelId) => configuredChannelIds.has(channelId))) {
return true;
}
if (!isGatewayStartupSidecar(plugin)) {
return false;
}
const activationState = resolveEffectivePluginActivationState({
id: plugin.id,
origin: plugin.origin,
@@ -116,13 +130,22 @@ export function resolveGatewayStartupPluginIds(params: {
enabledByDefault: plugin.enabledByDefault,
activationSource,
});
if (!activationState.enabled) {
const isAllowedStartupActivation = (): boolean => {
if (!activationState.enabled) {
return false;
}
if (plugin.origin !== "bundled") {
return activationState.explicitlyEnabled;
}
return activationState.source === "explicit" || activationState.source === "default";
};
if (startupDreamingPluginIds.has(plugin.id)) {
return isAllowedStartupActivation();
}
if (!isGatewayStartupSidecar(plugin)) {
return false;
}
if (plugin.origin !== "bundled") {
return activationState.explicitlyEnabled;
}
return activationState.source === "explicit" || activationState.source === "default";
return isAllowedStartupActivation();
})
.map((plugin) => plugin.id);
}
+49 -1
View File
@@ -2,7 +2,12 @@ import fs from "node:fs";
import path from "node:path";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { clearInternalHooks, getRegisteredEventKeys } from "../hooks/internal-hooks.js";
import {
clearInternalHooks,
createInternalHookEvent,
getRegisteredEventKeys,
triggerInternalHook,
} from "../hooks/internal-hooks.js";
import { emitDiagnosticEvent } from "../infra/diagnostic-events.js";
import { withEnv } from "../test-utils/env.js";
import { clearPluginCommands, getPluginCommandSpecs } from "./command-registry-state.js";
@@ -1483,6 +1488,49 @@ module.exports = { id: "throws-after-import", register() {} };`,
clearInternalHooks();
});
it("replaces prior plugin hook registrations on activating reloads", async () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "internal-hook-reload",
filename: "internal-hook-reload.cjs",
body: `module.exports = {
id: "internal-hook-reload",
register(api) {
api.registerHook(
"gateway:startup",
(event) => {
event.messages.push("reload-hook-fired");
},
{ name: "reload-hook" },
);
},
};`,
});
clearInternalHooks();
const loadOptions = {
cache: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["internal-hook-reload"],
},
},
onlyPluginIds: ["internal-hook-reload"],
} as const;
loadOpenClawPlugins(loadOptions);
loadOpenClawPlugins(loadOptions);
const event = createInternalHookEvent("gateway", "startup", "gateway:startup");
await triggerInternalHook(event);
expect(event.messages.filter((message) => message === "reload-hook-fired")).toHaveLength(1);
clearInternalHooks();
});
it("can scope bundled provider loads to deepseek without hanging", () => {
resetPluginLoaderTestStateForTest();
+18 -1
View File
@@ -4,7 +4,8 @@ import type { ChannelPlugin } from "../channels/plugins/types.js";
import { registerContextEngineForOwner } from "../context-engine/registry.js";
import type { OperatorScope } from "../gateway/method-scopes.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { registerInternalHook } from "../hooks/internal-hooks.js";
import { registerInternalHook, unregisterInternalHook } from "../hooks/internal-hooks.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import type { HookEntry } from "../hooks/types.js";
import {
NODE_EXEC_APPROVALS_COMMANDS,
@@ -160,6 +161,11 @@ const constrainLegacyPromptInjectionHook = (
export { createEmptyPluginRegistry } from "./registry-empty.js";
const ACTIVE_PLUGIN_HOOK_REGISTRATIONS_KEY = Symbol.for("openclaw.activePluginHookRegistrations");
const activePluginHookRegistrations = resolveGlobalSingleton<
Map<string, Array<{ event: string; handler: Parameters<typeof registerInternalHook>[1] }>>
>(ACTIVE_PLUGIN_HOOK_REGISTRATIONS_KEY, () => new Map());
export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registry = createEmptyPluginRegistry();
const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {}));
@@ -276,9 +282,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
return;
}
const previousRegistrations = activePluginHookRegistrations.get(name) ?? [];
for (const registration of previousRegistrations) {
unregisterInternalHook(registration.event, registration.handler);
}
const nextRegistrations: Array<{
event: string;
handler: Parameters<typeof registerInternalHook>[1];
}> = [];
for (const event of normalizedEvents) {
registerInternalHook(event, handler);
nextRegistrations.push({ event, handler });
}
activePluginHookRegistrations.set(name, nextRegistrations);
};
const registerGatewayMethod = (