From 89b3c5a3b70edb77278d69f6fa8a3b6dbf1f731e Mon Sep 17 00:00:00 2001 From: Yohanes Date: Fri, 28 Aug 2026 16:34:01 +0800 Subject: [PATCH] fix(memory): persist graph snapshots atomically --- src/memory/__tests__/knowledge-graph.test.ts | 10 ++++++++++ src/memory/index.ts | 18 +++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/memory/__tests__/knowledge-graph.test.ts b/src/memory/__tests__/knowledge-graph.test.ts index 236242413a..a6f59fb939 100644 --- a/src/memory/__tests__/knowledge-graph.test.ts +++ b/src/memory/__tests__/knowledge-graph.test.ts @@ -59,6 +59,16 @@ describe('KnowledgeGraphManager', () => { const newEntities = await manager.createEntities([]); expect(newEntities).toHaveLength(0); }); + + it('should persist graph snapshots without leaving temporary files', async () => { + await manager.createEntities([ + { name: 'Atomic', entityType: 'test', observations: ['durable'] }, + ]); + + const files = await fs.readdir(path.dirname(testFilePath)); + expect(files.filter(file => file.startsWith(path.basename(testFilePath) + '.') && file.endsWith('.tmp'))).toEqual([]); + await expect(fs.readFile(testFilePath, 'utf-8')).resolves.toContain('Atomic'); + }); }); describe('createRelations', () => { diff --git a/src/memory/index.ts b/src/memory/index.ts index 9865c5318e..ca3ee596dd 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { randomUUID } from 'crypto'; // Define memory file path using environment variable with fallback export const defaultMemoryPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory.jsonl'); @@ -114,7 +115,22 @@ export class KnowledgeGraphManager { relationType: r.relationType })), ]; - await fs.writeFile(this.memoryFilePath, lines.join("\n")); + // Write the complete snapshot away from the live file, then atomically + // replace it. A direct writeFile truncates memory.jsonl first, so a process + // interruption can leave the graph permanently partial or unreadable. + const temporaryPath = `${this.memoryFilePath}.${process.pid}.${randomUUID()}.tmp`; + try { + await fs.writeFile(temporaryPath, lines.join("\n")); + await fs.rename(temporaryPath, this.memoryFilePath); + } finally { + try { + await fs.unlink(temporaryPath); + } catch (error) { + if (!(error instanceof Error && 'code' in error && (error as any).code === 'ENOENT')) { + throw error; + } + } + } } async createEntities(entities: Entity[]): Promise {