fix(memory-core): isolate dreaming narrative sessions per workspace (#61674)

* fix(memory-core): isolate dreaming narrative sessions per workspace

* chore(changelog): add narrative isolation note

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
MrBrain
2026-04-13 00:39:28 +08:00
committed by GitHub
parent 24d769449d
commit 346e38e275
3 changed files with 52 additions and 2 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

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