refactor(FN-2132): bootstrap canonical memory before existence checks

- Run memory-layer bootstrap before backend exists checks so legacy migrations seed canonical files during upgrades
- Preserve first-run MEMORY.md scaffold creation when long-term memory was created from the default migration scaffold
- Remove legacy direct file-exists fallback from backend ensure flow and rely on backend canonical paths
- Add tests for canonical read precedence in file/qmd backends and migration-preserving project-memory bootstrap
This commit is contained in:
Fusion
2026-04-19 07:02:36 -07:00
committed by gsxdsm
parent a4ce10a1c5
commit 7ed7cf3277
3 changed files with 69 additions and 17 deletions

View File

@@ -141,6 +141,18 @@ describe("memory-backend", () => {
expect(result.exists).toBe(false);
});
it("reads only from long-term path when both long-term and legacy files exist", async () => {
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
writeFileSync(legacyMemoryPath(tempDir), "Legacy content", "utf-8");
writeFileSync(longTermMemoryPath(tempDir), "New content", "utf-8");
const backend = new FileMemoryBackend();
const result = await backend.read(tempDir);
expect(result.content).toBe("New content");
expect(result.exists).toBe(true);
});
it("should throw MemoryBackendError on read failure", async () => {
// Make the long-term memory path a directory so readFile throws EISDIR (not ENOENT)
const longTermDir = join(tempDir, ".fusion", "memory", "MEMORY.md");
@@ -411,6 +423,19 @@ describe("memory-backend", () => {
expect(result.content).toBe("");
expect(result.exists).toBe(false);
});
it("reads only from long-term path when both long-term and legacy files exist", async () => {
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
writeFileSync(legacyMemoryPath(tempDir), "Legacy content", "utf-8");
writeFileSync(longTermMemoryPath(tempDir), "New content", "utf-8");
const backend = new QmdMemoryBackend();
const result = await backend.read(tempDir);
expect(result.content).toBe("New content");
expect(result.exists).toBe(true);
expect(result.backend).toBe("qmd");
});
});
describe("write", () => {

View File

@@ -176,6 +176,19 @@ describe("project-memory", () => {
const content = await readProjectMemory(testDir);
expect(content).toBe("");
});
it("reads only from .fusion/memory/MEMORY.md, ignoring legacy path", async () => {
await mkdir(join(testDir, ".fusion"), { recursive: true });
const legacyContent = "# Legacy Content\n\nOld stuff";
await writeFile(join(testDir, LEGACY_MEMORY_FILE_PATH), legacyContent, "utf-8");
await mkdir(join(testDir, ".fusion", "memory"), { recursive: true });
const newContent = "# New Content\n\nNew stuff";
await writeFile(memoryPath, newContent, "utf-8");
const content = await readProjectMemory(testDir);
expect(content).toBe(newContent);
});
});
// ── buildTriageMemoryInstructions ─────────────────────────────────
@@ -285,6 +298,19 @@ describe("project-memory", () => {
expect(content).toBe(customContent);
});
it("preserves legacy content during upgrade when only legacy exists", async () => {
await mkdir(join(testDir, ".fusion"), { recursive: true });
const userContent = "# Legacy\n\nUser content that must be preserved";
await writeFile(join(testDir, LEGACY_MEMORY_FILE_PATH), userContent, "utf-8");
const created = await ensureMemoryFileWithBackend(testDir);
expect(created).toBe(false);
expect(existsSync(memoryPath)).toBe(true);
const content = readFileSync(memoryPath, "utf-8");
expect(content).toBe(userContent);
});
it("returns false when file already exists", async () => {
await ensureMemoryFile(testDir);
const created = await ensureMemoryFileWithBackend(testDir);
@@ -301,6 +327,7 @@ describe("project-memory", () => {
expect(created).toBe(true);
expect(existsSync(memoryPath)).toBe(true);
expect(readFileSync(memoryPath, "utf-8")).toBe(getDefaultMemoryScaffold());
});
it("does not throw for readonly backend (non-fatal bootstrap)", async () => {
@@ -452,7 +479,6 @@ describe("project-memory", () => {
const created = await ensureMemoryFileWithBackend(testDir, { memoryBackendType: "file" });
expect(created).toBe(true);
// File should exist and have default scaffold content
const content = readFileSync(memoryPath, "utf-8");
expect(content).toBe(getDefaultMemoryScaffold());
});

View File

@@ -272,17 +272,25 @@ export async function ensureMemoryFileWithBackend(
}
};
// Check if memory already exists using the backend
// OpenClaw-style memory layers are always bootstrapped for writable memory
// backends. `ensureOpenClawMemoryFiles()` handles one-way migration seeding
// from the legacy top-level memory file when upgrading older projects.
// This runs before existence checks so migrated legacy content is preserved.
let createdFromDefaultLongTermScaffold = false;
if (backend.capabilities.writable) {
const { longTermCreated } = await ensureOpenClawMemoryFiles(rootDir);
if (longTermCreated) {
const createdContent = await readFile(memoryLongTermPath(rootDir), "utf-8");
createdFromDefaultLongTermScaffold =
createdContent === getDefaultLongTermMemoryScaffold();
}
}
// Check if memory already exists using the backend.
// This catches both pre-existing canonical files and newly migrated files.
if (backend.exists) {
const exists = await backend.exists(rootDir);
if (exists) {
refreshQmdIfNeeded();
return false; // Memory already exists, don't overwrite
}
} else {
// Fall back to direct file check
const filePath = memoryFilePath(rootDir);
if (existsSync(filePath)) {
if (exists && !createdFromDefaultLongTermScaffold) {
refreshQmdIfNeeded();
return false; // Memory already exists, don't overwrite
}
@@ -294,13 +302,6 @@ export async function ensureMemoryFileWithBackend(
await mkdir(dir, { recursive: true });
}
// OpenClaw-style memory layers are always bootstrapped for writable memory
// backends. `ensureOpenClawMemoryFiles()` handles one-way migration seeding
// from the legacy top-level memory file when upgrading older projects.
if (backend.capabilities.writable) {
await ensureOpenClawMemoryFiles(rootDir);
}
// Try to write using the backend
try {
const result = await backend.write(rootDir, getDefaultMemoryScaffold());