diff --git a/extensions/memory-core/src/dreaming.test.ts b/extensions/memory-core/src/dreaming.test.ts index f2386a200cc..4aac9a96c2a 100644 --- a/extensions/memory-core/src/dreaming.test.ts +++ b/extensions/memory-core/src/dreaming.test.ts @@ -42,6 +42,7 @@ type DreamingPluginApiTestDouble = { function createLogger() { return { + debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), @@ -1240,6 +1241,111 @@ describe("gateway startup reconciliation", () => { clearInternalHooks(); } }); + + it("does not emit the cron-unavailable warning on gateway:startup when deps.cron is missing (regression #69939)", async () => { + clearInternalHooks(); + const logger = createLogger(); + const api: DreamingPluginApiTestDouble = { + config: { plugins: { entries: {} } }, + pluginConfig: {}, + logger, + runtime: {}, + registerHook: (event: string, handler: Parameters[1]) => { + registerInternalHook(event, handler); + }, + on: vi.fn(), + }; + + try { + registerShortTermPromotionDreamingForTest(api); + // Simulate the startup race: gateway:startup fires before deps.cron is attached. + await triggerInternalHook( + createInternalHookEvent("gateway", "startup", "gateway:startup", { + cfg: { + hooks: { internal: { enabled: true } }, + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + frequency: "15 4 * * *", + timezone: "UTC", + }, + }, + }, + }, + }, + } as OpenClawConfig, + deps: {}, + }), + ); + + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining("cron service unavailable"), + ); + // The startup-path log should be demoted to debug instead. + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining("cron service not yet available at gateway:startup"), + ); + } finally { + clearInternalHooks(); + } + }); + + it("still warns on runtime reconciliation when cron remains unavailable (preserves #69939 genuine-failure signal)", async () => { + clearInternalHooks(); + const logger = createLogger(); + const onMock = vi.fn(); + const api: DreamingPluginApiTestDouble = { + config: { + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + frequency: "15 4 * * *", + timezone: "UTC", + }, + }, + }, + }, + }, + }, + pluginConfig: {}, + logger, + runtime: {}, + registerHook: (event: string, handler: Parameters[1]) => { + registerInternalHook(event, handler); + }, + on: onMock, + }; + + try { + registerShortTermPromotionDreamingForTest(api); + // Startup without cron — must stay silent on warn. + await triggerInternalHook( + createInternalHookEvent("gateway", "startup", "gateway:startup", { + cfg: api.config, + deps: {}, + }), + ); + expect(logger.warn).not.toHaveBeenCalled(); + + // Now a runtime heartbeat reconciliation happens and cron is still missing + // (e.g. the cron service genuinely failed to initialize). The warning must fire. + const beforeAgentReply = getBeforeAgentReplyHandler(onMock); + await beforeAgentReply( + { cleanedBody: "" }, + { trigger: "heartbeat", workspaceDir: ".", sessionKey: "agent:main:main:heartbeat" }, + ); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("cron service unavailable")); + } finally { + clearInternalHooks(); + } + }); }); describe("short-term dreaming trigger", () => { diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index 34ab9976376..0041e0dbc29 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -718,10 +718,21 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void const cron = resolveCronServiceFromStartupSource(startupCronSource); const configKey = runtimeConfigKey(config); if (!cron && config.enabled && !unavailableCronWarningEmitted) { - api.logger.warn( - "memory-core: managed dreaming cron could not be reconciled (cron service unavailable).", - ); - unavailableCronWarningEmitted = true; + // The gateway emits `gateway:startup` via a deferred setTimeout, and + // `deps.cron` may not be attached to the event context yet when memory-core's + // startup hook fires (see issue #69939). Avoid logging a confusing warning on + // the startup path — the runtime reconciliation path (heartbeat-driven) will + // still warn if the cron service remains unavailable after boot. + if (params.reason === "startup") { + api.logger.debug?.( + "memory-core: cron service not yet available at gateway:startup; deferring to runtime reconciliation.", + ); + } else { + api.logger.warn( + "memory-core: managed dreaming cron could not be reconciled (cron service unavailable).", + ); + unavailableCronWarningEmitted = true; + } } if (cron) { unavailableCronWarningEmitted = false;