Skip to content

Commit bed7b4e

Browse files
openperfclaude
andcommitted
fix(memory): search memory and wiki concurrently for corpus=all (#92633)
corpus=all awaited the memory/sessions branch fully before starting the wiki/supplement branch, so two searches that each fit the 15s tool deadline summed past it and the tool returned empty with disabled=true. Start the supplement search concurrently with the memory branch within the same deadline; combined wall time drops to ~max(branch). Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent 340c245 commit bed7b4e

2 files changed

Lines changed: 122 additions & 15 deletions

File tree

extensions/memory-core/src/tools.citations.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,104 @@ describe("memory tools", () => {
495495
expect(getMemorySearchManagerMockCalls()).toBe(1);
496496
});
497497

498+
it("runs the memory and wiki branches concurrently for corpus=all (#92633)", async () => {
499+
// Regression: corpus=all awaited the memory/sessions branch fully before
500+
// starting the wiki/supplement branch, so two searches that each finished
501+
// well under the 15s tool deadline summed past it and the tool returned
502+
// nothing. Running them concurrently keeps the combined wall time near
503+
// max(branch), so the same two 9s searches now resolve at ~9s.
504+
vi.useFakeTimers();
505+
try {
506+
const delay = (ms: number) =>
507+
new Promise<void>((resolve) => {
508+
setTimeout(resolve, ms);
509+
});
510+
setMemorySearchImpl(async () => {
511+
await delay(9_000);
512+
return [
513+
{
514+
path: "MEMORY.md",
515+
startLine: 5,
516+
endLine: 7,
517+
score: 0.9,
518+
snippet: "@@ -5,3 @@\nAssistant: noted",
519+
source: "memory" as const,
520+
},
521+
];
522+
});
523+
registerMemoryCorpusSupplement("memory-wiki", {
524+
search: async () => {
525+
await delay(9_000);
526+
return [
527+
{
528+
corpus: "wiki",
529+
path: "entities/alpha.md",
530+
title: "Alpha",
531+
kind: "entity",
532+
score: 1.1,
533+
snippet: "Alpha wiki entry",
534+
},
535+
];
536+
},
537+
get: async () => null,
538+
});
539+
540+
const tool = createMemorySearchToolOrThrow();
541+
const resultPromise = tool.execute("call_all_concurrent", {
542+
query: "alpha",
543+
corpus: "all",
544+
});
545+
// Sequentially the two branches take 18s and trip the 15s deadline;
546+
// concurrently the tool resolves once the shared 9s elapses.
547+
await vi.advanceTimersByTimeAsync(9_000);
548+
const result = await resultPromise;
549+
const details = result.details as {
550+
disabled?: boolean;
551+
results: Array<{ corpus: string; path: string }>;
552+
};
553+
expect(details.disabled).toBeUndefined();
554+
expect(details.results.map((entry) => [entry.corpus, entry.path])).toEqual([
555+
["wiki", "entities/alpha.md"],
556+
["memory", "MEMORY.md"],
557+
]);
558+
} finally {
559+
vi.useRealTimers();
560+
}
561+
});
562+
563+
it("records the memory cooldown when corpus=all memory and wiki both fail (#92633)", async () => {
564+
// Concurrency must not let a wiki failure mask a real memory backend failure:
565+
// when both branches throw, the memory cooldown still has to be recorded so a
566+
// broken embedding provider is suppressed rather than re-hit on the next turn.
567+
let searchCalls = 0;
568+
setMemorySearchImpl(async () => {
569+
searchCalls += 1;
570+
throw new Error("openai embeddings failed: 500 server_error");
571+
});
572+
registerMemoryCorpusSupplement("memory-wiki", {
573+
search: async () => {
574+
throw new Error("wiki supplement unavailable");
575+
},
576+
get: async () => null,
577+
});
578+
579+
const tool = createMemorySearchToolOrThrow();
580+
const bothFail = await tool.execute("call_all_both_fail", {
581+
query: "alpha",
582+
corpus: "all",
583+
});
584+
expectUnavailableMemorySearchDetails(bothFail.details, {
585+
error: "openai embeddings failed: 500 server_error",
586+
warning: "Memory search is unavailable due to an embedding/provider error.",
587+
action: "Check embedding provider configuration and retry memory_search.",
588+
});
589+
590+
// The next memory search is suppressed by the cooldown (no second backend hit).
591+
const followup = await tool.execute("call_memory_after_both_fail", { query: "alpha" });
592+
expect((followup.details as { disabled?: boolean }).disabled).toBe(true);
593+
expect(searchCalls).toBe(1);
594+
});
595+
498596
it("does not cooldown primary memory when a corpus=all wiki supplement stalls", async () => {
499597
vi.useFakeTimers();
500598
try {

extensions/memory-core/src/tools.ts

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -393,10 +393,13 @@ export function createMemorySearchTool(options: {
393393
});
394394
const cooldown =
395395
requestedCorpus === "wiki" ? undefined : readMemorySearchToolCooldown(cooldownKey);
396-
let activeUnavailablePhase: "memory" | "supplement" | undefined;
397-
let failedUnavailablePhase: "memory" | "supplement" | undefined;
396+
// Only the memory/sessions branch feeds this tracker; the wiki branch
397+
// runs concurrently (see below) and a second writer would make the
398+
// corpus=all cooldown attribution race on last-writer-wins.
399+
let activeUnavailablePhase: "memory" | undefined;
400+
let failedUnavailablePhase: "memory" | undefined;
398401
const runUnavailablePhase = async <T>(
399-
phase: "memory" | "supplement",
402+
phase: "memory",
400403
task: () => Promise<T>,
401404
): Promise<T> => {
402405
activeUnavailablePhase = phase;
@@ -430,6 +433,23 @@ export function createMemorySearchTool(options: {
430433
return context;
431434
};
432435
try {
436+
// Run the wiki/supplement branch concurrently with memory/sessions:
437+
// corpus=all previously awaited them sequentially under one 15s
438+
// deadline, so two individually-fast searches summed past it and
439+
// returned nothing (#92633). It stays outside runUnavailablePhase so
440+
// the cooldown's memory-failure attribution is unaffected by the
441+
// wiki branch; .catch absorbs the rejection on the rare paused-index
442+
// early return that skips the await below.
443+
const supplementResultsPromise: Promise<MemoryCorpusSearchResult[]> =
444+
shouldQuerySupplements
445+
? searchMemoryCorpusSupplements({
446+
query,
447+
maxResults,
448+
agentSessionKey: options.agentSessionKey,
449+
corpus: requestedCorpus,
450+
})
451+
: Promise.resolve([]);
452+
supplementResultsPromise.catch(() => undefined);
433453
const memory = shouldQueryMemory
434454
? await runUnavailablePhase("memory", async () =>
435455
trackMemoryManager(
@@ -599,18 +619,7 @@ export function createMemorySearchTool(options: {
599619
);
600620
}
601621
}
602-
const supplementResults = shouldQuerySupplements
603-
? await runUnavailablePhase(
604-
"supplement",
605-
async () =>
606-
await searchMemoryCorpusSupplements({
607-
query,
608-
maxResults,
609-
agentSessionKey: options.agentSessionKey,
610-
corpus: requestedCorpus,
611-
}),
612-
)
613-
: [];
622+
const supplementResults = await supplementResultsPromise;
614623
// Wiki and memory scores use incomparable scales, so corpus=all first
615624
// balances candidate selection and then backfills any unused slots.
616625
const effectiveMax = Math.max(1, maxResults ?? 10);

0 commit comments

Comments
 (0)