fix(memory): suppress stale warning during session catch-up (#128894)

This commit is contained in:
Josh Lehman
2026-08-24 16:13:38 -07:00
committed by GitHub
parent 9b36c8bc56
commit 2ec0757c2c
6 changed files with 97 additions and 9 deletions
@@ -27,6 +27,7 @@ type MemoryManagerParams = {
let workspaceDir = "/workspace";
let statusDirty = false;
let pendingSyncSources: MemorySource[] | undefined;
let customStatus: Record<string, unknown> | undefined;
let sourceCounts: Array<{ source: MemorySource; files: number; chunks: number }> = [
{ source: "memory", files: 1, chunks: 1 },
@@ -56,6 +57,7 @@ const stubManager = {
files: 1,
chunks: 1,
dirty: statusDirty,
pendingSyncSources,
workspaceDir,
dbPath: "/workspace/.memory/index.sqlite",
provider: "builtin",
@@ -95,6 +97,10 @@ export function setMemoryStatusDirty(next: boolean): void {
statusDirty = next;
}
export function setMemoryPendingSyncSources(next: MemorySource[] | undefined): void {
pendingSyncSources = next;
}
export function setMemorySourceCounts(
next: Array<{ source: MemorySource; files: number; chunks: number }>,
): void {
@@ -131,6 +137,7 @@ export function resetMemoryToolMockState(overrides?: {
}): void {
workspaceDir = "/workspace";
statusDirty = false;
pendingSyncSources = undefined;
customStatus = undefined;
sourceCounts = [{ source: "memory", files: 1, chunks: 1 }];
getManagerImpl = undefined;
@@ -498,6 +498,23 @@ describe("memory index", () => {
await pendingSync;
});
it("reports session-only refreshes from the manager sync owner", async () => {
const manager = await getPersistentManager(
createCfg({ provider: "none", minScore: 0, onSearch: true, hybrid: { enabled: true } }),
);
await manager.sync({ reason: "test" });
Reflect.set(manager, "dirty", false);
Reflect.set(manager, "sessionsDirty", true);
Reflect.set(manager, "syncing", new Promise<void>(() => {}));
try {
expect(manager.status().pendingSyncSources).toEqual(["sessions"]);
} finally {
Reflect.set(manager, "syncing", null);
Reflect.set(manager, "sessionsDirty", false);
}
});
it("waits for dirty sync before querying", async () => {
providerFixture.forceNoProvider = true;
const manager = await getPersistentManager(
@@ -482,12 +482,22 @@ export class MemoryIndexManager extends MemorySearchOrchestration implements Mem
requestedProvider: this.requestedProvider,
configuredModel: this.settings.model || undefined,
});
const pendingSyncSources: MemorySource[] = [];
if (this.syncing) {
if (this.dirty) {
pendingSyncSources.push("memory");
}
if (this.sessionsDirty) {
pendingSyncSources.push("sessions");
}
}
return {
backend: "builtin",
files: aggregateState.files,
chunks: aggregateState.chunks,
dirty: this.dirty || this.sessionsDirty || this.indexIdentityDirty,
pendingSyncSources: pendingSyncSources.length > 0 ? pendingSyncSources : undefined,
workspaceDir: this.workspaceDir,
dbPath: this.settings.store.databasePath,
provider: providerInfo.provider,
+20
View File
@@ -12,6 +12,7 @@ import {
resetMemoryToolMockState,
setMemoryCloseImpl,
setMemoryCustomStatus,
setMemoryPendingSyncSources,
setMemorySearchImpl,
setMemorySearchManagerImpl,
setMemorySourceCounts,
@@ -554,6 +555,25 @@ describe("memory_search unavailable payloads", () => {
expect(getMemorySyncMockCalls()).toBe(0);
});
it("does not qualify results while session-only catch-up is in progress", async () => {
setMemoryStatusDirty(true);
setMemoryPendingSyncSources(["sessions"]);
setMemorySearchImpl(async () => []);
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { citations: "off" },
},
});
const result = await tool.execute("session-catch-up", { query: "hidden codeword" });
expect(result.details).toMatchObject({ results: [] });
expect(result.details).not.toHaveProperty("stale");
expect(result.details).not.toHaveProperty("warning");
expect(result.details).not.toHaveProperty("action");
});
it("surfaces embedding bootstrap degradation when keyword search has no hits", async () => {
let searchCalls = 0;
setMemorySearchImpl(async (opts) => {
+8 -2
View File
@@ -147,6 +147,8 @@ export type MemoryProviderStatus = {
files?: number;
chunks?: number;
dirty?: boolean;
/** Sources currently being refreshed by an admitted sync. */
pendingSyncSources?: MemorySource[];
workspaceDir?: string;
dbPath?: string;
extraPaths?: MemoryExtraPath[];
@@ -186,7 +188,7 @@ export type MemoryProviderStatus = {
};
export function resolveMemorySearchStaleness(
status: Pick<MemoryProviderStatus, "dirty" | "custom">,
status: Pick<MemoryProviderStatus, "dirty" | "pendingSyncSources" | "custom">,
agentId?: string,
): { stale: true; warning: string; action: string } | null {
const identity = status.custom?.indexIdentity as Record<string, unknown> | undefined;
@@ -195,7 +197,11 @@ export function resolveMemorySearchStaleness(
typeof identity.reason === "string"
? identity.reason.trim()
: undefined;
if (!status.dirty && !identityReason) {
const refreshingSessionsOnly =
status.dirty === true &&
status.pendingSyncSources?.length === 1 &&
status.pendingSyncSources[0] === "sessions";
if ((!status.dirty || refreshingSessionsOnly) && !identityReason) {
return null;
}
return {
@@ -2,7 +2,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSelectionRequiredError } from "../../agents/agent-scope-config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { MemorySearchResult } from "../../memory-host-sdk/host/types.js";
import type { MemoryProviderStatus, MemorySearchResult } from "../../memory-host-sdk/host/types.js";
import {
createOpenClawTestState,
type OpenClawTestState,
@@ -56,12 +56,14 @@ async function invokeMemorySearch(params: unknown, cfg: OpenClawConfig) {
function createStubManager() {
return {
search: vi.fn(async (): Promise<MemorySearchResult[]> => []),
status: vi.fn(() => ({
backend: "builtin" as const,
provider: "none",
dirty: false,
custom: { searchMode: "fts-only" },
})),
status: vi.fn(
(): MemoryProviderStatus => ({
backend: "builtin" as const,
provider: "none",
dirty: false,
custom: { searchMode: "fts-only" },
}),
),
close: vi.fn(async () => undefined),
};
}
@@ -284,4 +286,30 @@ describe("memory.search gateway method", () => {
undefined,
);
});
it("does not qualify results while session-only catch-up is in progress", async () => {
const cfg = createConfig(testState.workspaceDir);
const manager = createStubManager();
manager.status.mockReturnValue({
backend: "builtin",
provider: "none",
dirty: true,
pendingSyncSources: ["sessions"],
custom: { searchMode: "fts-only" },
});
getActiveMemorySearchManagerCore.mockResolvedValue({ manager });
const respond = await invokeMemorySearch({ query: "hidden codeword" }, cfg);
expect(respond).toHaveBeenCalledWith(
true,
{
agentId: "main",
provider: "none",
searchMode: "fts-only",
results: [],
},
undefined,
);
});
});