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 <newelljben@gmail.com>
This commit is contained in:
Ben Newell
2026-05-31 14:18:56 -04:00
committed by GitHub
parent 90329e2848
commit a88e4fb7e0
2 changed files with 110 additions and 2 deletions

View File

@@ -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<typeof import("openclaw/plugin-sdk/security-runtime")>(
"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<typeof import("openclaw/plugin-sdk/security-runtime")>(
"openclaw/plugin-sdk/security-runtime",
);
const mockedPrivateFileStore = vi.mocked(privateFileStore);
mockedPrivateFileStore.mockImplementation((rootDir: string) => {
const store = actual.privateFileStore(rootDir);
return {
...store,
readJsonIfExists: (async <T = unknown>(
relativePath: string,
options?: Parameters<typeof store.readJsonIfExists>[1],
): Promise<T | null> => {
if (rootDir === workspaceDir && store.path(relativePath) === phaseStorePath) {
const err = new Error("permission denied") as NodeJS.ErrnoException;
err.code = "EACCES";
throw err;
}
return store.readJsonIfExists<T>(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", [

View File

@@ -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;
}
}