From 2ec0757c2c85c31de1bb2baa2d7abc2a39f21bfe Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Mon, 24 Aug 2026 16:13:38 -0700 Subject: [PATCH] fix(memory): suppress stale warning during session catch-up (#128894) --- .../src/memory-tool-manager.test-mocks.ts | 7 ++++ .../manager-search-orchestration.test.ts | 17 ++++++++ extensions/memory-core/src/memory/manager.ts | 10 +++++ extensions/memory-core/src/tools.test.ts | 20 +++++++++ packages/memory-host-sdk/src/host/types.ts | 10 ++++- .../server-methods/memory-search.test.ts | 42 +++++++++++++++---- 6 files changed, 97 insertions(+), 9 deletions(-) diff --git a/extensions/memory-core/src/memory-tool-manager.test-mocks.ts b/extensions/memory-core/src/memory-tool-manager.test-mocks.ts index c112cfc810a1..2c9a30ab29f6 100644 --- a/extensions/memory-core/src/memory-tool-manager.test-mocks.ts +++ b/extensions/memory-core/src/memory-tool-manager.test-mocks.ts @@ -27,6 +27,7 @@ type MemoryManagerParams = { let workspaceDir = "/workspace"; let statusDirty = false; +let pendingSyncSources: MemorySource[] | undefined; let customStatus: Record | 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; diff --git a/extensions/memory-core/src/memory/manager-search-orchestration.test.ts b/extensions/memory-core/src/memory/manager-search-orchestration.test.ts index 5be86c82186b..141396bdb626 100644 --- a/extensions/memory-core/src/memory/manager-search-orchestration.test.ts +++ b/extensions/memory-core/src/memory/manager-search-orchestration.test.ts @@ -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(() => {})); + 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( diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index 28951cd602b4..60613c62432e 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -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, diff --git a/extensions/memory-core/src/tools.test.ts b/extensions/memory-core/src/tools.test.ts index 0b880e7fdaf9..fc0bc11f5a72 100644 --- a/extensions/memory-core/src/tools.test.ts +++ b/extensions/memory-core/src/tools.test.ts @@ -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) => { diff --git a/packages/memory-host-sdk/src/host/types.ts b/packages/memory-host-sdk/src/host/types.ts index 4d0a3f0fbe85..5e3f70e7d54c 100644 --- a/packages/memory-host-sdk/src/host/types.ts +++ b/packages/memory-host-sdk/src/host/types.ts @@ -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, + status: Pick, agentId?: string, ): { stale: true; warning: string; action: string } | null { const identity = status.custom?.indexIdentity as Record | 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 { diff --git a/src/gateway/server-methods/memory-search.test.ts b/src/gateway/server-methods/memory-search.test.ts index e92525a7e61b..1f9cd13cbf3a 100644 --- a/src/gateway/server-methods/memory-search.test.ts +++ b/src/gateway/server-methods/memory-search.test.ts @@ -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 => []), - 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, + ); + }); });