From f547fc54546087959f0d4a99c7dcab5527fded65 Mon Sep 17 00:00:00 2001 From: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:20:36 -0700 Subject: [PATCH] fix: unblock manual recall metadata patching --- dist/src/tools.js | 15 +++++- src/tools.ts | 19 ++++++- test/recall-text-cleanup.test.mjs | 85 +++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/dist/src/tools.js b/dist/src/tools.js index c2b11eae..10b77c8b 100644 --- a/dist/src/tools.js +++ b/dist/src/tools.js @@ -668,7 +668,11 @@ function createMemoryRecallTool(runtimeContext, options) { }; } const now = Date.now(); - await Promise.allSettled(results.map((result) => { + // Access metadata is best-effort usage telemetry. Keep it off the + // manual recall response path, matching auto-recall, so write-lock + // contention cannot turn an otherwise successful read into a tool + // timeout. + void Promise.allSettled(results.map((result) => { const meta = parseSmartMetadata(result.entry.metadata, result.entry); return runtimeContext.store.patchMetadata(result.entry.id, { access_count: meta.access_count + 1, @@ -683,7 +687,14 @@ function createMemoryRecallTool(runtimeContext, options) { // memory the user just explicitly searched for. suppressed_until_ms: 0, }, scopeFilter); - })); + })).then((settled) => { + const rejected = settled.filter((result) => result.status === "rejected"); + if (rejected.length > 0) { + console.warn(`[memory-lancedb-pro] background manual recall metadata patch failed for ${rejected.length}/${settled.length} memories`); + } + }).catch((error) => { + console.warn(`[memory-lancedb-pro] background manual recall metadata patch crashed: ${String(error)}`); + }); const text = results .map((r, i) => { const categoryTag = getDisplayCategoryTag(r.entry); diff --git a/src/tools.ts b/src/tools.ts index d0b3db6f..bff6379b 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -896,7 +896,11 @@ function createMemoryRecallTool( } const now = Date.now(); - await Promise.allSettled( + // Access metadata is best-effort usage telemetry. Keep it off the + // manual recall response path, matching auto-recall, so write-lock + // contention cannot turn an otherwise successful read into a tool + // timeout. + void Promise.allSettled( results.map((result) => { const meta = parseSmartMetadata(result.entry.metadata, result.entry); return runtimeContext.store.patchMetadata( @@ -917,7 +921,18 @@ function createMemoryRecallTool( scopeFilter, ); }), - ); + ).then((settled) => { + const rejected = settled.filter((result) => result.status === "rejected"); + if (rejected.length > 0) { + console.warn( + `[memory-lancedb-pro] background manual recall metadata patch failed for ${rejected.length}/${settled.length} memories`, + ); + } + }).catch((error) => { + console.warn( + `[memory-lancedb-pro] background manual recall metadata patch crashed: ${String(error)}`, + ); + }); const text = results .map((r, i) => { diff --git a/test/recall-text-cleanup.test.mjs b/test/recall-text-cleanup.test.mjs index 66b61ab6..77645196 100644 --- a/test/recall-text-cleanup.test.mjs +++ b/test/recall-text-cleanup.test.mjs @@ -460,6 +460,91 @@ describe("recall text cleanup", () => { assert.ok(res.details.memories[1].sources.bm25); }); + it("returns manual recall results before access metadata patches settle", async () => { + const context = makeRecallContext(); + let releasePatchGate; + const patchGate = new Promise((resolve) => { + releasePatchGate = resolve; + }); + const patchPromises = []; + const patchCalls = []; + let patchMetadataCalls = 0; + let patchMetadataSettled = 0; + + context.store.patchMetadata = (id, patch, scopeFilter) => { + patchMetadataCalls++; + patchCalls.push({ id, patch, scopeFilter }); + const patchPromise = patchGate.then(() => { + patchMetadataSettled++; + return null; + }); + patchPromises.push(patchPromise); + return patchPromise; + }; + + const tool = createTool(registerMemoryRecallTool, context); + const recallPromise = tool.execute(null, { query: "test" }); + const timeoutMarker = Symbol("timeout"); + const output = await Promise.race([ + recallPromise, + new Promise((resolve) => setTimeout(() => resolve(timeoutMarker), 500)), + ]); + + try { + assert.notEqual(output, timeoutMarker, "access metadata patches must not block manual recall"); + assert.match(output.content[0].text, //); + assert.equal(patchMetadataCalls, 2, "background access metadata patches should still start"); + assert.equal(patchMetadataSettled, 0, "manual recall should return before access metadata patches settle"); + assert.deepEqual( + patchCalls.map(({ id, scopeFilter }) => ({ id, scopeFilter })), + [ + { id: "m1", scopeFilter: ["global"] }, + { id: "m2", scopeFilter: ["global"] }, + ], + ); + assert.deepEqual(patchCalls[0].patch, { + access_count: 1, + last_accessed_at: patchCalls[0].patch.last_accessed_at, + last_confirmed_use_at: patchCalls[0].patch.last_confirmed_use_at, + bad_recall_count: 0, + suppressed_until_turn: 0, + suppressed_until_ms: 0, + }); + assert.equal(patchCalls[0].patch.last_accessed_at, patchCalls[0].patch.last_confirmed_use_at); + } finally { + releasePatchGate(); + await Promise.all(patchPromises); + await recallPromise; + } + + assert.equal(patchMetadataSettled, 2, "background access metadata patches should eventually settle"); + }); + + it("logs rejected background manual recall metadata patches", async () => { + const context = makeRecallContext(); + context.store.patchMetadata = async (id) => { + if (id === "m1") throw new Error("simulated metadata write failure"); + return null; + }; + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + + try { + const tool = createTool(registerMemoryRecallTool, context); + const output = await tool.execute(null, { query: "test" }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.match(output.content[0].text, //); + assert.ok( + warnings.some((line) => /background manual recall metadata patch failed for 1\/2 memories/.test(line)), + `expected background patch warning; warnings=${JSON.stringify(warnings)}`, + ); + } finally { + console.warn = originalWarn; + } + }); + it("keeps memory_recall neighbor summaries within the per-item character budget", async () => { const tool = createTool(registerMemoryRecallTool, makeRecallContext(makeNeighborSummaryResults())); const res = await tool.execute(null, {