mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 23:34:04 +00:00
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:
@@ -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", [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user