fix(gateway): stop lazy cron startup on hot reload

This commit is contained in:
Peter Steinberger
2026-05-03 13:43:47 +01:00
parent ecb901ca39
commit f30dd51d5f
4 changed files with 61 additions and 3 deletions

View File

@@ -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.

View File

@@ -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));

View File

@@ -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<LoadedGatewayCronState> | null = null;
let stopped = false;
const load = async (): Promise<LoadedGatewayCronState> => {
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();

View File

@@ -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);