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 <steipete@gmail.com>
(cherry picked from commit 0317d7e628)
This commit is contained in:
Yuval Dinodia
2026-07-16 06:43:21 -04:00
committed by Dallin Romney
parent e5db650a48
commit 028fc97809
2 changed files with 82 additions and 5 deletions
@@ -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([]);
});
});
});
});
@@ -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) {