From 028fc97809d2c5d9ddaf203dfd1a98064e2a3dd0 Mon Sep 17 00:00:00 2001 From: Yuval Dinodia <102706514+yetval@users.noreply.github.com> Date: Thu, 16 Jul 2026 06:43:21 -0400 Subject: [PATCH] fix(memory-core): write MEMORY.md atomically during short-term promotion (#108397) * fix(memory-core): write MEMORY.md atomically during short-term promotion applyShortTermPromotions rewrote MEMORY.md with a single non-atomic fs.writeFile, which truncates the file before streaming the new content. An OS write failure part way through (for example EFBIG on a size-limited or full volume) left MEMORY.md truncated to the bytes written before the failure, permanently dropping user long-term memory. The dreaming cron path invokes this writer automatically, and the recall store is only updated after the write, so the promotion stays eligible and the next run reads the already-truncated file. Route the write through replaceFileAtomic (temp file, fsync, atomic rename), the same durable-write helper the sibling DREAMS.md writer in this extension already uses. On failure the temp file is discarded and the existing MEMORY.md is left untouched; on success the content and the existing file mode are preserved. * fix(memory-core): harden atomic promotion durability --------- Co-authored-by: Peter Steinberger (cherry picked from commit 0317d7e628010d6e51a2a09f9d7e9af677216981) --- .../src/short-term-promotion.test.ts | 69 +++++++++++++++++++ .../memory-core/src/short-term-promotion.ts | 18 +++-- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/extensions/memory-core/src/short-term-promotion.test.ts b/extensions/memory-core/src/short-term-promotion.test.ts index 3422425ee042..082c21dd8112 100644 --- a/extensions/memory-core/src/short-term-promotion.test.ts +++ b/extensions/memory-core/src/short-term-promotion.test.ts @@ -3339,4 +3339,73 @@ describe("short-term promotion", () => { }); }); }); + + describe("MEMORY.md atomic promotion write", () => { + it("preserves the existing MEMORY.md when the promotion write fails", async () => { + await withTempWorkspace(async (workspaceDir) => { + await writeDailyMemoryNote(workspaceDir, "2026-04-29", [ + "Notes", + "", + "Rotate the staging Postgres credentials before next deploy.", + ]); + + const memoryPath = path.join(workspaceDir, "MEMORY.md"); + const sentinel = "FINAL-USER-MEMORY-SENTINEL-do-not-lose"; + const seeded = `# Long-Term Memory\n\n${"pad line filler content ".repeat(9_000)}\n- ${sentinel}\n`; + await fs.writeFile(memoryPath, seeded, "utf-8"); + await recordShortTermRecalls({ + workspaceDir, + query: "rotate creds", + nowMs: Date.parse("2026-04-29T10:00:00.000Z"), + results: [ + { + path: "memory/2026-04-29.md", + startLine: 3, + endLine: 3, + score: 0.96, + snippet: "Rotate the staging Postgres credentials before next deploy.", + source: "memory", + }, + ], + }); + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + }); + + const originalWriteFile = fs.writeFile.bind(fs); + vi.spyOn(fs, "writeFile").mockImplementation((async (target, data, options) => { + const targetPath = + typeof target === "string" ? target : target instanceof URL ? target.pathname : ""; + if (targetPath && path.basename(targetPath).startsWith("MEMORY.md")) { + const text = typeof data === "string" ? data : Buffer.from(data).toString(); + await originalWriteFile(target, text.slice(0, 51_200), options); + throw Object.assign(new Error("EFBIG: file too large, write"), { code: "EFBIG" }); + } + return await originalWriteFile(target, data, options); + }) as typeof fs.writeFile); + + await expect( + applyShortTermPromotions({ + workspaceDir, + candidates: ranked, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-04-29T10:00:00.000Z"), + memoryFileMaxChars: 5_000_000, + }), + ).rejects.toMatchObject({ code: "EFBIG" }); + + expect(await fs.readFile(memoryPath, "utf-8")).toBe(seeded); + expect( + (await fs.readdir(workspaceDir)).filter((entry) => + entry.startsWith("MEMORY.md.promotion"), + ), + ).toEqual([]); + }); + }); + }); }); diff --git a/extensions/memory-core/src/short-term-promotion.ts b/extensions/memory-core/src/short-term-promotion.ts index e4e9f4a1a3c2..e63a25345fbc 100644 --- a/extensions/memory-core/src/short-term-promotion.ts +++ b/extensions/memory-core/src/short-term-promotion.ts @@ -9,6 +9,7 @@ import { isSameMemoryDreamingDay, } from "openclaw/plugin-sdk/memory-core-host-status"; import { appendMemoryHostEvent } from "openclaw/plugin-sdk/memory-host-events"; +import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeStringEntries, @@ -2468,11 +2469,18 @@ export async function applyShortTermPromotions( compactedDates = compaction.droppedDates; const baseMemory = compaction.compacted; const header = baseMemory.trim().length > 0 ? "" : "# Long-Term Memory\n\n"; - await fs.writeFile( - memoryPath, - `${header}${withTrailingNewline(baseMemory)}${section}`, - "utf-8", - ); + const workspaceMode = (await fs.stat(workspaceDir)).mode & 0o7777; + await replaceFileAtomic({ + filePath: memoryPath, + content: `${header}${withTrailingNewline(baseMemory)}${section}`, + dirMode: workspaceMode, + mode: 0o600, + preserveExistingMode: true, + tempPrefix: `${path.basename(memoryPath)}.promotion`, + syncTempFile: true, + syncParentDir: true, + throwOnCleanupError: true, + }); } for (const candidate of rehydratedSelected) {