diff --git a/src/memory/index.test.ts b/src/memory/index.test.ts index 189fbc0c09d..c922aa25247 100644 --- a/src/memory/index.test.ts +++ b/src/memory/index.test.ts @@ -1123,4 +1123,87 @@ describe("memory index", () => { ); } }); + + it("shouldSyncSessions returns true for needsFullReindex even when reason is session-start or watch", async () => { + const cfg = createCfg({ storePath: indexMainPath }); + const manager = await getPersistentManager(cfg); + // Inject the sessions source so shouldSyncSessions passes the source guard + const sources = (manager as unknown as { sources: Set }).sources; + sources.add("sessions"); + try { + const shouldSync = manager as unknown as { + shouldSyncSessions: ( + params?: { reason?: string; force?: boolean }, + needsFullReindex?: boolean, + ) => boolean; + }; + // Core bug: reason gate must not block when needsFullReindex is true + expect(shouldSync.shouldSyncSessions({ reason: "session-start" }, true)).toBe(true); + expect(shouldSync.shouldSyncSessions({ reason: "watch" }, true)).toBe(true); + // Sanity: without needsFullReindex, these reasons should still block + expect(shouldSync.shouldSyncSessions({ reason: "session-start" }, false)).toBe(false); + expect(shouldSync.shouldSyncSessions({ reason: "watch" }, false)).toBe(false); + } finally { + sources.delete("sessions"); + } + }); + + it("restores sessionsDirty from persisted meta on manager construction", async () => { + const storePath = path.join(workspaceDir, `index-sessions-dirty-${Date.now()}.sqlite`); + const cfg: TestCfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: storePath, vector: { enabled: false } }, + chunking: { tokens: 4000, overlap: 0 }, + sync: { watch: false, onSessionStart: false, onSearch: false }, + query: { minScore: 0, hybrid: { enabled: false } }, + sources: ["memory", "sessions"], + experimental: { sessionMemory: true }, + }, + }, + list: [{ id: "main", default: true }], + }, + }; + + // First manager: write meta with sessionsDirty=true directly + const first = await getMemorySearchManager({ cfg, agentId: "main" }); + expect(first.manager).not.toBeNull(); + if (!first.manager) { + throw new Error("manager missing"); + } + const firstManager = first.manager as MemoryIndexManager; + managersForCleanup.add(firstManager); + (firstManager as unknown as { sessionsDirty: boolean }).sessionsDirty = true; + // Write meta that includes sessionsDirty=true + ( + firstManager as unknown as { + writeMeta: (meta: Record) => void; + } + ).writeMeta({ + model: "mock-embed", + provider: "openai", + chunkTokens: 4000, + chunkOverlap: 0, + sessionsDirty: true, + }); + await firstManager.close?.(); + managersForCleanup.delete(firstManager); + + // Second manager: should restore sessionsDirty from meta + const second = await getMemorySearchManager({ cfg, agentId: "main" }); + expect(second.manager).not.toBeNull(); + if (!second.manager) { + throw new Error("manager missing"); + } + const secondManager = second.manager as MemoryIndexManager; + managersForCleanup.add(secondManager); + const restoredDirty = (secondManager as unknown as { sessionsDirty: boolean }).sessionsDirty; + expect(restoredDirty).toBe(true); + await secondManager.close?.(); + managersForCleanup.delete(secondManager); + }); }); diff --git a/src/memory/manager-sync-ops.ts b/src/memory/manager-sync-ops.ts index 6babe931707..9d8a9366028 100644 --- a/src/memory/manager-sync-ops.ts +++ b/src/memory/manager-sync-ops.ts @@ -60,6 +60,7 @@ type MemoryIndexMeta = { chunkTokens: number; chunkOverlap: number; vectorDims?: number; + sessionsDirty?: boolean; }; type MemorySyncProgressState = { @@ -683,13 +684,13 @@ export abstract class MemoryManagerSyncOps { if (params?.force) { return true; } + if (needsFullReindex) { + return true; + } const reason = params?.reason; if (reason === "session-start" || reason === "watch") { return false; } - if (needsFullReindex) { - return true; - } return this.sessionsDirty && this.sessionsDirtyFiles.size > 0; } @@ -1227,6 +1228,10 @@ export abstract class MemoryManagerSyncOps { nextMeta.vectorDims = this.vector.dims; } + if (this.sources.has("sessions")) { + nextMeta.sessionsDirty = this.sessionsDirty; + } + this.writeMeta(nextMeta); this.pruneEmbeddingCacheIfNeeded?.(); @@ -1295,6 +1300,10 @@ export abstract class MemoryManagerSyncOps { nextMeta.vectorDims = this.vector.dims; } + if (this.sources.has("sessions")) { + nextMeta.sessionsDirty = this.sessionsDirty; + } + this.writeMeta(nextMeta); this.pruneEmbeddingCacheIfNeeded?.(); } diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 93a2332c9a9..0976f34f30a 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -255,6 +255,10 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem this.ensureIntervalSync(); const statusOnly = params.purpose === "status"; this.dirty = this.sources.has("memory") && (statusOnly ? !meta : true); + if (this.sources.has("sessions") && meta?.sessionsDirty) { + this.sessionsDirty = true; + void this.rebuildSessionsDirtyFiles(); + } this.batch = this.resolveBatchConfig(); } @@ -821,6 +825,30 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem } } + private async rebuildSessionsDirtyFiles(): Promise { + try { + const { listSessionFilesForAgent } = await import("./session-files.js"); + const files = await listSessionFilesForAgent(this.agentId); + + for (const absPath of files) { + const record = this.db + .prepare(`SELECT path FROM files WHERE path = ? AND source = ?`) + .get(absPath, "sessions") as { path: string } | undefined; + + if (!record) { + this.sessionsDirtyFiles.add(absPath); + } + } + + log.debug("Rebuilt sessionsDirtyFiles on startup", { + totalFiles: files.length, + dirtyFiles: this.sessionsDirtyFiles.size, + }); + } catch (err) { + log.warn(`Failed to rebuild sessionsDirtyFiles: ${String(err)}`); + } + } + async close(): Promise { if (this.closed) { return;