diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da1ff8b109..af57a1af112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/update: run `doctor --non-interactive --fix` after Control UI global package updates before reporting success, so legacy config is migrated before the gateway restart. Thanks @stevenchouai. +- Gateway/cron: stop a lazy cron startup that loses a hot-reload race, preventing the old cron service from starting after reload has already replaced cron state. - Active Memory: apply `setupGraceTimeoutMs` to the embedded recall runner as well as the outer prompt-build watchdog, so very-cold first recalls keep the configured setup grace end-to-end. (#74480) Thanks @volcano303. - CLI/config: keep JSON dry-run patches validating touched channel configuration against bundled channel schemas even when the patch only contains SecretRef objects. - Plugins/tools: keep disabled bundled tool plugins out of explicit runtime allowlist ownership and fall back from loaded-but-empty channel registries to tool-bearing plugin registries, so Active Memory can use bundled `memory-core` search/get tools even when `memory-lancedb` is disabled. Fixes #76603. Thanks @jwong-art. diff --git a/src/gateway/server-cron-lazy.test.ts b/src/gateway/server-cron-lazy.test.ts index d6c5286c905..b86bced8da5 100644 --- a/src/gateway/server-cron-lazy.test.ts +++ b/src/gateway/server-cron-lazy.test.ts @@ -56,6 +56,35 @@ describe("createLazyGatewayCronState", () => { expect(cron.start).toHaveBeenCalledTimes(1); }); + it("does not start cron after stop wins the lazy startup race", async () => { + const cron = createCronService(); + hoisted.setState(createCronState(cron)); + + const lazy = createLazyGatewayCronState(createParams()); + const startPromise = lazy.cron.start(); + + lazy.cron.stop(); + await startPromise; + + expect(cron.start).not.toHaveBeenCalled(); + expect(cron.stop).toHaveBeenCalledTimes(1); + }); + + it("allows a stopped loaded cron service to start again", async () => { + const cron = createCronService(); + hoisted.setState(createCronState(cron)); + + const lazy = createLazyGatewayCronState(createParams()); + + await lazy.cron.start(); + lazy.cron.stop(); + await lazy.cron.start(); + + expect(hoisted.buildGatewayCronService).toHaveBeenCalledTimes(1); + expect(cron.stop).toHaveBeenCalledTimes(1); + expect(cron.start).toHaveBeenCalledTimes(2); + }); + it("keeps synchronous wake non-blocking before the cron service is loaded", async () => { const cron = createCronService(); hoisted.setState(createCronState(cron)); diff --git a/src/gateway/server-cron-lazy.ts b/src/gateway/server-cron-lazy.ts index ec416341593..08c3808c4ad 100644 --- a/src/gateway/server-cron-lazy.ts +++ b/src/gateway/server-cron-lazy.ts @@ -20,6 +20,7 @@ export function createLazyGatewayCronState(params: LazyGatewayCronParams): Gatew const cronEnabled = process.env.OPENCLAW_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false; let loaded: LoadedGatewayCronState | null = null; let loading: Promise | null = null; + let stopped = false; const load = async (): Promise => { if (loaded) { @@ -37,15 +38,39 @@ export function createLazyGatewayCronState(params: LazyGatewayCronParams): Gatew const cron: CronServiceContract = { async start() { + stopped = false; const resolved = await load(); + if (stopped) { + return; + } if (resolved.started) { return; } resolved.started = true; await resolved.state.cron.start(); + if (stopped && resolved.started) { + resolved.started = false; + resolved.state.cron.stop(); + } }, stop() { - loaded?.state.cron.stop(); + stopped = true; + if (loaded) { + loaded.started = false; + loaded.state.cron.stop(); + return; + } + if (loading) { + void loading + .then((resolved) => { + if (!stopped) { + return; + } + resolved.started = false; + resolved.state.cron.stop(); + }) + .catch(() => {}); + } }, async status() { return await (await load()).state.cron.status(); diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index bc9be7508d6..c9b6661465d 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -704,8 +704,11 @@ describe("gateway hot reload", () => { ); expect(hoisted.cronInstances.length).toBe(2); - expect(hoisted.cronInstances[0].stop).toHaveBeenCalledTimes(1); - expect(hoisted.cronInstances[1].start).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect( + hoisted.cronInstances.some((instance) => instance.start.mock.calls.length === 1), + ).toBe(true); + }); expect(hoisted.providerManager.stopChannel).toHaveBeenCalledTimes(5); expect(hoisted.providerManager.startChannel).toHaveBeenCalledTimes(5);