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 7220f940d0.
- Required merge gates passed before the squash merge.

Prepared head SHA: 7220f940d0
Review: https://github.com/openclaw/openclaw/pull/89075#issuecomment-4592446250

Co-authored-by: bennewell35 <newelljben@gmail.com>
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>
This commit is contained in:
clawsweeper[bot]
2026-06-02 09:09:52 +00:00
committed by GitHub
parent db576c4a2d
commit abc3fa0396
2 changed files with 133 additions and 10 deletions

View File

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

View File

@@ -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<ShortTermPromotionDreamingConfig> => {
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) => {