diff --git a/packages/coding-agent/src/memories/index.ts b/packages/coding-agent/src/memories/index.ts index 81b5777b0cc..730f58eacfc 100644 --- a/packages/coding-agent/src/memories/index.ts +++ b/packages/coding-agent/src/memories/index.ts @@ -508,7 +508,13 @@ async function runPhase2(options: MemoryStartupOptions): Promise { await syncPhase2Artifacts(memoryRoot, outputs); if (!isMemoryStartupActive(options)) return; if (outputs.length === 0) { - await cleanupConsolidatedArtifacts(memoryRoot); + // Never wipe consolidated artifacts for an empty input: the scope + // may simply have no outputs yet (or its outputs landed under a + // sibling scope key, e.g. a case-variant cwd on Windows), while a + // live MEMORY.md / memory_summary.md / skills/ from a prior run — + // or a racing scope sharing this directory — is still valid. The + // destructive cleanup previously deleted those on every empty + // Phase 2, stranding and erasing real consolidations (#12596). if (!isMemoryStartupActive(options)) return; const marked = markGlobalPhase2Succeeded(db, { ownershipToken: claim.ownershipToken, @@ -861,12 +867,6 @@ async function syncPhase2Artifacts(memoryRoot: string, outputs: Stage1OutputRow[ await Bun.write(path.join(memoryRoot, "raw_memories.md"), rawBody); } -async function cleanupConsolidatedArtifacts(memoryRoot: string): Promise { - await fs.rm(path.join(memoryRoot, "MEMORY.md"), { force: true }); - await fs.rm(path.join(memoryRoot, "memory_summary.md"), { force: true }); - await fs.rm(path.join(memoryRoot, "skills"), { recursive: true, force: true }); -} - function buildRawMemoriesMarkdown(outputs: Stage1OutputRow[]): string { if (outputs.length === 0) { return "# Raw Memories\n\nNo raw memories yet.\n"; diff --git a/packages/coding-agent/test/memories-runtime.test.ts b/packages/coding-agent/test/memories-runtime.test.ts index 8ef810e02ee..7aa8e050051 100644 --- a/packages/coding-agent/test/memories-runtime.test.ts +++ b/packages/coding-agent/test/memories-runtime.test.ts @@ -398,7 +398,7 @@ describe("memories runtime", () => { expect(raw.indexOf("## thread-b")).toBeLessThan(raw.indexOf("## thread-a")); }); - test("phase2 empty-input cleanup removes consolidated files and skills dir", async () => { + test("phase2 empty-input preserves consolidated files and skills dir (issue #12596)", async () => { const fx = await createFixture(); const memoryRoot = getMemoryRoot(fx.agentDir, fx.session.sessionManager.getCwd()); await fs.mkdir(path.join(memoryRoot, "skills", "legacy"), { recursive: true }); @@ -420,10 +420,13 @@ describe("memories runtime", () => { taskDepth: 0, }); - await settle(fx.whenSettled, "phase2 empty-input cleanup"); - expect(await Bun.file(path.join(memoryRoot, "MEMORY.md")).exists()).toBe(false); - expect(await Bun.file(path.join(memoryRoot, "memory_summary.md")).exists()).toBe(false); - expect(await Bun.file(path.join(memoryRoot, "skills")).exists()).toBe(false); + await settle(fx.whenSettled, "phase2 empty-input preservation"); + // An empty Phase 2 must not wipe live consolidations: outputs may land + // later (or under a sibling scope key), and the artifacts on disk are + // still valid. Previously this path deleted them. + expect(await Bun.file(path.join(memoryRoot, "MEMORY.md")).exists()).toBe(true); + expect(await Bun.file(path.join(memoryRoot, "memory_summary.md")).exists()).toBe(true); + expect(await Bun.file(path.join(memoryRoot, "skills", "legacy", "SKILL.md")).exists()).toBe(true); expect((await fs.readFile(path.join(memoryRoot, "raw_memories.md"), "utf8")).trim()).toBe( "# Raw Memories\n\nNo raw memories yet.", );