Skip to content

Commit aca92b2

Browse files
memory/dreaming: decouple managed cron from heartbeat (#70737)
* Revert "fix(memory/dreaming): surface blocked status when heartbeat is disabled for main (#69875)" This reverts commit 529577e. Making way for the dreaming-vs-heartbeat decoupling from Josh's josh/dreaming-isolated-cron-fix branch, which moves the managed dreaming cron to isolated agent turns (sessionTarget: "isolated") so dreaming no longer requires heartbeat to fire. Once the cron no longer rides the heartbeat path, the blocked-reason observability has nothing left to report — removing it cleanly here before the cherry-picks land. * openclaw-3ba.1: move managed dreaming cron to isolated agent turns * openclaw-46d: claim cron runs before embedded attempts * openclaw-575: disable managed dreaming cron delivery * openclaw-575: accept wrapped dreaming cron tokens * openclaw-ccd: filter cron and wrapper transcript noise from dreaming corpus * openclaw-cd9: filter archived, cron, and heartbeat transcript noise from dreaming corpus * openclaw-cd9: suppress role-label reflection tags in rem dreaming * openclaw-b49: stop narrative timeouts from blocking dreaming cron * openclaw-b49: keep managed dreaming cron out of diary subagents * openclaw-ff9: restore cron dream diary generation without serial waits * openclaw-ff9: run dreaming narratives with lightweight isolated subagent lanes * openclaw-ff9: detach cron dream diary generation from run completion * openclaw-ff9: defer cron diary task startup until after cron completion * doctor/cron: migrate stale managed dreaming jobs to isolated agent turns After the dreaming cron moved off the heartbeat path to sessionTarget: "isolated" + payload.kind: "agentTurn" (see the preceding memory-core changes), users with existing ~/.openclaw/cron/jobs.json entries in the old sessionTarget: "main" + payload.kind: "systemEvent" shape still carry stale jobs until the gateway restart reconcile rewrites them. Add a dreaming-specific cron migration to the existing maybeRepairLegacyCronStore doctor path so "openclaw doctor" (and "openclaw doctor --fix") rewrites those jobs without needing a gateway restart. Match lives in a new doctor-cron-dreaming-payload-migration helper alongside the existing legacy-delivery and store-migration files. The matching uses the memory-core managed-job name and description tag plus the short-term-promotion payload token. Constants are mirrored from extensions/memory-core/src/dreaming.ts and commented so a future rename in memory-core is a visible drift point here too. * memory/dreaming: tighten cron-token match to known wrapper, not substring The previous match relaxed the line check from 'trimmed line equals token' to 'line contains token anywhere as a substring' to accept the `[cron:<id>] <token>` wrapper that isolated-cron turns add. Substring matching also let any user message embedding the token mid-sentence trigger the dream-promotion hook, and was flagged by both Greptile and Aisle on PR #70737. Replace it with strip-the-known-prefix-then-exact-match: keep the `[cron:<id>]` wrapper case working, reject every other variant. Add focused unit coverage that the bare token, the wrapped token, and bare multiline cases match while embedded / code-fenced / arbitrarily-wrapped variants do not. * memory/dreaming: drop assistant followup only on assistant-side signals Per PR #70737 review (aisle-research-bot, Medium): the previous logic suppressed the next assistant message whenever the prior user message matched a 'generated prompt' pattern (`[cron:...]`, `System (untrusted): ...`, heartbeat prompts, exec-completion events). Real users can type those same patterns, which let a user exfiltrate real assistant replies from the dreaming corpus by prefixing their own prompt — the assistant's reply would be silently dropped. Remove the cross-message coupling. Assistant-side machinery (silent replies, system wrappers) is already dropped by sanitizeSessionText, which is the right layer for that filter. Add an explicit assistant-side HEARTBEAT_TOKEN check to keep the legitimate `HEARTBEAT_OK` ack drop working without depending on the prior user message. Add a regression test exercising the spoofing scenario. * doctor/cron: assert mirrored dreaming constants stay in sync Per PR #70737 review (greptile-apps): the doctor migration mirrors three constants (MANAGED_DREAMING_CRON_NAME, MANAGED_DREAMING_CRON_TAG, DREAMING_SYSTEM_EVENT_TEXT) from extensions/memory-core/src/dreaming.ts. A future rename in either file would silently break the migration. Add a vitest unit that reads both files and asserts the literals match. Manually verified the assertion fires with a clear error when one side diverges. Adds no runtime cost; sits in the regular test pipeline. * fix(memory): stabilize dreaming CI checks * memory/dreaming: skip eager narrative session cleanup when detached Per PR #70737 review (chatgpt-codex-connector, P2): runDreamingSweepPhases called deleteNarrativeSessionBestEffort synchronously right after each phase. Once narrative generation moved to detached mode (queued via queueMicrotask), the eager cleanup races the writer: the session is deleted before the queued subagent run reads it, silently dropping cron diary entries. Skip the eager cleanup branch when params.detachNarratives is true. generateAndAppendDreamNarrative still runs its own deleteSession in the finally{} block, so the cleanup intent is preserved without the race. Heartbeat-driven (non-detached) runs keep the original eager-cleanup behavior. * fix(plugin-sdk): restore heartbeat-summary re-export Per PR #70737 review (chatgpt-codex-connector, P1): the revert of PR #69875 dropped the `heartbeat-summary` re-export from `openclaw/plugin-sdk/infra-runtime`. That subpath shipped publicly two days earlier, so removing it is technically a breaking change to a public SDK surface — third-party plugins importing `isHeartbeatEnabledForAgent` / `resolveHeartbeatIntervalMs` from this path would fail with no replacement contract introduced. Restore the re-export. Costs nothing to keep; the helpers are already public via `../infra/heartbeat-summary.ts`. SDK additions are by default backwards-compatible (CLAUDE.md), so removing within days of introduction violates that intent. * changelog: note dreaming decoupling from heartbeat Refs PR #70737. --------- Co-authored-by: Josh Lehman <[email protected]>
1 parent acbceb8 commit aca92b2

30 files changed

Lines changed: 1823 additions & 377 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Docs: https://docs.openclaw.ai
9090
- Codex harness/status: pin embedded harness selection per session, show active non-PI harness ids such as `codex` in `/status`, and keep legacy transcripts on PI until `/new` or `/reset` so config changes cannot hot-switch existing sessions.
9191
- Gateway/security: fail closed on agent-driven `gateway config.apply`/`config.patch` runtime edits by allowlisting a narrow set of agent-tunable prompt, model, and mention-gating paths (including Telegram topic-level `requireMention`) instead of relying on a hand-maintained denylist of protected subtrees that could miss new sensitive config keys. (#70726) Thanks @drobison00.
9292
- Webhooks/security: re-resolve `SecretRef`-backed webhook route secrets on each request so `openclaw secrets reload` revokes the previous secret immediately instead of waiting for a gateway restart. (#70727) Thanks @drobison00.
93+
- Memory/dreaming: decouple the managed dreaming cron from heartbeat by running it as an isolated lightweight agent turn, so dreaming runs even when heartbeat is disabled for the default agent and is no longer skipped by `heartbeat.activeHours`. `openclaw doctor --fix` migrates stale main-session dreaming jobs in persisted cron configs to the new shape. Fixes #69811, #67397, #68972. (#70737) Thanks @jalehman.
9394

9495
## 2026.4.22
9596

@@ -263,7 +264,6 @@ Docs: https://docs.openclaw.ai
263264
- 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.
264265
- Doctor/plugins: hydrate legacy partial interactive handler state before plugin reload clears dedupe caches, so `openclaw doctor` and post-update doctor runs no longer crash with `Cannot read properties of undefined (reading 'clear')`. (#70135) Thanks @ngutman.
265266
- Control UI/config: preserve intentionally empty raw config snapshots when clearing pending updates so reset restores the original bytes instead of synthesizing JSON for blank config files. (#68178) Thanks @BunsDev.
266-
- 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.
267267
- Cron/run-log: report generic `message` tool sends under the resolved delivery channel when they match the cron target, while preserving account-specific mismatch checks for delivery traces. (#69940) Thanks @davehappyminion.
268268
- Doctor/channels: merge configured-channel doctor hooks across read-only, loaded, setup, and runtime plugin discovery so partial adapters no longer hide runtime-only compatibility repair or allowlist warnings, preserve disabled-channel opt-outs, and ignore malformed hook values before they can mask valid fallbacks. (#69919) Thanks @gumadeiras.
269269
- Models/CLI: show bundled provider-owned static catalog rows in `models list --all` before auth is configured, including Kimi K2.6 rows for Moonshot, OpenRouter, and Vercel AI Gateway, while keeping local-only and workspace plugin catalog paths isolated. (#69909) Thanks @shakkernerd.

docs/concepts/dreaming.md

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -227,20 +227,8 @@ When enabled, the Gateway **Dreams** tab shows:
227227
- a distinct grounded Scene lane for staged historical replay entries
228228
- an expandable Dream Diary reader backed by `doctor.memory.dreamDiary`
229229

230-
## Troubleshooting
231-
232-
### Dreaming never runs (status shows blocked)
233-
234-
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.
235-
236-
Two common causes:
237-
238-
- 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).
239-
- `heartbeat.every` is `0`, empty, or unparsable. 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).
240-
241230
## Related
242231

243-
- [Heartbeat](/gateway/heartbeat)
244232
- [Memory](/concepts/memory)
245233
- [Memory Search](/concepts/memory-search)
246234
- [memory CLI](/cli/memory)

extensions/memory-core/src/cli.runtime.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,7 @@ import {
4444
type RepairDreamingArtifactsResult,
4545
} from "./dreaming-repair.js";
4646
import { asRecord } from "./dreaming-shared.js";
47-
import {
48-
resolveDreamingBlockedReason,
49-
resolveShortTermPromotionDreamingConfig,
50-
} from "./dreaming.js";
47+
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
5148
import { previewGroundedRemMarkdown } from "./rem-evidence.js";
5249
import {
5350
applyShortTermPromotions,
@@ -853,10 +850,6 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
853850
`${label("Workspace")} ${info(workspacePath)}`,
854851
`${label("Dreaming")} ${info(formatDreamingSummary(cfg))}`,
855852
].filter(Boolean) as string[];
856-
const dreamingBlockedReason = resolveDreamingBlockedReason(cfg);
857-
if (dreamingBlockedReason) {
858-
lines.push(`${label("Dreaming status")} ${warn(`blocked - ${dreamingBlockedReason}`)}`);
859-
}
860853
if (embeddingProbe) {
861854
const state = embeddingProbe.ok ? "ready" : "unavailable";
862855
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;

extensions/memory-core/src/cli.test.ts

Lines changed: 0 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -384,135 +384,6 @@ describe("memory cli", () => {
384384
});
385385
});
386386

387-
it("reports dreaming blocked when another explicit heartbeat agent excludes main", async () => {
388-
loadConfig.mockReturnValue({
389-
plugins: {
390-
entries: {
391-
"memory-core": {
392-
config: {
393-
dreaming: {
394-
enabled: true,
395-
},
396-
},
397-
},
398-
},
399-
},
400-
agents: {
401-
defaults: {
402-
heartbeat: {
403-
every: "30m",
404-
},
405-
},
406-
list: [
407-
{ id: "main", default: true },
408-
{
409-
id: "ops",
410-
heartbeat: {
411-
every: "1h",
412-
},
413-
},
414-
],
415-
},
416-
});
417-
const close = vi.fn(async () => {});
418-
mockManager({
419-
probeVectorAvailability: vi.fn(async () => true),
420-
status: () => makeMemoryStatus({ workspaceDir: "/tmp/openclaw" }),
421-
close,
422-
});
423-
424-
const log = spyRuntimeLogs(defaultRuntime);
425-
await runMemoryCli(["status", "--agent", "main"]);
426-
427-
expect(log).toHaveBeenCalledWith(
428-
expect.stringContaining(
429-
'Dreaming status: blocked - dreaming is enabled but will not run because heartbeat is disabled for "main". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting',
430-
),
431-
);
432-
expect(close).toHaveBeenCalled();
433-
});
434-
435-
it('reports dreaming blocked when main heartbeat interval is "0m"', async () => {
436-
loadConfig.mockReturnValue({
437-
plugins: {
438-
entries: {
439-
"memory-core": {
440-
config: {
441-
dreaming: {
442-
enabled: true,
443-
},
444-
},
445-
},
446-
},
447-
},
448-
agents: {
449-
defaults: {
450-
heartbeat: {
451-
every: "0m",
452-
},
453-
},
454-
list: [{ id: "main", default: true }],
455-
},
456-
});
457-
const close = vi.fn(async () => {});
458-
mockManager({
459-
probeVectorAvailability: vi.fn(async () => true),
460-
status: () => makeMemoryStatus({ workspaceDir: "/tmp/openclaw" }),
461-
close,
462-
});
463-
464-
const log = spyRuntimeLogs(defaultRuntime);
465-
await runMemoryCli(["status"]);
466-
467-
expect(log).toHaveBeenCalledWith(
468-
expect.stringContaining(
469-
'Dreaming status: blocked - dreaming is enabled but will not run because heartbeat is disabled for "main". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting',
470-
),
471-
);
472-
expect(close).toHaveBeenCalled();
473-
});
474-
475-
it("reports dreaming blocked for the configured default agent when it is not main", async () => {
476-
resolveDefaultAgentId.mockReturnValue("ops");
477-
loadConfig.mockReturnValue({
478-
plugins: {
479-
entries: {
480-
"memory-core": {
481-
config: {
482-
dreaming: {
483-
enabled: true,
484-
},
485-
},
486-
},
487-
},
488-
},
489-
agents: {
490-
defaults: {
491-
heartbeat: {
492-
every: "0m",
493-
},
494-
},
495-
list: [{ id: "ops", default: true }],
496-
},
497-
});
498-
const close = vi.fn(async () => {});
499-
mockManager({
500-
probeVectorAvailability: vi.fn(async () => true),
501-
status: () => makeMemoryStatus({ workspaceDir: "/tmp/openclaw" }),
502-
close,
503-
});
504-
505-
const log = spyRuntimeLogs(defaultRuntime);
506-
await runMemoryCli(["status", "--agent", "ops"]);
507-
508-
expect(log).toHaveBeenCalledWith(
509-
expect.stringContaining(
510-
'Dreaming status: blocked - dreaming is enabled but will not run because heartbeat is disabled for "ops". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting',
511-
),
512-
);
513-
expect(close).toHaveBeenCalled();
514-
});
515-
516387
it("repairs invalid recall metadata and stale locks with status --fix", async () => {
517388
await withTempWorkspace(async (workspaceDir) => {
518389
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");

extensions/memory-core/src/concept-vocabulary.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,22 @@ describe("concept vocabulary", () => {
5959
expect(classifyConceptTagScript("qmd路由器")).toBe("mixed");
6060
});
6161

62+
it("drops chat scaffolding stop words from derived concept tags", () => {
63+
const tags = deriveConceptTags({
64+
path: "memory/.dreams/session-corpus/2026-04-16.txt",
65+
snippet:
66+
"Assistant: the system should remind you about the Ollama provider setup in your workspace.",
67+
});
68+
69+
expect(tags).toContain("ollama");
70+
expect(tags).toContain("provider");
71+
expect(tags).not.toContain("assistant");
72+
expect(tags).not.toContain("system");
73+
expect(tags).not.toContain("the");
74+
expect(tags).not.toContain("you");
75+
expect(tags).not.toContain("your");
76+
});
77+
6278
it("summarizes entry coverage across latin, cjk, and mixed tags", () => {
6379
expect(
6480
summarizeConceptTagScriptCoverage([

extensions/memory-core/src/concept-vocabulary.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const LANGUAGE_STOP_WORDS = {
1919
"agent",
2020
"again",
2121
"also",
22+
"assistant",
2223
"because",
2324
"before",
2425
"being",
@@ -64,6 +65,8 @@ const LANGUAGE_STOP_WORDS = {
6465
"should",
6566
"since",
6667
"some",
68+
"subagent",
69+
"system",
6770
"than",
6871
"that",
6972
"their",
@@ -73,13 +76,14 @@ const LANGUAGE_STOP_WORDS = {
7376
"this",
7477
"through",
7578
"today",
79+
"user",
7680
"using",
7781
"with",
7882
"work",
7983
"workspace",
8084
"year",
8185
],
82-
english: ["and", "are", "for", "into", "its", "our", "then", "were"],
86+
english: ["and", "are", "for", "into", "its", "our", "the", "then", "were", "you", "your"],
8387
spanish: [
8488
"al",
8589
"con",

extensions/memory-core/src/dreaming-command.test.ts

Lines changed: 0 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -197,95 +197,4 @@ describe("memory-core /dreaming command", () => {
197197
expect(result.text).toContain("Usage: /dreaming status");
198198
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
199199
});
200-
201-
it("shows a blocked line directly after enabled when main heartbeat is disabled", async () => {
202-
const { command } = createHarness({
203-
plugins: {
204-
entries: {
205-
"memory-core": {
206-
config: {
207-
dreaming: {
208-
enabled: true,
209-
},
210-
},
211-
},
212-
},
213-
},
214-
agents: {
215-
defaults: {
216-
heartbeat: {
217-
every: "0m",
218-
},
219-
},
220-
list: [{ id: "main", default: true }],
221-
},
222-
});
223-
224-
const result = await command.handler(createCommandContext("status"));
225-
const text = result.text ?? "";
226-
227-
expect(text).toContain(
228-
'- blocked: dreaming is enabled but will not run because heartbeat is disabled for "main". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting',
229-
);
230-
231-
const lines = text.split("\n");
232-
const enabledIdx = lines.findIndex((line) => line.startsWith("- enabled:"));
233-
const blockedIdx = lines.findIndex((line) => line.startsWith("- blocked:"));
234-
expect(enabledIdx).toBeGreaterThan(-1);
235-
expect(blockedIdx).toBe(enabledIdx + 1);
236-
});
237-
238-
it("surfaces the blocked line on /dreaming on when main heartbeat is disabled", async () => {
239-
const { command } = createHarness({
240-
agents: {
241-
defaults: {
242-
heartbeat: {
243-
every: "0m",
244-
},
245-
},
246-
list: [{ id: "main", default: true }],
247-
},
248-
});
249-
250-
const result = await command.handler(
251-
createCommandContext("on", {
252-
gatewayClientScopes: ["operator.admin"],
253-
}),
254-
);
255-
const text = result.text ?? "";
256-
257-
expect(text).toContain("Dreaming enabled.");
258-
expect(text).toContain(
259-
'- blocked: dreaming is enabled but will not run because heartbeat is disabled for "main". See https://docs.openclaw.ai/concepts/dreaming#troubleshooting',
260-
);
261-
});
262-
263-
it("omits the blocked line when dreaming is enabled and main heartbeat is healthy", async () => {
264-
const { command } = createHarness({
265-
plugins: {
266-
entries: {
267-
"memory-core": {
268-
config: {
269-
dreaming: {
270-
enabled: true,
271-
},
272-
},
273-
},
274-
},
275-
},
276-
agents: {
277-
defaults: {
278-
heartbeat: {
279-
every: "30m",
280-
},
281-
},
282-
list: [{ id: "main", default: true }],
283-
},
284-
});
285-
286-
const result = await command.handler(createCommandContext("status"));
287-
288-
expect(result.text).toContain("- enabled: on");
289-
expect(result.text).not.toContain("- blocked:");
290-
});
291200
});

extensions/memory-core/src/dreaming-command.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,7 @@ import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memo
22
import { resolveMemoryDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status";
33
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
44
import { asRecord } from "./dreaming-shared.js";
5-
import {
6-
resolveDreamingBlockedReason,
7-
resolveShortTermPromotionDreamingConfig,
8-
} from "./dreaming.js";
5+
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
96

107
function resolveMemoryCorePluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
118
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
@@ -57,12 +54,10 @@ function formatStatus(cfg: OpenClawConfig): string {
5754
});
5855
const deep = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
5956
const timezone = dreaming.timezone ? ` (${dreaming.timezone})` : "";
60-
const blockedReason = resolveDreamingBlockedReason(cfg);
6157

6258
return [
6359
"Dreaming status:",
6460
`- enabled: ${formatEnabled(dreaming.enabled)}${timezone}`,
65-
...(blockedReason ? [`- blocked: ${blockedReason}`] : []),
6661
`- sweep cadence: ${dreaming.frequency}`,
6762
`- promotion policy: score>=${deep.minScore}, recalls>=${deep.minRecallCount}, uniqueQueries>=${deep.minUniqueQueries}`,
6863
].join("\n");

0 commit comments

Comments
 (0)