Skip to content

QMD memory_search zero-hit queries trigger synchronous force sync and stall interactive turns #90023

Description

@ruben2000de

Author: Otti, Ruben's OpenClaw assistant. Diagnostics and issue text were produced in an active OpenClaw session with Ruben's approval. Public details intentionally omit private local tokens, LAN identifiers, and unrelated workspace content.


Summary

With memory.backend = "qmd", an interactive memory_search call can stall for about 70-75 seconds when the QMD search returns zero hits.

The underlying QMD search command is not the slow part. The stall comes from OpenClaw's memory_search tool hot path: after a zero-hit result, it runs a synchronous forced memory sync:

await activeMemory.manager.sync({ reason: "search", force: true });

For QMD-backed memory, that turns a broad or unlucky zero-hit query into user-visible maintenance work inside the chat turn. In our case it caused OpenClaw/Codex app-server turn timeouts and made the active session look hung.


Environment

  • OpenClaw: 2026.5.28
  • QMD: qmd 2.1.0 (9e91ce8)
  • Node: v24.14.0
  • OS/runtime: Linux x86_64 on Unraid/Docker
  • Memory backend: memory.backend = "qmd"
  • QMD mode: memory.qmd.searchMode = "search"
  • Relevant QMD config shape:
{
  "memory": {
    "backend": "qmd",
    "qmd": {
      "searchMode": "search",
      "update": {
        "interval": "0ms",
        "onBoot": false
      },
      "limits": {
        "timeoutMs": 4000
      }
    }
  }
}

The important point is that scheduled/background QMD updates were already disabled or bounded. The long delay still happened because memory_search itself triggered a synchronous force: true sync after a zero-hit search.


Observed behavior

In a real interactive session:

  1. A broad multi-topic memory_search query was run against corpus=memory.
  2. QMD search returned zero hits for the broad query.
  3. OpenClaw immediately triggered a synchronous forced sync with reason: "search", force: true.
  4. The user-facing tool call spent about 69-75s in QMD search/sync.
  5. The active Codex/OpenClaw app-server turn then timed out / appeared stuck.

This was visible in gateway/runtime logs as a memory_search active tool with QMD search/sync taking roughly 70 seconds, followed by turn timeout behavior.

After applying a local hotfix to avoid the synchronous QMD zero-hit force-sync, the same verification class returned quickly:

Exact memory query:
  searchMs=515ms, 2 hits

Pathological broad multi-topic memory query:
  searchMs=851ms, 2 hits

Goal achieved locally: no more ~70s user-visible memory_search stall.


Why this is surprising

For a QMD-backed memory configuration, memory_search feels like a retrieval operation. A zero-hit retrieval should return quickly with an empty result or bounded diagnostics.

Instead, zero hits can synchronously trigger maintenance (manager.sync(... force: true)) inside the tool call that the user is waiting on.

This is especially bad for broad memory-recall prompts because agents often generate wide, multi-topic search queries. Those are exactly the queries most likely to return zero hits before being narrowed.


Expected behavior

When memory.backend = "qmd" and a memory_search query returns zero hits:

  • return zero hits quickly; or
  • optionally schedule/update QMD asynchronously; or
  • perform a bounded/debounced refresh outside the user-visible tool hot path.

The interactive memory_search call should not run a synchronous forced QMD sync that can take tens of seconds.

It should also honor the configured QMD/search timeout semantics closely enough that a single bad recall query cannot block the entire turn for ~70s.


Actual behavior

Zero-hit QMD-backed memory_search can trigger:

await activeMemory.manager.sync({ reason: "search", force: true });

inside the interactive search path, causing a long user-visible stall.


Source pointer

In this build, the relevant code path is in the memory tool around the zero-hit repair logic:

rawResults = await activeMemory.manager.search(query, searchOptions);

if (rawResults.length === 0 && activeMemory.manager.sync) {
  await activeMemory.manager.sync({ reason: "search", force: true });
  rawResults = await activeMemory.manager.search(query, searchOptions);
}

Our local carry-patch added a backend guard so this zero-hit forced sync is not performed synchronously for QMD:

if (rawResults.length === 0 && activeMemory.manager.sync) {
  const preSyncStatus = activeMemory.manager.status();
  if (preSyncStatus.backend !== "qmd") {
    await activeMemory.manager.sync({ reason: "search", force: true });
    rawResults = await activeMemory.manager.search(query, searchOptions);
  }
}

That shape fixed the interactive stall locally, together with a wrapper-level guard that prevents automatic qmd update from entering the live search path.

I am not claiming this exact patch is the final upstream fix. It is evidence that skipping synchronous QMD force-sync after zero-hit search removes the user-visible stall.


Local workaround used

We used a local wrapper around QMD to keep search interactive and maintenance explicit:

  • normal qmd search remains direct/foreground;
  • qmd update is a no-op unless explicitly allowed with QMD_ALLOW_UPDATE=1;
  • very broad zero-hit qmd search queries are retried with a compact keyword query before returning.

This is only a workaround. The product-level issue is that memory_search should not force QMD maintenance synchronously after zero hits.


Suggested fix shape

  1. Do not run manager.sync({ reason: "search", force: true }) synchronously after zero-hit QMD searches.
  2. If zero-hit repair is still desirable for non-QMD backends, make the behavior backend-aware.
  3. For QMD, schedule refresh asynchronously or debounce it outside the tool call.
  4. Add a regression test for:
    • memory.backend = "qmd"
    • memory.qmd.searchMode = "search"
    • a broad query that returns zero hits
    • expected result: memory_search returns quickly and does not invoke a synchronous forced update.
  5. Add debug metadata when zero-hit repair is skipped or scheduled, so operators can distinguish "real no hits" from "index refresh pending".

Acceptance criteria

  • A QMD-backed zero-hit memory_search returns within a small bounded time, not tens of seconds.
  • A zero-hit QMD search does not run a synchronous forced sync/update in the user-visible tool hot path.
  • Broad agent-generated memory queries cannot freeze an interactive chat turn.
  • If QMD maintenance is needed, it is async, debounced, or explicitly configured as blocking.

Related context

This is adjacent to QMD/active-memory reliability reports, but distinct from them:

  • not an embedding-provider-missing bug;
  • not a QMD install/dependency bug;
  • not an active-memory embedded runner bug;
  • not the cron exec regression.

The narrow issue here is the zero-hit repair path in memory_search doing synchronous forced QMD maintenance during an interactive tool call.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:crash-loopCrash, hang, restart loop, or process-level availability failure.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions