Skip to content

Commit 66e35e8

Browse files
committed
fix(memory-core): skip zero-hit forced sync for one-shot CLI managers
The bounded zero-hit forced sync prevents a slow/stuck sync from hanging the interactive memory_search deadline for long-lived (gateway) managers. But one-shot CLI runs (oneShotCliRun -> purpose=cli) tear the manager down in the tool's finally block, and close() awaits any in-flight sync UNBOUNDED (manager.ts awaitCurrentSync / qmd-manager pendingUpdate). That relocated the exact hang the bound prevents into cleanup. Skip the optional forced refresh entirely for purpose=cli: a one-shot run has no 'next call' to benefit from a warmed index, and search() already force-syncs a cold index inline + schedules a background sync, so nothing is lost. Addresses the ClawSweeper P1 on PR #94564. Adds a regression test: a one-shot run with a never-settling sync returns an available empty result, never starts the sync, and closes once.
1 parent 207da36 commit 66e35e8

2 files changed

Lines changed: 47 additions & 1 deletion

File tree

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,41 @@ describe("memory_search unavailable payloads", () => {
399399
}
400400
});
401401

402+
it("skips the zero-hit forced sync for one-shot CLI runs so cleanup cannot hang", async () => {
403+
// Regression: bounding the forced-sync wait in the tool body is not enough
404+
// for one-shot CLI runs, because the `finally` tears the manager down and
405+
// close() awaits any in-flight sync UNBOUNDED — relocating the hang to
406+
// cleanup. The fix skips the optional forced sync entirely for purpose=cli,
407+
// so a slow/stuck sync is never even started on that path.
408+
let searchCalls = 0;
409+
setMemorySearchImpl(async () => {
410+
searchCalls += 1;
411+
return [];
412+
});
413+
// If this sync were ever awaited (directly or via close()), the test would
414+
// hang forever rather than fail — which is exactly the bug under guard.
415+
setMemorySyncImpl(() => new Promise<void>(() => {}));
416+
417+
const tool = createMemorySearchToolOrThrow({
418+
config: {
419+
agents: { list: [{ id: "main", default: true }] },
420+
memory: { citations: "off" },
421+
},
422+
oneShotCliRun: true,
423+
});
424+
const result = await tool.execute("one-shot-zero-hit", { query: "hidden thread codename" });
425+
426+
// Available, empty result — NOT an unavailable/timeout payload.
427+
const details = result.details as { results?: unknown[]; disabled?: boolean };
428+
expect(details.disabled).toBeUndefined();
429+
expect(details.results).toEqual([]);
430+
// The optional forced sync was skipped (never started) and only the initial
431+
// search ran; the one-shot manager was still closed exactly once.
432+
expect(searchCalls).toBe(1);
433+
expect(getMemorySyncMockCalls()).toBe(0);
434+
expect(getMemoryCloseMockCalls()).toBe(1);
435+
});
436+
402437
it("surfaces an honest timeout message instead of blaming the embedding provider", async () => {
403438
vi.useFakeTimers();
404439
try {

extensions/memory-core/src/tools.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,14 +605,25 @@ export function createMemorySearchTool(options: {
605605
if (pausedIndexIdentityReason) {
606606
return;
607607
}
608-
if (rawResults.length === 0 && activeMemory.manager.sync) {
608+
if (
609+
rawResults.length === 0 &&
610+
activeMemory.manager.sync &&
611+
memoryManagerPurpose !== "cli"
612+
) {
609613
// A zero-hit query against a populated index triggers a
610614
// one-shot forced refresh in case the index is stale. Bound
611615
// that refresh so a slow/degraded sync cannot consume the
612616
// whole interactive budget (and falsely trip the
613617
// provider-error cooldown). If the sync outruns its budget
614618
// we keep our current results and let the background sync
615619
// already scheduled by search() catch up the next call.
620+
//
621+
// One-shot CLI managers are intentionally excluded: they are
622+
// torn down in the `finally` below, and close() awaits any
623+
// in-flight sync *unbounded* — which would re-introduce the
624+
// exact hang this bound prevents, just relocated to cleanup.
625+
// A one-shot run also has no "next call" to benefit from a
626+
// warmed index, so skipping the optional refresh is a pure win.
616627
const forcedSync = activeMemory.manager.sync({
617628
reason: "search",
618629
force: true,

0 commit comments

Comments
 (0)