Bug Description
After a successful basic-memory tool write-note, an asyncio.CancelledError
traceback is printed to stderr. The note is written correctly: stdout is
clean JSON ("action": "created") and the exit code is 0. Only stderr is
polluted.
This is not #763 (lost embeddings on exit, closed as intentional design). The
backgrounded sync_entity_vectors is fine. The problem is narrower: the
intentional cancellation of that background task on process exit is surfaced as
an unhandled-exception traceback, instead of being treated as the benign
shutdown signal it is.
Root Cause
LocalTaskScheduler.schedule() attaches _log_task_failure as the done-callback
of the detached sync_entity_vectors task (basic_memory/deps/services.py):
def _log_task_failure(completed: asyncio.Task) -> None:
try:
completed.result()
except Exception as exc: # pragma: no cover
logger.exception("Background task failed", error=str(exc))
When the short-lived CLI process tears down its event loop, the pending task is
cancelled. The done-callback fires, completed.result() re-raises
asyncio.CancelledError — but CancelledError subclasses BaseException, not
Exception, since Python 3.8, so the except Exception clause does not
catch it. It escapes the callback and the loop reports
Exception in callback _log_task_failure().
The traceback header in the wild confirms this exactly:
Exception in callback _log_task_failure() at .../basic_memory/deps/services.py:449.
Steps To Reproduce
basic-memory 0.21.1, any project with semantic_search_enabled: true.
-
basic-memory tool write-note --project <p> --title T --folder inbox \
--content "x" >out.txt 2>err.txt
echo "exit=$?"
out.txt = clean JSON, exit=0; err.txt = the CancelledError traceback
ending in:
File ".../aiosqlite/core.py", line 160, in _execute
return await future
asyncio.exceptions.CancelledError
Not Python-3.14-specific
Reproduced identically on CPython 3.13.13 and 3.14.5 (macOS aarch64, sqlite /
aiosqlite). Pinning the interpreter does not change it — consistent with the
except Exception vs BaseException analysis above. Likely the same async-teardown
family as #831, but a distinct signature/fix.
Impact
Cosmetic for interactive use, but it breaks automation: a caller that reads
stderr or treats any traceback as failure will think the write failed (it did not —
exit 0, JSON returned) and may retry (duplicating) or abort. The success JSON and
the traceback are on separate streams, so stdout-only callers are safe.
Suggested Fix (happy to send a PR)
Treat cancellation as benign in the done-callback:
def _log_task_failure(completed: asyncio.Task) -> None:
if completed.cancelled():
return
try:
completed.result()
except Exception as exc: # pragma: no cover
logger.exception("Background task failed", error=str(exc))
(or equivalently catch asyncio.CancelledError explicitly and ignore it).
Environment
- basic-memory 0.21.1 (latest on PyPI)
- Python 3.13.13 and 3.14.5
- macOS (aarch64), sqlite backend (aiosqlite),
semantic_search_enabled: true
Bug Description
After a successful
basic-memory tool write-note, anasyncio.CancelledErrortraceback is printed to stderr. The note is written correctly: stdout is
clean JSON (
"action": "created") and the exit code is0. Only stderr ispolluted.
This is not #763 (lost embeddings on exit, closed as intentional design). The
backgrounded
sync_entity_vectorsis fine. The problem is narrower: theintentional cancellation of that background task on process exit is surfaced as
an unhandled-exception traceback, instead of being treated as the benign
shutdown signal it is.
Root Cause
LocalTaskScheduler.schedule()attaches_log_task_failureas the done-callbackof the detached
sync_entity_vectorstask (basic_memory/deps/services.py):When the short-lived CLI process tears down its event loop, the pending task is
cancelled. The done-callback fires,
completed.result()re-raisesasyncio.CancelledError— butCancelledErrorsubclassesBaseException, notException, since Python 3.8, so theexcept Exceptionclause does notcatch it. It escapes the callback and the loop reports
Exception in callback _log_task_failure().The traceback header in the wild confirms this exactly:
Exception in callback _log_task_failure() at .../basic_memory/deps/services.py:449.Steps To Reproduce
basic-memory 0.21.1, any project withsemantic_search_enabled: true.out.txt= clean JSON,exit=0;err.txt= theCancelledErrortracebackending in:
Not Python-3.14-specific
Reproduced identically on CPython 3.13.13 and 3.14.5 (macOS aarch64, sqlite /
aiosqlite). Pinning the interpreter does not change it — consistent with the
except ExceptionvsBaseExceptionanalysis above. Likely the same async-teardownfamily as #831, but a distinct signature/fix.
Impact
Cosmetic for interactive use, but it breaks automation: a caller that reads
stderr or treats any traceback as failure will think the write failed (it did not —
exit 0, JSON returned) and may retry (duplicating) or abort. The success JSON and
the traceback are on separate streams, so stdout-only callers are safe.
Suggested Fix (happy to send a PR)
Treat cancellation as benign in the done-callback:
(or equivalently catch
asyncio.CancelledErrorexplicitly and ignore it).Environment
semantic_search_enabled: true