Skip to content

[BUG] CLI write-note prints CancelledError traceback on stderr: _log_task_failure doesn't handle task cancellation on process exit #839

@ronaldmego

Description

@ronaldmego

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

  1. basic-memory 0.21.1, any project with semantic_search_enabled: true.
  2. basic-memory tool write-note --project <p> --title T --folder inbox \
      --content "x" >out.txt 2>err.txt
    echo "exit=$?"
    
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions