Merge ff804bc14630563011a8a5147b081dc52fdeb6eb into 5e417b44e1540f528d2ae63e3e20229a902d1db2

This commit is contained in:
togotago 2026-03-20 19:39:02 -07:00 committed by GitHub
commit 1af68eecc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 123 additions and 3 deletions

View File

@ -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<string> }).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<string, unknown>) => 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);
});
});

View File

@ -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?.();
}

View File

@ -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<void> {
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<void> {
if (this.closed) {
return;