Dreaming: throttle cron lifecycle reconciliation

This commit is contained in:
Mariano Belinky
2026-04-09 23:48:20 +02:00
parent c2491b409c
commit 457e92fdb6
3 changed files with 106 additions and 2 deletions

View File

@@ -30,7 +30,7 @@ Docs: https://docs.openclaw.ai
- Sessions/model selection: preserve catalog-backed session model labels and keep already-qualified session model refs stable when catalog metadata is unavailable, so Control UI model selection survives reloads without bogus provider-prefixed values. (#61382) Thanks @Mule-ME.
- Gateway/startup: keep WebSocket RPC available while channels and plugin sidecars start, hold `chat.history` unavailable until startup sidecars finish so synchronous history reads cannot stall startup (reported in #63450), refresh advertised gateway methods after deferred plugin reloads, and enforce the pre-auth WebSocket upgrade budget before the no-handler 503 path so upgrade floods cannot bypass connection limits during that window. (#63480) Thanks @neeravmakwana.
- Dreaming/cron: reconcile managed dreaming cron from the resolved gateway startup config so boot-time schedule recovery respects the configured cadence and timezone. (#63873) Thanks @mbelinky.
- Dreaming/cron: keep managed dreaming cron reconciled after startup by rechecking lifecycle state during runtime config/plugin changes, recovering missing managed jobs, and applying cadence/timezone updates idempotently.
- Dreaming/cron: keep managed dreaming cron reconciled after startup by rechecking lifecycle state during runtime config/plugin changes, recovering missing managed jobs, and applying cadence/timezone updates idempotently. (#63929) Thanks @mbelinky.
- Gateway/tailscale: start Tailscale exposure and the gateway update check before awaiting channel and plugin sidecar startup so remote operators are not locked out when startup sidecars stall.
- QQBot/streaming: make block streaming configurable per QQ bot account via `streaming.mode` (`"partial"` | `"off"`, default `"partial"`) instead of hardcoding it off, so responses can be delivered incrementally. (#63746)
- Dreaming/gateway: require `operator.admin` for persistent `/dreaming on|off` changes and treat missing gateway client scopes as unprivileged instead of silently allowing config writes. (#63872) Thanks @mbelinky.

View File

@@ -49,12 +49,14 @@ function createCronHarness(
opts?: { removeResult?: "boolean" | "unknown"; removeThrowsForIds?: string[] },
) {
const jobs: CronJobLike[] = [...initialJobs];
let listCalls = 0;
const addCalls: CronAddInput[] = [];
const updateCalls: Array<{ id: string; patch: CronPatch }> = [];
const removeCalls: string[] = [];
const cron: CronParam = {
async list() {
listCalls += 1;
return jobs.map((job) => ({
...job,
...(job.schedule ? { schedule: { ...job.schedule } } : {}),
@@ -111,7 +113,16 @@ function createCronHarness(
},
};
return { cron, jobs, addCalls, updateCalls, removeCalls };
return {
cron,
jobs,
addCalls,
updateCalls,
removeCalls,
get listCalls() {
return listCalls;
},
};
}
function getBeforeAgentReplyHandler(
@@ -963,6 +974,63 @@ describe("gateway startup reconciliation", () => {
clearInternalHooks();
}
});
it("does not reconcile managed cron on every repeated runtime reply", async () => {
clearInternalHooks();
const logger = createLogger();
const harness = createCronHarness();
const onMock = vi.fn();
const now = Date.parse("2026-04-10T12:00:00Z");
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now);
const api = {
config: {
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
frequency: "0 2 * * *",
timezone: "UTC",
},
},
},
},
},
},
pluginConfig: {},
logger,
runtime: {},
registerHook: (event: string, handler: Parameters<typeof registerInternalHook>[1]) => {
registerInternalHook(event, handler);
},
on: onMock,
} as never;
try {
registerShortTermPromotionDreaming(api);
await triggerInternalHook(
createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: api.config,
deps: { cron: harness.cron },
}),
);
expect(harness.listCalls).toBe(1);
const beforeAgentReply = getBeforeAgentReplyHandler(onMock);
await beforeAgentReply({ cleanedBody: "hello" }, { trigger: "user", workspaceDir: "." });
await beforeAgentReply(
{ cleanedBody: "hello again" },
{ trigger: "user", workspaceDir: "." },
);
expect(harness.listCalls).toBe(2);
} finally {
nowSpy.mockRestore();
clearInternalHooks();
}
});
});
describe("short-term dreaming trigger", () => {

View File

@@ -35,6 +35,7 @@ const LEGACY_LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__";
const LEGACY_REM_SLEEP_CRON_NAME = "Memory REM Dreaming";
const LEGACY_REM_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.rem]";
const LEGACY_REM_SLEEP_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__";
const RUNTIME_CRON_RECONCILE_INTERVAL_MS = 60_000;
type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
@@ -618,6 +619,25 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void {
let startupCronSource: StartupCronSourceRefs | null = null;
let unavailableCronWarningEmitted = false;
let lastRuntimeReconcileAtMs = 0;
let lastRuntimeConfigKey: string | null = null;
let lastRuntimeCronRef: CronServiceLike | null = null;
const runtimeConfigKey = (config: ShortTermPromotionDreamingConfig): string =>
[
config.enabled ? "enabled" : "disabled",
config.cron,
config.timezone ?? "",
String(config.limit),
String(config.minScore),
String(config.minRecallCount),
String(config.minUniqueQueries),
String(config.recencyHalfLifeDays ?? ""),
String(config.maxAgeDays ?? ""),
config.verboseLogging ? "verbose" : "quiet",
config.storage?.mode ?? "",
config.storage?.separateReports ? "separate" : "inline",
].join("|");
const reconcileManagedDreamingCron = async (params: {
reason: "startup" | "runtime";
@@ -638,6 +658,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
startupCronSource = resolveStartupCronSourceFromEvent(params.startupEvent);
}
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).",
@@ -647,6 +668,21 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
if (cron) {
unavailableCronWarningEmitted = false;
}
if (params.reason === "runtime") {
const now = Date.now();
const withinThrottleWindow =
now - lastRuntimeReconcileAtMs < RUNTIME_CRON_RECONCILE_INTERVAL_MS;
if (
withinThrottleWindow &&
lastRuntimeConfigKey === configKey &&
lastRuntimeCronRef === cron
) {
return config;
}
lastRuntimeReconcileAtMs = now;
lastRuntimeConfigKey = configKey;
lastRuntimeCronRef = cron;
}
await reconcileShortTermDreamingCronJob({
cron,
config,