From abc3fa0396189a20b60b8b99539d616f5a0f6a8d Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:09:52 +0000 Subject: [PATCH] fix(memory-core): keep startup cron retries quiet (#89075) Summary: - The branch adds a memory-core `startup_retry` reconciliation mode and regression tests for quiet startup retries, retry-window exhaustion, and live-config retry semantics. - PR surface: Source +9, Tests +114. Total +123 across 2 files. - Reproducibility: yes. from source: current main routes the first startup retry through runtime reconciliatio ... st expects the warn-level `cron service unavailable` log. I did not execute tests in this read-only review. Automerge notes: - Ran the ClawSweeper repair loop before final review. - Included post-review commit in the final squash: fix(memory-core): keep startup cron retries quiet Validation: - ClawSweeper review passed for head 7220f940d0ab96489f54564caa9644bde73559dd. - Required merge gates passed before the squash merge. Prepared head SHA: 7220f940d0ab96489f54564caa9644bde73559dd Review: https://github.com/openclaw/openclaw/pull/89075#issuecomment-4592446250 Co-authored-by: bennewell35 Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com> --- extensions/memory-core/src/dreaming.test.ts | 116 +++++++++++++++++++- extensions/memory-core/src/dreaming.ts | 27 +++-- 2 files changed, 133 insertions(+), 10 deletions(-) diff --git a/extensions/memory-core/src/dreaming.test.ts b/extensions/memory-core/src/dreaming.test.ts index 7ae1a5a882cd..703161f16947 100644 --- a/extensions/memory-core/src/dreaming.test.ts +++ b/extensions/memory-core/src/dreaming.test.ts @@ -1682,7 +1682,8 @@ describe("gateway startup reconciliation", () => { await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS); expect(harness.addCalls).toHaveLength(0); - expectLogContains(logger.warn, "cron service unavailable"); + expectLogNotContains(logger.warn, "cron service unavailable"); + expectLogContains(logger.debug, "cron service not yet available at gateway_start"); cronAvailable = true; await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS); @@ -1701,6 +1702,58 @@ describe("gateway startup reconciliation", () => { } }); + it("keeps startup cron retry warnings quiet until the retry window is exhausted", async () => { + vi.useFakeTimers(); + 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: {}, + on: onMock, + }; + + try { + registerShortTermPromotionDreamingForTest(api); + await triggerGatewayStart(onMock, { + config: api.config, + getCron: () => undefined, + }); + + expectLogContains(logger.debug, "cron service not yet available at gateway_start"); + + await vi.advanceTimersByTimeAsync( + constants.STARTUP_CRON_RETRY_DELAY_MS * (constants.STARTUP_CRON_RETRY_MAX_ATTEMPTS - 1), + ); + expectLogNotContains(logger.warn, "cron service unavailable"); + + await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS); + + expectLogContains(logger.warn, "cron service unavailable"); + expect(logger.warn).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + await triggerGatewayStop(onMock); + clearInternalHooks(); + } + }); + it("retries disabled startup cleanup until cron is available", async () => { vi.useFakeTimers(); clearInternalHooks(); @@ -1829,6 +1882,67 @@ describe("gateway startup reconciliation", () => { } }); + it("does not recreate startup cron from stale enabled config after live memory-core config is removed", async () => { + vi.useFakeTimers(); + clearInternalHooks(); + const logger = createLogger(); + const harness = createCronHarness(); + const onMock = vi.fn(); + const runtimeCurrentConfig = vi.fn( + () => + ({ + plugins: { + entries: {}, + }, + }) as OpenClawConfig, + ); + const api: DreamingPluginApiTestDouble = { + config: { + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + frequency: "15 4 * * *", + timezone: "UTC", + }, + }, + }, + }, + }, + } as OpenClawConfig, + pluginConfig: {}, + logger, + runtime: { + config: { + current: runtimeCurrentConfig, + }, + }, + on: onMock, + }; + + try { + registerShortTermPromotionDreamingForTest(api); + let cronAvailable = false; + await triggerGatewayStart(onMock, { + config: api.config, + getCron: () => (cronAvailable ? harness.cron : undefined), + }); + + cronAvailable = true; + await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS); + + expect(runtimeCurrentConfig).toHaveBeenCalled(); + expect(harness.addCalls).toHaveLength(0); + expectLogNotContains(logger.warn, "cron service unavailable"); + } finally { + vi.useRealTimers(); + await triggerGatewayStop(onMock).catch(() => undefined); + clearInternalHooks(); + } + }); + it("clears pending startup cron retry on gateway stop", async () => { vi.useFakeTimers(); clearInternalHooks(); diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index 9d22af6577bf..fb53c1f2fb76 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -760,18 +760,18 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void ].join("|"); const reconcileManagedDreamingCron = async (params: { - reason: "startup" | "runtime"; + reason: "startup" | "startup_retry" | "runtime"; startupConfig?: OpenClawConfig; startupCron?: (() => CronServiceLike | null) | null; }): Promise => { const startupCfg = params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig(); const pluginConfig = - params.reason === "runtime" - ? resolveMemoryCorePluginConfig(startupCfg) - : (resolveMemoryCorePluginConfig(startupCfg) ?? + params.reason === "startup" + ? (resolveMemoryCorePluginConfig(startupCfg) ?? resolveMemoryCorePluginConfig(api.config) ?? - api.pluginConfig); + api.pluginConfig) + : resolveMemoryCorePluginConfig(startupCfg); const config = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg: startupCfg, @@ -784,7 +784,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void // This handles the case where the cron service was not yet available during // gateway_start (250ms deferred init race in startGatewaySidecars) but is // available now. Fixes #67362. - if (!cron && params.reason === "runtime" && gatewayContext) { + if (!cron && params.reason !== "startup" && gatewayContext) { try { cron = resolveCronServiceFromGatewayContext(gatewayContext); if (cron) { @@ -800,7 +800,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void // Avoid a noisy startup-path warning when the gateway has not exposed cron yet. // The runtime reconciliation path (heartbeat-driven) will still warn if the // cron service remains unavailable after boot. - if (params.reason === "startup") { + if (params.reason === "startup" || params.reason === "startup_retry") { api.logger.debug?.( "memory-core: cron service not yet available at gateway_start; deferring to runtime reconciliation.", ); @@ -815,6 +815,11 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void unavailableCronWarningEmitted = false; clearStartupCronRetry(); } + // Startup retries only probe cron availability; the exhausted retry path + // re-enters runtime reconciliation so persistent failures still warn once. + if (!cron && params.reason === "startup_retry") { + return config; + } if (params.reason === "runtime") { const now = Date.now(); const withinThrottleWindow = @@ -852,12 +857,16 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void return; } startupCronRetryAttempts += 1; - void reconcileManagedDreamingCron({ reason: "runtime" }) - .then(() => { + void reconcileManagedDreamingCron({ reason: "startup_retry" }) + .then(async () => { if (disposed || hasStartupCron()) { clearStartupCronRetry(); return; } + if (startupCronRetryAttempts >= STARTUP_CRON_RETRY_MAX_ATTEMPTS) { + await reconcileManagedDreamingCron({ reason: "runtime" }); + return; + } scheduleStartupCronRetry(); }) .catch((err: unknown) => {