[BUG] basic-memory doctor always fails with "API note file missing" outside test mode · Issue #1315 · basicmachines-co/basic-memory · GitHub
Skip to content

[BUG] basic-memory doctor always fails with "API note file missing" outside test mode #1315

Closed

Description

@cderv

Bug Description

basic-memory doctor fails right after creating its temp project, on every run:

Running Basic Memory doctor checks...
OK Created doctor project: doctor-f7d35861
Doctor failed: API note file missing: doctor/Doctor API Note.md

It passes when BASIC_MEMORY_ENV=test is set.

Steps To Reproduce

This uses a throwaway HOME and config dir, so it doesn't touch an existing install:

export HOME=$(mktemp -d)
export BASIC_MEMORY_CONFIG_DIR=$(mktemp -d)
export BASIC_MEMORY_HOME="$HOME/basic-memory"
mkdir -p "$HOME" "$BASIC_MEMORY_CONFIG_DIR"

# fails
basic-memory doctor --local

# passes
BASIC_MEMORY_ENV=test basic-memory doctor --local

Output of the two runs:

### arm 1: default ###
Running Basic Memory doctor checks...
OK Created doctor project: doctor-86749752
Doctor failed: API note file missing: doctor/Doctor API Note.md

### arm 2: BASIC_MEMORY_ENV=test ###
Running Basic Memory doctor checks...
OK Created doctor project: doctor-3a7d30b2
OK API write created file
OK Manual file written
OK Project index processed manual file
OK Search confirmed manual file
OK Status observed indexed file
Doctor checks passed.

Expected Behavior

basic-memory doctor runs its checks and reports the file/DB loop as healthy.

Actual Behavior

It fails on every run, 5 out of 5, with API note file missing: doctor/Doctor API Note.md.

Additional Context

Two observations that may or may not be relevant, I don't know the codebase well enough to say more than what I measured.

The markdown file does get written, it just isn't there yet at the moment doctor looks for it. I replicated doctor's create-then-check sequence and polled the path instead of checking once:

create_entity returned file_path='doctor/Probe Note.md' after 0.165s
FILE APPEARED after 0.436s (poll 1)
script used for that measurement
import asyncio, time, uuid
from pathlib import Path

from basic_memory.cli.commands.routing import force_routing
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.clients import KnowledgeClient, ProjectClient
from basic_memory.schemas.base import Entity
from basic_memory.schemas.project_info import ProjectInfoRequest

PROJ = Path(__file__).parent / "probe-proj"


async def main() -> None:
    PROJ.mkdir(exist_ok=True)
    name = f"probe-{uuid.uuid4().hex[:8]}"
    async with get_client() as client:
        pc = ProjectClient(client)
        status = await pc.create_project(
            ProjectInfoRequest(name=name, path=str(PROJ), set_default=False).model_dump()
        )
        pid = status.new_project.external_id
        project_path = Path(status.new_project.path)

        kc = KnowledgeClient(client, pid)
        note = Entity(
            title="Probe Note",
            directory="doctor",
            note_type="note",
            content_type="text/markdown",
            content="# Probe Note\n\n- [note] probe",
            entity_metadata={"tags": ["probe"]},
        )
        t0 = time.monotonic()
        result = await kc.create_entity(note.model_dump())
        print(f"create_entity returned file_path={result.file_path!r} after {time.monotonic()-t0:.3f}s")

        target = project_path / result.file_path
        for i in range(40):
            if target.exists():
                print(f"FILE APPEARED after {time.monotonic()-t0:.3f}s (poll {i})")
                break
            await asyncio.sleep(0.25)
        else:
            print(f"FILE STILL MISSING after {time.monotonic()-t0:.3f}s")
        await pc.delete_project(pid)


with force_routing(local=True, cloud=False):
    asyncio.run(main())

The other thing is that just doctor sets BASIC_MEMORY_ENV=test, which is the arm that passes for me, so running the recipe locally wouldn't surface this.

project_root is not set in my config, so this doesn't look like #665, which had the same error string but a different cause.

I only tested on Windows, I don't know whether this happens on other platforms. Happy to test a patch here.

Environment

  • OS: Windows 11
  • Python version: 3.13.9
  • Basic Memory version: 0.23.0
  • Installation method: uv tool install

Activity

  1. tonydzi commented on Aug 24, 2026

    @tonydzi
    Contributor

    Your BASIC_MEMORY_ENV=test arm is the whole diagnosis, and I think it makes this and #1317 the same root — worth linking them before either gets fixed separately.

    The two arms select different write paths, on purpose.

    is_test_env (config_models.py:774) is True when env == "test", when BASIC_MEMORY_ENV=test, or when PYTEST_CURRENT_TEST is set. It is threaded into the materialization service as test_mode (deps/services.py:415, 425, 441, 476, 529), and that flag picks the branch in note_content_materialization.py:632:

    if self.test_mode:
        return await self._materialize_write_now(accepted)
    self._schedule_materialization(accepted, materialization)
    return accepted

    The docstring right above it is explicit that this is intended, not incidental:

    PARITY INVARIANT: production must defer. Test mode runs inline ONLY so tests can assert file/search state synchronously — never make the production path synchronous to "simplify" this.

    Doctor asserts the test-mode contract. doctor.py:135-136 does:

    api_result = await knowledge_client.create_entity(api_note.model_dump())
    api_file = project_path / api_result.file_path
    if not api_file.exists():
        raise ValueError(f"API note file missing: {api_result.file_path}")

    No wait between the accept and the existence check. In production the accept returns as soon as note_content is persisted and the markdown write is merely queued, so the check runs before the file can exist. Under BASIC_MEMORY_ENV=test the same call materializes inline and the file is there. That is the entire delta between your two arms.

    So it is not a race, and @scarabone's 5/5 is the expected number, not bad luck. Ordering here is fixed: the accept path returns before the worker pool has run at all. A race would give a mixed record; a contract mismatch gives 5/5, which is what they measured.

    It also revises their reading that the failure is "confined to doctor's specific flow" (create_project into a temp dir, then create_entity). Nothing about the temp project matters. bm tool write-note looks fine only because a human inspects the file after the deferred worker has already drained; doctor is the one caller that checks in the same tick.

    Why CI did not catch it before 0.23.0 shipped. PYTEST_CURRENT_TEST is one of the three triggers for is_test_env, so any in-repo test that drives doctor through pytest runs it on the inline branch — the single path where the check passes. The doctor suite cannot observe this failure by construction, which is a fair explanation for a green pipeline next to a command that fails on every real invocation.

    Constraint on the fix. The docstring forbids the shortest patch — making the production path inline — and cites a ~3x write-load regression (benchmarks/docs/write-load-benchmark.md) behind that. So the wait belongs on doctor's side. Doctor already knows how to ask for that in its next step, which passes run_in_background=False to project_client.index(...); the API-write check is the only step in the command that assumes immediate completion, so the asymmetry is local and the fix does not have to touch the write path at all.

  2. added this to the v0.23.1 milestone on Aug 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions