diff --git a/CHANGELOG.md b/CHANGELOG.md index 40574642634..4a4fab28a7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Dreaming/diary: use the host local timezone for diary timestamps when `dreaming.timezone` is unset, so `DREAMS.md` and the UI stop defaulting to UTC. (#65034) Thanks @neo1027144-creator and @vincentkoc. - Dreaming/diary: include the timezone abbreviation in diary timestamps so `DREAMS.md` and the UI make UTC or local host time explicit. (#65057) Thanks @Yanhu007 and @vincentkoc. - Dreaming/narrative: harden transient narrative cleanup by retrying timed-out deletes and scrubbing stale dreaming session artifacts through the lock-aware session-store path. (#65320) Thanks @serkonyc and @vincentkoc. +- Dreaming/narrative: isolate transient narrative session keys per workspace so simultaneous dreaming phases cannot cross-read or delete each other's session state. (#61674) Thanks @GaosCode and @vincentkoc. - Plugins/memory: restore cached memory capability public artifacts on plugin-registry cache hits so memory-backed artifact surfaces stay visible after warm loads. Thanks @sercada and @vincentkoc. - Gateway/cron: preserve requested isolated-agent config across runtime reloads so subagent jobs and heartbeat overrides keep the right workspace and heartbeat settings when the hot-loaded snapshot is stale. Thanks @l0cka and @vincentkoc. - Gateway/plugins: always send a non-empty `idempotencyKey` for plugin subagent runs, so dreaming narrative jobs stop failing gateway schema validation. (#65354) Thanks @CodeForgeNet and @vincentkoc. diff --git a/extensions/memory-core/src/dreaming-narrative.test.ts b/extensions/memory-core/src/dreaming-narrative.test.ts index 178f56e29ef..e62ddaea61f 100644 --- a/extensions/memory-core/src/dreaming-narrative.test.ts +++ b/extensions/memory-core/src/dreaming-narrative.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import * as configRuntimeModule from "openclaw/plugin-sdk/config-runtime"; @@ -583,7 +584,8 @@ describe("generateAndAppendDreamNarrative", () => { const subagent = createMockSubagent("The repository whispered of forgotten endpoints."); const logger = createMockLogger(); const nowMs = Date.parse("2026-04-05T03:00:00Z"); - const expectedSessionKey = `dreaming-narrative-light-${nowMs}`; + const workspaceHash = createHash("sha1").update(workspaceDir).digest("hex").slice(0, 12); + const expectedSessionKey = `dreaming-narrative-light-${workspaceHash}-${nowMs}`; await generateAndAppendDreamNarrative({ subagent, @@ -848,4 +850,37 @@ describe("generateAndAppendDreamNarrative", () => { expect(sessionFiles).toContain("still-live.jsonl"); expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("dreaming cleanup scrubbed")); }); + + it("isolates narrative sessions across workspaces even at the same timestamp", async () => { + const firstWorkspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-"); + const secondWorkspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-"); + const subagent = createMockSubagent("A quiet memory took shape."); + const logger = createMockLogger(); + const nowMs = Date.parse("2026-04-05T03:00:00Z"); + + await generateAndAppendDreamNarrative({ + subagent, + workspaceDir: firstWorkspaceDir, + data: { phase: "light", snippets: ["first workspace fragment"] }, + nowMs, + logger, + }); + await generateAndAppendDreamNarrative({ + subagent, + workspaceDir: secondWorkspaceDir, + data: { phase: "light", snippets: ["second workspace fragment"] }, + nowMs, + logger, + }); + + const firstSessionKey = subagent.run.mock.calls[0]?.[0]?.sessionKey; + const secondSessionKey = subagent.run.mock.calls[1]?.[0]?.sessionKey; + expect(firstSessionKey).toBeTypeOf("string"); + expect(secondSessionKey).toBeTypeOf("string"); + expect(firstSessionKey).not.toBe(secondSessionKey); + expect(firstSessionKey).toContain("dreaming-narrative-light-"); + expect(secondSessionKey).toContain("dreaming-narrative-light-"); + expect(subagent.deleteSession.mock.calls[0]?.[0]?.sessionKey).toBe(firstSessionKey); + expect(subagent.deleteSession.mock.calls[1]?.[0]?.sessionKey).toBe(secondSessionKey); + }); }); diff --git a/extensions/memory-core/src/dreaming-narrative.ts b/extensions/memory-core/src/dreaming-narrative.ts index 6d579ddc822..9ff7a62271d 100644 --- a/extensions/memory-core/src/dreaming-narrative.ts +++ b/extensions/memory-core/src/dreaming-narrative.ts @@ -1,4 +1,5 @@ import type { Dirent } from "node:fs"; +import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { @@ -176,6 +177,15 @@ async function startNarrativeRunOrFallback(params: { } } +function buildNarrativeSessionKey(params: { + workspaceDir: string; + phase: NarrativePhaseData["phase"]; + nowMs: number; +}): string { + const workspaceHash = createHash("sha1").update(params.workspaceDir).digest("hex").slice(0, 12); + return `dreaming-narrative-${params.phase}-${workspaceHash}-${params.nowMs}`; +} + // ── Prompt building ──────────────────────────────────────────────────── export function buildNarrativePrompt(data: NarrativePhaseData): string { @@ -835,7 +845,11 @@ export async function generateAndAppendDreamNarrative(params: { return; } - const sessionKey = `dreaming-narrative-${params.data.phase}-${nowMs}`; + const sessionKey = buildNarrativeSessionKey({ + workspaceDir: params.workspaceDir, + phase: params.data.phase, + nowMs, + }); const message = buildNarrativePrompt(params.data); let runId: string | null = null; let waitStatus: string | null = null;