fix(memory-core): add dreaming narrative idempotency (#63876)

Merged via squash.

Prepared head SHA: 34f317cbcf
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-04-09 23:31:10 +02:00
committed by GitHub
parent 110782a26a
commit bed53c77aa
3 changed files with 95 additions and 6 deletions

View File

@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
appendNarrativeEntry,
buildBackfillDiaryEntry,
@@ -18,6 +18,10 @@ import { createMemoryCoreTestHarness } from "./test-helpers.js";
const { createTempWorkspace } = createMemoryCoreTestHarness();
afterEach(() => {
vi.restoreAllMocks();
});
describe("buildNarrativePrompt", () => {
it("builds a prompt from snippets only", () => {
const data: NarrativePhaseData = {
@@ -312,6 +316,64 @@ describe("appendNarrativeEntry", () => {
// Original content should still be there, after the diary.
expect(content).toContain("# Existing");
});
it("keeps existing diary content intact when the atomic replace fails", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(dreamsPath, "# Existing\n", "utf-8");
const renameError = Object.assign(new Error("replace failed"), { code: "ENOSPC" });
const renameSpy = vi.spyOn(fs, "rename").mockRejectedValueOnce(renameError);
await expect(
appendNarrativeEntry({
workspaceDir,
narrative: "Appended dream.",
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
}),
).rejects.toThrow("replace failed");
expect(renameSpy).toHaveBeenCalledOnce();
await expect(fs.readFile(dreamsPath, "utf-8")).resolves.toBe("# Existing\n");
});
it("preserves restrictive dreams file permissions across atomic replace", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(dreamsPath, "# Existing\n", { encoding: "utf-8", mode: 0o600 });
await fs.chmod(dreamsPath, 0o600);
await appendNarrativeEntry({
workspaceDir,
narrative: "Appended dream.",
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
});
const stat = await fs.stat(dreamsPath);
expect(stat.mode & 0o777).toBe(0o600);
});
it("surfaces temp cleanup failure after atomic replace error", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(dreamsPath, "# Existing\n", "utf-8");
vi.spyOn(fs, "rename").mockRejectedValueOnce(
Object.assign(new Error("replace failed"), { code: "ENOSPC" }),
);
vi.spyOn(fs, "rm").mockRejectedValueOnce(
Object.assign(new Error("cleanup failed"), { code: "EACCES" }),
);
await expect(
appendNarrativeEntry({
workspaceDir,
narrative: "Appended dream.",
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
}),
).rejects.toThrow("cleanup also failed");
});
});
describe("generateAndAppendDreamNarrative", () => {
@@ -341,6 +403,8 @@ describe("generateAndAppendDreamNarrative", () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
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}`;
await generateAndAppendDreamNarrative({
subagent,
@@ -349,13 +413,15 @@ describe("generateAndAppendDreamNarrative", () => {
phase: "light",
snippets: ["API endpoints need authentication"],
},
nowMs: Date.parse("2026-04-05T03:00:00Z"),
nowMs,
timezone: "UTC",
logger,
});
expect(subagent.run).toHaveBeenCalledOnce();
expect(subagent.run.mock.calls[0][0]).toMatchObject({
idempotencyKey: expectedSessionKey,
sessionKey: expectedSessionKey,
deliver: false,
});
expect(subagent.waitForRun).toHaveBeenCalledOnce();

View File

@@ -6,6 +6,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
type SubagentSurface = {
run: (params: {
idempotencyKey: string;
sessionKey: string;
message: string;
extraSystemPrompt?: string;
@@ -277,12 +278,26 @@ async function assertSafeDreamsPath(dreamsPath: string): Promise<void> {
async function writeDreamsFileAtomic(dreamsPath: string, content: string): Promise<void> {
await assertSafeDreamsPath(dreamsPath);
const existing = await fs.stat(dreamsPath).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
return null;
}
throw err;
});
const mode = existing?.mode ?? 0o600;
const tempPath = `${dreamsPath}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tempPath, content, { encoding: "utf-8", flag: "wx" });
await fs.writeFile(tempPath, content, { encoding: "utf-8", flag: "wx", mode });
await fs.chmod(tempPath, mode).catch(() => undefined);
try {
await fs.rename(tempPath, dreamsPath);
await fs.chmod(dreamsPath, mode).catch(() => undefined);
} catch (err) {
await fs.rm(tempPath, { force: true }).catch(() => {});
const cleanupError = await fs.rm(tempPath, { force: true }).catch((rmErr) => rmErr);
if (cleanupError) {
throw new Error(
`Atomic DREAMS.md write failed (${formatErrorMessage(err)}); cleanup also failed (${formatErrorMessage(cleanupError)})`,
);
}
throw err;
}
}
@@ -409,7 +424,7 @@ export async function appendNarrativeEntry(params: {
}
}
await fs.writeFile(dreamsPath, updated.endsWith("\n") ? updated : `${updated}\n`, "utf-8");
await writeDreamsFileAtomic(dreamsPath, updated.endsWith("\n") ? updated : `${updated}\n`);
return dreamsPath;
}
@@ -434,6 +449,7 @@ export async function generateAndAppendDreamNarrative(params: {
try {
const { runId } = await params.subagent.run({
idempotencyKey: sessionKey,
sessionKey,
message,
extraSystemPrompt: NARRATIVE_SYSTEM_PROMPT,