From 529577e0457b575a741d0c35dff8c5b03c63a3b3 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Tue, 21 Apr 2026 18:51:43 -0700 Subject: [PATCH] fix(memory/dreaming): surface blocked status when heartbeat is disabled for main (#69875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(memory/dreaming): surface blocked status in memory status when heartbeat disabled for main Replace the hand-rolled heartbeat-rules logic in resolveDreamingBlockedReason with the shared resolveHeartbeatSummaryForAgent helper, promoted from core to the plugin-sdk via infra-runtime. Collapses the two disabled-reason branches into a single message that points at a new Troubleshooting section in the dreaming docs, so the silent-failure mode described in openclaw/openclaw#69843 becomes legible without the extension re-encoding heartbeat-enablement rules. Refs openclaw/openclaw#69843, openclaw/openclaw#46046. * refactor(memory/dreaming): share resolveDreamingBlockedReason across cli and /dreaming surfaces - Move resolveDreamingBlockedReason from cli.runtime.ts into dreaming.ts as an exported helper and pin its heartbeat check to DEFAULT_AGENT_ID (now exported from plugin-sdk/routing) so the status-line check agrees with the cron's hardcoded sessionTarget even when the configured default agent is not main. - Render the blocked reason from formatStatus in dreaming-command.ts directly under the enabled line, so /dreaming status, /dreaming on, /dreaming off, and bare /dreaming all flag that the cron is blocked instead of implying dreaming is healthy. - Tighten the blocked-reason text to lead with user impact ('dreaming is enabled but will not run because heartbeat is disabled for main'), so operators immediately understand the config is toggled on but nothing is actually running. - Tighten the dreaming Troubleshooting copy to name main explicitly and mention both surfaces. - Add tests locking the new behavior across cli.test.ts (default-agent=ops still reports blocked for main) and dreaming-command.test.ts (/dreaming status ordering, /dreaming on surfacing, healthy-heartbeat omission). Refs openclaw/openclaw#69843, openclaw/openclaw#46046. * fix(memory/dreaming): check heartbeat for the resolved default agent, not the literal 'main' sessionTarget: 'main' is a cron session-type enum variant meaning 'the default agent's main session', not an agent id (see src/cron/service/jobs.ts). buildManagedDreamingCronJob does not set agentId, and cron runtime resolves the missing agentId through resolveDefaultAgentId(cfg) before enqueuing or waking. The previous pin to DEFAULT_AGENT_ID could produce a false 'blocked' reading when a configured default agent is not 'main' and its heartbeat is fine, and could miss a real block when the default agent is not 'main' and that agent's heartbeat is actually off. Switch resolveDreamingBlockedReason to resolveDefaultAgentId(cfg) and interpolate the resolved agent id into the message so the blocked line names the agent whose heartbeat is the blocker. Introduce a narrow local CRON_SESSION_TARGET_MAIN constant for the cron session-type enum variant (used by the sessionTarget type and value) so the remaining 'main' literal is semantically distinct from any agent id. Revert the DEFAULT_AGENT_ID export addition on plugin-sdk/routing; memory-core no longer needs it. Update the Troubleshooting doc wording and the cli test that was locking the wrong behaviour. Refs openclaw/openclaw#69843, openclaw/openclaw#46046. * fix(memory/dreaming): align blocked check with server-cron wake's defaults-only heartbeat resolveDreamingBlockedReason was using resolveHeartbeatSummaryForAgent, which merges agents.defaults.heartbeat with agents.list[].heartbeat. The managed dreaming cron leaves job.agentId and job.sessionKey unset, so server-cron's wake wrapper cannot look up a per-agent entry and calls runHeartbeatOnce with agents.defaults.heartbeat only. Using the summary helper would disagree with the actual wake when the default agent overrides heartbeat.every differently from the defaults (either direction — false blocked when the override would run, or false healthy when defaults block). Mirror the wake path explicitly: rule-1 enablement via isHeartbeatEnabledForAgent against the default agent, rule-3 interval via resolveHeartbeatIntervalMs with defaults-only heartbeat config. Comment points at server-cron so a future cleanup of that latent override-propagation gap sees the coupling. Refs openclaw/openclaw#69843. --- CHANGELOG.md | 4 + docs/concepts/dreaming.md | 12 ++ extensions/memory-core/src/cli.runtime.ts | 9 +- extensions/memory-core/src/cli.test.ts | 129 ++++++++++++++++++ .../memory-core/src/dreaming-command.test.ts | 91 ++++++++++++ .../memory-core/src/dreaming-command.ts | 7 +- extensions/memory-core/src/dreaming.ts | 35 ++++- src/plugin-sdk/infra-runtime.ts | 1 + 8 files changed, 282 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b113bc2f32..b03cfb587f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ Docs: https://docs.openclaw.ai - Plugins/discovery: reject package plugin source entries that escape the package directory before explicit runtime entries or inferred built JavaScript peers can be used. (#69868) thanks @gumadeiras. - CLI/channels: resolve channel presence through a shared policy that keeps ambient env vars and stale persisted auth from surfacing disabled bundled plugins in status, doctor, security audit, and cron delivery validation unless the channel or plugin is effectively enabled or explicitly configured. (#69862) Thanks @gumadeiras. +### Fixes + +- memory-core/dreaming: surface a `Dreaming status: blocked` line in `openclaw memory status` when dreaming is enabled but the heartbeat that drives the managed cron is not firing for the default agent, and add a Troubleshooting section to the dreaming docs covering the two common causes (per-agent `heartbeat` blocks excluding `main`, and `heartbeat.every` set to `0`/empty/invalid), so the silent failure described in #69843 becomes legible on the status surface. + ## 2026.4.21 ### Changes diff --git a/docs/concepts/dreaming.md b/docs/concepts/dreaming.md index 719cb3e62e5..e844af242ae 100644 --- a/docs/concepts/dreaming.md +++ b/docs/concepts/dreaming.md @@ -229,8 +229,20 @@ When enabled, the Gateway **Dreams** tab shows: - a distinct grounded Scene lane for staged historical replay entries - an expandable Dream Diary reader backed by `doctor.memory.dreamDiary` +## Troubleshooting + +### Dreaming never runs (status shows blocked) + +The managed dreaming cron rides the default agent's heartbeat. If heartbeat is not firing for that agent, the cron enqueues a system event that nobody consumes and dreaming silently does not run. Both `openclaw memory status` and `/dreaming status` will report `blocked` in that case and name the agent whose heartbeat is the blocker. + +Two common causes: + +- Another agent declares an explicit `heartbeat:` block. When any entry in `agents.list` has its own `heartbeat` block, only those agents heartbeat — the defaults stop applying to everyone else, so the default agent can go silent. Move the heartbeat settings to `agents.defaults.heartbeat`, or add an explicit `heartbeat` block on the default agent. See [Scope and precedence](/gateway/heartbeat#scope-and-precedence). +- `heartbeat.every` is `0`, empty, or unparseable. The cron has no interval to schedule against, so the heartbeat is effectively disabled. Set `every` to a positive duration such as `30m`. See [Defaults](/gateway/heartbeat#defaults). + ## Related +- [Heartbeat](/gateway/heartbeat) - [Memory](/concepts/memory) - [Memory Search](/concepts/memory-search) - [memory CLI](/cli/memory) diff --git a/extensions/memory-core/src/cli.runtime.ts b/extensions/memory-core/src/cli.runtime.ts index ee9947fef5f..501d94e5e7e 100644 --- a/extensions/memory-core/src/cli.runtime.ts +++ b/extensions/memory-core/src/cli.runtime.ts @@ -44,7 +44,10 @@ import { type RepairDreamingArtifactsResult, } from "./dreaming-repair.js"; import { asRecord } from "./dreaming-shared.js"; -import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js"; +import { + resolveDreamingBlockedReason, + resolveShortTermPromotionDreamingConfig, +} from "./dreaming.js"; import { previewGroundedRemMarkdown } from "./rem-evidence.js"; import { applyShortTermPromotions, @@ -858,6 +861,10 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { `${label("Workspace")} ${info(workspacePath)}`, `${label("Dreaming")} ${info(formatDreamingSummary(cfg))}`, ].filter(Boolean) as string[]; + const dreamingBlockedReason = resolveDreamingBlockedReason(cfg); + if (dreamingBlockedReason) { + lines.push(`${label("Dreaming status")} ${warn(`blocked - ${dreamingBlockedReason}`)}`); + } if (embeddingProbe) { const state = embeddingProbe.ok ? "ready" : "unavailable"; const stateColor = embeddingProbe.ok ? theme.success : theme.warn; diff --git a/extensions/memory-core/src/cli.test.ts b/extensions/memory-core/src/cli.test.ts index d463823554c..195d1be8155 100644 --- a/extensions/memory-core/src/cli.test.ts +++ b/extensions/memory-core/src/cli.test.ts @@ -384,6 +384,135 @@ describe("memory cli", () => { }); }); + it("reports dreaming blocked when another explicit heartbeat agent excludes main", async () => { + loadConfig.mockReturnValue({ + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + }, + }, + }, + }, + }, + agents: { + defaults: { + heartbeat: { + every: "30m", + }, + }, + list: [ + { id: "main", default: true }, + { + id: "ops", + heartbeat: { + every: "1h", + }, + }, + ], + }, + }); + const close = vi.fn(async () => {}); + mockManager({ + probeVectorAvailability: vi.fn(async () => true), + status: () => makeMemoryStatus({ workspaceDir: "/tmp/openclaw" }), + close, + }); + + const log = spyRuntimeLogs(defaultRuntime); + await runMemoryCli(["status", "--agent", "main"]); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'Dreaming status: blocked - dreaming is enabled but will not run because heartbeat is disabled for "main". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting', + ), + ); + expect(close).toHaveBeenCalled(); + }); + + it('reports dreaming blocked when main heartbeat interval is "0m"', async () => { + loadConfig.mockReturnValue({ + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + }, + }, + }, + }, + }, + agents: { + defaults: { + heartbeat: { + every: "0m", + }, + }, + list: [{ id: "main", default: true }], + }, + }); + const close = vi.fn(async () => {}); + mockManager({ + probeVectorAvailability: vi.fn(async () => true), + status: () => makeMemoryStatus({ workspaceDir: "/tmp/openclaw" }), + close, + }); + + const log = spyRuntimeLogs(defaultRuntime); + await runMemoryCli(["status"]); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'Dreaming status: blocked - dreaming is enabled but will not run because heartbeat is disabled for "main". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting', + ), + ); + expect(close).toHaveBeenCalled(); + }); + + it("reports dreaming blocked for the configured default agent when it is not main", async () => { + resolveDefaultAgentId.mockReturnValue("ops"); + loadConfig.mockReturnValue({ + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + }, + }, + }, + }, + }, + agents: { + defaults: { + heartbeat: { + every: "0m", + }, + }, + list: [{ id: "ops", default: true }], + }, + }); + const close = vi.fn(async () => {}); + mockManager({ + probeVectorAvailability: vi.fn(async () => true), + status: () => makeMemoryStatus({ workspaceDir: "/tmp/openclaw" }), + close, + }); + + const log = spyRuntimeLogs(defaultRuntime); + await runMemoryCli(["status", "--agent", "ops"]); + + expect(log).toHaveBeenCalledWith( + expect.stringContaining( + 'Dreaming status: blocked - dreaming is enabled but will not run because heartbeat is disabled for "ops". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting', + ), + ); + expect(close).toHaveBeenCalled(); + }); + it("repairs invalid recall metadata and stale locks with status --fix", async () => { await withTempWorkspace(async (workspaceDir) => { const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json"); diff --git a/extensions/memory-core/src/dreaming-command.test.ts b/extensions/memory-core/src/dreaming-command.test.ts index 95c5361ec06..6ad0d9451d5 100644 --- a/extensions/memory-core/src/dreaming-command.test.ts +++ b/extensions/memory-core/src/dreaming-command.test.ts @@ -197,4 +197,95 @@ describe("memory-core /dreaming command", () => { expect(result.text).toContain("Usage: /dreaming status"); expect(runtime.config.writeConfigFile).not.toHaveBeenCalled(); }); + + it("shows a blocked line directly after enabled when main heartbeat is disabled", async () => { + const { command } = createHarness({ + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + }, + }, + }, + }, + }, + agents: { + defaults: { + heartbeat: { + every: "0m", + }, + }, + list: [{ id: "main", default: true }], + }, + }); + + const result = await command.handler(createCommandContext("status")); + const text = result.text ?? ""; + + expect(text).toContain( + '- blocked: dreaming is enabled but will not run because heartbeat is disabled for "main". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting', + ); + + const lines = text.split("\n"); + const enabledIdx = lines.findIndex((line) => line.startsWith("- enabled:")); + const blockedIdx = lines.findIndex((line) => line.startsWith("- blocked:")); + expect(enabledIdx).toBeGreaterThan(-1); + expect(blockedIdx).toBe(enabledIdx + 1); + }); + + it("surfaces the blocked line on /dreaming on when main heartbeat is disabled", async () => { + const { command } = createHarness({ + agents: { + defaults: { + heartbeat: { + every: "0m", + }, + }, + list: [{ id: "main", default: true }], + }, + }); + + const result = await command.handler( + createCommandContext("on", { + gatewayClientScopes: ["operator.admin"], + }), + ); + const text = result.text ?? ""; + + expect(text).toContain("Dreaming enabled."); + expect(text).toContain( + '- blocked: dreaming is enabled but will not run because heartbeat is disabled for "main". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting', + ); + }); + + it("omits the blocked line when dreaming is enabled and main heartbeat is healthy", async () => { + const { command } = createHarness({ + plugins: { + entries: { + "memory-core": { + config: { + dreaming: { + enabled: true, + }, + }, + }, + }, + }, + agents: { + defaults: { + heartbeat: { + every: "30m", + }, + }, + list: [{ id: "main", default: true }], + }, + }); + + const result = await command.handler(createCommandContext("status")); + + expect(result.text).toContain("- enabled: on"); + expect(result.text).not.toContain("- blocked:"); + }); }); diff --git a/extensions/memory-core/src/dreaming-command.ts b/extensions/memory-core/src/dreaming-command.ts index 2f202b3e10c..d10e0e2f3ec 100644 --- a/extensions/memory-core/src/dreaming-command.ts +++ b/extensions/memory-core/src/dreaming-command.ts @@ -2,7 +2,10 @@ import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memo import { resolveMemoryDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { asRecord } from "./dreaming-shared.js"; -import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js"; +import { + resolveDreamingBlockedReason, + resolveShortTermPromotionDreamingConfig, +} from "./dreaming.js"; function resolveMemoryCorePluginConfig(cfg: OpenClawConfig): Record { const entry = asRecord(cfg.plugins?.entries?.["memory-core"]); @@ -54,10 +57,12 @@ function formatStatus(cfg: OpenClawConfig): string { }); const deep = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg }); const timezone = dreaming.timezone ? ` (${dreaming.timezone})` : ""; + const blockedReason = resolveDreamingBlockedReason(cfg); return [ "Dreaming status:", `- enabled: ${formatEnabled(dreaming.enabled)}${timezone}`, + ...(blockedReason ? [`- blocked: ${blockedReason}`] : []), `- sweep cadence: ${dreaming.frequency}`, `- promotion policy: score>=${deep.minScore}, recalls>=${deep.minRecallCount}, uniqueQueries>=${deep.minUniqueQueries}`, ].join("\n"); diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index 68172ec25e7..34ab9976376 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -1,4 +1,9 @@ -import { peekSystemEventEntries } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveDefaultAgentId } from "openclaw/plugin-sdk/config-runtime"; +import { + isHeartbeatEnabledForAgent, + peekSystemEventEntries, + resolveHeartbeatIntervalMs, +} from "openclaw/plugin-sdk/infra-runtime"; import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core"; import { DEFAULT_MEMORY_DREAMING_FREQUENCY as DEFAULT_MEMORY_DREAMING_CRON_EXPR, @@ -30,6 +35,7 @@ import { const MANAGED_DREAMING_CRON_NAME = "Memory Dreaming Promotion"; const MANAGED_DREAMING_CRON_TAG = "[managed-by=memory-core.short-term-promotion]"; const DREAMING_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__"; +const CRON_SESSION_TARGET_MAIN = "main" as const; const LEGACY_LIGHT_SLEEP_CRON_NAME = "Memory Light Dreaming"; const LEGACY_LIGHT_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.light]"; const LEGACY_LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__"; @@ -48,7 +54,7 @@ type ManagedCronJobCreate = { description: string; enabled: boolean; schedule: CronSchedule; - sessionTarget: "main"; + sessionTarget: typeof CRON_SESSION_TARGET_MAIN; wakeMode: "now"; payload: CronPayload; }; @@ -58,7 +64,7 @@ type ManagedCronJobPatch = { description?: string; enabled?: boolean; schedule?: CronSchedule; - sessionTarget?: "main"; + sessionTarget?: typeof CRON_SESSION_TARGET_MAIN; wakeMode?: "now"; payload?: CronPayload; }; @@ -155,7 +161,7 @@ function buildManagedDreamingCronJob( expr: config.cron, ...(config.timezone ? { tz: config.timezone } : {}), }, - sessionTarget: "main", + sessionTarget: CRON_SESSION_TARGET_MAIN, wakeMode: "now", payload: { kind: "systemEvent", @@ -395,6 +401,27 @@ export function resolveShortTermPromotionDreamingConfig(params: { }; } +export function resolveDreamingBlockedReason(cfg: OpenClawConfig): string | null { + const pluginConfig = resolveMemoryCorePluginConfig(cfg); + const dreaming = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg }); + if (!dreaming.enabled) { + return null; + } + + const defaultAgentId = resolveDefaultAgentId(cfg); + // Mirror the managed dreaming wake path in server-cron: the job carries no + // agentId/sessionKey, so the wake uses defaults-only heartbeat. Not using + // resolveHeartbeatSummaryForAgent since it would apply the per-agent override + // and diverge from actual runtime behavior. + const enabledForDefault = isHeartbeatEnabledForAgent(cfg, defaultAgentId); + const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, cfg.agents?.defaults?.heartbeat); + if (enabledForDefault && intervalMs != null) { + return null; + } + + return `dreaming is enabled but will not run because heartbeat is disabled for "${defaultAgentId}". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting`; +} + export async function reconcileShortTermDreamingCronJob(params: { cron: CronServiceLike | null; config: ShortTermPromotionDreamingConfig; diff --git a/src/plugin-sdk/infra-runtime.ts b/src/plugin-sdk/infra-runtime.ts index b0f0514973f..8885bbeb481 100644 --- a/src/plugin-sdk/infra-runtime.ts +++ b/src/plugin-sdk/infra-runtime.ts @@ -87,6 +87,7 @@ export * from "../infra/file-lock.js"; export * from "../infra/format-time/format-duration.ts"; export * from "../infra/fs-safe.ts"; export * from "../infra/heartbeat-events.ts"; +export * from "../infra/heartbeat-summary.ts"; export * from "../infra/heartbeat-visibility.ts"; export * from "../infra/home-dir.js"; export * from "../infra/http-body.js";