From a88e4fb7e05e69d15fc0746c7d2eb91c3eb5a1d2 Mon Sep 17 00:00:00 2001 From: Ben Newell <142949922+bennewell35@users.noreply.github.com> Date: Sun, 31 May 2026 14:18:56 -0400 Subject: [PATCH] fix(memory-core): preserve phase signals on read errors Phase-signal store reads now recover only missing files and corrupt JSON. Nonrecoverable filesystem read failures propagate so dreaming aborts before overwriting existing phase-signal history with an empty replacement. Fixes #77881. Thanks @bennewell35. Co-authored-by: bennewell35 --- .../src/short-term-promotion.test.ts | 105 ++++++++++++++++++ .../memory-core/src/short-term-promotion.ts | 7 +- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/extensions/memory-core/src/short-term-promotion.test.ts b/extensions/memory-core/src/short-term-promotion.test.ts index 25eb20fd974..5b9a675b096 100644 --- a/extensions/memory-core/src/short-term-promotion.test.ts +++ b/extensions/memory-core/src/short-term-promotion.test.ts @@ -7,6 +7,17 @@ vi.mock("openclaw/plugin-sdk/memory-host-events", () => ({ appendMemoryHostEvent: vi.fn(async () => {}), })); +vi.mock("openclaw/plugin-sdk/security-runtime", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/security-runtime", + ); + return { + ...actual, + privateFileStore: vi.fn((rootDir: string) => actual.privateFileStore(rootDir)), + }; +}); + +import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; import { applyShortTermPromotions, auditShortTermPromotionArtifacts, @@ -1104,6 +1115,100 @@ describe("short-term promotion", () => { }); }); + it("propagates unreadable phase-signal store errors without overwriting existing signals", async () => { + await withTempWorkspace(async (workspaceDir) => { + await recordShortTermRecalls({ + workspaceDir, + query: "glacier cadence", + nowMs: Date.parse("2026-04-01T10:00:00.000Z"), + results: [ + { + path: "memory/2026-04-01.md", + startLine: 1, + endLine: 1, + score: 0.9, + snippet: "Move backups to S3 Glacier.", + source: "memory", + }, + ], + }); + + const ranked = await rankShortTermPromotionCandidates({ + workspaceDir, + minScore: 0, + minRecallCount: 0, + minUniqueQueries: 0, + nowMs: Date.parse("2026-04-05T10:00:00.000Z"), + }); + const key = ranked[0]?.key; + expect(key).toBeTruthy(); + if (!key) { + throw new Error("expected ranked candidate key"); + } + + const phaseStorePath = resolveShortTermPhaseSignalStorePath(workspaceDir); + const existingRaw = `${JSON.stringify( + { + version: 1, + updatedAt: "2026-04-01T10:00:00.000Z", + entries: { + [key]: { + key, + lightHits: 2, + remHits: 1, + lastLightAt: "2026-04-01T10:00:00.000Z", + lastRemAt: "2026-04-02T10:00:00.000Z", + }, + }, + }, + null, + 2, + )}\n`; + await fs.writeFile(phaseStorePath, existingRaw, "utf-8"); + + const actual = await vi.importActual( + "openclaw/plugin-sdk/security-runtime", + ); + const mockedPrivateFileStore = vi.mocked(privateFileStore); + mockedPrivateFileStore.mockImplementation((rootDir: string) => { + const store = actual.privateFileStore(rootDir); + return { + ...store, + readJsonIfExists: (async ( + relativePath: string, + options?: Parameters[1], + ): Promise => { + if (rootDir === workspaceDir && store.path(relativePath) === phaseStorePath) { + const err = new Error("permission denied") as NodeJS.ErrnoException; + err.code = "EACCES"; + throw err; + } + return store.readJsonIfExists(relativePath, options); + }) as typeof store.readJsonIfExists, + }; + }); + + try { + await expect( + recordDreamingPhaseSignals({ + workspaceDir, + phase: "rem", + keys: [key], + nowMs: Date.parse("2026-04-05T10:00:00.000Z"), + }), + ).rejects.toMatchObject({ + code: "EACCES", + }); + } finally { + mockedPrivateFileStore.mockImplementation((rootDir: string) => + actual.privateFileStore(rootDir), + ); + } + + expect(await fs.readFile(phaseStorePath, "utf-8")).toBe(existingRaw); + }); + }); + it("reconciles existing promotion markers instead of appending duplicates", async () => { await withTempWorkspace(async (workspaceDir) => { await writeDailyMemoryNote(workspaceDir, "2026-04-01", [ diff --git a/extensions/memory-core/src/short-term-promotion.ts b/extensions/memory-core/src/short-term-promotion.ts index 951e6f706c7..0db3f3bfa8d 100644 --- a/extensions/memory-core/src/short-term-promotion.ts +++ b/extensions/memory-core/src/short-term-promotion.ts @@ -953,8 +953,11 @@ async function readPhaseSignalStore( await privateFileStore(workspaceDir).readJsonIfExists(SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH), nowIso, ); - } catch { - return emptyPhaseSignalStore(nowIso); + } catch (err) { + if (err instanceof SyntaxError) { + return emptyPhaseSignalStore(nowIso); + } + throw err; } }