Skip to content

bug(server): _revert_to_stored writes to read-only AppContext properties (#399 phase 1 regression) #409

@memtomem

Description

@memtomem

Problem

_revert_to_stored in packages/memtomem/src/memtomem/server/tools/status_config.py:254,256,266 writes to app.embedder, app.search_pipeline, app.index_engine:

def _revert_to_stored(app: AppContext) -> str:
    ...
    new_embedder = create_embedder(config.embedding)
    app.embedder = new_embedder                  # line 254
    app.search_pipeline = SearchPipeline(...)    # line 256
    ...
    app.index_engine = IndexEngine(...)          # line 266
    ...

After #399 Phase 1 (7855042), these three names on AppContext are read-only properties that proxy to self._components.<name>. Assigning to a property without a setter raises AttributeError: property 'embedder' of 'AppContext' object has no setter at runtime.

mypy flags it:

status_config.py:254: error: Property "embedder" defined in "AppContext" is read-only  [misc]
status_config.py:256: error: Property "search_pipeline" defined in "AppContext" is read-only  [misc]
status_config.py:266: error: Property "index_engine" defined in "AppContext" is read-only  [misc]

Because mypy is advisory in this repo (CLAUDE.md) and there is no test that calls mem_embedding_reset(mode="revert_to_stored") end-to-end (grep: only _maybe_offer_embedding_reset in tests/test_init_cmd.py, which hits a different code path), the regression shipped with Phase 1.

Impact

mem_embedding_reset(mode="revert_to_stored") is a recovery path — uncommon, but the whole point is that it stays callable when the DB is in degraded mode (#349). An AttributeError in the recovery tool defeats that. The user's fallback is re-starting the server after manually editing config, which is exactly the friction mem_embedding_reset exists to remove.

Fix sketch

Components is a mutable @dataclass (server/component_factory.py:30-44), so the natural fix is to mutate the inner container:

comp = app._components
assert comp is not None  # handler already awaited ensure_initialized
comp.embedder = new_embedder
comp.search_pipeline = SearchPipeline(...)
comp.index_engine = IndexEngine(...)

After this, app.embedder (the property) reads back the new value — no divergence.

Alternative: add explicit mutator methods on AppContext (replace_embedder / replace_search_pipeline / replace_index_engine) so the _components access stays behind the class's public surface. Probably overkill for a recovery-only path.

Test gap to close in the same fix

Add an integration test that calls mem_embedding_reset(mode="revert_to_stored") on a fixture in degraded mode and asserts:

  1. The tool returns the success message without raising.
  2. app.embedder / app.search_pipeline / app.index_engine reflect the stored (not configured) embedder dimension after the call.
  3. A follow-up mem_search (or any vector-dependent tool) succeeds — demonstrating the three slots were actually swapped, not just embedder.

Context

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions