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) {