You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[BUG] basic-memory doctor always fails with "API note file missing" outside test mode #1315
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
importasyncio, time, uuidfrompathlibimportPathfrombasic_memory.cli.commands.routingimportforce_routingfrombasic_memory.mcp.async_clientimportget_clientfrombasic_memory.mcp.clientsimportKnowledgeClient, ProjectClientfrombasic_memory.schemas.baseimportEntityfrombasic_memory.schemas.project_infoimportProjectInfoRequestPROJ=Path(__file__).parent/"probe-proj"asyncdefmain() ->None:
PROJ.mkdir(exist_ok=True)
name=f"probe-{uuid.uuid4().hex[:8]}"asyncwithget_client() asclient:
pc=ProjectClient(client)
status=awaitpc.create_project(
ProjectInfoRequest(name=name, path=str(PROJ), set_default=False).model_dump()
)
pid=status.new_project.external_idproject_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=awaitkc.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_pathforiinrange(40):
iftarget.exists():
print(f"FILE APPEARED after {time.monotonic()-t0:.3f}s (poll {i})")
breakawaitasyncio.sleep(0.25)
else:
print(f"FILE STILL MISSING after {time.monotonic()-t0:.3f}s")
awaitpc.delete_project(pid)
withforce_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.
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:
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:
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.
Activity
tonydzi commentedon Aug 24, 2026
Your
BASIC_MEMORY_ENV=testarm 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 whenenv == "test", whenBASIC_MEMORY_ENV=test, or whenPYTEST_CURRENT_TESTis set. It is threaded into the materialization service astest_mode(deps/services.py:415, 425, 441, 476, 529), and that flag picks the branch innote_content_materialization.py:632:The docstring right above it is explicit that this is intended, not incidental:
Doctor asserts the test-mode contract.
doctor.py:135-136does:No wait between the accept and the existence check. In production the accept returns as soon as
note_contentis persisted and the markdown write is merely queued, so the check runs before the file can exist. UnderBASIC_MEMORY_ENV=testthe 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_projectinto a temp dir, thencreate_entity). Nothing about the temp project matters.bm tool write-notelooks 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_TESTis one of the three triggers foris_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 passesrun_in_background=Falsetoproject_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.