Skip to content

Commit be3db39

Browse files
fix(memory-core): trigger verified reindex on missing-meta recovery
When identity is missing but indexed chunks exist, force a verified full reindex via runSafeReindex/runUnsafeReindex instead of returning with a dirty/paused index. The reindex path prunes stale chunks from deleted paths, old providers, or old scopes before writing meta, so the index does not become searchable with unverified memory rows. Guard: when a specific embedding provider is configured but currently unavailable, the recovery path leaves the index dirty/paused instead of forcing an FTS-only reindex that would wipe existing vector embeddings. For auto/none/unset providers or when the provider is available, the verified full reindex proceeds safely. Adds three regression tests: - triggers verified reindex when identity missing + chunks exist - prunes stale chunks from deleted paths during recovery - preserves index when specific provider is unavailable Closes #90338 Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent 98f52dc commit be3db39

2 files changed

Lines changed: 118 additions & 2 deletions

File tree

extensions/memory-core/src/memory/manager-sync-ops.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1846,7 +1846,7 @@ export abstract class MemoryManagerSyncOps {
18461846
const needsInitialIndex = indexIdentity.status !== "valid" && !hasIndexedChunks;
18471847
const needsExplicitIdentityReindex =
18481848
params?.reason === "cli" && indexIdentity.status !== "valid" && !hasTargetSessionFiles;
1849-
const needsFullReindex =
1849+
let needsFullReindex =
18501850
(params?.force && !hasTargetSessionFiles) ||
18511851
needsInitialIndex ||
18521852
needsExplicitIdentityReindex;
@@ -1859,7 +1859,28 @@ export abstract class MemoryManagerSyncOps {
18591859
if (sessionsDirty) {
18601860
this.sessionsDirty = true;
18611861
}
1862-
return;
1862+
// If meta is missing but indexed chunks exist, force a verified full
1863+
// reindex instead of returning with a dirty/paused index. The reindex
1864+
// prunes stale chunks from deleted paths, old providers, or old scopes
1865+
// before writing meta (PR #90395, issue #90338).
1866+
// Guard: when a specific embedding provider is configured but currently
1867+
// unavailable, the existing chunks may be semantic (vector-backed).
1868+
// Forcing a reindex without the provider would silently downgrade the
1869+
// index to FTS-only and wipe vector embeddings. In that case keep the
1870+
// index dirty/paused so the provider can recover on a later sync.
1871+
if (!meta && hasIndexedChunks) {
1872+
const isSpecificProviderConfigured =
1873+
this.settings.provider &&
1874+
this.settings.provider !== "none" &&
1875+
this.settings.provider !== "auto";
1876+
if (isSpecificProviderConfigured && !this.provider) {
1877+
return;
1878+
}
1879+
needsFullReindex = true;
1880+
// Fall through to runSafeReindex / runUnsafeReindex below.
1881+
} else {
1882+
return;
1883+
}
18631884
}
18641885
if (!needsFullReindex) {
18651886
const targetedSessionSync = await runMemoryTargetedSessionSync({

extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,99 @@ describe("memory manager FTS-only reindex", () => {
205205
);
206206
expect(memoryManager.status().provider).toBe("openai");
207207
});
208+
209+
it("triggers verified reindex when identity is missing but indexed chunks exist", async () => {
210+
// Explicit "none" provider: FTS-only, no semantic downgrade risk.
211+
const memoryManager = await createManager({ provider: "none", vectorEnabled: false });
212+
await memoryManager.sync({ force: true });
213+
214+
// Sanity: after initial sync, meta is present and chunks exist.
215+
expect(memoryManager.status().chunks).toBeGreaterThan(0);
216+
expect(memoryManager.status().custom?.indexIdentity).toEqual({ status: "valid" });
217+
218+
// Delete meta via the manager's own DB to simulate the dead-loop condition.
219+
const internals = memoryManager as unknown as {
220+
db: DatabaseSync;
221+
readMeta(): MemoryIndexMeta | null;
222+
};
223+
internals.db.exec("DELETE FROM meta WHERE key = 'memory_index_meta_v1'");
224+
225+
// Verify meta was actually deleted.
226+
expect(internals.readMeta()).toBeNull();
227+
228+
// Without force, the recovery path triggers a verified full reindex
229+
// (runSafeReindex) instead of writing unverified meta. The reindex
230+
// prunes stale rows and writes meta only after verification.
231+
await memoryManager.sync();
232+
233+
// After the fix: meta is recreated via verified reindex, dead loop broken.
234+
const metaAfter = internals.readMeta();
235+
expect(metaAfter).not.toBeNull();
236+
expect(memoryManager.status().custom?.indexIdentity).toEqual({ status: "valid" });
237+
});
238+
239+
it("prunes stale chunks during missing-meta recovery when memory files are deleted", async () => {
240+
// Explicit "none" provider: FTS-only, no semantic downgrade risk.
241+
const memoryManager = await createManager({ provider: "none", vectorEnabled: false });
242+
await memoryManager.sync({ force: true });
243+
244+
// Sanity: chunks exist from current MEMORY.md.
245+
const initialChunks = memoryManager.status().chunks;
246+
expect(initialChunks).toBeGreaterThan(0);
247+
248+
// Delete the memory file from disk — its chunks are now stale.
249+
await fs.rm(path.join(workspaceDir, "MEMORY.md"));
250+
251+
// Delete meta to trigger the recovery path.
252+
const internals = memoryManager as unknown as {
253+
db: DatabaseSync;
254+
readMeta(): MemoryIndexMeta | null;
255+
};
256+
internals.db.exec("DELETE FROM meta WHERE key = 'memory_index_meta_v1'");
257+
expect(internals.readMeta()).toBeNull();
258+
259+
// Sync without force — recovery path triggers verified reindex.
260+
// The reindex scans current sources, finds no memory files,
261+
// and prunes stale chunks from the deleted path.
262+
await memoryManager.sync();
263+
264+
// Stale chunks from deleted paths are pruned.
265+
const chunksAfter = (
266+
internals.db.prepare("SELECT count(*) as c FROM chunks").get() as { c: number }
267+
).c;
268+
expect(chunksAfter).toBe(0);
269+
270+
// Meta is recreated, identity valid — dead loop broken.
271+
expect(internals.readMeta()).not.toBeNull();
272+
expect(memoryManager.status().custom?.indexIdentity).toEqual({ status: "valid" });
273+
});
274+
275+
it("preserves index when specific provider is configured but unavailable during missing-meta recovery", async () => {
276+
// Configure a specific embedding provider that is unavailable.
277+
const memoryManager = await createManager({ provider: "openai" });
278+
await memoryManager.sync({ force: true });
279+
280+
expect(memoryManager.status().chunks).toBeGreaterThan(0);
281+
282+
// Delete meta to simulate the dead-loop condition.
283+
const internals = memoryManager as unknown as {
284+
db: DatabaseSync;
285+
readMeta(): MemoryIndexMeta | null;
286+
};
287+
internals.db.exec("DELETE FROM meta WHERE key = 'memory_index_meta_v1'");
288+
expect(internals.readMeta()).toBeNull();
289+
290+
// Sync without force — recovery path detects that a specific provider
291+
// ("openai") is configured but unavailable. Existing chunks may be
292+
// semantic, so the index stays dirty/paused instead of forcing an
293+
// FTS-only reindex that would wipe vector embeddings.
294+
await memoryManager.sync();
295+
296+
// Meta stays missing, index stays dirty — provider outage preserved.
297+
expect(internals.readMeta()).toBeNull();
298+
expect(memoryManager.status().dirty).toBe(true);
299+
expect(memoryManager.status().custom?.indexIdentity).toMatchObject({
300+
status: "missing",
301+
});
302+
});
208303
});

0 commit comments

Comments
 (0)