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";